26 Commits
Author SHA1 Message Date
sandrodachandClaude Opus 5.5 ad614f1169 Merge branch 'dev' into main: v0.7.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 00:26:28 +01:00
sandrodachandClaude Opus 5.5 02ce554c02 chore(release): v0.7.0
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 00:26:26 +01:00
sandrodachandClaude Opus 5.5 4d1b37c956 feat(workspace): turn a branch into its own note, merge notes
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m56s
- Extract: button on the bullet (desktop) or /extract (/notiz). The bullet
  becomes the title (hashtags stay at the link), its children the content
  of a new note in the same folder with the source's shares (a foreign
  source owner gets write access); the branch becomes [[title]] through
  the operation log, so open editors follow.
- Merge: drop a note onto another in the sidebar or use "Mit anderer
  Notiz zusammenführen…" in the note header. The own source is appended
  as an h2 section with its bullets below and goes to the trash; the toast
  offers a complete undo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 00:25:46 +01:00
sandrodachandClaude Opus 5.5 4c26eb2960 feat(workspace): link previews above bullets with a web address (M5)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m52s
- GET /link-preview?url= fetches the page on the server (https only,
  SSRF-guarded like webhooks), reads Open Graph/Twitter tags, title,
  description and icon from the head, loads picture and icon (no SVG) and
  keeps them in link_previews (migration 6) for 7 days; readers' browsers
  never contact the page. Off in the demo; 300 fetches per address in
  15 minutes.
- Compact card above the bullet (icon, site, title, one line, small
  picture); the duplicate link badge is left out.
- ✕ turns the address into [title](url), so hiding syncs with the note;
  "Link-Vorschauen anzeigen" under appearance switches all off per device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 00:06:05 +01:00
sandrodachandClaude Opus 5.5 83408d120d fix(workspace): more room for notes on phones
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m57s
Almost no side margin, 14 px indent per level (22 px from tablet width
on), zoom and delete buttons only take width in the active row. Very deep
rows keep about 14rem and the note scrolls sideways instead of squeezing
the text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 23:55:00 +01:00
sandrodachandClaude Opus 5.5 56c1fa02e6 feat(mail): sender display name (SMTP_FROM_NAME, default MindSift)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m54s
A bare SMTP_FROM address gets the display name in the From header; a name
already in SMTP_FROM wins, the envelope keeps the bare address. Also a
field in the admin mail settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 23:47:38 +01:00
sandrodachandClaude Opus 5.5 1632dd22c4 fix(workspace): English slash commands, short texts, wider menu
/image replaces /bild (aliases bild/foto keep working), shorter
descriptions for /link, /image, /audio, /diktat; the menu is wider, the
command never wraps and long descriptions are cut off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 23:47:38 +01:00
sandrodachandClaude Opus 5.5 1f63e08a73 feat(media): images and recordings in backups, exports and imports (M6)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m52s
- Backup ZIP (download and automatic per-user server backups) carries the
  files of the user's own notes as media/<id><ext>; restoring creates
  them with new IDs and rewrites the blocks.
- Markdown archive puts them under attachments/ and embeds them relative
  to each note (![caption](../attachments/…)); re-importing turns them
  back into image/audio blocks.
- Obsidian import takes images and audio a note embeds on its own
  (![[file.png]], ![](path/file.png)); unused or invalid files appear as
  skipped in the preview instead of vanishing.
- Format detection and file paths moved into storage for this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 23:41:26 +01:00
sandrodachandClaude Opus 5.5 3f2bc96732 feat(workspace): dictation via /diktat (M4)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m59s
/diktat (only in an empty bullet, only with voice input available)
listens right away; the bullet becomes one button with the live wave
across it and a translucent "Stopp" on top. A tap ends it and the
recognised text lands in the bullet as plain text. Uses the configured
speech recognition (own Whisper server or the browser); Esc cancels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 23:30:15 +01:00
sandrodachandClaude Opus 5.5 3f5e3b2ac1 feat(workspace): voice notes via /audio (M3)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m10s
- /audio (only in an empty bullet) shows a recording bubble: green round
  button starts, it turns red with a live wave and timer on its left, a
  tap saves the recording as an inline audio player. An audio file can be
  uploaded instead; Esc/✕ restores the empty bullet.
- Chrome's WebM recordings get their length measured, so the player shows
  the total time and seeking works.
- With the own Whisper server "Transkribieren" turns the recording into
  the (searchable) caption.
- Loaded files keep their Blob, as the CSP rightly blocks fetch() on blob:
  URLs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 23:23:54 +01:00
sandrodachandClaude Opus 5.5 e8fa14a4cb feat(workspace): image blocks via /bild (M2)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m18s
- /bild (only in an empty bullet) turns the bullet into an inline picker:
  file, camera on phones, drag & drop, paste, or an https link the server
  copies. Esc/✕ restores the empty bullet; nothing is synced before the
  upload succeeded.
- Photos are shrunk to 2048 px and stored as WebP (JPEG fallback); the
  re-encoding drops EXIF data such as the location.
- Image block: loaded with the login as blob URL, click opens full
  screen, optional caption kept in the block content
  (![caption](media:id)).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 23:14:08 +01:00
sandrodachandClaude Opus 5.5 177fa49363 feat(media): storage for images and audio in notes (M1)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m4s
- Table media (migration 5), files in $DATA_DIR/media/<docId>/.
- POST /documents/{id}/media (write access), POST .../media/fetch copies an
  external https image (SSRF-guarded, not in the demo), GET /media/{id}
  with range requests for readers of a note that shows the file.
- Format detected from the content (no SVG); own sandbox CSP on answers.
- Blocks point to files through their content (media:<id>), so the note
  sync needs no change; compaction removes files no block mentions any
  more after a day. Demo reset and demo start clear the folder.
- Limits MEDIA_MAX_IMAGE_MB, MEDIA_MAX_AUDIO_MB, MEDIA_QUOTA_MB,
  MEDIA_DEMO_MAX_MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:57:59 +01:00
sandrodachandClaude Opus 5.5 e385a44fb9 chore(docker): explanations in .env.example, whisper as commented block
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 8s
- docker-compose.yml only maps variables; every variable is explained in
  .env.example (German, with defaults, units and choices).
- The whisper service is a commented block to switch on instead of a
  compose profile.
- TZ, TRUSTED_PROXIES, LOGIN_PROTECTION, DEMO_RESET_MINUTES and
  LEGAL_*_CONTENT are now passed through (they had no effect from .env).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:51:57 +01:00
sandrodachandClaude Opus 5.5 d396d42acf docs(changelog): hold-to-record cancels by dragging up
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-29 22:49:32 +01:00
sandrodachandClaude Opus 5.5 d52992afd2 Merge branch 'changelog_backlog' into dev
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:49:02 +01:00
sandrodachandClaude Opus 5.5 cc68d3f334 fix(workspace): keep the cursor in a block that turns into a code block
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m58s
Converting via /code or ``` swapped the text field for the code editor's
and left the focus nowhere, so further typing triggered app shortcuts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:48:35 +01:00
sandrodach b624093980 docs(changelog): backfill release history from v0.1 to v0.5.2 2026-10-29 22:42:43 +01:00
sandrodachandClaude Opus 5.5 9124809f54 Merge branch 'feat/code-block' into dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:37:17 +01:00
sandrodachandClaude Opus 5.5 a9d0e49159 Merge branch 'feat/sidebar-explorer' into dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:37:17 +01:00
sandrodachandClaude Opus 5.5 7fa21e485b feat(voice): all microphone buttons use the server's speech recognition
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m56s
- New useVoiceInput hook (lib/voice.ts): "server" records with MediaRecorder
  and sends to /voice/transcribe, "browser" keeps the live recognition,
  "off" or missing browser support hides the microphone.
- Quick dump bar, mobile capture sheet and hold-to-record use it; live
  waves from the real input level (VoiceWave) and "Wird erkannt…".
- Recordings stop after STT_MAX_SECONDS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:23:48 +01:00
sandrodach ff53f06991 feat(workspace): code block with language picker and live highlighting 2026-10-29 22:18:03 +01:00
sandrodachandClaude Opus 5.5 7801cd13b9 feat(voice): self-hosted speech recognition via Whisper (STT_*)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m49s
- STT_URL switches voice input to an OpenAI-compatible Whisper server
  (e.g. speaches); without it the browser's recognition stays. Configured
  by environment only, with limits for upload size, parallel jobs, hourly
  budget per user and timeout. The demo always uses the browser.
- POST /voice/transcribe forwards recordings and returns the text; nothing
  is stored. On startup MindSift waits for the server and asks it to
  download the model if missing.
- Admin card shows the active mode and server state, with a test recording.
- docker-compose: optional "whisper" service (speaches 0.8.3 CPU, profile
  "whisper", models in ./whisper).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:12:57 +01:00
sandrodach 9f3c78dde0 feat(workspace): sidebar as a plain file explorer 2026-10-29 22:08:35 +01:00
sandrodachandClaude Opus 5.5 230fb3c5aa Merge branch 'dev' into main: v0.6.1
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:20:03 +01:00
sandrodachandClaude Opus 5.5 6a5e82431f chore(release): v0.6.1
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m6s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:20:00 +01:00
sandrodachandClaude Opus 5.5 fbc404fea1 fix(notes): no self-conflicts when typing in a rhythm
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m51s
Several unsent text changes of one block were applied one by one to the
local view, numbering local versions ahead of the server, which folds them
into one change (formBatch). The focused field kept the inflated version as
base, so the next edit after a sync conflicted with the user's own text.
The local view now applies pending ops as they will be sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:13:02 +01:00
84 changed files with 7101 additions and 808 deletions

No files matched your search

+116 -15
View File
@@ -1,27 +1,128 @@
# MindSift Environment Configuration
PORT=8080
DATA_DIR=/data
DATABASE_URL=sqlite:///data/mindsift.db
JWT_SECRET=change_this_to_a_secure_random_key_min_32_chars
BASE_URL=http://localhost:8080
ALLOW_REGISTRATION=false
# Logging level: debug, info, warn, error (Default: info)
LOG_LEVEL=info
# LOG_LEVEL=debug
# DEBUG=true
# MindSift – Einstellungen
# Kopieren nach .env (cp .env.example .env) und anpassen; docker-compose.yml liest die Werte von dort.
# Leer gelassene Werte nehmen den Standard aus docker-compose.yml. Nach Änderungen: docker compose up -d
# Optional SMTP Server Configuration for Email Alarms (can also be set in Admin Settings UI)
# ─── Grundeinstellungen ────────────────────────────────────────────────────────────────────────────
# Port auf dem Host, unter dem MindSift erreichbar ist (im Container immer 8080).
PORT=8080
# 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..
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
# (Passwort zurücksetzen) gebraucht und beschränkt, von welcher Adresse Live-Verbindungen kommen dürfen.
BASE_URL=http://localhost:8080
# Zeitzone für Fälligkeiten und Alarme (Standard Europe/Berlin).
TZ=Europe/Berlin
# true = jeder darf sich ein Konto anlegen; false = nur mit Einladungscode vom Admin.
# Lässt sich später in Administration → Server & Isolation ändern.
ALLOW_REGISTRATION=false
# ─── Sicherheit ────────────────────────────────────────────────────────────────────────────────────
# Wessen Angaben zur Besucher-Adresse (X-Forwarded-For / X-Real-IP) geglaubt werden. Wichtig für die
# Login-Sperre hinter einem Reverse-Proxy.
# private Proxys im lokalen Netz, Docker-Netz oder Tailscale (Standard, passt für die meisten Setups)
# none keinem Proxy glauben (MindSift direkt im Internet)
# all jedem glauben (schaltet die Prüfung ab)
# Liste zusätzlich eigene Adressen, z. B. private,203.0.113.0/24 (Cloudflare und andere externe Proxys)
TRUSTED_PROXIES=private
# Sperre nach Fehlversuchen beim Login und Limits für Registrierung und „Passwort vergessen“.
# false schaltet beides ab (nur sinnvoll, wenn ein vorgeschalteter Dienst das übernimmt).
LOGIN_PROTECTION=true
# ─── Protokoll ─────────────────────────────────────────────────────────────────────────────────────
# Wie ausführlich das Container-Log ist: debug, info, warn oder error.
LOG_LEVEL=info
# true = ausführliches Protokoll aller Anfragen und der Synchronisation (wie LOG_LEVEL=debug).
DEBUG=false
# ─── Demo ──────────────────────────────────────────────────────────────────────────────────────────
# true = öffentliche Demo: legt die Konten admin/admin und user/user mit Beispieldaten an. Die Demo
# verschickt nichts (keine Mails, Webhooks, Alarme) und schützt die gemeinsam genutzten Konten.
MINDA_DEMO=false
# Nur in der Demo: alle so viele Minuten auf die Beispieldaten zurücksetzen (0 = nie).
DEMO_RESET_MINUTES=20
# ─── E-Mail (optional) ─────────────────────────────────────────────────────────────────────────────
# Für Alarm-Mails und „Passwort vergessen“. Lässt sich alternativ in Administration → E-Mail einstellen;
# Werte dort haben Vorrang.
# Mailserver, z. B. smtp.example.com. Leer = kein Mailversand.
SMTP_HOST=
# 587 (STARTTLS) oder 465 (TLS ab Verbindungsbeginn).
SMTP_PORT=587
# Anmeldung am Mailserver; leer, wenn der Server keine verlangt.
SMTP_USER=
SMTP_PASS=
SMTP_FROM=noreply@mindsift.local
# Absenderadresse, z. B. noreply@example.com. Leer = SMTP_USER bzw. noreply@mindsift.local.
SMTP_FROM=
# 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.
SMTP_FROM_NAME=MindSift
# true = nur verschlüsselt senden.
SMTP_TLS=true
# Only for internal mail servers with self-signed certificates
# true = selbstsignierte Zertifikate akzeptieren (nur für interne Mailserver).
SMTP_SKIP_VERIFY=false
# Optional legal notices: external link and/or Markdown (inline or as file path)
# ─── Impressum und Datenschutz (optional) ──────────────────────────────────────────────────────────
# Werden auf der Anmeldeseite und in den Einstellungen verlinkt. Entweder als Link auf eine externe
# Seite (nur http/https) oder als Markdown-Text, direkt hier oder als Datei (per Volume einbinden).
# Ohne Angaben bleiben die Links unsichtbar.
LEGAL_IMPRINT_URL=
LEGAL_PRIVACY_URL=
LEGAL_IMPRINT_CONTENT=
LEGAL_PRIVACY_CONTENT=
# Pfad im Container, z. B. /legal/impressum.md
LEGAL_IMPRINT_CONTENT_FILE=
LEGAL_PRIVACY_CONTENT_FILE=
# ─── Bilder und Audio in Notizen ───────────────────────────────────────────────────────────────────
# Dateien liegen in data/media/ und sind nur für Personen sichtbar, die die Notiz lesen dürfen. Nicht mehr
# verwendete Dateien räumt MindSift nach einem Tag selbst weg.
# Größtes Bild in MB (der Browser verkleinert Fotos vorher auf höchstens 2048 Pixel).
MEDIA_MAX_IMAGE_MB=15
# Größte Audiodatei in MB (Sprachnotiz oder hochgeladene Datei).
MEDIA_MAX_AUDIO_MB=50
# Speicherplatz für Medien pro Person in MB; 0 = unbegrenzt.
MEDIA_QUOTA_MB=0
# Nur in der Demo: größte Datei in MB (Dateien verschwinden beim Demo-Reset).
MEDIA_DEMO_MAX_MB=3
# ─── Spracherkennung (optional) ────────────────────────────────────────────────────────────────────
# Ohne STT_URL nutzt MindSift die Spracherkennung des Browsers (Chrome, Edge, Safari; die Aufnahme geht
# dabei an Google, Microsoft bzw. Apple). Mit eigenem Whisper-Server bleibt alles auf deinem Server und
# es funktioniert in allen Browsern. Dazu den Block „whisper“ in docker-compose.yml einkommentieren
# (Anleitung steht dort). Fehler stehen im Log: docker compose logs mindsift whisper
# Adresse des Whisper-Servers (OpenAI-kompatibel, z. B. speaches). Gesetzt = Server-Modus.
STT_URL=
# Modell: deepdml/faster-whisper-large-v3-turbo-ct2 (beste Erkennung, ca. 2 GB RAM, langsamer)
# Systran/faster-whisper-small (ca. 1 GB RAM, deutlich schneller, kleine Fehler)
STT_MODEL=deepdml/faster-whisper-large-v3-turbo-ct2
# Sprachcode, z. B. de oder en; auto = automatisch erkennen.
STT_LANGUAGE=de
# Nur falls der Whisper-Server einen Schlüssel verlangt (bei speaches: dessen API_KEY).
STT_API_KEY=
# true = Mikrofon überall ausblenden, auch die Browser-Erkennung.
STT_DISABLED=false
# Schutzwerte für den Server-Modus
# Längste Aufnahme in Sekunden; danach beendet der Browser die Aufnahme selbst.
STT_MAX_SECONDS=600
# Größte Aufnahme in MB.
STT_MAX_UPLOAD_MB=25
# Wie viele Erkennungen gleichzeitig laufen; weitere warten in der Schlange. 1 schont schwache CPUs.
STT_MAX_PARALLEL=1
# Erkennungen pro Person und Stunde.
STT_PER_USER_PER_HOUR=60
# Höchstdauer einer Erkennung in Sekunden, Warten in der Schlange eingeschlossen.
STT_TIMEOUT_SECONDS=120
+3
View File
@@ -18,3 +18,6 @@ dev-data/
.idea/
.vscode/
e2e/node_modules/
# Whisper models (docker compose service "whisper")
whisper/
+166 -2
View File
@@ -6,6 +6,35 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased]
## [0.7.0] - 2026-10-30
### Added
- Punkt in eigene Notiz umwandeln: Knopf am Punkt (Desktop) oder `/extract` bzw. `/notiz`. Der Punkt wird Titel, seine Unterpunkte der Inhalt der neuen Notiz im selben Ordner; an seiner Stelle bleibt ein `[[Link]]` (Hashtags bleiben dort). In geteilten Notizen übernimmt die neue Notiz die Freigaben.
- Notizen zusammenführen: in der Seitenleiste eine Notiz auf eine andere ziehen oder im Kopf der Notiz „Mit anderer Notiz zusammenführen…“. Die Quelle wird als Abschnitt (Überschrift mit Unterpunkten) angehängt und wandert in den Papierkorb; „Rückgängig“ im Hinweis macht beides rückgängig.
- Eigene Spracherkennung (optional): Mit `STT_URL` schickt MindSift Aufnahmen an einen selbst betriebenen Whisper-Server (OpenAI-kompatibel, z. B. speaches) statt an Google oder Microsoft. Einstellung nur per Umgebungsvariablen (`STT_*`) inklusive Grenzwerten; `docker-compose.yml` enthält den Dienst `whisper` als auskommentierten Block zum Einschalten, das Modell lädt MindSift beim ersten Start selbst. Administration → Server & Isolation zeigt den aktiven Modus und bietet eine Testaufnahme. In der Demo bleibt es bei der Browser-Erkennung.
- API: `POST /voice/transcribe`, `GET /admin/voice`; `/auth/status` meldet `voice.mode`.
- Bilder in Notizen: `/image` (auch `/bild`) in einem leeren Punkt öffnet direkt im Punkt eine Auswahl – Datei, Kamera (Handy), Hineinziehen, Einfügen mit Strg+V oder Bild-Link (der Server übernimmt eine Kopie). Fotos werden vor dem Hochladen auf höchstens 2048 px verkleinert und als WebP gespeichert, Standortdaten entfallen dabei. Klick vergrößert, darunter eine optionale Beschriftung. Esc oder ✕ macht den Punkt wieder zum leeren Punkt.
- Sprachnotizen in Notizen: `/audio` in einem leeren Punkt zeigt eine Aufnahme-Blase – grüner Knopf startet, er wird rot und links laufen Live-Wellen mit der Zeit, ein Tipp speichert die Aufnahme als Audio-Player im Punkt. Alternativ eine Audiodatei hochladen. Mit eigener Spracherkennung macht „Transkribieren“ aus der Aufnahme die (durchsuchbare) Beschriftung.
- Diktat in Notizen: `/diktat` in einem leeren Punkt hört sofort zu; der Punkt wird zu einer Fläche mit Live-Wellen und „Stopp“, ein Tipp setzt den erkannten Text in den Punkt. Nutzt die eingestellte Spracherkennung (eigener Server oder Browser) und wird ohne Spracherkennung nicht angeboten.
- Backup und Export mit Medien: Das Backup-ZIP (auch die automatischen Server-Backups pro Konto) enthält die Bilder und Sprachnotizen der eigenen Notizen, das Markdown-Archiv legt sie unter `attachments/` ab und bettet sie Obsidian-kompatibel ein. Beim Zurückspielen kommen sie wieder als Bild- und Audioblöcke. Der Obsidian-Import übernimmt jetzt auch eingebettete Bilder und Audiodateien (`![[bild.png]]`, `![](bild.png)`).
- Link-Vorschau in Notizen: Über einem Punkt mit einer Web-Adresse (https) erscheint eine kompakte Karte mit Icon, Seitenname, Titel, einer Zeile Beschreibung und kleinem Bild. Der MindSift-Server ruft die Seite ab (wie bei Bildern nur öffentliche https-Adressen) und speichert die Vorschau 7 Tage; Browser der Leser kontaktieren die Seite nicht. ✕ blendet die Vorschau für diesen Link aus (er wird zu `[Titel](Adresse)`), Darstellung → „Link-Vorschauen anzeigen“ schaltet alle ab. In der Demo aus. API: `GET /link-preview?url=`.
- Medienablage für Notizen (Grundlage für Bilder und Audio): Hochladen, Kopieren externer Bilder, Abruf nur mit Leserecht; Grenzen per `MEDIA_*`; nicht mehr verwendete Dateien werden automatisch entfernt. API: `POST /documents/{id}/media`, `POST /documents/{id}/media/fetch`, `GET /media/{id}`.
- Code-Blöcke in Notizen: Sprachauswahl (rund 50 Sprachen, bei Bedarf nachgeladen), Farben beim Tippen im Stil von VS Code Dark+/Light+ für alle Themes, „Kopieren“, Tab/Shift+Tab rückt ein und aus. `/code yaml` oder ```` ```yaml ```` legt die Sprache direkt fest.
### Changed
- **Hinweis für den Betrieb:** Bilder und Sprachnotizen liegen in `data/media/`, nicht in der Datenbank. Wer den Server sichert, sichert den ganzen Ordner `data/` (der Datenbank-Abzug der Admin-Backups enthält keine Mediendateien). Optional neu in `docker-compose.yml`: der auskommentierte Dienst `whisper`.
- Notizen auf dem Handy: fast kein Seitenrand mehr, Einrückung 14 statt 22 px pro Ebene, Zoom- und Löschknopf belegen nur noch in der aktiven Zeile Platz. Sehr tief verschachtelte Punkte behalten eine lesbare Breite, die Notiz lässt sich dann seitlich verschieben statt den Text zu quetschen.
- E-Mails zeigen einen Absendernamen: `SMTP_FROM_NAME` bzw. das neue Feld „Absender-Name“ in Administration → E-Mail (Standard „MindSift“), wenn die Absenderadresse keinen eigenen Namen enthält.
- Slash-Befehle einheitlich englisch: `/image` statt `/bild` (`/bild` findet ihn weiterhin), kürzere Beschreibungen, breiteres Menü ohne Umbrüche.
- `docker-compose.yml` enthält nur noch die Zuordnungen; alle Erklärungen zu den Variablen stehen in `.env.example`. Neu durchgereicht werden `TZ`, `TRUSTED_PROXIES`, `LOGIN_PROTECTION`, `DEMO_RESET_MINUTES` und `LEGAL_*_CONTENT`, die vorher in `.env` gesetzt wirkungslos blieben.
- Workspace-Seitenleiste wie ein Datei-Explorer: angeheftete Notizen oben, darunter Ordner und Notizen ohne Zwischenüberschriften.
- Spracheingabe in Quick Dump, im mobilen Erfassen-Sheet und beim Halten des +-Knopfs folgt der Server-Einstellung: mit eigenem Whisper-Server funktioniert sie in allen Browsern (auch Firefox), mit Live-Wellen während der Aufnahme und „Wird erkannt…“ danach. Ohne Unterstützung im Browser oder mit `STT_DISABLED=true` ist das Mikrofon ausgeblendet statt einer Fehlermeldung.
## [0.6.1] - 2026-10-29
### Fixed
- Notizen: Wer im Rhythmus tippte (kurze Pausen zwischen den Wörtern), bekam Konfliktkopien gegen sich selbst, auch in nicht geteilten Notizen. Mehrere ungesendete Änderungen an einem Punkt zählten lokal als mehrere Versionen, beim Server aber als eine.
## [0.6.0] - 2026-10-29
### Added
@@ -39,7 +68,142 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
- Anfragekörper sind auf 2 MB begrenzt, Notiz-Sync auf 8 MB, Importe auf 101 MB (FLAW-36).
- Konto löschen entfernt auch die Server-Backups des Kontos (FLAW-37).
<!-- Ältere Versionen (bis v0.5.2) werden aus der Git-Historie nachgetragen. -->
## [0.5.2] - 2026-10-29
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.6.0...dev
### Fixed
- Einkaufsliste auf dem Smartphone (iOS): Der `+`-Knopf und der Menüeintrag setzen den Fokus für neue Einträge direkt in der Tipp-Geste statt verzögert nach 60 ms, sodass sich die Bildschirmtastatur auf iOS zuverlässig öffnet; Eingabefelder nutzen mobil 16 px Schriftgröße gegen automatisches Heranzoomen.
## [0.5.1] - 2026-10-29
### Fixed
- Mobiler `+`-Knopf in Triage und Aufgaben: Ein kurzes Antippen öffnet das Erfassen-Sheet wieder über das `click`-Event statt bei `pointerup`, damit der direkt folgende Browser-Klick nicht auf dem Hintergrund-Overlay landet und das Sheet sofort wieder schließt (nach langem Halten für die Sprachaufnahme wird der nachfolgende Klick ignoriert).
## [0.5] - 2026-10-29
### Added
- Automatische tägliche Server- und Benutzer-Backups (`$DATA_DIR/backups`): konsistenter SQLite-Gesamtabzug (`VACUUM INTO`, `.db.gz`) sowie ein MindSift-Backup-ZIP pro aktivem Konto; einstellbare Uhrzeit und Aufbewahrungsdauer (Standard 03:00 Uhr, 3 Tage) unter Administration → Datensicherung inkl. „Jetzt sichern“.
- Kontoeinstellungen → Server-Backups: Eigene Sicherungen herunterladen oder gezielt nach Bereichen (`notes`, `tasks`, `lists`, `inbox`, `themes`) wiederherstellen (ersetzen oder hinzufügen); auch der manuelle MindSift-Import unterstützt die Bereichsauswahl und nimmt eigene Themes mit.
- Mobil: Gedrückthalten des `+`-Knopfs startet eine Sprachaufnahme und legt das Transkript beim Loslassen direkt im Quick Dump ab (nach oben ziehen bricht ab); kurzes Tippen bzw. Tastatur/Screenreader öffnen weiterhin das Erfassen-Sheet.
- API-Key-Scopes (`webhook:triage`, `webhook:task`, `webhook:list`): Auswahl beim Anlegen, Anzeige als Badges und Webhook-Simulator für alle drei Ziele in den Kontoeinstellungen (FLAW-07).
- Demo-Modus: Automatischer Reset alle 20 Minuten (`DEMO_RESET_MINUTES`, `0` = aus) mit Live-Countdown (`✨ Demo · Reset in MM:SS`) in der Kopfzeile; Sitzungen bleiben über feste Demo-Konto-IDs erhalten. Tief verschachtelte Beispielnotiz „Hausrenovierung 2027“ (7 Ebenen) zum Ausprobieren des Zoom-Fokus.
- Zähler für offene Listeneinträge am Reiter „Listen“; die Suche in der Notiz-Seitenleiste öffnet die globale Volltextsuche (`⌘K`).
### Changed
- **Achtung:** WebSocket (`/ws`) und SSE (`/events`) akzeptieren kein `?token=` mehr in der URL, sondern kurzlebige Einmal-Tickets (`POST /auth/stream-ticket`, 60 s gültig) oder den `Authorization`-Header (FLAW-22).
- **Achtung:** Neue Passwörter müssen mindestens 8 Zeichen lang sein (Setup, Registrierung, Profil, Admin, Passwort-Reset) (FLAW-09).
- Nummerierte Datenbank-Migrationen über `schema_migrations`, die genau einmal laufen und bei Fehlern den Start abbrechen statt Fehler zu verschlucken (FLAW-13).
- Echtes ESLint (`eslint.config.js`) im Frontend- und Image-Build sowie erweiterte Frontend-Tests (FLAW-19); vereinheitlichte Zugriffsabfrage für Aufgabenlisten (FLAW-20).
### Fixed
- SQLite-Transaktionen starten mit `BEGIN IMMEDIATE` (`_txlock=immediate`), sodass parallele Schreiber auf das Lock warten statt mit `SQLITE_BUSY` zu scheitern (FLAW-23).
- Mehrstufige Datenbankänderungen (Ordner/Aufgabenlisten/Konten löschen, Freigaben, Passwort-Reset) laufen atomar in Transaktionen und melden Schreibfehler sauber zurück (FLAW-14).
- Fälligkeits-Alarme brechen nach 3 fehlgeschlagenen Zustellversuchen mit einer Warnung ab, statt jede Minute endlos weiterzusenden (FLAW-12).
- Bessere Lesbarkeit (dunkler Text) auf dem Türkis-Akzent des Themes `Polarized` sowie korrekte `rounded-full`-Darstellung runder Icon-Buttons in eckigen Themes.
### Security
- Einmal-Stream-Tickets statt langlebiger Sitzungs-Tokens in WebSocket-/SSE-URLs; `BASE_URL` schränkt CORS- und WebSocket-Origins ein; Abmelden, Passwortwechsel und Kontosperre trennen offene Live-Streams sofort (FLAW-22).
- Berechtigungsprüfung für alle Listeneintrags-Endpunkte und beim Umwandeln von Gedanken in Listeneinträge (Eigentümer oder Bearbeiter erforderlich, verhindert IDOR und Schreibzugriffe durch Leser) (FLAW-27).
- API-Keys sind strikt auf ihre Scopes (`webhook:triage`, `webhook:task`, `webhook:list`) beschränkt und können keine Live-Streams öffnen (FLAW-07).
- Schutz gegen Brute-Force beim Login (Sperre nach Fehlversuchen pro Konto+IP, Konto und IP mit `429` und `Retry-After`) sowie explizite Konfiguration vertrauenswürdiger Reverse-Proxys über `TRUSTED_PROXIES` (FLAW-09).
- `DeleteTaskList` prüft die Eigentümerschaft vor dem Entkoppeln von Aufgaben; Leser können Verknüpfungen zwischen Notiz-Blöcken und Aufgaben nicht mehr aufheben (FLAW-14).
## [0.4.1] - 2026-10-29
### Added
- Fälligkeitsdatum mit optionaler Uhrzeit (`YYYY-MM-DDTHH:MM`); Alarme berücksichtigen Uhrzeit (bzw. 09:00 Uhr bei ganztägigen Aufgaben), Vorlaufzeit (`notify_before_minutes`) und Zeitzone (`TZ`, Standard `Europe/Berlin`) (FLAW-12).
- Papierkorb für Notizen (`GET/POST/DELETE /documents/trash…`): Gelöschte Notizen und Ordner landen im Papierkorb, verschwinden aus Baum, Suche und Export und lassen sich vom Eigentümer wiederherstellen oder endgültig löschen.
- Backlinks am Ende einer Notiz (`GET /documents/{id}/backlinks`): Zeigt alle zugänglichen Notizen und Punkte, die per `[[Titel]]` auf die aktuelle Notiz verweisen, mit direktem Sprung zum Punkt.
- „Passwort vergessen?“ auf dem Anmeldebildschirm (wenn SMTP und `BASE_URL` eingerichtet sind): Einmal-Link per E-Mail (SHA-256-Hash gespeichert, 1 Stunde gültig), setzt das Passwort zurück und beendet alle bestehenden Sitzungen.
- Offline-fähige PWA: Service Worker hält die App-Shell vor, gelesene Daten werden pro Benutzer in IndexedDB zwischengespeichert und offline erfasste Gedanken sowie abgehakte Aufgaben/Listeneinträge über eine Outbox bei Rückkehr der Verbindung synchronisiert.
- Zwei neue dunkle Themes: `Polarized` (Obsidian & Türkis/Smaragd) und `Suri` (Mitternachts-Pflaume & Orchidee).
### Changed
- Aufteilung von `internal/storage/sqlite.go` und `internal/api/server.go` in domänenspezifische Dateien ohne Verhaltensänderung (FLAW-20).
### Security
- Ohne gesetztes `JWT_SECRET` (oder bei bekannten Platzhaltern) erzeugt der Server einmalig ein zufälliges 32-Byte-Geheimnis in `data/jwt.secret` (`0600`), statt ein öffentlich bekanntes Standard-Secret zu verwenden (FLAW-04).
- SSRF-Schutz für Discord- und benutzerdefinierte Alarm-Webhooks: Nur `https`, Host-Prüfung für Discord, Blockieren von Loopback/Link-Local/Cloud-Metadata bei der DNS-Auflösung; private Netze und `http` nur über expliziten Admin-Schalter (`allowPrivateWebhooks`) (FLAW-05).
- Einheitliche Authentifizierungsprüfung für Middleware und Webhook-Ingest inkl. Sperrliste, `token_version` und aktivem Konto; API-Keys dürfen keine Lese- oder Verwaltungs-Endpunkte mehr aufrufen (FLAW-07, FLAW-08).
## [0.4] - 2026-10-29
### Added
- Daten-Import mit Vorschau (`?preview=1`) und Größen-/Pfad-Limits: Wiederherstellen oder Hinzufügen von MindSift-Backups (JSON, ZIP oder Markdown-Archiv via `POST /import/mindsift`) sowie Import ganzer Obsidian-Vaults als ZIP (`POST /import/obsidian`).
- Vollständiger Datenexport auch als komprimiertes ZIP-Archiv (`GET /export?format=zip`).
- Wiederkehrende Aufgaben (`daily`, `weekly`, `monthly`, `yearly`): Beim Abschließen einer Aufgabe wird automatisch die nächste Instanz zum Folgetermin angelegt.
- Gefahrenzone in den Kontoeinstellungen: Eigene Daten (`DELETE /user/data`) oder das gesamte eigene Konto (`DELETE /user/account`) nach Passwortbestätigung löschen (der letzte Server-Admin ist vor Selbstlöschung geschützt).
- PWA-Startbildschirm (Splash Screen) und automatische Anpassung der Statusleisten-Farbe (`theme-color`) an das aktive Theme.
### Fixed
- SMTP-Versand fällt auf `AUTH LOGIN` zurück, wenn der Server `AUTH PLAIN` ablehnt (z. B. Postfix/Dovecot oder Exchange); das Port-Feld in den Admin-Einstellungen lässt sich leeren und ohne Typfehler speichern.
- Mobil: Formular-Sheets (`FormSheet`) und Emoji-Picker positionieren sich über der Bildschirmtastatur (`visualViewport`).
- Ältere Datenbanken werden beim Start nicht mehr durch den früheren Schema-Reset geleert.
## [0.3] - 2026-10-29
### Added
- Organisationen und Sichtbarkeits-Modi (`private`, `organizations`, `open`), verwaltet durch Server-Admins; bestimmt zentral, wer wen in der Benutzersuche sieht und mit wem Inhalte geteilt werden können (FLAW-10, FLAW-24).
- Aufgaben-Zuweisung (`assignees`) in geteilten Aufgabenlisten, einheitliche Benutzersuche (`UserAutocomplete`), Avatar-Stapel (`AvatarStack`) und runde Zähler-Badges (`CountBadge`).
- Eigene CSS-Themes pro Benutzer (`GET/POST /themes`, `DELETE /themes/{id}`): `.css`-Datei hochladen oder einfügen, Vorlage herunterladen und live umschalten.
- Globale Volltextsuche über Gedanken, Aufgaben, Notizen und Listeneinträge auf Basis von SQLite FTS5 mit BM25-Ranking, Berechtigungsprüfung und `⌘K`/`Ctrl+K`-Befehlspalette (FLAW-18).
- Obsidian-kompatibler Markdown-Export (`GET /export/markdown`) als gestreamtes ZIP-Archiv über alle Notizen, Ordner, Aufgabenlisten und Listen.
- Vollständige Emoji-Auswahl für Notiz-Icons (Lazy-Loading, auf dem Smartphone als Bottom-Sheet).
- Optionale Links/Texte für Impressum und Datenschutz (`LEGAL_IMPRINT_*`, `LEGAL_PRIVACY_*`, `GET /legal/{imprint|privacy}`) sowie „Über MindSift“-Dialog.
- Veröffentlichung unter [The Unlicense](LICENSE) (gemeinfrei, FLAW-21).
### Changed
- **Achtung:** Nur noch Eigentümer können Ressourcen (Aufgabenlisten, Listen, Notizen, Ordner) teilen; das alte Feld `task_lists.team_id` und die ungeschützten `/organizations`-Routen entfallen. Entfällt die Sichtbarkeit (z. B. durch Organisationswechsel), werden Freigaben, Gruppenmitgliedschaften und Zuweisungen automatisch bereinigt (FLAW-06, FLAW-10, FLAW-24).
- Demo-Modus setzt die Datenbank bei jedem Serverstart sauber zurück und befüllt sie neu.
### Fixed
- SMTP-Client mit explizitem Protokollablauf (`EHLO` mit Absender-Domain, `STARTTLS`, `AUTH`, RFC-2047-Header, `SMTP_SKIP_VERIFY` für selbstsignierte Zertifikate).
- Tailwind-Deckkraft-Modifikatoren (`bg-accent/15` usw.) funktionieren über `color-mix()` mit allen Theme-Variablen.
- Mobil- und Desktop-Verbesserungen: Stilles Nachladen von Aufgabenlisten ohne Verschlucken von Klicks, `#`-Anker in URLs werden nicht mehr als Hashtags erkannt, mehrzeilige Aufgaben-Notizen im Dialog voll scrollbar.
## [0.2.1] - 2026-10-28
### Added
- Notizen: Nach dem Umwandeln eines `/todo`-Punkts in eine erweiterte Aufgabe öffnet sich diese direkt; ein Klick auf das Badge „Erweitert“ öffnet den Aufgabendialog.
### Changed
- Container-Build taggt Release-Images (`v*`) zusätzlich als `:stable` und `:latest`, den `dev`-Branch als `:dev`.
## [0.2] - 2026-10-28
### Added
- Echtzeit-Zusammenarbeit in Notizen (`internal/collab`, `frontend/src/lib/docSync`): Server-autoritativer Operations-Log (`doc_ops`) mit Baumstruktur, Fractional-Indexing-Sortierschlüsseln, 20-Sekunden-Blocksperren (Leases), Offline-Warteschlange in IndexedDB und automatischem Transport-Fallback (WebSocket → SSE → Long-Polling → Intervall-Polling).
- Notizen und Ordner lassen sich jetzt auch mit Schreibrecht („Bearbeiten“) teilen (FLAW-17).
- Sichtbare Blocksperren mit Namen des Bearbeitenden, Anwesenheitsanzeige im Notizkopf, Konfliktkopien („Übernehmen“ / „Verwerfen“) statt Datenverlust bei gleichzeitigen Änderungen sowie benutzerbezogenes Undo/Redo (`Ctrl/Cmd+Z`, `Ctrl+Y`) über die gesamte Notiz (FLAW-25, FLAW-26).
- Automatische Hintergrund-Verdichtung des Operations-Logs und Bereinigung gelöschter Blöcke (Tombstones) nach 30 Tagen.
- Outliner: `Backspace` am Zeilenanfang verschmilzt den Punkt mit dem vorherigen; `#Hashtags` werden direkt im Fließtext als anklickbare Chips gerendert; Klick in einen Punkt setzt den Cursor exakt an die geklickte Textstelle.
- Vitest + Testing Library im Frontend-Build sowie Playwright-End-to-End-Tests (`e2e/`) für Live-Kollaboration, Sperren und Proxy-Fallbacks.
### Removed
- **Achtung:** Altes ganzheitliches Überschreiben von Notizen (`PATCH /documents/{id}/blocks`, `DOC_BLOCKS_PATCHED`) entfernt; alle Blockänderungen laufen über `/docs/{id}/sync`.
### Security
- Echtzeit-Ereignisse (`BroadcastToAll` entfernt) und WebSocket-/SSE-Räume sind strikt auf berechtigte, angemeldete Benutzer beschränkt; `ReorderTasks` prüft Schreibrechte (IDOR behoben); SSE-Streams werden nicht mehr nach 30 s durch `WriteTimeout` getrennt; Block-Upserts prüfen die Dokumentzugehörigkeit (FLAW-01, FLAW-02, FLAW-03, FLAW-11, FLAW-15).
## [0.1] - 2026-10-27
### Added
- Erste veröffentlichte Version von MindSift als einzelner selbst gehosteter Container (Go-Backend, eingebettetes React/TypeScript/Tailwind-Frontend, SQLite im WAL-Modus).
- Schnellerfassung (Quick Dump per `C`/`/`, Spracheingabe, mehrzeilig mit `Shift+Enter`, direkte Zielwahl) und Triage-Deck (Tastaturkürzel & Smartphone-Wischgesten).
- Aufgabenverwaltung mit mehreren Listen, frei konfigurierbarem Kanban-Board, Prioritäten, Fälligkeitsdaten, Unteraufgaben mit Fortschrittsbalken, Markdown-Notizen, Filtern/Gruppierungen sowie Fälligkeits-Alarmen per E-Mail (SMTP), Discord und eigenem Webhook.
- Hierarchischer Outliner-Workspace mit Ordnerbaum, Drag & Drop ganzer Äste, Zoom-Fokus mit Breadcrumbs, Slash-Befehlen (`/todo`, `/link`, `/h1`–`/h3`, `/code`, `/callout`, `/divider`), Wiki-Links `[[Notiz]]`, `#Hashtag`-Übersicht, globalem To-Do-Rollup und `.md`-Export.
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.7.0...dev
[0.7.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.6.0...v0.7.0
[0.6.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.6.0...v0.6.1
[0.6.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.5.2...v0.6.0
[0.5.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.5.1...v0.5.2
[0.5.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.5...v0.5.1
[0.5]: https://git.dsit.me/sandrodach/MindSift/compare/v0.4.1...v0.5
[0.4.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.4...v0.4.1
[0.4]: https://git.dsit.me/sandrodach/MindSift/compare/v0.3...v0.4
[0.3]: https://git.dsit.me/sandrodach/MindSift/compare/v0.2.1...v0.3
[0.2.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.2...v0.2.1
[0.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.1...v0.2
[0.1]: https://git.dsit.me/sandrodach/MindSift/releases/tag/v0.1
+10
View File
@@ -17,6 +17,15 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
| 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 | `/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 | `/events` | SSE-Fallback; `?ticket=` oder `Authorization`-Header |
@@ -193,6 +202,7 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant
| 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`) |
| POST | `/admin/smtp/test` | Testmail (`toEmail`) |
| GET | `/admin/voice` | Aktive Spracherkennung (`mode`: `server`/`browser`/`off`), Einstellungen aus `STT_*` ohne Schlüssel, Grenzen und im Server-Modus ein frischer Zustand (`status.reachable`, `status.modelReady`) |
| GET/POST | `/admin/backups` | Datenbank-Abzüge auflisten bzw. sofort sichern (Abzug + alle Benutzer-Backups) |
| GET | `/admin/backups/{name}` | Datenbank-Abzug herunterladen |
+26 -1
View File
@@ -68,7 +68,13 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
| `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen. Die Demo verschickt nichts (keine Alarme, Test- oder Hinweismails, Webhooks), Server-Einstellungen sind schreibgeschützt, Passwort, E-Mail und Konten der gemeinsam genutzten Demo-Konten lassen sich nicht ändern (`notInDemo` in `internal/api/demo.go`) |
| `DEMO_RESET_MINUTES` | `20` | Demo-Modus: periodischer Reset auf die Beispieldaten (`0` = aus). Feste IDs der Demo-Konten (`usr_demo_admin`, `usr_demo_user`) halten Sitzungen gültig; laufende Anfragen sperren den Reset (`internal/api/demo.go`, Streams ausgenommen); danach `DEMO_RESET` an alle Verbindungen, `/auth/status` liefert `demoNextReset`/`demoResetInSeconds`. |
| `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_SKIP_VERIFY` | `false` | Zertifikat des Mailservers nicht prüfen (nur interne Server mit selbstsigniertem Zertifikat) |
| `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_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_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_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. |
@@ -85,6 +91,24 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in
- **Passwort vergessen:** nur wenn SMTP und `BASE_URL` gesetzt sind (nicht im Demo-Modus; `passwordReset` in `/auth/status`). `POST /auth/forgot-password` antwortet immer gleich und verschickt die Mail im Hintergrund, höchstens alle 2 Minuten pro Konto. Der Link `BASE_URL/?resetToken=…` gilt eine Stunde und einmal; gespeichert wird nur der SHA-256-Hash (`password_reset_tokens`). `POST /auth/reset-password` setzt das Passwort, entwertet alle offenen Links des Kontos und erhöht `token_version` (alle Sitzungen enden). Die App entfernt den Token sofort aus der Adresszeile.
- **Registrierung:** nur mit Einladungscode oder wenn `allow_registration` aktiv ist. Das erste Konto entsteht über `/auth/setup` und wird Administrator.
## Medien in Notizen
Bilder und Audio liegen in `$DATA_DIR/media/<docId>/<id>.<ext>`, beschrieben in der Tabelle `media` (Migration 5, `internal/storage/media.go`). Ein Block verweist über seinen Inhalt darauf, z. B. Typ `image` mit `![Beschriftung](media:med_…)`; damit laufen Verweise ohne Änderung am Sync-Modell durch Synchronisation, Rückgängig und Konflikte. Hochladen (`POST /documents/{id}/media`) braucht Schreibrecht auf die Notiz, Lesen (`GET /media/{id}`) Leserecht auf die Notiz der Datei oder auf eine Notiz, deren Blöcke sie zeigen (kopierte Blöcke). Das Format wird am Inhalt erkannt (JPEG, PNG, GIF, WebP; WebM, Ogg, MP3, WAV, M4A), SVG und alles andere wird abgelehnt; Antworten tragen eine eigene CSP mit `sandbox`. Externe Bilder kopiert der Server einmal (`POST /documents/{id}/media/fetch`, nur https und öffentliche Adressen wie bei Webhooks, nicht in der Demo). Grenzen: `MEDIA_MAX_IMAGE_MB`, `MEDIA_MAX_AUDIO_MB`, `MEDIA_QUOTA_MB`, in der Demo `MEDIA_DEMO_MAX_MB`. Backup-ZIP (`media/<id><ext>`) und Markdown-Archiv (`attachments/`, Einbettung `![Beschriftung](../attachments/…)` relativ zur Notiz) enthalten die Dateien der eigenen Notizen; beim Import bekommen sie neue IDs. Der Obsidian-Import übernimmt Bilder und Audio, die eine Notiz allein in einem Punkt einbettet (`![[datei.png]]` oder `![](pfad/datei.png)`); andere Dateien erscheinen in der Vorschau als übersprungen. Die Verdichtung (alle 6 Stunden) löscht Dateien, die kein Block mehr erwähnt (auch keine gelöschten im Papierkorb oder Protokoll), frühestens einen Tag nach dem Hochladen. Der Demo-Reset leert `media/`.
## 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.
## Link-Vorschau
Punkte mit einer reinen https-Adresse zeigen darüber eine Karte (`LinkPreviewCard`, `lib/linkPreview.ts`). `GET /link-preview?url=` holt die Seite serverseitig mit demselben Schutz wie Webhooks (nur https, keine internen Adressen, Weiterleitungen geprüft), liest `og:*`/`twitter:*`, `<title>`, `description` und das Icon aus dem `<head>` (höchstens 1 MB HTML, Bild 2 MB, Icon 100 KB, kein SVG) und speichert alles in `link_previews` (Migration 6): Erfolg 7 Tage, nichts Brauchbares 1 Tag, nach 30 Tagen gelöscht. Bilder kommen über `GET /link-preview/{key}/image|icon` mit Login. Pro Adresse höchstens 300 Abrufe in 15 Minuten (Antworten aus dem Cache zählen nicht). Ausblenden einer Karte macht aus der Adresse `[Titel](url)` (ein benannter Link hat keine Karte), damit synchronisiert die Wahl mit der Notiz; „Link-Vorschauen anzeigen“ unter Darstellung gilt pro Gerät. Im Demo-Modus aus.
## Spracherkennung
Nur über Umgebungsvariablen (`STT_*`, siehe oben), nicht in der Oberfläche; Administration → Server & Isolation zeigt den aktiven Modus, den Zustand des Whisper-Servers und bietet eine Testaufnahme. Alle Mikrofon-Knöpfe (Quick-Dump-Leiste, mobiles Erfassen-Sheet, Halten des +-Knopfs) nutzen denselben Hook `useVoiceInput` (`frontend/src/lib/voice.ts`, `useVoiceInput.ts`): `server` nimmt auf und zeigt „Wird erkannt…“, `browser` liefert Text schon beim Sprechen, `off` oder ein Browser ohne die nötige Funktion blendet das Mikrofon aus. Im Server-Modus zeigen Wellen den echten Mikrofonpegel (`VoiceWave`), die Aufnahme endet spätestens nach `STT_MAX_SECONDS`. Der Browser nimmt per `MediaRecorder` auf (WebM/Opus, in Safari MP4/AAC) und schickt die Aufnahme an `POST /voice/transcribe`; MindSift reicht sie an `STT_URL/v1/audio/transcriptions` weiter (`model`, `language`) und gibt nur den Text zurück. Nichts wird gespeichert oder protokolliert außer Fehlern (`internal/api/voice.go`). Beim Start wartet MindSift auf den Whisper-Server und stößt den Download des Modells an, falls es fehlt (`POST /v1/models/<id>` bei speaches).
`docker-compose.yml` enthält den Dienst `whisper` (speaches 0.8.3, CPU) als auskommentierten Block: Rauten entfernen, `STT_URL=http://whisper:8000` in `.env` setzen, `docker compose up -d`; das Modell liegt in `./whisper` (Ordner gehört UID 1000). Gemessen auf einem Desktop mit 16 Kernen (int8) für einen Satz von 4 s: `Systran/faster-whisper-small` 1,6 s, `deepdml/faster-whisper-large-v3-turbo-ct2` 8 s bei besserer Erkennung (RAM ca. 1 bzw. 2 GB). Auf geteilten vCPUs ist mit dem Mehrfachen zu rechnen.
## Hintergrundjob: Alarme
`startAlarmTicker` prüft jede Minute alle offenen, noch nicht gemeldeten Aufgaben mit Fälligkeit. Ist der Alarmzeitpunkt erreicht (Fälligkeit mit Uhrzeit bzw. 9:00 bei ganztägigen Aufgaben, minus Vorlaufzeit des Benutzers, Zeitzone `TZ`), sendet er über die Kanäle des Benutzers (`email`, `discord`, `webhook`) und setzt `alarm_sent`, sobald mindestens ein Kanal erfolgreich war. Nach 3 Durchläufen ohne Erfolg gibt er auf (`alarm_failures`). Details und Einschränkungen: [modules/tasks.md](modules/tasks.md).
@@ -113,7 +137,8 @@ Die Tests laufen beim Image-Build (Frontend: `npm run test:run`, Backend: `go te
[backups.go](../internal/api/backups.go) / [backup.go](../internal/storage/backup.go): Ist „Automatische Backups“ in den Admin-Einstellungen an, läuft einmal täglich zur eingestellten Uhrzeit (`backup_time`, Zeitzone `TZ`; nach einem Neustart wird ein verpasster Lauf am selben Tag nachgeholt):
- `backups/system/mindsift-db-JJJJ-MM-TT-HHMM.db.gz`: Abzug der ganzen Datenbank per `VACUUM INTO` (stimmig, auch während geschrieben wird), gzip.
- `backups/users/<id>/mindsift-backup-….zip`: dasselbe ZIP wie „Backup herunterladen“ für jedes aktive Konto.
- `backups/users/<id>/mindsift-backup-….zip`: dasselbe ZIP wie „Backup herunterladen“ für jedes aktive Konto, mit den Bildern und Aufnahmen der eigenen Notizen unter `media/`.
- **Achtung:** Der Datenbank-Abzug enthält keine Mediendateien. Wer den Server sichert, sichert den ganzen Ordner `data/` (Datenbank, `media/`, `backups/`).
- Danach werden Dateien älter als `backup_retention_days` (Standard 3) gelöscht. Dateien entstehen über `.part` und Umbenennen, halbe Backups tauchen nie in der Liste auf. Die API liefert nur Dateien mit genau diesem Namensmuster aus.
- Im Demo-Modus läuft kein Zeitplan. Zurückspielen eines Datenbank-Abzugs: Container stoppen, `mindsift.db` durch die entpackte Datei ersetzen, starten. Benutzer spielen ihre eigenen Backups in den Kontoeinstellungen ein, auch nur für einzelne Bereiche (Notizen, Aufgaben, Listen, Inbox, Themes).
+2
View File
@@ -10,6 +10,8 @@ SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `j
|---|---|---|
| `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) |
| `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` |
| `link_previews` | Zwischengespeicherte Link-Vorschauen (Migration 6) | `key` (Hash der URL), `url`, `title`, `description`, `site_name`, `image`/`icon` (BLOB, mit `*_mime`), `ok`, `fetched_at` |
| `sessions` | Angemeldete Geräte (Migration 4, ersetzt `revoked_tokens`) | `id` (= `jti` des Tokens), `user_id`, `created_at`, `last_used_at` (höchstens alle 5 Minuten aktualisiert), `user_agent`, `ip`, `revoked_at` (leer = aktiv) |
| `user_themes` | Eigene CSS-Themes eines Benutzers | `user_id` (FK, `ON DELETE CASCADE`), `name`, `css_content` |
| `api_keys` | Webhook-/API-Schlüssel | `key_prefix`, `key_hash` (SHA-256), `scopes` (nicht ausgewertet), `last_used_at` |
+4 -2
View File
@@ -34,7 +34,7 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
### ⚡ Schnellerfassung & Triage
* Eingabeleiste im Kopf der App, jederzeit mit `C` oder `/` fokussierbar; auch per Spracheingabe (Browser-Funktion).
* 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)).
* 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.
* Papierkorb mit Wiederherstellen.
@@ -123,6 +123,8 @@ curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/.env.example -
docker compose up -d
```
Alle Einstellungen stehen mit Erklärung in `.env`. Optional kommt eine eigene Spracherkennung dazu: Block `whisper` in `docker-compose.yml` einkommentieren und `STT_URL=http://whisper:8000` setzen (Anleitung in der Datei).
Minimal:
```yaml
@@ -208,7 +210,7 @@ Die technische Doku liegt unter [Doku/](Doku/README.md): Architektur, Datenbank,
## 🔒 Deine Daten gehören dir
* Selbst gehostet, keine Telemetrie, keine Fremddienste. Ausgehende Verbindungen gibt es nur zu den Alarm-Zielen, die du selbst einrichtest (SMTP, Discord, Webhook).
* Alle Inhalte liegen in einer einzigen SQLite-Datei; der ganze Workspace lässt sich als Obsidian-kompatibles Markdown-Archiv (ZIP) exportieren, alle Daten eines Benutzers als JSON.
* Alle Inhalte liegen in einer einzigen SQLite-Datei; Bilder und Sprachnotizen liegen daneben in `data/media/`. Der ganze Workspace lässt sich als Obsidian-kompatibles Markdown-Archiv (ZIP, mit `attachments/`) exportieren, alle Daten eines Benutzers als Backup-ZIP (JSON plus Mediendateien).
---
+3 -1
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
_ "time/tzdata" // time zones without system tzdata (scratch/alpine images)
@@ -46,6 +47,7 @@ func main() {
if cfg.DemoMode {
slog.Info("🧪 Demo-Modus aktiv: Setze Datenbank zurück und richte Demo-Accounts ein...")
_ = os.RemoveAll(filepath.Join(filepath.Dir(cfg.DatabasePath), "media")) // uploads of the last run
if err := store.ResetAndSeedDemo(); err != nil {
slog.Warn("⚠️ Fehler beim Einrichten des Demo-Modus", "error", err)
} else {
@@ -54,7 +56,7 @@ func main() {
}
// Seed SMTP defaults from environment variables into DB if not yet configured in DB
store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPTLS, cfg.SMTPSkipVerify)
store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPFromName, cfg.SMTPTLS, cfg.SMTPSkipVerify)
wsHub := ws.NewHub()
+49 -6
View File
@@ -1,3 +1,6 @@
# MindSift – Docker Compose
# Eingestellt wird alles in der Datei .env neben dieser Datei. Was jede Variable bewirkt, steht in
# .env.example (kopieren: cp .env.example .env). Hier stehen nur die Zuordnungen mit Standardwerten.
services:
mindsift:
image: git.dsit.me/sandrodach/mindsift:dev
@@ -10,25 +13,65 @@ services:
environment:
- PORT=8080
- DATA_DIR=/data
- TZ=${TZ:-Europe/Berlin}
- JWT_SECRET=${JWT_SECRET:-change_this_to_a_secure_random_key_min_32_chars}
- BASE_URL=${BASE_URL:-http://localhost:8080}
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false}
- TRUSTED_PROXIES=${TRUSTED_PROXIES:-private}
- LOGIN_PROTECTION=${LOGIN_PROTECTION:-true}
- LOG_LEVEL=${LOG_LEVEL:-info}
# Set LOG_LEVEL=debug or DEBUG=true for detailed request and sync logging
# - LOG_LEVEL=debug
# - DEBUG=true
# Demo mode: if true, automatically seeds demo accounts admin/admin and user/user
- DEBUG=${DEBUG:-false}
- MINDA_DEMO=${MINDA_DEMO:-false}
# Optional SMTP / Mail Configuration (can also be configured in Admin UI)
- DEMO_RESET_MINUTES=${DEMO_RESET_MINUTES:-20}
# E-Mail
- SMTP_HOST=${SMTP_HOST:-}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER:-}
- SMTP_PASS=${SMTP_PASS:-}
- SMTP_FROM=${SMTP_FROM:-}
- SMTP_FROM_NAME=${SMTP_FROM_NAME:-MindSift}
- SMTP_TLS=${SMTP_TLS:-true}
- SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false}
# Optional: Impressum / Datenschutz (URL oder Markdown-Datei, z. B. per Volume eingebunden)
# Impressum / Datenschutz
- LEGAL_IMPRINT_URL=${LEGAL_IMPRINT_URL:-}
- LEGAL_PRIVACY_URL=${LEGAL_PRIVACY_URL:-}
- LEGAL_IMPRINT_CONTENT=${LEGAL_IMPRINT_CONTENT:-}
- LEGAL_PRIVACY_CONTENT=${LEGAL_PRIVACY_CONTENT:-}
- LEGAL_IMPRINT_CONTENT_FILE=${LEGAL_IMPRINT_CONTENT_FILE:-}
- LEGAL_PRIVACY_CONTENT_FILE=${LEGAL_PRIVACY_CONTENT_FILE:-}
# Bilder und Audio in Notizen
- MEDIA_MAX_IMAGE_MB=${MEDIA_MAX_IMAGE_MB:-15}
- MEDIA_MAX_AUDIO_MB=${MEDIA_MAX_AUDIO_MB:-50}
- MEDIA_QUOTA_MB=${MEDIA_QUOTA_MB:-0}
- MEDIA_DEMO_MAX_MB=${MEDIA_DEMO_MAX_MB:-3}
# Spracherkennung
- STT_URL=${STT_URL:-}
- STT_MODEL=${STT_MODEL:-deepdml/faster-whisper-large-v3-turbo-ct2}
- STT_LANGUAGE=${STT_LANGUAGE:-de}
- STT_API_KEY=${STT_API_KEY:-}
- STT_DISABLED=${STT_DISABLED:-false}
- STT_MAX_SECONDS=${STT_MAX_SECONDS:-600}
- STT_MAX_UPLOAD_MB=${STT_MAX_UPLOAD_MB:-25}
- STT_MAX_PARALLEL=${STT_MAX_PARALLEL:-1}
- STT_PER_USER_PER_HOUR=${STT_PER_USER_PER_HOUR:-60}
- STT_TIMEOUT_SECONDS=${STT_TIMEOUT_SECONDS:-120}
# Optional: eigene Spracherkennung (Whisper). Zum Einschalten:
# 1. die Rauten vor dem Block "whisper" unten entfernen
# 2. in .env setzen: STT_URL=http://whisper:8000
# 3. mkdir -p whisper && sudo chown 1000:1000 whisper (der Container läuft als UID 1000)
# 4. docker compose up -d
# Das Modell lädt MindSift beim ersten Start selbst nach ./whisper (large-v3-turbo ca. 1,6 GB).
# RAM: large-v3-turbo ca. 2 GB, small ca. 1 GB. Kein Port nach außen nötig.
#
# whisper:
# image: ghcr.io/speaches-ai/speaches:0.8.3-cpu
# container_name: mindsift-whisper
# restart: unless-stopped
# volumes:
# - ./whisper:/home/ubuntu/.cache/huggingface/hub
# environment:
# - WHISPER__COMPUTE_TYPE=int8 # schneller auf der CPU, kaum Qualitätsverlust
# - STT_MODEL_TTL=-1 # Modell im Speicher halten statt nach 5 Min. zu entladen
# - ENABLE_UI=false # ohne die Weboberfläche von speaches
# - LOG_LEVEL=info
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mindsift-frontend",
"version": "0.6.0",
"version": "0.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mindsift-frontend",
"version": "0.6.0",
"version": "0.7.0",
"license": "Unlicense",
"dependencies": {
"@types/prismjs": "^1.26.6",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mindsift-frontend",
"private": true,
"version": "0.6.0",
"version": "0.7.0",
"license": "Unlicense",
"type": "module",
"scripts": {
+65 -52
View File
@@ -23,6 +23,7 @@ import {
Flame,
Mic,
MicOff,
Loader2,
Search,
} from 'lucide-react';
import { AuthStatus, DashboardStats, PriorityLevel, SharedList, Thought, User, UserTheme } from './lib/types';
@@ -35,6 +36,8 @@ import {
disconnectWebSocket,
isSharingDisabled,
} from './lib/api';
import { useVoiceInput } from './lib/useVoiceInput';
import { VoiceWave } from './components/VoiceWave';
import {
AVAILABLE_THEMES,
customThemeOption,
@@ -175,49 +178,31 @@ export const App: React.FC = () => {
const [mobileTriageContent, setMobileTriageContent] = useState('');
const [mobileTriagePriority, setMobileTriagePriority] = useState<PriorityLevel>('normal');
const [mobileTriageDueDate, setMobileTriageDueDate] = useState('');
const [mobileVoiceActive, setMobileVoiceActive] = useState(false);
const [isVoiceCaptured, setIsVoiceCaptured] = useState(false);
const mobileRecognitionRef = useRef<any>(null);
const [mobileVoiceError, setMobileVoiceError] = useState<string | null>(null);
const mobileBaseVoiceRef = useRef('');
// Voice input of the mobile capture sheet: own Whisper server or the browser (lib/voice.ts)
const mobileVoice = useVoiceInput({
onText: (text) => {
if (!text) return;
const base = mobileBaseVoiceRef.current;
setMobileTriageContent(base ? `${base} ${text}` : text);
setIsVoiceCaptured(true);
},
onError: (message) => {
setMobileVoiceError(message);
window.setTimeout(() => setMobileVoiceError(null), 4000);
},
});
const mobileVoiceActive = mobileVoice.state === 'listening';
const toggleMobileVoice = () => {
const SpeechRecognition =
(window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
if (!SpeechRecognition) {
alert('Spracheingabe wird von diesem Browser leider nicht unterstützt.');
return;
}
if (mobileVoiceActive && mobileRecognitionRef.current) {
mobileRecognitionRef.current.stop();
setMobileVoiceActive(false);
return;
}
try {
if (mobileVoice.state === 'idle') {
mobileBaseVoiceRef.current = mobileTriageContent.replace(/\s*\[…\]$/, '').trim();
const recognition = new SpeechRecognition();
recognition.lang = 'de-DE';
recognition.continuous = false;
recognition.interimResults = true;
recognition.onstart = () => {
setMobileVoiceActive(true);
triggerHaptic(20);
};
recognition.onresult = (event: any) => {
let transcript = '';
for (let i = 0; i < event.results.length; i++) {
transcript += event.results[i][0].transcript;
}
const base = mobileBaseVoiceRef.current;
setMobileTriageContent(base ? `${base} ${transcript}` : transcript);
setIsVoiceCaptured(true);
};
recognition.onerror = () => setMobileVoiceActive(false);
recognition.onend = () => setMobileVoiceActive(false);
mobileRecognitionRef.current = recognition;
recognition.start();
} catch {
setMobileVoiceActive(false);
setMobileVoiceError(null);
triggerHaptic(20);
}
mobileVoice.toggle();
};
const handleMobileCreateTriage = async (e: React.FormEvent) => {
@@ -249,7 +234,7 @@ export const App: React.FC = () => {
setMobileTriagePriority('normal');
setMobileTriageDueDate('');
setIsVoiceCaptured(false);
setMobileVoiceActive(false);
if (mobileVoice.state !== 'idle') mobileVoice.cancel();
setShowMobileCapture(true);
};
@@ -1341,21 +1326,49 @@ export const App: React.FC = () => {
autoFocus
value={mobileTriageContent}
onChange={(e) => setMobileTriageContent(e.target.value)}
placeholder="Was geht dir durch den Kopf?…"
className="w-full input-field px-3 py-2 pr-10 text-sm"
placeholder={
mobileVoiceError
? mobileVoiceError
: mobileVoiceActive
? 'Ich höre zu…'
: mobileVoice.state === 'transcribing'
? 'Wird erkannt…'
: 'Was geht dir durch den Kopf?…'
}
className={
mobileVoiceActive && mobileVoice.mode === 'server'
? 'w-full input-field px-3 py-2 pr-24 text-sm'
: 'w-full input-field px-3 py-2 pr-10 text-sm'
}
/>
<button
type="button"
onClick={toggleMobileVoice}
className={`absolute right-1.5 top-1/2 -translate-y-1/2 w-7 h-7 rounded-full flex items-center justify-center transition-colors cursor-pointer ${
mobileVoiceActive
? 'bg-accent text-on-accent animate-pulse'
: 'text-muted hover:text-main hover:bg-panel-elevated'
}`}
title="Spracheingabe"
>
{mobileVoiceActive ? <MicOff className="w-3.5 h-3.5" /> : <Mic className="w-3.5 h-3.5" />}
</button>
{mobileVoice.available && (
<div className="absolute right-1.5 top-1/2 -translate-y-1/2 flex items-center gap-2">
{mobileVoiceActive && mobileVoice.mode === 'server' && (
<VoiceWave getLevel={mobileVoice.level} bars={7} className="h-4 text-accent" />
)}
<button
type="button"
onClick={toggleMobileVoice}
disabled={mobileVoice.state === 'transcribing'}
className={`w-9 h-9 rounded-full flex items-center justify-center transition-colors cursor-pointer ${
mobileVoiceActive
? mobileVoice.mode === 'server'
? 'bg-status-err text-on-accent'
: 'bg-accent text-on-accent animate-pulse'
: 'text-muted hover:text-main hover:bg-panel-elevated'
}`}
title={mobileVoiceActive ? 'Aufnahme beenden' : 'Spracheingabe'}
>
{mobileVoice.state === 'transcribing' ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : mobileVoiceActive ? (
<MicOff className="w-4 h-4" />
) : (
<Mic className="w-4 h-4" />
)}
</button>
</div>
)}
</div>
</div>
+58
View File
@@ -0,0 +1,58 @@
import React, { useEffect, useRef } from 'react';
interface VoiceWaveProps {
/** Input level 0..1; null (unknown, e.g. browser recognition) shows a calm idle wave. */
getLevel: () => number | null;
bars?: number;
/** Bar colour and size come from the caller, e.g. "h-4 text-accent". */
className?: string;
}
// Live wave reacting to the voice. Heights are set directly on the bars every frame, so React does
// not re-render 60 times a second.
export const VoiceWave: React.FC<VoiceWaveProps> = ({ getLevel, bars = 5, className = 'h-4 text-accent' }) => {
const barRefs = useRef<(HTMLSpanElement | null)[]>([]);
const getLevelRef = useRef(getLevel);
getLevelRef.current = getLevel;
useEffect(() => {
const history: number[] = new Array(bars).fill(0);
let frame = 0;
let raf = 0;
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
const tick = () => {
frame++;
const measured = getLevelRef.current();
const level = measured ?? 0.25 + 0.2 * Math.sin(frame / 9);
// Newest level enters in the middle and travels outwards
if (frame % 3 === 0) {
history.pop();
history.unshift(level);
}
const mid = Math.floor(bars / 2);
barRefs.current.forEach((el, i) => {
if (!el) return;
const v = reduceMotion ? 0.5 : history[Math.abs(i - mid)] ?? 0;
el.style.transform = `scaleY(${Math.max(0.15, Math.min(1, v * 1.4))})`;
});
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [bars]);
return (
<span className={`inline-flex items-center gap-[3px] ${className}`} aria-hidden="true">
{Array.from({ length: bars }, (_, i) => (
<span
key={i}
ref={(el) => {
barRefs.current[i] = el;
}}
className="w-[3px] h-full rounded-full bg-current origin-center transition-transform duration-75"
style={{ transform: 'scaleY(0.15)' }}
/>
))}
</span>
);
};
+79 -131
View File
@@ -1,6 +1,8 @@
import React, { useEffect, useRef, useState } from 'react';
import { Mic, Plus } from 'lucide-react';
import { triggerHaptic } from '../../lib/api';
import { useVoiceInput } from '../../lib/useVoiceInput';
import { VoiceWave } from '../../components/VoiceWave';
export interface HoldToRecordFabProps {
onTap: () => void;
@@ -24,14 +26,10 @@ export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTrans
const didEnterHoldRef = useRef(false);
const fallbackHandledRef = useRef(false);
const isCancelZoneRef = useRef(false);
const abortedRef = useRef(false);
const isStoppingRef = useRef(false);
const hasEndedRef = useRef(false);
const finalizedRef = useRef(false);
const startYRef = useRef(0);
const transcriptRef = useRef('');
const recognitionRef = useRef<any>(null);
const holdTimerRef = useRef<number | null>(null);
const stopTimerRef = useRef<number | null>(null);
const toastTimerRef = useRef<number | null>(null);
@@ -50,17 +48,66 @@ export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTrans
}
};
const showToast = (msg: string) => {
const showToast = (msg: string, sticky = false) => {
if (toastTimerRef.current !== null) {
window.clearTimeout(toastTimerRef.current);
toastTimerRef.current = null;
}
setToastMessage(msg);
if (sticky) return;
toastTimerRef.current = window.setTimeout(() => {
setToastMessage(null);
toastTimerRef.current = null;
}, TOAST_DURATION_MS);
};
const finalizeRecording = async (text: string) => {
if (finalizedRef.current) return;
finalizedRef.current = true;
clearStopTimer();
setLiveTranscript('');
const trimmed = text.trim();
if (!trimmed) {
showToast('Nichts erkannt');
return;
}
try {
await onTranscript(trimmed);
triggerHaptic(25);
showToast('Im Quick Dump gespeichert');
} catch {
showToast('Fehler beim Speichern');
}
};
// Own Whisper server (text after release) or the browser's recognition (text while speaking)
const voice = useVoiceInput({
continuous: true,
onText: (text, final) => {
transcriptRef.current = text;
if (!final) {
setLiveTranscript(text);
return;
}
void finalizeRecording(text);
},
onError: (message) => {
clearStopTimer();
setIsRecording(false);
setIsCancelZone(false);
setLiveTranscript('');
if (didEnterHoldRef.current && isPressedRef.current && !fallbackHandledRef.current) {
// Recording could not start: open the normal capture sheet instead
fallbackHandledRef.current = true;
showToast(message);
onTap();
return;
}
finalizedRef.current = true;
showToast(message);
},
});
useEffect(() => {
return () => {
clearHoldTimer();
@@ -68,112 +115,28 @@ export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTrans
if (toastTimerRef.current !== null) {
window.clearTimeout(toastTimerRef.current);
}
if (recognitionRef.current) {
try {
recognitionRef.current.abort?.();
} catch {
// ignore cleanup errors
}
}
};
}, []);
const finalizeRecording = async () => {
if (finalizedRef.current) return;
finalizedRef.current = true;
clearStopTimer();
recognitionRef.current = null;
if (abortedRef.current) return;
const text = transcriptRef.current.trim();
setLiveTranscript('');
if (!text) {
showToast('Nichts erkannt');
return;
}
try {
await onTranscript(text);
triggerHaptic(25);
showToast('Im Quick Dump gespeichert');
} catch {
showToast('Fehler beim Speichern');
}
};
useEffect(() => {
if (voice.state === 'transcribing') showToast('Wird erkannt…', true);
}, [voice.state]);
const startVoiceRecording = () => {
const SpeechRecognition =
(window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
if (!SpeechRecognition) {
if (voice.mode === 'off' || voice.mode === 'unsupported') {
fallbackHandledRef.current = true;
showToast('Spracheingabe wird von diesem Browser nicht unterstützt');
if (voice.mode === 'unsupported') showToast('Spracheingabe wird von diesem Browser nicht unterstützt');
onTap();
return;
}
try {
const recognition = new SpeechRecognition();
recognition.lang = 'de-DE';
recognition.interimResults = true;
recognition.continuous = true;
transcriptRef.current = '';
abortedRef.current = false;
isStoppingRef.current = false;
hasEndedRef.current = false;
finalizedRef.current = false;
isCancelZoneRef.current = false;
recognition.onresult = (event: any) => {
let combined = '';
for (let i = 0; i < event.results.length; i++) {
combined += event.results[i][0].transcript;
}
transcriptRef.current = combined;
setLiveTranscript(combined);
};
recognition.onerror = (event: any) => {
const errCode = event?.error;
if (errCode === 'not-allowed' || errCode === 'service-not-allowed') {
abortedRef.current = true;
clearStopTimer();
setIsRecording(false);
setIsCancelZone(false);
setLiveTranscript('');
if (!fallbackHandledRef.current) {
fallbackHandledRef.current = true;
showToast('Mikrofon nicht freigegeben');
onTap();
}
return;
}
if (isStoppingRef.current) {
void finalizeRecording();
}
};
recognition.onend = () => {
hasEndedRef.current = true;
if (isStoppingRef.current) {
void finalizeRecording();
}
};
recognitionRef.current = recognition;
setLiveTranscript('');
setIsCancelZone(false);
setIsRecording(true);
triggerHaptic([15, 60, 25]);
recognition.start();
} catch {
fallbackHandledRef.current = true;
setIsRecording(false);
showToast('Spracheingabe wird von diesem Browser nicht unterstützt');
onTap();
}
transcriptRef.current = '';
finalizedRef.current = false;
isCancelZoneRef.current = false;
setLiveTranscript('');
setIsCancelZone(false);
setIsRecording(true);
triggerHaptic([15, 60, 25]);
void voice.start();
};
const handlePointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
@@ -240,34 +203,23 @@ export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTrans
setIsCancelZone(false);
if (isCancelZoneRef.current) {
abortedRef.current = true;
finalizedRef.current = true;
clearStopTimer();
setLiveTranscript('');
triggerHaptic(15);
try {
recognitionRef.current?.abort?.();
} catch {
// ignore
}
recognitionRef.current = null;
voice.cancel();
return;
}
isStoppingRef.current = true;
if (hasEndedRef.current || !recognitionRef.current) {
void finalizeRecording();
return;
}
stopTimerRef.current = window.setTimeout(() => {
void finalizeRecording();
}, STOP_TIMEOUT_MS);
try {
recognitionRef.current.stop();
} catch {
void finalizeRecording();
// The browser's recognition sometimes never reports its end: save what was heard after a moment.
// The own server answers when it is done (with its own time limit).
if (voice.mode !== 'server') {
stopTimerRef.current = window.setTimeout(() => {
voice.cancel();
void finalizeRecording(transcriptRef.current);
}, STOP_TIMEOUT_MS);
}
void voice.stop();
};
const handlePointerCancel = (e: React.PointerEvent<HTMLButtonElement>) => {
@@ -283,16 +235,11 @@ export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTrans
clearStopTimer();
if (didEnterHoldRef.current && !fallbackHandledRef.current) {
abortedRef.current = true;
finalizedRef.current = true;
setIsRecording(false);
setIsCancelZone(false);
setLiveTranscript('');
try {
recognitionRef.current?.abort?.();
} catch {
// ignore
}
recognitionRef.current = null;
voice.cancel();
}
};
@@ -313,7 +260,8 @@ export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTrans
<div className="text-xs font-semibold">🗑️ Zum Abbrechen loslassen</div>
) : (
<div className="space-y-1">
<div className="text-xs font-semibold text-main">
<div className="text-xs font-semibold text-main flex items-center justify-center gap-2">
{voice.mode === 'server' && <VoiceWave getLevel={voice.level} bars={7} className="h-4 text-accent" />}
Ich höre zu… (loslassen speichert)
</div>
{displayedTranscript ? (
@@ -13,9 +13,12 @@ import {
ChevronDown,
AlertTriangle,
Flame,
Loader2,
} from 'lucide-react';
import { PriorityLevel, SharedList, Thought } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
import { useVoiceInput } from '../../lib/useVoiceInput';
import { VoiceWave } from '../../components/VoiceWave';
export type CaptureTarget = 'inbox' | 'task' | 'list_item' | 'document';
@@ -46,17 +49,31 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
const [target, setTarget] = useState<CaptureTarget>(fixedTarget || 'inbox');
const [targetListId, setTargetListId] = useState<string>('');
const [showOptions, setShowOptions] = useState(defaultShowOptions || hideTargetPicker);
const [isListening, setIsListening] = useState(false);
const [voiceError, setVoiceError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [justSavedLabel, setJustSavedLabel] = useState<string | null>(null);
const [isMultiline, setIsMultiline] = useState(false);
const inputRef = useRef<HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLFormElement>(null);
const recognitionRef = useRef<any>(null);
const baseVoiceTextRef = useRef('');
const hasVoiceInputRef = useRef(false);
// Voice input: own Whisper server or the browser's recognition (lib/voice.ts)
const voice = useVoiceInput({
onText: (text, final) => {
const base = baseVoiceTextRef.current;
if (text) setContent(base ? `${base} ${text}` : text);
if (final) inputRef.current?.focus();
},
onError: (message) => {
setVoiceError(message);
window.setTimeout(() => setVoiceError(null), 4000);
},
});
const isListening = voice.state === 'listening';
const isTranscribing = voice.state === 'transcribing';
const adjustHeight = (el?: HTMLTextAreaElement | null) => {
const targetEl = el || inputRef.current;
if (!targetEl) return;
@@ -120,57 +137,13 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
}, []);
const toggleVoiceDictation = () => {
const SpeechRecognition =
(window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
if (!SpeechRecognition) {
alert('Spracheingabe wird von diesem Browser nicht direkt unterstützt (bitte Chrome, Edge oder Safari nutzen).');
return;
}
if (isListening && recognitionRef.current) {
recognitionRef.current.stop();
setIsListening(false);
return;
}
try {
if (voice.state === 'idle') {
baseVoiceTextRef.current = content.replace(/\s*\[…\]$/, '').trim();
hasVoiceInputRef.current = true;
const recognition = new SpeechRecognition();
recognition.lang = 'de-DE';
recognition.continuous = false;
recognition.interimResults = true;
recognition.onstart = () => {
setIsListening(true);
triggerHaptic(20);
};
recognition.onresult = (event: any) => {
let transcript = '';
for (let i = 0; i < event.results.length; i++) {
transcript += event.results[i][0].transcript;
}
const base = baseVoiceTextRef.current;
setContent(base ? `${base} ${transcript}` : transcript);
};
recognition.onerror = () => {
setIsListening(false);
};
recognition.onend = () => {
setIsListening(false);
inputRef.current?.focus();
};
recognitionRef.current = recognition;
recognition.start();
} catch {
setIsListening(false);
setVoiceError(null);
triggerHaptic(20);
}
voice.toggle();
};
const executeCapture = async (
@@ -187,6 +160,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
const capturedDueDate = dueDate;
const capturedSource = hasVoiceInputRef.current || isListening ? 'voice' : 'manual';
hasVoiceInputRef.current = false;
// A recognition still running would refill the field after it was saved
if (voice.state !== 'idle') voice.cancel();
// Optimistically clear input immediately so the UI feels instant (<10ms) in all browsers
setContent('');
@@ -285,8 +260,12 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
}
}}
placeholder={
isListening
voiceError
? voiceError
: isListening
? 'Ich höre zu…'
: isTranscribing
? 'Wird erkannt…'
: hideTargetPicker || fixedTarget === 'inbox'
? 'Quick Dump erfassen… (Enter zum Speichern)'
: target === 'task'
@@ -336,19 +315,33 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
</button>
)}
{/* Voice Dictation Button */}
<button
type="button"
onClick={toggleVoiceDictation}
title="Spracheingabe"
className={`w-6 h-6 capture-pill rounded-full flex items-center justify-center transition-colors ${
isListening
? 'bg-accent-dim text-accent'
: 'text-muted hover:text-main hover:bg-panel-elevated'
}`}
>
{isListening ? <MicOff className="w-3 h-3" /> : <Mic className="w-3 h-3" />}
</button>
{/* Voice Dictation Button (hidden when voice input is off or not supported) */}
{voice.available && (
<>
{isListening && voice.mode === 'server' && (
<VoiceWave getLevel={voice.level} className="h-3.5 text-accent" />
)}
<button
type="button"
onClick={toggleVoiceDictation}
disabled={isTranscribing}
title={isListening ? 'Aufnahme beenden' : isTranscribing ? 'Wird erkannt…' : 'Spracheingabe'}
className={`w-6 h-6 capture-pill rounded-full flex items-center justify-center transition-colors ${
isListening || isTranscribing
? 'bg-accent-dim text-accent'
: 'text-muted hover:text-main hover:bg-panel-elevated'
}`}
>
{isTranscribing ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : isListening ? (
<MicOff className="w-3 h-3" />
) : (
<Mic className="w-3 h-3" />
)}
</button>
</>
)}
{/* Instant Send Button */}
<button
+25
View File
@@ -159,6 +159,15 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
'code',
'callout',
'divider',
'bild',
'extract',
'auslagern',
'zusammenführen',
'audio',
'sprachnotiz',
'diktat',
'image',
'foto',
'auto-formatting',
],
content: (
@@ -192,6 +201,22 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
<span className="text-accent font-bold">—</span>
<span>/divider (Trennlinie)</span>
</div>
<div className="p-1.5 bg-panel rounded border border-border flex items-center gap-1.5 text-main col-span-2">
<span className="text-accent font-bold">🖼️</span>
<span>/image oder /bild (Bild hochladen, fotografieren, einfügen oder per Link; nur im leeren Punkt)</span>
</div>
<div className="p-1.5 bg-panel rounded border border-border flex items-center gap-1.5 text-main col-span-2">
<span className="text-accent font-bold">📤</span>
<span>/extract oder /notiz (Punkt mit allen Unterpunkten in eine eigene Notiz auslagern, an seiner Stelle bleibt ein [[Link]])</span>
</div>
<div className="p-1.5 bg-panel rounded border border-border flex items-center gap-1.5 text-main col-span-2">
<span className="text-accent font-bold">🎙️</span>
<span>/audio (Sprachnotiz aufnehmen: grüner Knopf startet, roter speichert; oder Audiodatei hochladen)</span>
</div>
<div className="p-1.5 bg-panel rounded border border-border flex items-center gap-1.5 text-main col-span-2">
<span className="text-accent font-bold">🗣️</span>
<span>/diktat (sprechen statt tippen: startet sofort, Tipp auf „Stopp“ setzt den Text in den Punkt)</span>
</div>
</div>
</div>
<div className="pt-2 border-t border-border">
@@ -29,6 +29,7 @@ import { passwordProblem } from '../../lib/password';
import { UserAvatar } from '../../components/UserAvatar';
import { ToggleSwitch } from '../../components/ToggleSwitch';
import { BackupAdminCard } from './BackupAdminCard';
import { VoiceStatusCard } from './VoiceStatusCard';
import { CountBadge } from '../../components/CountBadge';
interface AdminSettingsViewProps {
@@ -102,6 +103,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
smtpUser: '',
smtpPass: '',
smtpFrom: '',
smtpFromName: '',
smtpTls: true,
smtpSkipVerify: false,
smtpConfiguredViaEnv: false,
@@ -132,6 +134,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
smtpUser: sys.smtpUser || '',
smtpPass: sys.smtpPass || '',
smtpFrom: sys.smtpFrom || '',
smtpFromName: sys.smtpFromName || '',
smtpTls: sys.smtpTls ?? true,
smtpSkipVerify: Boolean(sys.smtpSkipVerify),
allowPrivateWebhooks: Boolean(sys.allowPrivateWebhooks),
@@ -1115,6 +1118,8 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
<BackupAdminCard settings={settings} onSave={(patch) => handleSaveSystemSettings(patch)} />
<VoiceStatusCard />
{/* Alarm webhooks into the local network (SSRF protection) */}
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
<div className="pr-4">
@@ -1255,11 +1260,30 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
onChange={(e) =>
setSettings((prev) => ({ ...prev, smtpFrom: e.target.value }))
}
placeholder="MindSift <alerts@deinedomain.de>"
placeholder="alerts@deinedomain.de"
className="w-full h-9 px-3 input-field"
/>
</div>
<div>
<label className="block text-muted font-medium mb-1">
Absender-Name (`SMTP_FROM_NAME`)
</label>
<input
type="text"
value={settings.smtpFromName || ''}
onChange={(e) =>
setSettings((prev) => ({ ...prev, smtpFromName: e.target.value }))
}
placeholder="MindSift"
className="w-full h-9 px-3 input-field"
/>
<p className="text-[11px] text-faint mt-1">
Erscheint beim Empfänger als Absender. Leer = „MindSift“; ein Name direkt in der Adresse
(„Name &lt;adresse&gt;“) hat Vorrang.
</p>
</div>
<div className="sm:col-span-2 space-y-3 pt-1">
<div className="flex items-center justify-between gap-3">
<div>
@@ -30,6 +30,8 @@ import { LegalLinks } from '../../components/LegalLinks';
import { AboutModal } from '../../components/AboutModal';
import { ImportCard } from './ImportCard';
import { SessionsPanel } from './SessionsPanel';
import { ToggleSwitch } from '../../components/ToggleSwitch';
import { setLinkPreviewsEnabled, useLinkPreviewsEnabled } from '../../lib/linkPreview';
import { FormSheet } from '../../components/FormSheet';
interface UserSettingsViewProps {
@@ -99,6 +101,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
const [showAbout, setShowAbout] = useState(false);
// The public demo sends nothing and keeps the shared accounts intact (FLAW-33)
const [demoMode, setDemoMode] = useState(false);
const linkPreviewsOn = useLinkPreviewsEnabled();
useEffect(() => {
api
.getAuthStatus()
@@ -774,6 +777,18 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</p>
</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">Link-Vorschauen anzeigen</p>
<p className="text-[11px] text-faint">
Karte mit Titel, Beschreibung und kleinem Bild über Punkten mit einer Web-Adresse. Die Seite wird
dafür vom MindSift-Server abgerufen, nicht von deinem Browser. Gilt für dieses Gerät; einzelne
Vorschauen lassen sich mit ✕ ausblenden.
</p>
</div>
<ToggleSwitch checked={linkPreviewsOn} onChange={setLinkPreviewsEnabled} title="Link-Vorschauen anzeigen" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{themes.map((t) => {
const isSelected = activeTheme === t.id;
@@ -1213,8 +1228,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<div>
<h3 className="font-bold text-main">Vollständiges Backup (.zip)</h3>
<p className="text-muted text-[11px]">
Alle deine Gedanken, Aufgaben, Notizen und Listen als JSON, gepackt in eine ZIP-Datei. Über
„MindSift-Daten importieren“ lässt es sich vollständig zurückspielen.
Alle deine Gedanken, Aufgaben, Notizen und Listen als JSON, mit den Bildern und Sprachnotizen deiner
Notizen, gepackt in eine ZIP-Datei. Über „MindSift-Daten importieren“ lässt es sich vollständig zurückspielen.
</p>
</div>
<button
@@ -1234,8 +1249,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<p className="text-muted text-[11px]">
Alle Notizen als <code className="font-mono">.md</code>-Dateien in deiner Ordnerstruktur (mit Titel, Tags und
Datum im Kopf), Aufgaben je Liste unter <code className="font-mono">Aufgaben/</code> im Format des
Obsidian-Tasks-Plugins, Listen unter <code className="font-mono">Listen/</code>. Mit dir geteilte Notizen sind
dabei.
Obsidian-Tasks-Plugins, Listen unter <code className="font-mono">Listen/</code>, Bilder und Sprachnotizen unter{' '}
<code className="font-mono">attachments/</code>. Mit dir geteilte Notizen sind dabei.
</p>
{vaultError && <p className="text-[11px] text-status-err mt-1">{vaultError}</p>}
</div>
@@ -1278,7 +1293,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<>
Vault als <code className="font-mono">.zip</code>. Alle Notizen landen in einem eigenen Ordner mit dem Namen
des Vaults, Unterordner bleiben erhalten. Checkboxen bleiben Checkboxen in der Notiz, Frontmatter-Tags werden zu
#Hashtags. Bilder und andere Anhänge werden übersprungen.
#Hashtags. Bilder und Audiodateien, die eine Notiz einbettet (<code className="font-mono">![[bild.png]]</code>{' '}
oder <code className="font-mono">![](bild.png)</code>), werden übernommen; andere Anhänge wie PDFs nicht.
</>
}
/>
@@ -0,0 +1,189 @@
import React, { useEffect, useRef, useState } from 'react';
import { AlertCircle, Check, Loader2, Mic, RefreshCw, Square } from 'lucide-react';
import { api } from '../../lib/api';
import { VoiceAdminStatus } from '../../lib/types';
const TEST_SECONDS = 6;
// Admin: which speech recognition is active. Configured only by environment (STT_*), so this card
// just shows the state and offers a short test recording through the own Whisper server.
export const VoiceStatusCard: React.FC = () => {
const [info, setInfo] = useState<VoiceAdminStatus | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [recording, setRecording] = useState(false);
const [testing, setTesting] = useState(false);
const [result, setResult] = useState<{ text: string; ms: number } | null>(null);
const recorderRef = useRef<MediaRecorder | null>(null);
const stopTimerRef = useRef<number | null>(null);
const load = async () => {
setLoading(true);
try {
setInfo(await api.adminVoiceStatus());
setError(null);
} catch (err: any) {
setError(err.message || 'Status konnte nicht geladen werden');
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
return () => {
if (stopTimerRef.current !== null) window.clearTimeout(stopTimerRef.current);
recorderRef.current?.stream.getTracks().forEach((t) => t.stop());
};
}, []);
const stopRecording = () => {
if (stopTimerRef.current !== null) window.clearTimeout(stopTimerRef.current);
stopTimerRef.current = null;
if (recorderRef.current?.state === 'recording') recorderRef.current.stop();
};
const startTest = async () => {
setResult(null);
setError(null);
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch {
setError('Kein Zugriff auf das Mikrofon (Berechtigung, oder die Seite läuft nicht über HTTPS)');
return;
}
const recorder = new MediaRecorder(stream);
const chunks: Blob[] = [];
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunks.push(e.data);
};
recorder.onstop = async () => {
stream.getTracks().forEach((t) => t.stop());
setRecording(false);
setTesting(true);
const started = performance.now();
try {
const text = await api.transcribe(new Blob(chunks, { type: recorder.mimeType }));
setResult({ text, ms: Math.round(performance.now() - started) });
} catch (err: any) {
setError(err.message || 'Spracherkennung fehlgeschlagen');
} finally {
setTesting(false);
}
};
recorderRef.current = recorder;
recorder.start();
setRecording(true);
stopTimerRef.current = window.setTimeout(stopRecording, TEST_SECONDS * 1000);
};
const status = info?.status;
const serverOk = info?.mode === 'server' && status?.reachable && status?.modelReady;
return (
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="font-bold text-main flex items-center gap-1.5">
<Mic className="w-4 h-4 text-accent" />
Spracherkennung
</h3>
<p className="text-muted mt-0.5">
Wird nur über Umgebungsvariablen eingestellt (<code className="font-mono">STT_URL</code>,{' '}
<code className="font-mono">STT_MODEL</code> …); Fehler stehen im Container-Log.
</p>
</div>
<button
type="button"
onClick={load}
disabled={loading}
title="Status neu prüfen"
className="btn-action h-11 w-11 sm:h-8 sm:w-8 flex items-center justify-center shrink-0 disabled:opacity-40"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
{info && (
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-xs">
<dt className="text-muted">Aktiv</dt>
<dd className="text-main font-semibold">
{info.mode === 'server'
? 'Eigener Server (Whisper)'
: info.mode === 'off'
? 'Ausgeschaltet'
: info.demo && info.url
? 'Browser (in der Demo immer)'
: 'Browser (Spracherkennung des Browsers)'}
</dd>
{info.url && (
<>
<dt className="text-muted">Server</dt>
<dd className="text-main font-mono break-all">{info.url}</dd>
<dt className="text-muted">Modell</dt>
<dd className="text-main font-mono break-all">{info.model}</dd>
<dt className="text-muted">Sprache</dt>
<dd className="text-main">{info.language || 'automatisch'}</dd>
</>
)}
{info.mode === 'server' && (
<>
<dt className="text-muted">Zustand</dt>
<dd className="flex items-center gap-1.5">
{serverOk ? (
<span className="text-status-ok flex items-center gap-1">
<Check className="w-3.5 h-3.5" /> Erreichbar, Modell geladen
</span>
) : (
<span className="text-status-err flex items-center gap-1">
<AlertCircle className="w-3.5 h-3.5" />
{status?.reachable ? 'Modell fehlt noch (wird heruntergeladen)' : 'Nicht erreichbar'}
{status?.error && !status.reachable ? `: ${status.error}` : ''}
</span>
)}
</dd>
<dt className="text-muted">Grenzen</dt>
<dd className="text-main">
{Math.round(info.maxSeconds / 60)} Min. bzw. {info.maxUploadMb} MB pro Aufnahme, {info.maxParallel}{' '}
gleichzeitig, {info.perUserPerHour} pro Person und Stunde, {info.timeoutSeconds} s Zeitlimit
</dd>
</>
)}
</dl>
)}
{info?.mode === 'server' && (
<div className="pt-2 border-t border-border flex flex-col sm:flex-row sm:items-center gap-2">
{recording ? (
<button
type="button"
onClick={stopRecording}
className="btn-danger h-11 sm:h-8 px-3 text-xs flex items-center justify-center gap-1.5"
>
<Square className="w-3.5 h-3.5" /> Aufnahme beenden
</button>
) : (
<button
type="button"
onClick={startTest}
disabled={testing}
className="btn-secondary h-11 sm:h-8 px-3 text-xs flex items-center justify-center gap-1.5 disabled:opacity-40"
>
{testing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Mic className="w-3.5 h-3.5" />}
{testing ? 'Wird erkannt…' : `Testaufnahme (${TEST_SECONDS} s)`}
</button>
)}
{recording && <span className="text-muted">Sprich jetzt …</span>}
{result && (
<span className="text-main">
„{result.text || '(nichts erkannt)'}“ <span className="text-faint">· {(result.ms / 1000).toFixed(1)} s</span>
</span>
)}
</div>
)}
{error && <p className="text-status-err text-xs">{error}</p>}
</div>
);
};
@@ -0,0 +1,136 @@
import React, { useEffect, useRef, useState } from 'react';
import { FileAudio, Loader2, Wand2 } from 'lucide-react';
import { api } from '../../lib/api';
import { mediaBlob, mediaObjectURL, parseMediaContent } from '../../lib/media';
import { loadVoiceConfig } from '../../lib/voice';
interface AudioBlockProps {
content: string;
canEdit: boolean;
onCaptionChange: (caption: string) => void;
}
// Audio block: player plus caption. With the own Whisper server the recording can be turned into text,
// which becomes the caption (and so searchable).
export const AudioBlock: React.FC<AudioBlockProps> = ({ content, canEdit, onCaptionChange }) => {
const ref = parseMediaContent(content);
const mediaId = ref?.id;
const [src, setSrc] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
const [caption, setCaption] = useState(ref?.caption || '');
const [canTranscribe, setCanTranscribe] = useState(false);
const [transcribing, setTranscribing] = useState(false);
const [error, setError] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement>(null);
// Recordings from Chrome (WebM) carry no length: jump to the end once so the browser measures it,
// otherwise the player shows no total time and seeking does not work
const fixDuration = () => {
const a = audioRef.current;
if (!a || Number.isFinite(a.duration)) return;
const reset = () => {
a.removeEventListener('timeupdate', reset);
a.currentTime = 0;
};
a.addEventListener('timeupdate', reset);
a.currentTime = 1e101;
};
useEffect(() => {
setCaption(ref?.caption || '');
}, [ref?.caption]);
useEffect(() => {
if (!mediaId) return;
let alive = true;
setFailed(false);
mediaObjectURL(mediaId)
.then((u) => alive && setSrc(u))
.catch(() => alive && setFailed(true));
return () => {
alive = false;
};
}, [mediaId]);
useEffect(() => {
void loadVoiceConfig().then((cfg) => setCanTranscribe(cfg.mode === 'server'));
}, []);
if (!ref) {
return <div className="text-xs text-status-err py-1">Audio-Verweis ist beschädigt: {content}</div>;
}
const commitCaption = (value: string) => {
if (value.trim() !== ref.caption) onCaptionChange(value.trim());
};
const transcribe = async () => {
if (!src) return;
setTranscribing(true);
setError(null);
try {
const blob = await mediaBlob(ref.id);
const text = await api.transcribe(blob);
if (text) {
setCaption(text);
commitCaption(text);
} else {
setError('Nichts erkannt');
}
} catch (err: any) {
setError(err?.message || 'Spracherkennung fehlgeschlagen');
} finally {
setTranscribing(false);
}
};
return (
<div className="my-1 space-y-1">
{failed ? (
<div className="flex items-center gap-2 h-12 px-3 rounded-card border border-border bg-panel text-xs text-muted">
<FileAudio className="w-4 h-4" /> Aufnahme nicht verfügbar (offline oder gelöscht)
</div>
) : src ? (
<div className="flex items-center gap-2">
<audio ref={audioRef} controls preload="metadata" src={src} onLoadedMetadata={fixDuration} className="w-full max-w-md h-11" />
{canEdit && canTranscribe && (
<button
type="button"
onClick={() => void transcribe()}
disabled={transcribing}
title="In Text umwandeln (eigene Spracherkennung)"
className="btn-secondary h-11 sm:h-8 px-2.5 text-xs flex items-center gap-1.5 shrink-0 disabled:opacity-40"
>
{transcribing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Wand2 className="w-3.5 h-3.5" />}
<span className="hidden sm:inline">{transcribing ? 'Wird erkannt…' : 'Transkribieren'}</span>
</button>
)}
</div>
) : (
<div className="flex items-center h-12 px-3 rounded-card border border-border bg-panel w-64">
<Loader2 className="w-4 h-4 animate-spin text-muted" />
</div>
)}
{canEdit ? (
<input
type="text"
value={caption}
onChange={(e) => setCaption(e.target.value)}
onBlur={() => commitCaption(caption)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
(e.target as HTMLInputElement).blur();
}
}}
placeholder="Beschriftung hinzufügen…"
className="w-full bg-transparent border-0 p-0 text-xs text-muted placeholder-faint focus:outline-none focus:ring-0"
/>
) : (
ref.caption && <p className="text-xs text-muted">{ref.caption}</p>
)}
{error && <p className="text-xs text-status-err">{error}</p>}
</div>
);
};
@@ -0,0 +1,183 @@
import React, { useEffect, useRef, useState } from 'react';
import { Loader2, Mic, Square, Upload, X } from 'lucide-react';
import { UploadedMedia, uploadMedia } from '../../lib/media';
import { VoiceRecorder } from '../../lib/voice';
import { VoiceWave } from '../../components/VoiceWave';
import { triggerHaptic } from '../../lib/api';
interface AudioRecorderPickerProps {
docId: string;
onDone: (media: UploadedMedia) => void;
onCancel: () => void;
}
const MAX_RECORDING_MS = 30 * 60 * 1000;
function formatTime(ms: number): string {
const s = Math.floor(ms / 1000);
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}
// Inline bubble an empty bullet turns into after /audio. Green button = start, red button = stop and
// save; the live wave runs left of the red button. An existing audio file can be uploaded instead.
export const AudioRecorderPicker: React.FC<AudioRecorderPickerProps> = ({ docId, onDone, onCancel }) => {
const [state, setState] = useState<'ready' | 'recording' | 'uploading'>('ready');
const [elapsed, setElapsed] = useState(0);
const [error, setError] = useState<string | null>(null);
const recorderRef = useRef<VoiceRecorder | null>(null);
const startedRef = useRef(0);
const tickRef = useRef<number | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const boxRef = useRef<HTMLDivElement>(null);
const stopTicking = () => {
if (tickRef.current !== null) window.clearInterval(tickRef.current);
tickRef.current = null;
};
useEffect(() => {
boxRef.current?.focus();
return () => {
stopTicking();
recorderRef.current?.cancel();
};
}, []);
const upload = async (blob: Blob, name: string) => {
setState('uploading');
setError(null);
try {
onDone(await uploadMedia(docId, blob, name));
} catch (err: any) {
setError(err?.message || 'Hochladen fehlgeschlagen');
setState('ready');
}
};
const stop = async () => {
const rec = recorderRef.current;
if (!rec) return;
recorderRef.current = null;
stopTicking();
triggerHaptic(20);
const blob = await rec.stop();
if (!blob) {
setError('Die Aufnahme ist leer');
setState('ready');
return;
}
const ext = blob.type.includes('mp4') ? 'm4a' : blob.type.includes('ogg') ? 'ogg' : 'webm';
await upload(blob, `sprachnotiz.${ext}`);
};
const start = async () => {
setError(null);
const rec = new VoiceRecorder();
try {
await rec.start();
} catch {
setError('Kein Zugriff auf das Mikrofon (Berechtigung, oder die Seite läuft nicht über HTTPS)');
return;
}
recorderRef.current = rec;
startedRef.current = Date.now();
setElapsed(0);
setState('recording');
triggerHaptic([15, 60, 25]);
tickRef.current = window.setInterval(() => {
const ms = Date.now() - startedRef.current;
setElapsed(ms);
if (ms >= MAX_RECORDING_MS) void stop();
}, 250);
};
const cancel = () => {
stopTicking();
recorderRef.current?.cancel();
recorderRef.current = null;
onCancel();
};
return (
<div
ref={boxRef}
tabIndex={-1}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
cancel();
}
}}
className="my-1 inline-flex max-w-full flex-col gap-2 rounded-card border border-border bg-panel-elevated px-3 py-2 focus:outline-none"
>
<div className="flex items-center gap-3">
{state === 'recording' && (
<>
<span className="text-xs font-mono text-main tabular-nums">{formatTime(elapsed)}</span>
<VoiceWave getLevel={() => recorderRef.current?.level() ?? null} bars={11} className="h-6 text-status-err" />
</>
)}
{state === 'ready' && <span className="text-xs text-muted">Sprachnotiz aufnehmen</span>}
{state === 'uploading' && (
<span className="text-xs text-muted flex items-center gap-1.5">
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Wird gespeichert…
</span>
)}
{state === 'recording' ? (
<button
type="button"
onClick={() => void stop()}
title="Aufnahme beenden und speichern"
aria-label="Aufnahme beenden und speichern"
className="w-14 h-14 sm:w-10 sm:h-10 rounded-full bg-status-err text-on-accent flex items-center justify-center shadow-panel shrink-0"
>
<Square className="w-5 h-5 sm:w-4 sm:h-4 fill-current" />
</button>
) : (
<button
type="button"
onClick={() => void start()}
disabled={state === 'uploading'}
title="Aufnahme starten"
aria-label="Aufnahme starten"
className="w-14 h-14 sm:w-10 sm:h-10 rounded-full bg-status-ok text-on-accent flex items-center justify-center shadow-panel shrink-0 disabled:opacity-40"
>
<Mic className="w-6 h-6 sm:w-5 sm:h-5" />
</button>
)}
{state === 'ready' && (
<button
type="button"
onClick={() => fileRef.current?.click()}
title="Audiodatei hochladen"
className="btn-secondary h-11 sm:h-8 px-3 text-xs flex items-center gap-1.5 shrink-0"
>
<Upload className="w-3.5 h-3.5" /> <span className="hidden sm:inline">Datei</span>
</button>
)}
<button
type="button"
onClick={cancel}
disabled={state === 'uploading'}
title="Abbrechen (Esc)"
className="w-11 h-11 sm:w-7 sm:h-7 flex items-center justify-center rounded-btn text-muted hover:text-main hover:bg-panel shrink-0 disabled:opacity-40"
>
<X className="w-4 h-4" />
</button>
</div>
{error && <p className="text-xs text-status-err">{error}</p>}
<input
ref={fileRef}
type="file"
accept="audio/*"
hidden
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void upload(f, f.name || 'audio');
}}
/>
</div>
);
};
@@ -0,0 +1,625 @@
import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react';
import Prism from 'prismjs';
import { Code, Copy, Check } from 'lucide-react';
import { DocumentBlock } from '../../lib/types';
Prism.manual = true;
if (typeof globalThis !== 'undefined') {
(globalThis as Record<string, unknown>).Prism = Prism;
}
export interface CodeLanguageOption {
id: string;
label: string;
}
export interface CodeLanguageGroup {
label: string;
options: CodeLanguageOption[];
}
export const CODE_LANGUAGE_GROUPS: CodeLanguageGroup[] = [
{
label: 'Konfiguration & Daten',
options: [
{ id: 'yaml', label: 'YAML' },
{ id: 'json', label: 'JSON' },
{ id: 'toml', label: 'TOML' },
{ id: 'ini', label: 'INI' },
{ id: 'docker', label: 'Dockerfile' },
{ id: 'nginx', label: 'Nginx' },
{ id: 'hcl', label: 'HCL / Terraform' },
{ id: 'xml', label: 'XML' },
{ id: 'csv', label: 'CSV' },
{ id: 'graphql', label: 'GraphQL' },
{ id: 'protobuf', label: 'Protocol Buffers' },
],
},
{
label: 'Shell & Betrieb',
options: [
{ id: 'bash', label: 'Bash / Shell' },
{ id: 'powershell', label: 'PowerShell' },
{ id: 'makefile', label: 'Makefile' },
{ id: 'systemd', label: 'Systemd' },
{ id: 'vim', label: 'Vim' },
{ id: 'diff', label: 'Diff' },
{ id: 'git', label: 'Git' },
{ id: 'log', label: 'Log' },
],
},
{
label: 'Web & Frontend',
options: [
{ id: 'javascript', label: 'JavaScript' },
{ id: 'typescript', label: 'TypeScript' },
{ id: 'jsx', label: 'JSX' },
{ id: 'tsx', label: 'TSX' },
{ id: 'markup', label: 'HTML / Markup' },
{ id: 'html', label: 'HTML' },
{ id: 'css', label: 'CSS' },
{ id: 'scss', label: 'SCSS' },
],
},
{
label: 'Skriptsprachen',
options: [
{ id: 'python', label: 'Python' },
{ id: 'php', label: 'PHP' },
{ id: 'ruby', label: 'Ruby' },
{ id: 'lua', label: 'Lua' },
{ id: 'perl', label: 'Perl' },
],
},
{
label: 'System & Backend',
options: [
{ id: 'go', label: 'Go' },
{ id: 'rust', label: 'Rust' },
{ id: 'sql', label: 'SQL' },
{ id: 'c', label: 'C' },
{ id: 'cpp', label: 'C++' },
{ id: 'csharp', label: 'C#' },
{ id: 'java', label: 'Java' },
{ id: 'kotlin', label: 'Kotlin' },
{ id: 'swift', label: 'Swift' },
{ id: 'dart', label: 'Dart' },
{ id: 'zig', label: 'Zig' },
{ id: 'elixir', label: 'Elixir' },
{ id: 'scala', label: 'Scala' },
{ id: 'haskell', label: 'Haskell' },
],
},
{
label: 'Dokumentation & Text',
options: [
{ id: 'markdown', label: 'Markdown' },
{ id: 'latex', label: 'LaTeX' },
{ id: 'regex', label: 'Regex' },
{ id: 'text', label: 'Plain Text' },
],
},
];
export const CODE_LANGUAGES: CodeLanguageOption[] = CODE_LANGUAGE_GROUPS.flatMap((g) => g.options);
const KNOWN_OPTION_IDS = new Set<string>(CODE_LANGUAGES.map((l) => l.id));
const LANGUAGE_ALIASES: Record<string, string> = {
yml: 'yaml',
sh: 'bash',
shell: 'bash',
zsh: 'bash',
js: 'javascript',
mjs: 'javascript',
cjs: 'javascript',
ts: 'typescript',
mts: 'typescript',
cts: 'typescript',
py: 'python',
rb: 'ruby',
rs: 'rust',
cs: 'csharp',
'c#': 'csharp',
dotnet: 'csharp',
'c++': 'cpp',
cxx: 'cpp',
cc: 'cpp',
hpp: 'cpp',
h: 'c',
kt: 'kotlin',
kts: 'kotlin',
hs: 'haskell',
md: 'markdown',
tex: 'latex',
dockerfile: 'docker',
containerfile: 'docker',
htm: 'html',
svg: 'xml',
proto: 'protobuf',
ps1: 'powershell',
pwsh: 'powershell',
make: 'makefile',
gql: 'graphql',
tf: 'hcl',
terraform: 'hcl',
ex: 'elixir',
exs: 'elixir',
pl: 'perl',
conf: 'ini',
golang: 'go',
plaintext: 'text',
plain: 'text',
txt: 'text',
};
const SHORT_PREFIX_CANDIDATES = new Set<string>([
'c',
'go',
'js',
'ts',
'sh',
'py',
'rb',
'rs',
'cs',
'kt',
'hs',
'md',
'ex',
'pl',
'tf',
'tex',
'htm',
'yml',
'doc',
'docker',
'make',
'proto',
'plain',
]);
export function resolveLanguageId(raw?: string): string | undefined {
if (!raw) return undefined;
const key = raw.trim().toLowerCase();
if (!key) return undefined;
if (KNOWN_OPTION_IDS.has(key)) return key;
return LANGUAGE_ALIASES[key];
}
// True when a language token in `/code <lang>` or ````<lang>` cannot be a prefix of another language user is still typing
export function isUnambiguousLanguageShortcut(raw?: string): boolean {
if (!raw) return false;
const key = raw.trim().toLowerCase();
if (SHORT_PREFIX_CANDIDATES.has(key)) return false;
return Boolean(resolveLanguageId(key));
}
// On-demand loaders for Prism grammars (Vite code-splits each into its own chunk)
const PRISM_LOADERS: Record<string, () => Promise<unknown>> = {
yaml: () => import('prismjs/components/prism-yaml'),
json: () => import('prismjs/components/prism-json'),
toml: () => import('prismjs/components/prism-toml'),
ini: () => import('prismjs/components/prism-ini'),
docker: () => import('prismjs/components/prism-docker'),
nginx: () => import('prismjs/components/prism-nginx'),
hcl: () => import('prismjs/components/prism-hcl'),
csv: () => import('prismjs/components/prism-csv'),
graphql: () => import('prismjs/components/prism-graphql'),
protobuf: () => import('prismjs/components/prism-protobuf'),
bash: () => import('prismjs/components/prism-bash'),
powershell: () => import('prismjs/components/prism-powershell'),
makefile: () => import('prismjs/components/prism-makefile'),
systemd: () => import('prismjs/components/prism-systemd'),
vim: () => import('prismjs/components/prism-vim'),
diff: () => import('prismjs/components/prism-diff'),
git: () => import('prismjs/components/prism-git'),
log: () => import('prismjs/components/prism-log'),
typescript: () => import('prismjs/components/prism-typescript'),
jsx: () => import('prismjs/components/prism-jsx'),
tsx: () => import('prismjs/components/prism-tsx'),
scss: () => import('prismjs/components/prism-scss'),
python: () => import('prismjs/components/prism-python'),
'markup-templating': () => import('prismjs/components/prism-markup-templating'),
php: () => import('prismjs/components/prism-php'),
ruby: () => import('prismjs/components/prism-ruby'),
lua: () => import('prismjs/components/prism-lua'),
perl: () => import('prismjs/components/prism-perl'),
go: () => import('prismjs/components/prism-go'),
rust: () => import('prismjs/components/prism-rust'),
sql: () => import('prismjs/components/prism-sql'),
c: () => import('prismjs/components/prism-c'),
cpp: () => import('prismjs/components/prism-cpp'),
csharp: () => import('prismjs/components/prism-csharp'),
java: () => import('prismjs/components/prism-java'),
kotlin: () => import('prismjs/components/prism-kotlin'),
swift: () => import('prismjs/components/prism-swift'),
dart: () => import('prismjs/components/prism-dart'),
zig: () => import('prismjs/components/prism-zig'),
elixir: () => import('prismjs/components/prism-elixir'),
scala: () => import('prismjs/components/prism-scala'),
haskell: () => import('prismjs/components/prism-haskell'),
markdown: () => import('prismjs/components/prism-markdown'),
latex: () => import('prismjs/components/prism-latex'),
regex: () => import('prismjs/components/prism-regex'),
};
// Prerequisite grammars that must be loaded in order before the target grammar
const PRISM_DEPENDENCIES: Record<string, string[]> = {
tsx: ['jsx', 'typescript'],
cpp: ['c'],
php: ['markup-templating'],
scala: ['java'],
};
const inFlightGrammarLoads = new Map<string, Promise<boolean>>();
export function toPrismGrammarKey(lang?: string): string {
if (!lang) return '';
const resolved = resolveLanguageId(lang) || lang.trim().toLowerCase();
if (!resolved || resolved === 'text' || resolved === 'plaintext') return '';
if (resolved === 'html') return 'markup';
return resolved;
}
export async function loadPrismLanguage(lang?: string): Promise<boolean> {
const key = toPrismGrammarKey(lang);
if (!key) return false;
if (Prism.languages[key]) return true;
const existing = inFlightGrammarLoads.get(key);
if (existing) return existing;
const task = (async () => {
try {
const deps = PRISM_DEPENDENCIES[key];
if (deps) {
for (const dep of deps) {
if (!Prism.languages[dep]) {
await loadPrismLanguage(dep);
}
}
}
const loader = PRISM_LOADERS[key];
if (loader) {
await loader();
}
return Boolean(Prism.languages[key]);
} catch {
return false;
} finally {
inFlightGrammarLoads.delete(key);
}
})();
inFlightGrammarLoads.set(key, task);
return task;
}
export function escapeHtml(raw: string): string {
return raw
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
export interface CodeBlockEditorProps {
block: DocumentBlock;
content?: string;
canEdit?: boolean;
textareaRef?: React.MutableRefObject<HTMLTextAreaElement | null>;
registerTextareaRef?: (blockId: string, el: HTMLTextAreaElement | null) => void;
onChangeContent?: (content: string) => void;
onChangeLang?: (lang: string) => void;
onConvertToText?: () => void;
handleFocus?: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
handleBlur?: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
handleContentChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
handleKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
}
export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
block,
content,
canEdit = true,
textareaRef,
registerTextareaRef,
onChangeContent,
onChangeLang,
onConvertToText,
handleFocus,
handleBlur,
handleContentChange,
handleKeyDown,
}) => {
const isControlled = content !== undefined;
const [uncontrolledContent, setUncontrolledContent] = useState<string>(block.content || '');
const currentContent = isControlled ? content : uncontrolledContent;
useEffect(() => {
if (!isControlled) {
setUncontrolledContent(block.content || '');
}
}, [block.content, isControlled]);
const [copied, setCopied] = useState(false);
const [loadedGrammar, setLoadedGrammar] = useState<string>('');
const copyTimerRef = useRef<number | null>(null);
const localTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const preRef = useRef<HTMLPreElement | null>(null);
useEffect(() => {
return () => {
if (copyTimerRef.current !== null) {
window.clearTimeout(copyTimerRef.current);
}
};
}, []);
const prismLang = toPrismGrammarKey(block.lang);
useEffect(() => {
if (!prismLang) return;
if (Prism.languages[prismLang]) {
setLoadedGrammar(prismLang);
return;
}
let cancelled = false;
loadPrismLanguage(prismLang).then((loaded) => {
if (!cancelled && loaded) {
setLoadedGrammar(prismLang);
}
});
return () => {
cancelled = true;
};
}, [prismLang]);
const highlightedHtml = useMemo(() => {
if (!currentContent) return '';
const activeLang = prismLang && (Prism.languages[prismLang] || loadedGrammar === prismLang) ? prismLang : '';
if (!activeLang) return escapeHtml(currentContent);
const grammar = Prism.languages[activeLang];
if (!grammar) return escapeHtml(currentContent);
try {
return Prism.highlight(currentContent, grammar, activeLang);
} catch {
return escapeHtml(currentContent);
}
}, [currentContent, prismLang, loadedGrammar]);
const adjustHeight = useCallback(() => {
const el = localTextareaRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${Math.max(22, el.scrollHeight)}px`;
}, []);
useLayoutEffect(() => {
adjustHeight();
}, [currentContent, adjustHeight]);
const handleCopy = (e: React.MouseEvent) => {
e.stopPropagation();
try {
const p = navigator.clipboard?.writeText(currentContent);
if (p && typeof p.catch === 'function') {
p.catch(() => {});
}
} catch {
// ignore clipboard errors
}
setCopied(true);
if (copyTimerRef.current !== null) {
window.clearTimeout(copyTimerRef.current);
}
copyTimerRef.current = window.setTimeout(() => {
copyTimerRef.current = null;
setCopied(false);
}, 1500);
};
const applyContentUpdate = (newVal: string, target: HTMLTextAreaElement) => {
if (!isControlled) {
setUncontrolledContent(newVal);
}
if (onChangeContent) {
onChangeContent(newVal);
} else if (handleContentChange) {
target.value = newVal;
handleContentChange({
target,
currentTarget: target,
} as React.ChangeEvent<HTMLTextAreaElement>);
}
};
const onTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (!canEdit) return;
if (!isControlled) {
setUncontrolledContent(e.target.value);
}
if (handleContentChange) {
handleContentChange(e);
} else if (onChangeContent) {
onChangeContent(e.target.value);
}
};
const onTextareaKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (canEdit && e.key === 'Tab') {
e.preventDefault();
e.stopPropagation();
const ta = e.currentTarget;
const val = currentContent;
const start = ta.selectionStart ?? 0;
const end = ta.selectionEnd ?? start;
const lineStart = start === 0 ? 0 : val.lastIndexOf('\n', start - 1) + 1;
const effectiveEnd = end > start && val[end - 1] === '\n' ? end - 1 : end;
const lineEndIdx = val.indexOf('\n', effectiveEnd);
const blockEnd = lineEndIdx === -1 ? val.length : lineEndIdx;
const lines = val.slice(lineStart, blockEnd).split('\n');
if (!e.shiftKey) {
const updatedLines = lines.map((line) => ' ' + line);
const newVal = val.slice(0, lineStart) + updatedLines.join('\n') + val.slice(blockEnd);
const newStart = start + 2;
const newEnd = end + updatedLines.length * 2;
applyContentUpdate(newVal, ta);
ta.value = newVal;
ta.setSelectionRange(newStart, newEnd);
setTimeout(() => {
if (localTextareaRef.current) {
localTextareaRef.current.setSelectionRange(newStart, newEnd);
}
}, 0);
} else {
let removedFirst = 0;
let removedTotal = 0;
const updatedLines = lines.map((line, idx) => {
let removeCount = 0;
if (line.startsWith(' ')) removeCount = 2;
else if (line.startsWith(' ') || line.startsWith('\t')) removeCount = 1;
if (idx === 0) removedFirst = removeCount;
removedTotal += removeCount;
return line.slice(removeCount);
});
if (removedTotal > 0) {
const newVal = val.slice(0, lineStart) + updatedLines.join('\n') + val.slice(blockEnd);
const newStart = Math.max(lineStart, start - removedFirst);
const newEnd = Math.max(newStart, end - removedTotal);
applyContentUpdate(newVal, ta);
ta.value = newVal;
ta.setSelectionRange(newStart, newEnd);
setTimeout(() => {
if (localTextareaRef.current) {
localTextareaRef.current.setSelectionRange(newStart, newEnd);
}
}, 0);
}
}
return;
}
handleKeyDown?.(e);
};
const handleScroll = (e: React.UIEvent<HTMLTextAreaElement>) => {
if (preRef.current) {
preRef.current.scrollTop = e.currentTarget.scrollTop;
preRef.current.scrollLeft = e.currentTarget.scrollLeft;
}
};
const rawLang = block.lang || '';
const selectValue = rawLang ? resolveLanguageId(rawLang) || rawLang : '';
const hasCustomUnknownLang = Boolean(selectValue && !KNOWN_OPTION_IDS.has(selectValue));
return (
<div className="bg-code border border-border rounded-btn my-0.5 overflow-x-auto max-w-full">
{/* 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 gap-1.5 min-w-0">
<Code className="w-3.5 h-3.5 text-muted shrink-0" />
{canEdit ? (
<select
aria-label="Sprache auswählen"
value={selectValue}
onChange={(e) => onChangeLang?.(e.target.value)}
className="min-h-[44px] sm:min-h-0 bg-transparent text-muted hover:text-main focus:text-main font-mono text-[11px] font-medium border border-transparent hover:border-border focus:border-accent rounded-btn px-1.5 py-0.5 outline-none cursor-pointer"
>
<option value="" className="bg-panel text-main">
Text
</option>
{hasCustomUnknownLang && (
<option value={selectValue} className="bg-panel text-main">
{selectValue}
</option>
)}
{CODE_LANGUAGE_GROUPS.map((group) => (
<optgroup key={group.label} label={group.label} className="bg-panel text-muted">
{group.options.map((opt) => (
<option key={opt.id} value={opt.id} className="bg-panel text-main">
{opt.label}
</option>
))}
</optgroup>
))}
</select>
) : (
<span className="font-mono text-[11px] text-muted px-1">{block.lang || 'Text'}</span>
)}
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button
type="button"
onClick={handleCopy}
className="min-h-[44px] sm:min-h-0 px-2 py-0.5 rounded-btn text-[11px] font-mono text-muted hover:text-main hover:bg-panel-elevated transition-colors inline-flex items-center gap-1 cursor-pointer"
title="In die Zwischenablage kopieren"
>
{copied ? (
<>
<Check className="w-3 h-3 text-status-ok" />
<span className="text-status-ok">Kopiert!</span>
</>
) : (
<>
<Copy className="w-3 h-3 opacity-75" />
<span>Kopieren</span>
</>
)}
</button>
{canEdit && onConvertToText && (
<button
type="button"
onClick={onConvertToText}
className="min-h-[44px] sm:min-h-0 px-2 py-0.5 rounded-btn text-[11px] font-mono text-muted hover:text-accent hover:bg-panel-elevated transition-colors inline-flex items-center cursor-pointer"
>
Als Text
</button>
)}
</div>
</div>
{/* Live Highlighted Overlay + Textarea */}
<div className="relative w-full overflow-x-auto">
<pre
ref={preRef}
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"
>
<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-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>
);
};
@@ -1,282 +0,0 @@
import React, { useState, useMemo, useRef } from 'react';
import Prism from 'prismjs';
import 'prismjs/components/prism-python';
import 'prismjs/components/prism-typescript';
import 'prismjs/components/prism-bash';
import 'prismjs/components/prism-json';
import 'prismjs/components/prism-go';
import 'prismjs/components/prism-sql';
import 'prismjs/components/prism-yaml';
import 'prismjs/components/prism-rust';
import 'prismjs/components/prism-c';
import 'prismjs/components/prism-cpp';
import 'prismjs/components/prism-java';
import 'prismjs/components/prism-php';
import 'prismjs/components/prism-docker';
import 'prismjs/components/prism-markdown';
import { Code, Copy, Check, Eye, Edit3 } from 'lucide-react';
import { DocumentBlock } from '../../lib/types';
export interface CodeBlockItemProps {
block: DocumentBlock;
isReadOnly: boolean;
onChangeContent: (content: string) => void;
onChangeLang: (lang: string) => void;
onFocus?: () => void;
onBlur?: () => void;
inputRef?: (el: HTMLTextAreaElement | null) => void;
}
export const CODE_LANGUAGES = [
{ id: 'javascript', label: 'JavaScript' },
{ id: 'typescript', label: 'TypeScript' },
{ id: 'python', label: 'Python' },
{ id: 'go', label: 'Go' },
{ id: 'bash', label: 'Bash / Shell' },
{ id: 'json', label: 'JSON' },
{ id: 'html', label: 'HTML / XML', alias: 'markup' },
{ id: 'css', label: 'CSS' },
{ id: 'sql', label: 'SQL' },
{ id: 'rust', label: 'Rust' },
{ id: 'cpp', label: 'C++' },
{ id: 'c', label: 'C' },
{ id: 'java', label: 'Java' },
{ id: 'php', label: 'PHP' },
{ id: 'yaml', label: 'YAML' },
{ id: 'docker', label: 'Dockerfile' },
{ id: 'markdown', label: 'Markdown' },
{ id: 'text', label: 'Plain Text' },
];
export const CodeBlockItem: React.FC<CodeBlockItemProps> = ({
block,
isReadOnly,
onChangeContent,
onChangeLang,
onFocus,
onBlur,
inputRef,
}) => {
const [isEditing, setIsEditing] = useState(false);
const [copied, setCopied] = useState(false);
const localTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const currentLang = block.lang || 'javascript';
// Highlighted HTML generated by Prism
const highlightedCode = useMemo(() => {
const code = block.content || '';
if (!code) return '';
const prismLang = currentLang === 'html' ? 'markup' : currentLang;
const grammar = Prism.languages[prismLang] || Prism.languages.javascript;
if (!grammar) {
return code
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
try {
return Prism.highlight(code, grammar, prismLang);
} catch {
return code
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
}, [block.content, currentLang]);
// Code lines for line numbers
const lines = useMemo(() => {
return (block.content || '').split('\n');
}, [block.content]);
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(block.content || '');
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Escape') {
setIsEditing(false);
return;
}
// Tab key indent/outdent
if (e.key === 'Tab') {
e.preventDefault();
const textarea = e.currentTarget;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const val = textarea.value;
if (e.shiftKey) {
if (val.substring(start - 2, start) === ' ') {
const newVal = val.substring(0, start - 2) + val.substring(start);
onChangeContent(newVal);
setTimeout(() => {
textarea.selectionStart = textarea.selectionEnd = Math.max(0, start - 2);
}, 0);
}
} else {
const newVal = val.substring(0, start) + ' ' + val.substring(end);
onChangeContent(newVal);
setTimeout(() => {
textarea.selectionStart = textarea.selectionEnd = start + 2;
}, 0);
}
}
};
const switchToEdit = () => {
if (isReadOnly) return;
setIsEditing(true);
setTimeout(() => {
localTextareaRef.current?.focus();
}, 20);
};
return (
<div className="rounded-card border border-border bg-panel overflow-hidden shadow-xs my-1 group/code">
{/* Code Block Header Toolbar */}
<div className="flex items-center justify-between px-3 py-1.5 bg-canvas border-b border-border text-xs">
{/* Left: Language Selection */}
<div className="flex items-center gap-2">
<Code className="w-3.5 h-3.5 text-accent flex-shrink-0" />
<select
value={currentLang}
disabled={isReadOnly}
onChange={(e) => onChangeLang(e.target.value)}
className="bg-transparent text-main font-mono text-[11px] font-medium border border-transparent hover:border-border focus:border-accent rounded px-1.5 py-0.5 outline-none cursor-pointer"
>
{CODE_LANGUAGES.map((lang) => (
<option key={lang.id} value={lang.id} className="bg-panel text-main">
{lang.label}
</option>
))}
</select>
</div>
{/* Right: Mode Toggle & Copy */}
<div className="flex items-center gap-1.5">
{!isReadOnly && (
<button
type="button"
onClick={() => {
if (isEditing) {
setIsEditing(false);
} else {
switchToEdit();
}
}}
className="px-2 py-0.5 rounded text-[11px] font-medium text-muted hover:text-main hover:bg-panel-elevated transition-colors flex items-center gap-1 cursor-pointer"
title={isEditing ? 'Zur farbigen Vorschau wechseln (Esc)' : 'Code bearbeiten'}
>
{isEditing ? (
<>
<Eye className="w-3 h-3 text-accent" />
<span>Vorschau</span>
</>
) : (
<>
<Edit3 className="w-3 h-3 text-muted" />
<span>Bearbeiten</span>
</>
)}
</button>
)}
<button
type="button"
onClick={handleCopy}
className={`px-2 py-0.5 rounded text-[11px] font-medium transition-colors flex items-center gap-1 cursor-pointer ${
copied
? 'text-emerald-500 bg-emerald-500/10'
: 'text-muted hover:text-main hover:bg-panel-elevated'
}`}
title="Code in Zwischenablage kopieren"
>
{copied ? (
<>
<Check className="w-3 h-3 text-emerald-500" />
<span>Kopiert!</span>
</>
) : (
<>
<Copy className="w-3 h-3 opacity-70" />
<span>Kopieren</span>
</>
)}
</button>
</div>
</div>
{/* Code Block Content */}
{isEditing && !isReadOnly ? (
<div className="relative">
<textarea
ref={(el) => {
localTextareaRef.current = el;
if (inputRef) inputRef(el);
}}
rows={Math.max(3, lines.length)}
value={block.content || ''}
onFocus={() => onFocus && onFocus()}
onBlur={() => {
if (onBlur) onBlur();
}}
onChange={(e) => onChangeContent(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="// Code hier eingeben..."
className="w-full p-3 bg-panel-elevated/40 font-mono text-xs text-main leading-relaxed focus:outline-none resize-none placeholder:text-faint"
/>
<div className="px-3 py-1 bg-canvas/40 border-t border-border/50 text-[10px] text-faint flex items-center justify-between">
<span>Tab: Einrücken (2 Leerzeichen) • Shift+Tab: Ausrücken</span>
<button
type="button"
onClick={() => setIsEditing(false)}
className="hover:text-accent cursor-pointer"
>
Vorschau anzeigen (Esc)
</button>
</div>
</div>
) : (
<div
onClick={switchToEdit}
className={`relative p-3 font-mono text-xs overflow-x-auto ${
isReadOnly ? '' : 'cursor-pointer hover:bg-panel-elevated/20 transition-colors'
}`}
title={isReadOnly ? undefined : 'Klicken zum Bearbeiten'}
>
{block.content ? (
<div className="flex gap-3">
{/* Line numbers */}
<div className="select-none text-right text-faint opacity-50 font-mono text-xs leading-relaxed flex-shrink-0">
{lines.map((_, idx) => (
<div key={idx}>{idx + 1}</div>
))}
</div>
{/* Syntax highlighted code */}
<pre className="m-0 p-0 font-mono text-xs leading-relaxed whitespace-pre overflow-x-auto flex-1">
<code
dangerouslySetInnerHTML={{ __html: highlightedCode }}
className={`language-${currentLang}`}
/>
</pre>
</div>
) : (
<div className="text-faint italic py-2">
{isReadOnly ? '(Kein Code hinterlegt)' : '(Klicken, um Code einzugeben...)'}
</div>
)}
</div>
)}
</div>
);
};
@@ -0,0 +1,97 @@
import React, { useEffect, useRef, useState } from 'react';
import { Loader2, Square } from 'lucide-react';
import { useVoiceInput } from '../../lib/useVoiceInput';
import { VoiceWave } from '../../components/VoiceWave';
import { triggerHaptic } from '../../lib/api';
interface DictationBlockProps {
onDone: (text: string) => void;
onCancel: () => void;
}
// 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
// bullet as plain text.
export const DictationBlock: React.FC<DictationBlockProps> = ({ onDone, onCancel }) => {
const [interim, setInterim] = useState('');
const [error, setError] = useState<string | null>(null);
const doneRef = useRef(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const voice = useVoiceInput({
continuous: true,
onText: (text, final) => {
if (!final) {
setInterim(text);
return;
}
if (doneRef.current) return;
doneRef.current = true;
if (text.trim()) onDone(text.trim());
else onCancel();
},
onError: (message) => {
setError(message);
window.setTimeout(() => {
if (!doneRef.current) {
doneRef.current = true;
onCancel();
}
}, 2500);
},
});
useEffect(() => {
triggerHaptic([15, 60, 25]);
void voice.start();
buttonRef.current?.focus();
// start once when the bullet turns into the dictation
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const transcribing = voice.state === 'transcribing';
return (
<button
ref={buttonRef}
type="button"
onClick={() => {
if (voice.state === 'listening') {
triggerHaptic(20);
void voice.stop();
}
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
doneRef.current = true;
voice.cancel();
onCancel();
}
}}
disabled={transcribing || !!error}
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"
>
{/* Live wave across the whole bullet */}
<VoiceWave getLevel={voice.level} bars={32} className="absolute inset-x-3 inset-y-2 justify-between h-auto text-accent opacity-60" />
{interim && !error && (
<span className="absolute inset-x-3 bottom-1 text-[11px] text-main truncate text-left pointer-events-none">{interim}</span>
)}
{/* 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>
) : transcribing ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Wird erkannt…
</>
) : (
<>
<Square className="w-3 h-3 fill-current" /> Stopp
</>
)}
</span>
</button>
);
};
@@ -17,6 +17,7 @@ import {
Check,
CloudOff,
AlertTriangle,
GitMerge,
} from 'lucide-react';
import { Document, DocumentBlock, User } from '../../lib/types';
import { api } from '../../lib/api';
@@ -44,6 +45,8 @@ export interface DocumentEditorProps {
onBackMobile?: () => void;
onOpenTask?: (taskId: string) => void;
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
/** Opens the dialog to merge this note into another one (own notes only) */
onRequestMerge?: (doc: Document) => void;
}
import { EmojiPickerModal } from './EmojiPickerModal';
@@ -190,6 +193,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onBackMobile,
onOpenTask,
onSaveStatusChange,
onRequestMerge,
}) => {
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
const [title, setTitle] = useState<string>(initialDoc?.title || '');
@@ -375,14 +379,39 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
.catch(() => {});
}, [otherPeople.length]);
// Short hint at the bottom of the editor (blocked actions, skipped undo)
// Short hint at the bottom of the editor (blocked actions, skipped undo), optionally with one action
const [notice, setNotice] = useState<string | null>(null);
const [noticeAction, setNoticeAction] = useState<{ label: string; run: () => void } | null>(null);
const noticeTimerRef = useRef<number | null>(null);
const showNotice = useCallback((message: string) => {
const showNotice = useCallback((message: string, action?: { label: string; run: () => void }) => {
setNotice(message);
setNoticeAction(action || null);
if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current);
noticeTimerRef.current = window.setTimeout(() => setNotice(null), 3500);
noticeTimerRef.current = window.setTimeout(() => {
setNotice(null);
setNoticeAction(null);
}, action ? 7000 : 3500);
}, []);
// A bullet with its children becomes a note of its own; the server swaps the branch for a [[link]]
// and this editor receives that change through the normal sync
const handleExtractBlock = useCallback(
async (blockId: string) => {
const engine = engineRef.current;
try {
await engine?.syncNow(); // the branch as typed so far
const created = await api.extractBranch(docId, blockId, engine?.clientId || '');
await engine?.syncNow();
showNotice(`In eigene Notiz „${created.title}“ umgewandelt`, {
label: 'Öffnen',
run: () => onOpenDocBlock?.(created.id),
});
} catch (err: any) {
showNotice(err?.message || 'Umwandeln fehlgeschlagen');
}
},
[docId, onOpenDocBlock, showNotice]
);
useEffect(() => () => {
if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current);
}, []);
@@ -817,6 +846,16 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
>
<Users className="w-3.5 h-3.5" />
</button>
{onRequestMerge && doc.isOwner && !inTrash && (
<button
type="button"
onClick={() => onRequestMerge(doc)}
className="btn-icon"
title="Mit anderer Notiz zusammenführen…"
>
<GitMerge className="w-3.5 h-3.5" />
</button>
)}
</div>
);
})()}
@@ -883,7 +922,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
)}
{/* Main Content Area */}
<div className="w-full px-4 sm:px-8 py-5 flex-1">
{/* 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]">
{zoomedBlock ? (
/* =========================================================================
FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW)
@@ -968,6 +1008,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
canEdit={canEdit}
levelOffset={levelOffset}
onZoomBlock={setZoomedBlockId}
onExtractBlock={canEdit ? handleExtractBlock : undefined}
onUpdateBlock={handleUpdateBlock}
onUpdateBlocks={handleUpdateBlocks}
onCreateBlock={handleCreateBlock}
@@ -1055,6 +1096,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
canEdit={canEdit}
levelOffset={0}
onZoomBlock={setZoomedBlockId}
onExtractBlock={canEdit ? handleExtractBlock : undefined}
onUpdateBlock={handleUpdateBlock}
onUpdateBlocks={handleUpdateBlocks}
onCreateBlock={handleCreateBlock}
@@ -1083,7 +1125,20 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
role="status"
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 max-w-[90vw] px-3 py-2 rounded-card text-xs text-main bg-panel-elevated border border-border shadow-panel"
>
{notice}
<span>{notice}</span>
{noticeAction && (
<button
type="button"
onClick={() => {
noticeAction.run();
setNotice(null);
setNoticeAction(null);
}}
className="ml-3 font-semibold text-accent hover:underline min-h-[44px] sm:min-h-0"
>
{noticeAction.label}
</button>
)}
</div>
)}
</div>
@@ -0,0 +1,116 @@
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { ImageOff, Loader2, X } from 'lucide-react';
import { mediaObjectURL, parseMediaContent } from '../../lib/media';
interface ImageBlockProps {
content: string;
canEdit: boolean;
onCaptionChange: (caption: string) => void;
}
// Image block: the picture (click = full screen) and an optional caption below it.
export const ImageBlock: React.FC<ImageBlockProps> = ({ content, canEdit, onCaptionChange }) => {
const ref = parseMediaContent(content);
const [src, setSrc] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
const [zoomed, setZoomed] = useState(false);
const [caption, setCaption] = useState(ref?.caption || '');
useEffect(() => {
setCaption(ref?.caption || '');
}, [ref?.caption]);
const mediaId = ref?.id;
useEffect(() => {
if (!mediaId) return;
let alive = true;
setFailed(false);
mediaObjectURL(mediaId)
.then((u) => alive && setSrc(u))
.catch(() => alive && setFailed(true));
return () => {
alive = false;
};
}, [mediaId]);
useEffect(() => {
if (!zoomed) return;
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setZoomed(false);
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [zoomed]);
if (!ref) {
return <div className="text-xs text-status-err py-1">Bild-Verweis ist beschädigt: {content}</div>;
}
const commitCaption = () => {
if (caption.trim() !== ref.caption) onCaptionChange(caption.trim());
};
return (
<figure className="my-1 space-y-1">
{failed ? (
<div className="flex items-center gap-2 h-24 px-3 rounded-card border border-border bg-panel text-xs text-muted">
<ImageOff className="w-4 h-4" /> Bild nicht verfügbar (offline oder gelöscht)
</div>
) : src ? (
<button
type="button"
onClick={() => setZoomed(true)}
title="Vergrößern"
className="block max-w-full rounded-card overflow-hidden border border-border bg-panel cursor-zoom-in"
>
<img src={src} alt={ref.caption} className="block max-w-full max-h-[28rem] object-contain" />
</button>
) : (
<div className="flex items-center justify-center w-48 h-32 rounded-card border border-border bg-panel">
<Loader2 className="w-4 h-4 animate-spin text-muted" />
</div>
)}
{canEdit ? (
<input
type="text"
value={caption}
onChange={(e) => setCaption(e.target.value)}
onBlur={commitCaption}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
(e.target as HTMLInputElement).blur();
}
}}
placeholder="Beschriftung hinzufügen…"
className="w-full bg-transparent border-0 p-0 text-xs text-muted placeholder-faint focus:outline-none focus:ring-0"
/>
) : (
ref.caption && <figcaption className="text-xs text-muted">{ref.caption}</figcaption>
)}
{zoomed &&
src &&
createPortal(
<div
role="dialog"
aria-label={ref.caption || 'Bild'}
onClick={() => setZoomed(false)}
className="fixed inset-0 z-[100] bg-black/85 flex flex-col items-center justify-center p-4 cursor-zoom-out"
>
<button
type="button"
onClick={() => setZoomed(false)}
title="Schließen (Esc)"
className="absolute top-[calc(0.75rem+env(safe-area-inset-top))] right-3 w-11 h-11 flex items-center justify-center rounded-full bg-black/50 text-white"
>
<X className="w-5 h-5" />
</button>
<img src={src} alt={ref.caption} className="max-w-full max-h-[85vh] object-contain" />
{ref.caption && <p className="mt-3 text-sm text-white/90 text-center max-w-2xl">{ref.caption}</p>}
</div>,
document.body
)}
</figure>
);
};
@@ -0,0 +1,165 @@
import React, { useEffect, useRef, useState } from 'react';
import { Camera, ImagePlus, Link as LinkIcon, Loader2, X } from 'lucide-react';
import { copyExternalImage, prepareImage, UploadedMedia, uploadMedia } from '../../lib/media';
interface ImagePickerProps {
docId: string;
onDone: (media: UploadedMedia) => void;
onCancel: () => void;
}
// Inline box an empty bullet turns into after /image: pick a file (or the camera on phones), drop or
// paste an image, or give a link the server copies. Nothing is synced until the upload succeeded.
export const ImagePicker: React.FC<ImagePickerProps> = ({ docId, onDone, onCancel }) => {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dragOver, setDragOver] = useState(false);
const [url, setUrl] = useState('');
const fileRef = useRef<HTMLInputElement>(null);
const cameraRef = useRef<HTMLInputElement>(null);
const boxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
boxRef.current?.focus();
}, []);
const uploadFile = async (file: File | Blob | null | undefined) => {
if (!file || busy) return;
if (file.type && !file.type.startsWith('image/')) {
setError('Bitte eine Bilddatei wählen');
return;
}
setBusy(true);
setError(null);
try {
const { blob, name } = await prepareImage(file);
onDone(await uploadMedia(docId, blob, name));
} catch (err: any) {
setError(err?.message || 'Hochladen fehlgeschlagen');
setBusy(false);
}
};
const copyLink = async (e?: React.FormEvent) => {
e?.preventDefault();
const target = url.trim();
if (!target || busy) return;
setBusy(true);
setError(null);
try {
onDone(await copyExternalImage(docId, target));
} catch (err: any) {
setError(err?.message || 'Bild konnte nicht übernommen werden');
setBusy(false);
}
};
return (
<div
ref={boxRef}
tabIndex={-1}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
onCancel();
}
}}
onPaste={(e) => {
const item = Array.from(e.clipboardData.items).find((i) => i.type.startsWith('image/'));
if (item) {
e.preventDefault();
void uploadFile(item.getAsFile());
}
}}
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
void uploadFile(e.dataTransfer.files?.[0]);
}}
className={
dragOver
? 'my-1 p-3 rounded-card border-2 border-dashed border-accent bg-accent-dim space-y-3 focus:outline-none'
: 'my-1 p-3 rounded-card border-2 border-dashed border-border bg-panel space-y-3 focus:outline-none'
}
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-main flex items-center gap-1.5">
<ImagePlus className="w-4 h-4 text-accent" /> Bild einfügen
</span>
<button
type="button"
onClick={onCancel}
title="Abbrechen (Esc)"
className="w-11 h-11 sm:w-7 sm:h-7 flex items-center justify-center rounded-btn text-muted hover:text-main hover:bg-panel-elevated"
>
<X className="w-4 h-4" />
</button>
</div>
{busy ? (
<div className="flex items-center gap-2 text-xs text-muted py-3">
<Loader2 className="w-4 h-4 animate-spin text-accent" /> Wird hochgeladen…
</div>
) : (
<>
<div className="flex flex-col sm:flex-row gap-2">
<button
type="button"
onClick={() => fileRef.current?.click()}
className="btn-primary h-11 sm:h-8 px-3 text-xs flex items-center justify-center gap-1.5"
>
<ImagePlus className="w-4 h-4" /> Datei wählen
</button>
<button
type="button"
onClick={() => cameraRef.current?.click()}
className="btn-secondary h-11 sm:h-8 px-3 text-xs flex items-center justify-center gap-1.5 sm:hidden"
>
<Camera className="w-4 h-4" /> Foto aufnehmen
</button>
<span className="hidden sm:flex items-center text-[11px] text-faint">
oder hierher ziehen bzw. mit Strg+V einfügen
</span>
</div>
<form onSubmit={copyLink} className="flex gap-2">
<div className="relative flex-1 min-w-0">
<LinkIcon className="w-3.5 h-3.5 text-faint absolute left-2.5 top-1/2 -translate-y-1/2" />
<input
type="url"
inputMode="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://… Bild-Link"
className="w-full h-11 sm:h-8 pl-8 pr-2 input-field text-xs"
/>
</div>
<button
type="submit"
disabled={!url.trim()}
className="btn-secondary h-11 sm:h-8 px-3 text-xs shrink-0 disabled:opacity-40"
>
Übernehmen
</button>
</form>
</>
)}
{error && <p className="text-xs text-status-err">{error}</p>}
<input ref={fileRef} type="file" accept="image/*" hidden onChange={(e) => void uploadFile(e.target.files?.[0])} />
<input
ref={cameraRef}
type="file"
accept="image/*"
capture="environment"
hidden
onChange={(e) => void uploadFile(e.target.files?.[0])}
/>
</div>
);
};
@@ -0,0 +1,84 @@
import React, { useEffect, useState } from 'react';
import { Globe, X } from 'lucide-react';
import { LinkPreview, loadLinkPreview } from '../../lib/linkPreview';
import { loadAuthorized } from '../../lib/media';
import { safeHref } from '../../lib/safeUrl';
interface LinkPreviewCardProps {
url: string;
canEdit: boolean;
onHide: (title: string) => void;
}
function usePicture(preview: LinkPreview | null, kind: 'image' | 'icon'): string | null {
const [src, setSrc] = useState<string | null>(null);
const has = preview ? (kind === 'image' ? preview.hasImage : preview.hasIcon) : false;
const key = preview?.key;
useEffect(() => {
if (!has || !key) return;
let alive = true;
loadAuthorized(`/api/v1/link-preview/${key}/${kind}`)
.then((p) => alive && setSrc(p.url))
.catch(() => {});
return () => {
alive = false;
};
}, [has, key, kind]);
return src;
}
// Compact card above a bullet with a web address: icon and site, title, one line of description and a
// small picture. Nothing is shown when the page offers nothing usable.
export const LinkPreviewCard: React.FC<LinkPreviewCardProps> = ({ url, canEdit, onHide }) => {
const [preview, setPreview] = useState<LinkPreview | null>(null);
const image = usePicture(preview, 'image');
const icon = usePicture(preview, 'icon');
const href = safeHref(url);
useEffect(() => {
let alive = true;
setPreview(null);
void loadLinkPreview(url).then((p) => alive && setPreview(p));
return () => {
alive = false;
};
}, [url]);
if (!preview || !href) return null;
return (
<div className="relative mb-1 max-w-xl">
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="flex items-stretch gap-3 rounded-card border border-border bg-panel hover:bg-panel-elevated transition-colors overflow-hidden no-underline"
>
<div className="min-w-0 flex-1 py-2 pl-3 pr-11 sm:pr-8 space-y-0.5">
<div className="flex items-center gap-1.5 text-[11px] text-faint min-w-0">
{icon ? (
<img src={icon} alt="" className="w-3.5 h-3.5 rounded-sm shrink-0" />
) : (
<Globe className="w-3.5 h-3.5 shrink-0" />
)}
<span className="truncate">{preview.siteName}</span>
</div>
{preview.title && <div className="text-sm font-semibold text-main line-clamp-2 leading-snug">{preview.title}</div>}
{preview.description && <div className="text-xs text-muted truncate">{preview.description}</div>}
</div>
{image && <img src={image} alt="" className="w-16 sm:w-20 object-cover shrink-0 border-l border-border" />}
</a>
{canEdit && (
<button
type="button"
onClick={() => onHide(preview.title)}
title="Vorschau für diesen Link ausblenden"
aria-label="Vorschau ausblenden"
className="absolute top-0 right-0 sm:top-1 sm:right-1 w-11 h-11 sm:w-7 sm:h-7 flex items-center justify-center rounded-full bg-panel/70 text-muted hover:text-main"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
);
};
@@ -0,0 +1,109 @@
import React, { useMemo, useState } from 'react';
import { GitMerge, Search } from 'lucide-react';
import { FormSheet } from '../../components/FormSheet';
import { api } from '../../lib/api';
import { Document, MergeResult } from '../../lib/types';
interface MergeDialogProps {
docs: Document[];
sourceId: string;
/** Known after a drop in the sidebar; otherwise the user picks it here */
targetId?: string | null;
onClose: () => void;
onMerged: (result: MergeResult) => void;
}
const canWrite = (d: Document) => d.isOwner || d.permission === 'owner' || d.permission === 'write';
// Merging a note into another: pick the target (when not dropped onto one), confirm, merge. The source
// is appended to the target under a heading with its title and goes to the trash.
export const MergeDialog: React.FC<MergeDialogProps> = ({ docs, sourceId, targetId, onClose, onMerged }) => {
const [target, setTarget] = useState<string | null>(targetId || null);
const [query, setQuery] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const source = docs.find((d) => d.id === sourceId);
const targetDoc = docs.find((d) => d.id === target);
const candidates = useMemo(() => {
const q = query.trim().toLowerCase();
return docs
.filter((d) => d.id !== sourceId && !d.isArchived && canWrite(d))
.filter((d) => !q || (d.title || '').toLowerCase().includes(q))
.slice(0, 50);
}, [docs, sourceId, query]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!target) return;
setBusy(true);
setError(null);
try {
onMerged(await api.mergeDocuments(target, sourceId));
} catch (err: any) {
setError(err?.message || 'Zusammenführen fehlgeschlagen');
setBusy(false);
}
};
const title = (d?: Document) => `„${d?.title || 'Unbenannte Notiz'}“`;
return (
<FormSheet
title="Notizen zusammenführen"
icon={<GitMerge className="w-4 h-4" />}
onClose={onClose}
onSubmit={submit}
submitLabel={target ? 'Zusammenführen' : 'Ziel wählen'}
busy={busy || !target}
>
{targetDoc ? (
<div className="space-y-2 text-xs">
<p className="text-main">
Notiz {title(source)} in {title(targetDoc)} zusammenführen?
</p>
<p className="text-muted">
Die Punkte von {title(source)} werden als eigener Abschnitt an {title(targetDoc)} angehängt.{' '}
{title(source)} wandert danach in den Papierkorb; „Rückgängig“ macht beides wieder rückgängig.
</p>
{!targetId && (
<button type="button" onClick={() => setTarget(null)} className="text-accent hover:underline min-h-[44px] sm:min-h-0">
Anderes Ziel wählen
</button>
)}
</div>
) : (
<div className="space-y-2">
<p className="text-xs text-muted">In welche Notiz soll {title(source)} eingefügt werden?</p>
<div className="relative">
<Search className="w-3.5 h-3.5 text-faint absolute left-2.5 top-1/2 -translate-y-1/2" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Notiz suchen…"
className="w-full h-11 sm:h-9 pl-8 pr-2 input-field text-sm"
/>
</div>
<ul className="max-h-64 overflow-y-auto divide-y divide-border rounded-card border border-border">
{candidates.map((d) => (
<li key={d.id}>
<button
type="button"
onClick={() => setTarget(d.id)}
className="w-full min-h-[44px] sm:min-h-0 px-3 py-2 flex items-center gap-2 text-left text-xs text-main hover:bg-panel-elevated"
>
<span className="shrink-0">{d.icon || '📝'}</span>
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
{d.folderPath && <span className="text-[10px] text-faint truncate max-w-[40%]">{d.folderPath}</span>}
</button>
</li>
))}
{candidates.length === 0 && <li className="px-3 py-3 text-xs text-faint">Keine passende Notiz</li>}
</ul>
</div>
)}
{error && <p className="text-xs text-status-err">{error}</p>}
</FormSheet>
);
};
+294 -68
View File
@@ -20,29 +20,51 @@ import {
FileText,
Lock,
AlertTriangle,
Image as ImageIcon,
Mic,
AudioLines,
FileOutput,
} from 'lucide-react';
import { DocumentBlock, BlockType } from '../../lib/types';
import { LinkModal } from './LinkModal';
import { CodeBlockEditor, resolveLanguageId, isUnambiguousLanguageShortcut } from './CodeBlockEditor';
import { ImagePicker } from './ImagePicker';
import { ImageBlock } from './ImageBlock';
import { AudioRecorderPicker } from './AudioRecorderPicker';
import { AudioBlock } from './AudioBlock';
import { DictationBlock } from './DictationBlock';
import { currentVoiceConfig } from '../../lib/voice';
import { LinkPreviewCard } from './LinkPreviewCard';
import { firstBareUrl, hidePreviewContent, useLinkPreviewsEnabled } from '../../lib/linkPreview';
import { mediaContent, parseMediaContent } from '../../lib/media';
import { renderInlineMarkdown } from '../../lib/markdown';
import { api } from '../../lib/api';
import { safeHref } from '../../lib/safeUrl';
export interface SlashCommand {
type: BlockType | 'link';
type: BlockType | 'link' | 'dictation' | 'extract';
label: string;
desc: string;
icon: React.ReactNode;
/** Only offered in an empty bullet; the bullet turns into an inline picker first */
media?: boolean;
/** Further names that find the command (e.g. German ones) */
aliases?: string[];
}
const SLASH_COMMANDS: SlashCommand[] = [
{ type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: <ListTodo className="w-3.5 h-3.5" /> },
{ type: 'link', label: '/link', desc: 'Notiz verknüpfen oder Web-Link einfügen', icon: <LinkIcon className="w-3.5 h-3.5" /> },
{ type: 'link', label: '/link', desc: 'Notiz oder Weblink', icon: <LinkIcon className="w-3.5 h-3.5" /> },
{ type: 'h1', label: '/h1', desc: 'Große Überschrift', icon: <Heading1 className="w-3.5 h-3.5" /> },
{ type: 'h2', label: '/h2', desc: 'Abschnitts-Überschrift', icon: <Heading2 className="w-3.5 h-3.5" /> },
{ type: 'h3', label: '/h3', desc: 'Unterabschnitt', icon: <Heading3 className="w-3.5 h-3.5" /> },
{ type: 'code', label: '/code', desc: 'Code-Block mit Syntax', icon: <Code className="w-3.5 h-3.5" /> },
{ type: 'callout', label: '/callout', desc: 'Hinweisbox', icon: <MessageSquare className="w-3.5 h-3.5" /> },
{ type: 'divider', label: '/divider', desc: 'Trennlinie', icon: <Minus className="w-3.5 h-3.5" /> },
{ type: 'extract', label: '/extract', desc: 'Ast als eigene Notiz', icon: <FileOutput className="w-3.5 h-3.5" />, aliases: ['notiz', 'auslagern'] },
{ type: 'image', label: '/image', desc: 'Bilder hochladen oder verlinken', icon: <ImageIcon className="w-3.5 h-3.5" />, media: true, aliases: ['bild', 'foto'] },
{ type: 'audio', label: '/audio', desc: 'Sprachnotiz oder Audiodatei', icon: <Mic className="w-3.5 h-3.5" />, media: true, aliases: ['sprachnotiz'] },
{ type: 'dictation', label: '/diktat', desc: 'Sprechen statt tippen', icon: <AudioLines className="w-3.5 h-3.5" />, media: true, aliases: ['dictate'] },
];
export interface OutlinerBlockProps {
@@ -56,6 +78,8 @@ export interface OutlinerBlockProps {
canEdit: boolean;
levelOffset?: number;
onZoomBlock?: (blockId: string) => void;
/** Turns this bullet and its children into a note of its own */
onExtractBlock?: (blockId: string) => void;
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
onCreateBlockBelow: (afterBlockId: string, newContent?: string) => void;
onDeleteBlock: (blockId: string, deleteSubtree?: boolean) => void;
@@ -103,6 +127,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
canEdit,
levelOffset = 0,
onZoomBlock,
onExtractBlock,
onUpdateBlock,
onCreateBlockBelow,
onDeleteBlock,
@@ -133,6 +158,14 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
// A block someone else is editing can still get the cursor (read-only), e.g. to press Enter at its end
const canFocus = canEdit || (!!lockedBy && !!canAppendBelow);
const [slashSearch, setSlashSearch] = useState<string | null>(null);
// Inline picker an empty bullet turns into after /image (local only, nothing synced until done)
const [mediaPicker, setMediaPicker] = useState<'image' | 'audio' | 'dictation' | null>(null);
// Card above bullets with a plain web address (lib/linkPreview.ts)
const previewsOn = useLinkPreviewsEnabled();
const previewUrl =
previewsOn && !mediaPicker && ['bullet', 'text', 'paragraph', 'todo', 'h1', 'h2', 'h3'].includes(block.type || 'bullet')
? firstBareUrl(localContent)
: null;
const [slashIndex, setSlashIndex] = useState(0);
const [showLinkModal, setShowLinkModal] = useState<boolean>(false);
const [showTodoMenu, setShowTodoMenu] = useState<boolean>(false);
@@ -288,6 +321,20 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
adjustHeight();
}, [localContent, block.type, block.level, activeFocus, adjustHeight]);
// Turning the block into another type (slash menu, ``` or markdown shortcut) can swap the text field
// for a new one (e.g. the code editor); the cursor must stay in the block so typing goes on there
const refocusAfterTypeChangeRef = useRef(false);
useLayoutEffect(() => {
if (!refocusAfterTypeChangeRef.current) return;
refocusAfterTypeChangeRef.current = false;
const ta = textareaRef.current;
if (ta && document.activeElement !== ta) {
ta.focus();
const end = ta.value.length;
ta.setSelectionRange(end, end);
}
}, [block.type]);
useEffect(() => {
window.addEventListener('resize', adjustHeight);
// The field can be measured while hidden (phone layout shows the editor only after selecting a note);
@@ -313,22 +360,55 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
const filteredSlashCommands = useMemo(() => {
if (slashSearch === null) return [];
const q = slashSearch.toLowerCase();
const cmdQuery = q.startsWith('code ') ? 'code' : q;
// Media only in an otherwise empty bullet
const emptyBullet = localContent.replace(/\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim() === '';
return SLASH_COMMANDS.filter(
(c) => c.label.toLowerCase().includes(q) || c.desc.toLowerCase().includes(q)
(c) =>
(!c.media || emptyBullet) &&
(c.type !== 'dictation' || ['server', 'browser'].includes(currentVoiceConfig().mode)) &&
// The bullet's text becomes the title of the new note
(c.type !== 'extract' || (!emptyBullet && !!onExtractBlock)) &&
(c.label.toLowerCase().includes(cmdQuery) ||
c.desc.toLowerCase().includes(cmdQuery) ||
(c.aliases || []).some((a) => a.includes(cmdQuery)))
);
}, [slashSearch]);
}, [slashSearch, localContent, onExtractBlock]);
const applySlashCommand = (cmd: SlashCommand) => {
setSlashSearch(null);
const clean = localContent.replace(/\/([a-z0-9]*)$/i, '').trim();
const codeLangMatch = localContent.match(/\/code\s+([a-z0-9_+#.-]+)$/i);
const clean = localContent.replace(/\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim();
if (cmd.type === 'link') {
setLocalContent(clean);
setShowLinkModal(true);
return;
}
if (cmd.type === 'extract') {
setLocalContent(clean);
localContentRef.current = clean;
flushDebounce(clean);
onExtractBlock?.(block.id);
return;
}
if (cmd.media) {
// The typed "/image" must not stay in the synced text; the block type changes only after the upload
setLocalContent('');
localContentRef.current = '';
flushDebounce('');
setMediaPicker(cmd.type as 'image' | 'audio' | 'dictation');
return;
}
const langOverride =
cmd.type === 'code' && codeLangMatch
? resolveLanguageId(codeLangMatch[1]) || codeLangMatch[1].toLowerCase()
: undefined;
setLocalContent(clean);
localContentRef.current = clean;
if (cmd.type !== block.type) refocusAfterTypeChangeRef.current = true;
flushDebounce(clean, {
type: cmd.type,
type: cmd.type as BlockType, // media commands returned above
...(langOverride !== undefined ? { lang: langOverride } : {}),
checked: cmd.type === 'todo' ? Boolean(block.checked) : false,
});
};
@@ -348,45 +428,76 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
let val = e.target.value;
let typeOverride: BlockType | undefined;
let checkedOverride: boolean | undefined;
let langOverride: string | undefined;
// Quick markdown shortcuts
if (val.startsWith('[] ') || val.startsWith('[ ] ')) {
val = val.replace(/^\[\s?\]\s/, '');
typeOverride = 'todo';
checkedOverride = false;
} else if (val.startsWith('[x] ') || val.startsWith('[X] ')) {
val = val.replace(/^\[[xX]\]\s/, '');
typeOverride = 'todo';
checkedOverride = true;
} else if (val.startsWith('# ') && block.type !== 'h1') {
val = val.slice(2);
typeOverride = 'h1';
} else if (val.startsWith('## ') && block.type !== 'h2') {
val = val.slice(3);
typeOverride = 'h2';
} else if (val.startsWith('### ') && block.type !== 'h3') {
val = val.slice(4);
typeOverride = 'h3';
}
if (block.type !== 'code') {
// Quick markdown shortcuts
const fenceSpaceMatch = val.match(/^```([a-z0-9_+#.-]*)\s([\s\S]*)$/i);
const fenceDirectMatch = val.match(/^```([a-z0-9_+#.-]+)$/i);
const slashCodeSpaceMatch = val.match(/^\/code\s+([a-z0-9_+#.-]+)\s([\s\S]*)$/i);
const slashCodeDirectMatch = val.match(/^\/code\s+([a-z0-9_+#.-]+)$/i);
// Check for slash command invocation
const slashMatch = val.match(/\/([a-z0-9]*)$/i);
if (slashMatch) {
setSlashSearch(slashMatch[1]);
setSlashIndex(0);
if (fenceSpaceMatch) {
const rawLang = fenceSpaceMatch[1];
val = fenceSpaceMatch[2];
typeOverride = 'code';
langOverride = rawLang ? resolveLanguageId(rawLang) || rawLang.toLowerCase() : '';
} else if (fenceDirectMatch && isUnambiguousLanguageShortcut(fenceDirectMatch[1])) {
val = '';
typeOverride = 'code';
langOverride = resolveLanguageId(fenceDirectMatch[1]) || fenceDirectMatch[1].toLowerCase();
} else if (slashCodeSpaceMatch) {
const rawLang = slashCodeSpaceMatch[1];
val = slashCodeSpaceMatch[2];
typeOverride = 'code';
langOverride = resolveLanguageId(rawLang) || rawLang.toLowerCase();
} else if (slashCodeDirectMatch && isUnambiguousLanguageShortcut(slashCodeDirectMatch[1])) {
val = '';
typeOverride = 'code';
langOverride = resolveLanguageId(slashCodeDirectMatch[1]) || slashCodeDirectMatch[1].toLowerCase();
} else if (val.startsWith('[] ') || val.startsWith('[ ] ')) {
val = val.replace(/^\[\s?\]\s/, '');
typeOverride = 'todo';
checkedOverride = false;
} else if (val.startsWith('[x] ') || val.startsWith('[X] ')) {
val = val.replace(/^\[[xX]\]\s/, '');
typeOverride = 'todo';
checkedOverride = true;
} else if (val.startsWith('# ') && block.type !== 'h1') {
val = val.slice(2);
typeOverride = 'h1';
} else if (val.startsWith('## ') && block.type !== 'h2') {
val = val.slice(3);
typeOverride = 'h2';
} else if (val.startsWith('### ') && block.type !== 'h3') {
val = val.slice(4);
typeOverride = 'h3';
}
// Check for slash command invocation
const slashMatch = !typeOverride ? val.match(/\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i) : null;
if (slashMatch) {
setSlashSearch(slashMatch[1]);
setSlashIndex(0);
} else {
setSlashSearch(null);
}
} else {
setSlashSearch(null);
}
setLocalContent(val);
localContentRef.current = val;
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current);
}
if (typeOverride) {
refocusAfterTypeChangeRef.current = true;
flushDebounce(val, {
type: typeOverride,
...(langOverride !== undefined ? { lang: langOverride } : {}),
checked: checkedOverride ?? latestBlockRef.current.checked,
});
return;
@@ -533,6 +644,21 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
e.stopPropagation();
setSlashSearch(null);
const fenceMatch = localContent.match(/^```([a-z0-9_+#.-]*)$/i);
if (fenceMatch) {
const rawLang = fenceMatch[1];
const resolvedLang = rawLang ? resolveLanguageId(rawLang) || rawLang.toLowerCase() : '';
setLocalContent('');
localContentRef.current = '';
refocusAfterTypeChangeRef.current = true;
flushDebounce('', {
type: 'code',
lang: resolvedLang,
checked: false,
});
return;
}
const minLevel = levelOffset || 0;
if (localContent === '') {
if (Math.max(0, block.level || 0) > minLevel) {
@@ -645,12 +771,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
(t) =>
(t.startsWith('[[') && t.endsWith(']]')) ||
/^\[(.*?)\]\((https?:\/\/.*?)\)$/.test(t) ||
/^https?:\/\//.test(t)
(/^https?:\/\//.test(t) && t !== previewUrl)
);
return (
<div
className={`group relative flex items-start gap-1 py-1 px-1.5 rounded-card transition-all duration-300 ${
className={`group relative flex items-start gap-1 py-1 px-0.5 sm:px-1.5 rounded-card transition-all duration-300 ${
isDragTarget
? 'border-t-2 border-accent bg-accent/10'
: isTargeted
@@ -658,7 +784,10 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
: ''
} ${block.conflictOf ? 'border-l-2 border-status-warn bg-status-warn/5' : ''}`}
style={{
paddingLeft: `${visualLevel * 22 + 4}px`,
// Indent per level and room kept for text come from CSS (narrower on phones, see index.css);
// deep rows get wider than the screen and the note scrolls sideways instead of squeezing them
paddingLeft: `calc(var(--outline-indent) * ${visualLevel} + 4px)`,
minWidth: `calc(var(--outline-indent) * ${visualLevel} + var(--outline-min-text))`,
}}
onDragOver={(e) => onDragOverBlock?.(e, index)}
onDrop={() => onDropBlock?.(index)}
@@ -744,37 +873,116 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
{/* Main Block Content */}
<div className="flex-1 min-w-0 pr-1 relative min-h-[1.5rem]">
{block.type === 'divider' ? (
{previewUrl && (
<LinkPreviewCard
url={previewUrl}
canEdit={canEdit}
onHide={(title) => {
const content = hidePreviewContent(localContent, previewUrl, title);
setLocalContent(content);
localContentRef.current = content;
flushDebounce(content);
}}
/>
)}
{mediaPicker === 'image' ? (
<ImagePicker
docId={docId}
onDone={(media) => {
setMediaPicker(null);
const content = mediaContent('image', media.id);
setLocalContent(content);
localContentRef.current = content;
flushDebounce(content, { type: 'image' });
}}
onCancel={() => {
setMediaPicker(null);
requestAnimationFrame(() => textareaRef.current?.focus());
}}
/>
) : mediaPicker === 'dictation' ? (
<DictationBlock
onDone={(text) => {
setMediaPicker(null);
setLocalContent(text);
localContentRef.current = text;
flushDebounce(text);
requestAnimationFrame(() => {
const ta = textareaRef.current;
if (!ta) return;
ta.focus();
ta.setSelectionRange(ta.value.length, ta.value.length);
});
}}
onCancel={() => {
setMediaPicker(null);
requestAnimationFrame(() => textareaRef.current?.focus());
}}
/>
) : mediaPicker === 'audio' ? (
<AudioRecorderPicker
docId={docId}
onDone={(media) => {
setMediaPicker(null);
const content = mediaContent('audio', media.id);
setLocalContent(content);
localContentRef.current = content;
flushDebounce(content, { type: 'audio' });
}}
onCancel={() => {
setMediaPicker(null);
requestAnimationFrame(() => textareaRef.current?.focus());
}}
/>
) : block.type === 'audio' ? (
<AudioBlock
content={localContent}
canEdit={canEdit}
onCaptionChange={(caption) => {
const ref = parseMediaContent(localContent);
if (!ref) return;
const content = mediaContent('audio', ref.id, caption);
setLocalContent(content);
localContentRef.current = content;
flushDebounce(content);
}}
/>
) : block.type === 'image' ? (
<ImageBlock
content={localContent}
canEdit={canEdit}
onCaptionChange={(caption) => {
const ref = parseMediaContent(localContent);
if (!ref) return;
const content = mediaContent('image', ref.id, caption);
setLocalContent(content);
localContentRef.current = content;
flushDebounce(content);
}}
/>
) : block.type === 'divider' ? (
<div className="py-2">
<hr className="border-t border-border" />
</div>
) : block.type === 'code' ? (
<div className="bg-code border border-border rounded-btn p-2 my-0.5">
<div className="text-[10px] text-faint font-mono uppercase pb-1 flex justify-between">
<span>Code</span>
<button
type="button"
onClick={() => flushDebounce(localContent, { type: 'bullet' })}
className="hover:text-accent"
>
Als Text
</button>
</div>
<textarea
ref={(el) => {
textareaRef.current = el;
registerTextareaRef(block.id, el);
}}
rows={1}
value={localContent}
readOnly={!canEdit}
onFocus={handleFocus}
onBlur={handleBlur}
onChange={handleContentChange}
onKeyDown={handleKeyDown}
className="w-full resize-none font-mono text-xs bg-transparent text-status-ok border-0 focus:outline-none focus:ring-0 leading-relaxed"
/>
</div>
<CodeBlockEditor
block={block}
content={localContent}
canEdit={canEdit}
textareaRef={textareaRef}
registerTextareaRef={registerTextareaRef}
onChangeContent={(newContent) => {
setLocalContent(newContent);
localContentRef.current = newContent;
flushDebounce(newContent);
}}
onChangeLang={(lang) => flushDebounce(localContent, { lang })}
onConvertToText={() => flushDebounce(localContent, { type: 'bullet' })}
handleFocus={handleFocus}
handleBlur={handleBlur}
handleContentChange={handleContentChange}
handleKeyDown={handleKeyDown}
/>
) : block.type === 'callout' ? (
<div className="bg-panel-elevated border-l-2 border-accent p-2 rounded-r-btn my-0.5">
<textarea
@@ -1019,7 +1227,8 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
</a>
);
}
if (/^https?:\/\//.test(part) && safeHref(part)) {
// The preview card above already shows this address
if (/^https?:\/\//.test(part) && safeHref(part) && part !== previewUrl) {
return (
<a
key={idx}
@@ -1041,7 +1250,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
{/* Floating Slash Commands Menu */}
{slashSearch !== null && canEdit && (
<div className="absolute left-0 top-8 z-40 w-56 panel-surface shadow-panel border border-border rounded-card p-1">
<div className="absolute left-0 top-8 z-40 w-72 max-w-[calc(100vw-2rem)] panel-surface shadow-panel border border-border rounded-card p-1">
<div className="px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-faint border-b border-border mb-1">
Befehl auswählen
</div>
@@ -1060,11 +1269,11 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
: 'text-main hover:bg-panel-elevated'
}`}
>
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5 shrink-0">
{cmd.icon}
<span className="font-semibold">{cmd.label}</span>
<span className="font-semibold whitespace-nowrap">{cmd.label}</span>
</div>
<span className="text-[10px] opacity-75">{cmd.desc}</span>
<span className="ml-3 min-w-0 truncate text-right text-[10px] opacity-75">{cmd.desc}</span>
</button>
))}
{filteredSlashCommands.length === 0 && (
@@ -1077,11 +1286,24 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
)}
</div>
{/* Branch into its own note (desktop; phones use /extract) */}
{canEdit && onExtractBlock && localContent.trim() !== '' && !mediaPicker && (
<button
type="button"
onClick={() => onExtractBlock(block.id)}
className="hidden sm:block opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-accent rounded-btn transition-opacity shrink-0"
title="In eigene Notiz umwandeln (mit allen Unterpunkten)"
>
<FileOutput className="w-3.5 h-3.5" />
</button>
)}
{/* Zoom / Focus button on hover */}
<button
type="button"
onClick={() => onZoomBlock?.(block.id)}
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-accent rounded-btn transition-opacity shrink-0"
// Phones zoom by tapping the bullet; the button would only take width from the text there
className="hidden sm:block opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-accent rounded-btn transition-opacity shrink-0"
title="In diesen Ast hineinzoomen (Fokus-Modus)"
>
<Maximize2 className="w-3.5 h-3.5" />
@@ -1098,7 +1320,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
</span>
)}
{/* Delete button on hover (Trash2 icon) */}
{/* Delete button on hover (Trash2 icon); on phones only in the row being edited */}
{canEdit && (
<button
type="button"
@@ -1111,7 +1333,11 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
onDeleteBlockRef.current(block.id, false);
}
}}
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0 cursor-pointer"
className={
activeFocus
? 'opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0 cursor-pointer'
: 'hidden sm:block opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0 cursor-pointer'
}
title="Block löschen"
>
<Trash2 className="w-3.5 h-3.5" />
@@ -13,6 +13,7 @@ export interface OutlinerTreeProps {
canEdit: boolean;
levelOffset?: number;
onZoomBlock?: (blockId: string) => void;
onExtractBlock?: (blockId: string) => void;
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
onUpdateBlocks?: (updated: DocumentBlock[]) => void;
onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void;
@@ -37,6 +38,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
canEdit,
levelOffset = 0,
onZoomBlock,
onExtractBlock,
onUpdateBlock,
onUpdateBlocks,
onCreateBlock,
@@ -783,6 +785,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
onRedo={onRedo}
levelOffset={levelOffset}
onZoomBlock={onZoomBlock}
onExtractBlock={onExtractBlock}
onUpdateBlock={onUpdateBlock}
onCreateBlockBelow={handleCreateBlockBelow}
onPasteMultiLines={handlePasteMultiLines}
+224 -106
View File
@@ -27,6 +27,7 @@ import {
import {
Document,
DocumentBlock,
MergeResult,
DocShare,
SharedFolder,
TagDetails,
@@ -47,6 +48,7 @@ import { sendDocOps } from '../../lib/docSync/oneoff';
import { useMobileActions } from '../../context/MobileActionContext';
import { DocumentEditor, DocSaveStatus } from './DocumentEditor';
import { TrashView } from './TrashView';
import { MergeDialog } from './MergeDialog';
import { CountBadge } from '../../components/CountBadge';
import { extractHashtags } from '../../lib/hashtags';
@@ -87,6 +89,9 @@ interface FolderTreeItemProps {
handleShareFolderClick: (folderPath: string, existingSf?: SharedFolder) => void;
handleDeleteFolder: (folderPath: string) => void;
onSelectDoc: (id: string) => void;
// Dropping a note onto another one offers to merge them
docDropProps: (targetId: string) => React.HTMLAttributes<HTMLDivElement>;
mergeHoverId: string | null;
}
const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
@@ -106,6 +111,8 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
handleShareFolderClick,
handleDeleteFolder,
onSelectDoc,
docDropProps,
mergeHoverId,
}) => {
const hasContent = node.subfolders.length > 0 || node.docs.length > 0;
const isExpanded = Boolean(hasContent && expandedFolders[node.fullPath]);
@@ -234,6 +241,8 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
handleShareFolderClick={handleShareFolderClick}
handleDeleteFolder={handleDeleteFolder}
onSelectDoc={onSelectDoc}
docDropProps={docDropProps}
mergeHoverId={mergeHoverId}
/>
))}
@@ -243,10 +252,13 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
key={d.id}
draggable
onDragStart={(e) => handleDocDragStart(e, d.id)}
{...docDropProps(d.id)}
onClick={() => onSelectDoc(d.id)}
style={{ paddingLeft: `${(depth + 1) * 12 + 16}px` }}
className={`w-full flex items-center gap-2 pr-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
selectedDocId === d.id && subView === 'editor'
mergeHoverId === d.id
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
: selectedDocId === d.id && subView === 'editor'
? 'bg-panel-elevated text-main font-semibold border-border/80'
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
@@ -378,6 +390,20 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
// Drag and Drop state
const [draggingDocId, setDraggingDocId] = useState<string | null>(null);
// Merging notes: dialog (picker/confirmation) and a toast with undo afterwards
const [mergeDialog, setMergeDialog] = useState<{ sourceId: string; targetId?: string } | null>(null);
const [mergeHoverId, setMergeHoverId] = useState<string | null>(null);
const [toast, setToast] = useState<{ text: string; actions: { label: string; run: () => void }[] } | null>(null);
const toastTimerRef = useRef<number | null>(null);
const showToast = useCallback((text: string, actions: { label: string; run: () => void }[] = []) => {
setToast({ text, actions });
if (toastTimerRef.current !== null) window.clearTimeout(toastTimerRef.current);
toastTimerRef.current = window.setTimeout(() => setToast(null), actions.length ? 10000 : 4000);
}, []);
useEffect(() => () => {
if (toastTimerRef.current !== null) window.clearTimeout(toastTimerRef.current);
}, []);
const [draggingFolderPath, setDraggingFolderPath] = useState<string | null>(null);
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
@@ -674,6 +700,66 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setDraggingDocId(docId);
};
const requestMerge = (sourceId: string, targetId?: string) => {
const source = docs.find((d) => d.id === sourceId);
if (!source?.isOwner) {
showToast('Nur eigene Notizen lassen sich in eine andere einfügen');
return;
}
setMergeDialog({ sourceId, targetId });
};
// Dropping a dragged note onto another note (instead of a folder) offers to merge them
const docDropProps = (targetId: string): React.HTMLAttributes<HTMLDivElement> => ({
onDragOver: (e) => {
if (!draggingDocId || draggingDocId === targetId) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
if (mergeHoverId !== targetId) setMergeHoverId(targetId);
},
onDragLeave: () => setMergeHoverId((cur) => (cur === targetId ? null : cur)),
onDrop: (e) => {
const sourceId = e.dataTransfer.getData('application/mindsift-doc-id') || draggingDocId;
if (!sourceId || sourceId === targetId) return;
e.preventDefault();
e.stopPropagation();
setDraggingDocId(null);
setMergeHoverId(null);
setDragOverFolder(null);
requestMerge(sourceId, targetId);
},
});
const handleMerged = async (result: MergeResult) => {
setMergeDialog(null);
triggerHaptic(20);
setDocs((prev) => prev.filter((d) => d.id !== result.sourceId));
setSelectedDocId(result.targetId);
setTargetBlockId(null);
setSubView('editor');
setMobileShowDetail(true);
setEditorNonce((n) => n + 1);
reloadTrash();
showToast(`„${result.sourceTitle || 'Notiz'}“ wurde angehängt`, [
{
label: 'Rückgängig',
run: async () => {
try {
await api.undoMerge(result);
await loadWorkspaceData();
setSelectedDocId(result.sourceId);
setEditorNonce((n) => n + 1);
reloadTrash();
showToast('Zusammenführen rückgängig gemacht');
} catch (err: any) {
showToast(err?.message || 'Rückgängig fehlgeschlagen');
}
},
},
]);
};
const handleFolderDragStart = (e: React.DragEvent, path: string) => {
e.stopPropagation();
e.dataTransfer.setData('application/mindsift-folder-path', path);
@@ -911,7 +997,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
);
const rootDocs = useMemo(
() => docs.filter((d) => !d.folderPath),
() => docs.filter((d) => !d.folderPath && !d.isPinned),
[docs]
);
@@ -1330,114 +1416,113 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{/* Scrollable Navigation Tree */}
<div className="flex-1 overflow-y-auto p-2 space-y-3">
{/* Pinned Notes */}
{pinnedDocs.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>
<div className="px-2 mb-1 flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-faint">
<Pin className="w-3 h-3 text-status-warn" />
<span>Angeheftet</span>
</div>
<div className="space-y-0.5">
{pinnedDocs.map((d) => (
<div
key={`pin-${d.id}`}
draggable
onDragStart={(e) => handleDocDragStart(e, d.id)}
onClick={() => {
setSelectedDocId(d.id);
setSubView('editor');
setMobileShowDetail(true);
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
selectedDocId === d.id && subView === 'editor'
? 'bg-panel-elevated text-main font-semibold border-border/80'
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
>
<span>{d.icon || '📝'}</span>
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
</div>
))}
</div>
{/* Pinned Notes */}
{pinnedDocs.length > 0 && (
<div
className={`space-y-0.5 ${
folderTree.length > 0 || rootDocs.length > 0
? 'border-b border-border pb-1.5 mb-1.5'
: ''
}`}
>
{pinnedDocs.map((d) => (
<div
key={`pin-${d.id}`}
draggable
onDragStart={(e) => handleDocDragStart(e, d.id)}
{...docDropProps(d.id)}
onClick={() => {
setSelectedDocId(d.id);
setSubView('editor');
setMobileShowDetail(true);
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
mergeHoverId === d.id
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
: selectedDocId === d.id && subView === 'editor'
? 'bg-panel-elevated text-main font-semibold border-border/80'
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
>
<span className="shrink-0">{d.icon || '📝'}</span>
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
{d.sharedWith && d.sharedWith.length > 0 && (
<Users className="w-3 h-3 text-status-ok shrink-0" />
)}
<Pin className="w-3 h-3 text-muted shrink-0" />
</div>
))}
</div>
)}
{/* Folders and Root Documents */}
{(folderTree.length > 0 || rootDocs.length > 0) && (
<div className="space-y-0.5">
{folderTree.map((node) => (
<FolderTreeItem
key={node.fullPath}
node={node}
depth={0}
expandedFolders={expandedFolders}
setExpandedFolders={setExpandedFolders}
selectedDocId={selectedDocId}
subView={subView}
dragOverFolder={dragOverFolder}
setDragOverFolder={setDragOverFolder}
handleDropOnFolder={handleDropOnFolder}
handleFolderDragStart={handleFolderDragStart}
handleDocDragStart={handleDocDragStart}
handleCreateDocument={handleCreateDocument}
handleOpenNewFolder={handleOpenNewFolder}
handleShareFolderClick={handleShareFolderClick}
handleDeleteFolder={handleDeleteFolder}
onSelectDoc={(id) => {
setSelectedDocId(id);
setTargetBlockId(null);
setNewlyCreatedDocId(null);
setSubView('editor');
setMobileShowDetail(true);
}}
docDropProps={docDropProps}
mergeHoverId={mergeHoverId}
/>
))}
{rootDocs.map((d) => (
<div
key={d.id}
draggable
onDragStart={(e) => handleDocDragStart(e, d.id)}
{...docDropProps(d.id)}
onClick={() => {
setSelectedDocId(d.id);
setSubView('editor');
setMobileShowDetail(true);
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
mergeHoverId === d.id
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
: selectedDocId === d.id && subView === 'editor'
? 'bg-panel-elevated text-main font-semibold border-border/80'
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
>
<span className="shrink-0">{d.icon || '📝'}</span>
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
{d.sharedWith && d.sharedWith.length > 0 && (
<Users className="w-3 h-3 text-status-ok shrink-0" />
)}
</div>
))}
</div>
)}
</div>
)}
{/* Folders Tree with Recursive Hierarchical Nesting */}
{folderTree.length > 0 && (
<div>
<div className="px-2 mb-1 text-[10px] font-bold uppercase tracking-wider text-faint">
Ordner
</div>
<div className="space-y-0.5">
{folderTree.map((node) => (
<FolderTreeItem
key={node.fullPath}
node={node}
depth={0}
expandedFolders={expandedFolders}
setExpandedFolders={setExpandedFolders}
selectedDocId={selectedDocId}
subView={subView}
dragOverFolder={dragOverFolder}
setDragOverFolder={setDragOverFolder}
handleDropOnFolder={handleDropOnFolder}
handleFolderDragStart={handleFolderDragStart}
handleDocDragStart={handleDocDragStart}
handleCreateDocument={handleCreateDocument}
handleOpenNewFolder={handleOpenNewFolder}
handleShareFolderClick={handleShareFolderClick}
handleDeleteFolder={handleDeleteFolder}
onSelectDoc={(id) => {
setSelectedDocId(id);
setTargetBlockId(null);
setNewlyCreatedDocId(null);
setSubView('editor');
setMobileShowDetail(true);
}}
/>
))}
</div>
</div>
)}
{/* Root Documents */}
<div>
<div className="px-2 mb-1 text-[10px] font-bold uppercase tracking-wider text-faint">
Notizen (Hauptebene)
</div>
{rootDocs.length === 0 ? (
<div className="px-2 py-2 text-xs text-faint italic">
Keine Notizen auf der Hauptebene.
</div>
) : (
<div className="space-y-0.5">
{rootDocs.map((d) => (
<div
key={d.id}
draggable
onDragStart={(e) => handleDocDragStart(e, d.id)}
onClick={() => {
setSelectedDocId(d.id);
setSubView('editor');
setMobileShowDetail(true);
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
selectedDocId === d.id && subView === 'editor'
? 'bg-panel-elevated text-main font-semibold border-border/80'
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
>
<span>{d.icon || '📝'}</span>
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
{d.sharedWith && d.sharedWith.length > 0 && (
<Users className="w-3 h-3 text-status-ok shrink-0" />
)}
</div>
))}
</div>
)}
</div>
{/* Bottom drop target for moving to root */}
{(draggingDocId || draggingFolderPath) && (
<div
@@ -1536,6 +1621,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
onSelectTag={handleSelectTag}
onWikiLinkClick={handleWikiLinkClick}
onOpenDocBlock={handleJumpToDocBlock}
onRequestMerge={(d) => setMergeDialog({ sourceId: d.id })}
onBackMobile={() => setMobileShowDetail(false)}
onOpenTask={onOpenTask}
onSaveStatusChange={(status, err) => {
@@ -2260,6 +2346,38 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</FormSheet>
)}
{mergeDialog && (
<MergeDialog
docs={docs}
sourceId={mergeDialog.sourceId}
targetId={mergeDialog.targetId}
onClose={() => setMergeDialog(null)}
onMerged={(r) => void handleMerged(r)}
/>
)}
{toast && (
<div
role="status"
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] sm:bottom-6 z-50 max-w-[calc(100vw-1.5rem)] px-3 py-2 rounded-card text-xs text-main bg-panel-elevated border border-border shadow-panel flex items-center gap-3"
>
<span>{toast.text}</span>
{toast.actions.map((a) => (
<button
key={a.label}
type="button"
onClick={() => {
setToast(null);
a.run();
}}
className="font-semibold text-accent hover:underline min-h-[44px] sm:min-h-0 shrink-0"
>
{a.label}
</button>
))}
</div>
)}
{/* Unified Sharing Modal (Strictly Read-Only for Docs and Folders) */}
{(shareModalDoc || shareModalFolder) && (
<UnifiedShareModal
@@ -0,0 +1,212 @@
import React, { useState } from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, fireEvent, screen, waitFor, act } from '@testing-library/react';
import { CodeBlockEditor, loadPrismLanguage } from '../CodeBlockEditor';
import { OutlinerBlock } from '../OutlinerBlock';
import { DocumentBlock } from '../../../lib/types';
function makeCodeBlock(overrides: Partial<DocumentBlock> = {}): DocumentBlock {
return {
id: 'code-1',
type: 'code',
content: 'name: mindsift\nport: 8080',
level: 0,
position: 1000,
...overrides,
};
}
function renderOutlinerCodeBlock(
initialBlock: DocumentBlock,
props: Partial<React.ComponentProps<typeof OutlinerBlock>> = {}
) {
const updates: DocumentBlock[] = [];
const indentCalls: Array<[string, 1 | -1]> = [];
const Wrapper: React.FC = () => {
const [blk, setBlk] = useState<DocumentBlock>(initialBlock);
return (
<OutlinerBlock
docId="doc-1"
block={blk}
index={0}
isLast
maxAllowedLevel={1}
hasChildren={false}
isFocused={false}
canEdit={props.canEdit ?? true}
onUpdateBlock={(updated, immediate) => {
updates.push(updated);
setBlk(updated);
props.onUpdateBlock?.(updated, immediate);
}}
onCreateBlockBelow={props.onCreateBlockBelow || (() => {})}
onDeleteBlock={props.onDeleteBlock || (() => {})}
onIndentBlock={(id, delta) => {
indentCalls.push([id, delta]);
props.onIndentBlock?.(id, delta);
}}
onNavigateFocus={props.onNavigateFocus || (() => {})}
registerTextareaRef={props.registerTextareaRef || (() => {})}
/>
);
};
const utils = render(<Wrapper />);
return { ...utils, updates, indentCalls };
}
describe('CodeBlockEditor', () => {
beforeEach(() => {
vi.useRealTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('calls update with lang when selecting a language and hides the picker for readers', () => {
const { container, updates, unmount } = renderOutlinerCodeBlock(makeCodeBlock({ lang: undefined }));
const select = container.querySelector('select') as HTMLSelectElement;
expect(select).toBeTruthy();
expect(select.value).toBe('');
fireEvent.change(select, { target: { value: 'yaml' } });
expect(updates).toHaveLength(1);
expect(updates[0].lang).toBe('yaml');
expect(select.value).toBe('yaml');
// "Als Text" converts the code block back to a bullet
fireEvent.click(screen.getByRole('button', { name: 'Als Text' }));
expect(updates[updates.length - 1]?.type).toBe('bullet');
unmount();
// Reader view (!canEdit): shows plain text instead of <select> and hides "Als Text"
const readerView = render(
<CodeBlockEditor block={makeCodeBlock({ lang: 'yaml' })} canEdit={false} />
);
expect(readerView.container.querySelector('select')).toBeNull();
expect(readerView.getByText('yaml')).toBeTruthy();
expect(readerView.queryByText('Als Text')).toBeNull();
});
it('copies the code content to the clipboard and shows "Kopiert!" for 1.5s', () => {
vi.useFakeTimers();
const writeTextMock = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: writeTextMock },
configurable: true,
writable: true,
});
const block = makeCodeBlock({ content: 'echo "hello world"' });
render(<CodeBlockEditor block={block} content={block.content} canEdit />);
const copyBtn = screen.getByRole('button', { name: /Kopieren/i });
fireEvent.click(copyBtn);
expect(writeTextMock).toHaveBeenCalledWith('echo "hello world"');
expect(screen.getByText('Kopiert!')).toBeTruthy();
act(() => {
vi.advanceTimersByTime(1500);
});
expect(screen.queryByText('Kopiert!')).toBeNull();
expect(screen.getByText('Kopieren')).toBeTruthy();
});
it('indents by 2 spaces on Tab and outdents on Shift+Tab without indenting the bullet', () => {
const { container, updates, indentCalls } = renderOutlinerCodeBlock(
makeCodeBlock({ content: 'key: value', lang: 'yaml' })
);
const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
textarea.focus();
textarea.setSelectionRange(0, 0);
fireEvent.keyDown(textarea, { key: 'Tab' });
expect(textarea.value).toBe(' key: value');
expect(updates[updates.length - 1]?.content).toBe(' key: value');
expect(indentCalls).toHaveLength(0);
fireEvent.keyDown(textarea, { key: 'Tab', shiftKey: true });
expect(textarea.value).toBe('key: value');
expect(updates[updates.length - 1]?.content).toBe('key: value');
expect(indentCalls).toHaveLength(0);
});
it('indents and outdents multiple selected lines on Tab and Shift+Tab', () => {
const { container } = renderOutlinerCodeBlock(
makeCodeBlock({ content: 'first: 1\nsecond: 2', lang: 'yaml' })
);
const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
textarea.focus();
textarea.setSelectionRange(0, textarea.value.length);
fireEvent.keyDown(textarea, { key: 'Tab' });
expect(textarea.value).toBe(' first: 1\n second: 2');
fireEvent.keyDown(textarea, { key: 'Tab', shiftKey: true });
expect(textarea.value).toBe('first: 1\nsecond: 2');
});
it('renders Prism .token spans for yaml and escapes HTML tags like <script>', async () => {
const code = 'title: "<script>alert(1)</script>"\nenabled: true\ncount: 42';
const block = makeCodeBlock({ content: code, lang: 'yaml' });
const { container } = render(<CodeBlockEditor block={block} content={code} canEdit />);
await waitFor(() => {
expect(container.querySelectorAll('.token').length).toBeGreaterThan(0);
});
const pre = container.querySelector('pre') as HTMLPreElement;
const codeEl = pre.querySelector('code') as HTMLElement;
expect(pre.getAttribute('aria-hidden')).toBe('true');
expect(codeEl.querySelectorAll('.token').length).toBeGreaterThan(0);
// Ensure no real <script> element was injected into the DOM and raw HTML is escaped
expect(pre.querySelector('script')).toBeNull();
expect(codeEl.innerHTML).toContain('&lt;script&gt;');
expect(pre.textContent).toContain('<script>alert(1)</script>');
});
it('loads dependent grammars in the right order (tsx, cpp, php, scala)', async () => {
expect(await loadPrismLanguage('tsx')).toBe(true);
expect(await loadPrismLanguage('cpp')).toBe(true);
expect(await loadPrismLanguage('php')).toBe(true);
expect(await loadPrismLanguage('scala')).toBe(true);
});
it('sets lang directly when typing /code yaml or ```yaml in an empty bullet block', () => {
const bulletBlock: DocumentBlock = {
id: 'b-1',
type: 'bullet',
content: '',
level: 0,
position: 1000,
};
const { container, updates } = renderOutlinerCodeBlock(bulletBlock);
const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
fireEvent.change(textarea, { target: { value: '/code yaml' } });
expect(updates[updates.length - 1]?.type).toBe('code');
expect(updates[updates.length - 1]?.lang).toBe('yaml');
expect(updates[updates.length - 1]?.content).toBe('');
// Also test ```json shortcut
const bulletBlock2: DocumentBlock = {
id: 'b-2',
type: 'bullet',
content: '',
level: 0,
position: 2000,
};
const res2 = renderOutlinerCodeBlock(bulletBlock2);
const ta2 = res2.container.querySelector('textarea') as HTMLTextAreaElement;
fireEvent.change(ta2, { target: { value: '```json' } });
expect(res2.updates[res2.updates.length - 1]?.type).toBe('code');
expect(res2.updates[res2.updates.length - 1]?.lang).toBe('json');
});
});
@@ -0,0 +1,165 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { WorkspaceView } from '../WorkspaceView';
import { api } from '../../../lib/api';
import { Document } from '../../../lib/types';
vi.mock('../DocumentEditor', () => ({
DocumentEditor: () => <div data-testid="document-editor">DocumentEditor</div>,
}));
vi.mock('../../../lib/api', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../lib/api')>();
return {
...actual,
api: {
...actual.api,
getDocuments: vi.fn(),
getFolders: vi.fn(),
getSharedFolders: vi.fn(),
getWorkspaceTags: vi.fn(),
getTrash: vi.fn(),
},
onWebSocketMessage: vi.fn(() => () => {}),
triggerHaptic: vi.fn(),
};
});
const makeDoc = (overrides: Partial<Document> & { id: string; title: string }): Document => ({
id: overrides.id,
userId: 'u1',
title: overrides.title,
icon: '📝',
folderPath: '',
content: '',
blocks: [],
tags: [],
isPinned: false,
isArchived: false,
createdAt: '2026-10-29T10:00:00Z',
updatedAt: '2026-10-29T10:00:00Z',
...overrides,
});
describe('WorkspaceView sidebar explorer', () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.mocked(api.getFolders).mockResolvedValue([]);
vi.mocked(api.getSharedFolders).mockResolvedValue([]);
vi.mocked(api.getWorkspaceTags).mockResolvedValue([]);
vi.mocked(api.getTrash).mockResolvedValue([]);
});
it('renders empty state "Noch keine Notizen" when there are no notes and no folders', async () => {
vi.mocked(api.getDocuments).mockResolvedValue([]);
vi.mocked(api.getFolders).mockResolvedValue([]);
render(<WorkspaceView onStatsChange={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText('Noch keine Notizen')).toBeTruthy();
});
expect(screen.queryByText('Angeheftet')).toBeNull();
expect(screen.queryByText('Ordner')).toBeNull();
expect(screen.queryByText('Notizen (Hauptebene)')).toBeNull();
expect(screen.queryByText('Keine Notizen auf der Hauptebene.')).toBeNull();
});
it('renders pinned docs, folders, and root docs without headings', async () => {
const docs: Document[] = [
makeDoc({
id: 'doc_pin_root',
title: 'Wichtige Pinned Notiz',
icon: '⭐',
isPinned: true,
}),
makeDoc({
id: 'doc_root',
title: 'Normale Root Notiz',
icon: '📝',
}),
makeDoc({
id: 'doc_in_folder',
title: 'Projekt Plan',
icon: '📄',
folderPath: 'Projekte',
}),
];
vi.mocked(api.getDocuments).mockResolvedValue(docs);
vi.mocked(api.getFolders).mockResolvedValue(['Projekte']);
render(<WorkspaceView onStatsChange={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText('Wichtige Pinned Notiz')).toBeTruthy();
expect(screen.getByText('Normale Root Notiz')).toBeTruthy();
expect(screen.getByText('Projekte')).toBeTruthy();
});
// Ensure no old headings are rendered
expect(screen.queryByText('Angeheftet')).toBeNull();
expect(screen.queryByText('Ordner')).toBeNull();
expect(screen.queryByText('Notizen (Hauptebene)')).toBeNull();
expect(screen.queryByText('Keine Notizen auf der Hauptebene.')).toBeNull();
// The pinned root doc appears only once
const pinnedMatches = screen.getAllByText('Wichtige Pinned Notiz');
expect(pinnedMatches.length).toBe(1);
// Pinned container has separator when items follow
const pinnedItem = screen.getByText('Wichtige Pinned Notiz').closest('div[draggable="true"]');
const pinnedContainer = pinnedItem?.parentElement;
expect(pinnedContainer?.className).toContain('border-b border-border pb-1.5 mb-1.5');
});
it('does not render separator under pinned notes when nothing follows', async () => {
const docs: Document[] = [
makeDoc({
id: 'doc_pin_only',
title: 'Einzige Notiz Pinned',
icon: '⭐',
isPinned: true,
}),
];
vi.mocked(api.getDocuments).mockResolvedValue(docs);
vi.mocked(api.getFolders).mockResolvedValue([]);
render(<WorkspaceView onStatsChange={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText('Einzige Notiz Pinned')).toBeTruthy();
});
const pinnedItem = screen.getByText('Einzige Notiz Pinned').closest('div[draggable="true"]');
const pinnedContainer = pinnedItem?.parentElement;
expect(pinnedContainer?.className).not.toContain('border-b');
});
it('keeps pinned note in folder visible in its folder', async () => {
const docs: Document[] = [
makeDoc({
id: 'doc_pin_in_folder',
title: 'Pinned im Ordner',
icon: '📌',
folderPath: 'Team',
isPinned: true,
}),
];
vi.mocked(api.getDocuments).mockResolvedValue(docs);
vi.mocked(api.getFolders).mockResolvedValue(['Team']);
render(<WorkspaceView onStatsChange={vi.fn()} />);
await waitFor(() => {
// In pinned list
expect(screen.getByText('Pinned im Ordner')).toBeTruthy();
// Folder exists
expect(screen.getByText('Team')).toBeTruthy();
});
});
});
+90 -33
View File
@@ -411,62 +411,106 @@ body {
color: var(--text-main);
}
/* Prism Syntax Highlighting Tokens */
/* Prism Syntax Highlighting Tokens (VS Code Dark+ / Light+ via Theme Variables) */
:root {
--code-keyword: #569cd6;
--code-string: #ce9178;
--code-number: #b5cea8;
--code-comment: #6a9955;
--code-property: #9cdcfe;
--code-function: #dcdcaa;
--code-type: #4ec9b0;
--code-punctuation: #d4d4d4;
--code-variable: #9cdcfe;
}
:root:not(.dark),
:root[data-theme="light"] {
--code-keyword: #0000ff;
--code-string: #a31515;
--code-number: #098658;
--code-comment: #008000;
--code-property: #0451a5;
--code-function: #795e26;
--code-type: #267f99;
--code-punctuation: #3b3b3b;
--code-variable: #001080;
}
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: #71717a;
color: var(--code-comment);
font-style: italic;
}
.token.punctuation {
color: #94a3b8;
.token.punctuation,
.token.operator {
color: var(--code-punctuation);
}
.token.property,
.token.tag,
.token.boolean,
.token.key,
.token.attr-name {
color: var(--code-property);
}
.token.string,
.token.char,
.token.attr-value,
.token.scalar,
.token.inserted,
.language-css .token.string,
.style .token.string {
color: var(--code-string);
}
.token.number,
.token.constant,
.token.symbol,
.token.deleted {
color: #f43f5e;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
color: #10b981;
}
.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.style .token.string {
color: #38bdf8;
.token.datetime {
color: var(--code-number);
}
.token.keyword,
.token.boolean,
.token.null,
.token.atrule,
.token.attr-value,
.token.keyword {
color: #818cf8;
font-weight: 600;
.token.directive,
.token.tag,
.token.deleted {
color: var(--code-keyword);
}
.token.key.atrule {
color: var(--code-property);
}
.token.function,
.token.class-name {
color: #fbbf24;
.token.function-variable {
color: var(--code-function);
}
.token.class-name,
.token.type,
.token.builtin,
.token.namespace {
color: var(--code-type);
}
.token.variable,
.token.regex,
.token.important,
.token.variable {
color: #f97316;
.token.selector,
.token.entity,
.token.url {
color: var(--code-variable);
}
.token.boolean.important,
.token.null.important {
color: var(--code-keyword);
}
}
@@ -505,3 +549,16 @@ fieldset:disabled :is(button, input, select, textarea) {
opacity: 0.5;
cursor: not-allowed;
}
/* 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. */
:root {
--outline-indent: 14px;
--outline-min-text: 14rem;
}
@media (min-width: 640px) {
:root {
--outline-indent: 22px;
--outline-min-text: 0px;
}
}
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { firstBareUrl, hidePreviewContent } from '../linkPreview';
describe('link preview helpers', () => {
it('finds the first plain https address', () => {
expect(firstBareUrl('https://example.com/a?b=1')).toBe('https://example.com/a?b=1');
expect(firstBareUrl('Rezept: https://koch.de/spaetzle, lecker!')).toBe('https://koch.de/spaetzle');
expect(firstBareUrl('siehe (https://x.de/p).')).toBeNull(); // inside brackets: not a plain address
expect(firstBareUrl('[Kochseite](https://koch.de)')).toBeNull();
expect(firstBareUrl('http://unsicher.de')).toBeNull();
expect(firstBareUrl('nur Text')).toBeNull();
});
it('hides a preview by turning the address into a named link', () => {
expect(hidePreviewContent('Lesen: https://koch.de/x', 'https://koch.de/x', 'Käsespätzle [Rezept]')).toBe(
'Lesen: [Käsespätzle Rezept](https://koch.de/x)'
);
expect(hidePreviewContent('https://koch.de/x', 'https://koch.de/x', '')).toBe('[koch.de](https://koch.de/x)');
expect(firstBareUrl(hidePreviewContent('https://koch.de/x', 'https://koch.de/x', 'T'))).toBeNull();
});
});
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { mediaContent, parseMediaContent } from '../media';
describe('media content format', () => {
it('round-trips captions, also with brackets and backslashes', () => {
for (const caption of ['', 'Urlaub 2026', 'Plan [Entwurf]', 'C:\\Fotos\\x', 'zwei\nZeilen']) {
const content = mediaContent('image', 'med_0123abcd', caption);
expect(content.startsWith('![')).toBe(true);
expect(parseMediaContent(content)).toEqual({ id: 'med_0123abcd', caption: caption.replace(/\n/g, ' ') });
}
expect(mediaContent('audio', 'med_ff', 'Sprachnotiz')).toBe('[Sprachnotiz](media:med_ff)');
});
it('ignores anything that is not a media reference', () => {
expect(parseMediaContent('![x](https://example.com/a.png)')).toBeNull();
expect(parseMediaContent('Text ![x](media:med_aa)')).toBeNull();
expect(parseMediaContent('![x](media:../etc/passwd)')).toBeNull();
});
});
@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { act, renderHook, waitFor } from '@testing-library/react';
const getAuthStatus = vi.fn();
const transcribe = vi.fn();
vi.mock('../api', () => ({ api: { getAuthStatus: (...a: any[]) => getAuthStatus(...a), transcribe: (...a: any[]) => transcribe(...a) } }));
import { useVoiceInput } from '../useVoiceInput';
import { resetVoiceConfig } from '../voice';
class FakeMediaRecorder {
static isTypeSupported = (t: string) => t === 'audio/webm;codecs=opus';
state: 'inactive' | 'recording' = 'inactive';
mimeType = 'audio/webm;codecs=opus';
ondataavailable: ((e: { data: Blob }) => void) | null = null;
onstop: (() => void) | null = null;
constructor(public stream: any) {}
start() {
this.state = 'recording';
}
stop() {
this.state = 'inactive';
this.ondataavailable?.({ data: new Blob(['audio'], { type: this.mimeType }) });
this.onstop?.();
}
}
const stopTrack = vi.fn();
beforeEach(() => {
resetVoiceConfig();
getAuthStatus.mockReset();
transcribe.mockReset();
stopTrack.mockReset();
(window as any).MediaRecorder = FakeMediaRecorder;
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: { getUserMedia: vi.fn(async () => ({ getTracks: () => [{ stop: stopTrack }] })) },
});
});
afterEach(() => {
delete (window as any).MediaRecorder;
delete (window as any).SpeechRecognition;
vi.useRealTimers();
});
describe('useVoiceInput', () => {
it('server mode: records, sends the audio to the own server and returns its text', async () => {
getAuthStatus.mockResolvedValue({ voice: { mode: 'server', maxSeconds: 600 } });
transcribe.mockResolvedValue('Milch kaufen');
const onText = vi.fn();
const { result } = renderHook(() => useVoiceInput({ onText }));
await waitFor(() => expect(result.current.mode).toBe('server'));
expect(result.current.available).toBe(true);
await act(async () => {
await result.current.start();
});
expect(result.current.state).toBe('listening');
await act(async () => {
await result.current.stop();
});
expect(transcribe).toHaveBeenCalledTimes(1);
expect((transcribe.mock.calls[0][0] as Blob).type).toBe('audio/webm;codecs=opus');
expect(onText).toHaveBeenCalledWith('Milch kaufen', true);
expect(result.current.state).toBe('idle');
expect(stopTrack).toHaveBeenCalled(); // microphone released
});
it('server mode: cancel throws the recording away', async () => {
getAuthStatus.mockResolvedValue({ voice: { mode: 'server', maxSeconds: 600 } });
const onText = vi.fn();
const { result } = renderHook(() => useVoiceInput({ onText }));
await waitFor(() => expect(result.current.mode).toBe('server'));
await act(async () => {
await result.current.start();
});
act(() => result.current.cancel());
expect(transcribe).not.toHaveBeenCalled();
expect(onText).not.toHaveBeenCalled();
expect(result.current.state).toBe('idle');
});
it('server mode: a failing server reports an error instead of text', async () => {
getAuthStatus.mockResolvedValue({ voice: { mode: 'server', maxSeconds: 600 } });
transcribe.mockRejectedValue(new Error('Spracherkennung fehlgeschlagen'));
const onText = vi.fn();
const onError = vi.fn();
const { result } = renderHook(() => useVoiceInput({ onText, onError }));
await waitFor(() => expect(result.current.mode).toBe('server'));
await act(async () => {
await result.current.start();
});
await act(async () => {
await result.current.stop();
});
expect(onError).toHaveBeenCalledWith('Spracherkennung fehlgeschlagen');
expect(onText).not.toHaveBeenCalled();
expect(result.current.state).toBe('idle');
});
it('server mode: stops by itself after the maximum length', async () => {
getAuthStatus.mockResolvedValue({ voice: { mode: 'server', maxSeconds: 5 } });
transcribe.mockResolvedValue('Zu lang');
const onText = vi.fn();
const { result } = renderHook(() => useVoiceInput({ onText }));
await waitFor(() => expect(result.current.mode).toBe('server'));
vi.useFakeTimers();
await act(async () => {
await result.current.start();
});
await act(async () => {
vi.advanceTimersByTime(5000);
});
vi.useRealTimers();
await waitFor(() => expect(onText).toHaveBeenCalledWith('Zu lang', true));
});
it('hides the microphone when switched off, and without browser recognition', async () => {
getAuthStatus.mockResolvedValue({ voice: { mode: 'off', maxSeconds: 600 } });
const off = renderHook(() => useVoiceInput({ onText: vi.fn() }));
await waitFor(() => expect(off.result.current.mode).toBe('off'));
expect(off.result.current.available).toBe(false);
resetVoiceConfig();
getAuthStatus.mockResolvedValue({ voice: { mode: 'browser', maxSeconds: 600 } });
const none = renderHook(() => useVoiceInput({ onText: vi.fn() }));
await waitFor(() => expect(none.result.current.mode).toBe('unsupported'));
expect(none.result.current.available).toBe(false);
});
});
+45
View File
@@ -35,6 +35,8 @@ import {
TagSummary,
User,
UserInvite,
VoiceAdminStatus,
MergeResult,
WSMessage,
} from './types';
import { extractHashtags } from './hashtags';
@@ -707,6 +709,29 @@ export const api = {
return request<TrashedDocument[]>('/api/v1/documents/trash');
},
/** Turns a bullet and its children into a note of its own; the bullet becomes a [[link]]. */
async extractBranch(docId: string, blockId: string, clientId: string): Promise<Document> {
return request<Document>(`/api/v1/documents/${docId}/extract`, {
method: 'POST',
body: JSON.stringify({ blockId, clientId }),
});
},
/** Appends the source note to the target; the source goes to the trash. */
async mergeDocuments(targetId: string, sourceId: string): Promise<MergeResult> {
return request<MergeResult>(`/api/v1/documents/${targetId}/merge`, {
method: 'POST',
body: JSON.stringify({ sourceId }),
});
},
async undoMerge(m: MergeResult): Promise<void> {
await request(`/api/v1/documents/${m.targetId}/merge/undo`, {
method: 'POST',
body: JSON.stringify({ sourceId: m.sourceId, headingId: m.headingId }),
});
},
async restoreDocument(id: string): Promise<void> {
await request(`/api/v1/documents/${id}/restore`, { method: 'POST' });
},
@@ -1047,6 +1072,26 @@ export const api = {
await request(`/api/v1/admin/invites/${id}`, { method: 'DELETE' });
},
async adminVoiceStatus(): Promise<VoiceAdminStatus> {
return request<VoiceAdminStatus>('/api/v1/admin/voice');
},
// Sends a recording to the own Whisper server (voice mode "server") and returns the text
async transcribe(audio: Blob): Promise<string> {
const token = getToken();
const ext = audio.type.includes('mp4') ? 'm4a' : audio.type.includes('ogg') ? 'ogg' : 'webm';
const form = new FormData();
form.append('file', audio, `aufnahme.${ext}`);
const res = await fetch('/api/v1/voice/transcribe', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Spracherkennung fehlgeschlagen (HTTP ${res.status})`);
return data.text || '';
},
async adminGetSettings(): Promise<SystemSettings> {
return request<SystemSettings>('/api/v1/admin/settings');
},
@@ -335,6 +335,29 @@ describe('DocSyncEngine', () => {
expect(contents(a)).toEqual(expect.arrayContaining(['B hat geändert', 'Start + A']));
});
it('typing in a rhythm (several edits per sync) causes no conflict with oneself', async () => {
const server = new FakeServer();
const net = new FakeNet(server);
const engine = await startEngine(net);
engine.focusBlock('b1');
// Pauses longer than the field's debounce but shorter than the sync debounce: several edits per sync
for (const text of ['S', 'Sc', 'Sch']) edit(engine, (b) => [{ ...b[0], content: text }]);
await engine.syncNow();
edit(engine, (b) => [{ ...b[0], content: 'Schr' }]);
edit(engine, (b) => [{ ...b[0], content: 'Schre' }]);
// More typing while the request is on the wire
net.duringRequest = () => edit(engine, (b) => [{ ...b[0], content: 'Schrei' }]);
await engine.syncNow();
edit(engine, (b) => [{ ...b[0], content: 'Schreib' }]);
await engine.syncNow();
edit(engine, (b) => [{ ...b[0], content: 'Schreibe' }]);
await engine.syncNow();
expect(engine.getState().conflicts).toEqual([]);
expect(serverContents(server)).toEqual(['Schreibe']);
expect(contents(engine)).toEqual(['Schreibe']);
});
it('viewers cannot change anything', async () => {
const server = new FakeServer();
const viewer = await startEngine(new FakeNet(server, 'viewer'));
+4 -1
View File
@@ -462,7 +462,10 @@ export class DocSyncEngine {
private recompute(): void {
const v = cloneDoc(this.confirmed);
for (const op of [...(this.inflight || []), ...this.pending]) {
// Pending ops count the way they will be sent: formBatch folds all text changes of a block into one,
// so the server raises its version once. Applying every keystroke here would number local versions
// ahead of the server, and the next edit of the focused block would conflict with the user's own text.
for (const op of [...(this.inflight || []), ...formBatch(this.pending)]) {
const local = { ...op };
if (local.kind === 'set_content') local.baseVersion = v.blocks.get(local.blockId!)?.version ?? 0;
if (local.kind === 'set_title') local.baseVersion = v.titleVersion;
+85
View File
@@ -0,0 +1,85 @@
// Link previews in notes: a card above a bullet that contains a plain web address. The server fetches
// the page (https only) and keeps title, description, picture and icon; the browser only talks to
// MindSift. Hiding the card for one link turns the address into a named link [Titel](url), so the
// choice syncs with the note; a global switch (this device) turns all previews off.
import { useEffect, useState } from 'react';
import { getToken } from './api';
export interface LinkPreview {
key: string;
url: string;
title: string;
description: string;
siteName: string;
hasImage: boolean;
hasIcon: boolean;
ok: boolean;
}
// First plain https address in a text (not inside [text](url), not right after "(" or "<")
const BARE_URL_RE = /(^|[\s])(https:\/\/[^\s<>()\]]+[^\s<>()\].,;:!?'"])/;
export function firstBareUrl(content: string): string | null {
const m = BARE_URL_RE.exec(content || '');
return m ? m[2] : null;
}
/** Replaces the address by a named link, which shows no preview. */
export function hidePreviewContent(content: string, url: string, title: string): string {
const label = (title || new URL(url).hostname).replace(/[[\]]/g, '').trim() || url;
return content.replace(url, `[${label}](${url})`);
}
const cache = new Map<string, Promise<LinkPreview | null>>();
export function loadLinkPreview(url: string): Promise<LinkPreview | null> {
let p = cache.get(url);
if (!p) {
p = (async () => {
const token = getToken();
const res = await fetch(`/api/v1/link-preview?url=${encodeURIComponent(url)}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) return null; // demo, http link, offline: just no card
const data = (await res.json()) as LinkPreview;
return data.ok ? data : null;
})().catch(() => null);
cache.set(url, p);
}
return p;
}
const SETTING_KEY = 'mindsift_link_previews';
const SETTING_EVENT = 'mindsift:link-previews';
export function linkPreviewsEnabled(): boolean {
try {
return localStorage.getItem(SETTING_KEY) !== 'off';
} catch {
return true;
}
}
export function setLinkPreviewsEnabled(on: boolean): void {
try {
localStorage.setItem(SETTING_KEY, on ? 'on' : 'off');
} catch {
// private mode: only for this page
}
window.dispatchEvent(new CustomEvent(SETTING_EVENT));
}
/** The global switch, kept in sync across all open notes and tabs. */
export function useLinkPreviewsEnabled(): boolean {
const [on, setOn] = useState(linkPreviewsEnabled);
useEffect(() => {
const update = () => setOn(linkPreviewsEnabled());
window.addEventListener(SETTING_EVENT, update);
window.addEventListener('storage', update);
return () => {
window.removeEventListener(SETTING_EVENT, update);
window.removeEventListener('storage', update);
};
}, []);
return on;
}
+131
View File
@@ -0,0 +1,131 @@
// Images and audio in notes. A block points to its file through its content:
// image ![Beschriftung](media:med_…)
// audio [Beschriftung](media:med_…)
// so the reference travels with the normal note sync. Files are loaded with the login (fetch) and
// shown as blob: URLs; there are no public links.
import { getToken } from './api';
const MEDIA_RE = /^!?\[((?:[^\]\\]|\\.)*)\]\(media:(med_[0-9a-f]+)\)$/;
export interface MediaRef {
id: string;
caption: string;
}
export function parseMediaContent(content: string): MediaRef | null {
const m = MEDIA_RE.exec((content || '').trim());
if (!m) return null;
return { id: m[2], caption: m[1].replace(/\\(.)/g, '$1') };
}
export function mediaContent(kind: 'image' | 'audio', id: string, caption = ''): string {
const safe = caption.replace(/[\r\n]+/g, ' ').replace(/([\\\]])/g, '\\$1');
return `${kind === 'image' ? '!' : ''}[${safe}](media:${id})`;
}
// Loaded files, once per page. The Blob is kept as well: the CSP does not allow fetch() on blob: URLs.
const loaded = new Map<string, Promise<{ blob: Blob; url: string }>>();
function loadMedia(id: string): Promise<{ blob: Blob; url: string }> {
return loadAuthorized(`/api/v1/media/${encodeURIComponent(id)}`);
}
/** Any picture of the API (needs the login, so no plain <img src>), as Blob plus blob: URL. */
export function loadAuthorized(path: string): Promise<{ blob: Blob; url: string }> {
let p = loaded.get(path);
if (!p) {
p = (async () => {
const token = getToken();
const res = await fetch(path, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error(res.status === 404 ? 'Datei nicht gefunden' : `HTTP ${res.status}`);
const blob = await res.blob();
return { blob, url: URL.createObjectURL(blob) };
})();
p.catch(() => loaded.delete(path)); // try again next time (e.g. was offline)
loaded.set(path, p);
}
return p;
}
/** blob: URL of a file for <img>/<audio>. */
export function mediaObjectURL(id: string): Promise<string> {
return loadMedia(id).then((m) => m.url);
}
/** The file itself, e.g. to send a recording to the speech recognition. */
export function mediaBlob(id: string): Promise<Blob> {
return loadMedia(id).then((m) => m.blob);
}
export interface UploadedMedia {
id: string;
kind: 'image' | 'audio';
mime: string;
size: number;
url: string;
}
async function mediaRequest(path: string, init: RequestInit): Promise<UploadedMedia> {
const token = getToken();
const res = await fetch(path, {
...init,
headers: { ...(init.headers as Record<string, string>), ...(token ? { Authorization: `Bearer ${token}` } : {}) },
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Hochladen fehlgeschlagen (HTTP ${res.status})`);
return data as UploadedMedia;
}
export function uploadMedia(docId: string, file: Blob, filename: string): Promise<UploadedMedia> {
const form = new FormData();
form.append('file', file, filename);
return mediaRequest(`/api/v1/documents/${encodeURIComponent(docId)}/media`, { method: 'POST', body: form });
}
/** Lets the server copy an external image (https). */
export function copyExternalImage(docId: string, url: string): Promise<UploadedMedia> {
return mediaRequest(`/api/v1/documents/${encodeURIComponent(docId)}/media/fetch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
}
const MAX_EDGE = 2048;
function canvasBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise<Blob | null> {
return new Promise((resolve) => canvas.toBlob((b) => resolve(b), type, quality));
}
/**
* Shrinks photos to at most 2048 px and stores them as WebP (JPEG where the browser cannot write WebP).
* Re-encoding also drops EXIF data such as the location. GIFs stay as they are (animation); anything
* the browser cannot decode is sent unchanged and checked by the server.
*/
export async function prepareImage(file: Blob): Promise<{ blob: Blob; name: string }> {
if (file.type === 'image/gif' || typeof createImageBitmap === 'undefined') return { blob: file, name: 'bild' };
let bitmap: ImageBitmap;
try {
bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' } as ImageBitmapOptions);
} catch {
return { blob: file, name: 'bild' };
}
const scale = Math.min(1, MAX_EDGE / Math.max(bitmap.width, bitmap.height));
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
const ctx = canvas.getContext('2d');
if (!ctx) return { blob: file, name: 'bild' };
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
bitmap.close?.();
const webp = await canvasBlob(canvas, 'image/webp', 0.82);
if (webp && webp.type === 'image/webp') return { blob: webp, name: 'bild.webp' };
// No WebP encoder (older Safari): JPEG on white, since JPEG has no transparency
ctx.globalCompositeOperation = 'destination-over';
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const jpeg = await canvasBlob(canvas, 'image/jpeg', 0.85);
return jpeg ? { blob: jpeg, name: 'bild.jpg' } : { blob: file, name: 'bild' };
}
+30 -1
View File
@@ -21,6 +21,26 @@ export interface AuthStatus {
privacyUrl?: string;
hasPrivacyContent?: boolean;
};
// Voice input: own Whisper server, the browser's recognition, or switched off (STT_* environment)
voice?: { mode: VoiceMode; maxSeconds: number };
}
export type VoiceMode = 'server' | 'browser' | 'off';
// Admin view of the speech recognition (read-only, configured by environment)
export interface VoiceAdminStatus {
mode: VoiceMode;
demo: boolean;
url?: string;
model?: string;
language?: string;
apiKeySet?: boolean;
maxSeconds: number;
maxUploadMb: number;
maxParallel: number;
perUserPerHour: number;
timeoutSeconds: number;
status?: { reachable: boolean; modelReady: boolean; error?: string; checkedAt?: string };
}
// Who can see whom: nobody, members of the same organization, or everybody (Doku/sharing-and-permissions.md)
@@ -183,6 +203,7 @@ export interface SystemSettings {
smtpPass?: string; // write only: the server never sends it back, empty keeps the stored one
smtpPassSet?: boolean;
smtpFrom?: string;
smtpFromName?: string; // display name when smtpFrom is a bare address (empty = MindSift)
smtpTls?: boolean;
smtpSkipVerify?: boolean;
allowPrivateWebhooks?: boolean; // alarm webhooks may reach the LAN (and plain http)
@@ -210,7 +231,7 @@ export const DATA_SECTIONS: { id: DataSection; label: string }[] = [
{ id: 'themes', label: 'Themes' },
];
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'text' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress';
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'text' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress' | 'image' | 'audio';
export interface DocumentBlock {
id: string;
@@ -543,3 +564,11 @@ export interface Session {
ip: string;
current: boolean;
}
// Answer of merging two notes; kept by the client to undo it
export interface MergeResult {
targetId: string;
sourceId: string;
sourceTitle: string;
headingId: string;
}
+200
View File
@@ -0,0 +1,200 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from './api';
import { browserRecognition, currentVoiceConfig, EffectiveVoiceMode, loadVoiceConfig, VoiceRecorder } from './voice';
export type VoiceState = 'idle' | 'listening' | 'transcribing';
export interface VoiceInputOptions {
/** Text of the current recording; in browser mode repeatedly while speaking (final=false), always once at the end (final=true). */
onText: (text: string, final: boolean) => void;
onError?: (message: string) => void;
/** Browser mode: keep listening through pauses (hold-to-record) instead of stopping after a sentence. */
continuous?: boolean;
}
/**
* Microphone input for one button: start, stop (text arrives), cancel (nothing arrives). How the text
* is made depends on the server setting, see lib/voice.ts. mode is null until known, "off" or
* "unsupported" means: do not show a microphone.
*/
export function useVoiceInput({ onText, onError, continuous = false }: VoiceInputOptions) {
const [mode, setMode] = useState<EffectiveVoiceMode | null>(null);
const [state, setState] = useState<VoiceState>('idle');
const maxSecondsRef = useRef(600);
const recorderRef = useRef<VoiceRecorder | null>(null);
const recognitionRef = useRef<any>(null);
const timerRef = useRef<number | null>(null);
const cancelledRef = useRef(false);
const transcriptRef = useRef('');
const mountedRef = useRef(true);
const onTextRef = useRef(onText);
onTextRef.current = onText;
const onErrorRef = useRef(onError);
onErrorRef.current = onError;
useEffect(() => {
mountedRef.current = true;
void loadVoiceConfig().then((cfg) => {
if (!mountedRef.current) return;
setMode(cfg.mode);
maxSecondsRef.current = cfg.maxSeconds;
});
return () => {
mountedRef.current = false;
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
recorderRef.current?.cancel();
try {
recognitionRef.current?.abort?.();
} catch {
// ignore
}
};
}, []);
const setStateSafe = (s: VoiceState) => {
if (mountedRef.current) setState(s);
};
const fail = (message: string) => {
setStateSafe('idle');
onErrorRef.current?.(message);
};
const clearTimer = () => {
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
timerRef.current = null;
};
const stop = useCallback(async () => {
clearTimer();
if (recognitionRef.current) {
try {
recognitionRef.current.stop();
} catch {
// onend still follows
}
return;
}
const rec = recorderRef.current;
if (!rec) return;
recorderRef.current = null;
const blob = await rec.stop();
if (cancelledRef.current) return;
if (!blob) {
setStateSafe('idle');
onTextRef.current('', true);
return;
}
setStateSafe('transcribing');
try {
const text = await api.transcribe(blob);
setStateSafe('idle');
onTextRef.current(text, true);
} catch (err: any) {
fail(err?.message || 'Spracherkennung fehlgeschlagen');
}
// fail only touches refs and state setters
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const startServer = async () => {
const rec = new VoiceRecorder();
recorderRef.current = rec;
setStateSafe('listening');
try {
await rec.start();
} catch {
recorderRef.current = null;
fail('Kein Zugriff auf das Mikrofon (Berechtigung, oder die Seite läuft nicht über HTTPS)');
return;
}
if (cancelledRef.current || recorderRef.current !== rec) {
rec.cancel();
return;
}
timerRef.current = window.setTimeout(() => void stop(), maxSecondsRef.current * 1000);
};
const startBrowser = () => {
const Recognition = browserRecognition();
if (!Recognition) {
fail('Spracheingabe wird von diesem Browser nicht unterstützt');
return;
}
const recognition = new Recognition();
recognition.lang = 'de-DE';
recognition.interimResults = true;
recognition.continuous = continuous;
recognition.onresult = (event: any) => {
let combined = '';
for (let i = 0; i < event.results.length; i++) combined += event.results[i][0].transcript;
transcriptRef.current = combined;
onTextRef.current(combined, false);
};
recognition.onerror = (event: any) => {
if (event?.error === 'not-allowed' || event?.error === 'service-not-allowed') {
cancelledRef.current = true;
recognitionRef.current = null;
fail('Mikrofon nicht freigegeben');
}
};
recognition.onend = () => {
clearTimer();
recognitionRef.current = null;
if (cancelledRef.current) return;
setStateSafe('idle');
onTextRef.current(transcriptRef.current, true);
};
recognitionRef.current = recognition;
setStateSafe('listening');
try {
recognition.start();
timerRef.current = window.setTimeout(() => void stop(), maxSecondsRef.current * 1000);
} catch {
recognitionRef.current = null;
fail('Spracheingabe konnte nicht gestartet werden');
}
};
const start = useCallback(async () => {
if (recorderRef.current || recognitionRef.current) return;
cancelledRef.current = false;
transcriptRef.current = '';
const current = mode ?? currentVoiceConfig().mode;
if (current === 'server') await startServer();
else if (current === 'browser') startBrowser();
else fail(current === 'off' ? 'Spracheingabe ist ausgeschaltet' : 'Spracheingabe wird von diesem Browser nicht unterstützt');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, continuous]);
const cancel = useCallback(() => {
cancelledRef.current = true;
clearTimer();
recorderRef.current?.cancel();
recorderRef.current = null;
try {
recognitionRef.current?.abort?.();
} catch {
// ignore
}
recognitionRef.current = null;
setStateSafe('idle');
}, []);
const toggle = useCallback(() => {
if (state === 'listening') void stop();
else if (state === 'idle') void start();
}, [state, start, stop]);
/** Current input level 0..1 for waves (server mode), null when unknown. */
const level = useCallback(() => recorderRef.current?.level() ?? null, []);
return {
mode,
available: mode === 'server' || mode === 'browser',
state,
start,
stop,
cancel,
toggle,
level,
};
}
+157
View File
@@ -0,0 +1,157 @@
// Voice input for all places with a microphone button. The server decides how it works (STT_* in the
// environment, reported by /auth/status):
// server record with MediaRecorder, the own Whisper server turns it into text after stopping
// browser the browser's speech recognition (Chrome, Edge, Safari; text appears while speaking)
// off no voice input
import { api } from './api';
import type { VoiceMode } from './types';
export type EffectiveVoiceMode = VoiceMode | 'unsupported';
export interface VoiceConfig {
mode: EffectiveVoiceMode;
maxSeconds: number;
}
let configPromise: Promise<VoiceConfig> | null = null;
let loadedConfig: VoiceConfig | null = null;
export function browserRecognition(): any {
if (typeof window === 'undefined') return null;
return (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition || null;
}
export function canRecord(): boolean {
return typeof window !== 'undefined' && typeof (window as any).MediaRecorder !== 'undefined' && !!navigator.mediaDevices?.getUserMedia;
}
/** What this browser can actually do with the server's setting. */
export function effectiveMode(mode: VoiceMode | undefined): EffectiveVoiceMode {
if (mode === 'off') return 'off';
if (mode === 'server') return canRecord() ? 'server' : 'unsupported';
return browserRecognition() ? 'browser' : 'unsupported';
}
/** Loaded once per page; the setting only changes with a server restart. */
export function loadVoiceConfig(): Promise<VoiceConfig> {
if (!configPromise) {
configPromise = api
.getAuthStatus()
.then((st) => {
loadedConfig = { mode: effectiveMode(st.voice?.mode), maxSeconds: st.voice?.maxSeconds || 600 };
return loadedConfig;
})
.catch(() => {
configPromise = null; // offline at start: ask again next time
return { mode: effectiveMode('browser'), maxSeconds: 600 };
});
}
return configPromise;
}
/** The setting if already loaded; until then the browser's recognition (a button must react at once). */
export function currentVoiceConfig(): VoiceConfig {
return loadedConfig ?? { mode: effectiveMode('browser'), maxSeconds: 600 };
}
/** For tests. */
export function resetVoiceConfig(): void {
configPromise = null;
loadedConfig = null;
}
// Containers in order of preference; Safari only records MP4/AAC
const RECORDING_TYPES = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus'];
function recordingType(): string | undefined {
const MR = (window as any).MediaRecorder;
if (!MR?.isTypeSupported) return undefined;
return RECORDING_TYPES.find((t) => MR.isTypeSupported(t));
}
/**
* One recording from the microphone, with a live input level for wave animations. stop() resolves
* with the audio; cancel() throws it away.
*/
export class VoiceRecorder {
private stream: MediaStream | null = null;
private recorder: MediaRecorder | null = null;
private chunks: Blob[] = [];
private audioCtx: AudioContext | null = null;
private analyser: AnalyserNode | null = null;
private samples: Uint8Array | null = null;
private done: ((blob: Blob | null) => void) | null = null;
async start(): Promise<void> {
this.stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
});
const type = recordingType();
this.recorder = new MediaRecorder(this.stream, type ? { mimeType: type } : undefined);
this.chunks = [];
this.recorder.ondataavailable = (e) => {
if (e.data.size > 0) this.chunks.push(e.data);
};
this.recorder.onstop = () => {
const blob = this.chunks.length ? new Blob(this.chunks, { type: this.recorder?.mimeType || type || 'audio/webm' }) : null;
this.release();
this.done?.(blob);
this.done = null;
};
this.recorder.start(1000);
try {
const Ctx = (window as any).AudioContext || (window as any).webkitAudioContext;
this.audioCtx = new Ctx();
const source = this.audioCtx!.createMediaStreamSource(this.stream);
this.analyser = this.audioCtx!.createAnalyser();
this.analyser.fftSize = 512;
source.connect(this.analyser);
this.samples = new Uint8Array(this.analyser.fftSize);
void this.audioCtx!.resume?.();
} catch {
this.analyser = null; // no level, the recording still works
}
}
/** Input level 0..1 (roughly loudness), or null when unknown. */
level(): number | null {
if (!this.analyser || !this.samples) return null;
this.analyser.getByteTimeDomainData(this.samples as Uint8Array<ArrayBuffer>);
let sum = 0;
for (const v of this.samples) {
const x = (v - 128) / 128;
sum += x * x;
}
return Math.min(1, Math.sqrt(sum / this.samples.length) * 4);
}
stop(): Promise<Blob | null> {
return new Promise((resolve) => {
if (!this.recorder || this.recorder.state === 'inactive') {
this.release();
resolve(null);
return;
}
this.done = resolve;
this.recorder.stop();
});
}
cancel(): void {
this.done = null;
if (this.recorder && this.recorder.state !== 'inactive') {
this.recorder.onstop = () => this.release();
this.recorder.stop();
} else {
this.release();
}
}
private release(): void {
this.stream?.getTracks().forEach((t) => t.stop());
this.stream = null;
void this.audioCtx?.close?.().catch(() => {});
this.audioCtx = null;
this.analyser = null;
}
}
+11
View File
@@ -53,4 +53,15 @@
--radius-input: 2px;
--radius-checkbox: 0px;
--radius-pill: 9999px;
/* --- 7. CODE-SYNTAX-HIGHLIGHTING (VS Code Dark+) --- */
--code-keyword: #569cd6;
--code-string: #ce9178;
--code-number: #b5cea8;
--code-comment: #6a9955;
--code-property: #9cdcfe;
--code-function: #dcdcaa;
--code-type: #4ec9b0;
--code-punctuation: #d4d4d4;
--code-variable: #9cdcfe;
}
@@ -52,4 +52,15 @@
--radius-input: 0px;
--radius-checkbox: 0px;
--radius-pill: 0px;
/* --- 7. CODE-SYNTAX-HIGHLIGHTING (VS Code Dark+) --- */
--code-keyword: #569cd6;
--code-string: #ce9178;
--code-number: #b5cea8;
--code-comment: #6a9955;
--code-property: #9cdcfe;
--code-function: #dcdcaa;
--code-type: #4ec9b0;
--code-punctuation: #ffffff;
--code-variable: #9cdcfe;
}
+11
View File
@@ -52,4 +52,15 @@
--radius-input: 2px;
--radius-checkbox: 0px;
--radius-pill: 9999px;
/* --- 7. CODE-SYNTAX-HIGHLIGHTING (VS Code Light+) --- */
--code-keyword: #0000ff;
--code-string: #a31515;
--code-number: #098658;
--code-comment: #008000;
--code-property: #0451a5;
--code-function: #795e26;
--code-type: #267f99;
--code-punctuation: #3b3b3b;
--code-variable: #001080;
}
+11
View File
@@ -52,4 +52,15 @@
--radius-input: 2px;
--radius-checkbox: 0px;
--radius-pill: 9999px;
/* --- 7. CODE-SYNTAX-HIGHLIGHTING (VS Code Dark+) --- */
--code-keyword: #569cd6;
--code-string: #ce9178;
--code-number: #b5cea8;
--code-comment: #6a9955;
--code-property: #9cdcfe;
--code-function: #dcdcaa;
--code-type: #4ec9b0;
--code-punctuation: #d4d4d4;
--code-variable: #9cdcfe;
}
+11
View File
@@ -53,4 +53,15 @@
--radius-input: 6px;
--radius-checkbox: 4px;
--radius-pill: 9999px;
/* --- 7. CODE-SYNTAX-HIGHLIGHTING (VS Code Dark+) --- */
--code-keyword: #569cd6;
--code-string: #ce9178;
--code-number: #b5cea8;
--code-comment: #6a9955;
--code-property: #9cdcfe;
--code-function: #dcdcaa;
--code-type: #4ec9b0;
--code-punctuation: #d4d4d4;
--code-variable: #9cdcfe;
}
+11
View File
@@ -52,4 +52,15 @@
--radius-input: 6px;
--radius-checkbox: 4px;
--radius-pill: 9999px;
/* --- 7. CODE-SYNTAX-HIGHLIGHTING (VS Code Dark+) --- */
--code-keyword: #569cd6;
--code-string: #ce9178;
--code-number: #b5cea8;
--code-comment: #6a9955;
--code-property: #9cdcfe;
--code-function: #dcdcaa;
--code-type: #4ec9b0;
--code-punctuation: #d4d4d4;
--code-variable: #9cdcfe;
}
+11
View File
@@ -54,4 +54,15 @@
--radius-input: 2px; /* Eingabefelder & Dropdowns */
--radius-checkbox: 0px; /* Aufgaben-Checkboxen */
--radius-pill: 9999px; /* Quick-Capture-Blase & Profil-Pill */
/* --- 7. CODE-SYNTAX-HIGHLIGHTING (VS Code Dark+ Standardwerte) --- */
--code-keyword: #569cd6; /* Schlüsselwörter & Booleans */
--code-string: #ce9178; /* Zeichenketten (Strings) */
--code-number: #b5cea8; /* Zahlen & Konstanten */
--code-comment: #6a9955; /* Kommentare */
--code-property: #9cdcfe; /* Schlüssel (YAML/JSON Keys) & Attribute */
--code-function: #dcdcaa; /* Funktionen & Methoden */
--code-type: #4ec9b0; /* Typen & Klassen */
--code-punctuation: #d4d4d4; /* Klammern, Operatoren & Satzzeichen */
--code-variable: #9cdcfe; /* Variablen & Regex */
}
+1
View File
@@ -1 +1,2 @@
/// <reference types="vite/client" />
declare module 'prismjs/components/*';
+7 -3
View File
@@ -12,8 +12,12 @@ import (
// with huge uploads. Note sync and imports need more; their handlers apply the same limits again.
const defaultMaxBody = 2 << 20
func bodyLimitFor(path string) int64 {
func (s *Server) bodyLimitFor(path string) int64 {
switch {
case strings.HasPrefix(path, "/api/v1/documents/") && strings.HasSuffix(path, "/media"):
return s.mediaUploadLimit()
case path == "/api/v1/voice/transcribe":
return int64(s.cfg.Speech.MaxUploadMB)<<20 + 1<<20
case strings.HasPrefix(path, "/api/v1/import/"):
return storage.MaxImportUploadBytes + 1<<20
case strings.HasPrefix(path, "/api/v1/docs/") && strings.HasSuffix(path, "/sync"):
@@ -22,10 +26,10 @@ func bodyLimitFor(path string) int64 {
return defaultMaxBody
}
func bodyLimit(next http.Handler) http.Handler {
func (s *Server) bodyLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Body != nil && r.Body != http.NoBody {
r.Body = http.MaxBytesReader(w, r.Body, bodyLimitFor(r.URL.Path))
r.Body = http.MaxBytesReader(w, r.Body, s.bodyLimitFor(r.URL.Path))
}
next.ServeHTTP(w, r)
})
+1 -1
View File
@@ -16,7 +16,7 @@ func TestRequestBodyLimited(t *testing.T) {
if rec := sessionCall(router, "POST", "/api/v1/inbox", token, `{"content":"klein"}`, ""); rec.Code >= 300 {
t.Fatalf("normal body: got %d %s", rec.Code, rec.Body.String())
}
if bodyLimitFor("/api/v1/docs/doc_1/sync") != syncMaxBody || bodyLimitFor("/api/v1/import/obsidian") <= defaultMaxBody {
if srv.bodyLimitFor("/api/v1/docs/doc_1/sync") != syncMaxBody || srv.bodyLimitFor("/api/v1/import/obsidian") <= defaultMaxBody {
t.Fatal("sync and import keep their larger limits")
}
}
+3
View File
@@ -4,6 +4,7 @@ import (
"errors"
"log/slog"
"net/http"
"os"
"strings"
"sync"
"time"
@@ -59,6 +60,8 @@ func (s *Server) setDemoNextReset(t time.Time) {
func (s *Server) resetDemo() {
s.demo.gate.Lock()
err := s.store.ResetAndSeedDemo()
// Uploaded images and recordings belong to the old state; their rows are gone with the tables
_ = os.RemoveAll(s.mediaRoot())
s.demo.gate.Unlock()
if err != nil {
slog.Error("Demo-Reset fehlgeschlagen", "error", err)
+2
View File
@@ -270,6 +270,8 @@ func (s *Server) startCompaction() {
func (s *Server) compactOnce() {
s.leases.Sweep()
s.compactMedia(time.Now())
_ = s.store.PruneLinkPreviews(time.Now().Add(-30 * 24 * time.Hour))
st, err := s.store.CompactDocs(time.Now())
if err != nil {
slog.Error("Verdichtung des Änderungsprotokolls fehlgeschlagen", "error", err)
+1 -1
View File
@@ -156,7 +156,7 @@ func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("format") == "zip" {
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="mindsift-backup-%s.zip"`, time.Now().Format("2006-01-02")))
if err := storage.WriteBackupZip(exportData, w); err != nil {
if err := s.store.WriteBackupZip(userID, exportData, w); err != nil {
slog.Error("Backup-Export fehlgeschlagen", "user_id", userID, "error", err)
}
return
+2
View File
@@ -44,6 +44,8 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
"demoMode": s.cfg.DemoMode,
"sharingMode": s.store.SharingMode(),
"passwordReset": s.passwordResetAvailable(),
// How voice input works: "server" (own Whisper), "browser" or "off"; set by STT_* only
"voice": map[string]interface{}{"mode": s.voiceMode(), "maxSeconds": s.cfg.Speech.MaxSeconds},
// Public so the login screen can link Impressum and Datenschutz
"legal": map[string]interface{}{
"imprintUrl": s.cfg.Legal.ImprintURL,
+294
View File
@@ -0,0 +1,294 @@
package api
import (
"context"
"errors"
"html"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/storage"
)
// Link previews for notes: the server fetches the page once (https only, never into its own network,
// like alarm webhooks), reads title, description, picture and icon, and keeps them. Readers' browsers
// only talk to MindSift. Off in the demo, which must not send anything out.
const (
previewTTL = 7 * 24 * time.Hour
previewFailTTL = 24 * time.Hour
previewMaxHTML = 1 << 20
previewMaxImage = 2 << 20
previewMaxIcon = 100 << 10
previewPerAddress = 300 // fetches per address in 15 minutes (cached answers do not count)
)
var previewLocks sync.Map // URL -> *sync.Mutex, one fetch per page at a time
var (
metaTagRe = regexp.MustCompile(`(?is)<meta\s[^>]*>`)
linkTagRe = regexp.MustCompile(`(?is)<link\s[^>]*>`)
titleTagRe = regexp.MustCompile(`(?is)<title[^>]*>(.*?)</title>`)
attrRe = regexp.MustCompile(`(?is)([a-z:_-]+)\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))`)
headEndRe = regexp.MustCompile(`(?i)</head>|<body[\s>]`)
spacesRe = regexp.MustCompile(`\s+`)
)
func tagAttrs(tag string) map[string]string {
out := map[string]string{}
for _, m := range attrRe.FindAllStringSubmatch(tag, -1) {
v := m[3]
if v == "" {
v = m[4]
}
if v == "" {
v = m[5]
}
out[strings.ToLower(m[1])] = html.UnescapeString(v)
}
return out
}
func cleanText(s string, max int) string {
s = strings.TrimSpace(spacesRe.ReplaceAllString(html.UnescapeString(s), " "))
if utf8.RuneCountInString(s) > max {
r := []rune(s)
s = strings.TrimSpace(string(r[:max-1])) + "…"
}
return s
}
// latin1ToUTF8 for pages that still declare ISO-8859-1/windows-1252.
func latin1ToUTF8(b []byte) string {
r := make([]rune, len(b))
for i, c := range b {
r[i] = rune(c)
}
return string(r)
}
// pageMeta reads the head of an HTML page: Open Graph/Twitter tags, <title>, icon link.
type pageMeta struct {
title, description, siteName, image, icon string
}
func parsePageMeta(doc string) pageMeta {
if loc := headEndRe.FindStringIndex(doc); loc != nil {
doc = doc[:loc[0]]
}
var m pageMeta
for _, tag := range metaTagRe.FindAllString(doc, -1) {
a := tagAttrs(tag)
key := strings.ToLower(a["property"])
if key == "" {
key = strings.ToLower(a["name"])
}
v := a["content"]
switch key {
case "og:title", "twitter:title":
if m.title == "" {
m.title = v
}
case "og:description", "twitter:description", "description":
if m.description == "" {
m.description = v
}
case "og:site_name", "application-name":
if m.siteName == "" {
m.siteName = v
}
case "og:image", "og:image:url", "og:image:secure_url", "twitter:image":
if m.image == "" {
m.image = v
}
}
}
if m.title == "" {
if t := titleTagRe.FindStringSubmatch(doc); t != nil {
m.title = t[1]
}
}
for _, tag := range linkTagRe.FindAllString(doc, -1) {
a := tagAttrs(tag)
rel := " " + strings.ToLower(a["rel"]) + " "
if (strings.Contains(rel, " icon ") || strings.Contains(rel, "apple-touch-icon")) && a["href"] != "" && !strings.HasSuffix(strings.ToLower(a["href"]), ".svg") {
if m.icon == "" || strings.Contains(rel, "apple-touch-icon") {
m.icon = a["href"]
}
}
}
m.title, m.description, m.siteName = cleanText(m.title, 200), cleanText(m.description, 300), cleanText(m.siteName, 80)
return m
}
// fetchLimited GETs a public https URL and reads at most limit bytes.
func fetchLimited(ctx context.Context, raw string, limit int64, accept string) ([]byte, http.Header, error) {
if err := validateWebhookURL(raw, false, false); err != nil {
return nil, nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
if err != nil {
return nil, nil, err
}
req.Header.Set("Accept", accept)
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; MindSift link preview)")
req.Header.Set("Accept-Language", "de,en;q=0.8")
resp, err := guardedClient(false).Do(req)
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, nil, errors.New(resp.Status)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
if err != nil {
return nil, nil, err
}
if int64(len(data)) > limit {
return nil, nil, errors.New("zu groß")
}
return data, resp.Header, nil
}
func resolveRef(base *url.URL, ref string) string {
u, err := base.Parse(strings.TrimSpace(ref))
if err != nil || (u.Scheme != "https" && u.Scheme != "http") {
return ""
}
if u.Scheme == "http" {
u.Scheme = "https" // only https is fetched; most sites answer there as well
}
return u.String()
}
// buildLinkPreview fetches the page and its pictures.
func buildLinkPreview(ctx context.Context, pageURL string) *storage.LinkPreview {
p := &storage.LinkPreview{URL: pageURL}
base, err := url.Parse(pageURL)
if err != nil {
return p
}
body, hdr, err := fetchLimited(ctx, pageURL, previewMaxHTML, "text/html,application/xhtml+xml")
if err != nil {
return p
}
ct := strings.ToLower(hdr.Get("Content-Type"))
if !strings.Contains(ct, "html") {
// A direct link to an image: the image itself is the preview
if _, kind, ok := storage.DetectMedia(body, ""); ok && kind == "image" && len(body) <= previewMaxImage {
p.Image, p.ImageMime, p.OK = body, http.DetectContentType(body), true
p.Title = cleanText(strings.TrimSuffix(base.Path[strings.LastIndex(base.Path, "/")+1:], "/"), 200)
p.SiteName = base.Hostname()
}
return p
}
text := string(body)
if strings.Contains(ct, "iso-8859-1") || strings.Contains(ct, "windows-1252") || !utf8.Valid(body) {
text = latin1ToUTF8(body)
}
meta := parsePageMeta(text)
p.Title, p.Description, p.SiteName = meta.title, meta.description, meta.siteName
if p.SiteName == "" {
p.SiteName = strings.TrimPrefix(base.Hostname(), "www.")
}
if img := resolveRef(base, meta.image); img != "" {
if data, _, err := fetchLimited(ctx, img, previewMaxImage, "image/*"); err == nil {
if mime, kind, ok := storage.DetectMedia(data, ""); ok && kind == "image" {
p.Image, p.ImageMime = data, mime
}
}
}
iconURL := resolveRef(base, meta.icon)
if iconURL == "" {
iconURL = resolveRef(base, "/favicon.ico")
}
if data, _, err := fetchLimited(ctx, iconURL, previewMaxIcon, "image/*"); err == nil {
if mime := http.DetectContentType(data); strings.HasPrefix(mime, "image/") && mime != "image/svg+xml" {
p.Icon, p.IconMime = data, mime
}
}
p.OK = p.Title != "" || p.Description != "" || len(p.Image) > 0
return p
}
func previewFresh(p *storage.LinkPreview, now time.Time) bool {
at, err := time.Parse(time.RFC3339, p.FetchedAt)
if err != nil {
return false
}
ttl := previewTTL
if !p.OK {
ttl = previewFailTTL
}
return now.Sub(at) < ttl
}
// handleLinkPreview: GET /link-preview?url=https://…
func (s *Server) handleLinkPreview(w http.ResponseWriter, r *http.Request) {
raw := strings.TrimSpace(r.URL.Query().Get("url"))
u, err := url.Parse(raw)
if err != nil || u.Scheme != "https" || u.Host == "" || len(raw) > 2000 {
writeError(w, http.StatusUnprocessableEntity, "Vorschau nur für https-Links")
return
}
pageURL := u.String()
key := storage.LinkPreviewKey(pageURL)
if p, err := s.store.GetLinkPreview(key); err == nil && previewFresh(p, time.Now()) {
writeJSON(w, http.StatusOK, p)
return
}
lock, _ := previewLocks.LoadOrStore(pageURL, &sync.Mutex{})
mu := lock.(*sync.Mutex)
mu.Lock()
defer func() {
mu.Unlock()
previewLocks.Delete(pageURL)
}()
// Someone else may have fetched it while this request waited
if p, err := s.store.GetLinkPreview(key); err == nil && previewFresh(p, time.Now()) {
writeJSON(w, http.StatusOK, p)
return
}
if wait := s.loginGuard.throttle("preview", clientIPFrom(r), previewPerAddress); wait > 0 {
writeThrottled(w, wait)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
p := buildLinkPreview(ctx, pageURL)
if err := s.store.SaveLinkPreview(p); err != nil {
writeError(w, http.StatusInternalServerError, "Vorschau konnte nicht gespeichert werden")
return
}
writeJSON(w, http.StatusOK, p)
}
// handleLinkPreviewPicture: GET /link-preview/{key}/image or /icon
func (s *Server) handleLinkPreviewPicture(w http.ResponseWriter, r *http.Request) {
p, err := s.store.GetLinkPreview(chi.URLParam(r, "key"))
if err != nil {
writeError(w, http.StatusNotFound, "Kein Bild")
return
}
data, mime := p.Image, p.ImageMime
if chi.URLParam(r, "kind") == "icon" {
data, mime = p.Icon, p.IconMime
}
if len(data) == 0 {
writeError(w, http.StatusNotFound, "Kein Bild")
return
}
w.Header().Set("Content-Type", mime)
w.Header().Set("Cache-Control", "private, max-age=86400")
w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox")
_, _ = w.Write(data)
}
+58
View File
@@ -0,0 +1,58 @@
package api
import (
"net/http"
"strings"
"testing"
"github.com/sandrodach/mindsift/internal/storage"
)
func TestParsePageMeta(t *testing.T) {
doc := `<!doctype html><html><head>
<meta charset="utf-8"><title>Fallback Titel</title>
<meta property="og:title" content="Rezept: Käsespätzle &amp; Salat">
<meta name="description" content="Schnell gemacht,
für 4 Personen.">
<meta property='og:image' content='/img/spaetzle.jpg'>
<meta property="og:site_name" content="Kochseite">
<link rel="icon" href="/favicon.png"><link rel="stylesheet" href="x.css">
</head><body><meta property="og:title" content="nicht im Kopf"></body></html>`
m := parsePageMeta(doc)
if m.title != "Rezept: Käsespätzle & Salat" || m.description != "Schnell gemacht, für 4 Personen." ||
m.siteName != "Kochseite" || m.image != "/img/spaetzle.jpg" || m.icon != "/favicon.png" {
t.Fatalf("unexpected meta: %+v", m)
}
if m := parsePageMeta(`<head><title> Nur Titel </title></head>`); m.title != "Nur Titel" {
t.Fatalf("title fallback: %+v", m)
}
if long := cleanText(strings.Repeat("a", 500), 200); len([]rune(long)) != 200 || !strings.HasSuffix(long, "…") {
t.Fatalf("long text not shortened: %d", len(long))
}
}
func TestLinkPreviewEndpoints(t *testing.T) {
srv, store, token := setupTestServer(t)
router := srv.Router()
if rec := sessionCall(router, "GET", "/api/v1/link-preview?url=http://example.com", token, "", ""); rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("http link: want 422, got %d", rec.Code)
}
// A cached preview is answered without fetching; its pictures are served with the login
p := &storage.LinkPreview{URL: "https://example.com/", Title: "Beispiel", OK: true, Icon: []byte("\x89PNG\r\n\x1a\n"), IconMime: "image/png"}
if err := store.SaveLinkPreview(p); err != nil {
t.Fatal(err)
}
rec := sessionCall(router, "GET", "/api/v1/link-preview?url=https://example.com/", token, "", "")
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"title":"Beispiel"`) || !strings.Contains(rec.Body.String(), `"hasIcon":true`) {
t.Fatalf("cached preview: %d %s", rec.Code, rec.Body.String())
}
if rec := sessionCall(router, "GET", "/api/v1/link-preview/"+p.Key+"/icon", token, "", ""); rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "image/png" {
t.Fatalf("icon: %d", rec.Code)
}
if rec := sessionCall(router, "GET", "/api/v1/link-preview/"+p.Key+"/image", token, "", ""); rec.Code != http.StatusNotFound {
t.Fatalf("missing image: want 404, got %d", rec.Code)
}
if rec := sessionCall(router, "GET", "/api/v1/link-preview/"+p.Key+"/icon", "", "", ""); rec.Code != http.StatusUnauthorized {
t.Fatalf("without login: want 401, got %d", rec.Code)
}
}
+24 -5
View File
@@ -20,8 +20,9 @@ import (
// smtpSettings is the effective mail server configuration (admin settings, else environment).
type smtpSettings struct {
Host, Port, User, Pass, From string
TLS bool // 465: implicit TLS, otherwise STARTTLS (required when set)
SkipVerify bool // accept self-signed / mismatching certificates
FromName string // display name when From is a bare address
TLS bool // 465: implicit TLS, otherwise STARTTLS (required when set)
SkipVerify bool // accept self-signed / mismatching certificates
authMech string // "" = automatic; "LOGIN" forces AUTH LOGIN (fallback after a refused PLAIN)
}
@@ -46,13 +47,15 @@ func (s *Server) smtpSettings() (smtpSettings, error) {
cfg = smtpSettings{
Host: strings.TrimSpace(sys.SMTPHost), Port: strings.TrimSpace(sys.SMTPPort),
User: strings.TrimSpace(sys.SMTPUser), Pass: sys.SMTPPass, From: strings.TrimSpace(sys.SMTPFrom),
TLS: sys.SMTPTLS, SkipVerify: sys.SMTPSkipVerify,
FromName: strings.TrimSpace(sys.SMTPFromName),
TLS: sys.SMTPTLS, SkipVerify: sys.SMTPSkipVerify,
}
} else if strings.TrimSpace(s.cfg.SMTPHost) != "" {
cfg = smtpSettings{
Host: strings.TrimSpace(s.cfg.SMTPHost), Port: strings.TrimSpace(s.cfg.SMTPPort),
User: strings.TrimSpace(s.cfg.SMTPUser), Pass: s.cfg.SMTPPass, From: strings.TrimSpace(s.cfg.SMTPFrom),
TLS: s.cfg.SMTPTLS, SkipVerify: s.cfg.SMTPSkipVerify,
FromName: strings.TrimSpace(s.cfg.SMTPFromName),
TLS: s.cfg.SMTPTLS, SkipVerify: s.cfg.SMTPSkipVerify,
}
}
if cfg.Host == "" {
@@ -61,6 +64,9 @@ func (s *Server) smtpSettings() (smtpSettings, error) {
if cfg.Port == "" {
cfg.Port = "587"
}
if cfg.FromName == "" {
cfg.FromName = "MindSift"
}
if cfg.From == "" {
if strings.Contains(cfg.User, "@") {
cfg.From = cfg.User
@@ -96,6 +102,19 @@ func splitAddress(raw string) (envelope, header string, err error) {
return addr.Address, addr.String(), nil
}
// senderAddress is splitAddress for the sender: a bare address gets the display name, so mail clients
// show "MindSift" instead of only the address. A name already in SMTP_FROM wins.
func senderAddress(raw, name string) (envelope, header string, err error) {
addr, err := mail.ParseAddress(strings.TrimSpace(raw))
if err != nil {
return "", "", fmt.Errorf("ungültige E-Mail-Adresse %q", raw)
}
if addr.Name == "" {
addr.Name = strings.TrimSpace(name)
}
return addr.Address, addr.String(), nil
}
// buildMessage creates an RFC 5322 message: Date and Message-ID headers, RFC 2047 encoded subject
// (umlauts, emoji) and a quoted-printable UTF-8 body with CRLF line endings.
func buildMessage(from, to, subject, body string, now time.Time) []byte {
@@ -202,7 +221,7 @@ func sendMail(cfg smtpSettings, to, subject, body string) error {
// sendMailOnce runs the SMTP dialogue step by step instead of smtp.SendMail, so it can introduce itself
// properly, honour SkipVerify and report the exact step a server refuses.
func sendMailOnce(cfg smtpSettings, to, subject, body string) error {
fromAddr, fromHeader, err := splitAddress(cfg.From)
fromAddr, fromHeader, err := senderAddress(cfg.From, cfg.FromName)
if err != nil {
return &smtpStepError{"Absender", err}
}
+14
View File
@@ -270,3 +270,17 @@ func TestAuthLoginOnlyAndBothFailing(t *testing.T) {
t.Fatalf("expected both mechanisms and the server hint in the error, got %v", err)
}
}
func TestSenderAddressGetsDisplayName(t *testing.T) {
cases := []struct{ raw, name, envelope, header string }{
{"alerts@example.com", "MindSift", "alerts@example.com", `"MindSift" <alerts@example.com>`},
{`"Mein Dienst" <alerts@example.com>`, "MindSift", "alerts@example.com", `"Mein Dienst" <alerts@example.com>`},
{"alerts@example.com", "Büro Müller", "alerts@example.com", "=?utf-8?q?B=C3=BCro_M=C3=BCller?= <alerts@example.com>"},
}
for _, c := range cases {
env, hdr, err := senderAddress(c.raw, c.name)
if err != nil || env != c.envelope || hdr != c.header {
t.Fatalf("senderAddress(%q, %q) = %q, %q, %v", c.raw, c.name, env, hdr, err)
}
}
}
+239
View File
@@ -0,0 +1,239 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/storage"
)
// Images and audio in notes. Uploads need write access to the note, reading needs read access to a note
// that shows the file. Files are served through the API with the normal login (the browser loads them
// with fetch and shows a blob: URL), never under a public link.
const mediaGrace = 24 * time.Hour // uploads not yet placed in a block are kept this long
func (s *Server) mediaRoot() string { return s.store.MediaRoot() }
func (s *Server) mediaPath(m *storage.Media) string { return s.store.MediaFilePath(m) }
// mediaLimit is the largest allowed file of a kind in bytes.
func (s *Server) mediaLimit(kind string) int64 {
mb := s.cfg.Media.MaxImageMB
if kind == "audio" {
mb = s.cfg.Media.MaxAudioMB
}
if s.cfg.DemoMode && s.cfg.Media.DemoMaxMB < mb {
mb = s.cfg.Media.DemoMaxMB
}
return int64(mb) << 20
}
var detectMedia = storage.DetectMedia
func (s *Server) canEditDoc(userID, docID string) bool {
role := s.store.DocRole(userID, docID)
return role == "owner" || role == "editor"
}
// storeMedia checks limits and writes the file; size limits must have been applied while reading.
func (s *Server) storeMedia(w http.ResponseWriter, userID, docID string, data []byte, declared, sourceURL string) (*storage.Media, bool) {
mime, kind, ok := detectMedia(data, declared)
if !ok {
writeError(w, http.StatusUnsupportedMediaType, "Nicht unterstütztes Format (Bilder: JPEG, PNG, GIF, WebP; Audio: WebM, Ogg, MP3, WAV, M4A)")
return nil, false
}
if limit := s.mediaLimit(kind); int64(len(data)) > limit {
writeError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("Datei zu groß (höchstens %d MB)", limit>>20))
return nil, false
}
if quota := int64(s.cfg.Media.QuotaMB) << 20; quota > 0 {
used, _ := s.store.MediaUsage(userID)
if used+int64(len(data)) > quota {
writeError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("Speicherplatz für Medien aufgebraucht (%d MB)", s.cfg.Media.QuotaMB))
return nil, false
}
}
m, err := s.store.CreateMedia(docID, userID, kind, mime, int64(len(data)), sourceURL)
if err != nil {
writeError(w, http.StatusInternalServerError, "Datei konnte nicht gespeichert werden")
return nil, false
}
path := s.mediaPath(m)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err == nil {
err = os.WriteFile(path, data, 0o644)
if err == nil {
return m, true
}
slog.Error("Mediendatei konnte nicht geschrieben werden", "path", path, "error", err)
}
_ = s.store.DeleteMediaRow(m.ID)
writeError(w, http.StatusInternalServerError, "Datei konnte nicht gespeichert werden")
return nil, false
}
func mediaResponse(m *storage.Media) map[string]interface{} {
return map[string]interface{}{"id": m.ID, "kind": m.Kind, "mime": m.Mime, "size": m.Size, "url": "/api/v1/media/" + m.ID}
}
// handleUploadMedia: POST /documents/{id}/media (multipart, field "file")
func (s *Server) handleUploadMedia(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
if !s.canEditDoc(userID, docID) {
writeError(w, http.StatusForbidden, "Keine Schreibrechte für diese Notiz")
return
}
allowLongTransfer(w)
file, header, err := r.FormFile("file")
if err != nil {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
writeError(w, http.StatusRequestEntityTooLarge, "Datei zu groß")
return
}
writeError(w, http.StatusBadRequest, "Keine Datei im Feld \"file\"")
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil || len(data) == 0 {
writeError(w, http.StatusBadRequest, "Datei konnte nicht gelesen werden")
return
}
if m, ok := s.storeMedia(w, userID, docID, data, header.Header.Get("Content-Type"), ""); ok {
writeJSON(w, http.StatusCreated, mediaResponse(m))
}
}
// handleFetchMedia: POST /documents/{id}/media/fetch {url} copies an external image, so readers of the
// note do not contact the other server and the image stays when it disappears there.
func (s *Server) handleFetchMedia(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
if !s.canEditDoc(userID, docID) {
writeError(w, http.StatusForbidden, "Keine Schreibrechte für diese Notiz")
return
}
var req struct {
URL string `json:"url"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.URL) == "" {
writeError(w, http.StatusBadRequest, "Bild-URL erforderlich")
return
}
src := strings.TrimSpace(req.URL)
// Same protection as alarm webhooks: https only, no addresses in the server's own network
if err := validateWebhookURL(src, false, false); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
limit := s.mediaLimit("image")
httpReq, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, src, nil)
httpReq.Header.Set("Accept", "image/*")
httpReq.Header.Set("User-Agent", "MindSift (image copy)")
resp, err := guardedClient(false).Do(httpReq)
if err != nil {
writeError(w, http.StatusBadGateway, "Bild konnte nicht geladen werden")
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
writeError(w, http.StatusBadGateway, fmt.Sprintf("Bild konnte nicht geladen werden (HTTP %d)", resp.StatusCode))
return
}
data, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
if err != nil {
writeError(w, http.StatusBadGateway, "Bild konnte nicht geladen werden")
return
}
if int64(len(data)) > limit {
writeError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("Bild zu groß (höchstens %d MB)", limit>>20))
return
}
if _, kind, ok := detectMedia(data, ""); !ok || kind != "image" {
writeError(w, http.StatusUnsupportedMediaType, "Unter dieser Adresse liegt kein Bild (JPEG, PNG, GIF, WebP)")
return
}
if m, ok := s.storeMedia(w, userID, docID, data, "", src); ok {
writeJSON(w, http.StatusCreated, mediaResponse(m))
}
}
// canReadMedia: the user may read the note the file was uploaded to, or a note whose blocks show it
// (e.g. a block copied into another note).
func (s *Server) canReadMedia(userID string, m *storage.Media) bool {
if s.store.DocRole(userID, m.DocID) != "" {
return true
}
docs, _ := s.store.MediaReferencedIn(m.ID)
for _, d := range docs {
if s.store.DocRole(userID, d) != "" {
return true
}
}
return false
}
// handleGetMedia: GET /media/{id} with range requests (seeking in audio).
func (s *Server) handleGetMedia(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
m, err := s.store.GetMedia(chi.URLParam(r, "id"))
if err != nil || !s.canReadMedia(userID, m) {
writeError(w, http.StatusNotFound, storage.ErrMediaNotFound.Error())
return
}
f, err := os.Open(s.mediaPath(m))
if err != nil {
writeError(w, http.StatusNotFound, storage.ErrMediaNotFound.Error())
return
}
defer f.Close()
allowLongTransfer(w)
w.Header().Set("Content-Type", m.Mime)
// The content of an ID never changes; the login is checked on every request anyway
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox")
created, _ := time.Parse(time.RFC3339, m.CreatedAt)
http.ServeContent(w, r, "", created, f)
}
// compactMedia deletes files no block mentions any more (after the grace time) and their folders.
func (s *Server) compactMedia(now time.Time) {
orphans, err := s.store.UnreferencedMedia(now.Add(-mediaGrace))
if err != nil {
slog.Error("Aufräumen der Mediendateien fehlgeschlagen", "error", err)
return
}
for i := range orphans {
m := &orphans[i]
if err := os.Remove(s.mediaPath(m)); err != nil && !os.IsNotExist(err) {
slog.Warn("Mediendatei konnte nicht gelöscht werden", "id", m.ID, "error", err)
continue
}
_ = s.store.DeleteMediaRow(m.ID)
_ = os.Remove(filepath.Dir(s.mediaPath(m))) // only succeeds when the folder is empty
}
if len(orphans) > 0 {
slog.Info("Nicht mehr verwendete Mediendateien gelöscht", "count", len(orphans))
}
}
// bodyLimit for uploads: the largest kind plus room for the multipart envelope
func (s *Server) mediaUploadLimit() int64 {
img, audio := s.mediaLimit("image"), s.mediaLimit("audio")
if audio > img {
return audio + 1<<20
}
return img + 1<<20
}
+166
View File
@@ -0,0 +1,166 @@
package api
import (
"bytes"
"encoding/json"
"image"
"image/png"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
)
func tinyPNG(t *testing.T, extra int) []byte {
t.Helper()
var buf bytes.Buffer
if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 4, 4))); err != nil {
t.Fatal(err)
}
buf.Write(make([]byte, extra)) // padding behind the image data, still detected as PNG
return buf.Bytes()
}
func mediaUpload(t *testing.T, router http.Handler, token, docID string, data []byte, contentType string) *httptest.ResponseRecorder {
t.Helper()
var body bytes.Buffer
mw := multipart.NewWriter(&body)
h := make(map[string][]string)
h["Content-Disposition"] = []string{`form-data; name="file"; filename="datei"`}
h["Content-Type"] = []string{contentType}
fw, _ := mw.CreatePart(h)
_, _ = fw.Write(data)
_ = mw.Close()
req := httptest.NewRequest("POST", "/api/v1/documents/"+docID+"/media", &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func TestMediaUploadReadAndAccess(t *testing.T) {
srv, store, token := setupTestServer(t)
srv.cfg.Media.MaxImageMB, srv.cfg.Media.MaxAudioMB = 1, 2
router := srv.Router()
claims, _ := auth.ValidateToken(testJWTSecret, token)
doc, _ := store.CreateDocument(claims.UserID, "", "Bilder", "🖼️", "", nil, nil, false)
img := tinyPNG(t, 0)
rec := mediaUpload(t, router, token, doc.ID, img, "image/png")
if rec.Code != http.StatusCreated {
t.Fatalf("upload: %d %s", rec.Code, rec.Body.String())
}
var up struct{ ID, Kind, Mime, URL string }
_ = json.Unmarshal(rec.Body.Bytes(), &up)
if up.Kind != "image" || up.Mime != "image/png" || up.URL != "/api/v1/media/"+up.ID {
t.Fatalf("unexpected upload answer: %+v", up)
}
get := sessionCall(router, "GET", up.URL, token, "", "")
if get.Code != http.StatusOK || get.Header().Get("Content-Type") != "image/png" || !bytes.Equal(get.Body.Bytes(), img) {
t.Fatalf("read back: %d %s", get.Code, get.Header().Get("Content-Type"))
}
// Someone without access to the note sees nothing and cannot upload
_, strangerToken := newTestUser(t, store, "fremd@example.com")
if rec := sessionCall(router, "GET", up.URL, strangerToken, "", ""); rec.Code != http.StatusNotFound {
t.Fatalf("stranger read: want 404, got %d", rec.Code)
}
if rec := mediaUpload(t, router, strangerToken, doc.ID, img, "image/png"); rec.Code != http.StatusForbidden {
t.Fatalf("stranger upload: want 403, got %d", rec.Code)
}
// Only real images and recordings, within the limits
if rec := mediaUpload(t, router, token, doc.ID, []byte("<svg onload=alert(1)>"), "image/svg+xml"); rec.Code != http.StatusUnsupportedMediaType {
t.Fatalf("svg: want 415, got %d", rec.Code)
}
if rec := mediaUpload(t, router, token, doc.ID, tinyPNG(t, 1<<20+10), "image/png"); rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("too large: want 413, got %d %s", rec.Code, rec.Body.String())
}
// External images must not point into the server's own network
if rec := sessionCall(router, "POST", "/api/v1/documents/"+doc.ID+"/media/fetch", token, `{"url":"https://127.0.0.1/bild.png"}`, ""); rec.Code != http.StatusBadRequest {
t.Fatalf("fetch from localhost: want 400, got %d", rec.Code)
}
}
func TestMediaQuota(t *testing.T) {
srv, store, token := setupTestServer(t)
srv.cfg.Media.MaxImageMB, srv.cfg.Media.QuotaMB = 2, 1
router := srv.Router()
claims, _ := auth.ValidateToken(testJWTSecret, token)
doc, _ := store.CreateDocument(claims.UserID, "", "Quota", "", "", nil, nil, false)
if rec := mediaUpload(t, router, token, doc.ID, tinyPNG(t, 700<<10), "image/png"); rec.Code != http.StatusCreated {
t.Fatalf("first upload: %d", rec.Code)
}
if rec := mediaUpload(t, router, token, doc.ID, tinyPNG(t, 700<<10), "image/png"); rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("over the quota: want 413, got %d", rec.Code)
}
}
func TestMediaCleanupAndCopiedBlocks(t *testing.T) {
srv, store, token := setupTestServer(t)
srv.cfg.Media.MaxImageMB, srv.cfg.Media.MaxAudioMB = 2, 2
router := srv.Router()
claims, _ := auth.ValidateToken(testJWTSecret, token)
doc, _ := store.CreateDocument(claims.UserID, "", "Quelle", "", "", nil, nil, false)
upload := func() string {
var up struct{ ID string }
_ = json.Unmarshal(mediaUpload(t, router, token, doc.ID, tinyPNG(t, 0), "image/png").Body.Bytes(), &up)
return up.ID
}
used, unused := upload(), upload()
// A second user's note shows the used image (block copied there); that user may read it
other, otherToken := newTestUser(t, store, "kopie@example.com")
_, _ = store.CreateDocument(other.ID, "", "Kopie", "", "", []domain.DocumentBlock{{ID: "blk_copy", Type: "image", Content: "![Foto](media:" + used + ")"}}, nil, false)
if rec := sessionCall(router, "GET", "/api/v1/media/"+used, otherToken, "", ""); rec.Code != http.StatusOK {
t.Fatalf("copied block: want 200, got %d", rec.Code)
}
// Within the grace time nothing is removed, afterwards only the unused file
srv.compactMedia(time.Now())
if _, err := store.GetMedia(unused); err != nil {
t.Fatal("fresh uploads must survive the grace time")
}
srv.compactMedia(time.Now().Add(2 * mediaGrace))
if _, err := store.GetMedia(unused); err == nil {
t.Fatal("unused file must be removed")
}
m, err := store.GetMedia(used)
if err != nil {
t.Fatal("referenced file must stay")
}
if _, err := os.Stat(srv.mediaPath(m)); err != nil {
t.Fatalf("referenced file missing on disk: %v", err)
}
}
func TestDetectMedia(t *testing.T) {
webm := append([]byte{0x1A, 0x45, 0xDF, 0xA3}, make([]byte, 40)...)
m4a := append([]byte{0, 0, 0, 0x20}, []byte("ftypM4A ")...)
m4a = append(m4a, make([]byte, 40)...)
webp := append([]byte("RIFF\x00\x00\x00\x00WEBPVP8 "), make([]byte, 40)...)
cases := []struct {
data []byte
declared string
wantMime, wantKd string
}{
{webm, "audio/webm;codecs=opus", "audio/webm", "audio"},
{webm, "", "audio/webm", "audio"},
{m4a, "audio/mp4", "audio/mp4", "audio"},
{webp, "", "image/webp", "image"},
}
for _, c := range cases {
mime, kind, ok := detectMedia(c.data, c.declared)
if !ok || mime != c.wantMime || kind != c.wantKd {
t.Fatalf("detect(%q): got %s %s %v", c.declared, mime, kind, ok)
}
}
if _, _, ok := detectMedia([]byte("#!/bin/sh\nrm -rf /"), "audio/webm"); ok {
t.Fatal("scripts must not pass as audio")
}
}
+123
View File
@@ -0,0 +1,123 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/collab"
"github.com/sandrodach/mindsift/internal/storage"
)
// Turning a branch into its own note and merging notes (storage/restructure.go). The editor sends its
// client ID, so its own block leases do not block the change.
func (s *Server) restructureActor(r *http.Request, clientID string) collab.Actor {
userID := auth.GetUserID(r.Context())
a := collab.Actor{UserID: userID, ClientID: clientID}
if u, err := s.store.GetUserByID(userID); err == nil {
a.Name = u.DisplayName
}
return a
}
func restructureStatus(err error) int {
switch {
case errors.Is(err, storage.ErrBranchLocked):
return http.StatusConflict
case errors.Is(err, storage.ErrBlockNotFound), errors.Is(err, storage.ErrDocNotFound):
return http.StatusNotFound
case errors.Is(err, storage.ErrMergeSelf), errors.Is(err, storage.ErrDocInTrash):
return http.StatusBadRequest
case errors.Is(err, storage.ErrMergeNotOwner):
return http.StatusForbidden
}
return http.StatusInternalServerError
}
// handleExtractBranch: POST /documents/{id}/extract {blockId, clientId}
func (s *Server) handleExtractBranch(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
if !storage.CanEditDoc(s.store.DocRole(userID, docID)) {
writeError(w, http.StatusForbidden, "Keine Schreibrechte für diese Notiz")
return
}
var req struct {
BlockID string `json:"blockId"`
ClientID string `json:"clientId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.BlockID == "" {
writeError(w, http.StatusBadRequest, "blockId fehlt")
return
}
created, err := s.store.ExtractBranch(userID, docID, req.BlockID, s.restructureActor(r, req.ClientID), s.leases.View(docID))
if err != nil {
writeError(w, restructureStatus(err), err.Error())
return
}
s.notifyDocChanged(docID, s.store.DocHead(docID))
if full, err := s.store.GetDocumentByID(userID, created.ID); err == nil {
created = full
}
s.broadcastDocToAuthorizedUsers(created.ID, "DOC_CREATED")
writeJSON(w, http.StatusCreated, created)
}
// handleMergeDocuments: POST /documents/{id}/merge {sourceId, clientId} appends the source to this note.
func (s *Server) handleMergeDocuments(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
targetID := chi.URLParam(r, "id")
if !storage.CanEditDoc(s.store.DocRole(userID, targetID)) {
writeError(w, http.StatusForbidden, "Keine Schreibrechte für die Zielnotiz")
return
}
var req struct {
SourceID string `json:"sourceId"`
ClientID string `json:"clientId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.SourceID == "" {
writeError(w, http.StatusBadRequest, "sourceId fehlt")
return
}
sourceUsers, _ := s.store.GetAuthorizedDocUsers(req.SourceID)
res, err := s.store.MergeDocuments(userID, req.SourceID, targetID, s.restructureActor(r, req.ClientID), s.leases.View(targetID))
if err != nil {
writeError(w, restructureStatus(err), err.Error())
return
}
s.notifyDocChanged(targetID, s.store.DocHead(targetID))
for _, uid := range sourceUsers {
s.wsHub.BroadcastToUser(uid, "DOC_DELETED", map[string]string{"id": req.SourceID})
}
writeJSON(w, http.StatusOK, res)
}
// handleUndoMerge: POST /documents/{id}/merge/undo {sourceId, headingId, clientId}
func (s *Server) handleUndoMerge(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
targetID := chi.URLParam(r, "id")
if !storage.CanEditDoc(s.store.DocRole(userID, targetID)) {
writeError(w, http.StatusForbidden, "Keine Schreibrechte für die Zielnotiz")
return
}
var req struct {
SourceID string `json:"sourceId"`
HeadingID string `json:"headingId"`
ClientID string `json:"clientId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.SourceID == "" || req.HeadingID == "" {
writeError(w, http.StatusBadRequest, "sourceId und headingId fehlen")
return
}
m := storage.MergeResult{TargetID: targetID, SourceID: req.SourceID, HeadingID: req.HeadingID}
if err := s.store.UndoMerge(userID, m, s.restructureActor(r, req.ClientID), s.leases.View(targetID)); err != nil {
writeError(w, restructureStatus(err), err.Error())
return
}
s.notifyDocChanged(targetID, s.store.DocHead(targetID))
s.broadcastDocToAuthorizedUsers(req.SourceID, "DOC_CREATED")
writeJSON(w, http.StatusOK, map[string]bool{"undone": true})
}
+154
View File
@@ -0,0 +1,154 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
"github.com/sandrodach/mindsift/internal/storage"
)
func flatOf(t *testing.T, store *storage.Store, userID, docID string) []string {
t.Helper()
d, err := store.GetDocumentByID(userID, docID)
if err != nil {
t.Fatal(err)
}
out := make([]string, 0, len(d.Blocks))
for _, b := range d.Blocks {
out = append(out, strings.Repeat(">", b.Level)+b.Content)
}
return out
}
func TestExtractBranchIntoOwnNote(t *testing.T) {
srv, store, token := setupTestServer(t)
router := srv.Router()
claims, _ := auth.ValidateToken(testJWTSecret, token)
uid := claims.UserID
src, _ := store.CreateDocument(uid, "", "Projekt", "", "Arbeit", []domain.DocumentBlock{
{ID: "blk_a", Type: "bullet", Content: "- Konzept: Garten #draußen", Level: 0, Position: 1},
{ID: "blk_b", Type: "bullet", Content: "Beete", Level: 1, Position: 2},
{ID: "blk_c", Type: "todo", Content: "Erde kaufen", Level: 2, Position: 3, Checked: true},
{ID: "blk_d", Type: "bullet", Content: "Zaun", Level: 1, Position: 4},
{ID: "blk_e", Type: "bullet", Content: "Danach", Level: 0, Position: 5},
}, nil, false)
rec := sessionCall(router, "POST", "/api/v1/documents/"+src.ID+"/extract", token, `{"blockId":"blk_a"}`, "")
if rec.Code != http.StatusCreated {
t.Fatalf("extract: %d %s", rec.Code, rec.Body.String())
}
var created domain.Document
_ = json.Unmarshal(rec.Body.Bytes(), &created)
if created.Title != "Konzept: Garten" || created.FolderPath != "Arbeit" {
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" {
t.Fatalf("new note blocks: %s", got)
}
if got := strings.Join(flatOf(t, store, uid, src.ID), "|"); got != "[[Konzept: Garten]] #draußen|Danach" {
t.Fatalf("source after extract: %s", got)
}
// 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)
docs, _ := store.GetDocuments(uid)
var p2 string
for _, d := range docs {
if d.Title == "Projekt 2" {
p2 = d.ID
}
}
var second domain.Document
_ = json.Unmarshal(sessionCall(router, "POST", "/api/v1/documents/"+p2+"/extract", token, `{"blockId":"blk_x"}`, "").Body.Bytes(), &second)
if second.Title != "Konzept: Garten (2)" {
t.Fatalf("duplicate title: %q", second.Title)
}
}
func TestExtractInSharedNoteKeepsAccess(t *testing.T) {
srv, store, _ := setupTestServer(t)
router := srv.Router()
owner, _ := newTestUser(t, store, "owner@example.com")
editor, editorToken := newTestUser(t, store, "editor@example.com")
reader, readerToken := newTestUser(t, store, "reader@example.com")
src, _ := store.CreateDocument(owner.ID, "", "Gemeinsam", "", "", []domain.DocumentBlock{
{ID: "blk_s1", Type: "bullet", Content: "Idee", Level: 0, Position: 1},
{ID: "blk_s2", Type: "bullet", Content: "Detail", Level: 1, Position: 2},
}, nil, false)
if _, err := store.CreateDocShare(owner.ID, src.ID, editor.ID, "write"); err != nil {
t.Fatal(err)
}
_, _ = store.CreateDocShare(owner.ID, src.ID, reader.ID, "read")
if rec := sessionCall(router, "POST", "/api/v1/documents/"+src.ID+"/extract", readerToken, `{"blockId":"blk_s1"}`, ""); rec.Code != http.StatusForbidden {
t.Fatalf("reader must not extract: %d", rec.Code)
}
rec := sessionCall(router, "POST", "/api/v1/documents/"+src.ID+"/extract", editorToken, `{"blockId":"blk_s1"}`, "")
var created domain.Document
_ = json.Unmarshal(rec.Body.Bytes(), &created)
if rec.Code != http.StatusCreated {
t.Fatalf("editor extract: %d %s", rec.Code, rec.Body.String())
}
if r := store.DocRole(editor.ID, created.ID); r != storage.DocRoleOwner {
t.Fatalf("editor should own the new note, got %q", r)
}
if r := store.DocRole(owner.ID, created.ID); !storage.CanEditDoc(r) {
t.Fatalf("source owner must be able to edit the new note, got %q", r)
}
if r := store.DocRole(reader.ID, created.ID); r == "" || storage.CanEditDoc(r) {
t.Fatalf("reader must read (not edit) the new note, got %q", r)
}
}
func TestMergeDocumentsAndUndo(t *testing.T) {
srv, store, token := setupTestServer(t)
router := srv.Router()
claims, _ := auth.ValidateToken(testJWTSecret, token)
uid := claims.UserID
source, _ := store.CreateDocument(uid, "", "Einkauf", "🛒", "", []domain.DocumentBlock{
{ID: "blk_m1", Type: "bullet", Content: "Milch", Level: 0, Position: 1},
{ID: "blk_m2", Type: "bullet", Content: "laktosefrei", Level: 1, Position: 2},
}, nil, false)
target, _ := store.CreateDocument(uid, "", "Haushalt", "", "", []domain.DocumentBlock{{ID: "blk_t1", Type: "bullet", Content: "Putzen", Level: 0}}, nil, false)
if rec := sessionCall(router, "POST", "/api/v1/documents/"+target.ID+"/merge", token, `{"sourceId":"`+target.ID+`"}`, ""); rec.Code != http.StatusBadRequest {
t.Fatalf("merge into itself: want 400, got %d", rec.Code)
}
rec := sessionCall(router, "POST", "/api/v1/documents/"+target.ID+"/merge", token, `{"sourceId":"`+source.ID+`"}`, "")
if rec.Code != http.StatusOK {
t.Fatalf("merge: %d %s", rec.Code, rec.Body.String())
}
var res storage.MergeResult
_ = json.Unmarshal(rec.Body.Bytes(), &res)
got := flatOf(t, store, uid, target.ID)
if len(got) != 4 || got[0] != "Putzen" || !strings.HasPrefix(got[1], "🛒 Einkauf (") || got[2] != ">Milch" || got[3] != ">>laktosefrei" {
t.Fatalf("target after merge: %v", got)
}
if d, _ := store.GetDocumentByID(uid, source.ID); d == nil || !d.IsArchived {
t.Fatal("source must be in the trash")
}
undo := `{"sourceId":"` + source.ID + `","headingId":"` + res.HeadingID + `"}`
if rec := sessionCall(router, "POST", "/api/v1/documents/"+target.ID+"/merge/undo", token, undo, ""); rec.Code != http.StatusOK {
t.Fatalf("undo: %d %s", rec.Code, rec.Body.String())
}
if got := strings.Join(flatOf(t, store, uid, target.ID), "|"); got != "Putzen" {
t.Fatalf("target after undo: %s", got)
}
if d, _ := store.GetDocumentByID(uid, source.ID); d == nil || d.IsArchived {
t.Fatal("source must be back")
}
// Someone else's note cannot be the source
other, otherToken := newTestUser(t, store, "andere@example.com")
own, _ := store.CreateDocument(other.ID, "", "Eigen", "", "", nil, nil, false)
if _, err := store.CreateDocShare(uid, target.ID, other.ID, "write"); err != nil {
t.Fatal(err)
}
if rec := sessionCall(router, "POST", "/api/v1/documents/"+own.ID+"/merge", otherToken, `{"sourceId":"`+target.ID+`"}`, ""); rec.Code != http.StatusForbidden {
t.Fatalf("foreign source: want 403, got %d %s", rec.Code, rec.Body.String())
}
}
+16 -1
View File
@@ -33,6 +33,7 @@ type Server struct {
demo demoState // periodic reset in demo mode (demo.go)
streamTickets streamTickets // one-time tickets for WebSocket/SSE (streamticket.go)
loginGuard *loginGuard // brute-force protection (loginguard.go)
voice *voiceService // self-hosted speech recognition (voice.go)
}
func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server {
@@ -45,6 +46,10 @@ func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS
notifier: collab.NewNotifier(),
}
srv.loginGuard = newLoginGuard(!cfg.DisableLoginProtection)
srv.voice = newVoiceService(cfg.Speech.MaxParallel)
if srv.voiceMode() == "server" {
go srv.startVoiceCheck()
}
wsHub.SetRoomAuthorizer(srv.canJoinRoom)
wsHub.SetOriginCheck(srv.streamOriginAllowed)
// Next occurrences of repeating tasks appear live on every connected client
@@ -84,7 +89,7 @@ func (s *Server) Router() http.Handler {
r.Use(s.clientIPMiddleware)
r.Use(s.securityHeaders)
r.Use(bodyLimit)
r.Use(s.bodyLimit)
r.Use(s.demoResetGate)
r.Use(middleware.Recoverer)
r.Use(s.apiLoggingMiddleware)
@@ -126,6 +131,15 @@ func (s *Server) Router() http.Handler {
r.Get("/api/v1/auth/me", s.handleMe)
r.Post("/api/v1/events/rooms", s.handleSSERooms)
r.Post("/api/v1/auth/stream-ticket", s.handleStreamTicket)
r.Post("/api/v1/voice/transcribe", s.handleVoiceTranscribe)
r.Post("/api/v1/documents/{id}/media", s.handleUploadMedia)
r.Post("/api/v1/documents/{id}/media/fetch", s.notInDemo(s.handleFetchMedia))
r.Get("/api/v1/media/{id}", s.handleGetMedia)
r.Post("/api/v1/documents/{id}/extract", s.handleExtractBranch)
r.Post("/api/v1/documents/{id}/merge", s.handleMergeDocuments)
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/{key}/{kind}", s.handleLinkPreviewPicture)
r.Get("/api/v1/auth/sessions", s.handleListSessions)
r.Delete("/api/v1/auth/sessions/{id}", s.notInDemo(s.handleEndSession))
r.Post("/api/v1/auth/logout", s.handleLogout)
@@ -271,6 +285,7 @@ func (s *Server) Router() http.Handler {
r.Get("/api/v1/admin/backups/{name}", s.handleAdminDownloadBackup)
r.Put("/api/v1/admin/settings", s.notInDemo(s.handleAdminUpdateSettings))
r.Get("/api/v1/admin/settings/sharing-impact", s.handleAdminSharingImpact)
r.Get("/api/v1/admin/voice", s.handleAdminVoiceStatus)
// Organizations decide who can see whom; only server admins manage them
r.Get("/api/v1/admin/organizations", s.handleAdminGetOrganizations)
+306
View File
@@ -0,0 +1,306 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"mime/multipart"
"net/http"
"net/textproto"
"path"
"strings"
"sync"
"time"
"github.com/sandrodach/mindsift/internal/auth"
)
// Self-hosted speech recognition: the browser records audio and sends it here, MindSift passes it on
// to an OpenAI-compatible Whisper server (STT_URL, e.g. speaches) and returns the text. Nothing is
// stored. A limited number of transcriptions run at the same time (STT_MAX_PARALLEL), the others
// wait in line; each user has an hourly budget (STT_PER_USER_PER_HOUR). Problems are logged, the
// browser only gets a short message.
type voiceService struct {
slots chan struct{}
mu sync.Mutex
usage map[string][]time.Time // userID -> transcriptions within the last hour
status voiceStatus
checked time.Time
}
// voiceStatus is what the admin page shows about the Whisper server.
type voiceStatus struct {
Reachable bool `json:"reachable"`
ModelReady bool `json:"modelReady"`
Error string `json:"error,omitempty"`
CheckedAt string `json:"checkedAt,omitempty"`
}
func newVoiceService(parallel int) *voiceService {
if parallel < 1 {
parallel = 1
}
return &voiceService{slots: make(chan struct{}, parallel), usage: map[string][]time.Time{}}
}
func (s *Server) voiceMode() string {
return s.cfg.Speech.Mode(s.cfg.DemoMode)
}
// allowVoice counts one transcription for the user; false when the hourly budget is used up.
func (v *voiceService) allowVoice(userID string, perHour int, now time.Time) bool {
v.mu.Lock()
defer v.mu.Unlock()
recent := v.usage[userID][:0]
for _, t := range v.usage[userID] {
if now.Sub(t) < time.Hour {
recent = append(recent, t)
}
}
if len(recent) >= perHour {
v.usage[userID] = recent
return false
}
v.usage[userID] = append(recent, now)
return true
}
// sttEndpoint joins the base URL with an API path; a base URL ending in /v1 works as well.
func (s *Server) sttEndpoint(p string) string {
base := strings.TrimSuffix(s.cfg.Speech.URL, "/v1")
return base + p
}
func (s *Server) sttRequest(ctx context.Context, method, p string, body io.Reader, contentType string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, s.sttEndpoint(p), body)
if err != nil {
return nil, err
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
if s.cfg.Speech.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+s.cfg.Speech.APIKey)
}
return http.DefaultClient.Do(req)
}
func (s *Server) handleVoiceTranscribe(w http.ResponseWriter, r *http.Request) {
if s.voiceMode() != "server" {
writeError(w, http.StatusNotFound, "Serverseitige Spracherkennung ist nicht eingerichtet")
return
}
cfg := s.cfg.Speech
userID := auth.GetUserID(r.Context())
if !s.voice.allowVoice(userID, cfg.PerUserPerHour, time.Now()) {
writeError(w, http.StatusTooManyRequests, "Stundenlimit für die Spracherkennung erreicht. Bitte später erneut versuchen.")
return
}
allowLongTransfer(w)
file, header, err := r.FormFile("file")
if err != nil {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
writeError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("Aufnahme zu groß (höchstens %d MB)", cfg.MaxUploadMB))
return
}
writeError(w, http.StatusBadRequest, "Keine Aufnahme im Feld \"file\"")
return
}
defer file.Close()
audio, err := io.ReadAll(file)
if err != nil || len(audio) == 0 {
writeError(w, http.StatusBadRequest, "Aufnahme konnte nicht gelesen werden")
return
}
// Wait for a free slot; the request may be cancelled meanwhile (user stopped waiting)
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(cfg.TimeoutSeconds)*time.Second)
defer cancel()
select {
case s.voice.slots <- struct{}{}:
defer func() { <-s.voice.slots }()
case <-ctx.Done():
slog.Warn("Spracherkennung: keine freie Kapazität innerhalb der Zeitgrenze", "user_id", userID, "parallel", cfg.MaxParallel)
writeError(w, http.StatusServiceUnavailable, "Die Spracherkennung ist gerade ausgelastet. Bitte gleich noch einmal versuchen.")
return
}
started := time.Now()
text, err := s.transcribe(ctx, audio, header.Filename, header.Header.Get("Content-Type"))
if err != nil {
slog.Warn("Spracherkennung fehlgeschlagen", "url", s.sttEndpoint("/v1/audio/transcriptions"), "model", cfg.Model,
"bytes", len(audio), "duration_ms", time.Since(started).Milliseconds(), "error", err)
msg := "Spracherkennung fehlgeschlagen"
if errors.Is(err, context.DeadlineExceeded) {
msg = "Spracherkennung hat zu lange gedauert"
}
writeError(w, http.StatusBadGateway, msg)
return
}
slog.Debug("Spracherkennung", "user_id", userID, "bytes", len(audio), "duration_ms", time.Since(started).Milliseconds())
writeJSON(w, http.StatusOK, map[string]string{"text": text})
}
// transcribe sends the audio to the Whisper server (OpenAI API: POST /v1/audio/transcriptions).
func (s *Server) transcribe(ctx context.Context, audio []byte, filename, contentType string) (string, error) {
var body bytes.Buffer
mw := multipart.NewWriter(&body)
if filename == "" {
filename = "aufnahme.webm"
}
part := textproto.MIMEHeader{}
part.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, path.Base(strings.ReplaceAll(filename, `"`, ""))))
if contentType == "" {
contentType = "application/octet-stream"
}
part.Set("Content-Type", contentType)
fw, err := mw.CreatePart(part)
if err != nil {
return "", err
}
if _, err := fw.Write(audio); err != nil {
return "", err
}
_ = mw.WriteField("model", s.cfg.Speech.Model)
if lang := s.cfg.Speech.Language; lang != "" && lang != "auto" {
_ = mw.WriteField("language", lang)
}
_ = mw.WriteField("response_format", "json")
if err := mw.Close(); err != nil {
return "", err
}
resp, err := s.sttRequest(ctx, http.MethodPost, "/v1/audio/transcriptions", &body, mw.FormDataContentType())
if err != nil {
return "", err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
var out struct {
Text string `json:"text"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return "", fmt.Errorf("unerwartete Antwort: %s", strings.TrimSpace(string(raw)))
}
return strings.TrimSpace(out.Text), nil
}
// checkVoice asks the Whisper server whether it is up and has the model.
func (s *Server) checkVoice(ctx context.Context) voiceStatus {
st := voiceStatus{CheckedAt: time.Now().UTC().Format(time.RFC3339)}
resp, err := s.sttRequest(ctx, http.MethodGet, "/v1/models", nil, "")
if err != nil {
st.Error = err.Error()
return st
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
st.Error = fmt.Sprintf("GET /v1/models: HTTP %d", resp.StatusCode)
return st
}
st.Reachable = true
var list struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
_ = json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&list)
for _, m := range list.Data {
if m.ID == s.cfg.Speech.Model {
st.ModelReady = true
}
}
if !st.ModelReady {
st.Error = "Modell ist noch nicht heruntergeladen"
}
return st
}
func (s *Server) setVoiceStatus(st voiceStatus) {
s.voice.mu.Lock()
s.voice.status = st
s.voice.checked = time.Now()
s.voice.mu.Unlock()
}
// startVoiceCheck waits for the Whisper server after startup (it may start later than MindSift) and
// asks it to download the model if it does not have it yet, so the first recording works.
func (s *Server) startVoiceCheck() {
cfg := s.cfg.Speech
slog.Info("Spracherkennung: Server-Modus", "url", cfg.URL, "model", cfg.Model, "language", cfg.Language,
"max_parallel", cfg.MaxParallel, "per_user_per_hour", cfg.PerUserPerHour, "max_upload_mb", cfg.MaxUploadMB)
downloadRequested := false
for attempt := 0; ; attempt++ {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
st := s.checkVoice(ctx)
cancel()
s.setVoiceStatus(st)
switch {
case st.ModelReady:
slog.Info("Spracherkennung bereit", "url", cfg.URL, "model", cfg.Model)
return
case st.Reachable && !downloadRequested:
downloadRequested = true
slog.Info("Spracherkennung: Modell wird heruntergeladen (kann einige Minuten dauern)", "model", cfg.Model)
go s.requestModelDownload()
case !st.Reachable && attempt%6 == 0:
slog.Warn("Spracherkennung: Whisper-Server nicht erreichbar, neuer Versuch folgt", "url", cfg.URL, "error", st.Error)
}
time.Sleep(10 * time.Second)
}
}
func (s *Server) requestModelDownload() {
// Model IDs contain a slash (owner/name); speaches takes them as a path
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
defer cancel()
resp, err := s.sttRequest(ctx, http.MethodPost, "/v1/models/"+s.cfg.Speech.Model, nil, "")
if err != nil {
slog.Warn("Spracherkennung: Modell-Download fehlgeschlagen", "model", s.cfg.Speech.Model, "error", err)
return
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode >= 300 {
slog.Warn("Spracherkennung: Modell-Download abgelehnt", "model", s.cfg.Speech.Model, "status", resp.StatusCode, "body", strings.TrimSpace(string(raw)))
}
}
// handleAdminVoiceStatus shows the admin which speech recognition is active (configured by
// environment only) and checks the Whisper server right now.
func (s *Server) handleAdminVoiceStatus(w http.ResponseWriter, r *http.Request) {
cfg := s.cfg.Speech
out := map[string]interface{}{
"mode": s.voiceMode(),
"demo": s.cfg.DemoMode,
"maxSeconds": cfg.MaxSeconds,
"maxUploadMb": cfg.MaxUploadMB,
"maxParallel": cfg.MaxParallel,
"perUserPerHour": cfg.PerUserPerHour,
"timeoutSeconds": cfg.TimeoutSeconds,
}
if cfg.URL != "" {
out["url"] = cfg.URL
out["model"] = cfg.Model
out["language"] = cfg.Language
out["apiKeySet"] = cfg.APIKey != ""
}
if s.voiceMode() == "server" {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
st := s.checkVoice(ctx)
cancel()
s.setVoiceStatus(st)
out["status"] = st
}
writeJSON(w, http.StatusOK, out)
}
+133
View File
@@ -0,0 +1,133 @@
package api
import (
"bytes"
"context"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"github.com/sandrodach/mindsift/internal/config"
)
// fakeWhisper answers like speaches: model list and OpenAI-style transcriptions.
func fakeWhisper(t *testing.T, models []string, got *map[string]string, downloads *int32) *httptest.Server {
t.Helper()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/v1/models":
var data []map[string]string
for _, m := range models {
data = append(data, map[string]string{"id": m})
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"data": data})
case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/v1/models/"):
atomic.AddInt32(downloads, 1)
w.WriteHeader(http.StatusCreated)
case r.Method == http.MethodPost && r.URL.Path == "/v1/audio/transcriptions":
if r.Header.Get("Authorization") != "Bearer geheim" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
_ = r.ParseMultipartForm(10 << 20)
f, _, _ := r.FormFile("file")
audio, _ := io.ReadAll(f)
*got = map[string]string{"model": r.FormValue("model"), "language": r.FormValue("language"), "audio": string(audio)}
_ = json.NewEncoder(w).Encode(map[string]string{"text": " Hallo Welt, wie geht's? "})
default:
http.NotFound(w, r)
}
}))
t.Cleanup(ts.Close)
return ts
}
func voiceUpload(t *testing.T, router http.Handler, token string, audio []byte) *httptest.ResponseRecorder {
t.Helper()
var body bytes.Buffer
mw := multipart.NewWriter(&body)
fw, _ := mw.CreateFormFile("file", "aufnahme.webm")
_, _ = fw.Write(audio)
_ = mw.Close()
req := httptest.NewRequest("POST", "/api/v1/voice/transcribe", &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func voiceServer(t *testing.T, speech config.SpeechConfig) (*Server, string) {
t.Helper()
srv, _, token := setupTestServer(t)
srv.cfg.Speech = speech
srv.voice = newVoiceService(speech.MaxParallel)
return srv, token
}
func TestVoiceTranscribeForwardsToWhisper(t *testing.T) {
var got map[string]string
var downloads int32
whisper := fakeWhisper(t, []string{"Systran/faster-whisper-small"}, &got, &downloads)
srv, token := voiceServer(t, config.SpeechConfig{URL: whisper.URL + "/v1", Model: "Systran/faster-whisper-small", Language: "de",
APIKey: "geheim", MaxSeconds: 600, MaxUploadMB: 1, MaxParallel: 1, PerUserPerHour: 2, TimeoutSeconds: 10})
router := srv.Router()
rec := voiceUpload(t, router, token, []byte("RIFF-audio"))
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"text":"Hallo Welt, wie geht's?"`) {
t.Fatalf("transcribe: %d %s", rec.Code, rec.Body.String())
}
if got["model"] != "Systran/faster-whisper-small" || got["language"] != "de" || got["audio"] != "RIFF-audio" {
t.Fatalf("forwarded wrong data: %+v", got)
}
// Hourly budget per user
_ = voiceUpload(t, router, token, []byte("x"))
if rec := voiceUpload(t, router, token, []byte("x")); rec.Code != http.StatusTooManyRequests {
t.Fatalf("over the hourly budget: want 429, got %d", rec.Code)
}
// Model already there: the startup check reports ready and requests no download
st := srv.checkVoice(context.Background())
if !st.Reachable || !st.ModelReady || downloads != 0 {
t.Fatalf("status %+v, downloads %d", st, downloads)
}
// Status for the login screen and the admin card, without the API key
if body := sessionCall(router, "GET", "/api/v1/auth/status", "", "", "").Body.String(); !strings.Contains(body, `"mode":"server"`) {
t.Fatalf("auth status: %s", body)
}
body := sessionCall(router, "GET", "/api/v1/admin/voice", token, "", "").Body.String()
if !strings.Contains(body, `"modelReady":true`) || strings.Contains(body, "geheim") {
t.Fatalf("admin voice status: %s", body)
}
}
func TestVoiceUploadLimitAndModes(t *testing.T) {
var got map[string]string
var downloads int32
whisper := fakeWhisper(t, nil, &got, &downloads)
srv, token := voiceServer(t, config.SpeechConfig{URL: whisper.URL, Model: "m", MaxUploadMB: 1, MaxParallel: 1, PerUserPerHour: 10, TimeoutSeconds: 10})
if rec := voiceUpload(t, srv.Router(), token, bytes.Repeat([]byte("x"), 3<<20)); rec.Code != http.StatusRequestEntityTooLarge && rec.Code != http.StatusBadRequest {
t.Fatalf("too large: want 413/400, got %d", rec.Code)
}
if st := srv.checkVoice(context.Background()); st.ModelReady || !st.Reachable {
t.Fatalf("missing model must be reported: %+v", st)
}
if m := (config.SpeechConfig{URL: "http://whisper:8000"}).Mode(true); m != "browser" {
t.Fatalf("demo must use the browser, got %s", m)
}
if m := (config.SpeechConfig{}).Mode(false); m != "browser" {
t.Fatalf("without URL: browser, got %s", m)
}
if m := (config.SpeechConfig{URL: "x", Disabled: true}).Mode(false); m != "off" {
t.Fatalf("disabled: off, got %s", m)
}
browserSrv, browserToken := voiceServer(t, config.SpeechConfig{MaxParallel: 1, PerUserPerHour: 10})
if rec := voiceUpload(t, browserSrv.Router(), browserToken, []byte("x")); rec.Code != http.StatusNotFound {
t.Fatalf("browser mode: want 404, got %d", rec.Code)
}
}
+79 -1
View File
@@ -37,12 +37,57 @@ type Config struct {
SMTPUser string
SMTPPass string
SMTPFrom string
SMTPTLS bool
// SMTPFromName is the sender's display name when SMTP_FROM is a bare address
SMTPFromName string
SMTPTLS bool
// SMTPSkipVerify accepts self-signed or mismatching certificates (internal mail servers)
SMTPSkipVerify bool
// Legal notices (Impressum, Datenschutz): an external URL and/or Markdown text, shown even before login
Legal LegalTexts
// Speech recognition, configured only by environment (STT_*)
Speech SpeechConfig
// Images and audio in notes (MEDIA_*)
Media MediaConfig
}
// MediaConfig limits uploads of images and audio recordings in notes. Sizes in MB; QuotaMB 0 = no limit
// per user. The demo uses DemoMaxMB for every file.
type MediaConfig struct {
MaxImageMB int
MaxAudioMB int
QuotaMB int
DemoMaxMB int
}
// SpeechConfig: with STT_URL set, voice input is transcribed by a self-hosted, OpenAI-compatible
// Whisper server (e.g. speaches); otherwise the browser's own speech recognition is used.
type SpeechConfig struct {
URL string // base URL, e.g. http://whisper:8000
Model string
Language string
APIKey string
Disabled bool // STT_DISABLED=true hides voice input everywhere
MaxSeconds int // recordings stop after this long (enforced in the browser)
MaxUploadMB int
MaxParallel int // transcriptions running at the same time; more wait in line
PerUserPerHour int
TimeoutSeconds int
}
// Mode is "server", "browser" or "off". The demo never uses the server, so visitors cannot keep
// its CPU busy.
func (s SpeechConfig) Mode(demo bool) string {
switch {
case s.Disabled:
return "off"
case s.URL != "" && !demo:
return "server"
default:
return "browser"
}
}
type LegalTexts struct {
@@ -177,8 +222,27 @@ func Load() *Config {
SMTPUser: smtpUser,
SMTPPass: smtpPass,
SMTPFrom: smtpFrom,
SMTPFromName: getEnv("SMTP_FROM_NAME", "MindSift"),
SMTPTLS: smtpTLS,
SMTPSkipVerify: smtpSkipVerify,
Speech: SpeechConfig{
URL: strings.TrimRight(getEnv("STT_URL", ""), "/"),
Model: getEnv("STT_MODEL", "deepdml/faster-whisper-large-v3-turbo-ct2"),
Language: getEnv("STT_LANGUAGE", "de"),
APIKey: getEnv("STT_API_KEY", ""),
Disabled: strings.ToLower(getEnv("STT_DISABLED", "false")) == "true",
MaxSeconds: envInt("STT_MAX_SECONDS", 600, 5, 3600),
MaxUploadMB: envInt("STT_MAX_UPLOAD_MB", 25, 1, 500),
MaxParallel: envInt("STT_MAX_PARALLEL", 1, 1, 32),
PerUserPerHour: envInt("STT_PER_USER_PER_HOUR", 60, 1, 100000),
TimeoutSeconds: envInt("STT_TIMEOUT_SECONDS", 120, 5, 3600),
},
Media: MediaConfig{
MaxImageMB: envInt("MEDIA_MAX_IMAGE_MB", 15, 1, 1000),
MaxAudioMB: envInt("MEDIA_MAX_AUDIO_MB", 50, 1, 2000),
QuotaMB: envInt("MEDIA_QUOTA_MB", 0, 0, 10000000),
DemoMaxMB: envInt("MEDIA_DEMO_MAX_MB", 3, 1, 100),
},
Legal: LegalTexts{
ImprintURL: legalURL("LEGAL_IMPRINT_URL"),
ImprintContent: legalContent("LEGAL_IMPRINT_CONTENT"),
@@ -209,6 +273,20 @@ func getEnv(key, fallback string) string {
return fallback
}
// envInt reads a whole number within [min, max]; anything else falls back to def (with a warning).
func envInt(key string, def, min, max int) int {
raw := getEnv(key, "")
if raw == "" {
return def
}
n, err := strconv.Atoi(raw)
if err != nil || n < min || n > max {
slog.Warn("Ungültiger Wert, Standard wird verwendet", "variable", key, "value", raw, "default", def)
return def
}
return n
}
func demoResetMinutes() int {
n, err := strconv.Atoi(strings.TrimSpace(getEnv("DEMO_RESET_MINUTES", "20")))
if err != nil || n < 0 {
+5 -3
View File
@@ -287,9 +287,11 @@ type SystemSettings struct {
SMTPUser string `json:"smtpUser"`
// SMTPPass is never sent to the browser (FLAW-34): reads leave it empty and set SMTPPassSet;
// writing it empty keeps the stored password
SMTPPass string `json:"smtpPass"`
SMTPPassSet bool `json:"smtpPassSet,omitempty"`
SMTPFrom string `json:"smtpFrom"`
SMTPPass string `json:"smtpPass"`
SMTPPassSet bool `json:"smtpPassSet,omitempty"`
SMTPFrom string `json:"smtpFrom"`
// SMTPFromName is shown as sender when SMTPFrom is a bare address (empty = "MindSift")
SMTPFromName string `json:"smtpFromName"`
SMTPTLS bool `json:"smtpTls"`
SMTPSkipVerify bool `json:"smtpSkipVerify"` // accept self-signed certificates
// AllowPrivateWebhooks lets alarm webhooks reach LAN addresses (and plain http); localhost and cloud
+1 -1
View File
@@ -123,7 +123,7 @@ func (s *Store) WriteUserBackup(userID, dir string, now time.Time) (string, erro
return "", err
}
name := "mindsift-backup-" + now.Format(backupStampFmt) + ".zip"
return name, writeFileAtomically(filepath.Join(dir, name), func(w io.Writer) error { return WriteBackupZip(exp, w) })
return name, writeFileAtomically(filepath.Join(dir, name), func(w io.Writer) error { return s.WriteBackupZip(userID, exp, w) })
}
func gzipFile(src, dst string) error {
+39 -2
View File
@@ -32,15 +32,36 @@ func (s *Store) WriteMarkdownVault(userID string, w io.Writer) error {
}
}
// Images and recordings go to attachments/, notes link them relative to their folder
attachments := map[string]string{} // media ID -> path in the archive
if media, err := s.MediaOfUser(userID); err == nil {
for _, m := range media {
attachments[m.ID] = vaultAttachmentDir + "/" + m.ID + MediaExt(m.Mime)
}
}
if err := s.writeMediaFiles(zw, userID, func(m Media) string { return attachments[m.ID] }); err != nil {
return err
}
docs, err := s.GetDocuments(userID)
if err != nil {
return err
}
for _, d := range docs {
name := names.file(names.dir(d.FolderPath), d.Title, "Unbenannte Notiz")
dir := names.dir(d.FolderPath)
name := names.file(dir, d.Title, "Unbenannte Notiz")
up := ""
if dir != "" {
up = strings.Repeat("../", strings.Count(dir, "/")+1)
}
body := markdownFrontmatter(map[string]any{
"title": d.Title, "icon": d.Icon, "tags": d.Tags, "created_at": d.CreatedAt, "updated_at": d.UpdatedAt,
}) + BlocksToVaultMarkdown(d.Blocks)
}) + blocksToVaultMarkdown(d.Blocks, func(id string) string {
if p, ok := attachments[id]; ok {
return up + p
}
return ""
})
if err := writeZipFile(zw, name, body, parseISO(d.UpdatedAt, now)); err != nil {
return err
}
@@ -164,7 +185,16 @@ func taskMarkdownLine(t domain.Thought) string {
// BlocksToVaultMarkdown turns outliner blocks into Markdown that Obsidian renders natively:
// top-level headings, dividers and callouts as real block elements, everything else as a nested
// list (one tab per level), code as fenced blocks inside the list.
// vaultAttachmentDir holds images and recordings in the Markdown archive.
const vaultAttachmentDir = "attachments"
func BlocksToVaultMarkdown(blocks []domain.DocumentBlock) string {
return blocksToVaultMarkdown(blocks, nil)
}
// blocksToVaultMarkdown renders blocks as Markdown; attachment maps a media ID to its relative path
// (image and audio blocks become embeds Obsidian shows inline).
func blocksToVaultMarkdown(blocks []domain.DocumentBlock, attachment func(id string) string) string {
var sb strings.Builder
// Children of a top-level heading/callout/code/divider become a list one level up; otherwise their
// tab indent would turn them into an indented code block in Markdown
@@ -178,6 +208,13 @@ func BlocksToVaultMarkdown(blocks []domain.DocumentBlock) string {
}
indent := strings.Repeat("\t", lvl)
content := strings.ReplaceAll(b.Content, "\r\n", "\n")
if (b.Type == "image" || b.Type == "audio") && attachment != nil {
if ref, ok := ParseMediaContent(content); ok {
if p := attachment(ref.ID); p != "" {
content = "![" + ref.Caption + "](" + strings.ReplaceAll(p, " ", "%20") + ")"
}
}
}
// Soft line breaks continue the list item, aligned under its text
cont := func(text, prefix string) string {
return strings.ReplaceAll(text, "\n", "\n"+indent+prefix)
+235 -9
View File
@@ -7,7 +7,10 @@ import (
"errors"
"fmt"
"io"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
@@ -23,6 +26,7 @@ const (
maxImportFileBytes = 10 << 20 // one unpacked note
maxImportUnpackedBytes = 500 << 20 // all unpacked notes together
maxImportFiles = 20000
maxImportMediaBytes = 100 << 20 // one image or recording
)
// ImportPlan is everything an import would create. It is built completely (and validated) before
@@ -35,13 +39,15 @@ type ImportPlan struct {
Inbox []importTask `json:"-"`
Themes []domain.UserTheme `json:"-"`
Skipped []ImportSkip `json:"skipped"`
// Images and recordings of the archive: "media:<id>" (own backup) or the path in the archive
Media map[string][]byte `json:"-"`
}
// Only keeps the chosen sections of the plan (see NormalizeSections).
func (p *ImportPlan) Only(sections map[string]bool) *ImportPlan {
out := &ImportPlan{Skipped: p.Skipped}
if sections[SectionNotes] {
out.Folders, out.Docs = p.Folders, p.Docs
out.Folders, out.Docs, out.Media = p.Folders, p.Docs, p.Media
}
if sections[SectionTasks] {
out.TaskLists = p.TaskLists
@@ -79,6 +85,7 @@ type ImportSummary struct {
type importDoc struct {
folder, title, icon string
dir string // folder of the note inside the archive, for relative image links
blocks []domain.DocumentBlock
}
@@ -161,7 +168,23 @@ func readZipNotes(r io.ReaderAt, size int64, plan *ImportPlan) ([]zipNote, []str
continue
}
if !strings.EqualFold(path.Ext(name), ".md") {
plan.skip(name, "kein Markdown (Anhänge wie Bilder oder PDFs werden nicht importiert)")
if !importMediaExt[strings.ToLower(path.Ext(name))] {
plan.skip(name, "kein Markdown und kein Bild/Audio (z. B. PDFs werden nicht importiert)")
continue
}
if f.UncompressedSize64 > maxImportMediaBytes {
plan.skip(name, "zu groß (max. 100 MB pro Bild oder Aufnahme)")
continue
}
data, err := readZipFile(f, maxImportMediaBytes)
if err != nil {
plan.skip(name, "nicht lesbar")
continue
}
if unpacked += int64(len(data)); unpacked > maxImportUnpackedBytes {
return nil, nil, "", errors.New("Archiv ist entpackt zu groß (max. 500 MB)")
}
plan.addMedia(name, data)
continue
}
if f.UncompressedSize64 > maxImportFileBytes {
@@ -272,8 +295,9 @@ func PlanObsidianImport(r io.ReaderAt, size int64, fileName string) (*ImportPlan
if extra := missingTags(fm.tags, body); extra != "" {
blocks = append(blocks, newBlock("bullet", extra, 0))
}
plan.Docs = append(plan.Docs, importDoc{folder: folder, title: title, icon: fm.icon, blocks: blocks})
plan.Docs = append(plan.Docs, importDoc{folder: folder, title: title, icon: fm.icon, dir: path.Dir(n.path), blocks: blocks})
}
plan.reportUnusedMedia()
plan.Folders = sortedKeys(folders)
if len(plan.Docs) == 0 {
return nil, errors.New("Im Archiv wurden keine Markdown-Notizen gefunden")
@@ -315,9 +339,10 @@ func PlanMindSiftArchiveImport(r io.ReaderAt, size int64) (*ImportPlan, error) {
if folder != "" {
folders[folder] = true
}
plan.Docs = append(plan.Docs, importDoc{folder: folder, title: title, icon: fm.icon, blocks: MarkdownToBlocks(body)})
plan.Docs = append(plan.Docs, importDoc{folder: folder, title: title, icon: fm.icon, dir: path.Dir(n.path), blocks: MarkdownToBlocks(body)})
}
}
plan.reportUnusedMedia()
plan.Folders = sortedKeys(folders)
if len(plan.Docs)+len(plan.TaskLists)+len(plan.Lists) == 0 {
return nil, errors.New("Das Archiv enthält keine MindSift-Daten (Notizen, Aufgaben oder Listen)")
@@ -328,10 +353,12 @@ func PlanMindSiftArchiveImport(r io.ReaderAt, size int64) (*ImportPlan, error) {
// BackupFileName is the JSON inside the backup ZIP.
const BackupFileName = "mindsift-backup.json"
// WriteBackupZip streams the JSON backup compressed into a ZIP with a single file.
func WriteBackupZip(exp *domain.FullExport, w io.Writer) error {
// WriteBackupZip streams the JSON backup compressed into a ZIP, plus the images and recordings of the
// user's notes as media/<id><ext> (blocks point to them as media:<id>).
func (s *Store) WriteBackupZip(userID string, exp *domain.FullExport, w io.Writer) error {
zw := zip.NewWriter(w)
f, err := zw.CreateHeader(&zip.FileHeader{Name: BackupFileName, Method: zip.Deflate, Modified: parseISO(exp.ExportedAt, time.Now())})
modified := parseISO(exp.ExportedAt, time.Now())
f, err := zw.CreateHeader(&zip.FileHeader{Name: BackupFileName, Method: zip.Deflate, Modified: modified})
if err != nil {
return err
}
@@ -340,9 +367,36 @@ func WriteBackupZip(exp *domain.FullExport, w io.Writer) error {
if err := enc.Encode(exp); err != nil {
return err
}
if err := s.writeMediaFiles(zw, userID, func(m Media) string { return "media/" + m.ID + MediaExt(m.Mime) }); err != nil {
return err
}
return zw.Close()
}
// writeMediaFiles adds the user's media files to an archive (already compressed formats: stored as is).
// Files missing on disk are left out.
func (s *Store) writeMediaFiles(zw *zip.Writer, userID string, name func(Media) string) error {
media, err := s.MediaOfUser(userID)
if err != nil {
return err
}
for _, m := range media {
src, err := os.Open(s.MediaFilePath(&m))
if err != nil {
continue
}
dst, err := zw.CreateHeader(&zip.FileHeader{Name: name(m), Method: zip.Store, Modified: parseISO(m.CreatedAt, time.Now())})
if err == nil {
_, err = io.Copy(dst, src)
}
src.Close()
if err != nil {
return err
}
}
return nil
}
// PlanMindSiftImport picks the format of MindSift's own data: plain JSON backup, the backup ZIP
// (one JSON inside) or the Markdown archive.
func PlanMindSiftImport(data []byte, fileName string) (*ImportPlan, error) {
@@ -367,6 +421,22 @@ func PlanMindSiftImport(data []byte, fileName string) (*ImportPlan, error) {
if backup == nil {
return PlanMindSiftArchiveImport(bytes.NewReader(data), int64(len(data)))
}
// Images and recordings next to the JSON: media/<id><ext>
media := map[string][]byte{}
var mediaBytes int64
for _, f := range zr.File {
name := cleanArchivePath(f.Name)
if !strings.HasPrefix(name, "media/") || f.FileInfo().IsDir() || f.UncompressedSize64 > maxImportMediaBytes {
continue
}
if mediaBytes += int64(f.UncompressedSize64); mediaBytes > maxImportUnpackedBytes {
return nil, errors.New("Backup ist entpackt zu groß (max. 500 MB)")
}
if b, err := readZipFile(f, maxImportMediaBytes); err == nil {
base := path.Base(name)
media["media:"+strings.TrimSuffix(base, path.Ext(base))] = b
}
}
if backup.UncompressedSize64 > maxImportUnpackedBytes {
return nil, errors.New("Backup ist entpackt zu groß (max. 500 MB)")
}
@@ -379,7 +449,14 @@ func PlanMindSiftImport(data []byte, fileName string) (*ImportPlan, error) {
if err != nil || len(raw) > maxImportUnpackedBytes {
return nil, errors.New("Backup im Archiv ist nicht lesbar oder zu groß")
}
return PlanMindSiftJSONImport(raw)
plan, err := PlanMindSiftJSONImport(raw)
if err != nil {
return nil, err
}
if len(media) > 0 {
plan.Media = media
}
return plan, nil
}
// PlanMindSiftJSONImport reads the JSON backup ("Backup herunterladen"). Notes shared with the
@@ -461,7 +538,12 @@ func (s *Store) ApplyImport(userID string, plan *ImportPlan) (ImportSummary, err
if icon == "" {
icon = "📄"
}
if _, err := s.CreateDocument(userID, "", title, icon, d.folder, d.blocks, nil, false); err != nil {
blocks, files := linkImportMedia(d, plan.Media, nil)
doc, err := s.CreateDocument(userID, "", title, icon, d.folder, blocks, nil, false)
if err != nil {
return plan.Summary(false), fmt.Errorf("Notiz %q: %w", title, err)
}
if err := s.storeImportMedia(userID, doc.ID, files); err != nil {
return plan.Summary(false), fmt.Errorf("Notiz %q: %w", title, err)
}
}
@@ -902,3 +984,147 @@ func sortedKeys(m map[string]bool) []string {
sort.Strings(out)
return out
}
// --- Images and recordings ----------------------------------------------------------------------------
var importMediaExt = map[string]bool{".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".webp": true,
".webm": true, ".ogg": true, ".oga": true, ".mp3": true, ".wav": true, ".m4a": true, ".aac": true}
// reportUnusedMedia lists files no note shows (or that are no valid image/recording) as skipped in the
// preview and drops them from the plan.
func (p *ImportPlan) reportUnusedMedia() {
if len(p.Media) == 0 {
return
}
used := map[string]bool{}
for _, d := range p.Docs {
linkImportMedia(d, p.Media, used)
}
for _, k := range sortedKeys(keySet(p.Media)) {
if used[k] {
continue
}
if _, _, ok := DetectMedia(p.Media[k], ""); !ok {
p.skip(k, "kein gültiges Bild bzw. keine gültige Aufnahme")
} else {
p.skip(k, "wird in keiner Notiz angezeigt")
}
delete(p.Media, k)
}
}
func keySet(m map[string][]byte) map[string]bool {
out := make(map[string]bool, len(m))
for k := range m {
out[k] = true
}
return out
}
func (p *ImportPlan) addMedia(name string, data []byte) {
if p.Media == nil {
p.Media = map[string][]byte{}
}
p.Media[name] = data
}
func readZipFile(f *zip.File, limit int64) ([]byte, error) {
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
data, err := io.ReadAll(io.LimitReader(rc, limit+1))
if err != nil || int64(len(data)) > limit {
return nil, errors.New("nicht lesbar oder zu groß")
}
return data, nil
}
type importMediaFile struct {
id, kind, mime string
data []byte
}
var (
mdEmbedRe = regexp.MustCompile(`^!\[([^\]]*)\]\(<?([^)>]+?)>?(?:\s+"[^"]*")?\)$`)
wikiEmbedRe = regexp.MustCompile(`^!\[\[([^|\]]+)(?:\|([^\]]*))?\]\]$`)
sizeOnlyRe = regexp.MustCompile(`^\d+(x\d+)?$`)
)
// linkImportMedia turns blocks that only show a file of the archive into image/audio blocks with new
// file IDs: media:<id> of an own backup, Markdown images relative to the note, Obsidian ![[file]]
// embeds (found by name anywhere in the archive). Files without such a block are not imported.
func linkImportMedia(d importDoc, media map[string][]byte, used map[string]bool) ([]domain.DocumentBlock, []importMediaFile) {
if len(media) == 0 {
return d.blocks, nil
}
blocks := make([]domain.DocumentBlock, len(d.blocks))
copy(blocks, d.blocks)
byKey := map[string]importMediaFile{} // one copy per note, even when shown twice
var files []importMediaFile
for i, b := range blocks {
content := strings.TrimSpace(b.Content)
key, caption := "", ""
if ref, ok := ParseMediaContent(content); ok {
key, caption = "media:"+ref.ID, ref.Caption
} else if m := mdEmbedRe.FindStringSubmatch(content); m != nil {
target := m[2]
if u, err := url.PathUnescape(target); err == nil {
target = u
}
key, caption = path.Clean(path.Join(d.dir, target)), m[1]
} else if m := wikiEmbedRe.FindStringSubmatch(content); m != nil {
name := strings.TrimSpace(m[1])
for k := range media {
if path.Base(k) == name || k == name {
key = k
break
}
}
if !sizeOnlyRe.MatchString(m[2]) {
caption = m[2]
}
}
data, ok := media[key]
if key == "" || !ok {
continue
}
f, seen := byKey[key]
if !seen {
if used != nil {
used[key] = true
}
mime, kind, ok := DetectMedia(data, "")
if !ok {
continue
}
f = importMediaFile{id: domain.NewID("med"), kind: kind, mime: mime, data: data}
byKey[key] = f
files = append(files, f)
}
blocks[i].Type = f.kind
blocks[i].Content = MediaContent(f.kind, f.id, caption)
}
return blocks, files
}
func (s *Store) storeImportMedia(userID, docID string, files []importMediaFile) error {
if len(files) == 0 || s.mediaRoot == "" {
return nil
}
for _, f := range files {
m, err := s.createMediaWithID(f.id, docID, userID, f.kind, f.mime, int64(len(f.data)), "")
if err != nil {
return err
}
dst := s.MediaFilePath(m)
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
if err := os.WriteFile(dst, f.data, 0o644); err != nil {
return err
}
}
return nil
}
+1 -1
View File
@@ -204,7 +204,7 @@ func TestImportBackupZip(t *testing.T) {
seedAnna(t, store, anna)
exp, _ := store.ExportUserData(anna.ID)
var buf bytes.Buffer
if err := WriteBackupZip(exp, &buf); err != nil {
if err := store.WriteBackupZip(anna.ID, exp, &buf); err != nil {
t.Fatalf("WriteBackupZip: %v", err)
}
if buf.Len() == 0 {
+87
View File
@@ -0,0 +1,87 @@
package storage
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"time"
)
// Link previews: title, description, a small picture and the site icon of a web page, fetched once by
// the server and kept for a while, so readers' browsers never contact the page themselves.
type LinkPreview struct {
Key string `json:"key"`
URL string `json:"url"`
Title string `json:"title"`
Description string `json:"description"`
SiteName string `json:"siteName"`
HasImage bool `json:"hasImage"`
HasIcon bool `json:"hasIcon"`
OK bool `json:"ok"` // false: the page gave nothing usable (kept shorter, then tried again)
FetchedAt string `json:"fetchedAt"`
Image, Icon []byte `json:"-"`
ImageMime, IconMime string `json:"-"`
}
// LinkPreviewKey identifies the preview of a URL (also used in the picture URLs).
func LinkPreviewKey(url string) string {
sum := sha256.Sum256([]byte(url))
return hex.EncodeToString(sum[:12])
}
func (s *Store) migrateLinkPreviews() error {
return s.execInTx(stmt(`CREATE TABLE IF NOT EXISTS link_previews (
key TEXT PRIMARY KEY,
url TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
site_name TEXT NOT NULL DEFAULT '',
image BLOB,
image_mime TEXT NOT NULL DEFAULT '',
icon BLOB,
icon_mime TEXT NOT NULL DEFAULT '',
ok INTEGER NOT NULL DEFAULT 0,
fetched_at TEXT NOT NULL
)`))
}
var ErrLinkPreviewNotFound = errors.New("Keine Vorschau vorhanden")
func (s *Store) GetLinkPreview(key string) (*LinkPreview, error) {
var p LinkPreview
var ok int
err := s.db.QueryRow(`SELECT key, url, title, description, site_name, image, image_mime, icon, icon_mime, ok, fetched_at
FROM link_previews WHERE key = ?`, key).
Scan(&p.Key, &p.URL, &p.Title, &p.Description, &p.SiteName, &p.Image, &p.ImageMime, &p.Icon, &p.IconMime, &ok, &p.FetchedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrLinkPreviewNotFound
}
if err != nil {
return nil, err
}
p.OK = ok == 1
p.HasImage, p.HasIcon = len(p.Image) > 0, len(p.Icon) > 0
return &p, nil
}
func (s *Store) SaveLinkPreview(p *LinkPreview) error {
p.Key = LinkPreviewKey(p.URL)
p.FetchedAt = time.Now().UTC().Format(time.RFC3339)
_, err := s.db.Exec(`INSERT INTO link_previews (key, url, title, description, site_name, image, image_mime, icon, icon_mime, ok, fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET url = excluded.url, title = excluded.title, description = excluded.description,
site_name = excluded.site_name, image = excluded.image, image_mime = excluded.image_mime, icon = excluded.icon,
icon_mime = excluded.icon_mime, ok = excluded.ok, fetched_at = excluded.fetched_at`,
p.Key, p.URL, p.Title, p.Description, p.SiteName, p.Image, p.ImageMime, p.Icon, p.IconMime, boolInt(p.OK), p.FetchedAt)
p.HasImage, p.HasIcon = len(p.Image) > 0, len(p.Icon) > 0
return err
}
// PruneLinkPreviews forgets previews older than the given time (they are fetched again when needed).
func (s *Store) PruneLinkPreviews(olderThan time.Time) error {
_, err := s.db.Exec(`DELETE FROM link_previews WHERE fetched_at < ?`, olderThan.UTC().Format(time.RFC3339))
return err
}
+237
View File
@@ -0,0 +1,237 @@
package storage
import (
"database/sql"
"errors"
"net/http"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/sandrodach/mindsift/internal/domain"
)
// Media files of notes (images, audio). The file lies in $DATA_DIR/media/<docID>/<id><ext>; a block
// points to it through its content, e.g. "![Beschriftung](media:med_…)", so the reference travels with
// the normal note sync, undo and conflict handling. A file is kept as long as any block (also deleted
// ones still in the log or a note in the trash) mentions it; CompactMedia removes the rest.
type Media struct {
ID string `json:"id"`
DocID string `json:"docId"`
OwnerID string `json:"ownerId"`
Kind string `json:"kind"` // image | audio
Mime string `json:"mime"`
Size int64 `json:"size"`
SourceURL string `json:"sourceUrl,omitempty"` // copied from this address (external image)
CreatedAt string `json:"createdAt"`
}
var ErrMediaNotFound = errors.New("Datei nicht gefunden")
// Accepted formats (detected from the content, not from the name): file extension and kind
var mediaTypes = map[string]struct{ ext, kind string }{
"image/jpeg": {".jpg", "image"},
"image/png": {".png", "image"},
"image/gif": {".gif", "image"},
"image/webp": {".webp", "image"},
"audio/webm": {".webm", "audio"},
"video/webm": {".webm", "audio"}, // Chrome's audio-only recordings are detected as video/webm
"audio/ogg": {".ogg", "audio"},
"application/ogg": {".ogg", "audio"},
"audio/mpeg": {".mp3", "audio"},
"audio/wave": {".wav", "audio"},
"audio/wav": {".wav", "audio"},
"audio/mp4": {".m4a", "audio"},
"video/mp4": {".m4a", "audio"}, // Safari records MP4/AAC
"audio/aac": {".aac", "audio"},
}
// DetectMedia recognises the format from the first bytes. Browsers send audio as e.g.
// "audio/webm;codecs=opus"; that name is kept when the content looks like a container we accept.
func DetectMedia(data []byte, declared string) (mime, kind string, ok bool) {
sniffed := http.DetectContentType(data)
if i := strings.IndexByte(sniffed, ';'); i >= 0 {
sniffed = sniffed[:i]
}
// Go does not know WebP and M4A by name; check their signatures directly
switch {
case len(data) >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP":
sniffed = "image/webp"
case len(data) >= 12 && string(data[4:8]) == "ftyp":
sniffed = "video/mp4"
}
t, ok := mediaTypes[sniffed]
if !ok {
return "", "", false
}
if t.kind == "audio" {
if d := strings.ToLower(strings.TrimSpace(strings.SplitN(declared, ";", 2)[0])); strings.HasPrefix(d, "audio/") {
if dt, known := mediaTypes[d]; known && dt.kind == "audio" {
sniffed = d
}
} else if sniffed == "video/webm" {
sniffed = "audio/webm"
} else if sniffed == "video/mp4" {
sniffed = "audio/mp4"
}
}
return sniffed, t.kind, true
}
// MediaExt is the file extension for a stored format.
func MediaExt(mime string) string {
if t, ok := mediaTypes[mime]; ok {
return t.ext
}
return ".bin"
}
// MediaRoot is the folder with all media files ("" for in-memory databases).
func (s *Store) MediaRoot() string { return s.mediaRoot }
// MediaFilePath is where the file of m lies.
func (s *Store) MediaFilePath(m *Media) string {
return filepath.Join(s.mediaRoot, filepath.Base(m.DocID), filepath.Base(m.ID)+MediaExt(m.Mime))
}
func (s *Store) migrateMedia() error {
return s.execInTx(
stmt(`CREATE TABLE IF NOT EXISTS media (
id TEXT PRIMARY KEY,
doc_id TEXT NOT NULL,
owner_id TEXT NOT NULL,
kind TEXT NOT NULL,
mime TEXT NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
source_url TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
)`),
stmt(`CREATE INDEX IF NOT EXISTS idx_media_doc ON media(doc_id)`),
stmt(`CREATE INDEX IF NOT EXISTS idx_media_owner ON media(owner_id)`),
)
}
func (s *Store) CreateMedia(docID, ownerID, kind, mime string, size int64, sourceURL string) (*Media, error) {
return s.createMediaWithID(domain.NewID("med"), docID, ownerID, kind, mime, size, sourceURL)
}
func (s *Store) createMediaWithID(id, docID, ownerID, kind, mime string, size int64, sourceURL string) (*Media, error) {
m := &Media{ID: id, DocID: docID, OwnerID: ownerID, Kind: kind, Mime: mime, Size: size,
SourceURL: sourceURL, CreatedAt: nowUTC()}
_, err := s.db.Exec(`INSERT INTO media (id, doc_id, owner_id, kind, mime, size, source_url, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
m.ID, m.DocID, m.OwnerID, m.Kind, m.Mime, m.Size, m.SourceURL, m.CreatedAt)
if err != nil {
return nil, err
}
return m, nil
}
func (s *Store) GetMedia(id string) (*Media, error) {
var m Media
err := s.db.QueryRow(`SELECT id, doc_id, owner_id, kind, mime, size, source_url, created_at FROM media WHERE id = ?`, id).
Scan(&m.ID, &m.DocID, &m.OwnerID, &m.Kind, &m.Mime, &m.Size, &m.SourceURL, &m.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrMediaNotFound
}
if err != nil {
return nil, err
}
return &m, nil
}
// DeleteMediaRow forgets a file (the caller removes it from disk).
func (s *Store) DeleteMediaRow(id string) error {
_, err := s.db.Exec(`DELETE FROM media WHERE id = ?`, id)
return err
}
// MediaUsage is the stored size of all files the user uploaded.
func (s *Store) MediaUsage(ownerID string) (int64, error) {
var n sql.NullInt64
err := s.db.QueryRow(`SELECT SUM(size) FROM media WHERE owner_id = ?`, ownerID).Scan(&n)
return n.Int64, err
}
// MediaReferencedIn lists the notes whose blocks (deleted ones included) point to the file. A block
// copied into another note keeps working there.
func (s *Store) MediaReferencedIn(id string) ([]string, error) {
rows, err := s.db.Query(`SELECT DISTINCT document_id FROM blocks WHERE instr(content, ?) > 0`, "media:"+id)
if err != nil {
return nil, err
}
defer rows.Close()
var docs []string
for rows.Next() {
var d string
if rows.Scan(&d) == nil {
docs = append(docs, d)
}
}
return docs, rows.Err()
}
// UnreferencedMedia returns files older than the grace time that no block mentions any more. The grace
// time covers uploads whose block has not reached the server yet (offline, slow sync).
func (s *Store) UnreferencedMedia(olderThan time.Time) ([]Media, error) {
rows, err := s.db.Query(`SELECT id, doc_id, owner_id, kind, mime, size, source_url, created_at FROM media m
WHERE created_at < ? AND NOT EXISTS (SELECT 1 FROM blocks b WHERE instr(b.content, 'media:' || m.id) > 0)`,
olderThan.UTC().Format(time.RFC3339))
if err != nil {
return nil, err
}
defer rows.Close()
var out []Media
for rows.Next() {
var m Media
if rows.Scan(&m.ID, &m.DocID, &m.OwnerID, &m.Kind, &m.Mime, &m.Size, &m.SourceURL, &m.CreatedAt) == nil {
out = append(out, m)
}
}
return out, rows.Err()
}
// MediaOfUser lists the files shown in the user's own notes (for backups and exports).
func (s *Store) MediaOfUser(userID string) ([]Media, error) {
rows, err := s.db.Query(`SELECT DISTINCT m.id, m.doc_id, m.owner_id, m.kind, m.mime, m.size, m.source_url, m.created_at
FROM media m JOIN blocks b ON instr(b.content, 'media:' || m.id) > 0
JOIN documents d ON d.id = b.document_id WHERE d.user_id = ?`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Media
for rows.Next() {
var m Media
if rows.Scan(&m.ID, &m.DocID, &m.OwnerID, &m.Kind, &m.Mime, &m.Size, &m.SourceURL, &m.CreatedAt) == nil {
out = append(out, m)
}
}
return out, rows.Err()
}
// MediaRef is a block's pointer to a file: ![caption](media:id) for images, [caption](media:id) for audio.
type MediaRef struct{ ID, Caption string }
var mediaRefRe = regexp.MustCompile(`^!?\[((?:[^\]\\]|\\.)*)\]\(media:(med_[0-9a-f]+)\)$`)
var mediaRefEsc = regexp.MustCompile(`\\(.)`)
func ParseMediaContent(content string) (MediaRef, bool) {
m := mediaRefRe.FindStringSubmatch(strings.TrimSpace(content))
if m == nil {
return MediaRef{}, false
}
return MediaRef{ID: m[2], Caption: mediaRefEsc.ReplaceAllString(m[1], "$1")}, true
}
// MediaContent builds a block's content for a file (same format as the browser, lib/media.ts).
func MediaContent(kind, id, caption string) string {
caption = strings.Join(strings.Fields(strings.ReplaceAll(caption, "\n", " ")), " ")
caption = strings.NewReplacer(`\`, `\\`, `]`, `\]`).Replace(caption)
prefix := ""
if kind == "image" {
prefix = "!"
}
return prefix + "[" + caption + "](media:" + id + ")"
}
+193
View File
@@ -0,0 +1,193 @@
package storage
import (
"archive/zip"
"bytes"
"image"
"image/png"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/sandrodach/mindsift/internal/domain"
)
func pngBytes(t *testing.T, w int) []byte {
t.Helper()
var buf bytes.Buffer
if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, w, 2))); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
// storeTestMedia creates a media row with its file and returns it.
func storeTestMedia(t *testing.T, store *Store, docID, ownerID string, data []byte) *Media {
t.Helper()
mime, kind, ok := DetectMedia(data, "")
if !ok {
t.Fatal("test data not recognised")
}
m, err := store.CreateMedia(docID, ownerID, kind, mime, int64(len(data)), "")
if err != nil {
t.Fatal(err)
}
_ = os.MkdirAll(filepath.Dir(store.MediaFilePath(m)), 0o755)
if err := os.WriteFile(store.MediaFilePath(m), data, 0o644); err != nil {
t.Fatal(err)
}
return m
}
// mediaBlocksOf returns the image/audio blocks of the user's notes and the bytes of their files.
func mediaBlocksOf(t *testing.T, store *Store, userID string) map[string][]byte {
t.Helper()
docs, _ := store.GetDocuments(userID)
out := map[string][]byte{}
for _, d := range docs {
for _, b := range d.Blocks {
if b.Type != "image" && b.Type != "audio" {
continue
}
ref, ok := ParseMediaContent(b.Content)
if !ok {
t.Fatalf("block content is no media reference: %q", b.Content)
}
m, err := store.GetMedia(ref.ID)
if err != nil || m.OwnerID != userID || m.DocID != d.ID {
t.Fatalf("media row of %s: %+v %v", ref.ID, m, err)
}
data, err := os.ReadFile(store.MediaFilePath(m))
if err != nil {
t.Fatal(err)
}
out[ref.Caption] = data
}
}
return out
}
func TestBackupZipCarriesMedia(t *testing.T) {
store, anna, ben := newImportStore(t)
doc, _ := store.CreateDocument(anna.ID, "", "Urlaub", "🏖️", "Reisen", nil, nil, false)
img := pngBytes(t, 7)
m := storeTestMedia(t, store, doc.ID, anna.ID, img)
_, _ = store.CreateDocument(anna.ID, "", "Urlaub", "🏖️", "Reisen", []domain.DocumentBlock{
{ID: "blk_t", Type: "bullet", Content: "Strand"},
{ID: "blk_i", Type: "image", Content: MediaContent("image", m.ID, "Sonnenuntergang [Süd]")},
}, nil, false)
exp, _ := store.ExportUserData(anna.ID)
var buf bytes.Buffer
if err := store.WriteBackupZip(anna.ID, exp, &buf); err != nil {
t.Fatal(err)
}
zr, _ := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
found := false
for _, f := range zr.File {
found = found || f.Name == "media/"+m.ID+".png"
}
if !found {
t.Fatal("backup ZIP must contain the image")
}
plan, err := PlanMindSiftImport(buf.Bytes(), "backup.zip")
if err != nil {
t.Fatal(err)
}
if _, err := store.ApplyImport(ben.ID, plan); err != nil {
t.Fatal(err)
}
got := mediaBlocksOf(t, store, ben.ID)
if !bytes.Equal(got["Sonnenuntergang [Süd]"], img) {
t.Fatalf("restored image missing or different: %v", keysOf(got))
}
}
func TestMarkdownVaultCarriesMedia(t *testing.T) {
store, anna, ben := newImportStore(t)
doc, _ := store.CreateDocument(anna.ID, "", "Platzhalter", "", "", nil, nil, false)
img := pngBytes(t, 5)
m := storeTestMedia(t, store, doc.ID, anna.ID, img)
_, _ = store.CreateDocument(anna.ID, "", "Plan", "", "Projekte/Haus", []domain.DocumentBlock{
{ID: "blk_p", Type: "image", Content: MediaContent("image", m.ID, "Grundriss")},
}, nil, false)
var buf bytes.Buffer
if err := store.WriteMarkdownVault(anna.ID, &buf); err != nil {
t.Fatal(err)
}
zr, _ := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
var note string
hasFile := false
for _, f := range zr.File {
if f.Name == "attachments/"+m.ID+".png" {
hasFile = true
}
if f.Name == "Projekte/Haus/Plan.md" {
rc, _ := f.Open()
b, _ := io.ReadAll(rc)
rc.Close()
note = string(b)
}
}
if !hasFile || !strings.Contains(note, "![Grundriss](../../attachments/"+m.ID+".png)") {
t.Fatalf("vault: attachment %v, note:\n%s", hasFile, note)
}
plan, err := PlanMindSiftImport(buf.Bytes(), "vault.zip")
if err != nil {
t.Fatal(err)
}
if _, err := store.ApplyImport(ben.ID, plan); err != nil {
t.Fatal(err)
}
if got := mediaBlocksOf(t, store, ben.ID); !bytes.Equal(got["Grundriss"], img) {
t.Fatalf("re-imported image missing: %v", keysOf(got))
}
}
func TestObsidianVaultImagesAreImported(t *testing.T) {
store, _, ben := newImportStore(t)
img1, img2 := pngBytes(t, 3), pngBytes(t, 4)
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
add := func(name string, data []byte) {
w, _ := zw.Create(name)
_, _ = w.Write(data)
}
add("Vault/Reisen/Rom.md", []byte("# Rom\n\n- ![[kolosseum.png|400]]\n- ![Brunnen](bilder/trevi%20brunnen.png)\n- Text mit ![[kolosseum.png]] mittendrin\n"))
add("Vault/Anhänge/kolosseum.png", img1)
add("Vault/Reisen/bilder/trevi brunnen.png", img2)
add("Vault/Anhänge/handbuch.pdf", []byte("%PDF-1.4"))
_ = zw.Close()
plan, err := PlanObsidianImport(bytes.NewReader(buf.Bytes()), int64(buf.Len()), "Vault.zip")
if err != nil {
t.Fatal(err)
}
if _, err := store.ApplyImport(ben.ID, plan); err != nil {
t.Fatal(err)
}
got := mediaBlocksOf(t, store, ben.ID)
if !bytes.Equal(got[""], img1) || !bytes.Equal(got["Brunnen"], img2) || len(got) != 2 {
t.Fatalf("obsidian images: %v", keysOf(got))
}
skippedPDF := false
for _, s := range plan.Skipped {
skippedPDF = skippedPDF || strings.HasSuffix(s.Path, "handbuch.pdf")
}
if !skippedPDF {
t.Fatal("PDF must be reported as skipped")
}
}
func keysOf(m map[string][]byte) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
+2
View File
@@ -25,6 +25,8 @@ type migration struct {
var migrations = []migration{
{3, "Basis-Schema v0.4.1", (*Store).migrateBaseline},
{4, "Sitzungen", (*Store).migrateSessions},
{5, "Medien", (*Store).migrateMedia},
{6, "Link-Vorschauen", (*Store).migrateLinkPreviews},
}
func (s *Store) migrate() error {
+278
View File
@@ -0,0 +1,278 @@
package storage
import (
"errors"
"fmt"
"regexp"
"strings"
"time"
"github.com/sandrodach/mindsift/internal/collab"
"github.com/sandrodach/mindsift/internal/domain"
)
// Restructuring notes: turn a bullet with its children into a note of its own, and merge one note into
// another. Both change the affected notes through the normal operation log, so open editors follow
// live, and nothing is lost: an extracted branch lives on in the new note, a merged note goes to the
// trash and the merge can be undone.
var (
ErrBlockNotFound = errors.New("Punkt nicht gefunden")
ErrBranchLocked = errors.New("Jemand anderes bearbeitet gerade einen Punkt in diesem Ast. Bitte kurz warten.")
ErrMergeSelf = errors.New("Eine Notiz kann nicht mit sich selbst zusammengeführt werden")
ErrMergeNotOwner = errors.New("Zusammenführen geht nur mit eigenen Notizen als Quelle")
)
// readCollabDoc loads the current state of a note outside of a change.
func (s *Store) readCollabDoc(docID string) (*collab.Doc, error) {
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
doc, _, err := loadCollabDoc(tx, docID)
return doc, err
}
var (
titlePrefixRe = regexp.MustCompile(`^(\s*(-|\*|#{1,6}|\[[ xX]?\])\s+)+`)
titleTagRe = regexp.MustCompile(`(^|\s)#[\p{L}0-9_-]+`)
)
// branchTitle makes a note title from a bullet's text (without hashtags) and returns the hashtags,
// which stay next to the link in the original bullet.
func branchTitle(content string) (string, string) {
first := titlePrefixRe.ReplaceAllString(strings.SplitN(content, "\n", 2)[0], "")
var tags []string
for _, m := range titleTagRe.FindAllString(first, -1) {
tags = append(tags, strings.TrimSpace(m))
}
t := strings.TrimSpace(titleTagRe.ReplaceAllString(first, ""))
t = strings.NewReplacer("[[", "", "]]", "").Replace(t)
if r := []rune(t); len(r) > 120 {
t = strings.TrimSpace(string(r[:119])) + "…"
}
if t == "" {
t = "Unbenannte Notiz"
}
return t, strings.Join(tags, " ")
}
// uniqueTitle appends " (2)", " (3)" … when the user already has a note of that name, so the wiki link
// points to exactly one note.
func (s *Store) uniqueTitle(userID, title string) string {
docs, _ := s.GetDocuments(userID)
taken := map[string]bool{}
for _, d := range docs {
taken[strings.ToLower(d.Title)] = true
}
candidate := title
for n := 2; taken[strings.ToLower(candidate)]; n++ {
candidate = fmt.Sprintf("%s (%d)", title, n)
}
return candidate
}
func toDocumentBlock(b collab.FlatBlock, level int, pos int) domain.DocumentBlock {
return domain.DocumentBlock{
ID: domain.NewID("blk"), Type: b.Type, Content: b.Content, Level: level, Position: float64(pos + 1),
Checked: b.Props.Checked, Collapsed: b.Props.Collapsed, Lang: b.Props.Lang,
}
}
func foreignLeaseIn(ids []string, a collab.Actor, leases collab.LeaseView) bool {
if leases == nil {
return false
}
for _, id := range ids {
if holder, ok := leases.HolderOf(id); ok && holder != a.ClientID {
return true
}
}
return false
}
// ExtractBranch moves a bullet's children into a new note named after the bullet, in the same folder,
// shared like the original; the bullet itself becomes a [[link]] to it. Returns the new note.
func (s *Store) ExtractBranch(userID, docID, blockID string, a collab.Actor, leases collab.LeaseView) (*domain.Document, error) {
source, err := s.GetDocumentByID(userID, docID)
if err != nil {
return nil, err
}
state, err := s.readCollabDoc(docID)
if err != nil {
return nil, err
}
flat := state.Flatten()
start := -1
for i, b := range flat {
if b.ID == blockID {
start = i
break
}
}
if start < 0 {
return nil, ErrBlockNotFound
}
root := flat[start]
var children []domain.DocumentBlock
ids := []string{root.ID}
for i := start + 1; i < len(flat) && flat[i].Level > root.Level; i++ {
children = append(children, toDocumentBlock(flat[i], flat[i].Level-root.Level-1, len(children)))
ids = append(ids, flat[i].ID)
}
if foreignLeaseIn(ids, a, leases) {
return nil, ErrBranchLocked
}
base, tags := branchTitle(root.Content)
title := s.uniqueTitle(userID, base)
created, err := s.CreateDocument(userID, "", title, "📄", source.FolderPath, children, nil, false)
if err != nil {
return nil, err
}
s.copyShares(userID, source, created.ID)
link := "[[" + title + "]]"
if tags != "" {
link += " " + tags
}
_, _, err = s.applyDocChange(docID, a, leases, func(d *collab.Doc) []collab.Op {
b, ok := d.Blocks[blockID]
if !ok || b.Deleted {
return nil
}
ops := []collab.Op{{OpID: domain.NewID("op"), Kind: collab.OpSetContent, BlockID: blockID, BaseVersion: b.Version, Content: &link}}
for _, c := range d.Children(blockID) {
ops = append(ops, collab.Op{OpID: domain.NewID("op"), Kind: collab.OpDelete, BlockID: c.ID})
}
return ops
})
if err != nil {
_ = s.hardDeleteDocument(created.ID)
return nil, err
}
return created, nil
}
// copyShares gives the new note the direct and team shares of the source; when someone else owns the
// source, that owner may edit the new note as well (the branch was theirs to read before).
func (s *Store) copyShares(userID string, source *domain.Document, newDocID string) {
shares, _ := s.GetDocShares(source.ID)
for _, sh := range shares {
switch {
case sh.TargetType == "team":
_, _ = s.CreateDocTeamShare(userID, newDocID, sh.TargetID, sh.Permission)
case sh.UserID != "" && sh.UserID != userID:
_, _ = s.CreateDocShare(userID, newDocID, sh.UserID, sh.Permission)
}
}
if source.UserID != "" && source.UserID != userID {
_, _ = s.CreateDocShare(userID, newDocID, source.UserID, "write")
}
}
// MergeResult tells the client what to undo.
type MergeResult struct {
TargetID string `json:"targetId"`
SourceID string `json:"sourceId"`
SourceTitle string `json:"sourceTitle"`
HeadingID string `json:"headingId"`
}
// MergeDocuments appends the source note to the end of the target: a heading with the source's title,
// its blocks below it. The source goes to the trash (it must belong to the user; the caller checks
// write access to the target).
func (s *Store) MergeDocuments(userID, sourceID, targetID string, a collab.Actor, leases collab.LeaseView) (*MergeResult, error) {
if sourceID == targetID {
return nil, ErrMergeSelf
}
source, err := s.GetDocumentByID(userID, sourceID)
if err != nil {
return nil, err
}
if !source.IsOwner {
return nil, ErrMergeNotOwner
}
if source.IsArchived {
return nil, ErrDocInTrash
}
state, err := s.readCollabDoc(sourceID)
if err != nil {
return nil, err
}
flat := state.Flatten()
headingID := domain.NewID("blk")
heading := strings.TrimSpace(source.Icon + " " + source.Title)
if heading == "" {
heading = "Zusammengeführte Notiz"
}
heading += " (" + time.Now().Format("02.01.2006") + ")"
_, _, err = s.applyDocChange(targetID, a, leases, func(d *collab.Doc) []collab.Op {
// After the last top-level block, deleted ones included, so keys never collide
last := ""
for _, b := range d.Blocks {
if b.ParentID == "" && b.OrderKey > last {
last = b.OrderKey
}
}
key, err := collab.KeyBetween(last, "")
if err != nil {
return nil
}
root := ""
ops := []collab.Op{{OpID: domain.NewID("op"), Kind: collab.OpInsert, BlockID: headingID, ParentID: &root, OrderKey: key, Type: "h2", Content: &heading}}
// Parents by level: the heading holds the source's top level
parents := []string{headingID}
lastKey := map[string]string{}
for _, b := range flat {
if b.Level+1 > len(parents) {
continue // cannot happen with a flattened tree; guards against gaps
}
parents = parents[:b.Level+1]
parent := parents[b.Level]
k, err := collab.KeyBetween(lastKey[parent], "")
if err != nil {
return nil
}
lastKey[parent] = k
id := domain.NewID("blk")
content := b.Content
props := collab.Props{Checked: b.Props.Checked, Collapsed: b.Props.Collapsed, Lang: b.Props.Lang}
p := parent
ops = append(ops, collab.Op{OpID: domain.NewID("op"), Kind: collab.OpInsert, BlockID: id, ParentID: &p, OrderKey: k, Type: b.Type, Content: &content, Props: &props})
parents = append(parents, id)
}
return ops
})
if err != nil {
return nil, err
}
if err := s.trashDocument(userID, sourceID); err != nil {
return nil, err
}
return &MergeResult{TargetID: targetID, SourceID: sourceID, SourceTitle: source.Title, HeadingID: headingID}, nil
}
// UndoMerge removes the appended section from the target and brings the source back from the trash.
func (s *Store) UndoMerge(userID string, m MergeResult, a collab.Actor, leases collab.LeaseView) error {
if err := s.RestoreDocument(userID, m.SourceID); err != nil {
return err
}
_, _, err := s.applyDocChange(m.TargetID, a, leases, func(d *collab.Doc) []collab.Op {
if b, ok := d.Blocks[m.HeadingID]; !ok || b.Deleted {
return nil
}
return []collab.Op{{OpID: domain.NewID("op"), Kind: collab.OpDelete, BlockID: m.HeadingID}}
})
return err
}
// DocHead is the newest log position of a note (to tell open editors to sync).
func (s *Store) DocHead(docID string) int64 {
var head int64
_ = s.db.QueryRow(`SELECT head_seq FROM documents WHERE id = ?`, docID).Scan(&head)
return head
}
+5
View File
@@ -19,6 +19,8 @@ import (
type Store struct {
db *sql.DB
initMu sync.Mutex
// mediaRoot holds the files of images and audio in notes ($DATA_DIR/media), see media.go
mediaRoot string
// OnTaskCreated is told about tasks the store creates by itself (next occurrence of a repeating
// task), so the server can push them to connected clients.
@@ -50,6 +52,9 @@ func NewStore(dbPath string) (*Store, error) {
db.SetMaxIdleConns(4)
store := &Store{db: db}
if dbPath != ":memory:" && !strings.HasPrefix(dbPath, "file:") {
store.mediaRoot = filepath.Join(filepath.Dir(dbPath), "media")
}
if err := store.migrate(); err != nil {
return nil, fmt.Errorf("failed to run migrations: %w", err)
}
+6 -1
View File
@@ -92,7 +92,7 @@ func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) {
// --- SYSTEM SETTINGS ---
func (s *Store) SeedSMTPDefaultsFromEnv(host, port, user, pass, from string, tls, skipVerify bool) {
func (s *Store) SeedSMTPDefaultsFromEnv(host, port, user, pass, from, fromName string, tls, skipVerify bool) {
cur, err := s.GetSystemSettings()
if err != nil || cur == nil {
return
@@ -106,6 +106,7 @@ func (s *Store) SeedSMTPDefaultsFromEnv(host, port, user, pass, from string, tls
cur.SMTPUser = strings.TrimSpace(user)
cur.SMTPPass = pass
cur.SMTPFrom = strings.TrimSpace(from)
cur.SMTPFromName = strings.TrimSpace(fromName)
cur.SMTPTLS = tls
cur.SMTPSkipVerify = skipVerify
_ = s.UpdateSystemSettings(cur)
@@ -151,6 +152,8 @@ func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) {
settings.SMTPPass = v
case "smtp_from":
settings.SMTPFrom = v
case "smtp_from_name":
settings.SMTPFromName = v
case "smtp_tls":
settings.SMTPTLS = (v != "false" && v != "0")
case "smtp_skip_verify":
@@ -212,6 +215,7 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error {
('smtp_user', ?),
('smtp_pass', ?),
('smtp_from', ?),
('smtp_from_name', ?),
('smtp_tls', ?),
('smtp_skip_verify', ?),
('allow_private_webhooks', ?),
@@ -227,6 +231,7 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error {
strings.TrimSpace(settings.SMTPUser),
settings.SMTPPass,
strings.TrimSpace(settings.SMTPFrom),
strings.TrimSpace(settings.SMTPFromName),
tlsStr,
skipVerifyStr,
privateHooksStr,