15 Commits
Author SHA1 Message Date
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
50 changed files with 2717 additions and 257 deletions

No files matched your search

+6 -2
View File
@@ -30,10 +30,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 | `/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 | `/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 | `/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 | `/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) | | GET/POST | `/themes` | Eigene CSS-Themes; anlegen oder mit `id` ändern (`name`, `css`, max. 200 KB) |
| DELETE | `/themes/{id}` | Eigenes Theme löschen | | 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) | | GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung) |
### Globale Suche (`GET /api/v1/search?q=`) ### Globale Suche (`GET /api/v1/search?q=`)
@@ -67,7 +71,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) | | 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 | `/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 | | DELETE | `/thoughts/discarded/purge` | Papierkorb leeren |
| GET | `/triage/deck` | Posteingang (max. 100) | | GET | `/triage/deck` | Posteingang (max. 100) |
| PATCH | `/triage/{id}` | `action`: `keep`/`discard`/`restore`; mit `convertTo`: `task`/`document`/`list_item` | | PATCH | `/triage/{id}` | `action`: `keep`/`discard`/`restore`; mit `convertTo`: `task`/`document`/`list_item` |
+1 -1
View File
@@ -80,7 +80,7 @@ teams 1─n team_members n─1 users
- `users.username` wird aus dem E-Mail-Präfix aufgefüllt. - `users.username` wird aus dem E-Mail-Präfix aufgefüllt.
- Notiz-Synchronisation (`migrateDocSync`): neue Spalten an `blocks` (inkl. `deleted_at`; vorhandene Grabsteine erhalten den Migrationszeitpunkt) und `documents` (`head_seq`, `title_version`, `log_floor`), Tabellen `doc_ops` und `doc_op_results`. Blöcke im alten flachen Format (nur `level`/`position`) werden einmalig in den Baum umgewandelt. - Notiz-Synchronisation (`migrateDocSync`): neue Spalten an `blocks` (inkl. `deleted_at`; vorhandene Grabsteine erhalten den Migrationszeitpunkt) und `documents` (`head_seq`, `title_version`, `log_floor`), Tabellen `doc_ops` und `doc_op_results`. Blöcke im alten flachen Format (nur `level`/`position`) werden einmalig in den Baum umgewandelt.
- Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=organizations`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer. - Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=organizations`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer.
- **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 ## Sicherung
+1 -1
View File
@@ -115,7 +115,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. - **Maßnahme:** Fälligkeit mit Uhrzeit speichern, `alarm_sent` bei Änderung von `due_date` zurücksetzen, Wiederholungen begrenzen.
### FLAW-13: Migrationen ### 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). - **Fundstellen:** `Store.migrate` in [`internal/storage/sqlite.go`](../internal/storage/sqlite.go), [`migrations/001_init.sql`](../migrations/001_init.sql).
- **Probleme:** - **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. - 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.
+11 -1
View File
@@ -27,6 +27,15 @@ 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)). - 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). - 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). 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 ## 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 `due_date <= heute` und `alarm_sent = 0` sendet er an die Kanäle des Aufgaben-**Eigentümers**:
@@ -41,7 +50,8 @@ 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: **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. - `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. - 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). - `SMTP_SKIP_VERIFY` / Schalter „Zertifikatsprüfung deaktivieren“ akzeptiert selbstsignierte Zertifikate (Verbindung bleibt verschlüsselt).
+15 -1
View File
@@ -61,5 +61,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 `- [ ]`. - 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)“. - 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. - 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.
+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). - 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. - 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 ## 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. 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. - `inline`: im Textfluss.
- Zahlen im mobilen Menü (`MobileActionContext`, `badge: number`) werden automatisch als `CountBadge` gezeigt; Text-Badges wie „2 aktiv“ bleiben Textmarken. - 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. - **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.
+3 -3
View File
@@ -6,7 +6,7 @@
[![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#)
[![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart)
[![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir)
[![Version](https://img.shields.io/badge/Version-v0.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. **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.
@@ -128,7 +128,7 @@ Minimal:
```yaml ```yaml
services: services:
mindsift: 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 restart: unless-stopped
ports: ["8080:8080"] ports: ["8080:8080"]
volumes: ["./data:/data"] volumes: ["./data:/data"]
@@ -185,7 +185,7 @@ npm run test:run # Komponententests (Vitest, jsdom)
npm run build # Produktions-Build npm run build # Produktions-Build
cd .. cd ..
e2e/run.sh # Browser-Tests (Playwright, zwei Benutzer) gegen ein frisch gebautes Image 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. 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.
+9 -2
View File
@@ -3,7 +3,8 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> <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="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="MindSift" /> <meta name="apple-mobile-web-app-title" content="MindSift" />
@@ -49,7 +50,13 @@
</script> </script>
</head> </head>
<body class="bg-[#0b0f19] text-[#e3e3e3] antialiased selection:bg-[#8ab4f8]/30 selection:text-[#8ab4f8]"> <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 type="module" src="/src/main.tsx"></script>
<script> <script>
if ('caches' in window) { if ('caches' in window) {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"version": "0.3.0", "version": "0.4.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"version": "0.3.0", "version": "0.4.0",
"dependencies": { "dependencies": {
"@types/prismjs": "^1.26.6", "@types/prismjs": "^1.26.6",
"emoji-picker-react": "^4.12.0", "emoji-picker-react": "^4.12.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"private": true, "private": true,
"version": "0.3.0", "version": "0.4.0",
"license": "Unlicense", "license": "Unlicense",
"type": "module", "type": "module",
"scripts": { "scripts": {
+4 -4
View File
@@ -1,13 +1,13 @@
{ {
"id": "/", "id": "/",
"name": "MindSift — Der Kopf frei in drei Sekunden", "name": "MindSift",
"short_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": "/", "start_url": "/",
"scope": "/", "scope": "/",
"display": "standalone", "display": "standalone",
"background_color": "#0b0f19", "background_color": "#2563eb",
"theme_color": "#0b0f19", "theme_color": "#2563eb",
"orientation": "portrait-primary", "orientation": "portrait-primary",
"icons": [ "icons": [
{ {
-3
View File
@@ -716,9 +716,6 @@ export const App: React.FC = () => {
</span> </span>
<span className="text-[9px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span> <span className="text-[9px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
</div> </div>
<span className="text-[11px] font-semibold text-accent bg-accent/15 px-1.5 py-0.5 rounded-md">
{activeTabLabelMap[activeTab]}
</span>
<ChevronDown <ChevronDown
className={`w-4 h-4 text-muted transition-transform duration-150 ${ className={`w-4 h-4 text-muted transition-transform duration-150 ${
isMenuOpen ? 'rotate-180 text-accent' : '' isMenuOpen ? 'rotate-180 text-accent' : ''
+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>
);
};
+3
View File
@@ -558,6 +558,9 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
<li> <li>
<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. <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>
<li>
<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> <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">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.
</li> </li>
@@ -15,6 +15,7 @@ import { api, buildWebSocketURL, triggerHaptic, createRealtimeConnection, Realti
import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack'; import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
import { CountBadge } from '../../components/CountBadge'; import { CountBadge } from '../../components/CountBadge';
import { FormSheet } from '../../components/FormSheet';
import { useMobileActions } from '../../context/MobileActionContext'; import { useMobileActions } from '../../context/MobileActionContext';
interface SharedListsViewProps { interface SharedListsViewProps {
@@ -1017,23 +1018,12 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</div> </div>
{showNewListModal && ( {showNewListModal && (
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-4"> <FormSheet
<div className="w-full max-w-sm panel-surface shadow-panel p-4 space-y-3"> title="Neue Einkaufsliste erstellen"
<div className="flex items-center justify-between border-b border-border pb-2"> icon={<ShoppingCart className="w-4 h-4 text-accent" />}
<h3 className="text-sm font-semibold text-main flex items-center gap-1.5"> onClose={() => setShowNewListModal(false)}
<ShoppingCart className="w-4 h-4 text-accent" /> onSubmit={handleCreateList}
<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>
</div>
<form onSubmit={handleCreateList} className="space-y-3">
<div> <div>
<label className="block text-xs text-muted mb-1">Titel der Liste</label> <label className="block text-xs text-muted mb-1">Titel der Liste</label>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
@@ -1042,7 +1032,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
value={newListIcon} value={newListIcon}
onChange={(e) => setNewListIcon(e.target.value)} onChange={(e) => setNewListIcon(e.target.value)}
maxLength={2} maxLength={2}
className="w-10 h-8 text-center input-field text-sm" className="w-12 sm:w-10 h-11 sm:h-8 text-center input-field text-base sm:text-sm"
title="Icon (Emoji)" title="Icon (Emoji)"
/> />
<input <input
@@ -1052,26 +1042,11 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
placeholder="z.B. Supermarkt, Baumarkt, Drogerie" placeholder="z.B. Supermarkt, Baumarkt, Drogerie"
autoFocus autoFocus
required required
className="flex-1 h-8 input-field px-2.5 text-xs" className="flex-1 min-w-0 h-11 sm:h-8 input-field px-2.5 text-base sm:text-xs"
/> />
</div> </div>
</div> </div>
</FormSheet>
<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>
)} )}
{/* Active Shared List View */} {/* Active Shared List View */}
@@ -96,7 +96,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
allowRegistration: false, allowRegistration: false,
instanceName: 'MindSift', instanceName: 'MindSift',
smtpHost: '', smtpHost: '',
smtpPort: 587, smtpPort: '587',
smtpUser: '', smtpUser: '',
smtpPass: '', smtpPass: '',
smtpFrom: '', smtpFrom: '',
@@ -126,7 +126,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
allowRegistration: Boolean(sys.allowRegistration), allowRegistration: Boolean(sys.allowRegistration),
instanceName: sys.instanceName || 'MindSift', instanceName: sys.instanceName || 'MindSift',
smtpHost: sys.smtpHost || '', smtpHost: sys.smtpHost || '',
smtpPort: sys.smtpPort || 587, smtpPort: sys.smtpPort ? String(sys.smtpPort) : '587',
smtpUser: sys.smtpUser || '', smtpUser: sys.smtpUser || '',
smtpPass: sys.smtpPass || '', smtpPass: sys.smtpPass || '',
smtpFrom: sys.smtpFrom || '', smtpFrom: sys.smtpFrom || '',
@@ -1150,10 +1150,12 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
SMTP Port (`SMTP_PORT`) SMTP Port (`SMTP_PORT`)
</label> </label>
<input <input
type="number" type="text"
value={settings.smtpPort || 587} inputMode="numeric"
value={settings.smtpPort ?? ''}
onChange={(e) => 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" placeholder="587"
className="w-full h-9 px-3 input-field font-mono" className="w-full h-9 px-3 input-field font-mono"
@@ -115,11 +115,11 @@ export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
value={newKeyName} value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)} onChange={(e) => setNewKeyName(e.target.value)}
placeholder="Bezeichnung (z. B. Home Assistant, Proxmox Backup, Rauchmelder)…" 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 <button
type="submit" 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" /> <Plus className="w-4 h-4" />
<span>API-Key erzeugen</span> <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, Copy,
} from 'lucide-react'; } from 'lucide-react';
import { APIKey, AuthStatus, User, UserTheme } from '../../lib/types'; 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 { customThemeId, ThemeId, ThemeOption } from '../../lib/theme';
import themeTemplateCss from '../../themes/theme-template.css?raw'; import themeTemplateCss from '../../themes/theme-template.css?raw';
import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar'; import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar';
import { LegalLinks } from '../../components/LegalLinks'; import { LegalLinks } from '../../components/LegalLinks';
import { AboutModal } from '../../components/AboutModal'; import { AboutModal } from '../../components/AboutModal';
import { ImportCard } from './ImportCard';
import { FormSheet } from '../../components/FormSheet';
interface UserSettingsViewProps { interface UserSettingsViewProps {
currentUser: User; currentUser: User;
@@ -53,6 +55,45 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
}) => { }) => {
const [section, setSection] = useState<UserSettingsSection>('profile'); const [section, setSection] = useState<UserSettingsSection>('profile');
const [legal, setLegal] = useState<AuthStatus['legal']>(); 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); const [showAbout, setShowAbout] = useState(false);
useEffect(() => { useEffect(() => {
api.getAuthStatus().then((st) => setLegal(st.legal)).catch(() => {}); api.getAuthStatus().then((st) => setLegal(st.legal)).catch(() => {});
@@ -374,12 +415,16 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
window.setTimeout(() => URL.revokeObjectURL(url), 10000); window.setTimeout(() => URL.revokeObjectURL(url), 10000);
}; };
const [exportingBackup, setExportingBackup] = useState(false);
const handleExportData = async () => { const handleExportData = async () => {
const data = await api.exportAll(); setExportingBackup(true);
const blob = new Blob([JSON.stringify(data, null, 2)], { try {
type: 'application/json;charset=utf-8', saveBlob(await api.exportBackupZip(), `mindsift-backup-${new Date().toISOString().slice(0, 10)}.zip`);
}); } catch (err: any) {
saveBlob(blob, `mindsift-backup-${new Date().toISOString().slice(0, 10)}.json`); setVaultError(err.message || 'Backup fehlgeschlagen');
} finally {
setExportingBackup(false);
}
}; };
const [exportingVault, setExportingVault] = useState(false); const [exportingVault, setExportingVault] = useState(false);
@@ -692,6 +737,49 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</button> </button>
</div> </div>
</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> </div>
)} )}
@@ -1138,11 +1226,11 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
value={newKeyName} value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)} onChange={(e) => setNewKeyName(e.target.value)}
placeholder="Bezeichnung des Schlüssels (z.B. Proxmox Monitoring, Discord-Forwarder)…" 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 <button
type="submit" 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" /> <Plus className="w-3.5 h-3.5" />
<span>Neuen API-Key erzeugen</span> <span>Neuen API-Key erzeugen</span>
@@ -1237,18 +1325,19 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
{/* Full Data Backup */} {/* 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 className="pt-4 border-t border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div> <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]"> <p className="text-muted text-[11px]">
Lädt alle deine Gedanken, Aufgaben, Markdown-Dokumente und Listen als portable Alle deine Gedanken, Aufgaben, Notizen und Listen als JSON, gepackt in eine ZIP-Datei. Über
JSON-Datei herunter. „MindSift-Daten importieren“ lässt es sich vollständig zurückspielen.
</p> </p>
</div> </div>
<button <button
onClick={handleExportData} 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" /> <Download className="w-3.5 h-3.5 text-accent" />
<span>Backup herunterladen</span> <span>{exportingBackup ? 'Wird erstellt…' : 'Backup herunterladen'}</span>
</button> </button>
</div> </div>
@@ -1273,6 +1362,42 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<span>{exportingVault ? 'Wird erstellt…' : 'Markdown-Archiv herunterladen'}</span> <span>{exportingVault ? 'Wird erstellt…' : 'Markdown-Archiv herunterladen'}</span>
</button> </button>
</div> </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>
)} )}
</div> </div>
@@ -1285,6 +1410,35 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</button> </button>
</LegalLinks> </LegalLinks>
{showAbout && <AboutModal onClose={() => setShowAbout(false)} />} {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> </div>
); );
}; };
@@ -17,8 +17,9 @@ import {
Webhook, Webhook,
Lock, Lock,
UserPlus, UserPlus,
Repeat,
} from 'lucide-react'; } 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 { UserAvatar } from '../../components/UserAvatar';
import { UserAutocomplete } from '../UserAutocomplete'; import { UserAutocomplete } from '../UserAutocomplete';
import { triggerHaptic } from '../../lib/api'; import { triggerHaptic } from '../../lib/api';
@@ -34,6 +35,7 @@ interface TaskDetailModalProps {
onUpdateStatus: (task: Thought, newStatus: string) => Promise<void>; onUpdateStatus: (task: Thought, newStatus: string) => Promise<void>;
onUpdatePriority: (task: Thought, newPriority: string) => Promise<void>; onUpdatePriority: (task: Thought, newPriority: string) => Promise<void>;
onUpdateDueDate: (task: Thought, newDueDate: string) => Promise<void>; onUpdateDueDate: (task: Thought, newDueDate: string) => Promise<void>;
onUpdateRecurrence?: (task: Thought, recurrence: Recurrence) => Promise<void>;
onUpdateTaskList: (task: Thought, newListId: string) => Promise<void>; onUpdateTaskList: (task: Thought, newListId: string) => Promise<void>;
onUpdateNotes: (task: Thought, newNotes: string) => Promise<void>; onUpdateNotes: (task: Thought, newNotes: string) => Promise<void>;
onUpdateTags?: (task: Thought, newTags: string[]) => Promise<void>; onUpdateTags?: (task: Thought, newTags: string[]) => Promise<void>;
@@ -58,6 +60,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
onUpdateStatus, onUpdateStatus,
onUpdatePriority, onUpdatePriority,
onUpdateDueDate, onUpdateDueDate,
onUpdateRecurrence,
onUpdateTaskList, onUpdateTaskList,
onUpdateNotes, onUpdateNotes,
onUpdateTags, onUpdateTags,
@@ -509,6 +512,32 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
+1 Woche +1 Woche
</button> </button>
</div> </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> </div>
{/* Task List assignment */} {/* Task List assignment */}
+31 -34
View File
@@ -25,14 +25,16 @@ import {
Folder, Folder,
Filter, Filter,
Webhook, Webhook,
Repeat,
} from 'lucide-react'; } 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 { api, triggerHaptic } from '../../lib/api'; import { api, triggerHaptic } from '../../lib/api';
import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { TaskDetailModal } from './TaskDetailModal'; import { TaskDetailModal } from './TaskDetailModal';
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal'; import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal';
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack'; import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
import { CountBadge } from '../../components/CountBadge'; import { CountBadge } from '../../components/CountBadge';
import { FormSheet } from '../../components/FormSheet';
import { useMobileActions } from '../../context/MobileActionContext'; import { useMobileActions } from '../../context/MobileActionContext';
interface TasksViewProps { interface TasksViewProps {
@@ -503,6 +505,15 @@ export const TasksView: React.FC<TasksViewProps> = ({
await api.updateThought(task.id, { notes: newNotes }); 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[]) => { const handleUpdateTaskAssignees = async (task: Thought, assignees: string[]) => {
triggerHaptic(10); triggerHaptic(10);
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, assignees } : t))); setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, assignees } : t)));
@@ -1305,6 +1316,11 @@ export const TasksView: React.FC<TasksViewProps> = ({
<span>{task.dueDate}</span> <span>{task.dueDate}</span>
</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>
)}
{/* Hashtag badges */} {/* Hashtag badges */}
{task.tags?.map((tag) => ( {task.tags?.map((tag) => (
@@ -2016,22 +2032,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
{/* NEW LIST MODAL */} {/* NEW LIST MODAL */}
{showNewListModal && ( {showNewListModal && (
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-4"> <FormSheet
<div className="w-full max-w-sm panel-surface shadow-panel p-4 space-y-3"> title="Neue Aufgabenliste erstellen"
<div className="flex items-center justify-between border-b border-border pb-2"> icon={<Layers className="w-4 h-4 text-accent" />}
<h3 className="text-sm font-semibold text-main flex items-center gap-1.5"> onClose={() => setShowNewListModal(false)}
<Layers className="w-4 h-4 text-accent" /> onSubmit={handleCreateTaskList}
<span>Neue Aufgabenliste erstellen</span>
</h3>
<button
onClick={() => setShowNewListModal(false)}
className="text-muted hover:text-main"
> >
<X className="w-4 h-4" />
</button>
</div>
<form onSubmit={handleCreateTaskList} className="space-y-3">
<div> <div>
<label className="block text-xs text-muted mb-1">Titel der Liste</label> <label className="block text-xs text-muted mb-1">Titel der Liste</label>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
@@ -2040,7 +2046,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
value={newListIcon} value={newListIcon}
onChange={(e) => setNewListIcon(e.target.value)} onChange={(e) => setNewListIcon(e.target.value)}
maxLength={2} maxLength={2}
className="w-10 h-8 text-center input-field text-sm" className="w-12 sm:w-10 h-11 sm:h-8 text-center input-field text-base sm:text-sm"
title="Icon (Emoji)" title="Icon (Emoji)"
/> />
<input <input
@@ -2050,26 +2056,11 @@ export const TasksView: React.FC<TasksViewProps> = ({
placeholder="z.B. Homelab, Einkäufe, Steuern" placeholder="z.B. Homelab, Einkäufe, Steuern"
autoFocus autoFocus
required required
className="flex-1 h-8 input-field px-2.5 text-xs" className="flex-1 min-w-0 h-11 sm:h-8 input-field px-2.5 text-base sm:text-xs"
/> />
</div> </div>
</div> </div>
</FormSheet>
<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>
)} )}
{/* UNIFIED SHARE LIST MODAL */} {/* UNIFIED SHARE LIST MODAL */}
@@ -2155,6 +2146,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
onUpdateStatus={handleSetKanbanStatus} onUpdateStatus={handleSetKanbanStatus}
onUpdatePriority={handleSetPriority} onUpdatePriority={handleSetPriority}
onUpdateDueDate={handleUpdateTaskDueDate} onUpdateDueDate={handleUpdateTaskDueDate}
onUpdateRecurrence={handleUpdateTaskRecurrence}
onUpdateTaskList={handleUpdateTaskTaskList} onUpdateTaskList={handleUpdateTaskTaskList}
onUpdateNotes={handleUpdateTaskNotes} onUpdateNotes={handleUpdateTaskNotes}
onUpdateTags={handleUpdateTaskTags} onUpdateTags={handleUpdateTaskTags}
@@ -2348,6 +2340,11 @@ export const TasksView: React.FC<TasksViewProps> = ({
<span>{task.dueDate}</span> <span>{task.dueDate}</span>
</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) => ( {task.tags?.map((tag) => (
<button <button
key={tag} key={tag}
@@ -1,37 +1,25 @@
import React, { Suspense, useEffect, useRef, useState } from 'react'; 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 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 // Lazy-load emoji-picker-react so the main bundle size stays small
const EmojiPicker = React.lazy(() => import('emoji-picker-react')); const EmojiPicker = React.lazy(() => import('emoji-picker-react'));
const RECENT_EMOJIS_STORAGE_KEY = 'mindsift_recent_emojis'; // The library's own "recently used" category (stored by it) is the only recent list; a second bar
const MAX_RECENT_EMOJIS = 10; // would cost height on phones. Category names in German.
const CATEGORIES = [
function getStoredRecentEmojis(): string[] { { category: Categories.SUGGESTED, name: 'Zuletzt verwendet' },
try { { category: Categories.SMILEYS_PEOPLE, name: 'Smileys & Personen' },
const raw = localStorage.getItem(RECENT_EMOJIS_STORAGE_KEY); { category: Categories.ANIMALS_NATURE, name: 'Tiere & Natur' },
if (!raw) return ['📝', '💡', '🚀', '⭐', '🔥', '🎯', '📚', '💻']; { category: Categories.FOOD_DRINK, name: 'Essen & Trinken' },
const parsed = JSON.parse(raw); { category: Categories.TRAVEL_PLACES, name: 'Reisen & Orte' },
if (Array.isArray(parsed) && parsed.length > 0) { { category: Categories.ACTIVITIES, name: 'Aktivitäten' },
return parsed.slice(0, MAX_RECENT_EMOJIS); { category: Categories.OBJECTS, name: 'Objekte' },
} { category: Categories.SYMBOLS, name: 'Symbole' },
} catch { { category: Categories.FLAGS, name: 'Flaggen' },
// 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
}
}
interface EmojiPickerModalProps { interface EmojiPickerModalProps {
currentIcon?: string; currentIcon?: string;
@@ -44,7 +32,10 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
onSelectEmoji, onSelectEmoji,
onClose, 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>(() => const [isDark, setIsDark] = useState<boolean>(() =>
typeof document !== 'undefined' typeof document !== 'undefined'
? document.documentElement.classList.contains('dark') ? document.documentElement.classList.contains('dark')
@@ -92,8 +83,6 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
}, [onClose]); }, [onClose]);
const handlePick = (emoji: string) => { const handlePick = (emoji: string) => {
saveRecentEmoji(emoji);
setRecentEmojis(getStoredRecentEmojis());
onSelectEmoji(emoji); onSelectEmoji(emoji);
}; };
@@ -114,7 +103,7 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
<div <div
ref={containerRef} ref={containerRef}
className=" 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 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={ style={
@@ -130,6 +119,8 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
'--epr-focus-bg-color': 'var(--bg-panel-elevated)', '--epr-focus-bg-color': 'var(--bg-panel-elevated)',
'--epr-highlight-color': 'var(--accent-primary)', '--epr-highlight-color': 'var(--accent-primary)',
'--epr-category-icon-active-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 } as React.CSSProperties
} }
> >
@@ -170,30 +161,8 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
</div> </div>
</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 */} {/* 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 <Suspense
fallback={ fallback={
<div className="h-64 flex flex-col items-center justify-center gap-2 text-xs text-muted"> <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…"
searchPlaceHolder="Emoji suchen…" searchPlaceHolder="Emoji suchen…"
width="100%" width="100%"
height="350px" height={`${pickerHeight}px`}
categories={CATEGORIES as any}
previewConfig={{ previewConfig={{
showPreview: false, showPreview: false,
}} }}
@@ -49,6 +49,7 @@ import {
RealtimeTransport, RealtimeTransport,
} from '../../lib/api'; } from '../../lib/api';
import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { FormSheet } from '../../components/FormSheet';
import { sendDocOps } from '../../lib/docSync/oneoff'; import { sendDocOps } from '../../lib/docSync/oneoff';
import { useMobileActions } from '../../context/MobileActionContext'; import { useMobileActions } from '../../context/MobileActionContext';
import { DocumentEditor, DocSaveStatus } from './DocumentEditor'; import { DocumentEditor, DocSaveStatus } from './DocumentEditor';
@@ -340,7 +341,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
if (e) { if (e) {
setNewFolderCoords({ setNewFolderCoords({
x: Math.min(e.clientX, window.innerWidth - 270), 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 { } else {
setNewFolderCoords({ x: 100, y: 150 }); setNewFolderCoords({ x: 100, y: 150 });
@@ -2196,55 +2197,22 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{/* New Folder Popover (small & positioned near click, asks only for name & OK) */} {/* New Folder Popover (small & positioned near click, asks only for name & OK) */}
{showNewFolderModal && ( {showNewFolderModal && (
<div <FormSheet
className="fixed inset-0 z-50 bg-black/20" title={newFolderParentPath ? `Unterordner in "${newFolderParentPath}"` : 'Neuer Ordner'}
onClick={() => setShowNewFolderModal(false)} icon={<FolderPlus className="w-4 h-4 text-accent" />}
> onClose={() => setShowNewFolderModal(false)}
<form
onSubmit={handleCreateFolderSubmit} onSubmit={handleCreateFolderSubmit}
onClick={(e) => e.stopPropagation()} anchor={newFolderCoords}
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 <input
type="text" type="text"
autoFocus autoFocus
value={newFolderName} value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)} onChange={(e) => setNewFolderName(e.target.value)}
placeholder="Ordnername" placeholder="Ordnername"
className="input-field w-full px-2 py-1 text-xs" className="input-field w-full h-11 sm:h-8 px-2.5 text-base sm:text-xs"
/> />
<div className="flex justify-end gap-1.5 pt-1"> </FormSheet>
<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>
)} )}
{/* Unified Sharing Modal (Strictly Read-Only for Docs and Folders) */} {/* Unified Sharing Modal (Strictly Read-Only for Docs and Folders) */}
+5 -2
View File
@@ -484,7 +484,10 @@ body {
min-height: 44px !important; min-height: 44px !important;
} }
.mindsift-emoji-picker .EmojiPickerReact .epr-category-nav button { /* Category icons are sprite cut-outs: they must keep the library's square size (stretching them showed
min-height: 44px !important; 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;
} }
} }
@@ -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);
});
});
+52 -9
View File
@@ -19,6 +19,7 @@ import {
SystemSettings, SystemSettings,
SharingMode, SharingMode,
UserTheme, UserTheme,
ImportSummary,
Team, Team,
TaskList, TaskList,
TaskListShare, TaskListShare,
@@ -134,6 +135,14 @@ export const setCurrentSharingMode = (mode: SharingMode) => {
}; };
export const isSharingDisabled = () => currentSharingMode === 'private'; 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 = { export const api = {
// --- AUTH, FIRST-BOOT SETUP & SETTINGS --- // --- AUTH, FIRST-BOOT SETUP & SETTINGS ---
async getAuthStatus(): Promise<AuthStatus> { async getAuthStatus(): Promise<AuthStatus> {
@@ -200,8 +209,8 @@ export const api = {
return res; return res;
}, },
async getMe(): Promise<{ user: User; stats: DashboardStats }> { async getMe(): Promise<{ user: User; stats: DashboardStats; isLastAdmin?: boolean }> {
return request<{ user: User; stats: DashboardStats }>('/api/v1/auth/me'); return request<{ user: User; stats: DashboardStats; isLastAdmin?: boolean }>('/api/v1/auth/me');
}, },
async updateSettings(patch: { async updateSettings(patch: {
@@ -381,6 +390,7 @@ export const api = {
subtasks?: SubTask[]; subtasks?: SubTask[];
tags?: string[]; tags?: string[];
assignees?: string[]; assignees?: string[];
recurrence?: string;
} }
): Promise<Thought> { ): Promise<Thought> {
return request<Thought>(`/api/v1/thoughts/${thoughtId}`, { return request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
@@ -1076,15 +1086,48 @@ export const api = {
return request<any>('/api/v1/export'); 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 // Obsidian-compatible ZIP of the workspace; binary and possibly large, so no JSON helper and no timeout
async exportMarkdownVault(): Promise<Blob> { async exportMarkdownVault(): Promise<Blob> {
const token = getToken(); return downloadBlob('/api/v1/export/markdown');
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();
}, },
}; };
+12
View File
@@ -132,6 +132,18 @@ function applyToDocument(themeId: ThemeId, customCss: string | null): void {
root.setAttribute('data-theme', attr); root.setAttribute('data-theme', attr);
const light = customCss !== null ? isLightColor(readCssVar(customCss, 'bg-app')) : themeId === 'light'; const light = customCss !== null ? isLightColor(readCssVar(customCss, 'bg-app')) : themeId === 'light';
root.classList.toggle('dark', !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 { function store(key: string, value: string | null): void {
+25 -1
View File
@@ -57,6 +57,16 @@ export interface SubTask {
createdAt?: string; 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 { export interface Thought {
id: string; id: string;
userId: string; userId: string;
@@ -76,6 +86,7 @@ export interface Thought {
notes?: string; notes?: string;
subtasks?: SubTask[]; subtasks?: SubTask[];
assignees?: string[]; // user IDs, only people with access to the task list assignees?: string[]; // user IDs, only people with access to the task list
recurrence?: Recurrence;
alarmSent?: boolean; alarmSent?: boolean;
sortOrder?: number; sortOrder?: number;
createdAt: string; createdAt: string;
@@ -161,7 +172,7 @@ export interface SystemSettings {
allowRegistration: boolean; allowRegistration: boolean;
instanceName: string; instanceName: string;
smtpHost?: string; smtpHost?: string;
smtpPort?: number; smtpPort?: string; // sent as text, like the server stores it
smtpUser?: string; smtpUser?: string;
smtpPass?: string; smtpPass?: string;
smtpFrom?: string; smtpFrom?: string;
@@ -417,6 +428,19 @@ export interface SharedList {
createdAt: string; 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) // Custom CSS theme of the current user (Benutzereinstellungen > Darstellung)
export interface UserTheme { export interface UserTheme {
id: string; 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;
}
+86 -3
View File
@@ -5,6 +5,7 @@ import (
"crypto/rand" "crypto/rand"
"crypto/tls" "crypto/tls"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"mime" "mime"
"mime/quotedprintable" "mime/quotedprintable"
@@ -21,6 +22,8 @@ type smtpSettings struct {
Host, Port, User, Pass, From string Host, Port, User, Pass, From string
TLS bool // 465: implicit TLS, otherwise STARTTLS (required when set) TLS bool // 465: implicit TLS, otherwise STARTTLS (required when set)
SkipVerify bool // accept self-signed / mismatching certificates 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". // 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() return buf.Bytes()
} }
// sendMail runs the SMTP dialogue step by step instead of smtp.SendMail, so it can introduce itself // loginAuth implements AUTH LOGIN (username and password as separate Base64 challenges). Some servers,
// properly, honour SkipVerify and report the exact step a server refuses. // 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 { 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) fromAddr, fromHeader, err := splitAddress(cfg.From)
if err != nil { if err != nil {
return &smtpStepError{"Absender", err} return &smtpStepError{"Absender", err}
@@ -162,7 +245,7 @@ func sendMail(cfg smtpSettings, to, subject, body string) error {
} }
} }
if cfg.User != "" || cfg.Pass != "" { 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} return &smtpStepError{"AUTH", err}
} }
} }
+60 -3
View File
@@ -8,6 +8,7 @@ import (
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"crypto/x509/pkix" "crypto/x509/pkix"
"encoding/base64"
"errors" "errors"
"math/big" "math/big"
"net" "net"
@@ -21,8 +22,13 @@ import (
type fakeSMTP struct { type fakeSMTP struct {
addr string addr string
rcptCode string // e.g. "451 4.3.2 Internal server error" to refuse RCPT TO 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 mu sync.Mutex
authUsed []string // mechanisms tried, in order
authUser string // decoded user of a successful LOGIN
ehlo string ehlo string
mailFrom string mailFrom string
rcptTo string rcptTo string
@@ -53,7 +59,7 @@ func startFakeSMTP(t *testing.T, rcptCode string) *fakeSMTP {
t.Fatalf("listen: %v", err) t.Fatalf("listen: %v", err)
} }
t.Cleanup(func() { _ = ln.Close() }) 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)}} tlsCfg := &tls.Config{Certificates: []tls.Certificate{selfSignedCert(t)}}
go func() { go func() {
for { for {
@@ -86,9 +92,9 @@ func (f *fakeSMTP) serve(conn net.Conn, tlsCfg *tls.Config) {
f.ehlo = cmd[5:] f.ehlo = cmd[5:]
f.mu.Unlock() f.mu.Unlock()
if secure { if secure {
reply("250-fake\r\n250 AUTH PLAIN") reply("250-fake\r\n250 AUTH " + f.authMechs)
} else { } 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": case upper == "STARTTLS":
reply("220 go ahead") reply("220 go ahead")
@@ -99,7 +105,27 @@ func (f *fakeSMTP) serve(conn net.Conn, tlsCfg *tls.Config) {
conn, secure = tlsConn, true conn, secure = tlsConn, true
r, w = bufio.NewReader(conn), bufio.NewWriter(conn) r, w = bufio.NewReader(conn), bufio.NewWriter(conn)
case strings.HasPrefix(upper, "AUTH "): case strings.HasPrefix(upper, "AUTH "):
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") reply("235 ok")
}
case strings.HasPrefix(upper, "MAIL FROM"): case strings.HasPrefix(upper, "MAIL FROM"):
f.mu.Lock() f.mu.Lock()
f.mailFrom = strings.TrimSpace(cmd[len("MAIL FROM:"):]) 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) 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)
}
}
+142
View File
@@ -43,6 +43,10 @@ func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS
notifier: collab.NewNotifier(), notifier: collab.NewNotifier(),
} }
wsHub.SetRoomAuthorizer(srv.canJoinRoom) wsHub.SetRoomAuthorizer(srv.canJoinRoom)
// Next occurrences of repeating tasks appear live on every connected client
store.OnTaskCreated = func(th *domain.Thought) {
srv.wsHub.BroadcastToUsers(srv.taskAudience(th), "TASK_CREATED", th)
}
go srv.startAlarmTicker() go srv.startAlarmTicker()
go srv.startCompaction() go srv.startCompaction()
return srv return srv
@@ -110,6 +114,10 @@ func (s *Server) Router() http.Handler {
r.Get("/api/v1/search", s.handleSearch) r.Get("/api/v1/search", s.handleSearch)
r.Get("/api/v1/export", s.handleExport) r.Get("/api/v1/export", s.handleExport)
r.Get("/api/v1/export/markdown", s.handleExportMarkdown) r.Get("/api/v1/export/markdown", s.handleExportMarkdown)
r.Post("/api/v1/import/mindsift", s.handleImportMindSift)
r.Delete("/api/v1/user/data", s.handlePurgeOwnData)
r.Delete("/api/v1/user/account", s.handleDeleteOwnAccount)
r.Post("/api/v1/import/obsidian", s.handleImportObsidian)
r.Get("/api/v1/themes", s.handleGetThemes) r.Get("/api/v1/themes", s.handleGetThemes)
r.Post("/api/v1/themes", s.handleSaveTheme) r.Post("/api/v1/themes", s.handleSaveTheme)
r.Delete("/api/v1/themes/{id}", s.handleDeleteTheme) r.Delete("/api/v1/themes/{id}", s.handleDeleteTheme)
@@ -708,6 +716,8 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{ writeJSON(w, http.StatusOK, map[string]interface{}{
"user": user, "user": user,
"stats": stats, "stats": stats,
// The settings disable "Konto löschen" for the last administrator
"isLastAdmin": s.store.IsLastAdmin(userID),
}) })
} }
@@ -951,6 +961,7 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
Subtasks *[]domain.SubTask `json:"subtasks"` Subtasks *[]domain.SubTask `json:"subtasks"`
Tags *[]string `json:"tags"` Tags *[]string `json:"tags"`
Assignees *[]string `json:"assignees"` Assignees *[]string `json:"assignees"`
Recurrence *string `json:"recurrence"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON") writeError(w, http.StatusBadRequest, "Ungültiges JSON")
@@ -958,6 +969,13 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
} }
before, _ := s.store.GetThoughtByID(userID, thoughtID) 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) 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 { if err != nil {
writeStoreError(w, http.StatusBadRequest, err) writeStoreError(w, http.StatusBadRequest, err)
@@ -2115,6 +2133,121 @@ func (s *Server) handleDeleteTheme(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) 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
}
// 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
}
// 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). // handleExportMarkdown streams an Obsidian-compatible ZIP of the user's workspace (no temp files).
func (s *Server) handleExportMarkdown(w http.ResponseWriter, r *http.Request) { func (s *Server) handleExportMarkdown(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context()) userID := auth.GetUserID(r.Context())
@@ -2133,6 +2266,15 @@ func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return 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") w.Header().Set("Content-Disposition", "attachment; filename=mindsift-export.json")
writeJSON(w, http.StatusOK, exportData) writeJSON(w, http.StatusOK, exportData)
} }
+2 -1
View File
@@ -1369,7 +1369,8 @@ func TestFirstBootSetupWithSampleData(t *testing.T) {
// Impressum and Datenschutz must be reachable without login; unset texts stay hidden. // Impressum and Datenschutz must be reachable without login; unset texts stay hidden.
func TestLegalTextsArePublic(t *testing.T) { 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) store, err := storage.NewStore(dbPath)
if err != nil { if err != nil {
t.Fatalf("NewStore: %v", err) t.Fatalf("NewStore: %v", err)
+32
View File
@@ -3,6 +3,7 @@ package domain
import ( import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"encoding/json"
"fmt" "fmt"
"regexp" "regexp"
"strings" "strings"
@@ -183,6 +184,7 @@ type Thought struct {
Notes string `json:"notes,omitempty"` Notes string `json:"notes,omitempty"`
Subtasks []SubTask `json:"subtasks"` 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"` AlarmSent bool `json:"alarmSent,omitempty"`
SortOrder int `json:"sortOrder"` SortOrder int `json:"sortOrder"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
@@ -289,6 +291,36 @@ type SystemSettings struct {
SMTPConfiguredViaEnv bool `json:"smtpConfiguredViaEnv,omitempty"` 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 { type DocumentBlock struct {
ID string `json:"id"` ID string `json:"id"`
DocumentID string `json:"documentId,omitempty"` DocumentID string `json:"documentId,omitempty"`
+17
View File
@@ -1,6 +1,7 @@
package domain package domain
import ( import (
"encoding/json"
"reflect" "reflect"
"testing" "testing"
) )
@@ -32,3 +33,19 @@ func TestStaleAnchorTags(t *testing.T) {
t.Fatalf("only tags that exist solely as anchors are stale, got %v", got) 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")
}
}
+1 -1
View File
@@ -482,7 +482,7 @@ func (s *Store) ReconcileDocTaskLinks(docID string) []string {
// GetThoughtByIDUnchecked loads a task without access check, for server-internal notifications. // GetThoughtByIDUnchecked loads a task without access check, for server-internal notifications.
func (s *Store) GetThoughtByIDUnchecked(thoughtID string) (*domain.Thought, error) { func (s *Store) GetThoughtByIDUnchecked(thoughtID string) (*domain.Thought, error) {
return scanThought(s.db.QueryRow( 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)) FROM thoughts WHERE id = ?`, thoughtID))
} }
+6
View File
@@ -118,6 +118,9 @@ func (s *Store) writeVaultLists(zw *zip.Writer, userID string, names *vaultNames
return nil 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) // taskMarkdownLine writes a task in the format of the Obsidian Tasks plugin (📅 due date, ⏫/🔺 priority)
// with its subtasks and notes indented below. // with its subtasks and notes indented below.
func taskMarkdownLine(t domain.Thought) string { func taskMarkdownLine(t domain.Thought) string {
@@ -134,6 +137,9 @@ func taskMarkdownLine(t domain.Thought) string {
case "low": case "low":
line += " 🔽" line += " 🔽"
} }
if every, ok := recurrenceEvery[t.Recurrence]; ok {
line += " 🔁 every " + every
}
if t.DueDate != "" { if t.DueDate != "" {
line += " 📅 " + t.DueDate line += " 📅 " + t.DueDate
} }
+872
View File
@@ -0,0 +1,872 @@
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})`)
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]
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)
}
+129
View File
@@ -0,0 +1,129 @@
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 today when empty) one interval forward. 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 {
base, err := time.Parse("2006-01-02", due)
if err != nil {
base = time.Date(today.Year(), today.Month(), today.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)
}
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)
}
}
+97
View File
@@ -0,0 +1,97 @@
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"},
}
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")
}
}
+44 -26
View File
@@ -6,7 +6,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
@@ -23,6 +22,10 @@ import (
type Store struct { type Store struct {
db *sql.DB db *sql.DB
initMu sync.Mutex initMu sync.Mutex
// OnTaskCreated is told about tasks the store creates by itself (next occurrence of a repeating
// task), so the server can push them to connected clients.
OnTaskCreated func(*domain.Thought)
} }
func NewStore(dbPath string) (*Store, error) { func NewStore(dbPath string) (*Store, error) {
@@ -58,22 +61,11 @@ func (s *Store) Close() error {
return s.db.Close() return s.db.Close()
} }
// schemaVersion is stored in PRAGMA user_version. Databases from an older version are wiped and rebuilt // schemaVersion is stored in PRAGMA user_version so later releases can tell which migrations a database
// on start: MindSift is not in production yet, so breaking changes need no migration path. // already has. (Version 2 once wiped older databases on start; all installations are past that now.)
const schemaVersion = 2 const schemaVersion = 2
func (s *Store) resetOutdatedSchema() error { // dropAllTables removes every table; used by the demo mode reset.
var version int
if err := s.db.QueryRow(`PRAGMA user_version`).Scan(&version); err != nil {
return err
}
if version >= schemaVersion {
return nil
}
slog.Warn("Veraltetes Datenbankschema: alle Daten werden gelöscht und neu angelegt", "version", version, "neu", schemaVersion)
return s.dropAllTables()
}
func (s *Store) dropAllTables() error { func (s *Store) dropAllTables() error {
conn, err := s.db.Conn(context.Background()) conn, err := s.db.Conn(context.Background())
if err != nil { if err != nil {
@@ -107,9 +99,6 @@ func (s *Store) dropAllTables() error {
} }
func (s *Store) migrate() error { func (s *Store) migrate() error {
if err := s.resetOutdatedSchema(); err != nil {
return fmt.Errorf("reset outdated schema: %w", err)
}
schema := ` schema := `
PRAGMA journal_mode = WAL; PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL; PRAGMA synchronous = NORMAL;
@@ -469,6 +458,10 @@ func (s *Store) migrate() error {
// Who added an item to a (shopping) list // Who added an item to a (shopping) list
"ALTER TABLE list_items ADD COLUMN created_by TEXT NOT NULL DEFAULT '';", "ALTER TABLE list_items ADD COLUMN created_by TEXT NOT NULL DEFAULT '';",
// Repeating tasks: daily | weekly | monthly | yearly
"ALTER TABLE thoughts ADD COLUMN recurrence TEXT NOT NULL DEFAULT '';",
"ALTER TABLE thoughts ADD COLUMN recurrence_spawned INTEGER NOT NULL DEFAULT 0;",
} }
for _, q := range columnMigrations { for _, q := range columnMigrations {
_, _ = s.db.Exec(q) _, _ = s.db.Exec(q)
@@ -824,6 +817,10 @@ func (s *Store) UpdateUserPassword(userID, passwordHash string) error {
} }
func (s *Store) DeleteUser(userID string) error { 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 revoked_tokens WHERE user_id = ?`, userID)
_, _ = s.db.Exec(`DELETE FROM task_assignees 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) _, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, userID)
@@ -949,7 +946,7 @@ func scanThought(rows interface {
&t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source, &t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source,
&statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID, &statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID,
&t.DueDate, &t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent, &t.DueDate, &t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent,
&t.CreatedAt, &t.TriagedAt, &subtasksStr, &sortOrder, &t.CreatedAt, &t.TriagedAt, &subtasksStr, &sortOrder, &t.Recurrence,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -992,7 +989,7 @@ func (s *Store) accessibleTaskListIDsQuery() string {
func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) { func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) {
row := s.db.QueryRow( 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 `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()+`))`, FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`,
thoughtID, userID, userID, userID, userID, thoughtID, userID, userID, userID, userID,
) )
@@ -1013,14 +1010,14 @@ func (s *Store) GetThoughts(userID, statusFilter string, limit int) ([]domain.Th
if statusFilter != "" && statusFilter != "all" { if statusFilter != "" && statusFilter != "all" {
rows, err = s.db.Query( 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 `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 = ? 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 ?`, 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, userID, statusFilter, limit,
) )
} else { } else {
rows, err = s.db.Query( 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 `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 = ? FROM thoughts WHERE user_id = ?
ORDER BY sort_order ASC, created_at DESC LIMIT ?`, ORDER BY sort_order ASC, created_at DESC LIMIT ?`,
userID, limit, userID, limit,
@@ -1046,7 +1043,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
var err error var err error
if taskListID != "" { if taskListID != "" {
rows, err = s.db.Query( 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 `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 FROM thoughts
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`)) WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
AND task_list_id = ? AND task_list_id = ?
@@ -1056,7 +1053,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
) )
} else { } else {
rows, err = s.db.Query( 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 `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 FROM thoughts
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`)) WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task')) AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
@@ -1087,11 +1084,17 @@ func (s *Store) ReorderTasks(taskListID string, items []domain.ThoughtReorderIte
} }
defer tx.Rollback() defer tx.Rollback()
var completed []string // tasks that move into "done" now (repeating ones spawn their successor)
for _, it := range items { for _, it := range items {
if it.KanbanStatus != nil && *it.KanbanStatus != "" { if it.KanbanStatus != nil && *it.KanbanStatus != "" {
isCompleted := 0 isCompleted := 0
if *it.KanbanStatus == "done" { if *it.KanbanStatus == "done" {
isCompleted = 1 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( _, err = tx.Exec(
`UPDATE thoughts SET sort_order = ?, kanban_status = ?, is_completed = ? WHERE id = ?`, `UPDATE thoughts SET sort_order = ?, kanban_status = ?, is_completed = ? WHERE id = ?`,
@@ -1108,7 +1111,13 @@ func (s *Store) ReorderTasks(taskListID string, items []domain.ThoughtReorderIte
} }
} }
return tx.Commit() 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 // UpdateThoughtTriage handles swipe left (discard), swipe right (keep), restore, and conversion to task/document/list_item
@@ -1242,6 +1251,7 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string,
if err != nil { if err != nil {
return nil, err return nil, err
} }
wasCompleted := thought.IsCompleted
if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil { if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil {
return nil, err return nil, err
} }
@@ -1314,6 +1324,9 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string,
s.PruneTaskAssignees() s.PruneTaskAssignees()
thought.Assignees = s.getTaskAssignees(thoughtID) thought.Assignees = s.getTaskAssignees(thoughtID)
} }
if !wasCompleted && thought.IsCompleted {
s.spawnNextOccurrence(thoughtID)
}
return thought, nil return thought, nil
} }
@@ -3953,7 +3966,12 @@ func (s *Store) SyncTaskFromBlockCheck(userID, taskID string, checked bool) {
doneInt = 1 doneInt = 1
kanban = "done" kanban = "done"
} }
_, _ = s.db.Exec(`UPDATE thoughts SET is_completed = ?, kanban_status = ? WHERE id = ? AND user_id = ?`, doneInt, kanban, taskID, userID) res, _ := s.db.Exec(`UPDATE thoughts SET is_completed = ?, kanban_status = ? WHERE id = ? AND user_id = ? AND is_completed != ?`, doneInt, kanban, taskID, userID, doneInt)
if checked && res != nil {
if n, _ := res.RowsAffected(); n > 0 {
s.spawnNextOccurrence(taskID)
}
}
} }
func (s *Store) DeleteListItem(itemID string) (string, error) { func (s *Store) DeleteListItem(itemID string) (string, error) {
+5 -4
View File
@@ -144,8 +144,9 @@ func TestVisibilityFollowsOrganizations(t *testing.T) {
} }
} }
func TestOutdatedSchemaIsWiped(t *testing.T) { // Existing data must survive a restart, whatever user_version the database carries.
path := filepath.Join(t.TempDir(), "old.db") func TestDataSurvivesRestart(t *testing.T) {
path := filepath.Join(t.TempDir(), "keep.db")
store, err := NewStore(path) store, err := NewStore(path)
if err != nil { if err != nil {
t.Fatalf("NewStore failed: %v", err) t.Fatalf("NewStore failed: %v", err)
@@ -159,8 +160,8 @@ func TestOutdatedSchemaIsWiped(t *testing.T) {
t.Fatalf("reopen failed: %v", err) t.Fatalf("reopen failed: %v", err)
} }
defer store.Close() defer store.Close()
if n, _ := store.CountUsers(); n != 0 { if n, _ := store.CountUsers(); n != 1 {
t.Fatalf("outdated database should start empty, found %d users", n) t.Fatalf("data must be kept on restart, found %d users", n)
} }
var v int var v int
_ = store.db.QueryRow(`PRAGMA user_version`).Scan(&v) _ = store.db.QueryRow(`PRAGMA user_version`).Scan(&v)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 344 KiB

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 362 KiB

After

Width:  |  Height:  |  Size: 362 KiB