11 Commits
Author SHA1 Message Date
sandrodachandClaude Opus 5.5 e778e6d6dc Merge branch 'dev' into main: v0.8.0
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m58s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:00:11 +01:00
sandrodachandClaude Opus 5.5 6100d339f1 chore(release): v0.8.0
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m16s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:00:09 +01:00
sandrodach 4f5da9507f Merge branch 'feat/gemini-workspace' into dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	CHANGELOG.md
2026-10-30 09:54:30 +01:00
sandrodachandClaude Opus 5.5 6b3c32a370 fix(notify): daily summary includes lists shared through a group
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:54:10 +01:00
sandrodachandClaude Opus 5.5 a69bcbaf10 feat(notify): bundled notifications, task watchers, daily summary
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m4s
- Per-user routing (own mail / own Discord webhook) for task alarms
  (sent at once), shares, completed shared tasks and watched tasks;
  settings as JSON on the user (GET /auth/me, PATCH /auth/settings).
- Everything but alarms is queued and sent as one message per user
  after NOTIFY_DIGEST_MINUTES (default 5); mails now multipart with an
  HTML version, Discord as one embed. Three failed attempts drop it.
- Own changes never notify the actor, except on watched tasks when
  asked for; "paused" stops everything including alarms.
- Task watchers: POST/DELETE /tasks/{id}/watch, isWatching on tasks.
- Daily summary at a chosen time: overdue, today, tomorrow, plus the
  count of the next 7 days; nothing due, nothing sent.
- Migration 7; jobs do not run in the demo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:49:13 +01:00
sandrodach 5f27e7ca9c feat(workspace): move status pills to tab bar and add undo/redo and actions menu to note header 2026-10-30 09:41:02 +01:00
sandrodach fc7b2b3a2c Merge remote-tracking branch 'origin/dev' into feat/gemini-workspace
# Conflicts:
#	CHANGELOG.md
2026-10-30 09:33:23 +01:00
sandrodach d2afcf49df feat(notifications): add channel routing matrix, daily summary, and task watch button 2026-10-30 09:32:52 +01:00
sandrodach 6795edbf4b feat(voice): live dictation with browser pause restart and server audio chunking 2026-10-30 09:27:30 +01:00
sandrodach 446f8e4649 fix(workspace): tighten link preview card, slash menu matching/scrolling, and Enter on last empty bullet 2026-10-30 09:21:29 +01:00
sandrodachandClaude Opus 5.5 362dd3b4c6 fix(workspace): extract replaces the branch in the editor, with undo
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m2s
The server only creates the new note and returns the link; the editor
swaps the branch for it as an ordinary own edit through the sync engine.
No more clash with unsent typing (conflict copies), the link shows at
once also with polling, and Strg+Z restores the branch. Undoing (toast or
Strg+Z) puts the new note into the trash via POST /extract/undo. The
sidebar gets the new note right away and opens its folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:18:25 +01:00
44 changed files with 2664 additions and 308 deletions

No files matched your search

+3
View File
@@ -67,6 +67,9 @@ SMTP_FROM=
# Name, den Mailprogramme als Absender zeigen (wenn SMTP_FROM nur eine Adresse ist). # Name, den Mailprogramme als Absender zeigen (wenn SMTP_FROM nur eine Adresse ist).
# Ein Name direkt in SMTP_FROM ("Mein Dienst <noreply@example.com>") hat Vorrang. # Ein Name direkt in SMTP_FROM ("Mein Dienst <noreply@example.com>") hat Vorrang.
SMTP_FROM_NAME=MindSift SMTP_FROM_NAME=MindSift
# Benachrichtigungen (Freigaben, beobachtete Aufgaben …) werden gesammelt und höchstens alle so viele
# Minuten als eine Nachricht verschickt. Fällige Aufgaben kommen immer sofort.
NOTIFY_DIGEST_MINUTES=5
# true = nur verschlüsselt senden. # true = nur verschlüsselt senden.
SMTP_TLS=true SMTP_TLS=true
# true = selbstsignierte Zertifikate akzeptieren (nur für interne Mailserver). # true = selbstsignierte Zertifikate akzeptieren (nur für interne Mailserver).
+19 -1
View File
@@ -6,6 +6,23 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased] ## [Unreleased]
## [0.8.0] - 2026-10-30
### Added
- Benachrichtigungen pro Ereignis und Kanal (Kontoeinstellungen → Alarme): fällige Aufgaben (sofort), Freigabe erhalten, geteilte Aufgabe erledigt, beobachtete Aufgaben – jeweils per eigener E-Mail und/oder eigenem Discord-Webhook. Alles außer fälligen Aufgaben kommt gebündelt (höchstens alle 5 Minuten eine Nachricht, `NOTIFY_DIGEST_MINUTES`), E-Mails jetzt mit HTML-Layout. Eigene Änderungen lösen nichts aus (bei beobachteten Aufgaben zuschaltbar), ein Schalter pausiert alles.
- Aufgaben beobachten: Knopf im Aufgaben-Detail; jede Änderung daran wird gemeldet. API: `POST/DELETE /tasks/{id}/watch`.
- Tageszusammenfassung zur selbst gewählten Uhrzeit: überfällige, heutige und morgige Aufgaben mit Uhrzeit, dazu die Anzahl der nächsten 7 Tage; ohne fällige Aufgaben keine Nachricht.
### Changed
- Notizkopf aufgeräumt: Live-Status, Freigabe-Pille und Online-Avatare stehen oben in der Tab-Leiste neben „Gespeichert“; im Notizkopf gibt es Knöpfe für Rückgängig (`Strg+Z`) und Wiederholen (`Strg+Y`), „Teilen“ direkt sichtbar und ein Drei-Punkte-Menü für Anheften, Zusammenführen, Markdown-Export und Löschen.
- `/diktat` in Notizen: schreibt live mit (mehrzeiliger Text über den Wellen mit Zeitanzeige `mm:ss`), bricht im Browser-Modus bei Sprechpausen nicht mehr ab und schickt im Server-Modus alle ~8 Sekunden bzw. nach kurzen Sprechpausen Abschnitte zur Erkennung vorab.
### Fixed
- In eigene Notiz umwandeln: Der Link erscheint sofort im Punkt (auch hinter Proxys ohne Live-Verbindung), ohne Konfliktkopien bei gleichzeitigem Weitertippen; die neue Notiz steht sofort in der Seitenleiste. Strg+Z oder „Rückgängig“ im Hinweis holen den Ast zurück und legen die neue Notiz in den Papierkorb.
- Notizen: Link-Vorschau-Karten erscheinen nur noch, wenn der gesamte Punkt aus einer einzigen `https`-Adresse besteht (oder über `/link` in einem leeren Punkt eingefügt wurde), nicht mehr bei Links mitten im Fließtext.
- Notizen: Das Slash-Menü (`/`) öffnet sich nur noch am Textanfang oder nach einem Leerzeichen (nicht mehr beim Einfügen von Web-Adressen mit Schrägstrichen), schließt zuverlässig mit `Esc` oder `Backspace` und scrollt bei Pfeiltasten-Navigation automatisch mit.
- Notizen: `Enter` in einem leeren Punkt auf oberster Ebene legt auch am Ende der Notiz einen neuen Punkt darunter an.
## [0.7.0] - 2026-10-30 ## [0.7.0] - 2026-10-30
### Added ### Added
@@ -194,7 +211,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken. - Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung. - Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.7.0...dev [Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.8.0...dev
[0.8.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.7.0...v0.8.0
[0.7.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.6.0...v0.7.0 [0.7.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.6.0...v0.7.0
[0.6.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.6.0...v0.6.1 [0.6.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.6.0...v0.6.1
[0.6.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.5.2...v0.6.0 [0.6.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.5.2...v0.6.0
+4 -2
View File
@@ -20,9 +20,11 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
| POST | `/documents/{id}/media` | Bild oder Audio zu einer Notiz hochladen (`multipart/form-data`, Feld `file`; Schreibrecht nötig) → `{id, kind, mime, size, url}`. `415` falsches Format, `413` zu groß oder Kontingent voll | | POST | `/documents/{id}/media` | Bild oder Audio zu einer Notiz hochladen (`multipart/form-data`, Feld `file`; Schreibrecht nötig) → `{id, kind, mime, size, url}`. `415` falsches Format, `413` zu groß oder Kontingent voll |
| POST | `/documents/{id}/media/fetch` | Externes Bild kopieren: `{url}` (nur https, keine internen Adressen; nicht in der Demo) → wie oben | | POST | `/documents/{id}/media/fetch` | Externes Bild kopieren: `{url}` (nur https, keine internen Adressen; nicht in der Demo) → wie oben |
| GET | `/media/{id}` | Datei abrufen (Leserecht auf eine Notiz, die sie zeigt), mit Range-Anfragen; sonst `404` | | GET | `/media/{id}` | Datei abrufen (Leserecht auf eine Notiz, die sie zeigt), mit Range-Anfragen; sonst `404` |
| POST | `/documents/{id}/extract` | `{blockId, clientId}`: Punkt samt Unterpunkten wird eigene Notiz (Titel = Text ohne Hashtags, gleicher Ordner, Freigaben übernommen, fremder Besitzer bekommt Schreibrecht); der Punkt wird zu `[[Titel]]`. Schreibrecht nötig; `409`, wenn jemand anderes im Ast schreibt | | POST | `/documents/{id}/extract` | `{blockId, clientId}`: legt aus den Unterpunkten des Punkts eine eigene Notiz an (Titel = Text ohne Hashtags, gleicher Ordner, Freigaben übernommen, fremder Besitzer bekommt Schreibrecht) → `{document, link}`. Die Ursprungsnotiz ändert der Server nicht; der Editor ersetzt den Ast selbst durch `link` (normale eigene Änderung, Strg+Z möglich). Schreibrecht nötig; `409`, wenn jemand anderes im Ast schreibt |
| POST | `/documents/{id}/extract/undo` | Legt die ausgelagerte Notiz `{id}` wieder in den Papierkorb (nur ihr Ersteller) |
| POST | `/documents/{id}/merge` | `{sourceId}`: hängt die (eigene) Quellnotiz als Überschrift mit Unterpunkten an diese Notiz an, Quelle in den Papierkorb → `{targetId, sourceId, sourceTitle, headingId}` | | POST | `/documents/{id}/merge` | `{sourceId}`: hängt die (eigene) Quellnotiz als Überschrift mit Unterpunkten an diese Notiz an, Quelle in den Papierkorb → `{targetId, sourceId, sourceTitle, headingId}` |
| POST | `/documents/{id}/merge/undo` | `{sourceId, headingId}`: Abschnitt wieder entfernen, Quelle wiederherstellen | | POST | `/documents/{id}/merge/undo` | `{sourceId, headingId}`: Abschnitt wieder entfernen, Quelle wiederherstellen |
| POST/DELETE | `/tasks/{id}/watch` | Aufgabe beobachten bzw. nicht mehr → `{watching}`; `404` ohne Zugriff. Aufgaben-Antworten tragen `isWatching` |
| GET | `/link-preview?url=https://…` | Vorschau einer Web-Seite (`title`, `description`, `siteName`, `hasImage`, `hasIcon`, `ok`, `key`), vom Server geholt und 7 Tage zwischengespeichert; `422` ohne https, `403` in der Demo, `429` zu viele Abrufe | | GET | `/link-preview?url=https://…` | Vorschau einer Web-Seite (`title`, `description`, `siteName`, `hasImage`, `hasIcon`, `ok`, `key`), vom Server geholt und 7 Tage zwischengespeichert; `422` ohne https, `403` in der Demo, `429` zu viele Abrufe |
| GET | `/link-preview/{key}/image` bzw. `/icon` | Bild bzw. Icon einer Vorschau | | GET | `/link-preview/{key}/image` bzw. `/icon` | Bild bzw. Icon einer Vorschau |
| POST | `/voice/transcribe` | Nur im Server-Modus der Spracherkennung: `multipart/form-data` mit `file` (Aufnahme, z. B. WebM/Opus oder MP4/AAC) → `{text}`. `404` ohne `STT_URL`, `413` zu groß, `429` Stundenlimit, `503` ausgelastet, `502` Whisper-Fehler (Details im Log) | | POST | `/voice/transcribe` | Nur im Server-Modus der Spracherkennung: `multipart/form-data` mit `file` (Aufnahme, z. B. WebM/Opus oder MP4/AAC) → `{text}`. `404` ohne `STT_URL`, `413` zu groß, `429` Stundenlimit, `503` ausgelastet, `502` Whisper-Fehler (Details im Log) |
@@ -39,7 +41,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
| GET | `/auth/sessions` | Angemeldete Geräte: `[{id, createdAt, lastUsedAt, userAgent, ip, current}]` | | GET | `/auth/sessions` | Angemeldete Geräte: `[{id, createdAt, lastUsedAt, userAgent, ip, current}]` |
| DELETE | `/auth/sessions/{id}` | Ein Gerät abmelden; `404` bei fremder oder unbekannter Sitzung | | DELETE | `/auth/sessions/{id}` | Ein Gerät abmelden; `404` bei fremder oder unbekannter Sitzung |
| POST | `/auth/revoke-all` | Alle Sitzungen beenden (`token_version` +1) | | POST | `/auth/revoke-all` | Alle Sitzungen beenden (`token_version` +1) |
| PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes`. Neue `email` oder `password` nur mit `currentPassword` (`403` bei falschem, `429` bei Login-Sperre); nach neuem Passwort sind andere Geräte abgemeldet, das neue Token kommt im Header `X-Session-Token` | | PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes`. `notificationSettings` (`paused`, `includeOwnWatched`, `events.{due,shared,taskDone,watched}.{email,discord}`, `dailySummary.{enabled,time,email,discord}`) für Benachrichtigungen. Neue `email` oder `password` nur mit `currentPassword` (`403` bei falschem, `429` bei Login-Sperre); nach neuem Passwort sind andere Geräte abgemeldet, das neue Token kommt im Header `X-Session-Token` |
| POST | `/auth/alarms/test` | Test-Alarm (`channel`: `email`/`discord`/`webhook`, optional `target`) | | POST | `/auth/alarms/test` | Test-Alarm (`channel`: `email`/`discord`/`webhook`, optional `target`) |
| GET | `/users/directory`, `/collaborators` | Personen, die man sehen und mit denen man teilen kann (je nach `sharingMode`, [sharing-and-permissions.md](../sharing-and-permissions.md)) | | GET | `/users/directory`, `/collaborators` | Personen, die man sehen und mit denen man teilen kann (je nach `sharingMode`, [sharing-and-permissions.md](../sharing-and-permissions.md)) |
| GET | `/stats` | Zähler fürs Dashboard (u. a. `inboxCount`, `tasksOpenCount`, `listItemsOpenCount` = offene Einträge aller sichtbaren Listen) | | GET | `/stats` | Zähler fürs Dashboard (u. a. `inboxCount`, `tasksOpenCount`, `listItemsOpenCount` = offene Einträge aller sichtbaren Listen) |
+5 -1
View File
@@ -97,7 +97,11 @@ Bilder und Audio liegen in `$DATA_DIR/media/<docId>/<id>.<ext>`, beschrieben in
## Notizen umbauen ## Notizen umbauen
`internal/storage/restructure.go`: **Ast auslagern** liest den Ast, legt die neue Notiz an (Titel eindeutig, gleiche direkte und Gruppen-Freigaben, Besitzer der Ursprungsnotiz bekommt Schreibrecht) und ersetzt den Ast dann über das Operationsprotokoll durch `[[Titel]]` (Hashtags bleiben am Link). **Zusammenführen** hängt die Quelle als `h2`-Überschrift mit ihren Punkten als Unterpunkte an die Zielnotiz an (ebenfalls über Operationen, offene Editoren folgen live) und legt die Quelle in den Papierkorb; „Rückgängig“ löscht die Überschrift samt Unterpunkten und stellt die Quelle wieder her. Die Aktion läuft im Namen des Editor-Clients (`clientId`), damit dessen eigene Block-Sperren nicht stören. Bilder und Aufnahmen funktionieren weiter, weil der Zugriff über die Notizen läuft, die sie zeigen. `internal/storage/restructure.go`: **Ast auslagern** liest den Ast und legt die neue Notiz an (Titel eindeutig, gleiche direkte und Gruppen-Freigaben, Besitzer der Ursprungsnotiz bekommt Schreibrecht). Den Ast in der Ursprungsnotiz ersetzt der Editor selbst durch `[[Titel]]` (Hashtags bleiben am Link) – als eigene Änderung über die Sync-Engine, damit sie nicht mit ungesendeten Eingaben kollidiert und Strg+Z sie zurücknimmt; dabei legt `/extract/undo` die neue Notiz in den Papierkorb. **Zusammenführen** hängt die Quelle als `h2`-Überschrift mit ihren Punkten als Unterpunkte an die Zielnotiz an (ebenfalls über Operationen, offene Editoren folgen live) und legt die Quelle in den Papierkorb; „Rückgängig“ löscht die Überschrift samt Unterpunkten und stellt die Quelle wieder her. Die Aktion läuft im Namen des Editor-Clients (`clientId`), damit dessen eigene Block-Sperren nicht stören. Bilder und Aufnahmen funktionieren weiter, weil der Zugriff über die Notizen läuft, die sie zeigen.
## Benachrichtigungen
`internal/api/notify.go`, `internal/storage/notifications.go` (Migration 7). Jede Person wählt pro Ereignisart die Kanäle – eigene E-Mail (Alarm-Adresse, sonst Konto-Adresse) und eigener Discord-Webhook aus den Alarmen –, gespeichert als JSON in `users.notification_settings` (`GET /auth/me`, `PATCH /auth/settings` → `notificationSettings`). Ereignisse: `due` (Fälligkeits-Alarm, sofort über den Alarm-Ticker), `shared` (Notiz, Ordner, Aufgabenliste oder Liste geteilt; bei Gruppen alle Mitglieder), `taskDone` (Aufgabe einer geteilten Liste erledigt), `watched` (jede Änderung an einer beobachteten Aufgabe, `task_watchers`, `POST/DELETE /tasks/{id}/watch`). Alles außer `due` landet in `notification_queue`; ein Job im Minutentakt schickt je Person eine gebündelte Nachricht, sobald das älteste Ereignis `NOTIFY_DIGEST_MINUTES` (Standard 5) alt ist (E-Mail als Text + HTML, Discord als ein Embed mit höchstens 10 Zeilen), nach 3 Fehlversuchen wird verworfen. Eigene Änderungen benachrichtigen nie den Handelnden, außer bei beobachteten Aufgaben mit `includeOwnWatched`. `paused` stoppt alles, auch Alarme. **Tageszusammenfassung:** zur gewählten Uhrzeit (Server-Zeitzone, innerhalb von 30 Minuten, einmal pro Tag) überfällige, heutige und morgige offene Aufgaben samt Anzahl der folgenden 7 Tage; nichts fällig → keine Nachricht. Im Demo-Modus laufen die Jobs nicht.
## Link-Vorschau ## Link-Vorschau
+2
View File
@@ -12,6 +12,8 @@ SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `j
| `user_invites` | Einladungscodes | `code`, `email`, `role`, `organization_id`, `expires_at`, `used_by` | | `user_invites` | Einladungscodes | `code`, `email`, `role`, `organization_id`, `expires_at`, `used_by` |
| `media` | Bilder und Audio in Notizen (Migration 5); Datei unter `$DATA_DIR/media/<doc_id>/<id>.<ext>` | `id`, `doc_id`, `owner_id`, `kind` (`image`/`audio`), `mime`, `size`, `source_url` (kopiertes externes Bild), `created_at` | | `media` | Bilder und Audio in Notizen (Migration 5); Datei unter `$DATA_DIR/media/<doc_id>/<id>.<ext>` | `id`, `doc_id`, `owner_id`, `kind` (`image`/`audio`), `mime`, `size`, `source_url` (kopiertes externes Bild), `created_at` |
| `link_previews` | Zwischengespeicherte Link-Vorschauen (Migration 6) | `key` (Hash der URL), `url`, `title`, `description`, `site_name`, `image`/`icon` (BLOB, mit `*_mime`), `ok`, `fetched_at` | | `link_previews` | Zwischengespeicherte Link-Vorschauen (Migration 6) | `key` (Hash der URL), `url`, `title`, `description`, `site_name`, `image`/`icon` (BLOB, mit `*_mime`), `ok`, `fetched_at` |
| `notification_queue` | Wartende Benachrichtigungen bis zum gebündelten Versand (Migration 7) | `user_id`, `kind` (`shared`/`taskDone`/`watched`), `payload` (JSON), `created_at`, `attempts` |
| `task_watchers` | Beobachtete Aufgaben | `task_id`, `user_id`, `created_at` |
| `sessions` | Angemeldete Geräte (Migration 4, ersetzt `revoked_tokens`) | `id` (= `jti` des Tokens), `user_id`, `created_at`, `last_used_at` (höchstens alle 5 Minuten aktualisiert), `user_agent`, `ip`, `revoked_at` (leer = aktiv) | | `sessions` | Angemeldete Geräte (Migration 4, ersetzt `revoked_tokens`) | `id` (= `jti` des Tokens), `user_id`, `created_at`, `last_used_at` (höchstens alle 5 Minuten aktualisiert), `user_agent`, `ip`, `revoked_at` (leer = aktiv) |
| `user_themes` | Eigene CSS-Themes eines Benutzers | `user_id` (FK, `ON DELETE CASCADE`), `name`, `css_content` | | `user_themes` | Eigene CSS-Themes eines Benutzers | `user_id` (FK, `ON DELETE CASCADE`), `name`, `css_content` |
| `api_keys` | Webhook-/API-Schlüssel | `key_prefix`, `key_hash` (SHA-256), `scopes` (nicht ausgewertet), `last_used_at` | | `api_keys` | Webhook-/API-Schlüssel | `key_prefix`, `key_hash` (SHA-256), `scopes` (nicht ausgewertet), `last_used_at` |
+1
View File
@@ -30,6 +30,7 @@ services:
- SMTP_PASS=${SMTP_PASS:-} - SMTP_PASS=${SMTP_PASS:-}
- SMTP_FROM=${SMTP_FROM:-} - SMTP_FROM=${SMTP_FROM:-}
- SMTP_FROM_NAME=${SMTP_FROM_NAME:-MindSift} - SMTP_FROM_NAME=${SMTP_FROM_NAME:-MindSift}
- NOTIFY_DIGEST_MINUTES=${NOTIFY_DIGEST_MINUTES:-5}
- SMTP_TLS=${SMTP_TLS:-true} - SMTP_TLS=${SMTP_TLS:-true}
- SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false} - SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false}
# Impressum / Datenschutz # Impressum / Datenschutz
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"version": "0.7.0", "version": "0.8.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"version": "0.7.0", "version": "0.8.0",
"license": "Unlicense", "license": "Unlicense",
"dependencies": { "dependencies": {
"@types/prismjs": "^1.26.6", "@types/prismjs": "^1.26.6",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"private": true, "private": true,
"version": "0.7.0", "version": "0.8.0",
"license": "Unlicense", "license": "Unlicense",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -17,7 +17,7 @@ import {
Pencil, Pencil,
Copy, Copy,
} from 'lucide-react'; } from 'lucide-react';
import { AuthStatus, User, UserTheme } from '../../lib/types'; import { AuthStatus, NotificationEvents, NotificationSettings, User, UserTheme } from '../../lib/types';
import { ApiKeysPanel } from './ApiKeysPanel'; import { ApiKeysPanel } from './ApiKeysPanel';
import { ServerBackupsCard } from './ServerBackupsCard'; import { ServerBackupsCard } from './ServerBackupsCard';
import { saveBlob } from '../../lib/download'; import { saveBlob } from '../../lib/download';
@@ -47,6 +47,48 @@ interface UserSettingsViewProps {
type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'webhooks'; type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'webhooks';
export function buildNotificationSettings(user: User): NotificationSettings {
const hasLegacy = Array.isArray(user.alarmChannels);
const dueEmailDefault = hasLegacy ? user.alarmChannels!.includes('email') : true;
const dueDiscordDefault = hasLegacy ? user.alarmChannels!.includes('discord') : true;
const s = user.notificationSettings;
return {
paused: Boolean(s?.paused),
includeOwnWatched: Boolean(s?.includeOwnWatched),
events: {
due: {
email: s?.events?.due?.email ?? dueEmailDefault,
discord: s?.events?.due?.discord ?? dueDiscordDefault,
},
shared: {
email: s?.events?.shared?.email ?? true,
discord: s?.events?.shared?.discord ?? false,
},
taskDone: {
email: s?.events?.taskDone?.email ?? false,
discord: s?.events?.taskDone?.discord ?? false,
},
watched: {
email: s?.events?.watched?.email ?? true,
discord: s?.events?.watched?.discord ?? true,
},
},
dailySummary: {
enabled: s?.dailySummary?.enabled ?? false,
time: s?.dailySummary?.time || '07:00',
email: s?.dailySummary?.email ?? true,
discord: s?.dailySummary?.discord ?? false,
},
};
}
const NOTIFICATION_EVENT_ROWS: { key: keyof NotificationEvents; label: string }[] = [
{ key: 'due', label: 'Fällige Aufgaben (sofort)' },
{ key: 'shared', label: 'Freigabe erhalten' },
{ key: 'taskDone', label: 'Geteilte Aufgabe erledigt' },
{ key: 'watched', label: 'Beobachtete Aufgaben' },
];
export const UserSettingsView: React.FC<UserSettingsViewProps> = ({ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
currentUser, currentUser,
activeTheme, activeTheme,
@@ -211,6 +253,9 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
const [alarmChannels, setAlarmChannels] = useState<string[]>( const [alarmChannels, setAlarmChannels] = useState<string[]>(
Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : [] Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []
); );
const [notificationSettings, setNotificationSettings] = useState<NotificationSettings>(() =>
buildNotificationSettings(currentUser)
);
const [alarmEmail, setAlarmEmail] = useState(currentUser.alarmEmail || currentUser.email || ''); const [alarmEmail, setAlarmEmail] = useState(currentUser.alarmEmail || currentUser.email || '');
const [discordWebhookUrl, setDiscordWebhookUrl] = useState(currentUser.discordWebhookUrl || ''); const [discordWebhookUrl, setDiscordWebhookUrl] = useState(currentUser.discordWebhookUrl || '');
const [customWebhookUrl, setCustomWebhookUrl] = useState(currentUser.customWebhookUrl || ''); const [customWebhookUrl, setCustomWebhookUrl] = useState(currentUser.customWebhookUrl || '');
@@ -226,6 +271,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
setEmail(currentUser.email || ''); setEmail(currentUser.email || '');
setAvatarUrl(currentUser.avatarUrl || ''); setAvatarUrl(currentUser.avatarUrl || '');
setAlarmChannels(Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []); setAlarmChannels(Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []);
setNotificationSettings(buildNotificationSettings(currentUser));
setAlarmEmail(currentUser.alarmEmail || currentUser.email || ''); setAlarmEmail(currentUser.alarmEmail || currentUser.email || '');
setDiscordWebhookUrl(currentUser.discordWebhookUrl || ''); setDiscordWebhookUrl(currentUser.discordWebhookUrl || '');
setCustomWebhookUrl(currentUser.customWebhookUrl || ''); setCustomWebhookUrl(currentUser.customWebhookUrl || '');
@@ -238,6 +284,19 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
); );
}; };
const setEventChannel = (eventKey: keyof NotificationEvents, channel: 'email' | 'discord', value: boolean) => {
setNotificationSettings((prev) => ({
...prev,
events: {
...prev.events,
[eventKey]: {
...prev.events[eventKey],
[channel]: value,
},
},
}));
};
// --- Handlers: Avatar --- // --- Handlers: Avatar ---
const handleAvatarFileSelected = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleAvatarFileSelected = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@@ -324,14 +383,23 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
setAlarmMsg(null); setAlarmMsg(null);
setSavingAlarms(true); setSavingAlarms(true);
try { try {
const nextAlarmChannels = [
...(notificationSettings.events.due.email ? ['email'] : []),
...(notificationSettings.events.due.discord ? ['discord'] : []),
...(alarmChannels.includes('webhook') ? ['webhook'] : []),
];
const updated = await api.updateSettings({ const updated = await api.updateSettings({
alarmChannels, alarmChannels: nextAlarmChannels,
alarmEmail: alarmEmail.trim(), alarmEmail: alarmEmail.trim(),
discordWebhookUrl: discordWebhookUrl.trim(), discordWebhookUrl: discordWebhookUrl.trim(),
customWebhookUrl: customWebhookUrl.trim(), customWebhookUrl: customWebhookUrl.trim(),
notifyBeforeMinutes: Number(notifyBeforeMinutes), notifyBeforeMinutes: Number(notifyBeforeMinutes),
notificationSettings,
});
onUserUpdated({
...updated,
notificationSettings: updated.notificationSettings ?? notificationSettings,
}); });
onUserUpdated(updated);
setAlarmMsg({ type: 'ok', text: 'Alarm-Kanäle und Vorwarnzeit erfolgreich gespeichert.' }); setAlarmMsg({ type: 'ok', text: 'Alarm-Kanäle und Vorwarnzeit erfolgreich gespeichert.' });
} catch (err: any) { } catch (err: any) {
setAlarmMsg({ type: 'err', text: err.message || 'Fehler beim Speichern der Alarme' }); setAlarmMsg({ type: 'err', text: err.message || 'Fehler beim Speichern der Alarme' });
@@ -1019,6 +1087,176 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
)} )}
<form onSubmit={handleSaveAlarms} className="space-y-4"> <form onSubmit={handleSaveAlarms} className="space-y-4">
{/* Global Pause & Own Watched Toggles */}
<div className="p-3.5 bg-canvas border border-border space-y-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="font-semibold text-main">Alle Benachrichtigungen pausieren</p>
<p className="text-[11px] text-muted">
Pausiert vorübergehend sämtliche E-Mail- und Discord-Benachrichtigungen.
</p>
</div>
<ToggleSwitch
checked={notificationSettings.paused}
onChange={(v) => setNotificationSettings((prev) => ({ ...prev, paused: v }))}
title="Alle Benachrichtigungen pausieren"
/>
</div>
<div className="flex items-center justify-between gap-4 pt-3 border-t border-border">
<div>
<p className="font-semibold text-main">
Auch eigene Änderungen an beobachteten Aufgaben melden
</p>
<p className="text-[11px] text-muted">
Standardmäßig wirst du nur über Änderungen anderer Personen benachrichtigt.
</p>
</div>
<ToggleSwitch
checked={notificationSettings.includeOwnWatched}
onChange={(v) => setNotificationSettings((prev) => ({ ...prev, includeOwnWatched: v }))}
title="Auch eigene Änderungen an beobachteten Aufgaben melden"
/>
</div>
</div>
{/* Routing Matrix: Events x (E-Mail / Discord) */}
{(() => {
const hasDiscordWebhook = Boolean(discordWebhookUrl.trim());
return (
<>
<div className="p-4 bg-canvas border border-border space-y-3">
<div className="grid grid-cols-[1fr_auto_auto] items-center gap-4 pb-2 border-b border-border">
<span className="font-bold text-main">Ereignis</span>
<span className="w-16 text-center font-semibold text-muted flex items-center justify-center gap-1">
<Mail className="w-3.5 h-3.5 text-accent" />
<span>E-Mail</span>
</span>
<span className="w-16 text-center font-semibold text-muted flex items-center justify-center gap-1">
<MessageSquare className="w-3.5 h-3.5 text-accent" />
<span>Discord</span>
</span>
</div>
<div className="divide-y divide-border">
{NOTIFICATION_EVENT_ROWS.map((row) => {
const routing = notificationSettings.events[row.key];
return (
<div
key={row.key}
className="grid grid-cols-[1fr_auto_auto] items-center gap-4 py-2.5"
>
<span className="font-medium text-main">{row.label}</span>
<div className="w-16 flex justify-center">
<ToggleSwitch
checked={routing.email}
onChange={(v) => setEventChannel(row.key, 'email', v)}
title={`${row.label} – E-Mail`}
/>
</div>
<div className="w-16 flex justify-center">
<ToggleSwitch
checked={routing.discord}
disabled={!hasDiscordWebhook}
onChange={(v) => setEventChannel(row.key, 'discord', v)}
title={
hasDiscordWebhook
? `${row.label} – Discord`
: 'Bitte zuerst unten eine Discord Webhook URL eintragen'
}
/>
</div>
</div>
);
})}
</div>
<p className="text-[11px] text-faint pt-1">
Gebündelt: höchstens alle 5 Minuten eine Nachricht (außer fällige Aufgaben)
</p>
{!hasDiscordWebhook && (
<p className="text-[11px] text-status-warn">
Discord-Schalter sind deaktiviert, solange unten kein Discord-Webhook eingetragen ist.
</p>
)}
</div>
{/* Daily Summary */}
<div className="p-4 bg-canvas border border-border space-y-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="font-bold text-main">Tageszusammenfassung</p>
<p className="text-[11px] text-muted">
Überfällige, heutige und morgige Aufgaben; nichts fällig = keine Nachricht
</p>
</div>
<ToggleSwitch
checked={notificationSettings.dailySummary.enabled}
onChange={(v) =>
setNotificationSettings((prev) => ({
...prev,
dailySummary: { ...prev.dailySummary, enabled: v },
}))
}
title="Tageszusammenfassung"
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-4 pt-2 border-t border-border">
<label className="flex items-center gap-2 text-xs text-main font-medium">
<span>Uhrzeit:</span>
<input
type="time"
aria-label="Uhrzeit der Tageszusammenfassung"
value={notificationSettings.dailySummary.time}
onChange={(e) =>
setNotificationSettings((prev) => ({
...prev,
dailySummary: { ...prev.dailySummary, time: e.target.value },
}))
}
className="h-11 sm:h-8 px-2.5 input-field font-mono"
/>
</label>
<div className="flex items-center gap-5">
<div className="flex items-center gap-2">
<span className="text-xs text-muted">E-Mail</span>
<ToggleSwitch
checked={notificationSettings.dailySummary.email}
onChange={(v) =>
setNotificationSettings((prev) => ({
...prev,
dailySummary: { ...prev.dailySummary, email: v },
}))
}
title="Tageszusammenfassung – E-Mail"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted">Discord</span>
<ToggleSwitch
checked={notificationSettings.dailySummary.discord}
disabled={!hasDiscordWebhook}
onChange={(v) =>
setNotificationSettings((prev) => ({
...prev,
dailySummary: { ...prev.dailySummary, discord: v },
}))
}
title={
hasDiscordWebhook
? 'Tageszusammenfassung – Discord'
: 'Bitte zuerst unten eine Discord Webhook URL eintragen'
}
/>
</div>
</div>
</div>
</div>
</>
);
})()}
{/* Lead Time Selector */} {/* Lead Time Selector */}
<div className="p-3.5 bg-canvas border border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3"> <div className="p-3.5 bg-canvas border border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div> <div>
@@ -1044,19 +1282,9 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</div> </div>
{/* Channel 1: E-Mail */} {/* Channel 1: E-Mail */}
<div <div className="p-4 border border-border transition-colors space-y-3 bg-canvas">
className={`p-4 border transition-colors space-y-3 bg-canvas ${
alarmChannels.includes('email') ? 'border-accent' : 'border-border'
}`}
>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<label className="flex items-center gap-2.5 cursor-pointer select-none"> <div className="flex items-center gap-2.5">
<input
type="checkbox"
checked={alarmChannels.includes('email')}
onChange={() => toggleAlarmChannel('email')}
className="w-4 h-4 accent-blue-600"
/>
<Mail className="w-4 h-4 text-accent" /> <Mail className="w-4 h-4 text-accent" />
<div> <div>
<span className="font-bold text-main block">Ziel: E-Mail</span> <span className="font-bold text-main block">Ziel: E-Mail</span>
@@ -1064,7 +1292,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
Sendet eine E-Mail über den konfigurierten SMTP-Server der Instanz. Sendet eine E-Mail über den konfigurierten SMTP-Server der Instanz.
</span> </span>
</div> </div>
</label> </div>
<button <button
type="button" type="button"
@@ -1092,19 +1320,9 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</div> </div>
{/* Channel 2: Discord Webhook */} {/* Channel 2: Discord Webhook */}
<div <div className="p-4 border border-border transition-colors space-y-3 bg-canvas">
className={`p-4 border transition-colors space-y-3 bg-canvas ${
alarmChannels.includes('discord') ? 'border-accent' : 'border-border'
}`}
>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<label className="flex items-center gap-2.5 cursor-pointer select-none"> <div className="flex items-center gap-2.5">
<input
type="checkbox"
checked={alarmChannels.includes('discord')}
onChange={() => toggleAlarmChannel('discord')}
className="w-4 h-4 accent-blue-600"
/>
<MessageSquare className="w-4 h-4 text-accent" /> <MessageSquare className="w-4 h-4 text-accent" />
<div> <div>
<span className="font-bold text-main block">Ziel: Discord Webhook</span> <span className="font-bold text-main block">Ziel: Discord Webhook</span>
@@ -1113,7 +1331,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
Discord-Kanal. Discord-Kanal.
</span> </span>
</div> </div>
</label> </div>
<button <button
type="button" type="button"
@@ -0,0 +1,106 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { UserSettingsView } from '../UserSettingsView';
import { api } from '../../../lib/api';
import { User } from '../../../lib/types';
vi.mock('../../../lib/api', async () => {
const actual = await vi.importActual<typeof import('../../../lib/api')>('../../../lib/api');
return {
...actual,
api: {
...actual.api,
getMe: vi.fn(async () => ({ isLastAdmin: false })),
getAuthStatus: vi.fn(async () => ({ demoMode: false })),
updateSettings: vi.fn(async (patch) => ({ ...baseUser, ...patch })),
},
};
});
const baseUser: User = {
id: 'u1',
email: 'anna@example.com',
displayName: 'Anna',
role: 'member',
isActive: true,
alarmChannels: ['email'],
createdAt: '2026-10-30T00:00:00Z',
};
describe('UserSettingsView notification matrix', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('derives events.due from alarmChannels, disables Discord without webhook, and saves the exact notificationSettings object', async () => {
const onUserUpdated = vi.fn();
render(
<UserSettingsView
currentUser={baseUser}
activeTheme="dark"
themes={[]}
customThemes={[]}
onThemeChange={() => {}}
onCustomThemesChange={() => {}}
onUserUpdated={onUserUpdated}
onBack={() => {}}
/>
);
// Open "Alarme" section
fireEvent.click(screen.getByText('Alarme'));
// Without Discord webhook, Discord switches are disabled and hint is visible
const discordSwitches = screen.getAllByTitle('Bitte zuerst unten eine Discord Webhook URL eintragen');
expect(discordSwitches.length).toBe(5);
expect((discordSwitches[0] as HTMLButtonElement).disabled).toBe(true);
expect(screen.getByText(/Discord-Schalter sind deaktiviert/)).toBeTruthy();
// Enter a Discord Webhook URL to enable the Discord column
fireEvent.change(screen.getByPlaceholderText(/https:\/\/discord\.com\/api\/webhooks/), {
target: { value: 'https://discord.com/api/webhooks/123/abc' },
});
// Derive check: alarmChannels=['email'] -> due.email=true, due.discord=false
expect(screen.getByTitle('Fällige Aufgaben (sofort) – E-Mail').getAttribute('aria-checked')).toBe('true');
expect(screen.getByTitle('Fällige Aufgaben (sofort) – Discord').getAttribute('aria-checked')).toBe('false');
// Toggle settings in the matrix, daily summary, and global switches
fireEvent.click(screen.getByTitle('Fällige Aufgaben (sofort) – Discord'));
fireEvent.click(screen.getByTitle('Geteilte Aufgabe erledigt – E-Mail'));
fireEvent.click(screen.getByTitle('Beobachtete Aufgaben – Discord'));
fireEvent.click(screen.getByTitle('Alle Benachrichtigungen pausieren'));
fireEvent.click(screen.getByTitle('Auch eigene Änderungen an beobachteten Aufgaben melden'));
fireEvent.click(screen.getByTitle('Tageszusammenfassung'));
fireEvent.change(screen.getByLabelText('Uhrzeit der Tageszusammenfassung'), {
target: { value: '08:30' },
});
fireEvent.click(screen.getByTitle('Tageszusammenfassung – Discord'));
fireEvent.click(screen.getByRole('button', { name: 'Alarm-Einstellungen speichern' }));
await waitFor(() => expect(api.updateSettings).toHaveBeenCalledTimes(1));
expect(api.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
discordWebhookUrl: 'https://discord.com/api/webhooks/123/abc',
notificationSettings: {
paused: true,
includeOwnWatched: true,
events: {
due: { email: true, discord: true },
shared: { email: true, discord: false },
taskDone: { email: true, discord: false },
watched: { email: true, discord: false },
},
dailySummary: {
enabled: true,
time: '08:30',
email: true,
discord: true,
},
},
})
);
});
});
@@ -15,11 +15,13 @@ import {
Lock, Lock,
UserPlus, UserPlus,
Repeat, Repeat,
Eye,
EyeOff,
} from 'lucide-react'; } from 'lucide-react';
import { Thought, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types'; import { Thought, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types';
import { UserAvatar } from '../../components/UserAvatar'; import { UserAvatar } from '../../components/UserAvatar';
import { UserAutocomplete } from '../UserAutocomplete'; import { UserAutocomplete } from '../UserAutocomplete';
import { triggerHaptic } from '../../lib/api'; import { api, triggerHaptic } from '../../lib/api';
import { MarkdownViewer } from '../../lib/markdown'; import { MarkdownViewer } from '../../lib/markdown';
import { addDays, joinDue, splitDue } from '../../lib/due'; import { addDays, joinDue, splitDue } from '../../lib/due';
@@ -90,13 +92,32 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
const [newSubtaskTitle, setNewSubtaskTitle] = useState(''); const [newSubtaskTitle, setNewSubtaskTitle] = useState('');
const [newTagDraft, setNewTagDraft] = useState(''); const [newTagDraft, setNewTagDraft] = useState('');
const [isWatching, setIsWatching] = useState<boolean>(Boolean(task.isWatching));
const [watchBusy, setWatchBusy] = useState(false);
useEffect(() => { useEffect(() => {
setTitleDraft(task.content); setTitleDraft(task.content);
setNotesDraft(task.notes || ''); setNotesDraft(task.notes || '');
setIsEditingTitle(false); setIsEditingTitle(false);
setIsEditingNotes(false); setIsEditingNotes(false);
}, [task.id]); setIsWatching(Boolean(task.isWatching));
}, [task.id, task.isWatching]);
const handleToggleWatch = async () => {
if (watchBusy) return;
setWatchBusy(true);
try {
const res = isWatching ? await api.unwatchTask(task.id) : await api.watchTask(task.id);
const next = Boolean(res.watching);
setIsWatching(next);
task.isWatching = next;
triggerHaptic(10);
} catch {
// ignore
} finally {
setWatchBusy(false);
}
};
// Handle escape key to close // Handle escape key to close
useEffect(() => { useEffect(() => {
@@ -226,6 +247,27 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<button
type="button"
onClick={handleToggleWatch}
disabled={watchBusy}
title={isWatching ? 'Nicht mehr beobachten' : 'Beobachten'}
className={`btn-action min-h-[44px] sm:min-h-0 h-11 sm:h-7 px-2.5 sm:px-2 text-xs flex items-center gap-1.5 ${
isWatching ? 'border-accent text-accent' : ''
}`}
>
{isWatching ? (
<>
<EyeOff className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-accent" />
<span>Nicht mehr beobachten</span>
</>
) : (
<>
<Eye className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
<span>Beobachten</span>
</>
)}
</button>
{(task.convertedType === 'document_block' || task.docId) ? ( {(task.convertedType === 'document_block' || task.docId) ? (
<button <button
type="button" type="button"
@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { TaskDetailModal } from '../TaskDetailModal'; import { TaskDetailModal } from '../TaskDetailModal';
import { api } from '../../../lib/api';
import { Thought } from '../../../lib/types'; import { Thought } from '../../../lib/types';
const task: Thought = { const task: Thought = {
@@ -67,4 +68,20 @@ describe('TaskDetailModal', () => {
expect(screen.getByText('Aufgabe ansehen')).toBeTruthy(); expect(screen.getByText('Aufgabe ansehen')).toBeTruthy();
expect((screen.getByLabelText('Fälligkeitsdatum') as HTMLInputElement).closest('fieldset')?.disabled).toBe(true); expect((screen.getByLabelText('Fälligkeitsdatum') as HTMLInputElement).closest('fieldset')?.disabled).toBe(true);
}); });
it('toggles watching a task via POST and DELETE /tasks/{id}/watch', async () => {
const watchSpy = vi.spyOn(api, 'watchTask').mockResolvedValue({ watching: true });
const unwatchSpy = vi.spyOn(api, 'unwatchTask').mockResolvedValue({ watching: false });
render(<TaskDetailModal {...props({ task: { ...task, isWatching: false } })} />);
const btn = screen.getByRole('button', { name: 'Beobachten' });
fireEvent.click(btn);
await waitFor(() => expect(watchSpy).toHaveBeenCalledWith('th_1'));
expect(screen.getByRole('button', { name: 'Nicht mehr beobachten' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Nicht mehr beobachten' }));
await waitFor(() => expect(unwatchSpy).toHaveBeenCalledWith('th_1'));
expect(screen.getByRole('button', { name: 'Beobachten' })).toBeTruthy();
});
}); });
@@ -9,16 +9,24 @@ interface DictationBlockProps {
onCancel: () => void; onCancel: () => void;
} }
function formatDuration(sec: number): string {
const m = String(Math.floor(sec / 60)).padStart(2, '0');
const s = String(sec % 60).padStart(2, '0');
return `${m}:${s}`;
}
// What an empty bullet turns into after /diktat: listening starts right away, the whole bullet is one // What an empty bullet turns into after /diktat: listening starts right away, the whole bullet is one
// button with the live wave and a translucent "Stopp" on top. A tap ends it; the text lands in the // button with live text growing above the wave, mm:ss timer and a translucent "Stopp" on top. A tap ends
// bullet as plain text. // it; the text lands in the bullet as plain text.
export const DictationBlock: React.FC<DictationBlockProps> = ({ onDone, onCancel }) => { export const DictationBlock: React.FC<DictationBlockProps> = ({ onDone, onCancel }) => {
const [interim, setInterim] = useState(''); const [interim, setInterim] = useState('');
const [elapsedSec, setElapsedSec] = useState(0);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const doneRef = useRef(false); const doneRef = useRef(false);
const buttonRef = useRef<HTMLButtonElement>(null); const buttonRef = useRef<HTMLButtonElement>(null);
const voice = useVoiceInput({ const voice = useVoiceInput({
live: true,
continuous: true, continuous: true,
onText: (text, final) => { onText: (text, final) => {
if (!final) { if (!final) {
@@ -45,11 +53,17 @@ export const DictationBlock: React.FC<DictationBlockProps> = ({ onDone, onCancel
triggerHaptic([15, 60, 25]); triggerHaptic([15, 60, 25]);
void voice.start(); void voice.start();
buttonRef.current?.focus(); buttonRef.current?.focus();
const started = Date.now();
const timer = window.setInterval(() => {
setElapsedSec(Math.floor((Date.now() - started) / 1000));
}, 500);
return () => window.clearInterval(timer);
// start once when the bullet turns into the dictation // start once when the bullet turns into the dictation
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const transcribing = voice.state === 'transcribing'; const transcribing = voice.state === 'transcribing';
const chunkBusy = voice.pendingChunks > 0 || transcribing;
return ( return (
<button <button
@@ -71,13 +85,20 @@ export const DictationBlock: React.FC<DictationBlockProps> = ({ onDone, onCancel
}} }}
disabled={transcribing || !!error} disabled={transcribing || !!error}
aria-label="Diktat beenden" aria-label="Diktat beenden"
className="relative w-full min-h-[56px] sm:min-h-[44px] my-1 rounded-card border border-accent bg-accent-dim overflow-hidden flex items-center justify-center cursor-pointer disabled:cursor-default focus:outline-none" className="relative w-full min-h-[56px] sm:min-h-[44px] my-1 px-3 py-2 rounded-card border border-accent bg-accent-dim overflow-hidden flex flex-col gap-1.5 text-left cursor-pointer disabled:cursor-default focus:outline-none"
> >
{/* Live wave across the whole bullet */} {(interim || chunkBusy) && !error && (
<VoiceWave getLevel={voice.level} bars={32} className="absolute inset-x-3 inset-y-2 justify-between h-auto text-accent opacity-60" /> <div className="w-full text-sm text-main whitespace-pre-wrap break-words leading-relaxed pointer-events-none">
{interim && !error && ( {interim}
<span className="absolute inset-x-3 bottom-1 text-[11px] text-main truncate text-left pointer-events-none">{interim}</span> {chunkBusy && <span className="text-muted animate-pulse">{interim ? ' …' : '…'}</span>}
</div>
)} )}
<div className="relative w-full min-h-[32px] flex items-center justify-between gap-2">
{/* Live wave across the bottom bar */}
<VoiceWave getLevel={voice.level} bars={32} className="absolute inset-x-0 inset-y-0 justify-between h-auto text-accent opacity-60" />
<span className="relative z-10 px-2 py-0.5 rounded-pill bg-panel/70 text-muted text-[11px] font-mono tabular-nums backdrop-blur-sm">
{formatDuration(elapsedSec)}
</span>
{/* Floating stop label on top */} {/* Floating stop label on top */}
<span className="relative z-10 flex items-center gap-1.5 px-3 py-1 rounded-pill bg-panel/70 text-main text-xs font-semibold backdrop-blur-sm"> <span className="relative z-10 flex items-center gap-1.5 px-3 py-1 rounded-pill bg-panel/70 text-main text-xs font-semibold backdrop-blur-sm">
{error ? ( {error ? (
@@ -92,6 +113,7 @@ export const DictationBlock: React.FC<DictationBlockProps> = ({ onDone, onCancel
</> </>
)} )}
</span> </span>
</div>
</button> </button>
); );
}; };
+214 -114
View File
@@ -18,16 +18,26 @@ import {
CloudOff, CloudOff,
AlertTriangle, AlertTriangle,
GitMerge, GitMerge,
Undo2,
Redo2,
MoreHorizontal,
} from 'lucide-react'; } from 'lucide-react';
import { Document, DocumentBlock, User } from '../../lib/types'; import { Document, DocumentBlock, User } from '../../lib/types';
import { api } from '../../lib/api'; import { api } from '../../lib/api';
import { DocSyncEngine, EngineState } from '../../lib/docSync/engine'; import { DocSyncEngine, EngineState } from '../../lib/docSync/engine';
import { Presence } from '../../lib/docSync/transport';
import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree'; import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree';
import { PresenceAvatars } from './PresenceAvatars';
import { extractHashtags } from '../../lib/hashtags'; import { extractHashtags } from '../../lib/hashtags';
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error'; export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
export interface DocHeaderStatus {
showLive: boolean;
channel: 'ws' | 'sse' | 'longpoll' | 'interval';
otherPeople: Presence[];
avatarsById: Map<string, string>;
}
export interface DocumentEditorProps { export interface DocumentEditorProps {
docId: string; docId: string;
currentUser?: User | null; currentUser?: User | null;
@@ -45,8 +55,12 @@ export interface DocumentEditorProps {
onBackMobile?: () => void; onBackMobile?: () => void;
onOpenTask?: (taskId: string) => void; onOpenTask?: (taskId: string) => void;
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void; onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
onHeaderStatusChange?: (status: DocHeaderStatus | null) => void;
/** Opens the dialog to merge this note into another one (own notes only) */ /** Opens the dialog to merge this note into another one (own notes only) */
onRequestMerge?: (doc: Document) => void; onRequestMerge?: (doc: Document) => void;
/** A note was created from this one (extract) or removed again (undo); keeps the sidebar current */
onDocCreated?: (doc: Document) => void;
onDocRemoved?: (docId: string) => void;
} }
import { EmojiPickerModal } from './EmojiPickerModal'; import { EmojiPickerModal } from './EmojiPickerModal';
@@ -193,7 +207,10 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onBackMobile, onBackMobile,
onOpenTask, onOpenTask,
onSaveStatusChange, onSaveStatusChange,
onHeaderStatusChange,
onRequestMerge, onRequestMerge,
onDocCreated,
onDocRemoved,
}) => { }) => {
const [doc, setDoc] = useState<Document | null>(initialDoc || null); const [doc, setDoc] = useState<Document | null>(initialDoc || null);
const [title, setTitle] = useState<string>(initialDoc?.title || ''); const [title, setTitle] = useState<string>(initialDoc?.title || '');
@@ -204,6 +221,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false); const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null); const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
const [showActionsMenu, setShowActionsMenu] = useState<boolean>(false);
const actionsMenuRef = useRef<HTMLDivElement | null>(null);
// Sync engine (operation log): stores every change locally and syncs it with the server // Sync engine (operation log): stores every change locally and syncs it with the server
const engineRef = useRef<DocSyncEngine | null>(null); const engineRef = useRef<DocSyncEngine | null>(null);
@@ -379,39 +398,75 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
.catch(() => {}); .catch(() => {});
}, [otherPeople.length]); }, [otherPeople.length]);
useEffect(() => {
onHeaderStatusChange?.({
showLive: Boolean(engineState && (engineState.shared || engineState.sessions > 1)),
channel: engineState?.channel || 'ws',
otherPeople,
avatarsById,
});
}, [
engineState?.shared,
engineState?.sessions,
engineState?.channel,
otherPeople,
avatarsById,
onHeaderStatusChange,
]);
useEffect(() => {
return () => {
onHeaderStatusChange?.(null);
};
}, [onHeaderStatusChange]);
useEffect(() => {
if (!showActionsMenu) return;
const handleMouseDown = (e: MouseEvent) => {
if (actionsMenuRef.current && !actionsMenuRef.current.contains(e.target as Node)) {
setShowActionsMenu(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setShowActionsMenu(false);
}
};
document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [showActionsMenu]);
// Short hint at the bottom of the editor (blocked actions, skipped undo), optionally with one action // Short hint at the bottom of the editor (blocked actions, skipped undo), optionally with one action
const [notice, setNotice] = useState<string | null>(null); const [notice, setNotice] = useState<string | null>(null);
const [noticeAction, setNoticeAction] = useState<{ label: string; run: () => void } | null>(null); const [noticeActions, setNoticeActions] = useState<{ label: string; run: () => void }[]>([]);
const noticeTimerRef = useRef<number | null>(null); const noticeTimerRef = useRef<number | null>(null);
const showNotice = useCallback((message: string, action?: { label: string; run: () => void }) => { const showNotice = useCallback((message: string, actions: { label: string; run: () => void }[] = []) => {
setNotice(message); setNotice(message);
setNoticeAction(action || null); setNoticeActions(actions);
if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current); if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current);
noticeTimerRef.current = window.setTimeout(() => { noticeTimerRef.current = window.setTimeout(() => {
setNotice(null); setNotice(null);
setNoticeAction(null); setNoticeActions([]);
}, action ? 7000 : 3500); }, actions.length ? 8000 : 3500);
}, []); }, []);
// A bullet with its children becomes a note of its own; the server swaps the branch for a [[link]] // The last extraction, so undoing it (toast or Strg+Z) also puts the new note into the trash
// and this editor receives that change through the normal sync const lastExtractRef = useRef<{ newDocId: string; blockId: string; link: string } | null>(null);
const handleExtractBlock = useCallback( const finishUndoExtract = useCallback(() => {
async (blockId: string) => { const info = lastExtractRef.current;
const engine = engineRef.current; if (!info) return;
try { const block = currentBlocksRef.current.find((b) => b.id === info.blockId);
await engine?.syncNow(); // the branch as typed so far if (block && block.content === info.link) return; // the undone step was something else
const created = await api.extractBranch(docId, blockId, engine?.clientId || ''); lastExtractRef.current = null;
await engine?.syncNow(); api
showNotice(`In eigene Notiz „${created.title}“ umgewandelt`, { .undoExtract(info.newDocId)
label: 'Öffnen', .then(() => onDocRemoved?.(info.newDocId))
run: () => onOpenDocBlock?.(created.id), .catch((err: any) => showNotice(err?.message || 'Ausgelagerte Notiz konnte nicht entfernt werden'));
}); }, [onDocRemoved, showNotice]);
} catch (err: any) {
showNotice(err?.message || 'Umwandeln fehlgeschlagen');
}
},
[docId, onOpenDocBlock, showNotice]
);
useEffect(() => () => { useEffect(() => () => {
if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current); if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current);
}, []); }, []);
@@ -423,7 +478,9 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
const handleUndo = useCallback(() => { const handleUndo = useCallback(() => {
const res = engineRef.current?.undo(); const res = engineRef.current?.undo();
if (res?.message) showNotice(res.message); if (res?.message) showNotice(res.message);
}, [showNotice]); if (res?.ok) finishUndoExtract();
}, [showNotice, finishUndoExtract]);
const handleRedo = useCallback(() => { const handleRedo = useCallback(() => {
const res = engineRef.current?.redo(); const res = engineRef.current?.redo();
if (res?.message) showNotice(res.message); if (res?.message) showNotice(res.message);
@@ -449,6 +506,38 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
[] []
); );
// A bullet with its children becomes a note of its own. The server only creates the note; the branch
// is replaced here as an ordinary own edit, so it shows at once, syncs like typing and Strg+Z works.
const handleExtractBlock = useCallback(
async (blockId: string) => {
const engine = engineRef.current;
if (!engine) return;
try {
await engine.syncNow(); // the server reads the branch as typed so far
const { document: created, link } = await api.extractBranch(docId, blockId, engine.clientId);
// The focused field keeps its own text while typing; leave it so it shows the link
(document.activeElement as HTMLElement | null)?.blur?.();
const prev = currentBlocksRef.current;
const i = prev.findIndex((b) => b.id === blockId);
if (i >= 0) {
const level = prev[i].level || 0;
let end = i + 1;
while (end < prev.length && (prev[end].level || 0) > level) end++;
applyLocalBlocks([...prev.slice(0, i), { ...prev[i], content: link }, ...prev.slice(end)]);
}
lastExtractRef.current = { newDocId: created.id, blockId, link };
onDocCreated?.(created);
showNotice(`In eigene Notiz „${created.title}“ umgewandelt`, [
{ label: 'Öffnen', run: () => onOpenDocBlock?.(created.id) },
{ label: 'Rückgängig', run: () => handleUndo() },
]);
} catch (err: any) {
showNotice(err?.message || 'Umwandeln fehlgeschlagen');
}
},
[docId, onOpenDocBlock, onDocCreated, showNotice, handleUndo, applyLocalBlocks]
);
// Keep parent sidebar metadata in sync when blocks/tags change // Keep parent sidebar metadata in sync when blocks/tags change
useEffect(() => { useEffect(() => {
if (!docRef.current) return; if (!docRef.current) return;
@@ -682,7 +771,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
<button <button
type="button" type="button"
onClick={onBackMobile} onClick={onBackMobile}
className="lg:hidden btn-icon" className="lg:hidden btn-icon h-11 w-11 sm:h-7 sm:w-7"
title="Zurück zur Seitenleiste" title="Zurück zur Seitenleiste"
> >
<ArrowLeft className="w-3.5 h-3.5" /> <ArrowLeft className="w-3.5 h-3.5" />
@@ -780,119 +869,129 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
{/* Right Action Buttons */} {/* Right Action Buttons */}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{/* How changes of others arrive (only relevant for shared notes) */} {!inTrash && (
{engineState && (engineState.shared || engineState.sessions > 1) && ( <>
<span <button
className="hidden sm:inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] text-muted bg-panel border border-border" type="button"
title={ onClick={handleUndo}
engineState.channel === 'ws' disabled={!canEdit || !engineState?.canUndo}
? 'Live-Verbindung über WebSocket' className="btn-icon h-11 w-11 sm:h-7 sm:w-7 disabled:opacity-40 disabled:cursor-not-allowed"
: engineState.channel === 'sse' title="Rückgängig (Strg+Z)"
? 'Live-Verbindung über Server-Sent Events (WebSocket wird blockiert)' aria-label="Rückgängig (Strg+Z)"
: engineState.channel === 'longpoll'
? 'Live über Long-Polling (WebSocket und SSE werden blockiert)'
: 'Keine Live-Verbindung möglich – Änderungen werden alle 3 Sekunden abgefragt'
}
> >
<span <Undo2 className="w-3.5 h-3.5" />
className={`w-1.5 h-1.5 rounded-full ${ </button>
engineState.channel === 'interval' ? 'bg-status-warn' : 'bg-status-ok' <button
}`} type="button"
/> onClick={handleRedo}
{engineState.channel === 'longpoll' ? 'Live (Long-Polling)' : engineState.channel === 'interval' ? 'Abgleich alle 3 s' : 'Live'} disabled={!canEdit || !engineState?.canRedo}
</span> className="btn-icon h-11 w-11 sm:h-7 sm:w-7 disabled:opacity-40 disabled:cursor-not-allowed"
title="Wiederholen (Strg+Y)"
aria-label="Wiederholen (Strg+Y)"
>
<Redo2 className="w-3.5 h-3.5" />
</button>
</>
)} )}
{/* Who else has this note open */} {doc && !inTrash && (
<PresenceAvatars people={otherPeople} avatars={avatarsById} />
{/* Sharing Section */}
{doc && !inTrash && (() => {
const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin');
const sharesCount = doc.sharedWith?.length || 0;
const isShared = sharesCount > 0 || !isOwner || Boolean(doc.sharedFolderId);
const ownerDisplayName = doc.ownerName || 'Eigentümer';
return (
<div className="flex items-center gap-1.5">
{isShared && (
isOwner ? (
<button <button
type="button" type="button"
onClick={() => onOpenShareModal(doc)} onClick={() => onOpenShareModal(doc)}
className="px-2 py-0.5 rounded text-[10px] font-semibold bg-accent/15 text-accent border border-accent/30 flex items-center gap-1 hover:bg-accent/25 hover:border-accent/50 transition-colors cursor-pointer" className="btn-icon h-11 w-11 sm:h-7 sm:w-7"
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt{sharesCount > 0 ? ` (${sharesCount})` : ''}</span>
</button>
) : (
<button
type="button"
onClick={() => onOpenShareModal(doc)}
className="px-2 py-0.5 rounded text-[10px] font-medium border border-border text-muted flex items-center gap-1 bg-surface-subtle hover:bg-panel-elevated hover:text-main transition-colors cursor-pointer"
title="Teilen & Berechtigungen öffnen"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt von {ownerDisplayName}</span>
</button>
)
)}
<button
type="button"
onClick={() => onOpenShareModal(doc)}
className="btn-icon"
title="Notiz teilen und Berechtigungen" title="Notiz teilen und Berechtigungen"
aria-label="Notiz teilen und Berechtigungen"
> >
<Users className="w-3.5 h-3.5" /> <Users className="w-3.5 h-3.5" />
</button> </button>
{onRequestMerge && doc.isOwner && !inTrash && ( )}
<div className="relative" ref={actionsMenuRef}>
<button <button
type="button" type="button"
onClick={() => onRequestMerge(doc)} onClick={() => setShowActionsMenu((prev) => !prev)}
className="btn-icon" aria-expanded={showActionsMenu}
title="Mit anderer Notiz zusammenführen…" aria-haspopup="menu"
aria-label="Weitere Aktionen"
title="Weitere Aktionen"
className="btn-icon h-11 w-11 sm:h-7 sm:w-7"
> >
<GitMerge className="w-3.5 h-3.5" /> <MoreHorizontal className="w-3.5 h-3.5" />
</button>
{showActionsMenu && (
<div
role="menu"
className="absolute right-0 top-full mt-1.5 w-60 rounded-card bg-panel border border-border shadow-lg p-1 z-30 text-xs"
>
{!inTrash && (
<button
type="button"
role="menuitem"
onClick={() => {
setShowActionsMenu(false);
void handleTogglePin();
}}
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-panel-elevated text-left text-main font-medium flex items-center gap-2 transition-colors cursor-pointer"
>
<Pin
className={`w-3.5 h-3.5 shrink-0 ${
doc?.isPinned ? 'text-status-warn' : 'text-accent'
}`}
/>
<span>{doc?.isPinned ? 'Anheftung lösen' : 'Notiz anheften'}</span>
</button> </button>
)} )}
</div>
);
})()}
{!inTrash && <button {onRequestMerge && doc?.isOwner && !inTrash && (
<button
type="button" type="button"
onClick={handleTogglePin} role="menuitem"
className={`btn-icon ${ onClick={() => {
doc?.isPinned setShowActionsMenu(false);
? 'text-status-warn bg-status-warn/10 border-status-warn/30' onRequestMerge(doc);
: '' }}
}`} className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-panel-elevated text-left text-main font-medium flex items-center gap-2 transition-colors cursor-pointer"
title={doc?.isPinned ? 'Anheftung lösen' : 'Notiz anheften'}
> >
<Pin className="w-3.5 h-3.5" /> <GitMerge className="w-3.5 h-3.5 text-accent shrink-0" />
</button>} <span>Mit anderer Notiz zusammenführen…</span>
</button>
)}
<button <button
type="button" type="button"
onClick={handleExportMarkdown} role="menuitem"
className="btn-icon" onClick={() => {
title="Als Markdown (.md) exportieren" setShowActionsMenu(false);
handleExportMarkdown();
}}
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-panel-elevated text-left text-main font-medium flex items-center gap-2 transition-colors cursor-pointer"
> >
<Download className="w-3.5 h-3.5" /> <Download className="w-3.5 h-3.5 text-accent shrink-0" />
<span>Als Markdown (.md) exportieren</span>
</button> </button>
{canEdit && (doc?.isOwner || doc?.permission === 'owner') && ( {canEdit && (doc?.isOwner || doc?.permission === 'owner') && (
<>
<div className="my-1 border-t border-border" />
<button <button
type="button" type="button"
onClick={() => onDeleteDoc(docId)} role="menuitem"
className="btn-icon-danger" onClick={() => {
title="Notiz löschen" setShowActionsMenu(false);
onDeleteDoc(docId);
}}
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-status-err/10 text-left text-status-err font-medium flex items-center gap-2 transition-colors cursor-pointer"
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5 text-status-err shrink-0" />
<span>Notiz löschen</span>
</button> </button>
</>
)} )}
</div> </div>
)}
</div>
</div>
</div> </div>
{/* In the trash: read-only with a way back */} {/* In the trash: read-only with a way back */}
@@ -1126,19 +1225,20 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 max-w-[90vw] px-3 py-2 rounded-card text-xs text-main bg-panel-elevated border border-border shadow-panel" className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 max-w-[90vw] px-3 py-2 rounded-card text-xs text-main bg-panel-elevated border border-border shadow-panel"
> >
<span>{notice}</span> <span>{notice}</span>
{noticeAction && ( {noticeActions.map((a) => (
<button <button
key={a.label}
type="button" type="button"
onClick={() => { onClick={() => {
noticeAction.run();
setNotice(null); setNotice(null);
setNoticeAction(null); setNoticeActions([]);
a.run();
}} }}
className="ml-3 font-semibold text-accent hover:underline min-h-[44px] sm:min-h-0" className="ml-3 font-semibold text-accent hover:underline min-h-[44px] sm:min-h-0"
> >
{noticeAction.label} {a.label}
</button> </button>
)} ))}
</div> </div>
)} )}
</div> </div>
@@ -35,7 +35,7 @@ import { AudioBlock } from './AudioBlock';
import { DictationBlock } from './DictationBlock'; import { DictationBlock } from './DictationBlock';
import { currentVoiceConfig } from '../../lib/voice'; import { currentVoiceConfig } from '../../lib/voice';
import { LinkPreviewCard } from './LinkPreviewCard'; import { LinkPreviewCard } from './LinkPreviewCard';
import { firstBareUrl, hidePreviewContent, useLinkPreviewsEnabled } from '../../lib/linkPreview'; import { linkCardUrl, hidePreviewContent, useLinkPreviewsEnabled } from '../../lib/linkPreview';
import { mediaContent, parseMediaContent } from '../../lib/media'; import { mediaContent, parseMediaContent } from '../../lib/media';
import { renderInlineMarkdown } from '../../lib/markdown'; import { renderInlineMarkdown } from '../../lib/markdown';
import { api } from '../../lib/api'; import { api } from '../../lib/api';
@@ -121,7 +121,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
docId, docId,
block, block,
index, index,
isLast,
hasChildren, hasChildren,
isFocused, isFocused,
canEdit, canEdit,
@@ -160,13 +159,14 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
const [slashSearch, setSlashSearch] = useState<string | null>(null); const [slashSearch, setSlashSearch] = useState<string | null>(null);
// Inline picker an empty bullet turns into after /image (local only, nothing synced until done) // Inline picker an empty bullet turns into after /image (local only, nothing synced until done)
const [mediaPicker, setMediaPicker] = useState<'image' | 'audio' | 'dictation' | null>(null); const [mediaPicker, setMediaPicker] = useState<'image' | 'audio' | 'dictation' | null>(null);
// Card above bullets with a plain web address (lib/linkPreview.ts) // Card above bullets whose entire content is a plain https address (lib/linkPreview.ts)
const previewsOn = useLinkPreviewsEnabled(); const previewsOn = useLinkPreviewsEnabled();
const previewUrl = const previewUrl =
previewsOn && !mediaPicker && ['bullet', 'text', 'paragraph', 'todo', 'h1', 'h2', 'h3'].includes(block.type || 'bullet') previewsOn && !mediaPicker && ['bullet', 'text', 'paragraph', 'todo', 'h1', 'h2', 'h3'].includes(block.type || 'bullet')
? firstBareUrl(localContent) ? linkCardUrl(localContent)
: null; : null;
const [slashIndex, setSlashIndex] = useState(0); const [slashIndex, setSlashIndex] = useState(0);
const slashItemRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [showLinkModal, setShowLinkModal] = useState<boolean>(false); const [showLinkModal, setShowLinkModal] = useState<boolean>(false);
const [showTodoMenu, setShowTodoMenu] = useState<boolean>(false); const [showTodoMenu, setShowTodoMenu] = useState<boolean>(false);
@@ -362,7 +362,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
const q = slashSearch.toLowerCase(); const q = slashSearch.toLowerCase();
const cmdQuery = q.startsWith('code ') ? 'code' : q; const cmdQuery = q.startsWith('code ') ? 'code' : q;
// Media only in an otherwise empty bullet // Media only in an otherwise empty bullet
const emptyBullet = localContent.replace(/\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim() === ''; const emptyBullet = localContent.replace(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim() === '';
return SLASH_COMMANDS.filter( return SLASH_COMMANDS.filter(
(c) => (c) =>
(!c.media || emptyBullet) && (!c.media || emptyBullet) &&
@@ -375,12 +375,18 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
); );
}, [slashSearch, localContent, onExtractBlock]); }, [slashSearch, localContent, onExtractBlock]);
useEffect(() => {
if (slashSearch === null) return;
slashItemRefs.current[slashIndex]?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
}, [slashIndex, slashSearch]);
const applySlashCommand = (cmd: SlashCommand) => { const applySlashCommand = (cmd: SlashCommand) => {
setSlashSearch(null); setSlashSearch(null);
const codeLangMatch = localContent.match(/\/code\s+([a-z0-9_+#.-]+)$/i); const codeLangMatch = localContent.match(/(^|\s)\/code\s+([a-z0-9_+#.-]+)$/i);
const clean = localContent.replace(/\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim(); const clean = localContent.replace(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim();
if (cmd.type === 'link') { if (cmd.type === 'link') {
setLocalContent(clean); setLocalContent(clean);
localContentRef.current = clean;
setShowLinkModal(true); setShowLinkModal(true);
return; return;
} }
@@ -401,7 +407,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
} }
const langOverride = const langOverride =
cmd.type === 'code' && codeLangMatch cmd.type === 'code' && codeLangMatch
? resolveLanguageId(codeLangMatch[1]) || codeLangMatch[1].toLowerCase() ? resolveLanguageId(codeLangMatch[2]) || codeLangMatch[2].toLowerCase()
: undefined; : undefined;
setLocalContent(clean); setLocalContent(clean);
localContentRef.current = clean; localContentRef.current = clean;
@@ -415,11 +421,22 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
const handleInsertLink = (linkMarkdown: string) => { const handleInsertLink = (linkMarkdown: string) => {
setShowLinkModal(false); setShowLinkModal(false);
if (localContent.trim() === '') {
const webMatch = linkMarkdown.match(/^\[.*\]\((https?:\/\/[^\s)]+)\)$/);
if (webMatch) {
const bareUrl = webMatch[1];
setLocalContent(bareUrl);
localContentRef.current = bareUrl;
flushDebounce(bareUrl);
return;
}
}
const cursor = textareaRef.current?.selectionStart ?? localContent.length; const cursor = textareaRef.current?.selectionStart ?? localContent.length;
const before = localContent.slice(0, cursor); const before = localContent.slice(0, cursor);
const after = localContent.slice(cursor); const after = localContent.slice(cursor);
const newContent = `${before}${before && !before.endsWith(' ') ? ' ' : ''}${linkMarkdown}${after && !after.startsWith(' ') ? ' ' : ''}${after}`.trim(); const newContent = `${before}${before && !before.endsWith(' ') ? ' ' : ''}${linkMarkdown}${after && !after.startsWith(' ') ? ' ' : ''}${after}`.trim();
setLocalContent(newContent); setLocalContent(newContent);
localContentRef.current = newContent;
flushDebounce(newContent); flushDebounce(newContent);
}; };
@@ -475,9 +492,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
} }
// Check for slash command invocation // Check for slash command invocation
const slashMatch = !typeOverride ? val.match(/\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i) : null; const slashMatch = !typeOverride ? val.match(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i) : null;
if (slashMatch) { if (slashMatch) {
setSlashSearch(slashMatch[1]); setSlashSearch(slashMatch[2]);
setSlashIndex(0); setSlashIndex(0);
} else { } else {
setSlashSearch(null); setSlashSearch(null);
@@ -600,7 +617,13 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
if (!canEdit) return; if (!canEdit) return;
// Slash command navigation // Slash command navigation
if (slashSearch !== null && filteredSlashCommands.length > 0) { if (slashSearch !== null) {
if (e.key === 'Escape') {
e.preventDefault();
setSlashSearch(null);
return;
}
if (filteredSlashCommands.length > 0) {
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
e.preventDefault(); e.preventDefault();
setSlashIndex((prev) => (prev + 1) % filteredSlashCommands.length); setSlashIndex((prev) => (prev + 1) % filteredSlashCommands.length);
@@ -619,10 +642,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
} }
return; return;
} }
if (e.key === 'Escape') {
e.preventDefault();
setSlashSearch(null);
return;
} }
} }
@@ -637,7 +656,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
// 2. Enter (without Shift) -> If block is completely empty: // 2. Enter (without Shift) -> If block is completely empty:
// - If level > minLevel, outdent (Shift+Tab behaviour) // - 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 // - Otherwise split paragraph at cursor and create new block below
if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') { if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') {
e.preventDefault(); e.preventDefault();
@@ -666,10 +684,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
onIndentBlockRef.current(block.id, -1); onIndentBlockRef.current(block.id, -1);
return; return;
} }
if (isLast) {
// Bereits auf oberster Ebene und der letzte Block: kein weiterer leerer Block
return;
}
} }
const start = e.currentTarget.selectionStart ?? localContent.length; const start = e.currentTarget.selectionStart ?? localContent.length;
@@ -1258,6 +1272,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
{filteredSlashCommands.map((cmd, i) => ( {filteredSlashCommands.map((cmd, i) => (
<button <button
key={cmd.type} key={cmd.type}
ref={(el) => {
slashItemRefs.current[i] = el;
}}
type="button" type="button"
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
@@ -46,7 +46,8 @@ import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { FormSheet } from '../../components/FormSheet'; import { FormSheet } from '../../components/FormSheet';
import { sendDocOps } from '../../lib/docSync/oneoff'; import { sendDocOps } from '../../lib/docSync/oneoff';
import { useMobileActions } from '../../context/MobileActionContext'; import { useMobileActions } from '../../context/MobileActionContext';
import { DocumentEditor, DocSaveStatus } from './DocumentEditor'; import { DocumentEditor, DocSaveStatus, DocHeaderStatus } from './DocumentEditor';
import { PresenceAvatars } from './PresenceAvatars';
import { TrashView } from './TrashView'; import { TrashView } from './TrashView';
import { MergeDialog } from './MergeDialog'; import { MergeDialog } from './MergeDialog';
import { CountBadge } from '../../components/CountBadge'; import { CountBadge } from '../../components/CountBadge';
@@ -386,6 +387,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const [docSaveStatus, setDocSaveStatus] = useState<DocSaveStatus>('saved'); const [docSaveStatus, setDocSaveStatus] = useState<DocSaveStatus>('saved');
const [docSaveError, setDocSaveError] = useState<string | null>(null); const [docSaveError, setDocSaveError] = useState<string | null>(null);
const [docHeaderStatus, setDocHeaderStatus] = useState<DocHeaderStatus | null>(null);
// Drag and Drop state // Drag and Drop state
@@ -1306,10 +1308,83 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</button> </button>
</div> </div>
{/* Right Header Section: Save Status (on phones it sits compactly in the note's own header) */} {/* Right Header Section: Live/Share Status, Avatars & Save Status (on phones it sits compactly in the note's own header) */}
<div className="hidden sm:flex items-center gap-2"> <div className="hidden sm:flex items-center gap-2">
{subView === 'editor' && selectedDocId ? ( {subView === 'editor' && selectedDocId ? (
/* Save Status Pill */ <>
{/* Live connection pill */}
{docHeaderStatus?.showLive && (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] text-muted bg-panel border border-border"
title={
docHeaderStatus.channel === 'ws'
? 'Live-Verbindung über WebSocket'
: docHeaderStatus.channel === 'sse'
? 'Live-Verbindung über Server-Sent Events (WebSocket wird blockiert)'
: docHeaderStatus.channel === 'longpoll'
? 'Live über Long-Polling (WebSocket und SSE werden blockiert)'
: 'Keine Live-Verbindung möglich – Änderungen werden alle 3 Sekunden abgefragt'
}
>
<span
className={`w-1.5 h-1.5 rounded-full ${
docHeaderStatus.channel === 'interval' ? 'bg-status-warn' : 'bg-status-ok'
}`}
/>
{docHeaderStatus.channel === 'longpoll'
? 'Live (Long-Polling)'
: docHeaderStatus.channel === 'interval'
? 'Abgleich alle 3 s'
: 'Live'}
</span>
)}
{/* Who else has this note open */}
{docHeaderStatus && (
<PresenceAvatars
people={docHeaderStatus.otherPeople}
avatars={docHeaderStatus.avatarsById}
/>
)}
{/* Share pill */}
{selectedDocObj &&
!selectedDocObj.isArchived &&
(() => {
const isOwner =
selectedDocObj.isOwner !== false &&
(selectedDocObj.userId === currentUser?.id ||
!selectedDocObj.userId ||
currentUser?.role === 'admin');
const sharesCount = selectedDocObj.sharedWith?.length || 0;
const isShared =
sharesCount > 0 || !isOwner || Boolean(selectedDocObj.sharedFolderId);
const ownerDisplayName = selectedDocObj.ownerName || 'Eigentümer';
if (!isShared) return null;
return isOwner ? (
<button
type="button"
onClick={() => openDocShareModal(selectedDocObj)}
className="px-2 py-0.5 rounded text-[10px] font-semibold bg-accent/15 text-accent border border-accent/30 flex items-center gap-1 hover:bg-accent/25 hover:border-accent/50 transition-colors cursor-pointer"
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt{sharesCount > 0 ? ` (${sharesCount})` : ''}</span>
</button>
) : (
<button
type="button"
onClick={() => openDocShareModal(selectedDocObj)}
className="px-2 py-0.5 rounded text-[10px] font-medium border border-border text-muted flex items-center gap-1 bg-surface-subtle hover:bg-panel-elevated hover:text-main transition-colors cursor-pointer"
title="Teilen & Berechtigungen öffnen"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt von {ownerDisplayName}</span>
</button>
);
})()}
{/* Save Status Pill */}
<div <div
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium border transition-colors ${ className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium border transition-colors ${
docSaveStatus === 'saved' docSaveStatus === 'saved'
@@ -1355,6 +1430,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</> </>
)} )}
</div> </div>
</>
) : null} ) : null}
</div> </div>
</div> </div>
@@ -1622,12 +1698,30 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
onWikiLinkClick={handleWikiLinkClick} onWikiLinkClick={handleWikiLinkClick}
onOpenDocBlock={handleJumpToDocBlock} onOpenDocBlock={handleJumpToDocBlock}
onRequestMerge={(d) => setMergeDialog({ sourceId: d.id })} onRequestMerge={(d) => setMergeDialog({ sourceId: d.id })}
onDocCreated={(created) => {
setDocs((prev) => (prev.some((d) => d.id === created.id) ? prev : [created, ...prev]));
if (created.folderPath) {
const parts = created.folderPath.split('/');
const open: Record<string, boolean> = {};
parts.forEach((_, i) => {
open[parts.slice(0, i + 1).join('/')] = true;
});
setExpandedFolders((prev) => ({ ...prev, ...open }));
}
onStatsChange();
}}
onDocRemoved={(id) => {
setDocs((prev) => prev.filter((d) => d.id !== id));
reloadTrash();
onStatsChange();
}}
onBackMobile={() => setMobileShowDetail(false)} onBackMobile={() => setMobileShowDetail(false)}
onOpenTask={onOpenTask} onOpenTask={onOpenTask}
onSaveStatusChange={(status, err) => { onSaveStatusChange={(status, err) => {
setDocSaveStatus(status); setDocSaveStatus(status);
setDocSaveError(err || null); setDocSaveError(err || null);
}} }}
onHeaderStatusChange={setDocHeaderStatus}
/> />
) : subView === 'tags' ? ( ) : subView === 'tags' ? (
/* ========================================================================= /* =========================================================================
@@ -0,0 +1,163 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { DocumentEditor } from '../DocumentEditor';
import { api } from '../../../lib/api';
import { Document } from '../../../lib/types';
import { EngineState } from '../../../lib/docSync/engine';
let currentListener: ((state: EngineState) => void) | null = null;
const mockUndo = vi.fn(() => ({ ok: true }));
const mockRedo = vi.fn(() => ({ ok: true }));
const baseEngineState: EngineState = {
ready: true,
status: 'saved',
role: 'owner',
title: 'Test Notiz',
blocks: [{ id: 'b1', documentId: 'doc_1', type: 'text', content: 'Hallo', level: 0, position: 1000 }],
pendingCount: 0,
shared: true,
sessions: 2,
presence: [{ userId: 'u2', name: 'Bob' }],
lockedByOthers: [],
conflicts: [],
canUndo: false,
canRedo: false,
channel: 'ws',
};
vi.mock('../../../lib/docSync/engine', () => {
return {
DocSyncEngine: class {
subscribe(fn: (state: EngineState) => void) {
currentListener = fn;
fn(baseEngineState);
return () => {
currentListener = null;
};
}
start() {
return Promise.resolve();
}
stop() {}
getState() {
return baseEngineState;
}
setTaskLinks() {}
focusBlock() {}
applyFlat() {}
setTitle() {}
undo() {
return mockUndo();
}
redo() {
return mockRedo();
}
},
};
});
vi.mock('../../../lib/api', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../lib/api')>();
return {
...actual,
api: {
...actual.api,
getDocument: vi.fn(),
getUsersDirectory: vi.fn(() => Promise.resolve([])),
getDocumentBacklinks: vi.fn(() => Promise.resolve({ backlinks: [] })),
},
};
});
const makeDoc = (overrides: Partial<Document> = {}): Document => ({
id: 'doc_1',
userId: 'u1',
title: 'Test Notiz',
icon: '📝',
folderPath: '',
content: '',
blocks: [{ id: 'b1', documentId: 'doc_1', type: 'text', content: 'Hallo', level: 0, position: 1000 }],
tags: [],
isPinned: false,
isArchived: false,
isOwner: true,
createdAt: '2026-10-30T08:00:00Z',
updatedAt: '2026-10-30T08:00:00Z',
...overrides,
});
describe('DocumentEditor header actions & status callback (Teil D)', () => {
beforeEach(() => {
vi.clearAllMocks();
const doc = makeDoc();
vi.mocked(api.getDocument).mockResolvedValue(doc);
});
it('reports live/presence status upward via onHeaderStatusChange and renders Undo/Redo + Three-Dots menu', async () => {
const doc = makeDoc({ isOwner: true });
const onHeaderStatusChange = vi.fn();
const onRequestMerge = vi.fn();
const onDeleteDoc = vi.fn();
const onOpenShareModal = vi.fn();
render(
<DocumentEditor
docId="doc_1"
initialDoc={doc}
availableFolders={[]}
onDocMetadataChange={vi.fn()}
onDeleteDoc={onDeleteDoc}
onOpenShareModal={onOpenShareModal}
onSelectTag={vi.fn()}
onRequestMerge={onRequestMerge}
onHeaderStatusChange={onHeaderStatusChange}
/>
);
await waitFor(() => {
expect(onHeaderStatusChange).toHaveBeenCalledWith(
expect.objectContaining({
showLive: true,
channel: 'ws',
})
);
});
// Undo / Redo buttons disabled initially
const undoBtn = screen.getByTitle('Rückgängig (Strg+Z)') as HTMLButtonElement;
const redoBtn = screen.getByTitle('Wiederholen (Strg+Y)') as HTMLButtonElement;
expect(undoBtn.disabled).toBe(true);
expect(redoBtn.disabled).toBe(true);
// Enable Undo & Redo via engine state update
currentListener?.({ ...baseEngineState, canUndo: true, canRedo: true });
await waitFor(() => {
expect(undoBtn.disabled).toBe(false);
expect(redoBtn.disabled).toBe(false);
});
fireEvent.click(undoBtn);
expect(mockUndo).toHaveBeenCalledTimes(1);
fireEvent.click(redoBtn);
expect(mockRedo).toHaveBeenCalledTimes(1);
// Share button is directly visible
const shareBtn = screen.getByTitle('Notiz teilen und Berechtigungen');
fireEvent.click(shareBtn);
expect(onOpenShareModal).toHaveBeenCalledWith(doc);
// Three-dots menu opens on click, shows secondary actions, and closes on Escape
const moreBtn = screen.getByTitle('Weitere Aktionen');
fireEvent.click(moreBtn);
expect(screen.getByText('Notiz anheften')).toBeTruthy();
expect(screen.getByText('Mit anderer Notiz zusammenführen…')).toBeTruthy();
expect(screen.getByText('Als Markdown (.md) exportieren')).toBeTruthy();
expect(screen.getByText('Notiz löschen')).toBeTruthy();
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByText('Notiz anheften')).toBeNull();
});
});
@@ -118,3 +118,140 @@ describe('conflict copies', () => {
]); ]);
}); });
}); });
describe('slash command menu and outliner fixes', () => {
it('does not open the slash menu for URLs like https://a.de/b/ but opens for Text /co and closes on Escape or Backspace', () => {
const block = { id: 'b1', type: 'bullet', content: '', level: 0 } as DocumentBlock;
const { container, queryByText } = renderBlock(block);
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
fireEvent.change(ta, { target: { value: 'https://a.de/b/' } });
expect(queryByText('Befehl auswählen')).toBeNull();
fireEvent.change(ta, { target: { value: 'Text /co' } });
expect(queryByText('Befehl auswählen')).toBeTruthy();
expect(queryByText('/code')).toBeTruthy();
// Removing the slash closes the menu
fireEvent.change(ta, { target: { value: 'Text ' } });
expect(queryByText('Befehl auswählen')).toBeNull();
// Even when no command matches, Escape closes the menu
fireEvent.change(ta, { target: { value: '/zzzznotfound' } });
expect(queryByText('Kein Befehl gefunden')).toBeTruthy();
fireEvent.keyDown(ta, { key: 'Escape' });
expect(queryByText('Befehl auswählen')).toBeNull();
});
it('scrolls the active slash command button into view on ArrowDown', () => {
const scrollSpy = vi.fn();
HTMLElement.prototype.scrollIntoView = scrollSpy;
const block = { id: 'b1', type: 'bullet', content: '', level: 0 } as DocumentBlock;
const { container } = renderBlock(block);
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
fireEvent.change(ta, { target: { value: '/' } });
scrollSpy.mockClear();
fireEvent.keyDown(ta, { key: 'ArrowDown' });
expect(scrollSpy).toHaveBeenCalledWith({ block: 'nearest', inline: 'nearest' });
});
it('creates a new block on Enter at the last empty top-level bullet, after outdenting if indented', () => {
const onIndent = vi.fn();
const onCreateBelow = vi.fn();
const indented = { id: 'b1', type: 'bullet', content: '', level: 1 } as DocumentBlock;
const { container, rerender } = render(
<OutlinerBlock
docId="d"
block={indented}
index={0}
isLast
maxAllowedLevel={2}
hasChildren={false}
isFocused
canEdit
onUpdateBlock={() => {}}
onCreateBlockBelow={onCreateBelow}
onDeleteBlock={() => {}}
onIndentBlock={onIndent}
onNavigateFocus={() => {}}
registerTextareaRef={() => {}}
/>
);
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
fireEvent.keyDown(ta, { key: 'Enter' });
expect(onIndent).toHaveBeenCalledWith('b1', -1);
expect(onCreateBelow).not.toHaveBeenCalled();
rerender(
<OutlinerBlock
docId="d"
block={{ ...indented, level: 0 }}
index={0}
isLast
maxAllowedLevel={1}
hasChildren={false}
isFocused
canEdit
onUpdateBlock={() => {}}
onCreateBlockBelow={onCreateBelow}
onDeleteBlock={() => {}}
onIndentBlock={onIndent}
onNavigateFocus={() => {}}
registerTextareaRef={() => {}}
/>
);
fireEvent.keyDown(ta, { key: 'Enter' });
expect(onCreateBelow).toHaveBeenCalledWith('b1', '');
});
it('inserts a bare URL for /link with a web address in an empty bullet, and [Text](url) otherwise', () => {
const updates: DocumentBlock[] = [];
const block = { id: 'b1', type: 'bullet', content: '', level: 0 } as DocumentBlock;
const { container, getByText, getByRole, getByPlaceholderText, rerender } = renderBlock(block, {
onUpdateBlock: (u: DocumentBlock) => updates.push(u),
});
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
fireEvent.change(ta, { target: { value: '/link' } });
fireEvent.keyDown(ta, { key: 'Enter' });
fireEvent.click(getByText('Web-Link'));
fireEvent.change(getByPlaceholderText('https://example.com'), { target: { value: 'https://example.com/test' } });
fireEvent.click(getByRole('button', { name: 'Link einfügen' }));
expect(updates[updates.length - 1]?.content).toBe('https://example.com/test');
// Non-empty bullet keeps [Text](url)
rerender(
<OutlinerBlock
docId="d"
block={{ ...block, content: 'Siehe' }}
index={0}
isLast
maxAllowedLevel={1}
hasChildren={false}
isFocused={false}
canEdit
onUpdateBlock={(u: DocumentBlock) => updates.push(u)}
onCreateBlockBelow={() => {}}
onDeleteBlock={() => {}}
onIndentBlock={() => {}}
onNavigateFocus={() => {}}
registerTextareaRef={() => {}}
/>
);
ta.focus();
fireEvent.change(ta, { target: { value: 'Siehe /link' } });
fireEvent.keyDown(ta, { key: 'Enter' });
fireEvent.click(getByText('Web-Link'));
fireEvent.change(getByPlaceholderText('z. B. MindSift Dokumentation'), { target: { value: 'Doku' } });
fireEvent.change(getByPlaceholderText('https://example.com'), { target: { value: 'https://example.com/doku' } });
fireEvent.click(getByRole('button', { name: 'Link einfügen' }));
expect(updates[updates.length - 1]?.content).toBe('Siehe [Doku](https://example.com/doku)');
});
});
+11 -9
View File
@@ -1,14 +1,16 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { firstBareUrl, hidePreviewContent } from '../linkPreview'; import { linkCardUrl, hidePreviewContent } from '../linkPreview';
describe('link preview helpers', () => { describe('link preview helpers', () => {
it('finds the first plain https address', () => { it('returns the URL only when the entire trimmed content is a single https address', () => {
expect(firstBareUrl('https://example.com/a?b=1')).toBe('https://example.com/a?b=1'); expect(linkCardUrl('https://example.com/a?b=1')).toBe('https://example.com/a?b=1');
expect(firstBareUrl('Rezept: https://koch.de/spaetzle, lecker!')).toBe('https://koch.de/spaetzle'); expect(linkCardUrl(' https://example.com/page/ ')).toBe('https://example.com/page/');
expect(firstBareUrl('siehe (https://x.de/p).')).toBeNull(); // inside brackets: not a plain address expect(linkCardUrl('Rezept: https://koch.de/spaetzle, lecker!')).toBeNull();
expect(firstBareUrl('[Kochseite](https://koch.de)')).toBeNull(); expect(linkCardUrl('https://a.de https://b.de')).toBeNull();
expect(firstBareUrl('http://unsicher.de')).toBeNull(); expect(linkCardUrl('siehe (https://x.de/p).')).toBeNull();
expect(firstBareUrl('nur Text')).toBeNull(); expect(linkCardUrl('[Kochseite](https://koch.de)')).toBeNull();
expect(linkCardUrl('http://unsicher.de')).toBeNull();
expect(linkCardUrl('nur Text')).toBeNull();
}); });
it('hides a preview by turning the address into a named link', () => { it('hides a preview by turning the address into a named link', () => {
@@ -16,6 +18,6 @@ describe('link preview helpers', () => {
'Lesen: [Käsespätzle Rezept](https://koch.de/x)' 'Lesen: [Käsespätzle Rezept](https://koch.de/x)'
); );
expect(hidePreviewContent('https://koch.de/x', 'https://koch.de/x', '')).toBe('[koch.de](https://koch.de/x)'); expect(hidePreviewContent('https://koch.de/x', 'https://koch.de/x', '')).toBe('[koch.de](https://koch.de/x)');
expect(firstBareUrl(hidePreviewContent('https://koch.de/x', 'https://koch.de/x', 'T'))).toBeNull(); expect(linkCardUrl(hidePreviewContent('https://koch.de/x', 'https://koch.de/x', 'T'))).toBeNull();
}); });
}); });
@@ -130,4 +130,121 @@ describe('useVoiceInput', () => {
await waitFor(() => expect(none.result.current.mode).toBe('unsupported')); await waitFor(() => expect(none.result.current.mode).toBe('unsupported'));
expect(none.result.current.available).toBe(false); expect(none.result.current.available).toBe(false);
}); });
it('browser mode with live=true: restarts on pause (onend), keeps base text, rate-limits restarts to 300ms, and sends final=true only on stop()', async () => {
const instances: any[] = [];
class FakeSpeechRecognition {
lang = '';
interimResults = false;
continuous = false;
onresult: ((e: any) => void) | null = null;
onerror: ((e: any) => void) | null = null;
onend: (() => void) | null = null;
constructor() {
instances.push(this);
}
start() {}
stop() {
this.onend?.();
}
abort() {}
}
(window as any).SpeechRecognition = FakeSpeechRecognition;
getAuthStatus.mockResolvedValue({ voice: { mode: 'browser', maxSeconds: 600 } });
const onText = vi.fn();
const { result } = renderHook(() => useVoiceInput({ onText, live: true }));
await waitFor(() => expect(result.current.mode).toBe('browser'));
vi.useFakeTimers();
await act(async () => {
await result.current.start();
});
expect(instances).toHaveLength(1);
// First utterance before a pause
act(() => {
instances[0].onresult?.({ results: [[{ transcript: 'Erster Satz.' }]] });
});
expect(onText).toHaveBeenLastCalledWith('Erster Satz.', false);
// Browser ends recognition on speech pause -> immediate restart (#2)
act(() => {
instances[0].onend?.();
});
expect(instances).toHaveLength(2);
expect(onText).not.toHaveBeenCalledWith(expect.anything(), true);
// Another immediate onend within <300ms waits until 300ms have passed
act(() => {
instances[1].onend?.();
});
expect(instances).toHaveLength(2);
act(() => {
vi.advanceTimersByTime(300);
});
expect(instances).toHaveLength(3);
// Second utterance appends to base text
act(() => {
instances[2].onresult?.({ results: [[{ transcript: 'Zweiter Satz.' }]] });
});
expect(onText).toHaveBeenLastCalledWith('Erster Satz. Zweiter Satz.', false);
// Explicit stop() finishes with final=true
await act(async () => {
await result.current.stop();
});
expect(onText).toHaveBeenLastCalledWith('Erster Satz. Zweiter Satz.', true);
expect(result.current.state).toBe('idle');
});
it('server mode with live=true: cuts segments onto new MediaRecorders on the same stream, orders out-of-order responses by chunk index, and releases stream at the end', async () => {
getAuthStatus.mockResolvedValue({ voice: { mode: 'server', maxSeconds: 600 } });
const resolvers: Array<(text: string) => void> = [];
transcribe.mockImplementation(
() =>
new Promise<string>((resolve) => {
resolvers.push(resolve);
})
);
const onText = vi.fn();
const { result } = renderHook(() => useVoiceInput({ onText, live: true }));
await waitFor(() => expect(result.current.mode).toBe('server'));
vi.useFakeTimers();
await act(async () => {
await result.current.start();
});
// Advance 8s -> segment 0 is cut and sent to transcribe, stream is NOT released yet
await act(async () => {
await vi.advanceTimersByTimeAsync(8000);
});
expect(transcribe).toHaveBeenCalledTimes(1);
expect(stopTrack).not.toHaveBeenCalled();
// Advance 4s and stop -> segment 1 (final) is sent to transcribe, stream IS released now
await act(async () => {
await vi.advanceTimersByTimeAsync(4000);
await result.current.stop();
});
expect(transcribe).toHaveBeenCalledTimes(2);
expect(stopTrack).toHaveBeenCalledTimes(1);
// Resolve chunk 1 BEFORE chunk 0 to verify index-based ordering
await act(async () => {
resolvers[1]('zweiter Teil');
});
expect(onText).toHaveBeenLastCalledWith('zweiter Teil', false);
// Now resolve chunk 0 -> both chunks are done, ordered "Erster Teil zweiter Teil" with final=true
await act(async () => {
resolvers[0]('Erster Teil');
});
expect(onText).toHaveBeenLastCalledWith('Erster Teil zweiter Teil', true);
expect(result.current.state).toBe('idle');
});
}); });
+22 -3
View File
@@ -35,6 +35,7 @@ import {
TagSummary, TagSummary,
User, User,
UserInvite, UserInvite,
NotificationSettings,
VoiceAdminStatus, VoiceAdminStatus,
MergeResult, MergeResult,
WSMessage, WSMessage,
@@ -292,6 +293,7 @@ export const api = {
discordWebhookUrl?: string; discordWebhookUrl?: string;
customWebhookUrl?: string; customWebhookUrl?: string;
notifyBeforeMinutes?: number; notifyBeforeMinutes?: number;
notificationSettings?: NotificationSettings;
}): Promise<User> { }): Promise<User> {
return request<User>('/api/v1/auth/settings', { return request<User>('/api/v1/auth/settings', {
method: 'PATCH', method: 'PATCH',
@@ -410,6 +412,18 @@ export const api = {
return ensureArray(res); return ensureArray(res);
}, },
async watchTask(taskId: string): Promise<{ watching: boolean }> {
return request<{ watching: boolean }>(`/api/v1/tasks/${encodeURIComponent(taskId)}/watch`, {
method: 'POST',
});
},
async unwatchTask(taskId: string): Promise<{ watching: boolean }> {
return request<{ watching: boolean }>(`/api/v1/tasks/${encodeURIComponent(taskId)}/watch`, {
method: 'DELETE',
});
},
async reorderTasks(items: { id: string; sortOrder: number; kanbanStatus?: string }[], taskListId?: string): Promise<{ success: boolean }> { async reorderTasks(items: { id: string; sortOrder: number; kanbanStatus?: string }[], taskListId?: string): Promise<{ success: boolean }> {
const url = taskListId ? `/api/v1/task-lists/${taskListId}/tasks/reorder` : '/api/v1/tasks/reorder'; const url = taskListId ? `/api/v1/task-lists/${taskListId}/tasks/reorder` : '/api/v1/tasks/reorder';
return request<{ success: boolean }>(url, { return request<{ success: boolean }>(url, {
@@ -709,14 +723,19 @@ export const api = {
return request<TrashedDocument[]>('/api/v1/documents/trash'); return request<TrashedDocument[]>('/api/v1/documents/trash');
}, },
/** Turns a bullet and its children into a note of its own; the bullet becomes a [[link]]. */ /** Creates a note from a bullet's children; the editor then replaces the branch by `link` itself. */
async extractBranch(docId: string, blockId: string, clientId: string): Promise<Document> { async extractBranch(docId: string, blockId: string, clientId: string): Promise<{ document: Document; link: string }> {
return request<Document>(`/api/v1/documents/${docId}/extract`, { return request<{ document: Document; link: string }>(`/api/v1/documents/${docId}/extract`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ blockId, clientId }), body: JSON.stringify({ blockId, clientId }),
}); });
}, },
/** Puts a note created by extractBranch into the trash again. */
async undoExtract(newDocId: string): Promise<void> {
await request(`/api/v1/documents/${newDocId}/extract/undo`, { method: 'POST' });
},
/** Appends the source note to the target; the source goes to the trash. */ /** Appends the source note to the target; the source goes to the trash. */
async mergeDocuments(targetId: string, sourceId: string): Promise<MergeResult> { async mergeDocuments(targetId: string, sourceId: string): Promise<MergeResult> {
return request<MergeResult>(`/api/v1/documents/${targetId}/merge`, { return request<MergeResult>(`/api/v1/documents/${targetId}/merge`, {
+5 -5
View File
@@ -16,12 +16,12 @@ export interface LinkPreview {
ok: boolean; ok: boolean;
} }
// First plain https address in a text (not inside [text](url), not right after "(" or "<") // Entire bullet content (trimmed) is a single plain https address
const BARE_URL_RE = /(^|[\s])(https:\/\/[^\s<>()\]]+[^\s<>()\].,;:!?'"])/; const CARD_URL_RE = /^https:\/\/[^\s<>()\]]+[^\s<>()\].,;:!?'"]$/;
export function firstBareUrl(content: string): string | null { export function linkCardUrl(content: string): string | null {
const m = BARE_URL_RE.exec(content || ''); const trimmed = (content || '').trim();
return m ? m[2] : null; return CARD_URL_RE.test(trimmed) ? trimmed : null;
} }
/** Replaces the address by a named link, which shows no preview. */ /** Replaces the address by a named link, which shows no preview. */
+28
View File
@@ -46,6 +46,32 @@ export interface VoiceAdminStatus {
// Who can see whom: nobody, members of the same organization, or everybody (Doku/sharing-and-permissions.md) // Who can see whom: nobody, members of the same organization, or everybody (Doku/sharing-and-permissions.md)
export type SharingMode = 'private' | 'organizations' | 'open'; export type SharingMode = 'private' | 'organizations' | 'open';
export interface NotificationChannelRouting {
email: boolean;
discord: boolean;
}
export interface NotificationEvents {
due: NotificationChannelRouting;
shared: NotificationChannelRouting;
taskDone: NotificationChannelRouting;
watched: NotificationChannelRouting;
}
export interface DailySummarySettings {
enabled: boolean;
time: string;
email: boolean;
discord: boolean;
}
export interface NotificationSettings {
paused: boolean;
includeOwnWatched: boolean;
events: NotificationEvents;
dailySummary: DailySummarySettings;
}
export interface User { export interface User {
id: string; id: string;
email: string; email: string;
@@ -59,6 +85,7 @@ export interface User {
discordWebhookUrl?: string; discordWebhookUrl?: string;
customWebhookUrl?: string; customWebhookUrl?: string;
notifyBeforeMinutes?: number; notifyBeforeMinutes?: number;
notificationSettings?: NotificationSettings;
organizationId?: string; organizationId?: string;
createdAt: string; createdAt: string;
} }
@@ -113,6 +140,7 @@ export interface Thought {
subtasks?: SubTask[]; subtasks?: SubTask[];
assignees?: string[]; // user IDs, only people with access to the task list assignees?: string[]; // user IDs, only people with access to the task list
recurrence?: Recurrence; recurrence?: Recurrence;
isWatching?: boolean;
alarmSent?: boolean; alarmSent?: boolean;
sortOrder?: number; sortOrder?: number;
createdAt: string; createdAt: string;
+208 -43
View File
@@ -10,28 +10,54 @@ export interface VoiceInputOptions {
onError?: (message: string) => void; onError?: (message: string) => void;
/** Browser mode: keep listening through pauses (hold-to-record) instead of stopping after a sentence. */ /** Browser mode: keep listening through pauses (hold-to-record) instead of stopping after a sentence. */
continuous?: boolean; continuous?: boolean;
/** Live dictation mode (/diktat): auto-restart browser speech recognition on pause, and chunk server recordings every ~8s or ~0.8s of silence. */
live?: boolean;
} }
const LIVE_CHUNK_MS = 8000;
const LIVE_SILENCE_MS = 800;
const LIVE_TICK_MS = 200;
const LIVE_SPEECH_LEVEL = 0.02;
const MIN_RESTART_INTERVAL_MS = 300;
/** /**
* Microphone input for one button: start, stop (text arrives), cancel (nothing arrives). How the text * Microphone input for one button: start, stop (text arrives), cancel (nothing arrives). How the text
* is made depends on the server setting, see lib/voice.ts. mode is null until known, "off" or * is made depends on the server setting, see lib/voice.ts. mode is null until known, "off" or
* "unsupported" means: do not show a microphone. * "unsupported" means: do not show a microphone.
*/ */
export function useVoiceInput({ onText, onError, continuous = false }: VoiceInputOptions) { export function useVoiceInput({ onText, onError, continuous = false, live = false }: VoiceInputOptions) {
const [mode, setMode] = useState<EffectiveVoiceMode | null>(null); const [mode, setMode] = useState<EffectiveVoiceMode | null>(null);
const [state, setState] = useState<VoiceState>('idle'); const [state, setState] = useState<VoiceState>('idle');
const [pendingChunks, setPendingChunks] = useState(0);
const maxSecondsRef = useRef(600); const maxSecondsRef = useRef(600);
const recorderRef = useRef<VoiceRecorder | null>(null); const recorderRef = useRef<VoiceRecorder | null>(null);
const recognitionRef = useRef<any>(null); const recognitionRef = useRef<any>(null);
const timerRef = useRef<number | null>(null); const timerRef = useRef<number | null>(null);
const segmentTimerRef = useRef<number | null>(null);
const restartTimerRef = useRef<number | null>(null);
const cancelledRef = useRef(false); const cancelledRef = useRef(false);
const stoppedRef = useRef(false);
const transcriptRef = useRef(''); const transcriptRef = useRef('');
const baseTranscriptRef = useRef('');
const lastRestartAtRef = useRef(0);
const chunkIndexRef = useRef(0);
const chunkResultsRef = useRef<Map<number, string>>(new Map());
const inFlightChunksRef = useRef(0);
const mountedRef = useRef(true); const mountedRef = useRef(true);
const onTextRef = useRef(onText); const onTextRef = useRef(onText);
onTextRef.current = onText; onTextRef.current = onText;
const onErrorRef = useRef(onError); const onErrorRef = useRef(onError);
onErrorRef.current = onError; onErrorRef.current = onError;
const clearAllTimers = () => {
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
timerRef.current = null;
if (segmentTimerRef.current !== null) window.clearInterval(segmentTimerRef.current);
segmentTimerRef.current = null;
if (restartTimerRef.current !== null) window.clearTimeout(restartTimerRef.current);
restartTimerRef.current = null;
};
useEffect(() => { useEffect(() => {
mountedRef.current = true; mountedRef.current = true;
void loadVoiceConfig().then((cfg) => { void loadVoiceConfig().then((cfg) => {
@@ -41,7 +67,7 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu
}); });
return () => { return () => {
mountedRef.current = false; mountedRef.current = false;
if (timerRef.current !== null) window.clearTimeout(timerRef.current); clearAllTimers();
recorderRef.current?.cancel(); recorderRef.current?.cancel();
try { try {
recognitionRef.current?.abort?.(); recognitionRef.current?.abort?.();
@@ -54,17 +80,65 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu
const setStateSafe = (s: VoiceState) => { const setStateSafe = (s: VoiceState) => {
if (mountedRef.current) setState(s); if (mountedRef.current) setState(s);
}; };
const setPendingChunksSafe = (n: number) => {
if (mountedRef.current) setPendingChunks(n);
};
const fail = (message: string) => { const fail = (message: string) => {
setStateSafe('idle'); setStateSafe('idle');
setPendingChunksSafe(0);
onErrorRef.current?.(message); onErrorRef.current?.(message);
}; };
const clearTimer = () => {
if (timerRef.current !== null) window.clearTimeout(timerRef.current); const joinedChunkText = () =>
timerRef.current = null; [...chunkResultsRef.current.entries()]
.sort((a, b) => a[0] - b[0])
.map(([, t]) => t.trim())
.filter(Boolean)
.join(' ');
const processSegmentPromise = (idx: number, blobPromise: Promise<Blob | null>) => {
inFlightChunksRef.current += 1;
setPendingChunksSafe(inFlightChunksRef.current);
void blobPromise
.then(async (blob) => {
if (cancelledRef.current) return;
if (!blob || blob.size === 0) {
inFlightChunksRef.current = Math.max(0, inFlightChunksRef.current - 1);
setPendingChunksSafe(inFlightChunksRef.current);
if (stoppedRef.current && inFlightChunksRef.current === 0) {
setStateSafe('idle');
onTextRef.current(joinedChunkText(), true);
}
return;
}
const text = await api.transcribe(blob);
if (cancelledRef.current) return;
chunkResultsRef.current.set(idx, text || '');
inFlightChunksRef.current = Math.max(0, inFlightChunksRef.current - 1);
setPendingChunksSafe(inFlightChunksRef.current);
const combined = joinedChunkText();
transcriptRef.current = combined;
if (stoppedRef.current && inFlightChunksRef.current === 0) {
setStateSafe('idle');
onTextRef.current(combined, true);
} else {
onTextRef.current(combined, false);
}
})
.catch((err: any) => {
if (cancelledRef.current) return;
cancelledRef.current = true;
inFlightChunksRef.current = 0;
clearAllTimers();
recorderRef.current?.cancel();
recorderRef.current = null;
fail(err?.message || 'Spracherkennung fehlgeschlagen');
});
}; };
const stop = useCallback(async () => { const stop = useCallback(async () => {
clearTimer(); stoppedRef.current = true;
clearAllTimers();
if (recognitionRef.current) { if (recognitionRef.current) {
try { try {
recognitionRef.current.stop(); recognitionRef.current.stop();
@@ -74,8 +148,20 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu
return; return;
} }
const rec = recorderRef.current; const rec = recorderRef.current;
if (!rec) return; if (!rec) {
if (live && state === 'listening') {
setStateSafe('idle');
onTextRef.current(transcriptRef.current, true);
}
return;
}
recorderRef.current = null; recorderRef.current = null;
if (live) {
const idx = chunkIndexRef.current++;
setStateSafe('transcribing');
processSegmentPromise(idx, rec.stop());
return;
}
const blob = await rec.stop(); const blob = await rec.stop();
if (cancelledRef.current) return; if (cancelledRef.current) return;
if (!blob) { if (!blob) {
@@ -93,11 +179,15 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu
} }
// fail only touches refs and state setters // fail only touches refs and state setters
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, [live, state]);
const startServer = async () => { const startServer = async () => {
const rec = new VoiceRecorder(); const rec = new VoiceRecorder();
recorderRef.current = rec; recorderRef.current = rec;
chunkIndexRef.current = 0;
chunkResultsRef.current = new Map();
inFlightChunksRef.current = 0;
setPendingChunksSafe(0);
setStateSafe('listening'); setStateSafe('listening');
try { try {
await rec.start(); await rec.start();
@@ -111,6 +201,104 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu
return; return;
} }
timerRef.current = window.setTimeout(() => void stop(), maxSecondsRef.current * 1000); timerRef.current = window.setTimeout(() => void stop(), maxSecondsRef.current * 1000);
if (live) {
let segmentMs = 0;
let silentMs = 0;
let hadSpeech = false;
segmentTimerRef.current = window.setInterval(() => {
if (cancelledRef.current || stoppedRef.current || recorderRef.current !== rec) return;
const lvl = rec.level();
if (lvl !== null) {
if (lvl >= LIVE_SPEECH_LEVEL) {
hadSpeech = true;
silentMs = 0;
} else if (hadSpeech) {
silentMs += LIVE_TICK_MS;
}
}
segmentMs += LIVE_TICK_MS;
if (segmentMs >= LIVE_CHUNK_MS || (hadSpeech && silentMs >= LIVE_SILENCE_MS)) {
segmentMs = 0;
silentMs = 0;
hadSpeech = false;
const idx = chunkIndexRef.current++;
processSegmentPromise(idx, rec.cutSegment());
}
}, LIVE_TICK_MS);
}
};
const spawnBrowserRecognition = (Recognition: any, isRestart: boolean) => {
const recognition = new Recognition();
recognition.lang = 'de-DE';
recognition.interimResults = true;
recognition.continuous = continuous || live;
recognition.onresult = (event: any) => {
let sessionText = '';
for (let i = 0; i < event.results.length; i++) sessionText += event.results[i][0].transcript;
if (live && baseTranscriptRef.current) {
const base = baseTranscriptRef.current.trimEnd();
const next = sessionText.trimStart();
transcriptRef.current = next ? `${base} ${next}` : base;
} else {
transcriptRef.current = sessionText;
}
onTextRef.current(transcriptRef.current, false);
};
recognition.onerror = (event: any) => {
if (event?.error === 'not-allowed' || event?.error === 'service-not-allowed') {
cancelledRef.current = true;
clearAllTimers();
recognitionRef.current = null;
fail('Mikrofon nicht freigegeben');
}
};
recognition.onend = () => {
recognitionRef.current = null;
if (cancelledRef.current) {
clearAllTimers();
return;
}
if (!live || stoppedRef.current) {
clearAllTimers();
setStateSafe('idle');
onTextRef.current(transcriptRef.current, true);
return;
}
baseTranscriptRef.current = transcriptRef.current;
const now = Date.now();
const elapsed = now - lastRestartAtRef.current;
const doRestart = () => {
if (cancelledRef.current || stoppedRef.current) return;
lastRestartAtRef.current = Date.now();
spawnBrowserRecognition(Recognition, true);
};
if (lastRestartAtRef.current === 0 || elapsed >= MIN_RESTART_INTERVAL_MS) {
doRestart();
} else {
restartTimerRef.current = window.setTimeout(() => {
restartTimerRef.current = null;
doRestart();
}, MIN_RESTART_INTERVAL_MS - elapsed);
}
};
recognitionRef.current = recognition;
if (!isRestart) setStateSafe('listening');
try {
recognition.start();
if (!isRestart) {
timerRef.current = window.setTimeout(() => void stop(), maxSecondsRef.current * 1000);
}
} catch {
recognitionRef.current = null;
if (!isRestart) {
fail('Spracheingabe konnte nicht gestartet werden');
} else {
clearAllTimers();
setStateSafe('idle');
onTextRef.current(transcriptRef.current, true);
}
}
}; };
const startBrowser = () => { const startBrowser = () => {
@@ -119,55 +307,31 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu
fail('Spracheingabe wird von diesem Browser nicht unterstützt'); fail('Spracheingabe wird von diesem Browser nicht unterstützt');
return; return;
} }
const recognition = new Recognition(); baseTranscriptRef.current = '';
recognition.lang = 'de-DE'; lastRestartAtRef.current = 0;
recognition.interimResults = true; spawnBrowserRecognition(Recognition, false);
recognition.continuous = continuous;
recognition.onresult = (event: any) => {
let combined = '';
for (let i = 0; i < event.results.length; i++) combined += event.results[i][0].transcript;
transcriptRef.current = combined;
onTextRef.current(combined, false);
};
recognition.onerror = (event: any) => {
if (event?.error === 'not-allowed' || event?.error === 'service-not-allowed') {
cancelledRef.current = true;
recognitionRef.current = null;
fail('Mikrofon nicht freigegeben');
}
};
recognition.onend = () => {
clearTimer();
recognitionRef.current = null;
if (cancelledRef.current) return;
setStateSafe('idle');
onTextRef.current(transcriptRef.current, true);
};
recognitionRef.current = recognition;
setStateSafe('listening');
try {
recognition.start();
timerRef.current = window.setTimeout(() => void stop(), maxSecondsRef.current * 1000);
} catch {
recognitionRef.current = null;
fail('Spracheingabe konnte nicht gestartet werden');
}
}; };
const start = useCallback(async () => { const start = useCallback(async () => {
if (recorderRef.current || recognitionRef.current) return; if (recorderRef.current || recognitionRef.current) return;
cancelledRef.current = false; cancelledRef.current = false;
stoppedRef.current = false;
transcriptRef.current = ''; transcriptRef.current = '';
baseTranscriptRef.current = '';
lastRestartAtRef.current = 0;
const current = mode ?? currentVoiceConfig().mode; const current = mode ?? currentVoiceConfig().mode;
if (current === 'server') await startServer(); if (current === 'server') await startServer();
else if (current === 'browser') startBrowser(); else if (current === 'browser') startBrowser();
else fail(current === 'off' ? 'Spracheingabe ist ausgeschaltet' : 'Spracheingabe wird von diesem Browser nicht unterstützt'); else fail(current === 'off' ? 'Spracheingabe ist ausgeschaltet' : 'Spracheingabe wird von diesem Browser nicht unterstützt');
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, continuous]); }, [mode, continuous, live]);
const cancel = useCallback(() => { const cancel = useCallback(() => {
cancelledRef.current = true; cancelledRef.current = true;
clearTimer(); stoppedRef.current = true;
inFlightChunksRef.current = 0;
setPendingChunksSafe(0);
clearAllTimers();
recorderRef.current?.cancel(); recorderRef.current?.cancel();
recorderRef.current = null; recorderRef.current = null;
try { try {
@@ -191,6 +355,7 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu
mode, mode,
available: mode === 'server' || mode === 'browser', available: mode === 'server' || mode === 'browser',
state, state,
pendingChunks,
start, start,
stop, stop,
cancel, cancel,
+44 -13
View File
@@ -86,19 +86,7 @@ export class VoiceRecorder {
this.stream = await navigator.mediaDevices.getUserMedia({ this.stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
}); });
const type = recordingType(); this.startSegmentRecorder();
this.recorder = new MediaRecorder(this.stream, type ? { mimeType: type } : undefined);
this.chunks = [];
this.recorder.ondataavailable = (e) => {
if (e.data.size > 0) this.chunks.push(e.data);
};
this.recorder.onstop = () => {
const blob = this.chunks.length ? new Blob(this.chunks, { type: this.recorder?.mimeType || type || 'audio/webm' }) : null;
this.release();
this.done?.(blob);
this.done = null;
};
this.recorder.start(1000);
try { try {
const Ctx = (window as any).AudioContext || (window as any).webkitAudioContext; const Ctx = (window as any).AudioContext || (window as any).webkitAudioContext;
this.audioCtx = new Ctx(); this.audioCtx = new Ctx();
@@ -113,6 +101,25 @@ export class VoiceRecorder {
} }
} }
private startSegmentRecorder(): void {
if (!this.stream) return;
const type = recordingType();
const rec = new MediaRecorder(this.stream, type ? { mimeType: type } : undefined);
const chunks: Blob[] = [];
rec.ondataavailable = (e) => {
if (e.data.size > 0) chunks.push(e.data);
};
rec.onstop = () => {
const blob = chunks.length ? new Blob(chunks, { type: rec.mimeType || type || 'audio/webm' }) : null;
this.release();
this.done?.(blob);
this.done = null;
};
this.recorder = rec;
this.chunks = chunks;
rec.start(1000);
}
/** Input level 0..1 (roughly loudness), or null when unknown. */ /** Input level 0..1 (roughly loudness), or null when unknown. */
level(): number | null { level(): number | null {
if (!this.analyser || !this.samples) return null; if (!this.analyser || !this.samples) return null;
@@ -137,6 +144,30 @@ export class VoiceRecorder {
}); });
} }
/**
* Stops the current MediaRecorder segment and immediately starts a new one on the same MediaStream.
* Resolves with the finished segment's audio Blob while recording continues seamlessly.
*/
cutSegment(): Promise<Blob | null> {
if (!this.stream || !this.recorder || this.recorder.state === 'inactive') {
return Promise.resolve(null);
}
const oldRecorder = this.recorder;
const oldChunks = this.chunks;
this.startSegmentRecorder();
return new Promise((resolve) => {
oldRecorder.onstop = () => {
const blob = oldChunks.length
? new Blob(oldChunks, { type: oldRecorder.mimeType || 'audio/webm' })
: null;
resolve(blob);
};
oldRecorder.stop();
});
}
cancel(): void { cancel(): void {
this.done = null; this.done = null;
if (this.recorder && this.recorder.state !== 'inactive') { if (this.recorder && this.recorder.state !== 'inactive') {
+27 -1
View File
@@ -13,6 +13,11 @@ import (
// --- MULTI-CHANNEL ALARM DISPATCHER (EMAIL, DISCORD, CUSTOM WEBHOOK) --- // --- MULTI-CHANNEL ALARM DISPATCHER (EMAIL, DISCORD, CUSTOM WEBHOOK) ---
func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error { func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error {
return s.sendEmailHTML(toEmail, subject, bodyText, "")
}
// sendEmailHTML sends text plus an optional HTML version (multipart/alternative).
func (s *Server) sendEmailHTML(toEmail, subject, bodyText, html string) error {
if s.cfg.DemoMode { if s.cfg.DemoMode {
return errDemoOutgoing return errDemoOutgoing
} }
@@ -20,7 +25,7 @@ func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error
if err != nil { if err != nil {
return err return err
} }
if err := sendMail(cfg, toEmail, subject, bodyText); err != nil { if err := sendMail(cfg, toEmail, subject, bodyText, html); err != nil {
step := "" step := ""
if se, ok := err.(*smtpStepError); ok { if se, ok := err.(*smtpStepError); ok {
step = se.Step step = se.Step
@@ -123,6 +128,27 @@ func (s *Server) startAlarmTicker() {
if !alarmDue(a.DueDate, a.NotifyBeforeMinutes, now) { if !alarmDue(a.DueDate, a.NotifyBeforeMinutes, now) {
continue continue
} }
// Channels come from the notification settings (row "due"); a pause holds the alarm back
set := s.store.NotificationSettingsOf(a.UserID)
if set.Paused {
continue
}
channels := []string{}
if set.Events.Due.Email {
channels = append(channels, "email")
}
if set.Events.Due.Discord {
channels = append(channels, "discord")
}
for _, ch := range a.AlarmChannels {
if ch == "webhook" {
channels = append(channels, "webhook")
}
}
if a.AlarmEmail == "" {
a.AlarmEmail = a.UserEmail
}
a.AlarmChannels = channels
if len(a.AlarmChannels) == 0 { if len(a.AlarmChannels) == 0 {
continue continue
} }
+12
View File
@@ -341,6 +341,8 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
return return
} }
stats, _ := s.store.GetDashboardStats(userID) stats, _ := s.store.GetDashboardStats(userID)
ns := s.store.NotificationSettingsOf(userID)
user.NotificationSettings = &ns
writeJSON(w, http.StatusOK, map[string]interface{}{ writeJSON(w, http.StatusOK, map[string]interface{}{
"user": user, "user": user,
"stats": stats, "stats": stats,
@@ -448,6 +450,8 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
NotifyBeforeMinutes *int `json:"notifyBeforeMinutes"` NotifyBeforeMinutes *int `json:"notifyBeforeMinutes"`
// Needed to change password or e-mail address (FLAW-30) // Needed to change password or e-mail address (FLAW-30)
CurrentPassword string `json:"currentPassword"` CurrentPassword string `json:"currentPassword"`
// Which notifications go where (notify.go)
NotificationSettings *domain.NotificationSettings `json:"notificationSettings"`
} }
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")
@@ -528,6 +532,14 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
if changesPassword || changesEmail { if changesPassword || changesEmail {
s.notifyAccountChange(before, changesPassword, changesEmail, u.Email) s.notifyAccountChange(before, changesPassword, changesEmail, u.Email)
} }
if req.NotificationSettings != nil {
if err := s.store.SetNotificationSettings(userID, *req.NotificationSettings); err != nil {
writeError(w, http.StatusInternalServerError, "Benachrichtigungen konnten nicht gespeichert werden")
return
}
}
ns := s.store.NotificationSettingsOf(userID)
u.NotificationSettings = &ns
writeJSON(w, http.StatusOK, u) writeJSON(w, http.StatusOK, u)
} }
+11
View File
@@ -262,6 +262,9 @@ func (s *Server) handleCreateDocShare(w http.ResponseWriter, r *http.Request) {
if req.TeamID != "" { if req.TeamID != "" {
share, err := s.store.CreateDocTeamShare(userID, docID, req.TeamID, req.Permission) share, err := s.store.CreateDocTeamShare(userID, docID, req.TeamID, req.Permission)
if err == nil {
defer s.notifyShared(userID, "team", req.TeamID, "die Notiz", s.docTitle(userID, docID))
}
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
@@ -286,6 +289,7 @@ func (s *Server) handleCreateDocShare(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
} }
s.notifyShared(userID, "user", share.UserID, "die Notiz", s.docTitle(userID, docID))
s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED") s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED")
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED") s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
writeJSON(w, http.StatusCreated, share) writeJSON(w, http.StatusCreated, share)
@@ -313,3 +317,10 @@ func (s *Server) handleGetCollaborators(w http.ResponseWriter, r *http.Request)
} }
writeJSON(w, http.StatusOK, users) writeJSON(w, http.StatusOK, users)
} }
func (s *Server) docTitle(userID, docID string) string {
if d, err := s.store.GetDocumentByID(userID, docID); err == nil && d.Title != "" {
return d.Title
}
return "Unbenannte Notiz"
}
+1
View File
@@ -212,6 +212,7 @@ func (s *Server) handleCreateFolderShare(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
} }
s.notifyShared(userID, share.TargetType, share.TargetID, "den Ordner", s.store.SharedFolderName(folderID))
s.wsHub.BroadcastToUsers(s.store.GetSharedFolderUserIDs(folderID), "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID}) s.wsHub.BroadcastToUsers(s.store.GetSharedFolderUserIDs(folderID), "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID})
if docIDs, err := s.store.GetSharedFolderDocIDs(folderID); err == nil { if docIDs, err := s.store.GetSharedFolderDocIDs(folderID); err == nil {
for _, docID := range docIDs { for _, docID := range docIDs {
+3
View File
@@ -107,6 +107,9 @@ func (s *Server) handleCreateListShare(w http.ResponseWriter, r *http.Request) {
return return
} }
if l, err := s.store.GetListByID(currentUserID, listID); err == nil {
s.notifyShared(currentUserID, targetType, share.TargetID, "die Liste", l.Title)
}
if targetType == "team" { if targetType == "team" {
members, _ := s.store.GetTeamMembers(share.TargetID) members, _ := s.store.GetTeamMembers(share.TargetID)
for _, tm := range members { for _, tm := range members {
+3
View File
@@ -125,6 +125,9 @@ func (s *Server) handleCreateTaskListShare(w http.ResponseWriter, r *http.Reques
return return
} }
s.broadcastTaskListChange(listID, nil, "TASK_LIST_UPDATED") s.broadcastTaskListChange(listID, nil, "TASK_LIST_UPDATED")
if tl, err := s.store.GetTaskListByID(userID, listID); err == nil {
s.notifyShared(userID, req.TargetType, req.TargetID, "die Aufgabenliste", tl.Title)
}
writeJSON(w, http.StatusCreated, share) writeJSON(w, http.StatusCreated, share)
} }
+6
View File
@@ -94,6 +94,7 @@ func (s *Server) handleGetThoughts(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
s.markWatching(userID, thoughts)
writeJSON(w, http.StatusOK, thoughts) writeJSON(w, http.StatusOK, thoughts)
} }
@@ -278,6 +279,8 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
s.notifyDocChanged(docID, head) s.notifyDocChanged(docID, head)
} }
s.wsHub.BroadcastToUsers(append(s.taskAudience(before), s.taskAudience(updated)...), "TASK_UPDATED", updated) s.wsHub.BroadcastToUsers(append(s.taskAudience(before), s.taskAudience(updated)...), "TASK_UPDATED", updated)
s.notifyTaskChange(userID, before, updated, false)
updated.IsWatching = s.store.WatchedTaskIDs(userID)[updated.ID]
writeJSON(w, http.StatusOK, updated) writeJSON(w, http.StatusOK, updated)
} }
@@ -304,6 +307,9 @@ func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) {
writeStoreError(w, http.StatusInternalServerError, err) writeStoreError(w, http.StatusInternalServerError, err)
return return
} }
if th != nil {
s.notifyTaskChange(userID, th, nil, true)
}
s.wsHub.BroadcastToUsers(audience, "TASK_DELETED", map[string]string{"id": thoughtID}) s.wsHub.BroadcastToUsers(audience, "TASK_DELETED", map[string]string{"id": thoughtID})
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
} }
+22 -8
View File
@@ -117,7 +117,7 @@ func senderAddress(raw, name string) (envelope, header string, err error) {
// buildMessage creates an RFC 5322 message: Date and Message-ID headers, RFC 2047 encoded subject // buildMessage creates an RFC 5322 message: Date and Message-ID headers, RFC 2047 encoded subject
// (umlauts, emoji) and a quoted-printable UTF-8 body with CRLF line endings. // (umlauts, emoji) and a quoted-printable UTF-8 body with CRLF line endings.
func buildMessage(from, to, subject, body string, now time.Time) []byte { func buildMessage(from, to, subject, body, html string, now time.Time) []byte {
idBytes := make([]byte, 12) idBytes := make([]byte, 12)
_, _ = rand.Read(idBytes) _, _ = rand.Read(idBytes)
domain := ehloName(from) domain := ehloName(from)
@@ -129,12 +129,26 @@ func buildMessage(from, to, subject, body string, now time.Time) []byte {
fmt.Fprintf(&buf, "Date: %s\r\n", now.Format(time.RFC1123Z)) fmt.Fprintf(&buf, "Date: %s\r\n", now.Format(time.RFC1123Z))
fmt.Fprintf(&buf, "Message-ID: <%s@%s>\r\n", hex.EncodeToString(idBytes), domain) fmt.Fprintf(&buf, "Message-ID: <%s@%s>\r\n", hex.EncodeToString(idBytes), domain)
buf.WriteString("MIME-Version: 1.0\r\n") buf.WriteString("MIME-Version: 1.0\r\n")
buf.WriteString("Content-Type: text/plain; charset=\"utf-8\"\r\n") part := func(contentType, text string) {
fmt.Fprintf(&buf, "Content-Type: %s; charset=\"utf-8\"\r\n", contentType)
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n") buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
qp := quotedprintable.NewWriter(&buf) qp := quotedprintable.NewWriter(&buf)
_, _ = qp.Write([]byte(strings.ReplaceAll(strings.ReplaceAll(body, "\r\n", "\n"), "\n", "\r\n"))) _, _ = qp.Write([]byte(strings.ReplaceAll(strings.ReplaceAll(text, "\r\n", "\n"), "\n", "\r\n")))
_ = qp.Close() _ = qp.Close()
buf.WriteString("\r\n") buf.WriteString("\r\n")
}
if html == "" {
part("text/plain", body)
return buf.Bytes()
}
// Text for simple clients, HTML for the rest
boundary := "ms-" + hex.EncodeToString(idBytes)
fmt.Fprintf(&buf, "Content-Type: multipart/alternative; boundary=\"%s\"\r\n\r\n", boundary)
fmt.Fprintf(&buf, "--%s\r\n", boundary)
part("text/plain", body)
fmt.Fprintf(&buf, "--%s\r\n", boundary)
part("text/html", html)
fmt.Fprintf(&buf, "--%s--\r\n", boundary)
return buf.Bytes() return buf.Bytes()
} }
@@ -199,14 +213,14 @@ func authenticate(client *smtp.Client, cfg smtpSettings) error {
// sendMail sends one mail. If AUTH PLAIN is refused and the server also offers LOGIN, it reconnects once // sendMail sends one mail. If AUTH PLAIN is refused and the server also offers LOGIN, it reconnects once
// and tries LOGIN; a 451 on both points to the mail server's SASL backend. // and tries LOGIN; a 451 on both points to the mail server's SASL backend.
func sendMail(cfg smtpSettings, to, subject, body string) error { func sendMail(cfg smtpSettings, to, subject, body, html string) error {
err := sendMailOnce(cfg, to, subject, body) err := sendMailOnce(cfg, to, subject, body, html)
var first *authAttemptError var first *authAttemptError
if cfg.authMech != "" || !errors.As(err, &first) || first.mech != "PLAIN" || !first.loginOffered { if cfg.authMech != "" || !errors.As(err, &first) || first.mech != "PLAIN" || !first.loginOffered {
return err return err
} }
cfg.authMech = "LOGIN" cfg.authMech = "LOGIN"
retryErr := sendMailOnce(cfg, to, subject, body) retryErr := sendMailOnce(cfg, to, subject, body, html)
var second *authAttemptError var second *authAttemptError
if retryErr == nil || !errors.As(retryErr, &second) { if retryErr == nil || !errors.As(retryErr, &second) {
return retryErr return retryErr
@@ -220,7 +234,7 @@ func sendMail(cfg smtpSettings, to, subject, body string) error {
// sendMailOnce runs the SMTP dialogue step by step instead of smtp.SendMail, so it can introduce itself // sendMailOnce runs the SMTP dialogue step by step instead of smtp.SendMail, so it can introduce itself
// properly, honour SkipVerify and report the exact step a server refuses. // properly, honour SkipVerify and report the exact step a server refuses.
func sendMailOnce(cfg smtpSettings, to, subject, body string) error { func sendMailOnce(cfg smtpSettings, to, subject, body, html string) error {
fromAddr, fromHeader, err := senderAddress(cfg.From, cfg.FromName) fromAddr, fromHeader, err := senderAddress(cfg.From, cfg.FromName)
if err != nil { if err != nil {
return &smtpStepError{"Absender", err} return &smtpStepError{"Absender", err}
@@ -278,7 +292,7 @@ func sendMailOnce(cfg smtpSettings, to, subject, body string) error {
if err != nil { if err != nil {
return &smtpStepError{"DATA", err} return &smtpStepError{"DATA", err}
} }
if _, err := wc.Write(buildMessage(fromHeader, toHeader, subject, body, time.Now())); err != nil { if _, err := wc.Write(buildMessage(fromHeader, toHeader, subject, body, html, time.Now())); err != nil {
_ = wc.Close() _ = wc.Close()
return &smtpStepError{"DATA", err} return &smtpStepError{"DATA", err}
} }
+7 -7
View File
@@ -175,13 +175,13 @@ func TestSendMailStartTLSWithSelfSignedCertificate(t *testing.T) {
f := startFakeSMTP(t, "") f := startFakeSMTP(t, "")
// The certificate is self-signed: refused unless verification is switched off // The certificate is self-signed: refused unless verification is switched off
err := sendMail(f.settings(false), "anna@example.com", "Test", "Hallo") err := sendMail(f.settings(false), "anna@example.com", "Test", "Hallo", "")
var stepErr *smtpStepError var stepErr *smtpStepError
if !errors.As(err, &stepErr) || stepErr.Step != "STARTTLS" { if !errors.As(err, &stepErr) || stepErr.Step != "STARTTLS" {
t.Fatalf("expected a STARTTLS error with verification on, got %v", err) t.Fatalf("expected a STARTTLS error with verification on, got %v", err)
} }
if err := sendMail(f.settings(true), "anna@example.com", "⏰ Aufgabe fällig: Müll", "Zeile 1\nZeile 2 mit Ümlaut"); err != nil { if err := sendMail(f.settings(true), "anna@example.com", "⏰ Aufgabe fällig: Müll", "Zeile 1\nZeile 2 mit Ümlaut", ""); err != nil {
t.Fatalf("send with SkipVerify failed: %v", err) t.Fatalf("send with SkipVerify failed: %v", err)
} }
f.mu.Lock() f.mu.Lock()
@@ -206,7 +206,7 @@ func TestSendMailStartTLSWithSelfSignedCertificate(t *testing.T) {
func TestSendMailReportsTheRefusingStep(t *testing.T) { func TestSendMailReportsTheRefusingStep(t *testing.T) {
f := startFakeSMTP(t, "451 4.3.2 Internal server error") f := startFakeSMTP(t, "451 4.3.2 Internal server error")
err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo") err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo", "")
var stepErr *smtpStepError var stepErr *smtpStepError
if !errors.As(err, &stepErr) || stepErr.Step != "RCPT TO" || !strings.Contains(err.Error(), "451") { if !errors.As(err, &stepErr) || stepErr.Step != "RCPT TO" || !strings.Contains(err.Error(), "451") {
t.Fatalf("expected the 451 to be reported at RCPT TO, got %v", err) t.Fatalf("expected the 451 to be reported at RCPT TO, got %v", err)
@@ -218,7 +218,7 @@ func TestSendMailWithDisplayNameSender(t *testing.T) {
f := startFakeSMTP(t, "") f := startFakeSMTP(t, "")
cfg := f.settings(true) cfg := f.settings(true)
cfg.From = "MindSift Alarme <alarm@firma.de>" cfg.From = "MindSift Alarme <alarm@firma.de>"
if err := sendMail(cfg, "Anna <anna@example.com>", "Test", "Hallo"); err != nil { if err := sendMail(cfg, "Anna <anna@example.com>", "Test", "Hallo", ""); err != nil {
t.Fatalf("send failed: %v", err) t.Fatalf("send failed: %v", err)
} }
f.mu.Lock() f.mu.Lock()
@@ -244,7 +244,7 @@ func TestAuthFallsBackToLogin(t *testing.T) {
f := startFakeSMTP(t, "") f := startFakeSMTP(t, "")
f.authMechs = "PLAIN LOGIN" f.authMechs = "PLAIN LOGIN"
f.authFail["PLAIN"] = "451 4.3.2 Internal server error" f.authFail["PLAIN"] = "451 4.3.2 Internal server error"
if err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo"); err != nil { if err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo", ""); err != nil {
t.Fatalf("LOGIN fallback should succeed: %v", err) t.Fatalf("LOGIN fallback should succeed: %v", err)
} }
f.mu.Lock() f.mu.Lock()
@@ -257,7 +257,7 @@ func TestAuthFallsBackToLogin(t *testing.T) {
func TestAuthLoginOnlyAndBothFailing(t *testing.T) { func TestAuthLoginOnlyAndBothFailing(t *testing.T) {
f := startFakeSMTP(t, "") f := startFakeSMTP(t, "")
f.authMechs = "LOGIN" f.authMechs = "LOGIN"
if err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo"); err != nil { if err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo", ""); err != nil {
t.Fatalf("LOGIN-only server: %v", err) t.Fatalf("LOGIN-only server: %v", err)
} }
@@ -265,7 +265,7 @@ func TestAuthLoginOnlyAndBothFailing(t *testing.T) {
g.authMechs = "PLAIN LOGIN" g.authMechs = "PLAIN LOGIN"
g.authFail["PLAIN"] = "451 4.3.2 Internal server error" g.authFail["PLAIN"] = "451 4.3.2 Internal server error"
g.authFail["LOGIN"] = "451 4.3.2 Internal server error" g.authFail["LOGIN"] = "451 4.3.2 Internal server error"
err := sendMail(g.settings(true), "anna@example.com", "Test", "Hallo") err := sendMail(g.settings(true), "anna@example.com", "Test", "Hallo", "")
if err == nil || !strings.Contains(err.Error(), "PLAIN") || !strings.Contains(err.Error(), "LOGIN") || !strings.Contains(err.Error(), "SASL") { if err == nil || !strings.Contains(err.Error(), "PLAIN") || !strings.Contains(err.Error(), "LOGIN") || !strings.Contains(err.Error(), "SASL") {
t.Fatalf("expected both mechanisms and the server hint in the error, got %v", err) t.Fatalf("expected both mechanisms and the server hint in the error, got %v", err)
} }
+417
View File
@@ -0,0 +1,417 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"html"
"log/slog"
"net/http"
"sort"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
)
// Notifications besides task alarms: events go into a queue per recipient and are sent bundled, one
// message per recipient and channel once the oldest event is NOTIFY_DIGEST_MINUTES old. Each user picks
// the channels per event kind (own mail address, own Discord webhook from the alarm settings), can
// pause everything and gets a daily summary of due tasks at a chosen time. Own changes never notify the
// one who made them, except on watched tasks when the user asked for it. The demo sends nothing.
const notifyMaxAttempts = 3
// notifyItem is one line of a bundled message.
type notifyItem struct {
kind, title, text string
}
// notify queues an event for the recipients that want it (settings, pause, own-change rule).
func (s *Server) notify(kind, actorID string, recipients []string, payload map[string]string) {
if s.cfg.DemoMode {
return
}
seen := map[string]bool{}
for _, uid := range recipients {
if uid == "" || seen[uid] {
continue
}
seen[uid] = true
set := s.store.NotificationSettingsOf(uid)
if set.Paused {
continue
}
if uid == actorID && !(kind == "watched" && set.IncludeOwnWatched) {
continue
}
if ch := set.Channels(kind); !ch.Email && !ch.Discord {
continue
}
if err := s.store.QueueNotification(uid, kind, payload); err != nil {
slog.Warn("Benachrichtigung konnte nicht vorgemerkt werden", "user_id", uid, "error", err)
}
}
}
func (s *Server) actorName(userID string) string {
if u, err := s.store.GetUserByID(userID); err == nil && u != nil && u.DisplayName != "" {
return u.DisplayName
}
return "Jemand"
}
// notifyShared tells the people a note, folder or list was shared with (teams: all members).
func (s *Server) notifyShared(actorID, targetType, targetID, what, title string) {
var recipients []string
if targetType == "team" {
members, _ := s.store.GetTeamMembers(targetID)
for _, m := range members {
recipients = append(recipients, m.UserID)
}
} else {
recipients = []string{targetID}
}
s.notify("shared", actorID, recipients, map[string]string{
"title": title,
"text": fmt.Sprintf("%s hat %s „%s“ mit dir geteilt", s.actorName(actorID), what, title),
})
}
// notifyTaskChange covers watchers (any change) and list members (task completed).
func (s *Server) notifyTaskChange(actorID string, before, after *domain.Thought, deleted bool) {
t := after
if t == nil {
t = before
}
if t == nil {
return
}
title := strings.TrimSpace(strings.SplitN(t.Content, "\n", 2)[0])
name := s.actorName(actorID)
var change string
switch {
case deleted:
change = "gelöscht"
case before != nil && after != nil && !before.IsCompleted && after.IsCompleted:
change = "erledigt"
case before != nil && after != nil && before.IsCompleted && !after.IsCompleted:
change = "wieder geöffnet"
case before != nil && after != nil && before.DueDate != after.DueDate:
change = "Fälligkeit geändert auf " + domain.HumanDue(after.DueDate)
case before != nil && after != nil && before.Content != after.Content:
change = "Text geändert"
case before != nil && after != nil && before.Priority != after.Priority:
change = "Priorität geändert auf " + after.Priority
case before != nil && after != nil && before.TaskListID != after.TaskListID:
change = "in eine andere Liste verschoben"
default:
change = "geändert"
}
payload := map[string]string{"title": title, "text": fmt.Sprintf("%s hat „%s“ %s", name, title, change)}
watchers := s.store.TaskWatchers(t.ID)
s.notify("watched", actorID, watchers, payload)
if change == "erledigt" && t.TaskListID != "" {
watching := map[string]bool{}
for _, w := range watchers {
watching[w] = true
}
var members []string
for _, uid := range s.taskAudience(t) {
if !watching[uid] { // watchers already hear about it
members = append(members, uid)
}
}
s.notify("taskDone", actorID, members, payload)
}
if deleted {
s.store.ForgetTaskWatchers(t.ID)
}
}
// --- Background jobs ---
func (s *Server) startNotificationJobs() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for now := range ticker.C {
s.flushNotifications(now)
s.sendDailySummaries(now)
}
}
// flushNotifications sends one bundled message per recipient whose oldest event waited long enough.
func (s *Server) flushNotifications(now time.Time) {
minutes := s.cfg.NotifyDigestMinutes
if minutes < 1 {
minutes = 5
}
window := time.Duration(minutes) * time.Minute
users, err := s.store.DueNotificationUsers(now.Add(-window))
if err != nil {
slog.Error("Benachrichtigungen konnten nicht gelesen werden", "error", err)
return
}
for _, uid := range users {
queued, err := s.store.QueuedNotifications(uid)
if err != nil || len(queued) == 0 {
continue
}
set := s.store.NotificationSettingsOf(uid)
var emailItems, discordItems []notifyItem
ids := make([]int64, 0, len(queued))
maxAttempts := 0
for _, q := range queued {
ids = append(ids, q.ID)
if q.Attempts > maxAttempts {
maxAttempts = q.Attempts
}
if set.Paused {
continue
}
item := notifyItem{kind: q.Kind, title: q.Payload["title"], text: q.Payload["text"]}
ch := set.Channels(q.Kind)
if ch.Email {
emailItems = append(emailItems, item)
}
if ch.Discord {
discordItems = append(discordItems, item)
}
}
if err := s.deliver(uid, "Neuigkeiten", emailItems, discordItems); err != nil {
if maxAttempts+1 >= notifyMaxAttempts {
slog.Warn("Benachrichtigungen nach mehreren Fehlversuchen verworfen", "user_id", uid, "count", len(ids), "error", err)
_ = s.store.DeleteQueuedNotifications(ids)
} else {
_ = s.store.BumpQueuedNotifications(ids)
}
continue
}
_ = s.store.DeleteQueuedNotifications(ids)
}
}
// deliver sends the items to the user's mail address and/or Discord webhook.
func (s *Server) deliver(userID, heading string, emailItems, discordItems []notifyItem) error {
u, err := s.store.GetUserByID(userID)
if err != nil || u == nil || !u.IsActive {
return nil // account gone: nothing to deliver
}
var firstErr error
if len(emailItems) > 0 {
to := u.AlarmEmail
if to == "" {
to = u.Email
}
subject := "MindSift: " + heading
if len(emailItems) == 1 {
subject = "MindSift: " + emailItems[0].text
}
if err := s.sendNotifyEmail(to, subject, notifyText(heading, emailItems, s.notifyLink()), notifyHTML(heading, emailItems, s.notifyLink())); err != nil {
firstErr = err
}
}
if len(discordItems) > 0 && u.DiscordWebhookURL != "" {
if err := s.sendNotifyDiscord(u.DiscordWebhookURL, heading, discordItems); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
// sendNotifyEmail / sendNotifyDiscord are replaced in tests.
func (s *Server) sendNotifyEmail(to, subject, text, htmlBody string) error {
if s.notifyEmailHook != nil {
return s.notifyEmailHook(to, subject, text, htmlBody)
}
return s.sendEmailHTML(to, subject, text, htmlBody)
}
func (s *Server) sendNotifyDiscord(webhookURL, heading string, items []notifyItem) error {
if s.notifyDiscordHook != nil {
return s.notifyDiscordHook(webhookURL, heading, items)
}
return s.sendDiscordDigest(webhookURL, heading, items)
}
func (s *Server) notifyLink() string {
if !s.cfg.BaseURLSet {
return ""
}
return strings.TrimRight(s.cfg.BaseURL, "/") + "/"
}
// sendDiscordDigest posts one embed with a line per item (at most 10).
func (s *Server) sendDiscordDigest(webhookURL, heading string, items []notifyItem) error {
if s.cfg.DemoMode {
return errDemoOutgoing
}
allowPrivate := s.allowPrivateWebhooks()
if err := validateWebhookURL(webhookURL, true, allowPrivate); err != nil {
return err
}
lines := make([]string, 0, 11)
for i, it := range items {
if i == 10 {
lines = append(lines, fmt.Sprintf("… und %d weitere", len(items)-10))
break
}
lines = append(lines, "• "+it.text)
}
body, _ := json.Marshal(map[string]interface{}{
"embeds": []map[string]interface{}{{
"title": "MindSift: " + heading,
"description": strings.Join(lines, "\n"),
"color": 2450411,
}},
})
resp, err := guardedClient(allowPrivate).Post(webhookURL, "application/json", bytes.NewReader(body))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("HTTP Status %d", resp.StatusCode)
}
return nil
}
func notifyText(heading string, items []notifyItem, link string) string {
var b strings.Builder
b.WriteString(heading + "\n\n")
for _, it := range items {
b.WriteString("• " + it.text + "\n")
}
if link != "" {
b.WriteString("\nÖffnen: " + link + "\n")
}
b.WriteString("\nDiese Benachrichtigungen stellst du in MindSift unter Kontoeinstellungen → Alarme ein.\n")
return b.String()
}
// notifyHTML: simple table layout with inline styles, no external resources (works in most clients).
func notifyHTML(heading string, items []notifyItem, link string) string {
var cards strings.Builder
for _, it := range items {
cards.WriteString(`<tr><td style="padding:12px 16px;border:1px solid #e3e6ea;border-radius:8px;background:#ffffff;font-size:14px;color:#1f2328">` +
html.EscapeString(it.text) + `</td></tr><tr><td style="height:8px"></td></tr>`)
}
button := ""
if link != "" {
button = `<tr><td style="padding-top:8px"><a href="` + html.EscapeString(link) +
`" style="display:inline-block;padding:10px 18px;background:#2563eb;color:#ffffff;text-decoration:none;border-radius:6px;font-size:14px">In MindSift öffnen</a></td></tr>`
}
return `<!doctype html><html><body style="margin:0;padding:24px;background:#f4f5f7;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">` +
`<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:560px;margin:0 auto">` +
`<tr><td style="font-size:18px;font-weight:bold;color:#2563eb;padding-bottom:4px">MindSift</td></tr>` +
`<tr><td style="font-size:15px;color:#1f2328;padding-bottom:16px">` + html.EscapeString(heading) + `</td></tr>` +
cards.String() + button +
`<tr><td style="font-size:12px;color:#6b7280;padding-top:20px">Diese Benachrichtigungen stellst du in MindSift unter Kontoeinstellungen → Alarme ein.</td></tr>` +
`</table></body></html>`
}
// --- Daily summary ---
// sendDailySummaries sends each user who wants one their summary in the 30 minutes after the chosen
// time, once a day. Nothing due → no message.
func (s *Server) sendDailySummaries(now time.Time) {
day := now.Format("2006-01-02")
for _, uid := range s.store.ActiveUserIDs() {
set := s.store.NotificationSettingsOf(uid)
ds := set.DailySummary
if set.Paused || !ds.Enabled || (!ds.Email && !ds.Discord) {
continue
}
at, err := time.ParseInLocation("2006-01-02 15:04", day+" "+ds.Time, now.Location())
if err != nil || now.Before(at) || now.Sub(at) >= 30*time.Minute {
continue
}
items := s.summaryItems(uid, now)
if len(items) == 0 || !s.store.ClaimDailySummary(uid, day) {
continue
}
var mail, discord []notifyItem
if ds.Email {
mail = items
}
if ds.Discord {
discord = items
}
if err := s.deliver(uid, "Deine Aufgaben für heute", mail, discord); err != nil {
slog.Warn("Tageszusammenfassung konnte nicht verschickt werden", "user_id", uid, "error", err)
}
}
}
// summaryItems: overdue, today, tomorrow (with time), plus how many follow in the next 7 days.
func (s *Server) summaryItems(userID string, now time.Time) []notifyItem {
today := now.Format("2006-01-02")
tomorrow := now.AddDate(0, 0, 1).Format("2006-01-02")
tasks, err := s.store.OpenTasksDueBefore(userID, now.AddDate(0, 0, 8).Format("2006-01-02"))
if err != nil {
return nil
}
sort.SliceStable(tasks, func(i, j int) bool { return tasks[i].DueDate < tasks[j].DueDate })
var items []notifyItem
later := 0
for _, t := range tasks {
d := t.DueDate
if len(d) > 10 {
d = d[:10]
}
label := t.Title
if t.ListTitle != "" {
label += " (" + t.ListTitle + ")"
}
clock := ""
if len(t.DueDate) >= 16 {
clock = ", " + t.DueDate[11:16] + " Uhr"
}
switch {
case d < today:
items = append(items, notifyItem{kind: "due", title: t.Title, text: "Überfällig seit " + domain.HumanDue(t.DueDate) + ": " + label})
case d == today:
items = append(items, notifyItem{kind: "due", title: t.Title, text: "Heute" + clock + ": " + label})
case d == tomorrow:
items = append(items, notifyItem{kind: "due", title: t.Title, text: "Morgen" + clock + ": " + label})
default:
later++
}
}
if len(items) > 0 && later > 0 {
items = append(items, notifyItem{kind: "due", text: fmt.Sprintf("Außerdem %d weitere Aufgaben in den nächsten 7 Tagen", later)})
}
return items
}
// --- Watching tasks ---
// handleWatchTask: POST (watch) / DELETE (stop) /tasks/{id}/watch
func (s *Server) handleWatchTask(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
taskID := chi.URLParam(r, "id")
if _, err := s.store.GetThoughtByID(userID, taskID); err != nil {
writeError(w, http.StatusNotFound, "Aufgabe nicht gefunden")
return
}
on := r.Method == http.MethodPost
if err := s.store.WatchTask(taskID, userID, on); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"watching": on})
}
// markWatching sets IsWatching on tasks for the requesting user.
func (s *Server) markWatching(userID string, thoughts []domain.Thought) {
watched := s.store.WatchedTaskIDs(userID)
if len(watched) == 0 {
return
}
for i := range thoughts {
thoughts[i].IsWatching = watched[thoughts[i].ID]
}
}
+190
View File
@@ -0,0 +1,190 @@
package api
import (
"net/http"
"strings"
"testing"
"time"
"github.com/sandrodach/mindsift/internal/domain"
)
type sentNote struct{ channel, to, subject, text, html string }
func notifyServer(t *testing.T) (*Server, *[]sentNote) {
t.Helper()
srv, _, _ := setupTestServer(t)
srv.cfg.NotifyDigestMinutes = 5
var sent []sentNote
srv.notifyEmailHook = func(to, subject, text, html string) error {
sent = append(sent, sentNote{channel: "email", to: to, subject: subject, text: text, html: html})
return nil
}
srv.notifyDiscordHook = func(url, heading string, items []notifyItem) error {
lines := make([]string, 0, len(items))
for _, it := range items {
lines = append(lines, it.text)
}
sent = append(sent, sentNote{channel: "discord", to: url, text: strings.Join(lines, "\n")})
return nil
}
return srv, &sent
}
func TestWatchedTaskChangesAreBundled(t *testing.T) {
srv, sent := notifyServer(t)
store := srv.store
router := srv.Router()
owner, ownerToken := newTestUser(t, store, "owner@example.com")
watcher, watcherToken := newTestUser(t, store, "watcher@example.com")
list, _ := store.CreateTaskList(owner.ID, "", "", "Haushalt", "🏠", "#000000")
task, _ := store.CreateThoughtWithDetails(owner.ID, "Fenster putzen", "normal", "manual", "", list.ID, "todo", "")
if _, err := store.AddTaskListShare(list.ID, "user", watcher.ID, "editor", owner.ID); err != nil {
t.Fatal(err)
}
if rec := sessionCall(router, "POST", "/api/v1/tasks/"+task.ID+"/watch", watcherToken, "", ""); rec.Code != http.StatusOK {
t.Fatalf("watch: %d %s", rec.Code, rec.Body.String())
}
_, strangerToken := newTestUser(t, store, "fremd@example.com")
if rec := sessionCall(router, "POST", "/api/v1/tasks/"+task.ID+"/watch", strangerToken, "", ""); rec.Code != http.StatusNotFound {
t.Fatalf("stranger watch: want 404, got %d", rec.Code)
}
// Two changes by the owner within the window → one bundled mail for the watcher, none for the owner
sessionCall(router, "PATCH", "/api/v1/thoughts/"+task.ID, ownerToken, `{"content":"Fenster putzen (innen)"}`, "")
sessionCall(router, "PATCH", "/api/v1/thoughts/"+task.ID, ownerToken, `{"isCompleted":true}`, "")
srv.flushNotifications(time.Now())
if len(*sent) != 0 {
t.Fatalf("nothing may go out before the window ends: %+v", *sent)
}
srv.flushNotifications(time.Now().Add(6 * time.Minute))
var mails []sentNote
for _, n := range *sent {
if n.channel == "email" {
mails = append(mails, n)
}
}
if len(mails) != 1 || mails[0].to != "watcher@example.com" || !strings.Contains(mails[0].text, "Text geändert") ||
!strings.Contains(mails[0].text, "erledigt") || !strings.Contains(mails[0].html, "<html") {
t.Fatalf("expected one bundled HTML mail to the watcher, got %+v", *sent)
}
*sent = nil
srv.flushNotifications(time.Now().Add(20 * time.Minute))
if len(*sent) != 0 {
t.Fatal("queue must be empty after sending")
}
}
func TestOwnChangesPauseAndChannels(t *testing.T) {
srv, sent := notifyServer(t)
store := srv.store
router := srv.Router()
me, myToken := newTestUser(t, store, "me@example.com")
task, _ := store.CreateThoughtWithDetails(me.ID, "Steuer", "normal", "manual", "", "", "todo", "")
_ = store.WatchTask(task.ID, me.ID, true)
// Own change on a watched task: nothing …
sessionCall(router, "PATCH", "/api/v1/thoughts/"+task.ID, myToken, `{"priority":"high"}`, "")
srv.flushNotifications(time.Now().Add(10 * time.Minute))
if len(*sent) != 0 {
t.Fatalf("own change must not notify: %+v", *sent)
}
// … unless asked for, then only over the chosen channel
set := store.NotificationSettingsOf(me.ID)
set.IncludeOwnWatched = true
set.Events.Watched = domain.ChannelChoice{Discord: true}
_ = store.SetNotificationSettings(me.ID, set)
hook := "https://discord.com/api/webhooks/1/x"
_, _ = store.UpdateUserFull(me.ID, nil, nil, nil, nil, nil, nil, &hook, nil, nil, nil)
sessionCall(router, "PATCH", "/api/v1/thoughts/"+task.ID, myToken, `{"priority":"low"}`, "")
srv.flushNotifications(time.Now().Add(10 * time.Minute))
if len(*sent) != 1 || (*sent)[0].channel != "discord" {
t.Fatalf("expected one Discord message, got %+v", *sent)
}
// Paused: nothing at all
*sent = nil
set.Paused = true
_ = store.SetNotificationSettings(me.ID, set)
sessionCall(router, "PATCH", "/api/v1/thoughts/"+task.ID, myToken, `{"priority":"urgent"}`, "")
srv.flushNotifications(time.Now().Add(10 * time.Minute))
if len(*sent) != 0 {
t.Fatalf("paused must send nothing: %+v", *sent)
}
// Settings round trip over the profile API
rec := sessionCall(router, "PATCH", "/api/v1/auth/settings", myToken, `{"notificationSettings":{"paused":false,"events":{"shared":{"email":false,"discord":true}},"dailySummary":{"enabled":true,"time":"6:5"}}}`, "")
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"time":"07:00"`) || !strings.Contains(rec.Body.String(), `"shared":{"email":false,"discord":true}`) {
t.Fatalf("settings: %d %s", rec.Code, rec.Body.String())
}
}
func TestDailySummary(t *testing.T) {
srv, sent := notifyServer(t)
store := srv.store
me, _ := newTestUser(t, store, "summary@example.com")
now := time.Date(2026, 10, 30, 7, 0, 30, 0, time.Local)
set := store.NotificationSettingsOf(me.ID)
set.DailySummary = domain.DailySummary{Enabled: true, Time: "07:00", Email: true}
_ = store.SetNotificationSettings(me.ID, set)
srv.sendDailySummaries(now)
if len(*sent) != 0 {
t.Fatal("nothing due → no summary")
}
for _, td := range [][2]string{{"Altlast", "2026-10-28"}, {"Zahnarzt", "2026-10-30T15:30"}, {"Müll raus", "2026-10-31"}, {"Später", "2026-11-04"}} {
th, _ := store.CreateThoughtWithDetails(me.ID, td[0], "normal", "manual", td[1], "", "todo", "")
if _, err := store.UpdateThoughtTriage(me.ID, th.ID, "keep", "task", "", td[1], ""); err != nil { // triaged into a task
t.Fatal(err)
}
}
srv.sendDailySummaries(now.Add(-2 * time.Minute)) // before the chosen time
if len(*sent) != 0 {
t.Fatal("no summary before the chosen time")
}
srv.sendDailySummaries(now)
if len(*sent) != 1 {
t.Fatalf("expected one summary, got %d", len(*sent))
}
text := (*sent)[0].text
for _, want := range []string{"Überfällig", "Altlast", "Heute, 15:30 Uhr: Zahnarzt", "Morgen: Müll raus", "1 weitere"} {
if !strings.Contains(text, want) {
t.Fatalf("summary lacks %q:\n%s", want, text)
}
}
srv.sendDailySummaries(now.Add(5 * time.Minute))
if len(*sent) != 1 {
t.Fatal("only one summary per day")
}
}
func TestDailySummaryIncludesGroupSharedLists(t *testing.T) {
srv, sent := notifyServer(t)
store := srv.store
owner, _ := newTestUser(t, store, "chef@example.com")
me, _ := newTestUser(t, store, "team@example.com")
team, err := store.CreateTeam("", "Arbeit", "")
if err != nil {
t.Fatal(err)
}
for _, uid := range []string{owner.ID, me.ID} { // the owner must see the group to share with it
if err := store.AddTeamMember(team.ID, uid, "member"); err != nil {
t.Fatal(err)
}
}
list, _ := store.CreateTaskList(owner.ID, "", "", "Projekt", "📋", "#000000")
if _, err := store.AddTaskListShare(list.ID, "team", team.ID, "editor", owner.ID); err != nil {
t.Fatal(err)
}
th, _ := store.CreateThoughtWithDetails(owner.ID, "Bericht abgeben", "normal", "manual", "2026-10-30T10:00", list.ID, "todo", "")
_, _ = store.UpdateThoughtTriage(owner.ID, th.ID, "keep", "task", "", "2026-10-30T10:00", list.ID)
set := store.NotificationSettingsOf(me.ID)
set.DailySummary = domain.DailySummary{Enabled: true, Time: "07:00", Email: true}
_ = store.SetNotificationSettings(me.ID, set)
srv.sendDailySummaries(time.Date(2026, 10, 30, 7, 1, 0, 0, time.Local))
if len(*sent) != 1 || !strings.Contains((*sent)[0].text, "Bericht abgeben (Projekt)") {
t.Fatalf("group-shared task missing: %+v", *sent)
}
}
+17 -3
View File
@@ -53,17 +53,31 @@ func (s *Server) handleExtractBranch(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "blockId fehlt") writeError(w, http.StatusBadRequest, "blockId fehlt")
return return
} }
created, err := s.store.ExtractBranch(userID, docID, req.BlockID, s.restructureActor(r, req.ClientID), s.leases.View(docID)) created, link, err := s.store.ExtractBranch(userID, docID, req.BlockID, s.restructureActor(r, req.ClientID), s.leases.View(docID))
if err != nil { if err != nil {
writeError(w, restructureStatus(err), err.Error()) writeError(w, restructureStatus(err), err.Error())
return return
} }
s.notifyDocChanged(docID, s.store.DocHead(docID))
if full, err := s.store.GetDocumentByID(userID, created.ID); err == nil { if full, err := s.store.GetDocumentByID(userID, created.ID); err == nil {
created = full created = full
} }
s.broadcastDocToAuthorizedUsers(created.ID, "DOC_CREATED") s.broadcastDocToAuthorizedUsers(created.ID, "DOC_CREATED")
writeJSON(w, http.StatusCreated, created) writeJSON(w, http.StatusCreated, map[string]interface{}{"document": created, "link": link})
}
// handleUndoExtract: POST /documents/{id}/extract/undo puts the extracted note {id} into the trash.
func (s *Server) handleUndoExtract(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
users, _ := s.store.GetAuthorizedDocUsers(docID)
if err := s.store.UndoExtract(userID, docID); err != nil {
writeError(w, restructureStatus(err), err.Error())
return
}
for _, uid := range users {
s.wsHub.BroadcastToUser(uid, "DOC_DELETED", map[string]string{"id": docID})
}
writeJSON(w, http.StatusOK, map[string]bool{"undone": true})
} }
// handleMergeDocuments: POST /documents/{id}/merge {sourceId, clientId} appends the source to this note. // handleMergeDocuments: POST /documents/{id}/merge {sourceId, clientId} appends the source to this note.
+27 -10
View File
@@ -41,16 +41,21 @@ func TestExtractBranchIntoOwnNote(t *testing.T) {
if rec.Code != http.StatusCreated { if rec.Code != http.StatusCreated {
t.Fatalf("extract: %d %s", rec.Code, rec.Body.String()) t.Fatalf("extract: %d %s", rec.Code, rec.Body.String())
} }
var created domain.Document var out struct {
_ = json.Unmarshal(rec.Body.Bytes(), &created) Document domain.Document
if created.Title != "Konzept: Garten" || created.FolderPath != "Arbeit" { Link string
}
_ = json.Unmarshal(rec.Body.Bytes(), &out)
created := out.Document
if created.Title != "Konzept: Garten" || created.FolderPath != "Arbeit" || out.Link != "[[Konzept: Garten]] #draußen" {
t.Fatalf("new note: %q in %q", created.Title, created.FolderPath) t.Fatalf("new note: %q in %q", created.Title, created.FolderPath)
} }
if got := strings.Join(flatOf(t, store, uid, created.ID), "|"); got != "Beete|>Erde kaufen|Zaun" { if got := strings.Join(flatOf(t, store, uid, created.ID), "|"); got != "Beete|>Erde kaufen|Zaun" {
t.Fatalf("new note blocks: %s", got) t.Fatalf("new note blocks: %s", got)
} }
if got := strings.Join(flatOf(t, store, uid, src.ID), "|"); got != "[[Konzept: Garten]] #draußen|Danach" { // The server leaves the source alone; the editor replaces the branch itself
t.Fatalf("source after extract: %s", got) if got := strings.Join(flatOf(t, store, uid, src.ID), "|"); got != "- Konzept: Garten #draußen|>Beete|>>Erde kaufen|>Zaun|Danach" {
t.Fatalf("source must stay unchanged on the server: %s", got)
} }
// Same title again: the link must stay unique // Same title again: the link must stay unique
_, _ = store.CreateDocument(uid, "", "Projekt 2", "", "", []domain.DocumentBlock{{ID: "blk_x", Type: "bullet", Content: "Konzept: Garten", Level: 0}}, nil, false) _, _ = store.CreateDocument(uid, "", "Projekt 2", "", "", []domain.DocumentBlock{{ID: "blk_x", Type: "bullet", Content: "Konzept: Garten", Level: 0}}, nil, false)
@@ -61,10 +66,21 @@ func TestExtractBranchIntoOwnNote(t *testing.T) {
p2 = d.ID p2 = d.ID
} }
} }
var second domain.Document var second struct{ Document domain.Document }
_ = json.Unmarshal(sessionCall(router, "POST", "/api/v1/documents/"+p2+"/extract", token, `{"blockId":"blk_x"}`, "").Body.Bytes(), &second) _ = json.Unmarshal(sessionCall(router, "POST", "/api/v1/documents/"+p2+"/extract", token, `{"blockId":"blk_x"}`, "").Body.Bytes(), &second)
if second.Title != "Konzept: Garten (2)" { if second.Document.Title != "Konzept: Garten (2)" {
t.Fatalf("duplicate title: %q", second.Title) t.Fatalf("duplicate title: %q", second.Document.Title)
}
// Undo puts the new note into the trash; only its creator may
_, strangerToken := newTestUser(t, store, "fremd2@example.com")
if rec := sessionCall(router, "POST", "/api/v1/documents/"+created.ID+"/extract/undo", strangerToken, "", ""); rec.Code == http.StatusOK {
t.Fatal("stranger must not undo")
}
if rec := sessionCall(router, "POST", "/api/v1/documents/"+created.ID+"/extract/undo", token, "", ""); rec.Code != http.StatusOK {
t.Fatalf("undo: %d %s", rec.Code, rec.Body.String())
}
if d, _ := store.GetDocumentByID(uid, created.ID); d == nil || !d.IsArchived {
t.Fatal("extracted note must be in the trash after undo")
} }
} }
@@ -87,8 +103,9 @@ func TestExtractInSharedNoteKeepsAccess(t *testing.T) {
t.Fatalf("reader must not extract: %d", rec.Code) t.Fatalf("reader must not extract: %d", rec.Code)
} }
rec := sessionCall(router, "POST", "/api/v1/documents/"+src.ID+"/extract", editorToken, `{"blockId":"blk_s1"}`, "") rec := sessionCall(router, "POST", "/api/v1/documents/"+src.ID+"/extract", editorToken, `{"blockId":"blk_s1"}`, "")
var created domain.Document var out struct{ Document domain.Document }
_ = json.Unmarshal(rec.Body.Bytes(), &created) _ = json.Unmarshal(rec.Body.Bytes(), &out)
created := out.Document
if rec.Code != http.StatusCreated { if rec.Code != http.StatusCreated {
t.Fatalf("editor extract: %d %s", rec.Code, rec.Body.String()) t.Fatalf("editor extract: %d %s", rec.Code, rec.Body.String())
} }
+8
View File
@@ -34,6 +34,10 @@ type Server struct {
streamTickets streamTickets // one-time tickets for WebSocket/SSE (streamticket.go) streamTickets streamTickets // one-time tickets for WebSocket/SSE (streamticket.go)
loginGuard *loginGuard // brute-force protection (loginguard.go) loginGuard *loginGuard // brute-force protection (loginguard.go)
voice *voiceService // self-hosted speech recognition (voice.go) voice *voiceService // self-hosted speech recognition (voice.go)
// Replace the delivery of bundled notifications in tests (notify.go)
notifyEmailHook func(to, subject, text, html string) error
notifyDiscordHook func(webhookURL, heading string, items []notifyItem) error
} }
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 {
@@ -62,6 +66,7 @@ func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS
} }
if !cfg.DemoMode { if !cfg.DemoMode {
go srv.startAlarmTicker() // the demo sends nothing (FLAW-33) go srv.startAlarmTicker() // the demo sends nothing (FLAW-33)
go srv.startNotificationJobs()
go srv.startBackupScheduler() go srv.startBackupScheduler()
} }
return srv return srv
@@ -136,6 +141,9 @@ func (s *Server) Router() http.Handler {
r.Post("/api/v1/documents/{id}/media/fetch", s.notInDemo(s.handleFetchMedia)) r.Post("/api/v1/documents/{id}/media/fetch", s.notInDemo(s.handleFetchMedia))
r.Get("/api/v1/media/{id}", s.handleGetMedia) r.Get("/api/v1/media/{id}", s.handleGetMedia)
r.Post("/api/v1/documents/{id}/extract", s.handleExtractBranch) r.Post("/api/v1/documents/{id}/extract", s.handleExtractBranch)
r.Post("/api/v1/tasks/{id}/watch", s.handleWatchTask)
r.Delete("/api/v1/tasks/{id}/watch", s.handleWatchTask)
r.Post("/api/v1/documents/{id}/extract/undo", s.handleUndoExtract)
r.Post("/api/v1/documents/{id}/merge", s.handleMergeDocuments) r.Post("/api/v1/documents/{id}/merge", s.handleMergeDocuments)
r.Post("/api/v1/documents/{id}/merge/undo", s.handleUndoMerge) r.Post("/api/v1/documents/{id}/merge/undo", s.handleUndoMerge)
r.Get("/api/v1/link-preview", s.notInDemo(s.handleLinkPreview)) r.Get("/api/v1/link-preview", s.notInDemo(s.handleLinkPreview))
+4
View File
@@ -51,6 +51,9 @@ type Config struct {
// Images and audio in notes (MEDIA_*) // Images and audio in notes (MEDIA_*)
Media MediaConfig Media MediaConfig
// NotifyDigestMinutes: notifications (except task alarms) are bundled over this many minutes
NotifyDigestMinutes int
} }
// MediaConfig limits uploads of images and audio recordings in notes. Sizes in MB; QuotaMB 0 = no limit // MediaConfig limits uploads of images and audio recordings in notes. Sizes in MB; QuotaMB 0 = no limit
@@ -237,6 +240,7 @@ func Load() *Config {
PerUserPerHour: envInt("STT_PER_USER_PER_HOUR", 60, 1, 100000), PerUserPerHour: envInt("STT_PER_USER_PER_HOUR", 60, 1, 100000),
TimeoutSeconds: envInt("STT_TIMEOUT_SECONDS", 120, 5, 3600), TimeoutSeconds: envInt("STT_TIMEOUT_SECONDS", 120, 5, 3600),
}, },
NotifyDigestMinutes: envInt("NOTIFY_DIGEST_MINUTES", 5, 1, 1440),
Media: MediaConfig{ Media: MediaConfig{
MaxImageMB: envInt("MEDIA_MAX_IMAGE_MB", 15, 1, 1000), MaxImageMB: envInt("MEDIA_MAX_IMAGE_MB", 15, 1, 1000),
MaxAudioMB: envInt("MEDIA_MAX_AUDIO_MB", 50, 1, 2000), MaxAudioMB: envInt("MEDIA_MAX_AUDIO_MB", 50, 1, 2000),
+63
View File
@@ -135,6 +135,67 @@ type User struct {
NotifyBeforeMinutes int `json:"notifyBeforeMinutes"` NotifyBeforeMinutes int `json:"notifyBeforeMinutes"`
OrganizationID string `json:"organizationId,omitempty"` OrganizationID string `json:"organizationId,omitempty"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
// Only filled for the user's own profile (GET /auth/me, PATCH /auth/settings)
NotificationSettings *NotificationSettings `json:"notificationSettings,omitempty"`
}
// NotificationSettings: which events reach a user over which channel, the daily summary and a pause.
type NotificationSettings struct {
Paused bool `json:"paused"`
IncludeOwnWatched bool `json:"includeOwnWatched"`
Events NotificationEvents `json:"events"`
DailySummary DailySummary `json:"dailySummary"`
}
type NotificationEvents struct {
Due ChannelChoice `json:"due"` // task alarms (sent at once)
Shared ChannelChoice `json:"shared"` // something was shared with me
TaskDone ChannelChoice `json:"taskDone"` // a shared task was completed
Watched ChannelChoice `json:"watched"` // a watched task changed
}
type ChannelChoice struct {
Email bool `json:"email"`
Discord bool `json:"discord"`
}
type DailySummary struct {
Enabled bool `json:"enabled"`
Time string `json:"time"` // HH:MM, server time zone
Email bool `json:"email"`
Discord bool `json:"discord"`
}
// DefaultNotificationSettings for users who never saved any; task alarms follow the old alarm channels.
func DefaultNotificationSettings(alarmChannels []string) NotificationSettings {
due := ChannelChoice{}
for _, c := range alarmChannels {
due.Email = due.Email || c == "email"
due.Discord = due.Discord || c == "discord"
}
return NotificationSettings{
Events: NotificationEvents{
Due: due,
Shared: ChannelChoice{Email: true},
Watched: ChannelChoice{Email: true, Discord: true},
},
DailySummary: DailySummary{Time: "07:00", Email: true},
}
}
// Channels returns the choice for an event kind (due, shared, taskDone, watched).
func (n NotificationSettings) Channels(kind string) ChannelChoice {
switch kind {
case "due":
return n.Events.Due
case "shared":
return n.Events.Shared
case "taskDone":
return n.Events.TaskDone
case "watched":
return n.Events.Watched
}
return ChannelChoice{}
} }
type UserInvite struct { type UserInvite struct {
@@ -167,6 +228,8 @@ type SubTask struct {
} }
type Thought struct { type Thought struct {
// IsWatching: the requesting user gets notified about changes (task watchers)
IsWatching bool `json:"isWatching,omitempty"`
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"userId"` UserID string `json:"userId"`
Content string `json:"content"` Content string `json:"content"`
+1
View File
@@ -27,6 +27,7 @@ var migrations = []migration{
{4, "Sitzungen", (*Store).migrateSessions}, {4, "Sitzungen", (*Store).migrateSessions},
{5, "Medien", (*Store).migrateMedia}, {5, "Medien", (*Store).migrateMedia},
{6, "Link-Vorschauen", (*Store).migrateLinkPreviews}, {6, "Link-Vorschauen", (*Store).migrateLinkPreviews},
{7, "Benachrichtigungen", (*Store).migrateNotifications},
} }
func (s *Store) migrate() error { func (s *Store) migrate() error {
+258
View File
@@ -0,0 +1,258 @@
package storage
import (
"encoding/json"
"strings"
"time"
"github.com/sandrodach/mindsift/internal/domain"
)
// Notifications: per-user settings (JSON on the user), a queue that is sent bundled every few minutes,
// task watchers and the bookkeeping for the daily summary.
func (s *Store) migrateNotifications() error {
if err := s.addColumnIfMissing("users", "notification_settings", "TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
if err := s.addColumnIfMissing("users", "last_daily_summary", "TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
return s.execInTx(
stmt(`CREATE TABLE IF NOT EXISTS notification_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
kind TEXT NOT NULL,
payload TEXT NOT NULL,
created_at TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0
)`),
stmt(`CREATE INDEX IF NOT EXISTS idx_notification_queue_user ON notification_queue(user_id, created_at)`),
stmt(`CREATE TABLE IF NOT EXISTS task_watchers (
task_id TEXT NOT NULL,
user_id TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (task_id, user_id)
)`),
)
}
// NotificationSettingsOf returns the user's settings, defaults filled in for users who never saved any.
func (s *Store) NotificationSettingsOf(userID string) domain.NotificationSettings {
var raw, channels string
_ = s.db.QueryRow(`SELECT notification_settings, alarm_channels FROM users WHERE id = ?`, userID).Scan(&raw, &channels)
out := domain.DefaultNotificationSettings(parseAlarmChannels(channels))
if raw != "" {
_ = json.Unmarshal([]byte(raw), &out)
}
if !validClock(out.DailySummary.Time) {
out.DailySummary.Time = "07:00"
}
return out
}
func validClock(v string) bool {
t, err := time.Parse("15:04", v)
return err == nil && t.Format("15:04") == v
}
func (s *Store) SetNotificationSettings(userID string, n domain.NotificationSettings) error {
if !validClock(n.DailySummary.Time) {
n.DailySummary.Time = "07:00"
}
raw, _ := json.Marshal(n)
_, err := s.db.Exec(`UPDATE users SET notification_settings = ? WHERE id = ?`, string(raw), userID)
return err
}
// QueuedNotification is one event waiting to be sent bundled.
type QueuedNotification struct {
ID int64
UserID string
Kind string
Payload map[string]string
CreatedAt string
Attempts int
}
func (s *Store) QueueNotification(userID, kind string, payload map[string]string) error {
raw, _ := json.Marshal(payload)
_, err := s.db.Exec(`INSERT INTO notification_queue (user_id, kind, payload, created_at) VALUES (?, ?, ?, ?)`,
userID, kind, string(raw), time.Now().UTC().Format(time.RFC3339))
return err
}
// DueNotificationUsers lists users whose oldest waiting event is older than the bundling window.
func (s *Store) DueNotificationUsers(olderThan time.Time) ([]string, error) {
rows, err := s.db.Query(`SELECT user_id FROM notification_queue GROUP BY user_id HAVING MIN(created_at) <= ?`,
olderThan.UTC().Format(time.RFC3339))
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var id string
if rows.Scan(&id) == nil {
out = append(out, id)
}
}
return out, rows.Err()
}
func (s *Store) QueuedNotifications(userID string) ([]QueuedNotification, error) {
rows, err := s.db.Query(`SELECT id, user_id, kind, payload, created_at, attempts FROM notification_queue WHERE user_id = ? ORDER BY id`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []QueuedNotification
for rows.Next() {
var q QueuedNotification
var raw string
if rows.Scan(&q.ID, &q.UserID, &q.Kind, &raw, &q.CreatedAt, &q.Attempts) == nil {
_ = json.Unmarshal([]byte(raw), &q.Payload)
out = append(out, q)
}
}
return out, rows.Err()
}
func (s *Store) DeleteQueuedNotifications(ids []int64) error {
for _, id := range ids {
if _, err := s.db.Exec(`DELETE FROM notification_queue WHERE id = ?`, id); err != nil {
return err
}
}
return nil
}
func (s *Store) BumpQueuedNotifications(ids []int64) error {
for _, id := range ids {
if _, err := s.db.Exec(`UPDATE notification_queue SET attempts = attempts + 1 WHERE id = ?`, id); err != nil {
return err
}
}
return nil
}
// --- Task watchers ---
func (s *Store) WatchTask(taskID, userID string, on bool) error {
if on {
_, err := s.db.Exec(`INSERT OR IGNORE INTO task_watchers (task_id, user_id, created_at) VALUES (?, ?, ?)`, taskID, userID, nowUTC())
return err
}
_, err := s.db.Exec(`DELETE FROM task_watchers WHERE task_id = ? AND user_id = ?`, taskID, userID)
return err
}
func (s *Store) TaskWatchers(taskID string) []string {
rows, err := s.db.Query(`SELECT user_id FROM task_watchers WHERE task_id = ?`, taskID)
if err != nil {
return nil
}
defer rows.Close()
var out []string
for rows.Next() {
var id string
if rows.Scan(&id) == nil {
out = append(out, id)
}
}
return out
}
// WatchedTaskIDs is the set of tasks the user watches (to mark them in task lists).
func (s *Store) WatchedTaskIDs(userID string) map[string]bool {
out := map[string]bool{}
rows, err := s.db.Query(`SELECT task_id FROM task_watchers WHERE user_id = ?`, userID)
if err != nil {
return out
}
defer rows.Close()
for rows.Next() {
var id string
if rows.Scan(&id) == nil {
out[id] = true
}
}
return out
}
func (s *Store) ForgetTaskWatchers(taskID string) {
_, _ = s.db.Exec(`DELETE FROM task_watchers WHERE task_id = ?`, taskID)
}
// --- Daily summary ---
// SummaryTask is an open task with a due date, for the daily summary.
type SummaryTask struct {
Title, DueDate, ListTitle string
}
// ActiveUserIDs lists all active accounts (the summary job checks their settings).
func (s *Store) ActiveUserIDs() []string {
rows, err := s.db.Query(`SELECT id FROM users WHERE is_active = 1`)
if err != nil {
return nil
}
defer rows.Close()
var out []string
for rows.Next() {
var id string
if rows.Scan(&id) == nil {
out = append(out, id)
}
}
return out
}
// OpenTasksDueBefore lists the user's open tasks (own ones, those of own lists and of lists shared with
// the user directly or through a group) due before
// the given day (YYYY-MM-DD, exclusive), oldest first.
func (s *Store) OpenTasksDueBefore(userID, beforeDay string) ([]SummaryTask, error) {
rows, err := s.db.Query(`SELECT t.content, t.due_date, COALESCE(l.title, '')
FROM thoughts t LEFT JOIN task_lists l ON l.id = t.task_list_id
WHERE (t.status = 'kept' OR (t.status = 'converted' AND t.converted_type = 'task'))
AND t.is_completed = 0 AND t.due_date != '' AND substr(t.due_date, 1, 10) < ?
AND (t.user_id = ? OR (t.task_list_id != '' AND t.task_list_id IN (
SELECT id FROM task_lists WHERE user_id = ?
UNION SELECT task_list_id FROM task_list_shares WHERE target_type = 'user' AND target_id = ?
UNION SELECT task_list_id FROM task_list_shares WHERE target_type = 'team'
AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))))
ORDER BY t.due_date`, beforeDay, userID, userID, userID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []SummaryTask
for rows.Next() {
var t SummaryTask
if rows.Scan(&t.Title, &t.DueDate, &t.ListTitle) == nil {
t.Title = strings.TrimSpace(strings.SplitN(t.Title, "\n", 2)[0])
out = append(out, t)
}
}
return out, rows.Err()
}
// ClaimDailySummary marks today's summary as sent; false when it was already sent today.
func (s *Store) ClaimDailySummary(userID, day string) bool {
res, err := s.db.Exec(`UPDATE users SET last_daily_summary = ? WHERE id = ? AND last_daily_summary != ?`, day, userID, day)
if err != nil {
return false
}
n, _ := res.RowsAffected()
return n == 1
}
// SharedFolderName is the display name of a shared workspace folder.
func (s *Store) SharedFolderName(folderID string) string {
var name string
_ = s.db.QueryRow(`SELECT name FROM workspace_shared_folders WHERE id = ?`, folderID).Scan(&name)
if name == "" {
name = "Ordner"
}
return name
}
+23 -23
View File
@@ -92,16 +92,17 @@ func foreignLeaseIn(ids []string, a collab.Actor, leases collab.LeaseView) bool
return false return false
} }
// ExtractBranch moves a bullet's children into a new note named after the bullet, in the same folder, // ExtractBranch creates a new note from a bullet's children, named after the bullet, in the same folder
// shared like the original; the bullet itself becomes a [[link]] to it. Returns the new note. // and shared like the original. The original note is not changed here: the editor replaces the branch
func (s *Store) ExtractBranch(userID, docID, blockID string, a collab.Actor, leases collab.LeaseView) (*domain.Document, error) { // by the returned link itself, as an ordinary own edit (no clash with unsent typing, undo works).
func (s *Store) ExtractBranch(userID, docID, blockID string, a collab.Actor, leases collab.LeaseView) (*domain.Document, string, error) {
source, err := s.GetDocumentByID(userID, docID) source, err := s.GetDocumentByID(userID, docID)
if err != nil { if err != nil {
return nil, err return nil, "", err
} }
state, err := s.readCollabDoc(docID) state, err := s.readCollabDoc(docID)
if err != nil { if err != nil {
return nil, err return nil, "", err
} }
flat := state.Flatten() flat := state.Flatten()
start := -1 start := -1
@@ -112,7 +113,7 @@ func (s *Store) ExtractBranch(userID, docID, blockID string, a collab.Actor, lea
} }
} }
if start < 0 { if start < 0 {
return nil, ErrBlockNotFound return nil, "", ErrBlockNotFound
} }
root := flat[start] root := flat[start]
var children []domain.DocumentBlock var children []domain.DocumentBlock
@@ -122,37 +123,36 @@ func (s *Store) ExtractBranch(userID, docID, blockID string, a collab.Actor, lea
ids = append(ids, flat[i].ID) ids = append(ids, flat[i].ID)
} }
if foreignLeaseIn(ids, a, leases) { if foreignLeaseIn(ids, a, leases) {
return nil, ErrBranchLocked return nil, "", ErrBranchLocked
} }
base, tags := branchTitle(root.Content) base, tags := branchTitle(root.Content)
title := s.uniqueTitle(userID, base) title := s.uniqueTitle(userID, base)
created, err := s.CreateDocument(userID, "", title, "📄", source.FolderPath, children, nil, false) created, err := s.CreateDocument(userID, "", title, "📄", source.FolderPath, children, nil, false)
if err != nil { if err != nil {
return nil, err return nil, "", err
} }
s.copyShares(userID, source, created.ID) s.copyShares(userID, source, created.ID)
link := "[[" + title + "]]" link := "[[" + title + "]]"
if tags != "" { if tags != "" {
link += " " + tags link += " " + tags
} }
_, _, err = s.applyDocChange(docID, a, leases, func(d *collab.Doc) []collab.Op { return created, link, nil
b, ok := d.Blocks[blockID] }
if !ok || b.Deleted {
// UndoExtract puts a note created by ExtractBranch into the trash (only its creator may).
func (s *Store) UndoExtract(userID, docID string) error {
doc, err := s.GetDocumentByID(userID, docID)
if err != nil {
return err
}
if !doc.IsOwner {
return ErrMergeNotOwner
}
if doc.IsArchived {
return nil return nil
} }
ops := []collab.Op{{OpID: domain.NewID("op"), Kind: collab.OpSetContent, BlockID: blockID, BaseVersion: b.Version, Content: &link}} return s.trashDocument(userID, docID)
for _, c := range d.Children(blockID) {
ops = append(ops, collab.Op{OpID: domain.NewID("op"), Kind: collab.OpDelete, BlockID: c.ID})
}
return ops
})
if err != nil {
_ = s.hardDeleteDocument(created.ID)
return nil, err
}
return created, nil
} }
// copyShares gives the new note the direct and team shares of the source; when someone else owns the // copyShares gives the new note the direct and team shares of the source; when someone else owns the