28 Commits
Author SHA1 Message Date
sandrodachandClaude Opus 5.5 9f3ad841bd Merge branch 'dev': Polarized and Suri themes
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m30s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:42:18 +01:00
sandrodachandClaude Opus 5.5 a9933b837e Merge branch 'theme/polarized-suri' into dev: Polarized and Suri themes
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m32s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:42:04 +01:00
sandrodachandClaude Opus 5.5 52de2d09c1 Merge branch 'dev': due times & alarms, notes trash, backlinks, password reset, offline PWA, security fixes, file split
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:17:37 +01:00
sandrodachandClaude Opus 5.5 74c394efaa refactor: split sqlite.go and server.go by domain (FLAW-20)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m31s
Pure moves, no behaviour change: the storage layer now has users,
apikeys, thoughts, tasklists, documents, folders, tags, lists, teams and
system files next to sqlite.go (store, schema, migrations); the API has
handlers_*.go per area plus realtime.go and alarms.go next to server.go
(server, router, middleware). Declarations were moved verbatim with an
AST-based tool; imports pruned with goimports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:14:33 +01:00
sandrodachandClaude Opus 5.5 7bba174454 feat(pwa): start and keep working offline
The service worker now caches the app shell: page loads are network
first with the last index.html as fallback, hashed bundles come from the
cache, icons and manifest are refreshed in the background; API calls are
never intercepted. GET answers are kept per user in IndexedDB and served
when the server cannot be reached, so the app starts logged in with the
last known thoughts, tasks and lists. Captured thoughts and checked tasks
or list items go to an outbox that is sent in order when the connection
returns (also at start and every 30 s). A banner shows offline state and
waiting changes. Logging out clears cache and outbox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:11:47 +01:00
sandrodachandClaude Opus 5.5 5e20710aa7 feat(auth): reset a forgotten password by mail
"Passwort vergessen?" appears on the login screen when SMTP and BASE_URL
are configured. The request always gets the same answer and sends the mail
in the background (at most every 2 minutes per account). The link holds a
random token valid once for one hour; only its SHA-256 is stored. Setting
the new password invalidates all open links and ends every session. The
app removes the token from the address bar and offers a way back for
expired links.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:06:56 +01:00
sandrodachandClaude Opus 5.5 677fe0fc0a feat(workspace): backlinks at the end of a note
GET /documents/{id}/backlinks lists the notes the user can open whose
blocks link [[Title]] (also |alias and #section, case-insensitive),
excluding the note itself and the trash. The collapsible "Verlinkt auf
diese Notiz" section shows each note with folder and the linking bullets;
a click jumps straight to the bullet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:00:57 +01:00
sandrodach 8619008aaa feat(theme): add Polarized and Suri purple dark themes 2026-10-29 07:58:33 +01:00
sandrodachandClaude Opus 5.5 f69f74fdc7 feat(workspace): trash for notes
Deleting a note (or its folder) moves it to the trash instead of erasing
it. Trashed notes leave the tree, search, export and stats and are hidden
from everyone they were shared with; the owner sees them read-only with a
restore banner. New endpoints list, restore, purge and empty the trash;
restoring recreates a deleted folder. Sidebar entry and mobile menu item
with counter, list view with two-tap permanent delete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:57:42 +01:00
sandrodachandClaude Opus 5.5 344f5e4ab2 feat(tasks): due dates with optional time; alarms at the right moment
Due dates can carry a time (YYYY-MM-DDTHH:MM). Alarms fire at that time,
or 09:00 for all-day tasks, minus the user's lead time, in TZ (default
Europe/Berlin, tzdata embedded). A new due date or reopening a task
re-arms the alarm. Recurrence keeps the time; Markdown export/import
carry it. The UI shows relative labels ("Heute, 14:30"), marks overdue
tasks to the minute and groups by local day instead of the UTC date.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:48:37 +01:00
sandrodachandClaude Opus 5.5 300fd6a7be fix(security): API keys only deliver data; one auth check everywhere
Until API keys get scopes they have exactly one right: POST /api/v1/inbox
(thought or task via target) and the webhook ingest; every other endpoint
answers 403 for a key. Middleware and webhook ingest share one token check
(revocation list, token_version, active account), closing FLAW-08.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:39:29 +01:00
sandrodachandClaude Opus 5.5 62463bc6fd fix(security): SSRF protection for alarm webhooks (FLAW-05)
Discord and custom webhook URLs are checked when saved and before every
call: https only, Discord URLs must point to discord.com. A guarded HTTP
client resolves hosts itself and connects only to allowed IPs; localhost,
link-local and cloud metadata are always blocked, LAN ranges (incl.
Tailscale CGNAT) and plain http only with the new admin switch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:37:39 +01:00
sandrodachandClaude Opus 5.5 4cdbcbd6b5 fix(security): never sign tokens with a known JWT secret (FLAW-04)
Without JWT_SECRET, or with one of the published placeholders, the server
generates 32 random bytes once and keeps them in the data directory
(jwt.secret, mode 0600), so tokens cannot be forged and logins survive
restarts. Instances that ran on the old default log in once more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:35:45 +01:00
sandrodachandClaude Opus 5.5 c239358897 Merge dev into main for v0.4
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m34s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:30:10 +01:00
sandrodachandClaude Opus 5.5 beeacf8943 release: v0.4 – import, backup ZIP, account self-service, repeating tasks
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 1m48s
- Import of MindSift data (add or restore) and Obsidian vaults, backup as ZIP
- Danger zone (delete own data / account), last admin protected
- Repeating tasks, SMTP AUTH LOGIN fallback and port fix
- Mobile: sheets above the keyboard, emoji picker fixes, blue splash screen
- Package version 0.4.0, README badge/screenshots renewed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:30:08 +01:00
sandrodachandClaude Opus 5.5 79643d34f2 Merge dev into main
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 7s
Repeating tasks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:23:11 +01:00
sandrodachandClaude Opus 5.5 588c679c6a feat(tasks): repeating tasks (daily, weekly, monthly, yearly)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m28s
- recurrence on tasks, set in the detail dialog; 🔁 on rows and Kanban cards
- Completing a repeating task (detail, list, Kanban drag to done, note
  checkbox) creates the next one: title, tags, priority, notes, list,
  assignees and rule copied, subtasks reset, first open column, due date
  one interval later (month end clamped); pushed live as TASK_CREATED
- Each task spawns at most once (re-open + complete makes no duplicate)
- Export/import keep the rule (Obsidian Tasks "🔁 every week", JSON field)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:23:09 +01:00
sandrodachandClaude Opus 5.5 9eb26bc563 Merge dev into main
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 9s
Danger zone and restore import, SMTP port fix, PWA splash screen,
larger API key field on phones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:13:31 +01:00
sandrodachandClaude Opus 5.5 0e9fd1b3ad feat(account): danger zone, restore import, larger API key field on phones
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m27s
- Danger zone in settings: delete all own data or the account, both with
  password confirmation; the last server admin cannot delete the account
- DELETE /user/data and /user/account; PurgeUserData removes everything a
  user owns in one transaction (others' tasks in shared lists stay theirs),
  also used by admin user deletion
- MindSift import: 'Hinzufügen' or 'Wiederherstellen' (mode=restore, wipes
  own data only after the file is fully validated, confirm checkbox)
- API key name field and button 44 px on phones (flex-1 collapsed them)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:13:29 +01:00
sandrodachandClaude Opus 5.5 9bc8716f41 feat(pwa): blue splash screen with the logo, status bar follows the theme
Manifest: short name, blue background/theme colour for the Android splash.
index.html shows a plain blue screen with the logo until React mounts.
theme-color starts blue and then follows the active theme background.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:13:29 +01:00
sandrodachandClaude Opus 5.5 a3a709440f fix(admin): SMTP port can be cleared and saves without 'Ungültiges JSON'
The UI sent the port as a number while the server expects text, so every
save failed; the field also jumped back to 587 when emptied. The port is
now edited as text (digits only), and the server accepts number or text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:13:29 +01:00
sandrodachandClaude Opus 5.5 7daa7aaae1 Merge dev into main
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 7s
Backup as ZIP, no more schema wipe on start, mobile sheets above the
keyboard, emoji picker fixes, cleaner mobile header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 06:46:19 +01:00
sandrodachandClaude Opus 5.5 eaabe469d5 fix(mobile): sheets above the keyboard, emoji picker, cleaner header
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m25s
- FormSheet: create dialogs (task list, shopping list, folder) as bottom
  sheet right above the on-screen keyboard (visualViewport), large fields;
  centred or anchored dialog on desktop
- Emoji picker: category icons keep their sprite size (no doubled icons,
  round highlight), one 'Zuletzt verwendet' (library category, German
  names), sheet and picker height follow the keyboard
- Mobile header no longer repeats the current section next to the logo

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 06:43:51 +01:00
sandrodachandClaude Opus 5.5 108d166990 chore(db): stop wiping databases with an older schema version
All installations are past schema version 2, so the one-time reset is gone;
data is never deleted on start (demo mode still resets). user_version is kept
for future migrations. Legal test uses an in-memory DB (cleanup race).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 06:43:51 +01:00
sandrodachandClaude Opus 5.5 1df32ece92 feat(backup): download the full backup as ZIP
GET /export?format=zip streams the JSON backup compressed (plain JSON stays
the API default). The MindSift import recognises the backup ZIP next to the
plain JSON and the Markdown archive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 06:43:50 +01:00
sandrodachandClaude Opus 5.5 b71f044e08 Merge dev into main
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 8s
SMTP AUTH LOGIN fallback, import of MindSift data and Obsidian vaults.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 06:12:37 +01:00
sandrodachandClaude Opus 5.5 1601a6a2d5 feat(import): restore MindSift data and import Obsidian vaults
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m28s
- Two separate paths: POST /import/mindsift (JSON backup or the Markdown
  archive) and POST /import/obsidian (vault ZIP into a folder named after it)
- Everything is planned and validated first; ?preview=1 returns what would
  be created and what is skipped (with reason), nothing is written
- MindSift data round-trips: folders, note structure, task lists with
  subtasks/due dates/priorities/notes, lists with quantity/category/done,
  triage thoughts from the JSON backup
- Markdown to outliner: nested lists, heading sections, callouts, code,
  paragraphs; front matter tags become hashtags; .obsidian/attachments skipped
- Limits against zip bombs and path tricks (100 MB upload, 10 MB per note,
  500 MB unpacked, 20k files, no .. escapes)
- Settings: two import cards with drag & drop, preview, confirm, result;
  touch-sized buttons; mobile download/upload verified

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 06:10:21 +01:00
sandrodachandClaude Opus 5.5 d8a16c6775 fix(smtp): AUTH LOGIN fallback when PLAIN is refused
Servers like Postfix with Dovecot SASL or Exchange can fail PLAIN with
451 4.3.2 but accept LOGIN. MindSift now uses LOGIN directly when it is
the only mechanism, and otherwise reconnects and retries with LOGIN after
a refused PLAIN (net/smtp closes the connection on a failed AUTH). If both
fail with 451, the error names both attempts and points to the server's
SASL backend. LOGIN, like PLAIN, is never sent unencrypted to remote hosts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 00:09:27 +01:00
104 changed files with 13058 additions and 7691 deletions

No files matched your search

+14 -4
View File
@@ -1,6 +1,6 @@
# REST-API
Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer <JWT>` oder `Bearer mindsift_sec_…` bzw. Header `X-API-Key`. Zeitstempel sind ISO-8601 (UTC). Quelle der Wahrheit sind die Routen in [server.go](../../internal/api/server.go).
Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer <JWT>`. **API-Keys** (`Bearer mindsift_sec_…` bzw. Header `X-API-Key`) haben vorerst genau ein Recht: Daten einliefern über `POST /inbox` (Gedanke oder per `target` direkt Aufgabe) und `POST /webhooks/ingest`; alle anderen Endpunkte antworten mit `403`. Feinere Berechtigungen (Scopes) folgen. Zeitstempel sind ISO-8601 (UTC). Quelle der Wahrheit sind die Routen in [server.go](../../internal/api/server.go).
## Öffentlich
@@ -12,6 +12,8 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
| POST | `/auth/setup` | Erstes Administratorkonto (`email`, `password`, `displayName`, `instanceName`, `allowRegistration`, `sharingMode`, `seedSampleData`); nur solange es keine Konten gibt |
| POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben |
| POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` |
| POST | `/auth/forgot-password` | `{email}` → immer `{ok: true}`; Mail mit Link nur, wenn das Konto existiert. `503`, wenn SMTP oder `BASE_URL` fehlen |
| POST | `/auth/reset-password` | `{token, password}` (mind. 4 Zeichen) → neues Passwort, alle Sitzungen beendet; `400` bei ungültigem oder abgelaufenem Link |
| POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) |
| POST | `/webhooks/ingest` | Aufgabe per Webhook anlegen, Token als `?token=`, `X-API-Key` oder Bearer. Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; Klartext-Body wird als Inhalt genommen |
| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); Token als `?token=` nötig |
@@ -30,10 +32,14 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
| GET | `/users/directory`, `/collaborators` | Personen, die man sehen und mit denen man teilen kann (je nach `sharingMode`, [sharing-and-permissions.md](../sharing-and-permissions.md)) |
| GET | `/stats` | Zähler fürs Dashboard |
| GET | `/search?q=` | Globale FTS5-Suche in zugänglichen Notizen (Titel & Blöcke), Aufgaben und Listen → `{thoughts, documents, listItems, hits}` |
| GET | `/export` | Datenexport als JSON |
| GET | `/export` | Datenexport als JSON; mit `?format=zip` dieselben Daten als `mindsift-backup.json` in einer ZIP (so lädt die Oberfläche das Backup) |
| GET | `/export/markdown` | Obsidian-kompatibles ZIP (`mindsift-vault-DATUM.zip`), direkt gestreamt: Notizen als `.md` mit YAML-Frontmatter in der Ordnerstruktur des Nutzers (inkl. geteilter), `Aufgaben/<Liste>.md` im Format des Tasks-Plugins (`📅`, `⏫`/`🔺`), `Listen/<Liste>.md` |
| GET/POST | `/themes` | Eigene CSS-Themes; anlegen oder mit `id` ändern (`name`, `css`, max. 200 KB) |
| DELETE | `/themes/{id}` | Eigenes Theme löschen |
| POST | `/import/mindsift`, `/import/obsidian` | Import (Multipart-Feld `file`, max. 100 MB): MindSift-JSON/-Archiv bzw. Obsidian-Vault-ZIP; `?preview=1` liefert nur die Zusammenfassung (`folders`, `notes`, `taskLists`, `tasks`, `lists`, `listItems`, `inbox`, `skipped`) |
| — | `/import/mindsift?mode=restore` | Wiederherstellen: löscht vor dem Import alle eigenen Notizen, Ordner, Aufgaben, Gedanken und Listen (erst nachdem die Datei vollständig gelesen und geprüft ist) |
| DELETE | `/user/data` | Alle eigenen Daten löschen, Konto bleibt (`{"password"}` nötig) |
| 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/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung) |
### Globale Suche (`GET /api/v1/search?q=`)
@@ -67,7 +73,7 @@ Führt eine Volltextsuche über SQLite FTS5 mit BM25-Relevanz-Ranking aus.
|---|---|---|
| POST | `/inbox` | Schnellerfassung. Body: `content`, `priority`, `source`, `dueDate`, `target` (`inbox`/`task`/`list_item`/`document`), `targetListId`, `taskListId` (snake_case-Varianten werden akzeptiert) |
| GET | `/thoughts?status=` | Gedanken (`inbox`, `kept`, `converted`, `discarded`, `all`) |
| GET/PATCH/DELETE | `/thoughts/{id}` | Einzelabruf; Teiländerung (`content`, `isCompleted`, `dueDate`, `priority`, `taskListId`, `kanbanStatus`, `notes`, `subtasks`, `tags`, `assignees`); Löschen. `assignees` ersetzt die Zuweisungen; nur Personen mit Zugriff auf die Liste, sonst `403` |
| GET/PATCH/DELETE | `/thoughts/{id}` | Einzelabruf; Teiländerung (`content`, `isCompleted`, `dueDate`, `priority`, `taskListId`, `kanbanStatus`, `notes`, `subtasks`, `tags`, `assignees`, `recurrence` = `daily`/`weekly`/`monthly`/`yearly`/leer); Löschen. `assignees` ersetzt die Zuweisungen; nur Personen mit Zugriff auf die Liste, sonst `403` |
| DELETE | `/thoughts/discarded/purge` | Papierkorb leeren |
| GET | `/triage/deck` | Posteingang (max. 100) |
| PATCH | `/triage/{id}` | `action`: `keep`/`discard`/`restore`; mit `convertTo`: `task`/`document`/`list_item` |
@@ -100,7 +106,11 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein
| GET | `/workspace/tags`, `/workspace/tags/{tag}` | Tag-Übersicht und -Details |
| PUT | `/workspace/tags/{tag}/note` | Tag-Seite speichern (`content`) |
| GET/POST | `/documents` | Notizen auflisten; anlegen (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`) |
| GET/PUT/DELETE | `/documents/{id}` | Abruf; Ändern von `title`, `icon`, `folderPath`, `isPinned` (Blöcke → `400`, sie laufen über `/docs/{id}/sync`); Löschen |
| GET/PUT/DELETE | `/documents/{id}` | Abruf; Ändern von `title`, `icon`, `folderPath`, `isPinned` (Blöcke → `400`, sie laufen über `/docs/{id}/sync`); Löschen = in den Papierkorb (Eigentümer) bzw. Freigabe verlassen (andere) |
| GET/DELETE | `/documents/trash` | Papierkorb auflisten (`id`, `title`, `icon`, `folderPath`, `deletedAt`, `isOwner`) bzw. leeren |
| GET | `/documents/{id}/backlinks` | Notizen mit `[[Titel]]`-Link auf diese Notiz (nur sichtbare, ohne Papierkorb): `docId`, `title`, `icon`, `folderPath`, bis zu 5 `blocks` (`id`, `content`), `more` |
| POST | `/documents/{id}/restore` | Aus dem Papierkorb zurückholen (fehlender Ordner wird neu angelegt) |
| DELETE | `/documents/{id}/permanent` | Notiz im Papierkorb endgültig löschen |
| PUT | `/documents/{id}/move` | Ordner wechseln (`folderPath`) |
| 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`) |
+15 -6
View File
@@ -30,8 +30,8 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
| Pfad | Inhalt |
|---|---|
| `cmd/server/` | `main.go`, eingebettetes `dist/` |
| `internal/api/` | `server.go` (Routen, Handler, Alarm-Ticker, SMTP/Discord/Webhook-Versand) |
| `internal/storage/` | `sqlite.go` (Schema, Migrationen, gesamte Datenlogik) |
| `internal/api/` | `server.go` (Server, Router mit allen Routen, Middleware, JSON-Helfer); Handler nach Bereich in `handlers_*.go` (`auth`, `account`, `thoughts`, `documents`, `folders`, `lists`, `tasklists`, `teams`, `admin`, `integrations`), `realtime.go` (WebSocket/SSE), `alarms.go` (Ticker und Versand), `docsync.go`, `mail.go`, `netguard.go`, `password_reset.go` |
| `internal/storage/` | `sqlite.go` (Store, Schema, Migrationen); Datenlogik nach Bereich: `users.go`, `apikeys.go`, `thoughts.go` (inkl. Alarme), `tasklists.go`, `documents.go`, `folders.go`, `tags.go`, `lists.go`, `teams.go`, `system.go` (Einstellungen, Statistik, Export), dazu `visibility.go`, `docsync.go`, `search.go`, `trash.go`, `backlinks.go`, `recurrence.go` u. a. |
| `internal/domain/` | Strukturen (`models.go`), ID-Erzeugung, Tag-Extraktion |
| `internal/ws/` | `hub.go` |
| `internal/collab/` | Kern der Notiz-Synchronisation ohne Datenbank: Blockbaum, Operationen und Konfliktregeln, Sortierschlüssel, Leases, Long-Poll-Benachrichtigung |
@@ -58,8 +58,9 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
| `PORT` | `8080` | HTTP-Port |
| `DATA_DIR` | `./data` (Container: `/data`) | Verzeichnis der Datenbank |
| `DATABASE_URL` | leer | `sqlite:///pfad/datei.db` oder Pfad; ohne Angabe `DATA_DIR/mindsift.db`. Postgres-URLs werden **nicht** unterstützt (siehe Hinweis) |
| `JWT_SECRET` | festes Fallback (unsicher!) | Signaturschlüssel der Tokens; unbedingt setzen, siehe [FLAW-04](flaws.md) |
| `BASE_URL` | `http://localhost:<PORT>` | Basis-URL der Instanz |
| `JWT_SECRET` | zufällig erzeugt, gespeichert in `$DATA_DIR/jwt.secret` | Signaturschlüssel der Tokens; bekannte Platzhalter werden ignoriert ([FLAW-04](flaws.md)) |
| `BASE_URL` | `http://localhost:<PORT>` | Basis-URL der Instanz; explizit gesetzt Voraussetzung für das Passwort-Zurücksetzen per Mail |
| `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme; die Zeitzonendaten sind ins Binary eingebettet (`time/tzdata`) |
| `ALLOW_REGISTRATION` | `false` | Startwert; die Einstellung im Admin-UI (`system_settings`) hat Vorrang |
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | `debug`, `info`, `warn`, `error`; `DEBUG=true` erzwingt `debug` |
| `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen |
@@ -75,12 +76,13 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in
- **Login** mit E-Mail, Benutzername (aus dem E-Mail-Präfix) oder `login`. Passwörter: bcrypt (`DefaultCost`).
- **JWT:** HS256, Laufzeit 30 Tage, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Gültig nur, wenn Token nicht in `revoked_tokens` steht, das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht.
- **Logout** schreibt den Token-Hash in `revoked_tokens`.
- **API-Keys:** `mindsift_sec_<hex>`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key` und authentifiziert als Besitzer ([FLAW-07](flaws.md)).
- **API-Keys:** `mindsift_sec_<hex>`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key` und darf nur Daten anliefern (`POST /inbox`, `POST /webhooks/ingest`); alle anderen Endpunkte antworten mit `403` ([FLAW-07](flaws.md)).
- **Passwort vergessen:** nur wenn SMTP und `BASE_URL` gesetzt sind (nicht im Demo-Modus; `passwordReset` in `/auth/status`). `POST /auth/forgot-password` antwortet immer gleich und verschickt die Mail im Hintergrund, höchstens alle 2 Minuten pro Konto. Der Link `BASE_URL/?resetToken=…` gilt eine Stunde und einmal; gespeichert wird nur der SHA-256-Hash (`password_reset_tokens`). `POST /auth/reset-password` setzt das Passwort, entwertet alle offenen Links des Kontos und erhöht `token_version` (alle Sitzungen enden). Die App entfernt den Token sofort aus der Adresszeile.
- **Registrierung:** nur mit Einladungscode oder wenn `allow_registration` aktiv ist. Das erste Konto entsteht über `/auth/setup` und wird Administrator.
## Hintergrundjob: Alarme
`startAlarmTicker` prüft jede Minute alle offenen, noch nicht gemeldeten Aufgaben mit Fälligkeit. Ist `due_date <= heute` (Serverzeit), sendet er über die Kanäle des Benutzers (`email`, `discord`, `webhook`) und setzt `alarm_sent`, sobald mindestens ein Kanal erfolgreich war. Details und Einschränkungen: [modules/tasks.md](modules/tasks.md), [FLAW-12](flaws.md).
`startAlarmTicker` prüft jede Minute alle offenen, noch nicht gemeldeten Aufgaben mit Fälligkeit. Ist der Alarmzeitpunkt erreicht (Fälligkeit mit Uhrzeit bzw. 9:00 bei ganztägigen Aufgaben, minus Vorlaufzeit des Benutzers, Zeitzone `TZ`), sendet er über die Kanäle des Benutzers (`email`, `discord`, `webhook`) und setzt `alarm_sent`, sobald mindestens ein Kanal erfolgreich war. Details und Einschränkungen: [modules/tasks.md](modules/tasks.md), [FLAW-12](flaws.md).
## Hintergrundjob: Verdichtung der Notiz-Synchronisation
@@ -100,6 +102,13 @@ Die angezeigte Version (Kopf der App) setzt der Build-Parameter `APP_VERSION`: D
Die Tests laufen beim Image-Build (Frontend: `npm run test:run`, Backend: `go test -v ./...`); ein Fehler bricht den Build ab. Frontend-Tests liegen unter `frontend/src/**/__tests__/` (Vitest + jsdom + Testing Library, Setup in `frontend/src/test/setup.ts`).
## Offline (PWA)
- **Service Worker** ([public/sw.js](../frontend/public/sw.js)): Seitenaufrufe Network-First mit der zuletzt geladenen `index.html` als Rückfall, Bundles unter `/assets/` (Hash im Namen) aus dem Cache, Icons und Manifest Stale-While-Revalidate. API-Anfragen fasst er nie an. Alte Caches verschwinden beim Aktivieren einer neuen `VERSION`, Bundles sind auf 80 Einträge begrenzt.
- **Lese-Cache** ([lib/offline.ts](../frontend/src/lib/offline.ts)): Jede erfolgreiche GET-Antwort landet pro Benutzer in IndexedDB (`mindsift-offline`). Ohne Verbindung liefert `request()` die letzte bekannte Antwort; so startet die App offline angemeldet und zeigt Gedanken, Aufgaben und Listen. Ausgenommen: Streams, Long-Polling, Notiz-Sync (eigener Speicher, [modules/collaboration.md](modules/collaboration.md)), Export, Suche. Abmelden leert Cache und Warteschlange.
- **Warteschlange (Outbox):** Offline erfasste Gedanken (`POST /inbox`) und abgehakte Aufgaben bzw. Listeneinträge (`PATCH`) werden in `localStorage` gesammelt, wiederholtes Umschalten zählt nur mit dem Endstand. Gesendet wird in Reihenfolge beim `online`-Ereignis, beim Start und alle 30 s, solange etwas wartet; vom Server abgelehnte Einträge (4xx) entfallen. Andere Änderungen brauchen weiterhin eine Verbindung.
- **Hinweis:** Ein Banner über der unteren Navigation zeigt „Offline“ und die Zahl wartender Änderungen bzw. „Synchronisiere…“.
## App-Icons, PWA & Social-Preview (Open Graph)
MindSift verwendet plattformübergreifend ein einheitliches App-Icon-Design, das exakt dem Icon im Kopfbereich der mobilen App entspricht:
+4 -3
View File
@@ -19,7 +19,8 @@ Die Datei `migrations/001_init.sql` wird vom Code **nicht** gelesen und ist vera
### Gedanken und Aufgaben
| Tabelle | Zweck | Wichtige Spalten |
|---|---|---|
| `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` (nur Datum), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `sort_order`, `source` (`manual`, `voice`, `webhook` …) |
| `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`, `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_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`) |
@@ -29,7 +30,7 @@ Eine Aufgabe ist ein `thought` mit `status='kept'` oder `status='converted' AND
### Workspace
| Tabelle | Zweck |
|---|---|
| `documents` | Notizen: `title`, `icon`, `folder_path`, `tags` (JSON), `is_archived`, `markdown_cache`, `shared_folder_id`, `sub_folder_path`, `head_seq` (letzte Log-Position), `title_version`, `log_floor` (bis hierhin ist das Log verdichtet), Altspalten `content`, `blocks_json` |
| `documents` | Notizen: `title`, `icon`, `folder_path`, `tags` (JSON), `is_archived` (1 = im Papierkorb), `archived_at`, `archived_by` (wer gelöscht hat), `markdown_cache`, `shared_folder_id`, `sub_folder_path`, `head_seq` (letzte Log-Position), `title_version`, `log_floor` (bis hierhin ist das Log verdichtet), Altspalten `content`, `blocks_json` |
| `blocks` | Outliner-Blöcke als Baum: `document_id`, `parent_id` (`''` = oberste Ebene), `order_key` (Sortierschlüssel unter Geschwistern), `type`, `content`, `checked`, `collapsed`, `lang`, `version` (Textversion), `conflict_of`/`conflict_author` (Konfliktkopien), `updated_by`, `deleted` (Grabstein) und `deleted_at` (Zeitpunkt des Löschens, für das Aufräumen). `level`/`position` sind Altspalten und werden nicht mehr gepflegt; Ebene und Reihenfolge berechnet der Server beim Lesen aus dem Baum. |
| `doc_ops` | Änderungs-Log je Notiz: `seq` (fortlaufend pro Notiz), `op_id`, `user_id`, `client_id`, `kind`, `block_id`, `payload` (Effekt als JSON), `created_at`. Abspielen aller Einträge ab einem Snapshot ergibt den Stand. Wird verdichtet (siehe unten). |
| `doc_op_results` | Ergebnis je Client-Operation (`op_id` eindeutig) für idempotente Wiederholungen; wird nach 90 Tagen gelöscht |
@@ -80,7 +81,7 @@ teams 1─n team_members n─1 users
- `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.
- Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=organizations`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer.
- **Schema-Version:** `PRAGMA user_version` hält `schemaVersion` (derzeit 2). Ist die Datenbank älter, werden vor allem anderen **alle Tabellen gelöscht** und neu angelegt. Bewusst so, solange MindSift nicht produktiv läuft.
- **Schema-Version:** `PRAGMA user_version` hält `schemaVersion` (derzeit 2), damit spätere Versionen erkennen, welche Migrationen eine Datenbank schon hat. Daten werden beim Start nie gelöscht (das einmalige Zurücksetzen auf Version 2 ist entfernt, alle Installationen sind darüber hinweg). Nur der Demo-Modus leert die Datenbank bei jedem Start.
## Sicherung
+13 -7
View File
@@ -13,15 +13,15 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
| FLAW-01 | Realtime | ~~`BroadcastToAll` sendet Aufgaben- und Listendaten an alle Verbindungen~~ | **Behoben** |
| FLAW-02 | Realtime | ~~Räume ohne Berechtigungsprüfung~~ | **Behoben** |
| FLAW-03 | Aufgaben | ~~`ReorderTasks` ändert beliebige Aufgaben per ID (IDOR)~~ | **Behoben** |
| FLAW-04 | Auth | Fest eincodiertes Fallback-`JWT_SECRET` | **Hoch** |
| FLAW-05 | Benachrichtigungen | Discord-/Webhook-URLs ungeprüft (SSRF) | Mittel |
| FLAW-04 | Auth | ~~Fest eincodiertes Fallback-`JWT_SECRET`~~ | **Behoben** |
| FLAW-05 | Benachrichtigungen | ~~Discord-/Webhook-URLs ungeprüft (SSRF)~~ | **Behoben** |
| FLAW-06 | Aufgaben | ~~Jeder Editor einer Aufgabenliste kann sie per `teamId` an weitere Gruppen freigeben~~ | **Behoben** |
| FLAW-07 | Auth | API-Key-`scopes` werden nie ausgewertet; ein Key ist ein Vollzugriff | Mittel |
| FLAW-08 | Auth | Webhook-Ingest mit JWT prüft weder Sperrliste noch `token_version` noch `is_active` | Mittel |
| FLAW-07 | Auth | ~~Ein API-Key ist ein Vollzugriff~~ – Keys dürfen nur noch einliefern; Scopes fehlen noch | Niedrig |
| FLAW-08 | Auth | ~~Webhook-Ingest mit JWT prüft weder Sperrliste noch `token_version` noch `is_active`~~ | **Behoben** |
| FLAW-09 | Auth | Kein Rate-Limit, keine Sperre bei Fehlversuchen, Mindestpasswort 4 Zeichen | Mittel |
| FLAW-10 | Freigaben | ~~`sharing_mode` wird bei Freigaben nicht durchgesetzt; Benutzerverzeichnis leakt alle Konten~~ | **Behoben** |
| FLAW-11 | Workspace | ~~Block-Upsert (`ON CONFLICT(id)`) prüft das Dokument nicht~~ | **Behoben** |
| FLAW-12 | Benachrichtigungen | `notify_before_minutes` wirkungslos; Alarm feuert nach Umplanung nie wieder | Mittel |
| FLAW-12 | Benachrichtigungen | ~~`notify_before_minutes` wirkungslos; Alarm feuert nach Umplanung nie wieder~~; kein Backoff bei Fehlern | **Teilweise behoben** |
| FLAW-13 | Datenbank | Keine Schema-Versionierung; Migrationsfehler werden verschluckt; `migrations/001_init.sql` ist toter Code | Mittel |
| FLAW-14 | Backend | Fehler von Schreiboperationen werden häufig ignoriert (`_, _ =`) | Mittel |
| FLAW-15 | Realtime | ~~`WriteTimeout` von 30 s beendet SSE-Streams~~ | **Behoben** |
@@ -29,7 +29,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
| FLAW-17 | Workspace | ~~Bearbeiter-Freigaben für Notizen nicht wählbar~~ | **Behoben** |
| FLAW-18 | Suche | ~~Nur eigene Gedanken und Dokumente, `LIKE` ohne Escaping, keine FTS~~ | **Behoben** |
| FLAW-19 | Qualität | Frontend-Tests nur für den Outliner, `lint` ist ein Platzhalter | Niedrig |
| FLAW-20 | Qualität | Sehr große Dateien, duplizierte Zugriffslogik | Niedrig |
| FLAW-20 | Qualität | ~~Sehr große Dateien~~; duplizierte Zugriffslogik | Niedrig |
| FLAW-21 | Repo | `LICENSE` fehlt, obwohl README MIT angibt | Niedrig |
| FLAW-22 | Netzwerk | Token in der URL (WS/SSE), CORS `*`, `CheckOrigin` immer `true` | Niedrig |
| FLAW-23 | SQLite | Single-Writer-Engpass bei vielen gleichzeitigen Schreibern | Niedrig |
@@ -60,11 +60,13 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
- **Maßnahme:** Jede ID gegen `accessibleTaskListIDsQuery` bzw. `user_id` prüfen und bei Verstoß ablehnen.
### FLAW-04: Fallback-JWT-Secret
- **Status: Behoben.** Fehlt `JWT_SECRET` oder ist es ein bekannter Platzhalter, erzeugt der Server 32 Zufallsbytes und speichert sie als `jwt.secret` (Rechte 0600) im Datenverzeichnis; ein Hinweis steht im Log. Test: `internal/config/config_test.go`.
- **Fundstelle:** [`internal/config/config.go`](../internal/config/config.go): `getEnv("JWT_SECRET", "mindsift-default-selfhosted-jwt-secret-key-2026")`; `docker-compose.yml` und `.env.example` liefern ebenfalls einen Platzhalter.
- **Problem:** Wer `JWT_SECRET` nicht setzt (oder den Platzhalter behält), läuft mit einem öffentlich bekannten Schlüssel. Damit lassen sich gültige Tokens für jede Benutzer-ID fälschen. `install.sh` erzeugt zwar ein Secret, andere Installationswege nicht.
- **Maßnahme:** Start verweigern oder Secret beim ersten Start erzeugen und in `/data` ablegen.
### FLAW-05: SSRF über Alarm-URLs
- **Status: Behoben.** [netguard.go](../internal/api/netguard.go): URLs werden beim Speichern und vor jedem Versand geprüft (https Pflicht, Discord nur `discord.com/api/webhooks/`). Der HTTP-Client löst den Host selbst auf und verbindet nur zu erlaubten IPs: localhost, Link-Local/Cloud-Metadaten sind immer gesperrt, Heimnetz-Bereiche (RFC 1918, ULA, CGNAT/Tailscale) und http nur mit dem Admin-Schalter „Alarm-Webhooks ins lokale Netz erlauben“. Keine Umgebungs-Proxys, höchstens 3 Weiterleitungen.
- **Fundstellen:** `handleUpdateUserSettings` (keine Validierung), `sendDiscordNotification`, `sendCustomWebhookNotification`.
- **Problem:** Jeder Nutzer kann beliebige URLs hinterlegen. Der Server sendet POST-Anfragen dorthin, auch an interne Adressen (`http://169.254.169.254`, Docker-Netz, `localhost`). Über `handleTestUserAlarm` lässt sich das sofort auslösen.
- **Maßnahme:** Nur `https`, Discord-Host prüfen, private/Loopback-Adressen beim Verbinden sperren.
@@ -76,11 +78,13 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
- **Maßnahme:** `teamId`-Änderungen nur für den Eigentümer; das Legacy-Feld `task_lists.team_id` zugunsten von `task_list_shares` abschaffen.
### FLAW-07: API-Key-Scopes ohne Wirkung
- **Teilweise behoben:** API-Keys dürfen nur noch `POST /inbox` und `POST /webhooks/ingest` nutzen (`apiKeyAllowed` in `server.go`), alles andere gibt `403`. Auswählbare Scopes (Triage, Aufgaben, Listen) stehen noch aus.
- **Fundstellen:** Tabelle `api_keys.scopes` (Standard `ingest,read`), `authMiddleware`.
- **Problem:** Ein Key mit Präfix `mindsift_sec_` authentifiziert als der Benutzer, mit allen Rechten: alle `/api/v1`-Routen, bei Admins auch `/admin/*`, außerdem das Anlegen weiterer Keys. Für Webhooks (nur Aufgaben anlegen) ist das überdimensioniert.
- **Maßnahme:** Scopes auswerten; Keys standardmäßig nur auf `/inbox` und `/webhooks/ingest` beschränken.
### FLAW-08: Webhook-Ingest umgeht Sitzungsprüfung
- **Status: Behoben.** Middleware und Webhook-Ingest nutzen dieselbe Prüfung (`authenticateToken`): Sperrliste, `token_version`, aktives Konto. Test: `TestAPIKeysOnlyDeliverData`.
- **Fundstelle:** `handleWebhookIngest`, JWT-Zweig.
- **Problem:** Das Token wird nur kryptografisch validiert. `IsTokenRevoked`, `token_version` und `is_active` werden nicht geprüft; ein ausgeloggtes oder deaktiviertes Konto kann weiter Aufgaben anlegen (bis Token-Ablauf nach 30 Tagen).
- **Maßnahme:** Dieselbe Prüfkette wie in `authMiddleware` verwenden (gemeinsame Funktion statt Kopien; siehe auch `authenticateUserFromReq`).
@@ -106,6 +110,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
- **Maßnahme:** `DO UPDATE … WHERE blocks.document_id = excluded.document_id`; fremde IDs ablehnen.
### FLAW-12: Alarm-Logik
- **Teilweise behoben:** Fälligkeiten haben eine optionale Uhrzeit; der Alarm kommt zur Uhrzeit (ganztägig 9:00) minus `notify_before_minutes`, in der Zeitzone `TZ` (Standard Europe/Berlin, Zeitzonendaten eingebettet). Neue Fälligkeit oder Wiederöffnen setzt `alarm_sent` zurück. Tests: `internal/domain/due_test.go`, `internal/api/alarm_test.go`, `TestAlarmResetsOnNewDueDateOrReopen`. Offen bleibt der letzte Punkt (kein Backoff).
- **Fundstellen:** `startAlarmTicker`, `GetDueTasksForAlarm`, `UpdateThoughtDetails`, `UserSettingsView`.
- **Probleme:**
- `notify_before_minutes` (Einstellungen: „15 Minuten vorher“ usw.) wird nirgends gelesen. Fälligkeiten haben nur ein Datum (`YYYY-MM-DD`), eine Vorlaufzeit ist damit nicht abbildbar.
@@ -115,7 +120,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
- **Maßnahme:** Fälligkeit mit Uhrzeit speichern, `alarm_sent` bei Änderung von `due_date` zurücksetzen, Wiederholungen begrenzen.
### FLAW-13: Migrationen
- **Teilweise behoben:** Das Schema hat eine Version (`PRAGMA user_version`, `schemaVersion` in `sqlite.go`). Ist eine Datenbank älter, wird sie beim Start **komplett gelöscht** und neu angelegt (vor dem produktiven Einsatz bewusst so gewählt). Die übrigen Punkte unten gelten weiter.
- **Teilweise behoben:** Das Schema hat eine Version (`PRAGMA user_version`, `schemaVersion` in `sqlite.go`), auf die künftige Migrationen aufsetzen können. Die übrigen Punkte unten gelten weiter.
- **Fundstellen:** `Store.migrate` in [`internal/storage/sqlite.go`](../internal/storage/sqlite.go), [`migrations/001_init.sql`](../migrations/001_init.sql).
- **Probleme:**
- Es gibt keine Versionstabelle. Alle `ALTER TABLE`-Anweisungen laufen bei jedem Start erneut; Fehler werden pauschal ignoriert (`_, _ = s.db.Exec(q)`), echte Fehler fallen nicht auf.
@@ -156,6 +161,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
- **Maßnahme:** Tests für kritische Pfade ergänzen, ESLint als echtes `lint`-Skript.
### FLAW-20: Dateigröße und Duplikate
- **Teilweise behoben:** `sqlite.go` und `server.go` sind nach Bereichen in eigene Dateien aufgeteilt (reine Verschiebung, Verhalten unverändert; siehe [architecture.md](architecture.md#verzeichnisse)). Offen: große Frontend-Dateien und die doppelte Zugriffsabfrage.
- `internal/storage/sqlite.go` (≈ 5.600 Zeilen), `internal/api/server.go` (≈ 3.000), `WorkspaceView.tsx` (≈ 2.300), `TasksView.tsx` (≈ 2.500), `api.ts` (≈ 1.500).
- Die Zugriffsabfrage für Aufgabenlisten steht dreimal fast identisch im Code (`accessibleTaskListIDsQuery`, `GetTaskLists`, `GetTaskListByID`). Dokumentzugriff ist ähnlich verteilt. Änderungen an Berechtigungen müssen an mehreren Stellen erfolgen und werden leicht vergessen.
- **Maßnahme:** In Teilmodule zerlegen (Auth, Dokumente, Aufgaben, Listen, Teams), Zugriffsprüfung zentralisieren.
+17 -5
View File
@@ -4,13 +4,13 @@ Code: [TasksView.tsx](../../frontend/src/features/triage/TasksView.tsx), [TaskDe
## Datenmodell
Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus`, `priority` (`low`/`normal`/`high`/`urgent`), `dueDate` (nur Datum), `notes` (Markdown), `subtasks` (`[{id, title, isCompleted}]`), `tags`, `taskListId`, `sortOrder` und `assignees` (Benutzer-IDs). Aufgaben ohne Liste sind persönlich.
Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus`, `priority` (`low`/`normal`/`high`/`urgent`), `dueDate` (`YYYY-MM-DD` oder mit Uhrzeit `YYYY-MM-DDTHH:MM`; die API nimmt auch `YYYY-MM-DD HH:MM` an), `notes` (Markdown), `subtasks` (`[{id, title, isCompleted}]`), `tags`, `taskListId`, `sortOrder` und `assignees` (Benutzer-IDs). Aufgaben ohne Liste sind persönlich.
## Ansichten
- **Liste** mit Filtern (offen, erledigt, Papierkorb), Gruppierung (z. B. nach Status oder Fälligkeit), Sortierung und Hashtag-Filter.
- **Kanban:** Spalten pro Liste frei konfigurierbar (Name, Farbe, „Erledigt“-Spalte). Standard: *Zu erledigen*, *In Bearbeitung*, *Wartet auf andere*, *Erledigt*. Eine Karte in eine „Erledigt“-Spalte zu ziehen setzt `isCompleted`.
- Drag & Drop sortiert in der Liste und zwischen Spalten; ein Klick öffnet das Detail-Fenster (Titel, Status, Priorität, Fälligkeit mit Schnellwahl, Liste, Hashtags, Unteraufgaben mit Fortschrittsbalken, Notizen).
- Drag & Drop sortiert in der Liste und zwischen Spalten; ein Klick öffnet das Detail-Fenster (Titel, Status, Priorität, Fälligkeit mit optionaler Uhrzeit und Schnellwahl, Liste, Hashtags, Unteraufgaben mit Fortschrittsbalken, Notizen).
- Aufgaben lassen sich in eine Notiz umwandeln („Als Notiz vertiefen“). Aufgaben aus Notiz-Checkboxen (`enhanced-task`) bleiben verknüpft: Abhaken wirkt in beide Richtungen (`SyncDocBlockAndTask`), „In Notiz öffnen“ springt zum Block.
## Aufgabenlisten und Freigabe
@@ -27,9 +27,18 @@ Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus
- 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: [FLAW-03](../flaws.md) (Umsortieren per ID).
## Wiederkehrende Aufgaben
- Im Detail-Fenster unter der Fälligkeit: *Keine Wiederholung*, täglich, wöchentlich, monatlich oder jährlich (`thoughts.recurrence`, `PATCH /thoughts/{id}` mit `recurrence`). Zeilen und Kanban-Karten zeigen dann ein 🔁-Symbol.
- **Abhaken erzeugt die nächste Aufgabe** ([recurrence.go](../../internal/storage/recurrence.go)): egal ob im Detail, in der Liste, per Ziehen in „Erledigt“ im Kanban oder über die Checkbox in einer Notiz. Die erledigte Aufgabe bleibt erledigt (Historie); die neue übernimmt Titel, Tags, Priorität, Notizen, Liste, Zuweisungen und die Regel, Unteraufgaben kommen unerledigt mit, sie steht in der ersten offenen Spalte.
- **Fälligkeit:** das bisherige Datum plus ein Intervall (ohne Datum ab heute); eine Uhrzeit bleibt erhalten. Monate und Jahre behalten den Tag oder rutschen auf das Monatsende (31.1. → 28./29.2.).
- Jede Aufgabe erzeugt höchstens eine Nachfolgerin (`recurrence_spawned`): Wieder öffnen und erneut abhaken legt keine zweite an.
- Die neue Aufgabe geht per `TASK_CREATED` an alle Beteiligten, sie erscheint ohne Neuladen.
- Export/Import: im Markdown-Archiv als `🔁 every week` (Format des Obsidian-Tasks-Plugins), im JSON-Backup als Feld; beides wird beim Import wiederhergestellt.
## Alarme
Der Ticker läuft jede Minute ([architecture.md](../architecture.md)). Für offene Aufgaben mit `due_date <= heute` und `alarm_sent = 0` sendet er an die Kanäle des Aufgaben-**Eigentümers**:
Der Ticker läuft jede Minute ([architecture.md](../architecture.md)). Für offene Aufgaben mit `alarm_sent = 0` sendet er, sobald der Alarmzeitpunkt erreicht ist (Uhrzeit der Fälligkeit, ganztägig 9:00, jeweils minus `notifyBeforeMinutes`, Zeitzone `TZ`), an die Kanäle des Aufgaben-**Eigentümers**:
| Kanal | Ziel | Konfiguration |
|---|---|---|
@@ -41,10 +50,13 @@ Danach wird `alarm_sent = 1` gesetzt, sobald mindestens ein Kanal erfolgreich wa
**E-Mail-Versand** ([mail.go](../../internal/api/mail.go)) führt den SMTP-Dialog Schritt für Schritt:
- Verbindung (Port 465: TLS von Anfang an), `EHLO` mit der Domain der Absenderadresse (nie `localhost`, das lehnen strenge Server ab), bei `SMTP_TLS` Pflicht-STARTTLS, `AUTH PLAIN`, `MAIL FROM`, `RCPT TO`, Daten.
- Verbindung (Port 465: TLS von Anfang an), `EHLO` mit der Domain der Absenderadresse (nie `localhost`, das lehnen strenge Server ab), bei `SMTP_TLS` Pflicht-STARTTLS, Anmeldung, `MAIL FROM`, `RCPT TO`, Daten.
- Anmeldung: `AUTH PLAIN`; bietet der Server nur `LOGIN` an, direkt `AUTH LOGIN`. Lehnt er PLAIN ab und bietet auch LOGIN an, baut MindSift die Verbindung neu auf und versucht `AUTH LOGIN` (Go schließt die Verbindung nach einem gescheiterten AUTH). Scheitern beide mit `451`, nennt die Meldung beide Versuche und verweist auf das SASL-Backend des Mailservers (z. B. Dovecot-Auth), denn dann liegt der Fehler dort. Nie unverschlüsselt an fremde Hosts.
- `SMTP_FROM` darf einen Namen enthalten (`MindSift <alarm@domain>`): im Umschlag steht nur die Adresse, im Header Name und Adresse.
- Die Nachricht hat `Date` und `Message-ID`, einen nach RFC 2047 kodierten Betreff (Umlaute, Emoji) und einen Quoted-Printable-Text.
- `SMTP_SKIP_VERIFY` / Schalter „Zertifikatsprüfung deaktivieren“ akzeptiert selbstsignierte Zertifikate (Verbindung bleibt verschlüsselt).
- Fehler nennen den Schritt, an dem der Server abgelehnt hat (z. B. `SMTP-Schritt RCPT TO fehlgeschlagen: 451 …`), im Log als `smtp_step`. Test: `internal/api/mail_test.go` mit einem Fake-Server (STARTTLS, 451).
Einschränkungen ([FLAW-12](../flaws.md)): Fälligkeiten haben keine Uhrzeit, `notifyBeforeMinutes` wird ignoriert, die Serverzeit gilt, und ein verschobener Termin löst nicht erneut aus. URLs werden nicht geprüft ([FLAW-05](../flaws.md)).
Eine neue Fälligkeit oder das Wiederöffnen einer Aufgabe setzt `alarm_sent` zurück, der Alarm kommt dann erneut. Offen ([FLAW-12](../flaws.md)): Schlagen alle Kanäle fehl, wird jede Minute erneut versucht. URLs werden geprüft ([FLAW-05](../flaws.md)).
Die Oberfläche zeigt Fälligkeiten relativ („Heute, 14:30“, „Morgen“, „Fr., 30.10.“); überfällige offene Aufgaben (mit Uhrzeit minutengenau) sind rot. Gruppieren und Sortieren nach Fälligkeit berücksichtigen die Uhrzeit, ganztägige Aufgaben stehen vor denen mit Uhrzeit am selben Tag.
+23 -1
View File
@@ -16,6 +16,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
- **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.
- **Slash-Befehle:** `/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code`, `/callout`, `/divider` (Liste in `OutlinerBlock.tsx`).
- **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.
- **Einfügen:** Mehrzeiliger Text wird anhand von Markdown-Präfixen und Einrückung in Blöcke zerlegt; Überschriften gruppieren darunterliegenden Text und Code.
- **Speichern:** Jede Notiz hat im Browser eine `DocSyncEngine` ([lib/docSync/](../../frontend/src/lib/docSync/)). Der Editor meldet jede Änderung der Block-Liste; die Engine übersetzt sie in Operationen, speichert sie zuerst in IndexedDB und sendet sie dann gesammelt (300 ms) an `POST /docs/{id}/sync`. Beim Öffnen zeigt sie sofort den gespeicherten Stand, auch offline.
@@ -41,6 +42,13 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
- **To-Do-Rollup:** Die Ansicht „To-Dos“ sammelt alle `todo`-Blöcke aller Notizen, filterbar nach Ordner. Abhaken im Rollup ändert den Block in der Notiz.
- **Enhanced Tasks:** Ein `todo`-Block lässt sich als verknüpfte Aufgabe führen (`/documents/{id}/blocks/{blockId}/enhanced-task`); Erledigt-Status bleibt zwischen Notiz und Aufgabenansicht synchron.
## Papierkorb
- Löschen (einzeln oder mit dem Ordner) verschiebt eigene Notizen in den Papierkorb (`is_archived = 1`). Wer eine geteilte Notiz löscht, die ihm nicht gehört, verlässt nur die Freigabe.
- Notizen im Papierkorb fehlen im Baum, in Suche, Export und Statistik und sind für alle Empfänger unsichtbar (`DocRole` = keine Rolle). Eigentümer und Löschende sehen sie schreibgeschützt mit Hinweisleiste und „Wiederherstellen“; Umbenennen, Verschieben und Teilen werden abgelehnt.
- Eintrag „Papierkorb“ mit Zähler unten in der Ordnerleiste und im mobilen Menü. Die Liste zeigt Ordner und Löschzeitpunkt; „Endgültig löschen“ und „Papierkorb leeren“ brauchen einen zweiten Tipp.
- Wiederherstellen legt einen inzwischen gelöschten Ordner neu an; existiert ein geteilter Ordner nicht mehr, landet die Notiz auf der Hauptebene. Freigaben bleiben erhalten. Der Papierkorb leert sich nicht von selbst.
## 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), [FLAW-17](../flaws.md)).
@@ -61,5 +69,19 @@ Kontoeinstellungen → Private Webhooks & Export → „Markdown-Archiv herunter
- Jede Notiz: YAML-Frontmatter (`title`, `icon`, `tags`, `created_at`, `updated_at`) und Markdown, das Obsidian direkt darstellt: Überschriften, Trenner, Hinweisboxen (`> [!note]`) und Codeblöcke der obersten Ebene als echte Blöcke, ihre Kinder rücken eine Ebene nach vorn; alles andere als Liste mit einem Tab pro Ebene, To-dos als `- [ ]`.
- Dateinamen werden für alle Betriebssysteme bereinigt (keine `/ \ : * ? " < > |`, nie `.`/`..`); doppelte Titel bekommen „ (2)“.
- Das ZIP wird beim Lesen der Daten direkt in die Antwort geschrieben, ohne temporäre Dateien.
- Ein Import aus ZIP gibt es noch nicht.
## Import
Kontoeinstellungen → Private Webhooks & Export → „Daten importieren“, mit **zwei getrennten Wegen** (Code: [import_markdown.go](../../internal/storage/import_markdown.go), [ImportCard.tsx](../../frontend/src/features/settings/ImportCard.tsx)):
| Weg | Endpunkt | Dateien | Ergebnis |
|---|---|---|---|
| MindSift-Daten | `POST /import/mindsift` | Backup-ZIP oder JSON (`/export`) oder Markdown-Archiv (`/export/markdown`), das Format wird erkannt | Notizen in ihre Ordner, Aufgabenlisten mit Unteraufgaben, Fälligkeit, Priorität und Notizen, Listen mit Menge, Kategorie und Erledigt-Status, beim JSON-Backup auch die Gedanken der Triage. Fremde, nur mit dem Exporteur geteilte Inhalte im JSON werden übersprungen. |
| Obsidian-Vault | `POST /import/obsidian` | ZIP eines Vaults | Alles in einen neuen Ordner mit dem Namen des Vaults, Unterordner bleiben. Checkboxen bleiben Checkboxen, Frontmatter-Tags werden zu `#Hashtags`, Callouts zu Hinweisboxen, Wikilinks bleiben `[[…]]`. Versteckte Ordner (`.obsidian`, `.trash`) werden ignoriert, Anhänge übersprungen. |
- **Vorschau:** Die Oberfläche lädt die Datei zuerst mit `?preview=1` hoch. Der Server liest und prüft alles und meldet, was angelegt würde (inkl. übersprungener Dateien mit Grund), schreibt aber nichts. Erst „Jetzt importieren“ legt an.
- **Hinzufügen oder Wiederherstellen** (nur MindSift-Daten): „Hinzufügen“ legt nur neu an, Bestehendes bleibt unverändert. „Wiederherstellen“ (`?mode=restore`, in der Oberfläche nur nach bestätigtem Warnhinweis) löscht vorher alle eigenen Notizen, Ordner, Aufgaben, Gedanken und Listen – erst nachdem die Datei vollständig gelesen und geprüft ist, eine kaputte Datei löscht also nichts. Mit dem Nutzer geteilte Inhalte anderer bleiben. Ein Obsidian-Import fügt immer hinzu.
- **Markdown → Notiz:** Listen behalten ihre Verschachtelung (Tabs oder Leerzeichen), Inhalte unter einer Überschrift werden darunter eingerückt (Gegenstück zum Export), Absätze werden zu Punkten, Codeblöcke zu Codeblöcken.
- **Grenzen:** Upload max. 100 MB, eine Notiz max. 10 MB, entpackt zusammen max. 500 MB, höchstens 20 000 Dateien. Pfade mit `..` oder absolute Pfade werden entschärft und bleiben im Zielordner.
- Bricht ein Import wegen eines Fehlers ab, bleibt das bis dahin Angelegte erhalten; die Meldung nennt das Element, an dem es scheiterte.
+2
View File
@@ -82,6 +82,8 @@ Jede Theme-Datei gliedert sich in sechs exakt definierte Bereiche:
| `light` | [`theme-light.css`](../frontend/src/themes/theme-light.css) | Daylight Clean White / Minimalistisch | Modern dezent (`4px` Cards, `4px` Buttons) |
| `high-contrast` | [`theme-high-contrast.css`](../frontend/src/themes/theme-high-contrast.css) | Reines Schwarz/Weiß mit AAA-Kontrast | Streng eckig (`0px`, kräftige Rahmen) |
| `soft` | [`theme-soft.css`](../frontend/src/themes/theme-soft.css) | Warmes Soft Slate mit Indigo-Akzent | Weich gerundet (`12px` Cards, `8px` Buttons) |
| `polarized` | [`theme-polarized.css`](../frontend/src/themes/theme-polarized.css) | Tiefdunkles Obsidian mit elektrischem Ultraviolett | Streng eckig (`0px` Cards, `2px` Buttons) |
| `suri` | [`theme-suri.css`](../frontend/src/themes/theme-suri.css) | Warmes Mitternachts-Pflaume mit samtiger Orchidee | Weich gerundet (`10px` Cards, `6px` Buttons) |
| *Template* | [`theme-template.css`](../frontend/src/themes/theme-template.css) | Vollständig dokumentierte Kopiervorlage | Frei anpassbar |
---
+15
View File
@@ -39,6 +39,14 @@ Komponente: [ToggleSwitch.tsx](../frontend/src/components/ToggleSwitch.tsx)
- Ein/Aus-Einstellungen sind immer Schalter, keine Checkbox-Haken (Einrichtung, Admin-Bereich).
- Text links (Titel, darunter eine kurze Erklärung in `text-muted`), Schalter rechts.
## Kleine Dialoge (Anlegen, Umbenennen)
Komponente: [FormSheet.tsx](../frontend/src/components/FormSheet.tsx), Tastatur-Abstand über [useKeyboardInset.ts](../frontend/src/lib/useKeyboardInset.ts)
- **Handy:** Bottom-Sheet über die volle Breite, das direkt über der Bildschirmtastatur sitzt (der Abstand kommt aus `visualViewport`), Griff oben, Felder und Buttons mindestens 44 px hoch, Eingaben in 16 px (sonst zoomt iOS), Abbrechen/Erstellen nebeneinander über die ganze Breite.
- **Desktop:** zentriertes Fenster, oder mit `anchor` neben der Klickposition (z. B. „Neuer Ordner“ im Explorer).
- Escape und Klick auf den Hintergrund schließen. Genutzt für neue Aufgabenliste, neue Einkaufsliste und neuen Ordner; das Emoji-Sheet nutzt denselben Tastatur-Abstand.
## Zähler
Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Klassen `count-badge*` in [index.css](../frontend/src/index.css). Es gibt keine anderen Zähler-Stile mehr.
@@ -53,3 +61,10 @@ Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Kla
- `inline`: im Textfluss.
- Zahlen im mobilen Menü (`MobileActionContext`, `badge: number`) werden automatisch als `CountBadge` gezeigt; Text-Badges wie „2 aktiv“ bleiben Textmarken.
- **Wichtig für neue Klassen:** Tailwind entfernt Klassen aus `@layer components`, die nicht wörtlich im Code stehen. Klassennamen deshalb nie zusammensetzen (`count-badge-${variant}`), sondern ausschreiben.
## Ladebildschirm & Statusleiste
- `manifest.json`: `name` „MindSift“ (kurz, der Slogan steht in `description`), `background_color` und `theme_color` `#2563eb` – Android baut daraus den Splash-Screen.
- `index.html` enthält in `#root` einen reinen HTML-Platzhalter (vollflächig Blau, Logo in der Mitte, kein Text). React ersetzt ihn beim ersten Rendern, dazwischen blitzt nichts Weißes auf.
- `meta theme-color` startet blau und wird von [`theme.ts`](../frontend/src/lib/theme.ts) nach dem Start und bei jedem Theme-Wechsel auf `--bg-app` des aktiven Themes gesetzt, damit die Statusleiste zum Header passt.
+8 -7
View File
@@ -6,7 +6,7 @@
[![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#)
[![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart)
[![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir)
[![Version](https://img.shields.io/badge/Version-v0.3-16a34a?style=flat-square)](#-schnellstart)
[![Version](https://img.shields.io/badge/Version-v0.4-16a34a?style=flat-square)](#-schnellstart)
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
@@ -92,7 +92,7 @@ Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md)
* Geführte Ersteinrichtung, Rollen *Admin* und *Mitglied*, Einladungscodes, optional offene Registrierung.
* Gruppen mit Gruppen-Admins für Freigaben.
* Admin-Bereich: Benutzer, Einladungen, Instanzname, SMTP, Freigabe-Modus.
* Vier Themes (Dark, Light, High-Contrast, Soft), installierbar als PWA.
* 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.
### 🔌 Integrationen
@@ -128,7 +128,7 @@ Minimal:
```yaml
services:
mindsift:
image: git.dsit.me/sandrodach/mindsift:stable # oder feste Version (:v0.3); :latest = main, :dev = Entwicklungsstand
image: git.dsit.me/sandrodach/mindsift:stable # oder feste Version (:v0.4); :latest = main, :dev = Entwicklungsstand
restart: unless-stopped
ports: ["8080:8080"]
volumes: ["./data:/data"]
@@ -141,7 +141,7 @@ services:
Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch das Anlegen des Administrator-Kontos.
> ⚠️ **Setze `JWT_SECRET`.** Ohne Angabe nutzt der Server einen öffentlich bekannten Standardwert, mit dem sich Anmelde-Tokens fälschen lassen. Rufe die Instanz außerdem direkt nach dem Start auf, damit kein Fremder das erste Konto anlegt.
> ⚠️ **`JWT_SECRET`:** Ohne Angabe (oder mit einem der Platzhalter aus den Beispielen) erzeugt der Server beim ersten Start einen Zufallswert und speichert ihn als `jwt.secret` im Datenverzeichnis. Für Produktion lieber selbst einen langen Zufallswert setzen. Rufe die Instanz außerdem direkt nach dem Start auf, damit kein Fremder das erste Konto anlegt.
---
@@ -151,8 +151,9 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d
|---|---|---|
| `PORT` | `8080` | HTTP-Port |
| `DATA_DIR` | `/data` (Container) | Ablage der Datenbank `mindsift.db` |
| `JWT_SECRET` | unsicherer Standard | **Pflicht in Produktion**, Signaturschlüssel |
| `BASE_URL` | `http://localhost:8080` | Öffentliche URL |
| `JWT_SECRET` | zufällig erzeugt (`/data/jwt.secret`) | Signaturschlüssel der Anmelde-Tokens; Platzhalter werden ignoriert |
| `BASE_URL` | `http://localhost:8080` | Öffentliche URL. Nur wenn gesetzt (und SMTP eingerichtet ist), gibt es „Passwort vergessen?“ – die Links in den Mails werden ausschließlich daraus gebaut |
| `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme (ganztägige Aufgaben melden sich um 9:00) |
| `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) |
| `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) |
@@ -185,7 +186,7 @@ npm run test:run # Komponententests (Vitest, jsdom)
npm run build # Produktions-Build
cd ..
e2e/run.sh # Browser-Tests (Playwright, zwei Benutzer) gegen ein frisch gebautes Image
APP_VERSION=v0.3 e2e/screenshots.sh # Screenshots dieser README neu erzeugen (Demo-Daten)
APP_VERSION=v0.4 e2e/screenshots.sh # Screenshots dieser README neu erzeugen (Demo-Daten)
```
Für das Binary wird das gebaute Frontend nach `cmd/server/dist` kopiert (`go:embed`); das mehrstufige `Dockerfile` erledigt das und führt Typprüfung, Frontend-Tests, Go-Vet und Go-Tests beim Image-Build aus.
+7
View File
@@ -11,6 +11,7 @@ import (
"os/signal"
"syscall"
"time"
_ "time/tzdata" // time zones without system tzdata (scratch/alpine images)
"github.com/sandrodach/mindsift/internal/api"
"github.com/sandrodach/mindsift/internal/config"
@@ -22,6 +23,12 @@ import (
var embeddedDist embed.FS
func main() {
// Due dates and alarms use the server's local time; without TZ that is Europe/Berlin
if os.Getenv("TZ") == "" {
if loc, err := time.LoadLocation("Europe/Berlin"); err == nil {
time.Local = loc
}
}
cfg := config.Load()
level := cfg.SlogLevel()
+9 -2
View File
@@ -3,7 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#0b0f19" />
<!-- Blue while loading (matches the splash screen); the app switches it to the theme background -->
<meta name="theme-color" content="#2563eb" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="MindSift" />
@@ -49,7 +50,13 @@
</script>
</head>
<body class="bg-[#0b0f19] text-[#e3e3e3] antialiased selection:bg-[#8ab4f8]/30 selection:text-[#8ab4f8]">
<div id="root"></div>
<!-- Shown until React mounts and replaces it: full blue with the logo, no text, no white flash -->
<div id="root">
<div style="position:fixed;inset:0;background:#2563eb;display:flex;align-items:center;justify-content:center">
<!-- the icon's own blue square disappears on blue, so the visible brain is ~55% of this size -->
<img src="/icon.svg" width="128" height="128" alt="" style="display:block" />
</div>
</div>
<script type="module" src="/src/main.tsx"></script>
<script>
if ('caches' in window) {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mindsift-frontend",
"version": "0.3.0",
"version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mindsift-frontend",
"version": "0.3.0",
"version": "0.4.0",
"dependencies": {
"@types/prismjs": "^1.26.6",
"emoji-picker-react": "^4.12.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mindsift-frontend",
"private": true,
"version": "0.3.0",
"version": "0.4.0",
"license": "Unlicense",
"type": "module",
"scripts": {
+4 -4
View File
@@ -1,13 +1,13 @@
{
"id": "/",
"name": "MindSift — Der Kopf frei in drei Sekunden",
"name": "MindSift",
"short_name": "MindSift",
"description": "Instant Brain-Dump, Tinder-Style Triage, Workspace & Echtzeit-Familienlisten.",
"description": "MindSift — Der Kopf frei in drei Sekunden. Instant Brain-Dump, Tinder-Style Triage, Workspace & Echtzeit-Familienlisten.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#0b0f19",
"theme_color": "#0b0f19",
"background_color": "#2563eb",
"theme_color": "#2563eb",
"orientation": "portrait-primary",
"icons": [
{
+85 -7
View File
@@ -1,18 +1,96 @@
// Network-first / cache-clearing service worker to ensure fresh builds across all browsers
self.addEventListener('install', () => {
self.skipWaiting();
// MindSift service worker: keeps the app shell so the PWA also starts without network.
// - Page loads (navigation): network first, so a new release is picked up right away; offline the
// last loaded index.html is used.
// - Built bundles (/assets/*, file names contain a content hash): from the cache, loaded once.
// - Icons and manifest: from the cache, refreshed in the background.
// - API requests are never touched; offline data comes from the app's own cache (lib/offline.ts).
// Raise VERSION when this file changes in a way that needs fresh caches.
const VERSION = 'v2';
const SHELL_CACHE = `mindsift-shell-${VERSION}`;
const ASSET_CACHE = `mindsift-assets-${VERSION}`;
const MAX_ASSETS = 80;
const SHELL_URLS = [
'/',
'/index.html',
'/manifest.json',
'/favicon.ico',
'/icon.svg',
'/icon-192x192.png',
'/icon-512x512.png',
'/apple-touch-icon.png',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches
.open(SHELL_CACHE)
.then((cache) => Promise.all(SHELL_URLS.map((url) => cache.add(new Request(url, { cache: 'reload' })).catch(() => {}))))
.then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.map((k) => caches.delete(k))))
.then((keys) => Promise.all(keys.filter((k) => k !== SHELL_CACHE && k !== ASSET_CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
);
});
self.addEventListener('fetch', () => {
// Always use native browser network fetch so API calls and fresh Vite bundles are never intercepted by stale caches
return;
// Old bundles pile up with every release; keep only the newest entries
async function trimAssets() {
const cache = await caches.open(ASSET_CACHE);
const keys = await cache.keys();
await Promise.all(keys.slice(0, Math.max(0, keys.length - MAX_ASSETS)).map((k) => cache.delete(k)));
}
async function networkFirstPage(request) {
const cache = await caches.open(SHELL_CACHE);
try {
const response = await fetch(request, { cache: 'no-store' });
if (response.ok && response.type === 'basic') cache.put('/index.html', response.clone());
return response;
} catch {
return (await cache.match('/index.html')) || (await cache.match('/')) || Response.error();
}
}
async function cacheFirstAsset(request) {
const cache = await caches.open(ASSET_CACHE);
const hit = await cache.match(request);
if (hit) return hit;
const response = await fetch(request);
if (response.ok && response.type === 'basic') {
await cache.put(request, response.clone());
trimAssets();
}
return response;
}
async function staleWhileRevalidate(request) {
const cache = await caches.open(SHELL_CACHE);
const hit = await cache.match(request);
const refresh = fetch(request)
.then((response) => {
if (response.ok && response.type === 'basic') cache.put(request, response.clone());
return response;
})
.catch(() => hit || Response.error());
return hit || refresh;
}
self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') return;
const url = new URL(request.url);
if (url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return;
if (request.mode === 'navigate') {
event.respondWith(networkFirstPage(request));
} else if (url.pathname.startsWith('/assets/')) {
event.respondWith(cacheFirstAsset(request));
} else if (SHELL_URLS.includes(url.pathname)) {
event.respondWith(staleWhileRevalidate(request));
}
});
+29 -4
View File
@@ -50,6 +50,8 @@ import { TasksView } from './features/triage/TasksView';
import { WorkspaceView } from './features/workspace/WorkspaceView';
import { SharedListsView } from './features/lists/SharedListsView';
import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
import { OfflineBanner } from './components/OfflineBanner';
import { outboxCount } from './lib/offline';
import { UserSettingsView } from './features/settings/UserSettingsView';
import { AdminSettingsView } from './features/settings/AdminSettingsView';
import { HelpView } from './features/help/HelpView';
@@ -120,6 +122,17 @@ export const App: React.FC = () => {
instanceName: 'MindSift',
});
const [user, setUser] = useState<User | null>(null);
// Link from the password reset mail (?resetToken=…); removed from the address bar right away
const [resetToken, setResetToken] = useState<string | null>(() => {
try {
return new URLSearchParams(window.location.search).get('resetToken');
} catch {
return null;
}
});
useEffect(() => {
if (resetToken) window.history.replaceState(null, '', window.location.pathname);
}, [resetToken]);
const [customThemes, setCustomThemes] = useState<UserTheme[]>([]);
const allThemes = useMemo(() => [...AVAILABLE_THEMES, ...customThemes.map(customThemeOption)], [customThemes]);
const [stats, setStats] = useState<DashboardStats>({
@@ -514,9 +527,15 @@ export const App: React.FC = () => {
}
};
// Changes still waiting (server was unreachable although the device was online) are retried
const retry = window.setInterval(() => {
if (navigator.onLine && outboxCount() > 0) onOnline();
}, 30000);
window.addEventListener('mindsift:unauthorized', onUnauthorized);
window.addEventListener('online', onOnline);
return () => {
window.clearInterval(retry);
window.removeEventListener('mindsift:unauthorized', onUnauthorized);
window.removeEventListener('online', onOnline);
};
@@ -666,11 +685,19 @@ export const App: React.FC = () => {
}
// Show Setup Wizard or Login Screen if needsSetup or not logged in
if (authStatus.needsSetup || !user) {
if (authStatus.needsSetup || !user || resetToken) {
return (
<SetupAndAuthView
authStatus={authStatus}
onAuthenticated={handleAuthenticated}
resetToken={resetToken}
onResetDone={() => {
// The new password ended every session, this one included
setResetToken(null);
clearToken();
setUser(null);
}}
onResetCancel={() => setResetToken(null)}
/>
);
}
@@ -716,9 +743,6 @@ export const App: React.FC = () => {
</span>
<span className="text-[9px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
</div>
<span className="text-[11px] font-semibold text-accent bg-accent/15 px-1.5 py-0.5 rounded-md">
{activeTabLabelMap[activeTab]}
</span>
<ChevronDown
className={`w-4 h-4 text-muted transition-transform duration-150 ${
isMenuOpen ? 'rotate-180 text-accent' : ''
@@ -1027,6 +1051,7 @@ export const App: React.FC = () => {
</header>
<MobileHeaderMenu activeTabLabel={activeTabLabelMap[activeTab]} />
<OfflineBanner />
{/* Main Container */}
<main className="flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 sm:pb-6">
+89
View File
@@ -0,0 +1,89 @@
import React, { useEffect } from 'react';
import { X } from 'lucide-react';
import { useKeyboardInset } from '../lib/useKeyboardInset';
interface FormSheetProps {
title: string;
icon?: React.ReactNode;
onClose: () => void;
onSubmit: (e: React.FormEvent) => void;
children: React.ReactNode; // the fields
submitLabel?: string;
danger?: boolean; // red submit button for destructive actions
busy?: boolean;
// Desktop only: open next to the click instead of centred
anchor?: { x: number; y: number } | null;
}
// Small create/rename dialogs. Phone: bottom sheet directly above the keyboard, full width, large
// fields. Desktop: centred dialog (or next to the click). Escape and the backdrop close it.
export const FormSheet: React.FC<FormSheetProps> = ({
title,
icon,
onClose,
onSubmit,
children,
submitLabel = 'Erstellen',
anchor,
danger = false,
busy = false,
}) => {
const { inset, visibleHeight } = useKeyboardInset();
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
const desktopAnchor = anchor
? ({ '--sheet-left': `${anchor.x}px`, '--sheet-top': `${anchor.y}px` } as React.CSSProperties)
: undefined;
return (
<div className="fixed inset-0 !m-0 z-50 modal-overlay" onClick={onClose}>
<form
onSubmit={onSubmit}
onClick={(e) => e.stopPropagation()}
style={
{
...desktopAnchor,
'--kb-inset': `${inset}px`,
'--vv-max': visibleHeight ? `${visibleHeight - 16}px` : '85vh',
} as React.CSSProperties
}
className={`fixed inset-x-0 bottom-[var(--kb-inset)] max-h-[var(--vv-max)] panel-surface rounded-t-2xl border-t border-border shadow-panel p-4 space-y-3 overflow-y-auto
sm:inset-x-auto sm:bottom-auto sm:max-h-[85vh] sm:w-full sm:rounded-card sm:border ${
anchor
? 'sm:max-w-[16.5rem] sm:left-[var(--sheet-left)] sm:top-[var(--sheet-top)]'
: 'sm:max-w-sm sm:left-1/2 sm:top-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2'
}`}
>
<div className="w-10 h-1 bg-border rounded-full mx-auto sm:hidden" aria-hidden="true" />
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-main flex items-center gap-1.5 min-w-0">
{icon}
<span className="truncate">{title}</span>
</h3>
<button type="button" onClick={onClose} className="w-11 h-11 sm:w-7 sm:h-7 -mr-2 sm:mr-0 flex items-center justify-center text-muted hover:text-main" title="Schließen">
<X className="w-4 h-4" />
</button>
</div>
{children}
<div className="flex gap-2 pt-1 sm:justify-end">
<button type="button" onClick={onClose} className="btn-action flex-1 sm:flex-none h-11 sm:h-8 px-3 text-sm sm:text-xs">
Abbrechen
</button>
<button
type="submit"
disabled={busy}
className={`${danger ? 'btn-danger' : 'btn-primary'} flex-1 sm:flex-none h-11 sm:h-8 px-4 text-sm sm:text-xs font-semibold disabled:opacity-50`}
>
{submitLabel}
</button>
</div>
{/* keeps the buttons clear of the home indicator when no keyboard is open */}
<div className="sm:hidden" style={{ height: inset ? 0 : 'env(safe-area-inset-bottom, 0px)' }} />
</form>
</div>
);
};
+50
View File
@@ -0,0 +1,50 @@
import React, { useEffect, useState } from 'react';
import { CloudOff, RefreshCw } from 'lucide-react';
import { outboxCount } from '../lib/offline';
// Small notice above the bottom navigation: offline (with the number of changes waiting), or
// "synchronisiere" while queued changes are being sent after the connection came back.
export const OfflineBanner: React.FC = () => {
const [online, setOnline] = useState(() => (typeof navigator === 'undefined' ? true : navigator.onLine));
const [pending, setPending] = useState(() => outboxCount());
useEffect(() => {
const update = () => {
setOnline(navigator.onLine);
setPending(outboxCount());
};
window.addEventListener('online', update);
window.addEventListener('offline', update);
window.addEventListener('mindsift:outbox-changed', update);
window.addEventListener('storage', update);
return () => {
window.removeEventListener('online', update);
window.removeEventListener('offline', update);
window.removeEventListener('mindsift:outbox-changed', update);
window.removeEventListener('storage', update);
};
}, []);
if (online && pending === 0) return null;
const waiting = `${pending} ${pending === 1 ? 'Änderung wartet' : 'Änderungen warten'}`;
return (
<div
role="status"
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] sm:bottom-4 z-[60] max-w-[calc(100vw-1.5rem)] px-3 py-1.5 rounded-full border text-[11px] font-medium shadow-panel flex items-center gap-1.5 bg-panel-elevated border-status-warn/40 text-main"
>
{online ? (
<RefreshCw className="w-3.5 h-3.5 text-accent animate-spin shrink-0" />
) : (
<CloudOff className="w-3.5 h-3.5 text-status-warn shrink-0" />
)}
<span className="truncate">
{online
? `Synchronisiere – ${waiting}…`
: pending > 0
? `Offline · ${waiting} auf Verbindung`
: 'Offline – Änderungen werden bei Verbindung synchronisiert'}
</span>
</div>
);
};
+171 -4
View File
@@ -19,11 +19,17 @@ import { LegalLinks } from '../../components/LegalLinks';
interface SetupAndAuthViewProps {
authStatus: AuthStatus;
onAuthenticated: (user: User) => void;
resetToken?: string | null;
onResetDone?: () => void;
onResetCancel?: () => void;
}
export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
authStatus,
onAuthenticated,
resetToken,
onResetDone,
onResetCancel,
}) => {
// --- Setup Wizard State ---
const [setupName, setSetupName] = useState('');
@@ -36,7 +42,9 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
const [createSampleData, setCreateSampleData] = useState(false);
// --- Login / Invite / Register State ---
const [authTab, setAuthTab] = useState<'login' | 'invite' | 'register'>('login');
const [authTab, setAuthTab] = useState<'login' | 'invite' | 'register' | 'forgot' | 'reset'>(
resetToken ? 'reset' : 'login'
);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState('');
@@ -44,6 +52,48 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [newPassword, setNewPassword] = useState('');
const [newPasswordConfirm, setNewPasswordConfirm] = useState('');
const handleForgotPassword = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
await api.requestPasswordReset(email.trim());
setNotice('Falls ein Konto mit dieser E-Mail-Adresse existiert, haben wir dir einen Link zum Zurücksetzen geschickt. Er gilt eine Stunde.');
setAuthTab('login');
} catch (err: any) {
setError(err.message || 'Anfrage fehlgeschlagen');
} finally {
setSubmitting(false);
}
};
const handleResetPassword = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (newPassword !== newPasswordConfirm) {
setError('Die eingegebenen Passwörter stimmen nicht überein.');
triggerHaptic(25);
return;
}
setSubmitting(true);
try {
await api.resetPassword(resetToken || '', newPassword);
setNewPassword('');
setNewPasswordConfirm('');
setPassword('');
setNotice('Dein Passwort wurde geändert. Melde dich jetzt mit dem neuen Passwort an.');
setAuthTab('login');
onResetDone?.();
} catch (err: any) {
setError(err.message || 'Passwort konnte nicht geändert werden');
} finally {
setSubmitting(false);
}
};
const handleCompleteSetup = async (e: React.FormEvent) => {
e.preventDefault();
@@ -355,13 +405,14 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
<Lock className="w-4 h-4 text-faint" />
</div>
{/* Navigation Tabs */}
<div className="flex border-b border-border bg-canvas text-xs">
{/* Navigation Tabs (not while choosing a new password) */}
<div className={`${authTab === 'reset' || authTab === 'forgot' ? 'hidden' : 'flex'} border-b border-border bg-canvas text-xs`}>
<button
type="button"
onClick={() => {
setAuthTab('login');
setError(null);
setNotice(null);
}}
className={`flex-1 h-9 px-3 font-medium flex items-center justify-center gap-1.5 border-r border-border transition-colors ${
authTab === 'login'
@@ -416,6 +467,107 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
<span>{error}</span>
</div>
)}
{notice && !error && (
<div role="status" className="mb-4 p-2.5 rounded-card border border-status-ok/40 bg-status-ok/10 text-main flex items-start gap-2">
<Check className="w-4 h-4 text-status-ok flex-shrink-0 mt-px" />
<span>{notice}</span>
</div>
)}
{authTab === 'forgot' && (
<form onSubmit={handleForgotPassword} className="space-y-3.5">
<div>
<h2 className="text-sm font-bold text-main">Passwort vergessen?</h2>
<p className="text-muted mt-1">
Gib die E-Mail-Adresse deines Kontos ein. Wir schicken dir einen Link, mit dem du ein neues Passwort festlegst.
</p>
</div>
<div>
<label className="block text-muted mb-1">E-Mail-Adresse</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@beispiel.de"
required
autoFocus
autoComplete="email"
className="w-full h-11 sm:h-8 px-2.5 input-field text-base sm:text-xs"
/>
</div>
<div className="flex flex-col-reverse sm:flex-row gap-2 pt-1">
<button
type="button"
onClick={() => {
setAuthTab('login');
setError(null);
}}
className="btn-action h-11 sm:h-9 px-4 text-xs"
>
Zurück zur Anmeldung
</button>
<button
type="submit"
disabled={submitting}
className="flex-1 btn-primary h-11 sm:h-9 text-xs font-semibold disabled:opacity-50"
>
{submitting ? 'Sende…' : 'Link anfordern'}
</button>
</div>
</form>
)}
{authTab === 'reset' && (
<form onSubmit={handleResetPassword} className="space-y-3.5">
<div>
<h2 className="text-sm font-bold text-main">Neues Passwort festlegen</h2>
<p className="text-muted mt-1">Danach bist du überall abgemeldet und meldest dich mit dem neuen Passwort an.</p>
</div>
<div>
<label className="block text-muted mb-1">Neues Passwort</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
minLength={4}
required
autoFocus
autoComplete="new-password"
className="w-full h-11 sm:h-8 px-2.5 input-field text-base sm:text-xs"
/>
</div>
<div>
<label className="block text-muted mb-1">Passwort wiederholen</label>
<input
type="password"
value={newPasswordConfirm}
onChange={(e) => setNewPasswordConfirm(e.target.value)}
minLength={4}
required
autoComplete="new-password"
className="w-full h-11 sm:h-8 px-2.5 input-field text-base sm:text-xs"
/>
</div>
<button
type="submit"
disabled={submitting}
className="w-full btn-primary h-11 sm:h-9 text-xs font-semibold disabled:opacity-50"
>
{submitting ? 'Speichere…' : 'Passwort speichern'}
</button>
<button
type="button"
onClick={() => {
setError(null);
setAuthTab(authStatus.passwordReset ? 'forgot' : 'login');
onResetCancel?.();
}}
className="w-full btn-action h-11 sm:h-9 text-xs"
>
{authStatus.passwordReset ? 'Neuen Link anfordern' : 'Zur Anmeldung'}
</button>
</form>
)}
{authTab === 'login' && (
<form onSubmit={handleLogin} className="space-y-3.5">
@@ -464,7 +616,22 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
</div>
<div>
<label className="block text-muted mb-1">Passwort</label>
<div className="flex items-baseline justify-between mb-1">
<label className="block text-muted">Passwort</label>
{authStatus.passwordReset && (
<button
type="button"
onClick={() => {
setAuthTab('forgot');
setError(null);
setNotice(null);
}}
className="text-accent hover:underline text-[11px] py-1"
>
Passwort vergessen?
</button>
)}
</div>
<input
type="password"
value={password}
+19 -1
View File
@@ -240,6 +240,12 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
<li>
<strong className="text-main">Ansicht „Tags & Filter“:</strong> Im oberen Workspace-Reiter findest du die Tag-Übersicht. Hier siehst du alle verwendeten Hashtags, kannst Fundstellen aus allen Dokumenten gebündelt durchsuchen und sogar eigene freie Notizen auf Tag-Seiten hinterlegen.
</li>
<li>
<strong className="text-main">Rückverweise:</strong> Am Ende jeder Notiz siehst du unter „Verlinkt auf diese Notiz“, welche anderen Notizen sie mit <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">[[Titel]]</code> verlinken. Ein Klick auf die Fundstelle springt direkt dorthin.
</li>
<li>
<strong className="text-main">Papierkorb:</strong> Gelöschte Notizen (auch aus gelöschten Ordnern) landen im „Papierkorb“ unten in der Ordnerleiste bzw. im mobilen Menü. Dort kannst du sie schreibgeschützt ansehen, wiederherstellen oder endgültig löschen. Solange eine Notiz im Papierkorb liegt, sehen sie auch die Personen nicht, mit denen sie geteilt war.
</li>
</ul>
</div>
),
@@ -559,7 +565,13 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
<strong className="text-main">Zuweisen:</strong> In geteilten Aufgabenlisten weist du eine Aufgabe einer oder mehreren Personen mit Zugriff zu (Namen tippen, Enter). Die Zugewiesenen erscheinen als kleine Profilbilder an der Aufgabe; wer den Zugriff auf die Liste verliert, wird automatisch entfernt.
</li>
<li>
<strong className="text-main">Automatische Alarme:</strong> Ist ein Fälligkeitsdatum erreicht, kann MindSift dich automatisch per <strong className="text-main">E-Mail</strong>, <strong className="text-main">Discord-Webhook</strong> oder individuellem HTTP-Webhook benachrichtigen. Konfigurierbar in deinen Benutzereinstellungen.
<strong className="text-main">Wiederkehrende Aufgaben:</strong> Im Detail-Fenster eine Wiederholung wählen (täglich bis jährlich). Beim Abhaken entsteht automatisch die nächste Aufgabe mit fortgeschriebenem Datum; erkennbar am 🔁-Symbol.
</li>
<li>
<strong className="text-main">Fälligkeit mit Uhrzeit:</strong> Im Detail-Fenster neben dem Datum optional eine Uhrzeit setzen; „✕ Uhrzeit“ macht die Aufgabe wieder ganztägig. Angezeigt wird z.B. „Heute, 14:30“, Überfälliges in Rot.
</li>
<li>
<strong className="text-main">Automatische Alarme:</strong> Zur Fälligkeit (ganztägige Aufgaben um 9:00, abzüglich deiner Vorlaufzeit) benachrichtigt MindSift dich per <strong className="text-main">E-Mail</strong>, <strong className="text-main">Discord-Webhook</strong> oder individuellem HTTP-Webhook. Verschiebst du die Fälligkeit oder öffnest die Aufgabe wieder, kommt der Alarm erneut. Konfigurierbar in deinen Benutzereinstellungen.
</li>
</ul>
</div>
@@ -620,6 +632,12 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
<li>
<strong className="text-main">Eigenes Theme:</strong> Vorlage herunterladen oder einfügen, Farben und Rundungen anpassen, als <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">.css</code>-Datei hochladen oder einfügen und „Speichern &amp; aktivieren“. Das Theme greift sofort und steht dir auf allen Geräten zur Verfügung.
</li>
<li>
<strong className="text-main">Ohne Internet:</strong> Als App installiert (oder im Browser) startet MindSift auch offline mit dem zuletzt geladenen Stand. Gedanken erfassen sowie Aufgaben und Listeneinträge abhaken geht weiter; ein Hinweis unten zeigt, wie viele Änderungen warten, und sie werden automatisch übertragen, sobald die Verbindung zurück ist.
</li>
<li>
<strong className="text-main">Passwort vergessen:</strong> Hat die Administration den Mailversand eingerichtet, steht im Anmeldefenster „Passwort vergessen?“. Du bekommst einen Link, der eine Stunde gilt; mit dem neuen Passwort bist du auf allen Geräten abgemeldet.
</li>
</ul>
</div>
),
+29 -54
View File
@@ -15,6 +15,7 @@ import { api, buildWebSocketURL, triggerHaptic, createRealtimeConnection, Realti
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
import { CountBadge } from '../../components/CountBadge';
import { FormSheet } from '../../components/FormSheet';
import { useMobileActions } from '../../context/MobileActionContext';
interface SharedListsViewProps {
@@ -1017,61 +1018,35 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</div>
{showNewListModal && (
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-4">
<div className="w-full max-w-sm panel-surface shadow-panel p-4 space-y-3">
<div className="flex items-center justify-between border-b border-border pb-2">
<h3 className="text-sm font-semibold text-main flex items-center gap-1.5">
<ShoppingCart className="w-4 h-4 text-accent" />
<span>Neue Einkaufsliste erstellen</span>
</h3>
<button
type="button"
onClick={() => setShowNewListModal(false)}
className="text-muted hover:text-main"
>
<X className="w-4 h-4" />
</button>
<FormSheet
title="Neue Einkaufsliste erstellen"
icon={<ShoppingCart className="w-4 h-4 text-accent" />}
onClose={() => setShowNewListModal(false)}
onSubmit={handleCreateList}
>
<div>
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
<div className="flex items-center gap-1.5">
<input
type="text"
value={newListIcon}
onChange={(e) => setNewListIcon(e.target.value)}
maxLength={2}
className="w-12 sm:w-10 h-11 sm:h-8 text-center input-field text-base sm:text-sm"
title="Icon (Emoji)"
/>
<input
type="text"
value={newListTitle}
onChange={(e) => setNewListTitle(e.target.value)}
placeholder="z.B. Supermarkt, Baumarkt, Drogerie"
autoFocus
required
className="flex-1 min-w-0 h-11 sm:h-8 input-field px-2.5 text-base sm:text-xs"
/>
</div>
</div>
<form onSubmit={handleCreateList} className="space-y-3">
<div>
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
<div className="flex items-center gap-1.5">
<input
type="text"
value={newListIcon}
onChange={(e) => setNewListIcon(e.target.value)}
maxLength={2}
className="w-10 h-8 text-center input-field text-sm"
title="Icon (Emoji)"
/>
<input
type="text"
value={newListTitle}
onChange={(e) => setNewListTitle(e.target.value)}
placeholder="z.B. Supermarkt, Baumarkt, Drogerie"
autoFocus
required
className="flex-1 h-8 input-field px-2.5 text-xs"
/>
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
<button
type="button"
onClick={() => setShowNewListModal(false)}
className="btn-action h-7 px-3 text-xs"
>
Abbrechen
</button>
<button type="submit" className="btn-primary h-7 px-3 text-xs">
Erstellen
</button>
</div>
</form>
</div>
</div>
</FormSheet>
)}
{/* Active Shared List View */}
@@ -96,7 +96,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
allowRegistration: false,
instanceName: 'MindSift',
smtpHost: '',
smtpPort: 587,
smtpPort: '587',
smtpUser: '',
smtpPass: '',
smtpFrom: '',
@@ -126,12 +126,13 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
allowRegistration: Boolean(sys.allowRegistration),
instanceName: sys.instanceName || 'MindSift',
smtpHost: sys.smtpHost || '',
smtpPort: sys.smtpPort || 587,
smtpPort: sys.smtpPort ? String(sys.smtpPort) : '587',
smtpUser: sys.smtpUser || '',
smtpPass: sys.smtpPass || '',
smtpFrom: sys.smtpFrom || '',
smtpTls: sys.smtpTls ?? true,
smtpSkipVerify: Boolean(sys.smtpSkipVerify),
allowPrivateWebhooks: Boolean(sys.allowPrivateWebhooks),
smtpConfiguredViaEnv: Boolean(sys.smtpConfiguredViaEnv),
});
}
@@ -1081,6 +1082,22 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
)}
</div>
{/* Alarm webhooks into the local network (SSRF protection) */}
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
<div className="pr-4">
<p className="font-bold text-main">Alarm-Webhooks ins lokale Netz erlauben</p>
<p className="text-[11px] text-muted mt-0.5">
Standard: Discord- und Webhook-Alarme gehen nur an öffentliche https-Adressen. Eingeschaltet sind auch Ziele
im Heimnetz erlaubt (z. B. Home Assistant unter 192.168.x.x oder über Tailscale), dort auch per http.
Der Server selbst (localhost) und Cloud-Metadaten bleiben immer gesperrt.
</p>
</div>
<ToggleSwitch
checked={Boolean(settings.allowPrivateWebhooks)}
onChange={(allowPrivateWebhooks) => handleSaveSystemSettings({ allowPrivateWebhooks })}
/>
</div>
{/* Public Registration Toggle Switch */}
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
<div className="pr-4">
@@ -1150,10 +1167,12 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
SMTP Port (`SMTP_PORT`)
</label>
<input
type="number"
value={settings.smtpPort || 587}
type="text"
inputMode="numeric"
value={settings.smtpPort ?? ''}
onChange={(e) =>
setSettings((prev) => ({ ...prev, smtpPort: Number(e.target.value) }))
// Digits only; an empty field stays empty while typing (saved as 587)
setSettings((prev) => ({ ...prev, smtpPort: e.target.value.replace(/\D/g, '').slice(0, 5) }))
}
placeholder="587"
className="w-full h-9 px-3 input-field font-mono"
@@ -115,11 +115,11 @@ export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="Bezeichnung (z. B. Home Assistant, Proxmox Backup, Rauchmelder)…"
className="flex-1 h-9 px-3 input-field text-sm"
className="w-full sm:flex-1 min-w-0 h-11 sm:h-9 px-3 input-field text-base sm:text-sm"
/>
<button
type="submit"
className="btn-primary h-9 px-4 text-xs sm:text-sm"
className="btn-primary h-11 sm:h-9 px-4 text-sm flex items-center justify-center gap-1.5"
>
<Plus className="w-4 h-4" />
<span>API-Key erzeugen</span>
@@ -0,0 +1,222 @@
import React, { useRef, useState } from 'react';
import { Upload, Check, AlertCircle, FileArchive } from 'lucide-react';
import { ImportSummary } from '../../lib/types';
import { api } from '../../lib/api';
interface ImportCardProps {
source: 'mindsift' | 'obsidian';
title: string;
description: React.ReactNode;
accept: string;
}
const summaryParts = (s: ImportSummary) =>
[
s.folders && `${s.folders} Ordner`,
s.notes && `${s.notes} ${s.notes === 1 ? 'Notiz' : 'Notizen'}`,
s.taskLists && `${s.taskLists} Aufgabenliste${s.taskLists === 1 ? '' : 'n'}`,
s.tasks && `${s.tasks} Aufgabe${s.tasks === 1 ? '' : 'n'}`,
s.lists && `${s.lists} Liste${s.lists === 1 ? '' : 'n'} mit ${s.listItems} Einträgen`,
s.inbox && `${s.inbox} Gedanke${s.inbox === 1 ? '' : 'n'} für die Triage`,
].filter(Boolean) as string[];
// One import path: drop or pick a file, see a preview of what would be created, then import.
// Touch targets are large enough for phones; the file picker works on mobile browsers.
export const ImportCard: React.FC<ImportCardProps> = ({ source, title, description, accept }) => {
const inputRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<ImportSummary | null>(null);
const [result, setResult] = useState<ImportSummary | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dragOver, setDragOver] = useState(false);
// MindSift data can replace the current state instead of being added next to it
const [mode, setMode] = useState<'merge' | 'restore'>('merge');
const [confirmRestore, setConfirmRestore] = useState(false);
const restoring = source === 'mindsift' && mode === 'restore';
const reset = () => {
setFile(null);
setPreview(null);
setError(null);
setConfirmRestore(false);
};
const pick = async (f?: File) => {
if (!f) return;
reset();
setResult(null);
setFile(f);
setBusy(true);
try {
setPreview(await api.importData(source, f, true));
} catch (err: any) {
setError(err.message || 'Datei konnte nicht gelesen werden');
} finally {
setBusy(false);
}
};
const runImport = async () => {
if (!file) return;
setBusy(true);
setError(null);
try {
setResult(await api.importData(source, file, false, restoring ? 'restore' : 'merge'));
setFile(null);
setPreview(null);
setConfirmRestore(false);
} catch (err: any) {
setError(err.message || 'Import fehlgeschlagen');
} finally {
setBusy(false);
}
};
const shown = result || preview;
return (
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
<div>
<h4 className="font-bold text-main">{title}</h4>
<p className="text-muted text-[11px] mt-0.5">{description}</p>
</div>
{source === 'mindsift' && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2" role="radiogroup" aria-label="Import-Modus">
{(
[
['merge', 'Hinzufügen', 'Bestehende Daten behalten, Backup daneben anlegen.'],
['restore', 'Wiederherstellen', 'Eigene Notizen, Aufgaben und Listen vorher löschen.'],
] as const
).map(([value, label, text]) => (
<button
key={value}
type="button"
role="radio"
aria-checked={mode === value}
onClick={() => {
setMode(value);
setConfirmRestore(false);
}}
className={`p-2.5 min-h-[44px] text-left border rounded-card transition-colors ${
mode === value
? value === 'restore'
? 'border-status-err bg-status-err/10'
: 'border-accent bg-accent/10'
: 'border-border hover:border-strong'
}`}
>
<span className={`block text-xs font-semibold ${value === 'restore' && mode === value ? 'text-status-err' : 'text-main'}`}>{label}</span>
<span className="block text-[11px] text-muted">{text}</span>
</button>
))}
</div>
)}
<div
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
pick(e.dataTransfer.files?.[0]);
}}
className={`p-4 border border-dashed rounded-card flex flex-col sm:flex-row items-center justify-center gap-3 text-center transition-colors ${
dragOver ? 'border-accent bg-accent/10' : 'border-border'
}`}
>
<span className="text-[11px] text-muted hidden sm:inline">Datei hierher ziehen oder</span>
<button
type="button"
disabled={busy}
onClick={() => inputRef.current?.click()}
className="btn-action h-11 sm:h-9 px-4 text-xs flex items-center justify-center gap-2 disabled:opacity-50 w-full sm:w-auto"
>
<Upload className="w-3.5 h-3.5 text-accent" />
<span>Datei auswählen</span>
</button>
<input
ref={inputRef}
type="file"
accept={accept}
className="hidden"
onChange={(e) => {
pick(e.target.files?.[0]);
e.target.value = '';
}}
/>
</div>
{busy && <p className="text-[11px] text-muted">{preview ? 'Importiere…' : 'Lese Datei…'}</p>}
{error && (
<p className="text-[11px] text-status-err flex items-start gap-1.5">
<AlertCircle className="w-3.5 h-3.5 shrink-0 mt-px" />
<span>{error}</span>
</p>
)}
{shown && !busy && (
<div
className={`p-3 rounded-card border text-xs space-y-2 ${
result ? 'border-status-ok/40 bg-status-ok/10' : 'border-border bg-panel'
}`}
>
<p className="font-semibold text-main flex items-center gap-1.5">
{result ? <Check className="w-3.5 h-3.5 text-status-ok" /> : <FileArchive className="w-3.5 h-3.5 text-accent" />}
<span>{result ? 'Import abgeschlossen' : `Vorschau: ${file?.name}`}</span>
</p>
<p className="text-muted">
{result ? 'Angelegt: ' : 'Wird angelegt: '}
{summaryParts(shown).join(', ') || 'nichts'}.
</p>
{shown.skipped.length > 0 && (
<details className="text-[11px] text-muted">
<summary className="cursor-pointer">{shown.skipped.length} übersprungen</summary>
<ul className="mt-1 space-y-0.5 max-h-40 overflow-y-auto">
{shown.skipped.map((s, i) => (
<li key={i}>
<span className="font-mono text-main">{s.path}</span> – {s.reason}
</li>
))}
</ul>
</details>
)}
{!result && restoring && (
<label className="flex items-start gap-2 p-2.5 rounded-card border border-status-err/40 bg-status-err/10 text-[11px] text-main cursor-pointer">
<input
type="checkbox"
checked={confirmRestore}
onChange={(e) => setConfirmRestore(e.target.checked)}
className="mt-0.5 w-4 h-4 shrink-0"
/>
<span>
<strong className="text-status-err">Achtung:</strong> Alle deine bisherigen Notizen, Ordner, Aufgaben, Gedanken und
Listen werden vor dem Import unwiderruflich gelöscht und durch das Backup ersetzt. Mit dir geteilte Inhalte anderer
bleiben erhalten.
</span>
</label>
)}
{!result && (
<div className="flex flex-col sm:flex-row gap-2 pt-1">
<button
type="button"
onClick={runImport}
disabled={restoring && !confirmRestore}
className={`h-11 sm:h-8 px-4 text-xs font-semibold disabled:opacity-50 ${restoring ? 'btn-action border-status-err text-status-err' : 'btn-primary'}`}
>
{restoring ? 'Löschen und wiederherstellen' : 'Jetzt importieren'}
</button>
<button type="button" onClick={reset} className="btn-action h-11 sm:h-8 px-3 text-xs">
Abbrechen
</button>
</div>
)}
</div>
)}
</div>
);
};
@@ -21,12 +21,14 @@ import {
Copy,
} from 'lucide-react';
import { APIKey, AuthStatus, User, UserTheme } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
import { api, clearToken, triggerHaptic } from '../../lib/api';
import { customThemeId, ThemeId, ThemeOption } from '../../lib/theme';
import themeTemplateCss from '../../themes/theme-template.css?raw';
import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar';
import { LegalLinks } from '../../components/LegalLinks';
import { AboutModal } from '../../components/AboutModal';
import { ImportCard } from './ImportCard';
import { FormSheet } from '../../components/FormSheet';
interface UserSettingsViewProps {
currentUser: User;
@@ -53,6 +55,45 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
}) => {
const [section, setSection] = useState<UserSettingsSection>('profile');
const [legal, setLegal] = useState<AuthStatus['legal']>();
// --- Danger zone ---
const [dangerAction, setDangerAction] = useState<'data' | 'account' | null>(null);
const [dangerPassword, setDangerPassword] = useState('');
const [dangerError, setDangerError] = useState<string | null>(null);
const [dangerBusy, setDangerBusy] = useState(false);
const [dangerDone, setDangerDone] = useState<string | null>(null);
const [isLastAdmin, setIsLastAdmin] = useState(false);
useEffect(() => {
api.getMe().then((me) => setIsLastAdmin(Boolean(me.isLastAdmin))).catch(() => {});
}, []);
const openDanger = (action: 'data' | 'account') => {
setDangerAction(action);
setDangerPassword('');
setDangerError(null);
setDangerDone(null);
};
const handleDangerSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setDangerBusy(true);
setDangerError(null);
try {
if (dangerAction === 'account') {
await api.deleteOwnAccount(dangerPassword);
// The account is gone: drop the session and show the login screen
clearToken();
window.location.reload();
return;
}
await api.purgeOwnData(dangerPassword);
setDangerAction(null);
setDangerDone('Alle deine Daten wurden gelöscht.');
onUserUpdated(currentUser);
} catch (err: any) {
setDangerError(err.message || 'Aktion fehlgeschlagen');
} finally {
setDangerBusy(false);
}
};
const [showAbout, setShowAbout] = useState(false);
useEffect(() => {
api.getAuthStatus().then((st) => setLegal(st.legal)).catch(() => {});
@@ -374,12 +415,16 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
window.setTimeout(() => URL.revokeObjectURL(url), 10000);
};
const [exportingBackup, setExportingBackup] = useState(false);
const handleExportData = async () => {
const data = await api.exportAll();
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: 'application/json;charset=utf-8',
});
saveBlob(blob, `mindsift-backup-${new Date().toISOString().slice(0, 10)}.json`);
setExportingBackup(true);
try {
saveBlob(await api.exportBackupZip(), `mindsift-backup-${new Date().toISOString().slice(0, 10)}.zip`);
} catch (err: any) {
setVaultError(err.message || 'Backup fehlgeschlagen');
} finally {
setExportingBackup(false);
}
};
const [exportingVault, setExportingVault] = useState(false);
@@ -692,6 +737,49 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</button>
</div>
</div>
{/* Danger zone: delete own data or the whole account (password required) */}
<div className="p-4 border border-status-err/40 bg-status-err/5 rounded-card space-y-3">
<h3 className="font-bold text-status-err flex items-center gap-1.5">
<AlertCircle className="w-4 h-4" />
<span>Gefahrenzone</span>
</h3>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<p className="text-main font-medium">Alle eigenen Daten löschen</p>
<p className="text-[11px] text-muted">
Löscht deine Notizen, Ordner, Aufgaben, Gedanken und Listen. Konto, Passwort und Einstellungen bleiben.
Mit dir geteilte Inhalte anderer bleiben erhalten.
</p>
</div>
<button
type="button"
onClick={() => openDanger('data')}
className="btn-danger h-11 sm:h-8 px-3 text-xs shrink-0"
>
Daten löschen
</button>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pt-3 border-t border-status-err/20">
<div>
<p className="text-main font-medium">Konto unwiderruflich löschen</p>
<p className="text-[11px] text-muted">
{isLastAdmin
? 'Der letzte Server-Administrator kann nicht gelöscht werden. Bitte ernenne vorher ein anderes Konto zum Administrator.'
: 'Löscht dein Konto mit allen Daten. Du wirst danach abgemeldet.'}
</p>
</div>
<button
type="button"
onClick={() => openDanger('account')}
disabled={isLastAdmin}
className="btn-danger h-11 sm:h-8 px-3 text-xs shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
>
Konto löschen
</button>
</div>
{dangerDone && <p className="text-[11px] text-status-ok">{dangerDone}</p>}
</div>
</div>
)}
@@ -1138,11 +1226,11 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="Bezeichnung des Schlüssels (z.B. Proxmox Monitoring, Discord-Forwarder)…"
className="flex-1 h-9 px-3 input-field"
className="w-full sm:flex-1 min-w-0 h-11 sm:h-9 px-3 input-field text-base sm:text-xs"
/>
<button
type="submit"
className="btn-primary h-9 px-4 text-xs font-semibold flex items-center justify-center gap-1.5"
className="btn-primary h-11 sm:h-9 px-4 text-sm sm:text-xs font-semibold flex items-center justify-center gap-1.5"
>
<Plus className="w-3.5 h-3.5" />
<span>Neuen API-Key erzeugen</span>
@@ -1237,18 +1325,19 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
{/* Full Data Backup */}
<div className="pt-4 border-t border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h3 className="font-bold text-main">Vollständiger Daten-Export (JSON Backup)</h3>
<h3 className="font-bold text-main">Vollständiges Backup (.zip)</h3>
<p className="text-muted text-[11px]">
Lädt alle deine Gedanken, Aufgaben, Markdown-Dokumente und Listen als portable
JSON-Datei herunter.
Alle deine Gedanken, Aufgaben, Notizen und Listen als JSON, gepackt in eine ZIP-Datei. Über
„MindSift-Daten importieren“ lässt es sich vollständig zurückspielen.
</p>
</div>
<button
onClick={handleExportData}
className="btn-action h-9 px-4 text-xs flex items-center gap-2 flex-shrink-0"
disabled={exportingBackup}
className="btn-action h-11 sm:h-9 px-4 text-xs flex items-center justify-center gap-2 flex-shrink-0 disabled:opacity-50"
>
<Download className="w-3.5 h-3.5 text-accent" />
<span>Backup herunterladen</span>
<span>{exportingBackup ? 'Wird erstellt…' : 'Backup herunterladen'}</span>
</button>
</div>
@@ -1273,6 +1362,42 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<span>{exportingVault ? 'Wird erstellt…' : 'Markdown-Archiv herunterladen'}</span>
</button>
</div>
{/* Import: two separate paths, never guessed from the file */}
<div className="pt-4 border-t border-border space-y-3">
<div>
<h3 className="font-bold text-main">Daten importieren</h3>
<p className="text-muted text-[11px]">
Ein Import legt alles neu an und ändert nichts Bestehendes. Vor dem Import siehst du, was angelegt wird.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<ImportCard
source="mindsift"
title="MindSift-Daten"
accept=".json,.zip,application/json,application/zip"
description={
<>
Eigenes Backup zurückspielen: JSON-Backup (<code className="font-mono">.json</code>) oder Markdown-Archiv
(<code className="font-mono">.zip</code>). Notizen kommen in ihre Ordner, Aufgabenlisten mit Unteraufgaben,
Fälligkeiten und Prioritäten sowie Listen werden wiederhergestellt.
</>
}
/>
<ImportCard
source="obsidian"
title="Obsidian-Vault"
accept=".zip,application/zip"
description={
<>
Vault als <code className="font-mono">.zip</code>. Alle Notizen landen in einem eigenen Ordner mit dem Namen
des Vaults, Unterordner bleiben erhalten. Checkboxen bleiben Checkboxen in der Notiz, Frontmatter-Tags werden zu
#Hashtags. Bilder und andere Anhänge werden übersprungen.
</>
}
/>
</div>
</div>
</div>
)}
</div>
@@ -1285,6 +1410,35 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</button>
</LegalLinks>
{showAbout && <AboutModal onClose={() => setShowAbout(false)} />}
{dangerAction && (
<FormSheet
title={dangerAction === 'account' ? 'Konto unwiderruflich löschen' : 'Alle eigenen Daten löschen'}
icon={<AlertCircle className="w-4 h-4 text-status-err" />}
onClose={() => setDangerAction(null)}
onSubmit={handleDangerSubmit}
submitLabel={dangerAction === 'account' ? 'Konto löschen' : 'Daten löschen'}
danger
busy={dangerBusy || !dangerPassword}
>
<p className="text-xs text-muted">
{dangerAction === 'account'
? 'Dein Konto und alle deine Daten werden endgültig gelöscht. Das lässt sich nicht rückgängig machen.'
: 'Alle deine Notizen, Ordner, Aufgaben, Gedanken und Listen werden endgültig gelöscht. Lade vorher ein Backup herunter, wenn du sie behalten willst.'}
</p>
<label className="block text-xs text-muted">
Zur Bestätigung dein Passwort
<input
type="password"
autoFocus
autoComplete="current-password"
value={dangerPassword}
onChange={(e) => setDangerPassword(e.target.value)}
className="mt-1 input-field w-full h-11 sm:h-8 px-2.5 text-base sm:text-xs"
/>
</label>
{dangerError && <p className="text-[11px] text-status-err">{dangerError}</p>}
</FormSheet>
)}
</div>
);
};
@@ -17,12 +17,14 @@ import {
Webhook,
Lock,
UserPlus,
Repeat,
} from 'lucide-react';
import { Thought, SubTask, TaskList, TaskListMember, KanbanColumn, User } from '../../lib/types';
import { Thought, SubTask, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types';
import { UserAvatar } from '../../components/UserAvatar';
import { UserAutocomplete } from '../UserAutocomplete';
import { triggerHaptic } from '../../lib/api';
import { MarkdownViewer } from '../../lib/markdown';
import { addDays, joinDue, splitDue } from '../../lib/due';
interface TaskDetailModalProps {
task: Thought | null;
@@ -34,6 +36,7 @@ interface TaskDetailModalProps {
onUpdateStatus: (task: Thought, newStatus: string) => Promise<void>;
onUpdatePriority: (task: Thought, newPriority: string) => Promise<void>;
onUpdateDueDate: (task: Thought, newDueDate: string) => Promise<void>;
onUpdateRecurrence?: (task: Thought, recurrence: Recurrence) => Promise<void>;
onUpdateTaskList: (task: Thought, newListId: string) => Promise<void>;
onUpdateNotes: (task: Thought, newNotes: string) => Promise<void>;
onUpdateTags?: (task: Thought, newTags: string[]) => Promise<void>;
@@ -58,6 +61,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
onUpdateStatus,
onUpdatePriority,
onUpdateDueDate,
onUpdateRecurrence,
onUpdateTaskList,
onUpdateNotes,
onUpdateTags,
@@ -162,15 +166,15 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
}
};
const dueParts = splitDue(task.dueDate);
const handleQuickDueDate = (daysOffset: number | null) => {
if (daysOffset === null) {
onUpdateDueDate(task, '');
return;
}
const d = new Date();
d.setDate(d.getDate() + daysOffset);
const dateStr = d.toISOString().split('T')[0];
onUpdateDueDate(task, dateStr);
// Keep a chosen time when only the day changes
onUpdateDueDate(task, joinDue(addDays(daysOffset), splitDue(task.dueDate).time));
};
const currentColumn = activeColumns.find((c) => c.id === task.kanbanStatus) ||
@@ -466,21 +470,32 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-main flex items-center gap-1">
<Calendar className="w-3.5 h-3.5 text-accent" />
<span>Fälligkeitsdatum:</span>
<span>Fällig:</span>
</label>
<div className="flex items-center gap-1.5">
<input
type="date"
value={task.dueDate || ''}
onChange={(e) => onUpdateDueDate(task, e.target.value)}
className="flex-1 h-8 input-field px-2 text-xs bg-panel"
aria-label="Fälligkeitsdatum"
value={dueParts.date}
onChange={(e) => onUpdateDueDate(task, joinDue(e.target.value, dueParts.time))}
className="flex-1 min-w-0 h-11 sm:h-8 input-field px-2 text-base sm:text-xs bg-panel"
/>
<input
type="time"
aria-label="Uhrzeit (optional)"
title="Uhrzeit (optional) – ohne Uhrzeit gilt die Aufgabe den ganzen Tag"
value={dueParts.time}
disabled={!dueParts.date}
onChange={(e) => onUpdateDueDate(task, joinDue(dueParts.date, e.target.value))}
className="w-[7.5rem] shrink-0 h-11 sm:h-8 input-field px-2 text-base sm:text-xs bg-panel disabled:opacity-50"
/>
{task.dueDate && (
<button
type="button"
onClick={() => onUpdateDueDate(task, '')}
className="btn-action h-8 px-2 text-xs text-muted hover:text-status-err"
title="Datum entfernen"
className="btn-action h-11 w-11 sm:h-8 sm:w-8 flex items-center justify-center text-muted hover:text-status-err shrink-0"
title="Fälligkeit entfernen"
aria-label="Fälligkeit entfernen"
>
<X className="w-3.5 h-3.5" />
</button>
@@ -508,7 +523,44 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
>
+1 Woche
</button>
{dueParts.time && (
<button
type="button"
onClick={() => onUpdateDueDate(task, dueParts.date)}
className="px-1.5 py-0.5 bg-canvas border border-border rounded-btn text-muted hover:text-main inline-flex items-center gap-1"
title="Uhrzeit entfernen – die Aufgabe gilt dann den ganzen Tag"
>
<X className="w-2.5 h-2.5" />
Uhrzeit
</button>
)}
</div>
{onUpdateRecurrence && (
<div className="pt-1">
<label className="flex items-center gap-1 text-[11px] text-muted mb-1">
<Repeat className="w-3 h-3 text-accent" />
<span>Wiederholung</span>
</label>
<select
value={task.recurrence || ''}
disabled={readOnly}
onChange={(e) => onUpdateRecurrence(task, e.target.value as Recurrence)}
className="w-full h-11 sm:h-8 input-field px-2 text-base sm:text-xs bg-panel"
>
<option value="">Keine Wiederholung</option>
<option value="daily">🔁 Täglich</option>
<option value="weekly">🔁 Wöchentlich</option>
<option value="monthly">🔁 Monatlich</option>
<option value="yearly">🔁 Jährlich</option>
</select>
{task.recurrence && (
<p className="text-[10px] text-faint mt-1">
Beim Erledigen entsteht automatisch die nächste Aufgabe, fällig{' '}
{task.dueDate ? 'ein Intervall nach diesem Datum' : 'ein Intervall nach dem Erledigen'}.
</p>
)}
</div>
)}
</div>
{/* Task List assignment */}
+67 -62
View File
@@ -25,14 +25,17 @@ import {
Folder,
Filter,
Webhook,
Repeat,
} from 'lucide-react';
import { TaskList, Thought, SubTask, Team, User, TaskListShare, TaskListMember, KanbanColumn, PriorityLevel } from '../../lib/types';
import { TaskList, Thought, SubTask, Team, User, TaskListShare, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS } from '../../lib/types';
import { formatDue, isDueToday, isOverdue, localDay, splitDue } from '../../lib/due';
import { api, triggerHaptic } from '../../lib/api';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { TaskDetailModal } from './TaskDetailModal';
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal';
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
import { CountBadge } from '../../components/CountBadge';
import { FormSheet } from '../../components/FormSheet';
import { useMobileActions } from '../../context/MobileActionContext';
interface TasksViewProps {
@@ -414,10 +417,11 @@ export const TasksView: React.FC<TasksViewProps> = ({
if (high.length > 0) groups.push({ key: 'p:high', title: '⚠️ Wichtig', count: high.length, tasks: high });
if (normal.length > 0) groups.push({ key: 'p:normal', title: 'Normal', count: normal.length, tasks: normal });
} else if (groupBy === 'due') {
const today = new Date().toISOString().split('T')[0];
const overdue = filteredTasks.filter((t) => t.dueDate && t.dueDate < today);
const dueToday = filteredTasks.filter((t) => t.dueDate === today);
const upcoming = filteredTasks.filter((t) => t.dueDate && t.dueDate > today);
const now = new Date();
const today = localDay(now);
const overdue = filteredTasks.filter((t) => isOverdue(t.dueDate, now));
const dueToday = filteredTasks.filter((t) => isDueToday(t.dueDate, now) && !isOverdue(t.dueDate, now));
const upcoming = filteredTasks.filter((t) => t.dueDate && splitDue(t.dueDate).date > today);
const noDue = filteredTasks.filter((t) => !t.dueDate);
if (overdue.length > 0) groups.push({ key: 'due:overdue', title: '⚠️ Überfällig', count: overdue.length, tasks: overdue });
if (dueToday.length > 0) groups.push({ key: 'due:today', title: '📅 Heute fällig', count: dueToday.length, tasks: dueToday });
@@ -503,6 +507,15 @@ export const TasksView: React.FC<TasksViewProps> = ({
await api.updateThought(task.id, { notes: newNotes });
};
const handleUpdateTaskRecurrence = async (task: Thought, recurrence: Recurrence) => {
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, recurrence } : t)));
try {
await api.updateThought(task.id, { recurrence });
} catch {
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, recurrence: task.recurrence } : t)));
}
};
const handleUpdateTaskAssignees = async (task: Thought, assignees: string[]) => {
triggerHaptic(10);
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, assignees } : t)));
@@ -1300,9 +1313,17 @@ export const TasksView: React.FC<TasksViewProps> = ({
{/* Due Date */}
{task.dueDate && (
<span className="inline-flex items-center gap-1 text-[10px] text-muted">
<span
className={`inline-flex items-center gap-1 text-[10px] ${!task.isCompleted && isOverdue(task.dueDate) ? 'text-status-err font-semibold' : 'text-muted'}`}
title={`Fällig: ${task.dueDate.replace('T', ' ')}`}
>
<Calendar className="w-2.5 h-2.5 text-accent" />
<span>{task.dueDate}</span>
<span>{formatDue(task.dueDate)}</span>
</span>
)}
{task.recurrence && (
<span className="inline-flex items-center text-muted" title={`Wiederholt sich ${RECURRENCE_LABELS[task.recurrence]}`}>
<Repeat className="w-3 h-3" />
</span>
)}
@@ -2016,60 +2037,35 @@ export const TasksView: React.FC<TasksViewProps> = ({
{/* NEW LIST MODAL */}
{showNewListModal && (
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-4">
<div className="w-full max-w-sm panel-surface shadow-panel p-4 space-y-3">
<div className="flex items-center justify-between border-b border-border pb-2">
<h3 className="text-sm font-semibold text-main flex items-center gap-1.5">
<Layers className="w-4 h-4 text-accent" />
<span>Neue Aufgabenliste erstellen</span>
</h3>
<button
onClick={() => setShowNewListModal(false)}
className="text-muted hover:text-main"
>
<X className="w-4 h-4" />
</button>
<FormSheet
title="Neue Aufgabenliste erstellen"
icon={<Layers className="w-4 h-4 text-accent" />}
onClose={() => setShowNewListModal(false)}
onSubmit={handleCreateTaskList}
>
<div>
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
<div className="flex items-center gap-1.5">
<input
type="text"
value={newListIcon}
onChange={(e) => setNewListIcon(e.target.value)}
maxLength={2}
className="w-12 sm:w-10 h-11 sm:h-8 text-center input-field text-base sm:text-sm"
title="Icon (Emoji)"
/>
<input
type="text"
value={newListTitle}
onChange={(e) => setNewListTitle(e.target.value)}
placeholder="z.B. Homelab, Einkäufe, Steuern"
autoFocus
required
className="flex-1 min-w-0 h-11 sm:h-8 input-field px-2.5 text-base sm:text-xs"
/>
</div>
</div>
<form onSubmit={handleCreateTaskList} className="space-y-3">
<div>
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
<div className="flex items-center gap-1.5">
<input
type="text"
value={newListIcon}
onChange={(e) => setNewListIcon(e.target.value)}
maxLength={2}
className="w-10 h-8 text-center input-field text-sm"
title="Icon (Emoji)"
/>
<input
type="text"
value={newListTitle}
onChange={(e) => setNewListTitle(e.target.value)}
placeholder="z.B. Homelab, Einkäufe, Steuern"
autoFocus
required
className="flex-1 h-8 input-field px-2.5 text-xs"
/>
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
<button
type="button"
onClick={() => setShowNewListModal(false)}
className="btn-action h-7 px-3 text-xs"
>
Abbrechen
</button>
<button type="submit" className="btn-primary h-7 px-3 text-xs">
Erstellen
</button>
</div>
</form>
</div>
</div>
</FormSheet>
)}
{/* UNIFIED SHARE LIST MODAL */}
@@ -2155,6 +2151,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
onUpdateStatus={handleSetKanbanStatus}
onUpdatePriority={handleSetPriority}
onUpdateDueDate={handleUpdateTaskDueDate}
onUpdateRecurrence={handleUpdateTaskRecurrence}
onUpdateTaskList={handleUpdateTaskTaskList}
onUpdateNotes={handleUpdateTaskNotes}
onUpdateTags={handleUpdateTaskTags}
@@ -2343,9 +2340,17 @@ export const TasksView: React.FC<TasksViewProps> = ({
</span>
)}
{task.dueDate && (
<span className="inline-flex items-center gap-0.5 text-[9px] text-muted">
<span
className={`inline-flex items-center gap-0.5 text-[9px] ${!task.isCompleted && isOverdue(task.dueDate) ? 'text-status-err font-semibold' : 'text-muted'}`}
title={`Fällig: ${task.dueDate.replace('T', ' ')}`}
>
<Calendar className="w-2.5 h-2.5" />
<span>{task.dueDate}</span>
<span>{formatDue(task.dueDate)}</span>
</span>
)}
{task.recurrence && (
<span className="inline-flex items-center text-muted" title={`Wiederholt sich ${RECURRENCE_LABELS[task.recurrence]}`}>
<Repeat className="w-2.5 h-2.5" />
</span>
)}
{task.tags?.map((tag) => (
+3 -5
View File
@@ -20,6 +20,7 @@ import {
MoreHorizontal,
} from 'lucide-react';
import { SharedList, Thought } from '../../lib/types';
import { addDays, formatDue } from '../../lib/due';
import { api, triggerHaptic } from '../../lib/api';
interface TriageDeckProps {
@@ -223,10 +224,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
const [cards, setCards] = useState<Thought[]>(deck);
const [lastTriaged, setLastTriaged] = useState<Thought | null>(null);
const [showDateModal, setShowDateModal] = useState(false);
const [taskDueDate, setTaskDueDate] = useState(() => {
const tomorrow = new Date(Date.now() + 86400000);
return tomorrow.toISOString().slice(0, 10);
});
const [taskDueDate, setTaskDueDate] = useState(() => addDays(1));
const [showListSelector, setShowListSelector] = useState(false);
// Long-Press Action Sheet state (for Triage Card or Queue Item)
@@ -666,7 +664,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
{activeCard.dueDate && (
<span className="px-2 py-0.5 bg-canvas text-muted border border-border rounded-btn text-xs">
📅 {activeCard.dueDate}
📅 {formatDue(activeCard.dueDate)}
</span>
)}
</div>
@@ -0,0 +1,118 @@
import React, { useEffect, useState } from 'react';
import { ChevronDown, ChevronRight, Folder, Link2 } from 'lucide-react';
import { Backlink } from '../../lib/types';
import { api } from '../../lib/api';
interface BacklinksSectionProps {
docId: string;
title: string;
onOpen: (docId: string, blockId?: string) => void;
}
const COLLAPSED_KEY = 'mindsift:backlinks_collapsed';
// Shows the [[Wiki-Link]] parts of a block in the accent color, the rest as plain text
const Snippet: React.FC<{ text: string }> = ({ text }) => (
<>
{text.split(/(\[\[[^\]]*\]\])/g).map((part, i) =>
part.startsWith('[[') && part.endsWith(']]') ? (
<span key={i} className="text-accent font-medium">
{part}
</span>
) : (
<React.Fragment key={i}>{part}</React.Fragment>
)
)}
</>
);
// "Verlinkt auf diese Notiz" at the end of a note. Hidden while nothing links here; reloads shortly
// after the title changes, since links are matched by title.
export const BacklinksSection: React.FC<BacklinksSectionProps> = ({ docId, title, onOpen }) => {
const [links, setLinks] = useState<Backlink[]>([]);
const [collapsed, setCollapsed] = useState<boolean>(() => {
try {
return localStorage.getItem(COLLAPSED_KEY) === 'true';
} catch {
return false;
}
});
useEffect(() => {
let cancelled = false;
const timer = setTimeout(() => {
api
.getBacklinks(docId)
.then((res) => !cancelled && setLinks(res))
.catch(() => !cancelled && setLinks([]));
}, 400);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [docId, title]);
if (links.length === 0) return null;
const toggle = () => {
setCollapsed((prev) => {
try {
localStorage.setItem(COLLAPSED_KEY, String(!prev));
} catch {}
return !prev;
});
};
return (
<section className="mt-10 pt-4 border-t border-border" aria-label="Rückverweise">
<button
type="button"
onClick={toggle}
aria-expanded={!collapsed}
className="w-full flex items-center gap-1.5 min-h-[44px] sm:min-h-0 py-1 text-xs font-semibold text-muted hover:text-main cursor-pointer"
>
{collapsed ? <ChevronRight className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
<Link2 className="w-3.5 h-3.5 text-accent" />
<span>Verlinkt auf diese Notiz ({links.length})</span>
</button>
{!collapsed && (
<ul className="mt-2 space-y-2">
{links.map((l) => (
<li key={l.docId} className="p-3 bg-panel border border-border rounded-card">
<button
type="button"
onClick={() => onOpen(l.docId)}
className="flex items-center gap-2 min-w-0 max-w-full text-left cursor-pointer group"
>
<span className="shrink-0">{l.icon || '📝'}</span>
<span className="text-sm font-semibold text-main truncate group-hover:text-accent">
{l.title || 'Unbenannte Notiz'}
</span>
{l.folderPath && (
<span className="hidden sm:inline-flex items-center gap-1 text-[11px] text-faint min-w-0">
<Folder className="w-3 h-3 shrink-0" />
<span className="truncate">{l.folderPath}</span>
</span>
)}
</button>
<ul className="mt-1.5 space-y-1">
{l.blocks.map((b) => (
<li key={b.id}>
<button
type="button"
onClick={() => onOpen(l.docId, b.id)}
className="w-full text-left text-xs text-muted hover:text-main px-2 py-1.5 rounded-btn hover:bg-panel-elevated border-l-2 border-border line-clamp-3 break-words cursor-pointer"
>
<Snippet text={b.content} />
</button>
</li>
))}
</ul>
{!!l.more && <p className="mt-1 text-[11px] text-faint">… und {l.more} weitere Stellen</p>}
</li>
))}
</ul>
)}
</section>
);
};
@@ -3,6 +3,7 @@ import {
Pin,
Users,
Trash2,
RotateCcw,
Download,
FileText,
Loader2,
@@ -36,15 +37,18 @@ export interface DocumentEditorProps {
initialFocusedBlockId?: string | null;
onDocMetadataChange: (updated: Document) => void;
onDeleteDoc: (docId: string) => void;
onRestoreDoc?: (docId: string) => void;
onOpenShareModal: (doc: Document) => void;
onSelectTag: (tag: string) => void;
onWikiLinkClick?: (pageTitle: string) => void;
onOpenDocBlock?: (docId: string, blockId?: string) => void;
onBackMobile?: () => void;
onOpenTask?: (taskId: string) => void;
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
}
import { EmojiPickerModal } from './EmojiPickerModal';
import { BacklinksSection } from './BacklinksSection';
function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): DocumentBlock[] {
if (!blocks || blocks.length === 0) {
@@ -179,6 +183,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
initialFocusedBlockId,
onDocMetadataChange,
onDeleteDoc,
onRestoreDoc,
onOpenDocBlock,
onOpenShareModal,
onSelectTag,
onWikiLinkClick,
@@ -244,7 +250,9 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
}, [title, doc?.title, blocks, zoomedBlockId]);
// Owner and editors may edit; the role comes from the server via the sync engine
const inTrash = Boolean(doc?.isArchived);
const canEdit = useMemo(() => {
if (doc?.isArchived) return false;
if (engineState?.role) return engineState.role === 'owner' || engineState.role === 'editor';
if (!doc) return false;
return Boolean(doc.isOwner || doc.permission === 'owner' || doc.permission === 'write');
@@ -770,7 +778,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
{/* Who else has this note open */}
<PresenceAvatars people={otherPeople} avatars={avatarsById} />
{/* Sharing Section */}
{doc && (() => {
{doc && !inTrash && (() => {
const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin');
const sharesCount = doc.sharedWith?.length || 0;
const isShared = sharesCount > 0 || !isOwner || Boolean(doc.sharedFolderId);
@@ -814,7 +822,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
);
})()}
<button
{!inTrash && <button
type="button"
onClick={handleTogglePin}
className={`btn-icon ${
@@ -825,7 +833,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
title={doc?.isPinned ? 'Anheftung lösen' : 'Notiz anheften'}
>
<Pin className="w-3.5 h-3.5" />
</button>
</button>}
<button
type="button"
@@ -849,8 +857,26 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
</div>
</div>
{/* In the trash: read-only with a way back */}
{inTrash && (
<div className="flex flex-wrap items-center gap-2 px-4 sm:px-6 py-2 bg-status-err/10 border-b border-status-err/25 text-status-err text-xs">
<Trash2 className="w-3.5 h-3.5 shrink-0" />
<span className="flex-1 min-w-0">Diese Notiz liegt im Papierkorb und ist schreibgeschützt.</span>
{onRestoreDoc && (
<button
type="button"
onClick={() => onRestoreDoc(docId)}
className="btn-action h-11 sm:h-7 px-3 text-xs text-main inline-flex items-center gap-1.5"
>
<RotateCcw className="w-3.5 h-3.5 text-accent" />
Wiederherstellen
</button>
)}
</div>
)}
{/* Read-only notification banner */}
{!canEdit && (
{!canEdit && !inTrash && (
<div className="flex items-center gap-2 px-4 sm:px-6 py-2 bg-status-warn/10 border-b border-status-warn/25 text-status-warn text-xs">
<Lock className="w-3.5 h-3.5 shrink-0" />
<span>Dieses Dokument wurde schreibgeschützt mit dir geteilt (Nur Lesen).</span>
@@ -1047,6 +1073,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onUndo={handleUndo}
onRedo={handleRedo}
/>
{onOpenDocBlock && !inTrash && <BacklinksSection docId={docId} title={title} onOpen={onOpenDocBlock} />}
</>
)}
</div>
@@ -1,37 +1,25 @@
import React, { Suspense, useEffect, useRef, useState } from 'react';
import { X, Trash2, Loader2, Clock } from 'lucide-react';
import { X, Trash2, Loader2 } from 'lucide-react';
import type { EmojiClickData } from 'emoji-picker-react';
import { EmojiStyle, Theme, SuggestionMode } from 'emoji-picker-react';
import { Categories, EmojiStyle, Theme, SuggestionMode } from 'emoji-picker-react';
import { useKeyboardInset } from '../../lib/useKeyboardInset';
// Lazy-load emoji-picker-react so the main bundle size stays small
const EmojiPicker = React.lazy(() => import('emoji-picker-react'));
const RECENT_EMOJIS_STORAGE_KEY = 'mindsift_recent_emojis';
const MAX_RECENT_EMOJIS = 10;
function getStoredRecentEmojis(): string[] {
try {
const raw = localStorage.getItem(RECENT_EMOJIS_STORAGE_KEY);
if (!raw) return ['📝', '💡', '🚀', '⭐', '🔥', '🎯', '📚', '💻'];
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.length > 0) {
return parsed.slice(0, MAX_RECENT_EMOJIS);
}
} catch {
// fallback
}
return ['📝', '💡', '🚀', '⭐', '🔥', '🎯', '📚', '💻'];
}
function saveRecentEmoji(emoji: string): void {
try {
const current = getStoredRecentEmojis();
const updated = [emoji, ...current.filter((e) => e !== emoji)].slice(0, MAX_RECENT_EMOJIS);
localStorage.setItem(RECENT_EMOJIS_STORAGE_KEY, JSON.stringify(updated));
} catch {
// ignore
}
}
// The library's own "recently used" category (stored by it) is the only recent list; a second bar
// would cost height on phones. Category names in German.
const CATEGORIES = [
{ category: Categories.SUGGESTED, name: 'Zuletzt verwendet' },
{ category: Categories.SMILEYS_PEOPLE, name: 'Smileys & Personen' },
{ category: Categories.ANIMALS_NATURE, name: 'Tiere & Natur' },
{ category: Categories.FOOD_DRINK, name: 'Essen & Trinken' },
{ category: Categories.TRAVEL_PLACES, name: 'Reisen & Orte' },
{ category: Categories.ACTIVITIES, name: 'Aktivitäten' },
{ category: Categories.OBJECTS, name: 'Objekte' },
{ category: Categories.SYMBOLS, name: 'Symbole' },
{ category: Categories.FLAGS, name: 'Flaggen' },
];
interface EmojiPickerModalProps {
currentIcon?: string;
@@ -44,7 +32,10 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
onSelectEmoji,
onClose,
}) => {
const [recentEmojis, setRecentEmojis] = useState<string[]>(getStoredRecentEmojis);
const { inset, visibleHeight } = useKeyboardInset();
const isPhone = typeof window !== 'undefined' && window.innerWidth < 640;
// On phones the picker shrinks with the keyboard so search field and results stay visible
const pickerHeight = isPhone ? Math.max(200, Math.min(350, visibleHeight - 110)) : 350;
const [isDark, setIsDark] = useState<boolean>(() =>
typeof document !== 'undefined'
? document.documentElement.classList.contains('dark')
@@ -92,8 +83,6 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
}, [onClose]);
const handlePick = (emoji: string) => {
saveRecentEmoji(emoji);
setRecentEmojis(getStoredRecentEmojis());
onSelectEmoji(emoji);
};
@@ -114,7 +103,7 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
<div
ref={containerRef}
className="
fixed inset-x-0 bottom-0 z-50 rounded-t-2xl max-h-[85vh] bg-panel border-t border-border shadow-2xl flex flex-col p-3 space-y-3
fixed inset-x-0 bottom-[var(--kb-inset)] z-50 rounded-t-2xl max-h-[var(--vv-max)] bg-panel border-t border-border shadow-2xl flex flex-col p-3 space-y-3
sm:absolute sm:inset-auto sm:left-0 sm:top-full sm:mt-1 sm:z-40 sm:w-[360px] sm:max-h-[520px] sm:rounded-card sm:border sm:border-border sm:shadow-xl
"
style={
@@ -130,6 +119,8 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
'--epr-focus-bg-color': 'var(--bg-panel-elevated)',
'--epr-highlight-color': 'var(--accent-primary)',
'--epr-category-icon-active-color': 'var(--accent-primary)',
'--kb-inset': `${inset}px`,
'--vv-max': `${Math.round(visibleHeight * 0.9)}px`,
} as React.CSSProperties
}
>
@@ -170,30 +161,8 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
</div>
</div>
{/* Recent Emojis Bar */}
{recentEmojis.length > 0 && (
<div className="shrink-0 space-y-1.5">
<div className="flex items-center gap-1 text-[11px] text-muted">
<Clock className="w-3 h-3 text-accent" />
<span>Zuletzt verwendet:</span>
</div>
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 no-scrollbar">
{recentEmojis.map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => handlePick(emoji)}
className="min-w-[44px] min-h-[44px] sm:min-w-[32px] sm:min-h-[32px] rounded-btn bg-panel-elevated hover:bg-canvas border border-border flex items-center justify-center text-xl sm:text-base cursor-pointer transition-transform active:scale-95 shrink-0"
>
{emoji}
</button>
))}
</div>
</div>
)}
{/* Full Emoji Catalog via emoji-picker-react */}
<div className="flex-1 min-h-[300px] overflow-hidden rounded mindsift-emoji-picker">
<div className="flex-1 min-h-0 overflow-hidden rounded mindsift-emoji-picker">
<Suspense
fallback={
<div className="h-64 flex flex-col items-center justify-center gap-2 text-xs text-muted">
@@ -211,7 +180,8 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
searchPlaceholder="Emoji suchen…"
searchPlaceHolder="Emoji suchen…"
width="100%"
height="350px"
height={`${pickerHeight}px`}
categories={CATEGORIES as any}
previewConfig={{
showPreview: false,
}}
@@ -0,0 +1,141 @@
import React, { useState } from 'react';
import { ArrowLeft, Folder, RotateCcw, Trash2 } from 'lucide-react';
import { TrashedDocument } from '../../lib/types';
import { CountBadge } from '../../components/CountBadge';
interface TrashViewProps {
items: TrashedDocument[];
onOpen: (docId: string) => void;
onRestore: (docId: string) => Promise<void>;
onPurge: (docId: string) => Promise<void>;
onEmpty: () => Promise<void>;
onBackMobile: () => void;
}
const formatDeletedAt = (iso: string) => {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
};
// Deleted notes: open read-only, restore, or delete for good. Deleting for good always takes a second
// tap on the same button ("Wirklich löschen?"), which works the same with mouse and touch.
export const TrashView: React.FC<TrashViewProps> = ({ items, onOpen, onRestore, onPurge, onEmpty, onBackMobile }) => {
const [confirmId, setConfirmId] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const run = async (id: string, action: () => Promise<void>) => {
setBusyId(id);
setError(null);
try {
await action();
setConfirmId(null);
} catch (err: any) {
setError(err.message || 'Aktion fehlgeschlagen');
} finally {
setBusyId(null);
}
};
return (
<div className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8 bg-canvas">
<div className="w-full max-w-3xl space-y-4">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={onBackMobile}
className="lg:hidden btn-action h-11 w-11 flex items-center justify-center"
aria-label="Zurück zu den Notizen"
>
<ArrowLeft className="w-4 h-4" />
</button>
<Trash2 className="w-5 h-5 text-accent" />
<h1 className="text-xl font-bold text-main">Papierkorb</h1>
<CountBadge count={items.length} />
{items.length > 0 && (
<button
type="button"
disabled={busyId !== null}
onClick={() =>
confirmId === '__all__' ? run('__all__', onEmpty) : setConfirmId('__all__')
}
className={`ml-auto h-11 sm:h-8 px-3 text-xs font-semibold rounded-btn border transition-colors disabled:opacity-50 ${
confirmId === '__all__'
? 'bg-status-err/20 text-status-err border-status-err'
: 'btn-action text-status-err border-status-err/40'
}`}
>
{confirmId === '__all__' ? `Wirklich ${items.length} endgültig löschen?` : 'Papierkorb leeren'}
</button>
)}
</div>
<p className="text-xs text-muted">
Gelöschte Notizen bleiben hier, bis du sie wiederherstellst oder endgültig löschst. Für alle, mit denen sie geteilt
waren, sind sie bis dahin ausgeblendet.
</p>
{error && <p className="text-xs text-status-err">{error}</p>}
{items.length === 0 ? (
<div className="p-8 text-center text-sm text-faint border border-dashed border-border rounded-card">
Der Papierkorb ist leer.
</div>
) : (
<ul className="space-y-2">
{items.map((t) => (
<li
key={t.id}
className="p-3 bg-panel border border-border rounded-card flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3"
>
<button
type="button"
onClick={() => onOpen(t.id)}
className="flex items-center gap-2.5 flex-1 min-w-0 text-left cursor-pointer group"
title="Schreibgeschützt ansehen"
>
<span className="text-lg shrink-0">{t.icon || '📝'}</span>
<span className="min-w-0">
<span className="block text-sm font-semibold text-main truncate group-hover:text-accent">
{t.title || 'Unbenannte Notiz'}
</span>
<span className="flex flex-wrap items-center gap-x-2 text-[11px] text-muted">
<span className="inline-flex items-center gap-1 min-w-0">
<Folder className="w-3 h-3 shrink-0" />
<span className="truncate">{t.folderPath || 'Hauptebene'}</span>
</span>
<span>gelöscht {formatDeletedAt(t.deletedAt)}</span>
</span>
</span>
</button>
<div className="grid grid-cols-2 sm:flex gap-2 shrink-0">
<button
type="button"
disabled={busyId !== null}
onClick={() => run(t.id, () => onRestore(t.id))}
className="btn-action h-11 sm:h-8 px-3 text-xs inline-flex items-center justify-center gap-1.5 disabled:opacity-50"
>
<RotateCcw className="w-3.5 h-3.5 text-accent" />
Wiederherstellen
</button>
<button
type="button"
disabled={busyId !== null}
onClick={() => (confirmId === t.id ? run(t.id, () => onPurge(t.id)) : setConfirmId(t.id))}
className={`h-11 sm:h-8 px-3 text-xs rounded-btn border inline-flex items-center justify-center gap-1.5 transition-colors disabled:opacity-50 ${
confirmId === t.id
? 'bg-status-err/20 text-status-err border-status-err font-semibold'
: 'btn-action text-status-err border-status-err/40'
}`}
>
<Trash2 className="w-3.5 h-3.5" />
{confirmId === t.id ? 'Wirklich löschen?' : 'Endgültig löschen'}
</button>
</div>
</li>
))}
</ul>
)}
</div>
</div>
);
};
+117 -56
View File
@@ -36,6 +36,7 @@ import {
TagDetails,
TagSummary,
Team,
TrashedDocument,
UnifiedShare,
User,
} from '../../lib/types';
@@ -49,9 +50,12 @@ import {
RealtimeTransport,
} from '../../lib/api';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { FormSheet } from '../../components/FormSheet';
import { sendDocOps } from '../../lib/docSync/oneoff';
import { useMobileActions } from '../../context/MobileActionContext';
import { DocumentEditor, DocSaveStatus } from './DocumentEditor';
import { TrashView } from './TrashView';
import { CountBadge } from '../../components/CountBadge';
import { extractHashtags } from '../../lib/hashtags';
interface WorkspaceViewProps {
@@ -62,7 +66,7 @@ interface WorkspaceViewProps {
onOpenTask?: (taskId: string) => void;
}
type WorkspaceSubView = 'editor' | 'tag_detail' | 'todos' | 'tags';
type WorkspaceSubView = 'editor' | 'tag_detail' | 'todos' | 'tags' | 'trash';
interface FolderTreeNode {
name: string;
@@ -306,6 +310,15 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const [targetBlockId, setTargetBlockId] = useState<string | null>(initialBlockId || null);
const [newlyCreatedDocId, setNewlyCreatedDocId] = useState<string | null>(null);
const [subView, setSubView] = useState<WorkspaceSubView>('editor');
const [trash, setTrash] = useState<TrashedDocument[]>([]);
// Bumped after a restore so the open editor reloads the note without the trash banner
const [editorNonce, setEditorNonce] = useState(0);
const reloadTrash = useCallback(() => {
api.getTrash().then(setTrash).catch(() => {});
}, []);
useEffect(() => {
reloadTrash();
}, [reloadTrash]);
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [tagDetails, setTagDetails] = useState<TagDetails | null>(null);
const [tagNoteDraft, setTagNoteDraft] = useState<string>('');
@@ -340,7 +353,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
if (e) {
setNewFolderCoords({
x: Math.min(e.clientX, window.innerWidth - 270),
y: Math.min(e.clientY, window.innerHeight - 130),
y: Math.min(e.clientY, window.innerHeight - 200),
});
} else {
setNewFolderCoords({ x: 100, y: 150 });
@@ -465,14 +478,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const delId = msg.payload.id as string;
setDocs((prev) => prev.filter((d) => d.id !== delId));
setSelectedDocId((prev) => (prev === delId ? null : prev));
reloadTrash();
} else if (msg.event === 'WORKSPACE_FOLDERS_UPDATED') {
api.getFolders().then(setFolders).catch(() => {});
api.getSharedFolders().then(setSharedFolders).catch(() => {});
api.getDocuments().then(setDocs).catch(() => {});
reloadTrash();
}
});
return unsub;
}, [currentUser]);
}, [currentUser, reloadTrash]);
const handleCreateDocument = useCallback(
async (folderPath = '') => {
@@ -523,6 +538,12 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setMobileShowDetail(true);
}, []);
const openTrash = useCallback(() => {
reloadTrash();
setSubView('trash');
setMobileShowDetail(true);
}, [reloadTrash]);
// Register mobile FAB actions
useEffect(() => {
setPrimaryAction(() => handleCreateDocument(''));
@@ -544,6 +565,13 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
icon: <FolderPlus className="w-4 h-4" />,
onClick: () => setShowNewFolderModal(true),
},
{
id: 'trash',
label: 'Papierkorb',
icon: <Trash2 className="w-4 h-4" />,
badge: trash.length,
onClick: openTrash,
},
],
},
]);
@@ -551,7 +579,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setGroups([]);
setPrimaryAction(undefined);
};
}, [handleCreateDocument, setGroups, setPrimaryAction]);
}, [handleCreateDocument, setGroups, setPrimaryAction, trash.length, openTrash]);
const handleDeleteDocument = useCallback(
async (docId: string) => {
@@ -566,13 +594,47 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
});
setMobileShowDetail(false);
onStatsChange();
reloadTrash();
} catch {
// ignore
}
},
[onStatsChange, selectedDocId]
[onStatsChange, selectedDocId, reloadTrash]
);
const handleOpenTrashedDoc = (docId: string) => {
setSelectedDocId(docId);
setTargetBlockId(null);
setNewlyCreatedDocId(null);
setSubView('editor');
setMobileShowDetail(true);
};
const handleRestoreDocument = useCallback(
async (docId: string) => {
await api.restoreDocument(docId);
const [freshDocs, freshFolders] = await Promise.all([api.getDocuments(), api.getFolders()]);
setDocs(freshDocs);
setFolders(freshFolders);
setEditorNonce((n) => n + 1);
reloadTrash();
onStatsChange();
},
[reloadTrash, onStatsChange]
);
const handlePurgeDocument = async (docId: string) => {
await api.purgeDocument(docId);
if (selectedDocId === docId) setSelectedDocId(null);
reloadTrash();
};
const handleEmptyTrash = async () => {
await api.emptyTrash();
if (selectedDocId && trash.some((t) => t.id === selectedDocId)) setSelectedDocId(null);
reloadTrash();
};
const handleDocMetadataChange = useCallback((updated: Document) => {
setDocs((prev) => prev.map((d) => (d.id === updated.id ? { ...d, ...updated } : d)));
}, []);
@@ -614,6 +676,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}
}
onStatsChange();
reloadTrash();
} catch {
// ignore
}
@@ -1256,7 +1319,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{/* LEFT MASTER SIDEBAR */}
<aside
className={`${
subView !== 'editor' || isSidebarCollapsed
(subView !== 'editor' && subView !== 'trash') || isSidebarCollapsed
? 'hidden'
: mobileShowDetail
? 'hidden lg:flex'
@@ -1468,6 +1531,23 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</div>
)}
</div>
{/* Trash at the bottom of the tree */}
<div className="p-2 border-t border-border shrink-0">
<button
type="button"
onClick={openTrash}
className={`w-full flex items-center gap-2 px-2 h-11 sm:h-8 rounded-btn text-xs border transition-colors cursor-pointer ${
subView === 'trash'
? 'bg-panel-elevated text-main font-semibold border-border/80'
: 'border-transparent text-muted hover:text-main hover:bg-panel-elevated'
}`}
>
<Trash2 className="w-3.5 h-3.5" />
<span className="flex-1 text-left">Papierkorb</span>
<CountBadge count={trash.length} />
</button>
</div>
</aside>
{/* RIGHT DETAIL AREA (STRICTLY ISOLATED BY KEY) */}
@@ -1476,9 +1556,21 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
subView !== 'editor' || mobileShowDetail || isSidebarCollapsed ? 'flex' : 'hidden lg:flex'
} flex-1 flex-col min-w-0 h-full overflow-hidden bg-canvas`}
>
{subView === 'editor' && selectedDocId ? (
{subView === 'trash' ? (
<TrashView
items={trash}
onOpen={handleOpenTrashedDoc}
onRestore={handleRestoreDocument}
onPurge={handlePurgeDocument}
onEmpty={handleEmptyTrash}
onBackMobile={() => {
setSubView('editor');
setMobileShowDetail(false);
}}
/>
) : subView === 'editor' && selectedDocId ? (
<DocumentEditor
key={selectedDocId}
key={`${selectedDocId}:${editorNonce}`}
docId={selectedDocId}
currentUser={currentUser}
initialDoc={selectedDocObj}
@@ -1487,9 +1579,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
initialFocusedBlockId={targetBlockId}
onDocMetadataChange={handleDocMetadataChange}
onDeleteDoc={handleDeleteDocument}
onRestoreDoc={handleRestoreDocument}
onOpenShareModal={openDocShareModal}
onSelectTag={handleSelectTag}
onWikiLinkClick={handleWikiLinkClick}
onOpenDocBlock={handleJumpToDocBlock}
onBackMobile={() => setMobileShowDetail(false)}
onOpenTask={onOpenTask}
onSaveStatusChange={(status, err) => {
@@ -2196,55 +2290,22 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{/* New Folder Popover (small & positioned near click, asks only for name & OK) */}
{showNewFolderModal && (
<div
className="fixed inset-0 z-50 bg-black/20"
onClick={() => setShowNewFolderModal(false)}
<FormSheet
title={newFolderParentPath ? `Unterordner in "${newFolderParentPath}"` : 'Neuer Ordner'}
icon={<FolderPlus className="w-4 h-4 text-accent" />}
onClose={() => setShowNewFolderModal(false)}
onSubmit={handleCreateFolderSubmit}
anchor={newFolderCoords}
>
<form
onSubmit={handleCreateFolderSubmit}
onClick={(e) => e.stopPropagation()}
style={
newFolderCoords
? { top: `${newFolderCoords.y}px`, left: `${newFolderCoords.x}px` }
: undefined
}
className="fixed w-64 bg-panel border border-border rounded-card p-3 shadow-lg space-y-2.5 z-50 text-xs"
>
<div className="font-semibold text-main text-xs flex items-center justify-between">
<span className="truncate">{newFolderParentPath ? `Unterordner in "${newFolderParentPath}"` : 'Neuer Ordner'}</span>
<button
type="button"
onClick={() => setShowNewFolderModal(false)}
className="text-faint hover:text-main cursor-pointer"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
<input
type="text"
autoFocus
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder="Ordnername"
className="input-field w-full px-2 py-1 text-xs"
/>
<div className="flex justify-end gap-1.5 pt-1">
<button
type="button"
onClick={() => setShowNewFolderModal(false)}
className="btn-action px-2.5 py-1 text-xs"
>
Abbrechen
</button>
<button
type="submit"
className="btn-primary px-3 py-1 text-xs font-semibold"
>
OK
</button>
</div>
</form>
</div>
<input
type="text"
autoFocus
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder="Ordnername"
className="input-field w-full h-11 sm:h-8 px-2.5 text-base sm:text-xs"
/>
</FormSheet>
)}
{/* Unified Sharing Modal (Strictly Read-Only for Docs and Folders) */}
+7 -2
View File
@@ -13,6 +13,8 @@
@import './themes/theme-light.css';
@import './themes/theme-high-contrast.css';
@import './themes/theme-soft.css';
@import './themes/theme-polarized.css';
@import './themes/theme-suri.css';
@tailwind base;
@tailwind components;
@@ -484,7 +486,10 @@ body {
min-height: 44px !important;
}
.mindsift-emoji-picker .EmojiPickerReact .epr-category-nav button {
min-height: 44px !important;
/* Category icons are sprite cut-outs: they must keep the library's square size (stretching them showed
two icons and an oval highlight). More breathing room comes from the nav row instead. */
.mindsift-emoji-picker .EmojiPickerReact .epr-category-nav {
padding-top: 6px !important;
padding-bottom: 6px !important;
}
}
+30
View File
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { formatDue, isOverdue, isDueToday, joinDue, splitDue } from '../due';
const now = new Date(2026, 9, 29, 14, 0); // Thu 29.10.2026, 14:00 local
describe('due dates', () => {
it('splits and joins date and time', () => {
expect(splitDue('2026-10-30T14:30')).toEqual({ date: '2026-10-30', time: '14:30' });
expect(splitDue('2026-10-30')).toEqual({ date: '2026-10-30', time: '' });
expect(joinDue('2026-10-30', '09:15')).toBe('2026-10-30T09:15');
expect(joinDue('2026-10-30', '')).toBe('2026-10-30');
expect(joinDue('', '09:15')).toBe('');
});
it('formats relative to today', () => {
expect(formatDue('2026-10-29T14:30', now)).toBe('Heute, 14:30');
expect(formatDue('2026-10-30', now)).toBe('Morgen');
expect(formatDue('2026-10-28', now)).toBe('Gestern');
expect(formatDue('2026-11-06T08:00', now)).toBe('Fr., 06.11., 08:00');
expect(formatDue('2027-01-02', now)).toBe('02.01.2027');
});
it('knows overdue to the minute', () => {
expect(isOverdue('2026-10-29T13:59', now)).toBe(true);
expect(isOverdue('2026-10-29T14:30', now)).toBe(false);
expect(isOverdue('2026-10-29', now)).toBe(false);
expect(isOverdue('2026-10-28', now)).toBe(true);
expect(isDueToday('2026-10-29T08:00', now)).toBe(true);
});
});
@@ -0,0 +1,49 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { enqueue, flushOutbox, getOutbox, isCacheablePath } from '../offline';
const res = (status: number) => ({ status, ok: status >= 200 && status < 300 }) as Response;
describe('outbox', () => {
beforeEach(() => localStorage.clear());
it('keeps only the last state of repeated toggles', () => {
enqueue('PATCH', '/api/v1/thoughts/t1', { isCompleted: true });
enqueue('POST', '/api/v1/inbox', { content: 'Milch' });
enqueue('PATCH', '/api/v1/thoughts/t1', { isCompleted: false });
const box = getOutbox();
expect(box).toHaveLength(2);
expect(box[0].body).toEqual({ isCompleted: false });
});
it('takes over thoughts captured offline by older versions', () => {
localStorage.setItem(
'mindsift_offline_capture_queue',
JSON.stringify([{ id: 'th_offline_1', content: 'Alt', priority: 'normal', source: 'manual', createdAt: '2026-10-29T08:00:00Z' }])
);
const box = getOutbox();
expect(box).toHaveLength(1);
expect(box[0].path).toBe('/api/v1/inbox');
expect(localStorage.getItem('mindsift_offline_capture_queue')).toBeNull();
});
it('sends in order, drops rejected entries and stops while offline', async () => {
enqueue('POST', '/api/v1/inbox', { content: 'a' });
enqueue('PATCH', '/api/v1/lists/l/items/i', { isCompleted: true });
enqueue('POST', '/api/v1/inbox', { content: 'c' });
const answers = [res(201), res(404)];
const synced = await flushOutbox(async () => {
const next = answers.shift();
if (!next) throw new TypeError('offline');
return next;
});
expect(synced).toBe(1);
expect(getOutbox().map((e) => e.body.content)).toEqual(['c']);
});
it('does not cache streams, note sync or downloads', () => {
expect(isCacheablePath('/api/v1/thoughts?status=all')).toBe(true);
expect(isCacheablePath('/api/v1/events')).toBe(false);
expect(isCacheablePath('/api/v1/docs/d1/changes?since=3')).toBe(false);
expect(isCacheablePath('/api/v1/export/markdown')).toBe(false);
});
});
+12
View File
@@ -52,4 +52,16 @@ describe('custom themes', () => {
expect(opt.previewAccent).toBe('#15803d');
expect(opt.isRounded).toBe(true);
});
it('supports polarized and suri built-in purple dark themes', () => {
setActiveTheme('polarized');
expect(getActiveTheme()).toBe('polarized');
expect(document.documentElement.getAttribute('data-theme')).toBe('polarized');
expect(document.documentElement.classList.contains('dark')).toBe(true);
setActiveTheme('suri');
expect(getActiveTheme()).toBe('suri');
expect(document.documentElement.getAttribute('data-theme')).toBe('suri');
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
});
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { act, renderHook } from '@testing-library/react';
import { useKeyboardInset } from '../useKeyboardInset';
// Simulates iOS: the layout viewport keeps its height, the visual viewport shrinks by the keyboard
function mockVisualViewport(height: number) {
const listeners: Record<string, (() => void)[]> = {};
const vv = {
height,
offsetTop: 0,
addEventListener: (t: string, fn: () => void) => (listeners[t] = [...(listeners[t] || []), fn]),
removeEventListener: (t: string, fn: () => void) => (listeners[t] = (listeners[t] || []).filter((f) => f !== fn)),
};
Object.defineProperty(window, 'visualViewport', { value: vv, configurable: true });
Object.defineProperty(window, 'innerHeight', { value: 844, configurable: true });
return {
open(keyboard: number) {
vv.height = 844 - keyboard;
(listeners.resize || []).forEach((fn) => fn());
},
};
}
describe('useKeyboardInset', () => {
it('reports the keyboard height so sheets can sit above it', () => {
const vv = mockVisualViewport(844);
const { result } = renderHook(() => useKeyboardInset());
expect(result.current.inset).toBe(0);
act(() => vv.open(336));
expect(result.current.inset).toBe(336);
expect(result.current.visibleHeight).toBe(508);
act(() => vv.open(0));
expect(result.current.inset).toBe(0);
});
});
+169 -77
View File
@@ -3,6 +3,8 @@ import {
AuthStatus,
DashboardStats,
Document,
TrashedDocument,
Backlink,
DocumentBlock,
DocShare,
KanbanColumn,
@@ -19,6 +21,7 @@ import {
SystemSettings,
SharingMode,
UserTheme,
ImportSummary,
Team,
TaskList,
TaskListShare,
@@ -32,9 +35,18 @@ import {
WSMessage,
} from './types';
import { extractHashtags } from './hashtags';
import {
OfflineError,
isOffline,
isCacheablePath,
readCached,
writeCached,
clearOfflineData,
enqueue,
flushOutbox,
} from './offline';
const TOKEN_STORAGE_KEY = 'mindsift_jwt_token';
const OFFLINE_QUEUE_KEY = 'mindsift_offline_capture_queue';
export function getToken(): string | null {
return localStorage.getItem(TOKEN_STORAGE_KEY);
@@ -59,6 +71,8 @@ export function setToken(token: string): void {
export function clearToken(): void {
localStorage.removeItem(TOKEN_STORAGE_KEY);
// Cached answers and unsent changes belong to the account that just left
void clearOfflineData();
}
export function triggerHaptic(pattern: number | number[] = 15): void {
@@ -77,6 +91,15 @@ function ensureArray<T>(val: T[] | null | undefined): T[] {
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken();
const method = (options.method || 'GET').toUpperCase();
const cacheable = method === 'GET' && isCacheablePath(path);
const userKey = getCurrentUserId() || 'anon';
// Known to be offline: answer from the cache right away instead of waiting for the timeout
if (cacheable && isOffline()) {
const cached = await readCached<T>(userKey, path);
if (cached !== undefined) return cached;
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache',
@@ -98,6 +121,13 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
cache: 'no-store',
signal: controller.signal,
});
} catch {
// No HTTP answer at all (offline, server down, timeout): fall back to the last known answer
if (cacheable) {
const cached = await readCached<T>(userKey, path);
if (cached !== undefined) return cached;
}
throw new OfflineError();
} finally {
window.clearTimeout(timeoutId);
}
@@ -124,7 +154,20 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
throw new Error(errMsg);
}
return res.json();
const data = await res.json();
if (cacheable) writeCached(userKey, path, data);
return data;
}
// Sends a queued outbox entry with the current token (no timeout, no cache)
function sendOutboxEntry(entry: { method: string; path: string; body: Record<string, unknown> }): Promise<Response> {
const token = getToken();
return fetch(entry.path, {
method: entry.method,
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify(entry.body),
cache: 'no-store',
});
}
// Server-wide sharing mode from /auth/status; in private mode the UI hides sharing and groups
@@ -134,6 +177,14 @@ export const setCurrentSharingMode = (mode: SharingMode) => {
};
export const isSharingDisabled = () => currentSharingMode === 'private';
// Authenticated binary download (archives); no JSON parsing and no request timeout
async function downloadBlob(path: string): Promise<Blob> {
const token = getToken();
const res = await fetch(path, { headers: token ? { Authorization: `Bearer ${token}` } : {}, cache: 'no-store' });
if (!res.ok) throw new Error(`Download fehlgeschlagen (HTTP ${res.status})`);
return res.blob();
}
export const api = {
// --- AUTH, FIRST-BOOT SETUP & SETTINGS ---
async getAuthStatus(): Promise<AuthStatus> {
@@ -177,6 +228,15 @@ export const api = {
return res;
},
// Public: always the same answer, whether the address exists or not
async requestPasswordReset(email: string): Promise<void> {
await request('/api/v1/auth/forgot-password', { method: 'POST', body: JSON.stringify({ email }) });
},
async resetPassword(token: string, password: string): Promise<void> {
await request('/api/v1/auth/reset-password', { method: 'POST', body: JSON.stringify({ token, password }) });
},
async login(loginOrEmail: string, password: string): Promise<{ token: string; user: User }> {
const res = await request<{ token: string; user: User }>('/api/v1/auth/login', {
method: 'POST',
@@ -200,8 +260,8 @@ export const api = {
return res;
},
async getMe(): Promise<{ user: User; stats: DashboardStats }> {
return request<{ user: User; stats: DashboardStats }>('/api/v1/auth/me');
async getMe(): Promise<{ user: User; stats: DashboardStats; isLastAdmin?: boolean }> {
return request<{ user: User; stats: DashboardStats; isLastAdmin?: boolean }>('/api/v1/auth/me');
},
async updateSettings(patch: {
@@ -252,26 +312,24 @@ export const api = {
taskListId?: string;
}
): Promise<Thought> {
const body = {
content,
priority,
source,
dueDate,
target: options?.target || 'inbox',
targetListId: options?.targetListId || '',
taskListId: options?.taskListId || '',
};
try {
const thought = await request<Thought>('/api/v1/inbox', {
method: 'POST',
body: JSON.stringify({
content,
priority,
source,
dueDate,
target: options?.target || 'inbox',
targetListId: options?.targetListId || '',
taskListId: options?.taskListId || '',
}),
});
return thought;
} catch {
// Queue locally if offline
const queue = getOfflineQueue();
const offlineThought: Thought = {
id: `th_offline_${Date.now()}`,
userId: 'offline',
return await request<Thought>('/api/v1/inbox', { method: 'POST', body: JSON.stringify(body) });
} catch (err) {
if (!(err instanceof OfflineError)) throw err;
// Kept in the outbox and sent when the connection is back
const entry = enqueue('POST', '/api/v1/inbox', body);
return {
id: `th_offline_${entry.id}`,
userId: getCurrentUserId() || 'offline',
content,
tags: Array.from(new Set(extractHashtags(content))),
priority,
@@ -280,41 +338,15 @@ export const api = {
isCompleted: false,
dueDate,
taskListId: options?.taskListId || '',
createdAt: new Date().toISOString(),
createdAt: entry.createdAt,
};
queue.push(offlineThought);
localStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(queue));
return offlineThought;
}
},
/** Sends what was captured or checked off while offline; returns how many changes went through. */
async flushOfflineQueue(): Promise<number> {
const queue = getOfflineQueue();
if (queue.length === 0) return 0;
let synced = 0;
const remaining: Thought[] = [];
for (const item of queue) {
try {
await request<Thought>('/api/v1/inbox', {
method: 'POST',
body: JSON.stringify({
content: item.content,
priority: item.priority,
source: item.source,
dueDate: item.dueDate || '',
taskListId: item.taskListId || '',
}),
});
synced++;
} catch {
remaining.push(item);
}
}
localStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(remaining));
return synced;
if (!getToken()) return 0;
return flushOutbox(sendOutboxEntry);
},
// --- THOUGHTS, TRIAGE & TASKS ---
@@ -381,12 +413,21 @@ export const api = {
subtasks?: SubTask[];
tags?: string[];
assignees?: string[];
recurrence?: string;
}
): Promise<Thought> {
return request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
method: 'PATCH',
body: JSON.stringify(patch),
});
try {
return await request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
method: 'PATCH',
body: JSON.stringify(patch),
});
} catch (err) {
// Checking a task off (or on) also works offline; other edits need the server
const onlyStatus = Object.keys(patch).every((k) => k === 'isCompleted' || k === 'kanbanStatus');
if (!(err instanceof OfflineError) || !onlyStatus) throw err;
enqueue('PATCH', `/api/v1/thoughts/${thoughtId}`, patch);
return { id: thoughtId, ...patch } as Thought;
}
},
async deleteThought(thoughtId: string): Promise<void> {
@@ -637,10 +678,31 @@ export const api = {
});
},
/** Moves the note to the trash (owner) or leaves a shared note (others). */
async deleteDocument(id: string): Promise<void> {
await request(`/api/v1/documents/${id}`, { method: 'DELETE' });
},
async getBacklinks(id: string): Promise<Backlink[]> {
return request<Backlink[]>(`/api/v1/documents/${id}/backlinks`);
},
async getTrash(): Promise<TrashedDocument[]> {
return request<TrashedDocument[]>('/api/v1/documents/trash');
},
async restoreDocument(id: string): Promise<void> {
await request(`/api/v1/documents/${id}/restore`, { method: 'POST' });
},
async purgeDocument(id: string): Promise<void> {
await request(`/api/v1/documents/${id}/permanent`, { method: 'DELETE' });
},
async emptyTrash(): Promise<{ deleted: number }> {
return request<{ deleted: number }>('/api/v1/documents/trash', { method: 'DELETE' });
},
async moveDocument(id: string, folderPath: string): Promise<Document> {
const d = await request<Document>(`/api/v1/documents/${id}/move`, {
method: 'PUT',
@@ -811,10 +873,16 @@ export const api = {
},
async toggleListItem(listId: string, itemId: string, isCompleted?: boolean): Promise<ListItem> {
return request<ListItem>(`/api/v1/lists/${listId}/items/${itemId}`, {
method: 'PATCH',
body: JSON.stringify({ isCompleted }),
});
try {
return await request<ListItem>(`/api/v1/lists/${listId}/items/${itemId}`, {
method: 'PATCH',
body: JSON.stringify({ isCompleted }),
});
} catch (err) {
if (!(err instanceof OfflineError) || isCompleted === undefined) throw err;
enqueue('PATCH', `/api/v1/lists/${listId}/items/${itemId}`, { isCompleted });
return { id: itemId, listId, isCompleted } as ListItem;
}
},
async updateListItem(
@@ -1076,27 +1144,51 @@ export const api = {
return request<any>('/api/v1/export');
},
// Import of MindSift data (JSON backup or Markdown archive) or an Obsidian vault (ZIP).
// preview = true only reports what would be created.
// mode 'restore' (MindSift data only) deletes the user's notes, tasks and lists before importing
async importData(
source: 'mindsift' | 'obsidian',
file: File,
preview: boolean,
mode: 'merge' | 'restore' = 'merge'
): Promise<ImportSummary> {
const token = getToken();
const form = new FormData();
form.append('file', file);
const params = new URLSearchParams();
if (preview) params.set('preview', '1');
if (mode === 'restore') params.set('mode', 'restore');
const res = await fetch(`/api/v1/import/${source}${params.toString() ? `?${params}` : ''}`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Import fehlgeschlagen (HTTP ${res.status})`);
return data as ImportSummary;
},
// Danger zone: both need the current password
async purgeOwnData(password: string): Promise<void> {
await request('/api/v1/user/data', { method: 'DELETE', body: JSON.stringify({ password }) });
},
async deleteOwnAccount(password: string): Promise<void> {
await request('/api/v1/user/account', { method: 'DELETE', body: JSON.stringify({ password }) });
},
// Full backup (all data as JSON) packed into a ZIP; restorable via importData('mindsift', …)
async exportBackupZip(): Promise<Blob> {
return downloadBlob('/api/v1/export?format=zip');
},
// Obsidian-compatible ZIP of the workspace; binary and possibly large, so no JSON helper and no timeout
async exportMarkdownVault(): Promise<Blob> {
const token = getToken();
const res = await fetch('/api/v1/export/markdown', {
headers: token ? { Authorization: `Bearer ${token}` } : {},
cache: 'no-store',
});
if (!res.ok) throw new Error(`Export fehlgeschlagen (HTTP ${res.status})`);
return res.blob();
return downloadBlob('/api/v1/export/markdown');
},
};
export function getOfflineQueue(): Thought[] {
try {
const raw = localStorage.getItem(OFFLINE_QUEUE_KEY);
if (!raw) return [];
return JSON.parse(raw);
} catch {
return [];
}
}
export type RealtimeTransport = 'ws' | 'sse' | 'none';
+50
View File
@@ -0,0 +1,50 @@
// Due dates come as "YYYY-MM-DD" (all day) or "YYYY-MM-DDTHH:MM" (with time), in the server's time zone.
// Both sort correctly as plain strings.
const pad = (n: number) => String(n).padStart(2, '0');
/** Local calendar day as YYYY-MM-DD (toISOString would give the UTC day). */
export const localDay = (d: Date = new Date()): string => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
export const addDays = (days: number, from: Date = new Date()): string => {
const d = new Date(from);
d.setDate(d.getDate() + days);
return localDay(d);
};
export const splitDue = (due?: string): { date: string; time: string } => {
if (!due) return { date: '', time: '' };
const [date, time = ''] = due.split('T');
return { date, time: time.slice(0, 5) };
};
export const joinDue = (date: string, time: string): string => (date ? (time ? `${date}T${time}` : date) : '');
/** A task with a time is overdue once that minute has passed, an all-day task from the next day on. */
export const isOverdue = (due: string | undefined, now: Date = new Date()): boolean => {
if (!due) return false;
const { date, time } = splitDue(due);
const today = localDay(now);
if (date !== today) return date < today;
return !!time && time < `${pad(now.getHours())}:${pad(now.getMinutes())}`;
};
export const isDueToday = (due: string | undefined, now: Date = new Date()): boolean =>
!!due && splitDue(due).date === localDay(now);
const WEEKDAYS = ['So.', 'Mo.', 'Di.', 'Mi.', 'Do.', 'Fr.', 'Sa.'];
/** Short label: "Heute, 14:30", "Morgen", "Gestern", "Fr., 30.10." or "30.10.2027" in another year. */
export const formatDue = (due: string | undefined, now: Date = new Date()): string => {
if (!due) return '';
const { date, time } = splitDue(due);
const [y, m, d] = date.split('-').map(Number);
if (!y || !m || !d) return due;
let label: string;
if (date === localDay(now)) label = 'Heute';
else if (date === addDays(1, now)) label = 'Morgen';
else if (date === addDays(-1, now)) label = 'Gestern';
else if (y === now.getFullYear()) label = `${WEEKDAYS[new Date(y, m - 1, d).getDay()]}, ${pad(d)}.${pad(m)}.`;
else label = `${pad(d)}.${pad(m)}.${y}`;
return time ? `${label}, ${time}` : label;
};
+183
View File
@@ -0,0 +1,183 @@
// Offline support for the REST layer (lib/api.ts):
// - Read cache: successful GET responses are kept in IndexedDB per user. Without network the last
// known answer is returned, so thoughts, tasks and lists stay visible offline.
// - Outbox: a few mutations (capturing a thought, checking off a task or list item) are queued in
// localStorage while offline and sent in order once the connection is back.
// Notes have their own offline store in lib/docSync.
/** Thrown by request() when the server could not be reached at all (no HTTP answer). */
export class OfflineError extends Error {
constructor() {
super('Keine Verbindung zum Server');
this.name = 'OfflineError';
}
}
export const isOffline = () => typeof navigator !== 'undefined' && navigator.onLine === false;
// --- Read cache (IndexedDB) ---
const DB_NAME = 'mindsift-offline';
const STORE = 'responses';
// Endpoints whose answers are worth keeping; streams, long polls, note sync and downloads are not
const NOT_CACHED = [/^\/api\/v1\/(events|ws)/, /\/changes/, /\/sync/, /^\/api\/v1\/export/, /^\/api\/v1\/search/];
export const isCacheablePath = (path: string) => path.startsWith('/api/v1/') && !NOT_CACHED.some((re) => re.test(path));
let dbPromise: Promise<IDBDatabase | null> | null = null;
function openDB(): Promise<IDBDatabase | null> {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve) => {
try {
if (typeof indexedDB === 'undefined') return resolve(null);
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
req.onsuccess = () => resolve(req.result);
req.onerror = () => resolve(null);
} catch {
resolve(null);
}
});
return dbPromise;
}
async function withStore<T>(mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest<T> | void): Promise<T | undefined> {
const db = await openDB();
if (!db) return undefined;
return new Promise((resolve) => {
try {
const tx = db.transaction(STORE, mode);
const req = fn(tx.objectStore(STORE));
tx.oncomplete = () => resolve(req ? (req.result as T) : undefined);
tx.onerror = () => resolve(undefined);
tx.onabort = () => resolve(undefined);
} catch {
resolve(undefined);
}
});
}
// Cached answers belong to one account: the key starts with the user ID from the token
function cacheKey(userKey: string, path: string) {
return `${userKey}|${path}`;
}
export async function readCached<T>(userKey: string, path: string): Promise<T | undefined> {
return withStore<T>('readonly', (s) => s.get(cacheKey(userKey, path)));
}
export function writeCached(userKey: string, path: string, value: unknown): void {
void withStore('readwrite', (s) => {
s.put(value, cacheKey(userKey, path));
});
}
export async function clearOfflineData(): Promise<void> {
try {
localStorage.removeItem(OUTBOX_KEY);
localStorage.removeItem(LEGACY_QUEUE_KEY);
} catch {}
notifyOutbox();
await withStore('readwrite', (s) => {
s.clear();
});
}
// --- Outbox (localStorage) ---
export interface OutboxEntry {
id: string;
method: 'POST' | 'PATCH';
path: string;
body: Record<string, unknown>;
createdAt: string;
}
const OUTBOX_KEY = 'mindsift_outbox';
const LEGACY_QUEUE_KEY = 'mindsift_offline_capture_queue'; // thoughts captured offline before the outbox existed
function notifyOutbox() {
if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('mindsift:outbox-changed'));
}
export function getOutbox(): OutboxEntry[] {
try {
const entries: OutboxEntry[] = JSON.parse(localStorage.getItem(OUTBOX_KEY) || '[]');
const legacy = JSON.parse(localStorage.getItem(LEGACY_QUEUE_KEY) || '[]');
if (Array.isArray(legacy) && legacy.length > 0) {
for (const t of legacy) {
entries.push({
id: t.id,
method: 'POST',
path: '/api/v1/inbox',
body: { content: t.content, priority: t.priority, source: t.source, dueDate: t.dueDate || '', taskListId: t.taskListId || '' },
createdAt: t.createdAt,
});
}
localStorage.setItem(OUTBOX_KEY, JSON.stringify(entries));
localStorage.removeItem(LEGACY_QUEUE_KEY);
}
return Array.isArray(entries) ? entries : [];
} catch {
return [];
}
}
function saveOutbox(entries: OutboxEntry[]) {
try {
localStorage.setItem(OUTBOX_KEY, JSON.stringify(entries));
} catch {}
notifyOutbox();
}
/**
* Queues a request. A PATCH to the same path replaces the earlier one (checking a task off and on
* again while offline sends only the final state).
*/
export function enqueue(method: OutboxEntry['method'], path: string, body: Record<string, unknown>): OutboxEntry {
const entries = getOutbox();
const entry: OutboxEntry = {
id: `ob_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
method,
path,
body,
createdAt: new Date().toISOString(),
};
const same = method === 'PATCH' ? entries.findIndex((e) => e.method === 'PATCH' && e.path === path) : -1;
if (same >= 0) entries[same] = { ...entry, body: { ...entries[same].body, ...body } };
else entries.push(entry);
saveOutbox(entries);
return entry;
}
export const outboxCount = () => getOutbox().length;
let flushing: Promise<number> | null = null;
/**
* Sends the queued requests in order. Stops at the first network failure and keeps the rest; entries
* the server rejects (4xx, e.g. the task was deleted meanwhile) are dropped. Returns how many went through.
*/
export function flushOutbox(send: (e: OutboxEntry) => Promise<Response>): Promise<number> {
if (flushing) return flushing;
flushing = (async () => {
let synced = 0;
for (const entry of getOutbox()) {
let res: Response;
try {
res = await send(entry);
} catch {
break; // still offline
}
if (res.status === 401 || res.status === 408 || res.status === 429 || res.status >= 500) break;
saveOutbox(getOutbox().filter((e) => e.id !== entry.id));
if (res.ok) synced++;
}
return synced;
})().finally(() => {
flushing = null;
});
return flushing;
}
+34
View File
@@ -68,6 +68,28 @@ export const AVAILABLE_THEMES: ThemeOption[] = [
previewText: '#f8fafc',
isRounded: true,
},
{
id: 'polarized',
name: 'Polarized (Ultraviolet)',
subtitle: 'Tiefdunkles Obsidian mit elektrischem Violett (0px)',
file: 'src/themes/theme-polarized.css',
previewBg: '#0d0b14',
previewPanel: '#151221',
previewAccent: '#a855f7',
previewText: '#f5f3ff',
isRounded: false,
},
{
id: 'suri',
name: 'Suri (Velvet Dusk)',
subtitle: 'Warmes Mitternachts-Pflaume mit samtiger Orchidee (10px)',
file: 'src/themes/theme-suri.css',
previewBg: '#130f1a',
previewPanel: '#1e1728',
previewAccent: '#b86bf8',
previewText: '#fbf7ff',
isRounded: true,
},
];
export const isCustomThemeId = (id: ThemeId) => id.startsWith(CUSTOM_THEME_PREFIX);
@@ -132,6 +154,18 @@ function applyToDocument(themeId: ThemeId, customCss: string | null): void {
root.setAttribute('data-theme', attr);
const light = customCss !== null ? isLightColor(readCssVar(customCss, 'bg-app')) : themeId === 'light';
root.classList.toggle('dark', !light);
syncThemeColorMeta();
}
// The status bar (theme-color) starts blue for the splash screen; once the app runs it follows the
// active theme's background so it blends with the header.
function syncThemeColorMeta(): void {
const meta = document.querySelector('meta[name="theme-color"]');
if (!meta || typeof requestAnimationFrame === 'undefined') return;
requestAnimationFrame(() => {
const bg = getComputedStyle(document.documentElement).getPropertyValue('--bg-app').trim();
if (bg) meta.setAttribute('content', bg);
});
}
function store(key: string, value: string | null): void {
+48 -1
View File
@@ -8,6 +8,8 @@ export interface AuthStatus {
instanceName: string;
demoMode?: boolean;
sharingMode?: SharingMode;
// "Passwort vergessen?" works (SMTP and BASE_URL configured)
passwordReset?: boolean;
// Impressum / Datenschutz of this instance (LEGAL_* environment variables)
legal?: {
imprintUrl?: string;
@@ -57,6 +59,16 @@ export interface SubTask {
createdAt?: string;
}
// Repeat rule of a task: completing it creates the next one (due date one interval later)
export type Recurrence = '' | 'daily' | 'weekly' | 'monthly' | 'yearly';
export const RECURRENCE_LABELS: Record<Exclude<Recurrence, ''>, string> = {
daily: 'täglich',
weekly: 'wöchentlich',
monthly: 'monatlich',
yearly: 'jährlich',
};
export interface Thought {
id: string;
userId: string;
@@ -76,6 +88,7 @@ export interface Thought {
notes?: string;
subtasks?: SubTask[];
assignees?: string[]; // user IDs, only people with access to the task list
recurrence?: Recurrence;
alarmSent?: boolean;
sortOrder?: number;
createdAt: string;
@@ -161,12 +174,13 @@ export interface SystemSettings {
allowRegistration: boolean;
instanceName: string;
smtpHost?: string;
smtpPort?: number;
smtpPort?: string; // sent as text, like the server stores it
smtpUser?: string;
smtpPass?: string;
smtpFrom?: string;
smtpTls?: boolean;
smtpSkipVerify?: boolean;
allowPrivateWebhooks?: boolean; // alarm webhooks may reach the LAN (and plain http)
smtpConfiguredViaEnv?: boolean;
}
@@ -220,6 +234,26 @@ export interface DocShare {
createdAt: string;
}
/** A note linking to the current one via [[Title]] (GET /documents/{id}/backlinks). */
export interface Backlink {
docId: string;
title: string;
icon: string;
folderPath: string;
blocks: { id: string; content: string }[];
more?: number;
}
/** A note in the trash (GET /documents/trash). */
export interface TrashedDocument {
id: string;
title: string;
icon: string;
folderPath: string;
deletedAt: string;
isOwner: boolean;
}
export interface Document {
id: string;
userId: string;
@@ -417,6 +451,19 @@ export interface SharedList {
createdAt: string;
}
// Result of an import preview (applied = false) or of the import itself
export interface ImportSummary {
folders: number;
notes: number;
taskLists: number;
tasks: number;
lists: number;
listItems: number;
inbox: number;
skipped: { path: string; reason: string }[];
applied: boolean;
}
// Custom CSS theme of the current user (Benutzereinstellungen > Darstellung)
export interface UserTheme {
id: string;
+27
View File
@@ -0,0 +1,27 @@
import { useEffect, useState } from 'react';
// Space the on-screen keyboard takes at the bottom (0 without keyboard) and the height that stays
// visible. Fixed bottom sheets use it to sit right above the keyboard instead of behind it (iOS keeps
// fixed elements on the layout viewport; on Android the inset is usually 0 because the page shrinks).
export function useKeyboardInset(): { inset: number; visibleHeight: number } {
const read = () => {
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
if (!vv) return { inset: 0, visibleHeight: typeof window !== 'undefined' ? window.innerHeight : 0 };
return { inset: Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)), visibleHeight: Math.round(vv.height) };
};
const [state, setState] = useState(read);
useEffect(() => {
const vv = window.visualViewport;
if (!vv) return;
const update = () => setState(read());
vv.addEventListener('resize', update);
vv.addEventListener('scroll', update);
update();
return () => {
vv.removeEventListener('resize', update);
vv.removeEventListener('scroll', update);
};
}, []);
return state;
}
+55
View File
@@ -0,0 +1,55 @@
/* ==========================================================================
MINDSIFT THEME: POLARIZED (Ultraviolet / Obsidian)
Tiefdunkles Obsidian mit elektrischem Violett-Akzent & scharfen Kanten.
========================================================================== */
:root[data-theme="polarized"] {
/* --- 1. AKZENTFARBE (Elektrisches Ultraviolett) --- */
--accent-primary: #a855f7;
--accent-hover: #c084fc;
--accent-dim: rgba(168, 85, 247, 0.15);
--accent-border: rgba(192, 132, 252, 0.45);
--text-on-accent: #ffffff;
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE --- */
--bg-app: #0d0b14;
--bg-panel: #151221;
--bg-panel-elevated: #1d182e;
--bg-panel-hover: #26203c;
--bg-input: #0f0d18;
--bg-overlay: rgba(6, 4, 10, 0.78);
--bg-code: #08060d;
/* --- 3. TEXT & KONTRASTE --- */
--text-main: #f5f3ff;
--text-muted: #a79fc2;
--text-faint: #6d6585;
/* --- 4. RAHMEN, LINIEN & SCHATTEN --- */
--border-color: rgba(168, 85, 247, 0.14);
--border-subtle: rgba(168, 85, 247, 0.07);
--border-strong: rgba(192, 132, 252, 0.30);
--shadow-panel: 0 10px 30px rgba(5, 3, 10, 0.55);
--scrollbar-thumb: rgba(168, 85, 247, 0.22);
--scrollbar-thumb-hover: rgba(192, 132, 252, 0.40);
/* --- 5. STATUS- & SIGNALFARBEN --- */
--color-success: #10b981;
--bg-success: rgba(16, 185, 129, 0.14);
--border-success: rgba(16, 185, 129, 0.38);
--color-danger: #f43f5e;
--bg-danger: rgba(244, 63, 94, 0.14);
--border-danger: rgba(244, 63, 94, 0.38);
--color-warning: #f59e0b;
--bg-warning: rgba(245, 158, 11, 0.14);
--border-warning: rgba(245, 158, 11, 0.38);
/* --- 6. RUNDUNGEN (Geometrie: Klar & eckig) --- */
--radius-card: 0px;
--radius-btn: 2px;
--radius-input: 2px;
--radius-checkbox: 0px;
--radius-pill: 9999px;
}
+55
View File
@@ -0,0 +1,55 @@
/* ==========================================================================
MINDSIFT THEME: SURI (Velvet Dusk / Orchid)
Warmes Mitternachts-Pflaume mit samtigem Orchidee-Akzent & weichen Ecken.
========================================================================== */
:root[data-theme="suri"] {
/* --- 1. AKZENTFARBE (Samtige Orchidee & Magenta-Violett) --- */
--accent-primary: #b86bf8;
--accent-hover: #cf94fd;
--accent-dim: rgba(184, 107, 248, 0.16);
--accent-border: rgba(207, 148, 253, 0.42);
--text-on-accent: #ffffff;
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE --- */
--bg-app: #130f1a;
--bg-panel: #1e1728;
--bg-panel-elevated: #282036;
--bg-panel-hover: #332944;
--bg-input: #15101d;
--bg-overlay: rgba(11, 8, 16, 0.76);
--bg-code: #0d0a13;
/* --- 3. TEXT & KONTRASTE --- */
--text-main: #fbf7ff;
--text-muted: #b5a8cb;
--text-faint: #776b8d;
/* --- 4. RAHMEN, LINIEN & SCHATTEN --- */
--border-color: rgba(207, 148, 253, 0.14);
--border-subtle: rgba(207, 148, 253, 0.07);
--border-strong: rgba(207, 148, 253, 0.28);
--shadow-panel: 0 12px 32px rgba(8, 5, 12, 0.50);
--scrollbar-thumb: rgba(184, 107, 248, 0.20);
--scrollbar-thumb-hover: rgba(207, 148, 253, 0.36);
/* --- 5. STATUS- & SIGNALFARBEN --- */
--color-success: #34d399;
--bg-success: rgba(52, 211, 153, 0.14);
--border-success: rgba(52, 211, 153, 0.38);
--color-danger: #fb7185;
--bg-danger: rgba(251, 113, 133, 0.14);
--border-danger: rgba(251, 113, 133, 0.38);
--color-warning: #fbbf24;
--bg-warning: rgba(251, 191, 36, 0.14);
--border-warning: rgba(251, 191, 36, 0.38);
/* --- 6. RUNDUNGEN (Weich gerundete Ecken) --- */
--radius-card: 10px;
--radius-btn: 6px;
--radius-input: 6px;
--radius-checkbox: 4px;
--radius-pill: 9999px;
}
+29
View File
@@ -0,0 +1,29 @@
package api
import (
"testing"
"time"
)
func TestAlarmDue(t *testing.T) {
berlin, _ := time.LoadLocation("Europe/Berlin")
at := func(h, m int) time.Time { return time.Date(2026, 10, 30, h, m, 0, 0, berlin) }
cases := []struct {
due string
before int
now time.Time
want bool
}{
{"2026-10-30", 0, at(8, 59), false},
{"2026-10-30", 0, at(9, 0), true},
{"2026-10-30T14:30", 0, at(14, 29), false},
{"2026-10-30T14:30", 30, at(14, 0), true},
{"2026-10-29", 0, at(0, 1), true},
{"bald", 0, at(23, 0), false},
}
for _, c := range cases {
if got := alarmDue(c.due, c.before, c.now); got != c.want {
t.Errorf("alarmDue(%q, %d, %v) = %v, want %v", c.due, c.before, c.now.Format("15:04"), got, c.want)
}
}
}
+157
View File
@@ -0,0 +1,157 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/sandrodach/mindsift/internal/domain"
)
// --- MULTI-CHANNEL ALARM DISPATCHER (EMAIL, DISCORD, CUSTOM WEBHOOK) ---
func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error {
cfg, err := s.smtpSettings()
if err != nil {
return err
}
if err := sendMail(cfg, toEmail, subject, bodyText); err != nil {
step := ""
if se, ok := err.(*smtpStepError); ok {
step = se.Step
}
slog.Warn("E-Mail-Versand fehlgeschlagen", "smtp_step", step, "host", cfg.Host, "port", cfg.Port, "to", toEmail, "error", err)
return err
}
return nil
}
func (s *Server) sendDiscordNotification(webhookURL, title, description, dueDate, priority string) error {
allowPrivate := s.allowPrivateWebhooks()
if err := validateWebhookURL(webhookURL, true, allowPrivate); err != nil {
return err
}
httpClient := guardedClient(allowPrivate)
payload := map[string]interface{}{
"content": "⏰ **MindSift Aufgaben-Alarm**",
"embeds": []map[string]interface{}{
{
"title": title,
"description": fmt.Sprintf("%s\n\n**Fällig:** %s | **Priorität:** %s", description, dueDate, priority),
"color": 2450411,
"footer": map[string]string{
"text": "MindSift Alarm-System",
},
},
},
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
resp, err := httpClient.Post(webhookURL, "application/json", bytes.NewReader(body))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("HTTP Status %d", resp.StatusCode)
}
return nil
}
func (s *Server) sendCustomWebhookNotification(webhookURL, event, title, notes, dueDate, priority string) error {
allowPrivate := s.allowPrivateWebhooks()
if err := validateWebhookURL(webhookURL, false, allowPrivate); err != nil {
return err
}
httpClient := guardedClient(allowPrivate)
payload := map[string]interface{}{
"event": event,
"title": title,
"notes": notes,
"dueDate": dueDate,
"priority": priority,
"timestamp": domain.NowISO(),
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
resp, err := httpClient.Post(webhookURL, "application/json", bytes.NewReader(body))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("HTTP Status %d", resp.StatusCode)
}
return nil
}
// alarmDue reports whether the alarm for a due date should go out now: from the due time (09:00 for
// all-day tasks) minus the user's lead time on. Unreadable dates never fire.
func alarmDue(dueDate string, notifyBeforeMinutes int, now time.Time) bool {
at, ok := domain.DueAlarmAt(dueDate, notifyBeforeMinutes, now.Location())
return ok && !now.Before(at)
}
func (s *Server) startAlarmTicker() {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
for range ticker.C {
alarms, err := s.store.GetDueTasksForAlarm()
if err != nil || len(alarms) == 0 {
continue
}
now := time.Now()
for _, a := range alarms {
if !alarmDue(a.DueDate, a.NotifyBeforeMinutes, now) {
continue
}
if len(a.AlarmChannels) == 0 {
continue
}
sentAny := false
due := domain.HumanDue(a.DueDate)
desc := a.Notes
if desc == "" {
desc = "Aufgabe ist jetzt fällig."
}
for _, ch := range a.AlarmChannels {
switch ch {
case "discord":
if a.DiscordWebhookURL != "" {
if err := s.sendDiscordNotification(a.DiscordWebhookURL, a.Title, desc, due, a.Priority); err == nil {
sentAny = true
}
}
case "webhook":
if a.CustomWebhookURL != "" {
if err := s.sendCustomWebhookNotification(a.CustomWebhookURL, "task_due", a.Title, desc, a.DueDate, a.Priority); err == nil {
sentAny = true
}
}
case "email":
if a.AlarmEmail != "" {
emailBody := fmt.Sprintf("Hallo,\n\ndeine Aufgabe in MindSift ist fällig:\n\nAufgabe: %s\nFällig am: %s\nPriorität: %s\nNotizen: %s\n", a.Title, due, a.Priority, desc)
if err := s.sendEmailNotification(a.AlarmEmail, "⏰ Aufgabe fällig: "+a.Title, emailBody); err == nil {
sentAny = true
}
}
}
}
if sentAny {
_ = s.store.MarkTaskAlarmSent(a.TaskID)
}
}
}
}
+158
View File
@@ -0,0 +1,158 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
"github.com/sandrodach/mindsift/internal/storage"
)
// --- CUSTOM THEMES ---
func (s *Server) handleGetThemes(w http.ResponseWriter, r *http.Request) {
themes, err := s.store.GetUserThemes(auth.GetUserID(r.Context()))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, themes)
}
// handleSaveTheme creates a theme, or updates it when an id of the caller's theme is given.
func (s *Server) handleSaveTheme(w http.ResponseWriter, r *http.Request) {
var req struct {
ID string `json:"id"`
Name string `json:"name"`
CSS string `json:"css"`
}
r.Body = http.MaxBytesReader(w, r.Body, storage.MaxThemeCSSBytes+16*1024)
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage oder Theme zu groß")
return
}
theme, err := s.store.SaveUserTheme(auth.GetUserID(r.Context()), strings.TrimSpace(req.ID), req.Name, req.CSS)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
status := http.StatusCreated
if req.ID != "" {
status = http.StatusOK
}
writeJSON(w, status, theme)
}
func (s *Server) handleDeleteTheme(w http.ResponseWriter, r *http.Request) {
if err := s.store.DeleteUserTheme(auth.GetUserID(r.Context()), chi.URLParam(r, "id")); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// confirmPassword checks the current password sent as {"password": "…"} before destructive actions.
func (s *Server) confirmPassword(w http.ResponseWriter, r *http.Request) (*domain.User, bool) {
var req struct {
Password string `json:"password"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
user, err := s.store.GetUserByID(auth.GetUserID(r.Context()))
if err != nil || user == nil {
writeError(w, http.StatusUnauthorized, "Nicht autorisiert")
return nil, false
}
if req.Password == "" || !auth.CheckPassword(req.Password, user.PasswordHash) {
writeError(w, http.StatusForbidden, "Passwort ist falsch")
return nil, false
}
return user, true
}
// runImport answers a preview (?preview=1) with what would be created, otherwise imports.
func (s *Server) runImport(w http.ResponseWriter, r *http.Request, plan *storage.ImportPlan, err error) {
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if r.URL.Query().Get("preview") == "1" {
writeJSON(w, http.StatusOK, plan.Summary(false))
return
}
userID := auth.GetUserID(r.Context())
// mode=restore (MindSift data only): replace everything instead of adding next to it. The plan is
// fully read and validated at this point, so a broken file never wipes anything.
if r.URL.Query().Get("mode") == "restore" && strings.HasSuffix(r.URL.Path, "/mindsift") {
if err := s.store.PurgeUserData(userID); err != nil {
writeError(w, http.StatusInternalServerError, "Wiederherstellen abgebrochen: "+err.Error())
return
}
}
sum, err := s.store.ApplyImport(userID, plan)
if err != nil {
slog.Error("Import abgebrochen", "user_id", userID, "error", err)
writeError(w, http.StatusInternalServerError, "Import abgebrochen: "+err.Error())
return
}
writeJSON(w, http.StatusOK, sum)
}
// handleImportMindSift restores MindSift's own data: the JSON backup or the Markdown archive (ZIP).
func (s *Server) handleImportMindSift(w http.ResponseWriter, r *http.Request) {
data, name, err := importUpload(w, r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
plan, err := storage.PlanMindSiftImport(data, name)
s.runImport(w, r, plan, err)
}
// handleImportObsidian imports an Obsidian vault (ZIP) into a folder named after the vault.
func (s *Server) handleImportObsidian(w http.ResponseWriter, r *http.Request) {
data, name, err := importUpload(w, r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
plan, err := storage.PlanObsidianImport(bytes.NewReader(data), int64(len(data)), name)
s.runImport(w, r, plan, err)
}
// handleExportMarkdown streams an Obsidian-compatible ZIP of the user's workspace (no temp files).
func (s *Server) handleExportMarkdown(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="mindsift-vault-%s.zip"`, time.Now().Format("2006-01-02")))
if err := s.store.WriteMarkdownVault(userID, w); err != nil {
// Headers are already sent; the client gets a truncated archive
slog.Error("Markdown-Export fehlgeschlagen", "user_id", userID, "error", err)
}
}
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
exportData, err := s.store.ExportUserData(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// ?format=zip: the same JSON compressed (backups grow quickly with notes); plain JSON stays the API default
if r.URL.Query().Get("format") == "zip" {
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="mindsift-backup-%s.zip"`, time.Now().Format("2006-01-02")))
if err := storage.WriteBackupZip(exportData, w); err != nil {
slog.Error("Backup-Export fehlgeschlagen", "user_id", userID, "error", err)
}
return
}
w.Header().Set("Content-Disposition", "attachment; filename=mindsift-export.json")
writeJSON(w, http.StatusOK, exportData)
}
+340
View File
@@ -0,0 +1,340 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
)
// --- ORGANIZATIONS & TEAMS ---
func (s *Server) handleAdminGetOrganizations(w http.ResponseWriter, r *http.Request) {
orgs, err := s.store.GetOrganizations()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, orgs)
}
func (s *Server) handleAdminCreateOrganization(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
org, err := s.store.CreateOrganization(req.Name)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, org)
}
func (s *Server) handleAdminRenameOrganization(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if err := s.store.RenameOrganization(chi.URLParam(r, "id"), req.Name); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"updated": true})
}
func (s *Server) handleAdminDeleteOrganization(w http.ResponseWriter, r *http.Request) {
if err := s.store.DeleteOrganization(chi.URLParam(r, "id")); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleAdminSetUserOrganization(w http.ResponseWriter, r *http.Request) {
var req struct {
OrganizationID string `json:"organizationId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if err := s.store.SetUserOrganization(chi.URLParam(r, "id"), req.OrganizationID); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"updated": true})
}
// handleAdminSharingImpact counts the shares and group memberships a switch to ?mode= would remove.
func (s *Server) handleAdminSharingImpact(w http.ResponseWriter, r *http.Request) {
n, err := s.store.PruneSharing(r.URL.Query().Get("mode"), true)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]int64{"affected": n})
}
// --- ADMIN & STAFF PANEL ---
func (s *Server) handleAdminGetUsers(w http.ResponseWriter, r *http.Request) {
users, err := s.store.GetAllUsers()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, users)
}
func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
callerID := auth.GetUserID(r.Context())
caller, _ := s.store.GetUserByID(callerID)
var req struct {
Email string `json:"email"`
Password string `json:"password"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
OrganizationID string `json:"organizationId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if req.Email == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "E-Mail und Passwort erforderlich")
return
}
// Only full server admin can create another server admin
if req.Role == "admin" && (caller == nil || caller.Role != "admin") {
req.Role = "member"
}
if strings.TrimSpace(req.DisplayName) == "" {
req.DisplayName = strings.Split(req.Email, "@")[0]
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, "Passwort-Hash fehlgeschlagen")
return
}
u, err := s.store.CreateUser(req.Email, hash, req.DisplayName, req.Role)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if req.OrganizationID != "" {
if err := s.store.SetUserOrganization(u.ID, req.OrganizationID); err == nil {
u.OrganizationID = req.OrganizationID
}
}
writeJSON(w, http.StatusCreated, u)
}
func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
callerID := auth.GetUserID(r.Context())
caller, _ := s.store.GetUserByID(callerID)
targetUserID := chi.URLParam(r, "id")
targetUser, err := s.store.GetUserByID(targetUserID)
if err != nil || targetUser == nil {
writeError(w, http.StatusNotFound, "Benutzer nicht gefunden")
return
}
// Team-Admins cannot modify Server-Admins
if caller != nil && caller.Role != "admin" && targetUser.Role == "admin" {
writeError(w, http.StatusForbidden, "Nur Server-Administratoren können Server-Admin-Konten bearbeiten")
return
}
var req struct {
DisplayName *string `json:"displayName"`
Email *string `json:"email"`
Role *string `json:"role"`
IsActive *bool `json:"isActive"`
DiscordWebhookURL *string `json:"discordWebhookUrl"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if req.Role != nil && *req.Role == "admin" && (caller == nil || caller.Role != "admin") {
writeError(w, http.StatusForbidden, "Nur Server-Administratoren können die Rolle Server-Admin vergeben")
return
}
if req.DiscordWebhookURL != nil {
if err := validateWebhookURL(*req.DiscordWebhookURL, true, s.allowPrivateWebhooks()); err != nil {
writeError(w, http.StatusBadRequest, "Discord-Webhook: "+err.Error())
return
}
}
u, err := s.store.UpdateUser(targetUserID, req.DisplayName, req.Email, req.Role, req.IsActive, req.DiscordWebhookURL, nil)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, u)
}
func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request) {
callerID := auth.GetUserID(r.Context())
caller, _ := s.store.GetUserByID(callerID)
targetUserID := chi.URLParam(r, "id")
targetUser, _ := s.store.GetUserByID(targetUserID)
if caller != nil && caller.Role != "admin" && targetUser != nil && targetUser.Role == "admin" {
writeError(w, http.StatusForbidden, "Keine Berechtigung")
return
}
var req struct {
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Password) == "" {
writeError(w, http.StatusBadRequest, "Neues Passwort erforderlich")
return
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, "Passwort-Hash fehlgeschlagen")
return
}
if err := s.store.UpdateUserPassword(targetUserID, hash); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
targetUserID := chi.URLParam(r, "id")
currUserID := auth.GetUserID(r.Context())
if targetUserID == currUserID {
writeError(w, http.StatusBadRequest, "Sie können Ihren eigenen Benutzer nicht löschen")
return
}
caller, _ := s.store.GetUserByID(currUserID)
targetUser, _ := s.store.GetUserByID(targetUserID)
if caller != nil && caller.Role != "admin" && targetUser != nil && targetUser.Role == "admin" {
writeError(w, http.StatusForbidden, "Keine Berechtigung zum Löschen eines Server-Admins")
return
}
if err := s.store.DeleteUser(targetUserID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// --- INVITES MANAGEMENT ---
func (s *Server) handleAdminGetInvites(w http.ResponseWriter, r *http.Request) {
invites, err := s.store.GetUserInvites()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, invites)
}
func (s *Server) handleAdminCreateInvite(w http.ResponseWriter, r *http.Request) {
callerID := auth.GetUserID(r.Context())
caller, _ := s.store.GetUserByID(callerID)
var req struct {
Email string `json:"email"`
Role string `json:"role"`
OrganizationID string `json:"organizationId"`
ValidDays int `json:"validDays"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if req.Role == "admin" && (caller == nil || caller.Role != "admin") {
req.Role = "member"
}
inv, err := s.store.CreateUserInvite(callerID, req.Email, req.Role, req.OrganizationID, req.ValidDays)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, inv)
}
func (s *Server) handleAdminDeleteInvite(w http.ResponseWriter, r *http.Request) {
inviteID := chi.URLParam(r, "id")
if err := s.store.DeleteUserInvite(inviteID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// --- SERVER SETTINGS & SMTP ---
func (s *Server) handleAdminGetSettings(w http.ResponseWriter, r *http.Request) {
settings, err := s.store.GetSystemSettings()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
writeJSON(w, http.StatusOK, settings)
}
func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Request) {
var settings domain.SystemSettings
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
previousMode := s.store.SharingMode()
if err := s.store.UpdateSystemSettings(&settings); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// A stricter mode takes away what is no longer allowed (shares, group memberships, assignments)
if settings.SharingMode != previousMode {
_, _ = s.store.PruneSharing(settings.SharingMode, false)
}
settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
writeJSON(w, http.StatusOK, settings)
}
func (s *Server) handleAdminTestSMTP(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
user, _ := s.store.GetUserByID(userID)
var req struct {
ToEmail string `json:"toEmail"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
toEmail := strings.TrimSpace(req.ToEmail)
if toEmail == "" && user != nil {
toEmail = user.Email
}
if toEmail == "" {
writeError(w, http.StatusBadRequest, "Ziel-E-Mail-Adresse erforderlich")
return
}
if err := s.sendEmailNotification(toEmail, "MindSift SMTP-Server Test", "Die SMTP-Konfiguration deiner MindSift-Instanz funktioniert einwandfrei!"); err != nil {
writeError(w, http.StatusBadGateway, "SMTP-Fehler: "+err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"sent": true,
"toEmail": toEmail,
})
}
+523
View File
@@ -0,0 +1,523 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
"github.com/sandrodach/mindsift/internal/storage"
)
// --- HANDLERS ---
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "ok",
"service": "mindsift",
"time": domain.NowISO(),
})
}
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
count, _ := s.store.CountUsers()
sys, _ := s.store.GetSystemSettings()
allowReg := false
instanceName := "MindSift"
if sys != nil {
allowReg = sys.AllowRegistration
if sys.InstanceName != "" {
instanceName = sys.InstanceName
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"needsSetup": count == 0,
"allowRegistration": allowReg,
"instanceName": instanceName,
"demoMode": s.cfg.DemoMode,
"sharingMode": s.store.SharingMode(),
"passwordReset": s.passwordResetAvailable(),
// Public so the login screen can link Impressum and Datenschutz
"legal": map[string]interface{}{
"imprintUrl": s.cfg.Legal.ImprintURL,
"hasImprintContent": s.cfg.Legal.ImprintContent != "",
"privacyUrl": s.cfg.Legal.PrivacyURL,
"hasPrivacyContent": s.cfg.Legal.PrivacyContent != "",
},
})
}
// handleLegalText serves the configured Markdown of the imprint or privacy notice (public, no login).
func (s *Server) handleLegalText(w http.ResponseWriter, r *http.Request) {
content := ""
switch chi.URLParam(r, "type") {
case "imprint":
content = s.cfg.Legal.ImprintContent
case "privacy":
content = s.cfg.Legal.PrivacyContent
}
if content == "" {
writeError(w, http.StatusNotFound, "Kein Text hinterlegt")
return
}
writeJSON(w, http.StatusOK, map[string]string{"content": content})
}
func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) {
count, err := s.store.CountUsers()
if err != nil || count > 0 {
writeError(w, http.StatusForbidden, "Ersteinrichtung wurde bereits abgeschlossen")
return
}
var req struct {
Email string `json:"email"`
Password string `json:"password"`
DisplayName string `json:"displayName"`
InstanceName string `json:"instanceName"`
AllowRegistration bool `json:"allowRegistration"`
SharingMode string `json:"sharingMode"`
CreateSampleData bool `json:"createSampleData"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
if strings.TrimSpace(req.Email) == "" || len(req.Password) < 4 {
writeError(w, http.StatusBadRequest, "E-Mail und Passwort (mind. 4 Zeichen) erforderlich")
return
}
if strings.TrimSpace(req.DisplayName) == "" {
req.DisplayName = "Administrator"
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Passwort-Hashing")
return
}
user, err := s.store.CreateUser(req.Email, hash, req.DisplayName, "admin")
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
sys, _ := s.store.GetSystemSettings()
if sys != nil {
if strings.TrimSpace(req.InstanceName) != "" {
sys.InstanceName = strings.TrimSpace(req.InstanceName)
}
sys.AllowRegistration = req.AllowRegistration
sys.SharingMode = storage.NormalizeSharingMode(req.SharingMode)
_ = s.store.UpdateSystemSettings(sys)
// In organization mode the first admin starts in an organization named after the instance
if sys.SharingMode == storage.SharingOrganizations {
if org, err := s.store.CreateOrganization(sys.InstanceName); err == nil {
_ = s.store.SetUserOrganization(user.ID, org.ID)
user.OrganizationID = org.ID
}
}
}
if req.CreateSampleData {
_ = s.store.SeedStarterContent(user)
}
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
if err != nil {
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
return
}
writeJSON(w, http.StatusCreated, map[string]interface{}{
"token": token,
"user": user,
})
}
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
Password string `json:"password"`
DisplayName string `json:"displayName"`
InviteCode string `json:"inviteCode"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
if strings.TrimSpace(req.Email) == "" || len(req.Password) < 4 {
writeError(w, http.StatusBadRequest, "E-Mail und Passwort (mind. 4 Zeichen) erforderlich")
return
}
if strings.TrimSpace(req.DisplayName) == "" {
req.DisplayName = strings.Split(req.Email, "@")[0]
}
count, _ := s.store.CountUsers()
role := "member"
var validInvite *domain.UserInvite
if strings.TrimSpace(req.InviteCode) != "" {
inv, err := s.store.GetUserInviteByCode(req.InviteCode)
if err != nil {
writeError(w, http.StatusForbidden, err.Error())
return
}
if inv.Email != "" && !strings.EqualFold(inv.Email, strings.TrimSpace(req.Email)) {
writeError(w, http.StatusForbidden, "Dieser Einladungscode ist an eine andere E-Mail-Adresse gebunden")
return
}
validInvite = inv
role = inv.Role
} else if count == 0 {
role = "admin"
} else {
sysSettings, _ := s.store.GetSystemSettings()
if sysSettings == nil || !sysSettings.AllowRegistration {
writeError(w, http.StatusForbidden, "Öffentliche Registrierung ist deaktiviert – bitte nutze einen Einladungscode vom Administrator")
return
}
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Passwort-Hashing")
return
}
user, err := s.store.CreateUser(req.Email, hash, req.DisplayName, role)
if err != nil {
writeError(w, http.StatusConflict, "E-Mail-Adresse existiert bereits")
return
}
if validInvite != nil {
_ = s.store.MarkUserInviteUsed(validInvite.ID, user.ID)
if validInvite.OrganizationID != "" {
_ = s.store.SetUserOrganization(user.ID, validInvite.OrganizationID)
user.OrganizationID = validInvite.OrganizationID
}
}
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
if err != nil {
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
return
}
writeJSON(w, http.StatusCreated, map[string]interface{}{
"token": token,
"user": user,
})
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
Username string `json:"username"`
Login string `json:"login"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
identifier := strings.TrimSpace(req.Login)
if identifier == "" {
identifier = strings.TrimSpace(req.Username)
}
if identifier == "" {
identifier = strings.TrimSpace(req.Email)
}
if identifier == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "Benutzername/E-Mail und Passwort erforderlich")
return
}
user, err := s.store.GetUserByLogin(identifier)
if err != nil {
slog.Warn("Login fehlgeschlagen: Benutzer existiert nicht", "login", identifier, "remote_addr", r.RemoteAddr)
writeError(w, http.StatusUnauthorized, "Benutzername/E-Mail oder Passwort ungültig")
return
}
if !auth.CheckPassword(req.Password, user.PasswordHash) {
slog.Warn("Login fehlgeschlagen: Falsches Passwort", "login", identifier, "user_id", user.ID, "display_name", user.DisplayName, "remote_addr", r.RemoteAddr)
writeError(w, http.StatusUnauthorized, "Benutzername/E-Mail oder Passwort ungültig")
return
}
if !user.IsActive {
slog.Warn("Login verweigert: Benutzerkonto ist deaktiviert", "login", identifier, "user_id", user.ID, "display_name", user.DisplayName, "remote_addr", r.RemoteAddr)
writeError(w, http.StatusForbidden, "Dieses Benutzerkonto ist deaktiviert")
return
}
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
if err != nil {
slog.Error("Login-Token konnte nicht erstellt werden", "login", identifier, "user_id", user.ID, "error", err)
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
return
}
slog.Info("Login erfolgreich", "login", identifier, "user_id", user.ID, "display_name", user.DisplayName, "role", user.Role, "remote_addr", r.RemoteAddr)
writeJSON(w, http.StatusOK, map[string]interface{}{
"token": token,
"user": user,
})
}
func (s *Server) handleQuickStart(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusForbidden, "Automatischer Gast-Login ist deaktiviert – bitte anmelden")
}
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
user, err := s.store.GetUserByID(userID)
if err != nil {
writeError(w, http.StatusNotFound, "Benutzer nicht gefunden")
return
}
stats, _ := s.store.GetDashboardStats(userID)
writeJSON(w, http.StatusOK, map[string]interface{}{
"user": user,
"stats": stats,
// The settings disable "Konto löschen" for the last administrator
"isLastAdmin": s.store.IsLastAdmin(userID),
})
}
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
stats, err := s.store.GetDashboardStats(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, stats)
}
// handlePurgeOwnData deletes all notes, tasks, thoughts and lists of the caller; the account stays.
func (s *Server) handlePurgeOwnData(w http.ResponseWriter, r *http.Request) {
user, ok := s.confirmPassword(w, r)
if !ok {
return
}
if err := s.store.PurgeUserData(user.ID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
slog.Info("Benutzer hat alle eigenen Daten gelöscht", "user_id", user.ID)
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// handleDeleteOwnAccount deletes the caller's account with all data; never the last administrator.
func (s *Server) handleDeleteOwnAccount(w http.ResponseWriter, r *http.Request) {
user, ok := s.confirmPassword(w, r)
if !ok {
return
}
if err := s.store.DeleteOwnAccount(user.ID); err != nil {
status := http.StatusInternalServerError
if errors.Is(err, storage.ErrLastAdmin) {
status = http.StatusForbidden
}
writeError(w, status, err.Error())
return
}
slog.Info("Benutzer hat sein Konto gelöscht", "user_id", user.ID)
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// importUpload reads the multipart field "file" (max. storage.MaxImportUploadBytes).
func importUpload(w http.ResponseWriter, r *http.Request) ([]byte, string, error) {
r.Body = http.MaxBytesReader(w, r.Body, storage.MaxImportUploadBytes+1<<20)
file, header, err := r.FormFile("file")
if err != nil {
return nil, "", fmt.Errorf("Keine Datei erhalten oder Datei zu groß (max. 100 MB)")
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, storage.MaxImportUploadBytes+1))
if err != nil || len(data) > storage.MaxImportUploadBytes {
return nil, "", fmt.Errorf("Datei zu groß (max. 100 MB)")
}
return data, header.Filename, nil
}
// --- USER SETTINGS & ALARMS ---
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, tokenStr); err == nil && claims != nil {
var exp time.Time
if claims.ExpiresAt != nil {
exp = claims.ExpiresAt.Time
} else {
exp = time.Now().Add(30 * 24 * time.Hour)
}
_ = s.store.RevokeToken(tokenStr, userID, exp)
}
}
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
func (s *Server) handleRevokeAllSessions(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
if err := s.store.RevokeAllUserTokens(userID); err != nil {
writeError(w, http.StatusInternalServerError, "Sitzungen konnten nicht beendet werden")
return
}
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
DisplayName *string `json:"displayName"`
Email *string `json:"email"`
Password *string `json:"password"`
AvatarURL *string `json:"avatarUrl"`
AlarmChannels *[]string `json:"alarmChannels"`
AlarmEmail *string `json:"alarmEmail"`
DiscordWebhookURL *string `json:"discordWebhookUrl"`
CustomWebhookURL *string `json:"customWebhookUrl"`
NotifyBeforeMinutes *int `json:"notifyBeforeMinutes"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
// Alarm URLs are checked before they are stored (SSRF protection, see netguard.go)
allowPrivate := s.allowPrivateWebhooks()
if req.DiscordWebhookURL != nil {
if err := validateWebhookURL(*req.DiscordWebhookURL, true, allowPrivate); err != nil {
writeError(w, http.StatusBadRequest, "Discord-Webhook: "+err.Error())
return
}
}
if req.CustomWebhookURL != nil {
if err := validateWebhookURL(*req.CustomWebhookURL, false, allowPrivate); err != nil {
writeError(w, http.StatusBadRequest, "Webhook-URL: "+err.Error())
return
}
}
if req.Password != nil && strings.TrimSpace(*req.Password) != "" {
hash, err := auth.HashPassword(*req.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Hashen des Passworts")
return
}
_ = s.store.UpdateUserPassword(userID, hash)
}
u, err := s.store.UpdateUserFull(
userID,
req.DisplayName,
req.Email,
nil,
nil,
req.AlarmChannels,
req.AlarmEmail,
req.DiscordWebhookURL,
req.CustomWebhookURL,
req.NotifyBeforeMinutes,
req.AvatarURL,
)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, u)
}
func (s *Server) handleTestUserAlarm(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
user, err := s.store.GetUserByID(userID)
if err != nil || user == nil {
writeError(w, http.StatusNotFound, "Benutzer nicht gefunden")
return
}
var req struct {
Channel string `json:"channel"` // email | discord | webhook
Target string `json:"target"` // optional override URL or email
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
channel := strings.ToLower(strings.TrimSpace(req.Channel))
target := strings.TrimSpace(req.Target)
switch channel {
case "discord":
url := target
if url == "" {
url = user.DiscordWebhookURL
}
if url == "" {
writeError(w, http.StatusBadRequest, "Bitte eine Discord Webhook URL angeben")
return
}
if err := s.sendDiscordNotification(url, "Test-Alarm von MindSift", "Dein Discord-Alarmkanal funktioniert einwandfrei!", domain.NowISO()[:10], "normal"); err != nil {
writeError(w, http.StatusBadGateway, "Discord-Fehler: "+err.Error())
return
}
case "webhook":
url := target
if url == "" {
url = user.CustomWebhookURL
}
if url == "" {
writeError(w, http.StatusBadRequest, "Bitte eine Webhook-URL angeben")
return
}
if err := s.sendCustomWebhookNotification(url, "test_alarm", "Test-Alarm von MindSift", "Dein eigener Webhook empfängt MindSift-Alarme!", domain.NowISO()[:10], "normal"); err != nil {
writeError(w, http.StatusBadGateway, "Webhook-Fehler: "+err.Error())
return
}
case "email":
toEmail := target
if toEmail == "" {
toEmail = user.AlarmEmail
}
if toEmail == "" {
toEmail = user.Email
}
if err := s.sendEmailNotification(toEmail, "⏰ MindSift Test-Alarm", "Hallo "+user.DisplayName+",\n\ndein E-Mail-Alarmkanal in MindSift ist erfolgreich eingerichtet und einsatzbereit.\n\nViele Grüße\nDeine MindSift Instanz"); err != nil {
writeError(w, http.StatusBadGateway, "E-Mail-Versand fehlgeschlagen: "+err.Error())
return
}
default:
writeError(w, http.StatusBadRequest, "Unbekannter Alarm-Kanal (erlaubt: email, discord, webhook)")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"sent": true,
"channel": channel,
})
}
func (s *Server) handleGetUsersDirectory(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
users, err := s.store.GetDiscoverableUsers(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, users)
}
+315
View File
@@ -0,0 +1,315 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
"github.com/sandrodach/mindsift/internal/storage"
)
// --- DOCUMENTS ---
func (s *Server) broadcastDocToAuthorizedUsers(docID, event string) {
users, err := s.store.GetAuthorizedDocUsers(docID)
if err != nil {
return
}
var firstDoc *domain.Document
for _, uid := range users {
if userDoc, err := s.store.GetDocumentByID(uid, docID); err == nil && userDoc != nil {
if firstDoc == nil {
firstDoc = userDoc
}
s.wsHub.BroadcastToUser(uid, event, userDoc)
}
}
if s.wsHub != nil && firstDoc != nil {
s.wsHub.BroadcastRoom("doc:"+docID, event, firstDoc, nil)
}
}
func (s *Server) handleGetDocuments(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docs, err := s.store.GetDocuments(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, docs)
}
func (s *Server) handleCreateDocument(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
SpaceID string `json:"spaceId"`
Title string `json:"title"`
Icon string `json:"icon"`
FolderPath string `json:"folderPath"`
Blocks []domain.DocumentBlock `json:"blocks"`
Tags []string `json:"tags"`
IsPinned bool `json:"isPinned"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
doc, err := s.store.CreateDocument(userID, req.SpaceID, req.Title, req.Icon, req.FolderPath, req.Blocks, req.Tags, req.IsPinned)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.broadcastDocToAuthorizedUsers(doc.ID, "DOC_CREATED")
writeJSON(w, http.StatusCreated, doc)
}
func (s *Server) handleGetDocument(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
doc, err := s.store.GetDocumentByID(userID, docID)
if err != nil {
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
return
}
writeJSON(w, http.StatusOK, doc)
}
func (s *Server) handleGetBacklinks(w http.ResponseWriter, r *http.Request) {
links, err := s.store.GetBacklinks(auth.GetUserID(r.Context()), chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
return
}
writeJSON(w, http.StatusOK, links)
}
func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
var req struct {
Title *string `json:"title"`
Icon *string `json:"icon"`
FolderPath *string `json:"folderPath"`
Blocks *[]domain.DocumentBlock `json:"blocks"`
IsPinned *bool `json:"isPinned"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if req.Blocks != nil {
writeError(w, http.StatusBadRequest, "Blöcke werden über POST /api/v1/docs/{id}/sync gespeichert")
return
}
doc, err := s.store.UpdateDocument(userID, docID, req.Title, req.Icon, req.FolderPath, req.IsPinned)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if req.Title != nil {
_, head, _ := s.store.GetDocOpsSince(docID, 0, 0)
s.notifyDocChanged(docID, head)
}
if req.Title != nil || req.Icon != nil || req.FolderPath != nil {
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
}
writeJSON(w, http.StatusOK, doc)
}
func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
authorizedUsers, _ := s.store.GetAuthorizedDocUsers(docID)
if err := s.store.DeleteDocument(userID, docID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
for _, uid := range authorizedUsers {
s.wsHub.BroadcastToUser(uid, "DOC_DELETED", map[string]string{"id": docID})
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleGetTrash(w http.ResponseWriter, r *http.Request) {
items, err := s.store.GetTrash(auth.GetUserID(r.Context()))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, items)
}
func (s *Server) handleRestoreDocument(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
if err := s.store.RestoreDocument(userID, docID); err != nil {
writeTrashError(w, err)
return
}
// Everyone with access reloads their tree, so the note is back for shared users as well
authorized, _ := s.store.GetAuthorizedDocUsers(docID)
for _, uid := range append(authorized, userID) {
s.wsHub.BroadcastToUser(uid, "WORKSPACE_FOLDERS_UPDATED", map[string]string{"restored": docID})
}
writeJSON(w, http.StatusOK, map[string]bool{"restored": true})
}
func (s *Server) handlePurgeDocument(w http.ResponseWriter, r *http.Request) {
if err := s.store.PurgeDocument(auth.GetUserID(r.Context()), chi.URLParam(r, "id")); err != nil {
writeTrashError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleEmptyTrash(w http.ResponseWriter, r *http.Request) {
ids, err := s.store.EmptyTrash(auth.GetUserID(r.Context()))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]int{"deleted": len(ids)})
}
func writeTrashError(w http.ResponseWriter, err error) {
if errors.Is(err, storage.ErrNotInTrash) {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeError(w, http.StatusInternalServerError, err.Error())
}
func (s *Server) handleCreateEnhancedTask(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
blockID := chi.URLParam(r, "blockId")
var req struct {
Content string `json:"content"`
Checked bool `json:"checked"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
thought, err := s.store.CreateTaskFromDocBlock(userID, docID, blockID, req.Content, req.Checked)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
_, head, _ := s.store.GetDocOpsSince(docID, 0, 0)
s.notifyDocChanged(docID, head)
s.wsHub.BroadcastToUser(userID, "TASK_CREATED", thought)
writeJSON(w, http.StatusCreated, thought)
}
func (s *Server) handleMoveDocument(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
var req struct {
FolderPath string `json:"folderPath"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
doc, err := s.store.MoveDocument(userID, docID, req.FolderPath)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastDocEvent(docID, "DOC_UPDATED", doc)
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
docUsers, _ := s.store.GetAuthorizedDocUsers(docID)
s.wsHub.BroadcastToUsers(append(docUsers, userID), "WORKSPACE_FOLDERS_UPDATED", map[string]string{"docId": docID})
writeJSON(w, http.StatusOK, doc)
}
// --- DOCUMENT SHARING & COLLABORATION ---
func (s *Server) handleGetDocShares(w http.ResponseWriter, r *http.Request) {
docID := chi.URLParam(r, "id")
shares, err := s.store.GetDocShares(docID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, shares)
}
func (s *Server) handleCreateDocShare(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
var req struct {
UserID string `json:"userId"`
Email string `json:"email"`
TeamID string `json:"teamId"`
Permission string `json:"permission"` // read | write
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if req.TeamID != "" {
share, err := s.store.CreateDocTeamShare(userID, docID, req.TeamID, req.Permission)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED")
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
writeJSON(w, http.StatusCreated, share)
return
}
target := req.UserID
if target == "" {
target = req.Email
}
if target == "" {
writeError(w, http.StatusBadRequest, "Benutzer-ID, E-Mail oder Team erforderlich")
return
}
share, err := s.store.CreateDocShare(userID, docID, target, req.Permission)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED")
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
writeJSON(w, http.StatusCreated, share)
}
func (s *Server) handleDeleteDocShare(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
targetUserID := chi.URLParam(r, "userId")
if err := s.store.DeleteDocShare(userID, docID, targetUserID); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastToUser(targetUserID, "DOC_DELETED", map[string]string{"id": docID})
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleGetCollaborators(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
users, err := s.store.GetCollaborators(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, users)
}
+295
View File
@@ -0,0 +1,295 @@
package api
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
)
// --- WORKSPACE FOLDERS ---
func (s *Server) handleGetFolders(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
folders, err := s.store.GetFolders(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, folders)
}
func (s *Server) handleCreateFolder(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
Path string `json:"path"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
cleanPath, err := s.store.CreateFolder(userID, req.Path)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastToUser(userID, "WORKSPACE_FOLDERS_UPDATED", map[string]string{"path": cleanPath})
writeJSON(w, http.StatusCreated, map[string]string{"path": cleanPath})
}
func (s *Server) handleDeleteFolder(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
folderPath := r.URL.Query().Get("path")
if folderPath == "" {
writeError(w, http.StatusBadRequest, "Pfad erforderlich")
return
}
deletedDocIDs, err := s.store.DeleteFolder(userID, folderPath)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
for _, docID := range deletedDocIDs {
s.wsHub.BroadcastDocEvent(docID, "DOC_DELETED", map[string]string{"id": docID})
}
s.wsHub.BroadcastToUser(userID, "WORKSPACE_FOLDERS_UPDATED", map[string]string{"path": folderPath})
writeJSON(w, http.StatusOK, map[string]interface{}{
"deleted": true,
"deletedDocIds": deletedDocIDs,
})
}
func (s *Server) handleMoveFolder(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
SourcePath string `json:"sourcePath"`
TargetParentPath string `json:"targetParentPath"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
newPath, movedDocIDs, err := s.store.MoveFolder(userID, req.SourcePath, req.TargetParentPath)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
for _, id := range movedDocIDs {
if doc, err := s.store.GetDocumentByID(userID, id); err == nil && doc != nil {
s.wsHub.BroadcastDocEvent(id, "DOC_UPDATED", doc)
s.broadcastDocToAuthorizedUsers(id, "DOC_UPDATED")
}
}
s.wsHub.BroadcastToUser(userID, "WORKSPACE_FOLDERS_UPDATED", map[string]string{
"sourcePath": req.SourcePath,
"newPath": newPath,
})
writeJSON(w, http.StatusOK, map[string]interface{}{
"newPath": newPath,
"movedDocIds": movedDocIDs,
})
}
// --- WORKSPACE SHARED FOLDERS ---
func (s *Server) handleGetSharedFolders(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
folders, err := s.store.GetSharedFolders(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, folders)
}
func (s *Server) handleCreateSharedFolder(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
Name string `json:"name"`
MountPath string `json:"mountPath"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
folder, err := s.store.CreateSharedFolder(userID, req.Name, req.MountPath)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastToUser(userID, "WORKSPACE_FOLDERS_UPDATED", folder)
writeJSON(w, http.StatusCreated, folder)
}
func (s *Server) handleGetSharedFolderByID(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
folderID := chi.URLParam(r, "id")
folder, err := s.store.GetSharedFolderByID(userID, folderID)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, folder)
}
func (s *Server) handleDeleteSharedFolder(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
folderID := chi.URLParam(r, "id")
audience := s.store.GetSharedFolderUserIDs(folderID)
if err := s.store.DeleteSharedFolder(userID, folderID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastToUsers(append(audience, userID), "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID})
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleUpdateFolderMount(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
folderID := chi.URLParam(r, "id")
var req struct {
MountPath string `json:"mountPath"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if err := s.store.UpdateFolderMount(userID, folderID, req.MountPath); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastToUser(userID, "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID, "mountPath": req.MountPath})
writeJSON(w, http.StatusOK, map[string]bool{"updated": true})
}
func (s *Server) handleGetFolderShares(w http.ResponseWriter, r *http.Request) {
folderID := chi.URLParam(r, "id")
shares, err := s.store.GetFolderShares(folderID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, shares)
}
func (s *Server) handleCreateFolderShare(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
folderID := chi.URLParam(r, "id")
var req struct {
TargetType string `json:"targetType"` // team | user
TargetID string `json:"targetId"`
UserId string `json:"userId"`
Email string `json:"email"`
TeamId string `json:"teamId"`
Permission string `json:"permission"` // read | write
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
targetType := req.TargetType
targetIdentifier := req.TargetID
if targetIdentifier == "" {
if req.TeamId != "" {
targetType = "team"
targetIdentifier = req.TeamId
} else if req.UserId != "" {
targetType = "user"
targetIdentifier = req.UserId
} else if req.Email != "" {
targetType = "user"
targetIdentifier = req.Email
}
}
share, err := s.store.CreateFolderShare(userID, folderID, targetType, targetIdentifier, req.Permission)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastToUsers(s.store.GetSharedFolderUserIDs(folderID), "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID})
if docIDs, err := s.store.GetSharedFolderDocIDs(folderID); err == nil {
for _, docID := range docIDs {
s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED")
}
}
writeJSON(w, http.StatusCreated, share)
}
func (s *Server) handleDeleteFolderShare(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
folderID := chi.URLParam(r, "id")
shareID := chi.URLParam(r, "shareId")
docIDs, _ := s.store.GetSharedFolderDocIDs(folderID)
audienceBefore := s.store.GetSharedFolderUserIDs(folderID)
if err := s.store.DeleteFolderShare(userID, folderID, shareID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
audienceAfter := s.store.GetSharedFolderUserIDs(folderID)
stillAllowed := make(map[string]bool, len(audienceAfter))
for _, uid := range audienceAfter {
stillAllowed[uid] = true
}
s.wsHub.BroadcastToUsers(append(audienceBefore, audienceAfter...), "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID})
for _, docID := range docIDs {
// Users who lost access remove the notes; the rest refresh them
for _, uid := range audienceBefore {
if !stillAllowed[uid] {
s.wsHub.BroadcastToUser(uid, "DOC_DELETED", map[string]string{"id": docID})
}
}
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// --- WORKSPACE TAGS & PAGES ---
func (s *Server) handleGetWorkspaceTags(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
tags, err := s.store.GetWorkspaceTags(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, tags)
}
func (s *Server) handleGetTagDetails(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
tag := chi.URLParam(r, "tag")
details, err := s.store.GetTagDetails(userID, tag)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, details)
}
func (s *Server) handleUpdateTagPageNote(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
tag := chi.URLParam(r, "tag")
var req struct {
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
updatedAt, err := s.store.UpdateTagPageNote(userID, tag, req.Content)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{
"tag": tag,
"content": req.Content,
"updatedAt": updatedAt,
})
}
+144
View File
@@ -0,0 +1,144 @@
package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
)
// --- API KEYS & WEBHOOK INGESTION ---
func (s *Server) handleGetAPIKeys(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
keys, err := s.store.GetAPIKeys(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, keys)
}
func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
Name string `json:"name"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
key, err := s.store.CreateAPIKey(userID, req.Name)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, key)
}
func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
keyID := chi.URLParam(r, "id")
if err := s.store.DeleteAPIKey(userID, keyID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// handleWebhookIngest accepts Smarthome / External automation webhooks via ?token=... or X-API-Key or Bearer token
func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if token == "" {
token = r.Header.Get("X-API-Key")
}
if token == "" && strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
token = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
}
// Same checks as every other request (revoked/outdated sessions, inactive accounts)
user, _ := s.authenticateToken(token)
if user == nil {
writeError(w, http.StatusUnauthorized, "Ungültiger Webhook-Token oder API-Key")
return
}
var req struct {
Title string `json:"title"`
Content string `json:"content"`
Text string `json:"text"`
Message string `json:"message"`
Priority string `json:"priority"`
Source string `json:"source"`
Tags []string `json:"tags"`
DueDate string `json:"dueDate"`
DueDate2 string `json:"due_date"`
}
bodyBytes, _ := io.ReadAll(r.Body)
if len(bodyBytes) > 0 {
if jsonErr := json.Unmarshal(bodyBytes, &req); jsonErr != nil {
plain := strings.TrimSpace(string(bodyBytes))
if plain != "" {
req.Content = plain
}
}
}
if req.Content == "" {
if req.Text != "" {
req.Content = req.Text
} else if req.Message != "" {
req.Content = req.Message
}
}
dueDate := req.DueDate
if dueDate == "" {
dueDate = req.DueDate2
}
bodyText := strings.TrimSpace(req.Title)
if req.Content != "" {
if bodyText != "" {
bodyText = bodyText + " — " + strings.TrimSpace(req.Content)
} else {
bodyText = strings.TrimSpace(req.Content)
}
}
if bodyText == "" {
writeError(w, http.StatusBadRequest, "Feld 'title', 'content', 'text' oder 'message' ist erforderlich")
return
}
for _, tag := range req.Tags {
cleanTag := strings.TrimPrefix(strings.TrimSpace(tag), "#")
if cleanTag != "" && !strings.Contains(strings.ToLower(bodyText), "#"+strings.ToLower(cleanTag)) {
bodyText += fmt.Sprintf(" #%s", cleanTag)
}
}
if req.Priority == "" {
req.Priority = "high"
}
source := strings.ToLower(strings.TrimSpace(req.Source))
if source == "" {
source = "webhook"
}
thought, err := s.store.CreateThought(user.ID, bodyText, req.Priority, source, dueDate)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastToUsers(s.taskAudience(thought), "TASK_CREATED", thought)
writeJSON(w, http.StatusCreated, map[string]interface{}{
"status": "ingested",
"thought": thought,
})
}
+266
View File
@@ -0,0 +1,266 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
)
// --- REALTIME LISTS & SHARING ---
func (s *Server) handleGetAllLists(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
lists, err := s.store.GetAllListsForUser(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, lists)
}
func (s *Server) handleGetListByID(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
lst, err := s.store.GetListByID(userID, listID)
if err != nil {
writeError(w, http.StatusNotFound, "Liste nicht gefunden")
return
}
writeJSON(w, http.StatusOK, lst)
}
func (s *Server) handleCreateList(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
Title string `json:"title"`
Icon string `json:"icon"`
ListType string `json:"listType"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
lst, err := s.store.CreateList(userID, req.Title, req.Icon, req.ListType)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastListEvent(lst.ID, "LIST_CREATED", lst)
writeJSON(w, http.StatusCreated, lst)
}
func (s *Server) handleDeleteList(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
if err := s.store.DeleteList(userID, listID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastListEvent(listID, "LIST_DELETED", map[string]string{"listId": listID})
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleGetListShares(w http.ResponseWriter, r *http.Request) {
listID := chi.URLParam(r, "id")
shares, err := s.store.GetListShares(listID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, shares)
}
func (s *Server) handleCreateListShare(w http.ResponseWriter, r *http.Request) {
currentUserID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
var req struct {
TargetType string `json:"targetType"` // "user" or "team"
TargetID string `json:"targetId"` // userId/email or teamId
Role string `json:"role"` // "editor" or "viewer"
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
targetType := strings.ToLower(strings.TrimSpace(req.TargetType))
if targetType != "team" {
targetType = "user"
}
role := strings.ToLower(strings.TrimSpace(req.Role))
if role != "viewer" && role != "editor" {
role = "editor"
}
share, err := s.store.AddListShare(currentUserID, listID, targetType, req.TargetID, role)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if targetType == "team" {
members, _ := s.store.GetTeamMembers(share.TargetID)
for _, tm := range members {
s.wsHub.BroadcastToUser(tm.UserID, "LIST_SHARED", share)
}
} else {
s.wsHub.BroadcastToUser(share.TargetID, "LIST_SHARED", share)
}
s.wsHub.BroadcastListEvent(listID, "LIST_SHARED", share)
writeJSON(w, http.StatusCreated, share)
}
func (s *Server) handleDeleteListShare(w http.ResponseWriter, r *http.Request) {
currentUserID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
shareID := chi.URLParam(r, "shareId")
if err := s.store.RemoveListShare(currentUserID, listID, shareID); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastListEvent(listID, "LIST_UNSHARED", map[string]string{
"listId": listID,
"shareId": shareID,
})
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
var req struct {
Content string `json:"content"`
Category string `json:"category"`
Quantity string `json:"quantity"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
item, err := s.store.CreateListItem(listID, req.Content, req.Category, req.Quantity, 0, userID)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item)
writeJSON(w, http.StatusCreated, item)
}
func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
itemID := chi.URLParam(r, "itemId")
var req struct {
IsCompleted bool `json:"isCompleted"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
user, _ := s.store.GetUserByID(userID)
userName := "Mitglied"
if user != nil {
userName = user.DisplayName
}
item, err := s.store.ToggleListItem(itemID, req.IsCompleted, userID, userName)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastListEvent(listID, "ITEM_TOGGLED", item)
writeJSON(w, http.StatusOK, item)
}
func (s *Server) handleUpdateListItem(w http.ResponseWriter, r *http.Request) {
listID := chi.URLParam(r, "id")
itemID := chi.URLParam(r, "itemId")
var req struct {
Content *string `json:"content"`
Category *string `json:"category"`
Quantity *string `json:"quantity"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
item, err := s.store.UpdateListItem(itemID, req.Content, req.Category, req.Quantity)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastListEvent(listID, "ITEM_UPDATED", item)
writeJSON(w, http.StatusOK, item)
}
func (s *Server) handleDeleteListItem(w http.ResponseWriter, r *http.Request) {
listID := chi.URLParam(r, "id")
itemID := chi.URLParam(r, "itemId")
_, err := s.store.DeleteListItem(itemID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastListEvent(listID, "ITEM_DELETED", map[string]string{
"itemId": itemID,
"listId": listID,
})
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleClearCompletedItems(w http.ResponseWriter, r *http.Request) {
listID := chi.URLParam(r, "id")
removed, err := s.store.ClearCompletedListItems(listID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastListEvent(listID, "LIST_CLEARED", map[string]interface{}{
"listId": listID,
"removed": removed,
})
writeJSON(w, http.StatusOK, map[string]int64{"removed": removed})
}
func (s *Server) handleReorderListItems(w http.ResponseWriter, r *http.Request) {
listID := chi.URLParam(r, "id")
var req struct {
Items []domain.ListItemReorderItem `json:"items"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid request body")
return
}
if err := s.store.ReorderListItems(listID, req.Items); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastListEvent(listID, "ITEM_REORDERED", map[string]interface{}{
"listId": listID,
"items": req.Items,
})
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
+158
View File
@@ -0,0 +1,158 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
)
// --- TASK LISTS ---
func (s *Server) handleGetTaskLists(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
lists, err := s.store.GetTaskLists(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, lists)
}
func (s *Server) handleCreateTaskList(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
SpaceID string `json:"spaceId"`
TeamID string `json:"teamId"`
Title string `json:"title"`
Icon string `json:"icon"`
Color string `json:"color"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
tl, err := s.store.CreateTaskList(userID, req.SpaceID, req.TeamID, req.Title, req.Icon, req.Color)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, tl)
}
func (s *Server) handleUpdateTaskList(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
var req struct {
Title *string `json:"title"`
Icon *string `json:"icon"`
Color *string `json:"color"`
SpaceID *string `json:"spaceId"`
Columns *[]domain.KanbanColumn `json:"columns"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
audienceBefore := s.store.GetTaskListUserIDs(listID)
tl, err := s.store.UpdateTaskList(userID, listID, req.Title, req.Icon, req.Color, req.SpaceID, req.Columns)
if err != nil {
writeStoreError(w, http.StatusBadRequest, err)
return
}
s.broadcastTaskListChange(listID, audienceBefore, "TASK_LIST_UPDATED")
writeJSON(w, http.StatusOK, tl)
}
func (s *Server) handleDeleteTaskList(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
audienceBefore := s.store.GetTaskListUserIDs(listID)
if err := s.store.DeleteTaskList(userID, listID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.broadcastTaskListChange(listID, audienceBefore, "TASK_LIST_DELETED")
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleGetTaskListShares(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
if _, err := s.store.GetTaskListByID(userID, listID); err != nil {
writeError(w, http.StatusNotFound, "Aufgabenliste nicht gefunden oder kein Zugriff")
return
}
shares, err := s.store.GetTaskListShares(listID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, shares)
}
func (s *Server) handleCreateTaskListShare(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
tl, err := s.store.GetTaskListByID(userID, listID)
if err != nil || tl == nil {
writeError(w, http.StatusNotFound, "Aufgabenliste nicht gefunden")
return
}
if tl.UserID != userID {
writeError(w, http.StatusForbidden, "Nur der Eigentümer kann die Liste freigeben")
return
}
var req struct {
TargetType string `json:"targetType"` // team | user
TargetID string `json:"targetId"`
Permission string `json:"permission"` // editor | viewer
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
permission := strings.ToLower(strings.TrimSpace(req.Permission))
if permission != "viewer" {
permission = "editor"
}
share, err := s.store.AddTaskListShare(listID, req.TargetType, req.TargetID, permission, userID)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.broadcastTaskListChange(listID, nil, "TASK_LIST_UPDATED")
writeJSON(w, http.StatusCreated, share)
}
func (s *Server) handleDeleteTaskListShare(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
shareID := chi.URLParam(r, "shareId")
tl, err := s.store.GetTaskListByID(userID, listID)
if err != nil || tl == nil {
writeError(w, http.StatusNotFound, "Aufgabenliste nicht gefunden")
return
}
if tl.UserID != userID {
writeError(w, http.StatusForbidden, "Nur der Eigentümer kann Freigaben entfernen")
return
}
audienceBefore := s.store.GetTaskListUserIDs(listID)
if err := s.store.RemoveTaskListShare(listID, shareID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.broadcastTaskListChange(listID, audienceBefore, "TASK_LIST_UPDATED")
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// broadcastTaskListChange notifies everyone who could see the list before or can see it now. Only the ID is sent,
// because the list payload depends on the receiver's permission; clients reload their lists.
func (s *Server) broadcastTaskListChange(listID string, audienceBefore []string, event string) {
users := append(audienceBefore, s.store.GetTaskListUserIDs(listID)...)
s.wsHub.BroadcastToUsers(users, event, map[string]string{"id": listID})
}
+201
View File
@@ -0,0 +1,201 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/storage"
)
func (s *Server) isTeamAdmin(userID, teamID string) bool {
if userID == "" || teamID == "" {
return false
}
role, err := s.store.GetTeamMemberRole(teamID, userID)
if err != nil {
return false
}
return role == "admin"
}
func (s *Server) handleCreateTeamDirect(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
Name string `json:"name"`
Description string `json:"description"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if s.store.SharingMode() == storage.SharingPrivate {
writeError(w, http.StatusForbidden, storage.ErrSharingDisabled.Error())
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "Name für die Gruppe ist erforderlich")
return
}
// A group belongs to its creator's organization; members can only come from there
creator, _ := s.store.GetUserByID(userID)
orgID := ""
if creator != nil {
orgID = creator.OrganizationID
}
team, err := s.store.CreateTeam(orgID, name, req.Description)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// Automatically add creator to team as group admin
_ = s.store.AddTeamMember(team.ID, userID, "admin")
team.Members, _ = s.store.GetTeamMembers(team.ID)
writeJSON(w, http.StatusCreated, team)
}
func (s *Server) handleUpdateTeam(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
teamID := chi.URLParam(r, "id")
if !s.isTeamAdmin(userID, teamID) {
writeError(w, http.StatusForbidden, "Nur Gruppen-Administratoren können die Gruppe bearbeiten")
return
}
var req struct {
Name string `json:"name"`
Description string `json:"description"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "Name für das Team / die Gruppe ist erforderlich")
return
}
updated, err := s.store.UpdateTeam(teamID, name, req.Description)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, updated)
}
func (s *Server) handleDeleteTeam(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
teamID := chi.URLParam(r, "id")
if !s.isTeamAdmin(userID, teamID) {
writeError(w, http.StatusForbidden, "Nur Gruppen-Administratoren können die Gruppe löschen")
return
}
if err := s.store.DeleteTeam(teamID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleGetUserTeams(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
teams, err := s.store.GetTeamsForUser(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, teams)
}
func (s *Server) handleAddTeamMember(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
teamID := chi.URLParam(r, "id")
if !s.isTeamAdmin(userID, teamID) {
writeError(w, http.StatusForbidden, "Nur Gruppen-Administratoren können Mitglieder hinzufügen")
return
}
var req struct {
UserID string `json:"userId"`
Email string `json:"email"`
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
targetUserID := strings.TrimSpace(req.UserID)
if targetUserID == "" && strings.TrimSpace(req.Email) != "" {
if u, err := s.store.GetUserByEmail(req.Email); err == nil && u != nil {
targetUserID = u.ID
}
}
if targetUserID == "" && strings.TrimSpace(req.Email) == "" {
writeError(w, http.StatusBadRequest, "Benutzer-ID oder E-Mail erforderlich")
return
}
// Only people the group admin can see; unknown and invisible look the same
if targetUserID == "" || !s.store.CanShareWith(userID, "user", targetUserID) {
writeError(w, http.StatusNotFound, storage.ErrTargetNotFound.Error())
return
}
role := strings.ToLower(strings.TrimSpace(req.Role))
if role != "admin" && role != "member" {
role = "member"
}
if err := s.store.AddTeamMember(teamID, targetUserID, role); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
members, _ := s.store.GetTeamMembers(teamID)
writeJSON(w, http.StatusOK, members)
}
func (s *Server) handleUpdateTeamMemberRole(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
teamID := chi.URLParam(r, "id")
memberUserID := chi.URLParam(r, "userId")
if !s.isTeamAdmin(userID, teamID) {
writeError(w, http.StatusForbidden, "Nur Gruppen-Administratoren können Mitgliedsrollen ändern")
return
}
var req struct {
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
role := strings.ToLower(strings.TrimSpace(req.Role))
if role != "admin" && role != "member" {
writeError(w, http.StatusBadRequest, "Ungültige Rolle (erlaubt sind: admin, member)")
return
}
if err := s.store.UpdateTeamMemberRole(teamID, memberUserID, role); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
members, _ := s.store.GetTeamMembers(teamID)
writeJSON(w, http.StatusOK, members)
}
func (s *Server) handleRemoveTeamMember(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
teamID := chi.URLParam(r, "id")
memberUserID := chi.URLParam(r, "userId")
isSelf := userID == memberUserID
isAdmin := s.isTeamAdmin(userID, teamID)
if !isSelf && !isAdmin {
writeError(w, http.StatusForbidden, "Nicht berechtigt, dieses Mitglied zu entfernen")
return
}
if err := s.store.RemoveTeamMember(teamID, memberUserID); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"removed": true})
}
+326
View File
@@ -0,0 +1,326 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
"github.com/sandrodach/mindsift/internal/storage"
)
func (s *Server) handleCaptureInbox(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
Content string `json:"content"`
Priority string `json:"priority"`
Source string `json:"source"`
DueDate string `json:"dueDate"`
DueDate2 string `json:"due_date"`
Target string `json:"target"` // inbox | task | list_item | document
TargetListID string `json:"targetListId"`
TargetList2 string `json:"target_list_id"`
TaskListID string `json:"taskListId"`
TaskListID2 string `json:"task_list_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
dueDate := req.DueDate
if dueDate == "" {
dueDate = req.DueDate2
}
targetListID := req.TargetListID
if targetListID == "" {
targetListID = req.TargetList2
}
taskListID := req.TaskListID
if taskListID == "" {
taskListID = req.TaskListID2
}
source := strings.ToLower(strings.TrimSpace(req.Source))
if source == "" {
source = strings.ToLower(strings.TrimSpace(r.URL.Query().Get("source")))
}
if source == "" {
source = strings.ToLower(strings.TrimSpace(r.Header.Get("X-Source")))
}
if source == "" {
// When called via API key or webhook token, automatically default source to "webhook"
if r.Header.Get("X-API-Key") != "" ||
strings.HasPrefix(r.Header.Get("Authorization"), "Bearer mindsift_sec_") ||
strings.HasPrefix(r.URL.Query().Get("token"), "mindsift_sec_") {
source = "webhook"
} else {
source = "manual"
}
}
thought, err := s.store.CreateThoughtWithDetails(userID, req.Content, req.Priority, source, dueDate, taskListID, "todo", "")
if err != nil {
writeStoreError(w, http.StatusBadRequest, err)
return
}
target := strings.TrimSpace(req.Target)
if target == "task" || target == "document" || (target == "list_item" && targetListID != "") {
if converted, convErr := s.store.UpdateThoughtTriage(userID, thought.ID, "keep", target, targetListID, dueDate, taskListID); convErr == nil && converted != nil {
thought = converted
if target == "list_item" && targetListID != "" {
if lst, lErr := s.store.GetListByID(userID, targetListID); lErr == nil && lst != nil {
s.wsHub.BroadcastListEvent(lst.ID, "LIST_UPDATED", lst)
}
}
}
}
s.wsHub.BroadcastToUsers(s.taskAudience(thought), "TASK_CREATED", thought)
writeJSON(w, http.StatusCreated, thought)
}
func (s *Server) handleGetThoughts(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
status := r.URL.Query().Get("status")
thoughts, err := s.store.GetThoughts(userID, status, 250)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, thoughts)
}
func (s *Server) handleGetThought(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id")
thought, err := s.store.GetThoughtByID(userID, thoughtID)
if err != nil || thought == nil {
writeError(w, http.StatusNotFound, "Aufgabe nicht gefunden")
return
}
writeJSON(w, http.StatusOK, thought)
}
func (s *Server) handleGetTriageDeck(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughts, err := s.store.GetThoughts(userID, "inbox", 100)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, thoughts)
}
func (s *Server) handleTriageAction(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id")
var req struct {
Action string `json:"action"` // keep | discard | restore
ConvertTo string `json:"convertTo"`
ConvertTo2 string `json:"convert_to"`
TargetListID string `json:"targetListId"`
TargetList2 string `json:"target_list_id"`
TaskListID string `json:"taskListId"`
TaskListID2 string `json:"task_list_id"`
DueDate string `json:"dueDate"`
DueDate2 string `json:"due_date"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
convertTo := req.ConvertTo
if convertTo == "" {
convertTo = req.ConvertTo2
}
targetListID := req.TargetListID
if targetListID == "" {
targetListID = req.TargetList2
}
taskListID := req.TaskListID
if taskListID == "" {
taskListID = req.TaskListID2
}
dueDate := req.DueDate
if dueDate == "" {
dueDate = req.DueDate2
}
updated, err := s.store.UpdateThoughtTriage(userID, thoughtID, req.Action, convertTo, targetListID, dueDate, taskListID)
if err != nil {
writeStoreError(w, http.StatusBadRequest, err)
return
}
// If converted to a list item, broadcast via WebSocket to the space
if convertTo == "list_item" && targetListID != "" {
if lst, err := s.store.GetListByID(userID, targetListID); err == nil && lst != nil {
s.wsHub.BroadcastListEvent(lst.ID, "LIST_UPDATED", lst)
}
}
s.wsHub.BroadcastToUsers(s.taskAudience(updated), "TASK_UPDATED", updated)
writeJSON(w, http.StatusOK, updated)
}
func (s *Server) handleGetTasks(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
taskListID := r.URL.Query().Get("taskListId")
tasks, err := s.store.GetTasks(userID, taskListID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, tasks)
}
func (s *Server) handleReorderTasks(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
taskListID := chi.URLParam(r, "id")
if taskListID == "" {
taskListID = r.URL.Query().Get("taskListId")
}
var req struct {
Items []domain.ThoughtReorderItem `json:"items"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if taskListID != "" && !s.store.CanEditTaskList(userID, taskListID) {
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
return
}
// ReorderTasks updates by ID only, so every item must be a task the caller may edit
audience := []string{userID}
for _, it := range req.Items {
th, err := s.store.GetThoughtByID(userID, it.ID)
if err != nil {
writeError(w, http.StatusNotFound, "Aufgabe nicht gefunden")
return
}
if th.TaskListID != "" && !s.store.CanEditTaskList(userID, th.TaskListID) {
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
return
}
audience = append(audience, s.taskAudience(th)...)
}
if err := s.store.ReorderTasks(taskListID, req.Items); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastToUsers(audience, "TASK_REORDERED", map[string]interface{}{
"taskListId": taskListID,
"items": req.Items,
"userId": userID,
})
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id")
var req struct {
Content *string `json:"content"`
IsCompleted *bool `json:"isCompleted"`
DueDate *string `json:"dueDate"`
Priority *string `json:"priority"`
TaskListID *string `json:"taskListId"`
KanbanStatus *string `json:"kanbanStatus"`
Notes *string `json:"notes"`
Subtasks *[]domain.SubTask `json:"subtasks"`
Tags *[]string `json:"tags"`
Assignees *[]string `json:"assignees"`
Recurrence *string `json:"recurrence"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
before, _ := s.store.GetThoughtByID(userID, thoughtID)
// The rule is set first, so completing a task in the same request already repeats it
if req.Recurrence != nil {
if err := s.store.SetTaskRecurrence(userID, thoughtID, strings.TrimSpace(*req.Recurrence)); err != nil {
writeStoreError(w, http.StatusBadRequest, err)
return
}
}
updated, err := s.store.UpdateThoughtDetails(userID, thoughtID, req.Content, req.IsCompleted, req.DueDate, req.Priority, req.TaskListID, req.KanbanStatus, req.Notes, req.Subtasks, req.Tags)
if err != nil {
writeStoreError(w, http.StatusBadRequest, err)
return
}
if req.Assignees != nil {
assignees, err := s.store.SetTaskAssignees(userID, thoughtID, *req.Assignees)
if err != nil {
writeStoreError(w, http.StatusBadRequest, err)
return
}
updated.Assignees = assignees
}
if docID := s.store.SyncDocBlockAndTask(userID, thoughtID, updated.IsCompleted); docID != "" {
_, head, _ := s.store.GetDocOpsSince(docID, 0, 0)
s.notifyDocChanged(docID, head)
}
s.wsHub.BroadcastToUsers(append(s.taskAudience(before), s.taskAudience(updated)...), "TASK_UPDATED", updated)
writeJSON(w, http.StatusOK, updated)
}
func (s *Server) handleUnlinkEnhancedTask(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id")
s.store.UnlinkTaskFromDocBlock(userID, thoughtID)
writeJSON(w, http.StatusOK, map[string]bool{"unlinked": true})
}
func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id")
th, err := s.store.GetThoughtByID(userID, thoughtID)
if err == nil && th.TaskListID != "" && !s.store.CanEditTaskList(userID, th.TaskListID) {
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
return
}
audience := s.taskAudience(th)
s.store.UnlinkTaskFromDocBlock(userID, thoughtID)
if err := s.store.DeleteThought(userID, thoughtID); err != nil {
writeStoreError(w, http.StatusInternalServerError, err)
return
}
s.wsHub.BroadcastToUsers(audience, "TASK_DELETED", map[string]string{"id": thoughtID})
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handlePurgeDiscarded(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
count, err := s.store.PurgeDiscardedThoughts(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]int64{"purged": count})
}
// --- SEARCH & EXPORT ---
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
q := r.URL.Query().Get("q")
res, err := s.store.SearchGlobal(userID, q)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, res)
}
+86 -3
View File
@@ -5,6 +5,7 @@ import (
"crypto/rand"
"crypto/tls"
"encoding/hex"
"errors"
"fmt"
"mime"
"mime/quotedprintable"
@@ -21,6 +22,8 @@ type smtpSettings struct {
Host, Port, User, Pass, From string
TLS bool // 465: implicit TLS, otherwise STARTTLS (required when set)
SkipVerify bool // accept self-signed / mismatching certificates
authMech string // "" = automatic; "LOGIN" forces AUTH LOGIN (fallback after a refused PLAIN)
}
// smtpStepError names the protocol step at which the mail server refused, e.g. "RCPT TO".
@@ -116,9 +119,89 @@ func buildMessage(from, to, subject, body string, now time.Time) []byte {
return buf.Bytes()
}
// sendMail runs the SMTP dialogue step by step instead of smtp.SendMail, so it can introduce itself
// properly, honour SkipVerify and report the exact step a server refuses.
// loginAuth implements AUTH LOGIN (username and password as separate Base64 challenges). Some servers,
// e.g. Postfix with Dovecot SASL or Exchange, fail on PLAIN but accept LOGIN.
type loginAuth struct{ user, pass, host string }
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
// Same rule as smtp.PlainAuth: never send credentials unencrypted to a remote host
if !server.TLS && a.host != "localhost" && a.host != "127.0.0.1" && a.host != "::1" {
return "", nil, fmt.Errorf("unverschlüsselte Verbindung – Anmeldung verweigert")
}
return "LOGIN", nil, nil
}
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
if !more {
return nil, nil
}
switch prompt := strings.ToLower(strings.TrimSpace(string(fromServer))); {
case strings.Contains(prompt, "username"), strings.Contains(prompt, "user"):
return []byte(a.user), nil
case strings.Contains(prompt, "password"), strings.Contains(prompt, "pass"):
return []byte(a.pass), nil
default:
return nil, fmt.Errorf("unerwartete AUTH-LOGIN-Frage %q", prompt)
}
}
// authAttemptError records which mechanism failed and whether LOGIN would have been an alternative.
type authAttemptError struct {
mech string
loginOffered bool
err error
}
func (e *authAttemptError) Error() string { return e.mech + ": " + e.err.Error() }
func (e *authAttemptError) Unwrap() error { return e.err }
// authenticate uses PLAIN, or LOGIN when the server offers only that (or when cfg.authMech forces it).
// net/smtp closes the connection after a failed AUTH, so a fallback needs a new connection (sendMail).
func authenticate(client *smtp.Client, cfg smtpSettings) error {
_, mechs := client.Extension("AUTH")
offered := " " + strings.ToUpper(mechs) + " "
hasLogin := strings.Contains(offered, " LOGIN ")
mech := cfg.authMech
if mech == "" {
mech = "PLAIN"
if hasLogin && !strings.Contains(offered, " PLAIN ") {
mech = "LOGIN"
}
}
var a smtp.Auth = smtp.PlainAuth("", cfg.User, cfg.Pass, cfg.Host)
if mech == "LOGIN" {
a = &loginAuth{user: cfg.User, pass: cfg.Pass, host: cfg.Host}
}
if err := client.Auth(a); err != nil {
return &authAttemptError{mech: mech, loginOffered: hasLogin, err: err}
}
return nil
}
// sendMail sends one mail. If AUTH PLAIN is refused and the server also offers LOGIN, it reconnects once
// and tries LOGIN; a 451 on both points to the mail server's SASL backend.
func sendMail(cfg smtpSettings, to, subject, body string) error {
err := sendMailOnce(cfg, to, subject, body)
var first *authAttemptError
if cfg.authMech != "" || !errors.As(err, &first) || first.mech != "PLAIN" || !first.loginOffered {
return err
}
cfg.authMech = "LOGIN"
retryErr := sendMailOnce(cfg, to, subject, body)
var second *authAttemptError
if retryErr == nil || !errors.As(retryErr, &second) {
return retryErr
}
combined := fmt.Errorf("%v; %v", first, second)
if strings.Contains(first.err.Error(), "451") && strings.Contains(second.err.Error(), "451") {
combined = fmt.Errorf("%w (der Mailserver meldet einen internen Fehler bei der Anmeldung – bitte dessen SASL/Dovecot-Logs prüfen)", combined)
}
return &smtpStepError{"AUTH", combined}
}
// sendMailOnce runs the SMTP dialogue step by step instead of smtp.SendMail, so it can introduce itself
// properly, honour SkipVerify and report the exact step a server refuses.
func sendMailOnce(cfg smtpSettings, to, subject, body string) error {
fromAddr, fromHeader, err := splitAddress(cfg.From)
if err != nil {
return &smtpStepError{"Absender", err}
@@ -162,7 +245,7 @@ func sendMail(cfg smtpSettings, to, subject, body string) error {
}
}
if cfg.User != "" || cfg.Pass != "" {
if err := client.Auth(smtp.PlainAuth("", cfg.User, cfg.Pass, cfg.Host)); err != nil {
if err := authenticate(client, cfg); err != nil {
return &smtpStepError{"AUTH", err}
}
}
+61 -4
View File
@@ -8,6 +8,7 @@ import (
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"errors"
"math/big"
"net"
@@ -21,8 +22,13 @@ import (
type fakeSMTP struct {
addr string
rcptCode string // e.g. "451 4.3.2 Internal server error" to refuse RCPT TO
// AUTH behaviour: mechanisms announced, and per mechanism a reply code to fail with ("" = accept)
authMechs string
authFail map[string]string
mu sync.Mutex
authUsed []string // mechanisms tried, in order
authUser string // decoded user of a successful LOGIN
ehlo string
mailFrom string
rcptTo string
@@ -53,7 +59,7 @@ func startFakeSMTP(t *testing.T, rcptCode string) *fakeSMTP {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { _ = ln.Close() })
f := &fakeSMTP{addr: ln.Addr().String(), rcptCode: rcptCode}
f := &fakeSMTP{addr: ln.Addr().String(), rcptCode: rcptCode, authMechs: "PLAIN", authFail: map[string]string{}}
tlsCfg := &tls.Config{Certificates: []tls.Certificate{selfSignedCert(t)}}
go func() {
for {
@@ -86,9 +92,9 @@ func (f *fakeSMTP) serve(conn net.Conn, tlsCfg *tls.Config) {
f.ehlo = cmd[5:]
f.mu.Unlock()
if secure {
reply("250-fake\r\n250 AUTH PLAIN")
reply("250-fake\r\n250 AUTH " + f.authMechs)
} else {
reply("250-fake\r\n250-STARTTLS\r\n250 AUTH PLAIN")
reply("250-fake\r\n250-STARTTLS\r\n250 AUTH " + f.authMechs)
}
case upper == "STARTTLS":
reply("220 go ahead")
@@ -99,7 +105,27 @@ func (f *fakeSMTP) serve(conn net.Conn, tlsCfg *tls.Config) {
conn, secure = tlsConn, true
r, w = bufio.NewReader(conn), bufio.NewWriter(conn)
case strings.HasPrefix(upper, "AUTH "):
reply("235 ok")
mech := strings.Fields(upper)[1]
f.mu.Lock()
f.authUsed = append(f.authUsed, mech)
fail := f.authFail[mech]
f.mu.Unlock()
if mech == "LOGIN" {
reply("334 " + base64.StdEncoding.EncodeToString([]byte("Username:")))
u, _ := r.ReadString('\n')
reply("334 " + base64.StdEncoding.EncodeToString([]byte("Password:")))
_, _ = r.ReadString('\n')
if dec, err := base64.StdEncoding.DecodeString(strings.TrimSpace(u)); err == nil {
f.mu.Lock()
f.authUser = string(dec)
f.mu.Unlock()
}
}
if fail != "" {
reply(fail)
} else {
reply("235 ok")
}
case strings.HasPrefix(upper, "MAIL FROM"):
f.mu.Lock()
f.mailFrom = strings.TrimSpace(cmd[len("MAIL FROM:"):])
@@ -213,3 +239,34 @@ func TestEHLONameNeverLocalhost(t *testing.T) {
t.Errorf("fallback must not be localhost, got %q", got)
}
}
func TestAuthFallsBackToLogin(t *testing.T) {
f := startFakeSMTP(t, "")
f.authMechs = "PLAIN LOGIN"
f.authFail["PLAIN"] = "451 4.3.2 Internal server error"
if err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo"); err != nil {
t.Fatalf("LOGIN fallback should succeed: %v", err)
}
f.mu.Lock()
defer f.mu.Unlock()
if strings.Join(f.authUsed, ",") != "PLAIN,LOGIN" || f.authUser != "alarm@firma.de" {
t.Fatalf("expected PLAIN then LOGIN with the user, got %v / %q", f.authUsed, f.authUser)
}
}
func TestAuthLoginOnlyAndBothFailing(t *testing.T) {
f := startFakeSMTP(t, "")
f.authMechs = "LOGIN"
if err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo"); err != nil {
t.Fatalf("LOGIN-only server: %v", err)
}
g := startFakeSMTP(t, "")
g.authMechs = "PLAIN LOGIN"
g.authFail["PLAIN"] = "451 4.3.2 Internal server error"
g.authFail["LOGIN"] = "451 4.3.2 Internal server error"
err := sendMail(g.settings(true), "anna@example.com", "Test", "Hallo")
if err == nil || !strings.Contains(err.Error(), "PLAIN") || !strings.Contains(err.Error(), "LOGIN") || !strings.Contains(err.Error(), "SASL") {
t.Fatalf("expected both mechanisms and the server hint in the error, got %v", err)
}
}
+134
View File
@@ -0,0 +1,134 @@
package api
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"
)
// Outgoing alarm requests (Discord, custom webhooks) go to user-supplied URLs. Without checks a user
// could make the server call internal services (SSRF): localhost, the Docker network or cloud metadata.
var errBlockedTarget = errors.New("Ziel nicht erlaubt: interne oder reservierte Adresse")
func mustCIDRs(list ...string) []*net.IPNet {
out := make([]*net.IPNet, 0, len(list))
for _, c := range list {
_, n, err := net.ParseCIDR(c)
if err != nil {
panic(err)
}
out = append(out, n)
}
return out
}
var (
// Never reachable: this machine, link-local incl. cloud metadata (169.254.169.254), "this network"
alwaysBlocked = mustCIDRs("127.0.0.0/8", "::1/128", "169.254.0.0/16", "fe80::/10", "0.0.0.0/8", "::/128")
// Home/LAN ranges (incl. CGNAT as used by Tailscale), only with the admin switch
// "Ziele im lokalen Netz erlauben"
privateRanges = mustCIDRs("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fc00::/7", "100.64.0.0/10")
)
func targetIPAllowed(ip net.IP, allowPrivate bool) bool {
if ip == nil || ip.IsMulticast() || ip.IsUnspecified() {
return false
}
if v4 := ip.To4(); v4 != nil {
ip = v4
}
for _, n := range alwaysBlocked {
if n.Contains(ip) {
return false
}
}
if !allowPrivate {
for _, n := range privateRanges {
if n.Contains(ip) {
return false
}
}
}
return true
}
// validateWebhookURL checks a URL before it is stored or called. HTTPS is required unless the admin
// allows local targets (LAN services often speak plain HTTP). Discord URLs must point to Discord.
func validateWebhookURL(raw string, discord, allowPrivate bool) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return errors.New("Ungültige URL")
}
switch u.Scheme {
case "https":
case "http":
if !allowPrivate {
return errors.New("Nur https-URLs sind erlaubt (http nur mit freigegebenen Zielen im lokalen Netz)")
}
default:
return errors.New("Nur https-URLs sind erlaubt")
}
if discord {
host := strings.ToLower(u.Hostname())
if u.Scheme != "https" || (host != "discord.com" && host != "discordapp.com") || !strings.HasPrefix(u.Path, "/api/webhooks/") {
return errors.New("Discord-Webhooks müssen mit https://discord.com/api/webhooks/ beginnen")
}
}
if ip := net.ParseIP(u.Hostname()); ip != nil && !targetIPAllowed(ip, allowPrivate) {
return errBlockedTarget
}
return nil
}
// guardedClient resolves the host itself and only connects to allowed addresses. Checking at dial time
// (not just the URL) also covers DNS tricks and redirects; proxies from the environment are not used.
func guardedClient(allowPrivate bool) *http.Client {
dialer := &net.Dialer{Timeout: 5 * time.Second}
transport := &http.Transport{
Proxy: nil,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
for _, ip := range ips {
if targetIPAllowed(ip.IP, allowPrivate) {
return dialer.DialContext(ctx, network, net.JoinHostPort(ip.IP.String(), port))
}
}
return nil, fmt.Errorf("%w (%s)", errBlockedTarget, host)
},
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
MaxIdleConns: 10,
}
return &http.Client{
Timeout: 10 * time.Second,
Transport: transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return errors.New("zu viele Weiterleitungen")
}
return nil
},
}
}
func (s *Server) allowPrivateWebhooks() bool {
sys, _ := s.store.GetSystemSettings()
return sys != nil && sys.AllowPrivateWebhooks
}
+79
View File
@@ -0,0 +1,79 @@
package api
import (
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestTargetIPAllowed(t *testing.T) {
cases := []struct {
ip string
allowPrivate bool
want bool
}{
{"127.0.0.1", true, false},
{"::1", true, false},
{"169.254.169.254", true, false}, // cloud metadata
{"0.0.0.0", true, false},
{"192.168.1.20", false, false},
{"192.168.1.20", true, true},
{"10.0.0.5", false, false},
{"100.101.102.103", true, true}, // Tailscale
{"fd00::1", false, false},
{"93.184.216.34", false, true},
{"::ffff:127.0.0.1", true, false}, // IPv4-mapped loopback
}
for _, c := range cases {
if got := targetIPAllowed(net.ParseIP(c.ip), c.allowPrivate); got != c.want {
t.Errorf("targetIPAllowed(%s, private=%v) = %v, want %v", c.ip, c.allowPrivate, got, c.want)
}
}
}
func TestValidateWebhookURL(t *testing.T) {
ok := []struct {
url string
discord, private bool
}{
{"https://discord.com/api/webhooks/1/abc", true, false},
{"https://hooks.example.org/x", false, false},
{"http://192.168.1.20:8123/api/webhook/x", false, true},
{"", false, false},
}
for _, c := range ok {
if err := validateWebhookURL(c.url, c.discord, c.private); err != nil {
t.Errorf("%q should be allowed: %v", c.url, err)
}
}
bad := []struct {
url string
discord, private bool
}{
{"http://hooks.example.org/x", false, false},
{"https://evil.example.org/api/webhooks/1", true, false},
{"https://127.0.0.1/x", false, true},
{"https://169.254.169.254/latest/meta-data", false, true},
{"https://192.168.1.20/x", false, false},
{"file:///etc/passwd", false, true},
{"gopher://x", false, false},
}
for _, c := range bad {
if err := validateWebhookURL(c.url, c.discord, c.private); err == nil {
t.Errorf("%q must be rejected", c.url)
}
}
}
// A hostname that resolves to localhost (like a test server or a DNS rebinding trick) is refused at
// connection time even though the URL itself looks harmless.
func TestGuardedClientRefusesLoopbackAtDial(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) }))
defer ts.Close()
url := strings.Replace(ts.URL, "127.0.0.1", "localhost", 1)
if _, err := guardedClient(true).Get(url); err == nil || !strings.Contains(err.Error(), "nicht erlaubt") {
t.Fatalf("loopback must be refused at dial time, got %v", err)
}
}
+91
View File
@@ -0,0 +1,91 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/storage"
)
// passwordResetAvailable: mails can be sent and links point to a known address (BASE_URL).
func (s *Server) passwordResetAvailable() bool {
if !s.cfg.BaseURLSet || s.cfg.DemoMode {
return false
}
_, err := s.smtpSettings()
return err == nil
}
// handleForgotPassword always answers the same, whether the address exists or not, and sends the mail
// in the background so the response time does not tell either.
func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
if !s.passwordResetAvailable() {
writeError(w, http.StatusServiceUnavailable, "Passwort-Zurücksetzen ist auf diesem Server nicht eingerichtet. Bitte wende dich an die Administration.")
return
}
var req struct {
Email string `json:"email"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil || strings.TrimSpace(req.Email) == "" {
writeError(w, http.StatusBadRequest, "E-Mail-Adresse erforderlich")
return
}
email := strings.TrimSpace(req.Email)
go func() {
user, err := s.store.GetUserByEmail(email)
if err != nil || user == nil || !user.IsActive {
return
}
token, err := s.store.CreatePasswordResetToken(user.ID)
if err != nil {
if !errors.Is(err, storage.ErrResetTooSoon) {
slog.Warn("Passwort-Reset: Token konnte nicht erzeugt werden", "error", err)
}
return
}
link := strings.TrimRight(s.cfg.BaseURL, "/") + "/?resetToken=" + url.QueryEscape(token)
body := fmt.Sprintf("Hallo %s,\n\nfür dein MindSift-Konto wurde ein neues Passwort angefordert. "+
"Über diesen Link kannst du es innerhalb der nächsten Stunde festlegen:\n\n%s\n\n"+
"Der Link funktioniert nur einmal. Hast du das nicht angefordert, ignoriere diese E-Mail; dein Passwort bleibt unverändert.\n",
user.DisplayName, link)
_ = s.sendEmailNotification(user.Email, "MindSift: Passwort zurücksetzen", body)
}()
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
var req struct {
Token string `json:"token"`
Password string `json:"password"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil || req.Token == "" {
writeError(w, http.StatusBadRequest, storage.ErrResetTokenInvalid.Error())
return
}
if len(req.Password) < 4 {
writeError(w, http.StatusBadRequest, "Das Passwort braucht mindestens 4 Zeichen")
return
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, "Passwort konnte nicht gespeichert werden")
return
}
userID, err := s.store.ResetPasswordWithToken(req.Token, hash)
if err != nil {
if errors.Is(err, storage.ErrResetTokenInvalid) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeError(w, http.StatusInternalServerError, "Passwort konnte nicht gespeichert werden")
return
}
slog.Info("Passwort per E-Mail-Link zurückgesetzt", "user_id", userID)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
+128
View File
@@ -0,0 +1,128 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
)
func (s *Server) authenticateUserFromReq(r *http.Request) string {
token := r.URL.Query().Get("token")
if token == "" {
if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") {
token = strings.TrimPrefix(authHdr, "Bearer ")
}
}
if token != "" {
if strings.HasPrefix(token, "mindsift_sec_") {
if user, err := s.store.ValidateAPIKeyAndGetUser(token); err == nil && user != nil && user.IsActive {
return user.ID
}
}
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil && claims != nil {
if !s.store.IsTokenRevoked(token) {
if user, err := s.store.GetUserByID(claims.UserID); err == nil && user != nil && user.IsActive {
if claims.TokenVersion == 0 || claims.TokenVersion == user.TokenVersion {
return claims.UserID
}
}
}
}
}
return ""
}
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
// Some corporate proxies and reverse proxies (e.g. OpenResty/Nginx or HTTP/2 intermediaries)
// strip or alter the Connection header (e.g. "close" or missing) while passing
// Sec-WebSocket-Key or Upgrade: websocket. Normalize it so Gorilla Upgrader succeeds.
if r.Header.Get("Sec-Websocket-Key") != "" {
if !strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") {
r.Header.Set("Connection", "Upgrade")
}
if !strings.Contains(strings.ToLower(r.Header.Get("Upgrade")), "websocket") {
r.Header.Set("Upgrade", "websocket")
}
}
roomID := chi.URLParam(r, "listId")
if roomID == "" {
roomID = chi.URLParam(r, "spaceId")
}
if roomID == "" {
roomID = r.URL.Query().Get("listId")
}
if roomID == "" {
roomID = r.URL.Query().Get("spaceId")
}
userID := s.authenticateUserFromReq(r)
if userID == "" {
writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte anmelden")
return
}
s.wsHub.ServeWS(w, r, roomID, userID)
}
func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
roomID := r.URL.Query().Get("listId")
if roomID == "" {
roomID = r.URL.Query().Get("spaceId")
}
userID := s.authenticateUserFromReq(r)
if userID == "" {
writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte anmelden")
return
}
var initialRooms []string
if roomsParam := r.URL.Query().Get("rooms"); roomsParam != "" {
for _, rm := range strings.Split(roomsParam, ",") {
rm = strings.TrimSpace(rm)
if rm != "" {
initialRooms = append(initialRooms, rm)
}
}
}
if docID := r.URL.Query().Get("docId"); docID != "" {
initialRooms = append(initialRooms, "doc:"+strings.TrimSpace(docID))
}
s.wsHub.ServeSSE(w, r, roomID, userID, initialRooms)
}
func (s *Server) handleSSERooms(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
SSEID string `json:"sseId"`
Room string `json:"room"`
DocID string `json:"docId"`
Action string `json:"action"` // "join" or "leave"
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if req.Room == "" && req.DocID != "" {
req.Room = "doc:" + req.DocID
}
if req.SSEID == "" || req.Room == "" {
writeError(w, http.StatusBadRequest, "sseId und room erforderlich")
return
}
if req.Action == "leave" {
s.wsHub.LeaveSSERoomByID(req.SSEID, userID, req.Room)
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
return
}
if !s.wsHub.JoinSSERoomByID(req.SSEID, userID, req.Room) {
writeError(w, http.StatusForbidden, "Kein Zugriff auf diesen Raum")
return
}
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
+63 -2685
View File
File diff suppressed because it is too large. Load diff
+81 -1
View File
@@ -1369,7 +1369,8 @@ func TestFirstBootSetupWithSampleData(t *testing.T) {
// Impressum and Datenschutz must be reachable without login; unset texts stay hidden.
func TestLegalTextsArePublic(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "legal.db")
// In memory: no files the cleanup could race with
dbPath := ":memory:"
store, err := storage.NewStore(dbPath)
if err != nil {
t.Fatalf("NewStore: %v", err)
@@ -1405,3 +1406,82 @@ func TestLegalTextsArePublic(t *testing.T) {
t.Fatalf("privacy without text should be 404, got %d", rec.Code)
}
}
// API keys have exactly one right until scopes exist: delivering data. Revoked sessions are refused
// everywhere, the webhook ingest included.
func TestAPIKeysOnlyDeliverData(t *testing.T) {
srv, store, token := setupTestServer(t)
router := srv.Router()
claims, _ := auth.ValidateToken(testJWTSecret, token)
key, err := store.CreateAPIKey(claims.UserID, "Home Assistant")
if err != nil {
t.Fatalf("CreateAPIKey: %v", err)
}
do := func(method, path, auth, body string) int {
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if strings.HasPrefix(auth, "mindsift_sec_") {
req.Header.Set("X-API-Key", auth)
} else {
req.Header.Set("Authorization", "Bearer "+auth)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec.Code
}
if code := do(http.MethodPost, "/api/v1/inbox", key.RawKey, `{"content":"Waschmaschine fertig"}`); code >= 300 {
t.Fatalf("API key must be able to post into the inbox, got %d", code)
}
if code := do(http.MethodPost, "/api/v1/inbox", key.RawKey, `{"content":"Filter wechseln","target":"task"}`); code >= 300 {
t.Fatalf("API key must be able to create a task via the inbox, got %d", code)
}
if code := do(http.MethodGet, "/api/v1/documents", key.RawKey, ""); code != http.StatusForbidden {
t.Fatalf("API key must not read notes, got %d", code)
}
if code := do(http.MethodPost, "/api/v1/webhooks/ingest", key.RawKey, `{"content":"Rauchmelder"}`); code >= 300 {
t.Fatalf("API key must work on the webhook ingest, got %d", code)
}
// Logged-out session token: refused by the ingest as well (was FLAW-08)
if code := do(http.MethodPost, "/api/v1/auth/logout", token, ""); code >= 300 {
t.Fatalf("logout: %d", code)
}
if code := do(http.MethodPost, "/api/v1/webhooks/ingest", token, `{"content":"x"}`); code != http.StatusUnauthorized {
t.Fatalf("revoked token must be refused by the ingest, got %d", code)
}
}
func TestPasswordResetFlow(t *testing.T) {
srv, store, _ := setupTestServer(t)
router := srv.Router()
post := func(path, body string) (int, string) {
req := httptest.NewRequest("POST", path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec.Code, rec.Body.String()
}
// Without BASE_URL and SMTP the feature is off
if code, _ := post("/api/v1/auth/forgot-password", `{"email":"x@example.com"}`); code != http.StatusServiceUnavailable {
t.Errorf("forgot-password without setup: want 503, got %d", code)
}
// The link from the mail sets a new password once and ends the old sessions
u, _ := store.CreateUser("reset@example.com", "hash", "Reset", "member")
raw, err := store.CreatePasswordResetToken(u.ID)
if err != nil {
t.Fatalf("token: %v", err)
}
if code, _ := post("/api/v1/auth/reset-password", `{"token":"`+raw+`","password":"abc"}`); code != http.StatusBadRequest {
t.Errorf("too short password must be refused, got %d", code)
}
if code, body := post("/api/v1/auth/reset-password", `{"token":"`+raw+`","password":"neues-passwort"}`); code != http.StatusOK {
t.Fatalf("reset: %d %s", code, body)
}
if code, _ := post("/api/v1/auth/login", `{"login":"reset@example.com","password":"neues-passwort"}`); code != http.StatusOK {
t.Errorf("login with the new password failed: %d", code)
}
if code, _ := post("/api/v1/auth/reset-password", `{"token":"`+raw+`","password":"nochmal1"}`); code != http.StatusBadRequest {
t.Errorf("token must work only once, got %d", code)
}
}
+51 -1
View File
@@ -1,6 +1,8 @@
package config
import (
"crypto/rand"
"encoding/hex"
"log/slog"
"os"
"path/filepath"
@@ -13,6 +15,9 @@ type Config struct {
DatabasePath string
JWTSecret string
BaseURL string
// BaseURLSet: BASE_URL was configured explicitly. Links in mails (password reset) are only built
// from it, never from the request's Host header.
BaseURLSet bool
AllowRegistration bool
LogLevel string
Debug bool
@@ -37,6 +42,50 @@ type LegalTexts struct {
PrivacyURL, PrivacyContent string
}
// Publicly known JWT secrets (old code default and the placeholders from .env.example, compose and
// README). Signing with one of them would let anyone forge login tokens.
var insecureJWTSecrets = map[string]bool{
"mindsift-default-selfhosted-jwt-secret-key-2026": true,
"change_this_to_a_secure_random_key_min_32_chars": true,
"hier-ein-langer-zufaelliger-wert": true,
"changeme": true,
"secret": true,
}
const jwtSecretFile = "jwt.secret"
// resolveJWTSecret uses JWT_SECRET when it is set to a real value. Otherwise it generates a random
// secret once and keeps it in the data directory, so logins survive restarts without anybody being
// able to forge tokens with a known default.
func resolveJWTSecret(fromEnv, dataDir string) string {
if v := strings.TrimSpace(fromEnv); v != "" && !insecureJWTSecrets[v] {
if len(v) < 32 {
slog.Warn("JWT_SECRET ist kürzer als 32 Zeichen – bitte einen längeren Zufallswert verwenden")
}
return v
} else if v != "" {
slog.Warn("JWT_SECRET ist ein öffentlich bekannter Platzhalter und wird ignoriert")
}
path := filepath.Join(dataDir, jwtSecretFile)
if data, err := os.ReadFile(path); err == nil {
if s := strings.TrimSpace(string(data)); len(s) >= 32 {
return s
}
}
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
panic("crypto/rand: " + err.Error())
}
secret := hex.EncodeToString(buf)
_ = os.MkdirAll(dataDir, 0o755)
if err := os.WriteFile(path, []byte(secret+"\n"), 0o600); err != nil {
slog.Error("Erzeugtes JWT-Secret konnte nicht gespeichert werden – Anmeldungen gelten nur bis zum Neustart", "pfad", path, "error", err)
} else {
slog.Warn("Kein JWT_SECRET gesetzt: zufälliges Secret erzeugt und im Datenverzeichnis gespeichert. Für Produktion besser JWT_SECRET setzen.", "pfad", path)
}
return secret
}
// legalContent reads Markdown from NAME or, for longer texts, from the file named in NAME_FILE.
func legalContent(name string) string {
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
@@ -78,7 +127,7 @@ func Load() *Config {
dbPath = dbURL
}
jwtSecret := getEnv("JWT_SECRET", "mindsift-default-selfhosted-jwt-secret-key-2026")
jwtSecret := resolveJWTSecret(os.Getenv("JWT_SECRET"), filepath.Dir(dbPath))
baseURL := getEnv("BASE_URL", "http://localhost:"+port)
allowReg := strings.ToLower(getEnv("ALLOW_REGISTRATION", "false")) == "true"
@@ -107,6 +156,7 @@ func Load() *Config {
DatabasePath: dbPath,
JWTSecret: jwtSecret,
BaseURL: baseURL,
BaseURLSet: strings.TrimSpace(os.Getenv("BASE_URL")) != "",
AllowRegistration: allowReg,
LogLevel: logLevel,
Debug: debug,
+25
View File
@@ -3,10 +3,13 @@ package config
import (
"log/slog"
"os"
"path/filepath"
"testing"
)
func TestConfigLogLevelAndDebug(t *testing.T) {
// Load writes the generated JWT secret into the data directory; keep it out of the repository
t.Setenv("DATA_DIR", t.TempDir())
os.Setenv("PORT", "9090")
defer os.Unsetenv("PORT")
@@ -56,3 +59,25 @@ func TestConfigLogLevelAndDebug(t *testing.T) {
os.Unsetenv("DEBUG")
os.Unsetenv("LOG_LEVEL")
}
func TestJWTSecretNeverUsesAKnownDefault(t *testing.T) {
dir := t.TempDir()
for _, env := range []string{"", "mindsift-default-selfhosted-jwt-secret-key-2026", "change_this_to_a_secure_random_key_min_32_chars"} {
got := resolveJWTSecret(env, dir)
if got == env || len(got) < 32 {
t.Fatalf("env %q: insecure secret %q", env, got)
}
}
// The generated secret is stable across restarts
first := resolveJWTSecret("", dir)
if again := resolveJWTSecret("", dir); again != first {
t.Fatalf("generated secret must persist, got %q then %q", first, again)
}
info, err := os.Stat(filepath.Join(dir, jwtSecretFile))
if err != nil || info.Mode().Perm() != 0o600 {
t.Fatalf("secret file must exist with mode 0600: %v %v", info, err)
}
if got := resolveJWTSecret("ein-eigener-sehr-langer-zufaelliger-wert-123456", dir); got != "ein-eigener-sehr-langer-zufaelliger-wert-123456" {
t.Fatalf("a real JWT_SECRET must win, got %q", got)
}
}
+77
View File
@@ -0,0 +1,77 @@
package domain
import (
"strings"
"time"
)
// Due dates are stored as "2006-01-02" (all day) or "2006-01-02T15:04" (with time), always in the
// server's local time zone (TZ, default Europe/Berlin). Both sort correctly as plain strings.
const (
dueDateLayout = "2006-01-02"
dueDateTimeLayout = "2006-01-02T15:04"
)
// AllDayAlarmHour is when alarms for tasks without a time fire.
const AllDayAlarmHour = 9
// NormalizeDue brings accepted spellings ("2006-01-02 15:04", seconds, RFC3339) to the stored form.
// Values it cannot read are kept as they are so no input gets lost.
func NormalizeDue(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if t, hasTime, ok := ParseDue(raw, time.Local); ok {
if hasTime {
return t.Format(dueDateTimeLayout)
}
return t.Format(dueDateLayout)
}
return raw
}
// ParseDue reads a due date in loc. hasTime is false for all-day dates (then t is midnight).
func ParseDue(raw string, loc *time.Location) (t time.Time, hasTime, ok bool) {
raw = strings.TrimSpace(raw)
if t, err := time.ParseInLocation(dueDateLayout, raw, loc); err == nil {
return t, false, true
}
for _, layout := range []string{dueDateTimeLayout, "2006-01-02 15:04", "2006-01-02T15:04:05", "2006-01-02 15:04:05"} {
if t, err := time.ParseInLocation(layout, raw, loc); err == nil {
return t, true, true
}
}
if t, err := time.Parse(time.RFC3339, raw); err == nil {
return t.In(loc), true, true
}
return time.Time{}, false, false
}
// DueAlarmAt is the moment the alarm for a due date fires: the due time (09:00 for all-day tasks)
// minus the user's lead time.
func DueAlarmAt(raw string, notifyBeforeMinutes int, loc *time.Location) (time.Time, bool) {
t, hasTime, ok := ParseDue(raw, loc)
if !ok {
return time.Time{}, false
}
if !hasTime {
t = time.Date(t.Year(), t.Month(), t.Day(), AllDayAlarmHour, 0, 0, 0, loc)
}
if notifyBeforeMinutes > 0 {
t = t.Add(-time.Duration(notifyBeforeMinutes) * time.Minute)
}
return t, true
}
// HumanDue renders a due date for messages: "30.10.2026" or "30.10.2026, 14:30 Uhr".
func HumanDue(raw string) string {
t, hasTime, ok := ParseDue(raw, time.Local)
if !ok {
return raw
}
if hasTime {
return t.Format("02.01.2006, 15:04") + " Uhr"
}
return t.Format("02.01.2006")
}
+40
View File
@@ -0,0 +1,40 @@
package domain
import (
"testing"
"time"
)
func TestNormalizeDue(t *testing.T) {
cases := map[string]string{
"2026-10-30": "2026-10-30",
"2026-10-30T14:30": "2026-10-30T14:30",
"2026-10-30 14:30": "2026-10-30T14:30",
"2026-10-30T14:30:59": "2026-10-30T14:30",
" ": "",
"nächste Woche": "nächste Woche",
}
for in, want := range cases {
if got := NormalizeDue(in); got != want {
t.Errorf("NormalizeDue(%q) = %q, want %q", in, got, want)
}
}
}
func TestDueAlarmAt(t *testing.T) {
berlin, err := time.LoadLocation("Europe/Berlin")
if err != nil {
t.Fatal(err)
}
at, ok := DueAlarmAt("2026-10-30", 0, berlin)
if !ok || !at.Equal(time.Date(2026, 10, 30, 9, 0, 0, 0, berlin)) {
t.Errorf("all-day alarm should fire at 09:00 local, got %v", at)
}
at, _ = DueAlarmAt("2026-10-30T14:30", 15, berlin)
if !at.Equal(time.Date(2026, 10, 30, 14, 15, 0, 0, berlin)) {
t.Errorf("lead time not applied: %v", at)
}
if _, ok := DueAlarmAt("irgendwann", 0, berlin); ok {
t.Errorf("unreadable due date must not produce an alarm")
}
}
+47 -12
View File
@@ -3,6 +3,7 @@ package domain
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"regexp"
"strings"
@@ -182,7 +183,8 @@ type Thought struct {
KanbanStatus string `json:"kanbanStatus,omitempty"` // todo | in_progress | done
Notes string `json:"notes,omitempty"`
Subtasks []SubTask `json:"subtasks"`
Assignees []string `json:"assignees"` // user IDs; only people with access to the task list
Assignees []string `json:"assignees"` // user IDs; only people with access to the task list
Recurrence string `json:"recurrence,omitempty"` // "" | daily | weekly | monthly | yearly
AlarmSent bool `json:"alarmSent,omitempty"`
SortOrder int `json:"sortOrder"`
CreatedAt string `json:"createdAt"`
@@ -276,17 +278,50 @@ type TeamMember struct {
}
type SystemSettings struct {
SharingMode string `json:"sharingMode"` // private | organizations | open
AllowRegistration bool `json:"allowRegistration"`
InstanceName string `json:"instanceName"`
SMTPHost string `json:"smtpHost"`
SMTPPort string `json:"smtpPort"`
SMTPUser string `json:"smtpUser"`
SMTPPass string `json:"smtpPass"`
SMTPFrom string `json:"smtpFrom"`
SMTPTLS bool `json:"smtpTls"`
SMTPSkipVerify bool `json:"smtpSkipVerify"` // accept self-signed certificates
SMTPConfiguredViaEnv bool `json:"smtpConfiguredViaEnv,omitempty"`
SharingMode string `json:"sharingMode"` // private | organizations | open
AllowRegistration bool `json:"allowRegistration"`
InstanceName string `json:"instanceName"`
SMTPHost string `json:"smtpHost"`
SMTPPort string `json:"smtpPort"`
SMTPUser string `json:"smtpUser"`
SMTPPass string `json:"smtpPass"`
SMTPFrom string `json:"smtpFrom"`
SMTPTLS bool `json:"smtpTls"`
SMTPSkipVerify bool `json:"smtpSkipVerify"` // accept self-signed certificates
// AllowPrivateWebhooks lets alarm webhooks reach LAN addresses (and plain http); localhost and cloud
// metadata stay blocked
AllowPrivateWebhooks bool `json:"allowPrivateWebhooks"`
SMTPConfiguredViaEnv bool `json:"smtpConfiguredViaEnv,omitempty"`
}
// UnmarshalJSON accepts smtpPort as text or number: older clients sent a number, which made the whole
// settings request fail with "Ungültiges JSON".
func (s *SystemSettings) UnmarshalJSON(data []byte) error {
type plain SystemSettings
aux := struct {
*plain
SMTPPort json.RawMessage `json:"smtpPort"`
}{plain: (*plain)(s)}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
port := strings.TrimSpace(string(aux.SMTPPort))
switch {
case port == "" || port == "null":
case strings.HasPrefix(port, `"`):
var str string
if err := json.Unmarshal(aux.SMTPPort, &str); err != nil {
return err
}
s.SMTPPort = strings.TrimSpace(str)
default:
var n json.Number
if err := json.Unmarshal(aux.SMTPPort, &n); err != nil {
return err
}
s.SMTPPort = n.String()
}
return nil
}
type DocumentBlock struct {
+17
View File
@@ -1,6 +1,7 @@
package domain
import (
"encoding/json"
"reflect"
"testing"
)
@@ -32,3 +33,19 @@ func TestStaleAnchorTags(t *testing.T) {
t.Fatalf("only tags that exist solely as anchors are stale, got %v", got)
}
}
func TestSystemSettingsAcceptsPortAsNumberOrText(t *testing.T) {
for _, body := range []string{`{"smtpPort": 465, "smtpHost": "mail"}`, `{"smtpPort": "465", "smtpHost": "mail"}`} {
var s SystemSettings
if err := json.Unmarshal([]byte(body), &s); err != nil {
t.Fatalf("%s: %v", body, err)
}
if s.SMTPPort != "465" || s.SMTPHost != "mail" {
t.Errorf("%s: got port %q host %q", body, s.SMTPPort, s.SMTPHost)
}
}
var empty SystemSettings
if err := json.Unmarshal([]byte(`{"smtpPort": ""}`), &empty); err != nil || empty.SMTPPort != "" {
t.Errorf("empty port: %q (%v)", empty.SMTPPort, err)
}
}
+92
View File
@@ -0,0 +1,92 @@
package storage
import (
"errors"
"fmt"
)
// ErrLastAdmin protects the server from losing its last administrator.
var ErrLastAdmin = errors.New("Der letzte Server-Administrator kann nicht gelöscht werden. Bitte ernenne vorher ein anderes Konto zum Administrator.")
// PurgeUserData deletes everything the user owns – thoughts and tasks, notes (with blocks, history and
// shares), folders, shared folders, task lists and lists – in one transaction. The account, its
// settings, themes, API keys and group memberships stay. Other people's tasks in the user's task lists
// become their own personal tasks, as when a single list is deleted.
func (s *Store) PurgeUserData(userID string) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
ownDocs := `(SELECT id FROM documents WHERE user_id = ?)`
ownTaskLists := `(SELECT id FROM task_lists WHERE user_id = ?)`
ownFolders := `(SELECT id FROM workspace_shared_folders WHERE owner_id = ?)`
stmts := []struct {
q string
args int // how many times userID is passed
}{
// Task lists: keep the tasks of others, drop shares and the lists
{`UPDATE thoughts SET task_list_id = '' WHERE task_list_id IN ` + ownTaskLists + ` AND user_id != ?`, 2},
{`DELETE FROM task_list_shares WHERE task_list_id IN ` + ownTaskLists, 1},
{`DELETE FROM task_lists WHERE user_id = ?`, 1},
// Tasks of others linked to blocks of the user's notes lose the link
{`UPDATE thoughts SET converted_type = '', converted_ref_id = '' WHERE converted_type = 'document_block'
AND substr(converted_ref_id, 1, instr(converted_ref_id, ':') - 1) IN ` + ownDocs, 1},
{`DELETE FROM thoughts WHERE user_id = ?`, 1},
// Shared folders owned by the user
{`UPDATE documents SET shared_folder_id = '', sub_folder_path = '' WHERE shared_folder_id IN ` + ownFolders, 1},
{`DELETE FROM workspace_folder_mounts WHERE folder_id IN ` + ownFolders, 1},
{`DELETE FROM workspace_folder_shares WHERE folder_id IN ` + ownFolders, 1},
{`DELETE FROM workspace_folders WHERE shared_folder_id IN ` + ownFolders, 1},
{`DELETE FROM workspace_shared_folders WHERE owner_id = ?`, 1},
// Notes (history tables cascade with the document)
{`DELETE FROM blocks WHERE document_id IN ` + ownDocs, 1},
{`DELETE FROM block_tags WHERE document_id IN ` + ownDocs, 1},
{`DELETE FROM doc_user_folders WHERE doc_id IN ` + ownDocs, 1},
{`DELETE FROM doc_team_shares WHERE doc_id IN ` + ownDocs, 1},
{`DELETE FROM doc_shares WHERE doc_id IN ` + ownDocs, 1},
{`DELETE FROM documents WHERE user_id = ?`, 1},
// The user's own placement of notes, folders, pins and tag pages
{`DELETE FROM doc_user_folders WHERE user_id = ?`, 1},
{`DELETE FROM doc_pins WHERE user_id = ?`, 1},
{`DELETE FROM workspace_folders WHERE user_id = ?`, 1},
{`DELETE FROM tag_pages WHERE user_id = ?`, 1},
// Lists (items cascade)
{`DELETE FROM list_shares WHERE list_id IN (SELECT id FROM lists WHERE user_id = ?)`, 1},
{`DELETE FROM lists WHERE user_id = ?`, 1},
}
for _, st := range stmts {
args := make([]interface{}, st.args)
for i := range args {
args[i] = userID
}
if _, err := tx.Exec(st.q, args...); err != nil {
return fmt.Errorf("Daten löschen: %w", err)
}
}
if err := tx.Commit(); err != nil {
return err
}
s.PruneTaskAssignees()
return nil
}
// IsLastAdmin reports whether userID is an administrator and no other one exists.
func (s *Store) IsLastAdmin(userID string) bool {
var role string
if err := s.db.QueryRow(`SELECT role FROM users WHERE id = ?`, userID).Scan(&role); err != nil || role != "admin" {
return false
}
var others int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM users WHERE role = 'admin' AND id != ?`, userID).Scan(&others)
return others == 0
}
// DeleteOwnAccount removes the user with all data; refused for the last administrator.
func (s *Store) DeleteOwnAccount(userID string) error {
if s.IsLastAdmin(userID) {
return ErrLastAdmin
}
return s.DeleteUser(userID)
}
+92
View File
@@ -0,0 +1,92 @@
package storage
import (
"errors"
"path/filepath"
"testing"
"github.com/sandrodach/mindsift/internal/domain"
)
func TestPurgeUserDataKeepsAccountAndOthersData(t *testing.T) {
store, err := NewStore(filepath.Join(t.TempDir(), "purge.db"))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
defer store.Close()
openSharing(t, store)
anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member")
ben, _ := store.CreateUser("ben@example.com", "hash", "Ben", "member")
blocks := []domain.DocumentBlock{{ID: "b1", Type: "bullet", Content: "x"}}
annaDoc, _ := store.CreateDocument(anna.ID, "", "Annas Notiz", "", "Ordner", blocks, nil, false)
_, _ = store.CreateDocShare(anna.ID, annaDoc.ID, ben.ID, "write")
benDoc, _ := store.CreateDocument(ben.ID, "", "Bens Notiz", "", "", blocks, nil, false)
_, _ = store.CreateDocShare(ben.ID, benDoc.ID, anna.ID, "write")
tl, _ := store.CreateTaskList(anna.ID, "", "", "Haushalt", "", "")
_, _ = store.AddTaskListShare(tl.ID, "user", ben.ID, "editor", anna.ID)
bensTask, _ := store.CreateThoughtWithDetails(ben.ID, "Bens Aufgabe", "normal", "manual", "", tl.ID, "todo", "")
_, _ = store.UpdateThoughtTriage(ben.ID, bensTask.ID, "keep", "task", "", "", tl.ID)
_, _ = store.CreateThought(anna.ID, "Annas Gedanke", "normal", "manual", "")
list, _ := store.CreateList(anna.ID, "Einkauf", "", "shopping")
_, _ = store.CreateListItem(list.ID, "Milch", "", "", 0, anna.ID)
_, _ = store.SaveUserTheme(anna.ID, "", "Wald", ":root{--accent-primary:#16a34a}")
if err := store.PurgeUserData(anna.ID); err != nil {
t.Fatalf("PurgeUserData: %v", err)
}
if docs, _ := store.GetDocuments(anna.ID); len(docs) != 1 || docs[0].ID != benDoc.ID {
t.Errorf("Anna should only still see Ben's shared note, got %+v", docs)
}
if th, _ := store.GetThoughts(anna.ID, "all", 100); len(th) != 0 {
t.Errorf("Anna's thoughts must be gone, got %d", len(th))
}
if tls, _ := store.GetTaskLists(anna.ID); len(tls) != 0 {
t.Errorf("Anna's task lists must be gone")
}
if l, _ := store.GetAllListsForUser(anna.ID); len(l) != 0 {
t.Errorf("Anna's lists must be gone")
}
if u, err := store.GetUserByID(anna.ID); err != nil || u == nil {
t.Errorf("the account must stay")
}
if th, _ := store.GetUserThemes(anna.ID); len(th) != 1 {
t.Errorf("settings such as themes must stay")
}
// Ben keeps his task (now without list) and no longer sees Anna's note
task, err := store.GetThoughtByID(ben.ID, bensTask.ID)
if err != nil || task.TaskListID != "" {
t.Errorf("Ben's task must survive as personal task, got %+v (%v)", task, err)
}
if docs, _ := store.GetDocuments(ben.ID); len(docs) != 1 {
t.Errorf("Ben should only see his own note, got %d", len(docs))
}
}
func TestDeleteOwnAccountProtectsLastAdmin(t *testing.T) {
store, err := NewStore(filepath.Join(t.TempDir(), "account.db"))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
defer store.Close()
admin, _ := store.CreateUser("admin@example.com", "hash", "Admin", "admin")
member, _ := store.CreateUser("member@example.com", "hash", "Mitglied", "member")
if err := store.DeleteOwnAccount(admin.ID); !errors.Is(err, ErrLastAdmin) {
t.Fatalf("last admin must not delete the account, got %v", err)
}
if err := store.DeleteOwnAccount(member.ID); err != nil {
t.Fatalf("member deletes own account: %v", err)
}
if _, err := store.GetUserByID(member.ID); err == nil {
t.Errorf("member account should be gone")
}
second, _ := store.CreateUser("admin2@example.com", "hash", "Admin 2", "admin")
if err := store.DeleteOwnAccount(admin.ID); err != nil {
t.Fatalf("with a second admin the first may leave: %v", err)
}
if !store.IsLastAdmin(second.ID) {
t.Errorf("the remaining admin is now the last one")
}
}
+75
View File
@@ -0,0 +1,75 @@
package storage
import (
"errors"
"strings"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
)
// --- API KEYS & WEBHOOKS ---
func (s *Store) CreateAPIKey(userID, name string) (*domain.APIKey, error) {
if strings.TrimSpace(name) == "" {
name = "Smarthome / Webhook Key"
}
rawKey, prefix, hash := auth.GenerateAPIKey()
k := &domain.APIKey{
ID: domain.NewID("key"),
UserID: userID,
Name: strings.TrimSpace(name),
KeyPrefix: prefix,
KeyHash: hash,
RawKey: rawKey,
Scopes: "ingest,read",
CreatedAt: domain.NowISO(),
}
_, err := s.db.Exec(
`INSERT INTO api_keys (id, user_id, name, key_prefix, key_hash, scopes, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
k.ID, k.UserID, k.Name, k.KeyPrefix, k.KeyHash, k.Scopes, k.CreatedAt,
)
if err != nil {
return nil, err
}
return k, nil
}
func (s *Store) GetAPIKeys(userID string) ([]domain.APIKey, error) {
rows, err := s.db.Query(
`SELECT id, user_id, name, key_prefix, scopes, last_used_at, created_at
FROM api_keys WHERE user_id = ? ORDER BY created_at DESC`,
userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
keys := make([]domain.APIKey, 0)
for rows.Next() {
var k domain.APIKey
if err := rows.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyPrefix, &k.Scopes, &k.LastUsedAt, &k.CreatedAt); err == nil {
keys = append(keys, k)
}
}
return keys, nil
}
func (s *Store) DeleteAPIKey(userID, keyID string) error {
_, err := s.db.Exec(`DELETE FROM api_keys WHERE id = ? AND user_id = ?`, keyID, userID)
return err
}
func (s *Store) ValidateAPIKeyAndGetUser(rawKey string) (*domain.User, error) {
hash := auth.HashAPIKey(strings.TrimSpace(rawKey))
var keyID, userID string
err := s.db.QueryRow(`SELECT id, user_id FROM api_keys WHERE key_hash = ?`, hash).Scan(&keyID, &userID)
if err != nil {
return nil, errors.New("invalid API key")
}
_, _ = s.db.Exec(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`, domain.NowISO(), keyID)
return s.GetUserByID(userID)
}
+101
View File
@@ -0,0 +1,101 @@
package storage
import (
"errors"
"regexp"
"sort"
"strings"
)
// Backlinks ("Verlinkt auf diese Notiz") lists the notes whose blocks contain a wiki link [[Title]]
// to a note. Matching ignores case and surrounding spaces like the link click in the editor;
// [[Title|Text]] and [[Title#Abschnitt]] count as well. Only notes the user can open are listed,
// never the note itself or notes in the trash.
// ErrNoAccess is returned when the user cannot open the note.
var ErrNoAccess = errors.New("Kein Zugriff auf diese Notiz")
var wikiLinkRe = regexp.MustCompile(`\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]`)
const maxBacklinkBlocks = 5
type BacklinkBlock struct {
ID string `json:"id"`
Content string `json:"content"`
}
type Backlink struct {
DocID string `json:"docId"`
Title string `json:"title"`
Icon string `json:"icon"`
FolderPath string `json:"folderPath"`
Blocks []BacklinkBlock `json:"blocks"`
More int `json:"more,omitempty"` // further matching blocks not listed
}
func linksTo(content, title string) bool {
for _, m := range wikiLinkRe.FindAllStringSubmatch(content, -1) {
if strings.EqualFold(strings.TrimSpace(m[1]), title) {
return true
}
}
return false
}
func (s *Store) GetBacklinks(userID, docID string) ([]Backlink, error) {
if s.DocRole(userID, docID) == "" {
return nil, ErrNoAccess
}
var title string
if err := s.db.QueryRow(`SELECT title FROM documents WHERE id = ?`, docID).Scan(&title); err != nil {
return nil, err
}
title = strings.TrimSpace(title)
out := []Backlink{}
if title == "" {
return out, nil
}
// The tree of the user: what they can open, with their own folder paths
docs, err := s.GetDocuments(userID)
if err != nil {
return nil, err
}
byID := make(map[string]int, len(docs))
for i, d := range docs {
byID[d.ID] = i
}
rows, err := s.db.Query(`SELECT id, document_id, content FROM blocks
WHERE deleted = 0 AND document_id != ? AND content LIKE '%[[%'
ORDER BY document_id, order_key`, docID)
if err != nil {
return nil, err
}
defer rows.Close()
index := map[string]int{}
for rows.Next() {
var blockID, sourceID, content string
if rows.Scan(&blockID, &sourceID, &content) != nil {
continue
}
di, visible := byID[sourceID]
if !visible || !linksTo(content, title) {
continue
}
pos, seen := index[sourceID]
if !seen {
d := docs[di]
out = append(out, Backlink{DocID: d.ID, Title: d.Title, Icon: d.Icon, FolderPath: d.FolderPath})
pos = len(out) - 1
index[sourceID] = pos
}
if len(out[pos].Blocks) < maxBacklinkBlocks {
out[pos].Blocks = append(out[pos].Blocks, BacklinkBlock{ID: blockID, Content: content})
} else {
out[pos].More++
}
}
sort.SliceStable(out, func(i, j int) bool { return strings.ToLower(out[i].Title) < strings.ToLower(out[j].Title) })
return out, nil
}
+48
View File
@@ -0,0 +1,48 @@
package storage
import (
"path/filepath"
"testing"
"github.com/sandrodach/mindsift/internal/domain"
)
func TestBacklinks(t *testing.T) {
store, err := NewStore(filepath.Join(t.TempDir(), "backlinks.db"))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
defer store.Close()
openSharing(t, store)
anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member")
ben, _ := store.CreateUser("ben@example.com", "hash", "Ben", "member")
block := func(id, content string) []domain.DocumentBlock {
return []domain.DocumentBlock{{ID: id, Type: "bullet", Content: content, Level: 0}}
}
target, _ := store.CreateDocument(anna.ID, "", "Homelab", "🖥️", "", block("t1", "Server [[Homelab]] selbst"), nil, false)
_, _ = store.CreateDocument(anna.ID, "", "Einkauf", "🛒", "Privat", block("a1", "Kabel für [[ homelab |das Labor]] kaufen"), nil, false)
_, _ = store.CreateDocument(anna.ID, "", "Ohne", "", "", block("a2", "Homelab ohne Klammern, [[Homelabor]]"), nil, false)
trashed, _ := store.CreateDocument(anna.ID, "", "Alt", "", "", block("a3", "[[Homelab#Netz]]"), nil, false)
_ = store.DeleteDocument(anna.ID, trashed.ID)
bens, _ := store.CreateDocument(ben.ID, "", "Bens Plan", "", "", block("b1", "siehe [[Homelab]]"), nil, false)
links, err := store.GetBacklinks(anna.ID, target.ID)
if err != nil {
t.Fatalf("GetBacklinks: %v", err)
}
if len(links) != 1 || links[0].Title != "Einkauf" || links[0].FolderPath != "Privat" || len(links[0].Blocks) != 1 || links[0].Blocks[0].ID != "a1" {
t.Fatalf("expected only Einkauf (alias link), not itself, plain text, other titles, trash or Ben's private note: %+v", links)
}
// Ben's note counts once Anna can see it
if _, err := store.CreateDocShare(ben.ID, bens.ID, anna.ID, "read"); err != nil {
t.Fatalf("share: %v", err)
}
if links, _ := store.GetBacklinks(anna.ID, target.ID); len(links) != 2 {
t.Errorf("shared note should appear: %+v", links)
}
if _, err := store.GetBacklinks(ben.ID, target.ID); err == nil {
t.Errorf("Ben cannot open Homelab, so no backlinks for him")
}
}
+12 -4
View File
@@ -482,7 +482,7 @@ func (s *Store) ReconcileDocTaskLinks(docID string) []string {
// GetThoughtByIDUnchecked loads a task without access check, for server-internal notifications.
func (s *Store) GetThoughtByIDUnchecked(thoughtID string) (*domain.Thought, error) {
return scanThought(s.db.QueryRow(
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
FROM thoughts WHERE id = ?`, thoughtID))
}
@@ -549,14 +549,22 @@ func (s *Store) DocRole(userID, docID string) string {
if userID == "" {
return ""
}
var ownerID, sharedFolderID, folderOwner string
var ownerID, sharedFolderID, folderOwner, archivedBy string
var archived bool
err := s.db.QueryRow(
`SELECT d.user_id, d.shared_folder_id, COALESCE(wsf.owner_id, '')
`SELECT d.user_id, d.shared_folder_id, COALESCE(wsf.owner_id, ''), d.is_archived, d.archived_by
FROM documents d LEFT JOIN workspace_shared_folders wsf ON wsf.id = d.shared_folder_id
WHERE d.id = ?`, docID).Scan(&ownerID, &sharedFolderID, &folderOwner)
WHERE d.id = ?`, docID).Scan(&ownerID, &sharedFolderID, &folderOwner, &archived, &archivedBy)
if err != nil {
return ""
}
if archived {
// In the trash: read-only for the owner and whoever trashed it, hidden from everyone else
if ownerID == userID || archivedBy == userID {
return DocRoleViewer
}
return ""
}
if ownerID == userID {
return DocRoleOwner
}
+958
View File
@@ -0,0 +1,958 @@
package storage
import (
"encoding/json"
"fmt"
"path/filepath"
"strings"
"github.com/sandrodach/mindsift/internal/domain"
)
func (s *Store) loadDocBlocks(docID string, fallbackJSON string) []domain.DocumentBlock {
if d, _, err := loadCollabDoc(s.db, docID); err == nil && len(d.Blocks) > 0 {
blocks := flatDocumentBlocks(docID, d)
if links := s.docTaskLinks(docID); len(links) > 0 {
for i := range blocks {
blocks[i].TaskID = links[blocks[i].ID]
}
}
return blocks
}
// Very old notes only stored blocks_json; convert them once
blocks := make([]domain.DocumentBlock, 0)
if fallbackJSON != "" && fallbackJSON != "[]" {
_ = json.Unmarshal([]byte(fallbackJSON), &blocks)
if len(blocks) > 0 {
s.replaceDocBlocksInDB(docID, "", blocks, domain.NowISO())
if d, _, err := loadCollabDoc(s.db, docID); err == nil {
return flatDocumentBlocks(docID, d)
}
}
}
return blocks
}
// GetAuthorizedDocUsers returns all user IDs who have read/write/owner access to docID.
func (s *Store) GetAuthorizedDocUsers(docID string) ([]string, error) {
userSet := make(map[string]bool)
var ownerID, sharedFolderID string
err := s.db.QueryRow(`SELECT user_id, shared_folder_id FROM documents WHERE id = ?`, docID).Scan(&ownerID, &sharedFolderID)
if err != nil {
return nil, err
}
if ownerID != "" {
userSet[ownerID] = true
}
// Direct user shares
uRows, err := s.db.Query(`SELECT user_id FROM doc_shares WHERE doc_id = ?`, docID)
if err == nil {
for uRows.Next() {
var uid string
if err := uRows.Scan(&uid); err == nil && uid != "" {
userSet[uid] = true
}
}
_ = uRows.Close()
}
// Team shares
tRows, err := s.db.Query(`
SELECT tm.user_id
FROM doc_team_shares dts
JOIN team_members tm ON tm.team_id = dts.team_id
WHERE dts.doc_id = ?
`, docID)
if err == nil {
for tRows.Next() {
var uid string
if err := tRows.Scan(&uid); err == nil && uid != "" {
userSet[uid] = true
}
}
_ = tRows.Close()
}
// Shared folder access
if sharedFolderID != "" {
sfUsers, err := s.GetAuthorizedSharedFolderUsers(sharedFolderID)
if err == nil {
for _, uid := range sfUsers {
userSet[uid] = true
}
}
}
users := make([]string, 0, len(userSet))
for uid := range userSet {
users = append(users, uid)
}
return users, nil
}
// GetAuthorizedSharedFolderUsers returns all user IDs who have access to a shared folder.
func (s *Store) GetAuthorizedSharedFolderUsers(folderID string) ([]string, error) {
userSet := make(map[string]bool)
var ownerID string
if err := s.db.QueryRow(`SELECT owner_id FROM workspace_shared_folders WHERE id = ?`, folderID).Scan(&ownerID); err == nil && ownerID != "" {
userSet[ownerID] = true
}
rows, err := s.db.Query(`
SELECT target_type, target_id FROM workspace_folder_shares WHERE folder_id = ?
`, folderID)
if err == nil {
type shareTarget struct{ tType, tID string }
var targets []shareTarget
for rows.Next() {
var st shareTarget
if err := rows.Scan(&st.tType, &st.tID); err == nil {
targets = append(targets, st)
}
}
_ = rows.Close()
for _, st := range targets {
if st.tType == "user" && st.tID != "" {
userSet[st.tID] = true
} else if st.tType == "team" && st.tID != "" {
mRows, err := s.db.Query(`SELECT user_id FROM team_members WHERE team_id = ?`, st.tID)
if err == nil {
for mRows.Next() {
var uid string
if err := mRows.Scan(&uid); err == nil && uid != "" {
userSet[uid] = true
}
}
_ = mRows.Close()
}
}
}
}
users := make([]string, 0, len(userSet))
for uid := range userSet {
users = append(users, uid)
}
return users, nil
}
func (s *Store) CreateDocument(userID, spaceID, title, icon, folderPath string, blocks []domain.DocumentBlock, tags []string, isPinned bool) (*domain.Document, error) {
if strings.TrimSpace(title) == "" {
title = "Unbenannte Notiz"
}
if icon == "" {
icon = "📄"
}
if blocks == nil {
blocks = []domain.DocumentBlock{
{ID: domain.NewID("blk"), Type: "paragraph", Content: ""},
}
}
tags = stripFolderTags(tags)
var plainBuilder strings.Builder
for _, b := range blocks {
if b.Content != "" {
plainBuilder.WriteString(b.Content)
plainBuilder.WriteString("\n")
}
}
blocksJSON, _ := json.Marshal(blocks)
tagsJSON, _ := json.Marshal(tags)
now := domain.NowISO()
pinnedInt := 0
if isPinned {
pinnedInt = 1
}
cleanFolder := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath)))
if cleanFolder == "." {
cleanFolder = ""
}
mounts, _, _ := s.getUserFolderMounts(userID)
var sharedFolderID, subFolderPath string
if fID, sub, ok := matchMountPath(cleanFolder, mounts); ok {
sharedFolderID = fID
subFolderPath = sub
}
doc := &domain.Document{
ID: domain.NewID("doc"),
UserID: userID,
SpaceID: spaceID,
Title: strings.TrimSpace(title),
Icon: icon,
Content: plainBuilder.String(),
Blocks: blocks,
Tags: tags,
FolderPath: cleanFolder,
IsPinned: isPinned,
SharedFolderID: sharedFolderID,
SubFolderPath: subFolderPath,
IsSharedFolder: sharedFolderID != "",
Permission: "owner",
IsOwner: true,
CreatedAt: now,
UpdatedAt: now,
}
_, err := s.db.Exec(
`INSERT INTO documents (id, user_id, space_id, title, icon, content, blocks_json, tags, folder_path, is_pinned, is_archived, created_at, updated_at, shared_folder_id, sub_folder_path)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`,
doc.ID, doc.UserID, doc.SpaceID, doc.Title, doc.Icon, doc.Content, string(blocksJSON), string(tagsJSON), doc.FolderPath, pinnedInt, doc.CreatedAt, doc.UpdatedAt, doc.SharedFolderID, doc.SubFolderPath,
)
if err != nil {
return nil, err
}
if isPinned {
_, _ = s.db.Exec(`INSERT OR IGNORE INTO doc_pins (user_id, doc_id, created_at) VALUES (?, ?, ?)`, userID, doc.ID, doc.CreatedAt)
}
s.replaceDocBlocksInDB(doc.ID, userID, doc.Blocks, now)
_ = s.indexDocBlockTags(doc.ID, doc.UserID, doc.Blocks)
return doc, nil
}
// getUserFolderMounts returns map[folder_id]mount_path and map[folder_id]folder_name for all shared folders accessible to userID
func (s *Store) getUserFolderMounts(userID string) (map[string]string, map[string]string, error) {
mounts := make(map[string]string)
names := make(map[string]string)
rows, err := s.db.Query(`
SELECT f.id, f.name, COALESCE(m.mount_path, f.name) as mount_path
FROM workspace_shared_folders f
LEFT JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = ?
LEFT JOIN workspace_folder_shares s ON s.folder_id = f.id AND (
(s.target_type = 'user' AND s.target_id = ?) OR
(s.target_type = 'team' AND s.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
)
WHERE f.owner_id = ? OR s.id IS NOT NULL
GROUP BY f.id
`, userID, userID, userID, userID)
if err != nil {
return mounts, names, err
}
defer rows.Close()
for rows.Next() {
var fID, fName, mPath string
if err := rows.Scan(&fID, &fName, &mPath); err == nil {
names[fID] = fName
if mPath == "" {
mPath = fName
}
mounts[fID] = mPath
}
}
return mounts, names, nil
}
// matchMountPath checks if folderPath falls within any mounted shared folder.
// Returns (sharedFolderID, relativeSubPath, isMatch).
func matchMountPath(folderPath string, mounts map[string]string) (string, string, bool) {
clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath)))
if clean == "." {
clean = ""
}
if clean == "" {
return "", "", false
}
var bestFolderID string
var bestSubPath string
var longestMountLen int = -1
for fID, mPath := range mounts {
cleanMount := filepath.ToSlash(filepath.Clean(strings.TrimSpace(mPath)))
if cleanMount == "." {
cleanMount = ""
}
if cleanMount == "" {
continue
}
if clean == cleanMount {
if len(cleanMount) > longestMountLen {
longestMountLen = len(cleanMount)
bestFolderID = fID
bestSubPath = ""
}
} else if strings.HasPrefix(clean, cleanMount+"/") {
sub := strings.TrimPrefix(clean, cleanMount+"/")
if len(cleanMount) > longestMountLen {
longestMountLen = len(cleanMount)
bestFolderID = fID
bestSubPath = sub
}
}
}
if longestMountLen >= 0 {
return bestFolderID, bestSubPath, true
}
return "", "", false
}
func resolveDocumentPath(userID string, doc *domain.Document, mounts map[string]string, names map[string]string, userFolders map[string]string) {
if doc.SharedFolderID != "" {
mountPath, ok := mounts[doc.SharedFolderID]
if !ok || mountPath == "" {
mountPath = names[doc.SharedFolderID]
if mountPath == "" {
mountPath = "Geteilter Ordner"
}
}
doc.IsSharedFolder = true
if doc.SubFolderPath != "" {
doc.FolderPath = mountPath + "/" + doc.SubFolderPath
} else {
doc.FolderPath = mountPath
}
} else {
if uf, ok := userFolders[doc.ID]; ok {
doc.FolderPath = uf
} else if !doc.IsOwner {
doc.FolderPath = ""
}
}
}
func scanDocument(rows interface {
Scan(dest ...any) error
}) (*domain.Document, error) {
d := &domain.Document{}
var blocksStr, tagsStr string
var isPinned, isArchived int
err := rows.Scan(
&d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content,
&blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt,
&d.SharedFolderID, &d.SubFolderPath,
)
if err != nil {
return nil, err
}
d.IsPinned = isPinned != 0
d.IsArchived = isArchived != 0
d.Blocks = []domain.DocumentBlock{}
d.Tags = []string{}
_ = json.Unmarshal([]byte(blocksStr), &d.Blocks)
_ = json.Unmarshal([]byte(tagsStr), &d.Tags)
d.Tags = stripFolderTags(d.Tags)
return d, nil
}
func (s *Store) scanDocumentWithBlocks(rows interface {
Scan(dest ...any) error
}) (*domain.Document, error) {
d := &domain.Document{}
var blocksStr, tagsStr string
var isPinned, isArchived int
err := rows.Scan(
&d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content,
&blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt,
&d.SharedFolderID, &d.SubFolderPath,
)
if err != nil {
return nil, err
}
d.IsPinned = isPinned != 0
d.IsArchived = isArchived != 0
d.Tags = []string{}
_ = json.Unmarshal([]byte(tagsStr), &d.Tags)
d.Tags = stripFolderTags(d.Tags)
d.Blocks = s.loadDocBlocks(d.ID, blocksStr)
d.MarkdownCache = domain.CompileBlocksToMarkdown(d.Blocks)
d.FolderID = d.FolderPath
return d, nil
}
func (s *Store) GetDocuments(userID string) ([]domain.Document, error) {
mounts, names, _ := s.getUserFolderMounts(userID)
userFolders := make(map[string]string)
ufRows, err := s.db.Query(`SELECT doc_id, folder_path FROM doc_user_folders WHERE user_id = ?`, userID)
if err == nil {
for ufRows.Next() {
var did, fp string
if err := ufRows.Scan(&did, &fp); err == nil {
userFolders[did] = fp
}
}
_ = ufRows.Close()
}
// 1. Fetch user's own documents (with per-user is_pinned from doc_pins)
rows, err := s.db.Query(
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
d.is_archived, d.created_at, d.updated_at,
d.shared_folder_id, d.sub_folder_path
FROM documents d
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
WHERE d.user_id = ? AND d.is_archived = 0
ORDER BY is_pinned DESC, d.updated_at DESC`,
userID, userID,
)
if err != nil {
return nil, err
}
type rawDocRow struct {
doc domain.Document
blocksStr string
}
var ownRaw []rawDocRow
for rows.Next() {
d := domain.Document{}
var blocksStr, tagsStr string
var isPinned, isArchived int
if err := rows.Scan(
&d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content,
&blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt,
&d.SharedFolderID, &d.SubFolderPath,
); err == nil {
d.IsPinned = isPinned != 0
d.IsArchived = isArchived != 0
d.Tags = []string{}
_ = json.Unmarshal([]byte(tagsStr), &d.Tags)
d.Tags = stripFolderTags(d.Tags)
d.Permission = "owner"
d.IsOwner = true
resolveDocumentPath(userID, &d, mounts, names, userFolders)
ownRaw = append(ownRaw, rawDocRow{doc: d, blocksStr: blocksStr})
}
}
_ = rows.Close()
docs := make([]domain.Document, 0, len(ownRaw))
for _, r := range ownRaw {
r.doc.Blocks = s.loadDocBlocks(r.doc.ID, r.blocksStr)
docs = append(docs, r.doc)
}
// 2. Fetch documents shared with the user directly, via teams, or via shared folders
sharedRows, err := s.db.Query(
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
d.is_archived, d.created_at, d.updated_at,
d.shared_folder_id, d.sub_folder_path,
CASE
WHEN wsf.owner_id = ? THEN 'owner'
WHEN COALESCE(s.permission, '') = 'write' OR COALESCE(dts.permission, '') = 'write' OR COALESCE(wfs.permission, '') = 'write' THEN 'write'
ELSE 'read'
END AS permission,
u.display_name
FROM documents d
JOIN users u ON d.user_id = u.id
LEFT JOIN doc_shares s ON d.id = s.doc_id AND s.user_id = ?
LEFT JOIN (
SELECT dts2.doc_id, dts2.permission
FROM doc_team_shares dts2
JOIN team_members tm ON tm.team_id = dts2.team_id
WHERE tm.user_id = ?
) dts ON d.id = dts.doc_id
LEFT JOIN workspace_shared_folders wsf ON d.shared_folder_id = wsf.id
LEFT JOIN (
SELECT wfs2.folder_id, wfs2.permission
FROM workspace_folder_shares wfs2
WHERE (wfs2.target_type = 'user' AND wfs2.target_id = ?)
OR (wfs2.target_type = 'team' AND wfs2.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
) wfs ON d.shared_folder_id = wfs.folder_id
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
WHERE d.is_archived = 0 AND d.user_id != ? AND (
s.user_id IS NOT NULL OR dts.doc_id IS NOT NULL OR wsf.owner_id = ? OR wfs.folder_id IS NOT NULL
)
GROUP BY d.id
ORDER BY is_pinned DESC, d.updated_at DESC`,
userID, userID, userID, userID, userID, userID, userID, userID,
)
if err == nil {
var sharedRaw []rawDocRow
for sharedRows.Next() {
d := domain.Document{}
var blocksStr, tagsStr string
var isPinned, isArchived int
var perm, ownerName string
if scanErr := sharedRows.Scan(
&d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content,
&blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt,
&d.SharedFolderID, &d.SubFolderPath,
&perm, &ownerName,
); scanErr == nil {
d.IsPinned = isPinned != 0
d.IsArchived = isArchived != 0
d.Tags = []string{}
_ = json.Unmarshal([]byte(tagsStr), &d.Tags)
d.Tags = stripFolderTags(d.Tags)
d.Permission = perm
d.IsOwner = (perm == "owner")
d.OwnerName = ownerName
resolveDocumentPath(userID, &d, mounts, names, userFolders)
sharedRaw = append(sharedRaw, rawDocRow{doc: d, blocksStr: blocksStr})
}
}
_ = sharedRows.Close()
for _, r := range sharedRaw {
r.doc.Blocks = s.loadDocBlocks(r.doc.ID, r.blocksStr)
docs = append(docs, r.doc)
}
}
// 3. Attach shares to owned documents
for i := range docs {
if docs[i].IsOwner {
if shares, err := s.GetDocShares(docs[i].ID); err == nil {
docs[i].SharedWith = shares
}
}
}
return docs, nil
}
func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error) {
mounts, names, _ := s.getUserFolderMounts(userID)
userFolders := make(map[string]string)
var personalFp string
if err := s.db.QueryRow(`SELECT folder_path FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID).Scan(&personalFp); err == nil {
userFolders[docID] = personalFp
}
// Try owned document first (with per-user is_pinned)
row := s.db.QueryRow(
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
d.is_archived, d.created_at, d.updated_at,
d.shared_folder_id, d.sub_folder_path
FROM documents d
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
WHERE d.id = ? AND (d.user_id = ? OR (d.is_archived = 1 AND d.archived_by = ?))`,
userID, docID, userID, userID,
)
doc, err := s.scanDocumentWithBlocks(row)
if err == nil {
doc.Permission = "owner"
doc.IsOwner = doc.UserID == userID
if doc.IsArchived {
doc.Permission = "read" // read-only while in the trash
}
resolveDocumentPath(userID, doc, mounts, names, userFolders)
if shares, err := s.GetDocShares(doc.ID); err == nil {
doc.SharedWith = shares
}
return doc, nil
}
// Try shared document (direct, via team, or via shared folder)
sharedRow := s.db.QueryRow(
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
d.is_archived, d.created_at, d.updated_at,
d.shared_folder_id, d.sub_folder_path,
CASE
WHEN wsf.owner_id = ? THEN 'owner'
WHEN COALESCE(s.permission, '') = 'write' OR COALESCE(dts.permission, '') = 'write' OR COALESCE(wfs.permission, '') = 'write' THEN 'write'
ELSE 'read'
END AS permission,
u.display_name
FROM documents d
JOIN users u ON d.user_id = u.id
LEFT JOIN doc_shares s ON d.id = s.doc_id AND s.user_id = ?
LEFT JOIN (
SELECT dts2.doc_id, dts2.permission
FROM doc_team_shares dts2
JOIN team_members tm ON tm.team_id = dts2.team_id
WHERE tm.user_id = ?
) dts ON d.id = dts.doc_id
LEFT JOIN workspace_shared_folders wsf ON d.shared_folder_id = wsf.id
LEFT JOIN (
SELECT wfs2.folder_id, wfs2.permission
FROM workspace_folder_shares wfs2
WHERE (wfs2.target_type = 'user' AND wfs2.target_id = ?)
OR (wfs2.target_type = 'team' AND wfs2.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
) wfs ON d.shared_folder_id = wfs.folder_id
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
WHERE d.id = ? AND d.is_archived = 0 AND (s.user_id = ? OR dts.doc_id IS NOT NULL OR wsf.owner_id = ? OR wfs.folder_id IS NOT NULL)
LIMIT 1`,
userID, userID, userID, userID, userID, userID, docID, userID, userID,
)
d := &domain.Document{}
var blocksStr, tagsStr string
var isPinned, isArchived int
var perm, ownerName string
err = sharedRow.Scan(
&d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content,
&blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt,
&d.SharedFolderID, &d.SubFolderPath,
&perm, &ownerName,
)
if err != nil {
return nil, err
}
d.IsPinned = isPinned != 0
d.IsArchived = isArchived != 0
d.Tags = []string{}
_ = json.Unmarshal([]byte(tagsStr), &d.Tags)
d.Tags = stripFolderTags(d.Tags)
d.Blocks = s.loadDocBlocks(d.ID, blocksStr)
d.Permission = perm
d.IsOwner = (perm == "owner")
d.OwnerName = ownerName
resolveDocumentPath(userID, d, mounts, names, userFolders)
if shares, err := s.GetDocShares(d.ID); err == nil {
d.SharedWith = shares
}
return d, nil
}
func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *string, isPinned *bool) (*domain.Document, error) {
doc, err := s.GetDocumentByID(userID, docID)
if err != nil {
return nil, err
}
if doc.IsArchived {
return nil, ErrDocInTrash
}
// Pins and folder placement are personal; title and icon need edit rights. The content (blocks) is
// only changed through the operation log (POST /docs/{id}/sync).
changesContent := title != nil || icon != nil
if changesContent && !CanEditDoc(s.DocRole(userID, docID)) {
return nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)")
}
if isPinned != nil {
doc.IsPinned = *isPinned
if *isPinned {
_, _ = s.db.Exec(`INSERT OR IGNORE INTO doc_pins (user_id, doc_id, created_at) VALUES (?, ?, ?)`, userID, docID, domain.NowISO())
if doc.IsOwner {
_, _ = s.db.Exec(`UPDATE documents SET is_pinned = 1 WHERE id = ?`, docID)
}
} else {
_, _ = s.db.Exec(`DELETE FROM doc_pins WHERE user_id = ? AND doc_id = ?`, userID, docID)
if doc.IsOwner {
_, _ = s.db.Exec(`UPDATE documents SET is_pinned = 0 WHERE id = ?`, docID)
}
}
}
if folderPath != nil {
movedDoc, moveErr := s.MoveDocument(userID, docID, *folderPath)
if moveErr != nil {
return nil, moveErr
}
doc.FolderPath = movedDoc.FolderPath
doc.SharedFolderID = movedDoc.SharedFolderID
doc.SubFolderPath = movedDoc.SubFolderPath
doc.IsSharedFolder = movedDoc.IsSharedFolder
}
if !changesContent {
return doc, nil
}
if title != nil {
doc.Title = strings.TrimSpace(*title)
s.applyLegacyTitle(docID, userID, doc.Title)
}
if icon != nil {
doc.Icon = *icon
}
doc.UpdatedAt = domain.NowISO()
if _, err := s.db.Exec(`UPDATE documents SET icon = ?, updated_at = ? WHERE id = ?`, doc.Icon, doc.UpdatedAt, docID); err != nil {
return nil, err
}
return s.GetDocumentByID(userID, docID)
}
func (s *Store) DeleteDocument(userID, docID string) error {
doc, err := s.GetDocumentByID(userID, docID)
if err != nil {
return err
}
if !doc.IsOwner {
// Collaborator removing their shared access
_, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID)
return s.DeleteDocShare(userID, docID, userID)
}
if doc.IsArchived {
return s.hardDeleteDocument(docID)
}
return s.trashDocument(userID, docID)
}
func (s *Store) reindexAllDocBlockTags() error {
rows, err := s.db.Query(`SELECT id, user_id, blocks_json FROM documents WHERE is_archived = 0`)
if err != nil {
return err
}
defer rows.Close()
type docInfo struct {
id, userID, blocksJSON string
}
var docs []docInfo
for rows.Next() {
var d docInfo
if err := rows.Scan(&d.id, &d.userID, &d.blocksJSON); err == nil {
docs = append(docs, d)
}
}
_ = rows.Close()
for _, d := range docs {
blocks := s.loadDocBlocks(d.id, d.blocksJSON)
if len(blocks) > 0 {
_ = s.indexDocBlockTags(d.id, d.userID, blocks)
}
}
return nil
}
func (s *Store) indexDocBlockTags(docID, _ string, blocks []domain.DocumentBlock) error {
_, _ = s.db.Exec(`DELETE FROM block_tags WHERE document_id = ?`, docID)
if len(blocks) == 0 {
return nil
}
stmt, err := s.db.Prepare(`INSERT OR IGNORE INTO block_tags (tag, document_id, block_id) VALUES (?, ?, ?)`)
if err != nil {
return err
}
defer stmt.Close()
for _, b := range blocks {
if b.Content == "" {
continue
}
tags := domain.ExtractTags(b.Content)
for _, tag := range tags {
_, _ = stmt.Exec(tag, docID, b.ID)
}
}
return nil
}
func (s *Store) GetDocShares(docID string) ([]domain.DocShare, error) {
shares := make([]domain.DocShare, 0)
// 1. Direct user shares
uRows, err := s.db.Query(
`SELECT s.id, s.doc_id, s.user_id, u.email, u.display_name, s.permission, s.created_by, s.created_at
FROM doc_shares s
JOIN users u ON s.user_id = u.id
WHERE s.doc_id = ?
ORDER BY s.created_at ASC`,
docID,
)
if err == nil {
for uRows.Next() {
var sh domain.DocShare
if err := uRows.Scan(&sh.ID, &sh.DocID, &sh.UserID, &sh.UserEmail, &sh.DisplayName, &sh.Permission, &sh.CreatedBy, &sh.CreatedAt); err == nil {
sh.TargetType = "user"
sh.TargetID = sh.UserID
sh.TargetName = sh.DisplayName
shares = append(shares, sh)
}
}
_ = uRows.Close()
}
// 2. Team shares
tRows, err := s.db.Query(
`SELECT s.id, s.doc_id, s.team_id, t.name, s.permission, s.created_by, s.created_at
FROM doc_team_shares s
JOIN teams t ON s.team_id = t.id
WHERE s.doc_id = ?
ORDER BY s.created_at ASC`,
docID,
)
if err == nil {
for tRows.Next() {
var sh domain.DocShare
var teamID, teamName string
if err := tRows.Scan(&sh.ID, &sh.DocID, &teamID, &teamName, &sh.Permission, &sh.CreatedBy, &sh.CreatedAt); err == nil {
sh.TargetType = "team"
sh.TargetID = teamID
sh.TargetName = teamName
sh.DisplayName = teamName
shares = append(shares, sh)
}
}
_ = tRows.Close()
}
return shares, nil
}
func (s *Store) CreateDocTeamShare(currentUserID, docID, teamID, permission string) (*domain.DocShare, error) {
doc, err := s.GetDocumentByID(currentUserID, docID)
if err != nil {
return nil, err
}
if !doc.IsOwner {
return nil, fmt.Errorf("nur der Eigentümer kann Freigaben verwalten")
}
cleanTeamID := strings.TrimSpace(teamID)
var teamName string
err = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, cleanTeamID).Scan(&teamName)
if err != nil || !s.CanShareWith(currentUserID, "team", cleanTeamID) {
return nil, ErrTargetNotFound
}
permission = normalizeSharePermission(permission)
now := domain.NowISO()
shareID := domain.NewID("share")
_, err = s.db.Exec(
`INSERT INTO doc_team_shares (id, doc_id, team_id, permission, created_at, created_by)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(doc_id, team_id) DO UPDATE SET permission = excluded.permission`,
shareID, docID, cleanTeamID, permission, now, currentUserID,
)
if err != nil {
return nil, err
}
return &domain.DocShare{
ID: shareID,
DocID: docID,
TargetType: "team",
TargetID: cleanTeamID,
TargetName: teamName,
DisplayName: teamName,
Permission: permission,
CreatedBy: currentUserID,
CreatedAt: now,
}, nil
}
// normalizeSharePermission maps share roles from the API to the stored values: "write" (editor) or "read" (viewer).
func normalizeSharePermission(p string) string {
switch strings.ToLower(strings.TrimSpace(p)) {
case "write", "editor":
return "write"
}
return "read"
}
func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permission string) (*domain.DocShare, error) {
doc, err := s.GetDocumentByID(currentUserID, docID)
if err != nil {
return nil, err
}
if doc.IsArchived {
return nil, ErrDocInTrash
}
if !doc.IsOwner {
return nil, fmt.Errorf("nur der Eigentümer kann Freigaben verwalten")
}
cleanTarget := strings.TrimSpace(targetUserIdentifier)
// If identifier looks like a team or is prefixed with team_, route to CreateDocTeamShare
if strings.HasPrefix(cleanTarget, "team_") {
return s.CreateDocTeamShare(currentUserID, docID, cleanTarget, permission)
}
permission = normalizeSharePermission(permission)
// Resolve target user by ID, username, or Email
var targetUser *domain.User
if strings.HasPrefix(cleanTarget, "usr_") {
targetUser, _ = s.GetUserByID(cleanTarget)
}
if targetUser == nil {
targetUser, _ = s.GetUserByLogin(cleanTarget)
}
if targetUser == nil {
targetUser, err = s.GetUserByID(cleanTarget)
}
if err != nil || targetUser == nil {
// Check if it's a team ID that doesn't start with team_
var tName string
if s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, cleanTarget).Scan(&tName) == nil {
return s.CreateDocTeamShare(currentUserID, docID, cleanTarget, permission)
}
return nil, ErrTargetNotFound
}
if targetUser.ID == doc.UserID {
return nil, fmt.Errorf("dokument gehört bereits diesem Benutzer")
}
if !s.CanShareWith(currentUserID, "user", targetUser.ID) {
return nil, ErrTargetNotFound
}
now := domain.NowISO()
shareID := domain.NewID("share")
_, err = s.db.Exec(
`INSERT INTO doc_shares (id, doc_id, user_id, permission, created_at, created_by)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(doc_id, user_id) DO UPDATE SET permission = excluded.permission`,
shareID, docID, targetUser.ID, permission, now, currentUserID,
)
if err != nil {
return nil, err
}
return &domain.DocShare{
ID: shareID,
DocID: docID,
TargetType: "user",
TargetID: targetUser.ID,
TargetName: targetUser.DisplayName,
UserID: targetUser.ID,
UserEmail: targetUser.Email,
DisplayName: targetUser.DisplayName,
Permission: permission,
CreatedBy: currentUserID,
CreatedAt: now,
}, nil
}
func (s *Store) DeleteDocShare(currentUserID, docID, targetID string) error {
doc, err := s.GetDocumentByID(currentUserID, docID)
if err != nil {
return err
}
// Only the owner manages shares; everyone else can only remove their own
if !doc.IsOwner && currentUserID != targetID {
return fmt.Errorf("keine Berechtigung zum Entfernen der Freigabe")
}
cleanTarget := strings.TrimSpace(targetID)
// Try deleting team share first
res, err := s.db.Exec(`DELETE FROM doc_team_shares WHERE doc_id = ? AND (id = ? OR team_id = ?)`, docID, cleanTarget, cleanTarget)
if err == nil {
if rows, _ := res.RowsAffected(); rows > 0 {
return nil
}
}
// Fallback to user share
_, err = s.db.Exec(`DELETE FROM doc_shares WHERE doc_id = ? AND (id = ? OR user_id = ?)`, docID, cleanTarget, cleanTarget)
return err
}
// GetCollaborators returns the active people the user can share with (everyone visible except the user).
func (s *Store) GetCollaborators(currentUserID string) ([]domain.User, error) {
visible, err := s.GetDiscoverableUsers(currentUserID)
if err != nil {
return nil, err
}
users := make([]domain.User, 0, len(visible))
for _, u := range visible {
if u.ID != currentUserID && u.IsActive {
users = append(users, u)
}
}
return users, nil
}
+8 -1
View File
@@ -118,6 +118,9 @@ func (s *Store) writeVaultLists(zw *zip.Writer, userID string, names *vaultNames
return nil
}
// Obsidian Tasks writes repeat rules as "🔁 every week"
var recurrenceEvery = map[string]string{"daily": "day", "weekly": "week", "monthly": "month", "yearly": "year"}
// taskMarkdownLine writes a task in the format of the Obsidian Tasks plugin (📅 due date, ⏫/🔺 priority)
// with its subtasks and notes indented below.
func taskMarkdownLine(t domain.Thought) string {
@@ -134,8 +137,12 @@ func taskMarkdownLine(t domain.Thought) string {
case "low":
line += " 🔽"
}
if every, ok := recurrenceEvery[t.Recurrence]; ok {
line += " 🔁 every " + every
}
if t.DueDate != "" {
line += " 📅 " + t.DueDate
// Obsidian Tasks only knows the date; the time follows separated by a space
line += " 📅 " + strings.Replace(t.DueDate, "T", " ", 1)
}
var sb strings.Builder
sb.WriteString(line + "\n")
+862
View File
@@ -0,0 +1,862 @@
package storage
import (
"database/sql"
"fmt"
"path/filepath"
"sort"
"strings"
"time"
"github.com/sandrodach/mindsift/internal/domain"
)
func (s *Store) GetFolders(userID string) ([]string, error) {
folderSet := make(map[string]bool)
// 1. Collect from resolved documents for this user
docs, err := s.GetDocuments(userID)
if err == nil {
for _, doc := range docs {
if doc.FolderPath != "" {
addFolderSegments(doc.FolderPath, folderSet)
}
}
}
// 2. Add mounted shared folders (so even empty shared folders appear in the tree)
mounts, _, err := s.getUserFolderMounts(userID)
if err == nil {
for sfID, mPath := range mounts {
if mPath != "" {
addFolderSegments(mPath, folderSet)
// 2b. Add all subfolders created in this shared folder
sRows, err := s.db.Query(`SELECT sub_path FROM workspace_folders WHERE shared_folder_id = ?`, sfID)
if err == nil {
for sRows.Next() {
var sp string
if err := sRows.Scan(&sp); err == nil && sp != "" {
fullP := mPath + "/" + sp
addFolderSegments(fullP, folderSet)
}
}
_ = sRows.Close()
}
}
}
}
// 3. Add personal empty folders created by this user
pRows, err := s.db.Query(`SELECT path FROM workspace_folders WHERE user_id = ? AND (shared_folder_id = '' OR shared_folder_id IS NULL)`, userID)
if err == nil {
for pRows.Next() {
var p string
if err := pRows.Scan(&p); err == nil && p != "" {
addFolderSegments(p, folderSet)
}
}
_ = pRows.Close()
}
result := make([]string, 0, len(folderSet))
for f := range folderSet {
result = append(result, f)
}
sort.Strings(result)
return result, nil
}
func addFolderSegments(fp string, set map[string]bool) {
clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(fp)))
if clean == "." || clean == "" {
return
}
parts := strings.Split(clean, "/")
var cur string
for _, p := range parts {
if p != "" && p != "." {
if cur == "" {
cur = p
} else {
cur = cur + "/" + p
}
set[cur] = true
}
}
}
func (s *Store) CreateFolder(userID, folderPath string) (string, error) {
clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath)))
if clean == "" || clean == "." || strings.HasPrefix(clean, "/") || strings.Contains(clean, "..") {
return "", fmt.Errorf("ungültiger Ordnerpfad")
}
mounts, _, err := s.getUserFolderMounts(userID)
var sfID, subPath string
if err == nil {
sfID, subPath, _ = matchMountPath(clean, mounts)
}
now := time.Now().UTC().Format(time.RFC3339)
id := domain.NewID("wfld")
if sfID != "" {
var existsID string
err := s.db.QueryRow(`SELECT id FROM workspace_folders WHERE shared_folder_id = ? AND sub_path = ?`, sfID, subPath).Scan(&existsID)
if err == sql.ErrNoRows {
_, _ = s.db.Exec(`INSERT INTO workspace_folders (id, user_id, shared_folder_id, path, sub_path, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
id, userID, sfID, clean, subPath, now)
}
} else {
var existsID string
err := s.db.QueryRow(`SELECT id FROM workspace_folders WHERE user_id = ? AND path = ? AND (shared_folder_id = '' OR shared_folder_id IS NULL)`, userID, clean).Scan(&existsID)
if err == sql.ErrNoRows {
_, _ = s.db.Exec(`INSERT INTO workspace_folders (id, user_id, shared_folder_id, path, sub_path, created_at) VALUES (?, ?, '', ?, '', ?)`,
id, userID, clean, now)
}
}
return clean, nil
}
func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) {
clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath)))
if clean == "" || clean == "." || strings.HasPrefix(clean, "/") || strings.Contains(clean, "..") {
return nil, fmt.Errorf("ungültiger Ordnerpfad")
}
var deletedDocIDs []string
seenDocID := make(map[string]bool)
mounts, _, err := s.getUserFolderMounts(userID)
if err == nil {
sfID, subPath, isMatch := matchMountPath(clean, mounts)
if isMatch && sfID != "" {
if subPath == "" {
// Root of a shared folder
shFolder, sfErr := s.GetSharedFolderByID(userID, sfID)
if sfErr == nil && !shFolder.IsOwner {
// Collaborator removing mount/share
_ = s.DeleteSharedFolder(userID, sfID)
return []string{}, nil
}
rows, qErr := s.db.Query(`SELECT id FROM documents WHERE shared_folder_id = ?`, sfID)
if qErr == nil {
for rows.Next() {
var id string
if err := rows.Scan(&id); err == nil && !seenDocID[id] {
seenDocID[id] = true
deletedDocIDs = append(deletedDocIDs, id)
}
}
_ = rows.Close()
}
_, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ?`, sfID)
} else {
// Subfolder inside a shared folder
rows, qErr := s.db.Query(
`SELECT id FROM documents WHERE shared_folder_id = ? AND (sub_folder_path = ? OR sub_folder_path LIKE ?)`,
sfID, subPath, subPath+"/%",
)
if qErr == nil {
for rows.Next() {
var id string
if err := rows.Scan(&id); err == nil && !seenDocID[id] {
seenDocID[id] = true
deletedDocIDs = append(deletedDocIDs, id)
}
}
_ = rows.Close()
}
_, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ? AND (sub_path = ? OR sub_path LIKE ?)`,
sfID, subPath, subPath+"/%")
}
}
}
// Find all personal documents owned by this user in this folder and subfolders
rows, err := s.db.Query(
`SELECT id FROM documents
WHERE user_id = ? AND (shared_folder_id = '' OR shared_folder_id IS NULL) AND (folder_path = ? OR folder_path LIKE ?)`,
userID, clean, clean+"/%",
)
if err != nil {
return nil, err
}
for rows.Next() {
var id string
if err := rows.Scan(&id); err == nil && !seenDocID[id] {
seenDocID[id] = true
deletedDocIDs = append(deletedDocIDs, id)
}
}
_ = rows.Close()
// The notes of the folder go to the trash; restoring one creates its folder again
for _, id := range deletedDocIDs {
_ = s.trashDocument(userID, id)
}
// Also clear any personal folder placements for shared notes in this folder
_, _ = s.db.Exec(`UPDATE doc_user_folders SET folder_path = '' WHERE user_id = ? AND (folder_path = ? OR folder_path LIKE ?)`, userID, clean, clean+"/%")
_, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE user_id = ? AND (path = ? OR path LIKE ?)`, userID, clean, clean+"/%")
return deletedDocIDs, nil
}
func (s *Store) MoveDocument(userID, docID, targetFolderPath string) (*domain.Document, error) {
doc, err := s.GetDocumentByID(userID, docID)
if err != nil {
return nil, err
}
if doc.IsArchived {
return nil, ErrDocInTrash
}
cleanTarget := filepath.ToSlash(filepath.Clean(strings.TrimSpace(targetFolderPath)))
if cleanTarget == "." {
cleanTarget = ""
}
mounts, _, err := s.getUserFolderMounts(userID)
if err != nil {
return nil, err
}
now := domain.NowISO()
// Check if target folder falls within any mounted shared folder
targetFolderID, subPath, isTargetShared := matchMountPath(cleanTarget, mounts)
if isTargetShared {
// Moving into / within a shared folder
shFolder, err := s.GetSharedFolderByID(userID, targetFolderID)
if err != nil {
return nil, err
}
if !shFolder.IsOwner {
return nil, fmt.Errorf("nur der Eigentümer dieses geteilten Ordners kann Dateien dorthin verschieben")
}
// Update document to belong to targetSharedFolderID
_, err = s.db.Exec(`UPDATE documents SET shared_folder_id = ?, sub_folder_path = ?, updated_at = ? WHERE id = ?`,
targetFolderID, subPath, now, docID)
if err != nil {
return nil, err
}
// Remove personal override if any
_, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID)
doc.SharedFolderID = targetFolderID
doc.SubFolderPath = subPath
doc.IsSharedFolder = true
doc.FolderPath = cleanTarget
return doc, nil
}
// Target is NOT inside a shared folder (personal / standalone)
if doc.SharedFolderID != "" {
// Moving OUT of a shared folder into a personal folder
if !doc.IsOwner {
return nil, fmt.Errorf("keine Berechtigung, die Notiz aus dem geteilten Ordner zu verschieben")
}
_, err = s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '', folder_path = ?, updated_at = ? WHERE id = ?`,
cleanTarget, now, docID)
if err != nil {
return nil, err
}
doc.SharedFolderID = ""
doc.SubFolderPath = ""
doc.IsSharedFolder = false
doc.FolderPath = cleanTarget
return doc, nil
}
// Standalone note
if doc.IsOwner {
// Owner moves their own document -> updates documents.folder_path
_, err = s.db.Exec(`UPDATE documents SET folder_path = ?, updated_at = ? WHERE id = ?`, cleanTarget, now, docID)
if err != nil {
return nil, err
}
doc.FolderPath = cleanTarget
return doc, nil
}
// Recipient moves an individually shared note -> updates doc_user_folders ONLY!
_, err = s.db.Exec(`
INSERT INTO doc_user_folders (user_id, doc_id, folder_path, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id, doc_id) DO UPDATE SET folder_path = excluded.folder_path, updated_at = excluded.updated_at
`, userID, docID, cleanTarget, now, now)
if err != nil {
return nil, err
}
doc.FolderPath = cleanTarget
return doc, nil
}
// MoveFolder moves a folder (and all its documents/subfolders) to a new target parent path.
func (s *Store) MoveFolder(userID, sourcePath, targetParentPath string) (string, []string, error) {
cleanSource := filepath.ToSlash(filepath.Clean(strings.TrimSpace(sourcePath)))
if cleanSource == "" || cleanSource == "." || strings.HasPrefix(cleanSource, "/") || strings.Contains(cleanSource, "..") {
return "", nil, fmt.Errorf("ungültiger Quell-Ordnerpfad")
}
cleanTarget := filepath.ToSlash(filepath.Clean(strings.TrimSpace(targetParentPath)))
if cleanTarget == "." {
cleanTarget = ""
}
if cleanTarget != "" && (cleanTarget == cleanSource || strings.HasPrefix(cleanTarget, cleanSource+"/")) {
return "", nil, fmt.Errorf("ein Ordner kann nicht in sich selbst verschoben werden")
}
folderName := filepath.Base(cleanSource)
var newFolderPath string
if cleanTarget == "" {
newFolderPath = folderName
} else {
newFolderPath = cleanTarget + "/" + folderName
}
if newFolderPath == cleanSource {
return cleanSource, []string{}, nil
}
// 1. Check if sourcePath is the root mount of a shared folder
mounts, _, err := s.getUserFolderMounts(userID)
if err == nil {
for sfID, mPath := range mounts {
if mPath == cleanSource {
err := s.UpdateFolderMount(userID, sfID, newFolderPath)
if err != nil {
return "", nil, err
}
return newFolderPath, []string{}, nil
}
}
}
// 2. Move all affected documents
docs, err := s.GetDocuments(userID)
if err != nil {
return "", nil, err
}
var movedDocIDs []string
for _, doc := range docs {
if doc.FolderPath == cleanSource || strings.HasPrefix(doc.FolderPath, cleanSource+"/") {
var nextDocFolder string
if doc.FolderPath == cleanSource {
nextDocFolder = newFolderPath
} else {
suffix := strings.TrimPrefix(doc.FolderPath, cleanSource)
nextDocFolder = newFolderPath + suffix
}
_, moveErr := s.MoveDocument(userID, doc.ID, nextDocFolder)
if moveErr == nil {
movedDocIDs = append(movedDocIDs, doc.ID)
}
}
}
// 3. Re-map workspace_folders entries
targetSFID, targetSub, isTargetSF := matchMountPath(newFolderPath, mounts)
sourceSFID, sourceSub, isSourceSF := matchMountPath(cleanSource, mounts)
wfRows, qErr := s.db.Query(`SELECT id, user_id, shared_folder_id, path, sub_path FROM workspace_folders`)
if qErr == nil {
type wfRecord struct {
id, uid, sfID, path, subPath string
}
var records []wfRecord
for wfRows.Next() {
var r wfRecord
if err := wfRows.Scan(&r.id, &r.uid, &r.sfID, &r.path, &r.subPath); err == nil {
records = append(records, r)
}
}
_ = wfRows.Close()
for _, r := range records {
if isSourceSF && r.sfID == sourceSFID {
matches := false
var suffix string
if sourceSub == "" {
matches = true
suffix = "/" + r.subPath
} else if r.subPath == sourceSub {
matches = true
suffix = ""
} else if strings.HasPrefix(r.subPath, sourceSub+"/") {
matches = true
suffix = strings.TrimPrefix(r.subPath, sourceSub)
}
if matches {
var newSub string
if targetSub == "" {
newSub = strings.TrimPrefix(folderName+suffix, "/")
} else {
newSub = targetSub + "/" + folderName + suffix
}
if isTargetSF {
_, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ? WHERE id = ?`, targetSFID, newSub, r.id)
} else {
newP := newFolderPath + suffix
_, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = '', sub_path = '', path = ? WHERE id = ?`, newP, r.id)
}
}
} else if r.path == cleanSource || strings.HasPrefix(r.path, cleanSource+"/") {
suffix := strings.TrimPrefix(r.path, cleanSource)
newP := newFolderPath + suffix
if isTargetSF {
var newSub string
if targetSub == "" {
newSub = strings.TrimPrefix(folderName+suffix, "/")
} else {
newSub = targetSub + "/" + folderName + suffix
}
_, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ?, path = ? WHERE id = ?`, targetSFID, newSub, newP, r.id)
} else {
_, _ = s.db.Exec(`UPDATE workspace_folders SET path = ? WHERE id = ?`, newP, r.id)
}
}
}
}
_, _ = s.CreateFolder(userID, newFolderPath)
return newFolderPath, movedDocIDs, nil
}
// syncSharedFolderContents ensures all documents and subfolders under cleanMount owned by ownerID are linked to folderID.
func (s *Store) syncSharedFolderContents(ownerID, folderID, cleanMount string) {
if ownerID == "" || folderID == "" || cleanMount == "" {
return
}
// 1. Link all documents owned by ownerID in cleanMount to this shared folder
rows, err := s.db.Query(`SELECT id, folder_path FROM documents WHERE user_id = ? AND is_archived = 0 AND (folder_path = ? OR folder_path LIKE ?)`,
ownerID, cleanMount, cleanMount+"/%")
if err == nil {
type docEntry struct{ id, fp string }
var entries []docEntry
for rows.Next() {
var de docEntry
if err := rows.Scan(&de.id, &de.fp); err == nil {
entries = append(entries, de)
}
}
_ = rows.Close()
for _, de := range entries {
var subPath string
if de.fp == cleanMount {
subPath = ""
} else if strings.HasPrefix(de.fp, cleanMount+"/") {
subPath = strings.TrimPrefix(de.fp, cleanMount+"/")
}
_, _ = s.db.Exec(`UPDATE documents SET shared_folder_id = ?, sub_folder_path = ? WHERE id = ?`,
folderID, subPath, de.id)
}
}
// 2. Link any workspace_folders created by ownerID in cleanMount
fRows, err := s.db.Query(`SELECT id, path FROM workspace_folders WHERE user_id = ? AND (path = ? OR path LIKE ?)`,
ownerID, cleanMount, cleanMount+"/%")
if err == nil {
type wfEntry struct{ id, path string }
var wfEntries []wfEntry
for fRows.Next() {
var wfe wfEntry
if err := fRows.Scan(&wfe.id, &wfe.path); err == nil {
wfEntries = append(wfEntries, wfe)
}
}
_ = fRows.Close()
for _, wfe := range wfEntries {
subP := ""
if strings.HasPrefix(wfe.path, cleanMount+"/") {
subP = strings.TrimPrefix(wfe.path, cleanMount+"/")
}
_, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ? WHERE id = ?`,
folderID, subP, wfe.id)
}
}
}
func (s *Store) CreateSharedFolder(userID, folderName, initialMountPath string) (*domain.SharedFolder, error) {
cleanName := strings.TrimSpace(folderName)
if cleanName == "" {
return nil, fmt.Errorf("ordnername darf nicht leer sein")
}
cleanMount := filepath.ToSlash(filepath.Clean(strings.TrimSpace(initialMountPath)))
if cleanMount == "." || cleanMount == "" {
cleanMount = cleanName
}
// Check if already shared by this user at this mount path
var existingID string
err := s.db.QueryRow(`
SELECT f.id FROM workspace_shared_folders f
JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = ?
WHERE f.owner_id = ? AND (m.mount_path = ? OR f.name = ?)
`, userID, userID, cleanMount, cleanName).Scan(&existingID)
if err == nil && existingID != "" {
s.syncSharedFolderContents(userID, existingID, cleanMount)
return s.GetSharedFolderByID(userID, existingID)
}
now := domain.NowISO()
folderID := domain.NewID("fld")
_, err = s.db.Exec(`
INSERT INTO workspace_shared_folders (id, owner_id, name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
`, folderID, userID, cleanName, now, now)
if err != nil {
return nil, err
}
_, err = s.db.Exec(`
INSERT INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
`, userID, folderID, cleanMount, now, now)
if err != nil {
return nil, err
}
s.syncSharedFolderContents(userID, folderID, cleanMount)
var ownerName string
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, userID).Scan(&ownerName)
return &domain.SharedFolder{
ID: folderID,
OwnerID: userID,
OwnerName: ownerName,
Name: cleanName,
MountPath: cleanMount,
IsOwner: true,
Permission: "owner",
Shares: []domain.FolderShare{},
CreatedAt: now,
UpdatedAt: now,
}, nil
}
func (s *Store) GetSharedFolders(userID string) ([]domain.SharedFolder, error) {
rows, err := s.db.Query(`
SELECT f.id, f.owner_id, u.display_name, f.name,
COALESCE(m.mount_path, f.name) as mount_path,
CASE
WHEN f.owner_id = ? THEN 1
ELSE 0
END as is_owner,
CASE
WHEN f.owner_id = ? THEN 'owner'
WHEN MAX(CASE WHEN s.permission = 'write' THEN 1 ELSE 0 END) = 1 THEN 'write'
ELSE 'read'
END as permission,
f.created_at, f.updated_at
FROM workspace_shared_folders f
JOIN users u ON f.owner_id = u.id
LEFT JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = ?
LEFT JOIN workspace_folder_shares s ON s.folder_id = f.id AND (
(s.target_type = 'user' AND s.target_id = ?) OR
(s.target_type = 'team' AND s.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
)
WHERE f.owner_id = ? OR s.id IS NOT NULL
GROUP BY f.id
ORDER BY f.created_at ASC
`, userID, userID, userID, userID, userID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
folders := make([]domain.SharedFolder, 0)
for rows.Next() {
var f domain.SharedFolder
var isOwner int
if err := rows.Scan(&f.ID, &f.OwnerID, &f.OwnerName, &f.Name, &f.MountPath, &isOwner, &f.Permission, &f.CreatedAt, &f.UpdatedAt); err == nil {
f.IsOwner = isOwner != 0
if f.IsOwner {
if shares, err := s.GetFolderShares(f.ID); err == nil {
f.Shares = shares
}
}
folders = append(folders, f)
}
}
return folders, nil
}
func (s *Store) GetSharedFolderByID(userID, folderID string) (*domain.SharedFolder, error) {
row := s.db.QueryRow(`
SELECT f.id, f.owner_id, u.display_name, f.name,
COALESCE(m.mount_path, f.name) as mount_path,
CASE
WHEN f.owner_id = ? THEN 1
ELSE 0
END as is_owner,
CASE
WHEN f.owner_id = ? THEN 'owner'
WHEN MAX(CASE WHEN s.permission = 'write' THEN 1 ELSE 0 END) = 1 THEN 'write'
ELSE 'read'
END as permission,
f.created_at, f.updated_at
FROM workspace_shared_folders f
JOIN users u ON f.owner_id = u.id
LEFT JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = ?
LEFT JOIN workspace_folder_shares s ON s.folder_id = f.id AND (
(s.target_type = 'user' AND s.target_id = ?) OR
(s.target_type = 'team' AND s.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
)
WHERE f.id = ? AND (f.owner_id = ? OR s.id IS NOT NULL)
GROUP BY f.id
`, userID, userID, userID, userID, userID, folderID, userID)
var f domain.SharedFolder
var isOwner int
if err := row.Scan(&f.ID, &f.OwnerID, &f.OwnerName, &f.Name, &f.MountPath, &isOwner, &f.Permission, &f.CreatedAt, &f.UpdatedAt); err != nil {
return nil, fmt.Errorf("geteilter Ordner nicht gefunden")
}
f.IsOwner = isOwner != 0
if f.IsOwner {
if shares, err := s.GetFolderShares(f.ID); err == nil {
f.Shares = shares
}
}
return &f, nil
}
func (s *Store) UpdateFolderMount(userID, folderID, mountPath string) error {
cleanMount := filepath.ToSlash(filepath.Clean(strings.TrimSpace(mountPath)))
if cleanMount == "." || cleanMount == "" {
return fmt.Errorf("ungültiger Mount-Pfad")
}
now := domain.NowISO()
_, err := s.db.Exec(`
INSERT INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id, folder_id) DO UPDATE SET mount_path = excluded.mount_path, updated_at = excluded.updated_at
`, userID, folderID, cleanMount, now, now)
return err
}
func (s *Store) GetFolderShares(folderID string) ([]domain.FolderShare, error) {
shares := make([]domain.FolderShare, 0)
rows, err := s.db.Query(`
SELECT s.id, s.folder_id, s.target_type, s.target_id,
CASE
WHEN s.target_type = 'user' THEN u.display_name
WHEN s.target_type = 'team' THEN t.name
ELSE ''
END as target_name,
COALESCE(u.email, '') as email,
COALESCE(u.avatar_url, '') as avatar_url,
s.permission, s.created_by, s.created_at
FROM workspace_folder_shares s
LEFT JOIN users u ON s.target_type = 'user' AND s.target_id = u.id
LEFT JOIN teams t ON s.target_type = 'team' AND s.target_id = t.id
WHERE s.folder_id = ?
ORDER BY s.created_at ASC
`, folderID)
if err != nil {
return shares, err
}
defer rows.Close()
for rows.Next() {
var sh domain.FolderShare
if err := rows.Scan(&sh.ID, &sh.FolderID, &sh.TargetType, &sh.TargetID, &sh.TargetName, &sh.Email, &sh.AvatarURL, &sh.Permission, &sh.CreatedBy, &sh.CreatedAt); err == nil {
shares = append(shares, sh)
}
}
return shares, nil
}
func (s *Store) CreateFolderShare(currentUserID, folderID, targetType, targetIdentifier, permission string) (*domain.FolderShare, error) {
f, err := s.GetSharedFolderByID(currentUserID, folderID)
if err != nil {
return nil, err
}
if !f.IsOwner {
return nil, fmt.Errorf("nur der Eigentümer kann Ordner-Freigaben verwalten")
}
cleanType := strings.TrimSpace(targetType)
cleanTarget := strings.TrimSpace(targetIdentifier)
if cleanType == "" {
if strings.HasPrefix(cleanTarget, "team_") {
cleanType = "team"
} else {
cleanType = "user"
}
}
permission = normalizeSharePermission(permission)
var targetID, targetName, email, avatarURL string
if cleanType == "team" {
err = s.db.QueryRow(`SELECT id, name FROM teams WHERE id = ?`, cleanTarget).Scan(&targetID, &targetName)
if err != nil {
return nil, ErrTargetNotFound
}
} else {
var u *domain.User
if strings.HasPrefix(cleanTarget, "usr_") {
u, _ = s.GetUserByID(cleanTarget)
}
if u == nil {
u, _ = s.GetUserByLogin(cleanTarget)
}
if u == nil {
u, err = s.GetUserByID(cleanTarget)
}
if err != nil || u == nil {
if s.db.QueryRow(`SELECT id, name FROM teams WHERE id = ?`, cleanTarget).Scan(&targetID, &targetName) == nil {
cleanType = "team"
} else {
return nil, ErrTargetNotFound
}
} else {
if u.ID == f.OwnerID {
return nil, fmt.Errorf("ordner gehört bereits diesem Benutzer")
}
targetID = u.ID
targetName = u.DisplayName
email = u.Email
avatarURL = u.AvatarURL
}
}
if !s.CanShareWith(currentUserID, cleanType, targetID) {
return nil, ErrTargetNotFound
}
now := domain.NowISO()
shareID := domain.NewID("fsh")
_, err = s.db.Exec(`
INSERT INTO workspace_folder_shares (id, folder_id, target_type, target_id, permission, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(folder_id, target_type, target_id) DO UPDATE SET permission = excluded.permission
`, shareID, folderID, cleanType, targetID, permission, currentUserID, now)
if err != nil {
return nil, err
}
var ownerMount string
_ = s.db.QueryRow(`SELECT COALESCE(m.mount_path, f.name) FROM workspace_shared_folders f LEFT JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = f.owner_id WHERE f.id = ?`, folderID).Scan(&ownerMount)
if ownerMount == "" {
ownerMount = f.Name
}
s.syncSharedFolderContents(f.OwnerID, folderID, ownerMount)
if cleanType == "user" {
_, _ = s.db.Exec(`
INSERT OR IGNORE INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
`, targetID, folderID, f.Name, now, now)
} else if cleanType == "team" {
mRows, err := s.db.Query(`SELECT user_id FROM team_members WHERE team_id = ?`, targetID)
if err == nil {
var memberIDs []string
for mRows.Next() {
var uid string
if err := mRows.Scan(&uid); err == nil && uid != "" {
memberIDs = append(memberIDs, uid)
}
}
_ = mRows.Close()
for _, uid := range memberIDs {
_, _ = s.db.Exec(`
INSERT OR IGNORE INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
`, uid, folderID, f.Name, now, now)
}
}
}
return &domain.FolderShare{
ID: shareID,
FolderID: folderID,
TargetType: cleanType,
TargetID: targetID,
TargetName: targetName,
Email: email,
AvatarURL: avatarURL,
Permission: permission,
CreatedBy: currentUserID,
CreatedAt: now,
}, nil
}
func (s *Store) DeleteFolderShare(currentUserID, folderID, shareID string) error {
f, err := s.GetSharedFolderByID(currentUserID, folderID)
if err != nil {
return err
}
cleanID := strings.TrimSpace(shareID)
// Only the owner manages shares; everyone else can only leave the folder
if !f.IsOwner {
_, err = s.db.Exec(`DELETE FROM workspace_folder_shares WHERE folder_id = ? AND target_type = 'user' AND target_id = ? AND (id = ? OR target_id = ?)`,
folderID, currentUserID, cleanID, cleanID)
_, _ = s.db.Exec(`DELETE FROM workspace_folder_mounts WHERE folder_id = ? AND user_id = ?`, folderID, currentUserID)
return err
}
var targetType, targetID string
_ = s.db.QueryRow(`SELECT target_type, target_id FROM workspace_folder_shares WHERE folder_id = ? AND (id = ? OR target_id = ?)`,
folderID, cleanID, cleanID).Scan(&targetType, &targetID)
_, err = s.db.Exec(`DELETE FROM workspace_folder_shares WHERE folder_id = ? AND (id = ? OR target_id = ?)`, folderID, cleanID, cleanID)
if err != nil {
return err
}
if targetType == "user" && targetID != "" {
_, _ = s.db.Exec(`DELETE FROM workspace_folder_mounts WHERE folder_id = ? AND user_id = ?`, folderID, targetID)
}
return nil
}
func (s *Store) DeleteSharedFolder(currentUserID, folderID string) error {
f, err := s.GetSharedFolderByID(currentUserID, folderID)
if err != nil {
return err
}
if !f.IsOwner {
return s.DeleteFolderShare(currentUserID, folderID, currentUserID)
}
_, _ = s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '' WHERE shared_folder_id = ?`, folderID)
_, _ = s.db.Exec(`DELETE FROM workspace_folder_mounts WHERE folder_id = ?`, folderID)
_, _ = s.db.Exec(`DELETE FROM workspace_folder_shares WHERE folder_id = ?`, folderID)
_, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ?`, folderID)
_, err = s.db.Exec(`DELETE FROM workspace_shared_folders WHERE id = ?`, folderID)
return err
}
// GetSharedFolderDocIDs returns all non-archived document IDs belonging to a shared folder.
func (s *Store) GetSharedFolderDocIDs(folderID string) ([]string, error) {
rows, err := s.db.Query(`SELECT id FROM documents WHERE shared_folder_id = ? AND is_archived = 0`, folderID)
if err != nil {
return nil, err
}
defer rows.Close()
var docIDs []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err == nil && id != "" {
docIDs = append(docIDs, id)
}
}
return docIDs, nil
}
+875
View File
@@ -0,0 +1,875 @@
package storage
import (
"archive/zip"
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"path"
"regexp"
"sort"
"strings"
"time"
"unicode/utf8"
"github.com/sandrodach/mindsift/internal/domain"
)
// Import limits: they keep a crafted archive (zip bomb, millions of files) from exhausting the server.
const (
MaxImportUploadBytes = 100 << 20 // uploaded file
maxImportFileBytes = 10 << 20 // one unpacked note
maxImportUnpackedBytes = 500 << 20 // all unpacked notes together
maxImportFiles = 20000
)
// ImportPlan is everything an import would create. It is built completely (and validated) before
// anything is written, so the preview shows exactly what the import does.
type ImportPlan struct {
Folders []string `json:"-"`
Docs []importDoc `json:"-"`
TaskLists []importTaskList `json:"-"`
Lists []importList `json:"-"`
Inbox []importTask `json:"-"`
Skipped []ImportSkip `json:"skipped"`
}
type ImportSkip struct {
Path string `json:"path"`
Reason string `json:"reason"`
}
// ImportSummary is what the API returns for a preview and after the import.
type ImportSummary struct {
Folders int `json:"folders"`
Notes int `json:"notes"`
TaskLists int `json:"taskLists"`
Tasks int `json:"tasks"`
Lists int `json:"lists"`
ListItems int `json:"listItems"`
Inbox int `json:"inbox"`
Skipped []ImportSkip `json:"skipped"`
Applied bool `json:"applied"`
}
type importDoc struct {
folder, title, icon string
blocks []domain.DocumentBlock
}
type importTask struct {
content, priority, due, notes, recurrence string
done bool
subtasks []domain.SubTask
}
type importTaskList struct {
title, icon string
tasks []importTask
}
type importList struct {
title, icon string
items []importItem
}
type importItem struct {
content, category, quantity string
done bool
}
func (p *ImportPlan) skip(path, reason string) {
p.Skipped = append(p.Skipped, ImportSkip{Path: path, Reason: reason})
}
func (p *ImportPlan) Summary(applied bool) ImportSummary {
sum := ImportSummary{Folders: len(p.Folders), Notes: len(p.Docs), Lists: len(p.Lists), Inbox: len(p.Inbox), Skipped: p.Skipped, Applied: applied}
for _, tl := range p.TaskLists {
if tl.title != "" {
sum.TaskLists++
}
sum.Tasks += len(tl.tasks)
}
for _, l := range p.Lists {
sum.ListItems += len(l.items)
}
if sum.Skipped == nil {
sum.Skipped = []ImportSkip{}
}
return sum
}
// --- Reading archives -------------------------------------------------------------------------------
type zipNote struct {
path string // inside the archive, "/"-separated, without a leading vault folder
text string
}
// readZipNotes returns the Markdown files of an archive (other files are reported as skipped). A
// single top-level folder, as Obsidian and most zip tools create, is stripped and returned as name.
func readZipNotes(r io.ReaderAt, size int64, plan *ImportPlan) ([]zipNote, []string, string, error) {
zr, err := zip.NewReader(r, size)
if err != nil {
return nil, nil, "", errors.New("Die Datei ist kein gültiges ZIP-Archiv")
}
if len(zr.File) > maxImportFiles {
return nil, nil, "", fmt.Errorf("Zu viele Dateien im Archiv (max. %d)", maxImportFiles)
}
names := make([]string, 0, len(zr.File))
for _, f := range zr.File {
names = append(names, cleanArchivePath(f.Name))
}
prefix := commonTopFolder(names)
var notes []zipNote
var dirs []string
var unpacked int64
for i, f := range zr.File {
name := strings.TrimPrefix(names[i], prefix)
if name == "" || hiddenPath(name) {
continue
}
if f.FileInfo().IsDir() {
dirs = append(dirs, strings.TrimSuffix(name, "/"))
continue
}
if !strings.EqualFold(path.Ext(name), ".md") {
plan.skip(name, "kein Markdown (Anhänge wie Bilder oder PDFs werden nicht importiert)")
continue
}
if f.UncompressedSize64 > maxImportFileBytes {
plan.skip(name, "zu groß (max. 10 MB pro Notiz)")
continue
}
rc, err := f.Open()
if err != nil {
plan.skip(name, "nicht lesbar")
continue
}
data, err := io.ReadAll(io.LimitReader(rc, maxImportFileBytes+1))
rc.Close()
if err != nil || len(data) > maxImportFileBytes {
plan.skip(name, "nicht lesbar oder zu groß")
continue
}
if unpacked += int64(len(data)); unpacked > maxImportUnpackedBytes {
return nil, nil, "", errors.New("Archiv ist entpackt zu groß (max. 500 MB)")
}
if !utf8.Valid(data) {
plan.skip(name, "keine UTF-8-Textdatei")
continue
}
notes = append(notes, zipNote{path: name, text: strings.ReplaceAll(string(data), "\r\n", "\n")})
}
sort.Slice(notes, func(i, j int) bool { return notes[i].path < notes[j].path })
return notes, dirs, strings.TrimSuffix(prefix, "/"), nil
}
// cleanArchivePath normalises an entry name and removes anything that could escape (.., absolute paths).
func cleanArchivePath(name string) string {
name = strings.ReplaceAll(name, "\\", "/")
parts := make([]string, 0)
for _, p := range strings.Split(name, "/") {
if p == "" || p == "." || p == ".." {
continue
}
parts = append(parts, p)
}
out := strings.Join(parts, "/")
if strings.HasSuffix(name, "/") && out != "" {
out += "/"
}
return out
}
func commonTopFolder(names []string) string {
top := ""
for _, n := range names {
if n == "" || hiddenPath(n) {
continue
}
i := strings.Index(n, "/")
if i < 0 {
return "" // a file at the root: no common folder
}
if top == "" {
top = n[:i+1]
} else if top != n[:i+1] {
return ""
}
}
return top
}
// hiddenPath skips Obsidian's settings, trash and OS junk.
func hiddenPath(p string) bool {
for _, part := range strings.Split(p, "/") {
if strings.HasPrefix(part, ".") || part == "__MACOSX" {
return true
}
}
return false
}
// --- Planning ---------------------------------------------------------------------------------------
// PlanObsidianImport reads a vault ZIP. Everything lands in a top folder named after the vault, so an
// import never mixes into existing notes. Checkboxes stay checkboxes in their note.
func PlanObsidianImport(r io.ReaderAt, size int64, fileName string) (*ImportPlan, error) {
plan := &ImportPlan{}
notes, dirs, vault, err := readZipNotes(r, size, plan)
if err != nil {
return nil, err
}
if vault == "" {
vault = strings.TrimSuffix(path.Base(fileName), path.Ext(fileName))
}
root := segment(vault)
if root == "" {
root = "Obsidian-Import"
}
folders := map[string]bool{root: true}
for _, d := range dirs {
folders[path.Join(root, folderPathOf(d+"/x"))] = true
}
for _, n := range notes {
fm, body := splitFrontmatter(n.text)
folder := path.Join(root, folderPathOf(n.path))
folders[folder] = true
title := fm.title
if title == "" {
title = strings.TrimSuffix(path.Base(n.path), path.Ext(n.path))
}
blocks := MarkdownToBlocks(body)
// Front matter tags become hashtags, since MindSift reads tags from the text
if extra := missingTags(fm.tags, body); extra != "" {
blocks = append(blocks, newBlock("bullet", extra, 0))
}
plan.Docs = append(plan.Docs, importDoc{folder: folder, title: title, icon: fm.icon, blocks: blocks})
}
plan.Folders = sortedKeys(folders)
if len(plan.Docs) == 0 {
return nil, errors.New("Im Archiv wurden keine Markdown-Notizen gefunden")
}
return plan, nil
}
// PlanMindSiftArchiveImport reads a ZIP created by "Markdown-Archiv herunterladen": notes go back to
// their folders, Aufgaben/ back into task lists, Listen/ back into lists.
func PlanMindSiftArchiveImport(r io.ReaderAt, size int64) (*ImportPlan, error) {
plan := &ImportPlan{}
notes, dirs, _, err := readZipNotes(r, size, plan)
if err != nil {
return nil, err
}
folders := map[string]bool{}
for _, d := range dirs {
if top := strings.SplitN(d, "/", 2)[0]; top != "Aufgaben" && top != "Listen" {
folders[folderPathOf(d+"/x")] = true
}
}
for _, n := range notes {
fm, body := splitFrontmatter(n.text)
title := fm.title
if title == "" {
title = strings.TrimSuffix(path.Base(n.path), path.Ext(n.path))
}
switch folderPathOf(n.path) {
case "Aufgaben":
tl := importTaskList{title: title, icon: fm.icon, tasks: parseTaskFile(body)}
if title == "Ohne Liste" {
tl.title = ""
}
plan.TaskLists = append(plan.TaskLists, tl)
case "Listen":
plan.Lists = append(plan.Lists, importList{title: title, icon: fm.icon, items: parseListFile(body)})
default:
folder := folderPathOf(n.path)
if folder != "" {
folders[folder] = true
}
plan.Docs = append(plan.Docs, importDoc{folder: folder, title: title, icon: fm.icon, blocks: MarkdownToBlocks(body)})
}
}
plan.Folders = sortedKeys(folders)
if len(plan.Docs)+len(plan.TaskLists)+len(plan.Lists) == 0 {
return nil, errors.New("Das Archiv enthält keine MindSift-Daten (Notizen, Aufgaben oder Listen)")
}
return plan, nil
}
// BackupFileName is the JSON inside the backup ZIP.
const BackupFileName = "mindsift-backup.json"
// WriteBackupZip streams the JSON backup compressed into a ZIP with a single file.
func WriteBackupZip(exp *domain.FullExport, w io.Writer) error {
zw := zip.NewWriter(w)
f, err := zw.CreateHeader(&zip.FileHeader{Name: BackupFileName, Method: zip.Deflate, Modified: parseISO(exp.ExportedAt, time.Now())})
if err != nil {
return err
}
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
if err := enc.Encode(exp); err != nil {
return err
}
return zw.Close()
}
// PlanMindSiftImport picks the format of MindSift's own data: plain JSON backup, the backup ZIP
// (one JSON inside) or the Markdown archive.
func PlanMindSiftImport(data []byte, fileName string) (*ImportPlan, error) {
if strings.HasSuffix(strings.ToLower(fileName), ".json") || (len(data) > 0 && data[0] == '{') {
return PlanMindSiftJSONImport(data)
}
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, errors.New("Die Datei ist weder ein MindSift-Backup (.json/.zip) noch ein MindSift-Markdown-Archiv")
}
var backup *zip.File
for _, f := range zr.File {
name := cleanArchivePath(f.Name)
if strings.EqualFold(path.Ext(name), ".md") {
backup = nil
break
}
if strings.EqualFold(path.Ext(name), ".json") && !hiddenPath(name) {
backup = f
}
}
if backup == nil {
return PlanMindSiftArchiveImport(bytes.NewReader(data), int64(len(data)))
}
if backup.UncompressedSize64 > maxImportUnpackedBytes {
return nil, errors.New("Backup ist entpackt zu groß (max. 500 MB)")
}
rc, err := backup.Open()
if err != nil {
return nil, errors.New("Backup im Archiv ist nicht lesbar")
}
defer rc.Close()
raw, err := io.ReadAll(io.LimitReader(rc, maxImportUnpackedBytes+1))
if err != nil || len(raw) > maxImportUnpackedBytes {
return nil, errors.New("Backup im Archiv ist nicht lesbar oder zu groß")
}
return PlanMindSiftJSONImport(raw)
}
// PlanMindSiftJSONImport reads the JSON backup ("Backup herunterladen"). Notes shared with the
// exporting user belong to someone else and are skipped; discarded thoughts are not restored.
func PlanMindSiftJSONImport(data []byte) (*ImportPlan, error) {
var exp domain.FullExport
if err := json.Unmarshal(data, &exp); err != nil || (exp.Version == "" && exp.ExportedAt == "") {
return nil, errors.New("Die Datei ist kein MindSift-JSON-Backup")
}
plan := &ImportPlan{}
folders := map[string]bool{}
for _, d := range exp.Documents {
if !d.IsOwner && d.Permission != "" && d.Permission != "owner" {
plan.skip(d.Title, "geteilte Notiz eines anderen Benutzers")
continue
}
if d.FolderPath != "" {
folders[d.FolderPath] = true
}
blocks := make([]domain.DocumentBlock, len(d.Blocks))
for i, b := range d.Blocks {
b.ID, b.DocumentID, b.TaskID = domain.NewID("blk"), "", ""
blocks[i] = b
}
plan.Docs = append(plan.Docs, importDoc{folder: d.FolderPath, title: d.Title, icon: d.Icon, blocks: blocks})
}
listIndex := map[string]int{}
for _, tl := range exp.TaskLists {
if !tl.IsOwner {
plan.skip(tl.Title, "geteilte Aufgabenliste eines anderen Benutzers")
continue
}
listIndex[tl.ID] = len(plan.TaskLists)
plan.TaskLists = append(plan.TaskLists, importTaskList{title: tl.Title, icon: tl.Icon})
}
for _, t := range exp.Thoughts {
task := importTask{content: t.Content, priority: t.Priority, due: t.DueDate, notes: t.Notes, done: t.IsCompleted, subtasks: t.Subtasks, recurrence: t.Recurrence}
switch {
case t.Status == domain.StatusInbox:
plan.Inbox = append(plan.Inbox, task)
case t.Status == domain.StatusKept || (t.Status == domain.StatusConverted && t.ConvertedType == "task"):
if i, ok := listIndex[t.TaskListID]; ok {
plan.TaskLists[i].tasks = append(plan.TaskLists[i].tasks, task)
} else {
plan.TaskLists = append(plan.TaskLists, importTaskList{tasks: []importTask{task}})
}
}
}
for _, l := range exp.Lists {
if !l.IsOwner {
plan.skip(l.Title, "geteilte Liste eines anderen Benutzers")
continue
}
il := importList{title: l.Title, icon: l.Icon}
for _, it := range l.Items {
il.items = append(il.items, importItem{content: it.Content, category: it.Category, quantity: it.Quantity, done: it.IsCompleted})
}
plan.Lists = append(plan.Lists, il)
}
plan.Folders = sortedKeys(folders)
return plan, nil
}
// --- Applying ---------------------------------------------------------------------------------------
// ApplyImport creates everything of the plan for the user. Existing data is never changed; imported
// items are added next to it. Task lists of the same name get merged into one new list per import.
func (s *Store) ApplyImport(userID string, plan *ImportPlan) (ImportSummary, error) {
for _, f := range plan.Folders {
_, _ = s.CreateFolder(userID, f) // already existing folders are fine
}
for _, d := range plan.Docs {
title := strings.TrimSpace(d.title)
if title == "" {
title = "Unbenannte Notiz"
}
icon := d.icon
if icon == "" {
icon = "📄"
}
if _, err := s.CreateDocument(userID, "", title, icon, d.folder, d.blocks, nil, false); err != nil {
return plan.Summary(false), fmt.Errorf("Notiz %q: %w", title, err)
}
}
for _, tl := range plan.TaskLists {
listID := "" // tasks without a list
if tl.title != "" {
icon := tl.icon
if icon == "" {
icon = "📋"
}
created, err := s.CreateTaskList(userID, "", "", tl.title, icon, "")
if err != nil {
return plan.Summary(false), fmt.Errorf("Aufgabenliste %q: %w", tl.title, err)
}
listID = created.ID
}
for _, t := range tl.tasks {
if err := s.importTaskInto(userID, listID, t, true); err != nil {
return plan.Summary(false), err
}
}
}
for _, t := range plan.Inbox {
if err := s.importTaskInto(userID, "", t, false); err != nil {
return plan.Summary(false), err
}
}
for _, l := range plan.Lists {
icon := l.icon
if icon == "" {
icon = "🛒"
}
created, err := s.CreateList(userID, l.title, icon, "shopping")
if err != nil {
return plan.Summary(false), fmt.Errorf("Liste %q: %w", l.title, err)
}
for i, it := range l.items {
item, err := s.CreateListItem(created.ID, it.content, it.category, it.quantity, i, userID)
if err != nil {
continue // empty lines and the like
}
if it.done {
_, _ = s.ToggleListItem(item.ID, true, userID, "")
}
}
}
return plan.Summary(true), nil
}
func (s *Store) importTaskInto(userID, listID string, t importTask, asTask bool) error {
content := strings.TrimSpace(t.content)
if content == "" {
return nil
}
priority := t.priority
if priority != "low" && priority != "high" && priority != "urgent" {
priority = "normal"
}
th, err := s.CreateThoughtWithDetails(userID, content, priority, "import", t.due, listID, "todo", t.notes)
if err != nil {
return fmt.Errorf("Aufgabe %q: %w", content, err)
}
if !asTask {
return nil
}
if _, err := s.UpdateThoughtTriage(userID, th.ID, "keep", "task", "", t.due, listID); err != nil {
return fmt.Errorf("Aufgabe %q: %w", content, err)
}
if validRecurrence(t.recurrence) && t.recurrence != "" {
// Imported as already handled: completing a restored task must not create a second successor
_, _ = s.db.Exec(`UPDATE thoughts SET recurrence = ?, recurrence_spawned = ? WHERE id = ?`, t.recurrence, boolInt(t.done), th.ID)
}
if t.done || len(t.subtasks) > 0 {
var done *bool
if t.done {
done = &t.done
}
var subs *[]domain.SubTask
if len(t.subtasks) > 0 {
subs = &t.subtasks
}
_, _ = s.UpdateThoughtDetails(userID, th.ID, nil, done, nil, nil, nil, nil, nil, subs, nil)
}
return nil
}
// --- Markdown parsing -------------------------------------------------------------------------------
type frontmatter struct {
title, icon string
tags []string
}
// splitFrontmatter reads the few keys MindSift uses (title, icon, tags/tag) from YAML front matter.
// It is deliberately tolerant: unknown keys and nested structures are ignored.
func splitFrontmatter(text string) (frontmatter, string) {
var fm frontmatter
if !strings.HasPrefix(text, "---\n") {
return fm, text
}
end := strings.Index(text[4:], "\n---")
if end < 0 {
return fm, text
}
head := text[4 : 4+end]
body := strings.TrimLeft(text[4+end+4:], "\n")
key := ""
for _, line := range strings.Split(head, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "- ") && (key == "tags" || key == "tag") {
fm.tags = append(fm.tags, yamlScalar(trimmed[2:]))
continue
}
k, v, ok := strings.Cut(line, ":")
if !ok || strings.HasPrefix(line, " ") {
continue
}
key = strings.TrimSpace(strings.ToLower(k))
v = strings.TrimSpace(v)
switch key {
case "title":
fm.title = yamlScalar(v)
case "icon":
fm.icon = yamlScalar(v)
case "tags", "tag":
v = strings.Trim(v, "[]")
for _, t := range strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ' ' }) {
if t = yamlScalar(t); t != "" {
fm.tags = append(fm.tags, t)
}
}
}
}
return fm, body
}
func yamlScalar(v string) string {
v = strings.TrimSpace(v)
if strings.HasPrefix(v, `"`) {
var out string
if json.Unmarshal([]byte(v), &out) == nil {
return out
}
}
return strings.Trim(v, `"'`)
}
func missingTags(tags []string, body string) string {
lower := strings.ToLower(body)
var out []string
for _, t := range tags {
t = strings.TrimPrefix(strings.TrimSpace(t), "#")
if t == "" || strings.Contains(lower, "#"+strings.ToLower(t)) {
continue
}
out = append(out, "#"+strings.ReplaceAll(t, " ", "_"))
}
return strings.Join(out, " ")
}
var (
listItemRe = regexp.MustCompile(`^([ \t]*)(?:[-*+]|\d+[.)])(?:[ \t]+(.*))?$`)
checkboxRe = regexp.MustCompile(`^\[([ xX])\][ \t]*(.*)$`)
headingRe = regexp.MustCompile(`^(#{1,6})[ \t]+(.*)$`)
fenceRe = regexp.MustCompile("^([ \\t]*)(```|~~~)(.*)$")
calloutRe = regexp.MustCompile(`^\[![\w-]+\][+-]?[ \t]*(.*)$`)
)
func newBlock(typ, content string, level int) domain.DocumentBlock {
return domain.DocumentBlock{ID: domain.NewID("blk"), Type: typ, Content: content, Level: level}
}
// indentWidth counts a tab as 4 columns.
func indentWidth(s string) int {
w := 0
for _, r := range s {
if r == '\t' {
w += 4
} else {
w++
}
}
return w
}
// MarkdownToBlocks turns Markdown into outliner blocks. Lists keep their nesting, headings start a
// section whose following content is nested below them (the reverse of the export), callouts and
// quotes become callout blocks, fenced code becomes a code block, plain paragraphs become bullets.
func MarkdownToBlocks(md string) []domain.DocumentBlock {
lines := strings.Split(md, "\n")
var blocks []domain.DocumentBlock
base := 0 // 1 inside a heading section
var indents []int // indent widths of the open list levels
inList := false // the previous line belonged to a list item (for continuation lines)
listLevel := func(width int) int {
for len(indents) > 0 && indents[len(indents)-1] > width {
indents = indents[:len(indents)-1]
}
if len(indents) == 0 || indents[len(indents)-1] < width {
indents = append(indents, width)
}
return base + len(indents) - 1
}
for i := 0; i < len(lines); i++ {
line := lines[i]
trimmed := strings.TrimSpace(line)
if trimmed == "" {
inList = false
continue
}
// Fenced code (also inside lists)
if m := fenceRe.FindStringSubmatch(line); m != nil {
fence, lang, prefix := m[2], strings.TrimSpace(m[3]), m[1]
var code []string
for i++; i < len(lines); i++ {
l := lines[i]
if strings.HasPrefix(strings.TrimSpace(l), fence) {
break
}
code = append(code, strings.TrimPrefix(l, prefix))
}
level := base
if prefix != "" && len(indents) > 0 {
level = listLevel(indentWidth(prefix))
}
b := newBlock("code", strings.Join(code, "\n"), level)
b.Lang = lang
blocks = append(blocks, b)
inList = false
continue
}
if m := headingRe.FindStringSubmatch(line); m != nil && !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
typ := map[int]string{1: "h1", 2: "h2"}[len(m[1])]
if typ == "" {
typ = "h3"
}
blocks = append(blocks, newBlock(typ, strings.TrimSpace(m[2]), 0))
base, indents, inList = 1, nil, false
continue
}
if trimmed == "---" || trimmed == "***" || trimmed == "___" {
if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
blocks = append(blocks, newBlock("divider", "", 0))
base, indents, inList = 0, nil, false
continue
}
}
// Quotes and Obsidian callouts (> [!note] …): one callout block
if strings.HasPrefix(trimmed, ">") && !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
var parts []string
for ; i < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[i]), ">"); i++ {
q := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(lines[i]), ">"))
if m := calloutRe.FindStringSubmatch(q); m != nil {
if q = strings.TrimSpace(m[1]); q == "" {
continue
}
}
parts = append(parts, q)
}
i--
blocks = append(blocks, newBlock("callout", strings.Join(parts, "\n"), base))
indents, inList = nil, false
continue
}
if m := listItemRe.FindStringSubmatch(line); m != nil {
level := listLevel(indentWidth(m[1]))
content := m[2]
typ := "bullet"
checked := false
if c := checkboxRe.FindStringSubmatch(content); c != nil {
typ, checked, content = "todo", c[1] != " ", c[2]
} else if h := headingRe.FindStringSubmatch(content); h != nil {
typ = map[int]string{1: "h1", 2: "h2"}[len(h[1])]
if typ == "" {
typ = "h3"
}
content = strings.TrimSpace(h[2])
} else if strings.TrimSpace(content) == "---" {
typ, content = "divider", ""
} else if strings.HasPrefix(content, "> ") {
typ, content = "callout", strings.TrimPrefix(content, "> ")
}
b := newBlock(typ, content, level)
b.Checked = checked
blocks = append(blocks, b)
inList = true
continue
}
// Indented text right below a list item continues it (soft line break)
if inList && (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) && len(blocks) > 0 {
last := &blocks[len(blocks)-1]
last.Content += "\n" + strings.TrimPrefix(trimmed, "> ")
continue
}
// Plain paragraph (consecutive lines form one block)
var para []string
for ; i < len(lines); i++ {
l := strings.TrimSpace(lines[i])
if l == "" || headingRe.MatchString(lines[i]) || listItemRe.MatchString(lines[i]) || fenceRe.MatchString(lines[i]) || strings.HasPrefix(l, ">") {
break
}
para = append(para, l)
}
i--
blocks = append(blocks, newBlock("bullet", strings.Join(para, "\n"), base))
indents, inList = nil, false
}
return blocks
}
var (
recurRe = regexp.MustCompile(`(?i)[ \t]*🔁[ \t]*every[ \t]+(day|week|month|year)\b`)
dueRe = regexp.MustCompile(`[ \t]*📅[ \t]*(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}:\d{2}))?`)
priorityRe = regexp.MustCompile(`[ \t]*(🔺|⏫|🔼|🔽|⏬)`)
)
// parseTaskFile reads a task list in Obsidian Tasks format as written by the export.
func parseTaskFile(body string) []importTask {
var tasks []importTask
for _, line := range strings.Split(body, "\n") {
if strings.TrimSpace(line) == "" {
continue
}
indented := strings.HasPrefix(line, "\t") || strings.HasPrefix(line, " ")
trimmed := strings.TrimSpace(line)
if indented && len(tasks) > 0 {
last := &tasks[len(tasks)-1]
if strings.HasPrefix(trimmed, ">") {
note := strings.TrimSpace(strings.TrimPrefix(trimmed, ">"))
if last.notes != "" {
last.notes += "\n"
}
last.notes += note
} else if m := listItemRe.FindStringSubmatch(trimmed); m != nil {
if c := checkboxRe.FindStringSubmatch(m[2]); c != nil {
last.subtasks = append(last.subtasks, domain.SubTask{ID: domain.NewID("st"), Title: c[2], IsCompleted: c[1] != " ", CreatedAt: domain.NowISO()})
}
}
continue
}
m := listItemRe.FindStringSubmatch(trimmed)
if m == nil {
continue
}
t := importTask{content: m[2], priority: "normal"}
if c := checkboxRe.FindStringSubmatch(m[2]); c != nil {
t.done, t.content = c[1] != " ", c[2]
}
if r := recurRe.FindStringSubmatch(t.content); r != nil {
t.recurrence = map[string]string{"day": "daily", "week": "weekly", "month": "monthly", "year": "yearly"}[strings.ToLower(r[1])]
t.content = recurRe.ReplaceAllString(t.content, "")
}
if d := dueRe.FindStringSubmatch(t.content); d != nil {
t.due = d[1]
if d[2] != "" {
t.due += "T" + d[2]
}
t.content = dueRe.ReplaceAllString(t.content, "")
}
if p := priorityRe.FindStringSubmatch(t.content); p != nil {
t.priority = map[string]string{"🔺": "urgent", "⏫": "high", "🔼": "high", "🔽": "low", "⏬": "low"}[p[1]]
t.content = priorityRe.ReplaceAllString(t.content, "")
}
t.content = strings.TrimSpace(t.content)
tasks = append(tasks, t)
}
return tasks
}
var quantityRe = regexp.MustCompile(`[ \t]*\(([^()]+)\)[ \t]*$`)
// parseListFile reads list entries "- [ ] Milch (2L) #Kühlregal".
func parseListFile(body string) []importItem {
var items []importItem
for _, line := range strings.Split(body, "\n") {
m := listItemRe.FindStringSubmatch(strings.TrimSpace(line))
if m == nil || strings.TrimSpace(m[2]) == "" {
continue
}
it := importItem{content: m[2]}
if c := checkboxRe.FindStringSubmatch(m[2]); c != nil {
it.done, it.content = c[1] != " ", c[2]
}
// A trailing #Category is turned into the category by CreateListItem itself
if q := quantityRe.FindStringSubmatch(stripTrailingTag(it.content)); q != nil {
it.quantity = q[1]
it.content = strings.Replace(it.content, q[0], "", 1)
}
it.content = strings.TrimSpace(it.content)
items = append(items, it)
}
return items
}
func stripTrailingTag(s string) string {
if i := strings.LastIndex(s, " #"); i >= 0 && !strings.Contains(s[i+2:], " ") {
return s[:i]
}
return s
}
// folderPathOf returns the folder of a "/"-separated archive path with every part made safe.
func folderPathOf(p string) string {
dir := path.Dir(p)
if dir == "." || dir == "/" {
return ""
}
parts := make([]string, 0)
for _, part := range strings.Split(dir, "/") {
if seg := segment(part); seg != "" {
parts = append(parts, seg)
}
}
return strings.Join(parts, "/")
}
func sortedKeys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
if k != "" {
out = append(out, k)
}
}
sort.Strings(out)
return out
}
+221
View File
@@ -0,0 +1,221 @@
package storage
import (
"archive/zip"
"bytes"
"encoding/json"
"path/filepath"
"strings"
"testing"
"github.com/sandrodach/mindsift/internal/domain"
)
func newImportStore(t *testing.T) (*Store, *domain.User, *domain.User) {
t.Helper()
store, err := NewStore(filepath.Join(t.TempDir(), "import.db"))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
t.Cleanup(func() { store.Close() })
openSharing(t, store)
anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member")
ben, _ := store.CreateUser("ben@example.com", "hash", "Ben", "member")
return store, anna, ben
}
// seedAnna creates one of everything the import has to bring back.
func seedAnna(t *testing.T, store *Store, anna *domain.User) {
t.Helper()
blocks := []domain.DocumentBlock{
{ID: "b1", Type: "h2", Content: "Warum", Level: 0},
{ID: "b2", Type: "bullet", Content: "Strom sparen #homelab", Level: 1},
{ID: "b3", Type: "todo", Content: "Backup", Checked: true, Level: 2},
{ID: "b4", Type: "code", Content: "docker compose up", Lang: "bash", Level: 2},
}
if _, err := store.CreateDocument(anna.ID, "", "Umzug", "🚀", "Projekte/Server", blocks, nil, false); err != nil {
t.Fatalf("CreateDocument: %v", err)
}
tl, _ := store.CreateTaskList(anna.ID, "", "", "Haushalt", "🏠", "")
task, _ := store.CreateThoughtWithDetails(anna.ID, "Fenster putzen", "urgent", "manual", "2026-11-02", tl.ID, "todo", "Leiter im Keller")
_, _ = store.UpdateThoughtTriage(anna.ID, task.ID, "keep", "task", "", "2026-11-02", tl.ID)
subs := []domain.SubTask{{ID: "s1", Title: "Leiter holen", IsCompleted: true}}
_, _ = store.UpdateThoughtDetails(anna.ID, task.ID, nil, nil, nil, nil, nil, nil, nil, &subs, nil)
_ = store.SetTaskRecurrence(anna.ID, task.ID, "monthly")
list, _ := store.CreateList(anna.ID, "Einkauf", "🛒", "shopping")
item, _ := store.CreateListItem(list.ID, "Milch", "Kühlregal", "2L", 0, anna.ID)
_, _ = store.ToggleListItem(item.ID, true, anna.ID, "Anna")
}
func checkRestored(t *testing.T, store *Store, ben *domain.User) {
t.Helper()
docs, _ := store.GetDocuments(ben.ID)
if len(docs) != 1 || docs[0].Title != "Umzug" || docs[0].FolderPath != "Projekte/Server" || docs[0].Icon != "🚀" {
t.Fatalf("note not restored: %+v", docs)
}
got := []string{}
for _, b := range docs[0].Blocks {
got = append(got, b.Type+"@"+string(rune('0'+b.Level))+":"+b.Content)
}
want := []string{"h2@0:Warum", "bullet@1:Strom sparen #homelab", "todo@2:Backup", "code@2:docker compose up"}
if strings.Join(got, "|") != strings.Join(want, "|") {
t.Errorf("blocks differ:\n got %v\nwant %v", got, want)
}
lists, _ := store.GetTaskLists(ben.ID)
tasks, _ := store.GetTasks(ben.ID, "")
if len(lists) != 1 || lists[0].Title != "Haushalt" || len(tasks) != 1 {
t.Fatalf("task list not restored: %+v / %+v", lists, tasks)
}
tk := tasks[0]
if tk.Content != "Fenster putzen" || tk.Priority != "urgent" || tk.DueDate != "2026-11-02" || tk.Notes != "Leiter im Keller" || tk.Recurrence != "monthly" ||
len(tk.Subtasks) != 1 || !tk.Subtasks[0].IsCompleted || tk.TaskListID != lists[0].ID {
t.Errorf("task not restored: %+v", tk)
}
shop, _ := store.GetAllListsForUser(ben.ID)
if len(shop) != 1 || len(shop[0].Items) != 1 {
t.Fatalf("list not restored: %+v", shop)
}
it := shop[0].Items[0]
if it.Content != "Milch" || it.Quantity != "2L" || it.Category != "Kühlregal" || !it.IsCompleted {
t.Errorf("list item not restored: %+v", it)
}
}
func TestImportMindSiftArchiveRoundTrip(t *testing.T) {
store, anna, ben := newImportStore(t)
seedAnna(t, store, anna)
var buf bytes.Buffer
if err := store.WriteMarkdownVault(anna.ID, &buf); err != nil {
t.Fatalf("export: %v", err)
}
plan, err := PlanMindSiftArchiveImport(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
if err != nil {
t.Fatalf("plan: %v", err)
}
if sum := plan.Summary(false); sum.Notes != 1 || sum.TaskLists != 1 || sum.Tasks != 1 || sum.Lists != 1 || sum.ListItems != 1 {
t.Fatalf("preview counts wrong: %+v", sum)
}
// The preview alone writes nothing
if docs, _ := store.GetDocuments(ben.ID); len(docs) != 0 {
t.Fatalf("preview must not import")
}
if _, err := store.ApplyImport(ben.ID, plan); err != nil {
t.Fatalf("apply: %v", err)
}
checkRestored(t, store, ben)
}
func TestImportMindSiftJSONRoundTrip(t *testing.T) {
store, anna, ben := newImportStore(t)
seedAnna(t, store, anna)
exp, _ := store.ExportUserData(anna.ID)
data, _ := json.Marshal(exp)
plan, err := PlanMindSiftJSONImport(data)
if err != nil {
t.Fatalf("plan: %v", err)
}
if _, err := store.ApplyImport(ben.ID, plan); err != nil {
t.Fatalf("apply: %v", err)
}
checkRestored(t, store, ben)
if _, err := PlanMindSiftJSONImport([]byte(`{"foo":1}`)); err == nil {
t.Errorf("foreign JSON must be rejected")
}
}
func zipOf(t *testing.T, files map[string]string) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for name, body := range files {
w, _ := zw.Create(name)
_, _ = w.Write([]byte(body))
}
_ = zw.Close()
return buf.Bytes()
}
func TestImportObsidianVault(t *testing.T) {
store, _, ben := newImportStore(t)
data := zipOf(t, map[string]string{
"Mein Vault/Notizen/Idee.md": "---\ntags: [projekt, ideen]\naliases:\n - X\n---\n" +
"Ein Absatz mit [[Wikilink]]\nüber zwei Zeilen.\n\n" +
"> [!tip] Wichtig\n> Callout-Text\n\n" +
"## Liste\n- Punkt A\n - Unterpunkt #projekt\n - [x] Erledigt\n- Punkt B\n\n" +
"```go\nfmt.Println(1)\n```\n",
"Mein Vault/Willkommen.md": "# Hallo\nText",
"Mein Vault/bild.png": "PNG",
"Mein Vault/.obsidian/app.json": "{}",
"Mein Vault/../../boese.md": "- ausgebrochen?",
})
plan, err := PlanObsidianImport(bytes.NewReader(data), int64(len(data)), "Mein Vault.zip")
if err != nil {
t.Fatalf("plan: %v", err)
}
sum := plan.Summary(false)
if sum.Notes != 3 || len(sum.Skipped) != 1 || !strings.Contains(sum.Skipped[0].Path, "bild.png") {
t.Fatalf("unexpected preview: %+v", sum)
}
if _, err := store.ApplyImport(ben.ID, plan); err != nil {
t.Fatalf("apply: %v", err)
}
docs, _ := store.GetDocuments(ben.ID)
byTitle := map[string]domain.Document{}
for _, d := range docs {
byTitle[d.Title] = d
if !strings.HasPrefix(d.FolderPath, "Mein Vault") {
t.Errorf("note %q outside the vault folder: %q", d.Title, d.FolderPath)
}
}
idee, ok := byTitle["Idee"]
if !ok || idee.FolderPath != "Mein Vault/Notizen" {
t.Fatalf("Idee not in Mein Vault/Notizen: %+v", byTitle)
}
got := []string{}
for _, b := range idee.Blocks {
got = append(got, b.Type+"@"+string(rune('0'+b.Level))+":"+b.Content)
}
want := []string{
"bullet@0:Ein Absatz mit [[Wikilink]]\nüber zwei Zeilen.",
"callout@0:Wichtig\nCallout-Text",
"h2@0:Liste",
"bullet@1:Punkt A",
"bullet@2:Unterpunkt #projekt",
"todo@2:Erledigt",
"bullet@1:Punkt B",
"code@1:fmt.Println(1)",
"bullet@0:#ideen", // front matter tag not yet in the text
}
if strings.Join(got, "|") != strings.Join(want, "|") {
t.Errorf("blocks differ:\n got %q\nwant %q", got, want)
}
if _, ok := byTitle["boese"]; !ok {
t.Errorf("a ../ path must be imported inside the vault, not dropped silently")
}
if _, err := PlanObsidianImport(bytes.NewReader([]byte("kein zip")), 8, "x.zip"); err == nil {
t.Errorf("invalid archive must be rejected")
}
}
func TestImportBackupZip(t *testing.T) {
store, anna, ben := newImportStore(t)
seedAnna(t, store, anna)
exp, _ := store.ExportUserData(anna.ID)
var buf bytes.Buffer
if err := WriteBackupZip(exp, &buf); err != nil {
t.Fatalf("WriteBackupZip: %v", err)
}
if buf.Len() == 0 {
t.Fatalf("empty archive")
}
plan, err := PlanMindSiftImport(buf.Bytes(), "mindsift-backup-2026-10-29.zip")
if err != nil {
t.Fatalf("plan: %v", err)
}
if _, err := store.ApplyImport(ben.ID, plan); err != nil {
t.Fatalf("apply: %v", err)
}
checkRestored(t, store, ben)
}
+502
View File
@@ -0,0 +1,502 @@
package storage
import (
"database/sql"
"errors"
"regexp"
"strings"
"github.com/sandrodach/mindsift/internal/domain"
)
// --- STANDALONE REALTIME LISTS & SHARING ---
func (s *Store) CreateList(userID, title, icon, listType string) (*domain.SharedList, error) {
if strings.TrimSpace(title) == "" {
return nil, errors.New("list title required")
}
if icon == "" {
icon = "🛒"
}
if listType == "" {
listType = "shopping"
}
now := domain.NowISO()
lst := &domain.SharedList{
ID: domain.NewID("lst"),
UserID: userID,
IsOwner: true,
Role: "editor",
Title: strings.TrimSpace(title),
Icon: icon,
ListType: listType,
Items: []domain.ListItem{},
Shares: []domain.ListShare{},
UpdatedAt: now,
CreatedAt: now,
}
_, err := s.db.Exec(
`INSERT INTO lists (id, user_id, title, icon, list_type, updated_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
lst.ID, lst.UserID, lst.Title, lst.Icon, lst.ListType, lst.UpdatedAt, lst.CreatedAt,
)
if err != nil {
return nil, err
}
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, userID).Scan(&lst.OwnerName)
return lst, nil
}
func (s *Store) AddListShare(currentUserID, listID, targetType, targetID, role string) (*domain.ListShare, error) {
if targetType != "team" && targetType != "user" {
return nil, errors.New("ungültiger Freigabetyp")
}
targetID = strings.TrimSpace(targetID)
if targetID == "" {
return nil, errors.New("Ziel-ID erforderlich")
}
if role != "viewer" && role != "editor" {
role = "editor"
}
if targetType == "user" {
var targetUser *domain.User
if strings.HasPrefix(targetID, "usr_") {
targetUser, _ = s.GetUserByID(targetID)
}
if targetUser == nil {
targetUser, _ = s.GetUserByLogin(targetID)
}
if targetUser == nil {
targetUser, _ = s.GetUserByEmail(targetID)
}
if targetUser == nil {
return nil, ErrTargetNotFound
}
targetID = targetUser.ID
}
// Only the owner shares
var ownerID string
err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID)
if err != nil {
return nil, errors.New("liste nicht gefunden")
}
if ownerID != currentUserID {
return nil, errors.New("nur der Eigentümer kann die Liste freigeben")
}
if !s.CanShareWith(currentUserID, targetType, targetID) {
return nil, ErrTargetNotFound
}
now := domain.NowISO()
share := &domain.ListShare{
ID: domain.NewID("lsh"),
ListID: listID,
TargetType: targetType,
TargetID: targetID,
Role: role,
CreatedBy: currentUserID,
CreatedAt: now,
}
_, err = s.db.Exec(
`INSERT INTO list_shares (id, list_id, target_type, target_id, role, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(list_id, target_type, target_id) DO UPDATE SET role = excluded.role`,
share.ID, share.ListID, share.TargetType, share.TargetID, share.Role, share.CreatedBy, share.CreatedAt,
)
if err != nil {
return nil, err
}
_ = s.db.QueryRow(`SELECT id, created_at FROM list_shares WHERE list_id = ? AND target_type = ? AND target_id = ?`,
listID, targetType, targetID).Scan(&share.ID, &share.CreatedAt)
if targetType == "team" {
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, targetID).Scan(&share.TargetName)
} else {
_ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, targetID).Scan(&share.TargetName, &share.Email)
}
return share, nil
}
func (s *Store) RemoveListShare(currentUserID, listID, shareIDOrTargetID string) error {
var ownerID string
err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID)
if err != nil {
return errors.New("liste nicht gefunden")
}
if ownerID != currentUserID {
return errors.New("nur der Eigentümer kann Freigaben entfernen")
}
cleanTarget := strings.TrimSpace(shareIDOrTargetID)
_, err = s.db.Exec(
`DELETE FROM list_shares WHERE list_id = ? AND (id = ? OR target_id = ?)`,
listID, cleanTarget, cleanTarget,
)
return err
}
func (s *Store) GetListShares(listID string) ([]domain.ListShare, error) {
rows, err := s.db.Query(
`SELECT id, list_id, target_type, target_id, role, created_by, created_at
FROM list_shares WHERE list_id = ? ORDER BY created_at ASC`,
listID,
)
if err != nil {
return []domain.ListShare{}, err
}
defer rows.Close()
shares := make([]domain.ListShare, 0)
for rows.Next() {
var sh domain.ListShare
if err := rows.Scan(&sh.ID, &sh.ListID, &sh.TargetType, &sh.TargetID, &sh.Role, &sh.CreatedBy, &sh.CreatedAt); err == nil {
if sh.TargetType == "team" {
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, sh.TargetID).Scan(&sh.TargetName)
} else {
_ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, sh.TargetID).Scan(&sh.TargetName, &sh.Email)
}
shares = append(shares, sh)
}
}
return shares, nil
}
func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) {
rows, err := s.db.Query(
`SELECT DISTINCT l.id, l.user_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at,
u.display_name,
CASE
WHEN l.user_id = ? THEN 'owner'
WHEN ls.role IS NOT NULL THEN ls.role
ELSE 'editor'
END as effective_role
FROM lists l
LEFT JOIN users u ON u.id = l.user_id
LEFT JOIN list_shares ls ON ls.list_id = l.id AND (
(ls.target_type = 'user' AND ls.target_id = ?)
OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
)
WHERE l.user_id = ?
OR ls.id IS NOT NULL
ORDER BY l.updated_at DESC`,
userID, userID, userID, userID,
)
if err != nil {
return nil, err
}
lists := make([]domain.SharedList, 0)
for rows.Next() {
var l domain.SharedList
var ownerName sql.NullString
var effectiveRole string
if err := rows.Scan(&l.ID, &l.UserID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt, &ownerName, &effectiveRole); err == nil {
l.OwnerName = ownerName.String
l.IsOwner = (l.UserID == userID)
l.Role = effectiveRole
lists = append(lists, l)
}
}
_ = rows.Close()
for i := range lists {
lists[i].Items, _ = s.GetListItems(lists[i].ID)
lists[i].Shares, _ = s.GetListShares(lists[i].ID)
lists[i].Members = s.getListMembers(lists[i].ID, lists[i].UserID)
}
return lists, nil
}
func (s *Store) GetListByID(userID, listID string) (*domain.SharedList, error) {
var l domain.SharedList
var ownerName sql.NullString
var effectiveRole string
var query string
var args []interface{}
if userID == "" {
query = `SELECT l.id, l.user_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at,
u.display_name, 'owner' as effective_role
FROM lists l
LEFT JOIN users u ON u.id = l.user_id
WHERE l.id = ?`
args = []interface{}{listID}
} else {
query = `SELECT l.id, l.user_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at,
u.display_name,
CASE
WHEN l.user_id = ? THEN 'owner'
WHEN ls.role IS NOT NULL THEN ls.role
ELSE 'editor'
END as effective_role
FROM lists l
LEFT JOIN users u ON u.id = l.user_id
LEFT JOIN list_shares ls ON ls.list_id = l.id AND (
(ls.target_type = 'user' AND ls.target_id = ?)
OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
)
WHERE l.id = ? AND (
l.user_id = ?
OR ls.id IS NOT NULL
)`
args = []interface{}{userID, userID, userID, listID, userID}
}
err := s.db.QueryRow(query, args...).Scan(&l.ID, &l.UserID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt, &ownerName, &effectiveRole)
if err != nil {
return nil, err
}
l.OwnerName = ownerName.String
l.IsOwner = (userID == "" || l.UserID == userID)
l.Role = effectiveRole
l.Items, _ = s.GetListItems(l.ID)
l.Shares, _ = s.GetListShares(l.ID)
l.Members = s.getListMembers(l.ID, l.UserID)
return &l, nil
}
// getListMembers returns the owner and everyone a list is shared with (directly or via a team).
func (s *Store) getListMembers(listID, ownerID string) []domain.TaskListMember {
members := make([]domain.TaskListMember, 0)
rows, err := s.db.Query(
`SELECT id, display_name, email, avatar_url FROM users WHERE id IN (
SELECT user_id FROM lists WHERE id = ?
UNION SELECT target_id FROM list_shares WHERE list_id = ? AND target_type = 'user'
UNION SELECT user_id FROM team_members WHERE team_id IN (SELECT target_id FROM list_shares WHERE list_id = ? AND target_type = 'team')
) ORDER BY display_name`,
listID, listID, listID,
)
if err != nil {
return members
}
defer rows.Close()
for rows.Next() {
var m domain.TaskListMember
if rows.Scan(&m.UserID, &m.DisplayName, &m.Email, &m.AvatarURL) == nil {
m.Role = "member"
if m.UserID == ownerID {
m.Role = "owner"
}
members = append(members, m)
}
}
return members
}
func (s *Store) DeleteList(userID, listID string) error {
var ownerID string
err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID)
if err != nil {
return errors.New("liste nicht gefunden")
}
if ownerID != userID {
return errors.New("nur der Eigentümer kann die Liste löschen")
}
_, err = s.db.Exec(`DELETE FROM lists WHERE id = ?`, listID)
return err
}
func (s *Store) GetListItems(listID string) ([]domain.ListItem, error) {
rows, err := s.db.Query(
`SELECT li.id, li.list_id, li.content, li.category, li.quantity, li.is_completed, li.completed_by, li.completed_by_name,
li.created_by, COALESCE(u.display_name, ''), li.sort_order, li.created_at, li.completed_at
FROM list_items li LEFT JOIN users u ON u.id = li.created_by AND li.created_by != ''
WHERE li.list_id = ?
ORDER BY li.is_completed ASC, li.sort_order ASC, li.created_at DESC`,
listID,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]domain.ListItem, 0)
for rows.Next() {
var it domain.ListItem
var doneInt int
if err := rows.Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &doneInt, &it.CompletedBy, &it.CompletedByName, &it.CreatedBy, &it.CreatedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt); err == nil {
it.IsCompleted = doneInt != 0
items = append(items, it)
}
}
return items, nil
}
func (s *Store) CreateListItem(listID, content, category, quantity string, sortOrder int, createdBy string) (*domain.ListItem, error) {
content = strings.TrimSpace(content)
if content == "" {
return nil, errors.New("item content cannot be empty")
}
category = strings.TrimSpace(category)
// Auto-extract hashtag if category is empty
if category == "" && strings.Contains(content, "#") {
re := regexp.MustCompile(`#([a-zA-Z0-9äöüÄÖÜß_-]+)`)
if m := re.FindStringSubmatch(content); len(m) > 1 {
category = m[1]
cleaned := strings.TrimSpace(re.ReplaceAllString(content, ""))
if cleaned != "" {
content = cleaned
}
}
} else if category != "" {
category = strings.TrimPrefix(category, "#")
}
now := domain.NowISO()
it := &domain.ListItem{
ID: domain.NewID("itm"),
ListID: listID,
Content: content,
Category: category,
Quantity: strings.TrimSpace(quantity),
IsCompleted: false,
SortOrder: sortOrder,
CreatedBy: createdBy,
CreatedAt: now,
}
if createdBy != "" {
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, createdBy).Scan(&it.CreatedByName)
}
_, err := s.db.Exec(
`INSERT INTO list_items (id, list_id, content, category, quantity, is_completed, sort_order, created_at, created_by)
VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?)`,
it.ID, it.ListID, it.Content, it.Category, it.Quantity, it.SortOrder, it.CreatedAt, it.CreatedBy,
)
if err != nil {
return nil, err
}
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, now, listID)
return it, nil
}
func (s *Store) ToggleListItem(itemID string, isCompleted bool, userID, userName string) (*domain.ListItem, error) {
now := domain.NowISO()
doneInt := 0
completedAt := ""
completedBy := ""
completedByName := ""
if isCompleted {
doneInt = 1
completedAt = now
completedBy = userID
completedByName = userName
}
_, err := s.db.Exec(
`UPDATE list_items SET is_completed = ?, completed_by = ?, completed_by_name = ?, completed_at = ? WHERE id = ?`,
doneInt, completedBy, completedByName, completedAt, itemID,
)
if err != nil {
return nil, err
}
var it domain.ListItem
var dInt int
err = s.db.QueryRow(
`SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at
FROM list_items WHERE id = ?`,
itemID,
).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt)
if err != nil {
return nil, err
}
it.IsCompleted = dInt != 0
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, now, it.ListID)
return &it, nil
}
func (s *Store) UpdateListItem(itemID string, content *string, category *string, quantity *string) (*domain.ListItem, error) {
now := domain.NowISO()
var it domain.ListItem
var dInt int
err := s.db.QueryRow(
`SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at
FROM list_items WHERE id = ?`,
itemID,
).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt)
if err != nil {
return nil, err
}
it.IsCompleted = dInt != 0
if content != nil {
it.Content = *content
}
if category != nil {
it.Category = *category
}
if quantity != nil {
it.Quantity = *quantity
}
_, err = s.db.Exec(`UPDATE list_items SET content = ?, category = ?, quantity = ? WHERE id = ?`, it.Content, it.Category, it.Quantity, itemID)
if err != nil {
return nil, err
}
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, now, it.ListID)
return &it, nil
}
func (s *Store) DeleteListItem(itemID string) (string, error) {
var listID string
_ = s.db.QueryRow(`SELECT list_id FROM list_items WHERE id = ?`, itemID).Scan(&listID)
_, err := s.db.Exec(`DELETE FROM list_items WHERE id = ?`, itemID)
if listID != "" {
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, domain.NowISO(), listID)
}
return listID, err
}
func (s *Store) ClearCompletedListItems(listID string) (int64, error) {
res, err := s.db.Exec(`DELETE FROM list_items WHERE list_id = ? AND is_completed = 1`, listID)
if err != nil {
return 0, err
}
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, domain.NowISO(), listID)
return res.RowsAffected()
}
func (s *Store) ReorderListItems(listID string, items []domain.ListItemReorderItem) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, it := range items {
if it.Category != nil && it.Content != nil {
_, err = tx.Exec(
`UPDATE list_items SET sort_order = ?, category = ?, content = ? WHERE id = ? AND list_id = ?`,
it.SortOrder, *it.Category, *it.Content, it.ID, listID,
)
} else if it.Category != nil {
_, err = tx.Exec(
`UPDATE list_items SET sort_order = ?, category = ? WHERE id = ? AND list_id = ?`,
it.SortOrder, *it.Category, it.ID, listID,
)
} else if it.Content != nil {
_, err = tx.Exec(
`UPDATE list_items SET sort_order = ?, content = ? WHERE id = ? AND list_id = ?`,
it.SortOrder, *it.Content, it.ID, listID,
)
} else {
_, err = tx.Exec(
`UPDATE list_items SET sort_order = ? WHERE id = ? AND list_id = ?`,
it.SortOrder, it.ID, listID,
)
}
if err != nil {
return err
}
}
_, _ = tx.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, domain.NowISO(), listID)
return tx.Commit()
}
+89
View File
@@ -0,0 +1,89 @@
package storage
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"time"
)
// Password reset: the user gets a random link token by mail. Only its SHA-256 hash is stored, so a
// leaked database does not contain usable links. A token works once and for one hour; a new password
// also ends all sessions (token_version).
const (
resetTokenTTL = time.Hour
resetTokenCooldown = 2 * time.Minute // no new mail for the same account within this time
)
var (
ErrResetTokenInvalid = errors.New("Der Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an.")
ErrResetTooSoon = errors.New("reset requested again too soon")
)
func (s *Store) migratePasswordReset() error {
_, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS password_reset_tokens (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT NOT NULL DEFAULT ''
)`)
return err
}
func hashResetToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
// CreatePasswordResetToken returns a new raw token for the user, or ErrResetTooSoon while the last
// one is younger than the cooldown.
func (s *Store) CreatePasswordResetToken(userID string) (string, error) {
now := time.Now().UTC()
var last sql.NullString
_ = s.db.QueryRow(`SELECT MAX(created_at) FROM password_reset_tokens WHERE user_id = ?`, userID).Scan(&last)
if last.Valid {
if t, err := time.Parse(time.RFC3339, last.String); err == nil && now.Sub(t) < resetTokenCooldown {
return "", ErrResetTooSoon
}
}
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
raw := hex.EncodeToString(buf)
// Expired and used tokens are of no use any more
_, _ = s.db.Exec(`DELETE FROM password_reset_tokens WHERE expires_at < ? OR used_at != ''`, now.Format(time.RFC3339))
_, err := s.db.Exec(`INSERT INTO password_reset_tokens (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)`,
hashResetToken(raw), userID, now.Format(time.RFC3339), now.Add(resetTokenTTL).Format(time.RFC3339))
return raw, err
}
// ResetPasswordWithToken sets a new password hash if the token is valid and returns the user ID.
// All other open tokens of the user become invalid as well.
func (s *Store) ResetPasswordWithToken(raw, passwordHash string) (string, error) {
var userID, expires, used string
var active bool
err := s.db.QueryRow(`SELECT t.user_id, t.expires_at, t.used_at, u.is_active FROM password_reset_tokens t
JOIN users u ON u.id = t.user_id WHERE t.token_hash = ?`, hashResetToken(raw)).Scan(&userID, &expires, &used, &active)
if err != nil {
return "", ErrResetTokenInvalid
}
exp, perr := time.Parse(time.RFC3339, expires)
if used != "" || !active || perr != nil || time.Now().UTC().After(exp) {
return "", ErrResetTokenInvalid
}
now := time.Now().UTC().Format(time.RFC3339)
res, err := s.db.Exec(`UPDATE password_reset_tokens SET used_at = ? WHERE token_hash = ? AND used_at = ''`, now, hashResetToken(raw))
if err != nil {
return "", err
}
if n, _ := res.RowsAffected(); n == 0 {
return "", ErrResetTokenInvalid // used in parallel
}
_, _ = s.db.Exec(`UPDATE password_reset_tokens SET used_at = ? WHERE user_id = ? AND used_at = ''`, now, userID)
return userID, s.UpdateUserPassword(userID, passwordHash)
}
+51
View File
@@ -0,0 +1,51 @@
package storage
import (
"path/filepath"
"testing"
)
func TestPasswordResetToken(t *testing.T) {
store, err := NewStore(filepath.Join(t.TempDir(), "reset.db"))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
defer store.Close()
u, _ := store.CreateUser("anna@example.com", "old", "Anna", "member")
raw, err := store.CreatePasswordResetToken(u.ID)
if err != nil || len(raw) != 64 {
t.Fatalf("token: %q %v", raw, err)
}
var stored string
_ = store.db.QueryRow(`SELECT token_hash FROM password_reset_tokens`).Scan(&stored)
if stored == raw {
t.Fatalf("only the hash may be stored")
}
if _, err := store.CreatePasswordResetToken(u.ID); err != ErrResetTooSoon {
t.Errorf("second request within the cooldown must be refused, got %v", err)
}
before, _ := store.GetUserByID(u.ID)
if _, err := store.ResetPasswordWithToken("falsch", "new"); err != ErrResetTokenInvalid {
t.Errorf("unknown token must fail")
}
if id, err := store.ResetPasswordWithToken(raw, "new"); err != nil || id != u.ID {
t.Fatalf("reset: %v", err)
}
after, _ := store.GetUserByID(u.ID)
if after.PasswordHash != "new" || after.TokenVersion != before.TokenVersion+1 {
t.Errorf("password not changed or sessions not ended: %+v", after)
}
if _, err := store.ResetPasswordWithToken(raw, "again"); err != ErrResetTokenInvalid {
t.Errorf("a token works only once")
}
// Expired tokens fail
_, _ = store.db.Exec(`DELETE FROM password_reset_tokens`)
raw2, _ := store.CreatePasswordResetToken(u.ID)
_, _ = store.db.Exec(`UPDATE password_reset_tokens SET expires_at = '2000-01-01T00:00:00Z'`)
if _, err := store.ResetPasswordWithToken(raw2, "x"); err != ErrResetTokenInvalid {
t.Errorf("expired token must fail")
}
}
+134
View File
@@ -0,0 +1,134 @@
package storage
import (
"errors"
"time"
"github.com/sandrodach/mindsift/internal/domain"
)
// ErrInvalidRecurrence is returned for unknown repeat rules.
var ErrInvalidRecurrence = errors.New("Unbekannte Wiederholung (erlaubt: täglich, wöchentlich, monatlich, jährlich)")
func validRecurrence(r string) bool {
switch r {
case "", "daily", "weekly", "monthly", "yearly":
return true
}
return false
}
// NextDueDate moves a due date (YYYY-MM-DD or YYYY-MM-DDTHH:MM, today when empty) one interval forward
// and keeps the time. Months and years keep the day where possible and fall back to the month's last
// day (31 Jan -> 28/29 Feb).
func NextDueDate(due, rule string, today time.Time) string {
parsed, hasTime, ok := domain.ParseDue(due, time.UTC)
base := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.UTC)
if ok {
base = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, time.UTC)
}
switch rule {
case "daily":
base = base.AddDate(0, 0, 1)
case "weekly":
base = base.AddDate(0, 0, 7)
case "monthly":
base = addMonthsClamped(base, 1)
case "yearly":
base = addMonthsClamped(base, 12)
}
if hasTime {
return base.Format("2006-01-02") + parsed.Format("T15:04")
}
return base.Format("2006-01-02")
}
func addMonthsClamped(t time.Time, months int) time.Time {
first := time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC).AddDate(0, months, 0)
last := first.AddDate(0, 1, -1).Day()
day := t.Day()
if day > last {
day = last
}
return time.Date(first.Year(), first.Month(), day, 0, 0, 0, 0, time.UTC)
}
// SetTaskRecurrence changes the repeat rule of a task (editors of its list or its owner).
func (s *Store) SetTaskRecurrence(userID, taskID, rule string) error {
if !validRecurrence(rule) {
return ErrInvalidRecurrence
}
t, err := s.GetThoughtByID(userID, taskID)
if err != nil {
return err
}
if err := s.ensureTaskListWritable(userID, t.TaskListID); err != nil {
return err
}
_, err = s.db.Exec(`UPDATE thoughts SET recurrence = ? WHERE id = ?`, rule, taskID)
return err
}
func (s *Store) getThoughtRaw(id string) (*domain.Thought, error) {
t, err := scanThought(s.db.QueryRow(
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
FROM thoughts WHERE id = ?`, id))
if err != nil {
return nil, err
}
t.Assignees = s.getTaskAssignees(t.ID)
return t, nil
}
// spawnNextOccurrence creates the follow-up of a repeating task that was just completed: same title,
// tags, priority, notes, list, assignees and rule, subtasks unchecked, due date one interval later,
// in the list's first open column. Each task spawns at most once, so re-opening and completing it
// again creates no duplicate. The new task goes to OnTaskCreated (live update for all clients).
func (s *Store) spawnNextOccurrence(taskID string) {
t, err := s.getThoughtRaw(taskID)
if err != nil || t.Recurrence == "" || !t.IsCompleted {
return
}
res, err := s.db.Exec(`UPDATE thoughts SET recurrence_spawned = 1 WHERE id = ? AND recurrence_spawned = 0`, taskID)
if err != nil {
return
}
if n, _ := res.RowsAffected(); n == 0 {
return // already spawned its successor
}
column := "todo"
if t.TaskListID != "" {
if tl, err := s.GetTaskListByID(t.UserID, t.TaskListID); err == nil {
for _, c := range tl.Columns {
if !c.IsDone {
column = c.ID
break
}
}
}
}
due := NextDueDate(t.DueDate, t.Recurrence, time.Now())
next, err := s.CreateThoughtWithDetails(t.UserID, t.Content, t.Priority, t.Source, due, t.TaskListID, column, t.Notes)
if err != nil {
return
}
if _, err := s.UpdateThoughtTriage(t.UserID, next.ID, "keep", "task", "", due, t.TaskListID); err != nil {
return
}
subs := make([]domain.SubTask, len(t.Subtasks))
for i, st := range t.Subtasks {
subs[i] = domain.SubTask{ID: domain.NewID("st"), Title: st.Title, CreatedAt: domain.NowISO()}
}
tags := t.Tags
_, _ = s.UpdateThoughtDetails(t.UserID, next.ID, nil, nil, nil, nil, nil, &column, nil, &subs, &tags)
_, _ = s.db.Exec(`UPDATE thoughts SET recurrence = ?, sort_order = ? WHERE id = ?`, t.Recurrence, t.SortOrder, next.ID)
for _, uid := range t.Assignees {
_, _ = s.db.Exec(`INSERT OR IGNORE INTO task_assignees (task_id, user_id, created_at) VALUES (?, ?, ?)`, next.ID, uid, domain.NowISO())
}
s.PruneTaskAssignees()
if created, err := s.getThoughtRaw(next.ID); err == nil && s.OnTaskCreated != nil {
s.OnTaskCreated(created)
}
}
+140
View File
@@ -0,0 +1,140 @@
package storage
import (
"path/filepath"
"testing"
"time"
"github.com/sandrodach/mindsift/internal/domain"
)
func TestNextDueDate(t *testing.T) {
today := time.Date(2026, 10, 29, 15, 0, 0, 0, time.UTC)
cases := []struct{ due, rule, want string }{
{"2026-10-30", "daily", "2026-10-31"},
{"2026-10-30", "weekly", "2026-11-06"},
{"2026-01-31", "monthly", "2026-02-28"},
{"2026-03-15", "monthly", "2026-04-15"},
{"2028-02-29", "yearly", "2029-02-28"},
{"", "weekly", "2026-11-05"},
{"2026-01-31T07:15", "monthly", "2026-02-28T07:15"},
{"2026-10-30T23:30", "daily", "2026-10-31T23:30"},
}
for _, c := range cases {
if got := NextDueDate(c.due, c.rule, today); got != c.want {
t.Errorf("NextDueDate(%q, %s) = %s, want %s", c.due, c.rule, got, c.want)
}
}
}
func TestRepeatingTaskSpawnsItsSuccessorOnce(t *testing.T) {
store, err := NewStore(filepath.Join(t.TempDir(), "recur.db"))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
defer store.Close()
openSharing(t, store)
anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member")
ben, _ := store.CreateUser("ben@example.com", "hash", "Ben", "member")
tl, _ := store.CreateTaskList(anna.ID, "", "", "Haushalt", "", "")
_, _ = store.AddTaskListShare(tl.ID, "user", ben.ID, "editor", anna.ID)
var pushed []*domain.Thought
store.OnTaskCreated = func(th *domain.Thought) { pushed = append(pushed, th) }
task, _ := store.CreateThoughtWithDetails(anna.ID, "Müll rausbringen #haushalt", "high", "manual", "2026-11-02", tl.ID, "todo", "Gelbe Tonne")
_, _ = store.UpdateThoughtTriage(anna.ID, task.ID, "keep", "task", "", "2026-11-02", tl.ID)
subs := []domain.SubTask{{ID: "s1", Title: "Tüten", IsCompleted: true}}
_, _ = store.UpdateThoughtDetails(anna.ID, task.ID, nil, nil, nil, nil, nil, nil, nil, &subs, nil)
_, _ = store.SetTaskAssignees(anna.ID, task.ID, []string{ben.ID})
if err := store.SetTaskRecurrence(anna.ID, task.ID, "weekly"); err != nil {
t.Fatalf("SetTaskRecurrence: %v", err)
}
if err := store.SetTaskRecurrence(anna.ID, task.ID, "stündlich"); err == nil {
t.Errorf("unknown rule must be rejected")
}
done := true
if _, err := store.UpdateThoughtDetails(ben.ID, task.ID, nil, &done, nil, nil, nil, nil, nil, nil, nil); err != nil {
t.Fatalf("complete: %v", err)
}
if len(pushed) != 1 {
t.Fatalf("expected one successor pushed live, got %d", len(pushed))
}
next := pushed[0]
if next.Content != "Müll rausbringen #haushalt" || next.Priority != "high" || next.Notes != "Gelbe Tonne" ||
next.DueDate != "2026-11-09" || next.Recurrence != "weekly" || next.IsCompleted || next.TaskListID != tl.ID ||
next.KanbanStatus != "todo" || len(next.Subtasks) != 1 || next.Subtasks[0].IsCompleted ||
len(next.Assignees) != 1 || next.Assignees[0] != ben.ID || next.UserID != anna.ID {
t.Errorf("successor wrong: %+v", next)
}
if orig, _ := store.GetThoughtByID(anna.ID, task.ID); !orig.IsCompleted {
t.Errorf("the completed task stays completed for the history")
}
// Re-opening and completing again creates no duplicate
open := false
_, _ = store.UpdateThoughtDetails(anna.ID, task.ID, nil, &open, nil, nil, nil, nil, nil, nil, nil)
_, _ = store.UpdateThoughtDetails(anna.ID, task.ID, nil, &done, nil, nil, nil, nil, nil, nil, nil)
if len(pushed) != 1 {
t.Fatalf("re-completing must not spawn again, got %d successors", len(pushed))
}
// Dragging the successor into "done" on the Kanban board repeats as well
status := "done"
if err := store.ReorderTasks(tl.ID, []domain.ThoughtReorderItem{{ID: next.ID, SortOrder: 0, KanbanStatus: &status}}); err != nil {
t.Fatalf("ReorderTasks: %v", err)
}
if len(pushed) != 2 || pushed[1].DueDate != "2026-11-16" {
t.Fatalf("Kanban completion should spawn the next week, got %+v", pushed)
}
// A task without rule never repeats
plain, _ := store.CreateThoughtWithDetails(anna.ID, "Einmalig", "normal", "manual", "", tl.ID, "todo", "")
_, _ = store.UpdateThoughtTriage(anna.ID, plain.ID, "keep", "task", "", "", tl.ID)
_, _ = store.UpdateThoughtDetails(anna.ID, plain.ID, nil, &done, nil, nil, nil, nil, nil, nil, nil)
if len(pushed) != 2 {
t.Errorf("non-repeating task must not spawn")
}
}
func TestAlarmResetsOnNewDueDateOrReopen(t *testing.T) {
store, err := NewStore(filepath.Join(t.TempDir(), "alarm.db"))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
defer store.Close()
u, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member")
task, _ := store.CreateThoughtWithDetails(u.ID, "Zahnarzt", "normal", "manual", "2026-10-30 14:30", "", "todo", "")
_, _ = store.UpdateThoughtTriage(u.ID, task.ID, "keep", "task", "", "", "")
sent := func() bool {
th, _ := store.GetThoughtByID(u.ID, task.ID)
return th.AlarmSent
}
if th, _ := store.GetThoughtByID(u.ID, task.ID); th.DueDate != "2026-10-30T14:30" {
t.Fatalf("due date not normalized: %q", th.DueDate)
}
_ = store.MarkTaskAlarmSent(task.ID)
same := "2026-10-30T14:30"
_, _ = store.UpdateThoughtDetails(u.ID, task.ID, nil, nil, &same, nil, nil, nil, nil, nil, nil)
if !sent() {
t.Errorf("unchanged due date must not re-arm the alarm")
}
later := "2026-10-31T08:00"
_, _ = store.UpdateThoughtDetails(u.ID, task.ID, nil, nil, &later, nil, nil, nil, nil, nil, nil)
if sent() {
t.Errorf("new due date must re-arm the alarm")
}
_ = store.MarkTaskAlarmSent(task.ID)
done, open := true, false
_, _ = store.UpdateThoughtDetails(u.ID, task.ID, nil, &done, nil, nil, nil, nil, nil, nil, nil)
if !sent() {
t.Errorf("completing keeps the flag")
}
_, _ = store.UpdateThoughtDetails(u.ID, task.ID, nil, &open, nil, nil, nil, nil, nil, nil, nil)
if sent() {
t.Errorf("reopening must re-arm the alarm")
}
}
+18 -4563
View File
File diff suppressed because it is too large. Load diff
+9 -7
View File
@@ -429,14 +429,16 @@ func TestFolderManagementAndDeletion(t *testing.T) {
t.Fatalf("expected 2 deleted docs, got %d (%v)", len(deletedDocs), deletedDocs)
}
// Verify docs are gone from DB
_, err = store.GetDocumentByID(user.ID, doc1.ID)
if err == nil {
t.Fatalf("expected doc1 to be deleted")
// The notes are in the trash now, no longer in the tree
trash, _ := store.GetTrash(user.ID)
if len(trash) != 2 {
t.Fatalf("expected doc1 and doc2 in the trash, got %v", trash)
}
_, err = store.GetDocumentByID(user.ID, doc2.ID)
if err == nil {
t.Fatalf("expected doc2 to be deleted")
docs, _ := store.GetDocuments(user.ID)
for _, d := range docs {
if d.ID == doc1.ID || d.ID == doc2.ID {
t.Fatalf("trashed note %s still in the tree", d.ID)
}
}
}
+208
View File
@@ -0,0 +1,208 @@
package storage
import (
"encoding/json"
"strings"
"github.com/sandrodach/mindsift/internal/domain"
)
// --- STATS, SEARCH & EXPORT ---
func (s *Store) GetDashboardStats(userID string) (*domain.DashboardStats, error) {
st := &domain.DashboardStats{}
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND status = 'inbox'`, userID).Scan(&st.InboxCount)
// Same tasks as GetTasks without a list ("Alle Aufgaben"): own ones plus those in shared task lists
taskScope := `(user_id = ? OR task_list_id IN (` + s.accessibleTaskListIDsQuery() + `)) AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))`
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE `+taskScope+` AND is_completed = 0`, userID, userID, userID, userID).Scan(&st.TasksOpenCount)
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE `+taskScope+` AND is_completed = 1`, userID, userID, userID, userID).Scan(&st.TasksDoneCount)
_ = s.db.QueryRow(`SELECT COUNT(*) FROM documents WHERE user_id = ? AND is_archived = 0`, userID).Scan(&st.DocsCount)
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND status = 'discarded'`, userID).Scan(&st.DiscardedCount)
_ = s.db.QueryRow(`
SELECT COUNT(DISTINCT l.id)
FROM lists l
LEFT JOIN list_shares ls ON ls.list_id = l.id AND (
(ls.target_type = 'user' AND ls.target_id = ?)
OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
)
WHERE l.user_id = ? OR ls.id IS NOT NULL`,
userID, userID, userID).Scan(&st.ListsCount)
// Count open (uncompleted) To-Dos across accessible workspace documents (owned or shared)
docRows, err := s.db.Query(`
SELECT blocks_json FROM documents
WHERE (user_id = ? OR id IN (SELECT doc_id FROM doc_shares WHERE user_id = ?))
AND is_archived = 0`,
userID, userID,
)
if err == nil {
for docRows.Next() {
var bj string
if err := docRows.Scan(&bj); err == nil && bj != "" {
var blocks []domain.DocumentBlock
if err := json.Unmarshal([]byte(bj), &blocks); err == nil {
for _, b := range blocks {
if b.Type == "todo" && !b.Checked {
st.DocsTodosOpenCount++
}
}
}
}
}
_ = docRows.Close()
}
return st, nil
}
func (s *Store) SearchAll(userID, query string) ([]domain.Thought, []domain.Document, error) {
res, err := s.SearchGlobal(userID, query)
if err != nil {
return nil, nil, err
}
return res.Thoughts, res.Documents, nil
}
func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) {
user, err := s.GetUserByID(userID)
if err != nil {
return nil, err
}
thoughts, _ := s.GetThoughts(userID, "all", 5000)
docs, _ := s.GetDocuments(userID)
lists, _ := s.GetAllListsForUser(userID)
taskLists, _ := s.GetTaskLists(userID)
return &domain.FullExport{
ExportedAt: domain.NowISO(),
Version: "1.0.0",
User: *user,
Thoughts: thoughts,
Documents: docs,
Lists: lists,
TaskLists: taskLists,
}, nil
}
// --- SYSTEM SETTINGS ---
func (s *Store) SeedSMTPDefaultsFromEnv(host, port, user, pass, from string, tls, skipVerify bool) {
cur, err := s.GetSystemSettings()
if err != nil || cur == nil {
return
}
// Only populate DB from env if DB has no SMTP host configured yet and env provides one
if cur.SMTPHost == "" && strings.TrimSpace(host) != "" {
cur.SMTPHost = strings.TrimSpace(host)
if strings.TrimSpace(port) != "" {
cur.SMTPPort = strings.TrimSpace(port)
}
cur.SMTPUser = strings.TrimSpace(user)
cur.SMTPPass = pass
cur.SMTPFrom = strings.TrimSpace(from)
cur.SMTPTLS = tls
cur.SMTPSkipVerify = skipVerify
_ = s.UpdateSystemSettings(cur)
}
}
func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) {
settings := &domain.SystemSettings{
SharingMode: SharingOrganizations,
AllowRegistration: false,
InstanceName: "MindSift",
SMTPPort: "587",
SMTPTLS: true,
}
rows, err := s.db.Query(`SELECT key, value FROM system_settings`)
if err != nil {
return settings, nil
}
defer rows.Close()
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err == nil {
switch k {
case "sharing_mode":
settings.SharingMode = v
case "allow_registration":
settings.AllowRegistration = (v == "true" || v == "1")
case "instance_name":
settings.InstanceName = v
case "smtp_host":
settings.SMTPHost = v
case "smtp_port":
if v != "" {
settings.SMTPPort = v
}
case "smtp_user":
settings.SMTPUser = v
case "smtp_pass":
settings.SMTPPass = v
case "smtp_from":
settings.SMTPFrom = v
case "smtp_tls":
settings.SMTPTLS = (v != "false" && v != "0")
case "smtp_skip_verify":
settings.SMTPSkipVerify = v == "true"
case "allow_private_webhooks":
settings.AllowPrivateWebhooks = v == "true"
}
}
}
return settings, nil
}
func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error {
regStr := "false"
if settings.AllowRegistration {
regStr = "true"
}
tlsStr := "false"
if settings.SMTPTLS {
tlsStr = "true"
}
skipVerifyStr := "false"
if settings.SMTPSkipVerify {
skipVerifyStr = "true"
}
privateHooksStr := "false"
if settings.AllowPrivateWebhooks {
privateHooksStr = "true"
}
if settings.SMTPPort == "" {
settings.SMTPPort = "587"
}
settings.SharingMode = NormalizeSharingMode(settings.SharingMode)
if settings.InstanceName == "" {
settings.InstanceName = "MindSift"
}
_, err := s.db.Exec(`INSERT INTO system_settings (key, value) VALUES
('sharing_mode', ?),
('allow_registration', ?),
('instance_name', ?),
('smtp_host', ?),
('smtp_port', ?),
('smtp_user', ?),
('smtp_pass', ?),
('smtp_from', ?),
('smtp_tls', ?),
('smtp_skip_verify', ?),
('allow_private_webhooks', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
settings.SharingMode,
regStr,
settings.InstanceName,
strings.TrimSpace(settings.SMTPHost),
strings.TrimSpace(settings.SMTPPort),
strings.TrimSpace(settings.SMTPUser),
settings.SMTPPass,
strings.TrimSpace(settings.SMTPFrom),
tlsStr,
skipVerifyStr,
privateHooksStr,
)
return err
}
+242
View File
@@ -0,0 +1,242 @@
package storage
import (
"errors"
"strings"
"github.com/sandrodach/mindsift/internal/domain"
)
func (s *Store) UpdateTagPageNote(userID, tag, content string) (string, error) {
cleanTag := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(tag), "#"))
if cleanTag == "" {
return "", errors.New("tag name required")
}
now := domain.NowISO()
_, err := s.db.Exec(
`INSERT INTO tag_pages (user_id, tag, content, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, tag) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at`,
userID, cleanTag, content, now,
)
if err != nil {
return "", err
}
return now, nil
}
func (s *Store) GetWorkspaceTags(userID string) ([]domain.TagSummary, error) {
rows, err := s.db.Query(
`SELECT t.tag, COUNT(DISTINCT t.document_id || ':' || t.block_id) as count
FROM block_tags t
JOIN documents d ON t.document_id = d.id
LEFT JOIN doc_shares s ON s.doc_id = d.id AND s.user_id = ?
LEFT JOIN (
SELECT dts2.doc_id
FROM doc_team_shares dts2
JOIN team_members tm ON tm.team_id = dts2.team_id
WHERE tm.user_id = ?
) dts ON d.id = dts.doc_id
LEFT JOIN workspace_shared_folders wsf ON d.shared_folder_id = wsf.id
LEFT JOIN (
SELECT wfs2.folder_id
FROM workspace_folder_shares wfs2
WHERE (wfs2.target_type = 'user' AND wfs2.target_id = ?)
OR (wfs2.target_type = 'team' AND wfs2.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
) wfs ON d.shared_folder_id = wfs.folder_id
WHERE (d.user_id = ? OR s.user_id = ? OR dts.doc_id IS NOT NULL OR (wsf.id IS NOT NULL AND (wsf.owner_id = ? OR wfs.folder_id IS NOT NULL)))
AND d.is_archived = 0
GROUP BY t.tag
ORDER BY count DESC, t.tag ASC`,
userID, userID, userID, userID, userID, userID, userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
results := make([]domain.TagSummary, 0)
seen := make(map[string]bool)
for rows.Next() {
var ts domain.TagSummary
if err := rows.Scan(&ts.Tag, &ts.Count); err == nil {
results = append(results, ts)
seen[ts.Tag] = true
}
}
_ = rows.Close()
// Also include tags that have a non-empty tag_pages note even if not currently used in a block
pageRows, err := s.db.Query(
`SELECT tag FROM tag_pages WHERE user_id = ? AND TRIM(content) != '' ORDER BY tag ASC`,
userID,
)
if err == nil {
for pageRows.Next() {
var t string
if err := pageRows.Scan(&t); err == nil && t != "" && !seen[t] {
results = append(results, domain.TagSummary{Tag: t, Count: 0})
seen[t] = true
}
}
_ = pageRows.Close()
}
return results, nil
}
func (s *Store) GetTagDetails(userID, tag string) (*domain.TagDetails, error) {
cleanTag := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(tag), "#"))
if cleanTag == "" {
return &domain.TagDetails{Tag: "", Documents: []domain.TagDocumentGroup{}, TotalMatches: 0}, nil
}
var noteContent, noteUpdatedAt string
_ = s.db.QueryRow(
`SELECT content, updated_at FROM tag_pages WHERE user_id = ? AND tag = ?`,
userID, cleanTag,
).Scan(&noteContent, &noteUpdatedAt)
rows, err := s.db.Query(
`SELECT t.block_id, d.id, d.title, d.icon, d.folder_path, d.blocks_json, d.updated_at, d.user_id,
COALESCE(u.display_name, '') as owner_name,
COALESCE(s.permission, dts.permission, wfs.permission, '') as shared_permission
FROM block_tags t
JOIN documents d ON t.document_id = d.id
LEFT JOIN users u ON d.user_id = u.id
LEFT JOIN doc_shares s ON s.doc_id = d.id AND s.user_id = ?
LEFT JOIN (
SELECT dts2.doc_id, dts2.permission
FROM doc_team_shares dts2
JOIN team_members tm ON tm.team_id = dts2.team_id
WHERE tm.user_id = ?
) dts ON d.id = dts.doc_id
LEFT JOIN workspace_shared_folders wsf ON d.shared_folder_id = wsf.id
LEFT JOIN (
SELECT wfs2.folder_id, wfs2.permission
FROM workspace_folder_shares wfs2
WHERE (wfs2.target_type = 'user' AND wfs2.target_id = ?)
OR (wfs2.target_type = 'team' AND wfs2.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
) wfs ON d.shared_folder_id = wfs.folder_id
WHERE (d.user_id = ? OR s.user_id = ? OR dts.doc_id IS NOT NULL OR (wsf.id IS NOT NULL AND (wsf.owner_id = ? OR wfs.folder_id IS NOT NULL)))
AND t.tag = ?
AND d.is_archived = 0
ORDER BY d.updated_at DESC`,
userID, userID, userID, userID, userID, userID, userID, cleanTag,
)
if err != nil {
return nil, err
}
defer rows.Close()
type rawMatch struct {
blockID string
docID string
title string
icon string
folderPath string
blocksJSON string
updatedAt string
docUserID string
ownerName string
sharedPermission string
}
var rawMatches []rawMatch
for rows.Next() {
var rm rawMatch
if err := rows.Scan(&rm.blockID, &rm.docID, &rm.title, &rm.icon, &rm.folderPath, &rm.blocksJSON, &rm.updatedAt, &rm.docUserID, &rm.ownerName, &rm.sharedPermission); err == nil {
rawMatches = append(rawMatches, rm)
}
}
_ = rows.Close()
// Group rawMatches by docID
docMap := make(map[string][]rawMatch)
var docOrder []string
for _, rm := range rawMatches {
if _, exists := docMap[rm.docID]; !exists {
docOrder = append(docOrder, rm.docID)
}
docMap[rm.docID] = append(docMap[rm.docID], rm)
}
totalMatches := 0
docGroups := make([]domain.TagDocumentGroup, 0, len(docOrder))
for _, docID := range docOrder {
matches := docMap[docID]
first := matches[0]
blocks := s.loadDocBlocks(first.docID, first.blocksJSON)
blockIdxMap := make(map[string]int, len(blocks))
for i, b := range blocks {
blockIdxMap[b.ID] = i
}
matchedBlocks := make([]domain.MatchedTagBlock, 0)
processedBlockIDs := make(map[string]bool)
for _, m := range matches {
if processedBlockIDs[m.blockID] {
continue
}
idx, ok := blockIdxMap[m.blockID]
if !ok {
continue
}
rootBlock := blocks[idx]
processedBlockIDs[rootBlock.ID] = true
children := make([]domain.DocumentBlock, 0)
rootLevel := rootBlock.Level
for i := idx + 1; i < len(blocks); i++ {
if blocks[i].Level > rootLevel {
children = append(children, blocks[i])
processedBlockIDs[blocks[i].ID] = true
} else {
break
}
}
matchedBlocks = append(matchedBlocks, domain.MatchedTagBlock{
RootBlock: rootBlock,
Children: children,
})
totalMatches++
}
if len(matchedBlocks) == 0 {
continue
}
isOwner := first.docUserID == userID
permission := "read"
if isOwner {
permission = "owner"
} else if first.sharedPermission != "" {
permission = first.sharedPermission
}
docGroups = append(docGroups, domain.TagDocumentGroup{
DocID: first.docID,
Title: first.title,
Icon: first.icon,
FolderPath: first.folderPath,
UpdatedAt: first.updatedAt,
IsOwner: isOwner,
Permission: permission,
OwnerName: first.ownerName,
MatchedBlocks: matchedBlocks,
})
}
return &domain.TagDetails{
Tag: cleanTag,
NoteContent: noteContent,
NoteUpdatedAt: noteUpdatedAt,
Documents: docGroups,
TotalMatches: totalMatches,
}, nil
}
+541
View File
@@ -0,0 +1,541 @@
package storage
import (
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/sandrodach/mindsift/internal/domain"
)
// --- TASK LISTS ---
func (s *Store) CreateTaskList(userID, spaceID, teamID, title, icon, color string, columns ...[]domain.KanbanColumn) (*domain.TaskList, error) {
if strings.TrimSpace(title) == "" {
title = "Meine Aufgaben"
}
if icon == "" {
icon = "📋"
}
if color == "" {
color = "#2563eb"
}
cols := []domain.KanbanColumn{
{ID: "todo", Name: "Zu erledigen"},
{ID: "in_progress", Name: "In Bearbeitung", Color: "accent"},
{ID: "waiting", Name: "Wartet auf andere", Color: "amber"},
{ID: "done", Name: "Erledigt", IsDone: true, Color: "emerald"},
}
if len(columns) > 0 && len(columns[0]) > 0 {
cols = columns[0]
}
colsJSON, _ := json.Marshal(cols)
now := domain.NowISO()
tl := &domain.TaskList{
ID: domain.NewID("tkl"),
UserID: userID,
SpaceID: spaceID,
Title: strings.TrimSpace(title),
Icon: icon,
Color: color,
Columns: cols,
CreatedAt: now,
UpdatedAt: now,
IsOwner: true,
}
_, err := s.db.Exec(
`INSERT INTO task_lists (id, user_id, space_id, title, icon, color, columns, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
tl.ID, tl.UserID, tl.SpaceID, tl.Title, tl.Icon, tl.Color, string(colsJSON), tl.CreatedAt, tl.UpdatedAt,
)
if err != nil {
return nil, err
}
if teamID != "" && s.CanShareWith(userID, "team", teamID) {
_, _ = s.AddTaskListShare(tl.ID, "team", teamID, "editor", userID)
}
return s.GetTaskListByID(userID, tl.ID)
}
// ErrTaskListReadOnly is returned when a user with viewer permission tries to modify a task list or its tasks.
var ErrTaskListReadOnly = errors.New("Keine Schreibrechte für diese Aufgabenliste (nur Lesen)")
// ErrAssigneeNoAccess is returned when a task should be assigned to someone who cannot see its list.
var ErrAssigneeNoAccess = errors.New("Aufgaben können nur Personen mit Zugriff auf die Liste zugewiesen werden")
// GetTaskListPermission returns "owner", "editor", "viewer" or "" (no access) for the given user.
func (s *Store) GetTaskListPermission(userID, listID string) string {
var ownerID string
if err := s.db.QueryRow(`SELECT user_id FROM task_lists WHERE id = ?`, listID).Scan(&ownerID); err != nil {
return ""
}
if ownerID == userID {
return "owner"
}
rows, err := s.db.Query(
`SELECT permission FROM task_list_shares
WHERE task_list_id = ? AND (
(target_type = 'user' AND target_id = ?)
OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
)`,
listID, userID, userID,
)
perm := ""
if err == nil {
for rows.Next() {
var p string
if rows.Scan(&p) == nil {
if p == "viewer" {
if perm == "" {
perm = "viewer"
}
} else {
perm = "editor"
}
}
}
_ = rows.Close()
}
return perm
}
func (s *Store) CanEditTaskList(userID, listID string) bool {
p := s.GetTaskListPermission(userID, listID)
return p == "owner" || p == "editor"
}
// ensureTaskListWritable returns ErrTaskListReadOnly if the list is set and the user may not modify it.
func (s *Store) ensureTaskListWritable(userID, listID string) error {
if listID == "" || s.CanEditTaskList(userID, listID) {
return nil
}
return ErrTaskListReadOnly
}
func (s *Store) AddTaskListShare(taskListID, targetType, targetID, permission, createdBy string) (*domain.TaskListShare, error) {
if targetType != "team" && targetType != "user" {
return nil, errors.New("ungültiger Freigabetyp")
}
targetID = strings.TrimSpace(targetID)
if targetID == "" {
return nil, errors.New("Ziel-ID erforderlich")
}
if permission != "viewer" {
permission = "editor"
}
if !s.CanShareWith(createdBy, targetType, targetID) {
return nil, ErrTargetNotFound
}
now := domain.NowISO()
share := &domain.TaskListShare{
ID: domain.NewID("tls"),
TaskListID: taskListID,
TargetType: targetType,
TargetID: targetID,
Permission: permission,
CreatedAt: now,
}
_, err := s.db.Exec(
`INSERT INTO task_list_shares (id, task_list_id, target_type, target_id, permission, created_at, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(task_list_id, target_type, target_id) DO UPDATE SET created_at = excluded.created_at, permission = excluded.permission`,
share.ID, share.TaskListID, share.TargetType, share.TargetID, share.Permission, share.CreatedAt, createdBy,
)
if err != nil {
return nil, err
}
_ = s.db.QueryRow(`SELECT id, created_at FROM task_list_shares WHERE task_list_id = ? AND target_type = ? AND target_id = ?`,
taskListID, targetType, targetID).Scan(&share.ID, &share.CreatedAt)
if targetType == "team" {
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, targetID).Scan(&share.TargetName)
} else {
_ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, targetID).Scan(&share.TargetName, &share.Email)
}
return share, nil
}
func (s *Store) RemoveTaskListShare(taskListID, shareIDOrTargetID string) error {
_, err := s.db.Exec(
`DELETE FROM task_list_shares WHERE task_list_id = ? AND (id = ? OR target_id = ?)`,
taskListID, shareIDOrTargetID, shareIDOrTargetID,
)
s.PruneTaskAssignees()
return err
}
func (s *Store) GetTaskListShares(taskListID string) ([]domain.TaskListShare, error) {
rows, err := s.db.Query(
`SELECT id, task_list_id, target_type, target_id, permission, created_at
FROM task_list_shares WHERE task_list_id = ? ORDER BY created_at ASC`,
taskListID,
)
if err != nil {
return []domain.TaskListShare{}, err
}
defer rows.Close()
shares := make([]domain.TaskListShare, 0)
for rows.Next() {
var sh domain.TaskListShare
if err := rows.Scan(&sh.ID, &sh.TaskListID, &sh.TargetType, &sh.TargetID, &sh.Permission, &sh.CreatedAt); err == nil {
shares = append(shares, sh)
}
}
for i := range shares {
if shares[i].TargetType == "team" {
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, shares[i].TargetID).Scan(&shares[i].TargetName)
} else {
_ = s.db.QueryRow(`SELECT display_name, email, avatar_url FROM users WHERE id = ?`, shares[i].TargetID).Scan(&shares[i].TargetName, &shares[i].Email, &shares[i].AvatarURL)
}
}
return shares, nil
}
// queryUserIDs runs a query returning a single user_id column and collects the distinct IDs.
func (s *Store) queryUserIDs(query string, args ...interface{}) []string {
rows, err := s.db.Query(query, args...)
if err != nil {
return []string{}
}
defer rows.Close()
ids := make([]string, 0)
seen := make(map[string]bool)
for rows.Next() {
var id string
if rows.Scan(&id) == nil && id != "" && !seen[id] {
seen[id] = true
ids = append(ids, id)
}
}
return ids
}
// taskListAccessSQL selects every user ID with access to a task list: owner, direct shares and members of
// shared groups. %[1]s is the list ID expression ("?" or a correlated column).
const taskListAccessSQL = `SELECT user_id FROM task_lists WHERE id = %[1]s
UNION SELECT target_id FROM task_list_shares WHERE task_list_id = %[1]s AND target_type = 'user'
UNION SELECT tm.user_id FROM team_members tm
WHERE tm.team_id IN (SELECT target_id FROM task_list_shares WHERE task_list_id = %[1]s AND target_type = 'team')`
func (s *Store) getTaskAssignees(taskID string) []string {
ids := s.queryUserIDs(`SELECT user_id FROM task_assignees WHERE task_id = ? ORDER BY created_at ASC`, taskID)
return ids
}
// attachTaskAssignees fills Assignees for a batch of tasks with a single query.
func (s *Store) attachTaskAssignees(tasks []domain.Thought) {
if len(tasks) == 0 {
return
}
idx := make(map[string]int, len(tasks))
placeholders := make([]string, 0, len(tasks))
args := make([]interface{}, 0, len(tasks))
for i := range tasks {
idx[tasks[i].ID] = i
placeholders = append(placeholders, "?")
args = append(args, tasks[i].ID)
}
rows, err := s.db.Query(
`SELECT task_id, user_id FROM task_assignees WHERE task_id IN (`+strings.Join(placeholders, ",")+`) ORDER BY created_at ASC`,
args...,
)
if err != nil {
return
}
defer rows.Close()
for rows.Next() {
var taskID, uid string
if rows.Scan(&taskID, &uid) == nil {
if i, ok := idx[taskID]; ok {
tasks[i].Assignees = append(tasks[i].Assignees, uid)
}
}
}
}
// SetTaskAssignees replaces the assignees of a task. Only editors of the task's list may assign, and only
// people who can see the list are accepted.
func (s *Store) SetTaskAssignees(userID, taskID string, assignees []string) ([]string, error) {
thought, err := s.GetThoughtByID(userID, taskID)
if err != nil {
return nil, err
}
if thought.TaskListID == "" {
if len(assignees) > 0 {
return nil, ErrAssigneeNoAccess
}
return []string{}, nil
}
if !s.CanEditTaskList(userID, thought.TaskListID) {
return nil, ErrTaskListReadOnly
}
allowed := make(map[string]bool)
for _, id := range s.GetTaskListUserIDs(thought.TaskListID) {
allowed[id] = true
}
clean := make([]string, 0, len(assignees))
seen := make(map[string]bool)
for _, id := range assignees {
id = strings.TrimSpace(id)
if id == "" || seen[id] {
continue
}
if !allowed[id] {
return nil, ErrAssigneeNoAccess
}
seen[id] = true
clean = append(clean, id)
}
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
keep := make([]interface{}, 0, len(clean)+1)
keep = append(keep, taskID)
ph := make([]string, 0, len(clean))
for _, id := range clean {
keep = append(keep, id)
ph = append(ph, "?")
}
delQuery := `DELETE FROM task_assignees WHERE task_id = ?`
if len(ph) > 0 {
delQuery += ` AND user_id NOT IN (` + strings.Join(ph, ",") + `)`
}
if _, err := tx.Exec(delQuery, keep...); err != nil {
return nil, err
}
now := domain.NowISO()
for _, id := range clean {
if _, err := tx.Exec(`INSERT OR IGNORE INTO task_assignees (task_id, user_id, created_at) VALUES (?, ?, ?)`, taskID, id, now); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return s.getTaskAssignees(taskID), nil
}
// PruneTaskAssignees drops every assignment whose user can no longer see the task's list (share removed,
// left the team, team deleted, task moved to another list or out of any list). Called after each change
// that can take access away, so assignments never outlive permissions.
func (s *Store) PruneTaskAssignees() {
_, _ = s.db.Exec(
`DELETE FROM task_assignees WHERE rowid IN (
SELECT ta.rowid FROM task_assignees ta
LEFT JOIN thoughts t ON t.id = ta.task_id
WHERE t.id IS NULL OR t.task_list_id = ''
OR ta.user_id NOT IN (` + fmt.Sprintf(taskListAccessSQL, "t.task_list_id") + `)
)`,
)
}
// GetTaskListUserIDs returns every user who can see the task list: owner, direct shares and members of shared teams.
func (s *Store) GetTaskListUserIDs(listID string) []string {
if listID == "" {
return []string{}
}
return s.queryUserIDs(fmt.Sprintf(taskListAccessSQL, "?"), listID, listID, listID)
}
// GetSharedFolderUserIDs returns the owner of a shared workspace folder and everyone it is shared with.
func (s *Store) GetSharedFolderUserIDs(folderID string) []string {
if folderID == "" {
return []string{}
}
return s.queryUserIDs(
`SELECT owner_id FROM workspace_shared_folders WHERE id = ?
UNION SELECT target_id FROM workspace_folder_shares WHERE folder_id = ? AND target_type = 'user'
UNION SELECT tm.user_id FROM team_members tm
WHERE tm.team_id IN (SELECT target_id FROM workspace_folder_shares WHERE folder_id = ? AND target_type = 'team')`,
folderID, folderID, folderID,
)
}
func (s *Store) getMembersForTaskList(tl *domain.TaskList) []domain.TaskListMember {
memberMap := make(map[string]domain.TaskListMember)
// 1. Owner
var ownerName, ownerEmail, ownerAvatar string
_ = s.db.QueryRow(`SELECT display_name, email, avatar_url FROM users WHERE id = ?`, tl.UserID).Scan(&ownerName, &ownerEmail, &ownerAvatar)
memberMap[tl.UserID] = domain.TaskListMember{
UserID: tl.UserID,
DisplayName: ownerName,
Email: ownerEmail,
AvatarURL: ownerAvatar,
Role: "owner",
}
// 2. From task_list_shares
for _, sh := range tl.Shares {
if sh.TargetType == "team" {
rows, err := s.db.Query(
`SELECT u.id, u.display_name, u.email, u.avatar_url FROM team_members tm JOIN users u ON u.id = tm.user_id WHERE tm.team_id = ?`,
sh.TargetID,
)
if err == nil {
for rows.Next() {
var m domain.TaskListMember
if err := rows.Scan(&m.UserID, &m.DisplayName, &m.Email, &m.AvatarURL); err == nil {
m.Role = "member"
memberMap[m.UserID] = m
}
}
_ = rows.Close()
}
} else if sh.TargetType == "user" {
var m domain.TaskListMember
if err := s.db.QueryRow(`SELECT id, display_name, email, avatar_url FROM users WHERE id = ?`, sh.TargetID).Scan(&m.UserID, &m.DisplayName, &m.Email, &m.AvatarURL); err == nil {
m.Role = "member"
memberMap[m.UserID] = m
}
}
}
result := make([]domain.TaskListMember, 0, len(memberMap))
for _, m := range memberMap {
result = append(result, m)
}
return result
}
func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) {
rows, err := s.db.Query(
`SELECT id, user_id, space_id, title, icon, color, COALESCE(columns, '[]'), created_at, updated_at
FROM task_lists
WHERE user_id = ?
OR id IN (
SELECT task_list_id FROM task_list_shares
WHERE (target_type = 'user' AND target_id = ?)
OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
)
ORDER BY updated_at DESC`,
userID, userID, userID,
)
if err != nil {
return []domain.TaskList{}, err
}
defaultCols := []domain.KanbanColumn{
{ID: "todo", Name: "Zu erledigen"},
{ID: "in_progress", Name: "In Bearbeitung", Color: "accent"},
{ID: "waiting", Name: "Wartet auf andere", Color: "amber"},
{ID: "done", Name: "Erledigt", IsDone: true, Color: "emerald"},
}
list := make([]domain.TaskList, 0)
for rows.Next() {
var tl domain.TaskList
var colsJSON string
if err := rows.Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.Title, &tl.Icon, &tl.Color, &colsJSON, &tl.CreatedAt, &tl.UpdatedAt); err == nil {
tl.IsOwner = (tl.UserID == userID)
_ = json.Unmarshal([]byte(colsJSON), &tl.Columns)
if len(tl.Columns) == 0 {
tl.Columns = defaultCols
}
list = append(list, tl)
}
}
_ = rows.Close()
for i := range list {
list[i].Members = make([]domain.TaskListMember, 0)
list[i].Shares = make([]domain.TaskListShare, 0)
_ = s.db.QueryRow(
`SELECT COUNT(*) FROM thoughts WHERE task_list_id = ? AND is_completed = 0 AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))`,
list[i].ID,
).Scan(&list[i].TasksCount)
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, list[i].UserID).Scan(&list[i].OwnerName)
shares, _ := s.GetTaskListShares(list[i].ID)
list[i].Shares = shares
list[i].Permission = s.GetTaskListPermission(userID, list[i].ID)
list[i].Members = s.getMembersForTaskList(&list[i])
}
return list, nil
}
func (s *Store) GetTaskListByID(userID, listID string) (*domain.TaskList, error) {
var tl domain.TaskList
var colsJSON string
err := s.db.QueryRow(
`SELECT id, user_id, space_id, title, icon, color, COALESCE(columns, '[]'), created_at, updated_at
FROM task_lists
WHERE id = ? AND (
user_id = ?
OR id IN (
SELECT task_list_id FROM task_list_shares
WHERE (target_type = 'user' AND target_id = ?)
OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
)
)`,
listID, userID, userID, userID,
).Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.Title, &tl.Icon, &tl.Color, &colsJSON, &tl.CreatedAt, &tl.UpdatedAt)
if err != nil {
return nil, err
}
tl.IsOwner = (tl.UserID == userID)
_ = json.Unmarshal([]byte(colsJSON), &tl.Columns)
if len(tl.Columns) == 0 {
tl.Columns = []domain.KanbanColumn{
{ID: "todo", Name: "Zu erledigen"},
{ID: "in_progress", Name: "In Bearbeitung", Color: "accent"},
{ID: "waiting", Name: "Wartet auf andere", Color: "amber"},
{ID: "done", Name: "Erledigt", IsDone: true, Color: "emerald"},
}
}
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE task_list_id = ? AND is_completed = 0 AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))`, tl.ID).Scan(&tl.TasksCount)
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, tl.UserID).Scan(&tl.OwnerName)
shares, _ := s.GetTaskListShares(tl.ID)
tl.Shares = shares
tl.Permission = s.GetTaskListPermission(userID, tl.ID)
tl.Members = s.getMembersForTaskList(&tl)
return &tl, nil
}
func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, spaceID *string, columns *[]domain.KanbanColumn) (*domain.TaskList, error) {
tl, err := s.GetTaskListByID(userID, listID)
if err != nil {
return nil, err
}
if tl.Permission == "viewer" {
return nil, ErrTaskListReadOnly
}
if title != nil {
tl.Title = strings.TrimSpace(*title)
}
if icon != nil && *icon != "" {
tl.Icon = *icon
}
if color != nil && *color != "" {
tl.Color = *color
}
if spaceID != nil {
tl.SpaceID = *spaceID
}
if columns != nil {
tl.Columns = *columns
}
tl.UpdatedAt = domain.NowISO()
colsJSON, _ := json.Marshal(tl.Columns)
_, err = s.db.Exec(
`UPDATE task_lists SET title = ?, icon = ?, color = ?, space_id = ?, columns = ?, updated_at = ? WHERE id = ?`,
tl.Title, tl.Icon, tl.Color, tl.SpaceID, string(colsJSON), tl.UpdatedAt, listID,
)
if err != nil {
return nil, err
}
return s.GetTaskListByID(userID, listID)
}
func (s *Store) DeleteTaskList(userID, listID string) error {
_, _ = s.db.Exec(`UPDATE thoughts SET task_list_id = '' WHERE task_list_id = ?`, listID)
_, _ = s.db.Exec(`DELETE FROM task_list_shares WHERE task_list_id = ?`, listID)
_, err := s.db.Exec(`DELETE FROM task_lists WHERE id = ? AND user_id = ?`, listID, userID)
s.PruneTaskAssignees()
return err
}
+247
View File
@@ -0,0 +1,247 @@
package storage
import (
"database/sql"
"errors"
"strings"
"github.com/sandrodach/mindsift/internal/domain"
)
// --- ORGANIZATIONS & TEAMS ---
func (s *Store) CreateTeam(orgID, name, description string) (*domain.Team, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, errors.New("team name required")
}
orgID = strings.TrimSpace(orgID)
team := &domain.Team{
ID: domain.NewID("team"),
OrgID: orgID,
Name: name,
Description: strings.TrimSpace(description),
Members: []domain.TeamMember{},
CreatedAt: domain.NowISO(),
}
_, err := s.db.Exec(
`INSERT INTO teams (id, org_id, name, description, created_at) VALUES (?, ?, ?, ?, ?)`,
team.ID, team.OrgID, team.Name, team.Description, team.CreatedAt,
)
if err != nil {
return nil, err
}
return team, nil
}
func (s *Store) DeleteTeam(teamID string) error {
_, err := s.db.Exec(`DELETE FROM teams WHERE id = ?`, teamID)
s.pruneSharingNow()
return err
}
func (s *Store) GetAllTeams() ([]domain.Team, error) {
rows, err := s.db.Query(
`SELECT id, org_id, name, description, created_at FROM teams ORDER BY name COLLATE NOCASE ASC`,
)
if err != nil {
return []domain.Team{}, err
}
defer rows.Close()
teams := make([]domain.Team, 0)
for rows.Next() {
var t domain.Team
if err := rows.Scan(&t.ID, &t.OrgID, &t.Name, &t.Description, &t.CreatedAt); err == nil {
t.Members = []domain.TeamMember{}
teams = append(teams, t)
}
}
for i := range teams {
members, _ := s.GetTeamMembers(teams[i].ID)
teams[i].Members = members
}
return teams, nil
}
func (s *Store) GetTeamsForOrg(orgID string) ([]domain.Team, error) {
rows, err := s.db.Query(
`SELECT id, org_id, name, description, created_at FROM teams WHERE org_id = ? ORDER BY created_at ASC`,
orgID,
)
if err != nil {
return []domain.Team{}, err
}
teams := make([]domain.Team, 0)
for rows.Next() {
var t domain.Team
if err := rows.Scan(&t.ID, &t.OrgID, &t.Name, &t.Description, &t.CreatedAt); err == nil {
t.Members = []domain.TeamMember{}
teams = append(teams, t)
}
}
_ = rows.Close()
for i := range teams {
members, _ := s.GetTeamMembers(teams[i].ID)
teams[i].Members = members
}
return teams, nil
}
func (s *Store) GetTeamByID(teamID string) (*domain.Team, error) {
var t domain.Team
err := s.db.QueryRow(
`SELECT id, org_id, name, description, created_at FROM teams WHERE id = ?`,
teamID,
).Scan(&t.ID, &t.OrgID, &t.Name, &t.Description, &t.CreatedAt)
if err != nil {
return nil, err
}
t.Members, _ = s.GetTeamMembers(t.ID)
return &t, nil
}
func (s *Store) GetTeamsForUser(userID string) ([]domain.Team, error) {
rows, err := s.db.Query(
`SELECT DISTINCT t.id, t.org_id, t.name, t.description, t.created_at
FROM teams t
JOIN team_members tm ON tm.team_id = t.id
WHERE tm.user_id = ?
ORDER BY t.created_at ASC`,
userID,
)
if err != nil {
return []domain.Team{}, err
}
teams := make([]domain.Team, 0)
for rows.Next() {
var t domain.Team
if err := rows.Scan(&t.ID, &t.OrgID, &t.Name, &t.Description, &t.CreatedAt); err == nil {
t.Members = []domain.TeamMember{}
teams = append(teams, t)
}
}
_ = rows.Close()
for i := range teams {
members, _ := s.GetTeamMembers(teams[i].ID)
teams[i].Members = members
}
return teams, nil
}
func (s *Store) UpdateTeam(teamID, name, description string) (*domain.Team, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, errors.New("Name für die Gruppe ist erforderlich")
}
_, err := s.db.Exec(`UPDATE teams SET name = ?, description = ? WHERE id = ?`, name, strings.TrimSpace(description), teamID)
if err != nil {
return nil, err
}
return s.GetTeamByID(teamID)
}
func (s *Store) GetTeamMemberRole(teamID, userID string) (string, error) {
var role string
err := s.db.QueryRow(`SELECT role FROM team_members WHERE team_id = ? AND user_id = ?`, teamID, userID).Scan(&role)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return "", err
}
return role, nil
}
func (s *Store) CountTeamAdmins(teamID string) (int, error) {
var count int
err := s.db.QueryRow(`SELECT COUNT(*) FROM team_members WHERE team_id = ? AND role = 'admin'`, teamID).Scan(&count)
return count, err
}
func (s *Store) AddTeamMember(teamID, userID, role string) error {
role = strings.ToLower(strings.TrimSpace(role))
if role != "admin" && role != "member" {
role = "member"
}
_, err := s.db.Exec(
`INSERT INTO team_members (team_id, user_id, role, joined_at) VALUES (?, ?, ?, ?)
ON CONFLICT(team_id, user_id) DO UPDATE SET role = excluded.role`,
teamID, userID, role, domain.NowISO(),
)
return err
}
func (s *Store) UpdateTeamMemberRole(teamID, userID, role string) error {
role = strings.ToLower(strings.TrimSpace(role))
if role != "admin" && role != "member" {
return errors.New("Ungültige Rolle (erlaubt sind: admin, member)")
}
currentRole, err := s.GetTeamMemberRole(teamID, userID)
if err != nil {
return err
}
if currentRole == "" {
return errors.New("Benutzer ist kein Mitglied dieser Gruppe")
}
if currentRole == "admin" && role != "admin" {
adminCount, err := s.CountTeamAdmins(teamID)
if err != nil {
return err
}
if adminCount <= 1 {
return errors.New("Die Gruppe muss mindestens einen Gruppen-Admin behalten")
}
}
_, err = s.db.Exec(`UPDATE team_members SET role = ? WHERE team_id = ? AND user_id = ?`, role, teamID, userID)
return err
}
func (s *Store) RemoveTeamMember(teamID, userID string) error {
currentRole, err := s.GetTeamMemberRole(teamID, userID)
if err != nil {
return err
}
if currentRole == "admin" {
adminCount, err := s.CountTeamAdmins(teamID)
if err != nil {
return err
}
if adminCount <= 1 {
return errors.New("Du bist der letzte Gruppen-Admin. Bitte ernenne zuerst einen anderen Admin oder lösche die Gruppe.")
}
}
_, err = s.db.Exec(`DELETE FROM team_members WHERE team_id = ? AND user_id = ?`, teamID, userID)
// Whoever leaves a group also loses the shares they made with it
s.pruneSharingNow()
return err
}
func (s *Store) GetTeamMembers(teamID string) ([]domain.TeamMember, error) {
rows, err := s.db.Query(
`SELECT tm.team_id, tm.user_id, u.display_name, u.email, u.avatar_url, tm.role, tm.joined_at
FROM team_members tm
JOIN users u ON u.id = tm.user_id
WHERE tm.team_id = ?
ORDER BY tm.joined_at ASC`,
teamID,
)
if err != nil {
return []domain.TeamMember{}, err
}
defer rows.Close()
members := make([]domain.TeamMember, 0)
for rows.Next() {
var m domain.TeamMember
if err := rows.Scan(&m.TeamID, &m.UserID, &m.DisplayName, &m.Email, &m.AvatarURL, &m.Role, &m.JoinedAt); err == nil {
members = append(members, m)
}
}
return members, nil
}
+639
View File
@@ -0,0 +1,639 @@
package storage
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/sandrodach/mindsift/internal/domain"
)
// --- THOUGHTS & TRIAGE ---
func (s *Store) CreateThought(userID, content, priority, source, dueDate string) (*domain.Thought, error) {
return s.CreateThoughtWithDetails(userID, content, priority, source, dueDate, "", "todo", "")
}
func (s *Store) CreateThoughtWithDetails(userID, content, priority, source, dueDate, taskListID, kanbanStatus, notes string) (*domain.Thought, error) {
content = strings.TrimSpace(content)
dueDate = domain.NormalizeDue(dueDate)
if content == "" {
return nil, errors.New("content cannot be empty")
}
if priority == "" {
priority = "normal"
}
if source == "" {
source = "manual"
}
if kanbanStatus == "" {
kanbanStatus = "todo"
}
if err := s.ensureTaskListWritable(userID, taskListID); err != nil {
return nil, err
}
tags := domain.ExtractTags(content)
if strings.EqualFold(source, "voice") {
hasDiktat := false
for _, tag := range tags {
if strings.EqualFold(tag, "diktat") {
hasDiktat = true
break
}
}
if !hasDiktat {
tags = append(tags, "diktat")
}
}
if strings.EqualFold(source, "webhook") {
hasWebhook := false
for _, tag := range tags {
if strings.EqualFold(tag, "webhook") {
hasWebhook = true
break
}
}
if !hasWebhook {
tags = append(tags, "webhook")
}
}
tagsJSON, _ := json.Marshal(tags)
t := &domain.Thought{
ID: domain.NewID("th"),
UserID: userID,
Content: content,
Tags: tags,
Priority: priority,
Source: source,
Status: domain.StatusInbox,
DueDate: dueDate,
TaskListID: taskListID,
KanbanStatus: kanbanStatus,
Notes: notes,
CreatedAt: domain.NowISO(),
}
_, err := s.db.Exec(
`INSERT INTO thoughts (id, user_id, content, tags, priority, source, status, is_completed, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, subtasks)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, 0, ?, '[]')`,
t.ID, t.UserID, t.Content, string(tagsJSON), t.Priority, t.Source, string(t.Status), t.DueDate, t.TaskListID, t.KanbanStatus, t.Notes, t.CreatedAt,
)
if err != nil {
return nil, err
}
t.Subtasks = []domain.SubTask{}
return t, nil
}
func scanThought(rows interface {
Scan(dest ...any) error
}) (*domain.Thought, error) {
t := &domain.Thought{}
var tagsStr, subtasksStr, statusStr string
var isCompleted, alarmSent, sortOrder int
err := rows.Scan(
&t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source,
&statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID,
&t.DueDate, &t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent,
&t.CreatedAt, &t.TriagedAt, &subtasksStr, &sortOrder, &t.Recurrence,
)
if err != nil {
return nil, err
}
t.SortOrder = sortOrder
t.Status = domain.ThoughtStatus(statusStr)
t.IsCompleted = isCompleted != 0
t.AlarmSent = alarmSent != 0
if t.KanbanStatus == "" {
if t.IsCompleted {
t.KanbanStatus = "done"
} else {
t.KanbanStatus = "todo"
}
}
t.Tags = []string{}
_ = json.Unmarshal([]byte(tagsStr), &t.Tags)
if t.ConvertedType == "document_block" && strings.Contains(t.ConvertedRefID, ":") {
parts := strings.SplitN(t.ConvertedRefID, ":", 2)
t.DocID = parts[0]
t.BlockID = parts[1]
}
t.Assignees = []string{}
t.Subtasks = []domain.SubTask{}
if subtasksStr != "" && subtasksStr != "null" {
_ = json.Unmarshal([]byte(subtasksStr), &t.Subtasks)
}
return t, nil
}
func (s *Store) accessibleTaskListIDsQuery() string {
return `SELECT id FROM task_lists
WHERE user_id = ?
OR id IN (
SELECT task_list_id FROM task_list_shares
WHERE (target_type = 'user' AND target_id = ?)
OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
)`
}
func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) {
row := s.db.QueryRow(
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`,
thoughtID, userID, userID, userID, userID,
)
t, err := scanThought(row)
if err != nil {
return nil, err
}
t.Assignees = s.getTaskAssignees(t.ID)
return t, nil
}
func (s *Store) GetThoughts(userID, statusFilter string, limit int) ([]domain.Thought, error) {
if limit <= 0 || limit > 500 {
limit = 200
}
var rows *sql.Rows
var err error
if statusFilter != "" && statusFilter != "all" {
rows, err = s.db.Query(
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
FROM thoughts WHERE user_id = ? AND status = ?
ORDER BY CASE priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, sort_order ASC, created_at DESC LIMIT ?`,
userID, statusFilter, limit,
)
} else {
rows, err = s.db.Query(
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
FROM thoughts WHERE user_id = ?
ORDER BY sort_order ASC, created_at DESC LIMIT ?`,
userID, limit,
)
}
if err != nil {
return nil, err
}
result := make([]domain.Thought, 0)
for rows.Next() {
t, err := scanThought(rows)
if err == nil {
result = append(result, *t)
}
}
_ = rows.Close()
return result, nil
}
func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, error) {
var rows *sql.Rows
var err error
if taskListID != "" {
rows, err = s.db.Query(
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
FROM thoughts
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
AND task_list_id = ?
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
ORDER BY is_completed ASC, sort_order ASC, created_at DESC`,
userID, userID, userID, userID, taskListID,
)
} else {
rows, err = s.db.Query(
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
FROM thoughts
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
ORDER BY is_completed ASC, sort_order ASC, created_at DESC`,
userID, userID, userID, userID,
)
}
if err != nil {
return nil, err
}
result := make([]domain.Thought, 0)
for rows.Next() {
t, err := scanThought(rows)
if err == nil {
result = append(result, *t)
}
}
_ = rows.Close()
s.attachTaskAssignees(result)
return result, nil
}
func (s *Store) ReorderTasks(taskListID string, items []domain.ThoughtReorderItem) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var completed []string // tasks that move into "done" now (repeating ones spawn their successor)
for _, it := range items {
if it.KanbanStatus != nil && *it.KanbanStatus != "" {
isCompleted := 0
if *it.KanbanStatus == "done" {
isCompleted = 1
var was int
_ = tx.QueryRow(`SELECT is_completed FROM thoughts WHERE id = ?`, it.ID).Scan(&was)
if was == 0 {
completed = append(completed, it.ID)
}
}
_, err = tx.Exec(
`UPDATE thoughts SET sort_order = ?, kanban_status = ?, is_completed = ?,
alarm_sent = CASE WHEN ? = 0 THEN 0 ELSE alarm_sent END WHERE id = ?`,
it.SortOrder, *it.KanbanStatus, isCompleted, isCompleted, it.ID,
)
} else {
_, err = tx.Exec(
`UPDATE thoughts SET sort_order = ? WHERE id = ?`,
it.SortOrder, it.ID,
)
}
if err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
for _, id := range completed {
s.spawnNextOccurrence(id)
}
return nil
}
// UpdateThoughtTriage handles swipe left (discard), swipe right (keep), restore, and conversion to task/document/list_item
func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, targetListID, dueDate, taskListID string) (*domain.Thought, error) {
thought, err := s.GetThoughtByID(userID, thoughtID)
if err != nil {
return nil, err
}
now := domain.NowISO()
newStatus := domain.StatusKept
convertedType := thought.ConvertedType
convertedRefID := thought.ConvertedRefID
dueDate = domain.NormalizeDue(dueDate)
if dueDate == "" {
dueDate = thought.DueDate
}
if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil {
return nil, err
}
if taskListID == "" {
taskListID = thought.TaskListID
} else if err := s.ensureTaskListWritable(userID, taskListID); err != nil {
return nil, err
}
switch action {
case "discard":
newStatus = domain.StatusDiscarded
case "restore":
newStatus = domain.StatusInbox
case "keep":
newStatus = domain.StatusKept
if convertTo == "task" {
newStatus = domain.StatusConverted
convertedType = "task"
} else if convertTo == "document" {
newStatus = domain.StatusConverted
convertedType = "document"
title := thought.Content
if len(title) > 72 {
title = title[:72] + "…"
}
blocks := make([]domain.DocumentBlock, 0)
// 17. Wenn ich eine Aufgabe zu einer Notiz mache, muss die erste Zeile das "To-Do" noch mal als "To-Do" enthalten, auch wenn es schon in der Überschrift steht. Wenn es vorher schon abgehakt war, dann den Zustand natürlich übernehmen.
blocks = append(blocks, domain.DocumentBlock{
ID: domain.NewID("blk"),
Type: "todo",
Content: thought.Content,
Checked: thought.IsCompleted,
Level: 0,
})
// 17a. Die anderen Details aus der Aufgabe sollen natürlich ebenfalls übernommen werden (Notizen, Subtasks, Fälligkeit/Priorität).
if strings.TrimSpace(thought.Notes) != "" {
blocks = append(blocks, domain.DocumentBlock{
ID: domain.NewID("blk"),
Type: "bullet",
Content: strings.TrimSpace(thought.Notes),
Level: 1,
})
}
if len(thought.Subtasks) > 0 {
for _, st := range thought.Subtasks {
blocks = append(blocks, domain.DocumentBlock{
ID: domain.NewID("blk"),
Type: "todo",
Content: st.Title,
Checked: st.IsCompleted,
Level: 1,
})
}
}
var metaItems []string
if thought.DueDate != "" {
metaItems = append(metaItems, "Fällig: "+domain.HumanDue(thought.DueDate))
}
if thought.Priority != "" && thought.Priority != "normal" {
metaItems = append(metaItems, "Priorität: "+thought.Priority)
}
if len(metaItems) > 0 {
blocks = append(blocks, domain.DocumentBlock{
ID: domain.NewID("blk"),
Type: "bullet",
Content: strings.Join(metaItems, " • "),
Level: 1,
})
}
if len(blocks) == 1 {
blocks = append(blocks, domain.DocumentBlock{
ID: domain.NewID("blk"),
Type: "bullet",
Content: "",
Level: 0,
})
}
doc, err := s.CreateDocument(userID, "", title, "📄", "", blocks, thought.Tags, false)
if err == nil && doc != nil {
convertedRefID = doc.ID
}
} else if convertTo == "list_item" && targetListID != "" {
newStatus = domain.StatusConverted
convertedType = "list_item"
item, err := s.CreateListItem(targetListID, thought.Content, "", "", 0, userID)
if err == nil && item != nil {
convertedRefID = item.ID
}
}
default:
return nil, fmt.Errorf("invalid triage action: %s", action)
}
_, err = s.db.Exec(
`UPDATE thoughts
SET status = ?, converted_type = ?, converted_ref_id = ?, due_date = ?, task_list_id = ?, triaged_at = ?,
alarm_sent = CASE WHEN due_date != ? THEN 0 ELSE alarm_sent END
WHERE id = ? AND user_id = ?`,
string(newStatus), convertedType, convertedRefID, dueDate, taskListID, now, dueDate, thoughtID, userID,
)
if err != nil {
return nil, err
}
if taskListID != thought.TaskListID {
s.PruneTaskAssignees()
}
return s.GetThoughtByID(userID, thoughtID)
}
func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string, isCompleted *bool, dueDate *string, priority *string, taskListID *string, kanbanStatus *string, notes *string, subtasks *[]domain.SubTask, tags *[]string) (*domain.Thought, error) {
thought, err := s.GetThoughtByID(userID, thoughtID)
if err != nil {
return nil, err
}
wasCompleted := thought.IsCompleted
oldDue := thought.DueDate
if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil {
return nil, err
}
if taskListID != nil && *taskListID != thought.TaskListID {
if err := s.ensureTaskListWritable(userID, *taskListID); err != nil {
return nil, err
}
}
if content != nil {
thought.Content = strings.TrimSpace(*content)
thought.Tags = domain.ExtractTags(thought.Content)
}
if tags != nil {
thought.Tags = *tags
}
if isCompleted != nil {
thought.IsCompleted = *isCompleted
if *isCompleted {
thought.KanbanStatus = "done"
} else if thought.KanbanStatus == "done" {
thought.KanbanStatus = "todo"
}
}
if dueDate != nil {
thought.DueDate = domain.NormalizeDue(*dueDate)
}
if priority != nil {
p := strings.ToLower(strings.TrimSpace(*priority))
if p == "" || p == "none" {
p = "normal"
}
thought.Priority = p
}
if taskListID != nil {
thought.TaskListID = *taskListID
}
if kanbanStatus != nil && *kanbanStatus != "" {
thought.KanbanStatus = *kanbanStatus
if isCompleted == nil {
if *kanbanStatus == "done" {
thought.IsCompleted = true
} else {
thought.IsCompleted = false
}
}
}
if notes != nil {
thought.Notes = *notes
}
if subtasks != nil {
thought.Subtasks = *subtasks
}
tagsJSON, _ := json.Marshal(thought.Tags)
subtasksJSON, _ := json.Marshal(thought.Subtasks)
completedInt := 0
if thought.IsCompleted {
completedInt = 1
}
_, err = s.db.Exec(
`UPDATE thoughts SET content = ?, tags = ?, is_completed = ?, due_date = ?, priority = ?, task_list_id = ?, kanban_status = ?, notes = ?, subtasks = ? WHERE id = ?`,
thought.Content, string(tagsJSON), completedInt, thought.DueDate, thought.Priority, thought.TaskListID, thought.KanbanStatus, thought.Notes, string(subtasksJSON), thoughtID,
)
if err != nil {
return nil, err
}
// A new due date or a reopened task gets its alarm again
if thought.DueDate != oldDue || (wasCompleted && !thought.IsCompleted) {
_, _ = s.db.Exec(`UPDATE thoughts SET alarm_sent = 0 WHERE id = ?`, thoughtID)
thought.AlarmSent = false
}
if taskListID != nil {
s.PruneTaskAssignees()
thought.Assignees = s.getTaskAssignees(thoughtID)
}
if !wasCompleted && thought.IsCompleted {
s.spawnNextOccurrence(thoughtID)
}
return thought, nil
}
func (s *Store) DeleteThought(userID, thoughtID string) error {
thought, err := s.GetThoughtByID(userID, thoughtID)
if err != nil {
return err
}
if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil {
return err
}
_, err = s.db.Exec(`DELETE FROM thoughts WHERE id = ?`, thought.ID)
return err
}
func (s *Store) PurgeDiscardedThoughts(userID string) (int64, error) {
res, err := s.db.Exec(`DELETE FROM thoughts WHERE user_id = ? AND status = 'discarded'`, userID)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (s *Store) CreateTaskFromDocBlock(userID, docID, blockID, content string, checked bool) (*domain.Thought, error) {
// Linking changes the block (its tick follows the task), so edit rights are needed
if !CanEditDoc(s.DocRole(userID, docID)) {
return nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)")
}
t, err := s.CreateThoughtWithDetails(userID, content, "normal", "manual", "", "", "todo", "")
if err != nil {
return nil, err
}
t.ConvertedType = "document_block"
t.ConvertedRefID = docID + ":" + blockID
t.DocID = docID
t.BlockID = blockID
t.IsCompleted = checked
t.Status = domain.StatusKept
doneInt := 0
if checked {
doneInt = 1
t.KanbanStatus = "done"
}
_, _ = s.db.Exec(`UPDATE thoughts SET converted_type = 'document_block', converted_ref_id = ?, is_completed = ?, kanban_status = ?, status = 'kept' WHERE id = ?`,
t.ConvertedRefID, doneInt, t.KanbanStatus, t.ID)
s.SetBlockChecked(userID, docID, blockID, checked)
return t, nil
}
// SyncDocBlockAndTask ticks the block a task was created from when the task is completed (or reopened).
// Returns the note ID if the block changed, so the caller can notify its viewers.
func (s *Store) SyncDocBlockAndTask(userID, taskID string, isCompleted bool) string {
t, err := s.GetThoughtByID(userID, taskID)
if err != nil || t == nil || t.ConvertedType != "document_block" || !strings.Contains(t.ConvertedRefID, ":") {
return ""
}
docID, blockID, _ := strings.Cut(t.ConvertedRefID, ":")
if s.SetBlockChecked(userID, docID, blockID, isCompleted) {
return docID
}
return ""
}
// UnlinkTaskFromDocBlock removes the link between a task and its note block; both stay. The block's
// task ID is derived from this link (docTaskLinks), so nothing in the note itself needs to change.
func (s *Store) UnlinkTaskFromDocBlock(userID, taskID string) {
if t, err := s.GetThoughtByID(userID, taskID); err != nil || t == nil {
return
}
_, _ = s.db.Exec(`UPDATE thoughts SET converted_type = '', converted_ref_id = '' WHERE id = ?`, taskID)
}
func (s *Store) SyncTaskFromBlockCheck(userID, taskID string, checked bool) {
if taskID == "" {
return
}
doneInt := 0
kanban := "todo"
if checked {
doneInt = 1
kanban = "done"
}
res, _ := s.db.Exec(`UPDATE thoughts SET is_completed = ?, kanban_status = ?, alarm_sent = CASE WHEN ? = 0 THEN 0 ELSE alarm_sent END
WHERE id = ? AND user_id = ? AND is_completed != ?`, doneInt, kanban, doneInt, taskID, userID, doneInt)
if checked && res != nil {
if n, _ := res.RowsAffected(); n > 0 {
s.spawnNextOccurrence(taskID)
}
}
}
// --- ALARMS ---
type DueTaskAlarm struct {
TaskID string
UserID string
Title string
Notes string
DueDate string
Priority string
UserEmail string
AlarmChannels []string
AlarmEmail string
DiscordWebhookURL string
CustomWebhookURL string
NotifyBeforeMinutes int
}
func (s *Store) GetDueTasksForAlarm() ([]DueTaskAlarm, error) {
rows, err := s.db.Query(
`SELECT t.id, t.user_id, t.content, t.notes, t.due_date, t.priority,
u.email, u.alarm_channels, u.alarm_email, u.discord_webhook_url, u.custom_webhook_url, u.notify_before_minutes
FROM thoughts t
JOIN users u ON u.id = t.user_id
WHERE (t.status = 'kept' OR (t.status = 'converted' AND t.converted_type = 'task'))
AND t.is_completed = 0
AND t.alarm_sent = 0
AND t.due_date != ''
AND u.is_active = 1`,
)
if err != nil {
return []DueTaskAlarm{}, err
}
defer rows.Close()
alarms := make([]DueTaskAlarm, 0)
for rows.Next() {
var a DueTaskAlarm
var channelsRaw string
if err := rows.Scan(
&a.TaskID, &a.UserID, &a.Title, &a.Notes, &a.DueDate, &a.Priority,
&a.UserEmail, &channelsRaw, &a.AlarmEmail, &a.DiscordWebhookURL, &a.CustomWebhookURL, &a.NotifyBeforeMinutes,
); err == nil {
a.AlarmChannels = parseAlarmChannels(channelsRaw)
if a.AlarmEmail == "" {
a.AlarmEmail = a.UserEmail
}
alarms = append(alarms, a)
}
}
return alarms, nil
}
func (s *Store) MarkTaskAlarmSent(taskID string) error {
_, err := s.db.Exec(`UPDATE thoughts SET alarm_sent = 1 WHERE id = ?`, taskID)
return err
}
+149
View File
@@ -0,0 +1,149 @@
package storage
import (
"database/sql"
"errors"
"github.com/sandrodach/mindsift/internal/domain"
)
// Deleted notes go to a trash first (documents.is_archived = 1). Trashed notes vanish from the tree,
// search, export, stats and for everyone they were shared with; their owner can still read them,
// restore them or delete them for good. archived_by records who trashed the note (the owner, or the
// owner of the shared folder it lived in), so that person sees it in their trash as well.
// ErrNotInTrash is returned when restoring or purging a note that is not in the user's trash.
var ErrNotInTrash = errors.New("Notiz nicht im Papierkorb")
// ErrDocInTrash refuses changes to a trashed note; it has to be restored first.
var ErrDocInTrash = errors.New("Die Notiz liegt im Papierkorb. Stelle sie zuerst wieder her.")
// TrashedDocument is one entry of the trash list.
type TrashedDocument struct {
ID string `json:"id"`
Title string `json:"title"`
Icon string `json:"icon"`
FolderPath string `json:"folderPath"`
DeletedAt string `json:"deletedAt"`
IsOwner bool `json:"isOwner"`
}
const trashScope = `is_archived = 1 AND (user_id = ? OR archived_by = ?)`
func (s *Store) trashDocument(userID, docID string) error {
_, err := s.db.Exec(`UPDATE documents SET is_archived = 1, archived_at = ?, archived_by = ? WHERE id = ?`,
domain.NowISO(), userID, docID)
if err == nil {
_, _ = s.db.Exec(`DELETE FROM doc_pins WHERE doc_id = ?`, docID)
}
return err
}
// GetTrash lists the notes in the user's trash, newest deletion first.
func (s *Store) GetTrash(userID string) ([]TrashedDocument, error) {
rows, err := s.db.Query(`SELECT d.id, d.title, d.icon, d.folder_path, d.shared_folder_id, d.sub_folder_path, d.archived_at, d.user_id,
COALESCE(wsf.name, '')
FROM documents d LEFT JOIN workspace_shared_folders wsf ON wsf.id = d.shared_folder_id
WHERE d.`+trashScope+` ORDER BY d.archived_at DESC`, userID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []TrashedDocument{}
for rows.Next() {
var t TrashedDocument
var sharedFolderID, subPath, ownerID, folderName string
if err := rows.Scan(&t.ID, &t.Title, &t.Icon, &t.FolderPath, &sharedFolderID, &subPath, &t.DeletedAt, &ownerID, &folderName); err != nil {
continue
}
if sharedFolderID != "" {
t.FolderPath = folderName
if subPath != "" {
t.FolderPath += "/" + subPath
}
}
t.IsOwner = ownerID == userID
out = append(out, t)
}
return out, nil
}
func (s *Store) trashedDocOwner(userID, docID string) (string, error) {
var owner string
err := s.db.QueryRow(`SELECT user_id FROM documents WHERE id = ? AND `+trashScope, docID, userID, userID).Scan(&owner)
if err == sql.ErrNoRows {
return "", ErrNotInTrash
}
return owner, err
}
// RestoreDocument takes a note out of the trash. Its folder is created again if it was deleted in the
// meantime; a shared folder that no longer exists puts the note back into the owner's top level.
func (s *Store) RestoreDocument(userID, docID string) error {
owner, err := s.trashedDocOwner(userID, docID)
if err != nil {
return err
}
var folderPath, sharedFolderID string
_ = s.db.QueryRow(`SELECT folder_path, shared_folder_id FROM documents WHERE id = ?`, docID).Scan(&folderPath, &sharedFolderID)
if sharedFolderID != "" {
var exists int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM workspace_shared_folders WHERE id = ?`, sharedFolderID).Scan(&exists)
if exists == 0 {
_, _ = s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '', folder_path = '' WHERE id = ?`, docID)
}
} else if folderPath != "" {
_, _ = s.CreateFolder(owner, folderPath)
}
_, err = s.db.Exec(`UPDATE documents SET is_archived = 0, archived_at = '', archived_by = '' WHERE id = ?`, docID)
return err
}
// PurgeDocument deletes a trashed note for good.
func (s *Store) PurgeDocument(userID, docID string) error {
if _, err := s.trashedDocOwner(userID, docID); err != nil {
return err
}
return s.hardDeleteDocument(docID)
}
// EmptyTrash deletes every note in the user's trash for good and returns their IDs.
func (s *Store) EmptyTrash(userID string) ([]string, error) {
items, err := s.GetTrash(userID)
if err != nil {
return nil, err
}
ids := make([]string, 0, len(items))
for _, t := range items {
if err := s.hardDeleteDocument(t.ID); err != nil {
return ids, err
}
ids = append(ids, t.ID)
}
return ids, nil
}
func (s *Store) hardDeleteDocument(docID string) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, q := range []string{
// Tasks created from blocks of this note keep existing without the link
`UPDATE thoughts SET converted_type = '', converted_ref_id = '' WHERE converted_type = 'document_block'
AND substr(converted_ref_id, 1, instr(converted_ref_id, ':') - 1) = ?`,
`DELETE FROM blocks WHERE document_id = ?`,
`DELETE FROM block_tags WHERE document_id = ?`,
`DELETE FROM doc_pins WHERE doc_id = ?`,
`DELETE FROM doc_shares WHERE doc_id = ?`,
`DELETE FROM doc_team_shares WHERE doc_id = ?`,
`DELETE FROM doc_user_folders WHERE doc_id = ?`,
`DELETE FROM documents WHERE id = ?`,
} {
if _, err := tx.Exec(q, docID); err != nil {
return err
}
}
return tx.Commit()
}
+93
View File
@@ -0,0 +1,93 @@
package storage
import (
"path/filepath"
"testing"
"github.com/sandrodach/mindsift/internal/domain"
)
func TestTrashHidesRestoresAndPurgesNotes(t *testing.T) {
store, err := NewStore(filepath.Join(t.TempDir(), "trash.db"))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
defer store.Close()
openSharing(t, store)
anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member")
ben, _ := store.CreateUser("ben@example.com", "hash", "Ben", "member")
blocks := []domain.DocumentBlock{{ID: "b1", Type: "bullet", Content: "Zebrastreifen", Level: 0}}
doc, _ := store.CreateDocument(anna.ID, "", "Verkehr", "🚦", "Projekte/Stadt", blocks, nil, false)
_, _ = store.CreateFolder(anna.ID, "Projekte/Stadt")
if _, err := store.CreateDocShare(anna.ID, doc.ID, ben.ID, "write"); err != nil {
t.Fatalf("share: %v", err)
}
if err := store.DeleteDocument(anna.ID, doc.ID); err != nil {
t.Fatalf("trash: %v", err)
}
visible := func(uid string) bool {
docs, _ := store.GetDocuments(uid)
for _, d := range docs {
if d.ID == doc.ID {
return true
}
}
return false
}
if visible(anna.ID) || visible(ben.ID) {
t.Errorf("trashed note must leave the tree of owner and shared users")
}
if res, _ := store.SearchGlobal(anna.ID, "Zebrastreifen"); len(res.Documents) != 0 {
t.Errorf("trashed note must not be found: %+v", res.Documents)
}
if store.DocRole(anna.ID, doc.ID) != DocRoleViewer || store.DocRole(ben.ID, doc.ID) != "" {
t.Errorf("trash is read-only for the owner and hidden from others")
}
if got, err := store.GetDocumentByID(anna.ID, doc.ID); err != nil || !got.IsArchived || got.Permission != "read" {
t.Errorf("owner reads the trashed note read-only: %+v %v", got, err)
}
if _, err := store.GetDocumentByID(ben.ID, doc.ID); err == nil {
t.Errorf("shared user must not open a trashed note")
}
title := "Neu"
if _, err := store.UpdateDocument(anna.ID, doc.ID, &title, nil, nil, nil); err == nil {
t.Errorf("trashed note must not be renamed")
}
trash, _ := store.GetTrash(anna.ID)
if len(trash) != 1 || trash[0].FolderPath != "Projekte/Stadt" || trash[0].DeletedAt == "" {
t.Fatalf("trash list wrong: %+v", trash)
}
if err := store.RestoreDocument(ben.ID, doc.ID); err == nil {
t.Errorf("only the owner may restore")
}
// The folder was deleted meanwhile; restoring creates it again
_, _ = store.db.Exec(`DELETE FROM workspace_folders WHERE user_id = ?`, anna.ID)
if err := store.RestoreDocument(anna.ID, doc.ID); err != nil {
t.Fatalf("restore: %v", err)
}
if !visible(anna.ID) || !visible(ben.ID) || store.DocRole(ben.ID, doc.ID) != DocRoleEditor {
t.Errorf("restored note is back for everyone, shares intact")
}
if folders, _ := store.GetFolders(anna.ID); len(folders) == 0 {
t.Errorf("folder should be created again on restore")
}
// Deleting a folder moves its notes to the trash; emptying deletes them for good
if _, err := store.DeleteFolder(anna.ID, "Projekte"); err != nil {
t.Fatalf("DeleteFolder: %v", err)
}
if trash, _ := store.GetTrash(anna.ID); len(trash) != 1 {
t.Fatalf("folder deletion should trash the note, got %+v", trash)
}
if ids, err := store.EmptyTrash(anna.ID); err != nil || len(ids) != 1 {
t.Fatalf("EmptyTrash: %v %v", ids, err)
}
var docs, shares int
_ = store.db.QueryRow(`SELECT COUNT(*) FROM documents WHERE id = ?`, doc.ID).Scan(&docs)
_ = store.db.QueryRow(`SELECT COUNT(*) FROM doc_shares WHERE doc_id = ?`, doc.ID).Scan(&shares)
if docs+shares != 0 {
t.Errorf("purge must remove the note and its shares")
}
}
+413
View File
@@ -0,0 +1,413 @@
package storage
import (
"errors"
"fmt"
"strings"
"time"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
)
// --- USERS ---
const userSelectCols = `id, email, username, password_hash, display_name, role, is_active, token_version, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, organization_id, created_at`
func parseAlarmChannels(raw string) []string {
parts := strings.Split(raw, ",")
res := make([]string, 0, len(parts))
seen := make(map[string]bool)
for _, p := range parts {
p = strings.ToLower(strings.TrimSpace(p))
if (p == "email" || p == "discord" || p == "webhook") && !seen[p] {
seen[p] = true
res = append(res, p)
}
}
return res
}
func scanUser(row interface{ Scan(dest ...any) error }) (*domain.User, error) {
u := &domain.User{}
var isActiveInt int
var channelsRaw string
err := row.Scan(
&u.ID, &u.Email, &u.Username, &u.PasswordHash, &u.DisplayName,
&u.Role, &isActiveInt, &u.TokenVersion, &channelsRaw, &u.AlarmEmail,
&u.DiscordWebhookURL, &u.CustomWebhookURL, &u.NotifyBeforeMinutes, &u.AvatarURL, &u.OrganizationID, &u.CreatedAt,
)
if err != nil {
return nil, err
}
u.IsActive = isActiveInt != 0
if u.TokenVersion <= 0 {
u.TokenVersion = 1
}
if u.Role == "" {
u.Role = "member"
}
u.AlarmChannels = parseAlarmChannels(channelsRaw)
if u.AlarmEmail == "" {
u.AlarmEmail = u.Email
}
return u, nil
}
func (s *Store) CountUsers() (int, error) {
var count int
err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count)
return count, err
}
func (s *Store) CreateUser(email, passwordHash, displayName, role string) (*domain.User, error) {
cleanEmail := strings.ToLower(strings.TrimSpace(email))
username := strings.Split(cleanEmail, "@")[0]
return s.CreateUserWithUsername(cleanEmail, username, passwordHash, displayName, role)
}
func (s *Store) CreateUserWithUsername(email, username, passwordHash, displayName, role string) (*domain.User, error) {
if role != "admin" && role != "member" {
role = "member"
}
cleanEmail := strings.ToLower(strings.TrimSpace(email))
cleanUsername := strings.ToLower(strings.TrimSpace(username))
if cleanUsername == "" {
cleanUsername = strings.Split(cleanEmail, "@")[0]
}
u := &domain.User{
ID: domain.NewID("usr"),
Email: cleanEmail,
Username: cleanUsername,
PasswordHash: passwordHash,
DisplayName: strings.TrimSpace(displayName),
Role: role,
IsActive: true,
TokenVersion: 1,
AlarmChannels: []string{},
AlarmEmail: cleanEmail,
DiscordWebhookURL: "",
CustomWebhookURL: "",
NotifyBeforeMinutes: 30,
AvatarURL: "",
CreatedAt: domain.NowISO(),
}
_, err := s.db.Exec(
`INSERT INTO users (id, email, username, password_hash, display_name, role, is_active, token_version, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, created_at)
VALUES (?, ?, ?, ?, ?, ?, 1, 1, '', ?, '', '', 30, '', ?)`,
u.ID, u.Email, u.Username, u.PasswordHash, u.DisplayName, u.Role, u.AlarmEmail, u.CreatedAt,
)
if err != nil {
return nil, err
}
return u, nil
}
func (s *Store) GetUserByEmail(email string) (*domain.User, error) {
return s.GetUserByLogin(email)
}
func (s *Store) GetUserByLogin(login string) (*domain.User, error) {
cleanLogin := strings.ToLower(strings.TrimSpace(login))
if cleanLogin == "" {
return nil, errors.New("benutzername oder e-mail darf nicht leer sein")
}
row := s.db.QueryRow(
`SELECT `+userSelectCols+` FROM users
WHERE LOWER(email) = ?
OR (username != '' AND LOWER(username) = ?)
OR LOWER(display_name) = ?
OR (INSTR(email, '@') > 0 AND LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) = ?)
LIMIT 1`,
cleanLogin, cleanLogin, cleanLogin, cleanLogin,
)
return scanUser(row)
}
// EnsureDemoAccounts seeds admin/admin and user/user accounts for demo evaluation
func (s *Store) EnsureDemoAccounts() error {
s.initMu.Lock()
defer s.initMu.Unlock()
adminHash, err := auth.HashPassword("admin")
if err != nil {
return err
}
userHash, err := auth.HashPassword("user")
if err != nil {
return err
}
// 1. Admin user (admin / admin)
adminCreated := false
adminUser, err := s.GetUserByLogin("admin")
if err != nil || adminUser == nil {
adminUser, err = s.CreateUserWithUsername("admin@mindsift.local", "admin", adminHash, "Admin", "admin")
if err != nil {
return fmt.Errorf("failed to create demo admin: %w", err)
}
adminCreated = true
} else {
_ = s.UpdateUserPassword(adminUser.ID, adminHash)
_, _ = s.db.Exec(`UPDATE users SET is_active = 1, role = 'admin' WHERE id = ?`, adminUser.ID)
}
// 2. Regular user (user / user)
normalUser, err := s.GetUserByLogin("user")
if err != nil || normalUser == nil {
normalUser, err = s.CreateUserWithUsername("user@mindsift.local", "user", userHash, "User", "member")
if err != nil {
return fmt.Errorf("failed to create demo user: %w", err)
}
} else {
_ = s.UpdateUserPassword(normalUser.ID, userHash)
_, _ = s.db.Exec(`UPDATE users SET is_active = 1 WHERE id = ?`, normalUser.ID)
}
// 3. Sample data for the admin, partly shared with the demo user (only on first start). Both accounts
// share an organization so they can see each other in the default sharing mode.
if adminCreated {
if org, err := s.CreateOrganization("Demo"); err == nil {
_ = s.SetUserOrganization(adminUser.ID, org.ID)
_ = s.SetUserOrganization(normalUser.ID, org.ID)
}
_ = s.SeedStarterContent(adminUser)
s.seedDemoShares(adminUser, normalUser)
}
return nil
}
func (s *Store) GetUserByID(id string) (*domain.User, error) {
row := s.db.QueryRow(
`SELECT `+userSelectCols+` FROM users WHERE id = ?`,
id,
)
return scanUser(row)
}
func (s *Store) GetFirstUser() (*domain.User, error) {
row := s.db.QueryRow(
`SELECT ` + userSelectCols + ` FROM users ORDER BY created_at ASC LIMIT 1`,
)
return scanUser(row)
}
func (s *Store) GetAllUsers() ([]domain.User, error) {
rows, err := s.db.Query(
`SELECT ` + userSelectCols + ` FROM users ORDER BY created_at ASC`,
)
if err != nil {
return nil, err
}
users := make([]domain.User, 0)
for rows.Next() {
if u, err := scanUser(rows); err == nil {
users = append(users, *u)
}
}
_ = rows.Close()
return users, nil
}
func (s *Store) UpdateUser(
userID string,
displayName, email, role *string,
isActive *bool,
discordWebhookURL *string,
notifyBeforeMinutes *int,
) (*domain.User, error) {
return s.UpdateUserFull(userID, displayName, email, role, isActive, nil, nil, discordWebhookURL, nil, notifyBeforeMinutes, nil)
}
func (s *Store) UpdateUserFull(
userID string,
displayName, email, role *string,
isActive *bool,
alarmChannels *[]string,
alarmEmail, discordWebhookURL, customWebhookURL *string,
notifyBeforeMinutes *int,
avatarURL *string,
) (*domain.User, error) {
u, err := s.GetUserByID(userID)
if err != nil {
return nil, err
}
if displayName != nil {
u.DisplayName = strings.TrimSpace(*displayName)
}
if email != nil {
u.Email = strings.ToLower(strings.TrimSpace(*email))
}
if role != nil && (*role == "admin" || *role == "member") {
u.Role = *role
}
if isActive != nil {
u.IsActive = *isActive
}
if alarmChannels != nil {
u.AlarmChannels = parseAlarmChannels(strings.Join(*alarmChannels, ","))
}
if alarmEmail != nil {
u.AlarmEmail = strings.TrimSpace(*alarmEmail)
}
if discordWebhookURL != nil {
u.DiscordWebhookURL = strings.TrimSpace(*discordWebhookURL)
}
if customWebhookURL != nil {
u.CustomWebhookURL = strings.TrimSpace(*customWebhookURL)
}
if notifyBeforeMinutes != nil {
u.NotifyBeforeMinutes = *notifyBeforeMinutes
}
if avatarURL != nil {
u.AvatarURL = strings.TrimSpace(*avatarURL)
}
if (role != nil && *role != u.Role) || (isActive != nil && !*isActive && u.IsActive) {
u.TokenVersion++
}
activeInt := 0
if u.IsActive {
activeInt = 1
}
channelsStr := strings.Join(u.AlarmChannels, ",")
_, err = s.db.Exec(
`UPDATE users SET display_name = ?, email = ?, role = ?, is_active = ?, token_version = ?, alarm_channels = ?, alarm_email = ?, discord_webhook_url = ?, custom_webhook_url = ?, notify_before_minutes = ?, avatar_url = ? WHERE id = ?`,
u.DisplayName, u.Email, u.Role, activeInt, u.TokenVersion, channelsStr, u.AlarmEmail, u.DiscordWebhookURL, u.CustomWebhookURL, u.NotifyBeforeMinutes, u.AvatarURL, userID,
)
if err != nil {
return nil, err
}
return u, nil
}
func (s *Store) UpdateUserPassword(userID, passwordHash string) error {
_, err := s.db.Exec(`UPDATE users SET password_hash = ?, token_version = token_version + 1 WHERE id = ?`, passwordHash, userID)
return err
}
func (s *Store) DeleteUser(userID string) error {
// Remove owned data properly first (other people's tasks in the user's lists stay theirs)
if err := s.PurgeUserData(userID); err != nil {
return err
}
_, _ = s.db.Exec(`DELETE FROM revoked_tokens WHERE user_id = ?`, userID)
_, _ = s.db.Exec(`DELETE FROM task_assignees WHERE user_id = ?`, userID)
_, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, userID)
// Shares pointing at the deleted account are no longer visible to anyone
s.pruneSharingNow()
return err
}
// RevokeToken adds an individual JWT to the blacklist until its expiration date
func (s *Store) RevokeToken(tokenStr, userID string, expiresAt time.Time) error {
hash := auth.HashAPIKey(tokenStr)
_, err := s.db.Exec(
`INSERT OR REPLACE INTO revoked_tokens (token_hash, user_id, expires_at, revoked_at) VALUES (?, ?, ?, ?)`,
hash, userID, expiresAt.UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339),
)
// Idempotently prune old expired tokens
_, _ = s.db.Exec(`DELETE FROM revoked_tokens WHERE expires_at < ?`, time.Now().UTC().Format(time.RFC3339))
return err
}
// IsTokenRevoked checks if a specific JWT token has been explicitly blacklisted
func (s *Store) IsTokenRevoked(tokenStr string) bool {
hash := auth.HashAPIKey(tokenStr)
var count int
err := s.db.QueryRow(
`SELECT COUNT(1) FROM revoked_tokens WHERE token_hash = ? AND expires_at >= ?`,
hash, time.Now().UTC().Format(time.RFC3339),
).Scan(&count)
return err == nil && count > 0
}
// RevokeAllUserTokens increments the user's token_version, instantly invalidating all existing JWTs for that user
func (s *Store) RevokeAllUserTokens(userID string) error {
_, err := s.db.Exec(`UPDATE users SET token_version = token_version + 1 WHERE id = ?`, userID)
return err
}
// --- USER INVITES ---
func (s *Store) CreateUserInvite(createdBy, email, role, orgID string, validDays int) (*domain.UserInvite, error) {
if role != "admin" && role != "member" {
role = "member"
}
if validDays <= 0 || validDays > 365 {
validDays = 7
}
code := "INV-" + strings.ToUpper(domain.NewID("c")[2:])
now := time.Now().UTC()
inv := &domain.UserInvite{
ID: domain.NewID("inv"),
Code: code,
Email: strings.ToLower(strings.TrimSpace(email)),
Role: role,
OrganizationID: strings.TrimSpace(orgID),
CreatedBy: createdBy,
ExpiresAt: now.AddDate(0, 0, validDays).Format(time.RFC3339),
CreatedAt: now.Format(time.RFC3339),
}
_, err := s.db.Exec(
`INSERT INTO user_invites (id, code, email, role, organization_id, created_by, expires_at, used_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, '', ?)`,
inv.ID, inv.Code, inv.Email, inv.Role, inv.OrganizationID, inv.CreatedBy, inv.ExpiresAt, inv.CreatedAt,
)
if err != nil {
return nil, err
}
return inv, nil
}
func (s *Store) GetUserInvites() ([]domain.UserInvite, error) {
rows, err := s.db.Query(
`SELECT i.id, i.code, i.email, i.role, i.organization_id, COALESCE(o.name, ''), i.created_by, i.expires_at, i.used_by, i.created_at
FROM user_invites i
LEFT JOIN organizations o ON o.id = i.organization_id
ORDER BY i.created_at DESC`,
)
if err != nil {
return []domain.UserInvite{}, err
}
defer rows.Close()
invites := make([]domain.UserInvite, 0)
for rows.Next() {
var inv domain.UserInvite
if err := rows.Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.OrganizationID, &inv.OrganizationName, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt); err == nil {
invites = append(invites, inv)
}
}
return invites, nil
}
func (s *Store) GetUserInviteByCode(code string) (*domain.UserInvite, error) {
code = strings.ToUpper(strings.TrimSpace(code))
var inv domain.UserInvite
err := s.db.QueryRow(
`SELECT id, code, email, role, organization_id, created_by, expires_at, used_by, created_at
FROM user_invites WHERE UPPER(code) = ?`,
code,
).Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.OrganizationID, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt)
if err != nil {
return nil, errors.New("Ungültiger Einladungscode")
}
if inv.UsedBy != "" {
return nil, errors.New("Dieser Einladungscode wurde bereits eingelöst")
}
if exp, err := time.Parse(time.RFC3339, inv.ExpiresAt); err == nil && time.Now().UTC().After(exp) {
return nil, errors.New("Dieser Einladungscode ist abgelaufen")
}
return &inv, nil
}
func (s *Store) MarkUserInviteUsed(inviteID, usedByUserID string) error {
_, err := s.db.Exec(`UPDATE user_invites SET used_by = ? WHERE id = ?`, usedByUserID, inviteID)
return err
}
func (s *Store) DeleteUserInvite(inviteID string) error {
_, err := s.db.Exec(`DELETE FROM user_invites WHERE id = ?`, inviteID)
return err
}
+5 -4
View File
@@ -144,8 +144,9 @@ func TestVisibilityFollowsOrganizations(t *testing.T) {
}
}
func TestOutdatedSchemaIsWiped(t *testing.T) {
path := filepath.Join(t.TempDir(), "old.db")
// Existing data must survive a restart, whatever user_version the database carries.
func TestDataSurvivesRestart(t *testing.T) {
path := filepath.Join(t.TempDir(), "keep.db")
store, err := NewStore(path)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
@@ -159,8 +160,8 @@ func TestOutdatedSchemaIsWiped(t *testing.T) {
t.Fatalf("reopen failed: %v", err)
}
defer store.Close()
if n, _ := store.CountUsers(); n != 0 {
t.Fatalf("outdated database should start empty, found %d users", n)
if n, _ := store.CountUsers(); n != 1 {
t.Fatalf("data must be kept on restart, found %d users", n)
}
var v int
_ = store.db.QueryRow(`PRAGMA user_version`).Scan(&v)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more