Compare commits
No files matched your search
@@ -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 | `/stats` | Zähler fürs Dashboard |
|
||||
| GET | `/search?q=` | Globale FTS5-Suche in zugänglichen Notizen (Titel & Blöcke), Aufgaben und Listen → `{thoughts, documents, listItems, hits}` |
|
||||
| GET | `/export` | Datenexport als JSON |
|
||||
| GET | `/export` | Datenexport als JSON; mit `?format=zip` dieselben Daten als `mindsift-backup.json` in einer ZIP (so lädt die Oberfläche das Backup) |
|
||||
| GET | `/export/markdown` | Obsidian-kompatibles ZIP (`mindsift-vault-DATUM.zip`), direkt gestreamt: Notizen als `.md` mit YAML-Frontmatter in der Ordnerstruktur des Nutzers (inkl. geteilter), `Aufgaben/<Liste>.md` im Format des Tasks-Plugins (`📅`, `⏫`/`🔺`), `Listen/<Liste>.md` |
|
||||
| GET/POST | `/themes` | Eigene CSS-Themes; anlegen oder mit `id` ändern (`name`, `css`, max. 200 KB) |
|
||||
| DELETE | `/themes/{id}` | Eigenes Theme löschen |
|
||||
| POST | `/import/mindsift`, `/import/obsidian` | Import (Multipart-Feld `file`, max. 100 MB): MindSift-JSON/-Archiv bzw. Obsidian-Vault-ZIP; `?preview=1` liefert nur die Zusammenfassung (`folders`, `notes`, `taskLists`, `tasks`, `lists`, `listItems`, `inbox`, `skipped`) |
|
||||
| — | `/import/mindsift?mode=restore` | Wiederherstellen: löscht vor dem Import alle eigenen Notizen, Ordner, Aufgaben, Gedanken und Listen (erst nachdem die Datei vollständig gelesen und geprüft ist) |
|
||||
| DELETE | `/user/data` | Alle eigenen Daten löschen, Konto bleibt (`{"password"}` nötig) |
|
||||
| DELETE | `/user/account` | Eigenes Konto mit allen Daten löschen (`{"password"}` nötig); `403` für den letzten Server-Admin. `/auth/me` liefert dafür `isLastAdmin` |
|
||||
| GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung) |
|
||||
|
||||
### Globale Suche (`GET /api/v1/search?q=`)
|
||||
@@ -67,7 +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) |
|
||||
| GET | `/thoughts?status=` | Gedanken (`inbox`, `kept`, `converted`, `discarded`, `all`) |
|
||||
| GET/PATCH/DELETE | `/thoughts/{id}` | Einzelabruf; Teiländerung (`content`, `isCompleted`, `dueDate`, `priority`, `taskListId`, `kanbanStatus`, `notes`, `subtasks`, `tags`, `assignees`); Löschen. `assignees` ersetzt die Zuweisungen; nur Personen mit Zugriff auf die Liste, sonst `403` |
|
||||
| GET/PATCH/DELETE | `/thoughts/{id}` | Einzelabruf; Teiländerung (`content`, `isCompleted`, `dueDate`, `priority`, `taskListId`, `kanbanStatus`, `notes`, `subtasks`, `tags`, `assignees`, `recurrence` = `daily`/`weekly`/`monthly`/`yearly`/leer); Löschen. `assignees` ersetzt die Zuweisungen; nur Personen mit Zugriff auf die Liste, sonst `403` |
|
||||
| DELETE | `/thoughts/discarded/purge` | Papierkorb leeren |
|
||||
| GET | `/triage/deck` | Posteingang (max. 100) |
|
||||
| PATCH | `/triage/{id}` | `action`: `keep`/`discard`/`restore`; mit `convertTo`: `task`/`document`/`list_item` |
|
||||
|
||||
@@ -80,7 +80,7 @@ teams 1─n team_members n─1 users
|
||||
- `users.username` wird aus dem E-Mail-Präfix aufgefüllt.
|
||||
- Notiz-Synchronisation (`migrateDocSync`): neue Spalten an `blocks` (inkl. `deleted_at`; vorhandene Grabsteine erhalten den Migrationszeitpunkt) und `documents` (`head_seq`, `title_version`, `log_floor`), Tabellen `doc_ops` und `doc_op_results`. Blöcke im alten flachen Format (nur `level`/`position`) werden einmalig in den Baum umgewandelt.
|
||||
- Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=organizations`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer.
|
||||
- **Schema-Version:** `PRAGMA user_version` hält `schemaVersion` (derzeit 2). Ist die Datenbank älter, werden vor allem anderen **alle Tabellen gelöscht** und neu angelegt. Bewusst so, solange MindSift nicht produktiv läuft.
|
||||
- **Schema-Version:** `PRAGMA user_version` hält `schemaVersion` (derzeit 2), damit spätere Versionen erkennen, welche Migrationen eine Datenbank schon hat. Daten werden beim Start nie gelöscht (das einmalige Zurücksetzen auf Version 2 ist entfernt, alle Installationen sind darüber hinweg). Nur der Demo-Modus leert die Datenbank bei jedem Start.
|
||||
|
||||
## Sicherung
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
### FLAW-13: Migrationen
|
||||
- **Teilweise behoben:** Das Schema hat eine Version (`PRAGMA user_version`, `schemaVersion` in `sqlite.go`). Ist eine Datenbank älter, wird sie beim Start **komplett gelöscht** und neu angelegt (vor dem produktiven Einsatz bewusst so gewählt). Die übrigen Punkte unten gelten weiter.
|
||||
- **Teilweise behoben:** Das Schema hat eine Version (`PRAGMA user_version`, `schemaVersion` in `sqlite.go`), auf die künftige Migrationen aufsetzen können. Die übrigen Punkte unten gelten weiter.
|
||||
- **Fundstellen:** `Store.migrate` in [`internal/storage/sqlite.go`](../internal/storage/sqlite.go), [`migrations/001_init.sql`](../migrations/001_init.sql).
|
||||
- **Probleme:**
|
||||
- Es gibt keine Versionstabelle. Alle `ALTER TABLE`-Anweisungen laufen bei jedem Start erneut; Fehler werden pauschal ignoriert (`_, _ = s.db.Exec(q)`), echte Fehler fallen nicht auf.
|
||||
|
||||
@@ -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)).
|
||||
- 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
|
||||
|
||||
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:
|
||||
|
||||
- Verbindung (Port 465: TLS von Anfang an), `EHLO` mit der Domain der Absenderadresse (nie `localhost`, das lehnen strenge Server ab), bei `SMTP_TLS` Pflicht-STARTTLS, `AUTH PLAIN`, `MAIL FROM`, `RCPT TO`, Daten.
|
||||
- Verbindung (Port 465: TLS von Anfang an), `EHLO` mit der Domain der Absenderadresse (nie `localhost`, das lehnen strenge Server ab), bei `SMTP_TLS` Pflicht-STARTTLS, Anmeldung, `MAIL FROM`, `RCPT TO`, Daten.
|
||||
- Anmeldung: `AUTH PLAIN`; bietet der Server nur `LOGIN` an, direkt `AUTH LOGIN`. Lehnt er PLAIN ab und bietet auch LOGIN an, baut MindSift die Verbindung neu auf und versucht `AUTH LOGIN` (Go schließt die Verbindung nach einem gescheiterten AUTH). Scheitern beide mit `451`, nennt die Meldung beide Versuche und verweist auf das SASL-Backend des Mailservers (z. B. Dovecot-Auth), denn dann liegt der Fehler dort. Nie unverschlüsselt an fremde Hosts.
|
||||
- `SMTP_FROM` darf einen Namen enthalten (`MindSift <alarm@domain>`): im Umschlag steht nur die Adresse, im Header Name und Adresse.
|
||||
- Die Nachricht hat `Date` und `Message-ID`, einen nach RFC 2047 kodierten Betreff (Umlaute, Emoji) und einen Quoted-Printable-Text.
|
||||
- `SMTP_SKIP_VERIFY` / Schalter „Zertifikatsprüfung deaktivieren“ akzeptiert selbstsignierte Zertifikate (Verbindung bleibt verschlüsselt).
|
||||
|
||||
@@ -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 `- [ ]`.
|
||||
- Dateinamen werden für alle Betriebssysteme bereinigt (keine `/ \ : * ? " < > |`, nie `.`/`..`); doppelte Titel bekommen „ (2)“.
|
||||
- Das ZIP wird beim Lesen der Daten direkt in die Antwort geschrieben, ohne temporäre Dateien.
|
||||
- Ein Import aus ZIP gibt es noch nicht.
|
||||
|
||||
## Import
|
||||
|
||||
Kontoeinstellungen → Private Webhooks & Export → „Daten importieren“, mit **zwei getrennten Wegen** (Code: [import_markdown.go](../../internal/storage/import_markdown.go), [ImportCard.tsx](../../frontend/src/features/settings/ImportCard.tsx)):
|
||||
|
||||
| Weg | Endpunkt | Dateien | Ergebnis |
|
||||
|---|---|---|---|
|
||||
| MindSift-Daten | `POST /import/mindsift` | Backup-ZIP oder JSON (`/export`) oder Markdown-Archiv (`/export/markdown`), das Format wird erkannt | Notizen in ihre Ordner, Aufgabenlisten mit Unteraufgaben, Fälligkeit, Priorität und Notizen, Listen mit Menge, Kategorie und Erledigt-Status, beim JSON-Backup auch die Gedanken der Triage. Fremde, nur mit dem Exporteur geteilte Inhalte im JSON werden übersprungen. |
|
||||
| Obsidian-Vault | `POST /import/obsidian` | ZIP eines Vaults | Alles in einen neuen Ordner mit dem Namen des Vaults, Unterordner bleiben. Checkboxen bleiben Checkboxen, Frontmatter-Tags werden zu `#Hashtags`, Callouts zu Hinweisboxen, Wikilinks bleiben `[[…]]`. Versteckte Ordner (`.obsidian`, `.trash`) werden ignoriert, Anhänge übersprungen. |
|
||||
|
||||
- **Vorschau:** Die Oberfläche lädt die Datei zuerst mit `?preview=1` hoch. Der Server liest und prüft alles und meldet, was angelegt würde (inkl. übersprungener Dateien mit Grund), schreibt aber nichts. Erst „Jetzt importieren“ legt an.
|
||||
- **Hinzufügen oder Wiederherstellen** (nur MindSift-Daten): „Hinzufügen“ legt nur neu an, Bestehendes bleibt unverändert. „Wiederherstellen“ (`?mode=restore`, in der Oberfläche nur nach bestätigtem Warnhinweis) löscht vorher alle eigenen Notizen, Ordner, Aufgaben, Gedanken und Listen – erst nachdem die Datei vollständig gelesen und geprüft ist, eine kaputte Datei löscht also nichts. Mit dem Nutzer geteilte Inhalte anderer bleiben. Ein Obsidian-Import fügt immer hinzu.
|
||||
- **Markdown → Notiz:** Listen behalten ihre Verschachtelung (Tabs oder Leerzeichen), Inhalte unter einer Überschrift werden darunter eingerückt (Gegenstück zum Export), Absätze werden zu Punkten, Codeblöcke zu Codeblöcken.
|
||||
- **Grenzen:** Upload max. 100 MB, eine Notiz max. 10 MB, entpackt zusammen max. 500 MB, höchstens 20 000 Dateien. Pfade mit `..` oder absolute Pfade werden entschärft und bleiben im Zielordner.
|
||||
- Bricht ein Import wegen eines Fehlers ab, bleibt das bis dahin Angelegte erhalten; die Meldung nennt das Element, an dem es scheiterte.
|
||||
|
||||
@@ -39,6 +39,14 @@ Komponente: [ToggleSwitch.tsx](../frontend/src/components/ToggleSwitch.tsx)
|
||||
- Ein/Aus-Einstellungen sind immer Schalter, keine Checkbox-Haken (Einrichtung, Admin-Bereich).
|
||||
- Text links (Titel, darunter eine kurze Erklärung in `text-muted`), Schalter rechts.
|
||||
|
||||
## Kleine Dialoge (Anlegen, Umbenennen)
|
||||
|
||||
Komponente: [FormSheet.tsx](../frontend/src/components/FormSheet.tsx), Tastatur-Abstand über [useKeyboardInset.ts](../frontend/src/lib/useKeyboardInset.ts)
|
||||
|
||||
- **Handy:** Bottom-Sheet über die volle Breite, das direkt über der Bildschirmtastatur sitzt (der Abstand kommt aus `visualViewport`), Griff oben, Felder und Buttons mindestens 44 px hoch, Eingaben in 16 px (sonst zoomt iOS), Abbrechen/Erstellen nebeneinander über die ganze Breite.
|
||||
- **Desktop:** zentriertes Fenster, oder mit `anchor` neben der Klickposition (z. B. „Neuer Ordner“ im Explorer).
|
||||
- Escape und Klick auf den Hintergrund schließen. Genutzt für neue Aufgabenliste, neue Einkaufsliste und neuen Ordner; das Emoji-Sheet nutzt denselben Tastatur-Abstand.
|
||||
|
||||
## Zähler
|
||||
|
||||
Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Klassen `count-badge*` in [index.css](../frontend/src/index.css). Es gibt keine anderen Zähler-Stile mehr.
|
||||
@@ -53,3 +61,10 @@ Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Kla
|
||||
- `inline`: im Textfluss.
|
||||
- Zahlen im mobilen Menü (`MobileActionContext`, `badge: number`) werden automatisch als `CountBadge` gezeigt; Text-Badges wie „2 aktiv“ bleiben Textmarken.
|
||||
- **Wichtig für neue Klassen:** Tailwind entfernt Klassen aus `@layer components`, die nicht wörtlich im Code stehen. Klassennamen deshalb nie zusammensetzen (`count-badge-${variant}`), sondern ausschreiben.
|
||||
|
||||
## Ladebildschirm & Statusleiste
|
||||
|
||||
- `manifest.json`: `name` „MindSift“ (kurz, der Slogan steht in `description`), `background_color` und `theme_color` `#2563eb` – Android baut daraus den Splash-Screen.
|
||||
- `index.html` enthält in `#root` einen reinen HTML-Platzhalter (vollflächig Blau, Logo in der Mitte, kein Text). React ersetzt ihn beim ersten Rendern, dazwischen blitzt nichts Weißes auf.
|
||||
- `meta theme-color` startet blau und wird von [`theme.ts`](../frontend/src/lib/theme.ts) nach dem Start und bei jedem Theme-Wechsel auf `--bg-app` des aktiven Themes gesetzt, damit die Statusleiste zum Header passt.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
[](#)
|
||||
[](#-schnellstart)
|
||||
[](#-deine-daten-gehören-dir)
|
||||
[](#-schnellstart)
|
||||
[](#-schnellstart)
|
||||
|
||||
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
||||
|
||||
@@ -128,7 +128,7 @@ Minimal:
|
||||
```yaml
|
||||
services:
|
||||
mindsift:
|
||||
image: git.dsit.me/sandrodach/mindsift:stable # oder feste Version (:v0.3); :latest = main, :dev = Entwicklungsstand
|
||||
image: git.dsit.me/sandrodach/mindsift:stable # oder feste Version (:v0.4); :latest = main, :dev = Entwicklungsstand
|
||||
restart: unless-stopped
|
||||
ports: ["8080:8080"]
|
||||
volumes: ["./data:/data"]
|
||||
@@ -185,7 +185,7 @@ npm run test:run # Komponententests (Vitest, jsdom)
|
||||
npm run build # Produktions-Build
|
||||
cd ..
|
||||
e2e/run.sh # Browser-Tests (Playwright, zwei Benutzer) gegen ein frisch gebautes Image
|
||||
APP_VERSION=v0.3 e2e/screenshots.sh # Screenshots dieser README neu erzeugen (Demo-Daten)
|
||||
APP_VERSION=v0.4 e2e/screenshots.sh # Screenshots dieser README neu erzeugen (Demo-Daten)
|
||||
```
|
||||
|
||||
Für das Binary wird das gebaute Frontend nach `cmd/server/dist` kopiert (`go:embed`); das mehrstufige `Dockerfile` erledigt das und führt Typprüfung, Frontend-Tests, Go-Vet und Go-Tests beim Image-Build aus.
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="theme-color" content="#0b0f19" />
|
||||
<!-- Blue while loading (matches the splash screen); the app switches it to the theme background -->
|
||||
<meta name="theme-color" content="#2563eb" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="MindSift" />
|
||||
@@ -49,7 +50,13 @@
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-[#0b0f19] text-[#e3e3e3] antialiased selection:bg-[#8ab4f8]/30 selection:text-[#8ab4f8]">
|
||||
<div id="root"></div>
|
||||
<!-- Shown until React mounts and replaces it: full blue with the logo, no text, no white flash -->
|
||||
<div id="root">
|
||||
<div style="position:fixed;inset:0;background:#2563eb;display:flex;align-items:center;justify-content:center">
|
||||
<!-- the icon's own blue square disappears on blue, so the visible brain is ~55% of this size -->
|
||||
<img src="/icon.svg" width="128" height="128" alt="" style="display:block" />
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script>
|
||||
if ('caches' in window) {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mindsift-frontend",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"dependencies": {
|
||||
"@types/prismjs": "^1.26.6",
|
||||
"emoji-picker-react": "^4.12.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"private": true,
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"license": "Unlicense",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "MindSift — Der Kopf frei in drei Sekunden",
|
||||
"name": "MindSift",
|
||||
"short_name": "MindSift",
|
||||
"description": "Instant Brain-Dump, Tinder-Style Triage, Workspace & Echtzeit-Familienlisten.",
|
||||
"description": "MindSift — Der Kopf frei in drei Sekunden. Instant Brain-Dump, Tinder-Style Triage, Workspace & Echtzeit-Familienlisten.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0b0f19",
|
||||
"theme_color": "#0b0f19",
|
||||
"background_color": "#2563eb",
|
||||
"theme_color": "#2563eb",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
|
||||
@@ -716,9 +716,6 @@ export const App: React.FC = () => {
|
||||
</span>
|
||||
<span className="text-[9px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
|
||||
</div>
|
||||
<span className="text-[11px] font-semibold text-accent bg-accent/15 px-1.5 py-0.5 rounded-md">
|
||||
{activeTabLabelMap[activeTab]}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-muted transition-transform duration-150 ${
|
||||
isMenuOpen ? 'rotate-180 text-accent' : ''
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -558,6 +558,9 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
||||
<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.
|
||||
</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>
|
||||
<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>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { api, buildWebSocketURL, triggerHaptic, createRealtimeConnection, Realti
|
||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||
import { CountBadge } from '../../components/CountBadge';
|
||||
import { FormSheet } from '../../components/FormSheet';
|
||||
import { useMobileActions } from '../../context/MobileActionContext';
|
||||
|
||||
interface SharedListsViewProps {
|
||||
@@ -1017,23 +1018,12 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
</div>
|
||||
|
||||
{showNewListModal && (
|
||||
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm panel-surface shadow-panel p-4 space-y-3">
|
||||
<div className="flex items-center justify-between border-b border-border pb-2">
|
||||
<h3 className="text-sm font-semibold text-main flex items-center gap-1.5">
|
||||
<ShoppingCart className="w-4 h-4 text-accent" />
|
||||
<span>Neue Einkaufsliste erstellen</span>
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewListModal(false)}
|
||||
className="text-muted hover:text-main"
|
||||
<FormSheet
|
||||
title="Neue Einkaufsliste erstellen"
|
||||
icon={<ShoppingCart className="w-4 h-4 text-accent" />}
|
||||
onClose={() => setShowNewListModal(false)}
|
||||
onSubmit={handleCreateList}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreateList} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -1042,7 +1032,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
value={newListIcon}
|
||||
onChange={(e) => setNewListIcon(e.target.value)}
|
||||
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)"
|
||||
/>
|
||||
<input
|
||||
@@ -1052,26 +1042,11 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
placeholder="z.B. Supermarkt, Baumarkt, Drogerie"
|
||||
autoFocus
|
||||
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 className="flex items-center justify-end gap-2 pt-2 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewListModal(false)}
|
||||
className="btn-action h-7 px-3 text-xs"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" className="btn-primary h-7 px-3 text-xs">
|
||||
Erstellen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</FormSheet>
|
||||
)}
|
||||
|
||||
{/* Active Shared List View */}
|
||||
|
||||
@@ -96,7 +96,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
allowRegistration: false,
|
||||
instanceName: 'MindSift',
|
||||
smtpHost: '',
|
||||
smtpPort: 587,
|
||||
smtpPort: '587',
|
||||
smtpUser: '',
|
||||
smtpPass: '',
|
||||
smtpFrom: '',
|
||||
@@ -126,7 +126,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
allowRegistration: Boolean(sys.allowRegistration),
|
||||
instanceName: sys.instanceName || 'MindSift',
|
||||
smtpHost: sys.smtpHost || '',
|
||||
smtpPort: sys.smtpPort || 587,
|
||||
smtpPort: sys.smtpPort ? String(sys.smtpPort) : '587',
|
||||
smtpUser: sys.smtpUser || '',
|
||||
smtpPass: sys.smtpPass || '',
|
||||
smtpFrom: sys.smtpFrom || '',
|
||||
@@ -1150,10 +1150,12 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
SMTP Port (`SMTP_PORT`)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.smtpPort || 587}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={settings.smtpPort ?? ''}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({ ...prev, smtpPort: Number(e.target.value) }))
|
||||
// Digits only; an empty field stays empty while typing (saved as 587)
|
||||
setSettings((prev) => ({ ...prev, smtpPort: e.target.value.replace(/\D/g, '').slice(0, 5) }))
|
||||
}
|
||||
placeholder="587"
|
||||
className="w-full h-9 px-3 input-field font-mono"
|
||||
|
||||
@@ -115,11 +115,11 @@ export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
placeholder="Bezeichnung (z. B. Home Assistant, Proxmox Backup, Rauchmelder)…"
|
||||
className="flex-1 h-9 px-3 input-field text-sm"
|
||||
className="w-full sm:flex-1 min-w-0 h-11 sm:h-9 px-3 input-field text-base sm:text-sm"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary h-9 px-4 text-xs sm:text-sm"
|
||||
className="btn-primary h-11 sm:h-9 px-4 text-sm flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>API-Key erzeugen</span>
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Upload, Check, AlertCircle, FileArchive } from 'lucide-react';
|
||||
import { ImportSummary } from '../../lib/types';
|
||||
import { api } from '../../lib/api';
|
||||
|
||||
interface ImportCardProps {
|
||||
source: 'mindsift' | 'obsidian';
|
||||
title: string;
|
||||
description: React.ReactNode;
|
||||
accept: string;
|
||||
}
|
||||
|
||||
const summaryParts = (s: ImportSummary) =>
|
||||
[
|
||||
s.folders && `${s.folders} Ordner`,
|
||||
s.notes && `${s.notes} ${s.notes === 1 ? 'Notiz' : 'Notizen'}`,
|
||||
s.taskLists && `${s.taskLists} Aufgabenliste${s.taskLists === 1 ? '' : 'n'}`,
|
||||
s.tasks && `${s.tasks} Aufgabe${s.tasks === 1 ? '' : 'n'}`,
|
||||
s.lists && `${s.lists} Liste${s.lists === 1 ? '' : 'n'} mit ${s.listItems} Einträgen`,
|
||||
s.inbox && `${s.inbox} Gedanke${s.inbox === 1 ? '' : 'n'} für die Triage`,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
// One import path: drop or pick a file, see a preview of what would be created, then import.
|
||||
// Touch targets are large enough for phones; the file picker works on mobile browsers.
|
||||
export const ImportCard: React.FC<ImportCardProps> = ({ source, title, description, accept }) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [preview, setPreview] = useState<ImportSummary | null>(null);
|
||||
const [result, setResult] = useState<ImportSummary | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
// MindSift data can replace the current state instead of being added next to it
|
||||
const [mode, setMode] = useState<'merge' | 'restore'>('merge');
|
||||
const [confirmRestore, setConfirmRestore] = useState(false);
|
||||
const restoring = source === 'mindsift' && mode === 'restore';
|
||||
|
||||
const reset = () => {
|
||||
setFile(null);
|
||||
setPreview(null);
|
||||
setError(null);
|
||||
setConfirmRestore(false);
|
||||
};
|
||||
|
||||
const pick = async (f?: File) => {
|
||||
if (!f) return;
|
||||
reset();
|
||||
setResult(null);
|
||||
setFile(f);
|
||||
setBusy(true);
|
||||
try {
|
||||
setPreview(await api.importData(source, f, true));
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Datei konnte nicht gelesen werden');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runImport = async () => {
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setResult(await api.importData(source, file, false, restoring ? 'restore' : 'merge'));
|
||||
setFile(null);
|
||||
setPreview(null);
|
||||
setConfirmRestore(false);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Import fehlgeschlagen');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const shown = result || preview;
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
|
||||
<div>
|
||||
<h4 className="font-bold text-main">{title}</h4>
|
||||
<p className="text-muted text-[11px] mt-0.5">{description}</p>
|
||||
</div>
|
||||
|
||||
{source === 'mindsift' && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2" role="radiogroup" aria-label="Import-Modus">
|
||||
{(
|
||||
[
|
||||
['merge', 'Hinzufügen', 'Bestehende Daten behalten, Backup daneben anlegen.'],
|
||||
['restore', 'Wiederherstellen', 'Eigene Notizen, Aufgaben und Listen vorher löschen.'],
|
||||
] as const
|
||||
).map(([value, label, text]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={mode === value}
|
||||
onClick={() => {
|
||||
setMode(value);
|
||||
setConfirmRestore(false);
|
||||
}}
|
||||
className={`p-2.5 min-h-[44px] text-left border rounded-card transition-colors ${
|
||||
mode === value
|
||||
? value === 'restore'
|
||||
? 'border-status-err bg-status-err/10'
|
||||
: 'border-accent bg-accent/10'
|
||||
: 'border-border hover:border-strong'
|
||||
}`}
|
||||
>
|
||||
<span className={`block text-xs font-semibold ${value === 'restore' && mode === value ? 'text-status-err' : 'text-main'}`}>{label}</span>
|
||||
<span className="block text-[11px] text-muted">{text}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
pick(e.dataTransfer.files?.[0]);
|
||||
}}
|
||||
className={`p-4 border border-dashed rounded-card flex flex-col sm:flex-row items-center justify-center gap-3 text-center transition-colors ${
|
||||
dragOver ? 'border-accent bg-accent/10' : 'border-border'
|
||||
}`}
|
||||
>
|
||||
<span className="text-[11px] text-muted hidden sm:inline">Datei hierher ziehen oder</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="btn-action h-11 sm:h-9 px-4 text-xs flex items-center justify-center gap-2 disabled:opacity-50 w-full sm:w-auto"
|
||||
>
|
||||
<Upload className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Datei auswählen</span>
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
pick(e.target.files?.[0]);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{busy && <p className="text-[11px] text-muted">{preview ? 'Importiere…' : 'Lese Datei…'}</p>}
|
||||
{error && (
|
||||
<p className="text-[11px] text-status-err flex items-start gap-1.5">
|
||||
<AlertCircle className="w-3.5 h-3.5 shrink-0 mt-px" />
|
||||
<span>{error}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{shown && !busy && (
|
||||
<div
|
||||
className={`p-3 rounded-card border text-xs space-y-2 ${
|
||||
result ? 'border-status-ok/40 bg-status-ok/10' : 'border-border bg-panel'
|
||||
}`}
|
||||
>
|
||||
<p className="font-semibold text-main flex items-center gap-1.5">
|
||||
{result ? <Check className="w-3.5 h-3.5 text-status-ok" /> : <FileArchive className="w-3.5 h-3.5 text-accent" />}
|
||||
<span>{result ? 'Import abgeschlossen' : `Vorschau: ${file?.name}`}</span>
|
||||
</p>
|
||||
<p className="text-muted">
|
||||
{result ? 'Angelegt: ' : 'Wird angelegt: '}
|
||||
{summaryParts(shown).join(', ') || 'nichts'}.
|
||||
</p>
|
||||
{shown.skipped.length > 0 && (
|
||||
<details className="text-[11px] text-muted">
|
||||
<summary className="cursor-pointer">{shown.skipped.length} übersprungen</summary>
|
||||
<ul className="mt-1 space-y-0.5 max-h-40 overflow-y-auto">
|
||||
{shown.skipped.map((s, i) => (
|
||||
<li key={i}>
|
||||
<span className="font-mono text-main">{s.path}</span> – {s.reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
{!result && restoring && (
|
||||
<label className="flex items-start gap-2 p-2.5 rounded-card border border-status-err/40 bg-status-err/10 text-[11px] text-main cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmRestore}
|
||||
onChange={(e) => setConfirmRestore(e.target.checked)}
|
||||
className="mt-0.5 w-4 h-4 shrink-0"
|
||||
/>
|
||||
<span>
|
||||
<strong className="text-status-err">Achtung:</strong> Alle deine bisherigen Notizen, Ordner, Aufgaben, Gedanken und
|
||||
Listen werden vor dem Import unwiderruflich gelöscht und durch das Backup ersetzt. Mit dir geteilte Inhalte anderer
|
||||
bleiben erhalten.
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
{!result && (
|
||||
<div className="flex flex-col sm:flex-row gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={runImport}
|
||||
disabled={restoring && !confirmRestore}
|
||||
className={`h-11 sm:h-8 px-4 text-xs font-semibold disabled:opacity-50 ${restoring ? 'btn-action border-status-err text-status-err' : 'btn-primary'}`}
|
||||
>
|
||||
{restoring ? 'Löschen und wiederherstellen' : 'Jetzt importieren'}
|
||||
</button>
|
||||
<button type="button" onClick={reset} className="btn-action h-11 sm:h-8 px-3 text-xs">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -21,12 +21,14 @@ import {
|
||||
Copy,
|
||||
} from 'lucide-react';
|
||||
import { APIKey, AuthStatus, User, UserTheme } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { api, clearToken, triggerHaptic } from '../../lib/api';
|
||||
import { customThemeId, ThemeId, ThemeOption } from '../../lib/theme';
|
||||
import themeTemplateCss from '../../themes/theme-template.css?raw';
|
||||
import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar';
|
||||
import { LegalLinks } from '../../components/LegalLinks';
|
||||
import { AboutModal } from '../../components/AboutModal';
|
||||
import { ImportCard } from './ImportCard';
|
||||
import { FormSheet } from '../../components/FormSheet';
|
||||
|
||||
interface UserSettingsViewProps {
|
||||
currentUser: User;
|
||||
@@ -53,6 +55,45 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
}) => {
|
||||
const [section, setSection] = useState<UserSettingsSection>('profile');
|
||||
const [legal, setLegal] = useState<AuthStatus['legal']>();
|
||||
|
||||
// --- Danger zone ---
|
||||
const [dangerAction, setDangerAction] = useState<'data' | 'account' | null>(null);
|
||||
const [dangerPassword, setDangerPassword] = useState('');
|
||||
const [dangerError, setDangerError] = useState<string | null>(null);
|
||||
const [dangerBusy, setDangerBusy] = useState(false);
|
||||
const [dangerDone, setDangerDone] = useState<string | null>(null);
|
||||
const [isLastAdmin, setIsLastAdmin] = useState(false);
|
||||
useEffect(() => {
|
||||
api.getMe().then((me) => setIsLastAdmin(Boolean(me.isLastAdmin))).catch(() => {});
|
||||
}, []);
|
||||
const openDanger = (action: 'data' | 'account') => {
|
||||
setDangerAction(action);
|
||||
setDangerPassword('');
|
||||
setDangerError(null);
|
||||
setDangerDone(null);
|
||||
};
|
||||
const handleDangerSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setDangerBusy(true);
|
||||
setDangerError(null);
|
||||
try {
|
||||
if (dangerAction === 'account') {
|
||||
await api.deleteOwnAccount(dangerPassword);
|
||||
// The account is gone: drop the session and show the login screen
|
||||
clearToken();
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
await api.purgeOwnData(dangerPassword);
|
||||
setDangerAction(null);
|
||||
setDangerDone('Alle deine Daten wurden gelöscht.');
|
||||
onUserUpdated(currentUser);
|
||||
} catch (err: any) {
|
||||
setDangerError(err.message || 'Aktion fehlgeschlagen');
|
||||
} finally {
|
||||
setDangerBusy(false);
|
||||
}
|
||||
};
|
||||
const [showAbout, setShowAbout] = useState(false);
|
||||
useEffect(() => {
|
||||
api.getAuthStatus().then((st) => setLegal(st.legal)).catch(() => {});
|
||||
@@ -374,12 +415,16 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 10000);
|
||||
};
|
||||
|
||||
const [exportingBackup, setExportingBackup] = useState(false);
|
||||
const handleExportData = async () => {
|
||||
const data = await api.exportAll();
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||
type: 'application/json;charset=utf-8',
|
||||
});
|
||||
saveBlob(blob, `mindsift-backup-${new Date().toISOString().slice(0, 10)}.json`);
|
||||
setExportingBackup(true);
|
||||
try {
|
||||
saveBlob(await api.exportBackupZip(), `mindsift-backup-${new Date().toISOString().slice(0, 10)}.zip`);
|
||||
} catch (err: any) {
|
||||
setVaultError(err.message || 'Backup fehlgeschlagen');
|
||||
} finally {
|
||||
setExportingBackup(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [exportingVault, setExportingVault] = useState(false);
|
||||
@@ -692,6 +737,49 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Danger zone: delete own data or the whole account (password required) */}
|
||||
<div className="p-4 border border-status-err/40 bg-status-err/5 rounded-card space-y-3">
|
||||
<h3 className="font-bold text-status-err flex items-center gap-1.5">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<span>Gefahrenzone</span>
|
||||
</h3>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-main font-medium">Alle eigenen Daten löschen</p>
|
||||
<p className="text-[11px] text-muted">
|
||||
Löscht deine Notizen, Ordner, Aufgaben, Gedanken und Listen. Konto, Passwort und Einstellungen bleiben.
|
||||
Mit dir geteilte Inhalte anderer bleiben erhalten.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openDanger('data')}
|
||||
className="btn-danger h-11 sm:h-8 px-3 text-xs shrink-0"
|
||||
>
|
||||
Daten löschen
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pt-3 border-t border-status-err/20">
|
||||
<div>
|
||||
<p className="text-main font-medium">Konto unwiderruflich löschen</p>
|
||||
<p className="text-[11px] text-muted">
|
||||
{isLastAdmin
|
||||
? 'Der letzte Server-Administrator kann nicht gelöscht werden. Bitte ernenne vorher ein anderes Konto zum Administrator.'
|
||||
: 'Löscht dein Konto mit allen Daten. Du wirst danach abgemeldet.'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openDanger('account')}
|
||||
disabled={isLastAdmin}
|
||||
className="btn-danger h-11 sm:h-8 px-3 text-xs shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Konto löschen
|
||||
</button>
|
||||
</div>
|
||||
{dangerDone && <p className="text-[11px] text-status-ok">{dangerDone}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1138,11 +1226,11 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
placeholder="Bezeichnung des Schlüssels (z.B. Proxmox Monitoring, Discord-Forwarder)…"
|
||||
className="flex-1 h-9 px-3 input-field"
|
||||
className="w-full sm:flex-1 min-w-0 h-11 sm:h-9 px-3 input-field text-base sm:text-xs"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary h-9 px-4 text-xs font-semibold flex items-center justify-center gap-1.5"
|
||||
className="btn-primary h-11 sm:h-9 px-4 text-sm sm:text-xs font-semibold flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Neuen API-Key erzeugen</span>
|
||||
@@ -1237,18 +1325,19 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
{/* Full Data Backup */}
|
||||
<div className="pt-4 border-t border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-bold text-main">Vollständiger Daten-Export (JSON Backup)</h3>
|
||||
<h3 className="font-bold text-main">Vollständiges Backup (.zip)</h3>
|
||||
<p className="text-muted text-[11px]">
|
||||
Lädt alle deine Gedanken, Aufgaben, Markdown-Dokumente und Listen als portable
|
||||
JSON-Datei herunter.
|
||||
Alle deine Gedanken, Aufgaben, Notizen und Listen als JSON, gepackt in eine ZIP-Datei. Über
|
||||
„MindSift-Daten importieren“ lässt es sich vollständig zurückspielen.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExportData}
|
||||
className="btn-action h-9 px-4 text-xs flex items-center gap-2 flex-shrink-0"
|
||||
disabled={exportingBackup}
|
||||
className="btn-action h-11 sm:h-9 px-4 text-xs flex items-center justify-center gap-2 flex-shrink-0 disabled:opacity-50"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Backup herunterladen</span>
|
||||
<span>{exportingBackup ? 'Wird erstellt…' : 'Backup herunterladen'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1273,6 +1362,42 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
<span>{exportingVault ? 'Wird erstellt…' : 'Markdown-Archiv herunterladen'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Import: two separate paths, never guessed from the file */}
|
||||
<div className="pt-4 border-t border-border space-y-3">
|
||||
<div>
|
||||
<h3 className="font-bold text-main">Daten importieren</h3>
|
||||
<p className="text-muted text-[11px]">
|
||||
Ein Import legt alles neu an und ändert nichts Bestehendes. Vor dem Import siehst du, was angelegt wird.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
<ImportCard
|
||||
source="mindsift"
|
||||
title="MindSift-Daten"
|
||||
accept=".json,.zip,application/json,application/zip"
|
||||
description={
|
||||
<>
|
||||
Eigenes Backup zurückspielen: JSON-Backup (<code className="font-mono">.json</code>) oder Markdown-Archiv
|
||||
(<code className="font-mono">.zip</code>). Notizen kommen in ihre Ordner, Aufgabenlisten mit Unteraufgaben,
|
||||
Fälligkeiten und Prioritäten sowie Listen werden wiederhergestellt.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<ImportCard
|
||||
source="obsidian"
|
||||
title="Obsidian-Vault"
|
||||
accept=".zip,application/zip"
|
||||
description={
|
||||
<>
|
||||
Vault als <code className="font-mono">.zip</code>. Alle Notizen landen in einem eigenen Ordner mit dem Namen
|
||||
des Vaults, Unterordner bleiben erhalten. Checkboxen bleiben Checkboxen in der Notiz, Frontmatter-Tags werden zu
|
||||
#Hashtags. Bilder und andere Anhänge werden übersprungen.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1285,6 +1410,35 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
</button>
|
||||
</LegalLinks>
|
||||
{showAbout && <AboutModal onClose={() => setShowAbout(false)} />}
|
||||
{dangerAction && (
|
||||
<FormSheet
|
||||
title={dangerAction === 'account' ? 'Konto unwiderruflich löschen' : 'Alle eigenen Daten löschen'}
|
||||
icon={<AlertCircle className="w-4 h-4 text-status-err" />}
|
||||
onClose={() => setDangerAction(null)}
|
||||
onSubmit={handleDangerSubmit}
|
||||
submitLabel={dangerAction === 'account' ? 'Konto löschen' : 'Daten löschen'}
|
||||
danger
|
||||
busy={dangerBusy || !dangerPassword}
|
||||
>
|
||||
<p className="text-xs text-muted">
|
||||
{dangerAction === 'account'
|
||||
? 'Dein Konto und alle deine Daten werden endgültig gelöscht. Das lässt sich nicht rückgängig machen.'
|
||||
: 'Alle deine Notizen, Ordner, Aufgaben, Gedanken und Listen werden endgültig gelöscht. Lade vorher ein Backup herunter, wenn du sie behalten willst.'}
|
||||
</p>
|
||||
<label className="block text-xs text-muted">
|
||||
Zur Bestätigung dein Passwort
|
||||
<input
|
||||
type="password"
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
value={dangerPassword}
|
||||
onChange={(e) => setDangerPassword(e.target.value)}
|
||||
className="mt-1 input-field w-full h-11 sm:h-8 px-2.5 text-base sm:text-xs"
|
||||
/>
|
||||
</label>
|
||||
{dangerError && <p className="text-[11px] text-status-err">{dangerError}</p>}
|
||||
</FormSheet>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -17,8 +17,9 @@ import {
|
||||
Webhook,
|
||||
Lock,
|
||||
UserPlus,
|
||||
Repeat,
|
||||
} from 'lucide-react';
|
||||
import { Thought, SubTask, TaskList, TaskListMember, KanbanColumn, User } from '../../lib/types';
|
||||
import { Thought, SubTask, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types';
|
||||
import { UserAvatar } from '../../components/UserAvatar';
|
||||
import { UserAutocomplete } from '../UserAutocomplete';
|
||||
import { triggerHaptic } from '../../lib/api';
|
||||
@@ -34,6 +35,7 @@ interface TaskDetailModalProps {
|
||||
onUpdateStatus: (task: Thought, newStatus: string) => Promise<void>;
|
||||
onUpdatePriority: (task: Thought, newPriority: string) => Promise<void>;
|
||||
onUpdateDueDate: (task: Thought, newDueDate: string) => Promise<void>;
|
||||
onUpdateRecurrence?: (task: Thought, recurrence: Recurrence) => Promise<void>;
|
||||
onUpdateTaskList: (task: Thought, newListId: string) => Promise<void>;
|
||||
onUpdateNotes: (task: Thought, newNotes: string) => Promise<void>;
|
||||
onUpdateTags?: (task: Thought, newTags: string[]) => Promise<void>;
|
||||
@@ -58,6 +60,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
onUpdateStatus,
|
||||
onUpdatePriority,
|
||||
onUpdateDueDate,
|
||||
onUpdateRecurrence,
|
||||
onUpdateTaskList,
|
||||
onUpdateNotes,
|
||||
onUpdateTags,
|
||||
@@ -509,6 +512,32 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
+1 Woche
|
||||
</button>
|
||||
</div>
|
||||
{onUpdateRecurrence && (
|
||||
<div className="pt-1">
|
||||
<label className="flex items-center gap-1 text-[11px] text-muted mb-1">
|
||||
<Repeat className="w-3 h-3 text-accent" />
|
||||
<span>Wiederholung</span>
|
||||
</label>
|
||||
<select
|
||||
value={task.recurrence || ''}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onUpdateRecurrence(task, e.target.value as Recurrence)}
|
||||
className="w-full h-11 sm:h-8 input-field px-2 text-base sm:text-xs bg-panel"
|
||||
>
|
||||
<option value="">Keine Wiederholung</option>
|
||||
<option value="daily">🔁 Täglich</option>
|
||||
<option value="weekly">🔁 Wöchentlich</option>
|
||||
<option value="monthly">🔁 Monatlich</option>
|
||||
<option value="yearly">🔁 Jährlich</option>
|
||||
</select>
|
||||
{task.recurrence && (
|
||||
<p className="text-[10px] text-faint mt-1">
|
||||
Beim Erledigen entsteht automatisch die nächste Aufgabe, fällig{' '}
|
||||
{task.dueDate ? 'ein Intervall nach diesem Datum' : 'ein Intervall nach dem Erledigen'}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task List assignment */}
|
||||
|
||||
@@ -25,14 +25,16 @@ import {
|
||||
Folder,
|
||||
Filter,
|
||||
Webhook,
|
||||
Repeat,
|
||||
} from 'lucide-react';
|
||||
import { TaskList, Thought, SubTask, Team, User, TaskListShare, TaskListMember, KanbanColumn, PriorityLevel } from '../../lib/types';
|
||||
import { TaskList, Thought, SubTask, Team, User, TaskListShare, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
import { TaskDetailModal } from './TaskDetailModal';
|
||||
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal';
|
||||
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||
import { CountBadge } from '../../components/CountBadge';
|
||||
import { FormSheet } from '../../components/FormSheet';
|
||||
import { useMobileActions } from '../../context/MobileActionContext';
|
||||
|
||||
interface TasksViewProps {
|
||||
@@ -503,6 +505,15 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
await api.updateThought(task.id, { notes: newNotes });
|
||||
};
|
||||
|
||||
const handleUpdateTaskRecurrence = async (task: Thought, recurrence: Recurrence) => {
|
||||
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, recurrence } : t)));
|
||||
try {
|
||||
await api.updateThought(task.id, { recurrence });
|
||||
} catch {
|
||||
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, recurrence: task.recurrence } : t)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateTaskAssignees = async (task: Thought, assignees: string[]) => {
|
||||
triggerHaptic(10);
|
||||
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, assignees } : t)));
|
||||
@@ -1305,6 +1316,11 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
<span>{task.dueDate}</span>
|
||||
</span>
|
||||
)}
|
||||
{task.recurrence && (
|
||||
<span className="inline-flex items-center text-muted" title={`Wiederholt sich ${RECURRENCE_LABELS[task.recurrence]}`}>
|
||||
<Repeat className="w-3 h-3" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Hashtag badges */}
|
||||
{task.tags?.map((tag) => (
|
||||
@@ -2016,22 +2032,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
|
||||
{/* NEW LIST MODAL */}
|
||||
{showNewListModal && (
|
||||
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm panel-surface shadow-panel p-4 space-y-3">
|
||||
<div className="flex items-center justify-between border-b border-border pb-2">
|
||||
<h3 className="text-sm font-semibold text-main flex items-center gap-1.5">
|
||||
<Layers className="w-4 h-4 text-accent" />
|
||||
<span>Neue Aufgabenliste erstellen</span>
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowNewListModal(false)}
|
||||
className="text-muted hover:text-main"
|
||||
<FormSheet
|
||||
title="Neue Aufgabenliste erstellen"
|
||||
icon={<Layers className="w-4 h-4 text-accent" />}
|
||||
onClose={() => setShowNewListModal(false)}
|
||||
onSubmit={handleCreateTaskList}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreateTaskList} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -2040,7 +2046,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
value={newListIcon}
|
||||
onChange={(e) => setNewListIcon(e.target.value)}
|
||||
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)"
|
||||
/>
|
||||
<input
|
||||
@@ -2050,26 +2056,11 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
placeholder="z.B. Homelab, Einkäufe, Steuern"
|
||||
autoFocus
|
||||
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 className="flex items-center justify-end gap-2 pt-2 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewListModal(false)}
|
||||
className="btn-action h-7 px-3 text-xs"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" className="btn-primary h-7 px-3 text-xs">
|
||||
Erstellen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</FormSheet>
|
||||
)}
|
||||
|
||||
{/* UNIFIED SHARE LIST MODAL */}
|
||||
@@ -2155,6 +2146,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
onUpdateStatus={handleSetKanbanStatus}
|
||||
onUpdatePriority={handleSetPriority}
|
||||
onUpdateDueDate={handleUpdateTaskDueDate}
|
||||
onUpdateRecurrence={handleUpdateTaskRecurrence}
|
||||
onUpdateTaskList={handleUpdateTaskTaskList}
|
||||
onUpdateNotes={handleUpdateTaskNotes}
|
||||
onUpdateTags={handleUpdateTaskTags}
|
||||
@@ -2348,6 +2340,11 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
<span>{task.dueDate}</span>
|
||||
</span>
|
||||
)}
|
||||
{task.recurrence && (
|
||||
<span className="inline-flex items-center text-muted" title={`Wiederholt sich ${RECURRENCE_LABELS[task.recurrence]}`}>
|
||||
<Repeat className="w-2.5 h-2.5" />
|
||||
</span>
|
||||
)}
|
||||
{task.tags?.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
|
||||
@@ -1,37 +1,25 @@
|
||||
import React, { Suspense, useEffect, useRef, useState } from 'react';
|
||||
import { X, Trash2, Loader2, Clock } from 'lucide-react';
|
||||
import { X, Trash2, Loader2 } from 'lucide-react';
|
||||
import type { EmojiClickData } from 'emoji-picker-react';
|
||||
import { EmojiStyle, Theme, SuggestionMode } from 'emoji-picker-react';
|
||||
import { Categories, EmojiStyle, Theme, SuggestionMode } from 'emoji-picker-react';
|
||||
import { useKeyboardInset } from '../../lib/useKeyboardInset';
|
||||
|
||||
// Lazy-load emoji-picker-react so the main bundle size stays small
|
||||
const EmojiPicker = React.lazy(() => import('emoji-picker-react'));
|
||||
|
||||
const RECENT_EMOJIS_STORAGE_KEY = 'mindsift_recent_emojis';
|
||||
const MAX_RECENT_EMOJIS = 10;
|
||||
|
||||
function getStoredRecentEmojis(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(RECENT_EMOJIS_STORAGE_KEY);
|
||||
if (!raw) return ['📝', '💡', '🚀', '⭐', '🔥', '🎯', '📚', '💻'];
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
return parsed.slice(0, MAX_RECENT_EMOJIS);
|
||||
}
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
return ['📝', '💡', '🚀', '⭐', '🔥', '🎯', '📚', '💻'];
|
||||
}
|
||||
|
||||
function saveRecentEmoji(emoji: string): void {
|
||||
try {
|
||||
const current = getStoredRecentEmojis();
|
||||
const updated = [emoji, ...current.filter((e) => e !== emoji)].slice(0, MAX_RECENT_EMOJIS);
|
||||
localStorage.setItem(RECENT_EMOJIS_STORAGE_KEY, JSON.stringify(updated));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
// The library's own "recently used" category (stored by it) is the only recent list; a second bar
|
||||
// would cost height on phones. Category names in German.
|
||||
const CATEGORIES = [
|
||||
{ category: Categories.SUGGESTED, name: 'Zuletzt verwendet' },
|
||||
{ category: Categories.SMILEYS_PEOPLE, name: 'Smileys & Personen' },
|
||||
{ category: Categories.ANIMALS_NATURE, name: 'Tiere & Natur' },
|
||||
{ category: Categories.FOOD_DRINK, name: 'Essen & Trinken' },
|
||||
{ category: Categories.TRAVEL_PLACES, name: 'Reisen & Orte' },
|
||||
{ category: Categories.ACTIVITIES, name: 'Aktivitäten' },
|
||||
{ category: Categories.OBJECTS, name: 'Objekte' },
|
||||
{ category: Categories.SYMBOLS, name: 'Symbole' },
|
||||
{ category: Categories.FLAGS, name: 'Flaggen' },
|
||||
];
|
||||
|
||||
interface EmojiPickerModalProps {
|
||||
currentIcon?: string;
|
||||
@@ -44,7 +32,10 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
||||
onSelectEmoji,
|
||||
onClose,
|
||||
}) => {
|
||||
const [recentEmojis, setRecentEmojis] = useState<string[]>(getStoredRecentEmojis);
|
||||
const { inset, visibleHeight } = useKeyboardInset();
|
||||
const isPhone = typeof window !== 'undefined' && window.innerWidth < 640;
|
||||
// On phones the picker shrinks with the keyboard so search field and results stay visible
|
||||
const pickerHeight = isPhone ? Math.max(200, Math.min(350, visibleHeight - 110)) : 350;
|
||||
const [isDark, setIsDark] = useState<boolean>(() =>
|
||||
typeof document !== 'undefined'
|
||||
? document.documentElement.classList.contains('dark')
|
||||
@@ -92,8 +83,6 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
||||
}, [onClose]);
|
||||
|
||||
const handlePick = (emoji: string) => {
|
||||
saveRecentEmoji(emoji);
|
||||
setRecentEmojis(getStoredRecentEmojis());
|
||||
onSelectEmoji(emoji);
|
||||
};
|
||||
|
||||
@@ -114,7 +103,7 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="
|
||||
fixed inset-x-0 bottom-0 z-50 rounded-t-2xl max-h-[85vh] bg-panel border-t border-border shadow-2xl flex flex-col p-3 space-y-3
|
||||
fixed inset-x-0 bottom-[var(--kb-inset)] z-50 rounded-t-2xl max-h-[var(--vv-max)] bg-panel border-t border-border shadow-2xl flex flex-col p-3 space-y-3
|
||||
sm:absolute sm:inset-auto sm:left-0 sm:top-full sm:mt-1 sm:z-40 sm:w-[360px] sm:max-h-[520px] sm:rounded-card sm:border sm:border-border sm:shadow-xl
|
||||
"
|
||||
style={
|
||||
@@ -130,6 +119,8 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
||||
'--epr-focus-bg-color': 'var(--bg-panel-elevated)',
|
||||
'--epr-highlight-color': 'var(--accent-primary)',
|
||||
'--epr-category-icon-active-color': 'var(--accent-primary)',
|
||||
'--kb-inset': `${inset}px`,
|
||||
'--vv-max': `${Math.round(visibleHeight * 0.9)}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
@@ -170,30 +161,8 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Emojis Bar */}
|
||||
{recentEmojis.length > 0 && (
|
||||
<div className="shrink-0 space-y-1.5">
|
||||
<div className="flex items-center gap-1 text-[11px] text-muted">
|
||||
<Clock className="w-3 h-3 text-accent" />
|
||||
<span>Zuletzt verwendet:</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 no-scrollbar">
|
||||
{recentEmojis.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
type="button"
|
||||
onClick={() => handlePick(emoji)}
|
||||
className="min-w-[44px] min-h-[44px] sm:min-w-[32px] sm:min-h-[32px] rounded-btn bg-panel-elevated hover:bg-canvas border border-border flex items-center justify-center text-xl sm:text-base cursor-pointer transition-transform active:scale-95 shrink-0"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Full Emoji Catalog via emoji-picker-react */}
|
||||
<div className="flex-1 min-h-[300px] overflow-hidden rounded mindsift-emoji-picker">
|
||||
<div className="flex-1 min-h-0 overflow-hidden rounded mindsift-emoji-picker">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-64 flex flex-col items-center justify-center gap-2 text-xs text-muted">
|
||||
@@ -211,7 +180,8 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
||||
searchPlaceholder="Emoji suchen…"
|
||||
searchPlaceHolder="Emoji suchen…"
|
||||
width="100%"
|
||||
height="350px"
|
||||
height={`${pickerHeight}px`}
|
||||
categories={CATEGORIES as any}
|
||||
previewConfig={{
|
||||
showPreview: false,
|
||||
}}
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
RealtimeTransport,
|
||||
} from '../../lib/api';
|
||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
import { FormSheet } from '../../components/FormSheet';
|
||||
import { sendDocOps } from '../../lib/docSync/oneoff';
|
||||
import { useMobileActions } from '../../context/MobileActionContext';
|
||||
import { DocumentEditor, DocSaveStatus } from './DocumentEditor';
|
||||
@@ -340,7 +341,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
if (e) {
|
||||
setNewFolderCoords({
|
||||
x: Math.min(e.clientX, window.innerWidth - 270),
|
||||
y: Math.min(e.clientY, window.innerHeight - 130),
|
||||
y: Math.min(e.clientY, window.innerHeight - 200),
|
||||
});
|
||||
} else {
|
||||
setNewFolderCoords({ x: 100, y: 150 });
|
||||
@@ -2196,55 +2197,22 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
|
||||
{/* New Folder Popover (small & positioned near click, asks only for name & OK) */}
|
||||
{showNewFolderModal && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/20"
|
||||
onClick={() => setShowNewFolderModal(false)}
|
||||
>
|
||||
<form
|
||||
<FormSheet
|
||||
title={newFolderParentPath ? `Unterordner in "${newFolderParentPath}"` : 'Neuer Ordner'}
|
||||
icon={<FolderPlus className="w-4 h-4 text-accent" />}
|
||||
onClose={() => setShowNewFolderModal(false)}
|
||||
onSubmit={handleCreateFolderSubmit}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={
|
||||
newFolderCoords
|
||||
? { top: `${newFolderCoords.y}px`, left: `${newFolderCoords.x}px` }
|
||||
: undefined
|
||||
}
|
||||
className="fixed w-64 bg-panel border border-border rounded-card p-3 shadow-lg space-y-2.5 z-50 text-xs"
|
||||
anchor={newFolderCoords}
|
||||
>
|
||||
<div className="font-semibold text-main text-xs flex items-center justify-between">
|
||||
<span className="truncate">{newFolderParentPath ? `Unterordner in "${newFolderParentPath}"` : 'Neuer Ordner'}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewFolderModal(false)}
|
||||
className="text-faint hover:text-main cursor-pointer"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
placeholder="Ordnername"
|
||||
className="input-field w-full px-2 py-1 text-xs"
|
||||
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">
|
||||
<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>
|
||||
</FormSheet>
|
||||
)}
|
||||
|
||||
{/* Unified Sharing Modal (Strictly Read-Only for Docs and Folders) */}
|
||||
|
||||
@@ -484,7 +484,10 @@ body {
|
||||
min-height: 44px !important;
|
||||
}
|
||||
|
||||
.mindsift-emoji-picker .EmojiPickerReact .epr-category-nav button {
|
||||
min-height: 44px !important;
|
||||
/* Category icons are sprite cut-outs: they must keep the library's square size (stretching them showed
|
||||
two icons and an oval highlight). More breathing room comes from the nav row instead. */
|
||||
.mindsift-emoji-picker .EmojiPickerReact .epr-category-nav {
|
||||
padding-top: 6px !important;
|
||||
padding-bottom: 6px !important;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
SystemSettings,
|
||||
SharingMode,
|
||||
UserTheme,
|
||||
ImportSummary,
|
||||
Team,
|
||||
TaskList,
|
||||
TaskListShare,
|
||||
@@ -134,6 +135,14 @@ export const setCurrentSharingMode = (mode: SharingMode) => {
|
||||
};
|
||||
export const isSharingDisabled = () => currentSharingMode === 'private';
|
||||
|
||||
// Authenticated binary download (archives); no JSON parsing and no request timeout
|
||||
async function downloadBlob(path: string): Promise<Blob> {
|
||||
const token = getToken();
|
||||
const res = await fetch(path, { headers: token ? { Authorization: `Bearer ${token}` } : {}, cache: 'no-store' });
|
||||
if (!res.ok) throw new Error(`Download fehlgeschlagen (HTTP ${res.status})`);
|
||||
return res.blob();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// --- AUTH, FIRST-BOOT SETUP & SETTINGS ---
|
||||
async getAuthStatus(): Promise<AuthStatus> {
|
||||
@@ -200,8 +209,8 @@ export const api = {
|
||||
return res;
|
||||
},
|
||||
|
||||
async getMe(): Promise<{ user: User; stats: DashboardStats }> {
|
||||
return request<{ user: User; stats: DashboardStats }>('/api/v1/auth/me');
|
||||
async getMe(): Promise<{ user: User; stats: DashboardStats; isLastAdmin?: boolean }> {
|
||||
return request<{ user: User; stats: DashboardStats; isLastAdmin?: boolean }>('/api/v1/auth/me');
|
||||
},
|
||||
|
||||
async updateSettings(patch: {
|
||||
@@ -381,6 +390,7 @@ export const api = {
|
||||
subtasks?: SubTask[];
|
||||
tags?: string[];
|
||||
assignees?: string[];
|
||||
recurrence?: string;
|
||||
}
|
||||
): Promise<Thought> {
|
||||
return request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
|
||||
@@ -1076,15 +1086,48 @@ export const api = {
|
||||
return request<any>('/api/v1/export');
|
||||
},
|
||||
|
||||
// Import of MindSift data (JSON backup or Markdown archive) or an Obsidian vault (ZIP).
|
||||
// preview = true only reports what would be created.
|
||||
// mode 'restore' (MindSift data only) deletes the user's notes, tasks and lists before importing
|
||||
async importData(
|
||||
source: 'mindsift' | 'obsidian',
|
||||
file: File,
|
||||
preview: boolean,
|
||||
mode: 'merge' | 'restore' = 'merge'
|
||||
): Promise<ImportSummary> {
|
||||
const token = getToken();
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const params = new URLSearchParams();
|
||||
if (preview) params.set('preview', '1');
|
||||
if (mode === 'restore') params.set('mode', 'restore');
|
||||
const res = await fetch(`/api/v1/import/${source}${params.toString() ? `?${params}` : ''}`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || `Import fehlgeschlagen (HTTP ${res.status})`);
|
||||
return data as ImportSummary;
|
||||
},
|
||||
|
||||
// Danger zone: both need the current password
|
||||
async purgeOwnData(password: string): Promise<void> {
|
||||
await request('/api/v1/user/data', { method: 'DELETE', body: JSON.stringify({ password }) });
|
||||
},
|
||||
|
||||
async deleteOwnAccount(password: string): Promise<void> {
|
||||
await request('/api/v1/user/account', { method: 'DELETE', body: JSON.stringify({ password }) });
|
||||
},
|
||||
|
||||
// Full backup (all data as JSON) packed into a ZIP; restorable via importData('mindsift', …)
|
||||
async exportBackupZip(): Promise<Blob> {
|
||||
return downloadBlob('/api/v1/export?format=zip');
|
||||
},
|
||||
|
||||
// Obsidian-compatible ZIP of the workspace; binary and possibly large, so no JSON helper and no timeout
|
||||
async exportMarkdownVault(): Promise<Blob> {
|
||||
const token = getToken();
|
||||
const res = await fetch('/api/v1/export/markdown', {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) throw new Error(`Export fehlgeschlagen (HTTP ${res.status})`);
|
||||
return res.blob();
|
||||
return downloadBlob('/api/v1/export/markdown');
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -132,6 +132,18 @@ function applyToDocument(themeId: ThemeId, customCss: string | null): void {
|
||||
root.setAttribute('data-theme', attr);
|
||||
const light = customCss !== null ? isLightColor(readCssVar(customCss, 'bg-app')) : themeId === 'light';
|
||||
root.classList.toggle('dark', !light);
|
||||
syncThemeColorMeta();
|
||||
}
|
||||
|
||||
// The status bar (theme-color) starts blue for the splash screen; once the app runs it follows the
|
||||
// active theme's background so it blends with the header.
|
||||
function syncThemeColorMeta(): void {
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (!meta || typeof requestAnimationFrame === 'undefined') return;
|
||||
requestAnimationFrame(() => {
|
||||
const bg = getComputedStyle(document.documentElement).getPropertyValue('--bg-app').trim();
|
||||
if (bg) meta.setAttribute('content', bg);
|
||||
});
|
||||
}
|
||||
|
||||
function store(key: string, value: string | null): void {
|
||||
|
||||
@@ -57,6 +57,16 @@ export interface SubTask {
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
// Repeat rule of a task: completing it creates the next one (due date one interval later)
|
||||
export type Recurrence = '' | 'daily' | 'weekly' | 'monthly' | 'yearly';
|
||||
|
||||
export const RECURRENCE_LABELS: Record<Exclude<Recurrence, ''>, string> = {
|
||||
daily: 'täglich',
|
||||
weekly: 'wöchentlich',
|
||||
monthly: 'monatlich',
|
||||
yearly: 'jährlich',
|
||||
};
|
||||
|
||||
export interface Thought {
|
||||
id: string;
|
||||
userId: string;
|
||||
@@ -76,6 +86,7 @@ export interface Thought {
|
||||
notes?: string;
|
||||
subtasks?: SubTask[];
|
||||
assignees?: string[]; // user IDs, only people with access to the task list
|
||||
recurrence?: Recurrence;
|
||||
alarmSent?: boolean;
|
||||
sortOrder?: number;
|
||||
createdAt: string;
|
||||
@@ -161,7 +172,7 @@ export interface SystemSettings {
|
||||
allowRegistration: boolean;
|
||||
instanceName: string;
|
||||
smtpHost?: string;
|
||||
smtpPort?: number;
|
||||
smtpPort?: string; // sent as text, like the server stores it
|
||||
smtpUser?: string;
|
||||
smtpPass?: string;
|
||||
smtpFrom?: string;
|
||||
@@ -417,6 +428,19 @@ export interface SharedList {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// Result of an import preview (applied = false) or of the import itself
|
||||
export interface ImportSummary {
|
||||
folders: number;
|
||||
notes: number;
|
||||
taskLists: number;
|
||||
tasks: number;
|
||||
lists: number;
|
||||
listItems: number;
|
||||
inbox: number;
|
||||
skipped: { path: string; reason: string }[];
|
||||
applied: boolean;
|
||||
}
|
||||
|
||||
// Custom CSS theme of the current user (Benutzereinstellungen > Darstellung)
|
||||
export interface UserTheme {
|
||||
id: string;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime"
|
||||
"mime/quotedprintable"
|
||||
@@ -21,6 +22,8 @@ type smtpSettings struct {
|
||||
Host, Port, User, Pass, From string
|
||||
TLS bool // 465: implicit TLS, otherwise STARTTLS (required when set)
|
||||
SkipVerify bool // accept self-signed / mismatching certificates
|
||||
|
||||
authMech string // "" = automatic; "LOGIN" forces AUTH LOGIN (fallback after a refused PLAIN)
|
||||
}
|
||||
|
||||
// smtpStepError names the protocol step at which the mail server refused, e.g. "RCPT TO".
|
||||
@@ -116,9 +119,89 @@ func buildMessage(from, to, subject, body string, now time.Time) []byte {
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// sendMail runs the SMTP dialogue step by step instead of smtp.SendMail, so it can introduce itself
|
||||
// properly, honour SkipVerify and report the exact step a server refuses.
|
||||
// loginAuth implements AUTH LOGIN (username and password as separate Base64 challenges). Some servers,
|
||||
// e.g. Postfix with Dovecot SASL or Exchange, fail on PLAIN but accept LOGIN.
|
||||
type loginAuth struct{ user, pass, host string }
|
||||
|
||||
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
|
||||
// Same rule as smtp.PlainAuth: never send credentials unencrypted to a remote host
|
||||
if !server.TLS && a.host != "localhost" && a.host != "127.0.0.1" && a.host != "::1" {
|
||||
return "", nil, fmt.Errorf("unverschlüsselte Verbindung – Anmeldung verweigert")
|
||||
}
|
||||
return "LOGIN", nil, nil
|
||||
}
|
||||
|
||||
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
if !more {
|
||||
return nil, nil
|
||||
}
|
||||
switch prompt := strings.ToLower(strings.TrimSpace(string(fromServer))); {
|
||||
case strings.Contains(prompt, "username"), strings.Contains(prompt, "user"):
|
||||
return []byte(a.user), nil
|
||||
case strings.Contains(prompt, "password"), strings.Contains(prompt, "pass"):
|
||||
return []byte(a.pass), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unerwartete AUTH-LOGIN-Frage %q", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
// authAttemptError records which mechanism failed and whether LOGIN would have been an alternative.
|
||||
type authAttemptError struct {
|
||||
mech string
|
||||
loginOffered bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *authAttemptError) Error() string { return e.mech + ": " + e.err.Error() }
|
||||
func (e *authAttemptError) Unwrap() error { return e.err }
|
||||
|
||||
// authenticate uses PLAIN, or LOGIN when the server offers only that (or when cfg.authMech forces it).
|
||||
// net/smtp closes the connection after a failed AUTH, so a fallback needs a new connection (sendMail).
|
||||
func authenticate(client *smtp.Client, cfg smtpSettings) error {
|
||||
_, mechs := client.Extension("AUTH")
|
||||
offered := " " + strings.ToUpper(mechs) + " "
|
||||
hasLogin := strings.Contains(offered, " LOGIN ")
|
||||
mech := cfg.authMech
|
||||
if mech == "" {
|
||||
mech = "PLAIN"
|
||||
if hasLogin && !strings.Contains(offered, " PLAIN ") {
|
||||
mech = "LOGIN"
|
||||
}
|
||||
}
|
||||
var a smtp.Auth = smtp.PlainAuth("", cfg.User, cfg.Pass, cfg.Host)
|
||||
if mech == "LOGIN" {
|
||||
a = &loginAuth{user: cfg.User, pass: cfg.Pass, host: cfg.Host}
|
||||
}
|
||||
if err := client.Auth(a); err != nil {
|
||||
return &authAttemptError{mech: mech, loginOffered: hasLogin, err: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendMail sends one mail. If AUTH PLAIN is refused and the server also offers LOGIN, it reconnects once
|
||||
// and tries LOGIN; a 451 on both points to the mail server's SASL backend.
|
||||
func sendMail(cfg smtpSettings, to, subject, body string) error {
|
||||
err := sendMailOnce(cfg, to, subject, body)
|
||||
var first *authAttemptError
|
||||
if cfg.authMech != "" || !errors.As(err, &first) || first.mech != "PLAIN" || !first.loginOffered {
|
||||
return err
|
||||
}
|
||||
cfg.authMech = "LOGIN"
|
||||
retryErr := sendMailOnce(cfg, to, subject, body)
|
||||
var second *authAttemptError
|
||||
if retryErr == nil || !errors.As(retryErr, &second) {
|
||||
return retryErr
|
||||
}
|
||||
combined := fmt.Errorf("%v; %v", first, second)
|
||||
if strings.Contains(first.err.Error(), "451") && strings.Contains(second.err.Error(), "451") {
|
||||
combined = fmt.Errorf("%w (der Mailserver meldet einen internen Fehler bei der Anmeldung – bitte dessen SASL/Dovecot-Logs prüfen)", combined)
|
||||
}
|
||||
return &smtpStepError{"AUTH", combined}
|
||||
}
|
||||
|
||||
// sendMailOnce runs the SMTP dialogue step by step instead of smtp.SendMail, so it can introduce itself
|
||||
// properly, honour SkipVerify and report the exact step a server refuses.
|
||||
func sendMailOnce(cfg smtpSettings, to, subject, body string) error {
|
||||
fromAddr, fromHeader, err := splitAddress(cfg.From)
|
||||
if err != nil {
|
||||
return &smtpStepError{"Absender", err}
|
||||
@@ -162,7 +245,7 @@ func sendMail(cfg smtpSettings, to, subject, body string) error {
|
||||
}
|
||||
}
|
||||
if cfg.User != "" || cfg.Pass != "" {
|
||||
if err := client.Auth(smtp.PlainAuth("", cfg.User, cfg.Pass, cfg.Host)); err != nil {
|
||||
if err := authenticate(client, cfg); err != nil {
|
||||
return &smtpStepError{"AUTH", err}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"math/big"
|
||||
"net"
|
||||
@@ -21,8 +22,13 @@ import (
|
||||
type fakeSMTP struct {
|
||||
addr string
|
||||
rcptCode string // e.g. "451 4.3.2 Internal server error" to refuse RCPT TO
|
||||
// AUTH behaviour: mechanisms announced, and per mechanism a reply code to fail with ("" = accept)
|
||||
authMechs string
|
||||
authFail map[string]string
|
||||
|
||||
mu sync.Mutex
|
||||
authUsed []string // mechanisms tried, in order
|
||||
authUser string // decoded user of a successful LOGIN
|
||||
ehlo string
|
||||
mailFrom string
|
||||
rcptTo string
|
||||
@@ -53,7 +59,7 @@ func startFakeSMTP(t *testing.T, rcptCode string) *fakeSMTP {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
f := &fakeSMTP{addr: ln.Addr().String(), rcptCode: rcptCode}
|
||||
f := &fakeSMTP{addr: ln.Addr().String(), rcptCode: rcptCode, authMechs: "PLAIN", authFail: map[string]string{}}
|
||||
tlsCfg := &tls.Config{Certificates: []tls.Certificate{selfSignedCert(t)}}
|
||||
go func() {
|
||||
for {
|
||||
@@ -86,9 +92,9 @@ func (f *fakeSMTP) serve(conn net.Conn, tlsCfg *tls.Config) {
|
||||
f.ehlo = cmd[5:]
|
||||
f.mu.Unlock()
|
||||
if secure {
|
||||
reply("250-fake\r\n250 AUTH PLAIN")
|
||||
reply("250-fake\r\n250 AUTH " + f.authMechs)
|
||||
} else {
|
||||
reply("250-fake\r\n250-STARTTLS\r\n250 AUTH PLAIN")
|
||||
reply("250-fake\r\n250-STARTTLS\r\n250 AUTH " + f.authMechs)
|
||||
}
|
||||
case upper == "STARTTLS":
|
||||
reply("220 go ahead")
|
||||
@@ -99,7 +105,27 @@ func (f *fakeSMTP) serve(conn net.Conn, tlsCfg *tls.Config) {
|
||||
conn, secure = tlsConn, true
|
||||
r, w = bufio.NewReader(conn), bufio.NewWriter(conn)
|
||||
case strings.HasPrefix(upper, "AUTH "):
|
||||
mech := strings.Fields(upper)[1]
|
||||
f.mu.Lock()
|
||||
f.authUsed = append(f.authUsed, mech)
|
||||
fail := f.authFail[mech]
|
||||
f.mu.Unlock()
|
||||
if mech == "LOGIN" {
|
||||
reply("334 " + base64.StdEncoding.EncodeToString([]byte("Username:")))
|
||||
u, _ := r.ReadString('\n')
|
||||
reply("334 " + base64.StdEncoding.EncodeToString([]byte("Password:")))
|
||||
_, _ = r.ReadString('\n')
|
||||
if dec, err := base64.StdEncoding.DecodeString(strings.TrimSpace(u)); err == nil {
|
||||
f.mu.Lock()
|
||||
f.authUser = string(dec)
|
||||
f.mu.Unlock()
|
||||
}
|
||||
}
|
||||
if fail != "" {
|
||||
reply(fail)
|
||||
} else {
|
||||
reply("235 ok")
|
||||
}
|
||||
case strings.HasPrefix(upper, "MAIL FROM"):
|
||||
f.mu.Lock()
|
||||
f.mailFrom = strings.TrimSpace(cmd[len("MAIL FROM:"):])
|
||||
@@ -213,3 +239,34 @@ func TestEHLONameNeverLocalhost(t *testing.T) {
|
||||
t.Errorf("fallback must not be localhost, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthFallsBackToLogin(t *testing.T) {
|
||||
f := startFakeSMTP(t, "")
|
||||
f.authMechs = "PLAIN LOGIN"
|
||||
f.authFail["PLAIN"] = "451 4.3.2 Internal server error"
|
||||
if err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo"); err != nil {
|
||||
t.Fatalf("LOGIN fallback should succeed: %v", err)
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if strings.Join(f.authUsed, ",") != "PLAIN,LOGIN" || f.authUser != "alarm@firma.de" {
|
||||
t.Fatalf("expected PLAIN then LOGIN with the user, got %v / %q", f.authUsed, f.authUser)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginOnlyAndBothFailing(t *testing.T) {
|
||||
f := startFakeSMTP(t, "")
|
||||
f.authMechs = "LOGIN"
|
||||
if err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo"); err != nil {
|
||||
t.Fatalf("LOGIN-only server: %v", err)
|
||||
}
|
||||
|
||||
g := startFakeSMTP(t, "")
|
||||
g.authMechs = "PLAIN LOGIN"
|
||||
g.authFail["PLAIN"] = "451 4.3.2 Internal server error"
|
||||
g.authFail["LOGIN"] = "451 4.3.2 Internal server error"
|
||||
err := sendMail(g.settings(true), "anna@example.com", "Test", "Hallo")
|
||||
if err == nil || !strings.Contains(err.Error(), "PLAIN") || !strings.Contains(err.Error(), "LOGIN") || !strings.Contains(err.Error(), "SASL") {
|
||||
t.Fatalf("expected both mechanisms and the server hint in the error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,10 @@ func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS
|
||||
notifier: collab.NewNotifier(),
|
||||
}
|
||||
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.startCompaction()
|
||||
return srv
|
||||
@@ -110,6 +114,10 @@ func (s *Server) Router() http.Handler {
|
||||
r.Get("/api/v1/search", s.handleSearch)
|
||||
r.Get("/api/v1/export", s.handleExport)
|
||||
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.Post("/api/v1/themes", s.handleSaveTheme)
|
||||
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{}{
|
||||
"user": user,
|
||||
"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"`
|
||||
Tags *[]string `json:"tags"`
|
||||
Assignees *[]string `json:"assignees"`
|
||||
Recurrence *string `json:"recurrence"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
@@ -958,6 +969,13 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
before, _ := s.store.GetThoughtByID(userID, thoughtID)
|
||||
// The rule is set first, so completing a task in the same request already repeats it
|
||||
if req.Recurrence != nil {
|
||||
if err := s.store.SetTaskRecurrence(userID, thoughtID, strings.TrimSpace(*req.Recurrence)); err != nil {
|
||||
writeStoreError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
updated, err := s.store.UpdateThoughtDetails(userID, thoughtID, req.Content, req.IsCompleted, req.DueDate, req.Priority, req.TaskListID, req.KanbanStatus, req.Notes, req.Subtasks, req.Tags)
|
||||
if err != nil {
|
||||
writeStoreError(w, http.StatusBadRequest, err)
|
||||
@@ -2115,6 +2133,121 @@ func (s *Server) handleDeleteTheme(w http.ResponseWriter, r *http.Request) {
|
||||
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).
|
||||
func (s *Server) handleExportMarkdown(w http.ResponseWriter, r *http.Request) {
|
||||
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())
|
||||
return
|
||||
}
|
||||
// ?format=zip: the same JSON compressed (backups grow quickly with notes); plain JSON stays the API default
|
||||
if r.URL.Query().Get("format") == "zip" {
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="mindsift-backup-%s.zip"`, time.Now().Format("2006-01-02")))
|
||||
if err := storage.WriteBackupZip(exportData, w); err != nil {
|
||||
slog.Error("Backup-Export fehlgeschlagen", "user_id", userID, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=mindsift-export.json")
|
||||
writeJSON(w, http.StatusOK, exportData)
|
||||
}
|
||||
|
||||
@@ -1369,7 +1369,8 @@ func TestFirstBootSetupWithSampleData(t *testing.T) {
|
||||
|
||||
// Impressum and Datenschutz must be reachable without login; unset texts stay hidden.
|
||||
func TestLegalTextsArePublic(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "legal.db")
|
||||
// In memory: no files the cleanup could race with
|
||||
dbPath := ":memory:"
|
||||
store, err := storage.NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
|
||||
@@ -3,6 +3,7 @@ package domain
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -183,6 +184,7 @@ type Thought struct {
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Subtasks []SubTask `json:"subtasks"`
|
||||
Assignees []string `json:"assignees"` // user IDs; only people with access to the task list
|
||||
Recurrence string `json:"recurrence,omitempty"` // "" | daily | weekly | monthly | yearly
|
||||
AlarmSent bool `json:"alarmSent,omitempty"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
@@ -289,6 +291,36 @@ type SystemSettings struct {
|
||||
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 {
|
||||
ID string `json:"id"`
|
||||
DocumentID string `json:"documentId,omitempty"`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
@@ -32,3 +33,19 @@ func TestStaleAnchorTags(t *testing.T) {
|
||||
t.Fatalf("only tags that exist solely as anchors are stale, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemSettingsAcceptsPortAsNumberOrText(t *testing.T) {
|
||||
for _, body := range []string{`{"smtpPort": 465, "smtpHost": "mail"}`, `{"smtpPort": "465", "smtpHost": "mail"}`} {
|
||||
var s SystemSettings
|
||||
if err := json.Unmarshal([]byte(body), &s); err != nil {
|
||||
t.Fatalf("%s: %v", body, err)
|
||||
}
|
||||
if s.SMTPPort != "465" || s.SMTPHost != "mail" {
|
||||
t.Errorf("%s: got port %q host %q", body, s.SMTPPort, s.SMTPHost)
|
||||
}
|
||||
}
|
||||
var empty SystemSettings
|
||||
if err := json.Unmarshal([]byte(`{"smtpPort": ""}`), &empty); err != nil || empty.SMTPPort != "" {
|
||||
t.Errorf("empty port: %q (%v)", empty.SMTPPort, err)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -482,7 +482,7 @@ func (s *Store) ReconcileDocTaskLinks(docID string) []string {
|
||||
// GetThoughtByIDUnchecked loads a task without access check, for server-internal notifications.
|
||||
func (s *Store) GetThoughtByIDUnchecked(thoughtID string) (*domain.Thought, error) {
|
||||
return scanThought(s.db.QueryRow(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
|
||||
FROM thoughts WHERE id = ?`, thoughtID))
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,9 @@ func (s *Store) writeVaultLists(zw *zip.Writer, userID string, names *vaultNames
|
||||
return nil
|
||||
}
|
||||
|
||||
// Obsidian Tasks writes repeat rules as "🔁 every week"
|
||||
var recurrenceEvery = map[string]string{"daily": "day", "weekly": "week", "monthly": "month", "yearly": "year"}
|
||||
|
||||
// taskMarkdownLine writes a task in the format of the Obsidian Tasks plugin (📅 due date, ⏫/🔺 priority)
|
||||
// with its subtasks and notes indented below.
|
||||
func taskMarkdownLine(t domain.Thought) string {
|
||||
@@ -134,6 +137,9 @@ func taskMarkdownLine(t domain.Thought) string {
|
||||
case "low":
|
||||
line += " 🔽"
|
||||
}
|
||||
if every, ok := recurrenceEvery[t.Recurrence]; ok {
|
||||
line += " 🔁 every " + every
|
||||
}
|
||||
if t.DueDate != "" {
|
||||
line += " 📅 " + t.DueDate
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@@ -23,6 +22,10 @@ import (
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
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) {
|
||||
@@ -58,22 +61,11 @@ func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// schemaVersion is stored in PRAGMA user_version. Databases from an older version are wiped and rebuilt
|
||||
// on start: MindSift is not in production yet, so breaking changes need no migration path.
|
||||
// schemaVersion is stored in PRAGMA user_version so later releases can tell which migrations a database
|
||||
// already has. (Version 2 once wiped older databases on start; all installations are past that now.)
|
||||
const schemaVersion = 2
|
||||
|
||||
func (s *Store) resetOutdatedSchema() error {
|
||||
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()
|
||||
}
|
||||
|
||||
// dropAllTables removes every table; used by the demo mode reset.
|
||||
func (s *Store) dropAllTables() error {
|
||||
conn, err := s.db.Conn(context.Background())
|
||||
if err != nil {
|
||||
@@ -107,9 +99,6 @@ func (s *Store) dropAllTables() error {
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
if err := s.resetOutdatedSchema(); err != nil {
|
||||
return fmt.Errorf("reset outdated schema: %w", err)
|
||||
}
|
||||
schema := `
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
@@ -469,6 +458,10 @@ func (s *Store) migrate() error {
|
||||
|
||||
// Who added an item to a (shopping) list
|
||||
"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 {
|
||||
_, _ = s.db.Exec(q)
|
||||
@@ -824,6 +817,10 @@ func (s *Store) UpdateUserPassword(userID, passwordHash 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 task_assignees WHERE user_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,
|
||||
&statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID,
|
||||
&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 {
|
||||
return nil, err
|
||||
@@ -992,7 +989,7 @@ func (s *Store) accessibleTaskListIDsQuery() string {
|
||||
|
||||
func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) {
|
||||
row := s.db.QueryRow(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
|
||||
FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`,
|
||||
thoughtID, userID, userID, userID, userID,
|
||||
)
|
||||
@@ -1013,14 +1010,14 @@ func (s *Store) GetThoughts(userID, statusFilter string, limit int) ([]domain.Th
|
||||
|
||||
if statusFilter != "" && statusFilter != "all" {
|
||||
rows, err = s.db.Query(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
|
||||
FROM thoughts WHERE user_id = ? AND status = ?
|
||||
ORDER BY CASE priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, sort_order ASC, created_at DESC LIMIT ?`,
|
||||
userID, statusFilter, limit,
|
||||
)
|
||||
} else {
|
||||
rows, err = s.db.Query(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
|
||||
FROM thoughts WHERE user_id = ?
|
||||
ORDER BY sort_order ASC, created_at DESC LIMIT ?`,
|
||||
userID, limit,
|
||||
@@ -1046,7 +1043,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
|
||||
var err error
|
||||
if taskListID != "" {
|
||||
rows, err = s.db.Query(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
|
||||
FROM thoughts
|
||||
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
|
||||
AND task_list_id = ?
|
||||
@@ -1056,7 +1053,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
|
||||
)
|
||||
} else {
|
||||
rows, err = s.db.Query(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
|
||||
FROM thoughts
|
||||
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
|
||||
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
|
||||
@@ -1087,11 +1084,17 @@ func (s *Store) ReorderTasks(taskListID string, items []domain.ThoughtReorderIte
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var completed []string // tasks that move into "done" now (repeating ones spawn their successor)
|
||||
for _, it := range items {
|
||||
if it.KanbanStatus != nil && *it.KanbanStatus != "" {
|
||||
isCompleted := 0
|
||||
if *it.KanbanStatus == "done" {
|
||||
isCompleted = 1
|
||||
var was int
|
||||
_ = tx.QueryRow(`SELECT is_completed FROM thoughts WHERE id = ?`, it.ID).Scan(&was)
|
||||
if was == 0 {
|
||||
completed = append(completed, it.ID)
|
||||
}
|
||||
}
|
||||
_, err = tx.Exec(
|
||||
`UPDATE thoughts SET sort_order = ?, kanban_status = ?, is_completed = ? 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
|
||||
@@ -1242,6 +1251,7 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wasCompleted := thought.IsCompleted
|
||||
if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1314,6 +1324,9 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string,
|
||||
s.PruneTaskAssignees()
|
||||
thought.Assignees = s.getTaskAssignees(thoughtID)
|
||||
}
|
||||
if !wasCompleted && thought.IsCompleted {
|
||||
s.spawnNextOccurrence(thoughtID)
|
||||
}
|
||||
return thought, nil
|
||||
}
|
||||
|
||||
@@ -3953,7 +3966,12 @@ func (s *Store) SyncTaskFromBlockCheck(userID, taskID string, checked bool) {
|
||||
doneInt = 1
|
||||
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) {
|
||||
|
||||
@@ -144,8 +144,9 @@ func TestVisibilityFollowsOrganizations(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutdatedSchemaIsWiped(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "old.db")
|
||||
// Existing data must survive a restart, whatever user_version the database carries.
|
||||
func TestDataSurvivesRestart(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "keep.db")
|
||||
store, err := NewStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
@@ -159,8 +160,8 @@ func TestOutdatedSchemaIsWiped(t *testing.T) {
|
||||
t.Fatalf("reopen failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
if n, _ := store.CountUsers(); n != 0 {
|
||||
t.Fatalf("outdated database should start empty, found %d users", n)
|
||||
if n, _ := store.CountUsers(); n != 1 {
|
||||
t.Fatalf("data must be kept on restart, found %d users", n)
|
||||
}
|
||||
var v int
|
||||
_ = store.db.QueryRow(`PRAGMA user_version`).Scan(&v)
|
||||
|
||||
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 254 KiB After Width: | Height: | Size: 255 KiB |
|
Before Width: | Height: | Size: 344 KiB After Width: | Height: | Size: 344 KiB |
|
Before Width: | Height: | Size: 362 KiB After Width: | Height: | Size: 362 KiB |