diff --git a/.env.example b/.env.example index 195bde3..aa809fa 100644 --- a/.env.example +++ b/.env.example @@ -67,6 +67,9 @@ SMTP_FROM= # Name, den Mailprogramme als Absender zeigen (wenn SMTP_FROM nur eine Adresse ist). # Ein Name direkt in SMTP_FROM ("Mein Dienst ") hat Vorrang. 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. SMTP_TLS=true # true = selbstsignierte Zertifikate akzeptieren (nur für interne Mailserver). diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f9b5a4..56b08d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [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 ### 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. - 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.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 diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 6b5c1ca..4612e57 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -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/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` | -| 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/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/{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) | @@ -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}]` | | DELETE | `/auth/sessions/{id}` | Ein Gerät abmelden; `404` bei fremder oder unbekannter Sitzung | | 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`) | | 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) | diff --git a/Doku/architecture.md b/Doku/architecture.md index 9afd10e..af0fdba 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -97,7 +97,11 @@ Bilder und Audio liegen in `$DATA_DIR/media//.`, beschrieben in ## 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 diff --git a/Doku/database.md b/Doku/database.md index b5ecbba..ecbccb7 100644 --- a/Doku/database.md +++ b/Doku/database.md @@ -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` | | `media` | Bilder und Audio in Notizen (Migration 5); Datei unter `$DATA_DIR/media//.` | `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` | +| `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) | | `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` | diff --git a/docker-compose.yml b/docker-compose.yml index 83f3c5c..e00922c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,7 @@ services: - SMTP_PASS=${SMTP_PASS:-} - SMTP_FROM=${SMTP_FROM:-} - SMTP_FROM_NAME=${SMTP_FROM_NAME:-MindSift} + - NOTIFY_DIGEST_MINUTES=${NOTIFY_DIGEST_MINUTES:-5} - SMTP_TLS=${SMTP_TLS:-true} - SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false} # Impressum / Datenschutz diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fabc7ec..bb8878a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.7.0", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.7.0", + "version": "0.8.0", "license": "Unlicense", "dependencies": { "@types/prismjs": "^1.26.6", diff --git a/frontend/package.json b/frontend/package.json index 91bbc5c..fe3a8e7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.7.0", + "version": "0.8.0", "license": "Unlicense", "type": "module", "scripts": { diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index 5ade494..c11cae9 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -17,7 +17,7 @@ import { Pencil, Copy, } from 'lucide-react'; -import { AuthStatus, User, UserTheme } from '../../lib/types'; +import { AuthStatus, NotificationEvents, NotificationSettings, User, UserTheme } from '../../lib/types'; import { ApiKeysPanel } from './ApiKeysPanel'; import { ServerBackupsCard } from './ServerBackupsCard'; import { saveBlob } from '../../lib/download'; @@ -47,6 +47,48 @@ interface UserSettingsViewProps { 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 = ({ currentUser, activeTheme, @@ -211,6 +253,9 @@ export const UserSettingsView: React.FC = ({ const [alarmChannels, setAlarmChannels] = useState( Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : [] ); + const [notificationSettings, setNotificationSettings] = useState(() => + buildNotificationSettings(currentUser) + ); const [alarmEmail, setAlarmEmail] = useState(currentUser.alarmEmail || currentUser.email || ''); const [discordWebhookUrl, setDiscordWebhookUrl] = useState(currentUser.discordWebhookUrl || ''); const [customWebhookUrl, setCustomWebhookUrl] = useState(currentUser.customWebhookUrl || ''); @@ -226,6 +271,7 @@ export const UserSettingsView: React.FC = ({ setEmail(currentUser.email || ''); setAvatarUrl(currentUser.avatarUrl || ''); setAlarmChannels(Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []); + setNotificationSettings(buildNotificationSettings(currentUser)); setAlarmEmail(currentUser.alarmEmail || currentUser.email || ''); setDiscordWebhookUrl(currentUser.discordWebhookUrl || ''); setCustomWebhookUrl(currentUser.customWebhookUrl || ''); @@ -238,6 +284,19 @@ export const UserSettingsView: React.FC = ({ ); }; + const setEventChannel = (eventKey: keyof NotificationEvents, channel: 'email' | 'discord', value: boolean) => { + setNotificationSettings((prev) => ({ + ...prev, + events: { + ...prev.events, + [eventKey]: { + ...prev.events[eventKey], + [channel]: value, + }, + }, + })); + }; + // --- Handlers: Avatar --- const handleAvatarFileSelected = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; @@ -324,14 +383,23 @@ export const UserSettingsView: React.FC = ({ setAlarmMsg(null); setSavingAlarms(true); try { + const nextAlarmChannels = [ + ...(notificationSettings.events.due.email ? ['email'] : []), + ...(notificationSettings.events.due.discord ? ['discord'] : []), + ...(alarmChannels.includes('webhook') ? ['webhook'] : []), + ]; const updated = await api.updateSettings({ - alarmChannels, + alarmChannels: nextAlarmChannels, alarmEmail: alarmEmail.trim(), discordWebhookUrl: discordWebhookUrl.trim(), customWebhookUrl: customWebhookUrl.trim(), notifyBeforeMinutes: Number(notifyBeforeMinutes), + notificationSettings, + }); + onUserUpdated({ + ...updated, + notificationSettings: updated.notificationSettings ?? notificationSettings, }); - onUserUpdated(updated); setAlarmMsg({ type: 'ok', text: 'Alarm-Kanäle und Vorwarnzeit erfolgreich gespeichert.' }); } catch (err: any) { setAlarmMsg({ type: 'err', text: err.message || 'Fehler beim Speichern der Alarme' }); @@ -1019,6 +1087,176 @@ export const UserSettingsView: React.FC = ({ )}
+ {/* Global Pause & Own Watched Toggles */} +
+
+
+

Alle Benachrichtigungen pausieren

+

+ Pausiert vorübergehend sämtliche E-Mail- und Discord-Benachrichtigungen. +

+
+ setNotificationSettings((prev) => ({ ...prev, paused: v }))} + title="Alle Benachrichtigungen pausieren" + /> +
+
+
+

+ Auch eigene Änderungen an beobachteten Aufgaben melden +

+

+ Standardmäßig wirst du nur über Änderungen anderer Personen benachrichtigt. +

+
+ setNotificationSettings((prev) => ({ ...prev, includeOwnWatched: v }))} + title="Auch eigene Änderungen an beobachteten Aufgaben melden" + /> +
+
+ + {/* Routing Matrix: Events x (E-Mail / Discord) */} + {(() => { + const hasDiscordWebhook = Boolean(discordWebhookUrl.trim()); + return ( + <> +
+
+ Ereignis + + + E-Mail + + + + Discord + +
+ +
+ {NOTIFICATION_EVENT_ROWS.map((row) => { + const routing = notificationSettings.events[row.key]; + return ( +
+ {row.label} +
+ setEventChannel(row.key, 'email', v)} + title={`${row.label} – E-Mail`} + /> +
+
+ setEventChannel(row.key, 'discord', v)} + title={ + hasDiscordWebhook + ? `${row.label} – Discord` + : 'Bitte zuerst unten eine Discord Webhook URL eintragen' + } + /> +
+
+ ); + })} +
+ +

+ Gebündelt: höchstens alle 5 Minuten eine Nachricht (außer fällige Aufgaben) +

+ {!hasDiscordWebhook && ( +

+ Discord-Schalter sind deaktiviert, solange unten kein Discord-Webhook eingetragen ist. +

+ )} +
+ + {/* Daily Summary */} +
+
+
+

Tageszusammenfassung

+

+ Überfällige, heutige und morgige Aufgaben; nichts fällig = keine Nachricht +

+
+ + setNotificationSettings((prev) => ({ + ...prev, + dailySummary: { ...prev.dailySummary, enabled: v }, + })) + } + title="Tageszusammenfassung" + /> +
+ +
+ + +
+
+ E-Mail + + setNotificationSettings((prev) => ({ + ...prev, + dailySummary: { ...prev.dailySummary, email: v }, + })) + } + title="Tageszusammenfassung – E-Mail" + /> +
+
+ Discord + + setNotificationSettings((prev) => ({ + ...prev, + dailySummary: { ...prev.dailySummary, discord: v }, + })) + } + title={ + hasDiscordWebhook + ? 'Tageszusammenfassung – Discord' + : 'Bitte zuerst unten eine Discord Webhook URL eintragen' + } + /> +
+
+
+
+ + ); + })()} + {/* Lead Time Selector */}
@@ -1044,19 +1282,9 @@ export const UserSettingsView: React.FC = ({
{/* Channel 1: E-Mail */} -
+
-
{/* Channel 2: Discord Webhook */} -
+
-
+ {(task.convertedType === 'document_block' || task.docId) ? ( ); }; diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index c44327b..ef1d52d 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -18,16 +18,26 @@ import { CloudOff, AlertTriangle, GitMerge, + Undo2, + Redo2, + MoreHorizontal, } from 'lucide-react'; import { Document, DocumentBlock, User } from '../../lib/types'; import { api } from '../../lib/api'; import { DocSyncEngine, EngineState } from '../../lib/docSync/engine'; +import { Presence } from '../../lib/docSync/transport'; import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree'; -import { PresenceAvatars } from './PresenceAvatars'; import { extractHashtags } from '../../lib/hashtags'; export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error'; +export interface DocHeaderStatus { + showLive: boolean; + channel: 'ws' | 'sse' | 'longpoll' | 'interval'; + otherPeople: Presence[]; + avatarsById: Map; +} + export interface DocumentEditorProps { docId: string; currentUser?: User | null; @@ -45,8 +55,12 @@ export interface DocumentEditorProps { onBackMobile?: () => void; onOpenTask?: (taskId: 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) */ 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'; @@ -193,7 +207,10 @@ export const DocumentEditor: React.FC = ({ onBackMobile, onOpenTask, onSaveStatusChange, + onHeaderStatusChange, onRequestMerge, + onDocCreated, + onDocRemoved, }) => { const [doc, setDoc] = useState(initialDoc || null); const [title, setTitle] = useState(initialDoc?.title || ''); @@ -204,6 +221,8 @@ export const DocumentEditor: React.FC = ({ const [error, setError] = useState(null); const [showEmojiPicker, setShowEmojiPicker] = useState(false); const [zoomedBlockId, setZoomedBlockId] = useState(null); + const [showActionsMenu, setShowActionsMenu] = useState(false); + const actionsMenuRef = useRef(null); // Sync engine (operation log): stores every change locally and syncs it with the server const engineRef = useRef(null); @@ -379,39 +398,75 @@ export const DocumentEditor: React.FC = ({ .catch(() => {}); }, [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 const [notice, setNotice] = useState(null); - const [noticeAction, setNoticeAction] = useState<{ label: string; run: () => void } | null>(null); + const [noticeActions, setNoticeActions] = useState<{ label: string; run: () => void }[]>([]); const noticeTimerRef = useRef(null); - const showNotice = useCallback((message: string, action?: { label: string; run: () => void }) => { + const showNotice = useCallback((message: string, actions: { label: string; run: () => void }[] = []) => { setNotice(message); - setNoticeAction(action || null); + setNoticeActions(actions); if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current); noticeTimerRef.current = window.setTimeout(() => { setNotice(null); - setNoticeAction(null); - }, action ? 7000 : 3500); + setNoticeActions([]); + }, actions.length ? 8000 : 3500); }, []); - // A bullet with its children becomes a note of its own; the server swaps the branch for a [[link]] - // and this editor receives that change through the normal sync - const handleExtractBlock = useCallback( - async (blockId: string) => { - const engine = engineRef.current; - try { - await engine?.syncNow(); // the branch as typed so far - const created = await api.extractBranch(docId, blockId, engine?.clientId || ''); - await engine?.syncNow(); - showNotice(`In eigene Notiz „${created.title}“ umgewandelt`, { - label: 'Öffnen', - run: () => onOpenDocBlock?.(created.id), - }); - } catch (err: any) { - showNotice(err?.message || 'Umwandeln fehlgeschlagen'); - } - }, - [docId, onOpenDocBlock, showNotice] - ); + // The last extraction, so undoing it (toast or Strg+Z) also puts the new note into the trash + const lastExtractRef = useRef<{ newDocId: string; blockId: string; link: string } | null>(null); + const finishUndoExtract = useCallback(() => { + const info = lastExtractRef.current; + if (!info) return; + const block = currentBlocksRef.current.find((b) => b.id === info.blockId); + if (block && block.content === info.link) return; // the undone step was something else + lastExtractRef.current = null; + api + .undoExtract(info.newDocId) + .then(() => onDocRemoved?.(info.newDocId)) + .catch((err: any) => showNotice(err?.message || 'Ausgelagerte Notiz konnte nicht entfernt werden')); + }, [onDocRemoved, showNotice]); useEffect(() => () => { if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current); }, []); @@ -423,7 +478,9 @@ export const DocumentEditor: React.FC = ({ const handleUndo = useCallback(() => { const res = engineRef.current?.undo(); if (res?.message) showNotice(res.message); - }, [showNotice]); + if (res?.ok) finishUndoExtract(); + }, [showNotice, finishUndoExtract]); + const handleRedo = useCallback(() => { const res = engineRef.current?.redo(); if (res?.message) showNotice(res.message); @@ -449,6 +506,38 @@ export const DocumentEditor: React.FC = ({ [] ); + // 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 useEffect(() => { if (!docRef.current) return; @@ -682,7 +771,7 @@ export const DocumentEditor: React.FC = ({ + + )} - {/* Who else has this note open */} - - {/* 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'; + {doc && !inTrash && ( + + )} - return ( -
- {isShared && ( - isOwner ? ( - - ) : ( - - ) +
+ + + {showActionsMenu && ( +
+ {!inTrash && ( + + )} + + {onRequestMerge && doc?.isOwner && !inTrash && ( + )} - {onRequestMerge && doc.isOwner && !inTrash && ( - + + {canEdit && (doc?.isOwner || doc?.permission === 'owner') && ( + <> +
+ + )}
- ); - })()} - - {!inTrash && } - - - - {canEdit && (doc?.isOwner || doc?.permission === 'owner') && ( - - )} + )} +
@@ -1126,19 +1225,20 @@ export const DocumentEditor: React.FC = ({ 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" > {notice} - {noticeAction && ( + {noticeActions.map((a) => ( - )} + ))}
)}
diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index 08f29ef..03734fc 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -35,7 +35,7 @@ import { AudioBlock } from './AudioBlock'; import { DictationBlock } from './DictationBlock'; import { currentVoiceConfig } from '../../lib/voice'; 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 { renderInlineMarkdown } from '../../lib/markdown'; import { api } from '../../lib/api'; @@ -121,7 +121,6 @@ export const OutlinerBlock: React.FC = ({ docId, block, index, - isLast, hasChildren, isFocused, canEdit, @@ -160,13 +159,14 @@ export const OutlinerBlock: React.FC = ({ const [slashSearch, setSlashSearch] = useState(null); // Inline picker an empty bullet turns into after /image (local only, nothing synced until done) 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 previewUrl = previewsOn && !mediaPicker && ['bullet', 'text', 'paragraph', 'todo', 'h1', 'h2', 'h3'].includes(block.type || 'bullet') - ? firstBareUrl(localContent) + ? linkCardUrl(localContent) : null; const [slashIndex, setSlashIndex] = useState(0); + const slashItemRefs = useRef<(HTMLButtonElement | null)[]>([]); const [showLinkModal, setShowLinkModal] = useState(false); const [showTodoMenu, setShowTodoMenu] = useState(false); @@ -362,7 +362,7 @@ export const OutlinerBlock: React.FC = ({ const q = slashSearch.toLowerCase(); const cmdQuery = q.startsWith('code ') ? 'code' : q; // 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( (c) => (!c.media || emptyBullet) && @@ -375,12 +375,18 @@ export const OutlinerBlock: React.FC = ({ ); }, [slashSearch, localContent, onExtractBlock]); + useEffect(() => { + if (slashSearch === null) return; + slashItemRefs.current[slashIndex]?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' }); + }, [slashIndex, slashSearch]); + const applySlashCommand = (cmd: SlashCommand) => { setSlashSearch(null); - const codeLangMatch = localContent.match(/\/code\s+([a-z0-9_+#.-]+)$/i); - const clean = localContent.replace(/\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim(); + const codeLangMatch = localContent.match(/(^|\s)\/code\s+([a-z0-9_+#.-]+)$/i); + const clean = localContent.replace(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim(); if (cmd.type === 'link') { setLocalContent(clean); + localContentRef.current = clean; setShowLinkModal(true); return; } @@ -401,7 +407,7 @@ export const OutlinerBlock: React.FC = ({ } const langOverride = cmd.type === 'code' && codeLangMatch - ? resolveLanguageId(codeLangMatch[1]) || codeLangMatch[1].toLowerCase() + ? resolveLanguageId(codeLangMatch[2]) || codeLangMatch[2].toLowerCase() : undefined; setLocalContent(clean); localContentRef.current = clean; @@ -415,11 +421,22 @@ export const OutlinerBlock: React.FC = ({ const handleInsertLink = (linkMarkdown: string) => { 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 before = localContent.slice(0, cursor); const after = localContent.slice(cursor); const newContent = `${before}${before && !before.endsWith(' ') ? ' ' : ''}${linkMarkdown}${after && !after.startsWith(' ') ? ' ' : ''}${after}`.trim(); setLocalContent(newContent); + localContentRef.current = newContent; flushDebounce(newContent); }; @@ -475,9 +492,9 @@ export const OutlinerBlock: React.FC = ({ } // 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) { - setSlashSearch(slashMatch[1]); + setSlashSearch(slashMatch[2]); setSlashIndex(0); } else { setSlashSearch(null); @@ -600,30 +617,32 @@ export const OutlinerBlock: React.FC = ({ if (!canEdit) return; // Slash command navigation - if (slashSearch !== null && filteredSlashCommands.length > 0) { - if (e.key === 'ArrowDown') { - e.preventDefault(); - setSlashIndex((prev) => (prev + 1) % filteredSlashCommands.length); - return; - } - if (e.key === 'ArrowUp') { - e.preventDefault(); - setSlashIndex((prev) => (prev - 1 + filteredSlashCommands.length) % filteredSlashCommands.length); - return; - } - if (e.key === 'Enter' || e.key === 'Tab') { - e.preventDefault(); - const selected = filteredSlashCommands[slashIndex]; - if (selected) { - applySlashCommand(selected); - } - return; - } + if (slashSearch !== null) { if (e.key === 'Escape') { e.preventDefault(); setSlashSearch(null); return; } + if (filteredSlashCommands.length > 0) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSlashIndex((prev) => (prev + 1) % filteredSlashCommands.length); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + setSlashIndex((prev) => (prev - 1 + filteredSlashCommands.length) % filteredSlashCommands.length); + return; + } + if (e.key === 'Enter' || e.key === 'Tab') { + e.preventDefault(); + const selected = filteredSlashCommands[slashIndex]; + if (selected) { + applySlashCommand(selected); + } + return; + } + } } // 1. Tab / Shift+Tab -> Strict Indent / Outdent @@ -637,7 +656,6 @@ export const OutlinerBlock: React.FC = ({ // 2. Enter (without Shift) -> If block is completely empty: // - If level > minLevel, outdent (Shift+Tab behaviour) - // - If already at minLevel and is the last block, do NOT create another empty block below // - Otherwise split paragraph at cursor and create new block below if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') { e.preventDefault(); @@ -666,10 +684,6 @@ export const OutlinerBlock: React.FC = ({ onIndentBlockRef.current(block.id, -1); return; } - if (isLast) { - // Bereits auf oberster Ebene und der letzte Block: kein weiterer leerer Block - return; - } } const start = e.currentTarget.selectionStart ?? localContent.length; @@ -1258,6 +1272,9 @@ export const OutlinerBlock: React.FC = ({ {filteredSlashCommands.map((cmd, i) => (
- {/* 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) */}
{subView === 'editor' && selectedDocId ? ( - /* Save Status Pill */ -
- {docSaveStatus === 'saved' && ( - <> - - Gespeichert - + <> + {/* Live connection pill */} + {docHeaderStatus?.showLive && ( + + + {docHeaderStatus.channel === 'longpoll' + ? 'Live (Long-Polling)' + : docHeaderStatus.channel === 'interval' + ? 'Abgleich alle 3 s' + : 'Live'} + )} - {docSaveStatus === 'saving' && ( - <> - - Speichert… - + + {/* Who else has this note open */} + {docHeaderStatus && ( + )} - {docSaveStatus === 'unsaved' && ( - <> - - Ungespeichert - - )} - {docSaveStatus === 'error' && ( - <> - - Fehler - - )} -
+ + {/* 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 ? ( + + ) : ( + + ); + })()} + + {/* Save Status Pill */} +
+ {docSaveStatus === 'saved' && ( + <> + + Gespeichert + + )} + {docSaveStatus === 'saving' && ( + <> + + Speichert… + + )} + {docSaveStatus === 'unsaved' && ( + <> + + Ungespeichert + + )} + {docSaveStatus === 'error' && ( + <> + + Fehler + + )} +
+ ) : null}
@@ -1622,12 +1698,30 @@ export const WorkspaceView: React.FC = ({ onWikiLinkClick={handleWikiLinkClick} onOpenDocBlock={handleJumpToDocBlock} 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 = {}; + 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)} onOpenTask={onOpenTask} onSaveStatusChange={(status, err) => { setDocSaveStatus(status); setDocSaveError(err || null); }} + onHeaderStatusChange={setDocHeaderStatus} /> ) : subView === 'tags' ? ( /* ========================================================================= diff --git a/frontend/src/features/workspace/__tests__/DocumentEditor.header.test.tsx b/frontend/src/features/workspace/__tests__/DocumentEditor.header.test.tsx new file mode 100644 index 0000000..8466a4c --- /dev/null +++ b/frontend/src/features/workspace/__tests__/DocumentEditor.header.test.tsx @@ -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(); + return { + ...actual, + api: { + ...actual.api, + getDocument: vi.fn(), + getUsersDirectory: vi.fn(() => Promise.resolve([])), + getDocumentBacklinks: vi.fn(() => Promise.resolve({ backlinks: [] })), + }, + }; +}); + +const makeDoc = (overrides: Partial = {}): 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( + + ); + + 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(); + }); +}); diff --git a/frontend/src/features/workspace/__tests__/OutlinerBlock.caret.test.tsx b/frontend/src/features/workspace/__tests__/OutlinerBlock.caret.test.tsx index 9d0b468..c5b4d28 100644 --- a/frontend/src/features/workspace/__tests__/OutlinerBlock.caret.test.tsx +++ b/frontend/src/features/workspace/__tests__/OutlinerBlock.caret.test.tsx @@ -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( + {}} + 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( + {}} + 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( + 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)'); + }); +}); diff --git a/frontend/src/lib/__tests__/linkPreview.test.ts b/frontend/src/lib/__tests__/linkPreview.test.ts index b04208a..c266781 100644 --- a/frontend/src/lib/__tests__/linkPreview.test.ts +++ b/frontend/src/lib/__tests__/linkPreview.test.ts @@ -1,14 +1,16 @@ import { describe, expect, it } from 'vitest'; -import { firstBareUrl, hidePreviewContent } from '../linkPreview'; +import { linkCardUrl, hidePreviewContent } from '../linkPreview'; describe('link preview helpers', () => { - it('finds the first plain https address', () => { - expect(firstBareUrl('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(firstBareUrl('siehe (https://x.de/p).')).toBeNull(); // inside brackets: not a plain address - expect(firstBareUrl('[Kochseite](https://koch.de)')).toBeNull(); - expect(firstBareUrl('http://unsicher.de')).toBeNull(); - expect(firstBareUrl('nur Text')).toBeNull(); + it('returns the URL only when the entire trimmed content is a single https address', () => { + expect(linkCardUrl('https://example.com/a?b=1')).toBe('https://example.com/a?b=1'); + expect(linkCardUrl(' https://example.com/page/ ')).toBe('https://example.com/page/'); + expect(linkCardUrl('Rezept: https://koch.de/spaetzle, lecker!')).toBeNull(); + expect(linkCardUrl('https://a.de https://b.de')).toBeNull(); + expect(linkCardUrl('siehe (https://x.de/p).')).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', () => { @@ -16,6 +18,6 @@ describe('link preview helpers', () => { '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(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(); }); }); diff --git a/frontend/src/lib/__tests__/useVoiceInput.test.tsx b/frontend/src/lib/__tests__/useVoiceInput.test.tsx index 76d9faa..615a000 100644 --- a/frontend/src/lib/__tests__/useVoiceInput.test.tsx +++ b/frontend/src/lib/__tests__/useVoiceInput.test.tsx @@ -130,4 +130,121 @@ describe('useVoiceInput', () => { await waitFor(() => expect(none.result.current.mode).toBe('unsupported')); 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((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'); + }); }); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 384db62..7c5b3fe 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -35,6 +35,7 @@ import { TagSummary, User, UserInvite, + NotificationSettings, VoiceAdminStatus, MergeResult, WSMessage, @@ -292,6 +293,7 @@ export const api = { discordWebhookUrl?: string; customWebhookUrl?: string; notifyBeforeMinutes?: number; + notificationSettings?: NotificationSettings; }): Promise { return request('/api/v1/auth/settings', { method: 'PATCH', @@ -410,6 +412,18 @@ export const api = { 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 }> { const url = taskListId ? `/api/v1/task-lists/${taskListId}/tasks/reorder` : '/api/v1/tasks/reorder'; return request<{ success: boolean }>(url, { @@ -709,14 +723,19 @@ export const api = { return request('/api/v1/documents/trash'); }, - /** Turns a bullet and its children into a note of its own; the bullet becomes a [[link]]. */ - async extractBranch(docId: string, blockId: string, clientId: string): Promise { - return request(`/api/v1/documents/${docId}/extract`, { + /** 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: Document; link: string }> { + return request<{ document: Document; link: string }>(`/api/v1/documents/${docId}/extract`, { method: 'POST', body: JSON.stringify({ blockId, clientId }), }); }, + /** Puts a note created by extractBranch into the trash again. */ + async undoExtract(newDocId: string): Promise { + await request(`/api/v1/documents/${newDocId}/extract/undo`, { method: 'POST' }); + }, + /** Appends the source note to the target; the source goes to the trash. */ async mergeDocuments(targetId: string, sourceId: string): Promise { return request(`/api/v1/documents/${targetId}/merge`, { diff --git a/frontend/src/lib/linkPreview.ts b/frontend/src/lib/linkPreview.ts index 64f9e15..acf3672 100644 --- a/frontend/src/lib/linkPreview.ts +++ b/frontend/src/lib/linkPreview.ts @@ -16,12 +16,12 @@ export interface LinkPreview { ok: boolean; } -// First plain https address in a text (not inside [text](url), not right after "(" or "<") -const BARE_URL_RE = /(^|[\s])(https:\/\/[^\s<>()\]]+[^\s<>()\].,;:!?'"])/; +// Entire bullet content (trimmed) is a single plain https address +const CARD_URL_RE = /^https:\/\/[^\s<>()\]]+[^\s<>()\].,;:!?'"]$/; -export function firstBareUrl(content: string): string | null { - const m = BARE_URL_RE.exec(content || ''); - return m ? m[2] : null; +export function linkCardUrl(content: string): string | null { + const trimmed = (content || '').trim(); + return CARD_URL_RE.test(trimmed) ? trimmed : null; } /** Replaces the address by a named link, which shows no preview. */ diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index f67cbc4..7a428df 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -46,6 +46,32 @@ export interface VoiceAdminStatus { // Who can see whom: nobody, members of the same organization, or everybody (Doku/sharing-and-permissions.md) 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 { id: string; email: string; @@ -59,6 +85,7 @@ export interface User { discordWebhookUrl?: string; customWebhookUrl?: string; notifyBeforeMinutes?: number; + notificationSettings?: NotificationSettings; organizationId?: string; createdAt: string; } @@ -113,6 +140,7 @@ export interface Thought { subtasks?: SubTask[]; assignees?: string[]; // user IDs, only people with access to the task list recurrence?: Recurrence; + isWatching?: boolean; alarmSent?: boolean; sortOrder?: number; createdAt: string; diff --git a/frontend/src/lib/useVoiceInput.ts b/frontend/src/lib/useVoiceInput.ts index b139a24..783d683 100644 --- a/frontend/src/lib/useVoiceInput.ts +++ b/frontend/src/lib/useVoiceInput.ts @@ -10,28 +10,54 @@ export interface VoiceInputOptions { onError?: (message: string) => void; /** Browser mode: keep listening through pauses (hold-to-record) instead of stopping after a sentence. */ 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 * 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. */ -export function useVoiceInput({ onText, onError, continuous = false }: VoiceInputOptions) { +export function useVoiceInput({ onText, onError, continuous = false, live = false }: VoiceInputOptions) { const [mode, setMode] = useState(null); const [state, setState] = useState('idle'); + const [pendingChunks, setPendingChunks] = useState(0); const maxSecondsRef = useRef(600); const recorderRef = useRef(null); const recognitionRef = useRef(null); const timerRef = useRef(null); + const segmentTimerRef = useRef(null); + const restartTimerRef = useRef(null); const cancelledRef = useRef(false); + const stoppedRef = useRef(false); const transcriptRef = useRef(''); + const baseTranscriptRef = useRef(''); + const lastRestartAtRef = useRef(0); + const chunkIndexRef = useRef(0); + const chunkResultsRef = useRef>(new Map()); + const inFlightChunksRef = useRef(0); const mountedRef = useRef(true); const onTextRef = useRef(onText); onTextRef.current = onText; const onErrorRef = useRef(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(() => { mountedRef.current = true; void loadVoiceConfig().then((cfg) => { @@ -41,7 +67,7 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu }); return () => { mountedRef.current = false; - if (timerRef.current !== null) window.clearTimeout(timerRef.current); + clearAllTimers(); recorderRef.current?.cancel(); try { recognitionRef.current?.abort?.(); @@ -54,17 +80,65 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu const setStateSafe = (s: VoiceState) => { if (mountedRef.current) setState(s); }; + const setPendingChunksSafe = (n: number) => { + if (mountedRef.current) setPendingChunks(n); + }; const fail = (message: string) => { setStateSafe('idle'); + setPendingChunksSafe(0); onErrorRef.current?.(message); }; - const clearTimer = () => { - if (timerRef.current !== null) window.clearTimeout(timerRef.current); - timerRef.current = null; + + const joinedChunkText = () => + [...chunkResultsRef.current.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([, t]) => t.trim()) + .filter(Boolean) + .join(' '); + + const processSegmentPromise = (idx: number, blobPromise: Promise) => { + 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 () => { - clearTimer(); + stoppedRef.current = true; + clearAllTimers(); if (recognitionRef.current) { try { recognitionRef.current.stop(); @@ -74,8 +148,20 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu return; } const rec = recorderRef.current; - if (!rec) return; + if (!rec) { + if (live && state === 'listening') { + setStateSafe('idle'); + onTextRef.current(transcriptRef.current, true); + } + return; + } recorderRef.current = null; + if (live) { + const idx = chunkIndexRef.current++; + setStateSafe('transcribing'); + processSegmentPromise(idx, rec.stop()); + return; + } const blob = await rec.stop(); if (cancelledRef.current) return; if (!blob) { @@ -93,11 +179,15 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu } // fail only touches refs and state setters // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [live, state]); const startServer = async () => { const rec = new VoiceRecorder(); recorderRef.current = rec; + chunkIndexRef.current = 0; + chunkResultsRef.current = new Map(); + inFlightChunksRef.current = 0; + setPendingChunksSafe(0); setStateSafe('listening'); try { await rec.start(); @@ -111,6 +201,104 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu return; } 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 = () => { @@ -119,55 +307,31 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu fail('Spracheingabe wird von diesem Browser nicht unterstützt'); return; } - const recognition = new Recognition(); - recognition.lang = 'de-DE'; - recognition.interimResults = true; - 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'); - } + baseTranscriptRef.current = ''; + lastRestartAtRef.current = 0; + spawnBrowserRecognition(Recognition, false); }; const start = useCallback(async () => { if (recorderRef.current || recognitionRef.current) return; cancelledRef.current = false; + stoppedRef.current = false; transcriptRef.current = ''; + baseTranscriptRef.current = ''; + lastRestartAtRef.current = 0; const current = mode ?? currentVoiceConfig().mode; if (current === 'server') await startServer(); else if (current === 'browser') startBrowser(); else fail(current === 'off' ? 'Spracheingabe ist ausgeschaltet' : 'Spracheingabe wird von diesem Browser nicht unterstützt'); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [mode, continuous]); + }, [mode, continuous, live]); const cancel = useCallback(() => { cancelledRef.current = true; - clearTimer(); + stoppedRef.current = true; + inFlightChunksRef.current = 0; + setPendingChunksSafe(0); + clearAllTimers(); recorderRef.current?.cancel(); recorderRef.current = null; try { @@ -191,6 +355,7 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu mode, available: mode === 'server' || mode === 'browser', state, + pendingChunks, start, stop, cancel, diff --git a/frontend/src/lib/voice.ts b/frontend/src/lib/voice.ts index 9c0bfbe..8e447f4 100644 --- a/frontend/src/lib/voice.ts +++ b/frontend/src/lib/voice.ts @@ -86,19 +86,7 @@ export class VoiceRecorder { this.stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, }); - const type = recordingType(); - 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); + this.startSegmentRecorder(); try { const Ctx = (window as any).AudioContext || (window as any).webkitAudioContext; 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. */ level(): number | 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 { + 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 { this.done = null; if (this.recorder && this.recorder.state !== 'inactive') { diff --git a/internal/api/alarms.go b/internal/api/alarms.go index f362e94..33766da 100644 --- a/internal/api/alarms.go +++ b/internal/api/alarms.go @@ -13,6 +13,11 @@ import ( // --- MULTI-CHANNEL ALARM DISPATCHER (EMAIL, DISCORD, CUSTOM WEBHOOK) --- 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 { return errDemoOutgoing } @@ -20,7 +25,7 @@ func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error if err != nil { return err } - if err := sendMail(cfg, toEmail, subject, bodyText); err != nil { + if err := sendMail(cfg, toEmail, subject, bodyText, html); err != nil { step := "" if se, ok := err.(*smtpStepError); ok { step = se.Step @@ -123,6 +128,27 @@ func (s *Server) startAlarmTicker() { if !alarmDue(a.DueDate, a.NotifyBeforeMinutes, now) { 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 { continue } diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go index 8def15f..74d2d42 100644 --- a/internal/api/handlers_auth.go +++ b/internal/api/handlers_auth.go @@ -341,6 +341,8 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { return } stats, _ := s.store.GetDashboardStats(userID) + ns := s.store.NotificationSettingsOf(userID) + user.NotificationSettings = &ns writeJSON(w, http.StatusOK, map[string]interface{}{ "user": user, "stats": stats, @@ -448,6 +450,8 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request NotifyBeforeMinutes *int `json:"notifyBeforeMinutes"` // Needed to change password or e-mail address (FLAW-30) 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 { writeError(w, http.StatusBadRequest, "Ungültiges JSON") @@ -528,6 +532,14 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request if changesPassword || changesEmail { 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) } diff --git a/internal/api/handlers_documents.go b/internal/api/handlers_documents.go index aacfcca..6626459 100644 --- a/internal/api/handlers_documents.go +++ b/internal/api/handlers_documents.go @@ -262,6 +262,9 @@ func (s *Server) handleCreateDocShare(w http.ResponseWriter, r *http.Request) { if req.TeamID != "" { 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 { writeError(w, http.StatusBadRequest, err.Error()) return @@ -286,6 +289,7 @@ func (s *Server) handleCreateDocShare(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } + s.notifyShared(userID, "user", share.UserID, "die Notiz", s.docTitle(userID, docID)) s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED") s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED") writeJSON(w, http.StatusCreated, share) @@ -313,3 +317,10 @@ func (s *Server) handleGetCollaborators(w http.ResponseWriter, r *http.Request) } 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" +} diff --git a/internal/api/handlers_folders.go b/internal/api/handlers_folders.go index 39d2cd2..5b09b91 100644 --- a/internal/api/handlers_folders.go +++ b/internal/api/handlers_folders.go @@ -212,6 +212,7 @@ func (s *Server) handleCreateFolderShare(w http.ResponseWriter, r *http.Request) writeError(w, http.StatusBadRequest, err.Error()) 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}) if docIDs, err := s.store.GetSharedFolderDocIDs(folderID); err == nil { for _, docID := range docIDs { diff --git a/internal/api/handlers_lists.go b/internal/api/handlers_lists.go index 169a8a0..5a281a2 100644 --- a/internal/api/handlers_lists.go +++ b/internal/api/handlers_lists.go @@ -107,6 +107,9 @@ func (s *Server) handleCreateListShare(w http.ResponseWriter, r *http.Request) { return } + if l, err := s.store.GetListByID(currentUserID, listID); err == nil { + s.notifyShared(currentUserID, targetType, share.TargetID, "die Liste", l.Title) + } if targetType == "team" { members, _ := s.store.GetTeamMembers(share.TargetID) for _, tm := range members { diff --git a/internal/api/handlers_tasklists.go b/internal/api/handlers_tasklists.go index 8fac112..25a359e 100644 --- a/internal/api/handlers_tasklists.go +++ b/internal/api/handlers_tasklists.go @@ -125,6 +125,9 @@ func (s *Server) handleCreateTaskListShare(w http.ResponseWriter, r *http.Reques return } 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) } diff --git a/internal/api/handlers_thoughts.go b/internal/api/handlers_thoughts.go index a0ed90a..07bbf1c 100644 --- a/internal/api/handlers_thoughts.go +++ b/internal/api/handlers_thoughts.go @@ -94,6 +94,7 @@ func (s *Server) handleGetThoughts(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, err.Error()) return } + s.markWatching(userID, 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.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) } @@ -304,6 +307,9 @@ func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) { writeStoreError(w, http.StatusInternalServerError, err) return } + if th != nil { + s.notifyTaskChange(userID, th, nil, true) + } s.wsHub.BroadcastToUsers(audience, "TASK_DELETED", map[string]string{"id": thoughtID}) writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) } diff --git a/internal/api/mail.go b/internal/api/mail.go index 763cfd8..1b3ac27 100644 --- a/internal/api/mail.go +++ b/internal/api/mail.go @@ -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 // (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) _, _ = rand.Read(idBytes) 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, "Message-ID: <%s@%s>\r\n", hex.EncodeToString(idBytes), domain) buf.WriteString("MIME-Version: 1.0\r\n") - buf.WriteString("Content-Type: text/plain; charset=\"utf-8\"\r\n") - buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n") - qp := quotedprintable.NewWriter(&buf) - _, _ = qp.Write([]byte(strings.ReplaceAll(strings.ReplaceAll(body, "\r\n", "\n"), "\n", "\r\n"))) - _ = qp.Close() - buf.WriteString("\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") + qp := quotedprintable.NewWriter(&buf) + _, _ = qp.Write([]byte(strings.ReplaceAll(strings.ReplaceAll(text, "\r\n", "\n"), "\n", "\r\n"))) + _ = qp.Close() + 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() } @@ -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 // and tries LOGIN; a 451 on both points to the mail server's SASL backend. -func sendMail(cfg smtpSettings, to, subject, body string) error { - err := sendMailOnce(cfg, to, subject, body) +func sendMail(cfg smtpSettings, to, subject, body, html string) error { + err := sendMailOnce(cfg, to, subject, body, html) var first *authAttemptError if cfg.authMech != "" || !errors.As(err, &first) || first.mech != "PLAIN" || !first.loginOffered { return err } cfg.authMech = "LOGIN" - retryErr := sendMailOnce(cfg, to, subject, body) + retryErr := sendMailOnce(cfg, to, subject, body, html) var second *authAttemptError if retryErr == nil || !errors.As(retryErr, &second) { 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 // 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) if err != nil { return &smtpStepError{"Absender", err} @@ -278,7 +292,7 @@ func sendMailOnce(cfg smtpSettings, to, subject, body string) error { if err != nil { 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() return &smtpStepError{"DATA", err} } diff --git a/internal/api/mail_test.go b/internal/api/mail_test.go index d50d553..226d895 100644 --- a/internal/api/mail_test.go +++ b/internal/api/mail_test.go @@ -175,13 +175,13 @@ func TestSendMailStartTLSWithSelfSignedCertificate(t *testing.T) { f := startFakeSMTP(t, "") // 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 if !errors.As(err, &stepErr) || stepErr.Step != "STARTTLS" { 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) } f.mu.Lock() @@ -206,7 +206,7 @@ func TestSendMailStartTLSWithSelfSignedCertificate(t *testing.T) { func TestSendMailReportsTheRefusingStep(t *testing.T) { 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 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) @@ -218,7 +218,7 @@ func TestSendMailWithDisplayNameSender(t *testing.T) { f := startFakeSMTP(t, "") cfg := f.settings(true) cfg.From = "MindSift Alarme " - if err := sendMail(cfg, "Anna ", "Test", "Hallo"); err != nil { + if err := sendMail(cfg, "Anna ", "Test", "Hallo", ""); err != nil { t.Fatalf("send failed: %v", err) } f.mu.Lock() @@ -244,7 +244,7 @@ func TestAuthFallsBackToLogin(t *testing.T) { f := startFakeSMTP(t, "") f.authMechs = "PLAIN LOGIN" 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) } f.mu.Lock() @@ -257,7 +257,7 @@ func TestAuthFallsBackToLogin(t *testing.T) { func TestAuthLoginOnlyAndBothFailing(t *testing.T) { f := startFakeSMTP(t, "") 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) } @@ -265,7 +265,7 @@ func TestAuthLoginOnlyAndBothFailing(t *testing.T) { g.authMechs = "PLAIN LOGIN" g.authFail["PLAIN"] = "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") { t.Fatalf("expected both mechanisms and the server hint in the error, got %v", err) } diff --git a/internal/api/notify.go b/internal/api/notify.go new file mode 100644 index 0000000..0960103 --- /dev/null +++ b/internal/api/notify.go @@ -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(`` + + html.EscapeString(it.text) + ``) + } + button := "" + if link != "" { + button = `In MindSift öffnen` + } + return `` + + `` + + `` + + `` + + cards.String() + button + + `` + + `
MindSift
` + html.EscapeString(heading) + `
Diese Benachrichtigungen stellst du in MindSift unter Kontoeinstellungen → Alarme ein.
` +} + +// --- 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] + } +} diff --git a/internal/api/notify_test.go b/internal/api/notify_test.go new file mode 100644 index 0000000..5b9e8b3 --- /dev/null +++ b/internal/api/notify_test.go @@ -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, "