44 Commits
Author SHA1 Message Date
sandrodachandClaude Opus 5.5 1d68bb9acb Merge branch 'dev' into main: v0.10.0
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 7s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:47:16 +01:00
sandrodachandClaude Opus 5.5 bc7bdc481e chore(release): v0.10.0
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m16s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:47:15 +01:00
sandrodachandClaude Opus 5.5 7a90e6dae3 Merge branch 'feat/outliner-polish' into dev
Offer /number at start or end like the other block commands and start a
numbered list when typing "1. " into an empty bullet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:44:20 +01:00
sandrodachandClaude Opus 5.5 2bfe74e66b feat(workspace): numbered lists and reliable markdown paste
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m5s
- block type 'numbered' (/number): 1., a., i. by nesting, continues on
  Enter, an empty item ends the list; markdown export/import as 1.
- pasting: parser moved to lib/pasteOutline with tests; blank lines split
  paragraphs, numbered lists keep their type, indentation levels follow
  the widths in use (2, 3 or 4 spaces, tabs)
- text that stays in the focused block after pasting (a paragraph with
  line breaks) was dropped; the block now takes it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:36:15 +01:00
sandrodach 1753208cba feat(ui): update instant capture, count badges, mobile triage and folder modal 2026-10-30 19:27:15 +01:00
sandrodach fd72500d17 feat(workspace): polish outliner slash commands, typography and actions overlay 2026-10-30 19:26:52 +01:00
sandrodachandClaude Opus 5.5 9008d7c915 feat(workspace): journal with one note per day and a calendar
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m6s
- journal_days (migration 8) maps a person's day to an ordinary note;
  GET /journal lists the days, POST /journal/{date} opens or creates one
- the journal sits on top of the notes sidebar: days below each other
  (today first, older ones load while scrolling), a calendar with month
  and year picker follows the day in view and jumps to any day
- the header shares the day in view; journal notes stay out of the folder
  tree; can be switched off per device under Darstellung

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:20:58 +01:00
sandrodachandClaude Opus 5.5 261579f0f2 feat(notify): own webhook as a full notification channel
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m59s
The custom webhook could be entered but never switched on since the
notification matrix replaced the old alarm channels. It is now a third
column next to e-mail and Discord for every event and the daily summary;
bundles go out as JSON (event "digest").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:02:46 +01:00
sandrodachandClaude Opus 5.5 6c154af251 fix(workspace): single scroll area, drag cleanup, thin note titles with bundled Inter
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m18s
- the workspace fills the window; only the note scrolls (also on phones, where the
  sideways scroll area used to scroll on its own)
- clear drag state on dragend, calmer sidebar toggle when collapsed
- ship Inter as variable font, light and larger note titles; header shows the
  title only after the big title scrolled away and stays one row on phones
- main builds show the package version without -dev

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 18:58:55 +01:00
sandrodachandClaude Opus 5.5 ad4829796c fix(settings): let the profile section use the full width
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 18:36:50 +01:00
sandrodachandClaude Opus 5.5 2542e0e7b8 fix(themes): white editor and grey sidebar in light theme
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 18:03:46 +01:00
sandrodachandClaude Opus 5.5 22c52adc10 Merge branch 'dev' into main: v0.9.0
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m56s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:51:34 +01:00
sandrodachandClaude Opus 5.5 1b0c8ba18f chore(release): v0.9.0
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:51:32 +01:00
sandrodachandClaude Opus 5.5 5ff99c5434 fix(workspace): ignore formatting shortcuts in read-only notes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:51:14 +01:00
sandrodachandClaude Opus 5.5 8ecde4872d Merge branch 'feat/editor-polish' into dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:46:10 +01:00
sandrodach 07d86a0e2f docs(readme): add daily summary to features list 2026-10-30 16:23:26 +01:00
sandrodach a317ce0fee feat(workspace): add formatting shortcuts for bold, italic, and links in outliner 2026-10-30 16:22:55 +01:00
sandrodach 1d6863824b feat(workspace): add line numbers and horizontal scrolling to code block editor 2026-10-30 16:17:17 +01:00
sandrodach 108ac6621c fix(themes): improve contrast for code blocks and callouts in light theme 2026-10-30 16:12:36 +01:00
sandrodachandClaude Opus 5.5 e15b028171 Merge branch 'docs/review' into dev
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 7s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:25:36 +01:00
sandrodach 6952ae15e3 docs(env): fix typo in comment 2026-10-30 13:38:56 +01:00
sandrodach e8c7c55dd5 docs(changelog): note group-shared task lists in daily summary for 0.8.0 2026-10-30 13:38:21 +01:00
sandrodach 57de85d530 docs(readme): update feature list, version badge to v0.8.0, and config options 2026-10-30 13:38:06 +01:00
sandrodach d86623f2c9 docs(websocket): document DEMO_RESET event 2026-10-30 13:36:42 +01:00
sandrodach e83c1ae7dd docs(flaws): strike implemented images/audio feature idea 2026-10-30 13:36:27 +01:00
sandrodach 6ae4fbc2bf docs(sharing): complete sentence pointing to flaws.md 2026-10-30 13:36:08 +01:00
sandrodach 4a3ee2c570 docs(ui-patterns): update voice input description in HoldToRecordFab 2026-10-30 13:35:59 +01:00
sandrodach f5f21e1c9c docs(triage): update speech input description to useVoiceInput 2026-10-30 13:35:37 +01:00
sandrodach f2df3f8d86 docs(tasks): add task watchers and granular notifications documentation 2026-10-30 13:35:24 +01:00
sandrodach b5e89d6bd7 docs(workspace): update slash commands, extract/merge behavior, permissions, and link fix 2026-10-30 13:34:51 +01:00
sandrodach ad6a8ead61 docs(database): update schema columns, migrations 3-7, and remove obsolete statements 2026-10-30 13:33:51 +01:00
sandrodach b9df907ead docs(architecture): add missing env variables to configuration table 2026-10-30 13:32:58 +01:00
sandrodach e6ea8155e4 docs(api): fix routes in rest.md to match server.go 2026-10-30 13:32:18 +01:00
sandrodachandClaude Opus 5.5 e778e6d6dc Merge branch 'dev' into main: v0.8.0
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m58s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:00:11 +01:00
sandrodachandClaude Opus 5.5 6100d339f1 chore(release): v0.8.0
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m16s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:00:09 +01:00
sandrodach 4f5da9507f Merge branch 'feat/gemini-workspace' into dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

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

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

No files matched your search

+4 -1
View File
@@ -8,7 +8,7 @@
PORT=8080 PORT=8080
# Geheimer Schlüssel für die Anmeldung, mindestens 32 zufällige Zeichen, z. B. von: openssl rand -hex 32 # Geheimer Schlüssel für die Anmeldung, mindestens 32 zufällige Zeichen, z. B. von: openssl rand -hex 32
# Bekannte Beispielwerte (wie dieser) werden ignoriert; dann erzeugt MindSift selbst einen und legt ihn in data/ ab.. # Bekannte Beispielwerte (wie dieser) werden ignoriert; dann erzeugt MindSift selbst einen und legt ihn in data/ ab.
JWT_SECRET=change_this_to_a_secure_random_key_min_32_chars JWT_SECRET=change_this_to_a_secure_random_key_min_32_chars
# Öffentliche Adresse der Instanz, so wie sie im Browser aufgerufen wird. Wird für Links in E-Mails # Öffentliche Adresse der Instanz, so wie sie im Browser aufgerufen wird. Wird für Links in E-Mails
@@ -67,6 +67,9 @@ SMTP_FROM=
# Name, den Mailprogramme als Absender zeigen (wenn SMTP_FROM nur eine Adresse ist). # Name, den Mailprogramme als Absender zeigen (wenn SMTP_FROM nur eine Adresse ist).
# Ein Name direkt in SMTP_FROM ("Mein Dienst <noreply@example.com>") hat Vorrang. # Ein Name direkt in SMTP_FROM ("Mein Dienst <noreply@example.com>") hat Vorrang.
SMTP_FROM_NAME=MindSift SMTP_FROM_NAME=MindSift
# Benachrichtigungen (Freigaben, beobachtete Aufgaben …) werden gesammelt und höchstens alle so viele
# Minuten als eine Nachricht verschickt. Fällige Aufgaben kommen immer sofort.
NOTIFY_DIGEST_MINUTES=5
# true = nur verschlüsselt senden. # true = nur verschlüsselt senden.
SMTP_TLS=true SMTP_TLS=true
# true = selbstsignierte Zertifikate akzeptieren (nur für interne Mailserver). # true = selbstsignierte Zertifikate akzeptieren (nur für interne Mailserver).
+3 -1
View File
@@ -51,10 +51,12 @@ jobs:
echo "tag=${TAGS%% *}" >> "$GITHUB_OUTPUT" echo "tag=${TAGS%% *}" >> "$GITHUB_OUTPUT"
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT" echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
echo "actor=${ACTOR_NAME}" >> "$GITHUB_OUTPUT" echo "actor=${ACTOR_NAME}" >> "$GITHUB_OUTPUT"
# Releases (tags) show the tag as version in the app header # Version in the app header: tags show the tag, main the package version, everything else
# (dev, feature branches) keeps the "-dev" suffix from vite.config.ts
APP_VERSION="" APP_VERSION=""
case "${GITHUB_REF:-}" in case "${GITHUB_REF:-}" in
refs/tags/*) APP_VERSION="${REF_NAME}" ;; refs/tags/*) APP_VERSION="${REF_NAME}" ;;
refs/heads/main) APP_VERSION="v$(sed -nE 's/^ "version": "([^"]+)".*/\1/p' frontend/package.json | head -n1)" ;;
esac esac
echo "app_version=${APP_VERSION}" >> "$GITHUB_OUTPUT" echo "app_version=${APP_VERSION}" >> "$GITHUB_OUTPUT"
echo "Target Docker image: ${IMAGE_NAME} tags: ${TAGS} (Registry: ${REGISTRY_HOST}, Actor: ${ACTOR_NAME})" echo "Target Docker image: ${IMAGE_NAME} tags: ${TAGS} (Registry: ${REGISTRY_HOST}, Actor: ${ACTOR_NAME})"
+64 -1
View File
@@ -6,6 +6,66 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased] ## [Unreleased]
## [0.10.0] - 2026-10-30
### Added
- Nummerierte Listen im Outliner: `/number` (auch `/nummer`, `/liste`) oder `1. ` am Anfang eines leeren Punkts; Enter setzt die Liste fort, Enter in einem leeren Punkt beendet sie. Eingerückte Listen zählen mit Buchstaben (a., b.), die nächste Ebene römisch (i., ii.), danach wieder mit Zahlen. Export und Import als Markdown (`1.`).
- **Journal** im Workspace: ganz oben in der Seitenleiste; eine Notiz pro Tag mit festem Datumstitel, alle Tage untereinander (heute oben, ältere laden beim Scrollen nach). Solange das Journal offen ist, zeigt ein Kalender mit Monats- und Jahresauswahl den Tag, bei dem man gerade ist; Tage mit Einträgen haben einen Punkt, ein Klick springt hin (und legt den Tag bei Bedarf an). „Teilen“ gilt dem gerade sichtbaren Tag. Am Handy klappt der Kalender über die Kopfzeile auf. Unter Darstellung pro Gerät abschaltbar. API: `GET /journal`, `POST /journal/{date}`.
- Benachrichtigungen: Der eigene Webhook ist jetzt ein vollwertiger Kanal neben E-Mail und Discord – eigene Spalte „Webhook“ für jedes Ereignis und die Tageszusammenfassung; gebündelte Nachrichten kommen als JSON (`event: "digest"` mit Liste der Ereignisse).
- Outliner: Neuer Slash-Befehl `/text` (sowie Aliase `/p`, `/paragraph`, `/bullet`, `/standard`), um beliebige Blöcke (Überschriften, Callouts, Aufgaben, Trennlinien) in Standard-Text umzuwandeln; Aufgaben-Optionen und Block-Aktionsblase bieten ebenfalls die Rückumwandlung an.
- Triage-Deck: Touch-freundliche Schnellaktions-Leiste unter der aktiven mobilen Karte (Wegwerfen, Liste, Notiz, Aufgabe) mit Daumenbedienung (≥ 44 px).
### Changed
- Schrift: MindSift liefert die Schrift Inter jetzt selbst aus (ohne externen Dienst), damit die Oberfläche auf allen Geräten gleich aussieht. Notiztitel stehen in einer feinen, dünnen Schrift und etwas größer.
- Notizen: Der Titel in der Kopfleiste erscheint erst, wenn der große Titel aus dem Bild gescrollt ist; am Handy passt die Kopfleiste in eine Zeile.
- Das Bild für `main` (`latest`) zeigt die Versionsnummer ohne `-dev`; nur `dev` und andere Zweige behalten den Zusatz.
- Outliner: Schriftgrößen für H1 (`text-xl sm:text-2xl font-bold`), H2 (`text-lg sm:text-xl font-semibold`) und H3 (`text-base font-semibold`) deutlich angehoben bei synchroner Textarea und dezentem oberen Abstand.
- Outliner: Aktions-Buttons (Zoom, Auslagern, Löschen, Text-Umwandlung) als schwebende, kontrastreiche Blase (`backdrop-blur-sm`) positioniert, wodurch der Fließtext beim Einblenden nicht mehr ruckartig umbricht.
- Outliner: Aktiver Eintrag im Slash-Befehlsmenü dezent als `bg-accent-dim text-accent` hervorgehoben statt vollflächig deckend.
- Quick Dump (Instant Capture): Platzhaltertexte gekürzt, vertikales Scrolling im einzeiligen Zustand unterbunden und Absende-Pfeil nach unten ausgerichtet.
- Zähler-Badges (`CountBadge`): Akzent-Badge über `--accent-dim` mit Rand, Muted-Badge über `--bg-panel-elevated` und `--text-muted` für hohe Lesbarkeit in allen Themes.
- Workspace: Popover-Dialoge (`FormSheet` mit Anker) öffnen zentriert unter dem Mauszeiger mit Rand-Schutz (Viewport-Clamping).
### Fixed
- Notizen: Eingefügter Text mit Zeilenumbrüchen, der in einem einzigen Punkt landet (z. B. ein Absatz), verschwand – er wird jetzt übernommen. Leerzeilen trennen eingefügte Absätze in eigene Punkte, nummerierte Markdown-Listen bleiben nummeriert (auch mit 3 oder 4 Leerzeichen Einrückung), umbrochene Zeilen gehören zum Punkt darüber.
- Kontoeinstellungen: Der eigene Webhook ließ sich zwar eintragen, aber nicht mehr einschalten (seit dem Umbau der Benachrichtigungen in 0.8.0).
- Workspace: kein doppelter Scrollbalken mehr – die Seite selbst scrollt nicht, nur die Notiz; am Handy scrollt die Notiz nicht mehr getrennt von der Seite.
- Workspace: Ein abgebrochenes Ziehen in der Seitenleiste lässt „Auf Hauptebene verschieben“ nicht mehr stehen.
- Workspace: Der Knopf zum Ein-/Ausklappen der Ordnerleiste ist im eingeklappten Zustand dezent (nur das Symbol in Akzentfarbe).
- Outliner: Slash-Befehle werden an der tatsächlichen Cursorposition im Text erkannt (`/link`, `/diktat`); `/divider`, `/image`, `/audio` und `/code` werden nur noch in leeren Blöcken angeboten; das Menü schließt bei Klick ins Leere und Blur.
- Outliner: Aufgaben-Badges und Drei-Punkte-Menü aus dem Fließtext-Container entkoppelt, um störende Zeilenumbrüche am Zeilenende zu beheben.
- Outliner: Trennlinien (`divider`) können fokussiert und per `Backspace` oder `Entf` direkt in einen leeren Textpunkt umgewandelt werden.
- Kontoeinstellungen: Der Bereich „Profil & Anmeldedaten“ nutzt die volle Breite wie die übrigen Bereiche.
- Light-Theme: Notizen stehen auf einem weißen Blatt, die Ordnerleiste ist dezent grau abgesetzt (bisher umgekehrt). Themes können das über die neuen Variablen `--bg-editor` und `--bg-sidebar` selbst festlegen.
## [0.9.0] - 2026-10-30
### Added
- Formatierungs-Kürzel im Outliner: `Strg+B` / `Cmd+B` für fett, `Strg+I` / `Cmd+I` für kursiv (mit Ein-/Ausschalten bei markiertem Text) und `Strg+K` / `Cmd+K` für Links (mit Markierung `[Text](…)` mit vorselektierter Adresse, ohne Markierung Link-Dialog).
- Code-Blöcke: Zeilennummernspalte links mit synchronem Scrollen (wächst dynamisch bei 100+ Zeilen, zählt auch leere Endzeilen mit); horizontaler Bildlauf bei langen Zeilen ohne automatischen Zeilenumbruch (`white-space: pre`).
### Fixed
- Formatierungs-Kürzel wirken nicht in schreibgeschützten Notizen.
- Light-Theme: Kontraste für Code-Blöcke (`--bg-code`), Callouts (`--bg-panel-elevated`), Zitate und Rahmen auf Notizhintergründen geschärft; VS Code Light+ Syntaxfarben für hohe Lesbarkeit optimiert.
## [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.
- Benachrichtigungen: Die Tageszusammenfassung schließt nun auch Aufgaben aus Aufgabenlisten ein, die über eine Gruppe freigegeben wurden.
## [0.7.0] - 2026-10-30 ## [0.7.0] - 2026-10-30
### Added ### Added
@@ -194,7 +254,10 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken. - Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung. - Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.7.0...dev [Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.10.0...dev
[0.10.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.9.0...v0.10.0
[0.9.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.8.0...v0.9.0
[0.8.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.7.0...v0.8.0
[0.7.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.6.0...v0.7.0 [0.7.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.6.0...v0.7.0
[0.6.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.6.0...v0.6.1 [0.6.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.6.0...v0.6.1
[0.6.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.5.2...v0.6.0 [0.6.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.5.2...v0.6.0
+25 -16
View File
@@ -16,16 +16,6 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
| POST | `/auth/reset-password` | `{token, password}` (mind. 8 Zeichen) → neues Passwort, alle Sitzungen beendet; `400` bei ungültigem oder abgelaufenem Link | | POST | `/auth/reset-password` | `{token, password}` (mind. 8 Zeichen) → neues Passwort, alle Sitzungen beendet; `400` bei ungültigem oder abgelaufenem Link |
| POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) | | POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) |
| POST | `/webhooks/ingest` | Einliefern per Webhook, Key als `X-API-Key` (empfohlen), Bearer oder `?token=` (in der URL nur API-Keys, keine Sitzungs-Tokens). Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; `target` (oder `type`): `triage` (Standard, Inbox), `task` (optional `taskListId`), `list_item` (`listId`, `category`, `quantity`). Klartext-Body wird als Inhalt genommen. `403` ohne passenden Scope oder ohne Schreibrecht auf die Liste | | POST | `/webhooks/ingest` | Einliefern per Webhook, Key als `X-API-Key` (empfohlen), Bearer oder `?token=` (in der URL nur API-Keys, keine Sitzungs-Tokens). Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; `target` (oder `type`): `triage` (Standard, Inbox), `task` (optional `taskListId`), `list_item` (`listId`, `category`, `quantity`). Klartext-Body wird als Inhalt genommen. `403` ohne passenden Scope oder ohne Schreibrecht auf die Liste |
| POST | `/auth/stream-ticket` | Einmal-Ticket (60 s) für WebSocket/SSE |
| 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}/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 |
| 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) |
| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); `?ticket=` oder `Authorization`-Header | | GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); `?ticket=` oder `Authorization`-Header |
| GET | `/events` | SSE-Fallback; `?ticket=` oder `Authorization`-Header | | GET | `/events` | SSE-Fallback; `?ticket=` oder `Authorization`-Header |
@@ -35,11 +25,13 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|---|---|---| |---|---|---|
| GET | `/auth/me` | Benutzer und Kennzahlen | | GET | `/auth/me` | Benutzer und Kennzahlen |
| POST | `/events/rooms` | SSE-Raum beitreten/verlassen (`sseId`, `room`, `action`); nur eigene `sseId`, nur Räume mit Lesezugriff | | POST | `/events/rooms` | SSE-Raum beitreten/verlassen (`sseId`, `room`, `action`); nur eigene `sseId`, nur Räume mit Lesezugriff |
| POST | `/auth/stream-ticket` | Einmal-Ticket (60 s) für WebSocket/SSE |
| POST | `/voice/transcribe` | Nur im Server-Modus der Spracherkennung: `multipart/form-data` mit `file` (Aufnahme, z. B. WebM/Opus oder MP4/AAC) → `{text}`. `404` ohne `STT_URL`, `413` zu groß, `429` Stundenlimit, `503` ausgelastet, `502` Whisper-Fehler (Details im Log) |
| POST | `/auth/logout` | Sitzung dieses Geräts beenden | | POST | `/auth/logout` | Sitzung dieses Geräts beenden |
| GET | `/auth/sessions` | Angemeldete Geräte: `[{id, createdAt, lastUsedAt, userAgent, ip, current}]` | | GET | `/auth/sessions` | Angemeldete Geräte: `[{id, createdAt, lastUsedAt, userAgent, ip, current}]` |
| DELETE | `/auth/sessions/{id}` | Ein Gerät abmelden; `404` bei fremder oder unbekannter Sitzung | | DELETE | `/auth/sessions/{id}` | Ein Gerät abmelden; `404` bei fremder oder unbekannter Sitzung |
| POST | `/auth/revoke-all` | Alle Sitzungen beenden (`token_version` +1) | | POST | `/auth/revoke-all` | Alle Sitzungen beenden (`token_version` +1) |
| PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes`. Neue `email` oder `password` nur mit `currentPassword` (`403` bei falschem, `429` bei Login-Sperre); nach neuem Passwort sind andere Geräte abgemeldet, das neue Token kommt im Header `X-Session-Token` | | PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes`. `notificationSettings` (`paused`, `includeOwnWatched`, `events.{due,shared,taskDone,watched}.{email,discord}`, `dailySummary.{enabled,time,email,discord}`) für Benachrichtigungen. Neue `email` oder `password` nur mit `currentPassword` (`403` bei falschem, `429` bei Login-Sperre); nach neuem Passwort sind andere Geräte abgemeldet, das neue Token kommt im Header `X-Session-Token` |
| POST | `/auth/alarms/test` | Test-Alarm (`channel`: `email`/`discord`/`webhook`, optional `target`) | | POST | `/auth/alarms/test` | Test-Alarm (`channel`: `email`/`discord`/`webhook`, optional `target`) |
| GET | `/users/directory`, `/collaborators` | Personen, die man sehen und mit denen man teilen kann (je nach `sharingMode`, [sharing-and-permissions.md](../sharing-and-permissions.md)) | | GET | `/users/directory`, `/collaborators` | Personen, die man sehen und mit denen man teilen kann (je nach `sharingMode`, [sharing-and-permissions.md](../sharing-and-permissions.md)) |
| GET | `/stats` | Zähler fürs Dashboard (u. a. `inboxCount`, `tasksOpenCount`, `listItemsOpenCount` = offene Einträge aller sichtbaren Listen) | | GET | `/stats` | Zähler fürs Dashboard (u. a. `inboxCount`, `tasksOpenCount`, `listItemsOpenCount` = offene Einträge aller sichtbaren Listen) |
@@ -54,7 +46,8 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
| DELETE | `/user/account` | Eigenes Konto mit allen Daten löschen (`{"password"}` nötig); `403` für den letzten Server-Admin. `/auth/me` liefert dafür `isLastAdmin` | | DELETE | `/user/account` | Eigenes Konto mit allen Daten löschen (`{"password"}` nötig); `403` für den letzten Server-Admin. `/auth/me` liefert dafür `isLastAdmin` |
| GET | `/backups`, `/backups/{name}` | Eigene automatische Server-Backups auflisten bzw. herunterladen | | GET | `/backups`, `/backups/{name}` | Eigene automatische Server-Backups auflisten bzw. herunterladen |
| POST | `/backups/{name}/restore` | Server-Backup einspielen: `mode=merge` oder `restore` (gewählte Bereiche vorher leeren), `sections=notes,tasks,lists,inbox,themes`, `preview=1` | | POST | `/backups/{name}/restore` | Server-Backup einspielen: `mode=merge` oder `restore` (gewählte Bereiche vorher leeren), `sections=notes,tasks,lists,inbox,themes`, `preview=1` |
| GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung); `POST` mit `name` und `scopes` (Standard `["webhook:triage"]`) | | GET/POST | `/keys` | API-Keys auflisten bzw. anlegen (Klartext nur bei Erstellung); `POST` mit `name` und `scopes` (Standard `["webhook:triage"]`) |
| DELETE | `/keys/{id}` | API-Key löschen |
### Globale Suche (`GET /api/v1/search?q=`) ### Globale Suche (`GET /api/v1/search?q=`)
@@ -94,6 +87,7 @@ Führt eine Volltextsuche über SQLite FTS5 mit BM25-Relevanz-Ranking aus.
| GET | `/tasks?taskListId=` | Aufgaben (optional einer Liste) | | GET | `/tasks?taskListId=` | Aufgaben (optional einer Liste) |
| POST | `/tasks/reorder`, `/task-lists/{id}/tasks/reorder` | Reihenfolge/Spalte ändern (`items[{id, sortOrder, kanbanStatus}]`) | | POST | `/tasks/reorder`, `/task-lists/{id}/tasks/reorder` | Reihenfolge/Spalte ändern (`items[{id, sortOrder, kanbanStatus}]`) |
| POST | `/tasks/{id}/unlink` | Verknüpfung zu Notiz-Block lösen | | POST | `/tasks/{id}/unlink` | Verknüpfung zu Notiz-Block lösen |
| POST/DELETE | `/tasks/{id}/watch` | Aufgabe beobachten bzw. nicht mehr → `{watching}`; `404` ohne Zugriff. Aufgaben-Antworten tragen `isWatching` |
## Aufgabenlisten ## Aufgabenlisten
@@ -116,7 +110,8 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein
| GET/POST | `/workspace/folders/shared` | Geteilte Ordner; anlegen (`name`, `mountPath`) | | GET/POST | `/workspace/folders/shared` | Geteilte Ordner; anlegen (`name`, `mountPath`) |
| GET/DELETE | `/workspace/folders/shared/{id}` | Abruf; Löschen | | GET/DELETE | `/workspace/folders/shared/{id}` | Abruf; Löschen |
| PUT | `/workspace/folders/shared/{id}/mount` | Eigenen Einhängepfad ändern (`mountPath`) | | PUT | `/workspace/folders/shared/{id}/mount` | Eigenen Einhängepfad ändern (`mountPath`) |
| GET/POST/DELETE | `/workspace/folders/shared/{id}/shares[/{shareId}]` | Freigaben (`permission`: `read`/`write`) | | GET/POST | `/workspace/folders/shared/{id}/shares` | Freigaben geteilter Ordner (`permission`: `read`/`write`) |
| DELETE | `/workspace/folders/shared/{id}/shares/{shareId}` | Freigabe eines geteilten Ordners entfernen |
| GET | `/workspace/tags`, `/workspace/tags/{tag}` | Tag-Übersicht und -Details | | GET | `/workspace/tags`, `/workspace/tags/{tag}` | Tag-Übersicht und -Details |
| PUT | `/workspace/tags/{tag}/note` | Tag-Seite speichern (`content`) | | PUT | `/workspace/tags/{tag}/note` | Tag-Seite speichern (`content`) |
| GET/POST | `/documents` | Notizen auflisten; anlegen (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`) | | GET/POST | `/documents` | Notizen auflisten; anlegen (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`) |
@@ -127,7 +122,19 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein
| DELETE | `/documents/{id}/permanent` | Notiz im Papierkorb endgültig löschen | | DELETE | `/documents/{id}/permanent` | Notiz im Papierkorb endgültig löschen |
| PUT | `/documents/{id}/move` | Ordner wechseln (`folderPath`) | | PUT | `/documents/{id}/move` | Ordner wechseln (`folderPath`) |
| POST | `/documents/{id}/blocks/{blockId}/enhanced-task` | Aus einem Block eine verknüpfte Aufgabe machen (`content`, `checked`) | | POST | `/documents/{id}/blocks/{blockId}/enhanced-task` | Aus einem Block eine verknüpfte Aufgabe machen (`content`, `checked`) |
| GET/POST/DELETE | `/documents/{id}/shares[/{userId}]` | Notiz-Freigaben (`userId`/`email`/`teamId`, `permission`: `read`/`write`) | | GET/POST | `/documents/{id}/shares` | Notiz-Freigaben (`userId`/`email`/`teamId`, `permission`: `read`/`write`) |
| DELETE | `/documents/{id}/shares/{userId}` | Notiz-Freigabe entfernen |
| 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 |
| GET | `/journal` | Tage des eigenen Journals, neueste zuerst → `{days: [{date, docId, empty}]}` (`empty`: Notiz noch ohne Text; Tage im Papierkorb fehlen) |
| POST | `/journal/{date}` | Notiz des Tages (`JJJJ-MM-TT`) öffnen; gibt es noch keine, wird sie angelegt (Titel z. B. „Freitag, 30. Oktober 2026“, Icon 📅) → Dokument, `201` bei Neuanlage. Die Journal-Notizen sind normale Notizen (Teilen, Suche, Sync) |
| 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 | `/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` |
| 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 |
Der Inhalt einer Notiz (Blöcke) wird ausschließlich über `POST /docs/{id}/sync` geändert. `POST /documents` nimmt beim Anlegen weiterhin eine Start-Blockliste an; sie wird intern in Operationen umgerechnet. Der Inhalt einer Notiz (Blöcke) wird ausschließlich über `POST /docs/{id}/sync` geändert. `POST /documents` nimmt beim Anlegen weiterhin eine Start-Blockliste an; sie wird intern in Operationen umgerechnet.
@@ -171,7 +178,8 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant
|---|---|---| |---|---|---|
| GET/POST | `/lists` | Listen; anlegen (`title`, `icon`, `listType`) | | GET/POST | `/lists` | Listen; anlegen (`title`, `icon`, `listType`) |
| GET/DELETE | `/lists/{id}` | Abruf mit Einträgen; Löschen | | GET/DELETE | `/lists/{id}` | Abruf mit Einträgen; Löschen |
| GET/POST/DELETE | `/lists/{id}/shares[/{shareId}]` | Freigaben (`targetType`, `targetId`, `role`: `editor`/`viewer`) | | GET/POST | `/lists/{id}/shares` | Freigaben abrufen bzw. anlegen (`targetType`, `targetId`, `role`: `editor`/`viewer`) |
| DELETE | `/lists/{id}/shares/{shareId}` | Freigabe entfernen |
| POST | `/lists/{id}/items` | Eintrag (`content`, `category`, `quantity`) | | POST | `/lists/{id}/items` | Eintrag (`content`, `category`, `quantity`) |
| PATCH | `/lists/{id}/items/{itemId}` | Abhaken (`isCompleted`) | | PATCH | `/lists/{id}/items/{itemId}` | Abhaken (`isCompleted`) |
| PUT | `/lists/{id}/items/{itemId}` | Ändern (`content`, `category`, `quantity`) | | PUT | `/lists/{id}/items/{itemId}` | Ändern (`content`, `category`, `quantity`) |
@@ -198,7 +206,8 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant
| PUT/DELETE | `/admin/organizations/{id}` | Umbenennen (`name`); löschen (Mitglieder sind danach ohne Organisation) | | PUT/DELETE | `/admin/organizations/{id}` | Umbenennen (`name`); löschen (Mitglieder sind danach ohne Organisation) |
| PATCH/DELETE | `/admin/users/{id}` | `displayName`, `email`, `role`, `isActive`, `discordWebhookUrl`; löschen | | PATCH/DELETE | `/admin/users/{id}` | `displayName`, `email`, `role`, `isActive`, `discordWebhookUrl`; löschen |
| PUT | `/admin/users/{id}/password` | Passwort zurücksetzen | | PUT | `/admin/users/{id}/password` | Passwort zurücksetzen |
| GET/POST/DELETE | `/admin/invites[/{id}]` | Einladungen (`email`, `role`, `organizationId`, `validDays`) | | GET/POST | `/admin/invites` | Einladungen auflisten bzw. anlegen (`email`, `role`, `organizationId`, `validDays`) |
| DELETE | `/admin/invites/{id}` | Einladung widerrufen/löschen |
| GET/PUT | `/admin/settings` | `instanceName`, `allowRegistration`, `sharingMode` (`private`/`organizations`/`open`), SMTP-Felder; ein Moduswechsel bereinigt Freigaben. `smtpPass` kommt nie zurück (`smtpPassSet` zeigt, ob eines gespeichert ist); leer = unverändert | | GET/PUT | `/admin/settings` | `instanceName`, `allowRegistration`, `sharingMode` (`private`/`organizations`/`open`), SMTP-Felder; ein Moduswechsel bereinigt Freigaben. `smtpPass` kommt nie zurück (`smtpPassSet` zeigt, ob eines gespeichert ist); leer = unverändert |
| GET | `/admin/settings/sharing-impact?mode=` | Probelauf: wie viele Freigaben und Gruppen-Mitgliedschaften ein Wechsel entfernen würde (`affected`) | | GET | `/admin/settings/sharing-impact?mode=` | Probelauf: wie viele Freigaben und Gruppen-Mitgliedschaften ein Wechsel entfernen würde (`affected`) |
| POST | `/admin/smtp/test` | Testmail (`toEmail`) | | POST | `/admin/smtp/test` | Testmail (`toEmail`) |
+1
View File
@@ -55,6 +55,7 @@ Client → Server versteht der Server nur: `{"event":"JOIN_ROOM","room":"<id>"}`
| `LIST_CREATED`, `LIST_UPDATED`, `LIST_DELETED`, `LIST_SHARED`, `LIST_UNSHARED`, `LIST_CLEARED` | Listenverwaltung | Benutzer bzw. Raum der Liste | | `LIST_CREATED`, `LIST_UPDATED`, `LIST_DELETED`, `LIST_SHARED`, `LIST_UNSHARED`, `LIST_CLEARED` | Listenverwaltung | Benutzer bzw. Raum der Liste |
| `ITEM_CREATED`, `ITEM_UPDATED`, `ITEM_TOGGLED`, `ITEM_DELETED`, `ITEM_REORDERED` | Listeneinträge | Raum der Liste | | `ITEM_CREATED`, `ITEM_UPDATED`, `ITEM_TOGGLED`, `ITEM_DELETED`, `ITEM_REORDERED` | Listeneinträge | Raum der Liste |
| `USER_PRESENCE` | Verbindung auf-/abgebaut (`onlineCount`) | Raum | | `USER_PRESENCE` | Verbindung auf-/abgebaut (`onlineCount`) | Raum |
| `DEMO_RESET` | Periodischer Reset im Demo-Modus; Clients verwerfen lokale Caches und laden Daten neu | Alle aktiven Verbindungen |
| `SSE_CONNECTED` | Nur SSE, direkt nach Verbindung | Verbindung | | `SSE_CONNECTED` | Nur SSE, direkt nach Verbindung | Verbindung |
## Verhalten im Frontend ## Verhalten im Frontend
+11 -2
View File
@@ -70,13 +70,18 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer / `587` / `true` | Mailversand, auch im Admin-UI einstellbar | | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer / `587` / `true` | Mailversand, auch im Admin-UI einstellbar |
| `SMTP_FROM_NAME` | `MindSift` | Anzeigename des Absenders, wenn `SMTP_FROM` nur eine Adresse ist (`From: "MindSift" <adresse>`, der SMTP-Umschlag bleibt die reine Adresse); ein Name in `SMTP_FROM` hat Vorrang | | `SMTP_FROM_NAME` | `MindSift` | Anzeigename des Absenders, wenn `SMTP_FROM` nur eine Adresse ist (`From: "MindSift" <adresse>`, der SMTP-Umschlag bleibt die reine Adresse); ein Name in `SMTP_FROM` hat Vorrang |
| `SMTP_SKIP_VERIFY` | `false` | Zertifikat des Mailservers nicht prüfen (nur interne Server mit selbstsigniertem Zertifikat) | | `SMTP_SKIP_VERIFY` | `false` | Zertifikat des Mailservers nicht prüfen (nur interne Server mit selbstsigniertem Zertifikat) |
| `NOTIFY_DIGEST_MINUTES` | `5` | Benachrichtigungen (Freigaben, beobachtete Aufgaben …) werden gesammelt und höchstens alle so viele Minuten gebündelt verschickt |
| `MEDIA_MAX_IMAGE_MB`, `MEDIA_MAX_AUDIO_MB` | `15` / `50` | Größtes Bild bzw. größte Audiodatei in Notizen in MB |
| `MEDIA_QUOTA_MB` | `0` | Speicherplatz für Medien pro Benutzer in MB (`0` = unbegrenzt) |
| `MEDIA_DEMO_MAX_MB` | `3` | Nur im Demo-Modus: maximale Dateigröße für Medien-Uploads in MB |
| `STT_URL` | leer | Eigene Spracherkennung: Basis-URL eines OpenAI-kompatiblen Whisper-Servers (z. B. `http://whisper:8000`, speaches). Gesetzt = Server-Modus, sonst Spracherkennung des Browsers; in der Demo immer Browser | | `STT_URL` | leer | Eigene Spracherkennung: Basis-URL eines OpenAI-kompatiblen Whisper-Servers (z. B. `http://whisper:8000`, speaches). Gesetzt = Server-Modus, sonst Spracherkennung des Browsers; in der Demo immer Browser |
| `STT_MODEL`, `STT_LANGUAGE`, `STT_API_KEY` | `deepdml/faster-whisper-large-v3-turbo-ct2` / `de` / leer | Modell, Sprache (`auto` = erkennen), Schlüssel falls der Whisper-Server einen verlangt | | `STT_MODEL`, `STT_LANGUAGE`, `STT_API_KEY` | `deepdml/faster-whisper-large-v3-turbo-ct2` / `de` / leer | Modell, Sprache (`auto` = erkennen), Schlüssel falls der Whisper-Server einen verlangt |
| `STT_DISABLED` | `false` | `true` blendet die Spracheingabe überall aus | | `STT_DISABLED` | `false` | `true` blendet die Spracheingabe überall aus |
| `STT_MAX_SECONDS`, `STT_MAX_UPLOAD_MB` | `600` / `25` | Längste Aufnahme (stoppt im Browser), größte Aufnahme | | `STT_MAX_SECONDS`, `STT_MAX_UPLOAD_MB` | `600` / `25` | Längste Aufnahme (stoppt im Browser), größte Aufnahme |
| `STT_MAX_PARALLEL`, `STT_PER_USER_PER_HOUR`, `STT_TIMEOUT_SECONDS` | `1` / `60` / `120` | Gleichzeitige Erkennungen (weitere warten), Erkennungen pro Person und Stunde, Höchstdauer inkl. Warten | | `STT_MAX_PARALLEL`, `STT_PER_USER_PER_HOUR`, `STT_TIMEOUT_SECONDS` | `1` / `60` / `120` | Gleichzeitige Erkennungen (weitere warten), Erkennungen pro Person und Stunde, Höchstdauer inkl. Warten |
| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) | | `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) |
| `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` (oder `…_CONTENT_FILE` mit Dateipfad) | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster. Ohne URL und Text bleibt der Link unsichtbar. | | `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster |
| `LEGAL_IMPRINT_CONTENT_FILE`, `LEGAL_PRIVACY_CONTENT_FILE` | leer | Pfad zu einer Datei im Container mit Markdown-Inhalt für Impressum bzw. Datenschutz. Ohne URL und Text bleibt der Link unsichtbar. |
Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in `DATA_DIR` genutzt wird; Postgres ist nicht implementiert. Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in `DATA_DIR` genutzt wird; Postgres ist nicht implementiert.
@@ -97,7 +102,11 @@ Bilder und Audio liegen in `$DATA_DIR/media/<docId>/<id>.<ext>`, beschrieben in
## Notizen umbauen ## Notizen umbauen
`internal/storage/restructure.go`: **Ast auslagern** liest den Ast, legt die neue Notiz an (Titel eindeutig, gleiche direkte und Gruppen-Freigaben, Besitzer der Ursprungsnotiz bekommt Schreibrecht) und ersetzt den Ast dann über das Operationsprotokoll durch `[[Titel]]` (Hashtags bleiben am Link). **Zusammenführen** hängt die Quelle als `h2`-Überschrift mit ihren Punkten als Unterpunkte an die Zielnotiz an (ebenfalls über Operationen, offene Editoren folgen live) und legt die Quelle in den Papierkorb; „Rückgängig“ löscht die Überschrift samt Unterpunkten und stellt die Quelle wieder her. Die Aktion läuft im Namen des Editor-Clients (`clientId`), damit dessen eigene Block-Sperren nicht stören. Bilder und Aufnahmen funktionieren weiter, weil der Zugriff über die Notizen läuft, die sie zeigen. `internal/storage/restructure.go`: **Ast auslagern** liest den Ast und legt die neue Notiz an (Titel eindeutig, gleiche direkte und Gruppen-Freigaben, Besitzer der Ursprungsnotiz bekommt Schreibrecht). Den Ast in der Ursprungsnotiz ersetzt der Editor selbst durch `[[Titel]]` (Hashtags bleiben am Link) – als eigene Änderung über die Sync-Engine, damit sie nicht mit ungesendeten Eingaben kollidiert und Strg+Z sie zurücknimmt; dabei legt `/extract/undo` die neue Notiz in den Papierkorb. **Zusammenführen** hängt die Quelle als `h2`-Überschrift mit ihren Punkten als Unterpunkte an die Zielnotiz an (ebenfalls über Operationen, offene Editoren folgen live) und legt die Quelle in den Papierkorb; „Rückgängig“ löscht die Überschrift samt Unterpunkten und stellt die Quelle wieder her. Die Aktion läuft im Namen des Editor-Clients (`clientId`), damit dessen eigene Block-Sperren nicht stören. Bilder und Aufnahmen funktionieren weiter, weil der Zugriff über die Notizen läuft, die sie zeigen.
## Benachrichtigungen
`internal/api/notify.go`, `internal/storage/notifications.go` (Migration 7). Jede Person wählt pro Ereignisart die Kanäle – eigene E-Mail (Alarm-Adresse, sonst Konto-Adresse) eigener Discord-Webhook und eigener Webhook (`customWebhookUrl`, JSON per POST) 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, eigener Webhook als JSON `{"event":"digest","heading","items":[{"kind","title","text"}],"link","timestamp"}`; fällige Aufgaben gehen dort als `{"event":"task_due",…}` einzeln raus), nach 3 Fehlversuchen wird verworfen. Eigene Änderungen benachrichtigen nie den Handelnden, außer bei beobachteten Aufgaben mit `includeOwnWatched`. `paused` stoppt alles, auch Alarme. **Tageszusammenfassung:** zur gewählten Uhrzeit (Server-Zeitzone, innerhalb von 30 Minuten, einmal pro Tag) überfällige, heutige und morgige offene Aufgaben samt Anzahl der folgenden 7 Tage; nichts fällig → keine Nachricht. Im Demo-Modus laufen die Jobs nicht.
## Link-Vorschau ## Link-Vorschau
+8 -5
View File
@@ -8,20 +8,23 @@ SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `j
### Benutzer und Zugang ### Benutzer und Zugang
| Tabelle | Zweck | Wichtige Spalten | | Tabelle | Zweck | Wichtige Spalten |
|---|---|---| |---|---|---|
| `users` | Konten | `email` (eindeutig), `username`, `password_hash`, `display_name`, `role` (`admin`/`member`), `is_active`, `token_version`, `alarm_channels` (kommagetrennt), `alarm_email`, `discord_webhook_url`, `custom_webhook_url`, `notify_before_minutes`, `avatar_url`, `organization_id` (leer = keine Organisation) | | `users` | Konten | `email` (eindeutig), `username`, `password_hash`, `display_name`, `role` (`admin`/`member`), `is_active`, `token_version`, `alarm_channels` (kommagetrennt), `alarm_email`, `discord_webhook_url`, `custom_webhook_url`, `notify_before_minutes`, `avatar_url`, `organization_id` (leer = keine Organisation), `notification_settings` (JSON der Kanäle und Ereignisse), `last_daily_summary` (Datum des letzten Versands) |
| `user_invites` | Einladungscodes | `code`, `email`, `role`, `organization_id`, `expires_at`, `used_by` | | `user_invites` | Einladungscodes | `code`, `email`, `role`, `organization_id`, `expires_at`, `used_by` |
| `media` | Bilder und Audio in Notizen (Migration 5); Datei unter `$DATA_DIR/media/<doc_id>/<id>.<ext>` | `id`, `doc_id`, `owner_id`, `kind` (`image`/`audio`), `mime`, `size`, `source_url` (kopiertes externes Bild), `created_at` | | `media` | Bilder und Audio in Notizen (Migration 5); Datei unter `$DATA_DIR/media/<doc_id>/<id>.<ext>` | `id`, `doc_id`, `owner_id`, `kind` (`image`/`audio`), `mime`, `size`, `source_url` (kopiertes externes Bild), `created_at` |
| `link_previews` | Zwischengespeicherte Link-Vorschauen (Migration 6) | `key` (Hash der URL), `url`, `title`, `description`, `site_name`, `image`/`icon` (BLOB, mit `*_mime`), `ok`, `fetched_at` | | `link_previews` | Zwischengespeicherte Link-Vorschauen (Migration 6) | `key` (Hash der URL), `url`, `title`, `description`, `site_name`, `image`/`icon` (BLOB, mit `*_mime`), `ok`, `fetched_at` |
| `notification_queue` | Wartende Benachrichtigungen bis zum gebündelten Versand (Migration 7) | `user_id`, `kind` (`shared`/`taskDone`/`watched`), `payload` (JSON), `created_at`, `attempts` |
| `task_watchers` | Beobachtete Aufgaben (Migration 7) | `task_id`, `user_id`, `created_at` |
| `journal_days` | Journal: welche Notiz zu welchem Tag gehört (Migration 8) | `user_id`, `day` (`JJJJ-MM-TT`), `doc_id`; Primärschlüssel `(user_id, day)` |
| `sessions` | Angemeldete Geräte (Migration 4, ersetzt `revoked_tokens`) | `id` (= `jti` des Tokens), `user_id`, `created_at`, `last_used_at` (höchstens alle 5 Minuten aktualisiert), `user_agent`, `ip`, `revoked_at` (leer = aktiv) | | `sessions` | Angemeldete Geräte (Migration 4, ersetzt `revoked_tokens`) | `id` (= `jti` des Tokens), `user_id`, `created_at`, `last_used_at` (höchstens alle 5 Minuten aktualisiert), `user_agent`, `ip`, `revoked_at` (leer = aktiv) |
| `user_themes` | Eigene CSS-Themes eines Benutzers | `user_id` (FK, `ON DELETE CASCADE`), `name`, `css_content` | | `user_themes` | Eigene CSS-Themes eines Benutzers | `user_id` (FK, `ON DELETE CASCADE`), `name`, `css_content` |
| `api_keys` | Webhook-/API-Schlüssel | `key_prefix`, `key_hash` (SHA-256), `scopes` (nicht ausgewertet), `last_used_at` | | `api_keys` | Webhook-/API-Schlüssel | `name`, `key_prefix`, `key_hash` (SHA-256), `scopes` (`webhook:triage`, `webhook:task`, `webhook:list`), `last_used_at` |
| `system_settings` | Schlüssel/Wert für Instanz-Einstellungen | `sharing_mode`, `allow_registration`, `instance_name`, `smtp_*` | | `system_settings` | Schlüssel/Wert für Instanz-Einstellungen | `sharing_mode`, `allow_registration`, `instance_name`, `smtp_*` |
### Gedanken und Aufgaben ### Gedanken und Aufgaben
| Tabelle | Zweck | Wichtige Spalten | | Tabelle | Zweck | Wichtige Spalten |
|---|---|---| |---|---|---|
| `password_reset_tokens` | Links aus „Passwort vergessen?“ | `token_hash` (SHA-256), `user_id`, `created_at`, `expires_at` (1 h), `used_at` | | `password_reset_tokens` | Links aus „Passwort vergessen?“ | `token_hash` (SHA-256), `user_id`, `created_at`, `expires_at` (1 h), `used_at` |
| `thoughts` | Gedanken **und** Aufgaben in einer Tabelle | `status` (`inbox`/`kept`/`converted`/`discarded`), `converted_type` (`task`/`document`/`list_item`/`document_block`), `converted_ref_id`, `priority`, `is_completed`, `due_date` (`YYYY-MM-DD` ganztägig oder `YYYY-MM-DDTHH:MM`, Zeitzone des Servers), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `alarm_failures` (erfolglose Alarm-Durchläufe, ab 3 aufgegeben), `sort_order`, `source` (`manual`, `voice`, `webhook` …) | | `thoughts` | Gedanken **und** Aufgaben in einer Tabelle | `status` (`inbox`/`kept`/`converted`/`discarded`), `converted_type` (`task`/`document`/`list_item`/`document_block`), `converted_ref_id`, `priority`, `is_completed`, `due_date` (`YYYY-MM-DD` ganztägig oder `YYYY-MM-DDTHH:MM`, Zeitzone des Servers), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `alarm_failures` (erfolglose Alarm-Durchläufe, ab 3 aufgegeben), `recurrence` (`daily`/`weekly`/`monthly`/`yearly`/leer), `recurrence_spawned`, `sort_order`, `source` (`manual`, `voice`, `webhook` …) |
| `task_assignees` | Zuweisungen von Aufgaben | `task_id` (FK `thoughts`, `ON DELETE CASCADE`), `user_id`, `created_at`; nur Personen mit Zugriff auf die Liste, bereinigt durch `PruneTaskAssignees` | | `task_assignees` | Zuweisungen von Aufgaben | `task_id` (FK `thoughts`, `ON DELETE CASCADE`), `user_id`, `created_at`; nur Personen mit Zugriff auf die Liste, bereinigt durch `PruneTaskAssignees` |
| `task_lists` | Aufgabenlisten | `user_id` (Eigentümer), `title`, `icon`, `color`, `columns` (JSON, Kanban-Spalten) | | `task_lists` | Aufgabenlisten | `user_id` (Eigentümer), `title`, `icon`, `color`, `columns` (JSON, Kanban-Spalten) |
| `task_list_shares` | Freigaben von Aufgabenlisten | `target_type` (`team`/`user`), `target_id`, **`permission`** (`editor`/`viewer`, Standard `editor`) | | `task_list_shares` | Freigaben von Aufgabenlisten | `target_type` (`team`/`user`), `target_id`, **`permission`** (`editor`/`viewer`, Standard `editor`) |
@@ -78,11 +81,11 @@ teams 1─n team_members n─1 users
## Migrationen und Aufräumarbeiten beim Start ## Migrationen und Aufräumarbeiten beim Start
- Spalten/Tabellen/Indizes werden nachgezogen (`ALTER TABLE … ADD COLUMN`, Fehler werden ignoriert). - Spalten/Tabellen/Indizes werden nachgezogen (`ALTER TABLE … ADD COLUMN`, Fehler werden ignoriert).
- Veraltete Tabellen werden gelöscht: `spaces`, `space_members`, `space_team_shares`, `document_crdt_state`, `document_crdt_updates`, `document_blocks`, `doc_block_tags`. - Veraltete Tabellen werden gelöscht: `spaces`, `space_members`, `space_team_shares`, `document_crdt_state`, `document_crdt_updates`, `document_blocks`, `doc_block_tags`, `revoked_tokens`.
- `users.username` wird aus dem E-Mail-Präfix aufgefüllt. - `users.username` wird aus dem E-Mail-Präfix aufgefüllt.
- Notiz-Synchronisation (`migrateDocSync`): neue Spalten an `blocks` (inkl. `deleted_at`; vorhandene Grabsteine erhalten den Migrationszeitpunkt) und `documents` (`head_seq`, `title_version`, `log_floor`), Tabellen `doc_ops` und `doc_op_results`. Blöcke im alten flachen Format (nur `level`/`position`) werden einmalig in den Baum umgewandelt. - Notiz-Synchronisation (`migrateDocSync`): neue Spalten an `blocks` (inkl. `deleted_at`; vorhandene Grabsteine erhalten den Migrationszeitpunkt) und `documents` (`head_seq`, `title_version`, `log_floor`), Tabellen `doc_ops` und `doc_op_results`. Blöcke im alten flachen Format (nur `level`/`position`) werden einmalig in den Baum umgewandelt.
- Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=organizations`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer. - Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=organizations`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer.
- **Migrationen:** [migrations.go](../internal/storage/migrations.go) führt nummerierte Schritte genau einmal aus und vermerkt sie in `schema_migrations` (`id`, `name`, `applied_at`); `PRAGMA user_version` zeigt die höchste angewandte Nummer. Schritt 3 („Basis-Schema v0.4.1“, `migrateBaseline`) baut das komplette Schema auf und bringt ältere Datenbanken auf diesen Stand; Spalten werden nur angelegt, wenn sie fehlen. Schlägt ein Schritt fehl, bricht der Start mit der Fehlermeldung ab, statt mit halbem Schema weiterzulaufen. Neue Änderungen kommen als neuer Schritt ans Ende, alte Schritte werden nie verändert. Daten werden beim Start nie gelöscht; nur der Demo-Modus leert die Datenbank. - **Migrationen:** [migrations.go](../internal/storage/migrations.go) führt nummerierte Schritte (Migrationen 3 bis 8: 3 „Basis-Schema v0.4.1“, 4 „Sitzungen“, 5 „Medien“, 6 „Link-Vorschauen“, 7 „Benachrichtigungen“, 8 „Journal“) genau einmal aus und vermerkt sie in `schema_migrations` (`id`, `name`, `applied_at`); `PRAGMA user_version` zeigt die höchste angewandte Nummer. Schritt 3 (`migrateBaseline`) baut das Basisschema auf und bringt ältere Datenbanken auf diesen Stand; Spalten werden nur angelegt, wenn sie fehlen. Schlägt ein Schritt fehl, bricht der Start mit der Fehlermeldung ab, statt mit halbem Schema weiterzulaufen. Neue Änderungen kommen als neuer Schritt ans Ende, alte Schritte werden nie verändert. Daten werden beim Start nie gelöscht; nur der Demo-Modus leert die Datenbank.
## Sicherung ## Sicherung
+1 -1
View File
@@ -131,7 +131,7 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel**
### Notizen ### Notizen
- **Versionsverlauf ansehen und zurückholen:** das Operations-Log (`doc_ops`) ist vorhanden, eine Ansicht fehlt. - **Versionsverlauf ansehen und zurückholen:** das Operations-Log (`doc_ops`) ist vorhanden, eine Ansicht fehlt.
- **Bilder und Anhänge** in Notizen. - ~~**Bilder und Anhänge** in Notizen.~~ (Umgesetzt in v0.7.0 mit `/image`, `/audio` und Medienablage).
- **Vorlagen und Tagesnotiz** (z. B. „Wochenrückblick“, „Heute“). - **Vorlagen und Tagesnotiz** (z. B. „Wochenrückblick“, „Heute“).
- **Öffentlicher Lese-Link** für einzelne Notizen, mit Ablaufdatum. - **Öffentlicher Lese-Link** für einzelne Notizen, mit Ablaufdatum.
- **Verlinkungs-Graph** auf Basis der Backlinks. - **Verlinkungs-Graph** auf Basis der Backlinks.
+2 -2
View File
@@ -25,7 +25,7 @@ Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus
- Gespeichert in `task_assignees`; `PATCH /thoughts/{id}` mit `assignees` ersetzt die Liste. Der Server lehnt Personen ohne Zugriff ab (`403`, `storage.ErrAssigneeNoAccess`). - Gespeichert in `task_assignees`; `PATCH /thoughts/{id}` mit `assignees` ersetzt die Liste. Der Server lehnt Personen ohne Zugriff ab (`403`, `storage.ErrAssigneeNoAccess`).
- **Zuweisungen folgen den Rechten:** `Store.PruneTaskAssignees` entfernt jede Zuweisung, deren Person die Liste nicht mehr sehen kann. Es läuft nach Entfernen einer Freigabe, Austritt aus oder Entfernen aus einer Gruppe, Löschen einer Gruppe, Löschen der Liste und Verschieben der Aufgabe in eine andere oder keine Liste; beim Löschen eines Kontos werden dessen Zuweisungen direkt gelöscht. Wer über einen zweiten Weg (z. B. direkt und über eine Gruppe) weiter Zugriff hat, bleibt zugewiesen. - **Zuweisungen folgen den Rechten:** `Store.PruneTaskAssignees` entfernt jede Zuweisung, deren Person die Liste nicht mehr sehen kann. Es läuft nach Entfernen einer Freigabe, Austritt aus oder Entfernen aus einer Gruppe, Löschen einer Gruppe, Löschen der Liste und Verschieben der Aufgabe in eine andere oder keine Liste; beim Löschen eines Kontos werden dessen Zuweisungen direkt gelöscht. Wer über einen zweiten Weg (z. B. direkt und über eine Gruppe) weiter Zugriff hat, bleibt zugewiesen.
- Wer sehen und zuweisen kann, hängt außerdem von der Sichtbarkeit ab: Verliert jemand sie (z. B. Wechsel der Organisation), fallen Freigabe und Zuweisungen weg ([sharing-and-permissions.md](../sharing-and-permissions.md)). - Wer sehen und zuweisen kann, hängt außerdem von der Sichtbarkeit ab: Verliert jemand sie (z. B. Wechsel der Organisation), fallen Freigabe und Zuweisungen weg ([sharing-and-permissions.md](../sharing-and-permissions.md)).
- Bekannte Lücke: (Umsortieren per ID). - **Aufgaben beobachten (`task_watchers`):** Im Detail-Fenster lässt sich eine Aufgabe über „Beobachten“ abonnieren (`POST /tasks/{id}/watch`, `DELETE /tasks/{id}/watch`). Jede Änderung an einer beobachteten Aufgabe wird dem Beobachter über seine konfigurierten Benachrichtigungskanäle gemeldet.
## Wiederkehrende Aufgaben ## Wiederkehrende Aufgaben
@@ -46,7 +46,7 @@ Der Ticker läuft jede Minute ([architecture.md](../architecture.md)). Für offe
| Discord | Webhook-URL | Benutzereinstellungen | | Discord | Webhook-URL | Benutzereinstellungen |
| Webhook | beliebige URL (JSON: `event`, `title`, `notes`, `dueDate`, `priority`, `timestamp`) | Benutzereinstellungen | | Webhook | beliebige URL (JSON: `event`, `title`, `notes`, `dueDate`, `priority`, `timestamp`) | Benutzereinstellungen |
Danach wird `alarm_sent = 1` gesetzt, sobald mindestens ein Kanal erfolgreich war. Danach wird `alarm_sent = 1` gesetzt, sobald mindestens ein Kanal erfolgreich war. Neben Fälligkeits-Alarmen (die sofort versendet werden) unterstützt MindSift granulare Benachrichtigungen für Freigaben, erledigte geteilte Aufgaben und beobachtete Aufgaben, die über `notification_queue` gebündelt nach `NOTIFY_DIGEST_MINUTES` versendet werden ([architecture.md](../architecture.md#benachrichtigungen)).
**E-Mail-Versand** ([mail.go](../../internal/api/mail.go)) führt den SMTP-Dialog Schritt für Schritt: **E-Mail-Versand** ([mail.go](../../internal/api/mail.go)) führt den SMTP-Dialog Schritt für Schritt:
+1 -1
View File
@@ -6,7 +6,7 @@ Ziel: Gedanken in Sekunden festhalten und später in Ruhe sortieren. Code: [Inst
- Die Eingabeleiste sitzt im Header; `C` oder `/` fokussiert sie (außer beim Tippen in einem Feld). - Die Eingabeleiste sitzt im Header; `C` oder `/` fokussiert sie (außer beim Tippen in einem Feld).
- Ziel (`target`): **Posteingang** (`inbox`, Standard), **Direkt-Aufgabe** (`task`), **Listeneintrag** (`list_item`, mit Zielliste) oder **Direkt-Notiz** (`document`). - Ziel (`target`): **Posteingang** (`inbox`, Standard), **Direkt-Aufgabe** (`task`), **Listeneintrag** (`list_item`, mit Zielliste) oder **Direkt-Notiz** (`document`).
- Mehrzeilige Eingabe wird unterstützt. Spracheingabe nutzt die Web Speech API des Browsers; Quelle `voice` fügt den Tag `#diktat` an. - Mehrzeilige Eingabe wird unterstützt. Spracheingabe nutzt `useVoiceInput` (Whisper-Server oder Spracherkennung des Browsers); Quelle `voice` fügt den Tag `#diktat` an.
- Quelle `webhook` (per API-Key/Webhook) fügt den Tag `#webhook` an. Hashtags im Text werden als Tags extrahiert (`domain.ExtractTags`). - Quelle `webhook` (per API-Key/Webhook) fügt den Tag `#webhook` an. Hashtags im Text werden als Tags extrahiert (`domain.ExtractTags`).
- Per API: `POST /inbox` und `POST /webhooks/ingest` (siehe [rest.md](../api/rest.md)). - Per API: `POST /inbox` und `POST /webhooks/ingest` (siehe [rest.md](../api/rest.md)).
+7 -4
View File
@@ -1,6 +1,6 @@
# Modul: Workspace (Outliner-Notizen) # Modul: Workspace (Outliner-Notizen)
Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.tsx) (Explorer, Tags, To-Do-Rollup), [DocumentEditor.tsx](../../frontend/src/features/workspace/DocumentEditor.tsx), [OutlinerTree.tsx](../../frontend/src/features/workspace/OutlinerTree.tsx), [OutlinerBlock.tsx](../../frontend/src/features/workspace/OutlinerBlock.tsx), [CodeBlockItem.tsx](../../frontend/src/features/workspace/CodeBlockItem.tsx), [LinkModal.tsx](../../frontend/src/features/workspace/LinkModal.tsx); Backend: Dokument-, Ordner- und Tag-Handler, Sync-Handler in `internal/api/docsync.go`, `internal/collab`, `internal/storage/docsync.go`. Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.tsx) (Explorer, Tags, To-Do-Rollup), [DocumentEditor.tsx](../../frontend/src/features/workspace/DocumentEditor.tsx), [OutlinerTree.tsx](../../frontend/src/features/workspace/OutlinerTree.tsx), [OutlinerBlock.tsx](../../frontend/src/features/workspace/OutlinerBlock.tsx), [CodeBlockEditor.tsx](../../frontend/src/features/workspace/CodeBlockEditor.tsx), [LinkModal.tsx](../../frontend/src/features/workspace/LinkModal.tsx); Backend: Dokument-, Ordner- und Tag-Handler, Sync-Handler in `internal/api/docsync.go`, `internal/collab`, `internal/storage/docsync.go`.
## Datenhaltung ## Datenhaltung
@@ -15,7 +15,10 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
- **Outliner:** `Enter` neuer Block, `Tab`/`Shift+Tab` ein-/ausrücken, `Shift+Enter` weicher Umbruch, Blöcke ein-/ausklappen. - **Outliner:** `Enter` neuer Block, `Tab`/`Shift+Tab` ein-/ausrücken, `Shift+Enter` weicher Umbruch, Blöcke ein-/ausklappen.
- **Zoom (Hoisting):** Klick auf den Bullet fokussiert den Ast; die Breadcrumb-Leiste führt zurück. - **Zoom (Hoisting):** Klick auf den Bullet fokussiert den Ast; die Breadcrumb-Leiste führt zurück.
- **Drag & Drop** verschiebt Blöcke samt Unterästen; auf einen anderen Block gezogen, wird der Block zum Unterpunkt. - **Drag & Drop** verschiebt Blöcke samt Unterästen; auf einen anderen Block gezogen, wird der Block zum Unterpunkt.
- **Slash-Befehle:** `/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code`, `/callout`, `/divider` (Liste in `OutlinerBlock.tsx`). - **Slash-Befehle:** `/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code` (mit Syntax-Highlighting im VS-Code-Stil), `/callout`, `/divider`, `/extract` (Ast als eigene Notiz auslagern), `/image` (Bilder einfügen/hochladen, Aliase `/bild`, `/foto`), `/audio` (Sprachnotiz/Audiodatei, Alias `/sprachnotiz`), `/diktat` (Spracheingabe mit Live-Mitschreiben, Alias `/dictate`) (Liste in `OutlinerBlock.tsx`). Das Menü öffnet sich nur am Textanfang oder nach einem Leerzeichen und schließt mit `Esc` oder `Backspace`.
- **Link-Vorschau:** Besteht ein Bulletpoint ausschließlich aus einer einzelnen `https`-Adresse (oder wurde über `/link` in einem leeren Punkt eingefügt), zeigt MindSift darüber eine interaktive Vorschaukarte (`LinkPreviewCard`, serverseitig via `/link-preview`). ✕ blendet die Karte aus und wandelt die Adresse synchronisiert in `[Titel](url)` um.
- **Ast auslagern (`/extract`):** Wandelt den aktuellen Punkt samt Unterpunkten in eine eigenständige Notiz im selben Ordner um. Der Server legt die Notiz an und liefert den Link zurück; der Editor ersetzt den Ast lokal über die Sync-Engine als eigene Operation (Strg+Z nimmt die Auslagerung zurück und legt die neue Notiz via `POST /documents/{id}/extract/undo` in den Papierkorb).
- **Notizen zusammenführen (Merge):** Per Drag & Drop einer Notiz auf eine andere in der Seitenleiste oder über das Drei-Punkte-Menü im Notizkopf („Mit anderer Notiz zusammenführen…“). Die eigene Quellnotiz wird als `h2`-Abschnitt angehängt und in den Papierkorb verschoben; der Hinweis bietet vollständiges Rückgängigmachen (`POST /documents/{id}/merge/undo`).
- **Rückverweise:** Unter jeder Notiz listet „Verlinkt auf diese Notiz (n)“ alle Notizen mit `[[Titel]]` (auch `[[Titel|Text]]`, `[[Titel#Abschnitt]]`, Groß-/Kleinschreibung egal) samt Fundstelle; ein Klick springt direkt zum Bullet. Ohne Rückverweise bleibt der Bereich unsichtbar, eingeklappt merkt er sich im Browser ([BacklinksSection.tsx](../../frontend/src/features/workspace/BacklinksSection.tsx), `GET /documents/{id}/backlinks`). - **Rückverweise:** Unter jeder Notiz listet „Verlinkt auf diese Notiz (n)“ alle Notizen mit `[[Titel]]` (auch `[[Titel|Text]]`, `[[Titel#Abschnitt]]`, Groß-/Kleinschreibung egal) samt Fundstelle; ein Klick springt direkt zum Bullet. Ohne Rückverweise bleibt der Bereich unsichtbar, eingeklappt merkt er sich im Browser ([BacklinksSection.tsx](../../frontend/src/features/workspace/BacklinksSection.tsx), `GET /documents/{id}/backlinks`).
- **Links:** `[[Notiztitel]]`, Markdown-Links `[Titel](url)` und reine URLs werden als Token dargestellt; `LinkModal` hilft beim Verknüpfen von Notizen. - **Links:** `[[Notiztitel]]`, Markdown-Links `[Titel](url)` und reine URLs werden als Token dargestellt; `LinkModal` hilft beim Verknüpfen von Notizen.
- **Einfügen:** Mehrzeiliger Text wird anhand von Markdown-Präfixen und Einrückung in Blöcke zerlegt; Überschriften gruppieren darunterliegenden Text und Code. - **Einfügen:** Mehrzeiliger Text wird anhand von Markdown-Präfixen und Einrückung in Blöcke zerlegt; Überschriften gruppieren darunterliegenden Text und Code.
@@ -51,13 +54,13 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
## Freigabe ## Freigabe
- Einzelne Notizen und ganze **geteilte Ordner** lassen sich an Benutzer oder Gruppen geben. Empfänger haben **nur Leserechte**; nur der Eigentümer schreibt ([sharing-and-permissions.md](../sharing-and-permissions.md)). - Einzelne Notizen und ganze **geteilte Ordner** lassen sich an Benutzer oder Gruppen freigeben – wahlweise mit der Rolle **Bearbeiter** (`write`) oder **Leser** (`read`) ([sharing-and-permissions.md](../sharing-and-permissions.md)). Bearbeiter ändern Inhalt und Struktur über die Sync-Engine; Leser sehen Änderungen live.
- Empfänger ordnen geteilte Notizen und Ordner in ihrem eigenen Baum ein (`doc_user_folders`, `workspace_folder_mounts`), ohne dass der Eigentümer etwas davon merkt. - Empfänger ordnen geteilte Notizen und Ordner in ihrem eigenen Baum ein (`doc_user_folders`, `workspace_folder_mounts`), ohne dass der Eigentümer etwas davon merkt.
- Änderungen erreichen offene Fenster über den Weckruf `DOC_SEQ` und anschließenden Sync; Notizlisten über `DOC_UPDATED` ([websocket.md](../api/websocket.md)). - Änderungen erreichen offene Fenster über den Weckruf `DOC_SEQ` und anschließenden Sync; Notizlisten über `DOC_UPDATED` ([websocket.md](../api/websocket.md)).
## Grenzen ## Grenzen
- Keine gleichzeitige Mehrbenutzerbearbeitung, keine Cursor-Anzeige, keine Versionshistorie. - Keine gleichzeitige Bearbeitung im selben einzelnen Bullet (Sperren pro Bullet und Konfliktkopie statt Zeichen-für-Zeichen-Zusammenführung), keine Cursor-Anzeige fremder Eingaben, keine Versionshistorie.
- Keine Virtualisierung langer Dokumente; sehr große Notizen können auf Mobilgeräten träge werden *(nicht gemessen)*. - Keine Virtualisierung langer Dokumente; sehr große Notizen können auf Mobilgeräten träge werden *(nicht gemessen)*.
- ~~Suche (`/search`) erfasst nur Titel und Inhalt eigener Notizen~~: Mit der globalen FTS5-Suche (`Cmd+K` / `Ctrl+K` / `/`) werden sowohl Titel als auch Block-Inhalte aller zugänglichen Notizen (eigene und freigegebene) durchsucht. - ~~Suche (`/search`) erfasst nur Titel und Inhalt eigener Notizen~~: Mit der globalen FTS5-Suche (`Cmd+K` / `Ctrl+K` / `/`) werden sowohl Titel als auch Block-Inhalte aller zugänglichen Notizen (eigene und freigegebene) durchsucht.
+1 -1
View File
@@ -70,4 +70,4 @@ Bei der Einrichtung im Modus `organizations` wird eine Organisation mit dem Name
- Jede Anmeldung ist eine Sitzung (`sessions`), die einzeln beendet werden kann (Logout, Profil → Aktive Sitzungen). Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ beenden alle Sitzungen (`token_version`); eine Passwortänderung alle außer der aktuellen. - Jede Anmeldung ist eine Sitzung (`sessions`), die einzeln beendet werden kann (Logout, Profil → Aktive Sitzungen). Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ beenden alle Sitzungen (`token_version`); eine Passwortänderung alle außer der aktuellen.
- Registrierung nur per Einladungscode oder, wenn vom Admin freigeschaltet, offen. Das erste Konto wird immer Administrator. - Registrierung nur per Einladungscode oder, wenn vom Admin freigeschaltet, offen. Das erste Konto wird immer Administrator.
- Bekannte Lücken in der Zugriffskontrolle:. - Bekannte Lücken in der Zugriffskontrolle: siehe [flaws.md](flaws.md).
+2 -2
View File
@@ -73,9 +73,9 @@ Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Kla
Komponente: [HoldToRecordFab.tsx](../frontend/src/features/capture/HoldToRecordFab.tsx) Komponente: [HoldToRecordFab.tsx](../frontend/src/features/capture/HoldToRecordFab.tsx)
- **Kurz tippen (< 350 ms):** Führt wie bisher die primäre Aktion der aktuellen Ansicht (`primaryAction`) oder den mobilen Erfassungsdialog (`openMobileTriageCapture`) aus. - **Kurz tippen (< 350 ms):** Führt wie bisher die primäre Aktion der aktuellen Ansicht (`primaryAction`) oder den mobilen Erfassungsdialog (`openMobileTriageCapture`) aus.
- **Gedrückt halten (≥ 350 ms):** Startet direkt eine Sprachaufnahme über die Web Speech API (`de-DE`, `continuous`, `interimResults`). Der Button wird zum pulsierenden Mikrofon (`bg-status-err`), darüber zeigt eine schwebende Pille „Ich höre zu… (loslassen speichert)“ samt Live-Transkript. - **Gedrückt halten (≥ 350 ms):** Startet direkt eine Sprachaufnahme über `useVoiceInput` (im Server-Modus per Whisper mit echten Pegel-Wellen, sonst über die Spracherkennung des Browsers). Der Button wird zum pulsierenden Mikrofon (`bg-status-err`), darüber zeigt eine schwebende Pille „Ich höre zu… (loslassen speichert)“ samt Live-Transkript.
- **Loslassen:** Stoppt die Aufnahme, wartet kurz auf das letzte Wort und speichert den Text unabhängig von der aktiven Ansicht immer als Gedanken in die Inbox (`source: 'voice'`, `target: 'inbox'`). - **Loslassen:** Stoppt die Aufnahme, wartet kurz auf das letzte Wort und speichert den Text unabhängig von der aktiven Ansicht immer als Gedanken in die Inbox (`source: 'voice'`, `target: 'inbox'`).
- **Nach oben ziehen (> 60 px):** Schaltet die Pille in den Abbruch-Zustand („🗑️ Zum Abbrechen loslassen“); Loslassen dort verwirft die Aufnahme (`abort()`). - **Nach oben ziehen (> 60 px):** Schaltet die Pille in den Abbruch-Zustand („🗑️ Zum Abbrechen loslassen“); Loslassen dort verwirft die Aufnahme (`abort()`).
- **Fallback:** Unterstützt der Browser keine Spracherkennung oder ist das Mikrofon gesperrt (`not-allowed`), öffnet Halten wie beim Tippen den Dialog und blendet eine kurze Statusmeldung über der Bottom-Leiste ein. - **Fallback:** Ist die Spracheingabe abgeschaltet (`STT_DISABLED=true`), unterstützt der Browser keine Spracherkennung oder ist das Mikrofon gesperrt (`not-allowed`), öffnet Halten wie beim Tippen den Dialog und blendet eine kurze Statusmeldung über der Bottom-Leiste ein.
+17 -6
View File
@@ -6,7 +6,7 @@
[![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#)
[![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart)
[![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir)
[![Version](https://img.shields.io/badge/Version-v0.4-16a34a?style=flat-square)](#-schnellstart) [![Version](https://img.shields.io/badge/Version-v0.8.0-16a34a?style=flat-square)](#-schnellstart)
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server. **MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
@@ -34,7 +34,7 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
### ⚡ Schnellerfassung & Triage ### ⚡ Schnellerfassung & Triage
* Eingabeleiste im Kopf der App, jederzeit mit `C` oder `/` fokussierbar; auch per Spracheingabe: über den Browser oder datenschutzfreundlich über einen eigenen Whisper-Container (optional in `docker-compose.yml`, siehe [Architektur](Doku/architecture.md#spracherkennung)). * Eingabeleiste im Kopf der App, jederzeit mit `C` oder `/` fokussierbar; auch per Spracheingabe: über den Browser oder datenschutzfreundlich über einen eigenen Whisper-Container (optional in `docker-compose.yml`, siehe [Architektur](Doku/architecture.md#spracherkennung)); auf Smartphones auch per Gedrückthalten des `+`-Knopfs (Hold-to-Record mit Pegel-Wellen).
* Ziel beim Erfassen wählbar: Posteingang, Aufgabe, Listeneintrag oder Notiz. * Ziel beim Erfassen wählbar: Posteingang, Aufgabe, Listeneintrag oder Notiz.
* **Triage-Deck:** `H`/`←` verwerfen, `L`/`→` zur Aufgabe, `T` Aufgabe mit Termin, `D` in Notiz verwandeln, Leertaste = später. Auf dem Smartphone per Wischgeste. * **Triage-Deck:** `H`/`←` verwerfen, `L`/`→` zur Aufgabe, `T` Aufgabe mit Termin, `D` in Notiz verwandeln, Leertaste = später. Auf dem Smartphone per Wischgeste.
* Papierkorb mit Wiederherstellen. * Papierkorb mit Wiederherstellen.
@@ -49,7 +49,10 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
* Prioritäten, Fälligkeitsdatum, Hashtags, Unteraufgaben mit Fortschrittsbalken, Markdown-Notizen. * Prioritäten, Fälligkeitsdatum, Hashtags, Unteraufgaben mit Fortschrittsbalken, Markdown-Notizen.
* Filter, Gruppierung und Sortierung; Drag & Drop in Liste und Board. * Filter, Gruppierung und Sortierung; Drag & Drop in Liste und Board.
* **Aufgabenlisten teilen** mit Benutzern oder Gruppen, wahlweise als **Bearbeiter** oder **nur lesend**. * **Aufgabenlisten teilen** mit Benutzern oder Gruppen, wahlweise als **Bearbeiter** oder **nur lesend**.
* **Alarme** bei Fälligkeit per E-Mail, Discord oder eigenem Webhook. * **Aufgaben beobachten:** Beliebige Aufgaben per Klick abonnieren, um über alle Änderungen informiert zu werden.
* **Wiederholungen:** Wiederkehrende Aufgaben (täglich, wöchentlich, monatlich, jährlich) mit automatischer Folgeaufgabe beim Abhaken.
* **Tageszusammenfassung:** Tägliche Übersicht fälliger Aufgaben zur selbst gewählten Uhrzeit per E-Mail/Discord.
* **Alarme & Benachrichtigungen:** Fälligkeits-Alarme sofort per E-Mail, Discord oder eigenem Webhook; granulare Benachrichtigungen für Freigaben, erledigte geteilte Aufgaben und beobachtete Aufgaben sowie tägliche Zusammenfassungen zur gewählten Uhrzeit.
<p align="center"> <p align="center">
<img src="screenshots/aufgaben_desktop.png" alt="MindSift Aufgaben und Kanban-Board" width="90%" /> <img src="screenshots/aufgaben_desktop.png" alt="MindSift Aufgaben und Kanban-Board" width="90%" />
@@ -59,7 +62,11 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
* Jeder Gedanke ein Bullet: `Enter`, `Tab`/`Shift+Tab`, Ein-/Ausklappen, Drag & Drop ganzer Äste. * Jeder Gedanke ein Bullet: `Enter`, `Tab`/`Shift+Tab`, Ein-/Ausklappen, Drag & Drop ganzer Äste.
* **Zoom-Fokus** per Klick auf den Bullet, Breadcrumbs zum Zurücknavigieren. * **Zoom-Fokus** per Klick auf den Bullet, Breadcrumbs zum Zurücknavigieren.
* Blocktypen und Slash-Befehle: `/todo`, `/link`, `/h1`–`/h3`, `/code` (mit Syntax-Hervorhebung), `/callout`, `/divider`. * **Journal:** eine Notiz pro Tag, alle Tage untereinander (heute oben, ältere laden beim Scrollen nach), mit Kalender zum Springen; einzelne Tage lassen sich teilen. Pro Gerät abschaltbar.
* Blocktypen und Slash-Befehle: `/todo`, `/number` (nummerierte Liste: 1., a., i.), `/link`, `/h1`–`/h3`, `/code` (mit Syntax-Hervorhebung), `/callout`, `/divider`, `/extract`, `/image`, `/audio`, `/diktat`.
* **Bilder & Audio in Notizen:** `/image` (Datei, Kamera, Zwischenablage oder Link), `/audio` (Sprachnotiz oder Datei-Upload) und `/diktat` (Live-Transkription).
* **Link-Vorschau:** Automatische Vorschaukarten über Punkten mit reiner Web-Adresse.
* **Ast auslagern & Notizen zusammenführen:** Bulletpoints samt Unterbaum in eigenständige Notizen auslagern (`/extract`) oder Notizen per Drag & Drop bzw. Menü zusammenführen (jeweils mit vollständiger Rückgängig-Funktion).
* Wiki-Links `[[Notiz]]`, Links und `#Hashtags`; Tag-Seiten bündeln alle Fundstellen. * Wiki-Links `[[Notiz]]`, Links und `#Hashtags`; Tag-Seiten bündeln alle Fundstellen.
* Ordner, Favoriten, **To-Do-Rollup** über alle Notizen und Export einer Notiz als `.md`. * Ordner, Favoriten, **To-Do-Rollup** über alle Notizen und Export einer Notiz als `.md`.
* Einfügen von mehrzeiligem Markdown wird in Blöcke zerlegt; `Strg+Z` / `Strg+Umschalt+Z` nimmt eigene Änderungen zurück bzw. stellt sie wieder her. * Einfügen von mehrzeiligem Markdown wird in Blöcke zerlegt; `Strg+Z` / `Strg+Umschalt+Z` nimmt eigene Änderungen zurück bzw. stellt sie wieder her.
@@ -92,7 +99,7 @@ Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md)
* Geführte Ersteinrichtung, Rollen *Admin* und *Mitglied*, Einladungscodes, optional offene Registrierung. * Geführte Ersteinrichtung, Rollen *Admin* und *Mitglied*, Einladungscodes, optional offene Registrierung.
* Gruppen mit Gruppen-Admins für Freigaben. * Gruppen mit Gruppen-Admins für Freigaben.
* Admin-Bereich: Benutzer, Einladungen, Instanzname, SMTP, Freigabe-Modus. * Admin-Bereich: Benutzer, Einladungen, Instanzname, SMTP, Freigabe-Modus.
* Vier Themes (Dark, Light, High-Contrast, Soft), installierbar als PWA. Die App startet auch ohne Netz mit dem zuletzt geladenen Stand; offline erfasste Gedanken und abgehakte Aufgaben oder Listeneinträge werden nachgereicht, sobald die Verbindung wieder da ist. * Sechs Themes (Dark, Light, High-Contrast, Soft, Polarized, Suri) plus Theme-Manager für eigene CSS-Themes, installierbar als PWA. Die App startet auch ohne Netz mit dem zuletzt geladenen Stand; offline erfasste Gedanken und abgehakte Aufgaben oder Listeneinträge werden nachgereicht, sobald die Verbindung wieder da ist.
### 🔌 Integrationen ### 🔌 Integrationen
@@ -130,7 +137,7 @@ Minimal:
```yaml ```yaml
services: services:
mindsift: mindsift:
image: git.dsit.me/sandrodach/mindsift:stable # oder feste Version (:v0.4); :latest = main, :dev = Entwicklungsstand image: git.dsit.me/sandrodach/mindsift:stable # oder feste Version (:v0.8.0); :latest = main, :dev = Entwicklungsstand
restart: unless-stopped restart: unless-stopped
ports: ["8080:8080"] ports: ["8080:8080"]
volumes: ["./data:/data"] volumes: ["./data:/data"]
@@ -161,7 +168,11 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d
| `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) | | `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) |
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging | | `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging |
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer | E-Mail-Alarme (auch im Admin-UI) | | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer | E-Mail-Alarme (auch im Admin-UI) |
| `SMTP_FROM_NAME` | `MindSift` | Anzeigename des Absenders bei E-Mails |
| `SMTP_SKIP_VERIFY` | `false` | Zertifikatsprüfung für interne Mailserver abschalten | | `SMTP_SKIP_VERIFY` | `false` | Zertifikatsprüfung für interne Mailserver abschalten |
| `NOTIFY_DIGEST_MINUTES` | `5` | Bündelungsintervall für Benachrichtigungen in Minuten |
| `MEDIA_*` | diverse | Grenzen für Bilder und Audio in Notizen (`MEDIA_MAX_IMAGE_MB=15`, `MEDIA_MAX_AUDIO_MB=50`, `MEDIA_QUOTA_MB=0`) |
| `STT_*` | diverse | Optionale eigene Spracherkennung per Whisper (`STT_URL`, `STT_MODEL`, `STT_LANGUAGE` …; siehe [Doku/architecture.md](Doku/architecture.md#spracherkennung)) |
| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) | | `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) |
| `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` (oder `…_CONTENT_FILE` mit Dateipfad) | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster. Ohne URL und Text bleibt der Link unsichtbar. | | `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` (oder `…_CONTENT_FILE` mit Dateipfad) | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster. Ohne URL und Text bleibt der Link unsichtbar. |
| `MINDA_DEMO` | `false` | Legt Demo-Konten `admin/admin` (mit Beispieldaten) und `user/user` an; eine Notiz und zwei Listen sind zwischen beiden geteilt, um das gemeinsame Bearbeiten auszuprobieren. **Nie auf öffentlichen Servern!** | | `MINDA_DEMO` | `false` | Legt Demo-Konten `admin/admin` (mit Beispieldaten) und `user/user` an; eine Notiz und zwei Listen sind zwischen beiden geteilt, um das gemeinsame Bearbeiten auszuprobieren. **Nie auf öffentlichen Servern!** |
+1
View File
@@ -30,6 +30,7 @@ services:
- SMTP_PASS=${SMTP_PASS:-} - SMTP_PASS=${SMTP_PASS:-}
- SMTP_FROM=${SMTP_FROM:-} - SMTP_FROM=${SMTP_FROM:-}
- SMTP_FROM_NAME=${SMTP_FROM_NAME:-MindSift} - SMTP_FROM_NAME=${SMTP_FROM_NAME:-MindSift}
- NOTIFY_DIGEST_MINUTES=${NOTIFY_DIGEST_MINUTES:-5}
- SMTP_TLS=${SMTP_TLS:-true} - SMTP_TLS=${SMTP_TLS:-true}
- SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false} - SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false}
# Impressum / Datenschutz # Impressum / Datenschutz
+12 -2
View File
@@ -1,14 +1,15 @@
{ {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"version": "0.7.0", "version": "0.10.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"version": "0.7.0", "version": "0.10.0",
"license": "Unlicense", "license": "Unlicense",
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "5.3.0",
"@types/prismjs": "^1.26.6", "@types/prismjs": "^1.26.6",
"emoji-picker-react": "4.12.0", "emoji-picker-react": "4.12.0",
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",
@@ -1024,6 +1025,15 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@fontsource-variable/inter": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
"integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
+2 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"private": true, "private": true,
"version": "0.7.0", "version": "0.10.0",
"license": "Unlicense", "license": "Unlicense",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -13,6 +13,7 @@
"test:run": "vitest run" "test:run": "vitest run"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "5.3.0",
"@types/prismjs": "^1.26.6", "@types/prismjs": "^1.26.6",
"emoji-picker-react": "4.12.0", "emoji-picker-react": "4.12.0",
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",
+12 -3
View File
@@ -750,7 +750,11 @@ export const App: React.FC = () => {
}; };
return ( return (
<div className="min-h-screen bg-canvas text-main flex flex-col font-sans selection:bg-accent/30"> <div
className={`bg-canvas text-main flex flex-col font-sans selection:bg-accent/30 ${
activeTab === 'workspace' ? 'h-[100dvh] overflow-hidden' : 'min-h-screen'
}`}
>
{/* Top Header Bar: Floating on mobile, solid panel on desktop */} {/* Top Header Bar: Floating on mobile, solid panel on desktop */}
<header className="sticky top-0 z-30 bg-canvas/85 backdrop-blur-md sm:bg-panel sm:border-b sm:border-border sm:shadow-sm"> <header className="sticky top-0 z-30 bg-canvas/85 backdrop-blur-md sm:bg-panel sm:border-b sm:border-border sm:shadow-sm">
{/* Mobile Header: Floating Bar with Logo Action Dropdown Left & Profile Avatar Right */} {/* Mobile Header: Floating Bar with Logo Action Dropdown Left & Profile Avatar Right */}
@@ -1113,9 +1117,14 @@ export const App: React.FC = () => {
)} )}
{/* Main Container */} {/* Main Container */}
<main className="flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 sm:pb-6"> {/* The workspace fills the window exactly and scrolls only inside the note, never the page */}
<main
className={`flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 sm:pb-6 ${
activeTab === 'workspace' ? 'min-h-0 flex flex-col' : ''
}`}
>
<ViewErrorBoundary onReset={() => setActiveTab('triage')}> <ViewErrorBoundary onReset={() => setActiveTab('triage')}>
<section key={dataEpoch}> <section key={dataEpoch} className={activeTab === 'workspace' ? 'flex-1 min-h-0 flex flex-col' : undefined}>
{activeTab === 'triage' && ( {activeTab === 'triage' && (
<TriageDeck <TriageDeck
deck={deck} deck={deck}
+7 -2
View File
@@ -36,7 +36,12 @@ export const FormSheet: React.FC<FormSheetProps> = ({
}, [onClose]); }, [onClose]);
const desktopAnchor = anchor const desktopAnchor = anchor
? ({ '--sheet-left': `${anchor.x}px`, '--sheet-top': `${anchor.y}px` } as React.CSSProperties) ? ({
'--sheet-left': `${anchor.x}px`,
'--sheet-top': `${anchor.y}px`,
'--sheet-x': `clamp(12px, calc(${anchor.x}px - 8.25rem), calc(100vw - 16.5rem - 12px))`,
'--sheet-y': `calc(${anchor.y}px + 8px)`,
} as React.CSSProperties)
: undefined; : undefined;
return ( return (
@@ -54,7 +59,7 @@ export const FormSheet: React.FC<FormSheetProps> = ({
className={`fixed inset-x-0 bottom-[var(--kb-inset)] max-h-[var(--vv-max)] panel-surface rounded-t-2xl border-t border-border shadow-panel p-4 space-y-3 overflow-y-auto className={`fixed inset-x-0 bottom-[var(--kb-inset)] max-h-[var(--vv-max)] panel-surface rounded-t-2xl border-t border-border shadow-panel p-4 space-y-3 overflow-y-auto
sm:inset-x-auto sm:bottom-auto sm:max-h-[85vh] sm:w-full sm:rounded-card sm:border ${ sm:inset-x-auto sm:bottom-auto sm:max-h-[85vh] sm:w-full sm:rounded-card sm:border ${
anchor anchor
? 'sm:max-w-[16.5rem] sm:left-[var(--sheet-left)] sm:top-[var(--sheet-top)]' ? 'sm:max-w-[16.5rem] sm:left-[var(--sheet-x)] sm:top-[var(--sheet-y)]'
: 'sm:max-w-sm sm:left-1/2 sm:top-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2' : 'sm:max-w-sm sm:left-1/2 sm:top-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2'
}`} }`}
> >
@@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState, useMemo } from 'react';
import { import {
Mic, Mic,
MicOff, MicOff,
ArrowUp, ArrowDown,
Sparkles, Sparkles,
Calendar, Calendar,
Check, Check,
@@ -73,6 +73,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
}); });
const isListening = voice.state === 'listening'; const isListening = voice.state === 'listening';
const isTranscribing = voice.state === 'transcribing'; const isTranscribing = voice.state === 'transcribing';
const [isOverMaxHeight, setIsOverMaxHeight] = useState(false);
const adjustHeight = (el?: HTMLTextAreaElement | null) => { const adjustHeight = (el?: HTMLTextAreaElement | null) => {
const targetEl = el || inputRef.current; const targetEl = el || inputRef.current;
@@ -82,8 +83,10 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
const scrollH = targetEl.scrollHeight; const scrollH = targetEl.scrollHeight;
targetEl.style.height = `${Math.min(scrollH, 140)}px`; targetEl.style.height = `${Math.min(scrollH, 140)}px`;
setIsMultiline(targetEl.value.includes('\n') || scrollH > 28); setIsMultiline(targetEl.value.includes('\n') || scrollH > 28);
setIsOverMaxHeight(scrollH > 140);
} else { } else {
setIsMultiline(false); setIsMultiline(false);
setIsOverMaxHeight(false);
} }
}; };
@@ -267,16 +270,18 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
: isTranscribing : isTranscribing
? 'Wird erkannt…' ? 'Wird erkannt…'
: hideTargetPicker || fixedTarget === 'inbox' : hideTargetPicker || fixedTarget === 'inbox'
? 'Quick Dump erfassen… (Enter zum Speichern)' ? 'Quick Dump erfassen…'
: target === 'task' : target === 'task'
? 'Neue Aufgabe direkt anlegen… (Enter / Shift+Enter für Mehrzeiler)' ? 'Aufgabe erfassen…'
: target === 'list_item' : target === 'list_item'
? `Eintrag direkt zu "${selectedList?.title || 'Liste'}" hinzufügen…` ? `Zu „${selectedList?.title || 'Liste'}“ hinzufügen…`
: target === 'document' : target === 'document'
? 'Neue Workspace-Notiz direkt anlegen… (Enter / Shift+Enter für Mehrzeiler)' ? 'Notiz erfassen…'
: 'Quick Dump: Gedanke reinrotzen… (C / Enter, Shift+Enter für Mehrzeiler)' : 'Quick Dump…'
} }
className="w-full bg-transparent text-main placeholder-faint text-xs sm:text-sm focus:outline-none resize-none leading-5 py-0.5 overflow-y-auto" className={`w-full bg-transparent text-main placeholder-faint text-xs sm:text-sm focus:outline-none resize-none leading-5 py-0.5 ${
isOverMaxHeight ? 'overflow-y-auto' : 'overflow-hidden'
}`}
style={{ minHeight: '22px', maxHeight: '140px' }} style={{ minHeight: '22px', maxHeight: '140px' }}
autoComplete="off" autoComplete="off"
/> />
@@ -360,7 +365,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
</> </>
) : ( ) : (
<> <>
<ArrowUp className="w-3 h-3" /> <ArrowDown className="w-3 h-3" />
<span className="inline"> <span className="inline">
{hideTargetPicker || (fixedTarget || target) === 'inbox' {hideTargetPicker || (fixedTarget || target) === 'inbox'
? 'Quick Dump' ? 'Quick Dump'
@@ -17,7 +17,7 @@ import {
Pencil, Pencil,
Copy, Copy,
} from 'lucide-react'; } from 'lucide-react';
import { AuthStatus, User, UserTheme } from '../../lib/types'; import { AuthStatus, NotificationEvents, NotificationSettings, User, UserTheme } from '../../lib/types';
import { ApiKeysPanel } from './ApiKeysPanel'; import { ApiKeysPanel } from './ApiKeysPanel';
import { ServerBackupsCard } from './ServerBackupsCard'; import { ServerBackupsCard } from './ServerBackupsCard';
import { saveBlob } from '../../lib/download'; import { saveBlob } from '../../lib/download';
@@ -32,6 +32,7 @@ import { ImportCard } from './ImportCard';
import { SessionsPanel } from './SessionsPanel'; import { SessionsPanel } from './SessionsPanel';
import { ToggleSwitch } from '../../components/ToggleSwitch'; import { ToggleSwitch } from '../../components/ToggleSwitch';
import { setLinkPreviewsEnabled, useLinkPreviewsEnabled } from '../../lib/linkPreview'; import { setLinkPreviewsEnabled, useLinkPreviewsEnabled } from '../../lib/linkPreview';
import { isJournalEnabled, setJournalEnabled } from '../../lib/journal';
import { FormSheet } from '../../components/FormSheet'; import { FormSheet } from '../../components/FormSheet';
interface UserSettingsViewProps { interface UserSettingsViewProps {
@@ -47,6 +48,54 @@ interface UserSettingsViewProps {
type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'webhooks'; type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'webhooks';
export function buildNotificationSettings(user: User): NotificationSettings {
const hasLegacy = Array.isArray(user.alarmChannels);
const dueEmailDefault = hasLegacy ? user.alarmChannels!.includes('email') : true;
const dueDiscordDefault = hasLegacy ? user.alarmChannels!.includes('discord') : true;
const dueWebhookDefault = hasLegacy ? user.alarmChannels!.includes('webhook') : false;
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,
webhook: s?.events?.due?.webhook ?? dueWebhookDefault,
},
shared: {
email: s?.events?.shared?.email ?? true,
discord: s?.events?.shared?.discord ?? false,
webhook: s?.events?.shared?.webhook ?? false,
},
taskDone: {
email: s?.events?.taskDone?.email ?? false,
discord: s?.events?.taskDone?.discord ?? false,
webhook: s?.events?.taskDone?.webhook ?? false,
},
watched: {
email: s?.events?.watched?.email ?? true,
discord: s?.events?.watched?.discord ?? true,
webhook: s?.events?.watched?.webhook ?? false,
},
},
dailySummary: {
enabled: s?.dailySummary?.enabled ?? false,
time: s?.dailySummary?.time || '07:00',
email: s?.dailySummary?.email ?? true,
discord: s?.dailySummary?.discord ?? false,
webhook: s?.dailySummary?.webhook ?? false,
},
};
}
const NOTIFICATION_EVENT_ROWS: { key: keyof NotificationEvents; label: string }[] = [
{ key: 'due', label: 'Fällige Aufgaben (sofort)' },
{ key: 'shared', label: 'Freigabe erhalten' },
{ key: 'taskDone', label: 'Geteilte Aufgabe erledigt' },
{ key: 'watched', label: 'Beobachtete Aufgaben' },
];
export const UserSettingsView: React.FC<UserSettingsViewProps> = ({ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
currentUser, currentUser,
activeTheme, activeTheme,
@@ -102,6 +151,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
// The public demo sends nothing and keeps the shared accounts intact (FLAW-33) // The public demo sends nothing and keeps the shared accounts intact (FLAW-33)
const [demoMode, setDemoMode] = useState(false); const [demoMode, setDemoMode] = useState(false);
const linkPreviewsOn = useLinkPreviewsEnabled(); const linkPreviewsOn = useLinkPreviewsEnabled();
const [journalOn, setJournalOn] = useState(isJournalEnabled);
useEffect(() => { useEffect(() => {
api api
.getAuthStatus() .getAuthStatus()
@@ -208,8 +258,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// --- Multi-Channel Alarms State --- // --- Multi-Channel Alarms State ---
const [alarmChannels, setAlarmChannels] = useState<string[]>( const [notificationSettings, setNotificationSettings] = useState<NotificationSettings>(() =>
Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : [] buildNotificationSettings(currentUser)
); );
const [alarmEmail, setAlarmEmail] = useState(currentUser.alarmEmail || currentUser.email || ''); const [alarmEmail, setAlarmEmail] = useState(currentUser.alarmEmail || currentUser.email || '');
const [discordWebhookUrl, setDiscordWebhookUrl] = useState(currentUser.discordWebhookUrl || ''); const [discordWebhookUrl, setDiscordWebhookUrl] = useState(currentUser.discordWebhookUrl || '');
@@ -225,17 +275,25 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
setDisplayName(currentUser.displayName || ''); setDisplayName(currentUser.displayName || '');
setEmail(currentUser.email || ''); setEmail(currentUser.email || '');
setAvatarUrl(currentUser.avatarUrl || ''); setAvatarUrl(currentUser.avatarUrl || '');
setAlarmChannels(Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []); setNotificationSettings(buildNotificationSettings(currentUser));
setAlarmEmail(currentUser.alarmEmail || currentUser.email || ''); setAlarmEmail(currentUser.alarmEmail || currentUser.email || '');
setDiscordWebhookUrl(currentUser.discordWebhookUrl || ''); setDiscordWebhookUrl(currentUser.discordWebhookUrl || '');
setCustomWebhookUrl(currentUser.customWebhookUrl || ''); setCustomWebhookUrl(currentUser.customWebhookUrl || '');
setNotifyBeforeMinutes(currentUser.notifyBeforeMinutes ?? 30); setNotifyBeforeMinutes(currentUser.notifyBeforeMinutes ?? 30);
}, [currentUser]); }, [currentUser]);
const toggleAlarmChannel = (ch: string) => {
setAlarmChannels((prev) => const setEventChannel = (eventKey: keyof NotificationEvents, channel: 'email' | 'discord' | 'webhook', value: boolean) => {
prev.includes(ch) ? prev.filter((item) => item !== ch) : [...prev, ch] setNotificationSettings((prev) => ({
); ...prev,
events: {
...prev.events,
[eventKey]: {
...prev.events[eventKey],
[channel]: value,
},
},
}));
}; };
// --- Handlers: Avatar --- // --- Handlers: Avatar ---
@@ -324,14 +382,23 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
setAlarmMsg(null); setAlarmMsg(null);
setSavingAlarms(true); setSavingAlarms(true);
try { try {
const nextAlarmChannels = [
...(notificationSettings.events.due.email ? ['email'] : []),
...(notificationSettings.events.due.discord ? ['discord'] : []),
...(notificationSettings.events.due.webhook ? ['webhook'] : []),
];
const updated = await api.updateSettings({ const updated = await api.updateSettings({
alarmChannels, alarmChannels: nextAlarmChannels,
alarmEmail: alarmEmail.trim(), alarmEmail: alarmEmail.trim(),
discordWebhookUrl: discordWebhookUrl.trim(), discordWebhookUrl: discordWebhookUrl.trim(),
customWebhookUrl: customWebhookUrl.trim(), customWebhookUrl: customWebhookUrl.trim(),
notifyBeforeMinutes: Number(notifyBeforeMinutes), notifyBeforeMinutes: Number(notifyBeforeMinutes),
notificationSettings,
});
onUserUpdated({
...updated,
notificationSettings: updated.notificationSettings ?? notificationSettings,
}); });
onUserUpdated(updated);
setAlarmMsg({ type: 'ok', text: 'Alarm-Kanäle und Vorwarnzeit erfolgreich gespeichert.' }); setAlarmMsg({ type: 'ok', text: 'Alarm-Kanäle und Vorwarnzeit erfolgreich gespeichert.' });
} catch (err: any) { } catch (err: any) {
setAlarmMsg({ type: 'err', text: err.message || 'Fehler beim Speichern der Alarme' }); setAlarmMsg({ type: 'err', text: err.message || 'Fehler beim Speichern der Alarme' });
@@ -513,7 +580,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
{/* SECTION 1: PROFIL & SICHERHEIT */} {/* SECTION 1: PROFIL & SICHERHEIT */}
{/* ================================================================= */} {/* ================================================================= */}
{section === 'profile' && ( {section === 'profile' && (
<div className="space-y-5 max-w-2xl"> <div className="space-y-5">
<div className="border-b border-border pb-3"> <div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-main">Profil & Anmeldedaten</h2> <h2 className="text-sm font-bold text-main">Profil & Anmeldedaten</h2>
<p className="text-muted mt-0.5"> <p className="text-muted mt-0.5">
@@ -789,6 +856,24 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<ToggleSwitch checked={linkPreviewsOn} onChange={setLinkPreviewsEnabled} title="Link-Vorschauen anzeigen" /> <ToggleSwitch checked={linkPreviewsOn} onChange={setLinkPreviewsEnabled} title="Link-Vorschauen anzeigen" />
</div> </div>
<div className="p-3.5 rounded-card border border-border bg-canvas flex items-center justify-between gap-4">
<div className="text-xs">
<p className="font-semibold text-main">Journal anzeigen</p>
<p className="text-[11px] text-faint">
Eine Notiz pro Tag, untereinander mit Kalender – ganz oben in der Notizen-Seitenleiste. Ausschalten
blendet es nur aus, die Tage bleiben als Notizen erhalten. Gilt für dieses Gerät.
</p>
</div>
<ToggleSwitch
checked={journalOn}
onChange={(v) => {
setJournalEnabled(v);
setJournalOn(v);
}}
title="Journal anzeigen"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{themes.map((t) => { {themes.map((t) => {
const isSelected = activeTheme === t.id; const isSelected = activeTheme === t.id;
@@ -1019,6 +1104,216 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
)} )}
<form onSubmit={handleSaveAlarms} className="space-y-4"> <form onSubmit={handleSaveAlarms} className="space-y-4">
{/* Global Pause & Own Watched Toggles */}
<div className="p-3.5 bg-canvas border border-border space-y-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="font-semibold text-main">Alle Benachrichtigungen pausieren</p>
<p className="text-[11px] text-muted">
Pausiert vorübergehend sämtliche E-Mail- und Discord-Benachrichtigungen.
</p>
</div>
<ToggleSwitch
checked={notificationSettings.paused}
onChange={(v) => setNotificationSettings((prev) => ({ ...prev, paused: v }))}
title="Alle Benachrichtigungen pausieren"
/>
</div>
<div className="flex items-center justify-between gap-4 pt-3 border-t border-border">
<div>
<p className="font-semibold text-main">
Auch eigene Änderungen an beobachteten Aufgaben melden
</p>
<p className="text-[11px] text-muted">
Standardmäßig wirst du nur über Änderungen anderer Personen benachrichtigt.
</p>
</div>
<ToggleSwitch
checked={notificationSettings.includeOwnWatched}
onChange={(v) => setNotificationSettings((prev) => ({ ...prev, includeOwnWatched: v }))}
title="Auch eigene Änderungen an beobachteten Aufgaben melden"
/>
</div>
</div>
{/* Routing Matrix: Events x (E-Mail / Discord / own webhook) */}
{(() => {
const hasDiscordWebhook = Boolean(discordWebhookUrl.trim());
const hasCustomWebhook = Boolean(customWebhookUrl.trim());
return (
<>
<div className="p-4 bg-canvas border border-border space-y-3">
<div className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-2 sm:gap-4 pb-2 border-b border-border">
<span className="font-bold text-main">Ereignis</span>
<span className="w-16 text-center font-semibold text-muted flex items-center justify-center gap-1">
<Mail className="w-3.5 h-3.5 text-accent" />
<span>E-Mail</span>
</span>
<span className="w-16 text-center font-semibold text-muted flex items-center justify-center gap-1">
<MessageSquare className="w-3.5 h-3.5 text-accent" />
<span>Discord</span>
</span>
<span className="w-16 text-center font-semibold text-muted flex items-center justify-center gap-1">
<Globe className="w-3.5 h-3.5 text-accent" />
<span>Webhook</span>
</span>
</div>
<div className="divide-y divide-border">
{NOTIFICATION_EVENT_ROWS.map((row) => {
const routing = notificationSettings.events[row.key];
return (
<div
key={row.key}
className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-2 sm:gap-4 py-2.5"
>
<span className="font-medium text-main">{row.label}</span>
<div className="w-16 flex justify-center">
<ToggleSwitch
checked={routing.email}
onChange={(v) => setEventChannel(row.key, 'email', v)}
title={`${row.label} – E-Mail`}
/>
</div>
<div className="w-16 flex justify-center">
<ToggleSwitch
checked={routing.discord}
disabled={!hasDiscordWebhook}
onChange={(v) => setEventChannel(row.key, 'discord', v)}
title={
hasDiscordWebhook
? `${row.label} – Discord`
: 'Bitte zuerst unten eine Discord Webhook URL eintragen'
}
/>
</div>
<div className="w-16 flex justify-center">
<ToggleSwitch
checked={routing.webhook}
disabled={!hasCustomWebhook}
onChange={(v) => setEventChannel(row.key, 'webhook', v)}
title={
hasCustomWebhook
? `${row.label} – Webhook`
: 'Bitte zuerst unten eine eigene Webhook URL eintragen'
}
/>
</div>
</div>
);
})}
</div>
<p className="text-[11px] text-faint pt-1">
Gebündelt: höchstens alle 5 Minuten eine Nachricht (außer fällige Aufgaben)
</p>
{!hasDiscordWebhook && (
<p className="text-[11px] text-status-warn">
Discord-Schalter sind deaktiviert, solange unten kein Discord-Webhook eingetragen ist.
</p>
)}
{!hasCustomWebhook && (
<p className="text-[11px] text-faint">
Webhook-Schalter werden aktiv, sobald unten eine eigene Webhook-URL eingetragen ist.
</p>
)}
</div>
{/* Daily Summary */}
<div className="p-4 bg-canvas border border-border space-y-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="font-bold text-main">Tageszusammenfassung</p>
<p className="text-[11px] text-muted">
Überfällige, heutige und morgige Aufgaben; nichts fällig = keine Nachricht
</p>
</div>
<ToggleSwitch
checked={notificationSettings.dailySummary.enabled}
onChange={(v) =>
setNotificationSettings((prev) => ({
...prev,
dailySummary: { ...prev.dailySummary, enabled: v },
}))
}
title="Tageszusammenfassung"
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-4 pt-2 border-t border-border">
<label className="flex items-center gap-2 text-xs text-main font-medium">
<span>Uhrzeit:</span>
<input
type="time"
aria-label="Uhrzeit der Tageszusammenfassung"
value={notificationSettings.dailySummary.time}
onChange={(e) =>
setNotificationSettings((prev) => ({
...prev,
dailySummary: { ...prev.dailySummary, time: e.target.value },
}))
}
className="h-11 sm:h-8 px-2.5 input-field font-mono"
/>
</label>
<div className="flex items-center gap-5">
<div className="flex items-center gap-2">
<span className="text-xs text-muted">E-Mail</span>
<ToggleSwitch
checked={notificationSettings.dailySummary.email}
onChange={(v) =>
setNotificationSettings((prev) => ({
...prev,
dailySummary: { ...prev.dailySummary, email: v },
}))
}
title="Tageszusammenfassung – E-Mail"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted">Discord</span>
<ToggleSwitch
checked={notificationSettings.dailySummary.discord}
disabled={!hasDiscordWebhook}
onChange={(v) =>
setNotificationSettings((prev) => ({
...prev,
dailySummary: { ...prev.dailySummary, discord: v },
}))
}
title={
hasDiscordWebhook
? 'Tageszusammenfassung – Discord'
: 'Bitte zuerst unten eine Discord Webhook URL eintragen'
}
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted">Webhook</span>
<ToggleSwitch
checked={notificationSettings.dailySummary.webhook}
disabled={!hasCustomWebhook}
onChange={(v) =>
setNotificationSettings((prev) => ({
...prev,
dailySummary: { ...prev.dailySummary, webhook: v },
}))
}
title={
hasCustomWebhook
? 'Tageszusammenfassung – Webhook'
: 'Bitte zuerst unten eine eigene Webhook URL eintragen'
}
/>
</div>
</div>
</div>
</div>
</>
);
})()}
{/* Lead Time Selector */} {/* Lead Time Selector */}
<div className="p-3.5 bg-canvas border border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3"> <div className="p-3.5 bg-canvas border border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div> <div>
@@ -1044,19 +1339,9 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</div> </div>
{/* Channel 1: E-Mail */} {/* Channel 1: E-Mail */}
<div <div className="p-4 border border-border transition-colors space-y-3 bg-canvas">
className={`p-4 border transition-colors space-y-3 bg-canvas ${
alarmChannels.includes('email') ? 'border-accent' : 'border-border'
}`}
>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<label className="flex items-center gap-2.5 cursor-pointer select-none"> <div className="flex items-center gap-2.5">
<input
type="checkbox"
checked={alarmChannels.includes('email')}
onChange={() => toggleAlarmChannel('email')}
className="w-4 h-4 accent-blue-600"
/>
<Mail className="w-4 h-4 text-accent" /> <Mail className="w-4 h-4 text-accent" />
<div> <div>
<span className="font-bold text-main block">Ziel: E-Mail</span> <span className="font-bold text-main block">Ziel: E-Mail</span>
@@ -1064,7 +1349,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
Sendet eine E-Mail über den konfigurierten SMTP-Server der Instanz. Sendet eine E-Mail über den konfigurierten SMTP-Server der Instanz.
</span> </span>
</div> </div>
</label> </div>
<button <button
type="button" type="button"
@@ -1092,19 +1377,9 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</div> </div>
{/* Channel 2: Discord Webhook */} {/* Channel 2: Discord Webhook */}
<div <div className="p-4 border border-border transition-colors space-y-3 bg-canvas">
className={`p-4 border transition-colors space-y-3 bg-canvas ${
alarmChannels.includes('discord') ? 'border-accent' : 'border-border'
}`}
>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<label className="flex items-center gap-2.5 cursor-pointer select-none"> <div className="flex items-center gap-2.5">
<input
type="checkbox"
checked={alarmChannels.includes('discord')}
onChange={() => toggleAlarmChannel('discord')}
className="w-4 h-4 accent-blue-600"
/>
<MessageSquare className="w-4 h-4 text-accent" /> <MessageSquare className="w-4 h-4 text-accent" />
<div> <div>
<span className="font-bold text-main block">Ziel: Discord Webhook</span> <span className="font-bold text-main block">Ziel: Discord Webhook</span>
@@ -1113,7 +1388,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
Discord-Kanal. Discord-Kanal.
</span> </span>
</div> </div>
</label> </div>
<button <button
type="button" type="button"
@@ -1141,17 +1416,11 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
{/* Channel 3: Custom HTTP Webhook */} {/* Channel 3: Custom HTTP Webhook */}
<div <div
className={`p-4 border transition-colors space-y-3 bg-canvas ${ className={`p-4 border transition-colors space-y-3 bg-canvas ${
alarmChannels.includes('webhook') ? 'border-accent' : 'border-border' customWebhookUrl.trim() ? 'border-accent' : 'border-border'
}`} }`}
> >
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<label className="flex items-center gap-2.5 cursor-pointer select-none"> <div className="flex items-center gap-2.5">
<input
type="checkbox"
checked={alarmChannels.includes('webhook')}
onChange={() => toggleAlarmChannel('webhook')}
className="w-4 h-4 accent-blue-600"
/>
<Globe className="w-4 h-4 text-accent" /> <Globe className="w-4 h-4 text-accent" />
<div> <div>
<span className="font-bold text-main block"> <span className="font-bold text-main block">
@@ -1159,10 +1428,10 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</span> </span>
<span className="text-[11px] text-muted"> <span className="text-[11px] text-muted">
Für eigene Empfänger-Skripte, Ntfy, Gotify, Slack, Mattermost oder Für eigene Empfänger-Skripte, Ntfy, Gotify, Slack, Mattermost oder
Automatisierungen. Automatisierungen. Welche Ereignisse hierher gehen, stellst du oben in der Spalte „Webhook“ ein.
</span> </span>
</div> </div>
</label> </div>
<button <button
type="button" type="button"
@@ -0,0 +1,140 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { UserSettingsView } from '../UserSettingsView';
import { api } from '../../../lib/api';
import { User } from '../../../lib/types';
vi.mock('../../../lib/api', async () => {
const actual = await vi.importActual<typeof import('../../../lib/api')>('../../../lib/api');
return {
...actual,
api: {
...actual.api,
getMe: vi.fn(async () => ({ isLastAdmin: false })),
getAuthStatus: vi.fn(async () => ({ demoMode: false })),
updateSettings: vi.fn(async (patch) => ({ ...baseUser, ...patch })),
},
};
});
const baseUser: User = {
id: 'u1',
email: 'anna@example.com',
displayName: 'Anna',
role: 'member',
isActive: true,
alarmChannels: ['email'],
createdAt: '2026-10-30T00:00:00Z',
};
describe('UserSettingsView notification matrix', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('derives events.due from alarmChannels, disables Discord without webhook, and saves the exact notificationSettings object', async () => {
const onUserUpdated = vi.fn();
render(
<UserSettingsView
currentUser={baseUser}
activeTheme="dark"
themes={[]}
customThemes={[]}
onThemeChange={() => {}}
onCustomThemesChange={() => {}}
onUserUpdated={onUserUpdated}
onBack={() => {}}
/>
);
// Open "Alarme" section
fireEvent.click(screen.getByText('Alarme'));
// Without Discord webhook, Discord switches are disabled and hint is visible
const discordSwitches = screen.getAllByTitle('Bitte zuerst unten eine Discord Webhook URL eintragen');
expect(discordSwitches.length).toBe(5);
expect((discordSwitches[0] as HTMLButtonElement).disabled).toBe(true);
expect(screen.getByText(/Discord-Schalter sind deaktiviert/)).toBeTruthy();
// Enter a Discord Webhook URL to enable the Discord column
fireEvent.change(screen.getByPlaceholderText(/https:\/\/discord\.com\/api\/webhooks/), {
target: { value: 'https://discord.com/api/webhooks/123/abc' },
});
// Derive check: alarmChannels=['email'] -> due.email=true, due.discord=false
expect(screen.getByTitle('Fällige Aufgaben (sofort) – E-Mail').getAttribute('aria-checked')).toBe('true');
expect(screen.getByTitle('Fällige Aufgaben (sofort) – Discord').getAttribute('aria-checked')).toBe('false');
// Toggle settings in the matrix, daily summary, and global switches
fireEvent.click(screen.getByTitle('Fällige Aufgaben (sofort) – Discord'));
fireEvent.click(screen.getByTitle('Geteilte Aufgabe erledigt – E-Mail'));
fireEvent.click(screen.getByTitle('Beobachtete Aufgaben – Discord'));
fireEvent.click(screen.getByTitle('Alle Benachrichtigungen pausieren'));
fireEvent.click(screen.getByTitle('Auch eigene Änderungen an beobachteten Aufgaben melden'));
fireEvent.click(screen.getByTitle('Tageszusammenfassung'));
fireEvent.change(screen.getByLabelText('Uhrzeit der Tageszusammenfassung'), {
target: { value: '08:30' },
});
fireEvent.click(screen.getByTitle('Tageszusammenfassung – Discord'));
fireEvent.click(screen.getByRole('button', { name: 'Alarm-Einstellungen speichern' }));
await waitFor(() => expect(api.updateSettings).toHaveBeenCalledTimes(1));
expect(api.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
discordWebhookUrl: 'https://discord.com/api/webhooks/123/abc',
notificationSettings: {
paused: true,
includeOwnWatched: true,
events: {
due: { email: true, discord: true, webhook: false },
shared: { email: true, discord: false, webhook: false },
taskDone: { email: true, discord: false, webhook: false },
watched: { email: true, discord: false, webhook: false },
},
dailySummary: {
enabled: true,
time: '08:30',
email: true,
discord: true,
webhook: false,
},
},
})
);
});
it('enables the webhook column once an own webhook URL is entered and saves it as due channel', async () => {
render(
<UserSettingsView
currentUser={baseUser}
activeTheme="dark"
themes={[]}
customThemes={[]}
onThemeChange={() => {}}
onCustomThemesChange={() => {}}
onUserUpdated={() => {}}
onBack={() => {}}
/>
);
fireEvent.click(screen.getByText('Alarme'));
const off = screen.getAllByTitle('Bitte zuerst unten eine eigene Webhook URL eintragen');
expect(off.length).toBe(5);
expect((off[0] as HTMLButtonElement).disabled).toBe(true);
fireEvent.change(screen.getByPlaceholderText(/dein-server\.local/), {
target: { value: 'https://hooks.example.com/mindsift' },
});
fireEvent.click(screen.getByTitle('Fällige Aufgaben (sofort) – Webhook'));
fireEvent.click(screen.getByTitle('Freigabe erhalten – Webhook'));
fireEvent.click(screen.getByRole('button', { name: 'Alarm-Einstellungen speichern' }));
await waitFor(() => expect(api.updateSettings).toHaveBeenCalledTimes(1));
const patch = vi.mocked(api.updateSettings).mock.calls[0][0];
expect(patch.customWebhookUrl).toBe('https://hooks.example.com/mindsift');
expect(patch.alarmChannels).toEqual(['email', 'webhook']);
expect(patch.notificationSettings?.events.due.webhook).toBe(true);
expect(patch.notificationSettings?.events.shared.webhook).toBe(true);
});
});
@@ -15,11 +15,13 @@ import {
Lock, Lock,
UserPlus, UserPlus,
Repeat, Repeat,
Eye,
EyeOff,
} from 'lucide-react'; } from 'lucide-react';
import { Thought, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types'; import { Thought, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types';
import { UserAvatar } from '../../components/UserAvatar'; import { UserAvatar } from '../../components/UserAvatar';
import { UserAutocomplete } from '../UserAutocomplete'; import { UserAutocomplete } from '../UserAutocomplete';
import { triggerHaptic } from '../../lib/api'; import { api, triggerHaptic } from '../../lib/api';
import { MarkdownViewer } from '../../lib/markdown'; import { MarkdownViewer } from '../../lib/markdown';
import { addDays, joinDue, splitDue } from '../../lib/due'; import { addDays, joinDue, splitDue } from '../../lib/due';
@@ -90,13 +92,32 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
const [newSubtaskTitle, setNewSubtaskTitle] = useState(''); const [newSubtaskTitle, setNewSubtaskTitle] = useState('');
const [newTagDraft, setNewTagDraft] = useState(''); const [newTagDraft, setNewTagDraft] = useState('');
const [isWatching, setIsWatching] = useState<boolean>(Boolean(task.isWatching));
const [watchBusy, setWatchBusy] = useState(false);
useEffect(() => { useEffect(() => {
setTitleDraft(task.content); setTitleDraft(task.content);
setNotesDraft(task.notes || ''); setNotesDraft(task.notes || '');
setIsEditingTitle(false); setIsEditingTitle(false);
setIsEditingNotes(false); setIsEditingNotes(false);
}, [task.id]); setIsWatching(Boolean(task.isWatching));
}, [task.id, task.isWatching]);
const handleToggleWatch = async () => {
if (watchBusy) return;
setWatchBusy(true);
try {
const res = isWatching ? await api.unwatchTask(task.id) : await api.watchTask(task.id);
const next = Boolean(res.watching);
setIsWatching(next);
task.isWatching = next;
triggerHaptic(10);
} catch {
// ignore
} finally {
setWatchBusy(false);
}
};
// Handle escape key to close // Handle escape key to close
useEffect(() => { useEffect(() => {
@@ -226,6 +247,27 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<button
type="button"
onClick={handleToggleWatch}
disabled={watchBusy}
title={isWatching ? 'Nicht mehr beobachten' : 'Beobachten'}
className={`btn-action min-h-[44px] sm:min-h-0 h-11 sm:h-7 px-2.5 sm:px-2 text-xs flex items-center gap-1.5 ${
isWatching ? 'border-accent text-accent' : ''
}`}
>
{isWatching ? (
<>
<EyeOff className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-accent" />
<span>Nicht mehr beobachten</span>
</>
) : (
<>
<Eye className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
<span>Beobachten</span>
</>
)}
</button>
{(task.convertedType === 'document_block' || task.docId) ? ( {(task.convertedType === 'document_block' || task.docId) ? (
<button <button
type="button" type="button"
+67 -1
View File
@@ -725,7 +725,73 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
</div> </div>
)} )}
{/* Primary Triage Decision Bar: Hidden on mobile (handled via Swipe + Long-Press), shown on Desktop */} {/* Mobile Quick Action Buttons under the card (Touch targets >= 44px) */}
<div className="grid grid-cols-4 gap-1.5 sm:hidden">
<button
type="button"
onClick={() => handleAction('discard')}
title="Wegwerfen"
className="btn-action h-11 px-1 flex flex-col items-center justify-center gap-0.5 text-[11px] whitespace-nowrap touch-manipulation cursor-pointer"
>
<X className="w-4 h-4 text-muted flex-shrink-0" />
<span>Wegwerfen</span>
</button>
<button
type="button"
disabled={writableLists.length === 0}
onClick={() => {
if (singleWritableList) {
handleAction('keep', {
convertTo: 'list_item',
targetListId: singleWritableList.id,
});
return;
}
setShowDateModal(false);
setShowListSelector((prev) => !prev);
}}
title={
writableLists.length === 0
? 'Keine Liste vorhanden'
: singleWritableList
? `Auf ${singleWritableList.title}`
: 'Auf eine geteilte Liste setzen'
}
className={`btn-action h-11 px-1 flex flex-col items-center justify-center gap-0.5 text-[11px] whitespace-nowrap touch-manipulation cursor-pointer ${
writableLists.length === 0
? 'opacity-40 cursor-not-allowed'
: showListSelector
? 'border-accent text-accent bg-accent-dim'
: ''
}`}
>
<ShoppingCart className="w-4 h-4 text-accent flex-shrink-0" />
<span className="truncate max-w-full">{singleWritableList ? singleWritableList.title : 'Liste'}</span>
</button>
<button
type="button"
onClick={() => handleAction('keep', { convertTo: 'document' })}
title="In Notiz umwandeln"
className="btn-action h-11 px-1 flex flex-col items-center justify-center gap-0.5 text-[11px] whitespace-nowrap touch-manipulation cursor-pointer"
>
<FileText className="w-4 h-4 text-accent flex-shrink-0" />
<span>Notiz</span>
</button>
<button
type="button"
onClick={() => handleAction('keep', { convertTo: 'task' })}
title="Als Aufgabe übernehmen"
className="btn-primary h-11 px-1 flex flex-col items-center justify-center gap-0.5 text-[11px] font-semibold whitespace-nowrap touch-manipulation cursor-pointer"
>
<CheckSquare className="w-4 h-4 flex-shrink-0" />
<span>Aufgabe</span>
</button>
</div>
{/* Primary Triage Decision Bar: Hidden on mobile (handled via Mobile Quick Actions / Swipe), shown on Desktop */}
<div className="hidden sm:flex items-center gap-1.5"> <div className="hidden sm:flex items-center gap-1.5">
<button <button
onClick={() => handleAction('discard')} onClick={() => handleAction('discard')}
@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { TaskDetailModal } from '../TaskDetailModal'; import { TaskDetailModal } from '../TaskDetailModal';
import { api } from '../../../lib/api';
import { Thought } from '../../../lib/types'; import { Thought } from '../../../lib/types';
const task: Thought = { const task: Thought = {
@@ -67,4 +68,20 @@ describe('TaskDetailModal', () => {
expect(screen.getByText('Aufgabe ansehen')).toBeTruthy(); expect(screen.getByText('Aufgabe ansehen')).toBeTruthy();
expect((screen.getByLabelText('Fälligkeitsdatum') as HTMLInputElement).closest('fieldset')?.disabled).toBe(true); expect((screen.getByLabelText('Fälligkeitsdatum') as HTMLInputElement).closest('fieldset')?.disabled).toBe(true);
}); });
it('toggles watching a task via POST and DELETE /tasks/{id}/watch', async () => {
const watchSpy = vi.spyOn(api, 'watchTask').mockResolvedValue({ watching: true });
const unwatchSpy = vi.spyOn(api, 'unwatchTask').mockResolvedValue({ watching: false });
render(<TaskDetailModal {...props({ task: { ...task, isWatching: false } })} />);
const btn = screen.getByRole('button', { name: 'Beobachten' });
fireEvent.click(btn);
await waitFor(() => expect(watchSpy).toHaveBeenCalledWith('th_1'));
expect(screen.getByRole('button', { name: 'Nicht mehr beobachten' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Nicht mehr beobachten' }));
await waitFor(() => expect(unwatchSpy).toHaveBeenCalledWith('th_1'));
expect(screen.getByRole('button', { name: 'Beobachten' })).toBeTruthy();
});
}); });
@@ -160,4 +160,26 @@ describe('TriageDeck single-list shortcut', () => {
expect(api.triageAction).not.toHaveBeenCalled(); expect(api.triageAction).not.toHaveBeenCalled();
expect(onDeckChange).not.toHaveBeenCalled(); expect(onDeckChange).not.toHaveBeenCalled();
}); });
it('renders mobile quick action buttons and triggers discard', async () => {
const onDeckChange = vi.fn();
render(
<TriageDeck
deck={sampleDeck}
lists={[ownList]}
onDeckChange={onDeckChange}
onOpenDocument={vi.fn()}
onFocusCapture={vi.fn()}
/>
);
const [mobileDiscard] = screen.getAllByRole('button', { name: /^Wegwerfen$/i });
fireEvent.click(mobileDiscard);
await waitFor(() => {
expect(api.triageAction).toHaveBeenCalledWith('th_1', 'discard', expect.objectContaining({}));
expect(onDeckChange).toHaveBeenCalledTimes(1);
});
});
}); });
@@ -347,6 +347,7 @@ export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
const copyTimerRef = useRef<number | null>(null); const copyTimerRef = useRef<number | null>(null);
const localTextareaRef = useRef<HTMLTextAreaElement | null>(null); const localTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const preRef = useRef<HTMLPreElement | null>(null); const preRef = useRef<HTMLPreElement | null>(null);
const lineNumbersRef = useRef<HTMLDivElement | null>(null);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -506,18 +507,42 @@ export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
}; };
const handleScroll = (e: React.UIEvent<HTMLTextAreaElement>) => { const handleScroll = (e: React.UIEvent<HTMLTextAreaElement>) => {
const target = e.currentTarget;
if (preRef.current) { if (preRef.current) {
preRef.current.scrollTop = e.currentTarget.scrollTop; preRef.current.scrollTop = target.scrollTop;
preRef.current.scrollLeft = e.currentTarget.scrollLeft; preRef.current.scrollLeft = target.scrollLeft;
}
if (lineNumbersRef.current) {
lineNumbersRef.current.scrollTop = target.scrollTop;
} }
}; };
const handleLineNumbersScroll = (e: React.UIEvent<HTMLDivElement>) => {
const target = e.currentTarget;
if (localTextareaRef.current) {
localTextareaRef.current.scrollTop = target.scrollTop;
}
if (preRef.current) {
preRef.current.scrollTop = target.scrollTop;
}
};
const handleLineNumbersWheel = (e: React.WheelEvent) => {
if (localTextareaRef.current) {
localTextareaRef.current.scrollTop += e.deltaY;
}
};
const lineCount = useMemo(() => {
return currentContent.split('\n').length;
}, [currentContent]);
const rawLang = block.lang || ''; const rawLang = block.lang || '';
const selectValue = rawLang ? resolveLanguageId(rawLang) || rawLang : ''; const selectValue = rawLang ? resolveLanguageId(rawLang) || rawLang : '';
const hasCustomUnknownLang = Boolean(selectValue && !KNOWN_OPTION_IDS.has(selectValue)); const hasCustomUnknownLang = Boolean(selectValue && !KNOWN_OPTION_IDS.has(selectValue));
return ( return (
<div className="bg-code border border-border rounded-btn my-0.5 overflow-x-auto max-w-full"> <div className="bg-code border border-border rounded-btn my-0.5 max-w-full overflow-hidden">
{/* Code Block Header */} {/* Code Block Header */}
<div className="flex items-center justify-between gap-2 px-2 py-1 border-b border-border text-[11px] font-mono text-faint"> <div className="flex items-center justify-between gap-2 px-2 py-1 border-b border-border text-[11px] font-mono text-faint">
<div className="flex items-center gap-1.5 min-w-0"> <div className="flex items-center gap-1.5 min-w-0">
@@ -584,41 +609,60 @@ export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
</div> </div>
</div> </div>
{/* Live Highlighted Overlay + Textarea */} {/* Live Highlighted Overlay + Textarea with Line Numbers */}
<div className="relative w-full overflow-x-auto"> <div className="flex relative w-full overflow-hidden">
<pre {/* Line Numbers Column */}
ref={preRef} <div
ref={lineNumbersRef}
aria-hidden="true" aria-hidden="true"
className="absolute inset-0 m-0 p-2 font-mono text-xs text-main leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] pointer-events-none select-none overflow-hidden" onScroll={handleLineNumbersScroll}
onWheel={handleLineNumbersWheel}
data-testid="code-line-numbers"
className="select-none py-2 pl-2.5 pr-2 font-mono text-xs text-faint leading-relaxed text-right border-r border-border/40 shrink-0 overflow-hidden"
> >
<code {Array.from({ length: lineCount }, (_, i) => (
className={prismLang ? `language-${prismLang}` : 'language-none'} <div key={i + 1} data-line-number={i + 1}>
dangerouslySetInnerHTML={{ {i + 1}
__html: highlightedHtml + (currentContent.endsWith('\n') ? '\n' : ''), </div>
))}
</div>
{/* Code Content (Overlay + Textarea) */}
<div className="relative flex-1 min-w-0 overflow-hidden">
<pre
ref={preRef}
aria-hidden="true"
className="absolute inset-0 m-0 p-2 font-mono text-xs text-main leading-relaxed whitespace-pre pointer-events-none select-none overflow-hidden"
>
<code
className={prismLang ? `language-${prismLang}` : 'language-none'}
dangerouslySetInnerHTML={{
__html: highlightedHtml + (currentContent.endsWith('\n') ? '\n' : ''),
}}
/>
</pre>
<textarea
ref={(el) => {
localTextareaRef.current = el;
if (textareaRef) textareaRef.current = el;
registerTextareaRef?.(block.id, el);
}} }}
rows={1}
value={currentContent}
readOnly={!canEdit}
spellCheck={false}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
onFocus={handleFocus}
onBlur={handleBlur}
onChange={onTextareaChange}
onKeyDown={onTextareaKeyDown}
onScroll={handleScroll}
style={{ caretColor: 'var(--text-main)' }}
className="relative block w-full m-0 p-2 font-mono text-xs leading-relaxed whitespace-pre bg-transparent text-transparent caret-main selection:bg-accent/25 selection:text-transparent border-0 focus:outline-none focus:ring-0 resize-none overflow-x-auto"
/> />
</pre> </div>
<textarea
ref={(el) => {
localTextareaRef.current = el;
if (textareaRef) textareaRef.current = el;
registerTextareaRef?.(block.id, el);
}}
rows={1}
value={currentContent}
readOnly={!canEdit}
spellCheck={false}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
onFocus={handleFocus}
onBlur={handleBlur}
onChange={onTextareaChange}
onKeyDown={onTextareaKeyDown}
onScroll={handleScroll}
style={{ caretColor: 'var(--text-main)' }}
className="relative block w-full m-0 p-2 font-mono text-xs leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent text-transparent caret-main selection:bg-accent/25 selection:text-transparent border-0 focus:outline-none focus:ring-0 resize-none overflow-hidden"
/>
</div> </div>
</div> </div>
); );
@@ -9,16 +9,24 @@ interface DictationBlockProps {
onCancel: () => void; onCancel: () => void;
} }
function formatDuration(sec: number): string {
const m = String(Math.floor(sec / 60)).padStart(2, '0');
const s = String(sec % 60).padStart(2, '0');
return `${m}:${s}`;
}
// What an empty bullet turns into after /diktat: listening starts right away, the whole bullet is one // What an empty bullet turns into after /diktat: listening starts right away, the whole bullet is one
// button with the live wave and a translucent "Stopp" on top. A tap ends it; the text lands in the // button with live text growing above the wave, mm:ss timer and a translucent "Stopp" on top. A tap ends
// bullet as plain text. // it; the text lands in the bullet as plain text.
export const DictationBlock: React.FC<DictationBlockProps> = ({ onDone, onCancel }) => { export const DictationBlock: React.FC<DictationBlockProps> = ({ onDone, onCancel }) => {
const [interim, setInterim] = useState(''); const [interim, setInterim] = useState('');
const [elapsedSec, setElapsedSec] = useState(0);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const doneRef = useRef(false); const doneRef = useRef(false);
const buttonRef = useRef<HTMLButtonElement>(null); const buttonRef = useRef<HTMLButtonElement>(null);
const voice = useVoiceInput({ const voice = useVoiceInput({
live: true,
continuous: true, continuous: true,
onText: (text, final) => { onText: (text, final) => {
if (!final) { if (!final) {
@@ -45,11 +53,17 @@ export const DictationBlock: React.FC<DictationBlockProps> = ({ onDone, onCancel
triggerHaptic([15, 60, 25]); triggerHaptic([15, 60, 25]);
void voice.start(); void voice.start();
buttonRef.current?.focus(); buttonRef.current?.focus();
const started = Date.now();
const timer = window.setInterval(() => {
setElapsedSec(Math.floor((Date.now() - started) / 1000));
}, 500);
return () => window.clearInterval(timer);
// start once when the bullet turns into the dictation // start once when the bullet turns into the dictation
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const transcribing = voice.state === 'transcribing'; const transcribing = voice.state === 'transcribing';
const chunkBusy = voice.pendingChunks > 0 || transcribing;
return ( return (
<button <button
@@ -71,27 +85,35 @@ export const DictationBlock: React.FC<DictationBlockProps> = ({ onDone, onCancel
}} }}
disabled={transcribing || !!error} disabled={transcribing || !!error}
aria-label="Diktat beenden" aria-label="Diktat beenden"
className="relative w-full min-h-[56px] sm:min-h-[44px] my-1 rounded-card border border-accent bg-accent-dim overflow-hidden flex items-center justify-center cursor-pointer disabled:cursor-default focus:outline-none" className="relative w-full min-h-[56px] sm:min-h-[44px] my-1 px-3 py-2 rounded-card border border-accent bg-accent-dim overflow-hidden flex flex-col gap-1.5 text-left cursor-pointer disabled:cursor-default focus:outline-none"
> >
{/* Live wave across the whole bullet */} {(interim || chunkBusy) && !error && (
<VoiceWave getLevel={voice.level} bars={32} className="absolute inset-x-3 inset-y-2 justify-between h-auto text-accent opacity-60" /> <div className="w-full text-sm text-main whitespace-pre-wrap break-words leading-relaxed pointer-events-none">
{interim && !error && ( {interim}
<span className="absolute inset-x-3 bottom-1 text-[11px] text-main truncate text-left pointer-events-none">{interim}</span> {chunkBusy && <span className="text-muted animate-pulse">{interim ? ' …' : '…'}</span>}
</div>
)} )}
{/* Floating stop label on top */} <div className="relative w-full min-h-[32px] flex items-center justify-between gap-2">
<span className="relative z-10 flex items-center gap-1.5 px-3 py-1 rounded-pill bg-panel/70 text-main text-xs font-semibold backdrop-blur-sm"> {/* Live wave across the bottom bar */}
{error ? ( <VoiceWave getLevel={voice.level} bars={32} className="absolute inset-x-0 inset-y-0 justify-between h-auto text-accent opacity-60" />
<span className="text-status-err font-normal">{error}</span> <span className="relative z-10 px-2 py-0.5 rounded-pill bg-panel/70 text-muted text-[11px] font-mono tabular-nums backdrop-blur-sm">
) : transcribing ? ( {formatDuration(elapsedSec)}
<> </span>
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Wird erkannt… {/* Floating stop label on top */}
</> <span className="relative z-10 flex items-center gap-1.5 px-3 py-1 rounded-pill bg-panel/70 text-main text-xs font-semibold backdrop-blur-sm">
) : ( {error ? (
<> <span className="text-status-err font-normal">{error}</span>
<Square className="w-3 h-3 fill-current" /> Stopp ) : transcribing ? (
</> <>
)} <Loader2 className="w-3.5 h-3.5 animate-spin" /> Wird erkannt…
</span> </>
) : (
<>
<Square className="w-3 h-3 fill-current" /> Stopp
</>
)}
</span>
</div>
</button> </button>
); );
}; };
+288 -145
View File
@@ -18,16 +18,26 @@ import {
CloudOff, CloudOff,
AlertTriangle, AlertTriangle,
GitMerge, GitMerge,
Undo2,
Redo2,
MoreHorizontal,
} from 'lucide-react'; } from 'lucide-react';
import { Document, DocumentBlock, User } from '../../lib/types'; import { Document, DocumentBlock, User } from '../../lib/types';
import { api } from '../../lib/api'; import { api } from '../../lib/api';
import { DocSyncEngine, EngineState } from '../../lib/docSync/engine'; import { DocSyncEngine, EngineState } from '../../lib/docSync/engine';
import { Presence } from '../../lib/docSync/transport';
import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree'; import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree';
import { PresenceAvatars } from './PresenceAvatars';
import { extractHashtags } from '../../lib/hashtags'; import { extractHashtags } from '../../lib/hashtags';
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error'; export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
export interface DocHeaderStatus {
showLive: boolean;
channel: 'ws' | 'sse' | 'longpoll' | 'interval';
otherPeople: Presence[];
avatarsById: Map<string, string>;
}
export interface DocumentEditorProps { export interface DocumentEditorProps {
docId: string; docId: string;
currentUser?: User | null; currentUser?: User | null;
@@ -45,8 +55,14 @@ export interface DocumentEditorProps {
onBackMobile?: () => void; onBackMobile?: () => void;
onOpenTask?: (taskId: string) => void; onOpenTask?: (taskId: string) => void;
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void; onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
onHeaderStatusChange?: (status: DocHeaderStatus | null) => void;
/** Opens the dialog to merge this note into another one (own notes only) */ /** Opens the dialog to merge this note into another one (own notes only) */
onRequestMerge?: (doc: Document) => void; onRequestMerge?: (doc: Document) => void;
/** A note was created from this one (extract) or removed again (undo); keeps the sidebar current */
onDocCreated?: (doc: Document) => void;
onDocRemoved?: (docId: string) => void;
/** 'journal': one day inside the journal list – no own scrolling, header or backlinks, fixed title */
variant?: 'page' | 'journal';
} }
import { EmojiPickerModal } from './EmojiPickerModal'; import { EmojiPickerModal } from './EmojiPickerModal';
@@ -193,8 +209,13 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onBackMobile, onBackMobile,
onOpenTask, onOpenTask,
onSaveStatusChange, onSaveStatusChange,
onHeaderStatusChange,
onRequestMerge, onRequestMerge,
onDocCreated,
onDocRemoved,
variant = 'page',
}) => { }) => {
const inJournal = variant === 'journal';
const [doc, setDoc] = useState<Document | null>(initialDoc || null); const [doc, setDoc] = useState<Document | null>(initialDoc || null);
const [title, setTitle] = useState<string>(initialDoc?.title || ''); const [title, setTitle] = useState<string>(initialDoc?.title || '');
const [blocks, setBlocks] = useState<DocumentBlock[]>(() => const [blocks, setBlocks] = useState<DocumentBlock[]>(() =>
@@ -204,6 +225,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false); const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null); const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
const [showActionsMenu, setShowActionsMenu] = useState<boolean>(false);
const actionsMenuRef = useRef<HTMLDivElement | null>(null);
// Sync engine (operation log): stores every change locally and syncs it with the server // Sync engine (operation log): stores every change locally and syncs it with the server
const engineRef = useRef<DocSyncEngine | null>(null); const engineRef = useRef<DocSyncEngine | null>(null);
@@ -211,6 +234,9 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
const titleInputRef = useRef<HTMLTextAreaElement | HTMLInputElement | null>(null); const titleInputRef = useRef<HTMLTextAreaElement | HTMLInputElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
// The header repeats the title only once the big title has scrolled out of view
const [titleScrolledAway, setTitleScrolledAway] = useState(false);
const outlinerTreeRef = useRef<OutlinerTreeHandle | null>(null); const outlinerTreeRef = useRef<OutlinerTreeHandle | null>(null);
const docRef = useRef<Document | null>(doc); const docRef = useRef<Document | null>(doc);
docRef.current = doc; docRef.current = doc;
@@ -220,6 +246,22 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
const currentBlocksRef = useRef<DocumentBlock[]>(blocks); const currentBlocksRef = useRef<DocumentBlock[]>(blocks);
currentBlocksRef.current = blocks; currentBlocksRef.current = blocks;
useEffect(() => {
const root = scrollRef.current;
const el = titleInputRef.current;
if (inJournal || !root || !el || typeof IntersectionObserver === 'undefined') {
setTitleScrolledAway(false);
return;
}
const observer = new IntersectionObserver(
([entry]) => setTitleScrolledAway(!entry.isIntersecting),
// the sticky header covers the top of the scroll area
{ root, rootMargin: '-56px 0px 0px 0px' }
);
observer.observe(el);
return () => observer.disconnect();
}, [zoomedBlockId, doc?.id, inJournal]);
useEffect(() => { useEffect(() => {
if (!loading && (autoFocusTitle || (doc?.title === 'Neue Notiz' && (!doc?.blocks || doc?.blocks.length <= 1)))) { if (!loading && (autoFocusTitle || (doc?.title === 'Neue Notiz' && (!doc?.blocks || doc?.blocks.length <= 1)))) {
const focusTitle = () => { const focusTitle = () => {
@@ -379,39 +421,75 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
.catch(() => {}); .catch(() => {});
}, [otherPeople.length]); }, [otherPeople.length]);
useEffect(() => {
onHeaderStatusChange?.({
showLive: Boolean(engineState && (engineState.shared || engineState.sessions > 1)),
channel: engineState?.channel || 'ws',
otherPeople,
avatarsById,
});
}, [
engineState?.shared,
engineState?.sessions,
engineState?.channel,
otherPeople,
avatarsById,
onHeaderStatusChange,
]);
useEffect(() => {
return () => {
onHeaderStatusChange?.(null);
};
}, [onHeaderStatusChange]);
useEffect(() => {
if (!showActionsMenu) return;
const handleMouseDown = (e: MouseEvent) => {
if (actionsMenuRef.current && !actionsMenuRef.current.contains(e.target as Node)) {
setShowActionsMenu(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setShowActionsMenu(false);
}
};
document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [showActionsMenu]);
// Short hint at the bottom of the editor (blocked actions, skipped undo), optionally with one action // Short hint at the bottom of the editor (blocked actions, skipped undo), optionally with one action
const [notice, setNotice] = useState<string | null>(null); const [notice, setNotice] = useState<string | null>(null);
const [noticeAction, setNoticeAction] = useState<{ label: string; run: () => void } | null>(null); const [noticeActions, setNoticeActions] = useState<{ label: string; run: () => void }[]>([]);
const noticeTimerRef = useRef<number | null>(null); const noticeTimerRef = useRef<number | null>(null);
const showNotice = useCallback((message: string, action?: { label: string; run: () => void }) => { const showNotice = useCallback((message: string, actions: { label: string; run: () => void }[] = []) => {
setNotice(message); setNotice(message);
setNoticeAction(action || null); setNoticeActions(actions);
if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current); if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current);
noticeTimerRef.current = window.setTimeout(() => { noticeTimerRef.current = window.setTimeout(() => {
setNotice(null); setNotice(null);
setNoticeAction(null); setNoticeActions([]);
}, action ? 7000 : 3500); }, actions.length ? 8000 : 3500);
}, []); }, []);
// A bullet with its children becomes a note of its own; the server swaps the branch for a [[link]] // The last extraction, so undoing it (toast or Strg+Z) also puts the new note into the trash
// and this editor receives that change through the normal sync const lastExtractRef = useRef<{ newDocId: string; blockId: string; link: string } | null>(null);
const handleExtractBlock = useCallback( const finishUndoExtract = useCallback(() => {
async (blockId: string) => { const info = lastExtractRef.current;
const engine = engineRef.current; if (!info) return;
try { const block = currentBlocksRef.current.find((b) => b.id === info.blockId);
await engine?.syncNow(); // the branch as typed so far if (block && block.content === info.link) return; // the undone step was something else
const created = await api.extractBranch(docId, blockId, engine?.clientId || ''); lastExtractRef.current = null;
await engine?.syncNow(); api
showNotice(`In eigene Notiz „${created.title}“ umgewandelt`, { .undoExtract(info.newDocId)
label: 'Öffnen', .then(() => onDocRemoved?.(info.newDocId))
run: () => onOpenDocBlock?.(created.id), .catch((err: any) => showNotice(err?.message || 'Ausgelagerte Notiz konnte nicht entfernt werden'));
}); }, [onDocRemoved, showNotice]);
} catch (err: any) {
showNotice(err?.message || 'Umwandeln fehlgeschlagen');
}
},
[docId, onOpenDocBlock, showNotice]
);
useEffect(() => () => { useEffect(() => () => {
if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current); if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current);
}, []); }, []);
@@ -423,7 +501,9 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
const handleUndo = useCallback(() => { const handleUndo = useCallback(() => {
const res = engineRef.current?.undo(); const res = engineRef.current?.undo();
if (res?.message) showNotice(res.message); if (res?.message) showNotice(res.message);
}, [showNotice]); if (res?.ok) finishUndoExtract();
}, [showNotice, finishUndoExtract]);
const handleRedo = useCallback(() => { const handleRedo = useCallback(() => {
const res = engineRef.current?.redo(); const res = engineRef.current?.redo();
if (res?.message) showNotice(res.message); if (res?.message) showNotice(res.message);
@@ -449,6 +529,38 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
[] []
); );
// A bullet with its children becomes a note of its own. The server only creates the note; the branch
// is replaced here as an ordinary own edit, so it shows at once, syncs like typing and Strg+Z works.
const handleExtractBlock = useCallback(
async (blockId: string) => {
const engine = engineRef.current;
if (!engine) return;
try {
await engine.syncNow(); // the server reads the branch as typed so far
const { document: created, link } = await api.extractBranch(docId, blockId, engine.clientId);
// The focused field keeps its own text while typing; leave it so it shows the link
(document.activeElement as HTMLElement | null)?.blur?.();
const prev = currentBlocksRef.current;
const i = prev.findIndex((b) => b.id === blockId);
if (i >= 0) {
const level = prev[i].level || 0;
let end = i + 1;
while (end < prev.length && (prev[end].level || 0) > level) end++;
applyLocalBlocks([...prev.slice(0, i), { ...prev[i], content: link }, ...prev.slice(end)]);
}
lastExtractRef.current = { newDocId: created.id, blockId, link };
onDocCreated?.(created);
showNotice(`In eigene Notiz „${created.title}“ umgewandelt`, [
{ label: 'Öffnen', run: () => onOpenDocBlock?.(created.id) },
{ label: 'Rückgängig', run: () => handleUndo() },
]);
} catch (err: any) {
showNotice(err?.message || 'Umwandeln fehlgeschlagen');
}
},
[docId, onOpenDocBlock, onDocCreated, showNotice, handleUndo, applyLocalBlocks]
);
// Keep parent sidebar metadata in sync when blocks/tags change // Keep parent sidebar metadata in sync when blocks/tags change
useEffect(() => { useEffect(() => {
if (!docRef.current) return; if (!docRef.current) return;
@@ -634,6 +746,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
lines.push(`${indent}- \`\`\`${b.lang || ''}\n${b.content}\n${indent} \`\`\``); lines.push(`${indent}- \`\`\`${b.lang || ''}\n${b.content}\n${indent} \`\`\``);
} else if (b.type === 'divider') { } else if (b.type === 'divider') {
lines.push(`${indent}- ---`); lines.push(`${indent}- ---`);
} else if (b.type === 'numbered') {
lines.push(`${indent}1. ${b.content}`);
} else { } else {
lines.push(`${indent}- ${b.content}`); lines.push(`${indent}- ${b.content}`);
} }
@@ -674,15 +788,20 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
} }
return ( return (
<div className="flex-1 flex flex-col h-full min-h-0 bg-canvas overflow-y-auto" data-sync-channel={engineState?.channel}> <div
{/* Top Document Action Bar */} ref={scrollRef}
<div className="sticky top-0 z-20 flex flex-wrap items-center justify-between gap-2 px-4 sm:px-6 py-2 bg-canvas border-b border-border"> className={inJournal ? 'flex flex-col bg-editor' : 'flex-1 flex flex-col h-full min-h-0 bg-editor overflow-y-auto'}
<div className="flex items-center gap-2 min-w-0"> data-sync-channel={engineState?.channel}
>
{/* Top Document Action Bar (the journal has one for all days) */}
{!inJournal && (
<div className="sticky top-0 z-20 flex items-center justify-between gap-2 px-4 sm:px-6 py-2 bg-editor border-b border-border">
<div className="flex flex-1 items-center gap-2 min-w-0">
{onBackMobile && ( {onBackMobile && (
<button <button
type="button" type="button"
onClick={onBackMobile} onClick={onBackMobile}
className="lg:hidden btn-icon" className="lg:hidden btn-icon shrink-0 h-11 w-11 sm:h-7 sm:w-7"
title="Zurück zur Seitenleiste" title="Zurück zur Seitenleiste"
> >
<ArrowLeft className="w-3.5 h-3.5" /> <ArrowLeft className="w-3.5 h-3.5" />
@@ -734,7 +853,12 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
})} })}
</nav> </nav>
) : ( ) : (
<div className="flex items-center gap-1.5 text-xs text-muted"> <div
className={`flex min-w-0 items-center gap-1.5 text-xs text-muted transition-opacity duration-200 ${
titleScrolledAway ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
aria-hidden={!titleScrolledAway}
>
<FileText className="w-3.5 h-3.5 text-accent shrink-0" /> <FileText className="w-3.5 h-3.5 text-accent shrink-0" />
<span className="truncate max-w-[180px] font-medium text-main"> <span className="truncate max-w-[180px] font-medium text-main">
{title || doc?.title || 'Dokument'} {title || doc?.title || 'Dokument'}
@@ -779,121 +903,132 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
</div> </div>
{/* Right Action Buttons */} {/* Right Action Buttons */}
<div className="flex items-center gap-1.5"> <div className="flex shrink-0 items-center gap-1.5">
{/* How changes of others arrive (only relevant for shared notes) */} {!inTrash && (
{engineState && (engineState.shared || engineState.sessions > 1) && ( <>
<span <button
className="hidden sm:inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] text-muted bg-panel border border-border" type="button"
title={ onClick={handleUndo}
engineState.channel === 'ws' disabled={!canEdit || !engineState?.canUndo}
? 'Live-Verbindung über WebSocket' className="btn-icon h-11 w-11 sm:h-7 sm:w-7 disabled:opacity-40 disabled:cursor-not-allowed"
: engineState.channel === 'sse' title="Rückgängig (Strg+Z)"
? 'Live-Verbindung über Server-Sent Events (WebSocket wird blockiert)' aria-label="Rückgängig (Strg+Z)"
: engineState.channel === 'longpoll' >
? 'Live über Long-Polling (WebSocket und SSE werden blockiert)' <Undo2 className="w-3.5 h-3.5" />
: 'Keine Live-Verbindung möglich – Änderungen werden alle 3 Sekunden abgefragt' </button>
} <button
> type="button"
<span onClick={handleRedo}
className={`w-1.5 h-1.5 rounded-full ${ disabled={!canEdit || !engineState?.canRedo}
engineState.channel === 'interval' ? 'bg-status-warn' : 'bg-status-ok' className="btn-icon h-11 w-11 sm:h-7 sm:w-7 disabled:opacity-40 disabled:cursor-not-allowed"
}`} title="Wiederholen (Strg+Y)"
/> aria-label="Wiederholen (Strg+Y)"
{engineState.channel === 'longpoll' ? 'Live (Long-Polling)' : engineState.channel === 'interval' ? 'Abgleich alle 3 s' : 'Live'} >
</span> <Redo2 className="w-3.5 h-3.5" />
</button>
</>
)} )}
{/* Who else has this note open */} {doc && !inTrash && (
<PresenceAvatars people={otherPeople} avatars={avatarsById} /> <button
{/* Sharing Section */} type="button"
{doc && !inTrash && (() => { onClick={() => onOpenShareModal(doc)}
const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin'); className="btn-icon h-11 w-11 sm:h-7 sm:w-7"
const sharesCount = doc.sharedWith?.length || 0; title="Notiz teilen und Berechtigungen"
const isShared = sharesCount > 0 || !isOwner || Boolean(doc.sharedFolderId); aria-label="Notiz teilen und Berechtigungen"
const ownerDisplayName = doc.ownerName || 'Eigentümer'; >
<Users className="w-3.5 h-3.5" />
</button>
)}
return ( <div className="relative" ref={actionsMenuRef}>
<div className="flex items-center gap-1.5"> <button
{isShared && ( type="button"
isOwner ? ( onClick={() => setShowActionsMenu((prev) => !prev)}
<button aria-expanded={showActionsMenu}
type="button" aria-haspopup="menu"
onClick={() => onOpenShareModal(doc)} aria-label="Weitere Aktionen"
className="px-2 py-0.5 rounded text-[10px] font-semibold bg-accent/15 text-accent border border-accent/30 flex items-center gap-1 hover:bg-accent/25 hover:border-accent/50 transition-colors cursor-pointer" title="Weitere Aktionen"
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten" className="btn-icon h-11 w-11 sm:h-7 sm:w-7"
> >
<Users className="w-2.5 h-2.5" /> <MoreHorizontal className="w-3.5 h-3.5" />
<span>Geteilt{sharesCount > 0 ? ` (${sharesCount})` : ''}</span> </button>
</button>
) : ( {showActionsMenu && (
<button <div
type="button" role="menu"
onClick={() => onOpenShareModal(doc)} className="absolute right-0 top-full mt-1.5 w-60 rounded-card bg-panel border border-border shadow-lg p-1 z-30 text-xs"
className="px-2 py-0.5 rounded text-[10px] font-medium border border-border text-muted flex items-center gap-1 bg-surface-subtle hover:bg-panel-elevated hover:text-main transition-colors cursor-pointer" >
title="Teilen & Berechtigungen öffnen" {!inTrash && (
> <button
<Users className="w-2.5 h-2.5" /> type="button"
<span>Geteilt von {ownerDisplayName}</span> role="menuitem"
</button> onClick={() => {
) setShowActionsMenu(false);
void handleTogglePin();
}}
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-panel-elevated text-left text-main font-medium flex items-center gap-2 transition-colors cursor-pointer"
>
<Pin
className={`w-3.5 h-3.5 shrink-0 ${
doc?.isPinned ? 'text-status-warn' : 'text-accent'
}`}
/>
<span>{doc?.isPinned ? 'Anheftung lösen' : 'Notiz anheften'}</span>
</button>
)}
{onRequestMerge && doc?.isOwner && !inTrash && (
<button
type="button"
role="menuitem"
onClick={() => {
setShowActionsMenu(false);
onRequestMerge(doc);
}}
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-panel-elevated text-left text-main font-medium flex items-center gap-2 transition-colors cursor-pointer"
>
<GitMerge className="w-3.5 h-3.5 text-accent shrink-0" />
<span>Mit anderer Notiz zusammenführen…</span>
</button>
)} )}
<button <button
type="button" type="button"
onClick={() => onOpenShareModal(doc)} role="menuitem"
className="btn-icon" onClick={() => {
title="Notiz teilen und Berechtigungen" setShowActionsMenu(false);
handleExportMarkdown();
}}
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-panel-elevated text-left text-main font-medium flex items-center gap-2 transition-colors cursor-pointer"
> >
<Users className="w-3.5 h-3.5" /> <Download className="w-3.5 h-3.5 text-accent shrink-0" />
<span>Als Markdown (.md) exportieren</span>
</button> </button>
{onRequestMerge && doc.isOwner && !inTrash && (
<button {canEdit && (doc?.isOwner || doc?.permission === 'owner') && (
type="button" <>
onClick={() => onRequestMerge(doc)} <div className="my-1 border-t border-border" />
className="btn-icon" <button
title="Mit anderer Notiz zusammenführen…" type="button"
> role="menuitem"
<GitMerge className="w-3.5 h-3.5" /> onClick={() => {
</button> setShowActionsMenu(false);
onDeleteDoc(docId);
}}
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-status-err/10 text-left text-status-err font-medium flex items-center gap-2 transition-colors cursor-pointer"
>
<Trash2 className="w-3.5 h-3.5 text-status-err shrink-0" />
<span>Notiz löschen</span>
</button>
</>
)} )}
</div> </div>
); )}
})()} </div>
{!inTrash && <button
type="button"
onClick={handleTogglePin}
className={`btn-icon ${
doc?.isPinned
? 'text-status-warn bg-status-warn/10 border-status-warn/30'
: ''
}`}
title={doc?.isPinned ? 'Anheftung lösen' : 'Notiz anheften'}
>
<Pin className="w-3.5 h-3.5" />
</button>}
<button
type="button"
onClick={handleExportMarkdown}
className="btn-icon"
title="Als Markdown (.md) exportieren"
>
<Download className="w-3.5 h-3.5" />
</button>
{canEdit && (doc?.isOwner || doc?.permission === 'owner') && (
<button
type="button"
onClick={() => onDeleteDoc(docId)}
className="btn-icon-danger"
title="Notiz löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div> </div>
</div> </div>
)}
{/* In the trash: read-only with a way back */} {/* In the trash: read-only with a way back */}
{inTrash && ( {inTrash && (
@@ -923,7 +1058,12 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
{/* Main Content Area */} {/* Main Content Area */}
{/* Phones: almost no side margin; very deep outlines scroll sideways (rows keep a minimum width) */} {/* Phones: almost no side margin; very deep outlines scroll sideways (rows keep a minimum width) */}
<div className="w-full pl-2 pr-1 sm:px-8 py-5 flex-1 overflow-x-auto sm:overflow-x-visible [-webkit-overflow-scrolling:touch]"> {/* shrink-0: the sideways scroll area must keep its full height, so only the editor scrolls vertically */}
<div
className={`w-full pl-2 pr-1 sm:px-8 flex-auto shrink-0 overflow-x-auto sm:overflow-x-visible [-webkit-overflow-scrolling:touch] ${
inJournal ? 'py-3' : 'py-5'
}`}
>
{zoomedBlock ? ( {zoomedBlock ? (
/* ========================================================================= /* =========================================================================
FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW) FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW)
@@ -962,7 +1102,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
}} }}
placeholder="Ast-Titel…" placeholder="Ast-Titel…"
readOnly={!canEdit} readOnly={!canEdit}
className="w-full text-2xl sm:text-3xl font-extrabold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight leading-tight" className="w-full text-2xl sm:text-3xl font-light text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight leading-tight"
/> />
</div> </div>
@@ -1003,6 +1143,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
</div> </div>
) : ( ) : (
<OutlinerTree <OutlinerTree
compact={inJournal}
docId={docId} docId={docId}
blocks={childBlocks} blocks={childBlocks}
canEdit={canEdit} canEdit={canEdit}
@@ -1038,7 +1179,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
<div className="relative"> <div className="relative">
<button <button
type="button" type="button"
onClick={() => canEdit && setShowEmojiPicker((prev) => !prev)} onClick={() => canEdit && !inJournal && setShowEmojiPicker((prev) => !prev)}
className="text-2xl sm:text-3xl hover:opacity-80 transition-opacity cursor-pointer select-none leading-none bg-transparent border-0 outline-none p-0 focus:outline-none focus:ring-0 shadow-none" className="text-2xl sm:text-3xl hover:opacity-80 transition-opacity cursor-pointer select-none leading-none bg-transparent border-0 outline-none p-0 focus:outline-none focus:ring-0 shadow-none"
title="Icon ändern" title="Icon ändern"
> >
@@ -1063,7 +1204,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
}} }}
rows={1} rows={1}
value={title} value={title}
readOnly={!canEdit} readOnly={!canEdit || inJournal}
autoFocus={autoFocusTitle} autoFocus={autoFocusTitle}
onChange={(e) => { onChange={(e) => {
handleTitleChange(e); handleTitleChange(e);
@@ -1084,12 +1225,13 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
}} }}
onBlur={handleTitleBlur} onBlur={handleTitleBlur}
placeholder="Titel der Notiz…" placeholder="Titel der Notiz…"
className="flex-1 min-w-0 text-xl sm:text-2xl font-bold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight resize-none leading-snug whitespace-pre-wrap break-words [overflow-wrap:anywhere]" className="flex-1 min-w-0 text-2xl sm:text-3xl font-light text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight resize-none leading-snug whitespace-pre-wrap break-words [overflow-wrap:anywhere]"
/> />
</div> </div>
{/* Logseq Outliner Tree */} {/* Logseq Outliner Tree */}
<OutlinerTree <OutlinerTree
compact={inJournal}
ref={outlinerTreeRef} ref={outlinerTreeRef}
docId={docId} docId={docId}
blocks={blocks} blocks={blocks}
@@ -1115,7 +1257,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onRedo={handleRedo} onRedo={handleRedo}
/> />
{onOpenDocBlock && !inTrash && <BacklinksSection docId={docId} title={title} onOpen={onOpenDocBlock} />} {onOpenDocBlock && !inTrash && !inJournal && <BacklinksSection docId={docId} title={title} onOpen={onOpenDocBlock} />}
</> </>
)} )}
</div> </div>
@@ -1126,19 +1268,20 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 max-w-[90vw] px-3 py-2 rounded-card text-xs text-main bg-panel-elevated border border-border shadow-panel" className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 max-w-[90vw] px-3 py-2 rounded-card text-xs text-main bg-panel-elevated border border-border shadow-panel"
> >
<span>{notice}</span> <span>{notice}</span>
{noticeAction && ( {noticeActions.map((a) => (
<button <button
key={a.label}
type="button" type="button"
onClick={() => { onClick={() => {
noticeAction.run();
setNotice(null); setNotice(null);
setNoticeAction(null); setNoticeActions([]);
a.run();
}} }}
className="ml-3 font-semibold text-accent hover:underline min-h-[44px] sm:min-h-0" className="ml-3 font-semibold text-accent hover:underline min-h-[44px] sm:min-h-0"
> >
{noticeAction.label} {a.label}
</button> </button>
)} ))}
</div> </div>
)} )}
</div> </div>
@@ -0,0 +1,123 @@
import React, { useEffect, useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { MONTHS, monthGrid, parseISODate, todayISO } from '../../lib/journal';
interface JournalCalendarProps {
/** Day currently in view; the calendar follows its month */
activeDate: string;
/** Days with written entries (get a dot) */
entryDates: Set<string>;
onSelect: (date: string) => void;
}
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
export const JournalCalendar: React.FC<JournalCalendarProps> = ({ activeDate, entryDates, onSelect }) => {
const active = parseISODate(activeDate);
const [view, setView] = useState({ year: active.getFullYear(), month: active.getMonth() });
useEffect(() => {
const d = parseISODate(activeDate);
setView({ year: d.getFullYear(), month: d.getMonth() });
}, [activeDate]);
const today = todayISO();
const weeks = useMemo(() => monthGrid(view.year, view.month), [view]);
const years = useMemo(() => {
const now = new Date().getFullYear();
let first = now - 5;
entryDates.forEach((d) => {
first = Math.min(first, Number(d.slice(0, 4)));
});
const list: number[] = [];
for (let y = now + 1; y >= first; y--) list.push(y);
return list;
}, [entryDates]);
const shiftMonth = (delta: number) =>
setView((v) => {
const d = new Date(v.year, v.month + delta, 1);
return { year: d.getFullYear(), month: d.getMonth() };
});
return (
<div className="select-none text-xs" aria-label="Journal-Kalender">
<div className="flex items-center gap-1 mb-1.5">
<button
type="button"
onClick={() => shiftMonth(-1)}
className="btn-icon h-9 w-9 sm:h-7 sm:w-7 shrink-0"
title="Vorheriger Monat"
aria-label="Vorheriger Monat"
>
<ChevronLeft className="w-3.5 h-3.5" />
</button>
<select
value={view.month}
onChange={(e) => setView((v) => ({ ...v, month: Number(e.target.value) }))}
className="flex-1 min-w-0 h-9 sm:h-7 px-1.5 input-field text-xs font-semibold"
aria-label="Monat"
>
{MONTHS.map((m, i) => (
<option key={m} value={i}>
{m}
</option>
))}
</select>
<select
value={view.year}
onChange={(e) => setView((v) => ({ ...v, year: Number(e.target.value) }))}
className="w-[4.5rem] h-9 sm:h-7 px-1.5 input-field text-xs font-semibold"
aria-label="Jahr"
>
{years.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
<button
type="button"
onClick={() => shiftMonth(1)}
className="btn-icon h-9 w-9 sm:h-7 sm:w-7 shrink-0"
title="Nächster Monat"
aria-label="Nächster Monat"
>
<ChevronRight className="w-3.5 h-3.5" />
</button>
</div>
<div className="grid grid-cols-7 gap-0.5 text-center text-[10px] text-faint font-semibold mb-0.5">
{WEEKDAYS.map((w) => (
<span key={w}>{w}</span>
))}
</div>
<div className="grid grid-cols-7 gap-0.5">
{weeks.flat().map((date, i) =>
date ? (
<button
key={date}
type="button"
onClick={() => onSelect(date)}
aria-current={date === activeDate ? 'date' : undefined}
title={parseISODate(date).toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
className={`relative h-9 sm:h-7 rounded-btn text-xs tabular-nums transition-colors cursor-pointer ${
date === activeDate
? 'bg-accent-dim text-accent font-bold'
: date === today
? 'text-accent font-semibold ring-1 ring-inset ring-accent-border hover:bg-panel-elevated'
: 'text-main hover:bg-panel-elevated'
}`}
>
{Number(date.slice(8))}
{entryDates.has(date) && (
<span className="absolute left-1/2 -translate-x-1/2 bottom-0.5 w-1 h-1 rounded-full bg-accent" />
)}
</button>
) : (
<span key={`empty-${i}`} />
)
)}
</div>
</div>
);
};
@@ -0,0 +1,246 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ArrowLeft, CalendarDays, ChevronDown, Users } from 'lucide-react';
import { Document, JournalDay, User } from '../../lib/types';
import { shortDateLabel, todayISO } from '../../lib/journal';
import { DocumentEditor } from './DocumentEditor';
import { JournalCalendar } from './JournalCalendar';
const PAGE = 7;
interface JournalViewProps {
days: JournalDay[];
/** Days jumped to over the calendar (shown even while still empty) */
extraDates: string[];
activeDate: string;
onActiveDateChange: (date: string) => void;
/** Set by the calendar; the view scrolls there (nonce: the same day again) */
jumpRequest: { date: string; nonce: number } | null;
/** Opens (creates on first use) the note of a day */
onOpenDay: (date: string) => Promise<Document | null>;
onJump: (date: string) => void;
getDoc: (id: string) => Document | undefined;
currentUser?: User | null;
availableFolders: string[];
onOpenShareModal: (doc: Document) => void;
onDocMetadataChange: (updated: Document) => void;
onDeleteDoc: (docId: string) => void;
onSelectTag: (tag: string) => void;
onWikiLinkClick?: (pageTitle: string) => void;
onOpenDocBlock?: (docId: string, blockId?: string) => void;
onOpenTask?: (taskId: string) => void;
onBackMobile: () => void;
/** A day was saved (its calendar dot may change) */
onDaySaved: () => void;
}
// The journal: one note per day below each other, today on top, older days load while scrolling.
export const JournalView: React.FC<JournalViewProps> = ({
days,
extraDates,
activeDate,
onActiveDateChange,
jumpRequest,
onOpenDay,
onJump,
getDoc,
currentUser,
availableFolders,
onOpenShareModal,
onDocMetadataChange,
onDeleteDoc,
onSelectTag,
onWikiLinkClick,
onOpenDocBlock,
onOpenTask,
onBackMobile,
onDaySaved,
}) => {
const today = todayISO();
const docIdByDate = useMemo(() => new Map(days.map((d) => [d.date, d.docId])), [days]);
const entryDates = useMemo(() => new Set(days.filter((d) => !d.empty).map((d) => d.date)), [days]);
// Today, every day with text and the days jumped to – newest first
const dates = useMemo(() => {
const set = new Set<string>([today, ...extraDates]);
days.forEach((d) => {
if (!d.empty) set.add(d.date);
});
return Array.from(set).sort((a, b) => b.localeCompare(a));
}, [days, extraDates, today]);
const [limit, setLimit] = useState(PAGE);
const shown = dates.slice(0, limit);
const [showCalendar, setShowCalendar] = useState(false);
// Days on screen without a note yet (today, a day jumped to) get one
const opening = useRef(new Set<string>());
useEffect(() => {
shown.forEach((date) => {
if (!docIdByDate.has(date) && !opening.current.has(date)) {
opening.current.add(date);
void onOpenDay(date).finally(() => opening.current.delete(date));
}
});
}, [shown, docIdByDate, onOpenDay]);
const scrollRef = useRef<HTMLDivElement | null>(null);
const sectionRefs = useRef(new Map<string, HTMLElement>());
// Jump: make sure the day is rendered, then scroll to it once its note is there
const pendingJump = useRef<string | null>(null);
const handledJump = useRef(0);
useEffect(() => {
if (!jumpRequest || handledJump.current === jumpRequest.nonce) return;
const idx = dates.indexOf(jumpRequest.date);
if (idx === -1) return; // the parent adds the day first
handledJump.current = jumpRequest.nonce;
if (idx >= limit) setLimit(idx + PAGE);
pendingJump.current = jumpRequest.date;
}, [jumpRequest, dates, limit]);
useEffect(() => {
const date = pendingJump.current;
if (!date) return;
const el = sectionRefs.current.get(date);
if (el && docIdByDate.has(date)) {
pendingJump.current = null;
el.scrollIntoView({ block: 'start' });
}
});
// The day at the top of the scroll area is the active one (calendar, share button)
const onScroll = useCallback(() => {
const root = scrollRef.current;
if (!root) return;
const top = root.getBoundingClientRect().top + 48;
let current = shown[0];
for (const date of shown) {
const el = sectionRefs.current.get(date);
if (!el) continue;
if (el.getBoundingClientRect().top <= top) current = date;
else break;
}
if (current && current !== activeDate) onActiveDateChange(current);
}, [shown, activeDate, onActiveDateChange]);
// Load older days when the end comes into view
const sentinelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const el = sentinelRef.current;
const root = scrollRef.current;
if (!el || !root || typeof IntersectionObserver === 'undefined' || limit >= dates.length) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) setLimit((l) => l + PAGE);
},
{ root, rootMargin: '400px 0px' }
);
observer.observe(el);
return () => observer.disconnect();
}, [limit, dates.length]);
const activeDocId = docIdByDate.get(activeDate);
const activeDoc = activeDocId ? getDoc(activeDocId) : undefined;
const sharedCount = activeDoc?.sharedWith?.length ?? 0;
return (
<div className="flex-1 flex flex-col min-h-0 bg-editor">
{/* One header for all days: the day in view and its sharing */}
<div className="shrink-0 flex items-center gap-2 px-4 sm:px-6 py-2 border-b border-border">
<button
type="button"
onClick={onBackMobile}
className="lg:hidden btn-icon shrink-0 h-11 w-11"
title="Zurück zur Seitenleiste"
>
<ArrowLeft className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => setShowCalendar((v) => !v)}
className="lg:pointer-events-none flex-1 min-w-0 flex items-center gap-1.5 text-xs text-main font-medium text-left cursor-pointer"
aria-expanded={showCalendar}
title="Kalender"
>
<CalendarDays className="w-3.5 h-3.5 text-accent shrink-0" />
<span className="truncate">Journal · {shortDateLabel(activeDate)}</span>
<ChevronDown className={`lg:hidden w-3.5 h-3.5 text-muted shrink-0 transition-transform ${showCalendar ? 'rotate-180' : ''}`} />
</button>
{activeDate !== today && (
<button
type="button"
onClick={() => onJump(today)}
className="btn-action shrink-0 h-11 sm:h-7 px-2.5 text-xs"
>
Heute
</button>
)}
<button
type="button"
disabled={!activeDoc || !activeDoc.isOwner}
onClick={() => activeDoc && onOpenShareModal(activeDoc)}
className="btn-icon shrink-0 h-11 w-11 sm:h-7 sm:w-auto sm:px-2 gap-1 disabled:opacity-40"
title={activeDoc ? `${activeDoc.title} teilen` : 'Teilen'}
>
<Users className="w-3.5 h-3.5" />
<span className="hidden sm:inline text-xs">{sharedCount > 0 ? `Geteilt (${sharedCount})` : 'Teilen'}</span>
</button>
</div>
{showCalendar && (
<div className="lg:hidden shrink-0 px-4 py-3 border-b border-border bg-sidebar">
<JournalCalendar
activeDate={activeDate}
entryDates={entryDates}
onSelect={(date) => {
setShowCalendar(false);
onJump(date);
}}
/>
</div>
)}
<div ref={scrollRef} onScroll={onScroll} className="flex-1 min-h-0 overflow-y-auto">
{shown.map((date) => {
const docId = docIdByDate.get(date);
return (
<section
key={date}
data-journal-date={date}
ref={(el) => {
if (el) sectionRefs.current.set(date, el);
else sectionRefs.current.delete(date);
}}
className="border-b border-border last:border-b-0 min-h-[8rem]"
>
{docId ? (
<DocumentEditor
key={docId}
variant="journal"
docId={docId}
currentUser={currentUser}
initialDoc={getDoc(docId) || null}
availableFolders={availableFolders}
onDocMetadataChange={onDocMetadataChange}
onDeleteDoc={onDeleteDoc}
onOpenShareModal={onOpenShareModal}
onSelectTag={onSelectTag}
onWikiLinkClick={onWikiLinkClick}
onOpenDocBlock={onOpenDocBlock}
onOpenTask={onOpenTask}
onSaveStatusChange={(status) => {
if (status === 'saved') onDaySaved();
}}
/>
) : (
<div className="px-4 sm:px-8 py-6 text-xs text-muted">{shortDateLabel(date)} wird geöffnet…</div>
)}
</section>
);
})}
{limit < dates.length && <div ref={sentinelRef} className="h-12" />}
{/* Room below the oldest day, so every day can be scrolled to the top */}
<div className="h-[60vh]" aria-hidden />
</div>
</div>
);
};
File diff suppressed because it is too large. Load diff
+12 -174
View File
@@ -1,6 +1,8 @@
import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react'; import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
import { DocumentBlock, BlockType } from '../../lib/types'; import { DocumentBlock, BlockType } from '../../lib/types';
import { OutlinerBlock } from './OutlinerBlock'; import { OutlinerBlock } from './OutlinerBlock';
import { parsePastedLines } from '../../lib/pasteOutline';
import { numberedLabels } from '../../lib/outlineList';
export interface OutlinerTreeHandle { export interface OutlinerTreeHandle {
focusFirstBlock: () => void; focusFirstBlock: () => void;
@@ -9,6 +11,8 @@ export interface OutlinerTreeHandle {
export interface OutlinerTreeProps { export interface OutlinerTreeProps {
docId: string; docId: string;
/** Smaller click area below the last bullet (journal days) */
compact?: boolean;
blocks: DocumentBlock[]; blocks: DocumentBlock[];
canEdit: boolean; canEdit: boolean;
levelOffset?: number; levelOffset?: number;
@@ -34,6 +38,7 @@ export interface OutlinerTreeProps {
export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
docId, docId,
compact = false,
blocks, blocks,
canEdit, canEdit,
levelOffset = 0, levelOffset = 0,
@@ -235,13 +240,16 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
return result; return result;
}, [blocks, levelOffset]); }, [blocks, levelOffset]);
const listLabels = useMemo(() => numberedLabels(blocks), [blocks]);
const handleCreateBlockBelow = useCallback( const handleCreateBlockBelow = useCallback(
(afterBlockId: string, newContent: string = '') => { (afterBlockId: string, newContent: string = '') => {
if (!canEdit) return; if (!canEdit) return;
const idx = blocks.findIndex((b) => b.id === afterBlockId); const idx = blocks.findIndex((b) => b.id === afterBlockId);
const currentBlock = idx >= 0 ? blocks[idx] : null; const currentBlock = idx >= 0 ? blocks[idx] : null;
const level = currentBlock ? Math.max(0, currentBlock.level || 0) : 0; const level = currentBlock ? Math.max(0, currentBlock.level || 0) : 0;
const inheritedType: BlockType = 'text'; // Numbered lists continue on Enter (an empty item ends the list, see OutlinerBlock)
const inheritedType: BlockType = currentBlock?.type === 'numbered' ? 'numbered' : 'text';
const newBlock: DocumentBlock = { const newBlock: DocumentBlock = {
id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`, id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
@@ -270,178 +278,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
const currentBlock = blocks[idx]; const currentBlock = blocks[idx];
const baseLevel = Math.max(0, currentBlock.level || 0); const baseLevel = Math.max(0, currentBlock.level || 0);
// Detect indentation style (2-space vs 4-space tabs) const parsed = parsePastedLines(rawLines);
let detectedSpaceStep = 2;
const leadingSpacesList: number[] = [];
for (const line of rawLines) {
if (!line.trim()) continue;
let sp = 0;
while (sp < line.length && line[sp] === ' ') sp++;
if (sp > 0) leadingSpacesList.push(sp);
}
if (leadingSpacesList.length > 0 && leadingSpacesList.every((s) => s % 4 === 0)) {
detectedSpaceStep = 4;
}
// Parse lines into structured blocks
interface ParsedPasteItem {
type: BlockType;
checked?: boolean;
content: string;
indent: number;
}
const parsed: ParsedPasteItem[] = [];
let currentItem: ParsedPasteItem | null = null;
let inCodeBlock = false;
let afterHeading = false;
const pushCurrentItem = () => {
if (currentItem) {
currentItem.content = currentItem.content.trimEnd();
parsed.push(currentItem);
currentItem = null;
}
};
for (let i = 0; i < rawLines.length; i++) {
const rawLine = rawLines[i];
// 1. Inside a fenced code block
if (inCodeBlock) {
if (currentItem) {
currentItem.content += '\n' + rawLine;
}
if (rawLine.trim().startsWith('```')) {
inCodeBlock = false;
}
continue;
}
// 2. Starts a code fence
if (rawLine.trim().startsWith('```')) {
inCodeBlock = true;
if (afterHeading) {
afterHeading = false;
pushCurrentItem();
currentItem = {
type: 'text',
content: rawLine,
indent: 0,
};
} else if (currentItem) {
currentItem.content += (currentItem.content.endsWith('\n') ? '' : '\n') + rawLine;
} else {
currentItem = {
type: 'text',
content: rawLine,
indent: 0,
};
}
continue;
}
// Measure leading indent
let tabs = 0;
let spaces = 0;
let charIdx = 0;
while (charIdx < rawLine.length) {
if (rawLine[charIdx] === '\t') {
tabs++;
charIdx++;
} else if (rawLine[charIdx] === ' ') {
spaces++;
charIdx++;
} else {
break;
}
}
const strippedLine = rawLine.slice(charIdx);
const lineIndent = tabs + Math.floor(spaces / detectedSpaceStep);
// Blank lines
if (strippedLine.trim() === '') {
if (currentItem) {
currentItem.content += '\n';
}
continue;
}
// Headings: #, ##, ###
const h3Match = strippedLine.match(/^###\s+(.*)$/);
const h2Match = strippedLine.match(/^##\s+(.*)$/);
const h1Match = strippedLine.match(/^#\s+(.*)$/);
if (h1Match || h2Match || h3Match) {
pushCurrentItem();
afterHeading = true;
const type: BlockType = h1Match ? 'h1' : h2Match ? 'h2' : 'h3';
const content = (h1Match || h2Match || h3Match)![1];
parsed.push({ type, content, indent: lineIndent });
continue;
}
// Dividers: ---, ***, ___
if (/^(?:---|---|\*\*\*|___)\s*$/.test(strippedLine.trim())) {
pushCurrentItem();
afterHeading = false;
parsed.push({ type: 'divider', content: '---', indent: lineIndent });
continue;
}
// Todo items: - [ ], - [x], * [ ], * [x]
const todoMatch = strippedLine.match(/^[-*]\s*\[([ xX])\]\s*(.*)$/);
if (todoMatch) {
pushCurrentItem();
afterHeading = false;
parsed.push({
type: 'todo',
checked: todoMatch[1].toLowerCase() === 'x',
content: todoMatch[2],
indent: lineIndent,
});
continue;
}
// Explicit bullet or numbered list: - item, * item, • item, 1. item, 1) item
const bulletMatch = strippedLine.match(/^[-*•]\s+(.*)$/);
const numMatch = strippedLine.match(/^\d+[.)]\s+(.*)$/);
if (bulletMatch || numMatch) {
pushCurrentItem();
afterHeading = false;
parsed.push({
type: 'text',
content: (bulletMatch ? bulletMatch[1] : numMatch![1]),
indent: lineIndent,
});
continue;
}
// Regular text line
if (afterHeading) {
// This starts the single bullet point after the heading
afterHeading = false;
pushCurrentItem();
currentItem = {
type: 'text',
content: strippedLine,
indent: lineIndent,
};
} else if (currentItem) {
if (currentItem.content.endsWith('\n')) {
currentItem.content += strippedLine;
} else {
currentItem.content += '\n' + strippedLine;
}
} else {
currentItem = {
type: 'text',
content: strippedLine,
indent: lineIndent,
};
}
}
pushCurrentItem();
if (parsed.length === 0) return; if (parsed.length === 0) return;
@@ -789,6 +626,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
onUpdateBlock={onUpdateBlock} onUpdateBlock={onUpdateBlock}
onCreateBlockBelow={handleCreateBlockBelow} onCreateBlockBelow={handleCreateBlockBelow}
onPasteMultiLines={handlePasteMultiLines} onPasteMultiLines={handlePasteMultiLines}
listMarker={listLabels.get(block.id)}
onDeleteBlock={handleDeleteBlock} onDeleteBlock={handleDeleteBlock}
onMergeWithPrevious={handleMergeWithPrevious} onMergeWithPrevious={handleMergeWithPrevious}
onIndentBlock={handleIndentBlock} onIndentBlock={handleIndentBlock}
@@ -810,7 +648,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
{canEdit && ( {canEdit && (
<div <div
className={`min-h-[14rem] pt-2 pl-2 transition-colors cursor-text ${ className={`${compact ? 'min-h-[2.5rem]' : 'min-h-[14rem]'} pt-2 pl-2 transition-colors cursor-text ${
dragOverIndex === blocks.length ? 'border-t-2 border-accent bg-accent/10 rounded' : '' dragOverIndex === blocks.length ? 'border-t-2 border-accent bg-accent/10 rounded' : ''
}`} }`}
onClick={(e) => { onClick={(e) => {
+286 -62
View File
@@ -23,6 +23,7 @@ import {
Clock, Clock,
AlertTriangle, AlertTriangle,
Loader2, Loader2,
CalendarDays,
} from 'lucide-react'; } from 'lucide-react';
import { import {
Document, Document,
@@ -36,6 +37,7 @@ import {
TrashedDocument, TrashedDocument,
UnifiedShare, UnifiedShare,
User, User,
JournalDay,
} from '../../lib/types'; } from '../../lib/types';
import { import {
api, api,
@@ -46,8 +48,12 @@ import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { FormSheet } from '../../components/FormSheet'; import { FormSheet } from '../../components/FormSheet';
import { sendDocOps } from '../../lib/docSync/oneoff'; import { sendDocOps } from '../../lib/docSync/oneoff';
import { useMobileActions } from '../../context/MobileActionContext'; import { useMobileActions } from '../../context/MobileActionContext';
import { DocumentEditor, DocSaveStatus } from './DocumentEditor'; import { DocumentEditor, DocSaveStatus, DocHeaderStatus } from './DocumentEditor';
import { PresenceAvatars } from './PresenceAvatars';
import { TrashView } from './TrashView'; import { TrashView } from './TrashView';
import { JournalView } from './JournalView';
import { JournalCalendar } from './JournalCalendar';
import { isJournalEnabled, todayISO } from '../../lib/journal';
import { MergeDialog } from './MergeDialog'; import { MergeDialog } from './MergeDialog';
import { CountBadge } from '../../components/CountBadge'; import { CountBadge } from '../../components/CountBadge';
import { extractHashtags } from '../../lib/hashtags'; import { extractHashtags } from '../../lib/hashtags';
@@ -62,7 +68,7 @@ interface WorkspaceViewProps {
onOpenSearch?: () => void; onOpenSearch?: () => void;
} }
type WorkspaceSubView = 'editor' | 'tag_detail' | 'todos' | 'tags' | 'trash'; type WorkspaceSubView = 'editor' | 'tag_detail' | 'todos' | 'tags' | 'trash' | 'journal';
interface FolderTreeNode { interface FolderTreeNode {
name: string; name: string;
@@ -326,6 +332,33 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
useEffect(() => { useEffect(() => {
reloadTrash(); reloadTrash();
}, [reloadTrash]); }, [reloadTrash]);
// Journal: one note per day, listed separately from the folder tree (can be switched off per device)
const [journalEnabled] = useState(isJournalEnabled);
const [journalDays, setJournalDays] = useState<JournalDay[]>([]);
const [journalExtra, setJournalExtra] = useState<string[]>([]);
const [journalActive, setJournalActive] = useState(todayISO);
const [journalJump, setJournalJump] = useState<{ date: string; nonce: number } | null>(null);
const reloadJournal = useCallback(() => {
if (!journalEnabled) return;
api.getJournal().then(setJournalDays).catch(() => {});
}, [journalEnabled]);
useEffect(() => {
reloadJournal();
}, [reloadJournal]);
// After typing, refresh which days have text (calendar dots) – at most every few seconds
const journalReloadTimer = useRef<number | null>(null);
const reloadJournalSoon = useCallback(() => {
if (journalReloadTimer.current !== null) return;
journalReloadTimer.current = window.setTimeout(() => {
journalReloadTimer.current = null;
reloadJournal();
}, 3000);
}, [reloadJournal]);
useEffect(() => () => {
if (journalReloadTimer.current !== null) window.clearTimeout(journalReloadTimer.current);
}, []);
const journalDocIds = useMemo(() => new Set(journalDays.map((d) => d.docId)), [journalDays]);
const [selectedTag, setSelectedTag] = useState<string | null>(null); const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [tagDetails, setTagDetails] = useState<TagDetails | null>(null); const [tagDetails, setTagDetails] = useState<TagDetails | null>(null);
const [tagNoteDraft, setTagNoteDraft] = useState<string>(''); const [tagNoteDraft, setTagNoteDraft] = useState<string>('');
@@ -386,6 +419,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const [docSaveStatus, setDocSaveStatus] = useState<DocSaveStatus>('saved'); const [docSaveStatus, setDocSaveStatus] = useState<DocSaveStatus>('saved');
const [docSaveError, setDocSaveError] = useState<string | null>(null); const [docSaveError, setDocSaveError] = useState<string | null>(null);
const [docHeaderStatus, setDocHeaderStatus] = useState<DocHeaderStatus | null>(null);
// Drag and Drop state // Drag and Drop state
@@ -406,6 +440,17 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}, []); }, []);
const [draggingFolderPath, setDraggingFolderPath] = useState<string | null>(null); const [draggingFolderPath, setDraggingFolderPath] = useState<string | null>(null);
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null); const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
// A drag that ends without a drop (released elsewhere, Escape) fires only dragend: clear every drag state
useEffect(() => {
const clear = () => {
setDraggingDocId(null);
setDraggingFolderPath(null);
setDragOverFolder(null);
setMergeHoverId(null);
};
document.addEventListener('dragend', clear);
return () => document.removeEventListener('dragend', clear);
}, []);
// Rollup drilldown & View Mode // Rollup drilldown & View Mode
const [todoFolderDrilldown, setTodoFolderDrilldown] = useState<string | null>(null); const [todoFolderDrilldown, setTodoFolderDrilldown] = useState<string | null>(null);
@@ -547,6 +592,34 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setMobileShowDetail(true); setMobileShowDetail(true);
}, []); }, []);
const openJournalDay = useCallback(async (date: string): Promise<Document | null> => {
try {
const doc = await api.openJournalDay(date);
setDocs((prev) => (prev.some((d) => d.id === doc.id) ? prev : [doc, ...prev]));
setJournalDays((prev) =>
[
...prev.filter((d) => d.date !== date),
{ date, docId: doc.id, empty: !(doc.content || '').trim() },
].sort((a, b) => b.date.localeCompare(a.date))
);
return doc;
} catch {
return null;
}
}, []);
const jumpToJournalDay = useCallback((date: string) => {
setJournalExtra((prev) => (prev.includes(date) ? prev : [...prev, date]));
setJournalActive(date);
setJournalJump({ date, nonce: Date.now() });
}, []);
const openJournal = useCallback(() => {
reloadJournal();
setSubView('journal');
setMobileShowDetail(true);
}, [reloadJournal]);
const openTrash = useCallback(() => { const openTrash = useCallback(() => {
reloadTrash(); reloadTrash();
setSubView('trash'); setSubView('trash');
@@ -926,6 +999,12 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
} }
}; };
// Own journal notes appear under "Journal", not in the folder tree
const treeDocs = useMemo(
() => (journalDocIds.size ? docs.filter((d) => !(d.isOwner && journalDocIds.has(d.id))) : docs),
[docs, journalDocIds]
);
const allFolderPaths = useMemo(() => { const allFolderPaths = useMemo(() => {
const set = new Set<string>(folders); const set = new Set<string>(folders);
for (const d of docs) { for (const d of docs) {
@@ -964,7 +1043,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const sharedFolderObj = sharedFolders.find( const sharedFolderObj = sharedFolders.find(
(sf) => sf.mountPath === p || sf.name === p (sf) => sf.mountPath === p || sf.name === p
); );
const folderDocs = docs.filter((d) => d.folderPath === p); const folderDocs = treeDocs.filter((d) => d.folderPath === p);
const node: FolderTreeNode = { const node: FolderTreeNode = {
name: leafName, name: leafName,
@@ -989,16 +1068,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
} }
return rootNodes; return rootNodes;
}, [allFolderPaths, docs, sharedFolders]); }, [allFolderPaths, treeDocs, sharedFolders]);
const pinnedDocs = useMemo( const pinnedDocs = useMemo(
() => docs.filter((d) => d.isPinned), () => treeDocs.filter((d) => d.isPinned),
[docs] [treeDocs]
); );
const rootDocs = useMemo( const rootDocs = useMemo(
() => docs.filter((d) => !d.folderPath && !d.isPinned), () => treeDocs.filter((d) => !d.folderPath && !d.isPinned),
[docs] [treeDocs]
); );
// Global To-Dos rollup collections // Global To-Dos rollup collections
@@ -1216,7 +1295,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}, [tagsSummary, tagSearchQuery, tagSortBy]); }, [tagsSummary, tagSearchQuery, tagSortBy]);
return ( return (
<div className="w-full space-y-3"> <div className="w-full h-full min-h-0 flex flex-col gap-3">
{/* Top Header: Workspace Tabs & Actions */} {/* Top Header: Workspace Tabs & Actions */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2"> <div className="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2">
{/* Workspace selector tabs & left sidebar toggle; on phones the three tabs share the full width */} {/* Workspace selector tabs & left sidebar toggle; on phones the three tabs share the full width */}
@@ -1229,8 +1308,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
subView !== 'editor' subView !== 'editor'
? 'opacity-35 bg-canvas border-border text-faint cursor-not-allowed pointer-events-none' ? 'opacity-35 bg-canvas border-border text-faint cursor-not-allowed pointer-events-none'
: isSidebarCollapsed : isSidebarCollapsed
? 'bg-panel border-accent text-accent cursor-pointer' ? 'bg-canvas border-border text-accent hover:bg-panel cursor-pointer'
: 'bg-canvas border-border text-muted hover:text-main cursor-pointer' : 'bg-canvas border-border text-muted hover:text-main hover:bg-panel cursor-pointer'
}`} }`}
title={ title={
subView !== 'editor' subView !== 'editor'
@@ -1306,70 +1385,144 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</button> </button>
</div> </div>
{/* Right Header Section: Save Status (on phones it sits compactly in the note's own header) */} {/* Right Header Section: Live/Share Status, Avatars & Save Status (on phones it sits compactly in the note's own header) */}
<div className="hidden sm:flex items-center gap-2"> <div className="hidden sm:flex items-center gap-2">
{subView === 'editor' && selectedDocId ? ( {subView === 'editor' && selectedDocId ? (
/* Save Status Pill */ <>
<div {/* Live connection pill */}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium border transition-colors ${ {docHeaderStatus?.showLive && (
docSaveStatus === 'saved' <span
? 'bg-canvas border-border text-muted' className="inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] text-muted bg-panel border border-border"
: docSaveStatus === 'saving' title={
? 'bg-panel border-accent/40 text-accent animate-pulse' docHeaderStatus.channel === 'ws'
: docSaveStatus === 'unsaved' ? 'Live-Verbindung über WebSocket'
? 'bg-status-warn/10 border-status-warn/30 text-status-warn' : docHeaderStatus.channel === 'sse'
: 'bg-status-err/10 border-status-err/30 text-status-err' ? 'Live-Verbindung über Server-Sent Events (WebSocket wird blockiert)'
}`} : docHeaderStatus.channel === 'longpoll'
title={ ? 'Live über Long-Polling (WebSocket und SSE werden blockiert)'
docSaveStatus === 'saved' : 'Keine Live-Verbindung möglich – Änderungen werden alle 3 Sekunden abgefragt'
? 'Alle Änderungen per HTTPS gesichert' }
: docSaveStatus === 'saving' >
? 'Änderungen werden per HTTPS gespeichert…' <span
: docSaveStatus === 'unsaved' className={`w-1.5 h-1.5 rounded-full ${
? 'Ungespeicherte Änderungen (Auto-Save aktiv)' docHeaderStatus.channel === 'interval' ? 'bg-status-warn' : 'bg-status-ok'
: docSaveError || 'Fehler beim Speichern' }`}
} />
> {docHeaderStatus.channel === 'longpoll'
{docSaveStatus === 'saved' && ( ? 'Live (Long-Polling)'
<> : docHeaderStatus.channel === 'interval'
<Check className="w-3.5 h-3.5 text-status-ok" /> ? 'Abgleich alle 3 s'
<span>Gespeichert</span> : 'Live'}
</> </span>
)} )}
{docSaveStatus === 'saving' && (
<> {/* Who else has this note open */}
<Loader2 className="w-3.5 h-3.5 animate-spin text-accent" /> {docHeaderStatus && (
<span>Speichert…</span> <PresenceAvatars
</> people={docHeaderStatus.otherPeople}
avatars={docHeaderStatus.avatarsById}
/>
)} )}
{docSaveStatus === 'unsaved' && (
<> {/* Share pill */}
<Clock className="w-3.5 h-3.5 text-status-warn" /> {selectedDocObj &&
<span>Ungespeichert</span> !selectedDocObj.isArchived &&
</> (() => {
)} const isOwner =
{docSaveStatus === 'error' && ( selectedDocObj.isOwner !== false &&
<> (selectedDocObj.userId === currentUser?.id ||
<AlertTriangle className="w-3.5 h-3.5 text-status-err" /> !selectedDocObj.userId ||
<span>Fehler</span> currentUser?.role === 'admin');
</> const sharesCount = selectedDocObj.sharedWith?.length || 0;
)} const isShared =
</div> sharesCount > 0 || !isOwner || Boolean(selectedDocObj.sharedFolderId);
const ownerDisplayName = selectedDocObj.ownerName || 'Eigentümer';
if (!isShared) return null;
return isOwner ? (
<button
type="button"
onClick={() => openDocShareModal(selectedDocObj)}
className="px-2 py-0.5 rounded text-[10px] font-semibold bg-accent/15 text-accent border border-accent/30 flex items-center gap-1 hover:bg-accent/25 hover:border-accent/50 transition-colors cursor-pointer"
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt{sharesCount > 0 ? ` (${sharesCount})` : ''}</span>
</button>
) : (
<button
type="button"
onClick={() => openDocShareModal(selectedDocObj)}
className="px-2 py-0.5 rounded text-[10px] font-medium border border-border text-muted flex items-center gap-1 bg-surface-subtle hover:bg-panel-elevated hover:text-main transition-colors cursor-pointer"
title="Teilen & Berechtigungen öffnen"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt von {ownerDisplayName}</span>
</button>
);
})()}
{/* Save Status Pill */}
<div
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium border transition-colors ${
docSaveStatus === 'saved'
? 'bg-canvas border-border text-muted'
: docSaveStatus === 'saving'
? 'bg-panel border-accent/40 text-accent animate-pulse'
: docSaveStatus === 'unsaved'
? 'bg-status-warn/10 border-status-warn/30 text-status-warn'
: 'bg-status-err/10 border-status-err/30 text-status-err'
}`}
title={
docSaveStatus === 'saved'
? 'Alle Änderungen per HTTPS gesichert'
: docSaveStatus === 'saving'
? 'Änderungen werden per HTTPS gespeichert…'
: docSaveStatus === 'unsaved'
? 'Ungespeicherte Änderungen (Auto-Save aktiv)'
: docSaveError || 'Fehler beim Speichern'
}
>
{docSaveStatus === 'saved' && (
<>
<Check className="w-3.5 h-3.5 text-status-ok" />
<span>Gespeichert</span>
</>
)}
{docSaveStatus === 'saving' && (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />
<span>Speichert…</span>
</>
)}
{docSaveStatus === 'unsaved' && (
<>
<Clock className="w-3.5 h-3.5 text-status-warn" />
<span>Ungespeichert</span>
</>
)}
{docSaveStatus === 'error' && (
<>
<AlertTriangle className="w-3.5 h-3.5 text-status-err" />
<span>Fehler</span>
</>
)}
</div>
</>
) : null} ) : null}
</div> </div>
</div> </div>
{/* Main Workspace Split Pane */} {/* Main Workspace Split Pane */}
<div className="flex h-[calc(100vh-7.5rem)] bg-canvas rounded-card border border-border overflow-hidden"> <div className="flex flex-1 min-h-0 bg-editor rounded-card border border-border overflow-hidden">
{/* LEFT MASTER SIDEBAR */} {/* LEFT MASTER SIDEBAR */}
<aside <aside
className={`${ className={`${
(subView !== 'editor' && subView !== 'trash') || isSidebarCollapsed (subView !== 'editor' && subView !== 'trash' && subView !== 'journal') || isSidebarCollapsed
? 'hidden' ? 'hidden'
: mobileShowDetail : mobileShowDetail
? 'hidden lg:flex' ? 'hidden lg:flex'
: 'flex' : 'flex'
} w-full lg:w-72 xl:w-80 shrink-0 flex-col bg-panel border-r border-border transition-all duration-200`} } w-full lg:w-72 xl:w-80 shrink-0 flex-col bg-sidebar border-r border-border transition-all duration-200`}
> >
{/* Sidebar Header & Actions */} {/* Sidebar Header & Actions */}
<div className="p-3 border-b border-border space-y-2"> <div className="p-3 border-b border-border space-y-2">
@@ -1416,6 +1569,37 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{/* Scrollable Navigation Tree */} {/* Scrollable Navigation Tree */}
<div className="flex-1 overflow-y-auto p-2 space-y-3"> <div className="flex-1 overflow-y-auto p-2 space-y-3">
{/* Journal on top; while open, its calendar stays below it */}
{journalEnabled && (
<div className="border-b border-border pb-1.5 -mb-1.5">
<button
type="button"
onClick={openJournal}
className={`w-full flex items-center gap-2 px-2 h-11 sm:h-8 rounded-btn text-xs border transition-colors cursor-pointer ${
subView === 'journal'
? 'bg-panel-elevated text-main font-semibold border-border/80'
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
>
<CalendarDays className="w-3.5 h-3.5 text-accent" />
<span className="flex-1 text-left">Journal</span>
{subView === 'journal' ? (
<ChevronDown className="w-3.5 h-3.5 text-muted" />
) : (
<ChevronRight className="w-3.5 h-3.5 text-muted" />
)}
</button>
{subView === 'journal' && (
<div className="hidden lg:block px-1 pt-2 pb-1">
<JournalCalendar
activeDate={journalActive}
entryDates={new Set(journalDays.filter((d) => !d.empty).map((d) => d.date))}
onSelect={jumpToJournalDay}
/>
</div>
)}
</div>
)}
{pinnedDocs.length === 0 && folderTree.length === 0 && rootDocs.length === 0 ? ( {pinnedDocs.length === 0 && folderTree.length === 0 && rootDocs.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted">Noch keine Notizen</div> <div className="px-2 py-2 text-xs text-muted">Noch keine Notizen</div>
) : ( ) : (
@@ -1591,9 +1775,31 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<main <main
className={`${ className={`${
subView !== 'editor' || mobileShowDetail || isSidebarCollapsed ? 'flex' : 'hidden lg:flex' subView !== 'editor' || mobileShowDetail || isSidebarCollapsed ? 'flex' : 'hidden lg:flex'
} flex-1 flex-col min-w-0 h-full overflow-hidden bg-canvas`} } flex-1 flex-col min-w-0 h-full overflow-hidden bg-editor`}
> >
{subView === 'trash' ? ( {subView === 'journal' && journalEnabled ? (
<JournalView
days={journalDays}
extraDates={journalExtra}
activeDate={journalActive}
onActiveDateChange={setJournalActive}
jumpRequest={journalJump}
onOpenDay={openJournalDay}
onJump={jumpToJournalDay}
getDoc={(id) => docs.find((d) => d.id === id)}
currentUser={currentUser}
availableFolders={allFolderPaths}
onOpenShareModal={openDocShareModal}
onDocMetadataChange={handleDocMetadataChange}
onDeleteDoc={handleDeleteDocument}
onSelectTag={handleSelectTag}
onWikiLinkClick={handleWikiLinkClick}
onOpenDocBlock={handleJumpToDocBlock}
onOpenTask={onOpenTask}
onBackMobile={() => setMobileShowDetail(false)}
onDaySaved={reloadJournalSoon}
/>
) : subView === 'trash' ? (
<TrashView <TrashView
items={trash} items={trash}
onOpen={handleOpenTrashedDoc} onOpen={handleOpenTrashedDoc}
@@ -1622,12 +1828,30 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
onWikiLinkClick={handleWikiLinkClick} onWikiLinkClick={handleWikiLinkClick}
onOpenDocBlock={handleJumpToDocBlock} onOpenDocBlock={handleJumpToDocBlock}
onRequestMerge={(d) => setMergeDialog({ sourceId: d.id })} onRequestMerge={(d) => setMergeDialog({ sourceId: d.id })}
onDocCreated={(created) => {
setDocs((prev) => (prev.some((d) => d.id === created.id) ? prev : [created, ...prev]));
if (created.folderPath) {
const parts = created.folderPath.split('/');
const open: Record<string, boolean> = {};
parts.forEach((_, i) => {
open[parts.slice(0, i + 1).join('/')] = true;
});
setExpandedFolders((prev) => ({ ...prev, ...open }));
}
onStatsChange();
}}
onDocRemoved={(id) => {
setDocs((prev) => prev.filter((d) => d.id !== id));
reloadTrash();
onStatsChange();
}}
onBackMobile={() => setMobileShowDetail(false)} onBackMobile={() => setMobileShowDetail(false)}
onOpenTask={onOpenTask} onOpenTask={onOpenTask}
onSaveStatusChange={(status, err) => { onSaveStatusChange={(status, err) => {
setDocSaveStatus(status); setDocSaveStatus(status);
setDocSaveError(err || null); setDocSaveError(err || null);
}} }}
onHeaderStatusChange={setDocHeaderStatus}
/> />
) : subView === 'tags' ? ( ) : subView === 'tags' ? (
/* ========================================================================= /* =========================================================================
@@ -2292,7 +2516,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</div> </div>
</div> </div>
) : ( ) : (
<div className="flex-1 flex flex-col items-center justify-center p-12 text-center bg-canvas"> <div className="flex-1 flex flex-col items-center justify-center p-12 text-center bg-editor">
<FileText className="w-10 h-10 text-faint mb-2" /> <FileText className="w-10 h-10 text-faint mb-2" />
<h3 className="text-sm font-semibold text-main mb-1"> <h3 className="text-sm font-semibold text-main mb-1">
Keine Notiz ausgewählt Keine Notiz ausgewählt
@@ -209,4 +209,55 @@ describe('CodeBlockEditor', () => {
expect(res2.updates[res2.updates.length - 1]?.type).toBe('code'); expect(res2.updates[res2.updates.length - 1]?.type).toBe('code');
expect(res2.updates[res2.updates.length - 1]?.lang).toBe('json'); expect(res2.updates[res2.updates.length - 1]?.lang).toBe('json');
}); });
it('renders line numbers column with aria-hidden, user-select: none, and correct line count including trailing empty lines and 100+ lines', () => {
// 1. Single empty line
const { container, rerender } = render(
<CodeBlockEditor block={makeCodeBlock({ id: 'code-empty', content: '' })} content="" canEdit />
);
const getLineContainer = () => container.querySelector('[data-testid="code-line-numbers"]') as HTMLElement;
expect(getLineContainer()).toBeTruthy();
expect(getLineContainer().getAttribute('aria-hidden')).toBe('true');
expect(getLineContainer().className).toContain('select-none');
let lineEls = container.querySelectorAll('[data-line-number]');
expect(lineEls).toHaveLength(1);
expect(lineEls[0].textContent?.trim()).toBe('1');
// 2. Trailing empty line gives an extra line number (e.g. line 1 + empty line 2)
rerender(
<CodeBlockEditor
block={makeCodeBlock({ id: 'code-trailing', content: `first line
` })}
content={`first line
`}
canEdit
/>
);
lineEls = container.querySelectorAll('[data-line-number]');
expect(lineEls).toHaveLength(2);
expect(lineEls[0].textContent?.trim()).toBe('1');
expect(lineEls[1].textContent?.trim()).toBe('2');
// 3. 120 lines (100+ lines test)
const longCode = Array.from({ length: 120 }, (_, i) => `const x_${i + 1} = ${i + 1};`).join('\n');
rerender(
<CodeBlockEditor
block={makeCodeBlock({ id: 'code-long', content: longCode })}
content={longCode}
canEdit
/>
);
lineEls = container.querySelectorAll('[data-line-number]');
expect(lineEls).toHaveLength(120);
expect(lineEls[0].textContent?.trim()).toBe('1');
expect(lineEls[99].textContent?.trim()).toBe('100');
expect(lineEls[119].textContent?.trim()).toBe('120');
// Textarea has whitespace-pre and overlay has whitespace-pre
const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
const pre = container.querySelector('pre') as HTMLPreElement;
expect(textarea.className).toContain('whitespace-pre');
expect(pre.className).toContain('whitespace-pre');
});
}); });
@@ -0,0 +1,163 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { DocumentEditor } from '../DocumentEditor';
import { api } from '../../../lib/api';
import { Document } from '../../../lib/types';
import { EngineState } from '../../../lib/docSync/engine';
let currentListener: ((state: EngineState) => void) | null = null;
const mockUndo = vi.fn(() => ({ ok: true }));
const mockRedo = vi.fn(() => ({ ok: true }));
const baseEngineState: EngineState = {
ready: true,
status: 'saved',
role: 'owner',
title: 'Test Notiz',
blocks: [{ id: 'b1', documentId: 'doc_1', type: 'text', content: 'Hallo', level: 0, position: 1000 }],
pendingCount: 0,
shared: true,
sessions: 2,
presence: [{ userId: 'u2', name: 'Bob' }],
lockedByOthers: [],
conflicts: [],
canUndo: false,
canRedo: false,
channel: 'ws',
};
vi.mock('../../../lib/docSync/engine', () => {
return {
DocSyncEngine: class {
subscribe(fn: (state: EngineState) => void) {
currentListener = fn;
fn(baseEngineState);
return () => {
currentListener = null;
};
}
start() {
return Promise.resolve();
}
stop() {}
getState() {
return baseEngineState;
}
setTaskLinks() {}
focusBlock() {}
applyFlat() {}
setTitle() {}
undo() {
return mockUndo();
}
redo() {
return mockRedo();
}
},
};
});
vi.mock('../../../lib/api', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../lib/api')>();
return {
...actual,
api: {
...actual.api,
getDocument: vi.fn(),
getUsersDirectory: vi.fn(() => Promise.resolve([])),
getDocumentBacklinks: vi.fn(() => Promise.resolve({ backlinks: [] })),
},
};
});
const makeDoc = (overrides: Partial<Document> = {}): Document => ({
id: 'doc_1',
userId: 'u1',
title: 'Test Notiz',
icon: '📝',
folderPath: '',
content: '',
blocks: [{ id: 'b1', documentId: 'doc_1', type: 'text', content: 'Hallo', level: 0, position: 1000 }],
tags: [],
isPinned: false,
isArchived: false,
isOwner: true,
createdAt: '2026-10-30T08:00:00Z',
updatedAt: '2026-10-30T08:00:00Z',
...overrides,
});
describe('DocumentEditor header actions & status callback (Teil D)', () => {
beforeEach(() => {
vi.clearAllMocks();
const doc = makeDoc();
vi.mocked(api.getDocument).mockResolvedValue(doc);
});
it('reports live/presence status upward via onHeaderStatusChange and renders Undo/Redo + Three-Dots menu', async () => {
const doc = makeDoc({ isOwner: true });
const onHeaderStatusChange = vi.fn();
const onRequestMerge = vi.fn();
const onDeleteDoc = vi.fn();
const onOpenShareModal = vi.fn();
render(
<DocumentEditor
docId="doc_1"
initialDoc={doc}
availableFolders={[]}
onDocMetadataChange={vi.fn()}
onDeleteDoc={onDeleteDoc}
onOpenShareModal={onOpenShareModal}
onSelectTag={vi.fn()}
onRequestMerge={onRequestMerge}
onHeaderStatusChange={onHeaderStatusChange}
/>
);
await waitFor(() => {
expect(onHeaderStatusChange).toHaveBeenCalledWith(
expect.objectContaining({
showLive: true,
channel: 'ws',
})
);
});
// Undo / Redo buttons disabled initially
const undoBtn = screen.getByTitle('Rückgängig (Strg+Z)') as HTMLButtonElement;
const redoBtn = screen.getByTitle('Wiederholen (Strg+Y)') as HTMLButtonElement;
expect(undoBtn.disabled).toBe(true);
expect(redoBtn.disabled).toBe(true);
// Enable Undo & Redo via engine state update
currentListener?.({ ...baseEngineState, canUndo: true, canRedo: true });
await waitFor(() => {
expect(undoBtn.disabled).toBe(false);
expect(redoBtn.disabled).toBe(false);
});
fireEvent.click(undoBtn);
expect(mockUndo).toHaveBeenCalledTimes(1);
fireEvent.click(redoBtn);
expect(mockRedo).toHaveBeenCalledTimes(1);
// Share button is directly visible
const shareBtn = screen.getByTitle('Notiz teilen und Berechtigungen');
fireEvent.click(shareBtn);
expect(onOpenShareModal).toHaveBeenCalledWith(doc);
// Three-dots menu opens on click, shows secondary actions, and closes on Escape
const moreBtn = screen.getByTitle('Weitere Aktionen');
fireEvent.click(moreBtn);
expect(screen.getByText('Notiz anheften')).toBeTruthy();
expect(screen.getByText('Mit anderer Notiz zusammenführen…')).toBeTruthy();
expect(screen.getByText('Als Markdown (.md) exportieren')).toBeTruthy();
expect(screen.getByText('Notiz löschen')).toBeTruthy();
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByText('Notiz anheften')).toBeNull();
});
});
@@ -118,3 +118,363 @@ 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 /ca' } });
expect(queryByText('Befehl auswählen')).toBeTruthy();
expect(queryByText('/callout')).toBeTruthy();
// Removing the slash closes the menu
fireEvent.change(ta, { target: { value: 'Text ' } });
expect(queryByText('Befehl auswählen')).toBeNull();
// Even when no command matches, Escape closes the menu
fireEvent.change(ta, { target: { value: '/zzzznotfound' } });
expect(queryByText('Kein Befehl gefunden')).toBeTruthy();
fireEvent.keyDown(ta, { key: 'Escape' });
expect(queryByText('Befehl auswählen')).toBeNull();
});
it('scrolls the active slash command button into view on ArrowDown', () => {
const scrollSpy = vi.fn();
HTMLElement.prototype.scrollIntoView = scrollSpy;
const block = { id: 'b1', type: 'bullet', content: '', level: 0 } as DocumentBlock;
const { container } = renderBlock(block);
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
fireEvent.change(ta, { target: { value: '/' } });
scrollSpy.mockClear();
fireEvent.keyDown(ta, { key: 'ArrowDown' });
expect(scrollSpy).toHaveBeenCalledWith({ block: 'nearest', inline: 'nearest' });
});
it('creates a new block on Enter at the last empty top-level bullet, after outdenting if indented', () => {
const onIndent = vi.fn();
const onCreateBelow = vi.fn();
const indented = { id: 'b1', type: 'bullet', content: '', level: 1 } as DocumentBlock;
const { container, rerender } = render(
<OutlinerBlock
docId="d"
block={indented}
index={0}
isLast
maxAllowedLevel={2}
hasChildren={false}
isFocused
canEdit
onUpdateBlock={() => {}}
onCreateBlockBelow={onCreateBelow}
onDeleteBlock={() => {}}
onIndentBlock={onIndent}
onNavigateFocus={() => {}}
registerTextareaRef={() => {}}
/>
);
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
fireEvent.keyDown(ta, { key: 'Enter' });
expect(onIndent).toHaveBeenCalledWith('b1', -1);
expect(onCreateBelow).not.toHaveBeenCalled();
rerender(
<OutlinerBlock
docId="d"
block={{ ...indented, level: 0 }}
index={0}
isLast
maxAllowedLevel={1}
hasChildren={false}
isFocused
canEdit
onUpdateBlock={() => {}}
onCreateBlockBelow={onCreateBelow}
onDeleteBlock={() => {}}
onIndentBlock={onIndent}
onNavigateFocus={() => {}}
registerTextareaRef={() => {}}
/>
);
fireEvent.keyDown(ta, { key: 'Enter' });
expect(onCreateBelow).toHaveBeenCalledWith('b1', '');
});
it('inserts a bare URL for /link with a web address in an empty bullet, and [Text](url) otherwise', () => {
const updates: DocumentBlock[] = [];
const block = { id: 'b1', type: 'bullet', content: '', level: 0 } as DocumentBlock;
const { container, getByText, getByRole, getByPlaceholderText, rerender } = renderBlock(block, {
onUpdateBlock: (u: DocumentBlock) => updates.push(u),
});
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
fireEvent.change(ta, { target: { value: '/link' } });
fireEvent.keyDown(ta, { key: 'Enter' });
fireEvent.click(getByText('Web-Link'));
fireEvent.change(getByPlaceholderText('https://example.com'), { target: { value: 'https://example.com/test' } });
fireEvent.click(getByRole('button', { name: 'Link einfügen' }));
expect(updates[updates.length - 1]?.content).toBe('https://example.com/test');
// Non-empty bullet keeps [Text](url)
rerender(
<OutlinerBlock
docId="d"
block={{ ...block, content: 'Siehe' }}
index={0}
isLast
maxAllowedLevel={1}
hasChildren={false}
isFocused={false}
canEdit
onUpdateBlock={(u: DocumentBlock) => updates.push(u)}
onCreateBlockBelow={() => {}}
onDeleteBlock={() => {}}
onIndentBlock={() => {}}
onNavigateFocus={() => {}}
registerTextareaRef={() => {}}
/>
);
ta.focus();
fireEvent.change(ta, { target: { value: 'Siehe /link' } });
fireEvent.keyDown(ta, { key: 'Enter' });
fireEvent.click(getByText('Web-Link'));
fireEvent.change(getByPlaceholderText('z. B. MindSift Dokumentation'), { target: { value: 'Doku' } });
fireEvent.change(getByPlaceholderText('https://example.com'), { target: { value: 'https://example.com/doku' } });
fireEvent.click(getByRole('button', { name: 'Link einfügen' }));
expect(updates[updates.length - 1]?.content).toBe('Siehe [Doku](https://example.com/doku)');
});
});
describe('formatting shortcuts (Teil C)', () => {
it('toggles bold with and without selection (Ctrl+B and Cmd+B)', () => {
const block = { id: 'b1', type: 'bullet', content: 'hallo welt', level: 0 } as DocumentBlock;
const { container } = renderBlock(block);
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
// 1. With selection: wraps in **
ta.setSelectionRange(6, 10); // "welt"
const event1 = new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, bubbles: true, cancelable: true });
ta.dispatchEvent(event1);
expect(event1.defaultPrevented).toBe(true);
expect(ta.value).toBe('hallo **welt**');
expect(ta.selectionStart).toBe(8);
expect(ta.selectionEnd).toBe(12);
// 2. With selection where delimiters are directly outside: unwraps **
const event2 = new KeyboardEvent('keydown', { key: 'b', metaKey: true, bubbles: true, cancelable: true });
ta.dispatchEvent(event2);
expect(event2.defaultPrevented).toBe(true);
expect(ta.value).toBe('hallo welt');
expect(ta.selectionStart).toBe(6);
expect(ta.selectionEnd).toBe(10);
// 3. With selection including delimiters: unwraps **
ta.value = 'hallo **welt**';
ta.setSelectionRange(6, 14); // "**welt**"
const event3 = new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, bubbles: true, cancelable: true });
ta.dispatchEvent(event3);
expect(ta.value).toBe('hallo welt');
expect(ta.selectionStart).toBe(6);
expect(ta.selectionEnd).toBe(10);
// 4. Without selection: inserts **** and places cursor between
ta.value = 'start';
ta.setSelectionRange(5, 5);
const event4 = new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, bubbles: true, cancelable: true });
ta.dispatchEvent(event4);
expect(ta.value).toBe('start****');
expect(ta.selectionStart).toBe(7);
expect(ta.selectionEnd).toBe(7);
});
it('toggles italic with and without selection (Ctrl+I)', () => {
const block = { id: 'b1', type: 'bullet', content: 'hallo welt', level: 0 } as DocumentBlock;
const { container } = renderBlock(block);
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
// 1. With selection: wraps in *
ta.setSelectionRange(6, 10); // "welt"
fireEvent.keyDown(ta, { key: 'i', ctrlKey: true });
expect(ta.value).toBe('hallo *welt*');
expect(ta.selectionStart).toBe(7);
expect(ta.selectionEnd).toBe(11);
// 2. Toggles off when already italic
fireEvent.keyDown(ta, { key: 'i', ctrlKey: true });
expect(ta.value).toBe('hallo welt');
expect(ta.selectionStart).toBe(6);
expect(ta.selectionEnd).toBe(10);
// 3. Without selection: inserts ** and places cursor between
fireEvent.change(ta, { target: { value: 'a' } });
ta.setSelectionRange(1, 1);
fireEvent.keyDown(ta, { key: 'i', ctrlKey: true });
expect(ta.value).toBe('a**');
expect(ta.selectionStart).toBe(2);
expect(ta.selectionEnd).toBe(2);
});
it('handles Ctrl+K: with selection wraps in [Text](…) and selects placeholder, without selection opens modal', () => {
const block = { id: 'b1', type: 'bullet', content: 'besuche mindsift heute', level: 0 } as DocumentBlock;
const { container, queryByText } = renderBlock(block);
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
// 1. With selection
ta.setSelectionRange(8, 16); // "mindsift"
const eventWithSel = new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true, cancelable: true });
ta.dispatchEvent(eventWithSel);
expect(eventWithSel.defaultPrevented).toBe(true);
expect(ta.value).toBe('besuche [mindsift](…) heute');
// Placeholder "…" is selected
expect(ta.selectionStart).toBe(19);
expect(ta.selectionEnd).toBe(20);
expect(ta.value.slice(ta.selectionStart, ta.selectionEnd)).toBe('…');
// 2. Without selection: opens LinkModal
ta.setSelectionRange(0, 0);
expect(queryByText('Link einfügen')).toBeNull();
fireEvent.keyDown(ta, { key: 'k', ctrlKey: true });
expect(queryByText('Link einfügen')).toBeTruthy();
});
it('does nothing in code blocks or for read-only readers', () => {
// In code block
const codeBlock = { id: 'c1', type: 'code', content: 'const x = 1;', level: 0 } as DocumentBlock;
const codeView = renderBlock(codeBlock);
const codeTa = codeView.container.querySelector('textarea') as HTMLTextAreaElement;
codeTa.focus();
codeTa.setSelectionRange(6, 7); // "x"
const codeEvent = new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, bubbles: true, cancelable: true });
codeTa.dispatchEvent(codeEvent);
expect(codeEvent.defaultPrevented).toBe(false);
expect(codeTa.value).toBe('const x = 1;');
// For readers (!canEdit)
const bulletBlock = { id: 'b1', type: 'bullet', content: 'text', level: 0 } as DocumentBlock;
const readerView = renderBlock(bulletBlock, { canEdit: false });
const readerTa = readerView.container.querySelector('textarea') as HTMLTextAreaElement;
readerTa.focus();
readerTa.setSelectionRange(0, 4);
const readerEvent = new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, bubbles: true, cancelable: true });
readerTa.dispatchEvent(readerEvent);
expect(readerEvent.defaultPrevented).toBe(false);
expect(readerTa.value).toBe('text');
});
});
describe('outliner polish (Teil A)', () => {
it('opens slash menu at cursor position in the middle of text and inserts link at cursor', () => {
const updates: DocumentBlock[] = [];
const block = { id: 'b1', type: 'bullet', content: 'Vorher Nachher', level: 0 } as DocumentBlock;
const { container, queryByText, getByText, getByPlaceholderText, getByRole } = renderBlock(block, {
onUpdateBlock: (u: DocumentBlock) => updates.push(u),
});
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
// Type "/link" in the middle of the text (selectionStart at 12, right after "/link")
fireEvent.change(ta, { target: { value: 'Vorher /link Nachher', selectionStart: 12, selectionEnd: 12 } });
// Menu should be open with /link, but without /divider or /h1
expect(queryByText('Befehl auswählen')).toBeTruthy();
expect(queryByText('/link')).toBeTruthy();
expect(queryByText('/divider')).toBeNull();
expect(queryByText('/h1')).toBeNull();
// Select /link by pressing Enter
fireEvent.keyDown(ta, { key: 'Enter' });
// Link modal opens
expect(queryByText('Web-Link')).toBeTruthy();
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/test' } });
fireEvent.click(getByRole('button', { name: 'Link einfügen' }));
// Result has link inserted in the middle
expect(updates[updates.length - 1]?.content).toBe('Vorher [Doku](https://example.com/test) Nachher');
});
it('does not offer /divider at end of text, but offers it in empty block', () => {
const block = { id: 'b1', type: 'bullet', content: 'Ein Text', level: 0 } as DocumentBlock;
const { container, queryByText } = renderBlock(block);
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
// At end of non-empty text
fireEvent.change(ta, { target: { value: 'Ein Text /div' } });
expect(queryByText('Befehl auswählen')).toBeTruthy();
expect(queryByText('/divider')).toBeNull();
// In an empty block
fireEvent.change(ta, { target: { value: '/div' } });
expect(queryByText('Befehl auswählen')).toBeTruthy();
expect(queryByText('/divider')).toBeTruthy();
});
it('converts H1 block back to bullet via /text slash command', () => {
const updates: DocumentBlock[] = [];
const block = { id: 'b1', type: 'h1', content: 'Meine Überschrift', level: 0 } as DocumentBlock;
const { container, queryByText } = renderBlock(block, {
onUpdateBlock: (u: DocumentBlock) => updates.push(u),
});
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
fireEvent.change(ta, { target: { value: 'Meine Überschrift /text' } });
expect(queryByText('Befehl auswählen')).toBeTruthy();
expect(queryByText('/text')).toBeTruthy();
fireEvent.keyDown(ta, { key: 'Enter' });
const last = updates[updates.length - 1];
expect(last?.type).toBe('bullet');
expect(last?.content).toBe('Meine Überschrift');
});
it('closes slash menu on mousedown outside', () => {
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: '/' } });
expect(queryByText('Befehl auswählen')).toBeTruthy();
// Click outside on document body
fireEvent.mouseDown(document.body);
expect(queryByText('Befehl auswählen')).toBeNull();
});
it('converts focused divider to empty bullet on Backspace', () => {
const updates: DocumentBlock[] = [];
const block = { id: 'd1', type: 'divider', content: '', level: 0 } as DocumentBlock;
const { container } = renderBlock(block, {
onUpdateBlock: (u: DocumentBlock) => updates.push(u),
});
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
fireEvent.keyDown(ta, { key: 'Backspace' });
const last = updates[updates.length - 1];
expect(last?.type).toBe('bullet');
expect(last?.content).toBe('');
});
});
+16 -7
View File
@@ -28,7 +28,7 @@
body { body {
background-color: var(--bg-app); background-color: var(--bg-app);
color: var(--text-main); color: var(--text-main);
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: 'Inter Variable', 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
overflow-x: hidden; overflow-x: hidden;
transition: background-color 140ms ease, color 140ms ease; transition: background-color 140ms ease, color 140ms ease;
} }
@@ -318,12 +318,14 @@ body {
pointer-events: none; pointer-events: none;
} }
.count-badge-accent { .count-badge-accent {
background-color: var(--accent-primary); background: var(--accent-dim);
color: var(--text-on-accent); color: var(--accent-primary);
border: 1px solid var(--accent-border);
} }
.count-badge-muted { .count-badge-muted {
background-color: var(--text-muted); background-color: var(--bg-panel-elevated);
color: var(--bg-panel); color: var(--text-muted);
border: 1px solid var(--border-color);
} }
.count-badge-corner { .count-badge-corner {
position: absolute; position: absolute;
@@ -429,11 +431,11 @@ body {
--code-keyword: #0000ff; --code-keyword: #0000ff;
--code-string: #a31515; --code-string: #a31515;
--code-number: #098658; --code-number: #098658;
--code-comment: #008000; --code-comment: #007000;
--code-property: #0451a5; --code-property: #0451a5;
--code-function: #795e26; --code-function: #795e26;
--code-type: #267f99; --code-type: #267f99;
--code-punctuation: #3b3b3b; --code-punctuation: #2e2e2e;
--code-variable: #001080; --code-variable: #001080;
} }
@@ -550,6 +552,13 @@ fieldset:disabled :is(button, input, select, textarea) {
cursor: not-allowed; cursor: not-allowed;
} }
/* Workspace surfaces: the note editor and the folder sidebar. Themes may override them (the light theme
uses a white sheet next to a grey sidebar); by default they follow the page and panel colours. */
:root {
--bg-editor: var(--bg-app);
--bg-sidebar: var(--bg-panel);
}
/* Outliner: indent per level and the width a row keeps for its text. Phones indent less and let very /* Outliner: indent per level and the width a row keeps for its text. Phones indent less and let very
deep rows run wider than the screen (the note then scrolls sideways) instead of squeezing the text. */ deep rows run wider than the screen (the note then scrolls sideways) instead of squeezing the text. */
:root { :root {
@@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest';
import { monthGrid, toISODate, parseISODate } from '../journal';
describe('journal dates', () => {
it('builds a Monday-first month grid', () => {
// October 2026 starts on a Thursday
const weeks = monthGrid(2026, 9);
expect(weeks[0]).toEqual([null, null, null, '2026-10-01', '2026-10-02', '2026-10-03', '2026-10-04']);
expect(weeks.flat().filter(Boolean)).toHaveLength(31);
expect(weeks.every((w) => w.length === 7)).toBe(true);
});
it('round-trips local dates', () => {
expect(toISODate(parseISODate('2026-03-01'))).toBe('2026-03-01');
});
});
+11 -9
View File
@@ -1,14 +1,16 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { firstBareUrl, hidePreviewContent } from '../linkPreview'; import { linkCardUrl, hidePreviewContent } from '../linkPreview';
describe('link preview helpers', () => { describe('link preview helpers', () => {
it('finds the first plain https address', () => { it('returns the URL only when the entire trimmed content is a single https address', () => {
expect(firstBareUrl('https://example.com/a?b=1')).toBe('https://example.com/a?b=1'); expect(linkCardUrl('https://example.com/a?b=1')).toBe('https://example.com/a?b=1');
expect(firstBareUrl('Rezept: https://koch.de/spaetzle, lecker!')).toBe('https://koch.de/spaetzle'); expect(linkCardUrl(' https://example.com/page/ ')).toBe('https://example.com/page/');
expect(firstBareUrl('siehe (https://x.de/p).')).toBeNull(); // inside brackets: not a plain address expect(linkCardUrl('Rezept: https://koch.de/spaetzle, lecker!')).toBeNull();
expect(firstBareUrl('[Kochseite](https://koch.de)')).toBeNull(); expect(linkCardUrl('https://a.de https://b.de')).toBeNull();
expect(firstBareUrl('http://unsicher.de')).toBeNull(); expect(linkCardUrl('siehe (https://x.de/p).')).toBeNull();
expect(firstBareUrl('nur Text')).toBeNull(); expect(linkCardUrl('[Kochseite](https://koch.de)')).toBeNull();
expect(linkCardUrl('http://unsicher.de')).toBeNull();
expect(linkCardUrl('nur Text')).toBeNull();
}); });
it('hides a preview by turning the address into a named link', () => { it('hides a preview by turning the address into a named link', () => {
@@ -16,6 +18,6 @@ describe('link preview helpers', () => {
'Lesen: [Käsespätzle Rezept](https://koch.de/x)' 'Lesen: [Käsespätzle Rezept](https://koch.de/x)'
); );
expect(hidePreviewContent('https://koch.de/x', 'https://koch.de/x', '')).toBe('[koch.de](https://koch.de/x)'); expect(hidePreviewContent('https://koch.de/x', 'https://koch.de/x', '')).toBe('[koch.de](https://koch.de/x)');
expect(firstBareUrl(hidePreviewContent('https://koch.de/x', 'https://koch.de/x', 'T'))).toBeNull(); expect(linkCardUrl(hidePreviewContent('https://koch.de/x', 'https://koch.de/x', 'T'))).toBeNull();
}); });
}); });
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { renderInlineMarkdown } from '../markdown';
describe('renderInlineMarkdown', () => {
it('renders bold text with <strong> tag', () => {
const { container } = render(<div>{renderInlineMarkdown('Text mit **fettem** Wort')}</div>);
const strong = container.querySelector('strong');
expect(strong).toBeTruthy();
expect(strong?.textContent).toBe('fettem');
});
it('renders italic text with <em> tag', () => {
const { container } = render(<div>{renderInlineMarkdown('Text mit *kursivem* Wort')}</div>);
const em = container.querySelector('em');
expect(em).toBeTruthy();
expect(em?.textContent).toBe('kursivem');
});
it('renders both bold and italic text in one paragraph', () => {
const { container } = render(<div>{renderInlineMarkdown('Hier ist **fett** und *kursiv* vereint')}</div>);
const strong = container.querySelector('strong');
const em = container.querySelector('em');
expect(strong?.textContent).toBe('fett');
expect(em?.textContent).toBe('kursiv');
});
it('renders links correctly', () => {
const { container } = render(<div>{renderInlineMarkdown('[Testseite](https://example.com)')}</div>);
const link = container.querySelector('a');
expect(link).toBeTruthy();
expect(link?.getAttribute('href')).toBe('https://example.com/');
expect(link?.textContent).toBe('Testseite');
});
});
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { listLabel, numberedLabels } from '../outlineList';
import type { DocumentBlock } from '../types';
const blk = (id: string, level: number, type: DocumentBlock['type'] = 'numbered'): DocumentBlock => ({
id, documentId: 'd', type, content: id, level, position: 0,
});
describe('numbered lists', () => {
it('formats numbers, letters and roman numerals by depth', () => {
expect(listLabel(3, 0)).toBe('3.');
expect(listLabel(2, 1)).toBe('b.');
expect(listLabel(27, 1)).toBe('aa.');
expect(listLabel(4, 2)).toBe('iv.');
expect(listLabel(1, 3)).toBe('1.');
});
it('counts siblings, nests and restarts after other blocks', () => {
const labels = numberedLabels([
blk('a', 0), blk('a1', 1), blk('a2', 1), blk('a2i', 2), blk('b', 0),
blk('note', 0, 'text'), blk('c', 0), blk('plain', 1, 'text'), blk('c1', 2),
]);
expect(Object.fromEntries(labels)).toEqual({
a: '1.', a1: 'a.', a2: 'b.', a2i: 'i.', b: '2.', c: '1.', c1: 'a.',
});
});
});
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { parsePastedLines } from '../pasteOutline';
const parse = (text: string) => parsePastedLines(text.split('\n'));
describe('parsePastedLines', () => {
it('splits paragraphs at blank lines and keeps wrapped lines together', () => {
expect(parse('Erster Absatz\nnoch erster\n\nZweiter Absatz')).toEqual([
{ type: 'text', content: 'Erster Absatz\nnoch erster', indent: 0 },
{ type: 'text', content: 'Zweiter Absatz', indent: 0 },
]);
});
it('keeps numbered lists with nesting (3-space indent)', () => {
expect(parse('1. Eins\n 1. Unterpunkt\n 1. Tiefer\n2. Zwei')).toEqual([
{ type: 'numbered', content: 'Eins', indent: 0 },
{ type: 'numbered', content: 'Unterpunkt', indent: 1 },
{ type: 'numbered', content: 'Tiefer', indent: 2 },
{ type: 'numbered', content: 'Zwei', indent: 0 },
]);
});
it('reads headings, todos, bullets and dividers', () => {
expect(parse('# Titel\nText danach\n- [x] erledigt\n- Punkt\n---')).toEqual([
{ type: 'h1', content: 'Titel', indent: 0 },
{ type: 'text', content: 'Text danach', indent: 0 },
{ type: 'todo', checked: true, content: 'erledigt', indent: 0 },
{ type: 'text', content: 'Punkt', indent: 0 },
{ type: 'divider', content: '---', indent: 0 },
]);
});
it('keeps fenced code as one block', () => {
const items = parse('```go\nfunc main() {}\n\n```\nDanach');
expect(items[0].content).toBe('```go\nfunc main() {}\n\n```');
expect(items[1]).toEqual({ type: 'text', content: 'Danach', indent: 0 });
});
});
@@ -130,4 +130,121 @@ describe('useVoiceInput', () => {
await waitFor(() => expect(none.result.current.mode).toBe('unsupported')); await waitFor(() => expect(none.result.current.mode).toBe('unsupported'));
expect(none.result.current.available).toBe(false); expect(none.result.current.available).toBe(false);
}); });
it('browser mode with live=true: restarts on pause (onend), keeps base text, rate-limits restarts to 300ms, and sends final=true only on stop()', async () => {
const instances: any[] = [];
class FakeSpeechRecognition {
lang = '';
interimResults = false;
continuous = false;
onresult: ((e: any) => void) | null = null;
onerror: ((e: any) => void) | null = null;
onend: (() => void) | null = null;
constructor() {
instances.push(this);
}
start() {}
stop() {
this.onend?.();
}
abort() {}
}
(window as any).SpeechRecognition = FakeSpeechRecognition;
getAuthStatus.mockResolvedValue({ voice: { mode: 'browser', maxSeconds: 600 } });
const onText = vi.fn();
const { result } = renderHook(() => useVoiceInput({ onText, live: true }));
await waitFor(() => expect(result.current.mode).toBe('browser'));
vi.useFakeTimers();
await act(async () => {
await result.current.start();
});
expect(instances).toHaveLength(1);
// First utterance before a pause
act(() => {
instances[0].onresult?.({ results: [[{ transcript: 'Erster Satz.' }]] });
});
expect(onText).toHaveBeenLastCalledWith('Erster Satz.', false);
// Browser ends recognition on speech pause -> immediate restart (#2)
act(() => {
instances[0].onend?.();
});
expect(instances).toHaveLength(2);
expect(onText).not.toHaveBeenCalledWith(expect.anything(), true);
// Another immediate onend within <300ms waits until 300ms have passed
act(() => {
instances[1].onend?.();
});
expect(instances).toHaveLength(2);
act(() => {
vi.advanceTimersByTime(300);
});
expect(instances).toHaveLength(3);
// Second utterance appends to base text
act(() => {
instances[2].onresult?.({ results: [[{ transcript: 'Zweiter Satz.' }]] });
});
expect(onText).toHaveBeenLastCalledWith('Erster Satz. Zweiter Satz.', false);
// Explicit stop() finishes with final=true
await act(async () => {
await result.current.stop();
});
expect(onText).toHaveBeenLastCalledWith('Erster Satz. Zweiter Satz.', true);
expect(result.current.state).toBe('idle');
});
it('server mode with live=true: cuts segments onto new MediaRecorders on the same stream, orders out-of-order responses by chunk index, and releases stream at the end', async () => {
getAuthStatus.mockResolvedValue({ voice: { mode: 'server', maxSeconds: 600 } });
const resolvers: Array<(text: string) => void> = [];
transcribe.mockImplementation(
() =>
new Promise<string>((resolve) => {
resolvers.push(resolve);
})
);
const onText = vi.fn();
const { result } = renderHook(() => useVoiceInput({ onText, live: true }));
await waitFor(() => expect(result.current.mode).toBe('server'));
vi.useFakeTimers();
await act(async () => {
await result.current.start();
});
// Advance 8s -> segment 0 is cut and sent to transcribe, stream is NOT released yet
await act(async () => {
await vi.advanceTimersByTimeAsync(8000);
});
expect(transcribe).toHaveBeenCalledTimes(1);
expect(stopTrack).not.toHaveBeenCalled();
// Advance 4s and stop -> segment 1 (final) is sent to transcribe, stream IS released now
await act(async () => {
await vi.advanceTimersByTimeAsync(4000);
await result.current.stop();
});
expect(transcribe).toHaveBeenCalledTimes(2);
expect(stopTrack).toHaveBeenCalledTimes(1);
// Resolve chunk 1 BEFORE chunk 0 to verify index-based ordering
await act(async () => {
resolvers[1]('zweiter Teil');
});
expect(onText).toHaveBeenLastCalledWith('zweiter Teil', false);
// Now resolve chunk 0 -> both chunks are done, ordered "Erster Teil zweiter Teil" with final=true
await act(async () => {
resolvers[0]('Erster Teil');
});
expect(onText).toHaveBeenLastCalledWith('Erster Teil zweiter Teil', true);
expect(result.current.state).toBe('idle');
});
}); });
+34 -3
View File
@@ -4,6 +4,7 @@ import {
DashboardStats, DashboardStats,
Document, Document,
TrashedDocument, TrashedDocument,
JournalDay,
BackupFile, BackupFile,
DataSection, DataSection,
ApiKeyScope, ApiKeyScope,
@@ -35,6 +36,7 @@ import {
TagSummary, TagSummary,
User, User,
UserInvite, UserInvite,
NotificationSettings,
VoiceAdminStatus, VoiceAdminStatus,
MergeResult, MergeResult,
WSMessage, WSMessage,
@@ -292,6 +294,7 @@ export const api = {
discordWebhookUrl?: string; discordWebhookUrl?: string;
customWebhookUrl?: string; customWebhookUrl?: string;
notifyBeforeMinutes?: number; notifyBeforeMinutes?: number;
notificationSettings?: NotificationSettings;
}): Promise<User> { }): Promise<User> {
return request<User>('/api/v1/auth/settings', { return request<User>('/api/v1/auth/settings', {
method: 'PATCH', method: 'PATCH',
@@ -410,6 +413,18 @@ export const api = {
return ensureArray(res); return ensureArray(res);
}, },
async watchTask(taskId: string): Promise<{ watching: boolean }> {
return request<{ watching: boolean }>(`/api/v1/tasks/${encodeURIComponent(taskId)}/watch`, {
method: 'POST',
});
},
async unwatchTask(taskId: string): Promise<{ watching: boolean }> {
return request<{ watching: boolean }>(`/api/v1/tasks/${encodeURIComponent(taskId)}/watch`, {
method: 'DELETE',
});
},
async reorderTasks(items: { id: string; sortOrder: number; kanbanStatus?: string }[], taskListId?: string): Promise<{ success: boolean }> { async reorderTasks(items: { id: string; sortOrder: number; kanbanStatus?: string }[], taskListId?: string): Promise<{ success: boolean }> {
const url = taskListId ? `/api/v1/task-lists/${taskListId}/tasks/reorder` : '/api/v1/tasks/reorder'; const url = taskListId ? `/api/v1/task-lists/${taskListId}/tasks/reorder` : '/api/v1/tasks/reorder';
return request<{ success: boolean }>(url, { return request<{ success: boolean }>(url, {
@@ -709,14 +724,30 @@ export const api = {
return request<TrashedDocument[]>('/api/v1/documents/trash'); return request<TrashedDocument[]>('/api/v1/documents/trash');
}, },
/** Turns a bullet and its children into a note of its own; the bullet becomes a [[link]]. */ /** Days of the own journal (newest first) with their notes. */
async extractBranch(docId: string, blockId: string, clientId: string): Promise<Document> { async getJournal(): Promise<JournalDay[]> {
return request<Document>(`/api/v1/documents/${docId}/extract`, { const res = await request<{ days: JournalDay[] }>('/api/v1/journal');
return res.days || [];
},
/** The note of a journal day (YYYY-MM-DD); created on first use. */
async openJournalDay(date: string): Promise<Document> {
return request<Document>(`/api/v1/journal/${date}`, { method: 'POST' });
},
/** 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', method: 'POST',
body: JSON.stringify({ blockId, clientId }), body: JSON.stringify({ blockId, clientId }),
}); });
}, },
/** Puts a note created by extractBranch into the trash again. */
async undoExtract(newDocId: string): Promise<void> {
await request(`/api/v1/documents/${newDocId}/extract/undo`, { method: 'POST' });
},
/** Appends the source note to the target; the source goes to the trash. */ /** Appends the source note to the target; the source goes to the trash. */
async mergeDocuments(targetId: string, sourceId: string): Promise<MergeResult> { async mergeDocuments(targetId: string, sourceId: string): Promise<MergeResult> {
return request<MergeResult>(`/api/v1/documents/${targetId}/merge`, { return request<MergeResult>(`/api/v1/documents/${targetId}/merge`, {
+62
View File
@@ -0,0 +1,62 @@
// Journal helpers: dates are local calendar days as YYYY-MM-DD (the server stores them as given).
export const JOURNAL_ENABLED_KEY = 'mindsift_journal_enabled';
export function isJournalEnabled(): boolean {
try {
return localStorage.getItem(JOURNAL_ENABLED_KEY) !== 'false';
} catch {
return true;
}
}
export function setJournalEnabled(on: boolean) {
try {
localStorage.setItem(JOURNAL_ENABLED_KEY, on ? 'true' : 'false');
} catch {
// storage blocked: the setting simply does not stick
}
}
export function toISODate(d: Date): string {
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${d.getFullYear()}-${m}-${day}`;
}
export function todayISO(): string {
return toISODate(new Date());
}
export function parseISODate(date: string): Date {
const [y, m, d] = date.split('-').map(Number);
return new Date(y, (m || 1) - 1, d || 1);
}
export const MONTHS = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
];
/** "Fr, 30. Okt. 2026" for compact labels */
export function shortDateLabel(date: string): string {
return parseISODate(date).toLocaleDateString('de-DE', {
weekday: 'short',
day: 'numeric',
month: 'short',
year: 'numeric',
});
}
/** Weeks of a month for a Monday-first grid; days outside the month are null. */
export function monthGrid(year: number, month: number): (string | null)[][] {
const first = new Date(year, month, 1);
const offset = (first.getDay() + 6) % 7; // Monday = 0
const daysInMonth = new Date(year, month + 1, 0).getDate();
const cells: (string | null)[] = Array(offset).fill(null);
for (let d = 1; d <= daysInMonth; d++) cells.push(toISODate(new Date(year, month, d)));
while (cells.length % 7 !== 0) cells.push(null);
const weeks: (string | null)[][] = [];
for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7));
return weeks;
}
+5 -5
View File
@@ -16,12 +16,12 @@ export interface LinkPreview {
ok: boolean; ok: boolean;
} }
// First plain https address in a text (not inside [text](url), not right after "(" or "<") // Entire bullet content (trimmed) is a single plain https address
const BARE_URL_RE = /(^|[\s])(https:\/\/[^\s<>()\]]+[^\s<>()\].,;:!?'"])/; const CARD_URL_RE = /^https:\/\/[^\s<>()\]]+[^\s<>()\].,;:!?'"]$/;
export function firstBareUrl(content: string): string | null { export function linkCardUrl(content: string): string | null {
const m = BARE_URL_RE.exec(content || ''); const trimmed = (content || '').trim();
return m ? m[2] : null; return CARD_URL_RE.test(trimmed) ? trimmed : null;
} }
/** Replaces the address by a named link, which shows no preview. */ /** Replaces the address by a named link, which shows no preview. */
+65
View File
@@ -0,0 +1,65 @@
import type { DocumentBlock } from './types';
// Numbered lists in the outliner: consecutive 'numbered' siblings count 1, 2, 3 …; a numbered list
// inside another one uses a, b, c, the next level i, ii, iii, then numbers again.
function alpha(n: number): string {
let s = '';
while (n > 0) {
n--;
s = String.fromCharCode(97 + (n % 26)) + s;
n = Math.floor(n / 26);
}
return s;
}
const ROMAN: [number, string][] = [
[1000, 'm'], [900, 'cm'], [500, 'd'], [400, 'cd'], [100, 'c'], [90, 'xc'],
[50, 'l'], [40, 'xl'], [10, 'x'], [9, 'ix'], [5, 'v'], [4, 'iv'], [1, 'i'],
];
function roman(n: number): string {
let s = '';
for (const [v, r] of ROMAN) {
while (n >= v) {
s += r;
n -= v;
}
}
return s;
}
export function listLabel(n: number, depth: number): string {
switch (depth % 3) {
case 1:
return `${alpha(n)}.`;
case 2:
return `${roman(n)}.`;
default:
return `${n}.`;
}
}
/** Label ("1.", "b.", "iii.") for every numbered block, in document order. */
export function numberedLabels(blocks: DocumentBlock[]): Map<string, string> {
const labels = new Map<string, string>();
const runs = new Map<number, number>(); // level -> count of the current run
const chain: DocumentBlock[] = []; // last block seen per level = ancestors of the next one
for (const b of blocks) {
const level = Math.max(0, b.level || 0);
for (const l of Array.from(runs.keys())) {
if (l > level) runs.delete(l);
}
chain.length = Math.min(chain.length, level);
if (b.type === 'numbered') {
const n = (runs.get(level) ?? 0) + 1;
runs.set(level, n);
const depth = chain.filter((a) => a && a.type === 'numbered').length;
labels.set(b.id, listLabel(n, depth));
} else {
runs.delete(level);
}
chain[level] = b;
}
return labels;
}
+109
View File
@@ -0,0 +1,109 @@
import type { BlockType } from './types';
// Turns pasted (Markdown) text into outline items: headings, todos, bullets, numbered items, dividers,
// fenced code and paragraphs. A blank line ends a paragraph; indentation becomes the nesting level.
export interface PastedItem {
type: BlockType;
checked?: boolean;
content: string;
indent: number;
}
function leadingWidth(line: string): { width: number; rest: string } {
let width = 0;
let i = 0;
while (i < line.length && (line[i] === ' ' || line[i] === '\t')) {
width += line[i] === '\t' ? 4 : 1;
i++;
}
return { width, rest: line.slice(i) };
}
export function parsePastedLines(rawLines: string[]): PastedItem[] {
// Indentation steps differ (2, 3 or 4 spaces, tabs): the distinct widths in use are the levels
const widths = Array.from(
new Set(rawLines.filter((l) => l.trim() !== '').map((l) => leadingWidth(l).width))
).sort((a, b) => a - b);
const levelOf = (w: number) => Math.max(0, widths.indexOf(w));
const items: PastedItem[] = [];
let current: PastedItem | null = null;
let inCode = false;
let afterHeading = false;
const flush = () => {
if (current) {
current.content = current.content.trimEnd();
items.push(current);
current = null;
}
};
for (const rawLine of rawLines) {
if (inCode) {
if (current) (current as PastedItem).content += '\n' + rawLine;
if (rawLine.trim().startsWith('```')) {
inCode = false;
flush();
}
continue;
}
if (rawLine.trim().startsWith('```')) {
inCode = true;
flush();
current = { type: 'text', content: rawLine, indent: 0 };
afterHeading = false;
continue;
}
const { width, rest } = leadingWidth(rawLine);
const indent = levelOf(width);
const line = rest.trimEnd();
// A blank line ends the paragraph or list item
if (line === '') {
flush();
continue;
}
const heading = line.match(/^(#{1,3})\s+(.*)$/);
if (heading) {
flush();
afterHeading = true;
items.push({ type: (['h1', 'h2', 'h3'] as const)[heading[1].length - 1], content: heading[2], indent });
continue;
}
if (/^(?:-{3,}|\*{3,}|_{3,})$/.test(line)) {
flush();
afterHeading = false;
items.push({ type: 'divider', content: '---', indent });
continue;
}
const todo = line.match(/^[-*+]\s*\[([ xX])\]\s*(.*)$/);
const bullet = line.match(/^[-*+•]\s+(.*)$/);
const numbered = line.match(/^\d+[.)]\s+(.*)$/);
if (todo || bullet || numbered) {
flush();
afterHeading = false;
current = todo
? { type: 'todo', checked: todo[1].toLowerCase() === 'x', content: todo[2], indent }
: numbered
? { type: 'numbered', content: numbered[1], indent }
: { type: 'text', content: bullet![1], indent };
continue;
}
// Plain text: continues the paragraph or list item above (a wrapped line), else starts a paragraph
if (current && !afterHeading) {
(current as PastedItem).content += '\n' + line;
} else {
flush();
current = { type: 'text', content: line, indent };
}
afterHeading = false;
}
flush();
return items;
}
+38 -1
View File
@@ -46,6 +46,34 @@ export interface VoiceAdminStatus {
// Who can see whom: nobody, members of the same organization, or everybody (Doku/sharing-and-permissions.md) // Who can see whom: nobody, members of the same organization, or everybody (Doku/sharing-and-permissions.md)
export type SharingMode = 'private' | 'organizations' | 'open'; export type SharingMode = 'private' | 'organizations' | 'open';
export interface NotificationChannelRouting {
email: boolean;
discord: boolean;
webhook: boolean;
}
export interface NotificationEvents {
due: NotificationChannelRouting;
shared: NotificationChannelRouting;
taskDone: NotificationChannelRouting;
watched: NotificationChannelRouting;
}
export interface DailySummarySettings {
enabled: boolean;
time: string;
email: boolean;
discord: boolean;
webhook: boolean;
}
export interface NotificationSettings {
paused: boolean;
includeOwnWatched: boolean;
events: NotificationEvents;
dailySummary: DailySummarySettings;
}
export interface User { export interface User {
id: string; id: string;
email: string; email: string;
@@ -59,6 +87,7 @@ export interface User {
discordWebhookUrl?: string; discordWebhookUrl?: string;
customWebhookUrl?: string; customWebhookUrl?: string;
notifyBeforeMinutes?: number; notifyBeforeMinutes?: number;
notificationSettings?: NotificationSettings;
organizationId?: string; organizationId?: string;
createdAt: string; createdAt: string;
} }
@@ -113,6 +142,7 @@ export interface Thought {
subtasks?: SubTask[]; subtasks?: SubTask[];
assignees?: string[]; // user IDs, only people with access to the task list assignees?: string[]; // user IDs, only people with access to the task list
recurrence?: Recurrence; recurrence?: Recurrence;
isWatching?: boolean;
alarmSent?: boolean; alarmSent?: boolean;
sortOrder?: number; sortOrder?: number;
createdAt: string; createdAt: string;
@@ -231,7 +261,7 @@ export const DATA_SECTIONS: { id: DataSection; label: string }[] = [
{ id: 'themes', label: 'Themes' }, { id: 'themes', label: 'Themes' },
]; ];
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'text' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress' | 'image' | 'audio'; export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'text' | 'todo' | 'bullet' | 'numbered' | 'code' | 'callout' | 'divider' | 'table' | 'progress' | 'image' | 'audio';
export interface DocumentBlock { export interface DocumentBlock {
id: string; id: string;
@@ -292,6 +322,13 @@ export interface Backlink {
} }
/** A note in the trash (GET /documents/trash). */ /** A note in the trash (GET /documents/trash). */
/** A day of the own journal; `empty` while its note has no text yet. */
export interface JournalDay {
date: string; // YYYY-MM-DD
docId: string;
empty: boolean;
}
export interface TrashedDocument { export interface TrashedDocument {
id: string; id: string;
title: string; title: string;
+208 -43
View File
@@ -10,28 +10,54 @@ export interface VoiceInputOptions {
onError?: (message: string) => void; onError?: (message: string) => void;
/** Browser mode: keep listening through pauses (hold-to-record) instead of stopping after a sentence. */ /** Browser mode: keep listening through pauses (hold-to-record) instead of stopping after a sentence. */
continuous?: boolean; continuous?: boolean;
/** Live dictation mode (/diktat): auto-restart browser speech recognition on pause, and chunk server recordings every ~8s or ~0.8s of silence. */
live?: boolean;
} }
const LIVE_CHUNK_MS = 8000;
const LIVE_SILENCE_MS = 800;
const LIVE_TICK_MS = 200;
const LIVE_SPEECH_LEVEL = 0.02;
const MIN_RESTART_INTERVAL_MS = 300;
/** /**
* Microphone input for one button: start, stop (text arrives), cancel (nothing arrives). How the text * Microphone input for one button: start, stop (text arrives), cancel (nothing arrives). How the text
* is made depends on the server setting, see lib/voice.ts. mode is null until known, "off" or * is made depends on the server setting, see lib/voice.ts. mode is null until known, "off" or
* "unsupported" means: do not show a microphone. * "unsupported" means: do not show a microphone.
*/ */
export function useVoiceInput({ onText, onError, continuous = false }: VoiceInputOptions) { export function useVoiceInput({ onText, onError, continuous = false, live = false }: VoiceInputOptions) {
const [mode, setMode] = useState<EffectiveVoiceMode | null>(null); const [mode, setMode] = useState<EffectiveVoiceMode | null>(null);
const [state, setState] = useState<VoiceState>('idle'); const [state, setState] = useState<VoiceState>('idle');
const [pendingChunks, setPendingChunks] = useState(0);
const maxSecondsRef = useRef(600); const maxSecondsRef = useRef(600);
const recorderRef = useRef<VoiceRecorder | null>(null); const recorderRef = useRef<VoiceRecorder | null>(null);
const recognitionRef = useRef<any>(null); const recognitionRef = useRef<any>(null);
const timerRef = useRef<number | null>(null); const timerRef = useRef<number | null>(null);
const segmentTimerRef = useRef<number | null>(null);
const restartTimerRef = useRef<number | null>(null);
const cancelledRef = useRef(false); const cancelledRef = useRef(false);
const stoppedRef = useRef(false);
const transcriptRef = useRef(''); const transcriptRef = useRef('');
const baseTranscriptRef = useRef('');
const lastRestartAtRef = useRef(0);
const chunkIndexRef = useRef(0);
const chunkResultsRef = useRef<Map<number, string>>(new Map());
const inFlightChunksRef = useRef(0);
const mountedRef = useRef(true); const mountedRef = useRef(true);
const onTextRef = useRef(onText); const onTextRef = useRef(onText);
onTextRef.current = onText; onTextRef.current = onText;
const onErrorRef = useRef(onError); const onErrorRef = useRef(onError);
onErrorRef.current = onError; onErrorRef.current = onError;
const clearAllTimers = () => {
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
timerRef.current = null;
if (segmentTimerRef.current !== null) window.clearInterval(segmentTimerRef.current);
segmentTimerRef.current = null;
if (restartTimerRef.current !== null) window.clearTimeout(restartTimerRef.current);
restartTimerRef.current = null;
};
useEffect(() => { useEffect(() => {
mountedRef.current = true; mountedRef.current = true;
void loadVoiceConfig().then((cfg) => { void loadVoiceConfig().then((cfg) => {
@@ -41,7 +67,7 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu
}); });
return () => { return () => {
mountedRef.current = false; mountedRef.current = false;
if (timerRef.current !== null) window.clearTimeout(timerRef.current); clearAllTimers();
recorderRef.current?.cancel(); recorderRef.current?.cancel();
try { try {
recognitionRef.current?.abort?.(); recognitionRef.current?.abort?.();
@@ -54,17 +80,65 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu
const setStateSafe = (s: VoiceState) => { const setStateSafe = (s: VoiceState) => {
if (mountedRef.current) setState(s); if (mountedRef.current) setState(s);
}; };
const setPendingChunksSafe = (n: number) => {
if (mountedRef.current) setPendingChunks(n);
};
const fail = (message: string) => { const fail = (message: string) => {
setStateSafe('idle'); setStateSafe('idle');
setPendingChunksSafe(0);
onErrorRef.current?.(message); onErrorRef.current?.(message);
}; };
const clearTimer = () => {
if (timerRef.current !== null) window.clearTimeout(timerRef.current); const joinedChunkText = () =>
timerRef.current = null; [...chunkResultsRef.current.entries()]
.sort((a, b) => a[0] - b[0])
.map(([, t]) => t.trim())
.filter(Boolean)
.join(' ');
const processSegmentPromise = (idx: number, blobPromise: Promise<Blob | null>) => {
inFlightChunksRef.current += 1;
setPendingChunksSafe(inFlightChunksRef.current);
void blobPromise
.then(async (blob) => {
if (cancelledRef.current) return;
if (!blob || blob.size === 0) {
inFlightChunksRef.current = Math.max(0, inFlightChunksRef.current - 1);
setPendingChunksSafe(inFlightChunksRef.current);
if (stoppedRef.current && inFlightChunksRef.current === 0) {
setStateSafe('idle');
onTextRef.current(joinedChunkText(), true);
}
return;
}
const text = await api.transcribe(blob);
if (cancelledRef.current) return;
chunkResultsRef.current.set(idx, text || '');
inFlightChunksRef.current = Math.max(0, inFlightChunksRef.current - 1);
setPendingChunksSafe(inFlightChunksRef.current);
const combined = joinedChunkText();
transcriptRef.current = combined;
if (stoppedRef.current && inFlightChunksRef.current === 0) {
setStateSafe('idle');
onTextRef.current(combined, true);
} else {
onTextRef.current(combined, false);
}
})
.catch((err: any) => {
if (cancelledRef.current) return;
cancelledRef.current = true;
inFlightChunksRef.current = 0;
clearAllTimers();
recorderRef.current?.cancel();
recorderRef.current = null;
fail(err?.message || 'Spracherkennung fehlgeschlagen');
});
}; };
const stop = useCallback(async () => { const stop = useCallback(async () => {
clearTimer(); stoppedRef.current = true;
clearAllTimers();
if (recognitionRef.current) { if (recognitionRef.current) {
try { try {
recognitionRef.current.stop(); recognitionRef.current.stop();
@@ -74,8 +148,20 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu
return; return;
} }
const rec = recorderRef.current; const rec = recorderRef.current;
if (!rec) return; if (!rec) {
if (live && state === 'listening') {
setStateSafe('idle');
onTextRef.current(transcriptRef.current, true);
}
return;
}
recorderRef.current = null; recorderRef.current = null;
if (live) {
const idx = chunkIndexRef.current++;
setStateSafe('transcribing');
processSegmentPromise(idx, rec.stop());
return;
}
const blob = await rec.stop(); const blob = await rec.stop();
if (cancelledRef.current) return; if (cancelledRef.current) return;
if (!blob) { if (!blob) {
@@ -93,11 +179,15 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu
} }
// fail only touches refs and state setters // fail only touches refs and state setters
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, [live, state]);
const startServer = async () => { const startServer = async () => {
const rec = new VoiceRecorder(); const rec = new VoiceRecorder();
recorderRef.current = rec; recorderRef.current = rec;
chunkIndexRef.current = 0;
chunkResultsRef.current = new Map();
inFlightChunksRef.current = 0;
setPendingChunksSafe(0);
setStateSafe('listening'); setStateSafe('listening');
try { try {
await rec.start(); await rec.start();
@@ -111,6 +201,104 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu
return; return;
} }
timerRef.current = window.setTimeout(() => void stop(), maxSecondsRef.current * 1000); timerRef.current = window.setTimeout(() => void stop(), maxSecondsRef.current * 1000);
if (live) {
let segmentMs = 0;
let silentMs = 0;
let hadSpeech = false;
segmentTimerRef.current = window.setInterval(() => {
if (cancelledRef.current || stoppedRef.current || recorderRef.current !== rec) return;
const lvl = rec.level();
if (lvl !== null) {
if (lvl >= LIVE_SPEECH_LEVEL) {
hadSpeech = true;
silentMs = 0;
} else if (hadSpeech) {
silentMs += LIVE_TICK_MS;
}
}
segmentMs += LIVE_TICK_MS;
if (segmentMs >= LIVE_CHUNK_MS || (hadSpeech && silentMs >= LIVE_SILENCE_MS)) {
segmentMs = 0;
silentMs = 0;
hadSpeech = false;
const idx = chunkIndexRef.current++;
processSegmentPromise(idx, rec.cutSegment());
}
}, LIVE_TICK_MS);
}
};
const spawnBrowserRecognition = (Recognition: any, isRestart: boolean) => {
const recognition = new Recognition();
recognition.lang = 'de-DE';
recognition.interimResults = true;
recognition.continuous = continuous || live;
recognition.onresult = (event: any) => {
let sessionText = '';
for (let i = 0; i < event.results.length; i++) sessionText += event.results[i][0].transcript;
if (live && baseTranscriptRef.current) {
const base = baseTranscriptRef.current.trimEnd();
const next = sessionText.trimStart();
transcriptRef.current = next ? `${base} ${next}` : base;
} else {
transcriptRef.current = sessionText;
}
onTextRef.current(transcriptRef.current, false);
};
recognition.onerror = (event: any) => {
if (event?.error === 'not-allowed' || event?.error === 'service-not-allowed') {
cancelledRef.current = true;
clearAllTimers();
recognitionRef.current = null;
fail('Mikrofon nicht freigegeben');
}
};
recognition.onend = () => {
recognitionRef.current = null;
if (cancelledRef.current) {
clearAllTimers();
return;
}
if (!live || stoppedRef.current) {
clearAllTimers();
setStateSafe('idle');
onTextRef.current(transcriptRef.current, true);
return;
}
baseTranscriptRef.current = transcriptRef.current;
const now = Date.now();
const elapsed = now - lastRestartAtRef.current;
const doRestart = () => {
if (cancelledRef.current || stoppedRef.current) return;
lastRestartAtRef.current = Date.now();
spawnBrowserRecognition(Recognition, true);
};
if (lastRestartAtRef.current === 0 || elapsed >= MIN_RESTART_INTERVAL_MS) {
doRestart();
} else {
restartTimerRef.current = window.setTimeout(() => {
restartTimerRef.current = null;
doRestart();
}, MIN_RESTART_INTERVAL_MS - elapsed);
}
};
recognitionRef.current = recognition;
if (!isRestart) setStateSafe('listening');
try {
recognition.start();
if (!isRestart) {
timerRef.current = window.setTimeout(() => void stop(), maxSecondsRef.current * 1000);
}
} catch {
recognitionRef.current = null;
if (!isRestart) {
fail('Spracheingabe konnte nicht gestartet werden');
} else {
clearAllTimers();
setStateSafe('idle');
onTextRef.current(transcriptRef.current, true);
}
}
}; };
const startBrowser = () => { const startBrowser = () => {
@@ -119,55 +307,31 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu
fail('Spracheingabe wird von diesem Browser nicht unterstützt'); fail('Spracheingabe wird von diesem Browser nicht unterstützt');
return; return;
} }
const recognition = new Recognition(); baseTranscriptRef.current = '';
recognition.lang = 'de-DE'; lastRestartAtRef.current = 0;
recognition.interimResults = true; spawnBrowserRecognition(Recognition, false);
recognition.continuous = continuous;
recognition.onresult = (event: any) => {
let combined = '';
for (let i = 0; i < event.results.length; i++) combined += event.results[i][0].transcript;
transcriptRef.current = combined;
onTextRef.current(combined, false);
};
recognition.onerror = (event: any) => {
if (event?.error === 'not-allowed' || event?.error === 'service-not-allowed') {
cancelledRef.current = true;
recognitionRef.current = null;
fail('Mikrofon nicht freigegeben');
}
};
recognition.onend = () => {
clearTimer();
recognitionRef.current = null;
if (cancelledRef.current) return;
setStateSafe('idle');
onTextRef.current(transcriptRef.current, true);
};
recognitionRef.current = recognition;
setStateSafe('listening');
try {
recognition.start();
timerRef.current = window.setTimeout(() => void stop(), maxSecondsRef.current * 1000);
} catch {
recognitionRef.current = null;
fail('Spracheingabe konnte nicht gestartet werden');
}
}; };
const start = useCallback(async () => { const start = useCallback(async () => {
if (recorderRef.current || recognitionRef.current) return; if (recorderRef.current || recognitionRef.current) return;
cancelledRef.current = false; cancelledRef.current = false;
stoppedRef.current = false;
transcriptRef.current = ''; transcriptRef.current = '';
baseTranscriptRef.current = '';
lastRestartAtRef.current = 0;
const current = mode ?? currentVoiceConfig().mode; const current = mode ?? currentVoiceConfig().mode;
if (current === 'server') await startServer(); if (current === 'server') await startServer();
else if (current === 'browser') startBrowser(); else if (current === 'browser') startBrowser();
else fail(current === 'off' ? 'Spracheingabe ist ausgeschaltet' : 'Spracheingabe wird von diesem Browser nicht unterstützt'); else fail(current === 'off' ? 'Spracheingabe ist ausgeschaltet' : 'Spracheingabe wird von diesem Browser nicht unterstützt');
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, continuous]); }, [mode, continuous, live]);
const cancel = useCallback(() => { const cancel = useCallback(() => {
cancelledRef.current = true; cancelledRef.current = true;
clearTimer(); stoppedRef.current = true;
inFlightChunksRef.current = 0;
setPendingChunksSafe(0);
clearAllTimers();
recorderRef.current?.cancel(); recorderRef.current?.cancel();
recorderRef.current = null; recorderRef.current = null;
try { try {
@@ -191,6 +355,7 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu
mode, mode,
available: mode === 'server' || mode === 'browser', available: mode === 'server' || mode === 'browser',
state, state,
pendingChunks,
start, start,
stop, stop,
cancel, cancel,
+44 -13
View File
@@ -86,19 +86,7 @@ export class VoiceRecorder {
this.stream = await navigator.mediaDevices.getUserMedia({ this.stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
}); });
const type = recordingType(); this.startSegmentRecorder();
this.recorder = new MediaRecorder(this.stream, type ? { mimeType: type } : undefined);
this.chunks = [];
this.recorder.ondataavailable = (e) => {
if (e.data.size > 0) this.chunks.push(e.data);
};
this.recorder.onstop = () => {
const blob = this.chunks.length ? new Blob(this.chunks, { type: this.recorder?.mimeType || type || 'audio/webm' }) : null;
this.release();
this.done?.(blob);
this.done = null;
};
this.recorder.start(1000);
try { try {
const Ctx = (window as any).AudioContext || (window as any).webkitAudioContext; const Ctx = (window as any).AudioContext || (window as any).webkitAudioContext;
this.audioCtx = new Ctx(); this.audioCtx = new Ctx();
@@ -113,6 +101,25 @@ export class VoiceRecorder {
} }
} }
private startSegmentRecorder(): void {
if (!this.stream) return;
const type = recordingType();
const rec = new MediaRecorder(this.stream, type ? { mimeType: type } : undefined);
const chunks: Blob[] = [];
rec.ondataavailable = (e) => {
if (e.data.size > 0) chunks.push(e.data);
};
rec.onstop = () => {
const blob = chunks.length ? new Blob(chunks, { type: rec.mimeType || type || 'audio/webm' }) : null;
this.release();
this.done?.(blob);
this.done = null;
};
this.recorder = rec;
this.chunks = chunks;
rec.start(1000);
}
/** Input level 0..1 (roughly loudness), or null when unknown. */ /** Input level 0..1 (roughly loudness), or null when unknown. */
level(): number | null { level(): number | null {
if (!this.analyser || !this.samples) return null; if (!this.analyser || !this.samples) return null;
@@ -137,6 +144,30 @@ export class VoiceRecorder {
}); });
} }
/**
* Stops the current MediaRecorder segment and immediately starts a new one on the same MediaStream.
* Resolves with the finished segment's audio Blob while recording continues seamlessly.
*/
cutSegment(): Promise<Blob | null> {
if (!this.stream || !this.recorder || this.recorder.state === 'inactive') {
return Promise.resolve(null);
}
const oldRecorder = this.recorder;
const oldChunks = this.chunks;
this.startSegmentRecorder();
return new Promise((resolve) => {
oldRecorder.onstop = () => {
const blob = oldChunks.length
? new Blob(oldChunks, { type: oldRecorder.mimeType || 'audio/webm' })
: null;
resolve(blob);
};
oldRecorder.stop();
});
}
cancel(): void { cancel(): void {
this.done = null; this.done = null;
if (this.recorder && this.recorder.state !== 'inactive') { if (this.recorder && this.recorder.state !== 'inactive') {
+2
View File
@@ -2,6 +2,8 @@ import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import App from './App'; import App from './App';
import { MobileActionProvider } from './context/MobileActionContext'; import { MobileActionProvider } from './context/MobileActionContext';
// Inter ships with the app (no external font service); the variable font covers all weights
import '@fontsource-variable/inter';
import './index.css'; import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
+14 -12
View File
@@ -14,24 +14,26 @@
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE --- */ /* --- 2. OBERFLÄCHEN & HINTERGRÜNDE --- */
--bg-app: #f3f4f6; --bg-app: #f3f4f6;
--bg-panel: #ffffff; --bg-panel: #ffffff;
--bg-panel-elevated: #ebedf0; --bg-panel-elevated: #e2e8f0;
--bg-panel-hover: #e2e5ea; --bg-panel-hover: #cbd5e1;
--bg-input: #ffffff; --bg-input: #ffffff;
--bg-overlay: rgba(15, 23, 42, 0.45); --bg-overlay: rgba(15, 23, 42, 0.45);
--bg-code: #f1f5f9; --bg-code: #e2e8f0;
--bg-editor: #ffffff;
--bg-sidebar: #f3f4f6;
/* --- 3. TEXT & KONTRASTE --- */ /* --- 3. TEXT & KONTRASTE --- */
--text-main: #111827; --text-main: #111827;
--text-muted: #4b5563; --text-muted: #374151;
--text-faint: #9ca3af; --text-faint: #6b7280;
/* --- 4. RAHMEN, LINIEN & SCHATTEN --- */ /* --- 4. RAHMEN, LINIEN & SCHATTEN --- */
--border-color: rgba(17, 24, 39, 0.13); --border-color: rgba(17, 24, 39, 0.18);
--border-subtle: rgba(17, 24, 39, 0.06); --border-subtle: rgba(17, 24, 39, 0.08);
--border-strong: rgba(17, 24, 39, 0.28); --border-strong: rgba(17, 24, 39, 0.32);
--shadow-panel: 0 8px 24px rgba(15, 23, 42, 0.08); --shadow-panel: 0 8px 24px rgba(15, 23, 42, 0.08);
--scrollbar-thumb: rgba(17, 24, 39, 0.18); --scrollbar-thumb: rgba(17, 24, 39, 0.22);
--scrollbar-thumb-hover: rgba(17, 24, 39, 0.32); --scrollbar-thumb-hover: rgba(17, 24, 39, 0.38);
/* --- 5. STATUS- & SIGNALFARBEN --- */ /* --- 5. STATUS- & SIGNALFARBEN --- */
--color-success: #15803d; --color-success: #15803d;
@@ -57,10 +59,10 @@
--code-keyword: #0000ff; --code-keyword: #0000ff;
--code-string: #a31515; --code-string: #a31515;
--code-number: #098658; --code-number: #098658;
--code-comment: #008000; --code-comment: #007000;
--code-property: #0451a5; --code-property: #0451a5;
--code-function: #795e26; --code-function: #795e26;
--code-type: #267f99; --code-type: #267f99;
--code-punctuation: #3b3b3b; --code-punctuation: #2e2e2e;
--code-variable: #001080; --code-variable: #001080;
} }
+1
View File
@@ -21,6 +21,7 @@
--bg-input: #121316; /* Textfelder, Selects, Textareas */ --bg-input: #121316; /* Textfelder, Selects, Textareas */
--bg-overlay: rgba(0, 0, 0, 0.72); /* Hintergrund bei Modals/Dialogen */ --bg-overlay: rgba(0, 0, 0, 0.72); /* Hintergrund bei Modals/Dialogen */
--bg-code: #0d0e11; /* Code-Blöcke & Terminal-Boxen */ --bg-code: #0d0e11; /* Code-Blöcke & Terminal-Boxen */
/* Optional: --bg-editor (Notiz-Editor, Standard --bg-app) und --bg-sidebar (Ordnerleiste, Standard --bg-panel) */
/* --- 3. TEXT & KONTRASTE --- */ /* --- 3. TEXT & KONTRASTE --- */
--text-main: #f4f4f5; /* Haupttext & Überschriften */ --text-main: #f4f4f5; /* Haupttext & Überschriften */
+3
View File
@@ -14,6 +14,8 @@ export default {
extend: { extend: {
colors: { colors: {
canvas: v('--bg-app'), canvas: v('--bg-app'),
editor: v('--bg-editor'),
sidebar: v('--bg-sidebar'),
panel: { panel: {
DEFAULT: v('--bg-panel'), DEFAULT: v('--bg-panel'),
elevated: v('--bg-panel-elevated'), elevated: v('--bg-panel-elevated'),
@@ -83,6 +85,7 @@ export default {
}, },
fontFamily: { fontFamily: {
sans: [ sans: [
'Inter Variable',
'Inter', 'Inter',
'system-ui', 'system-ui',
'-apple-system', '-apple-system',
+1 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
// Shown in the header. Release builds get the git tag (APP_VERSION build argument, e.g. "v0.6.0"); // Shown in the header. Tag and main builds get APP_VERSION as build argument (e.g. "v0.6.0");
// other builds show the package version with a "-dev" suffix (Semantic Versioning, see CHANGELOG.md). // other builds show the package version with a "-dev" suffix (Semantic Versioning, see CHANGELOG.md).
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')); const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
const appVersion = process.env.APP_VERSION || `v${pkg.version}-dev`; const appVersion = process.env.APP_VERSION || `v${pkg.version}-dev`;
+25 -1
View File
@@ -13,6 +13,11 @@ import (
// --- MULTI-CHANNEL ALARM DISPATCHER (EMAIL, DISCORD, CUSTOM WEBHOOK) --- // --- MULTI-CHANNEL ALARM DISPATCHER (EMAIL, DISCORD, CUSTOM WEBHOOK) ---
func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error { func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error {
return s.sendEmailHTML(toEmail, subject, bodyText, "")
}
// sendEmailHTML sends text plus an optional HTML version (multipart/alternative).
func (s *Server) sendEmailHTML(toEmail, subject, bodyText, html string) error {
if s.cfg.DemoMode { if s.cfg.DemoMode {
return errDemoOutgoing return errDemoOutgoing
} }
@@ -20,7 +25,7 @@ func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error
if err != nil { if err != nil {
return err return err
} }
if err := sendMail(cfg, toEmail, subject, bodyText); err != nil { if err := sendMail(cfg, toEmail, subject, bodyText, html); err != nil {
step := "" step := ""
if se, ok := err.(*smtpStepError); ok { if se, ok := err.(*smtpStepError); ok {
step = se.Step step = se.Step
@@ -123,6 +128,25 @@ func (s *Server) startAlarmTicker() {
if !alarmDue(a.DueDate, a.NotifyBeforeMinutes, now) { if !alarmDue(a.DueDate, a.NotifyBeforeMinutes, now) {
continue continue
} }
// Channels come from the notification settings (row "due"); a pause holds the alarm back
set := s.store.NotificationSettingsOf(a.UserID)
if set.Paused {
continue
}
channels := []string{}
if set.Events.Due.Email {
channels = append(channels, "email")
}
if set.Events.Due.Discord {
channels = append(channels, "discord")
}
if set.Events.Due.Webhook {
channels = append(channels, "webhook")
}
if a.AlarmEmail == "" {
a.AlarmEmail = a.UserEmail
}
a.AlarmChannels = channels
if len(a.AlarmChannels) == 0 { if len(a.AlarmChannels) == 0 {
continue continue
} }
+12
View File
@@ -341,6 +341,8 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
return return
} }
stats, _ := s.store.GetDashboardStats(userID) stats, _ := s.store.GetDashboardStats(userID)
ns := s.store.NotificationSettingsOf(userID)
user.NotificationSettings = &ns
writeJSON(w, http.StatusOK, map[string]interface{}{ writeJSON(w, http.StatusOK, map[string]interface{}{
"user": user, "user": user,
"stats": stats, "stats": stats,
@@ -448,6 +450,8 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
NotifyBeforeMinutes *int `json:"notifyBeforeMinutes"` NotifyBeforeMinutes *int `json:"notifyBeforeMinutes"`
// Needed to change password or e-mail address (FLAW-30) // Needed to change password or e-mail address (FLAW-30)
CurrentPassword string `json:"currentPassword"` CurrentPassword string `json:"currentPassword"`
// Which notifications go where (notify.go)
NotificationSettings *domain.NotificationSettings `json:"notificationSettings"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON") writeError(w, http.StatusBadRequest, "Ungültiges JSON")
@@ -528,6 +532,14 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
if changesPassword || changesEmail { if changesPassword || changesEmail {
s.notifyAccountChange(before, changesPassword, changesEmail, u.Email) s.notifyAccountChange(before, changesPassword, changesEmail, u.Email)
} }
if req.NotificationSettings != nil {
if err := s.store.SetNotificationSettings(userID, *req.NotificationSettings); err != nil {
writeError(w, http.StatusInternalServerError, "Benachrichtigungen konnten nicht gespeichert werden")
return
}
}
ns := s.store.NotificationSettingsOf(userID)
u.NotificationSettings = &ns
writeJSON(w, http.StatusOK, u) writeJSON(w, http.StatusOK, u)
} }
+11
View File
@@ -262,6 +262,9 @@ func (s *Server) handleCreateDocShare(w http.ResponseWriter, r *http.Request) {
if req.TeamID != "" { if req.TeamID != "" {
share, err := s.store.CreateDocTeamShare(userID, docID, req.TeamID, req.Permission) share, err := s.store.CreateDocTeamShare(userID, docID, req.TeamID, req.Permission)
if err == nil {
defer s.notifyShared(userID, "team", req.TeamID, "die Notiz", s.docTitle(userID, docID))
}
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
@@ -286,6 +289,7 @@ func (s *Server) handleCreateDocShare(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
} }
s.notifyShared(userID, "user", share.UserID, "die Notiz", s.docTitle(userID, docID))
s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED") s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED")
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED") s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
writeJSON(w, http.StatusCreated, share) writeJSON(w, http.StatusCreated, share)
@@ -313,3 +317,10 @@ func (s *Server) handleGetCollaborators(w http.ResponseWriter, r *http.Request)
} }
writeJSON(w, http.StatusOK, users) writeJSON(w, http.StatusOK, users)
} }
func (s *Server) docTitle(userID, docID string) string {
if d, err := s.store.GetDocumentByID(userID, docID); err == nil && d.Title != "" {
return d.Title
}
return "Unbenannte Notiz"
}
+1
View File
@@ -212,6 +212,7 @@ func (s *Server) handleCreateFolderShare(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
} }
s.notifyShared(userID, share.TargetType, share.TargetID, "den Ordner", s.store.SharedFolderName(folderID))
s.wsHub.BroadcastToUsers(s.store.GetSharedFolderUserIDs(folderID), "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID}) s.wsHub.BroadcastToUsers(s.store.GetSharedFolderUserIDs(folderID), "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID})
if docIDs, err := s.store.GetSharedFolderDocIDs(folderID); err == nil { if docIDs, err := s.store.GetSharedFolderDocIDs(folderID); err == nil {
for _, docID := range docIDs { for _, docID := range docIDs {
+3
View File
@@ -107,6 +107,9 @@ func (s *Server) handleCreateListShare(w http.ResponseWriter, r *http.Request) {
return return
} }
if l, err := s.store.GetListByID(currentUserID, listID); err == nil {
s.notifyShared(currentUserID, targetType, share.TargetID, "die Liste", l.Title)
}
if targetType == "team" { if targetType == "team" {
members, _ := s.store.GetTeamMembers(share.TargetID) members, _ := s.store.GetTeamMembers(share.TargetID)
for _, tm := range members { for _, tm := range members {
+3
View File
@@ -125,6 +125,9 @@ func (s *Server) handleCreateTaskListShare(w http.ResponseWriter, r *http.Reques
return return
} }
s.broadcastTaskListChange(listID, nil, "TASK_LIST_UPDATED") s.broadcastTaskListChange(listID, nil, "TASK_LIST_UPDATED")
if tl, err := s.store.GetTaskListByID(userID, listID); err == nil {
s.notifyShared(userID, req.TargetType, req.TargetID, "die Aufgabenliste", tl.Title)
}
writeJSON(w, http.StatusCreated, share) writeJSON(w, http.StatusCreated, share)
} }
+6
View File
@@ -94,6 +94,7 @@ func (s *Server) handleGetThoughts(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
s.markWatching(userID, thoughts)
writeJSON(w, http.StatusOK, thoughts) writeJSON(w, http.StatusOK, thoughts)
} }
@@ -278,6 +279,8 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
s.notifyDocChanged(docID, head) s.notifyDocChanged(docID, head)
} }
s.wsHub.BroadcastToUsers(append(s.taskAudience(before), s.taskAudience(updated)...), "TASK_UPDATED", updated) s.wsHub.BroadcastToUsers(append(s.taskAudience(before), s.taskAudience(updated)...), "TASK_UPDATED", updated)
s.notifyTaskChange(userID, before, updated, false)
updated.IsWatching = s.store.WatchedTaskIDs(userID)[updated.ID]
writeJSON(w, http.StatusOK, updated) writeJSON(w, http.StatusOK, updated)
} }
@@ -304,6 +307,9 @@ func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) {
writeStoreError(w, http.StatusInternalServerError, err) writeStoreError(w, http.StatusInternalServerError, err)
return return
} }
if th != nil {
s.notifyTaskChange(userID, th, nil, true)
}
s.wsHub.BroadcastToUsers(audience, "TASK_DELETED", map[string]string{"id": thoughtID}) s.wsHub.BroadcastToUsers(audience, "TASK_DELETED", map[string]string{"id": thoughtID})
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
} }
+70
View File
@@ -0,0 +1,70 @@
package api
import (
"fmt"
"net/http"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
)
// Journal: one ordinary note per day, opened (and created on first use) by date. The web app shows the
// days below each other with a calendar; the notes can be shared and searched like any other.
var journalMu sync.Mutex // one creation at a time, so a day never gets two notes
var (
journalWeekdays = []string{"Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"}
journalMonths = []string{"Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"}
)
// journalTitle: "Freitag, 30. Oktober 2026"
func journalTitle(day time.Time) string {
return fmt.Sprintf("%s, %d. %s %d", journalWeekdays[day.Weekday()], day.Day(), journalMonths[day.Month()-1], day.Year())
}
func (s *Server) handleGetJournal(w http.ResponseWriter, r *http.Request) {
days, err := s.store.JournalDays(auth.GetUserID(r.Context()))
if err != nil {
writeError(w, http.StatusInternalServerError, "Journal konnte nicht geladen werden")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"days": days})
}
// handleOpenJournalDay returns the note of a day and creates it when there is none yet.
func (s *Server) handleOpenJournalDay(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
date := chi.URLParam(r, "date")
day, err := time.Parse("2006-01-02", date)
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges Datum (erwartet JJJJ-MM-TT)")
return
}
journalMu.Lock()
defer journalMu.Unlock()
docID, err := s.store.JournalDocID(userID, date)
if err != nil {
writeError(w, http.StatusInternalServerError, "Journal konnte nicht geladen werden")
return
}
if docID != "" {
if doc, err := s.store.GetDocumentByID(userID, docID); err == nil {
writeJSON(w, http.StatusOK, doc)
return
}
}
doc, err := s.store.CreateDocument(userID, "", journalTitle(day), "📅", "", nil, nil, false)
if err != nil {
writeError(w, http.StatusInternalServerError, "Journal-Tag konnte nicht angelegt werden")
return
}
if err := s.store.SetJournalDay(userID, date, doc.ID); err != nil {
writeError(w, http.StatusInternalServerError, "Journal-Tag konnte nicht angelegt werden")
return
}
s.broadcastDocToAuthorizedUsers(doc.ID, "DOC_CREATED")
writeJSON(w, http.StatusCreated, doc)
}
+78
View File
@@ -0,0 +1,78 @@
package api
import (
"encoding/json"
"net/http"
"testing"
"time"
)
func TestJournalDayIsCreatedOnceAndListed(t *testing.T) {
srv, store, _ := setupTestServer(t)
router := srv.Router()
me, token := newTestUser(t, store, "journal@example.com")
rec := sessionCall(router, "POST", "/api/v1/journal/2026-10-30", token, "", "")
if rec.Code != http.StatusCreated {
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
}
var doc struct {
ID, Title, Icon string
}
_ = json.Unmarshal(rec.Body.Bytes(), &doc)
if doc.Title != "Freitag, 30. Oktober 2026" || doc.Icon != "📅" {
t.Fatalf("unexpected note: %+v", doc)
}
// Opening the same day again returns the same note
rec = sessionCall(router, "POST", "/api/v1/journal/2026-10-30", token, "", "")
var again struct{ ID string }
_ = json.Unmarshal(rec.Body.Bytes(), &again)
if rec.Code != http.StatusOK || again.ID != doc.ID {
t.Fatalf("second open: %d id %s want %s", rec.Code, again.ID, doc.ID)
}
rec = sessionCall(router, "GET", "/api/v1/journal", token, "", "")
var list struct {
Days []struct {
Date, DocID string
Empty bool
}
}
_ = json.Unmarshal(rec.Body.Bytes(), &list)
if len(list.Days) != 1 || list.Days[0].Date != "2026-10-30" || list.Days[0].DocID != doc.ID || !list.Days[0].Empty {
t.Fatalf("list: %s", rec.Body.String())
}
// Other people have their own journal
_, otherToken := newTestUser(t, store, "andere@example.com")
rec = sessionCall(router, "GET", "/api/v1/journal", otherToken, "", "")
if rec.Code != http.StatusOK || rec.Body.String() == "" {
t.Fatalf("other list: %d", rec.Code)
}
_ = json.Unmarshal(rec.Body.Bytes(), &list)
if len(list.Days) != 0 {
t.Fatalf("other user must not see my journal: %+v", list.Days)
}
// A day whose note went to the trash gets a fresh note
if err := store.DeleteDocument(me.ID, doc.ID); err != nil {
t.Fatalf("trash: %v", err)
}
rec = sessionCall(router, "POST", "/api/v1/journal/2026-10-30", token, "", "")
_ = json.Unmarshal(rec.Body.Bytes(), &again)
if rec.Code != http.StatusCreated || again.ID == doc.ID {
t.Fatalf("after trash: %d id %s", rec.Code, again.ID)
}
if rec := sessionCall(router, "POST", "/api/v1/journal/30.10.2026", token, "", ""); rec.Code != http.StatusBadRequest {
t.Fatalf("bad date: want 400, got %d", rec.Code)
}
}
func TestJournalTitle(t *testing.T) {
day := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
if got := journalTitle(day); got != "Sonntag, 1. März 2026" {
t.Fatalf("got %q", got)
}
}
+26 -12
View File
@@ -117,7 +117,7 @@ func senderAddress(raw, name string) (envelope, header string, err error) {
// buildMessage creates an RFC 5322 message: Date and Message-ID headers, RFC 2047 encoded subject // buildMessage creates an RFC 5322 message: Date and Message-ID headers, RFC 2047 encoded subject
// (umlauts, emoji) and a quoted-printable UTF-8 body with CRLF line endings. // (umlauts, emoji) and a quoted-printable UTF-8 body with CRLF line endings.
func buildMessage(from, to, subject, body string, now time.Time) []byte { func buildMessage(from, to, subject, body, html string, now time.Time) []byte {
idBytes := make([]byte, 12) idBytes := make([]byte, 12)
_, _ = rand.Read(idBytes) _, _ = rand.Read(idBytes)
domain := ehloName(from) domain := ehloName(from)
@@ -129,12 +129,26 @@ func buildMessage(from, to, subject, body string, now time.Time) []byte {
fmt.Fprintf(&buf, "Date: %s\r\n", now.Format(time.RFC1123Z)) fmt.Fprintf(&buf, "Date: %s\r\n", now.Format(time.RFC1123Z))
fmt.Fprintf(&buf, "Message-ID: <%s@%s>\r\n", hex.EncodeToString(idBytes), domain) fmt.Fprintf(&buf, "Message-ID: <%s@%s>\r\n", hex.EncodeToString(idBytes), domain)
buf.WriteString("MIME-Version: 1.0\r\n") buf.WriteString("MIME-Version: 1.0\r\n")
buf.WriteString("Content-Type: text/plain; charset=\"utf-8\"\r\n") part := func(contentType, text string) {
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n") fmt.Fprintf(&buf, "Content-Type: %s; charset=\"utf-8\"\r\n", contentType)
qp := quotedprintable.NewWriter(&buf) buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
_, _ = qp.Write([]byte(strings.ReplaceAll(strings.ReplaceAll(body, "\r\n", "\n"), "\n", "\r\n"))) qp := quotedprintable.NewWriter(&buf)
_ = qp.Close() _, _ = qp.Write([]byte(strings.ReplaceAll(strings.ReplaceAll(text, "\r\n", "\n"), "\n", "\r\n")))
buf.WriteString("\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() return buf.Bytes()
} }
@@ -199,14 +213,14 @@ func authenticate(client *smtp.Client, cfg smtpSettings) error {
// sendMail sends one mail. If AUTH PLAIN is refused and the server also offers LOGIN, it reconnects once // sendMail sends one mail. If AUTH PLAIN is refused and the server also offers LOGIN, it reconnects once
// and tries LOGIN; a 451 on both points to the mail server's SASL backend. // and tries LOGIN; a 451 on both points to the mail server's SASL backend.
func sendMail(cfg smtpSettings, to, subject, body string) error { func sendMail(cfg smtpSettings, to, subject, body, html string) error {
err := sendMailOnce(cfg, to, subject, body) err := sendMailOnce(cfg, to, subject, body, html)
var first *authAttemptError var first *authAttemptError
if cfg.authMech != "" || !errors.As(err, &first) || first.mech != "PLAIN" || !first.loginOffered { if cfg.authMech != "" || !errors.As(err, &first) || first.mech != "PLAIN" || !first.loginOffered {
return err return err
} }
cfg.authMech = "LOGIN" cfg.authMech = "LOGIN"
retryErr := sendMailOnce(cfg, to, subject, body) retryErr := sendMailOnce(cfg, to, subject, body, html)
var second *authAttemptError var second *authAttemptError
if retryErr == nil || !errors.As(retryErr, &second) { if retryErr == nil || !errors.As(retryErr, &second) {
return retryErr return retryErr
@@ -220,7 +234,7 @@ func sendMail(cfg smtpSettings, to, subject, body string) error {
// sendMailOnce runs the SMTP dialogue step by step instead of smtp.SendMail, so it can introduce itself // sendMailOnce runs the SMTP dialogue step by step instead of smtp.SendMail, so it can introduce itself
// properly, honour SkipVerify and report the exact step a server refuses. // properly, honour SkipVerify and report the exact step a server refuses.
func sendMailOnce(cfg smtpSettings, to, subject, body string) error { func sendMailOnce(cfg smtpSettings, to, subject, body, html string) error {
fromAddr, fromHeader, err := senderAddress(cfg.From, cfg.FromName) fromAddr, fromHeader, err := senderAddress(cfg.From, cfg.FromName)
if err != nil { if err != nil {
return &smtpStepError{"Absender", err} return &smtpStepError{"Absender", err}
@@ -278,7 +292,7 @@ func sendMailOnce(cfg smtpSettings, to, subject, body string) error {
if err != nil { if err != nil {
return &smtpStepError{"DATA", err} return &smtpStepError{"DATA", err}
} }
if _, err := wc.Write(buildMessage(fromHeader, toHeader, subject, body, time.Now())); err != nil { if _, err := wc.Write(buildMessage(fromHeader, toHeader, subject, body, html, time.Now())); err != nil {
_ = wc.Close() _ = wc.Close()
return &smtpStepError{"DATA", err} return &smtpStepError{"DATA", err}
} }
+7 -7
View File
@@ -175,13 +175,13 @@ func TestSendMailStartTLSWithSelfSignedCertificate(t *testing.T) {
f := startFakeSMTP(t, "") f := startFakeSMTP(t, "")
// The certificate is self-signed: refused unless verification is switched off // The certificate is self-signed: refused unless verification is switched off
err := sendMail(f.settings(false), "anna@example.com", "Test", "Hallo") err := sendMail(f.settings(false), "anna@example.com", "Test", "Hallo", "")
var stepErr *smtpStepError var stepErr *smtpStepError
if !errors.As(err, &stepErr) || stepErr.Step != "STARTTLS" { if !errors.As(err, &stepErr) || stepErr.Step != "STARTTLS" {
t.Fatalf("expected a STARTTLS error with verification on, got %v", err) t.Fatalf("expected a STARTTLS error with verification on, got %v", err)
} }
if err := sendMail(f.settings(true), "anna@example.com", "⏰ Aufgabe fällig: Müll", "Zeile 1\nZeile 2 mit Ümlaut"); err != nil { if err := sendMail(f.settings(true), "anna@example.com", "⏰ Aufgabe fällig: Müll", "Zeile 1\nZeile 2 mit Ümlaut", ""); err != nil {
t.Fatalf("send with SkipVerify failed: %v", err) t.Fatalf("send with SkipVerify failed: %v", err)
} }
f.mu.Lock() f.mu.Lock()
@@ -206,7 +206,7 @@ func TestSendMailStartTLSWithSelfSignedCertificate(t *testing.T) {
func TestSendMailReportsTheRefusingStep(t *testing.T) { func TestSendMailReportsTheRefusingStep(t *testing.T) {
f := startFakeSMTP(t, "451 4.3.2 Internal server error") f := startFakeSMTP(t, "451 4.3.2 Internal server error")
err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo") err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo", "")
var stepErr *smtpStepError var stepErr *smtpStepError
if !errors.As(err, &stepErr) || stepErr.Step != "RCPT TO" || !strings.Contains(err.Error(), "451") { if !errors.As(err, &stepErr) || stepErr.Step != "RCPT TO" || !strings.Contains(err.Error(), "451") {
t.Fatalf("expected the 451 to be reported at RCPT TO, got %v", err) t.Fatalf("expected the 451 to be reported at RCPT TO, got %v", err)
@@ -218,7 +218,7 @@ func TestSendMailWithDisplayNameSender(t *testing.T) {
f := startFakeSMTP(t, "") f := startFakeSMTP(t, "")
cfg := f.settings(true) cfg := f.settings(true)
cfg.From = "MindSift Alarme <alarm@firma.de>" cfg.From = "MindSift Alarme <alarm@firma.de>"
if err := sendMail(cfg, "Anna <anna@example.com>", "Test", "Hallo"); err != nil { if err := sendMail(cfg, "Anna <anna@example.com>", "Test", "Hallo", ""); err != nil {
t.Fatalf("send failed: %v", err) t.Fatalf("send failed: %v", err)
} }
f.mu.Lock() f.mu.Lock()
@@ -244,7 +244,7 @@ func TestAuthFallsBackToLogin(t *testing.T) {
f := startFakeSMTP(t, "") f := startFakeSMTP(t, "")
f.authMechs = "PLAIN LOGIN" f.authMechs = "PLAIN LOGIN"
f.authFail["PLAIN"] = "451 4.3.2 Internal server error" f.authFail["PLAIN"] = "451 4.3.2 Internal server error"
if err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo"); err != nil { if err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo", ""); err != nil {
t.Fatalf("LOGIN fallback should succeed: %v", err) t.Fatalf("LOGIN fallback should succeed: %v", err)
} }
f.mu.Lock() f.mu.Lock()
@@ -257,7 +257,7 @@ func TestAuthFallsBackToLogin(t *testing.T) {
func TestAuthLoginOnlyAndBothFailing(t *testing.T) { func TestAuthLoginOnlyAndBothFailing(t *testing.T) {
f := startFakeSMTP(t, "") f := startFakeSMTP(t, "")
f.authMechs = "LOGIN" f.authMechs = "LOGIN"
if err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo"); err != nil { if err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo", ""); err != nil {
t.Fatalf("LOGIN-only server: %v", err) t.Fatalf("LOGIN-only server: %v", err)
} }
@@ -265,7 +265,7 @@ func TestAuthLoginOnlyAndBothFailing(t *testing.T) {
g.authMechs = "PLAIN LOGIN" g.authMechs = "PLAIN LOGIN"
g.authFail["PLAIN"] = "451 4.3.2 Internal server error" g.authFail["PLAIN"] = "451 4.3.2 Internal server error"
g.authFail["LOGIN"] = "451 4.3.2 Internal server error" g.authFail["LOGIN"] = "451 4.3.2 Internal server error"
err := sendMail(g.settings(true), "anna@example.com", "Test", "Hallo") err := sendMail(g.settings(true), "anna@example.com", "Test", "Hallo", "")
if err == nil || !strings.Contains(err.Error(), "PLAIN") || !strings.Contains(err.Error(), "LOGIN") || !strings.Contains(err.Error(), "SASL") { if err == nil || !strings.Contains(err.Error(), "PLAIN") || !strings.Contains(err.Error(), "LOGIN") || !strings.Contains(err.Error(), "SASL") {
t.Fatalf("expected both mechanisms and the server hint in the error, got %v", err) t.Fatalf("expected both mechanisms and the server hint in the error, got %v", err)
} }
+467
View File
@@ -0,0 +1,467 @@
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, own webhook with a JSON body), 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 !set.Channels(kind).Any() {
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 out channelItems
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"]}
out.add(set.Channels(q.Kind), item)
}
if err := s.deliver(uid, "Neuigkeiten", out); 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)
}
}
// channelItems collects what goes out on each channel.
type channelItems struct {
email, discord, webhook []notifyItem
}
func (c *channelItems) add(ch domain.ChannelChoice, items ...notifyItem) {
if ch.Email {
c.email = append(c.email, items...)
}
if ch.Discord {
c.discord = append(c.discord, items...)
}
if ch.Webhook {
c.webhook = append(c.webhook, items...)
}
}
// deliver sends the items to the user's mail address, Discord webhook and/or own webhook.
func (s *Server) deliver(userID, heading string, out channelItems) error {
emailItems, discordItems := out.email, out.discord
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
}
}
if len(out.webhook) > 0 && u.CustomWebhookURL != "" {
if err := s.sendNotifyWebhook(u.CustomWebhookURL, heading, out.webhook); 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) sendNotifyWebhook(webhookURL, heading string, items []notifyItem) error {
if s.notifyWebhookHook != nil {
return s.notifyWebhookHook(webhookURL, heading, items)
}
return s.sendWebhookDigest(webhookURL, heading, items)
}
// sendWebhookDigest posts the bundle as JSON: {"event":"digest","heading":…,"items":[{kind,title,text}],…}.
func (s *Server) sendWebhookDigest(webhookURL, heading string, items []notifyItem) error {
if s.cfg.DemoMode {
return errDemoOutgoing
}
allowPrivate := s.allowPrivateWebhooks()
if err := validateWebhookURL(webhookURL, false, allowPrivate); err != nil {
return err
}
list := make([]map[string]string, 0, len(items))
for _, it := range items {
list = append(list, map[string]string{"kind": it.kind, "title": it.title, "text": it.text})
}
body, _ := json.Marshal(map[string]interface{}{
"event": "digest",
"heading": heading,
"items": list,
"link": s.notifyLink(),
"timestamp": domain.NowISO(),
})
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 (s *Server) notifyLink() string {
if !s.cfg.BaseURLSet {
return ""
}
return strings.TrimRight(s.cfg.BaseURL, "/") + "/"
}
// sendDiscordDigest posts one embed with a line per item (at most 10).
func (s *Server) sendDiscordDigest(webhookURL, heading string, items []notifyItem) error {
if s.cfg.DemoMode {
return errDemoOutgoing
}
allowPrivate := s.allowPrivateWebhooks()
if err := validateWebhookURL(webhookURL, true, allowPrivate); err != nil {
return err
}
lines := make([]string, 0, 11)
for i, it := range items {
if i == 10 {
lines = append(lines, fmt.Sprintf("… und %d weitere", len(items)-10))
break
}
lines = append(lines, "• "+it.text)
}
body, _ := json.Marshal(map[string]interface{}{
"embeds": []map[string]interface{}{{
"title": "MindSift: " + heading,
"description": strings.Join(lines, "\n"),
"color": 2450411,
}},
})
resp, err := guardedClient(allowPrivate).Post(webhookURL, "application/json", bytes.NewReader(body))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("HTTP Status %d", resp.StatusCode)
}
return nil
}
func notifyText(heading string, items []notifyItem, link string) string {
var b strings.Builder
b.WriteString(heading + "\n\n")
for _, it := range items {
b.WriteString("• " + it.text + "\n")
}
if link != "" {
b.WriteString("\nÖffnen: " + link + "\n")
}
b.WriteString("\nDiese Benachrichtigungen stellst du in MindSift unter Kontoeinstellungen → Alarme ein.\n")
return b.String()
}
// notifyHTML: simple table layout with inline styles, no external resources (works in most clients).
func notifyHTML(heading string, items []notifyItem, link string) string {
var cards strings.Builder
for _, it := range items {
cards.WriteString(`<tr><td style="padding:12px 16px;border:1px solid #e3e6ea;border-radius:8px;background:#ffffff;font-size:14px;color:#1f2328">` +
html.EscapeString(it.text) + `</td></tr><tr><td style="height:8px"></td></tr>`)
}
button := ""
if link != "" {
button = `<tr><td style="padding-top:8px"><a href="` + html.EscapeString(link) +
`" style="display:inline-block;padding:10px 18px;background:#2563eb;color:#ffffff;text-decoration:none;border-radius:6px;font-size:14px">In MindSift öffnen</a></td></tr>`
}
return `<!doctype html><html><body style="margin:0;padding:24px;background:#f4f5f7;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">` +
`<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:560px;margin:0 auto">` +
`<tr><td style="font-size:18px;font-weight:bold;color:#2563eb;padding-bottom:4px">MindSift</td></tr>` +
`<tr><td style="font-size:15px;color:#1f2328;padding-bottom:16px">` + html.EscapeString(heading) + `</td></tr>` +
cards.String() + button +
`<tr><td style="font-size:12px;color:#6b7280;padding-top:20px">Diese Benachrichtigungen stellst du in MindSift unter Kontoeinstellungen → Alarme ein.</td></tr>` +
`</table></body></html>`
}
// --- Daily summary ---
// sendDailySummaries sends each user who wants one their summary in the 30 minutes after the chosen
// time, once a day. Nothing due → no message.
func (s *Server) sendDailySummaries(now time.Time) {
day := now.Format("2006-01-02")
for _, uid := range s.store.ActiveUserIDs() {
set := s.store.NotificationSettingsOf(uid)
ds := set.DailySummary
if set.Paused || !ds.Enabled || (!ds.Email && !ds.Discord && !ds.Webhook) {
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 out channelItems
out.add(domain.ChannelChoice{Email: ds.Email, Discord: ds.Discord, Webhook: ds.Webhook}, items...)
if err := s.deliver(uid, "Deine Aufgaben für heute", out); 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]
}
}
+210
View File
@@ -0,0 +1,210 @@
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
}
srv.notifyWebhookHook = 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: "webhook", to: url, text: strings.Join(lines, "\n")})
return nil
}
return srv, &sent
}
func TestWatchedTaskChangesAreBundled(t *testing.T) {
srv, sent := notifyServer(t)
store := srv.store
router := srv.Router()
owner, ownerToken := newTestUser(t, store, "owner@example.com")
watcher, watcherToken := newTestUser(t, store, "watcher@example.com")
list, _ := store.CreateTaskList(owner.ID, "", "", "Haushalt", "🏠", "#000000")
task, _ := store.CreateThoughtWithDetails(owner.ID, "Fenster putzen", "normal", "manual", "", list.ID, "todo", "")
if _, err := store.AddTaskListShare(list.ID, "user", watcher.ID, "editor", owner.ID); err != nil {
t.Fatal(err)
}
if rec := sessionCall(router, "POST", "/api/v1/tasks/"+task.ID+"/watch", watcherToken, "", ""); rec.Code != http.StatusOK {
t.Fatalf("watch: %d %s", rec.Code, rec.Body.String())
}
_, strangerToken := newTestUser(t, store, "fremd@example.com")
if rec := sessionCall(router, "POST", "/api/v1/tasks/"+task.ID+"/watch", strangerToken, "", ""); rec.Code != http.StatusNotFound {
t.Fatalf("stranger watch: want 404, got %d", rec.Code)
}
// Two changes by the owner within the window → one bundled mail for the watcher, none for the owner
sessionCall(router, "PATCH", "/api/v1/thoughts/"+task.ID, ownerToken, `{"content":"Fenster putzen (innen)"}`, "")
sessionCall(router, "PATCH", "/api/v1/thoughts/"+task.ID, ownerToken, `{"isCompleted":true}`, "")
srv.flushNotifications(time.Now())
if len(*sent) != 0 {
t.Fatalf("nothing may go out before the window ends: %+v", *sent)
}
srv.flushNotifications(time.Now().Add(6 * time.Minute))
var mails []sentNote
for _, n := range *sent {
if n.channel == "email" {
mails = append(mails, n)
}
}
if len(mails) != 1 || mails[0].to != "watcher@example.com" || !strings.Contains(mails[0].text, "Text geändert") ||
!strings.Contains(mails[0].text, "erledigt") || !strings.Contains(mails[0].html, "<html") {
t.Fatalf("expected one bundled HTML mail to the watcher, got %+v", *sent)
}
*sent = nil
srv.flushNotifications(time.Now().Add(20 * time.Minute))
if len(*sent) != 0 {
t.Fatal("queue must be empty after sending")
}
}
func TestOwnChangesPauseAndChannels(t *testing.T) {
srv, sent := notifyServer(t)
store := srv.store
router := srv.Router()
me, myToken := newTestUser(t, store, "me@example.com")
task, _ := store.CreateThoughtWithDetails(me.ID, "Steuer", "normal", "manual", "", "", "todo", "")
_ = store.WatchTask(task.ID, me.ID, true)
// Own change on a watched task: nothing …
sessionCall(router, "PATCH", "/api/v1/thoughts/"+task.ID, myToken, `{"priority":"high"}`, "")
srv.flushNotifications(time.Now().Add(10 * time.Minute))
if len(*sent) != 0 {
t.Fatalf("own change must not notify: %+v", *sent)
}
// … unless asked for, then only over the chosen channel
set := store.NotificationSettingsOf(me.ID)
set.IncludeOwnWatched = true
set.Events.Watched = domain.ChannelChoice{Discord: true}
_ = store.SetNotificationSettings(me.ID, set)
hook := "https://discord.com/api/webhooks/1/x"
_, _ = store.UpdateUserFull(me.ID, nil, nil, nil, nil, nil, nil, &hook, nil, nil, nil)
sessionCall(router, "PATCH", "/api/v1/thoughts/"+task.ID, myToken, `{"priority":"low"}`, "")
srv.flushNotifications(time.Now().Add(10 * time.Minute))
if len(*sent) != 1 || (*sent)[0].channel != "discord" {
t.Fatalf("expected one Discord message, got %+v", *sent)
}
// Own webhook as a channel of its own
*sent = nil
set.Events.Watched = domain.ChannelChoice{Webhook: true}
_ = store.SetNotificationSettings(me.ID, set)
own := "https://hooks.example.com/mindsift"
_, _ = store.UpdateUserFull(me.ID, nil, nil, nil, nil, nil, nil, nil, &own, nil, nil)
sessionCall(router, "PATCH", "/api/v1/thoughts/"+task.ID, myToken, `{"priority":"normal"}`, "")
srv.flushNotifications(time.Now().Add(10 * time.Minute))
if len(*sent) != 1 || (*sent)[0].channel != "webhook" || (*sent)[0].to != own {
t.Fatalf("expected one webhook message, got %+v", *sent)
}
// Paused: nothing at all
*sent = nil
set.Paused = true
_ = store.SetNotificationSettings(me.ID, set)
sessionCall(router, "PATCH", "/api/v1/thoughts/"+task.ID, myToken, `{"priority":"urgent"}`, "")
srv.flushNotifications(time.Now().Add(10 * time.Minute))
if len(*sent) != 0 {
t.Fatalf("paused must send nothing: %+v", *sent)
}
// Settings round trip over the profile API
rec := sessionCall(router, "PATCH", "/api/v1/auth/settings", myToken, `{"notificationSettings":{"paused":false,"events":{"shared":{"email":false,"discord":true}},"dailySummary":{"enabled":true,"time":"6:5"}}}`, "")
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"time":"07:00"`) || !strings.Contains(rec.Body.String(), `"shared":{"email":false,"discord":true,"webhook":false}`) {
t.Fatalf("settings: %d %s", rec.Code, rec.Body.String())
}
}
func TestDailySummary(t *testing.T) {
srv, sent := notifyServer(t)
store := srv.store
me, _ := newTestUser(t, store, "summary@example.com")
now := time.Date(2026, 10, 30, 7, 0, 30, 0, time.Local)
set := store.NotificationSettingsOf(me.ID)
set.DailySummary = domain.DailySummary{Enabled: true, Time: "07:00", Email: true}
_ = store.SetNotificationSettings(me.ID, set)
srv.sendDailySummaries(now)
if len(*sent) != 0 {
t.Fatal("nothing due → no summary")
}
for _, td := range [][2]string{{"Altlast", "2026-10-28"}, {"Zahnarzt", "2026-10-30T15:30"}, {"Müll raus", "2026-10-31"}, {"Später", "2026-11-04"}} {
th, _ := store.CreateThoughtWithDetails(me.ID, td[0], "normal", "manual", td[1], "", "todo", "")
if _, err := store.UpdateThoughtTriage(me.ID, th.ID, "keep", "task", "", td[1], ""); err != nil { // triaged into a task
t.Fatal(err)
}
}
srv.sendDailySummaries(now.Add(-2 * time.Minute)) // before the chosen time
if len(*sent) != 0 {
t.Fatal("no summary before the chosen time")
}
srv.sendDailySummaries(now)
if len(*sent) != 1 {
t.Fatalf("expected one summary, got %d", len(*sent))
}
text := (*sent)[0].text
for _, want := range []string{"Überfällig", "Altlast", "Heute, 15:30 Uhr: Zahnarzt", "Morgen: Müll raus", "1 weitere"} {
if !strings.Contains(text, want) {
t.Fatalf("summary lacks %q:\n%s", want, text)
}
}
srv.sendDailySummaries(now.Add(5 * time.Minute))
if len(*sent) != 1 {
t.Fatal("only one summary per day")
}
}
func TestDailySummaryIncludesGroupSharedLists(t *testing.T) {
srv, sent := notifyServer(t)
store := srv.store
owner, _ := newTestUser(t, store, "chef@example.com")
me, _ := newTestUser(t, store, "team@example.com")
team, err := store.CreateTeam("", "Arbeit", "")
if err != nil {
t.Fatal(err)
}
for _, uid := range []string{owner.ID, me.ID} { // the owner must see the group to share with it
if err := store.AddTeamMember(team.ID, uid, "member"); err != nil {
t.Fatal(err)
}
}
list, _ := store.CreateTaskList(owner.ID, "", "", "Projekt", "📋", "#000000")
if _, err := store.AddTaskListShare(list.ID, "team", team.ID, "editor", owner.ID); err != nil {
t.Fatal(err)
}
th, _ := store.CreateThoughtWithDetails(owner.ID, "Bericht abgeben", "normal", "manual", "2026-10-30T10:00", list.ID, "todo", "")
_, _ = store.UpdateThoughtTriage(owner.ID, th.ID, "keep", "task", "", "2026-10-30T10:00", list.ID)
set := store.NotificationSettingsOf(me.ID)
set.DailySummary = domain.DailySummary{Enabled: true, Time: "07:00", Email: true}
_ = store.SetNotificationSettings(me.ID, set)
srv.sendDailySummaries(time.Date(2026, 10, 30, 7, 1, 0, 0, time.Local))
if len(*sent) != 1 || !strings.Contains((*sent)[0].text, "Bericht abgeben (Projekt)") {
t.Fatalf("group-shared task missing: %+v", *sent)
}
}
+17 -3
View File
@@ -53,17 +53,31 @@ func (s *Server) handleExtractBranch(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "blockId fehlt") writeError(w, http.StatusBadRequest, "blockId fehlt")
return return
} }
created, err := s.store.ExtractBranch(userID, docID, req.BlockID, s.restructureActor(r, req.ClientID), s.leases.View(docID)) created, link, err := s.store.ExtractBranch(userID, docID, req.BlockID, s.restructureActor(r, req.ClientID), s.leases.View(docID))
if err != nil { if err != nil {
writeError(w, restructureStatus(err), err.Error()) writeError(w, restructureStatus(err), err.Error())
return return
} }
s.notifyDocChanged(docID, s.store.DocHead(docID))
if full, err := s.store.GetDocumentByID(userID, created.ID); err == nil { if full, err := s.store.GetDocumentByID(userID, created.ID); err == nil {
created = full created = full
} }
s.broadcastDocToAuthorizedUsers(created.ID, "DOC_CREATED") s.broadcastDocToAuthorizedUsers(created.ID, "DOC_CREATED")
writeJSON(w, http.StatusCreated, created) writeJSON(w, http.StatusCreated, map[string]interface{}{"document": created, "link": link})
}
// handleUndoExtract: POST /documents/{id}/extract/undo puts the extracted note {id} into the trash.
func (s *Server) handleUndoExtract(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
users, _ := s.store.GetAuthorizedDocUsers(docID)
if err := s.store.UndoExtract(userID, docID); err != nil {
writeError(w, restructureStatus(err), err.Error())
return
}
for _, uid := range users {
s.wsHub.BroadcastToUser(uid, "DOC_DELETED", map[string]string{"id": docID})
}
writeJSON(w, http.StatusOK, map[string]bool{"undone": true})
} }
// handleMergeDocuments: POST /documents/{id}/merge {sourceId, clientId} appends the source to this note. // handleMergeDocuments: POST /documents/{id}/merge {sourceId, clientId} appends the source to this note.
+27 -10
View File
@@ -41,16 +41,21 @@ func TestExtractBranchIntoOwnNote(t *testing.T) {
if rec.Code != http.StatusCreated { if rec.Code != http.StatusCreated {
t.Fatalf("extract: %d %s", rec.Code, rec.Body.String()) t.Fatalf("extract: %d %s", rec.Code, rec.Body.String())
} }
var created domain.Document var out struct {
_ = json.Unmarshal(rec.Body.Bytes(), &created) Document domain.Document
if created.Title != "Konzept: Garten" || created.FolderPath != "Arbeit" { Link string
}
_ = json.Unmarshal(rec.Body.Bytes(), &out)
created := out.Document
if created.Title != "Konzept: Garten" || created.FolderPath != "Arbeit" || out.Link != "[[Konzept: Garten]] #draußen" {
t.Fatalf("new note: %q in %q", created.Title, created.FolderPath) t.Fatalf("new note: %q in %q", created.Title, created.FolderPath)
} }
if got := strings.Join(flatOf(t, store, uid, created.ID), "|"); got != "Beete|>Erde kaufen|Zaun" { if got := strings.Join(flatOf(t, store, uid, created.ID), "|"); got != "Beete|>Erde kaufen|Zaun" {
t.Fatalf("new note blocks: %s", got) t.Fatalf("new note blocks: %s", got)
} }
if got := strings.Join(flatOf(t, store, uid, src.ID), "|"); got != "[[Konzept: Garten]] #draußen|Danach" { // The server leaves the source alone; the editor replaces the branch itself
t.Fatalf("source after extract: %s", got) if got := strings.Join(flatOf(t, store, uid, src.ID), "|"); got != "- Konzept: Garten #draußen|>Beete|>>Erde kaufen|>Zaun|Danach" {
t.Fatalf("source must stay unchanged on the server: %s", got)
} }
// Same title again: the link must stay unique // Same title again: the link must stay unique
_, _ = store.CreateDocument(uid, "", "Projekt 2", "", "", []domain.DocumentBlock{{ID: "blk_x", Type: "bullet", Content: "Konzept: Garten", Level: 0}}, nil, false) _, _ = store.CreateDocument(uid, "", "Projekt 2", "", "", []domain.DocumentBlock{{ID: "blk_x", Type: "bullet", Content: "Konzept: Garten", Level: 0}}, nil, false)
@@ -61,10 +66,21 @@ func TestExtractBranchIntoOwnNote(t *testing.T) {
p2 = d.ID p2 = d.ID
} }
} }
var second domain.Document var second struct{ Document domain.Document }
_ = json.Unmarshal(sessionCall(router, "POST", "/api/v1/documents/"+p2+"/extract", token, `{"blockId":"blk_x"}`, "").Body.Bytes(), &second) _ = json.Unmarshal(sessionCall(router, "POST", "/api/v1/documents/"+p2+"/extract", token, `{"blockId":"blk_x"}`, "").Body.Bytes(), &second)
if second.Title != "Konzept: Garten (2)" { if second.Document.Title != "Konzept: Garten (2)" {
t.Fatalf("duplicate title: %q", second.Title) t.Fatalf("duplicate title: %q", second.Document.Title)
}
// Undo puts the new note into the trash; only its creator may
_, strangerToken := newTestUser(t, store, "fremd2@example.com")
if rec := sessionCall(router, "POST", "/api/v1/documents/"+created.ID+"/extract/undo", strangerToken, "", ""); rec.Code == http.StatusOK {
t.Fatal("stranger must not undo")
}
if rec := sessionCall(router, "POST", "/api/v1/documents/"+created.ID+"/extract/undo", token, "", ""); rec.Code != http.StatusOK {
t.Fatalf("undo: %d %s", rec.Code, rec.Body.String())
}
if d, _ := store.GetDocumentByID(uid, created.ID); d == nil || !d.IsArchived {
t.Fatal("extracted note must be in the trash after undo")
} }
} }
@@ -87,8 +103,9 @@ func TestExtractInSharedNoteKeepsAccess(t *testing.T) {
t.Fatalf("reader must not extract: %d", rec.Code) t.Fatalf("reader must not extract: %d", rec.Code)
} }
rec := sessionCall(router, "POST", "/api/v1/documents/"+src.ID+"/extract", editorToken, `{"blockId":"blk_s1"}`, "") rec := sessionCall(router, "POST", "/api/v1/documents/"+src.ID+"/extract", editorToken, `{"blockId":"blk_s1"}`, "")
var created domain.Document var out struct{ Document domain.Document }
_ = json.Unmarshal(rec.Body.Bytes(), &created) _ = json.Unmarshal(rec.Body.Bytes(), &out)
created := out.Document
if rec.Code != http.StatusCreated { if rec.Code != http.StatusCreated {
t.Fatalf("editor extract: %d %s", rec.Code, rec.Body.String()) t.Fatalf("editor extract: %d %s", rec.Code, rec.Body.String())
} }
+11
View File
@@ -34,6 +34,11 @@ type Server struct {
streamTickets streamTickets // one-time tickets for WebSocket/SSE (streamticket.go) streamTickets streamTickets // one-time tickets for WebSocket/SSE (streamticket.go)
loginGuard *loginGuard // brute-force protection (loginguard.go) loginGuard *loginGuard // brute-force protection (loginguard.go)
voice *voiceService // self-hosted speech recognition (voice.go) voice *voiceService // self-hosted speech recognition (voice.go)
// Replace the delivery of bundled notifications in tests (notify.go)
notifyEmailHook func(to, subject, text, html string) error
notifyDiscordHook func(webhookURL, heading string, items []notifyItem) error
notifyWebhookHook func(webhookURL, heading string, items []notifyItem) error
} }
func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server { func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server {
@@ -62,6 +67,7 @@ func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS
} }
if !cfg.DemoMode { if !cfg.DemoMode {
go srv.startAlarmTicker() // the demo sends nothing (FLAW-33) go srv.startAlarmTicker() // the demo sends nothing (FLAW-33)
go srv.startNotificationJobs()
go srv.startBackupScheduler() go srv.startBackupScheduler()
} }
return srv return srv
@@ -136,6 +142,9 @@ func (s *Server) Router() http.Handler {
r.Post("/api/v1/documents/{id}/media/fetch", s.notInDemo(s.handleFetchMedia)) r.Post("/api/v1/documents/{id}/media/fetch", s.notInDemo(s.handleFetchMedia))
r.Get("/api/v1/media/{id}", s.handleGetMedia) r.Get("/api/v1/media/{id}", s.handleGetMedia)
r.Post("/api/v1/documents/{id}/extract", s.handleExtractBranch) r.Post("/api/v1/documents/{id}/extract", s.handleExtractBranch)
r.Post("/api/v1/tasks/{id}/watch", s.handleWatchTask)
r.Delete("/api/v1/tasks/{id}/watch", s.handleWatchTask)
r.Post("/api/v1/documents/{id}/extract/undo", s.handleUndoExtract)
r.Post("/api/v1/documents/{id}/merge", s.handleMergeDocuments) r.Post("/api/v1/documents/{id}/merge", s.handleMergeDocuments)
r.Post("/api/v1/documents/{id}/merge/undo", s.handleUndoMerge) r.Post("/api/v1/documents/{id}/merge/undo", s.handleUndoMerge)
r.Get("/api/v1/link-preview", s.notInDemo(s.handleLinkPreview)) r.Get("/api/v1/link-preview", s.notInDemo(s.handleLinkPreview))
@@ -208,6 +217,8 @@ func (s *Server) Router() http.Handler {
r.Get("/api/v1/workspace/tags/{tag}", s.handleGetTagDetails) r.Get("/api/v1/workspace/tags/{tag}", s.handleGetTagDetails)
r.Put("/api/v1/workspace/tags/{tag}/note", s.handleUpdateTagPageNote) r.Put("/api/v1/workspace/tags/{tag}/note", s.handleUpdateTagPageNote)
r.Get("/api/v1/journal", s.handleGetJournal)
r.Post("/api/v1/journal/{date}", s.handleOpenJournalDay)
r.Get("/api/v1/documents", s.handleGetDocuments) r.Get("/api/v1/documents", s.handleGetDocuments)
r.Post("/api/v1/documents", s.handleCreateDocument) r.Post("/api/v1/documents", s.handleCreateDocument)
r.Get("/api/v1/documents/{id}", s.handleGetDocument) r.Get("/api/v1/documents/{id}", s.handleGetDocument)
+4
View File
@@ -51,6 +51,9 @@ type Config struct {
// Images and audio in notes (MEDIA_*) // Images and audio in notes (MEDIA_*)
Media MediaConfig Media MediaConfig
// NotifyDigestMinutes: notifications (except task alarms) are bundled over this many minutes
NotifyDigestMinutes int
} }
// MediaConfig limits uploads of images and audio recordings in notes. Sizes in MB; QuotaMB 0 = no limit // MediaConfig limits uploads of images and audio recordings in notes. Sizes in MB; QuotaMB 0 = no limit
@@ -237,6 +240,7 @@ func Load() *Config {
PerUserPerHour: envInt("STT_PER_USER_PER_HOUR", 60, 1, 100000), PerUserPerHour: envInt("STT_PER_USER_PER_HOUR", 60, 1, 100000),
TimeoutSeconds: envInt("STT_TIMEOUT_SECONDS", 120, 5, 3600), TimeoutSeconds: envInt("STT_TIMEOUT_SECONDS", 120, 5, 3600),
}, },
NotifyDigestMinutes: envInt("NOTIFY_DIGEST_MINUTES", 5, 1, 1440),
Media: MediaConfig{ Media: MediaConfig{
MaxImageMB: envInt("MEDIA_MAX_IMAGE_MB", 15, 1, 1000), MaxImageMB: envInt("MEDIA_MAX_IMAGE_MB", 15, 1, 1000),
MaxAudioMB: envInt("MEDIA_MAX_AUDIO_MB", 50, 1, 2000), MaxAudioMB: envInt("MEDIA_MAX_AUDIO_MB", 50, 1, 2000),
+71
View File
@@ -135,6 +135,73 @@ type User struct {
NotifyBeforeMinutes int `json:"notifyBeforeMinutes"` NotifyBeforeMinutes int `json:"notifyBeforeMinutes"`
OrganizationID string `json:"organizationId,omitempty"` OrganizationID string `json:"organizationId,omitempty"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
// Only filled for the user's own profile (GET /auth/me, PATCH /auth/settings)
NotificationSettings *NotificationSettings `json:"notificationSettings,omitempty"`
}
// NotificationSettings: which events reach a user over which channel, the daily summary and a pause.
type NotificationSettings struct {
Paused bool `json:"paused"`
IncludeOwnWatched bool `json:"includeOwnWatched"`
Events NotificationEvents `json:"events"`
DailySummary DailySummary `json:"dailySummary"`
}
type NotificationEvents struct {
Due ChannelChoice `json:"due"` // task alarms (sent at once)
Shared ChannelChoice `json:"shared"` // something was shared with me
TaskDone ChannelChoice `json:"taskDone"` // a shared task was completed
Watched ChannelChoice `json:"watched"` // a watched task changed
}
type ChannelChoice struct {
Email bool `json:"email"`
Discord bool `json:"discord"`
Webhook bool `json:"webhook"` // own webhook (customWebhookUrl), JSON payload
}
// Any reports whether at least one channel is chosen.
func (c ChannelChoice) Any() bool { return c.Email || c.Discord || c.Webhook }
type DailySummary struct {
Enabled bool `json:"enabled"`
Time string `json:"time"` // HH:MM, server time zone
Email bool `json:"email"`
Discord bool `json:"discord"`
Webhook bool `json:"webhook"`
}
// DefaultNotificationSettings for users who never saved any; task alarms follow the old alarm channels.
func DefaultNotificationSettings(alarmChannels []string) NotificationSettings {
due := ChannelChoice{}
for _, c := range alarmChannels {
due.Email = due.Email || c == "email"
due.Discord = due.Discord || c == "discord"
due.Webhook = due.Webhook || c == "webhook"
}
return NotificationSettings{
Events: NotificationEvents{
Due: due,
Shared: ChannelChoice{Email: true},
Watched: ChannelChoice{Email: true, Discord: true},
},
DailySummary: DailySummary{Time: "07:00", Email: true},
}
}
// Channels returns the choice for an event kind (due, shared, taskDone, watched).
func (n NotificationSettings) Channels(kind string) ChannelChoice {
switch kind {
case "due":
return n.Events.Due
case "shared":
return n.Events.Shared
case "taskDone":
return n.Events.TaskDone
case "watched":
return n.Events.Watched
}
return ChannelChoice{}
} }
type UserInvite struct { type UserInvite struct {
@@ -167,6 +234,8 @@ type SubTask struct {
} }
type Thought struct { type Thought struct {
// IsWatching: the requesting user gets notified about changes (task watchers)
IsWatching bool `json:"isWatching,omitempty"`
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"userId"` UserID string `json:"userId"`
Content string `json:"content"` Content string `json:"content"`
@@ -372,6 +441,8 @@ func CompileBlocksToMarkdown(blocks []DocumentBlock) string {
sb.WriteString(indent + "- ```" + b.Lang + "\n" + b.Content + "\n" + indent + " ```\n") sb.WriteString(indent + "- ```" + b.Lang + "\n" + b.Content + "\n" + indent + " ```\n")
case "divider": case "divider":
sb.WriteString(indent + "- ---\n") sb.WriteString(indent + "- ---\n")
case "numbered":
sb.WriteString(indent + "1. " + b.Content + "\n")
default: default:
sb.WriteString(indent + "- " + b.Content + "\n") sb.WriteString(indent + "- " + b.Content + "\n")
} }
+3
View File
@@ -258,6 +258,9 @@ func blocksToVaultMarkdown(blocks []domain.DocumentBlock, attachment func(id str
sb.WriteString(indent + "- ---\n") sb.WriteString(indent + "- ---\n")
case "callout": case "callout":
sb.WriteString(indent + "- > " + cont(content, " > ") + "\n") sb.WriteString(indent + "- > " + cont(content, " > ") + "\n")
case "numbered":
// Markdown renumbers the items itself
sb.WriteString(indent + "1. " + cont(content, " ") + "\n")
default: default:
if strings.TrimSpace(content) == "" { if strings.TrimSpace(content) == "" {
sb.WriteString(indent + "-\n") sb.WriteString(indent + "-\n")
+3
View File
@@ -824,6 +824,9 @@ func MarkdownToBlocks(md string) []domain.DocumentBlock {
level := listLevel(indentWidth(m[1])) level := listLevel(indentWidth(m[1]))
content := m[2] content := m[2]
typ := "bullet" typ := "bullet"
if t := strings.TrimLeft(line, " \t"); t != "" && t[0] >= '0' && t[0] <= '9' {
typ = "numbered"
}
checked := false checked := false
if c := checkboxRe.FindStringSubmatch(content); c != nil { if c := checkboxRe.FindStringSubmatch(content); c != nil {
typ, checked, content = "todo", c[1] != " ", c[2] typ, checked, content = "todo", c[1] != " ", c[2]
+65
View File
@@ -0,0 +1,65 @@
package storage
import (
"database/sql"
"errors"
)
// Journal: one note per person and day. The notes are ordinary documents (sharing, search, sync work as
// usual); this table only remembers which note belongs to which day.
type JournalDay struct {
Date string `json:"date"` // YYYY-MM-DD
DocID string `json:"docId"`
Empty bool `json:"empty"` // no text yet (the journal still shows today and days jumped to)
}
func (s *Store) migrateJournal() error {
return s.execInTx(
stmt(`CREATE TABLE IF NOT EXISTS journal_days (
user_id TEXT NOT NULL,
day TEXT NOT NULL,
doc_id TEXT NOT NULL,
PRIMARY KEY (user_id, day)
)`),
stmt(`CREATE INDEX IF NOT EXISTS idx_journal_days_doc ON journal_days(doc_id)`),
)
}
// JournalDays lists the user's days whose note still exists (not in the trash), newest first.
func (s *Store) JournalDays(userID string) ([]JournalDay, error) {
rows, err := s.db.Query(`SELECT j.day, j.doc_id, TRIM(d.content) = ''
FROM journal_days j JOIN documents d ON d.id = j.doc_id
WHERE j.user_id = ? AND d.is_archived = 0
ORDER BY j.day DESC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
days := []JournalDay{}
for rows.Next() {
var d JournalDay
if err := rows.Scan(&d.Date, &d.DocID, &d.Empty); err != nil {
return nil, err
}
days = append(days, d)
}
return days, rows.Err()
}
// JournalDocID returns the note of a day; "" when there is none or it was moved to the trash.
func (s *Store) JournalDocID(userID, day string) (string, error) {
var docID string
err := s.db.QueryRow(`SELECT j.doc_id FROM journal_days j JOIN documents d ON d.id = j.doc_id
WHERE j.user_id = ? AND j.day = ? AND d.is_archived = 0`, userID, day).Scan(&docID)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return docID, err
}
func (s *Store) SetJournalDay(userID, day, docID string) error {
_, err := s.db.Exec(`INSERT INTO journal_days (user_id, day, doc_id) VALUES (?, ?, ?)
ON CONFLICT(user_id, day) DO UPDATE SET doc_id = excluded.doc_id`, userID, day, docID)
return err
}
+2
View File
@@ -27,6 +27,8 @@ var migrations = []migration{
{4, "Sitzungen", (*Store).migrateSessions}, {4, "Sitzungen", (*Store).migrateSessions},
{5, "Medien", (*Store).migrateMedia}, {5, "Medien", (*Store).migrateMedia},
{6, "Link-Vorschauen", (*Store).migrateLinkPreviews}, {6, "Link-Vorschauen", (*Store).migrateLinkPreviews},
{7, "Benachrichtigungen", (*Store).migrateNotifications},
{8, "Journal", (*Store).migrateJournal},
} }
func (s *Store) migrate() error { func (s *Store) migrate() error {
+258
View File
@@ -0,0 +1,258 @@
package storage
import (
"encoding/json"
"strings"
"time"
"github.com/sandrodach/mindsift/internal/domain"
)
// Notifications: per-user settings (JSON on the user), a queue that is sent bundled every few minutes,
// task watchers and the bookkeeping for the daily summary.
func (s *Store) migrateNotifications() error {
if err := s.addColumnIfMissing("users", "notification_settings", "TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
if err := s.addColumnIfMissing("users", "last_daily_summary", "TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
return s.execInTx(
stmt(`CREATE TABLE IF NOT EXISTS notification_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
kind TEXT NOT NULL,
payload TEXT NOT NULL,
created_at TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0
)`),
stmt(`CREATE INDEX IF NOT EXISTS idx_notification_queue_user ON notification_queue(user_id, created_at)`),
stmt(`CREATE TABLE IF NOT EXISTS task_watchers (
task_id TEXT NOT NULL,
user_id TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (task_id, user_id)
)`),
)
}
// NotificationSettingsOf returns the user's settings, defaults filled in for users who never saved any.
func (s *Store) NotificationSettingsOf(userID string) domain.NotificationSettings {
var raw, channels string
_ = s.db.QueryRow(`SELECT notification_settings, alarm_channels FROM users WHERE id = ?`, userID).Scan(&raw, &channels)
out := domain.DefaultNotificationSettings(parseAlarmChannels(channels))
if raw != "" {
_ = json.Unmarshal([]byte(raw), &out)
}
if !validClock(out.DailySummary.Time) {
out.DailySummary.Time = "07:00"
}
return out
}
func validClock(v string) bool {
t, err := time.Parse("15:04", v)
return err == nil && t.Format("15:04") == v
}
func (s *Store) SetNotificationSettings(userID string, n domain.NotificationSettings) error {
if !validClock(n.DailySummary.Time) {
n.DailySummary.Time = "07:00"
}
raw, _ := json.Marshal(n)
_, err := s.db.Exec(`UPDATE users SET notification_settings = ? WHERE id = ?`, string(raw), userID)
return err
}
// QueuedNotification is one event waiting to be sent bundled.
type QueuedNotification struct {
ID int64
UserID string
Kind string
Payload map[string]string
CreatedAt string
Attempts int
}
func (s *Store) QueueNotification(userID, kind string, payload map[string]string) error {
raw, _ := json.Marshal(payload)
_, err := s.db.Exec(`INSERT INTO notification_queue (user_id, kind, payload, created_at) VALUES (?, ?, ?, ?)`,
userID, kind, string(raw), time.Now().UTC().Format(time.RFC3339))
return err
}
// DueNotificationUsers lists users whose oldest waiting event is older than the bundling window.
func (s *Store) DueNotificationUsers(olderThan time.Time) ([]string, error) {
rows, err := s.db.Query(`SELECT user_id FROM notification_queue GROUP BY user_id HAVING MIN(created_at) <= ?`,
olderThan.UTC().Format(time.RFC3339))
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var id string
if rows.Scan(&id) == nil {
out = append(out, id)
}
}
return out, rows.Err()
}
func (s *Store) QueuedNotifications(userID string) ([]QueuedNotification, error) {
rows, err := s.db.Query(`SELECT id, user_id, kind, payload, created_at, attempts FROM notification_queue WHERE user_id = ? ORDER BY id`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []QueuedNotification
for rows.Next() {
var q QueuedNotification
var raw string
if rows.Scan(&q.ID, &q.UserID, &q.Kind, &raw, &q.CreatedAt, &q.Attempts) == nil {
_ = json.Unmarshal([]byte(raw), &q.Payload)
out = append(out, q)
}
}
return out, rows.Err()
}
func (s *Store) DeleteQueuedNotifications(ids []int64) error {
for _, id := range ids {
if _, err := s.db.Exec(`DELETE FROM notification_queue WHERE id = ?`, id); err != nil {
return err
}
}
return nil
}
func (s *Store) BumpQueuedNotifications(ids []int64) error {
for _, id := range ids {
if _, err := s.db.Exec(`UPDATE notification_queue SET attempts = attempts + 1 WHERE id = ?`, id); err != nil {
return err
}
}
return nil
}
// --- Task watchers ---
func (s *Store) WatchTask(taskID, userID string, on bool) error {
if on {
_, err := s.db.Exec(`INSERT OR IGNORE INTO task_watchers (task_id, user_id, created_at) VALUES (?, ?, ?)`, taskID, userID, nowUTC())
return err
}
_, err := s.db.Exec(`DELETE FROM task_watchers WHERE task_id = ? AND user_id = ?`, taskID, userID)
return err
}
func (s *Store) TaskWatchers(taskID string) []string {
rows, err := s.db.Query(`SELECT user_id FROM task_watchers WHERE task_id = ?`, taskID)
if err != nil {
return nil
}
defer rows.Close()
var out []string
for rows.Next() {
var id string
if rows.Scan(&id) == nil {
out = append(out, id)
}
}
return out
}
// WatchedTaskIDs is the set of tasks the user watches (to mark them in task lists).
func (s *Store) WatchedTaskIDs(userID string) map[string]bool {
out := map[string]bool{}
rows, err := s.db.Query(`SELECT task_id FROM task_watchers WHERE user_id = ?`, userID)
if err != nil {
return out
}
defer rows.Close()
for rows.Next() {
var id string
if rows.Scan(&id) == nil {
out[id] = true
}
}
return out
}
func (s *Store) ForgetTaskWatchers(taskID string) {
_, _ = s.db.Exec(`DELETE FROM task_watchers WHERE task_id = ?`, taskID)
}
// --- Daily summary ---
// SummaryTask is an open task with a due date, for the daily summary.
type SummaryTask struct {
Title, DueDate, ListTitle string
}
// ActiveUserIDs lists all active accounts (the summary job checks their settings).
func (s *Store) ActiveUserIDs() []string {
rows, err := s.db.Query(`SELECT id FROM users WHERE is_active = 1`)
if err != nil {
return nil
}
defer rows.Close()
var out []string
for rows.Next() {
var id string
if rows.Scan(&id) == nil {
out = append(out, id)
}
}
return out
}
// OpenTasksDueBefore lists the user's open tasks (own ones, those of own lists and of lists shared with
// the user directly or through a group) due before
// the given day (YYYY-MM-DD, exclusive), oldest first.
func (s *Store) OpenTasksDueBefore(userID, beforeDay string) ([]SummaryTask, error) {
rows, err := s.db.Query(`SELECT t.content, t.due_date, COALESCE(l.title, '')
FROM thoughts t LEFT JOIN task_lists l ON l.id = t.task_list_id
WHERE (t.status = 'kept' OR (t.status = 'converted' AND t.converted_type = 'task'))
AND t.is_completed = 0 AND t.due_date != '' AND substr(t.due_date, 1, 10) < ?
AND (t.user_id = ? OR (t.task_list_id != '' AND t.task_list_id IN (
SELECT id FROM task_lists WHERE user_id = ?
UNION SELECT task_list_id FROM task_list_shares WHERE target_type = 'user' AND target_id = ?
UNION SELECT task_list_id FROM task_list_shares WHERE target_type = 'team'
AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))))
ORDER BY t.due_date`, beforeDay, userID, userID, userID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []SummaryTask
for rows.Next() {
var t SummaryTask
if rows.Scan(&t.Title, &t.DueDate, &t.ListTitle) == nil {
t.Title = strings.TrimSpace(strings.SplitN(t.Title, "\n", 2)[0])
out = append(out, t)
}
}
return out, rows.Err()
}
// ClaimDailySummary marks today's summary as sent; false when it was already sent today.
func (s *Store) ClaimDailySummary(userID, day string) bool {
res, err := s.db.Exec(`UPDATE users SET last_daily_summary = ? WHERE id = ? AND last_daily_summary != ?`, day, userID, day)
if err != nil {
return false
}
n, _ := res.RowsAffected()
return n == 1
}
// SharedFolderName is the display name of a shared workspace folder.
func (s *Store) SharedFolderName(folderID string) string {
var name string
_ = s.db.QueryRow(`SELECT name FROM workspace_shared_folders WHERE id = ?`, folderID).Scan(&name)
if name == "" {
name = "Ordner"
}
return name
}
+23 -23
View File
@@ -92,16 +92,17 @@ func foreignLeaseIn(ids []string, a collab.Actor, leases collab.LeaseView) bool
return false return false
} }
// ExtractBranch moves a bullet's children into a new note named after the bullet, in the same folder, // ExtractBranch creates a new note from a bullet's children, named after the bullet, in the same folder
// shared like the original; the bullet itself becomes a [[link]] to it. Returns the new note. // and shared like the original. The original note is not changed here: the editor replaces the branch
func (s *Store) ExtractBranch(userID, docID, blockID string, a collab.Actor, leases collab.LeaseView) (*domain.Document, error) { // by the returned link itself, as an ordinary own edit (no clash with unsent typing, undo works).
func (s *Store) ExtractBranch(userID, docID, blockID string, a collab.Actor, leases collab.LeaseView) (*domain.Document, string, error) {
source, err := s.GetDocumentByID(userID, docID) source, err := s.GetDocumentByID(userID, docID)
if err != nil { if err != nil {
return nil, err return nil, "", err
} }
state, err := s.readCollabDoc(docID) state, err := s.readCollabDoc(docID)
if err != nil { if err != nil {
return nil, err return nil, "", err
} }
flat := state.Flatten() flat := state.Flatten()
start := -1 start := -1
@@ -112,7 +113,7 @@ func (s *Store) ExtractBranch(userID, docID, blockID string, a collab.Actor, lea
} }
} }
if start < 0 { if start < 0 {
return nil, ErrBlockNotFound return nil, "", ErrBlockNotFound
} }
root := flat[start] root := flat[start]
var children []domain.DocumentBlock var children []domain.DocumentBlock
@@ -122,37 +123,36 @@ func (s *Store) ExtractBranch(userID, docID, blockID string, a collab.Actor, lea
ids = append(ids, flat[i].ID) ids = append(ids, flat[i].ID)
} }
if foreignLeaseIn(ids, a, leases) { if foreignLeaseIn(ids, a, leases) {
return nil, ErrBranchLocked return nil, "", ErrBranchLocked
} }
base, tags := branchTitle(root.Content) base, tags := branchTitle(root.Content)
title := s.uniqueTitle(userID, base) title := s.uniqueTitle(userID, base)
created, err := s.CreateDocument(userID, "", title, "📄", source.FolderPath, children, nil, false) created, err := s.CreateDocument(userID, "", title, "📄", source.FolderPath, children, nil, false)
if err != nil { if err != nil {
return nil, err return nil, "", err
} }
s.copyShares(userID, source, created.ID) s.copyShares(userID, source, created.ID)
link := "[[" + title + "]]" link := "[[" + title + "]]"
if tags != "" { if tags != "" {
link += " " + tags link += " " + tags
} }
_, _, err = s.applyDocChange(docID, a, leases, func(d *collab.Doc) []collab.Op { return created, link, nil
b, ok := d.Blocks[blockID] }
if !ok || b.Deleted {
return nil // UndoExtract puts a note created by ExtractBranch into the trash (only its creator may).
} func (s *Store) UndoExtract(userID, docID string) error {
ops := []collab.Op{{OpID: domain.NewID("op"), Kind: collab.OpSetContent, BlockID: blockID, BaseVersion: b.Version, Content: &link}} doc, err := s.GetDocumentByID(userID, docID)
for _, c := range d.Children(blockID) {
ops = append(ops, collab.Op{OpID: domain.NewID("op"), Kind: collab.OpDelete, BlockID: c.ID})
}
return ops
})
if err != nil { if err != nil {
_ = s.hardDeleteDocument(created.ID) return err
return nil, err
} }
return created, nil if !doc.IsOwner {
return ErrMergeNotOwner
}
if doc.IsArchived {
return nil
}
return s.trashDocument(userID, docID)
} }
// copyShares gives the new note the direct and team shares of the source; when someone else owns the // copyShares gives the new note the direct and team shares of the source; when someone else owns the