Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e778e6d6dc | ||
|
|
6100d339f1 | ||
|
|
4f5da9507f | ||
|
|
6b3c32a370 | ||
|
|
a69bcbaf10 | ||
|
|
5f27e7ca9c | ||
|
|
fc7b2b3a2c | ||
|
|
d2afcf49df | ||
|
|
6795edbf4b | ||
|
|
446f8e4649 | ||
|
|
362dd3b4c6 |
No files matched your search
@@ -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
@@ -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
@@ -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) |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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` |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Generated
+2
-2
@@ -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,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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -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)');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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`, {
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user