Merge branch 'dev' into main: v0.9.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
9cbc47167b
22 files changed
+509
-94
No files matched your search
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
PORT=8080
|
PORT=8080
|
||||||
|
|
||||||
# Geheimer Schlüssel für die Anmeldung, mindestens 32 zufällige Zeichen, z. B. von: openssl rand -hex 32
|
# Geheimer Schlüssel für die Anmeldung, mindestens 32 zufällige Zeichen, z. B. von: openssl rand -hex 32
|
||||||
# Bekannte Beispielwerte (wie dieser) werden ignoriert; dann erzeugt MindSift selbst einen und legt ihn in data/ ab..
|
# Bekannte Beispielwerte (wie dieser) werden ignoriert; dann erzeugt MindSift selbst einen und legt ihn in data/ ab.
|
||||||
JWT_SECRET=change_this_to_a_secure_random_key_min_32_chars
|
JWT_SECRET=change_this_to_a_secure_random_key_min_32_chars
|
||||||
|
|
||||||
# Öffentliche Adresse der Instanz, so wie sie im Browser aufgerufen wird. Wird für Links in E-Mails
|
# Öffentliche Adresse der Instanz, so wie sie im Browser aufgerufen wird. Wird für Links in E-Mails
|
||||||
|
|||||||
+13
-1
@@ -6,6 +6,16 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.9.0] - 2026-10-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Formatierungs-Kürzel im Outliner: `Strg+B` / `Cmd+B` für fett, `Strg+I` / `Cmd+I` für kursiv (mit Ein-/Ausschalten bei markiertem Text) und `Strg+K` / `Cmd+K` für Links (mit Markierung `[Text](…)` mit vorselektierter Adresse, ohne Markierung Link-Dialog).
|
||||||
|
- Code-Blöcke: Zeilennummernspalte links mit synchronem Scrollen (wächst dynamisch bei 100+ Zeilen, zählt auch leere Endzeilen mit); horizontaler Bildlauf bei langen Zeilen ohne automatischen Zeilenumbruch (`white-space: pre`).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Formatierungs-Kürzel wirken nicht in schreibgeschützten Notizen.
|
||||||
|
- Light-Theme: Kontraste für Code-Blöcke (`--bg-code`), Callouts (`--bg-panel-elevated`), Zitate und Rahmen auf Notizhintergründen geschärft; VS Code Light+ Syntaxfarben für hohe Lesbarkeit optimiert.
|
||||||
|
|
||||||
## [0.8.0] - 2026-10-30
|
## [0.8.0] - 2026-10-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -22,6 +32,7 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
- Notizen: Link-Vorschau-Karten erscheinen nur noch, wenn der gesamte Punkt aus einer einzigen `https`-Adresse besteht (oder über `/link` in einem leeren Punkt eingefügt wurde), nicht mehr bei Links mitten im Fließtext.
|
- Notizen: Link-Vorschau-Karten erscheinen nur noch, wenn der gesamte Punkt aus einer einzigen `https`-Adresse besteht (oder über `/link` in einem leeren Punkt eingefügt wurde), nicht mehr bei Links mitten im Fließtext.
|
||||||
- Notizen: Das Slash-Menü (`/`) öffnet sich nur noch am Textanfang oder nach einem Leerzeichen (nicht mehr beim Einfügen von Web-Adressen mit Schrägstrichen), schließt zuverlässig mit `Esc` oder `Backspace` und scrollt bei Pfeiltasten-Navigation automatisch mit.
|
- Notizen: Das Slash-Menü (`/`) öffnet sich nur noch am Textanfang oder nach einem Leerzeichen (nicht mehr beim Einfügen von Web-Adressen mit Schrägstrichen), schließt zuverlässig mit `Esc` oder `Backspace` und scrollt bei Pfeiltasten-Navigation automatisch mit.
|
||||||
- Notizen: `Enter` in einem leeren Punkt auf oberster Ebene legt auch am Ende der Notiz einen neuen Punkt darunter an.
|
- Notizen: `Enter` in einem leeren Punkt auf oberster Ebene legt auch am Ende der Notiz einen neuen Punkt darunter an.
|
||||||
|
- Benachrichtigungen: Die Tageszusammenfassung schließt nun auch Aufgaben aus Aufgabenlisten ein, die über eine Gruppe freigegeben wurden.
|
||||||
|
|
||||||
## [0.7.0] - 2026-10-30
|
## [0.7.0] - 2026-10-30
|
||||||
|
|
||||||
@@ -211,7 +222,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
||||||
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
||||||
|
|
||||||
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.8.0...dev
|
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.9.0...dev
|
||||||
|
[0.9.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.8.0...v0.9.0
|
||||||
[0.8.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.7.0...v0.8.0
|
[0.8.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.7.0...v0.8.0
|
||||||
[0.7.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.6.0...v0.7.0
|
[0.7.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.6.0...v0.7.0
|
||||||
[0.6.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.6.0...v0.6.1
|
[0.6.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.6.0...v0.6.1
|
||||||
|
|||||||
+22
-17
@@ -16,18 +16,6 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
|||||||
| POST | `/auth/reset-password` | `{token, password}` (mind. 8 Zeichen) → neues Passwort, alle Sitzungen beendet; `400` bei ungültigem oder abgelaufenem Link |
|
| POST | `/auth/reset-password` | `{token, password}` (mind. 8 Zeichen) → neues Passwort, alle Sitzungen beendet; `400` bei ungültigem oder abgelaufenem Link |
|
||||||
| POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) |
|
| POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) |
|
||||||
| POST | `/webhooks/ingest` | Einliefern per Webhook, Key als `X-API-Key` (empfohlen), Bearer oder `?token=` (in der URL nur API-Keys, keine Sitzungs-Tokens). Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; `target` (oder `type`): `triage` (Standard, Inbox), `task` (optional `taskListId`), `list_item` (`listId`, `category`, `quantity`). Klartext-Body wird als Inhalt genommen. `403` ohne passenden Scope oder ohne Schreibrecht auf die Liste |
|
| POST | `/webhooks/ingest` | Einliefern per Webhook, Key als `X-API-Key` (empfohlen), Bearer oder `?token=` (in der URL nur API-Keys, keine Sitzungs-Tokens). Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; `target` (oder `type`): `triage` (Standard, Inbox), `task` (optional `taskListId`), `list_item` (`listId`, `category`, `quantity`). Klartext-Body wird als Inhalt genommen. `403` ohne passenden Scope oder ohne Schreibrecht auf die Liste |
|
||||||
| POST | `/auth/stream-ticket` | Einmal-Ticket (60 s) für WebSocket/SSE |
|
|
||||||
| POST | `/documents/{id}/media` | Bild oder Audio zu einer Notiz hochladen (`multipart/form-data`, Feld `file`; Schreibrecht nötig) → `{id, kind, mime, size, url}`. `415` falsches Format, `413` zu groß oder Kontingent voll |
|
|
||||||
| POST | `/documents/{id}/media/fetch` | Externes Bild kopieren: `{url}` (nur https, keine internen Adressen; nicht in der Demo) → wie oben |
|
|
||||||
| GET | `/media/{id}` | Datei abrufen (Leserecht auf eine Notiz, die sie zeigt), mit Range-Anfragen; sonst `404` |
|
|
||||||
| POST | `/documents/{id}/extract` | `{blockId, clientId}`: legt aus den Unterpunkten des Punkts eine eigene Notiz an (Titel = Text ohne Hashtags, gleicher Ordner, Freigaben übernommen, fremder Besitzer bekommt Schreibrecht) → `{document, link}`. Die Ursprungsnotiz ändert der Server nicht; der Editor ersetzt den Ast selbst durch `link` (normale eigene Änderung, Strg+Z möglich). Schreibrecht nötig; `409`, wenn jemand anderes im Ast schreibt |
|
|
||||||
| POST | `/documents/{id}/extract/undo` | Legt die ausgelagerte Notiz `{id}` wieder in den Papierkorb (nur ihr Ersteller) |
|
|
||||||
| POST | `/documents/{id}/merge` | `{sourceId}`: hängt die (eigene) Quellnotiz als Überschrift mit Unterpunkten an diese Notiz an, Quelle in den Papierkorb → `{targetId, sourceId, sourceTitle, headingId}` |
|
|
||||||
| POST | `/documents/{id}/merge/undo` | `{sourceId, headingId}`: Abschnitt wieder entfernen, Quelle wiederherstellen |
|
|
||||||
| POST/DELETE | `/tasks/{id}/watch` | Aufgabe beobachten bzw. nicht mehr → `{watching}`; `404` ohne Zugriff. Aufgaben-Antworten tragen `isWatching` |
|
|
||||||
| GET | `/link-preview?url=https://…` | Vorschau einer Web-Seite (`title`, `description`, `siteName`, `hasImage`, `hasIcon`, `ok`, `key`), vom Server geholt und 7 Tage zwischengespeichert; `422` ohne https, `403` in der Demo, `429` zu viele Abrufe |
|
|
||||||
| GET | `/link-preview/{key}/image` bzw. `/icon` | Bild bzw. Icon einer Vorschau |
|
|
||||||
| POST | `/voice/transcribe` | Nur im Server-Modus der Spracherkennung: `multipart/form-data` mit `file` (Aufnahme, z. B. WebM/Opus oder MP4/AAC) → `{text}`. `404` ohne `STT_URL`, `413` zu groß, `429` Stundenlimit, `503` ausgelastet, `502` Whisper-Fehler (Details im Log) |
|
|
||||||
| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); `?ticket=` oder `Authorization`-Header |
|
| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); `?ticket=` oder `Authorization`-Header |
|
||||||
| GET | `/events` | SSE-Fallback; `?ticket=` oder `Authorization`-Header |
|
| GET | `/events` | SSE-Fallback; `?ticket=` oder `Authorization`-Header |
|
||||||
|
|
||||||
@@ -37,6 +25,8 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET | `/auth/me` | Benutzer und Kennzahlen |
|
| GET | `/auth/me` | Benutzer und Kennzahlen |
|
||||||
| POST | `/events/rooms` | SSE-Raum beitreten/verlassen (`sseId`, `room`, `action`); nur eigene `sseId`, nur Räume mit Lesezugriff |
|
| POST | `/events/rooms` | SSE-Raum beitreten/verlassen (`sseId`, `room`, `action`); nur eigene `sseId`, nur Räume mit Lesezugriff |
|
||||||
|
| POST | `/auth/stream-ticket` | Einmal-Ticket (60 s) für WebSocket/SSE |
|
||||||
|
| POST | `/voice/transcribe` | Nur im Server-Modus der Spracherkennung: `multipart/form-data` mit `file` (Aufnahme, z. B. WebM/Opus oder MP4/AAC) → `{text}`. `404` ohne `STT_URL`, `413` zu groß, `429` Stundenlimit, `503` ausgelastet, `502` Whisper-Fehler (Details im Log) |
|
||||||
| POST | `/auth/logout` | Sitzung dieses Geräts beenden |
|
| POST | `/auth/logout` | Sitzung dieses Geräts beenden |
|
||||||
| GET | `/auth/sessions` | Angemeldete Geräte: `[{id, createdAt, lastUsedAt, userAgent, ip, current}]` |
|
| GET | `/auth/sessions` | Angemeldete Geräte: `[{id, createdAt, lastUsedAt, userAgent, ip, current}]` |
|
||||||
| DELETE | `/auth/sessions/{id}` | Ein Gerät abmelden; `404` bei fremder oder unbekannter Sitzung |
|
| DELETE | `/auth/sessions/{id}` | Ein Gerät abmelden; `404` bei fremder oder unbekannter Sitzung |
|
||||||
@@ -56,7 +46,8 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
|||||||
| DELETE | `/user/account` | Eigenes Konto mit allen Daten löschen (`{"password"}` nötig); `403` für den letzten Server-Admin. `/auth/me` liefert dafür `isLastAdmin` |
|
| DELETE | `/user/account` | Eigenes Konto mit allen Daten löschen (`{"password"}` nötig); `403` für den letzten Server-Admin. `/auth/me` liefert dafür `isLastAdmin` |
|
||||||
| GET | `/backups`, `/backups/{name}` | Eigene automatische Server-Backups auflisten bzw. herunterladen |
|
| GET | `/backups`, `/backups/{name}` | Eigene automatische Server-Backups auflisten bzw. herunterladen |
|
||||||
| POST | `/backups/{name}/restore` | Server-Backup einspielen: `mode=merge` oder `restore` (gewählte Bereiche vorher leeren), `sections=notes,tasks,lists,inbox,themes`, `preview=1` |
|
| POST | `/backups/{name}/restore` | Server-Backup einspielen: `mode=merge` oder `restore` (gewählte Bereiche vorher leeren), `sections=notes,tasks,lists,inbox,themes`, `preview=1` |
|
||||||
| GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung); `POST` mit `name` und `scopes` (Standard `["webhook:triage"]`) |
|
| GET/POST | `/keys` | API-Keys auflisten bzw. anlegen (Klartext nur bei Erstellung); `POST` mit `name` und `scopes` (Standard `["webhook:triage"]`) |
|
||||||
|
| DELETE | `/keys/{id}` | API-Key löschen |
|
||||||
|
|
||||||
### Globale Suche (`GET /api/v1/search?q=`)
|
### Globale Suche (`GET /api/v1/search?q=`)
|
||||||
|
|
||||||
@@ -96,6 +87,7 @@ Führt eine Volltextsuche über SQLite FTS5 mit BM25-Relevanz-Ranking aus.
|
|||||||
| GET | `/tasks?taskListId=` | Aufgaben (optional einer Liste) |
|
| GET | `/tasks?taskListId=` | Aufgaben (optional einer Liste) |
|
||||||
| POST | `/tasks/reorder`, `/task-lists/{id}/tasks/reorder` | Reihenfolge/Spalte ändern (`items[{id, sortOrder, kanbanStatus}]`) |
|
| POST | `/tasks/reorder`, `/task-lists/{id}/tasks/reorder` | Reihenfolge/Spalte ändern (`items[{id, sortOrder, kanbanStatus}]`) |
|
||||||
| POST | `/tasks/{id}/unlink` | Verknüpfung zu Notiz-Block lösen |
|
| POST | `/tasks/{id}/unlink` | Verknüpfung zu Notiz-Block lösen |
|
||||||
|
| POST/DELETE | `/tasks/{id}/watch` | Aufgabe beobachten bzw. nicht mehr → `{watching}`; `404` ohne Zugriff. Aufgaben-Antworten tragen `isWatching` |
|
||||||
|
|
||||||
## Aufgabenlisten
|
## Aufgabenlisten
|
||||||
|
|
||||||
@@ -118,7 +110,8 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein
|
|||||||
| GET/POST | `/workspace/folders/shared` | Geteilte Ordner; anlegen (`name`, `mountPath`) |
|
| GET/POST | `/workspace/folders/shared` | Geteilte Ordner; anlegen (`name`, `mountPath`) |
|
||||||
| GET/DELETE | `/workspace/folders/shared/{id}` | Abruf; Löschen |
|
| GET/DELETE | `/workspace/folders/shared/{id}` | Abruf; Löschen |
|
||||||
| PUT | `/workspace/folders/shared/{id}/mount` | Eigenen Einhängepfad ändern (`mountPath`) |
|
| PUT | `/workspace/folders/shared/{id}/mount` | Eigenen Einhängepfad ändern (`mountPath`) |
|
||||||
| GET/POST/DELETE | `/workspace/folders/shared/{id}/shares[/{shareId}]` | Freigaben (`permission`: `read`/`write`) |
|
| GET/POST | `/workspace/folders/shared/{id}/shares` | Freigaben geteilter Ordner (`permission`: `read`/`write`) |
|
||||||
|
| DELETE | `/workspace/folders/shared/{id}/shares/{shareId}` | Freigabe eines geteilten Ordners entfernen |
|
||||||
| GET | `/workspace/tags`, `/workspace/tags/{tag}` | Tag-Übersicht und -Details |
|
| GET | `/workspace/tags`, `/workspace/tags/{tag}` | Tag-Übersicht und -Details |
|
||||||
| PUT | `/workspace/tags/{tag}/note` | Tag-Seite speichern (`content`) |
|
| PUT | `/workspace/tags/{tag}/note` | Tag-Seite speichern (`content`) |
|
||||||
| GET/POST | `/documents` | Notizen auflisten; anlegen (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`) |
|
| GET/POST | `/documents` | Notizen auflisten; anlegen (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`) |
|
||||||
@@ -129,7 +122,17 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein
|
|||||||
| DELETE | `/documents/{id}/permanent` | Notiz im Papierkorb endgültig löschen |
|
| DELETE | `/documents/{id}/permanent` | Notiz im Papierkorb endgültig löschen |
|
||||||
| PUT | `/documents/{id}/move` | Ordner wechseln (`folderPath`) |
|
| PUT | `/documents/{id}/move` | Ordner wechseln (`folderPath`) |
|
||||||
| POST | `/documents/{id}/blocks/{blockId}/enhanced-task` | Aus einem Block eine verknüpfte Aufgabe machen (`content`, `checked`) |
|
| POST | `/documents/{id}/blocks/{blockId}/enhanced-task` | Aus einem Block eine verknüpfte Aufgabe machen (`content`, `checked`) |
|
||||||
| GET/POST/DELETE | `/documents/{id}/shares[/{userId}]` | Notiz-Freigaben (`userId`/`email`/`teamId`, `permission`: `read`/`write`) |
|
| GET/POST | `/documents/{id}/shares` | Notiz-Freigaben (`userId`/`email`/`teamId`, `permission`: `read`/`write`) |
|
||||||
|
| DELETE | `/documents/{id}/shares/{userId}` | Notiz-Freigabe entfernen |
|
||||||
|
| POST | `/documents/{id}/extract` | `{blockId, clientId}`: legt aus den Unterpunkten des Punkts eine eigene Notiz an (Titel = Text ohne Hashtags, gleicher Ordner, Freigaben übernommen, fremder Besitzer bekommt Schreibrecht) → `{document, link}`. Die Ursprungsnotiz ändert der Server nicht; der Editor ersetzt den Ast selbst durch `link` (normale eigene Änderung, Strg+Z möglich). Schreibrecht nötig; `409`, wenn jemand anderes im Ast schreibt |
|
||||||
|
| POST | `/documents/{id}/extract/undo` | Legt die ausgelagerte Notiz `{id}` wieder in den Papierkorb (nur ihr Ersteller) |
|
||||||
|
| POST | `/documents/{id}/merge` | `{sourceId}`: hängt die (eigene) Quellnotiz als Überschrift mit Unterpunkten an diese Notiz an, Quelle in den Papierkorb → `{targetId, sourceId, sourceTitle, headingId}` |
|
||||||
|
| POST | `/documents/{id}/merge/undo` | `{sourceId, headingId}`: Abschnitt wieder entfernen, Quelle wiederherstellen |
|
||||||
|
| POST | `/documents/{id}/media` | Bild oder Audio zu einer Notiz hochladen (`multipart/form-data`, Feld `file`; Schreibrecht nötig) → `{id, kind, mime, size, url}`. `415` falsches Format, `413` zu groß oder Kontingent voll |
|
||||||
|
| POST | `/documents/{id}/media/fetch` | Externes Bild kopieren: `{url}` (nur https, keine internen Adressen; nicht in der Demo) → wie oben |
|
||||||
|
| GET | `/media/{id}` | Datei abrufen (Leserecht auf eine Notiz, die sie zeigt), mit Range-Anfragen; sonst `404` |
|
||||||
|
| GET | `/link-preview?url=https://…` | Vorschau einer Web-Seite (`title`, `description`, `siteName`, `hasImage`, `hasIcon`, `ok`, `key`), vom Server geholt und 7 Tage zwischengespeichert; `422` ohne https, `403` in der Demo, `429` zu viele Abrufe |
|
||||||
|
| GET | `/link-preview/{key}/image` bzw. `/icon` | Bild bzw. Icon einer Vorschau |
|
||||||
|
|
||||||
Der Inhalt einer Notiz (Blöcke) wird ausschließlich über `POST /docs/{id}/sync` geändert. `POST /documents` nimmt beim Anlegen weiterhin eine Start-Blockliste an; sie wird intern in Operationen umgerechnet.
|
Der Inhalt einer Notiz (Blöcke) wird ausschließlich über `POST /docs/{id}/sync` geändert. `POST /documents` nimmt beim Anlegen weiterhin eine Start-Blockliste an; sie wird intern in Operationen umgerechnet.
|
||||||
|
|
||||||
@@ -173,7 +176,8 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET/POST | `/lists` | Listen; anlegen (`title`, `icon`, `listType`) |
|
| GET/POST | `/lists` | Listen; anlegen (`title`, `icon`, `listType`) |
|
||||||
| GET/DELETE | `/lists/{id}` | Abruf mit Einträgen; Löschen |
|
| GET/DELETE | `/lists/{id}` | Abruf mit Einträgen; Löschen |
|
||||||
| GET/POST/DELETE | `/lists/{id}/shares[/{shareId}]` | Freigaben (`targetType`, `targetId`, `role`: `editor`/`viewer`) |
|
| GET/POST | `/lists/{id}/shares` | Freigaben abrufen bzw. anlegen (`targetType`, `targetId`, `role`: `editor`/`viewer`) |
|
||||||
|
| DELETE | `/lists/{id}/shares/{shareId}` | Freigabe entfernen |
|
||||||
| POST | `/lists/{id}/items` | Eintrag (`content`, `category`, `quantity`) |
|
| POST | `/lists/{id}/items` | Eintrag (`content`, `category`, `quantity`) |
|
||||||
| PATCH | `/lists/{id}/items/{itemId}` | Abhaken (`isCompleted`) |
|
| PATCH | `/lists/{id}/items/{itemId}` | Abhaken (`isCompleted`) |
|
||||||
| PUT | `/lists/{id}/items/{itemId}` | Ändern (`content`, `category`, `quantity`) |
|
| PUT | `/lists/{id}/items/{itemId}` | Ändern (`content`, `category`, `quantity`) |
|
||||||
@@ -200,7 +204,8 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant
|
|||||||
| PUT/DELETE | `/admin/organizations/{id}` | Umbenennen (`name`); löschen (Mitglieder sind danach ohne Organisation) |
|
| PUT/DELETE | `/admin/organizations/{id}` | Umbenennen (`name`); löschen (Mitglieder sind danach ohne Organisation) |
|
||||||
| PATCH/DELETE | `/admin/users/{id}` | `displayName`, `email`, `role`, `isActive`, `discordWebhookUrl`; löschen |
|
| PATCH/DELETE | `/admin/users/{id}` | `displayName`, `email`, `role`, `isActive`, `discordWebhookUrl`; löschen |
|
||||||
| PUT | `/admin/users/{id}/password` | Passwort zurücksetzen |
|
| PUT | `/admin/users/{id}/password` | Passwort zurücksetzen |
|
||||||
| GET/POST/DELETE | `/admin/invites[/{id}]` | Einladungen (`email`, `role`, `organizationId`, `validDays`) |
|
| GET/POST | `/admin/invites` | Einladungen auflisten bzw. anlegen (`email`, `role`, `organizationId`, `validDays`) |
|
||||||
|
| DELETE | `/admin/invites/{id}` | Einladung widerrufen/löschen |
|
||||||
| GET/PUT | `/admin/settings` | `instanceName`, `allowRegistration`, `sharingMode` (`private`/`organizations`/`open`), SMTP-Felder; ein Moduswechsel bereinigt Freigaben. `smtpPass` kommt nie zurück (`smtpPassSet` zeigt, ob eines gespeichert ist); leer = unverändert |
|
| GET/PUT | `/admin/settings` | `instanceName`, `allowRegistration`, `sharingMode` (`private`/`organizations`/`open`), SMTP-Felder; ein Moduswechsel bereinigt Freigaben. `smtpPass` kommt nie zurück (`smtpPassSet` zeigt, ob eines gespeichert ist); leer = unverändert |
|
||||||
| GET | `/admin/settings/sharing-impact?mode=` | Probelauf: wie viele Freigaben und Gruppen-Mitgliedschaften ein Wechsel entfernen würde (`affected`) |
|
| GET | `/admin/settings/sharing-impact?mode=` | Probelauf: wie viele Freigaben und Gruppen-Mitgliedschaften ein Wechsel entfernen würde (`affected`) |
|
||||||
| POST | `/admin/smtp/test` | Testmail (`toEmail`) |
|
| POST | `/admin/smtp/test` | Testmail (`toEmail`) |
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ Client → Server versteht der Server nur: `{"event":"JOIN_ROOM","room":"<id>"}`
|
|||||||
| `LIST_CREATED`, `LIST_UPDATED`, `LIST_DELETED`, `LIST_SHARED`, `LIST_UNSHARED`, `LIST_CLEARED` | Listenverwaltung | Benutzer bzw. Raum der Liste |
|
| `LIST_CREATED`, `LIST_UPDATED`, `LIST_DELETED`, `LIST_SHARED`, `LIST_UNSHARED`, `LIST_CLEARED` | Listenverwaltung | Benutzer bzw. Raum der Liste |
|
||||||
| `ITEM_CREATED`, `ITEM_UPDATED`, `ITEM_TOGGLED`, `ITEM_DELETED`, `ITEM_REORDERED` | Listeneinträge | Raum der Liste |
|
| `ITEM_CREATED`, `ITEM_UPDATED`, `ITEM_TOGGLED`, `ITEM_DELETED`, `ITEM_REORDERED` | Listeneinträge | Raum der Liste |
|
||||||
| `USER_PRESENCE` | Verbindung auf-/abgebaut (`onlineCount`) | Raum |
|
| `USER_PRESENCE` | Verbindung auf-/abgebaut (`onlineCount`) | Raum |
|
||||||
|
| `DEMO_RESET` | Periodischer Reset im Demo-Modus; Clients verwerfen lokale Caches und laden Daten neu | Alle aktiven Verbindungen |
|
||||||
| `SSE_CONNECTED` | Nur SSE, direkt nach Verbindung | Verbindung |
|
| `SSE_CONNECTED` | Nur SSE, direkt nach Verbindung | Verbindung |
|
||||||
|
|
||||||
## Verhalten im Frontend
|
## Verhalten im Frontend
|
||||||
|
|||||||
@@ -70,13 +70,18 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
|
|||||||
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer / `587` / `true` | Mailversand, auch im Admin-UI einstellbar |
|
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer / `587` / `true` | Mailversand, auch im Admin-UI einstellbar |
|
||||||
| `SMTP_FROM_NAME` | `MindSift` | Anzeigename des Absenders, wenn `SMTP_FROM` nur eine Adresse ist (`From: "MindSift" <adresse>`, der SMTP-Umschlag bleibt die reine Adresse); ein Name in `SMTP_FROM` hat Vorrang |
|
| `SMTP_FROM_NAME` | `MindSift` | Anzeigename des Absenders, wenn `SMTP_FROM` nur eine Adresse ist (`From: "MindSift" <adresse>`, der SMTP-Umschlag bleibt die reine Adresse); ein Name in `SMTP_FROM` hat Vorrang |
|
||||||
| `SMTP_SKIP_VERIFY` | `false` | Zertifikat des Mailservers nicht prüfen (nur interne Server mit selbstsigniertem Zertifikat) |
|
| `SMTP_SKIP_VERIFY` | `false` | Zertifikat des Mailservers nicht prüfen (nur interne Server mit selbstsigniertem Zertifikat) |
|
||||||
|
| `NOTIFY_DIGEST_MINUTES` | `5` | Benachrichtigungen (Freigaben, beobachtete Aufgaben …) werden gesammelt und höchstens alle so viele Minuten gebündelt verschickt |
|
||||||
|
| `MEDIA_MAX_IMAGE_MB`, `MEDIA_MAX_AUDIO_MB` | `15` / `50` | Größtes Bild bzw. größte Audiodatei in Notizen in MB |
|
||||||
|
| `MEDIA_QUOTA_MB` | `0` | Speicherplatz für Medien pro Benutzer in MB (`0` = unbegrenzt) |
|
||||||
|
| `MEDIA_DEMO_MAX_MB` | `3` | Nur im Demo-Modus: maximale Dateigröße für Medien-Uploads in MB |
|
||||||
| `STT_URL` | leer | Eigene Spracherkennung: Basis-URL eines OpenAI-kompatiblen Whisper-Servers (z. B. `http://whisper:8000`, speaches). Gesetzt = Server-Modus, sonst Spracherkennung des Browsers; in der Demo immer Browser |
|
| `STT_URL` | leer | Eigene Spracherkennung: Basis-URL eines OpenAI-kompatiblen Whisper-Servers (z. B. `http://whisper:8000`, speaches). Gesetzt = Server-Modus, sonst Spracherkennung des Browsers; in der Demo immer Browser |
|
||||||
| `STT_MODEL`, `STT_LANGUAGE`, `STT_API_KEY` | `deepdml/faster-whisper-large-v3-turbo-ct2` / `de` / leer | Modell, Sprache (`auto` = erkennen), Schlüssel falls der Whisper-Server einen verlangt |
|
| `STT_MODEL`, `STT_LANGUAGE`, `STT_API_KEY` | `deepdml/faster-whisper-large-v3-turbo-ct2` / `de` / leer | Modell, Sprache (`auto` = erkennen), Schlüssel falls der Whisper-Server einen verlangt |
|
||||||
| `STT_DISABLED` | `false` | `true` blendet die Spracheingabe überall aus |
|
| `STT_DISABLED` | `false` | `true` blendet die Spracheingabe überall aus |
|
||||||
| `STT_MAX_SECONDS`, `STT_MAX_UPLOAD_MB` | `600` / `25` | Längste Aufnahme (stoppt im Browser), größte Aufnahme |
|
| `STT_MAX_SECONDS`, `STT_MAX_UPLOAD_MB` | `600` / `25` | Längste Aufnahme (stoppt im Browser), größte Aufnahme |
|
||||||
| `STT_MAX_PARALLEL`, `STT_PER_USER_PER_HOUR`, `STT_TIMEOUT_SECONDS` | `1` / `60` / `120` | Gleichzeitige Erkennungen (weitere warten), Erkennungen pro Person und Stunde, Höchstdauer inkl. Warten |
|
| `STT_MAX_PARALLEL`, `STT_PER_USER_PER_HOUR`, `STT_TIMEOUT_SECONDS` | `1` / `60` / `120` | Gleichzeitige Erkennungen (weitere warten), Erkennungen pro Person und Stunde, Höchstdauer inkl. Warten |
|
||||||
| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) |
|
| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) |
|
||||||
| `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` (oder `…_CONTENT_FILE` mit Dateipfad) | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster. Ohne URL und Text bleibt der Link unsichtbar. |
|
| `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster |
|
||||||
|
| `LEGAL_IMPRINT_CONTENT_FILE`, `LEGAL_PRIVACY_CONTENT_FILE` | leer | Pfad zu einer Datei im Container mit Markdown-Inhalt für Impressum bzw. Datenschutz. Ohne URL und Text bleibt der Link unsichtbar. |
|
||||||
|
|
||||||
Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in `DATA_DIR` genutzt wird; Postgres ist nicht implementiert.
|
Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in `DATA_DIR` genutzt wird; Postgres ist nicht implementiert.
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -8,22 +8,22 @@ SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `j
|
|||||||
### Benutzer und Zugang
|
### Benutzer und Zugang
|
||||||
| Tabelle | Zweck | Wichtige Spalten |
|
| Tabelle | Zweck | Wichtige Spalten |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `users` | Konten | `email` (eindeutig), `username`, `password_hash`, `display_name`, `role` (`admin`/`member`), `is_active`, `token_version`, `alarm_channels` (kommagetrennt), `alarm_email`, `discord_webhook_url`, `custom_webhook_url`, `notify_before_minutes`, `avatar_url`, `organization_id` (leer = keine Organisation) |
|
| `users` | Konten | `email` (eindeutig), `username`, `password_hash`, `display_name`, `role` (`admin`/`member`), `is_active`, `token_version`, `alarm_channels` (kommagetrennt), `alarm_email`, `discord_webhook_url`, `custom_webhook_url`, `notify_before_minutes`, `avatar_url`, `organization_id` (leer = keine Organisation), `notification_settings` (JSON der Kanäle und Ereignisse), `last_daily_summary` (Datum des letzten Versands) |
|
||||||
| `user_invites` | Einladungscodes | `code`, `email`, `role`, `organization_id`, `expires_at`, `used_by` |
|
| `user_invites` | Einladungscodes | `code`, `email`, `role`, `organization_id`, `expires_at`, `used_by` |
|
||||||
| `media` | Bilder und Audio in Notizen (Migration 5); Datei unter `$DATA_DIR/media/<doc_id>/<id>.<ext>` | `id`, `doc_id`, `owner_id`, `kind` (`image`/`audio`), `mime`, `size`, `source_url` (kopiertes externes Bild), `created_at` |
|
| `media` | Bilder und Audio in Notizen (Migration 5); Datei unter `$DATA_DIR/media/<doc_id>/<id>.<ext>` | `id`, `doc_id`, `owner_id`, `kind` (`image`/`audio`), `mime`, `size`, `source_url` (kopiertes externes Bild), `created_at` |
|
||||||
| `link_previews` | Zwischengespeicherte Link-Vorschauen (Migration 6) | `key` (Hash der URL), `url`, `title`, `description`, `site_name`, `image`/`icon` (BLOB, mit `*_mime`), `ok`, `fetched_at` |
|
| `link_previews` | Zwischengespeicherte Link-Vorschauen (Migration 6) | `key` (Hash der URL), `url`, `title`, `description`, `site_name`, `image`/`icon` (BLOB, mit `*_mime`), `ok`, `fetched_at` |
|
||||||
| `notification_queue` | Wartende Benachrichtigungen bis zum gebündelten Versand (Migration 7) | `user_id`, `kind` (`shared`/`taskDone`/`watched`), `payload` (JSON), `created_at`, `attempts` |
|
| `notification_queue` | Wartende Benachrichtigungen bis zum gebündelten Versand (Migration 7) | `user_id`, `kind` (`shared`/`taskDone`/`watched`), `payload` (JSON), `created_at`, `attempts` |
|
||||||
| `task_watchers` | Beobachtete Aufgaben | `task_id`, `user_id`, `created_at` |
|
| `task_watchers` | Beobachtete Aufgaben (Migration 7) | `task_id`, `user_id`, `created_at` |
|
||||||
| `sessions` | Angemeldete Geräte (Migration 4, ersetzt `revoked_tokens`) | `id` (= `jti` des Tokens), `user_id`, `created_at`, `last_used_at` (höchstens alle 5 Minuten aktualisiert), `user_agent`, `ip`, `revoked_at` (leer = aktiv) |
|
| `sessions` | Angemeldete Geräte (Migration 4, ersetzt `revoked_tokens`) | `id` (= `jti` des Tokens), `user_id`, `created_at`, `last_used_at` (höchstens alle 5 Minuten aktualisiert), `user_agent`, `ip`, `revoked_at` (leer = aktiv) |
|
||||||
| `user_themes` | Eigene CSS-Themes eines Benutzers | `user_id` (FK, `ON DELETE CASCADE`), `name`, `css_content` |
|
| `user_themes` | Eigene CSS-Themes eines Benutzers | `user_id` (FK, `ON DELETE CASCADE`), `name`, `css_content` |
|
||||||
| `api_keys` | Webhook-/API-Schlüssel | `key_prefix`, `key_hash` (SHA-256), `scopes` (nicht ausgewertet), `last_used_at` |
|
| `api_keys` | Webhook-/API-Schlüssel | `name`, `key_prefix`, `key_hash` (SHA-256), `scopes` (`webhook:triage`, `webhook:task`, `webhook:list`), `last_used_at` |
|
||||||
| `system_settings` | Schlüssel/Wert für Instanz-Einstellungen | `sharing_mode`, `allow_registration`, `instance_name`, `smtp_*` |
|
| `system_settings` | Schlüssel/Wert für Instanz-Einstellungen | `sharing_mode`, `allow_registration`, `instance_name`, `smtp_*` |
|
||||||
|
|
||||||
### Gedanken und Aufgaben
|
### Gedanken und Aufgaben
|
||||||
| Tabelle | Zweck | Wichtige Spalten |
|
| Tabelle | Zweck | Wichtige Spalten |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `password_reset_tokens` | Links aus „Passwort vergessen?“ | `token_hash` (SHA-256), `user_id`, `created_at`, `expires_at` (1 h), `used_at` |
|
| `password_reset_tokens` | Links aus „Passwort vergessen?“ | `token_hash` (SHA-256), `user_id`, `created_at`, `expires_at` (1 h), `used_at` |
|
||||||
| `thoughts` | Gedanken **und** Aufgaben in einer Tabelle | `status` (`inbox`/`kept`/`converted`/`discarded`), `converted_type` (`task`/`document`/`list_item`/`document_block`), `converted_ref_id`, `priority`, `is_completed`, `due_date` (`YYYY-MM-DD` ganztägig oder `YYYY-MM-DDTHH:MM`, Zeitzone des Servers), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `alarm_failures` (erfolglose Alarm-Durchläufe, ab 3 aufgegeben), `sort_order`, `source` (`manual`, `voice`, `webhook` …) |
|
| `thoughts` | Gedanken **und** Aufgaben in einer Tabelle | `status` (`inbox`/`kept`/`converted`/`discarded`), `converted_type` (`task`/`document`/`list_item`/`document_block`), `converted_ref_id`, `priority`, `is_completed`, `due_date` (`YYYY-MM-DD` ganztägig oder `YYYY-MM-DDTHH:MM`, Zeitzone des Servers), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `alarm_failures` (erfolglose Alarm-Durchläufe, ab 3 aufgegeben), `recurrence` (`daily`/`weekly`/`monthly`/`yearly`/leer), `recurrence_spawned`, `sort_order`, `source` (`manual`, `voice`, `webhook` …) |
|
||||||
| `task_assignees` | Zuweisungen von Aufgaben | `task_id` (FK `thoughts`, `ON DELETE CASCADE`), `user_id`, `created_at`; nur Personen mit Zugriff auf die Liste, bereinigt durch `PruneTaskAssignees` |
|
| `task_assignees` | Zuweisungen von Aufgaben | `task_id` (FK `thoughts`, `ON DELETE CASCADE`), `user_id`, `created_at`; nur Personen mit Zugriff auf die Liste, bereinigt durch `PruneTaskAssignees` |
|
||||||
| `task_lists` | Aufgabenlisten | `user_id` (Eigentümer), `title`, `icon`, `color`, `columns` (JSON, Kanban-Spalten) |
|
| `task_lists` | Aufgabenlisten | `user_id` (Eigentümer), `title`, `icon`, `color`, `columns` (JSON, Kanban-Spalten) |
|
||||||
| `task_list_shares` | Freigaben von Aufgabenlisten | `target_type` (`team`/`user`), `target_id`, **`permission`** (`editor`/`viewer`, Standard `editor`) |
|
| `task_list_shares` | Freigaben von Aufgabenlisten | `target_type` (`team`/`user`), `target_id`, **`permission`** (`editor`/`viewer`, Standard `editor`) |
|
||||||
@@ -80,11 +80,11 @@ teams 1─n team_members n─1 users
|
|||||||
## Migrationen und Aufräumarbeiten beim Start
|
## Migrationen und Aufräumarbeiten beim Start
|
||||||
|
|
||||||
- Spalten/Tabellen/Indizes werden nachgezogen (`ALTER TABLE … ADD COLUMN`, Fehler werden ignoriert).
|
- Spalten/Tabellen/Indizes werden nachgezogen (`ALTER TABLE … ADD COLUMN`, Fehler werden ignoriert).
|
||||||
- Veraltete Tabellen werden gelöscht: `spaces`, `space_members`, `space_team_shares`, `document_crdt_state`, `document_crdt_updates`, `document_blocks`, `doc_block_tags`.
|
- Veraltete Tabellen werden gelöscht: `spaces`, `space_members`, `space_team_shares`, `document_crdt_state`, `document_crdt_updates`, `document_blocks`, `doc_block_tags`, `revoked_tokens`.
|
||||||
- `users.username` wird aus dem E-Mail-Präfix aufgefüllt.
|
- `users.username` wird aus dem E-Mail-Präfix aufgefüllt.
|
||||||
- Notiz-Synchronisation (`migrateDocSync`): neue Spalten an `blocks` (inkl. `deleted_at`; vorhandene Grabsteine erhalten den Migrationszeitpunkt) und `documents` (`head_seq`, `title_version`, `log_floor`), Tabellen `doc_ops` und `doc_op_results`. Blöcke im alten flachen Format (nur `level`/`position`) werden einmalig in den Baum umgewandelt.
|
- Notiz-Synchronisation (`migrateDocSync`): neue Spalten an `blocks` (inkl. `deleted_at`; vorhandene Grabsteine erhalten den Migrationszeitpunkt) und `documents` (`head_seq`, `title_version`, `log_floor`), Tabellen `doc_ops` und `doc_op_results`. Blöcke im alten flachen Format (nur `level`/`position`) werden einmalig in den Baum umgewandelt.
|
||||||
- Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=organizations`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer.
|
- Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=organizations`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer.
|
||||||
- **Migrationen:** [migrations.go](../internal/storage/migrations.go) führt nummerierte Schritte genau einmal aus und vermerkt sie in `schema_migrations` (`id`, `name`, `applied_at`); `PRAGMA user_version` zeigt die höchste angewandte Nummer. Schritt 3 („Basis-Schema v0.4.1“, `migrateBaseline`) baut das komplette Schema auf und bringt ältere Datenbanken auf diesen Stand; Spalten werden nur angelegt, wenn sie fehlen. Schlägt ein Schritt fehl, bricht der Start mit der Fehlermeldung ab, statt mit halbem Schema weiterzulaufen. Neue Änderungen kommen als neuer Schritt ans Ende, alte Schritte werden nie verändert. Daten werden beim Start nie gelöscht; nur der Demo-Modus leert die Datenbank.
|
- **Migrationen:** [migrations.go](../internal/storage/migrations.go) führt nummerierte Schritte (Migrationen 3 bis 7: 3 „Basis-Schema v0.4.1“, 4 „Sitzungen“, 5 „Medien“, 6 „Link-Vorschauen“, 7 „Benachrichtigungen“) genau einmal aus und vermerkt sie in `schema_migrations` (`id`, `name`, `applied_at`); `PRAGMA user_version` zeigt die höchste angewandte Nummer. Schritt 3 (`migrateBaseline`) baut das Basisschema auf und bringt ältere Datenbanken auf diesen Stand; Spalten werden nur angelegt, wenn sie fehlen. Schlägt ein Schritt fehl, bricht der Start mit der Fehlermeldung ab, statt mit halbem Schema weiterzulaufen. Neue Änderungen kommen als neuer Schritt ans Ende, alte Schritte werden nie verändert. Daten werden beim Start nie gelöscht; nur der Demo-Modus leert die Datenbank.
|
||||||
|
|
||||||
## Sicherung
|
## Sicherung
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -131,7 +131,7 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel**
|
|||||||
|
|
||||||
### Notizen
|
### Notizen
|
||||||
- **Versionsverlauf ansehen und zurückholen:** das Operations-Log (`doc_ops`) ist vorhanden, eine Ansicht fehlt.
|
- **Versionsverlauf ansehen und zurückholen:** das Operations-Log (`doc_ops`) ist vorhanden, eine Ansicht fehlt.
|
||||||
- **Bilder und Anhänge** in Notizen.
|
- ~~**Bilder und Anhänge** in Notizen.~~ (Umgesetzt in v0.7.0 mit `/image`, `/audio` und Medienablage).
|
||||||
- **Vorlagen und Tagesnotiz** (z. B. „Wochenrückblick“, „Heute“).
|
- **Vorlagen und Tagesnotiz** (z. B. „Wochenrückblick“, „Heute“).
|
||||||
- **Öffentlicher Lese-Link** für einzelne Notizen, mit Ablaufdatum.
|
- **Öffentlicher Lese-Link** für einzelne Notizen, mit Ablaufdatum.
|
||||||
- **Verlinkungs-Graph** auf Basis der Backlinks.
|
- **Verlinkungs-Graph** auf Basis der Backlinks.
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus
|
|||||||
- Gespeichert in `task_assignees`; `PATCH /thoughts/{id}` mit `assignees` ersetzt die Liste. Der Server lehnt Personen ohne Zugriff ab (`403`, `storage.ErrAssigneeNoAccess`).
|
- Gespeichert in `task_assignees`; `PATCH /thoughts/{id}` mit `assignees` ersetzt die Liste. Der Server lehnt Personen ohne Zugriff ab (`403`, `storage.ErrAssigneeNoAccess`).
|
||||||
- **Zuweisungen folgen den Rechten:** `Store.PruneTaskAssignees` entfernt jede Zuweisung, deren Person die Liste nicht mehr sehen kann. Es läuft nach Entfernen einer Freigabe, Austritt aus oder Entfernen aus einer Gruppe, Löschen einer Gruppe, Löschen der Liste und Verschieben der Aufgabe in eine andere oder keine Liste; beim Löschen eines Kontos werden dessen Zuweisungen direkt gelöscht. Wer über einen zweiten Weg (z. B. direkt und über eine Gruppe) weiter Zugriff hat, bleibt zugewiesen.
|
- **Zuweisungen folgen den Rechten:** `Store.PruneTaskAssignees` entfernt jede Zuweisung, deren Person die Liste nicht mehr sehen kann. Es läuft nach Entfernen einer Freigabe, Austritt aus oder Entfernen aus einer Gruppe, Löschen einer Gruppe, Löschen der Liste und Verschieben der Aufgabe in eine andere oder keine Liste; beim Löschen eines Kontos werden dessen Zuweisungen direkt gelöscht. Wer über einen zweiten Weg (z. B. direkt und über eine Gruppe) weiter Zugriff hat, bleibt zugewiesen.
|
||||||
- Wer sehen und zuweisen kann, hängt außerdem von der Sichtbarkeit ab: Verliert jemand sie (z. B. Wechsel der Organisation), fallen Freigabe und Zuweisungen weg ([sharing-and-permissions.md](../sharing-and-permissions.md)).
|
- Wer sehen und zuweisen kann, hängt außerdem von der Sichtbarkeit ab: Verliert jemand sie (z. B. Wechsel der Organisation), fallen Freigabe und Zuweisungen weg ([sharing-and-permissions.md](../sharing-and-permissions.md)).
|
||||||
- Bekannte Lücke: (Umsortieren per ID).
|
- **Aufgaben beobachten (`task_watchers`):** Im Detail-Fenster lässt sich eine Aufgabe über „Beobachten“ abonnieren (`POST /tasks/{id}/watch`, `DELETE /tasks/{id}/watch`). Jede Änderung an einer beobachteten Aufgabe wird dem Beobachter über seine konfigurierten Benachrichtigungskanäle gemeldet.
|
||||||
|
|
||||||
## Wiederkehrende Aufgaben
|
## Wiederkehrende Aufgaben
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ Der Ticker läuft jede Minute ([architecture.md](../architecture.md)). Für offe
|
|||||||
| Discord | Webhook-URL | Benutzereinstellungen |
|
| Discord | Webhook-URL | Benutzereinstellungen |
|
||||||
| Webhook | beliebige URL (JSON: `event`, `title`, `notes`, `dueDate`, `priority`, `timestamp`) | Benutzereinstellungen |
|
| Webhook | beliebige URL (JSON: `event`, `title`, `notes`, `dueDate`, `priority`, `timestamp`) | Benutzereinstellungen |
|
||||||
|
|
||||||
Danach wird `alarm_sent = 1` gesetzt, sobald mindestens ein Kanal erfolgreich war.
|
Danach wird `alarm_sent = 1` gesetzt, sobald mindestens ein Kanal erfolgreich war. Neben Fälligkeits-Alarmen (die sofort versendet werden) unterstützt MindSift granulare Benachrichtigungen für Freigaben, erledigte geteilte Aufgaben und beobachtete Aufgaben, die über `notification_queue` gebündelt nach `NOTIFY_DIGEST_MINUTES` versendet werden ([architecture.md](../architecture.md#benachrichtigungen)).
|
||||||
|
|
||||||
**E-Mail-Versand** ([mail.go](../../internal/api/mail.go)) führt den SMTP-Dialog Schritt für Schritt:
|
**E-Mail-Versand** ([mail.go](../../internal/api/mail.go)) führt den SMTP-Dialog Schritt für Schritt:
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Ziel: Gedanken in Sekunden festhalten und später in Ruhe sortieren. Code: [Inst
|
|||||||
|
|
||||||
- Die Eingabeleiste sitzt im Header; `C` oder `/` fokussiert sie (außer beim Tippen in einem Feld).
|
- Die Eingabeleiste sitzt im Header; `C` oder `/` fokussiert sie (außer beim Tippen in einem Feld).
|
||||||
- Ziel (`target`): **Posteingang** (`inbox`, Standard), **Direkt-Aufgabe** (`task`), **Listeneintrag** (`list_item`, mit Zielliste) oder **Direkt-Notiz** (`document`).
|
- Ziel (`target`): **Posteingang** (`inbox`, Standard), **Direkt-Aufgabe** (`task`), **Listeneintrag** (`list_item`, mit Zielliste) oder **Direkt-Notiz** (`document`).
|
||||||
- Mehrzeilige Eingabe wird unterstützt. Spracheingabe nutzt die Web Speech API des Browsers; Quelle `voice` fügt den Tag `#diktat` an.
|
- Mehrzeilige Eingabe wird unterstützt. Spracheingabe nutzt `useVoiceInput` (Whisper-Server oder Spracherkennung des Browsers); Quelle `voice` fügt den Tag `#diktat` an.
|
||||||
- Quelle `webhook` (per API-Key/Webhook) fügt den Tag `#webhook` an. Hashtags im Text werden als Tags extrahiert (`domain.ExtractTags`).
|
- Quelle `webhook` (per API-Key/Webhook) fügt den Tag `#webhook` an. Hashtags im Text werden als Tags extrahiert (`domain.ExtractTags`).
|
||||||
- Per API: `POST /inbox` und `POST /webhooks/ingest` (siehe [rest.md](../api/rest.md)).
|
- Per API: `POST /inbox` und `POST /webhooks/ingest` (siehe [rest.md](../api/rest.md)).
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Modul: Workspace (Outliner-Notizen)
|
# Modul: Workspace (Outliner-Notizen)
|
||||||
|
|
||||||
Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.tsx) (Explorer, Tags, To-Do-Rollup), [DocumentEditor.tsx](../../frontend/src/features/workspace/DocumentEditor.tsx), [OutlinerTree.tsx](../../frontend/src/features/workspace/OutlinerTree.tsx), [OutlinerBlock.tsx](../../frontend/src/features/workspace/OutlinerBlock.tsx), [CodeBlockItem.tsx](../../frontend/src/features/workspace/CodeBlockItem.tsx), [LinkModal.tsx](../../frontend/src/features/workspace/LinkModal.tsx); Backend: Dokument-, Ordner- und Tag-Handler, Sync-Handler in `internal/api/docsync.go`, `internal/collab`, `internal/storage/docsync.go`.
|
Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.tsx) (Explorer, Tags, To-Do-Rollup), [DocumentEditor.tsx](../../frontend/src/features/workspace/DocumentEditor.tsx), [OutlinerTree.tsx](../../frontend/src/features/workspace/OutlinerTree.tsx), [OutlinerBlock.tsx](../../frontend/src/features/workspace/OutlinerBlock.tsx), [CodeBlockEditor.tsx](../../frontend/src/features/workspace/CodeBlockEditor.tsx), [LinkModal.tsx](../../frontend/src/features/workspace/LinkModal.tsx); Backend: Dokument-, Ordner- und Tag-Handler, Sync-Handler in `internal/api/docsync.go`, `internal/collab`, `internal/storage/docsync.go`.
|
||||||
|
|
||||||
## Datenhaltung
|
## Datenhaltung
|
||||||
|
|
||||||
@@ -15,7 +15,10 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
|
|||||||
- **Outliner:** `Enter` neuer Block, `Tab`/`Shift+Tab` ein-/ausrücken, `Shift+Enter` weicher Umbruch, Blöcke ein-/ausklappen.
|
- **Outliner:** `Enter` neuer Block, `Tab`/`Shift+Tab` ein-/ausrücken, `Shift+Enter` weicher Umbruch, Blöcke ein-/ausklappen.
|
||||||
- **Zoom (Hoisting):** Klick auf den Bullet fokussiert den Ast; die Breadcrumb-Leiste führt zurück.
|
- **Zoom (Hoisting):** Klick auf den Bullet fokussiert den Ast; die Breadcrumb-Leiste führt zurück.
|
||||||
- **Drag & Drop** verschiebt Blöcke samt Unterästen; auf einen anderen Block gezogen, wird der Block zum Unterpunkt.
|
- **Drag & Drop** verschiebt Blöcke samt Unterästen; auf einen anderen Block gezogen, wird der Block zum Unterpunkt.
|
||||||
- **Slash-Befehle:** `/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code`, `/callout`, `/divider` (Liste in `OutlinerBlock.tsx`).
|
- **Slash-Befehle:** `/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code` (mit Syntax-Highlighting im VS-Code-Stil), `/callout`, `/divider`, `/extract` (Ast als eigene Notiz auslagern), `/image` (Bilder einfügen/hochladen, Aliase `/bild`, `/foto`), `/audio` (Sprachnotiz/Audiodatei, Alias `/sprachnotiz`), `/diktat` (Spracheingabe mit Live-Mitschreiben, Alias `/dictate`) (Liste in `OutlinerBlock.tsx`). Das Menü öffnet sich nur am Textanfang oder nach einem Leerzeichen und schließt mit `Esc` oder `Backspace`.
|
||||||
|
- **Link-Vorschau:** Besteht ein Bulletpoint ausschließlich aus einer einzelnen `https`-Adresse (oder wurde über `/link` in einem leeren Punkt eingefügt), zeigt MindSift darüber eine interaktive Vorschaukarte (`LinkPreviewCard`, serverseitig via `/link-preview`). ✕ blendet die Karte aus und wandelt die Adresse synchronisiert in `[Titel](url)` um.
|
||||||
|
- **Ast auslagern (`/extract`):** Wandelt den aktuellen Punkt samt Unterpunkten in eine eigenständige Notiz im selben Ordner um. Der Server legt die Notiz an und liefert den Link zurück; der Editor ersetzt den Ast lokal über die Sync-Engine als eigene Operation (Strg+Z nimmt die Auslagerung zurück und legt die neue Notiz via `POST /documents/{id}/extract/undo` in den Papierkorb).
|
||||||
|
- **Notizen zusammenführen (Merge):** Per Drag & Drop einer Notiz auf eine andere in der Seitenleiste oder über das Drei-Punkte-Menü im Notizkopf („Mit anderer Notiz zusammenführen…“). Die eigene Quellnotiz wird als `h2`-Abschnitt angehängt und in den Papierkorb verschoben; der Hinweis bietet vollständiges Rückgängigmachen (`POST /documents/{id}/merge/undo`).
|
||||||
- **Rückverweise:** Unter jeder Notiz listet „Verlinkt auf diese Notiz (n)“ alle Notizen mit `[[Titel]]` (auch `[[Titel|Text]]`, `[[Titel#Abschnitt]]`, Groß-/Kleinschreibung egal) samt Fundstelle; ein Klick springt direkt zum Bullet. Ohne Rückverweise bleibt der Bereich unsichtbar, eingeklappt merkt er sich im Browser ([BacklinksSection.tsx](../../frontend/src/features/workspace/BacklinksSection.tsx), `GET /documents/{id}/backlinks`).
|
- **Rückverweise:** Unter jeder Notiz listet „Verlinkt auf diese Notiz (n)“ alle Notizen mit `[[Titel]]` (auch `[[Titel|Text]]`, `[[Titel#Abschnitt]]`, Groß-/Kleinschreibung egal) samt Fundstelle; ein Klick springt direkt zum Bullet. Ohne Rückverweise bleibt der Bereich unsichtbar, eingeklappt merkt er sich im Browser ([BacklinksSection.tsx](../../frontend/src/features/workspace/BacklinksSection.tsx), `GET /documents/{id}/backlinks`).
|
||||||
- **Links:** `[[Notiztitel]]`, Markdown-Links `[Titel](url)` und reine URLs werden als Token dargestellt; `LinkModal` hilft beim Verknüpfen von Notizen.
|
- **Links:** `[[Notiztitel]]`, Markdown-Links `[Titel](url)` und reine URLs werden als Token dargestellt; `LinkModal` hilft beim Verknüpfen von Notizen.
|
||||||
- **Einfügen:** Mehrzeiliger Text wird anhand von Markdown-Präfixen und Einrückung in Blöcke zerlegt; Überschriften gruppieren darunterliegenden Text und Code.
|
- **Einfügen:** Mehrzeiliger Text wird anhand von Markdown-Präfixen und Einrückung in Blöcke zerlegt; Überschriften gruppieren darunterliegenden Text und Code.
|
||||||
@@ -51,13 +54,13 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
|
|||||||
|
|
||||||
## Freigabe
|
## Freigabe
|
||||||
|
|
||||||
- Einzelne Notizen und ganze **geteilte Ordner** lassen sich an Benutzer oder Gruppen geben. Empfänger haben **nur Leserechte**; nur der Eigentümer schreibt ([sharing-and-permissions.md](../sharing-and-permissions.md)).
|
- Einzelne Notizen und ganze **geteilte Ordner** lassen sich an Benutzer oder Gruppen freigeben – wahlweise mit der Rolle **Bearbeiter** (`write`) oder **Leser** (`read`) ([sharing-and-permissions.md](../sharing-and-permissions.md)). Bearbeiter ändern Inhalt und Struktur über die Sync-Engine; Leser sehen Änderungen live.
|
||||||
- Empfänger ordnen geteilte Notizen und Ordner in ihrem eigenen Baum ein (`doc_user_folders`, `workspace_folder_mounts`), ohne dass der Eigentümer etwas davon merkt.
|
- Empfänger ordnen geteilte Notizen und Ordner in ihrem eigenen Baum ein (`doc_user_folders`, `workspace_folder_mounts`), ohne dass der Eigentümer etwas davon merkt.
|
||||||
- Änderungen erreichen offene Fenster über den Weckruf `DOC_SEQ` und anschließenden Sync; Notizlisten über `DOC_UPDATED` ([websocket.md](../api/websocket.md)).
|
- Änderungen erreichen offene Fenster über den Weckruf `DOC_SEQ` und anschließenden Sync; Notizlisten über `DOC_UPDATED` ([websocket.md](../api/websocket.md)).
|
||||||
|
|
||||||
## Grenzen
|
## Grenzen
|
||||||
|
|
||||||
- Keine gleichzeitige Mehrbenutzerbearbeitung, keine Cursor-Anzeige, keine Versionshistorie.
|
- Keine gleichzeitige Bearbeitung im selben einzelnen Bullet (Sperren pro Bullet und Konfliktkopie statt Zeichen-für-Zeichen-Zusammenführung), keine Cursor-Anzeige fremder Eingaben, keine Versionshistorie.
|
||||||
- Keine Virtualisierung langer Dokumente; sehr große Notizen können auf Mobilgeräten träge werden *(nicht gemessen)*.
|
- Keine Virtualisierung langer Dokumente; sehr große Notizen können auf Mobilgeräten träge werden *(nicht gemessen)*.
|
||||||
- ~~Suche (`/search`) erfasst nur Titel und Inhalt eigener Notizen~~: Mit der globalen FTS5-Suche (`Cmd+K` / `Ctrl+K` / `/`) werden sowohl Titel als auch Block-Inhalte aller zugänglichen Notizen (eigene und freigegebene) durchsucht.
|
- ~~Suche (`/search`) erfasst nur Titel und Inhalt eigener Notizen~~: Mit der globalen FTS5-Suche (`Cmd+K` / `Ctrl+K` / `/`) werden sowohl Titel als auch Block-Inhalte aller zugänglichen Notizen (eigene und freigegebene) durchsucht.
|
||||||
|
|
||||||
|
|||||||
@@ -70,4 +70,4 @@ Bei der Einrichtung im Modus `organizations` wird eine Organisation mit dem Name
|
|||||||
|
|
||||||
- Jede Anmeldung ist eine Sitzung (`sessions`), die einzeln beendet werden kann (Logout, Profil → Aktive Sitzungen). Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ beenden alle Sitzungen (`token_version`); eine Passwortänderung alle außer der aktuellen.
|
- Jede Anmeldung ist eine Sitzung (`sessions`), die einzeln beendet werden kann (Logout, Profil → Aktive Sitzungen). Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ beenden alle Sitzungen (`token_version`); eine Passwortänderung alle außer der aktuellen.
|
||||||
- Registrierung nur per Einladungscode oder, wenn vom Admin freigeschaltet, offen. Das erste Konto wird immer Administrator.
|
- Registrierung nur per Einladungscode oder, wenn vom Admin freigeschaltet, offen. Das erste Konto wird immer Administrator.
|
||||||
- Bekannte Lücken in der Zugriffskontrolle:.
|
- Bekannte Lücken in der Zugriffskontrolle: siehe [flaws.md](flaws.md).
|
||||||
+2
-2
@@ -73,9 +73,9 @@ Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Kla
|
|||||||
Komponente: [HoldToRecordFab.tsx](../frontend/src/features/capture/HoldToRecordFab.tsx)
|
Komponente: [HoldToRecordFab.tsx](../frontend/src/features/capture/HoldToRecordFab.tsx)
|
||||||
|
|
||||||
- **Kurz tippen (< 350 ms):** Führt wie bisher die primäre Aktion der aktuellen Ansicht (`primaryAction`) oder den mobilen Erfassungsdialog (`openMobileTriageCapture`) aus.
|
- **Kurz tippen (< 350 ms):** Führt wie bisher die primäre Aktion der aktuellen Ansicht (`primaryAction`) oder den mobilen Erfassungsdialog (`openMobileTriageCapture`) aus.
|
||||||
- **Gedrückt halten (≥ 350 ms):** Startet direkt eine Sprachaufnahme über die Web Speech API (`de-DE`, `continuous`, `interimResults`). Der Button wird zum pulsierenden Mikrofon (`bg-status-err`), darüber zeigt eine schwebende Pille „Ich höre zu… (loslassen speichert)“ samt Live-Transkript.
|
- **Gedrückt halten (≥ 350 ms):** Startet direkt eine Sprachaufnahme über `useVoiceInput` (im Server-Modus per Whisper mit echten Pegel-Wellen, sonst über die Spracherkennung des Browsers). Der Button wird zum pulsierenden Mikrofon (`bg-status-err`), darüber zeigt eine schwebende Pille „Ich höre zu… (loslassen speichert)“ samt Live-Transkript.
|
||||||
- **Loslassen:** Stoppt die Aufnahme, wartet kurz auf das letzte Wort und speichert den Text unabhängig von der aktiven Ansicht immer als Gedanken in die Inbox (`source: 'voice'`, `target: 'inbox'`).
|
- **Loslassen:** Stoppt die Aufnahme, wartet kurz auf das letzte Wort und speichert den Text unabhängig von der aktiven Ansicht immer als Gedanken in die Inbox (`source: 'voice'`, `target: 'inbox'`).
|
||||||
- **Nach oben ziehen (> 60 px):** Schaltet die Pille in den Abbruch-Zustand („🗑️ Zum Abbrechen loslassen“); Loslassen dort verwirft die Aufnahme (`abort()`).
|
- **Nach oben ziehen (> 60 px):** Schaltet die Pille in den Abbruch-Zustand („🗑️ Zum Abbrechen loslassen“); Loslassen dort verwirft die Aufnahme (`abort()`).
|
||||||
- **Fallback:** Unterstützt der Browser keine Spracherkennung oder ist das Mikrofon gesperrt (`not-allowed`), öffnet Halten wie beim Tippen den Dialog und blendet eine kurze Statusmeldung über der Bottom-Leiste ein.
|
- **Fallback:** Ist die Spracheingabe abgeschaltet (`STT_DISABLED=true`), unterstützt der Browser keine Spracherkennung oder ist das Mikrofon gesperrt (`not-allowed`), öffnet Halten wie beim Tippen den Dialog und blendet eine kurze Statusmeldung über der Bottom-Leiste ein.
|
||||||
|
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
[](#)
|
[](#)
|
||||||
[](#-schnellstart)
|
[](#-schnellstart)
|
||||||
[](#-deine-daten-gehören-dir)
|
[](#-deine-daten-gehören-dir)
|
||||||
[](#-schnellstart)
|
[](#-schnellstart)
|
||||||
|
|
||||||
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
|
|||||||
|
|
||||||
### ⚡ Schnellerfassung & Triage
|
### ⚡ Schnellerfassung & Triage
|
||||||
|
|
||||||
* Eingabeleiste im Kopf der App, jederzeit mit `C` oder `/` fokussierbar; auch per Spracheingabe: über den Browser oder datenschutzfreundlich über einen eigenen Whisper-Container (optional in `docker-compose.yml`, siehe [Architektur](Doku/architecture.md#spracherkennung)).
|
* Eingabeleiste im Kopf der App, jederzeit mit `C` oder `/` fokussierbar; auch per Spracheingabe: über den Browser oder datenschutzfreundlich über einen eigenen Whisper-Container (optional in `docker-compose.yml`, siehe [Architektur](Doku/architecture.md#spracherkennung)); auf Smartphones auch per Gedrückthalten des `+`-Knopfs (Hold-to-Record mit Pegel-Wellen).
|
||||||
* Ziel beim Erfassen wählbar: Posteingang, Aufgabe, Listeneintrag oder Notiz.
|
* Ziel beim Erfassen wählbar: Posteingang, Aufgabe, Listeneintrag oder Notiz.
|
||||||
* **Triage-Deck:** `H`/`←` verwerfen, `L`/`→` zur Aufgabe, `T` Aufgabe mit Termin, `D` in Notiz verwandeln, Leertaste = später. Auf dem Smartphone per Wischgeste.
|
* **Triage-Deck:** `H`/`←` verwerfen, `L`/`→` zur Aufgabe, `T` Aufgabe mit Termin, `D` in Notiz verwandeln, Leertaste = später. Auf dem Smartphone per Wischgeste.
|
||||||
* Papierkorb mit Wiederherstellen.
|
* Papierkorb mit Wiederherstellen.
|
||||||
@@ -49,7 +49,10 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
|
|||||||
* Prioritäten, Fälligkeitsdatum, Hashtags, Unteraufgaben mit Fortschrittsbalken, Markdown-Notizen.
|
* Prioritäten, Fälligkeitsdatum, Hashtags, Unteraufgaben mit Fortschrittsbalken, Markdown-Notizen.
|
||||||
* Filter, Gruppierung und Sortierung; Drag & Drop in Liste und Board.
|
* Filter, Gruppierung und Sortierung; Drag & Drop in Liste und Board.
|
||||||
* **Aufgabenlisten teilen** mit Benutzern oder Gruppen, wahlweise als **Bearbeiter** oder **nur lesend**.
|
* **Aufgabenlisten teilen** mit Benutzern oder Gruppen, wahlweise als **Bearbeiter** oder **nur lesend**.
|
||||||
* **Alarme** bei Fälligkeit per E-Mail, Discord oder eigenem Webhook.
|
* **Aufgaben beobachten:** Beliebige Aufgaben per Klick abonnieren, um über alle Änderungen informiert zu werden.
|
||||||
|
* **Wiederholungen:** Wiederkehrende Aufgaben (täglich, wöchentlich, monatlich, jährlich) mit automatischer Folgeaufgabe beim Abhaken.
|
||||||
|
* **Tageszusammenfassung:** Tägliche Übersicht fälliger Aufgaben zur selbst gewählten Uhrzeit per E-Mail/Discord.
|
||||||
|
* **Alarme & Benachrichtigungen:** Fälligkeits-Alarme sofort per E-Mail, Discord oder eigenem Webhook; granulare Benachrichtigungen für Freigaben, erledigte geteilte Aufgaben und beobachtete Aufgaben sowie tägliche Zusammenfassungen zur gewählten Uhrzeit.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="screenshots/aufgaben_desktop.png" alt="MindSift Aufgaben und Kanban-Board" width="90%" />
|
<img src="screenshots/aufgaben_desktop.png" alt="MindSift Aufgaben und Kanban-Board" width="90%" />
|
||||||
@@ -59,7 +62,10 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
|
|||||||
|
|
||||||
* Jeder Gedanke ein Bullet: `Enter`, `Tab`/`Shift+Tab`, Ein-/Ausklappen, Drag & Drop ganzer Äste.
|
* Jeder Gedanke ein Bullet: `Enter`, `Tab`/`Shift+Tab`, Ein-/Ausklappen, Drag & Drop ganzer Äste.
|
||||||
* **Zoom-Fokus** per Klick auf den Bullet, Breadcrumbs zum Zurücknavigieren.
|
* **Zoom-Fokus** per Klick auf den Bullet, Breadcrumbs zum Zurücknavigieren.
|
||||||
* Blocktypen und Slash-Befehle: `/todo`, `/link`, `/h1`–`/h3`, `/code` (mit Syntax-Hervorhebung), `/callout`, `/divider`.
|
* Blocktypen und Slash-Befehle: `/todo`, `/link`, `/h1`–`/h3`, `/code` (mit Syntax-Hervorhebung), `/callout`, `/divider`, `/extract`, `/image`, `/audio`, `/diktat`.
|
||||||
|
* **Bilder & Audio in Notizen:** `/image` (Datei, Kamera, Zwischenablage oder Link), `/audio` (Sprachnotiz oder Datei-Upload) und `/diktat` (Live-Transkription).
|
||||||
|
* **Link-Vorschau:** Automatische Vorschaukarten über Punkten mit reiner Web-Adresse.
|
||||||
|
* **Ast auslagern & Notizen zusammenführen:** Bulletpoints samt Unterbaum in eigenständige Notizen auslagern (`/extract`) oder Notizen per Drag & Drop bzw. Menü zusammenführen (jeweils mit vollständiger Rückgängig-Funktion).
|
||||||
* Wiki-Links `[[Notiz]]`, Links und `#Hashtags`; Tag-Seiten bündeln alle Fundstellen.
|
* Wiki-Links `[[Notiz]]`, Links und `#Hashtags`; Tag-Seiten bündeln alle Fundstellen.
|
||||||
* Ordner, Favoriten, **To-Do-Rollup** über alle Notizen und Export einer Notiz als `.md`.
|
* Ordner, Favoriten, **To-Do-Rollup** über alle Notizen und Export einer Notiz als `.md`.
|
||||||
* Einfügen von mehrzeiligem Markdown wird in Blöcke zerlegt; `Strg+Z` / `Strg+Umschalt+Z` nimmt eigene Änderungen zurück bzw. stellt sie wieder her.
|
* Einfügen von mehrzeiligem Markdown wird in Blöcke zerlegt; `Strg+Z` / `Strg+Umschalt+Z` nimmt eigene Änderungen zurück bzw. stellt sie wieder her.
|
||||||
@@ -92,7 +98,7 @@ Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md)
|
|||||||
* Geführte Ersteinrichtung, Rollen *Admin* und *Mitglied*, Einladungscodes, optional offene Registrierung.
|
* Geführte Ersteinrichtung, Rollen *Admin* und *Mitglied*, Einladungscodes, optional offene Registrierung.
|
||||||
* Gruppen mit Gruppen-Admins für Freigaben.
|
* Gruppen mit Gruppen-Admins für Freigaben.
|
||||||
* Admin-Bereich: Benutzer, Einladungen, Instanzname, SMTP, Freigabe-Modus.
|
* Admin-Bereich: Benutzer, Einladungen, Instanzname, SMTP, Freigabe-Modus.
|
||||||
* Vier Themes (Dark, Light, High-Contrast, Soft), installierbar als PWA. Die App startet auch ohne Netz mit dem zuletzt geladenen Stand; offline erfasste Gedanken und abgehakte Aufgaben oder Listeneinträge werden nachgereicht, sobald die Verbindung wieder da ist.
|
* Sechs Themes (Dark, Light, High-Contrast, Soft, Polarized, Suri) plus Theme-Manager für eigene CSS-Themes, installierbar als PWA. Die App startet auch ohne Netz mit dem zuletzt geladenen Stand; offline erfasste Gedanken und abgehakte Aufgaben oder Listeneinträge werden nachgereicht, sobald die Verbindung wieder da ist.
|
||||||
|
|
||||||
### 🔌 Integrationen
|
### 🔌 Integrationen
|
||||||
|
|
||||||
@@ -130,7 +136,7 @@ Minimal:
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
mindsift:
|
mindsift:
|
||||||
image: git.dsit.me/sandrodach/mindsift:stable # oder feste Version (:v0.4); :latest = main, :dev = Entwicklungsstand
|
image: git.dsit.me/sandrodach/mindsift:stable # oder feste Version (:v0.8.0); :latest = main, :dev = Entwicklungsstand
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports: ["8080:8080"]
|
ports: ["8080:8080"]
|
||||||
volumes: ["./data:/data"]
|
volumes: ["./data:/data"]
|
||||||
@@ -161,7 +167,11 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d
|
|||||||
| `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) |
|
| `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) |
|
||||||
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging |
|
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging |
|
||||||
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer | E-Mail-Alarme (auch im Admin-UI) |
|
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer | E-Mail-Alarme (auch im Admin-UI) |
|
||||||
|
| `SMTP_FROM_NAME` | `MindSift` | Anzeigename des Absenders bei E-Mails |
|
||||||
| `SMTP_SKIP_VERIFY` | `false` | Zertifikatsprüfung für interne Mailserver abschalten |
|
| `SMTP_SKIP_VERIFY` | `false` | Zertifikatsprüfung für interne Mailserver abschalten |
|
||||||
|
| `NOTIFY_DIGEST_MINUTES` | `5` | Bündelungsintervall für Benachrichtigungen in Minuten |
|
||||||
|
| `MEDIA_*` | diverse | Grenzen für Bilder und Audio in Notizen (`MEDIA_MAX_IMAGE_MB=15`, `MEDIA_MAX_AUDIO_MB=50`, `MEDIA_QUOTA_MB=0`) |
|
||||||
|
| `STT_*` | diverse | Optionale eigene Spracherkennung per Whisper (`STT_URL`, `STT_MODEL`, `STT_LANGUAGE` …; siehe [Doku/architecture.md](Doku/architecture.md#spracherkennung)) |
|
||||||
| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) |
|
| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) |
|
||||||
| `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` (oder `…_CONTENT_FILE` mit Dateipfad) | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster. Ohne URL und Text bleibt der Link unsichtbar. |
|
| `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` (oder `…_CONTENT_FILE` mit Dateipfad) | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster. Ohne URL und Text bleibt der Link unsichtbar. |
|
||||||
| `MINDA_DEMO` | `false` | Legt Demo-Konten `admin/admin` (mit Beispieldaten) und `user/user` an; eine Notiz und zwei Listen sind zwischen beiden geteilt, um das gemeinsame Bearbeiten auszuprobieren. **Nie auf öffentlichen Servern!** |
|
| `MINDA_DEMO` | `false` | Legt Demo-Konten `admin/admin` (mit Beispieldaten) und `user/user` an; eine Notiz und zwei Listen sind zwischen beiden geteilt, um das gemeinsame Bearbeiten auszuprobieren. **Nie auf öffentlichen Servern!** |
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.8.0",
|
"version": "0.9.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.8.0",
|
"version": "0.9.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prismjs": "^1.26.6",
|
"@types/prismjs": "^1.26.6",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.8.0",
|
"version": "0.9.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -347,6 +347,7 @@ export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
|
|||||||
const copyTimerRef = useRef<number | null>(null);
|
const copyTimerRef = useRef<number | null>(null);
|
||||||
const localTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const localTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const preRef = useRef<HTMLPreElement | null>(null);
|
const preRef = useRef<HTMLPreElement | null>(null);
|
||||||
|
const lineNumbersRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -506,18 +507,42 @@ export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleScroll = (e: React.UIEvent<HTMLTextAreaElement>) => {
|
const handleScroll = (e: React.UIEvent<HTMLTextAreaElement>) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
if (preRef.current) {
|
if (preRef.current) {
|
||||||
preRef.current.scrollTop = e.currentTarget.scrollTop;
|
preRef.current.scrollTop = target.scrollTop;
|
||||||
preRef.current.scrollLeft = e.currentTarget.scrollLeft;
|
preRef.current.scrollLeft = target.scrollLeft;
|
||||||
|
}
|
||||||
|
if (lineNumbersRef.current) {
|
||||||
|
lineNumbersRef.current.scrollTop = target.scrollTop;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLineNumbersScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
if (localTextareaRef.current) {
|
||||||
|
localTextareaRef.current.scrollTop = target.scrollTop;
|
||||||
|
}
|
||||||
|
if (preRef.current) {
|
||||||
|
preRef.current.scrollTop = target.scrollTop;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLineNumbersWheel = (e: React.WheelEvent) => {
|
||||||
|
if (localTextareaRef.current) {
|
||||||
|
localTextareaRef.current.scrollTop += e.deltaY;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const lineCount = useMemo(() => {
|
||||||
|
return currentContent.split('\n').length;
|
||||||
|
}, [currentContent]);
|
||||||
|
|
||||||
const rawLang = block.lang || '';
|
const rawLang = block.lang || '';
|
||||||
const selectValue = rawLang ? resolveLanguageId(rawLang) || rawLang : '';
|
const selectValue = rawLang ? resolveLanguageId(rawLang) || rawLang : '';
|
||||||
const hasCustomUnknownLang = Boolean(selectValue && !KNOWN_OPTION_IDS.has(selectValue));
|
const hasCustomUnknownLang = Boolean(selectValue && !KNOWN_OPTION_IDS.has(selectValue));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-code border border-border rounded-btn my-0.5 overflow-x-auto max-w-full">
|
<div className="bg-code border border-border rounded-btn my-0.5 max-w-full overflow-hidden">
|
||||||
{/* Code Block Header */}
|
{/* Code Block Header */}
|
||||||
<div className="flex items-center justify-between gap-2 px-2 py-1 border-b border-border text-[11px] font-mono text-faint">
|
<div className="flex items-center justify-between gap-2 px-2 py-1 border-b border-border text-[11px] font-mono text-faint">
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
@@ -584,41 +609,60 @@ export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Live Highlighted Overlay + Textarea */}
|
{/* Live Highlighted Overlay + Textarea with Line Numbers */}
|
||||||
<div className="relative w-full overflow-x-auto">
|
<div className="flex relative w-full overflow-hidden">
|
||||||
<pre
|
{/* Line Numbers Column */}
|
||||||
ref={preRef}
|
<div
|
||||||
|
ref={lineNumbersRef}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="absolute inset-0 m-0 p-2 font-mono text-xs text-main leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] pointer-events-none select-none overflow-hidden"
|
onScroll={handleLineNumbersScroll}
|
||||||
|
onWheel={handleLineNumbersWheel}
|
||||||
|
data-testid="code-line-numbers"
|
||||||
|
className="select-none py-2 pl-2.5 pr-2 font-mono text-xs text-faint leading-relaxed text-right border-r border-border/40 shrink-0 overflow-hidden"
|
||||||
>
|
>
|
||||||
<code
|
{Array.from({ length: lineCount }, (_, i) => (
|
||||||
className={prismLang ? `language-${prismLang}` : 'language-none'}
|
<div key={i + 1} data-line-number={i + 1}>
|
||||||
dangerouslySetInnerHTML={{
|
{i + 1}
|
||||||
__html: highlightedHtml + (currentContent.endsWith('\n') ? '\n' : ''),
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Code Content (Overlay + Textarea) */}
|
||||||
|
<div className="relative flex-1 min-w-0 overflow-hidden">
|
||||||
|
<pre
|
||||||
|
ref={preRef}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 m-0 p-2 font-mono text-xs text-main leading-relaxed whitespace-pre pointer-events-none select-none overflow-hidden"
|
||||||
|
>
|
||||||
|
<code
|
||||||
|
className={prismLang ? `language-${prismLang}` : 'language-none'}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: highlightedHtml + (currentContent.endsWith('\n') ? '\n' : ''),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</pre>
|
||||||
|
<textarea
|
||||||
|
ref={(el) => {
|
||||||
|
localTextareaRef.current = el;
|
||||||
|
if (textareaRef) textareaRef.current = el;
|
||||||
|
registerTextareaRef?.(block.id, el);
|
||||||
}}
|
}}
|
||||||
|
rows={1}
|
||||||
|
value={currentContent}
|
||||||
|
readOnly={!canEdit}
|
||||||
|
spellCheck={false}
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onChange={onTextareaChange}
|
||||||
|
onKeyDown={onTextareaKeyDown}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
style={{ caretColor: 'var(--text-main)' }}
|
||||||
|
className="relative block w-full m-0 p-2 font-mono text-xs leading-relaxed whitespace-pre bg-transparent text-transparent caret-main selection:bg-accent/25 selection:text-transparent border-0 focus:outline-none focus:ring-0 resize-none overflow-x-auto"
|
||||||
/>
|
/>
|
||||||
</pre>
|
</div>
|
||||||
<textarea
|
|
||||||
ref={(el) => {
|
|
||||||
localTextareaRef.current = el;
|
|
||||||
if (textareaRef) textareaRef.current = el;
|
|
||||||
registerTextareaRef?.(block.id, el);
|
|
||||||
}}
|
|
||||||
rows={1}
|
|
||||||
value={currentContent}
|
|
||||||
readOnly={!canEdit}
|
|
||||||
spellCheck={false}
|
|
||||||
autoCapitalize="off"
|
|
||||||
autoComplete="off"
|
|
||||||
autoCorrect="off"
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
onChange={onTextareaChange}
|
|
||||||
onKeyDown={onTextareaKeyDown}
|
|
||||||
onScroll={handleScroll}
|
|
||||||
style={{ caretColor: 'var(--text-main)' }}
|
|
||||||
className="relative block w-full m-0 p-2 font-mono text-xs leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent text-transparent caret-main selection:bg-accent/25 selection:text-transparent border-0 focus:outline-none focus:ring-0 resize-none overflow-hidden"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -440,6 +440,33 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
flushDebounce(newContent);
|
flushDebounce(newContent);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyFormattingChange = (newVal: string, selStart: number, selEnd: number) => {
|
||||||
|
setLocalContent(newVal);
|
||||||
|
localContentRef.current = newVal;
|
||||||
|
if (debounceTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
debounceTimerRef.current = window.setTimeout(() => {
|
||||||
|
debounceTimerRef.current = null;
|
||||||
|
onUpdateBlockRef.current({
|
||||||
|
...latestBlockRef.current,
|
||||||
|
content: newVal,
|
||||||
|
});
|
||||||
|
}, 150);
|
||||||
|
|
||||||
|
const ta = textareaRef.current;
|
||||||
|
if (ta) {
|
||||||
|
ta.value = newVal;
|
||||||
|
ta.setSelectionRange(selStart, selEnd);
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
if (textareaRef.current) {
|
||||||
|
textareaRef.current.setSelectionRange(selStart, selEnd);
|
||||||
|
adjustHeight();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
let val = e.target.value;
|
let val = e.target.value;
|
||||||
@@ -616,6 +643,108 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
}
|
}
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
|
|
||||||
|
// Formatting shortcuts (Ctrl/Cmd+B, Ctrl/Cmd+I, Ctrl/Cmd+K) - not in code blocks
|
||||||
|
const isMod = (e.ctrlKey || e.metaKey) && !e.altKey;
|
||||||
|
if (isMod && canEdit && block.type !== 'code' && ['b', 'i', 'k'].includes(e.key.toLowerCase())) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const ta = textareaRef.current || e.currentTarget;
|
||||||
|
const start = ta.selectionStart ?? 0;
|
||||||
|
const end = ta.selectionEnd ?? 0;
|
||||||
|
const text = ta.value;
|
||||||
|
const hasSelection = start < end;
|
||||||
|
|
||||||
|
if (e.key.toLowerCase() === 'k') {
|
||||||
|
if (!hasSelection) {
|
||||||
|
setShowLinkModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const selected = text.slice(start, end);
|
||||||
|
const placeholder = '…';
|
||||||
|
const before = text.slice(0, start);
|
||||||
|
const after = text.slice(end);
|
||||||
|
const inserted = `[${selected}](${placeholder})`;
|
||||||
|
const newVal = before + inserted + after;
|
||||||
|
const selStart = start + 1 + selected.length + 2;
|
||||||
|
const selEnd = selStart + placeholder.length;
|
||||||
|
applyFormattingChange(newVal, selStart, selEnd);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBold = e.key.toLowerCase() === 'b';
|
||||||
|
const delim = isBold ? '**' : '*';
|
||||||
|
|
||||||
|
if (!hasSelection) {
|
||||||
|
const pair = isBold ? '****' : '**';
|
||||||
|
const newVal = text.slice(0, start) + pair + text.slice(start);
|
||||||
|
const newPos = start + delim.length;
|
||||||
|
applyFormattingChange(newVal, newPos, newPos);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = text.slice(start, end);
|
||||||
|
|
||||||
|
if (isBold) {
|
||||||
|
const isOutside =
|
||||||
|
start >= 2 &&
|
||||||
|
end + 2 <= text.length &&
|
||||||
|
text.slice(start - 2, start) === '**' &&
|
||||||
|
text.slice(end, end + 2) === '**';
|
||||||
|
|
||||||
|
const isInside =
|
||||||
|
selected.length >= 4 &&
|
||||||
|
selected.startsWith('**') &&
|
||||||
|
selected.endsWith('**');
|
||||||
|
|
||||||
|
if (isOutside) {
|
||||||
|
const newVal = text.slice(0, start - 2) + selected + text.slice(end + 2);
|
||||||
|
applyFormattingChange(newVal, start - 2, end - 2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isInside) {
|
||||||
|
const unwrapped = selected.slice(2, -2);
|
||||||
|
const newVal = text.slice(0, start) + unwrapped + text.slice(end);
|
||||||
|
applyFormattingChange(newVal, start, start + unwrapped.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newVal = text.slice(0, start) + '**' + selected + '**' + text.slice(end);
|
||||||
|
applyFormattingChange(newVal, start + 2, end + 2);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
const isOutside =
|
||||||
|
start >= 1 &&
|
||||||
|
end + 1 <= text.length &&
|
||||||
|
text[start - 1] === '*' &&
|
||||||
|
text[end] === '*' &&
|
||||||
|
(start < 2 || text[start - 2] !== '*') &&
|
||||||
|
(end + 1 >= text.length || text[end + 1] !== '*');
|
||||||
|
|
||||||
|
const isInside =
|
||||||
|
selected.length >= 2 &&
|
||||||
|
selected.startsWith('*') &&
|
||||||
|
selected.endsWith('*') &&
|
||||||
|
(!selected.startsWith('**') || !selected.endsWith('**') || selected.length < 4);
|
||||||
|
|
||||||
|
if (isOutside) {
|
||||||
|
const newVal = text.slice(0, start - 1) + selected + text.slice(end + 1);
|
||||||
|
applyFormattingChange(newVal, start - 1, end - 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isInside) {
|
||||||
|
const unwrapped = selected.slice(1, -1);
|
||||||
|
const newVal = text.slice(0, start) + unwrapped + text.slice(end);
|
||||||
|
applyFormattingChange(newVal, start, start + unwrapped.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newVal = text.slice(0, start) + '*' + selected + '*' + text.slice(end);
|
||||||
|
applyFormattingChange(newVal, start + 1, end + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Slash command navigation
|
// Slash command navigation
|
||||||
if (slashSearch !== null) {
|
if (slashSearch !== null) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
|
|||||||
@@ -209,4 +209,55 @@ describe('CodeBlockEditor', () => {
|
|||||||
expect(res2.updates[res2.updates.length - 1]?.type).toBe('code');
|
expect(res2.updates[res2.updates.length - 1]?.type).toBe('code');
|
||||||
expect(res2.updates[res2.updates.length - 1]?.lang).toBe('json');
|
expect(res2.updates[res2.updates.length - 1]?.lang).toBe('json');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders line numbers column with aria-hidden, user-select: none, and correct line count including trailing empty lines and 100+ lines', () => {
|
||||||
|
// 1. Single empty line
|
||||||
|
const { container, rerender } = render(
|
||||||
|
<CodeBlockEditor block={makeCodeBlock({ id: 'code-empty', content: '' })} content="" canEdit />
|
||||||
|
);
|
||||||
|
const getLineContainer = () => container.querySelector('[data-testid="code-line-numbers"]') as HTMLElement;
|
||||||
|
expect(getLineContainer()).toBeTruthy();
|
||||||
|
expect(getLineContainer().getAttribute('aria-hidden')).toBe('true');
|
||||||
|
expect(getLineContainer().className).toContain('select-none');
|
||||||
|
|
||||||
|
let lineEls = container.querySelectorAll('[data-line-number]');
|
||||||
|
expect(lineEls).toHaveLength(1);
|
||||||
|
expect(lineEls[0].textContent?.trim()).toBe('1');
|
||||||
|
|
||||||
|
// 2. Trailing empty line gives an extra line number (e.g. line 1 + empty line 2)
|
||||||
|
rerender(
|
||||||
|
<CodeBlockEditor
|
||||||
|
block={makeCodeBlock({ id: 'code-trailing', content: `first line
|
||||||
|
` })}
|
||||||
|
content={`first line
|
||||||
|
`}
|
||||||
|
canEdit
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
lineEls = container.querySelectorAll('[data-line-number]');
|
||||||
|
expect(lineEls).toHaveLength(2);
|
||||||
|
expect(lineEls[0].textContent?.trim()).toBe('1');
|
||||||
|
expect(lineEls[1].textContent?.trim()).toBe('2');
|
||||||
|
|
||||||
|
// 3. 120 lines (100+ lines test)
|
||||||
|
const longCode = Array.from({ length: 120 }, (_, i) => `const x_${i + 1} = ${i + 1};`).join('\n');
|
||||||
|
rerender(
|
||||||
|
<CodeBlockEditor
|
||||||
|
block={makeCodeBlock({ id: 'code-long', content: longCode })}
|
||||||
|
content={longCode}
|
||||||
|
canEdit
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
lineEls = container.querySelectorAll('[data-line-number]');
|
||||||
|
expect(lineEls).toHaveLength(120);
|
||||||
|
expect(lineEls[0].textContent?.trim()).toBe('1');
|
||||||
|
expect(lineEls[99].textContent?.trim()).toBe('100');
|
||||||
|
expect(lineEls[119].textContent?.trim()).toBe('120');
|
||||||
|
|
||||||
|
// Textarea has whitespace-pre and overlay has whitespace-pre
|
||||||
|
const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
const pre = container.querySelector('pre') as HTMLPreElement;
|
||||||
|
expect(textarea.className).toContain('whitespace-pre');
|
||||||
|
expect(pre.className).toContain('whitespace-pre');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -255,3 +255,123 @@ describe('slash command menu and outliner fixes', () => {
|
|||||||
expect(updates[updates.length - 1]?.content).toBe('Siehe [Doku](https://example.com/doku)');
|
expect(updates[updates.length - 1]?.content).toBe('Siehe [Doku](https://example.com/doku)');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('formatting shortcuts (Teil C)', () => {
|
||||||
|
it('toggles bold with and without selection (Ctrl+B and Cmd+B)', () => {
|
||||||
|
const block = { id: 'b1', type: 'bullet', content: 'hallo welt', level: 0 } as DocumentBlock;
|
||||||
|
const { container } = renderBlock(block);
|
||||||
|
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
ta.focus();
|
||||||
|
|
||||||
|
// 1. With selection: wraps in **
|
||||||
|
ta.setSelectionRange(6, 10); // "welt"
|
||||||
|
const event1 = new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, bubbles: true, cancelable: true });
|
||||||
|
ta.dispatchEvent(event1);
|
||||||
|
expect(event1.defaultPrevented).toBe(true);
|
||||||
|
expect(ta.value).toBe('hallo **welt**');
|
||||||
|
expect(ta.selectionStart).toBe(8);
|
||||||
|
expect(ta.selectionEnd).toBe(12);
|
||||||
|
|
||||||
|
// 2. With selection where delimiters are directly outside: unwraps **
|
||||||
|
const event2 = new KeyboardEvent('keydown', { key: 'b', metaKey: true, bubbles: true, cancelable: true });
|
||||||
|
ta.dispatchEvent(event2);
|
||||||
|
expect(event2.defaultPrevented).toBe(true);
|
||||||
|
expect(ta.value).toBe('hallo welt');
|
||||||
|
expect(ta.selectionStart).toBe(6);
|
||||||
|
expect(ta.selectionEnd).toBe(10);
|
||||||
|
|
||||||
|
// 3. With selection including delimiters: unwraps **
|
||||||
|
ta.value = 'hallo **welt**';
|
||||||
|
ta.setSelectionRange(6, 14); // "**welt**"
|
||||||
|
const event3 = new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, bubbles: true, cancelable: true });
|
||||||
|
ta.dispatchEvent(event3);
|
||||||
|
expect(ta.value).toBe('hallo welt');
|
||||||
|
expect(ta.selectionStart).toBe(6);
|
||||||
|
expect(ta.selectionEnd).toBe(10);
|
||||||
|
|
||||||
|
// 4. Without selection: inserts **** and places cursor between
|
||||||
|
ta.value = 'start';
|
||||||
|
ta.setSelectionRange(5, 5);
|
||||||
|
const event4 = new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, bubbles: true, cancelable: true });
|
||||||
|
ta.dispatchEvent(event4);
|
||||||
|
expect(ta.value).toBe('start****');
|
||||||
|
expect(ta.selectionStart).toBe(7);
|
||||||
|
expect(ta.selectionEnd).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles italic with and without selection (Ctrl+I)', () => {
|
||||||
|
const block = { id: 'b1', type: 'bullet', content: 'hallo welt', level: 0 } as DocumentBlock;
|
||||||
|
const { container } = renderBlock(block);
|
||||||
|
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
ta.focus();
|
||||||
|
|
||||||
|
// 1. With selection: wraps in *
|
||||||
|
ta.setSelectionRange(6, 10); // "welt"
|
||||||
|
fireEvent.keyDown(ta, { key: 'i', ctrlKey: true });
|
||||||
|
expect(ta.value).toBe('hallo *welt*');
|
||||||
|
expect(ta.selectionStart).toBe(7);
|
||||||
|
expect(ta.selectionEnd).toBe(11);
|
||||||
|
|
||||||
|
// 2. Toggles off when already italic
|
||||||
|
fireEvent.keyDown(ta, { key: 'i', ctrlKey: true });
|
||||||
|
expect(ta.value).toBe('hallo welt');
|
||||||
|
expect(ta.selectionStart).toBe(6);
|
||||||
|
expect(ta.selectionEnd).toBe(10);
|
||||||
|
|
||||||
|
// 3. Without selection: inserts ** and places cursor between
|
||||||
|
fireEvent.change(ta, { target: { value: 'a' } });
|
||||||
|
ta.setSelectionRange(1, 1);
|
||||||
|
fireEvent.keyDown(ta, { key: 'i', ctrlKey: true });
|
||||||
|
expect(ta.value).toBe('a**');
|
||||||
|
expect(ta.selectionStart).toBe(2);
|
||||||
|
expect(ta.selectionEnd).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles Ctrl+K: with selection wraps in [Text](…) and selects placeholder, without selection opens modal', () => {
|
||||||
|
const block = { id: 'b1', type: 'bullet', content: 'besuche mindsift heute', level: 0 } as DocumentBlock;
|
||||||
|
const { container, queryByText } = renderBlock(block);
|
||||||
|
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
ta.focus();
|
||||||
|
|
||||||
|
// 1. With selection
|
||||||
|
ta.setSelectionRange(8, 16); // "mindsift"
|
||||||
|
const eventWithSel = new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true, cancelable: true });
|
||||||
|
ta.dispatchEvent(eventWithSel);
|
||||||
|
expect(eventWithSel.defaultPrevented).toBe(true);
|
||||||
|
expect(ta.value).toBe('besuche [mindsift](…) heute');
|
||||||
|
// Placeholder "…" is selected
|
||||||
|
expect(ta.selectionStart).toBe(19);
|
||||||
|
expect(ta.selectionEnd).toBe(20);
|
||||||
|
expect(ta.value.slice(ta.selectionStart, ta.selectionEnd)).toBe('…');
|
||||||
|
|
||||||
|
// 2. Without selection: opens LinkModal
|
||||||
|
ta.setSelectionRange(0, 0);
|
||||||
|
expect(queryByText('Link einfügen')).toBeNull();
|
||||||
|
fireEvent.keyDown(ta, { key: 'k', ctrlKey: true });
|
||||||
|
expect(queryByText('Link einfügen')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing in code blocks or for read-only readers', () => {
|
||||||
|
// In code block
|
||||||
|
const codeBlock = { id: 'c1', type: 'code', content: 'const x = 1;', level: 0 } as DocumentBlock;
|
||||||
|
const codeView = renderBlock(codeBlock);
|
||||||
|
const codeTa = codeView.container.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
codeTa.focus();
|
||||||
|
codeTa.setSelectionRange(6, 7); // "x"
|
||||||
|
const codeEvent = new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, bubbles: true, cancelable: true });
|
||||||
|
codeTa.dispatchEvent(codeEvent);
|
||||||
|
expect(codeEvent.defaultPrevented).toBe(false);
|
||||||
|
expect(codeTa.value).toBe('const x = 1;');
|
||||||
|
|
||||||
|
// For readers (!canEdit)
|
||||||
|
const bulletBlock = { id: 'b1', type: 'bullet', content: 'text', level: 0 } as DocumentBlock;
|
||||||
|
const readerView = renderBlock(bulletBlock, { canEdit: false });
|
||||||
|
const readerTa = readerView.container.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
readerTa.focus();
|
||||||
|
readerTa.setSelectionRange(0, 4);
|
||||||
|
const readerEvent = new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, bubbles: true, cancelable: true });
|
||||||
|
readerTa.dispatchEvent(readerEvent);
|
||||||
|
expect(readerEvent.defaultPrevented).toBe(false);
|
||||||
|
expect(readerTa.value).toBe('text');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -429,11 +429,11 @@ body {
|
|||||||
--code-keyword: #0000ff;
|
--code-keyword: #0000ff;
|
||||||
--code-string: #a31515;
|
--code-string: #a31515;
|
||||||
--code-number: #098658;
|
--code-number: #098658;
|
||||||
--code-comment: #008000;
|
--code-comment: #007000;
|
||||||
--code-property: #0451a5;
|
--code-property: #0451a5;
|
||||||
--code-function: #795e26;
|
--code-function: #795e26;
|
||||||
--code-type: #267f99;
|
--code-type: #267f99;
|
||||||
--code-punctuation: #3b3b3b;
|
--code-punctuation: #2e2e2e;
|
||||||
--code-variable: #001080;
|
--code-variable: #001080;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { renderInlineMarkdown } from '../markdown';
|
||||||
|
|
||||||
|
describe('renderInlineMarkdown', () => {
|
||||||
|
it('renders bold text with <strong> tag', () => {
|
||||||
|
const { container } = render(<div>{renderInlineMarkdown('Text mit **fettem** Wort')}</div>);
|
||||||
|
const strong = container.querySelector('strong');
|
||||||
|
expect(strong).toBeTruthy();
|
||||||
|
expect(strong?.textContent).toBe('fettem');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders italic text with <em> tag', () => {
|
||||||
|
const { container } = render(<div>{renderInlineMarkdown('Text mit *kursivem* Wort')}</div>);
|
||||||
|
const em = container.querySelector('em');
|
||||||
|
expect(em).toBeTruthy();
|
||||||
|
expect(em?.textContent).toBe('kursivem');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders both bold and italic text in one paragraph', () => {
|
||||||
|
const { container } = render(<div>{renderInlineMarkdown('Hier ist **fett** und *kursiv* vereint')}</div>);
|
||||||
|
const strong = container.querySelector('strong');
|
||||||
|
const em = container.querySelector('em');
|
||||||
|
expect(strong?.textContent).toBe('fett');
|
||||||
|
expect(em?.textContent).toBe('kursiv');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders links correctly', () => {
|
||||||
|
const { container } = render(<div>{renderInlineMarkdown('[Testseite](https://example.com)')}</div>);
|
||||||
|
const link = container.querySelector('a');
|
||||||
|
expect(link).toBeTruthy();
|
||||||
|
expect(link?.getAttribute('href')).toBe('https://example.com/');
|
||||||
|
expect(link?.textContent).toBe('Testseite');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,24 +14,24 @@
|
|||||||
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE --- */
|
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE --- */
|
||||||
--bg-app: #f3f4f6;
|
--bg-app: #f3f4f6;
|
||||||
--bg-panel: #ffffff;
|
--bg-panel: #ffffff;
|
||||||
--bg-panel-elevated: #ebedf0;
|
--bg-panel-elevated: #e2e8f0;
|
||||||
--bg-panel-hover: #e2e5ea;
|
--bg-panel-hover: #cbd5e1;
|
||||||
--bg-input: #ffffff;
|
--bg-input: #ffffff;
|
||||||
--bg-overlay: rgba(15, 23, 42, 0.45);
|
--bg-overlay: rgba(15, 23, 42, 0.45);
|
||||||
--bg-code: #f1f5f9;
|
--bg-code: #e2e8f0;
|
||||||
|
|
||||||
/* --- 3. TEXT & KONTRASTE --- */
|
/* --- 3. TEXT & KONTRASTE --- */
|
||||||
--text-main: #111827;
|
--text-main: #111827;
|
||||||
--text-muted: #4b5563;
|
--text-muted: #374151;
|
||||||
--text-faint: #9ca3af;
|
--text-faint: #6b7280;
|
||||||
|
|
||||||
/* --- 4. RAHMEN, LINIEN & SCHATTEN --- */
|
/* --- 4. RAHMEN, LINIEN & SCHATTEN --- */
|
||||||
--border-color: rgba(17, 24, 39, 0.13);
|
--border-color: rgba(17, 24, 39, 0.18);
|
||||||
--border-subtle: rgba(17, 24, 39, 0.06);
|
--border-subtle: rgba(17, 24, 39, 0.08);
|
||||||
--border-strong: rgba(17, 24, 39, 0.28);
|
--border-strong: rgba(17, 24, 39, 0.32);
|
||||||
--shadow-panel: 0 8px 24px rgba(15, 23, 42, 0.08);
|
--shadow-panel: 0 8px 24px rgba(15, 23, 42, 0.08);
|
||||||
--scrollbar-thumb: rgba(17, 24, 39, 0.18);
|
--scrollbar-thumb: rgba(17, 24, 39, 0.22);
|
||||||
--scrollbar-thumb-hover: rgba(17, 24, 39, 0.32);
|
--scrollbar-thumb-hover: rgba(17, 24, 39, 0.38);
|
||||||
|
|
||||||
/* --- 5. STATUS- & SIGNALFARBEN --- */
|
/* --- 5. STATUS- & SIGNALFARBEN --- */
|
||||||
--color-success: #15803d;
|
--color-success: #15803d;
|
||||||
@@ -57,10 +57,10 @@
|
|||||||
--code-keyword: #0000ff;
|
--code-keyword: #0000ff;
|
||||||
--code-string: #a31515;
|
--code-string: #a31515;
|
||||||
--code-number: #098658;
|
--code-number: #098658;
|
||||||
--code-comment: #008000;
|
--code-comment: #007000;
|
||||||
--code-property: #0451a5;
|
--code-property: #0451a5;
|
||||||
--code-function: #795e26;
|
--code-function: #795e26;
|
||||||
--code-type: #267f99;
|
--code-type: #267f99;
|
||||||
--code-punctuation: #3b3b3b;
|
--code-punctuation: #2e2e2e;
|
||||||
--code-variable: #001080;
|
--code-variable: #001080;
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user