Merge dev into main
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 8s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 8s
Organizations & visibility, task assignment, custom themes, SMTP hardening, CountBadge, legal notices, Markdown export, help page, global search, emoji picker, demo reset. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
5f51637c78
38 files changed
+2387
-223
No files matched your search
@@ -19,3 +19,9 @@ SMTP_FROM=noreply@mindsift.local
|
||||
SMTP_TLS=true
|
||||
# Only for internal mail servers with self-signed certificates
|
||||
SMTP_SKIP_VERIFY=false
|
||||
|
||||
# Optional legal notices: external link and/or Markdown (inline or as file path)
|
||||
LEGAL_IMPRINT_URL=
|
||||
LEGAL_PRIVACY_URL=
|
||||
LEGAL_IMPRINT_CONTENT_FILE=
|
||||
LEGAL_PRIVACY_CONTENT_FILE=
|
||||
+29
-2
@@ -7,7 +7,8 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
||||
| Methode | Pfad | Zweck |
|
||||
|---|---|---|
|
||||
| GET | `/health` | Lebenszeichen |
|
||||
| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode` |
|
||||
| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode`, `legal` (`imprintUrl`, `hasImprintContent`, `privacyUrl`, `hasPrivacyContent`) |
|
||||
| GET | `/legal/{imprint\|privacy}` | Hinterlegter Markdown-Text (`content`), ohne Anmeldung; `404`, wenn keiner gesetzt ist |
|
||||
| POST | `/auth/setup` | Erstes Administratorkonto (`email`, `password`, `displayName`, `instanceName`, `allowRegistration`, `sharingMode`, `seedSampleData`); nur solange es keine Konten gibt |
|
||||
| POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben |
|
||||
| POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` |
|
||||
@@ -28,12 +29,38 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
||||
| POST | `/auth/alarms/test` | Test-Alarm (`channel`: `email`/`discord`/`webhook`, optional `target`) |
|
||||
| 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=` | Suche in eigenen Gedanken und Notizen |
|
||||
| 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/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 |
|
||||
| GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung) |
|
||||
|
||||
### Globale Suche (`GET /api/v1/search?q=`)
|
||||
|
||||
Führt eine Volltextsuche über SQLite FTS5 mit BM25-Relevanz-Ranking aus.
|
||||
- Durchsucht Aufgaben/Gedanken (Inhalt, Notizen, Unteraufgaben), Notizen (Titel sowie Inhalte aktiver Blöcke) und Listeneinträge.
|
||||
- Beachtet strikt alle Zugriffsregeln: Nur eigene Gedanken/Aufgaben und solche in zugänglichen Aufgabenlisten; nur eigene und geteilte Notizen (direkt, über Gruppen oder freigegebene Ordner); nur eigene und geteilte Listen.
|
||||
- Titel-Treffer werden höher gewichtet als Treffer im Fließtext.
|
||||
- Liefert rückwärtskompatible Collections (`thoughts`, `documents`, `listItems`) sowie strukturierte `hits` mit Snippet und `<mark>`-Hervorhebung:
|
||||
```json
|
||||
{
|
||||
"thoughts": [...],
|
||||
"documents": [...],
|
||||
"listItems": [...],
|
||||
"hits": [
|
||||
{
|
||||
"type": "document",
|
||||
"id": "blk_123",
|
||||
"title": "Projektplanung",
|
||||
"snippet": "... wichtige Notiz zum <mark>Release</mark> ...",
|
||||
"docId": "doc_abc",
|
||||
"blockId": "blk_123"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Gedanken, Triage, Aufgaben
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
|
||||
@@ -65,6 +65,8 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
|
||||
| `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen |
|
||||
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer / `587` / `true` | Mailversand, auch im Admin-UI einstellbar |
|
||||
| `SMTP_SKIP_VERIFY` | `false` | Zertifikat des Mailservers nicht prüfen (nur interne Server mit selbstsigniertem Zertifikat) |
|
||||
| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) |
|
||||
| `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` (oder `…_CONTENT_FILE` mit Dateipfad) | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster. Ohne URL und Text bleibt der Link unsichtbar. |
|
||||
|
||||
Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in `DATA_DIR` genutzt wird; Postgres ist nicht implementiert.
|
||||
|
||||
|
||||
+3
-4
@@ -27,7 +27,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
|
||||
| FLAW-15 | Realtime | ~~`WriteTimeout` von 30 s beendet SSE-Streams~~ | **Behoben** |
|
||||
| FLAW-16 | Setup | `/auth/setup` ist bis zum ersten Konto offen; Demo-Modus legt `admin/admin` an | Mittel |
|
||||
| FLAW-17 | Workspace | ~~Bearbeiter-Freigaben für Notizen nicht wählbar~~ | **Behoben** |
|
||||
| FLAW-18 | Suche | Nur eigene Gedanken und Dokumente, `LIKE` ohne Escaping, keine FTS | Niedrig |
|
||||
| FLAW-18 | Suche | ~~Nur eigene Gedanken und Dokumente, `LIKE` ohne Escaping, keine FTS~~ | **Behoben** |
|
||||
| FLAW-19 | Qualität | Frontend-Tests nur für den Outliner, `lint` ist ein Platzhalter | Niedrig |
|
||||
| FLAW-20 | Qualität | Sehr große Dateien, duplizierte Zugriffslogik | Niedrig |
|
||||
| FLAW-21 | Repo | `LICENSE` fehlt, obwohl README MIT angibt | Niedrig |
|
||||
@@ -147,9 +147,8 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
|
||||
- **Maßnahme:** Entweder Schreibfreigaben mit Konfliktbehandlung ergänzen oder den toten Code und das Schema bereinigen.
|
||||
|
||||
### FLAW-18: Suche
|
||||
- **Fundstelle:** `Store.SearchAll`.
|
||||
- **Probleme:** Durchsucht nur eigene Gedanken (30) und eigene Dokumente (20); geteilte Dokumente, Aufgaben aus geteilten Listen, Einkaufslisten und Blöcke bleiben außen vor. `LIKE '%…%'` ohne Escaping von `%`/`_`, keine Indizes, keine Relevanz.
|
||||
- **Maßnahme:** FTS5-Index über Blöcke/Gedanken; Berechtigungen mitberücksichtigen.
|
||||
- **Status: Behoben.** SQLite FTS5-Volltexttabellen mit BM25-Ranking (`thoughts_fts`, `documents_fts`, `list_items_fts`), Trigger zur automatischen Synchronisation und initialem Backfill beim Start. Sicheres Query-Escaping, Präfixsuche. Strikte Berechtigungsprüfung über `accessibleTaskListIDsQuery`, `DocRole` (eigene, geteilte Notizen, über Ordner gemountete Notizen) und zugängliche Listen. Rückwärtskompatible API-Antwort mit Snippets (`<mark>`) und Frontend-Integration über `CommandPalette` (`Cmd+K` / `Ctrl+K` / `/`).
|
||||
- **Fundstellen:** `internal/storage/search.go`, `internal/api/server.go`, `frontend/src/components/CommandPalette.tsx`.
|
||||
|
||||
### FLAW-19: Geringe Frontend-Testabdeckung
|
||||
- **Stand:** Vitest mit jsdom und Testing Library ist eingerichtet (`npm run test:run`) und läuft beim Image-Build. Getestet sind bisher nur Outliner-Funktionen (Backspace-Zusammenführen, Cursor-Position beim Klicken).
|
||||
|
||||
@@ -32,6 +32,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
|
||||
- **Konfliktkopien** sind farbig markiert („Konflikt – Version von …“) mit „Übernehmen“ (Text ersetzt den ursprünglichen Punkt bzw. den Titel, die Kopie verschwindet) und „Verwerfen“.
|
||||
- **Rückgängig:** `Strg+Z`/`Cmd+Z` nimmt die eigene letzte Änderung zurück, `Strg+Umschalt+Z` oder `Strg+Y` wiederholt (auch im Titel und über das Kontextmenü/mobile Tastaturen). Tippen im selben Punkt bis 1 s Pause ist ein Schritt; bis zu 200 Schritte je Tab. Hat jemand anderes den Punkt inzwischen geändert oder hält ihn gesperrt, wird der Schritt mit Hinweis übersprungen.
|
||||
- `PUT /documents/{id}` ändert nur noch Symbol, Anheften und Ordner (Titel wird ins Log übernommen); Inhalte laufen ausschließlich über die Engine. Das To-Do-Rollup hakt per einzelner Operation über denselben Sync-Endpunkt ab.
|
||||
- **Notiz-Icon (Emoji-Picker):** Vollständiger Emoji-Katalog mit Kategoriereitern, Suche, Verlauf zuletzt verwendeter Symbole (`localStorage`) und dynamischer Theme-Anpassung. Auf Mobilgeräten (< 640 px) als Bottom-Sheet mit touch-freundlichen Zielen (≥ 44 px) und `text-base`-Suchfeld (verhindert iOS-Zoom).
|
||||
|
||||
## Explorer, Tags, To-Dos
|
||||
|
||||
@@ -50,4 +51,15 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
|
||||
|
||||
- Keine gleichzeitige Mehrbenutzerbearbeitung, keine Cursor-Anzeige, keine Versionshistorie.
|
||||
- Keine Virtualisierung langer Dokumente; sehr große Notizen können auf Mobilgeräten träge werden *(nicht gemessen)*.
|
||||
- Suche (`/search`) erfasst nur Titel und Inhalt eigener Notizen ([FLAW-18](../flaws.md)).
|
||||
- ~~Suche (`/search`) erfasst nur Titel und Inhalt eigener Notizen~~: Mit der globalen FTS5-Suche (`Cmd+K` / `Ctrl+K` / `/`) werden sowohl Titel als auch Block-Inhalte aller zugänglichen Notizen (eigene und freigegebene) durchsucht ([FLAW-18](../flaws.md)).
|
||||
|
||||
## Export als Markdown-Archiv
|
||||
|
||||
Kontoeinstellungen → Private Webhooks & Export → „Markdown-Archiv herunterladen“ (`GET /export/markdown`, Code: [export_markdown.go](../../internal/storage/export_markdown.go)).
|
||||
|
||||
- Ordner und Notizen so, wie der Nutzer sie sieht (eigene Ordner, eingehängte geteilte Ordner, mit ihm geteilte Notizen); leere Ordner bleiben erhalten.
|
||||
- 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.
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Kla
|
||||
- `muted` (Standard): reine Anzahl. Tabs der Aufgabenlisten und Listen, Organisationen, mobiles Menü.
|
||||
- Beide kommen aus den Theme-Variablen (`--accent-primary`/`--text-on-accent` bzw. `--text-muted`/`--bg-panel`) und passen sich damit jedem Theme an.
|
||||
- **Position (`position`):**
|
||||
- `corner`: Benachrichtigungs-Badge oben rechts am nächsten `relative` Elternelement, mit Ring in der Seitenfarbe. So in der Navigation und an den Tabs. Zeilen mit solchen Tabs brauchen etwas Abstand (`gap-x-3`, `pt-2`), damit der Kreis nicht in den Nachbar-Tab ragt.
|
||||
- `corner`: Benachrichtigungs-Badge oben rechts am nächsten `relative` Elternelement, mit Ring in der Seitenfarbe. In der Navigation und an den Tabs sitzt er **auf dem Text**: der Text steht in einem `relative inline-flex`-Span (mit `pr-2`, solange ein Zähler da ist), der Kreis bekommt `-top-1.5 -right-1`.
|
||||
- `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.
|
||||
@@ -157,6 +157,8 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d
|
||||
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging |
|
||||
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer | E-Mail-Alarme (auch im Admin-UI) |
|
||||
| `SMTP_SKIP_VERIFY` | `false` | Zertifikatsprüfung für interne Mailserver abschalten |
|
||||
| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) |
|
||||
| `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` (oder `…_CONTENT_FILE` mit Dateipfad) | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster. Ohne URL und Text bleibt der Link unsichtbar. |
|
||||
| `MINDA_DEMO` | `false` | Legt Demo-Konten `admin/admin` (mit Beispieldaten) und `user/user` an; eine Notiz und zwei Listen sind zwischen beiden geteilt, um das gemeinsame Bearbeiten auszuprobieren. **Nie auf öffentlichen Servern!** |
|
||||
|
||||
* **Reverse-Proxy:** Am besten WebSocket-Upgrade und ungepufferte SSE-Streams durchreichen. Blockiert ein Proxy beides, funktioniert MindSift trotzdem über Long-Polling bzw. regelmäßige Abfragen, nur etwas träger. Beispiele für Nginx, Traefik, Caddy und Cloudflare: [Doku/reverse-proxy.md](Doku/reverse-proxy.md).
|
||||
|
||||
+6
-6
@@ -37,18 +37,18 @@ func main() {
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Seed SMTP defaults from environment variables into DB if not yet configured in DB
|
||||
store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPTLS, cfg.SMTPSkipVerify)
|
||||
|
||||
if cfg.DemoMode {
|
||||
slog.Info("🧪 Demo-Modus aktiv (MINDA_DEMO=true): Richte Demo-Benutzer admin/admin und user/user ein...")
|
||||
if err := store.EnsureDemoAccounts(); err != nil {
|
||||
slog.Warn("⚠️ Fehler beim Anlegen der Demo-Accounts", "error", err)
|
||||
slog.Info("🧪 Demo-Modus aktiv: Setze Datenbank zurück und richte Demo-Accounts ein...")
|
||||
if err := store.ResetAndSeedDemo(); err != nil {
|
||||
slog.Warn("⚠️ Fehler beim Einrichten des Demo-Modus", "error", err)
|
||||
} else {
|
||||
slog.Info("✅ Demo-Accounts bereit: admin / admin (Admin-Rolle) und user / user (Standard-Rolle)")
|
||||
}
|
||||
}
|
||||
|
||||
// Seed SMTP defaults from environment variables into DB if not yet configured in DB
|
||||
store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPTLS, cfg.SMTPSkipVerify)
|
||||
|
||||
wsHub := ws.NewHub()
|
||||
|
||||
staticFS, err := fs.Sub(embeddedDist, "dist")
|
||||
|
||||
@@ -27,3 +27,8 @@ services:
|
||||
- SMTP_FROM=${SMTP_FROM:-}
|
||||
- SMTP_TLS=${SMTP_TLS:-true}
|
||||
- SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false}
|
||||
# Optional: Impressum / Datenschutz (URL oder Markdown-Datei, z. B. per Volume eingebunden)
|
||||
- LEGAL_IMPRINT_URL=${LEGAL_IMPRINT_URL:-}
|
||||
- LEGAL_PRIVACY_URL=${LEGAL_PRIVACY_URL:-}
|
||||
- LEGAL_IMPRINT_CONTENT_FILE=${LEGAL_IMPRINT_CONTENT_FILE:-}
|
||||
- LEGAL_PRIVACY_CONTENT_FILE=${LEGAL_PRIVACY_CONTENT_FILE:-}
|
||||
@@ -27,3 +27,8 @@ services:
|
||||
- SMTP_FROM=${SMTP_FROM:-}
|
||||
- SMTP_TLS=${SMTP_TLS:-true}
|
||||
- SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false}
|
||||
# Optional: Impressum / Datenschutz (URL oder Markdown-Datei, z. B. per Volume eingebunden)
|
||||
- LEGAL_IMPRINT_URL=${LEGAL_IMPRINT_URL:-}
|
||||
- LEGAL_PRIVACY_URL=${LEGAL_PRIVACY_URL:-}
|
||||
- LEGAL_IMPRINT_CONTENT_FILE=${LEGAL_IMPRINT_CONTENT_FILE:-}
|
||||
- LEGAL_PRIVACY_CONTENT_FILE=${LEGAL_PRIVACY_CONTENT_FILE:-}
|
||||
Generated
+22
@@ -9,6 +9,7 @@
|
||||
"version": "0.2.0",
|
||||
"dependencies": {
|
||||
"@types/prismjs": "^1.26.6",
|
||||
"emoji-picker-react": "^4.12.0",
|
||||
"framer-motion": "^11.15.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"prismjs": "^1.30.0",
|
||||
@@ -2101,6 +2102,21 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/emoji-picker-react": {
|
||||
"version": "4.12.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.12.0.tgz",
|
||||
"integrity": "sha512-q2c8UcZH0eRIMj41bj0k1akTjk69tsu+E7EzkW7giN66iltF6H9LQvQvw6ugscsxdC+1lmt3WZpQkkY65J95tg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"flairup": "1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
@@ -2302,6 +2318,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/flairup": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/flairup/-/flairup-1.0.0.tgz",
|
||||
"integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/prismjs": "^1.26.6",
|
||||
"emoji-picker-react": "4.12.0",
|
||||
"framer-motion": "^11.15.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"prismjs": "^1.30.0",
|
||||
|
||||
+78
-7
@@ -23,6 +23,7 @@ import {
|
||||
Flame,
|
||||
Mic,
|
||||
MicOff,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { AuthStatus, DashboardStats, PriorityLevel, SharedList, Thought, User, UserTheme } from './lib/types';
|
||||
import {
|
||||
@@ -56,6 +57,7 @@ import { GroupsView } from './features/teams/GroupsView';
|
||||
import { useMobileActions } from './context/MobileActionContext';
|
||||
import { MobileHeaderMenu } from './components/MobileHeaderMenu';
|
||||
import { UserAvatar } from './components/UserAvatar';
|
||||
import { CommandPalette } from './components/CommandPalette';
|
||||
import { APP_VERSION } from './version';
|
||||
|
||||
type ActiveTab =
|
||||
@@ -136,6 +138,8 @@ export const App: React.FC = () => {
|
||||
const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
|
||||
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const [selectedListId, setSelectedListId] = useState<string | null>(null);
|
||||
const [showCommandPalette, setShowCommandPalette] = useState(false);
|
||||
const [captureFocusCount, setCaptureFocusCount] = useState(0);
|
||||
const [refreshRevision, setRefreshRevision] = useState(0);
|
||||
|
||||
@@ -594,6 +598,36 @@ export const App: React.FC = () => {
|
||||
setActiveTab('tasks');
|
||||
}, []);
|
||||
|
||||
const handleOpenList = (listId: string) => {
|
||||
setSelectedListId(listId);
|
||||
setActiveTab('lists');
|
||||
};
|
||||
|
||||
// Global Command Palette shortcuts: Cmd+K / Ctrl+K, and '/' when not in input
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const el = e.target as HTMLElement;
|
||||
const isEditing =
|
||||
el.tagName === 'INPUT' ||
|
||||
el.tagName === 'TEXTAREA' ||
|
||||
el.isContentEditable;
|
||||
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
setShowCommandPalette((prev) => !prev);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isEditing && e.key === '/') {
|
||||
e.preventDefault();
|
||||
setShowCommandPalette(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
const handleTriggerAddButton = () => {
|
||||
triggerHaptic(20);
|
||||
if (window.innerWidth < 640) {
|
||||
@@ -710,6 +744,19 @@ export const App: React.FC = () => {
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setShowCommandPalette(true);
|
||||
}}
|
||||
className="w-11 h-11 rounded-full flex items-center justify-center text-muted hover:text-main bg-panel border border-border transition-transform active:scale-95 cursor-pointer"
|
||||
title="Globale Suche"
|
||||
aria-label="Globale Suche öffnen"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -750,12 +797,23 @@ export const App: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Quick-Capture Input Bubble directly in Header (above Tabs) */}
|
||||
<div className="flex-1 max-w-xl mx-auto">
|
||||
<InstantCaptureBar
|
||||
onCaptured={handleCaptured}
|
||||
lists={lists}
|
||||
autoFocusTrigger={captureFocusCount}
|
||||
/>
|
||||
<div className="flex-1 max-w-xl mx-auto flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<InstantCaptureBar
|
||||
onCaptured={handleCaptured}
|
||||
lists={lists}
|
||||
autoFocusTrigger={captureFocusCount}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCommandPalette(true)}
|
||||
className="h-8 px-2.5 rounded-btn bg-panel border border-border text-muted hover:text-main flex items-center gap-1.5 text-xs transition-colors cursor-pointer shrink-0"
|
||||
title="Globale Suche öffnen (Cmd+K oder /)"
|
||||
>
|
||||
<Search className="w-3.5 h-3.5" />
|
||||
<span className="hidden lg:inline font-mono text-[10px] text-accent font-semibold">⌘K</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* User Profile Pill & Dropdown Menu */}
|
||||
@@ -1010,7 +1068,11 @@ export const App: React.FC = () => {
|
||||
)}
|
||||
|
||||
{activeTab === 'lists' && (
|
||||
<SharedListsView onRefreshGlobal={refreshAll} currentUser={user} />
|
||||
<SharedListsView
|
||||
onRefreshGlobal={refreshAll}
|
||||
currentUser={user}
|
||||
initialListId={selectedListId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'groups' && (
|
||||
@@ -1533,6 +1595,15 @@ export const App: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Global Command Palette (Cmd+K / Ctrl+K / '/') */}
|
||||
<CommandPalette
|
||||
isOpen={showCommandPalette}
|
||||
onClose={() => setShowCommandPalette(false)}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
onOpenTask={handleOpenTask}
|
||||
onOpenList={handleOpenList}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { Brain, ExternalLink } from 'lucide-react';
|
||||
import { APP_VERSION } from '../version';
|
||||
import { ReadingModal } from './ReadingModal';
|
||||
|
||||
// Project facts shown in "Über MindSift"; change here when the repository or license changes
|
||||
export const PROJECT_REPO_URL = 'https://git.dsit.me/sandrodach/MindSift';
|
||||
export const PROJECT_LICENSE = 'Noch nicht festgelegt (keine LICENSE-Datei im Repository)';
|
||||
|
||||
export const AboutModal: React.FC<{ onClose: () => void }> = ({ onClose }) => (
|
||||
<ReadingModal title="Über MindSift" onClose={onClose}>
|
||||
<div className="space-y-4 text-xs text-muted leading-relaxed">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 bg-accent text-on-accent rounded-btn flex items-center justify-center shrink-0">
|
||||
<Brain className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-main">MindSift</p>
|
||||
<p>Der Kopf frei in drei Sekunden.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment. MindSift trennt deshalb{' '}
|
||||
<strong className="text-main">Erfassen</strong> und <strong className="text-main">Organisieren</strong>: Gedanken
|
||||
landen sofort im Quick Dump und werden später mit einem Tastendruck zu Aufgabe, Notiz oder Listeneintrag. Alles läuft
|
||||
selbst gehostet in einem Container mit einer SQLite-Datei – deine Daten bleiben bei dir.
|
||||
</p>
|
||||
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5">
|
||||
<dt className="font-semibold text-main">Version</dt>
|
||||
<dd className="font-mono">{APP_VERSION}</dd>
|
||||
<dt className="font-semibold text-main">Lizenz</dt>
|
||||
<dd>{PROJECT_LICENSE}</dd>
|
||||
<dt className="font-semibold text-main">Quellcode</dt>
|
||||
<dd>
|
||||
<a href={PROJECT_REPO_URL} target="_blank" rel="noopener noreferrer" className="text-accent hover:underline inline-flex items-center gap-1">
|
||||
{PROJECT_REPO_URL.replace(/^https:\/\//, '')}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<p className="text-[11px] text-faint">
|
||||
Impressum und Datenschutz dieser Instanz verantwortet ihr Betreiber; die Links stehen in den Kontoeinstellungen und auf der Anmeldeseite.
|
||||
</p>
|
||||
</div>
|
||||
</ReadingModal>
|
||||
);
|
||||
@@ -0,0 +1,327 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Search,
|
||||
FileText,
|
||||
CheckSquare,
|
||||
ShoppingCart,
|
||||
X,
|
||||
Loader2,
|
||||
ArrowRight,
|
||||
CornerDownLeft,
|
||||
} from 'lucide-react';
|
||||
import { SearchHit } from '../lib/types';
|
||||
import { api } from '../lib/api';
|
||||
|
||||
interface CommandPaletteProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onOpenDocument: (docId: string, blockId?: string) => void;
|
||||
onOpenTask: (taskId: string) => void;
|
||||
onOpenList: (listId: string) => void;
|
||||
}
|
||||
|
||||
// Safely render snippet containing <mark>...</mark> without dangerouslySetInnerHTML
|
||||
const SnippetRenderer: React.FC<{ snippet: string }> = ({ snippet }) => {
|
||||
const parts = useMemo(() => {
|
||||
if (!snippet) return [];
|
||||
return snippet.split(/(<mark>[\s\S]*?<\/mark>)/g);
|
||||
}, [snippet]);
|
||||
|
||||
return (
|
||||
<span className="text-muted text-xs line-clamp-2 leading-relaxed">
|
||||
{parts.map((part, i) => {
|
||||
if (part.startsWith('<mark>') && part.endsWith('</mark>')) {
|
||||
const text = part.slice(6, -7);
|
||||
return (
|
||||
<mark
|
||||
key={i}
|
||||
className="bg-accent-dim text-accent font-semibold px-0.5 rounded"
|
||||
>
|
||||
{text}
|
||||
</mark>
|
||||
);
|
||||
}
|
||||
return <span key={i}>{part}</span>;
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const CommandPalette: React.FC<CommandPaletteProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onOpenDocument,
|
||||
onOpenTask,
|
||||
onOpenList,
|
||||
}) => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [hits, setHits] = useState<SearchHit[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const debounceTimerRef = useRef<number | null>(null);
|
||||
|
||||
// Group hits by category: Notizen, Aufgaben, Listen
|
||||
const groupedHits = useMemo(() => {
|
||||
const docs: SearchHit[] = [];
|
||||
const tasks: SearchHit[] = [];
|
||||
const lists: SearchHit[] = [];
|
||||
|
||||
for (const h of hits) {
|
||||
if (h.type === 'document') {
|
||||
docs.push(h);
|
||||
} else if (h.type === 'thought') {
|
||||
tasks.push(h);
|
||||
} else if (h.type === 'list_item') {
|
||||
lists.push(h);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
{ key: 'documents', label: 'Notizen', icon: FileText, items: docs },
|
||||
{ key: 'tasks', label: 'Aufgaben', icon: CheckSquare, items: tasks },
|
||||
{ key: 'lists', label: 'Listen', icon: ShoppingCart, items: lists },
|
||||
].filter((g) => g.items.length > 0);
|
||||
}, [hits]);
|
||||
|
||||
// Flattened array for simple up/down index navigation
|
||||
const flatHits = useMemo(() => {
|
||||
return groupedHits.flatMap((g) => g.items);
|
||||
}, [groupedHits]);
|
||||
|
||||
// Focus input and reset on open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setQuery('');
|
||||
setHits([]);
|
||||
setSelectedIndex(0);
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// 150ms debounce search
|
||||
useEffect(() => {
|
||||
if (debounceTimerRef.current !== null) {
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) {
|
||||
setHits([]);
|
||||
setLoading(false);
|
||||
setSelectedIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
debounceTimerRef.current = window.setTimeout(async () => {
|
||||
try {
|
||||
const res = await api.searchAll(trimmed);
|
||||
setHits(res.hits || []);
|
||||
setSelectedIndex(0);
|
||||
} catch {
|
||||
setHits([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 150);
|
||||
|
||||
return () => {
|
||||
if (debounceTimerRef.current !== null) {
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
// Keyboard navigation: ↑, ↓, Enter, Escape
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (flatHits.length === 0) return;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev + 1) % flatHits.length);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev - 1 + flatHits.length) % flatHits.length);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const selected = flatHits[selectedIndex];
|
||||
if (selected) {
|
||||
executeSelection(selected);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, flatHits, selectedIndex]);
|
||||
|
||||
const executeSelection = (hit: SearchHit) => {
|
||||
onClose();
|
||||
if (hit.type === 'document') {
|
||||
onOpenDocument(hit.docId || hit.id, hit.blockId);
|
||||
} else if (hit.type === 'thought') {
|
||||
onOpenTask(hit.id);
|
||||
} else if (hit.type === 'list_item') {
|
||||
if (hit.listId) {
|
||||
onOpenList(hit.listId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center p-0 sm:p-4 sm:pt-16 bg-black/60 backdrop-blur-xs animate-in fade-in duration-150"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Search Modal Container */}
|
||||
<div
|
||||
className="
|
||||
w-full h-full bg-panel flex flex-col overflow-hidden shadow-2xl
|
||||
sm:h-auto sm:max-h-[80vh] sm:max-w-2xl sm:rounded-card sm:border sm:border-border
|
||||
"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Top Input Bar */}
|
||||
<div className="flex items-center gap-2.5 px-4 py-3 border-b border-border bg-canvas/40 shrink-0">
|
||||
<Search className="w-5 h-5 text-accent shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Globale Suche in Notizen, Aufgaben und Listen…"
|
||||
className="flex-1 bg-transparent border-0 outline-none text-main placeholder-muted text-base sm:text-sm focus:ring-0 p-0"
|
||||
/>
|
||||
{loading && <Loader2 className="w-4 h-4 animate-spin text-accent shrink-0" />}
|
||||
{query && !loading && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setQuery('');
|
||||
setHits([]);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
className="text-muted hover:text-main p-1"
|
||||
title="Eingabe leeren"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="sm:hidden px-2 py-1 text-xs font-semibold text-accent"
|
||||
>
|
||||
Fertig
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Results Container */}
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-4 min-h-[160px]">
|
||||
{query.trim() === '' ? (
|
||||
<div className="py-12 px-4 text-center text-xs text-muted space-y-1">
|
||||
<p className="font-semibold text-main">MindSift Globale Suche</p>
|
||||
<p>Tippe ein Stichwort ein, um alle Inhalte in Sekundenschnelle zu durchsuchen.</p>
|
||||
</div>
|
||||
) : flatHits.length === 0 && !loading ? (
|
||||
<div className="py-12 px-4 text-center text-xs text-muted space-y-1">
|
||||
<p className="font-semibold text-main">Keine Treffer gefunden</p>
|
||||
<p>Keine Notizen, Aufgaben oder Listen-Einträge stimmen mit „{query}“ überein.</p>
|
||||
</div>
|
||||
) : (
|
||||
groupedHits.map((group) => {
|
||||
const GroupIcon = group.icon;
|
||||
return (
|
||||
<div key={group.key} className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-bold text-accent uppercase tracking-wider">
|
||||
<GroupIcon className="w-3.5 h-3.5" />
|
||||
<span>{group.label}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{group.items.map((item) => {
|
||||
const overallIndex = flatHits.indexOf(item);
|
||||
const isSelected = overallIndex === selectedIndex;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => executeSelection(item)}
|
||||
onMouseEnter={() => setSelectedIndex(overallIndex)}
|
||||
className={`
|
||||
px-3 py-2.5 rounded-btn flex items-start gap-2.5 transition-colors cursor-pointer border
|
||||
${
|
||||
isSelected
|
||||
? 'bg-panel-elevated border-accent text-main'
|
||||
: 'bg-canvas/50 border-transparent text-main hover:bg-panel-elevated'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="mt-0.5 shrink-0 text-base leading-none">
|
||||
{item.icon || (item.type === 'document' ? '📝' : item.type === 'thought' ? '✅' : '🛒')}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-0.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-semibold text-xs truncate">
|
||||
{item.title || 'Unbenannt'}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<span className="hidden sm:inline-flex items-center text-[10px] text-accent gap-0.5 shrink-0">
|
||||
<span>Öffnen</span>
|
||||
<CornerDownLeft className="w-3 h-3" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.snippet && (
|
||||
<div>
|
||||
<SnippetRenderer snippet={item.snippet} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Shortcut Bar (Desktop) */}
|
||||
<div className="hidden sm:flex items-center justify-between px-4 py-2 bg-canvas/40 border-t border-border text-[11px] text-muted shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<span>
|
||||
<kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px] text-accent">↑</kbd>{' '}
|
||||
<kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px] text-accent">↓</kbd> Navigieren
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px] text-accent">↵</kbd> Auswählen
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px] text-accent">Esc</kbd> Schließen
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span>{flatHits.length} Treffer</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import React, { useState } from 'react';
|
||||
import { AuthStatus } from '../lib/types';
|
||||
import { api } from '../lib/api';
|
||||
import { MarkdownViewer } from '../lib/markdown';
|
||||
import { ReadingModal } from './ReadingModal';
|
||||
|
||||
type LegalType = 'imprint' | 'privacy';
|
||||
const TITLES: Record<LegalType, string> = { imprint: 'Impressum', privacy: 'Datenschutz' };
|
||||
|
||||
interface LegalLinksProps {
|
||||
legal?: AuthStatus['legal'];
|
||||
className?: string;
|
||||
children?: React.ReactNode; // further footer links, e.g. "Über MindSift"
|
||||
}
|
||||
|
||||
// Impressum / Datenschutz links: an external URL opens in a new tab, Markdown in a reading modal.
|
||||
// Links without URL or text are not shown at all.
|
||||
export const LegalLinks: React.FC<LegalLinksProps> = ({ legal, className = '', children }) => {
|
||||
const [open, setOpen] = useState<{ type: LegalType; content: string } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const entries = (['imprint', 'privacy'] as LegalType[])
|
||||
.map((type) => ({
|
||||
type,
|
||||
url: type === 'imprint' ? legal?.imprintUrl : legal?.privacyUrl,
|
||||
hasContent: type === 'imprint' ? legal?.hasImprintContent : legal?.hasPrivacyContent,
|
||||
}))
|
||||
.filter((e) => e.url || e.hasContent);
|
||||
|
||||
if (entries.length === 0 && !children) return null;
|
||||
|
||||
const openText = async (type: LegalType) => {
|
||||
setError(null);
|
||||
try {
|
||||
setOpen({ type, content: await api.getLegalText(type) });
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Text konnte nicht geladen werden');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-[11px] text-muted ${className}`}>
|
||||
{entries.map((e) =>
|
||||
e.url ? (
|
||||
<a key={e.type} href={e.url} target="_blank" rel="noopener noreferrer" className="hover:text-main underline-offset-2 hover:underline">
|
||||
{TITLES[e.type]}
|
||||
</a>
|
||||
) : (
|
||||
<button key={e.type} type="button" onClick={() => openText(e.type)} className="hover:text-main underline-offset-2 hover:underline">
|
||||
{TITLES[e.type]}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
{children}
|
||||
{error && <span className="text-status-err">{error}</span>}
|
||||
{open && (
|
||||
<ReadingModal title={TITLES[open.type]} onClose={() => setOpen(null)}>
|
||||
<MarkdownViewer content={open.content} />
|
||||
</ReadingModal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface ReadingModalProps {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// Slim modal for reading text (legal notices, about); Escape or backdrop click closes it
|
||||
export const ReadingModal: React.FC<ReadingModalProps> = ({ title, onClose, children }) => {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
>
|
||||
<div className="panel-surface w-full max-w-2xl max-h-[85vh] flex flex-col shadow-panel">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<h2 className="text-sm font-bold text-main">{title}</h2>
|
||||
<button type="button" onClick={onClose} className="btn-icon" title="Schließen">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 overflow-y-auto text-sm text-main">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { AuthStatus, SharingMode, User } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||
import { LegalLinks } from '../../components/LegalLinks';
|
||||
|
||||
interface SetupAndAuthViewProps {
|
||||
authStatus: AuthStatus;
|
||||
@@ -330,6 +331,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<LegalLinks legal={authStatus.legal} className="mt-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -574,6 +576,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<LegalLinks legal={authStatus.legal} className="mt-4" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -103,7 +103,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
el.tagName === 'TEXTAREA' ||
|
||||
el.isContentEditable;
|
||||
|
||||
if (!isEditing && (e.key === 'c' || e.key === 'C' || e.key === '/')) {
|
||||
if (!isEditing && (e.key === 'c' || e.key === 'C')) {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
} else if (e.key === 'Escape' && document.activeElement === inputRef.current) {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
Flame,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import { AboutModal } from '../../components/AboutModal';
|
||||
|
||||
interface HelpViewProps {
|
||||
onBack?: () => void;
|
||||
@@ -55,6 +56,7 @@ interface DocCard {
|
||||
export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
||||
const [activeCategory, setActiveCategory] = useState<HelpCategory>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showAbout, setShowAbout] = useState(false);
|
||||
|
||||
// Documentation sections & cards data
|
||||
const cards: DocCard[] = useMemo(
|
||||
@@ -341,6 +343,42 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'workspace-search-cmdk',
|
||||
category: 'workspace',
|
||||
title: 'Globale Suche & Command Palette (Cmd+K)',
|
||||
subtitle: 'FTS5-Volltextsuche in Notizen, Blöcken, Aufgaben und Listen',
|
||||
icon: Command,
|
||||
keywords: [
|
||||
'suche',
|
||||
'search',
|
||||
'command',
|
||||
'palette',
|
||||
'cmdk',
|
||||
'find',
|
||||
'fts',
|
||||
'volltext',
|
||||
'tastatur',
|
||||
],
|
||||
content: (
|
||||
<div className="space-y-2 text-muted leading-relaxed">
|
||||
<p>
|
||||
Drücke <kbd className="px-1.5 py-0.5 bg-panel border border-border rounded font-mono text-[11px] text-accent">Cmd + K</kbd> / <kbd className="px-1.5 py-0.5 bg-panel border border-border rounded font-mono text-[11px] text-accent">Strg + K</kbd> oder <kbd className="px-1.5 py-0.5 bg-panel border border-border rounded font-mono text-[11px] text-accent">/</kbd> (wenn kein Textfeld aktiv ist), um die globale Suche zu öffnen:
|
||||
</p>
|
||||
<ul className="space-y-1.5 list-disc list-inside">
|
||||
<li>
|
||||
<strong className="text-main">Volltextsuche (FTS5):</strong> Durchsucht Notiz-Titel und Blöcke, Aufgaben und Listeneinträge blitzschnell mit Relevanz-Ranking (BM25) und Präfixsuche.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-main">Sicherheit:</strong> Zeigt nur Inhalte, auf die du tatsächlich Zugriff hast (eigene sowie freigegebene Notizen, Aufgabenlisten und Listen).
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-main">Tastaturnavigation:</strong> Mit <kbd className="px-1.5 py-0.5 bg-panel border border-border rounded font-mono text-[11px] text-accent">↑</kbd> und <kbd className="px-1.5 py-0.5 bg-panel border border-border rounded font-mono text-[11px] text-accent">↓</kbd> blättern, mit <kbd className="px-1.5 py-0.5 bg-panel border border-border rounded font-mono text-[11px] text-accent">Enter</kbd> Treffer direkt öffnen (Notizen direkt am gefundenen Block).
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
// TRIAGE & QUICK DUMP
|
||||
{
|
||||
@@ -656,7 +694,8 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
||||
// Keyboard shortcuts table data
|
||||
const shortcuts = useMemo(
|
||||
() => [
|
||||
{ key: 'C oder /', scope: 'Global', desc: 'Fokus sofort in die Schnellerfassungsleiste setzen' },
|
||||
{ key: 'Cmd + K / Strg + K oder /', scope: 'Global', desc: 'Globale Volltextsuche (Command Palette) öffnen' },
|
||||
{ key: 'C', scope: 'Global', desc: 'Fokus sofort in die Schnellerfassungsleiste setzen' },
|
||||
{ key: 'Enter', scope: 'Schnellerfassung', desc: 'Gedanken blitzschnell speichern' },
|
||||
{ key: 'Esc', scope: 'Schnellerfassung', desc: 'Eingabe leeren & Optionen schließen' },
|
||||
{ key: 'Enter', scope: 'Workspace Outliner', desc: 'Neuen Bullet-Point anlegen (oder leeren Block ausrücken)' },
|
||||
@@ -876,6 +915,13 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center pt-2">
|
||||
<button type="button" onClick={() => setShowAbout(true)} className="text-[11px] text-muted hover:text-main hover:underline underline-offset-2">
|
||||
Über MindSift
|
||||
</button>
|
||||
</div>
|
||||
{showAbout && <AboutModal onClose={() => setShowAbout(false)} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -20,6 +20,7 @@ import { useMobileActions } from '../../context/MobileActionContext';
|
||||
interface SharedListsViewProps {
|
||||
onRefreshGlobal: () => void;
|
||||
currentUser?: User | null;
|
||||
initialListId?: string | null;
|
||||
}
|
||||
|
||||
export function parseListItemInput(rawText: string, defaultQty?: string): {
|
||||
@@ -79,10 +80,16 @@ export function deduplicateItems(items: ListItem[]): ListItem[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal, currentUser }) => {
|
||||
export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal, currentUser, initialListId }) => {
|
||||
const { setGroups, setPrimaryAction } = useMobileActions();
|
||||
const [lists, setLists] = useState<SharedList[]>([]);
|
||||
const [activeListId, setActiveListId] = useState<string>('');
|
||||
const [activeListId, setActiveListId] = useState<string>(initialListId || '');
|
||||
|
||||
useEffect(() => {
|
||||
if (initialListId) {
|
||||
setActiveListId(initialListId);
|
||||
}
|
||||
}, [initialListId]);
|
||||
|
||||
const [newItemText, setNewItemText] = useState('');
|
||||
const [newItemQty, setNewItemQty] = useState('');
|
||||
@@ -128,7 +135,9 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
items: deduplicateItems(l.items || []),
|
||||
}));
|
||||
setLists(deduplicated);
|
||||
if (deduplicated.length > 0 && (!activeListId || !deduplicated.some((l) => l.id === activeListId))) {
|
||||
if (initialListId && deduplicated.some((l) => l.id === initialListId)) {
|
||||
setActiveListId(initialListId);
|
||||
} else if (deduplicated.length > 0 && (!activeListId || !deduplicated.some((l) => l.id === activeListId))) {
|
||||
setActiveListId(deduplicated[0].id);
|
||||
}
|
||||
} catch {
|
||||
@@ -843,8 +852,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
<div className="w-full space-y-3">
|
||||
{/* Top Header: List Tabs & Actions (Desktop) */}
|
||||
<div className="hidden sm:flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2 !mt-0">
|
||||
{/* List Tabs (counters sit on the top right corner, hence the extra gap) */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-3 pt-2">
|
||||
{/* List Tabs */}
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{lists.map((lst) => {
|
||||
const openCount = deduplicateItems(lst.items || []).filter((i) => !i.isCompleted).length;
|
||||
const isSelected = activeList?.id === lst.id;
|
||||
@@ -860,7 +869,11 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
className="h-full px-3 text-xs font-medium flex items-center gap-1.5 rounded-btn"
|
||||
>
|
||||
<span>{lst.icon}</span>
|
||||
<span>{lst.title}</span>
|
||||
{/* Counter on the label, like in the nav bar */}
|
||||
<span className={`relative inline-flex items-center ${openCount > 0 ? 'pr-2' : ''}`}>
|
||||
{lst.title}
|
||||
<CountBadge count={openCount} position="corner" className="-top-1.5 -right-1" />
|
||||
</span>
|
||||
<AvatarStack
|
||||
people={otherMembers(lst)}
|
||||
size="xxs"
|
||||
@@ -868,7 +881,6 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
className="ml-0.5"
|
||||
/>
|
||||
</button>
|
||||
<CountBadge count={openCount} position="corner" />
|
||||
{isSelected && lists.length > 1 && (
|
||||
<button
|
||||
onClick={() => handleDeleteList(lst.id)}
|
||||
|
||||
@@ -20,11 +20,13 @@ import {
|
||||
Pencil,
|
||||
Copy,
|
||||
} from 'lucide-react';
|
||||
import { APIKey, User, UserTheme } from '../../lib/types';
|
||||
import { APIKey, AuthStatus, User, UserTheme } from '../../lib/types';
|
||||
import { api, 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';
|
||||
|
||||
interface UserSettingsViewProps {
|
||||
currentUser: User;
|
||||
@@ -50,6 +52,11 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
onBack,
|
||||
}) => {
|
||||
const [section, setSection] = useState<UserSettingsSection>('profile');
|
||||
const [legal, setLegal] = useState<AuthStatus['legal']>();
|
||||
const [showAbout, setShowAbout] = useState(false);
|
||||
useEffect(() => {
|
||||
api.getAuthStatus().then((st) => setLegal(st.legal)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// --- Custom Theme Editor State ---
|
||||
const [themeEditId, setThemeEditId] = useState<string | null>(null); // null = new theme
|
||||
@@ -355,17 +362,38 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
setTimeout(() => setCopiedLabel(null), 2000);
|
||||
};
|
||||
|
||||
// Works on mobile browsers too: the link must be in the document and the URL alive until the click ran
|
||||
const saveBlob = (blob: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 10000);
|
||||
};
|
||||
|
||||
const handleExportData = async () => {
|
||||
const data = await api.exportAll();
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||
type: 'application/json;charset=utf-8',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `mindsift-backup-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
saveBlob(blob, `mindsift-backup-${new Date().toISOString().slice(0, 10)}.json`);
|
||||
};
|
||||
|
||||
const [exportingVault, setExportingVault] = useState(false);
|
||||
const [vaultError, setVaultError] = useState<string | null>(null);
|
||||
const handleExportVault = async () => {
|
||||
setExportingVault(true);
|
||||
setVaultError(null);
|
||||
try {
|
||||
saveBlob(await api.exportMarkdownVault(), `mindsift-vault-${new Date().toISOString().slice(0, 10)}.zip`);
|
||||
} catch (err: any) {
|
||||
setVaultError(err.message || 'Export fehlgeschlagen');
|
||||
} finally {
|
||||
setExportingVault(false);
|
||||
}
|
||||
};
|
||||
|
||||
const baseUrl = window.location.origin;
|
||||
@@ -1223,10 +1251,40 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
<span>Backup herunterladen</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Obsidian-compatible Markdown vault */}
|
||||
<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">Markdown-Archiv für Obsidian (.zip)</h3>
|
||||
<p className="text-muted text-[11px]">
|
||||
Alle Notizen als <code className="font-mono">.md</code>-Dateien in deiner Ordnerstruktur (mit Titel, Tags und
|
||||
Datum im Kopf), Aufgaben je Liste unter <code className="font-mono">Aufgaben/</code> im Format des
|
||||
Obsidian-Tasks-Plugins, Listen unter <code className="font-mono">Listen/</code>. Mit dir geteilte Notizen sind
|
||||
dabei.
|
||||
</p>
|
||||
{vaultError && <p className="text-[11px] text-status-err mt-1">{vaultError}</p>}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExportVault}
|
||||
disabled={exportingVault}
|
||||
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>{exportingVault ? 'Wird erstellt…' : 'Markdown-Archiv herunterladen'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer: legal notices of this instance and project info */}
|
||||
<LegalLinks legal={legal} className="pt-4 border-t border-border">
|
||||
<button type="button" onClick={() => setShowAbout(true)} className="hover:text-main underline-offset-2 hover:underline">
|
||||
Über MindSift
|
||||
</button>
|
||||
</LegalLinks>
|
||||
{showAbout && <AboutModal onClose={() => setShowAbout(false)} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1373,18 +1373,21 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
<div className="w-full space-y-3">
|
||||
{/* Top Header: Task Lists Tabs & View Mode Switcher (Desktop) */}
|
||||
<div className="hidden sm:flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2 !mt-0">
|
||||
{/* Task Lists selector tabs (counters sit on the top right corner, hence the extra gap) */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-3 pt-2">
|
||||
{/* Task Lists selector tabs */}
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<button
|
||||
onClick={() => setSelectedListId('all')}
|
||||
className={`relative h-8 px-3 text-xs font-medium border rounded-btn transition-colors ${
|
||||
className={`h-8 px-3 text-xs font-medium border rounded-btn transition-colors ${
|
||||
selectedListId === 'all'
|
||||
? 'bg-panel border-accent text-main'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
Alle Aufgaben
|
||||
<CountBadge count={openTasksCount} position="corner" />
|
||||
{/* Counter on the label, like in the nav bar */}
|
||||
<span className={`relative inline-flex items-center ${openTasksCount ? 'pr-2' : ''}`}>
|
||||
Alle Aufgaben
|
||||
<CountBadge count={openTasksCount} position="corner" className="-top-1.5 -right-1" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{safeTaskLists.map((tl) => (
|
||||
@@ -1399,7 +1402,10 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
className="h-full px-3 text-xs font-medium flex items-center gap-1.5 rounded-btn"
|
||||
>
|
||||
<span>{tl.icon}</span>
|
||||
<span>{tl.title}</span>
|
||||
<span className={`relative inline-flex items-center ${tl.tasksCount ? 'pr-2' : ''}`}>
|
||||
{tl.title}
|
||||
<CountBadge count={tl.tasksCount} position="corner" className="-top-1.5 -right-1" />
|
||||
</span>
|
||||
|
||||
{(() => {
|
||||
const isShared = (tl.shares && tl.shares.length > 0) || (tl.members && tl.members.length > 1);
|
||||
@@ -1422,7 +1428,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
);
|
||||
})()}
|
||||
</button>
|
||||
<CountBadge count={tl.tasksCount} position="corner" />
|
||||
{selectedListId === tl.id && (
|
||||
<button
|
||||
onClick={() => handleDeleteTaskList(tl.id)}
|
||||
|
||||
@@ -44,63 +44,7 @@ export interface DocumentEditorProps {
|
||||
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
|
||||
}
|
||||
|
||||
interface EmojiItem {
|
||||
emoji: string;
|
||||
category: string;
|
||||
keywords: string;
|
||||
}
|
||||
|
||||
const EMOJI_CATALOG: EmojiItem[] = [
|
||||
// Notizen & Dokumente
|
||||
{ emoji: '📝', category: 'Notizen', keywords: 'notiz schreiben memo text note' },
|
||||
{ emoji: '📄', category: 'Notizen', keywords: 'dokument seite page doc' },
|
||||
{ emoji: '📋', category: 'Notizen', keywords: 'clipboard klemmbrett liste' },
|
||||
{ emoji: '📂', category: 'Notizen', keywords: 'ordner mappe folder datei' },
|
||||
{ emoji: '📁', category: 'Notizen', keywords: 'ordner folder directory' },
|
||||
{ emoji: '📌', category: 'Notizen', keywords: 'pin anheften bookmark wichtig' },
|
||||
{ emoji: '📍', category: 'Notizen', keywords: 'standort ort pin location' },
|
||||
{ emoji: '🏷️', category: 'Notizen', keywords: 'tag etikett label markierung' },
|
||||
{ emoji: '📚', category: 'Notizen', keywords: 'buch bibliothek lesen studium' },
|
||||
{ emoji: '📖', category: 'Notizen', keywords: 'buch open book wissen' },
|
||||
|
||||
// Ideen & Fokus
|
||||
{ emoji: '🧠', category: 'Ideen', keywords: 'gehirn brain denken wissen thought mind' },
|
||||
{ emoji: '💡', category: 'Ideen', keywords: 'idee inspiration lampe glühbirne idea' },
|
||||
{ emoji: '🎯', category: 'Ideen', keywords: 'ziel target fokus mission okr' },
|
||||
{ emoji: '🚀', category: 'Ideen', keywords: 'rakete start launch projekt rocket' },
|
||||
{ emoji: '⚡', category: 'Ideen', keywords: 'blitz energie schnell quick power' },
|
||||
{ emoji: '🔥', category: 'Ideen', keywords: 'feuer flame priorität hot wichtig' },
|
||||
{ emoji: '⭐', category: 'Ideen', keywords: 'stern favoriten star top' },
|
||||
{ emoji: '✨', category: 'Ideen', keywords: 'funkeln magie neu neuigkeit sparkles' },
|
||||
{ emoji: '🧩', category: 'Ideen', keywords: 'puzzle teil lösung problem' },
|
||||
{ emoji: '🔬', category: 'Ideen', keywords: 'mikroskop forschung analyse test' },
|
||||
|
||||
// Tech & Dev
|
||||
{ emoji: '💻', category: 'Tech', keywords: 'laptop computer dev code programming' },
|
||||
{ emoji: '🖥️', category: 'Tech', keywords: 'desktop monitor bildschirm workstation' },
|
||||
{ emoji: '🛠️', category: 'Tech', keywords: 'werkzeug tools optionen settings einstellungen' },
|
||||
{ emoji: '⚙️', category: 'Tech', keywords: 'zahnrad config settings system' },
|
||||
{ emoji: '📊', category: 'Tech', keywords: 'diagramm chart daten statistik dashboard' },
|
||||
{ emoji: '📈', category: 'Tech', keywords: 'wachstum trend kurve business' },
|
||||
{ emoji: '🔒', category: 'Tech', keywords: 'schloss sicherheit geheim privat secure' },
|
||||
{ emoji: '🔑', category: 'Tech', keywords: 'schlüssel key passwort access' },
|
||||
{ emoji: '🌐', category: 'Tech', keywords: 'web internet network welt globus' },
|
||||
{ emoji: '📦', category: 'Tech', keywords: 'paket box release lieferung package' },
|
||||
|
||||
// Alltag & Organisation
|
||||
{ emoji: '💬', category: 'Alltag', keywords: 'chat nachricht kommentar call sprechen' },
|
||||
{ emoji: '✉️', category: 'Alltag', keywords: 'mail email brief post nachricht' },
|
||||
{ emoji: '📅', category: 'Alltag', keywords: 'kalender datum termin plan event' },
|
||||
{ emoji: '⏰', category: 'Alltag', keywords: 'wecker uhr zeit deadline alarm' },
|
||||
{ emoji: '🛒', category: 'Alltag', keywords: 'warenkorb einkauf shopping liste' },
|
||||
{ emoji: '☕', category: 'Alltag', keywords: 'kaffee pause bistro morning' },
|
||||
{ emoji: '🏠', category: 'Alltag', keywords: 'haus heim home wohnung' },
|
||||
{ emoji: '🏢', category: 'Alltag', keywords: 'büro firma arbeit office work' },
|
||||
{ emoji: '✈️', category: 'Alltag', keywords: 'flugzeug reise urlaub trip travel' },
|
||||
{ emoji: '🎨', category: 'Alltag', keywords: 'kunst farbe design creative' },
|
||||
{ emoji: '🏆', category: 'Alltag', keywords: 'pokal erfolg win trophy award' },
|
||||
{ emoji: '✅', category: 'Alltag', keywords: 'haken check done erledigt' },
|
||||
];
|
||||
import { EmojiPickerModal } from './EmojiPickerModal';
|
||||
|
||||
function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): DocumentBlock[] {
|
||||
if (!blocks || blocks.length === 0) {
|
||||
@@ -250,21 +194,12 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
const [loading, setLoading] = useState<boolean>(!initialDoc);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
|
||||
const [emojiSearch, setEmojiSearch] = useState<string>('');
|
||||
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
|
||||
|
||||
// Sync engine (operation log): stores every change locally and syncs it with the server
|
||||
const engineRef = useRef<DocSyncEngine | null>(null);
|
||||
const [engineState, setEngineState] = useState<EngineState | null>(null);
|
||||
|
||||
const filteredEmojis = useMemo(() => {
|
||||
const q = emojiSearch.trim().toLowerCase();
|
||||
if (!q) return EMOJI_CATALOG;
|
||||
return EMOJI_CATALOG.filter(
|
||||
(item) => item.keywords.includes(q) || item.emoji.includes(q) || item.category.toLowerCase().includes(q)
|
||||
);
|
||||
}, [emojiSearch]);
|
||||
|
||||
|
||||
const titleInputRef = useRef<HTMLTextAreaElement | HTMLInputElement | null>(null);
|
||||
const outlinerTreeRef = useRef<OutlinerTreeHandle | null>(null);
|
||||
@@ -1044,72 +979,11 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
{doc?.icon || '📝'}
|
||||
</button>
|
||||
{showEmojiPicker && canEdit && (
|
||||
<div
|
||||
className="absolute left-0 mt-1 p-2 panel-surface shadow-lg border border-border rounded-card z-30 w-72 space-y-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="relative">
|
||||
<Search className="w-3.5 h-3.5 text-faint absolute left-2 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={emojiSearch}
|
||||
onChange={(e) => setEmojiSearch(e.target.value)}
|
||||
placeholder="Icon suchen oder Emoji eingeben…"
|
||||
className="input-field w-full pl-7 pr-2 py-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
{doc?.icon && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleSelectIcon('');
|
||||
setShowEmojiPicker(false);
|
||||
setEmojiSearch('');
|
||||
}}
|
||||
className="w-full text-left px-2 py-1 text-status-warn/90 hover:bg-status-warn/10 rounded text-xs font-medium flex items-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
<span>Icon entfernen</span>
|
||||
</button>
|
||||
)}
|
||||
{emojiSearch.trim() && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleSelectIcon(emojiSearch.trim());
|
||||
setShowEmojiPicker(false);
|
||||
setEmojiSearch('');
|
||||
}}
|
||||
className="w-full text-left px-2 py-1 bg-accent/10 hover:bg-accent/20 text-accent rounded text-xs font-medium flex items-center justify-between cursor-pointer"
|
||||
>
|
||||
<span>Eingabe als Icon nutzen</span>
|
||||
<span className="text-base">{emojiSearch.trim()}</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="max-h-48 overflow-y-auto grid grid-cols-6 gap-1 pr-1">
|
||||
{filteredEmojis.map((item) => (
|
||||
<button
|
||||
key={item.emoji}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleSelectIcon(item.emoji);
|
||||
setShowEmojiPicker(false);
|
||||
setEmojiSearch('');
|
||||
}}
|
||||
className="p-1.5 rounded-btn hover:bg-panel-elevated text-lg flex items-center justify-center cursor-pointer transition-colors"
|
||||
title={item.keywords}
|
||||
>
|
||||
{item.emoji}
|
||||
</button>
|
||||
))}
|
||||
{filteredEmojis.length === 0 && (
|
||||
<div className="col-span-6 text-center text-xs text-faint py-3">
|
||||
Kein passendes Standard-Icon
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<EmojiPickerModal
|
||||
currentIcon={doc?.icon}
|
||||
onSelectEmoji={handleSelectIcon}
|
||||
onClose={() => setShowEmojiPicker(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import React, { Suspense, useEffect, useRef, useState } from 'react';
|
||||
import { X, Trash2, Loader2, Clock } from 'lucide-react';
|
||||
import type { EmojiClickData } from 'emoji-picker-react';
|
||||
import { EmojiStyle, Theme, SuggestionMode } from 'emoji-picker-react';
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
interface EmojiPickerModalProps {
|
||||
currentIcon?: string;
|
||||
onSelectEmoji: (emoji: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
||||
currentIcon,
|
||||
onSelectEmoji,
|
||||
onClose,
|
||||
}) => {
|
||||
const [recentEmojis, setRecentEmojis] = useState<string[]>(getStoredRecentEmojis);
|
||||
const [isDark, setIsDark] = useState<boolean>(() =>
|
||||
typeof document !== 'undefined'
|
||||
? document.documentElement.classList.contains('dark')
|
||||
: true
|
||||
);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Sync theme changes
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDark(document.documentElement.classList.contains('dark'));
|
||||
});
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'data-theme'],
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Close on Escape or click outside (desktop popover)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const handlePick = (emoji: string) => {
|
||||
saveRecentEmoji(emoji);
|
||||
setRecentEmojis(getStoredRecentEmojis());
|
||||
onSelectEmoji(emoji);
|
||||
};
|
||||
|
||||
const handlePickerEmojiClick = (emojiData: EmojiClickData) => {
|
||||
handlePick(emojiData.emoji);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Backdrop (< 640px) */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 z-40 sm:hidden backdrop-blur-xs"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Main Container: Mobile Bottom-Sheet (<640px) & Desktop Popover (>=640px) */}
|
||||
<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
|
||||
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={
|
||||
{
|
||||
// Inject MindSift CSS variables into emoji-picker-react for seamless theming
|
||||
'--epr-bg-color': 'var(--bg-panel)',
|
||||
'--epr-category-label-bg-color': 'var(--bg-panel)',
|
||||
'--epr-picker-border-color': 'var(--border-color)',
|
||||
'--epr-text-color': 'var(--text-main)',
|
||||
'--epr-search-input-bg-color': 'var(--bg-app)',
|
||||
'--epr-search-input-bg-color-active': 'var(--bg-panel-elevated)',
|
||||
'--epr-hover-bg-color': 'var(--bg-panel-elevated)',
|
||||
'--epr-focus-bg-color': 'var(--bg-panel-elevated)',
|
||||
'--epr-highlight-color': 'var(--accent-primary)',
|
||||
'--epr-category-icon-active-color': 'var(--accent-primary)',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* Mobile handle indicator */}
|
||||
<div className="w-10 h-1 bg-border rounded-full mx-auto sm:hidden shrink-0" />
|
||||
|
||||
{/* Top Header: Title, Actions & Close */}
|
||||
<div className="flex items-center justify-between gap-2 shrink-0 border-b border-border pb-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-main">
|
||||
<span>Icon auswählen</span>
|
||||
{currentIcon && (
|
||||
<span className="text-base leading-none ml-1">{currentIcon}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{currentIcon && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectEmoji('')}
|
||||
className="h-11 sm:h-7 px-3 sm:px-2 rounded text-xs font-medium text-status-err hover:bg-status-err/10 flex items-center gap-1 transition-colors cursor-pointer"
|
||||
title="Icon entfernen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Entfernen</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-11 h-11 sm:w-7 sm:h-7 rounded flex items-center justify-center text-muted hover:text-main hover:bg-panel-elevated transition-colors cursor-pointer"
|
||||
title="Schließen"
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<X className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
|
||||
</button>
|
||||
</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">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-64 flex flex-col items-center justify-center gap-2 text-xs text-muted">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-accent" />
|
||||
<span>Emoji-Katalog wird geladen…</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<EmojiPicker
|
||||
onEmojiClick={handlePickerEmojiClick}
|
||||
autoFocusSearch={true}
|
||||
theme={isDark ? Theme.DARK : Theme.LIGHT}
|
||||
emojiStyle={EmojiStyle.NATIVE}
|
||||
suggestedEmojisMode={SuggestionMode.RECENT}
|
||||
searchPlaceholder="Emoji suchen…"
|
||||
searchPlaceHolder="Emoji suchen…"
|
||||
width="100%"
|
||||
height="350px"
|
||||
previewConfig={{
|
||||
showPreview: false,
|
||||
}}
|
||||
skinTonesDisabled={false}
|
||||
lazyLoadEmojis={true}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -461,3 +461,30 @@ body {
|
||||
color: #f97316;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
EMOJI PICKER (emoji-picker-react Integration & Mobile Touch Tuning)
|
||||
========================================================================== */
|
||||
|
||||
.mindsift-emoji-picker .EmojiPickerReact {
|
||||
border: none !important;
|
||||
font-family: inherit !important;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
/* Prevent iOS auto-zoom on input focus */
|
||||
.mindsift-emoji-picker .EmojiPickerReact input.epr-search-input {
|
||||
font-size: 1rem !important;
|
||||
min-height: 44px !important;
|
||||
}
|
||||
|
||||
/* Min 44px touch targets on mobile */
|
||||
.mindsift-emoji-picker .EmojiPickerReact button.epr-emoji {
|
||||
min-width: 44px !important;
|
||||
min-height: 44px !important;
|
||||
}
|
||||
|
||||
.mindsift-emoji-picker .EmojiPickerReact .epr-category-nav button {
|
||||
min-height: 44px !important;
|
||||
}
|
||||
}
|
||||
+20
-2
@@ -24,6 +24,7 @@ import {
|
||||
TaskListShare,
|
||||
Thought,
|
||||
SubTask,
|
||||
SearchResults,
|
||||
TagDetails,
|
||||
TagSummary,
|
||||
User,
|
||||
@@ -141,6 +142,12 @@ export const api = {
|
||||
return st;
|
||||
},
|
||||
|
||||
// Public, works before login
|
||||
async getLegalText(type: 'imprint' | 'privacy'): Promise<string> {
|
||||
const res = await request<{ content: string }>(`/api/v1/legal/${type}`);
|
||||
return res.content || '';
|
||||
},
|
||||
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
await request<{ success: boolean }>('/api/v1/auth/logout', { method: 'POST' });
|
||||
@@ -1059,8 +1066,8 @@ export const api = {
|
||||
);
|
||||
},
|
||||
|
||||
async searchAll(q: string): Promise<{ thoughts: Thought[]; documents: Document[] }> {
|
||||
return request<{ thoughts: Thought[]; documents: Document[] }>(
|
||||
async searchAll(q: string): Promise<SearchResults> {
|
||||
return request<SearchResults>(
|
||||
`/api/v1/search?q=${encodeURIComponent(q)}`
|
||||
);
|
||||
},
|
||||
@@ -1068,6 +1075,17 @@ export const api = {
|
||||
async exportAll(): Promise<any> {
|
||||
return request<any>('/api/v1/export');
|
||||
},
|
||||
|
||||
// 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();
|
||||
},
|
||||
};
|
||||
|
||||
export function getOfflineQueue(): Thought[] {
|
||||
|
||||
@@ -8,6 +8,13 @@ export interface AuthStatus {
|
||||
instanceName: string;
|
||||
demoMode?: boolean;
|
||||
sharingMode?: SharingMode;
|
||||
// Impressum / Datenschutz of this instance (LEGAL_* environment variables)
|
||||
legal?: {
|
||||
imprintUrl?: string;
|
||||
hasImprintContent?: boolean;
|
||||
privacyUrl?: string;
|
||||
hasPrivacyContent?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// Who can see whom: nobody, members of the same organization, or everybody (Doku/sharing-and-permissions.md)
|
||||
@@ -297,6 +304,25 @@ export interface TagDetails {
|
||||
totalMatches: number;
|
||||
}
|
||||
|
||||
export interface SearchHit {
|
||||
type: 'document' | 'thought' | 'list_item';
|
||||
id: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
score?: number;
|
||||
docId?: string;
|
||||
blockId?: string;
|
||||
listId?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface SearchResults {
|
||||
thoughts: Thought[];
|
||||
documents: Document[];
|
||||
listItems: ListItem[];
|
||||
hits: SearchHit[];
|
||||
}
|
||||
|
||||
export interface SpaceMember {
|
||||
spaceId: string;
|
||||
userId: string;
|
||||
|
||||
+38
-5
@@ -83,6 +83,7 @@ func (s *Server) Router() http.Handler {
|
||||
// Public Health, Setup & Auth Routes
|
||||
r.Get("/api/v1/health", s.handleHealth)
|
||||
r.Get("/api/v1/auth/status", s.handleAuthStatus)
|
||||
r.Get("/api/v1/legal/{type}", s.handleLegalText)
|
||||
r.Post("/api/v1/auth/setup", s.handleFirstBootSetup)
|
||||
r.Post("/api/v1/auth/register", s.handleRegister)
|
||||
r.Post("/api/v1/auth/login", s.handleLogin)
|
||||
@@ -108,6 +109,7 @@ func (s *Server) Router() http.Handler {
|
||||
r.Get("/api/v1/stats", s.handleStats)
|
||||
r.Get("/api/v1/search", s.handleSearch)
|
||||
r.Get("/api/v1/export", s.handleExport)
|
||||
r.Get("/api/v1/export/markdown", s.handleExportMarkdown)
|
||||
r.Get("/api/v1/themes", s.handleGetThemes)
|
||||
r.Post("/api/v1/themes", s.handleSaveTheme)
|
||||
r.Delete("/api/v1/themes/{id}", s.handleDeleteTheme)
|
||||
@@ -458,9 +460,32 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||
"instanceName": instanceName,
|
||||
"demoMode": s.cfg.DemoMode,
|
||||
"sharingMode": s.store.SharingMode(),
|
||||
// Public so the login screen can link Impressum and Datenschutz
|
||||
"legal": map[string]interface{}{
|
||||
"imprintUrl": s.cfg.Legal.ImprintURL,
|
||||
"hasImprintContent": s.cfg.Legal.ImprintContent != "",
|
||||
"privacyUrl": s.cfg.Legal.PrivacyURL,
|
||||
"hasPrivacyContent": s.cfg.Legal.PrivacyContent != "",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// handleLegalText serves the configured Markdown of the imprint or privacy notice (public, no login).
|
||||
func (s *Server) handleLegalText(w http.ResponseWriter, r *http.Request) {
|
||||
content := ""
|
||||
switch chi.URLParam(r, "type") {
|
||||
case "imprint":
|
||||
content = s.cfg.Legal.ImprintContent
|
||||
case "privacy":
|
||||
content = s.cfg.Legal.PrivacyContent
|
||||
}
|
||||
if content == "" {
|
||||
writeError(w, http.StatusNotFound, "Kein Text hinterlegt")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"content": content})
|
||||
}
|
||||
|
||||
func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) {
|
||||
count, err := s.store.CountUsers()
|
||||
if err != nil || count > 0 {
|
||||
@@ -2039,15 +2064,12 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
q := r.URL.Query().Get("q")
|
||||
thoughts, docs, err := s.store.SearchAll(userID, q)
|
||||
res, err := s.store.SearchGlobal(userID, q)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"thoughts": thoughts,
|
||||
"documents": docs,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// --- CUSTOM THEMES ---
|
||||
@@ -2093,6 +2115,17 @@ func (s *Server) handleDeleteTheme(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
// handleExportMarkdown streams an Obsidian-compatible ZIP of the user's workspace (no temp files).
|
||||
func (s *Server) handleExportMarkdown(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="mindsift-vault-%s.zip"`, time.Now().Format("2006-01-02")))
|
||||
if err := s.store.WriteMarkdownVault(userID, w); err != nil {
|
||||
// Headers are already sent; the client gets a truncated archive
|
||||
slog.Error("Markdown-Export fehlgeschlagen", "user_id", userID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
exportData, err := s.store.ExportUserData(userID)
|
||||
|
||||
@@ -1366,3 +1366,42 @@ func TestFirstBootSetupWithSampleData(t *testing.T) {
|
||||
t.Fatalf("expected sample notes and task lists, got %d notes, %d task lists", len(docs), len(lists))
|
||||
}
|
||||
}
|
||||
|
||||
// Impressum and Datenschutz must be reachable without login; unset texts stay hidden.
|
||||
func TestLegalTextsArePublic(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "legal.db")
|
||||
store, err := storage.NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
cfg := &config.Config{Port: "8080", DatabasePath: dbPath, JWTSecret: "test-jwt-secret-key",
|
||||
Legal: config.LegalTexts{ImprintContent: "# Impressum\nMax Muster", PrivacyURL: "https://example.org/datenschutz"}}
|
||||
router := NewServer(cfg, store, ws.NewHub(), nil).Router()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/auth/status", nil))
|
||||
var st struct {
|
||||
Legal struct {
|
||||
ImprintURL string `json:"imprintUrl"`
|
||||
HasImprintContent bool `json:"hasImprintContent"`
|
||||
PrivacyURL string `json:"privacyUrl"`
|
||||
HasPrivacyContent bool `json:"hasPrivacyContent"`
|
||||
} `json:"legal"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &st)
|
||||
if !st.Legal.HasImprintContent || st.Legal.HasPrivacyContent || st.Legal.PrivacyURL != "https://example.org/datenschutz" {
|
||||
t.Fatalf("unexpected legal status: %+v", st.Legal)
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/legal/imprint", nil))
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Max Muster") {
|
||||
t.Fatalf("imprint without login: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/legal/privacy", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("privacy without text should be 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,43 @@ type Config struct {
|
||||
SMTPTLS bool
|
||||
// SMTPSkipVerify accepts self-signed or mismatching certificates (internal mail servers)
|
||||
SMTPSkipVerify bool
|
||||
|
||||
// Legal notices (Impressum, Datenschutz): an external URL and/or Markdown text, shown even before login
|
||||
Legal LegalTexts
|
||||
}
|
||||
|
||||
type LegalTexts struct {
|
||||
ImprintURL, ImprintContent string
|
||||
PrivacyURL, PrivacyContent string
|
||||
}
|
||||
|
||||
// legalContent reads Markdown from NAME or, for longer texts, from the file named in NAME_FILE.
|
||||
func legalContent(name string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
|
||||
return v
|
||||
}
|
||||
if path := strings.TrimSpace(os.Getenv(name + "_FILE")); path != "" {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
slog.Warn("Rechtstext konnte nicht gelesen werden", "variable", name+"_FILE", "error", err)
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// legalURL accepts only http(s) links, so a misconfiguration cannot inject javascript: URLs.
|
||||
func legalURL(name string) string {
|
||||
v := strings.TrimSpace(os.Getenv(name))
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(v, "https://") && !strings.HasPrefix(v, "http://") {
|
||||
slog.Warn("Rechtstext-URL ignoriert: nur http(s) erlaubt", "variable", name)
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
@@ -81,6 +118,12 @@ func Load() *Config {
|
||||
SMTPFrom: smtpFrom,
|
||||
SMTPTLS: smtpTLS,
|
||||
SMTPSkipVerify: smtpSkipVerify,
|
||||
Legal: LegalTexts{
|
||||
ImprintURL: legalURL("LEGAL_IMPRINT_URL"),
|
||||
ImprintContent: legalContent("LEGAL_IMPRINT_CONTENT"),
|
||||
PrivacyURL: legalURL("LEGAL_PRIVACY_URL"),
|
||||
PrivacyContent: legalContent("LEGAL_PRIVACY_CONTENT"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// ResetAndSeedDemo drops all tables, recreates the schema, and seeds demo accounts and demo data.
|
||||
func (s *Store) ResetAndSeedDemo() error {
|
||||
if err := s.dropAllTables(); err != nil {
|
||||
return fmt.Errorf("reset demo: drop all tables: %w", err)
|
||||
}
|
||||
if err := s.migrate(); err != nil {
|
||||
return fmt.Errorf("reset demo: migrate schema: %w", err)
|
||||
}
|
||||
if err := s.EnsureDemoAccounts(); err != nil {
|
||||
return fmt.Errorf("reset demo: ensure demo accounts: %w", err)
|
||||
}
|
||||
slog.Info("🧹 Demo-Modus: Datenbank für Testumgebung zurückgesetzt und neu befüllt")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
)
|
||||
|
||||
func TestResetAndSeedDemo(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "demo_test.db")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Create custom user and data
|
||||
hash, _ := auth.HashPassword("secret")
|
||||
customUser, err := store.CreateUserWithUsername("custom@test.local", "customuser", hash, "Custom", "member")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser failed: %v", err)
|
||||
}
|
||||
_, err = store.CreateThought(customUser.ID, "A personal secret note", "normal", "manual", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateThought failed: %v", err)
|
||||
}
|
||||
|
||||
// Call ResetAndSeedDemo
|
||||
if err := store.ResetAndSeedDemo(); err != nil {
|
||||
t.Fatalf("ResetAndSeedDemo failed: %v", err)
|
||||
}
|
||||
|
||||
// Custom user must be wiped
|
||||
u, err := store.GetUserByID(customUser.ID)
|
||||
if err == nil && u != nil {
|
||||
t.Fatalf("expected custom user to be wiped, but still found: %v", u)
|
||||
}
|
||||
|
||||
// Demo accounts admin and user must exist
|
||||
admin, err := store.GetUserByLogin("admin")
|
||||
if err != nil || admin == nil {
|
||||
t.Fatalf("expected demo admin to exist after reset: %v", err)
|
||||
}
|
||||
user, err := store.GetUserByLogin("user")
|
||||
if err != nil || user == nil {
|
||||
t.Fatalf("expected demo user to exist after reset: %v", err)
|
||||
}
|
||||
|
||||
// Resetting again should work cleanly (idempotence on repeated server starts)
|
||||
if err := store.ResetAndSeedDemo(); err != nil {
|
||||
t.Fatalf("second ResetAndSeedDemo failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithoutDemoModeDataIsNotWiped(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "nodemo_test.db")
|
||||
|
||||
// 1. Initial start without demo mode
|
||||
store1, err := NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("first NewStore failed: %v", err)
|
||||
}
|
||||
|
||||
hash, _ := auth.HashPassword("secret123")
|
||||
user1, err := store1.CreateUserWithUsername("persistent@example.com", "persistent", hash, "Persistent", "member")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithUsername failed: %v", err)
|
||||
}
|
||||
thought1, err := store1.CreateThought(user1.ID, "Important persistent note #keep", "high", "manual", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateThought failed: %v", err)
|
||||
}
|
||||
|
||||
// Close store simulating server shutdown
|
||||
store1.Close()
|
||||
|
||||
// 2. Server restart without demo mode (standard NewStore)
|
||||
store2, err := NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("second NewStore failed: %v", err)
|
||||
}
|
||||
defer store2.Close()
|
||||
|
||||
// Verify existing data is preserved
|
||||
foundUser, err := store2.GetUserByID(user1.ID)
|
||||
if err != nil || foundUser == nil {
|
||||
t.Fatalf("expected user %s to be preserved without demo mode, err: %v", user1.ID, err)
|
||||
}
|
||||
if foundUser.Username != "persistent" {
|
||||
t.Fatalf("expected username 'persistent', got '%s'", foundUser.Username)
|
||||
}
|
||||
|
||||
thoughts, err := store2.GetThoughts(user1.ID, "", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("GetThoughts failed: %v", err)
|
||||
}
|
||||
if len(thoughts) != 1 || thoughts[0].ID != thought1.ID {
|
||||
t.Fatalf("expected thought %s to persist, got: %v", thought1.ID, thoughts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
// WriteMarkdownVault streams the user's workspace as an Obsidian-compatible ZIP straight into w:
|
||||
// notes as .md files in their folders (as this user sees them, shared ones included), task lists
|
||||
// under Aufgaben/, shopping and other lists under Listen/. Nothing is buffered on disk.
|
||||
func (s *Store) WriteMarkdownVault(userID string, w io.Writer) error {
|
||||
zw := zip.NewWriter(w)
|
||||
names := newVaultNames()
|
||||
now := time.Now()
|
||||
|
||||
// Folders first, so empty ones survive the round trip
|
||||
if folders, err := s.GetFolders(userID); err == nil {
|
||||
for _, f := range folders {
|
||||
if dir := names.dir(f); dir != "" {
|
||||
if _, err := zw.CreateHeader(&zip.FileHeader{Name: dir + "/", Modified: now}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
docs, err := s.GetDocuments(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, d := range docs {
|
||||
name := names.file(names.dir(d.FolderPath), d.Title, "Unbenannte Notiz")
|
||||
body := markdownFrontmatter(map[string]any{
|
||||
"title": d.Title, "icon": d.Icon, "tags": d.Tags, "created_at": d.CreatedAt, "updated_at": d.UpdatedAt,
|
||||
}) + BlocksToVaultMarkdown(d.Blocks)
|
||||
if err := writeZipFile(zw, name, body, parseISO(d.UpdatedAt, now)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.writeVaultTasks(zw, userID, names, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.writeVaultLists(zw, userID, names, now); err != nil {
|
||||
return err
|
||||
}
|
||||
return zw.Close()
|
||||
}
|
||||
|
||||
func (s *Store) writeVaultTasks(zw *zip.Writer, userID string, names *vaultNames, now time.Time) error {
|
||||
tasks, err := s.GetTasks(userID, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lists, _ := s.GetTaskLists(userID)
|
||||
byList := make(map[string][]domain.Thought)
|
||||
for _, t := range tasks {
|
||||
byList[t.TaskListID] = append(byList[t.TaskListID], t)
|
||||
}
|
||||
write := func(title string, tl *domain.TaskList, items []domain.Thought) error {
|
||||
sort.SliceStable(items, func(i, j int) bool { return items[i].SortOrder < items[j].SortOrder })
|
||||
meta := map[string]any{"title": title}
|
||||
if tl != nil {
|
||||
meta["icon"], meta["created_at"], meta["updated_at"] = tl.Icon, tl.CreatedAt, tl.UpdatedAt
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString(markdownFrontmatter(meta))
|
||||
for _, t := range items {
|
||||
sb.WriteString(taskMarkdownLine(t))
|
||||
}
|
||||
return writeZipFile(zw, names.file("Aufgaben", title, "Aufgaben"), sb.String(), now)
|
||||
}
|
||||
for i := range lists {
|
||||
if err := write(lists[i].Title, &lists[i], byList[lists[i].ID]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if items := byList[""]; len(items) > 0 {
|
||||
return write("Ohne Liste", nil, items)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) writeVaultLists(zw *zip.Writer, userID string, names *vaultNames, now time.Time) error {
|
||||
lists, err := s.GetAllListsForUser(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, l := range lists {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(markdownFrontmatter(map[string]any{"title": l.Title, "icon": l.Icon, "created_at": l.CreatedAt, "updated_at": l.UpdatedAt}))
|
||||
for _, it := range l.Items {
|
||||
mark := "[ ]"
|
||||
if it.IsCompleted {
|
||||
mark = "[x]"
|
||||
}
|
||||
line := "- " + mark + " " + oneLine(it.Content)
|
||||
if it.Quantity != "" {
|
||||
line += " (" + oneLine(it.Quantity) + ")"
|
||||
}
|
||||
if it.Category != "" && !strings.Contains(it.Content, "#") {
|
||||
line += " #" + strings.ReplaceAll(it.Category, " ", "_")
|
||||
}
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
if err := writeZipFile(zw, names.file("Listen", l.Title, "Liste"), sb.String(), parseISO(l.UpdatedAt, now)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
mark := "[ ]"
|
||||
if t.IsCompleted {
|
||||
mark = "[x]"
|
||||
}
|
||||
line := "- " + mark + " " + oneLine(t.Content)
|
||||
switch t.Priority {
|
||||
case "urgent":
|
||||
line += " 🔺"
|
||||
case "high":
|
||||
line += " ⏫"
|
||||
case "low":
|
||||
line += " 🔽"
|
||||
}
|
||||
if t.DueDate != "" {
|
||||
line += " 📅 " + t.DueDate
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString(line + "\n")
|
||||
for _, st := range t.Subtasks {
|
||||
sub := "[ ]"
|
||||
if st.IsCompleted {
|
||||
sub = "[x]"
|
||||
}
|
||||
sb.WriteString("\t- " + sub + " " + oneLine(st.Title) + "\n")
|
||||
}
|
||||
if notes := strings.TrimSpace(t.Notes); notes != "" {
|
||||
for _, l := range strings.Split(notes, "\n") {
|
||||
sb.WriteString("\t> " + strings.TrimRight(l, "\r") + "\n")
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// BlocksToVaultMarkdown turns outliner blocks into Markdown that Obsidian renders natively:
|
||||
// top-level headings, dividers and callouts as real block elements, everything else as a nested
|
||||
// list (one tab per level), code as fenced blocks inside the list.
|
||||
func BlocksToVaultMarkdown(blocks []domain.DocumentBlock) string {
|
||||
var sb strings.Builder
|
||||
// Children of a top-level heading/callout/code/divider become a list one level up; otherwise their
|
||||
// tab indent would turn them into an indented code block in Markdown
|
||||
lift := 0
|
||||
for _, b := range blocks {
|
||||
lvl := b.Level
|
||||
if lvl <= 0 {
|
||||
lvl, lift = 0, 0
|
||||
} else if lvl -= lift; lvl < 0 {
|
||||
lvl = 0
|
||||
}
|
||||
indent := strings.Repeat("\t", lvl)
|
||||
content := strings.ReplaceAll(b.Content, "\r\n", "\n")
|
||||
// Soft line breaks continue the list item, aligned under its text
|
||||
cont := func(text, prefix string) string {
|
||||
return strings.ReplaceAll(text, "\n", "\n"+indent+prefix)
|
||||
}
|
||||
|
||||
if b.Level <= 0 {
|
||||
switch b.Type {
|
||||
case "h1", "h2", "h3":
|
||||
hashes := map[string]string{"h1": "#", "h2": "##", "h3": "###"}[b.Type]
|
||||
sb.WriteString("\n" + hashes + " " + oneLine(content) + "\n\n")
|
||||
lift = 1
|
||||
continue
|
||||
case "divider":
|
||||
sb.WriteString("\n---\n\n")
|
||||
lift = 1
|
||||
continue
|
||||
case "callout":
|
||||
sb.WriteString("\n> [!note]\n> " + strings.ReplaceAll(content, "\n", "\n> ") + "\n\n")
|
||||
lift = 1
|
||||
continue
|
||||
case "code":
|
||||
sb.WriteString("\n```" + b.Lang + "\n" + content + "\n```\n\n")
|
||||
lift = 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
switch b.Type {
|
||||
case "code":
|
||||
// Fenced block nested under the previous list item
|
||||
sb.WriteString(indent + "```" + b.Lang + "\n" + indent + strings.ReplaceAll(content, "\n", "\n"+indent) + "\n" + indent + "```\n")
|
||||
case "todo":
|
||||
mark := "[ ]"
|
||||
if b.Checked {
|
||||
mark = "[x]"
|
||||
}
|
||||
sb.WriteString(indent + "- " + mark + " " + cont(content, " ") + "\n")
|
||||
case "h1", "h2", "h3":
|
||||
hashes := map[string]string{"h1": "#", "h2": "##", "h3": "###"}[b.Type]
|
||||
sb.WriteString(indent + "- " + hashes + " " + oneLine(content) + "\n")
|
||||
case "divider":
|
||||
sb.WriteString(indent + "- ---\n")
|
||||
case "callout":
|
||||
sb.WriteString(indent + "- > " + cont(content, " > ") + "\n")
|
||||
default:
|
||||
if strings.TrimSpace(content) == "" {
|
||||
sb.WriteString(indent + "-\n")
|
||||
} else {
|
||||
sb.WriteString(indent + "- " + cont(content, " ") + "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimLeft(sb.String(), "\n")
|
||||
}
|
||||
|
||||
// markdownFrontmatter writes YAML front matter; JSON-encoded strings are valid YAML scalars.
|
||||
func markdownFrontmatter(meta map[string]any) string {
|
||||
keys := make([]string, 0, len(meta))
|
||||
for k := range meta {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
order := map[string]int{"title": 0, "icon": 1, "tags": 2, "created_at": 3, "updated_at": 4}
|
||||
sort.Slice(keys, func(i, j int) bool { return order[keys[i]] < order[keys[j]] })
|
||||
var sb strings.Builder
|
||||
sb.WriteString("---\n")
|
||||
for _, k := range keys {
|
||||
switch v := meta[k].(type) {
|
||||
case string:
|
||||
if v != "" {
|
||||
q, _ := json.Marshal(v)
|
||||
sb.WriteString(k + ": " + string(q) + "\n")
|
||||
}
|
||||
case []string:
|
||||
if len(v) > 0 {
|
||||
sb.WriteString(k + ":\n")
|
||||
for _, t := range v {
|
||||
q, _ := json.Marshal(t)
|
||||
sb.WriteString(" - " + string(q) + "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.WriteString("---\n\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func writeZipFile(zw *zip.Writer, name, body string, modified time.Time) error {
|
||||
f, err := zw.CreateHeader(&zip.FileHeader{Name: name, Method: zip.Deflate, Modified: modified})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.WriteString(f, body)
|
||||
return err
|
||||
}
|
||||
|
||||
func oneLine(s string) string {
|
||||
return strings.Join(strings.Fields(strings.ReplaceAll(s, "\n", " ")), " ")
|
||||
}
|
||||
|
||||
func parseISO(v string, fallback time.Time) time.Time {
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
return t
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// vaultNames builds safe, unique file and folder names inside the archive.
|
||||
type vaultNames struct{ used map[string]bool }
|
||||
|
||||
func newVaultNames() *vaultNames { return &vaultNames{used: make(map[string]bool)} }
|
||||
|
||||
// segment makes one path part safe on Windows, macOS and Linux and never "." or "..".
|
||||
func segment(s string) string {
|
||||
s = strings.Map(func(r rune) rune {
|
||||
if r < 32 || strings.ContainsRune(`/\:*?"<>|`, r) {
|
||||
return '_'
|
||||
}
|
||||
return r
|
||||
}, strings.TrimSpace(s))
|
||||
s = strings.Trim(s, ". ")
|
||||
if len([]rune(s)) > 100 {
|
||||
s = string([]rune(s)[:100])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (n *vaultNames) dir(folderPath string) string {
|
||||
parts := make([]string, 0)
|
||||
for _, p := range strings.Split(folderPath, "/") {
|
||||
if seg := segment(p); seg != "" {
|
||||
parts = append(parts, seg)
|
||||
}
|
||||
}
|
||||
return path.Join(parts...)
|
||||
}
|
||||
|
||||
// file returns "dir/title.md", adding " (2)", " (3)" … when the name is already taken.
|
||||
func (n *vaultNames) file(dir, title, fallback string) string {
|
||||
base := segment(title)
|
||||
if base == "" {
|
||||
base = fallback
|
||||
}
|
||||
name := path.Join(dir, base+".md")
|
||||
for i := 2; n.used[strings.ToLower(name)]; i++ {
|
||||
name = path.Join(dir, fmt.Sprintf("%s (%d).md", base, i))
|
||||
}
|
||||
n.used[strings.ToLower(name)] = true
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
func TestBlocksToVaultMarkdown(t *testing.T) {
|
||||
md := BlocksToVaultMarkdown([]domain.DocumentBlock{
|
||||
{Type: "h2", Content: "Warum", Level: 0},
|
||||
{Type: "bullet", Content: "Strom sparen", Level: 1},
|
||||
{Type: "bullet", Content: "120 € im Jahr", Level: 2},
|
||||
{Type: "h1", Content: "Umzug", Level: 0},
|
||||
{Type: "bullet", Content: "Server", Level: 0},
|
||||
{Type: "todo", Content: "Backup ziehen", Checked: true, Level: 1},
|
||||
{Type: "bullet", Content: "Zeile 1\nZeile 2", Level: 1},
|
||||
{Type: "code", Content: "docker compose up -d", Lang: "bash", Level: 2},
|
||||
{Type: "divider", Level: 0},
|
||||
{Type: "code", Content: "echo hi", Lang: "sh", Level: 0},
|
||||
})
|
||||
want := []string{
|
||||
// children of a heading start a list at the margin, not an indented code block
|
||||
"## Warum\n\n- Strom sparen\n\t- 120 € im Jahr\n",
|
||||
"# Umzug\n\n- Server\n",
|
||||
"\t- [x] Backup ziehen\n",
|
||||
"\t- Zeile 1\n\t Zeile 2\n",
|
||||
"\t\t```bash\n\t\tdocker compose up -d\n\t\t```\n",
|
||||
"\n---\n\n",
|
||||
"```sh\necho hi\n```\n",
|
||||
}
|
||||
for _, w := range want {
|
||||
if !strings.Contains(md, w) {
|
||||
t.Errorf("missing %q in:\n%s", w, md)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMarkdownVault(t *testing.T) {
|
||||
store, err := NewStore(filepath.Join(t.TempDir(), "vault.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")
|
||||
|
||||
if _, err := store.CreateFolder(anna.ID, "Projekte/Leer"); err != nil {
|
||||
t.Fatalf("CreateFolder: %v", err)
|
||||
}
|
||||
blocks := []domain.DocumentBlock{{ID: "b1", Type: "bullet", Content: "Inhalt", Level: 0}}
|
||||
// Note tags come from #hashtags in the text
|
||||
tagged := []domain.DocumentBlock{{ID: "b0", Type: "bullet", Content: "Inhalt #server", Level: 0}}
|
||||
_, _ = store.CreateDocument(anna.ID, "", "Homelab", "🖥️", "Projekte", tagged, nil, false)
|
||||
_, _ = store.CreateDocument(anna.ID, "", "Homelab", "🖥️", "Projekte", blocks, nil, false)
|
||||
_, _ = store.CreateDocument(anna.ID, "", "../../etc/passwd", "", "", blocks, nil, false)
|
||||
shared, _ := store.CreateDocument(ben.ID, "", "Von Ben", "", "", blocks, nil, false)
|
||||
_, _ = store.CreateDocument(ben.ID, "", "Bens Geheimnis", "", "", blocks, nil, false)
|
||||
if _, err := store.CreateDocShare(ben.ID, shared.ID, anna.ID, "read"); err != nil {
|
||||
t.Fatalf("share: %v", err)
|
||||
}
|
||||
|
||||
tl, _ := store.CreateTaskList(anna.ID, "", "", "Haushalt", "🏠", "")
|
||||
task, _ := store.CreateThoughtWithDetails(anna.ID, "Fenster putzen", "high", "manual", "2026-11-02", tl.ID, "todo", "")
|
||||
_, _ = 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)
|
||||
list, _ := store.CreateList(anna.ID, "Einkauf", "🛒", "shopping")
|
||||
_, _ = store.CreateListItem(list.ID, "Milch", "Kühlregal", "2L", 0, anna.ID)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := store.WriteMarkdownVault(anna.ID, &buf); err != nil {
|
||||
t.Fatalf("WriteMarkdownVault: %v", err)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
|
||||
if err != nil {
|
||||
t.Fatalf("not a zip: %v", err)
|
||||
}
|
||||
files := map[string]string{}
|
||||
for _, f := range zr.File {
|
||||
for _, part := range strings.Split(f.Name, "/") {
|
||||
if part == ".." || part == "." || strings.HasPrefix(f.Name, "/") {
|
||||
t.Errorf("unsafe path in archive: %q", f.Name)
|
||||
}
|
||||
}
|
||||
rc, _ := f.Open()
|
||||
data, _ := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
files[f.Name] = string(data)
|
||||
}
|
||||
|
||||
for _, name := range []string{"Projekte/Leer/", "Projekte/Homelab.md", "Projekte/Homelab (2).md", "Von Ben.md", "Aufgaben/Haushalt.md", "Listen/Einkauf.md"} {
|
||||
if _, ok := files[name]; !ok {
|
||||
t.Errorf("missing %q; archive has %v", name, keys(files))
|
||||
}
|
||||
}
|
||||
for name := range files {
|
||||
if strings.Contains(name, "Geheimnis") {
|
||||
t.Errorf("Ben's unshared note must not be exported: %q", name)
|
||||
}
|
||||
}
|
||||
tagged1, tagged2 := files["Projekte/Homelab.md"], files["Projekte/Homelab (2).md"]
|
||||
if !strings.Contains(tagged1+tagged2, "---\ntitle: \"Homelab\"\nicon: \"🖥️\"\ntags:\n - \"server\"\n") {
|
||||
t.Errorf("frontmatter wrong:\n%s\n--- (2):\n%s", files["Projekte/Homelab.md"], files["Projekte/Homelab (2).md"])
|
||||
}
|
||||
if want := "- [ ] Fenster putzen ⏫ 📅 2026-11-02\n\t- [x] Leiter holen\n"; !strings.Contains(files["Aufgaben/Haushalt.md"], want) {
|
||||
t.Errorf("task line wrong:\n%s", files["Aufgaben/Haushalt.md"])
|
||||
}
|
||||
if !strings.Contains(files["Listen/Einkauf.md"], "- [ ] Milch (2L) #Kühlregal") {
|
||||
t.Errorf("list item wrong:\n%s", files["Listen/Einkauf.md"])
|
||||
}
|
||||
}
|
||||
|
||||
func keys(m map[string]string) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
// SearchHit represents a single ranked match across documents, tasks/thoughts, and lists.
|
||||
type SearchHit struct {
|
||||
Type string `json:"type"` // "document", "thought", "list_item"
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Snippet string `json:"snippet"`
|
||||
Score float64 `json:"score,omitempty"`
|
||||
DocID string `json:"docId,omitempty"`
|
||||
BlockID string `json:"blockId,omitempty"`
|
||||
ListID string `json:"listId,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
}
|
||||
|
||||
// SearchResults encapsulates all results for backwards-compatible API serialization.
|
||||
type SearchResults struct {
|
||||
Thoughts []domain.Thought `json:"thoughts"`
|
||||
Documents []domain.Document `json:"documents"`
|
||||
ListItems []domain.ListItem `json:"listItems"`
|
||||
Hits []SearchHit `json:"hits"`
|
||||
}
|
||||
|
||||
var wordTokenRegex = regexp.MustCompile(`[\p{L}\p{N}]+`)
|
||||
|
||||
// PrepareFTSQuery extracts valid words from user input and formats them as prefix match tokens.
|
||||
// E.g. "einkau" -> `"einkau"*`, "Hello World!" -> `"Hello"* "World"*`.
|
||||
func PrepareFTSQuery(raw string) string {
|
||||
matches := wordTokenRegex.FindAllString(strings.TrimSpace(raw), -1)
|
||||
if len(matches) == 0 {
|
||||
return ""
|
||||
}
|
||||
tokens := make([]string, 0, len(matches))
|
||||
for _, m := range matches {
|
||||
clean := strings.ReplaceAll(m, `"`, `""`)
|
||||
tokens = append(tokens, fmt.Sprintf(`"%s"*`, clean))
|
||||
}
|
||||
return strings.Join(tokens, " ")
|
||||
}
|
||||
|
||||
// migrateSearchFTS creates the virtual FTS5 tables and synchronization triggers.
|
||||
func (s *Store) migrateSearchFTS() {
|
||||
tables := []string{
|
||||
`CREATE VIRTUAL TABLE IF NOT EXISTS thoughts_fts USING fts5(
|
||||
thought_id UNINDEXED,
|
||||
content,
|
||||
notes,
|
||||
subtasks,
|
||||
tokenize='unicode61'
|
||||
);`,
|
||||
`CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5(
|
||||
doc_id UNINDEXED,
|
||||
block_id UNINDEXED,
|
||||
title,
|
||||
content,
|
||||
tokenize='unicode61'
|
||||
);`,
|
||||
`CREATE VIRTUAL TABLE IF NOT EXISTS list_items_fts USING fts5(
|
||||
item_id UNINDEXED,
|
||||
list_id UNINDEXED,
|
||||
content,
|
||||
category,
|
||||
tokenize='unicode61'
|
||||
);`,
|
||||
}
|
||||
|
||||
for _, stmt := range tables {
|
||||
if _, err := s.db.Exec(stmt); err != nil {
|
||||
slog.Error("Failed to create FTS virtual table", "error", err, "statement", stmt)
|
||||
}
|
||||
}
|
||||
|
||||
triggers := []string{
|
||||
// Thoughts triggers
|
||||
`CREATE TRIGGER IF NOT EXISTS trg_thoughts_ai AFTER INSERT ON thoughts BEGIN
|
||||
INSERT INTO thoughts_fts(thought_id, content, notes, subtasks)
|
||||
VALUES (new.id, new.content, new.notes, new.subtasks);
|
||||
END;`,
|
||||
`CREATE TRIGGER IF NOT EXISTS trg_thoughts_au AFTER UPDATE ON thoughts BEGIN
|
||||
DELETE FROM thoughts_fts WHERE thought_id = old.id;
|
||||
INSERT INTO thoughts_fts(thought_id, content, notes, subtasks)
|
||||
VALUES (new.id, new.content, new.notes, new.subtasks);
|
||||
END;`,
|
||||
`CREATE TRIGGER IF NOT EXISTS trg_thoughts_ad AFTER DELETE ON thoughts BEGIN
|
||||
DELETE FROM thoughts_fts WHERE thought_id = old.id;
|
||||
END;`,
|
||||
|
||||
// Documents triggers
|
||||
`CREATE TRIGGER IF NOT EXISTS trg_documents_ai AFTER INSERT ON documents BEGIN
|
||||
INSERT INTO documents_fts(doc_id, block_id, title, content)
|
||||
VALUES (new.id, '', new.title, '');
|
||||
END;`,
|
||||
`CREATE TRIGGER IF NOT EXISTS trg_documents_au AFTER UPDATE OF title, is_archived ON documents BEGIN
|
||||
DELETE FROM documents_fts WHERE doc_id = old.id AND block_id = '';
|
||||
INSERT INTO documents_fts(doc_id, block_id, title, content)
|
||||
SELECT new.id, '', new.title, '' WHERE new.is_archived = 0;
|
||||
END;`,
|
||||
`CREATE TRIGGER IF NOT EXISTS trg_documents_ad AFTER DELETE ON documents BEGIN
|
||||
DELETE FROM documents_fts WHERE doc_id = old.id;
|
||||
END;`,
|
||||
|
||||
// Blocks triggers (excluding tombstones/deleted blocks)
|
||||
`CREATE TRIGGER IF NOT EXISTS trg_blocks_ai AFTER INSERT ON blocks BEGIN
|
||||
INSERT INTO documents_fts(doc_id, block_id, title, content)
|
||||
SELECT new.document_id, new.id, '', new.content
|
||||
WHERE new.deleted = 0 AND (new.deleted_at = '' OR new.deleted_at IS NULL);
|
||||
END;`,
|
||||
`CREATE TRIGGER IF NOT EXISTS trg_blocks_au AFTER UPDATE OF content, deleted, deleted_at, document_id ON blocks BEGIN
|
||||
DELETE FROM documents_fts WHERE doc_id = old.document_id AND block_id = old.id;
|
||||
INSERT INTO documents_fts(doc_id, block_id, title, content)
|
||||
SELECT new.document_id, new.id, '', new.content
|
||||
WHERE new.deleted = 0 AND (new.deleted_at = '' OR new.deleted_at IS NULL);
|
||||
END;`,
|
||||
`CREATE TRIGGER IF NOT EXISTS trg_blocks_ad AFTER DELETE ON blocks BEGIN
|
||||
DELETE FROM documents_fts WHERE doc_id = old.document_id AND block_id = old.id;
|
||||
END;`,
|
||||
|
||||
// List items triggers
|
||||
`CREATE TRIGGER IF NOT EXISTS trg_list_items_ai AFTER INSERT ON list_items BEGIN
|
||||
INSERT INTO list_items_fts(item_id, list_id, content, category)
|
||||
VALUES (new.id, new.list_id, new.content, new.category);
|
||||
END;`,
|
||||
`CREATE TRIGGER IF NOT EXISTS trg_list_items_au AFTER UPDATE ON list_items BEGIN
|
||||
DELETE FROM list_items_fts WHERE item_id = old.id;
|
||||
INSERT INTO list_items_fts(item_id, list_id, content, category)
|
||||
VALUES (new.id, new.list_id, new.content, new.category);
|
||||
END;`,
|
||||
`CREATE TRIGGER IF NOT EXISTS trg_list_items_ad AFTER DELETE ON list_items BEGIN
|
||||
DELETE FROM list_items_fts WHERE item_id = old.id;
|
||||
END;`,
|
||||
}
|
||||
|
||||
for _, trg := range triggers {
|
||||
if _, err := s.db.Exec(trg); err != nil {
|
||||
slog.Error("Failed to create FTS trigger", "error", err, "statement", trg)
|
||||
}
|
||||
}
|
||||
|
||||
s.backfillSearchFTS()
|
||||
}
|
||||
|
||||
// backfillSearchFTS populates empty FTS indices once on startup.
|
||||
func (s *Store) backfillSearchFTS() {
|
||||
var thoughtCount int
|
||||
_ = s.db.QueryRow(`SELECT count(*) FROM thoughts_fts`).Scan(&thoughtCount)
|
||||
if thoughtCount == 0 {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO thoughts_fts(thought_id, content, notes, subtasks)
|
||||
SELECT id, content, notes, subtasks FROM thoughts;
|
||||
`)
|
||||
if err != nil {
|
||||
slog.Error("Failed to backfill thoughts_fts", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
var docCount int
|
||||
_ = s.db.QueryRow(`SELECT count(*) FROM documents_fts`).Scan(&docCount)
|
||||
if docCount == 0 {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO documents_fts(doc_id, block_id, title, content)
|
||||
SELECT id, '', title, '' FROM documents WHERE is_archived = 0;
|
||||
|
||||
INSERT INTO documents_fts(doc_id, block_id, title, content)
|
||||
SELECT document_id, id, '', content FROM blocks
|
||||
WHERE deleted = 0 AND (deleted_at = '' OR deleted_at IS NULL);
|
||||
`)
|
||||
if err != nil {
|
||||
slog.Error("Failed to backfill documents_fts", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
var listCount int
|
||||
_ = s.db.QueryRow(`SELECT count(*) FROM list_items_fts`).Scan(&listCount)
|
||||
if listCount == 0 {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO list_items_fts(item_id, list_id, content, category)
|
||||
SELECT id, list_id, content, category FROM list_items;
|
||||
`)
|
||||
if err != nil {
|
||||
slog.Error("Failed to backfill list_items_fts", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SearchGlobal executes an FTS5 full-text search across documents, thoughts/tasks and list items,
|
||||
// filtering results strictly to what the user is authorized to view.
|
||||
func (s *Store) SearchGlobal(userID, query string) (*SearchResults, error) {
|
||||
ftsQuery := PrepareFTSQuery(query)
|
||||
res := &SearchResults{
|
||||
Thoughts: make([]domain.Thought, 0),
|
||||
Documents: make([]domain.Document, 0),
|
||||
ListItems: make([]domain.ListItem, 0),
|
||||
Hits: make([]SearchHit, 0),
|
||||
}
|
||||
|
||||
if ftsQuery == "" {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// 1. Search Documents and Blocks
|
||||
// Weights: 10.0 for title, 1.0 for block content
|
||||
docRows, err := s.db.Query(`
|
||||
SELECT f.doc_id, f.block_id,
|
||||
snippet(documents_fts, -1, '<mark>', '</mark>', '…', 14) AS snip,
|
||||
bm25(documents_fts, 10.0, 1.0) AS score,
|
||||
d.title, d.icon, d.user_id, d.is_archived
|
||||
FROM documents_fts f
|
||||
JOIN documents d ON d.id = f.doc_id
|
||||
WHERE documents_fts MATCH ? AND d.is_archived = 0
|
||||
ORDER BY score ASC
|
||||
LIMIT 40
|
||||
`, ftsQuery)
|
||||
if err == nil {
|
||||
seenDocIDs := make(map[string]bool)
|
||||
for docRows.Next() {
|
||||
var docID, blockID, snip, title, icon, ownerID string
|
||||
var score float64
|
||||
var isArchived int
|
||||
if scanErr := docRows.Scan(&docID, &blockID, &snip, &score, &title, &icon, &ownerID, &isArchived); scanErr == nil {
|
||||
// Access check via DocRole
|
||||
if s.DocRole(userID, docID) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
hitID := docID
|
||||
if blockID != "" {
|
||||
hitID = docID + ":" + blockID
|
||||
}
|
||||
|
||||
res.Hits = append(res.Hits, SearchHit{
|
||||
Type: "document",
|
||||
ID: hitID,
|
||||
Title: title,
|
||||
Snippet: snip,
|
||||
Score: score,
|
||||
DocID: docID,
|
||||
BlockID: blockID,
|
||||
Icon: icon,
|
||||
})
|
||||
|
||||
if !seenDocIDs[docID] {
|
||||
seenDocIDs[docID] = true
|
||||
if doc, err := s.GetDocumentByID(userID, docID); err == nil && doc != nil {
|
||||
res.Documents = append(res.Documents, *doc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = docRows.Close()
|
||||
}
|
||||
|
||||
// 2. Search Thoughts & Tasks
|
||||
// Weights: 5.0 for content, 1.0 for notes, 1.0 for subtasks
|
||||
thoughtRows, err := s.db.Query(`
|
||||
SELECT f.thought_id,
|
||||
snippet(thoughts_fts, -1, '<mark>', '</mark>', '…', 14) AS snip,
|
||||
bm25(thoughts_fts, 5.0, 1.0, 1.0) AS score,
|
||||
t.id, t.user_id, t.content, t.tags, t.priority, t.source, t.status,
|
||||
t.is_completed, t.converted_type, t.converted_ref_id, t.due_date,
|
||||
t.task_list_id, t.kanban_status, t.notes, t.alarm_sent, t.created_at,
|
||||
t.triaged_at, t.subtasks, t.sort_order
|
||||
FROM thoughts_fts f
|
||||
JOIN thoughts t ON t.id = f.thought_id
|
||||
WHERE thoughts_fts MATCH ?
|
||||
AND (t.user_id = ? OR t.task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
|
||||
ORDER BY score ASC
|
||||
LIMIT 40
|
||||
`, ftsQuery, userID, userID, userID, userID)
|
||||
if err == nil {
|
||||
for thoughtRows.Next() {
|
||||
var thoughtID, snip string
|
||||
var score float64
|
||||
var t domain.Thought
|
||||
var tagsStr, subtasksStr string
|
||||
var isCompleted, alarmSent int
|
||||
var triagedAt, dueDate, convertedType, convertedRefId sql.NullString
|
||||
if scanErr := thoughtRows.Scan(
|
||||
&thoughtID, &snip, &score,
|
||||
&t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source, &t.Status,
|
||||
&isCompleted, &convertedType, &convertedRefId, &dueDate,
|
||||
&t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent, &t.CreatedAt,
|
||||
&triagedAt, &subtasksStr, &t.SortOrder,
|
||||
); scanErr == nil {
|
||||
t.IsCompleted = isCompleted != 0
|
||||
t.AlarmSent = alarmSent != 0
|
||||
t.DueDate = dueDate.String
|
||||
t.ConvertedType = convertedType.String
|
||||
t.ConvertedRefID = convertedRefId.String
|
||||
t.TriagedAt = triagedAt.String
|
||||
|
||||
res.Hits = append(res.Hits, SearchHit{
|
||||
Type: "thought",
|
||||
ID: t.ID,
|
||||
Title: t.Content,
|
||||
Snippet: snip,
|
||||
Score: score,
|
||||
})
|
||||
res.Thoughts = append(res.Thoughts, t)
|
||||
}
|
||||
}
|
||||
_ = thoughtRows.Close()
|
||||
}
|
||||
|
||||
// 3. Search List Items
|
||||
// Weights: 2.0 for content, 1.0 for category
|
||||
accessibleLists, _ := s.GetAllListsForUser(userID)
|
||||
accessibleListIDs := make(map[string]bool, len(accessibleLists))
|
||||
for _, l := range accessibleLists {
|
||||
accessibleListIDs[l.ID] = true
|
||||
}
|
||||
|
||||
if len(accessibleListIDs) > 0 {
|
||||
itemRows, err := s.db.Query(`
|
||||
SELECT f.item_id, f.list_id,
|
||||
snippet(list_items_fts, -1, '<mark>', '</mark>', '…', 14) AS snip,
|
||||
bm25(list_items_fts, 2.0, 1.0) AS score,
|
||||
li.id, li.list_id, li.content, li.category, li.quantity,
|
||||
li.is_completed, li.completed_by, li.completed_by_name,
|
||||
li.sort_order, li.created_at, li.completed_at
|
||||
FROM list_items_fts f
|
||||
JOIN list_items li ON li.id = f.item_id
|
||||
WHERE list_items_fts MATCH ?
|
||||
ORDER BY score ASC
|
||||
LIMIT 40
|
||||
`, ftsQuery)
|
||||
if err == nil {
|
||||
for itemRows.Next() {
|
||||
var itemID, listID, snip string
|
||||
var score float64
|
||||
var li domain.ListItem
|
||||
var isCompleted int
|
||||
if scanErr := itemRows.Scan(
|
||||
&itemID, &listID, &snip, &score,
|
||||
&li.ID, &li.ListID, &li.Content, &li.Category, &li.Quantity,
|
||||
&isCompleted, &li.CompletedBy, &li.CompletedByName,
|
||||
&li.SortOrder, &li.CreatedAt, &li.CompletedAt,
|
||||
); scanErr == nil {
|
||||
if !accessibleListIDs[listID] {
|
||||
continue
|
||||
}
|
||||
li.IsCompleted = isCompleted != 0
|
||||
|
||||
res.Hits = append(res.Hits, SearchHit{
|
||||
Type: "list_item",
|
||||
ID: li.ID,
|
||||
Title: li.Content,
|
||||
Snippet: snip,
|
||||
Score: score,
|
||||
ListID: listID,
|
||||
})
|
||||
res.ListItems = append(res.ListItems, li)
|
||||
}
|
||||
}
|
||||
_ = itemRows.Close()
|
||||
}
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
func setupTestStore(t *testing.T) (*Store, *domain.User, *domain.User) {
|
||||
t.Helper()
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "test_search.db")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
openSharing(t, store)
|
||||
|
||||
userA, err := store.CreateUserWithUsername("alice@test.local", "alice", "hash123", "Alice", "member")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser Alice failed: %v", err)
|
||||
}
|
||||
|
||||
userB, err := store.CreateUserWithUsername("bob@test.local", "bob", "hash123", "Bob", "member")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser Bob failed: %v", err)
|
||||
}
|
||||
|
||||
return store, userA, userB
|
||||
}
|
||||
|
||||
func TestSearchBlockContent(t *testing.T) {
|
||||
store, userA, _ := setupTestStore(t)
|
||||
defer store.Close()
|
||||
|
||||
// Create document with block content
|
||||
doc, err := store.CreateDocument(userA.ID, "", "Rezeptbuch", "📖", "", nil, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocument failed: %v", err)
|
||||
}
|
||||
|
||||
blockID := domain.NewID("blk")
|
||||
pos := 1.0
|
||||
_, err = store.db.Exec(`
|
||||
INSERT INTO blocks (id, document_id, content, level, position, type, deleted, deleted_at)
|
||||
VALUES (?, ?, ?, 0, ?, 'bullet', 0, '')
|
||||
`, blockID, doc.ID, "Zutaten: 500g Mehl und Hefe für Pizza", pos)
|
||||
if err != nil {
|
||||
t.Fatalf("Insert block failed: %v", err)
|
||||
}
|
||||
|
||||
// Search for block content
|
||||
res, err := store.SearchGlobal(userA.ID, "Hefe")
|
||||
if err != nil {
|
||||
t.Fatalf("SearchGlobal failed: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, hit := range res.Hits {
|
||||
if hit.DocID == doc.ID && hit.BlockID == blockID {
|
||||
found = true
|
||||
if hit.Snippet == "" {
|
||||
t.Errorf("expected snippet with marked match, got empty string")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected to find hit for block content 'Hefe', hits: %+v", res.Hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchTitleRanking(t *testing.T) {
|
||||
store, userA, _ := setupTestStore(t)
|
||||
defer store.Close()
|
||||
|
||||
// Doc 1 has "Architektur" in block content
|
||||
docContent, err := store.CreateDocument(userA.ID, "", "Notizen zu Systemen", "📝", "", nil, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocument failed: %v", err)
|
||||
}
|
||||
_, err = store.db.Exec(`
|
||||
INSERT INTO blocks (id, document_id, content, level, position, type, deleted, deleted_at)
|
||||
VALUES (?, ?, 'Hier wird die Architektur im Detail besprochen', 0, 1.0, 'bullet', 0, '')
|
||||
`, domain.NewID("blk"), docContent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Insert block failed: %v", err)
|
||||
}
|
||||
|
||||
// Doc 2 has "Architektur" in title
|
||||
docTitle, err := store.CreateDocument(userA.ID, "", "Architektur Leitfaden", "🏛️", "", nil, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocument failed: %v", err)
|
||||
}
|
||||
|
||||
res, err := store.SearchGlobal(userA.ID, "Architektur")
|
||||
if err != nil {
|
||||
t.Fatalf("SearchGlobal failed: %v", err)
|
||||
}
|
||||
|
||||
if len(res.Hits) < 2 {
|
||||
t.Fatalf("expected at least 2 hits, got %d", len(res.Hits))
|
||||
}
|
||||
|
||||
// First hit must be the document with the title match
|
||||
if res.Hits[0].DocID != docTitle.ID {
|
||||
t.Errorf("expected title match (doc %s) to rank first, got doc %s", docTitle.ID, res.Hits[0].DocID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchPermissionsAndUnshared(t *testing.T) {
|
||||
store, userA, userB := setupTestStore(t)
|
||||
defer store.Close()
|
||||
|
||||
// Alice creates private doc, private thought, and private list
|
||||
privateDoc, err := store.CreateDocument(userA.ID, "", "Geheimes Projekt", "🔒", "", nil, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocument failed: %v", err)
|
||||
}
|
||||
|
||||
privateThought, err := store.CreateThought(userA.ID, "Geheimer Gedanke für Alice", "high", "manual", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateThought failed: %v", err)
|
||||
}
|
||||
|
||||
privateList, err := store.CreateList(userA.ID, "Geheime Einkaufsliste", "🛒", "shopping")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateList failed: %v", err)
|
||||
}
|
||||
_, err = store.CreateListItem(privateList.ID, "Geheimer Kuchen", "", "", 0, userA.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateListItem failed: %v", err)
|
||||
}
|
||||
|
||||
// Bob searches for "Geheim"
|
||||
resBob, err := store.SearchGlobal(userB.ID, "Geheim")
|
||||
if err != nil {
|
||||
t.Fatalf("SearchGlobal for Bob failed: %v", err)
|
||||
}
|
||||
|
||||
if len(resBob.Hits) != 0 || len(resBob.Thoughts) != 0 || len(resBob.Documents) != 0 || len(resBob.ListItems) != 0 {
|
||||
t.Fatalf("expected Bob to find 0 private hits of Alice, got hits=%+v thoughts=%+v docs=%+v",
|
||||
resBob.Hits, resBob.Thoughts, resBob.Documents)
|
||||
}
|
||||
|
||||
// Now share privateDoc with Bob
|
||||
if _, err := store.CreateDocShare(userA.ID, privateDoc.ID, userB.Email, "read"); err != nil {
|
||||
t.Fatalf("CreateDocShare failed: %v", err)
|
||||
}
|
||||
|
||||
// Bob searches again
|
||||
resBobShared, err := store.SearchGlobal(userB.ID, "Geheim")
|
||||
if err != nil {
|
||||
t.Fatalf("SearchGlobal for Bob after share failed: %v", err)
|
||||
}
|
||||
|
||||
if len(resBobShared.Documents) != 1 || resBobShared.Documents[0].ID != privateDoc.ID {
|
||||
t.Fatalf("expected Bob to find shared document %s, got %+v", privateDoc.ID, resBobShared.Documents)
|
||||
}
|
||||
|
||||
// Alice searches and finds her own items
|
||||
resAlice, err := store.SearchGlobal(userA.ID, "Geheim")
|
||||
if err != nil {
|
||||
t.Fatalf("SearchGlobal for Alice failed: %v", err)
|
||||
}
|
||||
if len(resAlice.Documents) != 1 || len(resAlice.Thoughts) != 1 || len(resAlice.ListItems) != 1 {
|
||||
t.Fatalf("expected Alice to find her items, got docs=%d thoughts=%d lists=%d",
|
||||
len(resAlice.Documents), len(resAlice.Thoughts), len(resAlice.ListItems))
|
||||
}
|
||||
_ = privateThought
|
||||
}
|
||||
|
||||
func TestSearchPrefixAndSpecialCharacters(t *testing.T) {
|
||||
store, userA, _ := setupTestStore(t)
|
||||
defer store.Close()
|
||||
|
||||
_, err := store.CreateThought(userA.ID, "Großer Wocheneinkauf im Supermarkt", "normal", "manual", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateThought failed: %v", err)
|
||||
}
|
||||
|
||||
// Prefix search: "einkau" should find "Wocheneinkauf" or "Einkauf"
|
||||
// Note: FTS5 prefix matches word prefixes: "Wochen..." vs "einkauf"
|
||||
_, err = store.CreateThought(userA.ID, "Einkaufszettel schreiben", "normal", "manual", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateThought failed: %v", err)
|
||||
}
|
||||
|
||||
res, err := store.SearchGlobal(userA.ID, "einkau")
|
||||
if err != nil {
|
||||
t.Fatalf("SearchGlobal prefix failed: %v", err)
|
||||
}
|
||||
if len(res.Hits) == 0 {
|
||||
t.Errorf("expected prefix match for 'einkau', got 0 hits")
|
||||
}
|
||||
|
||||
// Special characters should not cause syntax errors
|
||||
crazyQueries := []string{
|
||||
`"unclosed quote`,
|
||||
`test: foo AND OR NOT (bar)^`,
|
||||
`!@#$%^&*()_+=-[]{}\|;:'",.<>/?`,
|
||||
`* * *`,
|
||||
``,
|
||||
` `,
|
||||
}
|
||||
for _, cq := range crazyQueries {
|
||||
_, err := store.SearchGlobal(userA.ID, cq)
|
||||
if err != nil {
|
||||
t.Errorf("query %q caused error: %v", cq, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-40
@@ -70,6 +70,11 @@ func (s *Store) resetOutdatedSchema() error {
|
||||
if version >= schemaVersion {
|
||||
return nil
|
||||
}
|
||||
slog.Warn("Veraltetes Datenbankschema: alle Daten werden gelöscht und neu angelegt", "version", version, "neu", schemaVersion)
|
||||
return s.dropAllTables()
|
||||
}
|
||||
|
||||
func (s *Store) dropAllTables() error {
|
||||
conn, err := s.db.Conn(context.Background())
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -88,9 +93,7 @@ func (s *Store) resetOutdatedSchema() error {
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
if len(tables) > 0 {
|
||||
slog.Warn("Veraltetes Datenbankschema: alle Daten werden gelöscht und neu angelegt", "version", version, "neu", schemaVersion)
|
||||
}
|
||||
|
||||
if _, err := conn.ExecContext(ctx, `PRAGMA foreign_keys = OFF`); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -514,6 +517,7 @@ func (s *Store) migrate() error {
|
||||
_ = s.reindexAllDocBlockTags()
|
||||
}
|
||||
s.migrateAnchorTags()
|
||||
s.migrateSearchFTS()
|
||||
|
||||
_, err := s.db.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, schemaVersion))
|
||||
return err
|
||||
@@ -4124,44 +4128,11 @@ func (s *Store) GetDashboardStats(userID string) (*domain.DashboardStats, error)
|
||||
}
|
||||
|
||||
func (s *Store) SearchAll(userID, query string) ([]domain.Thought, []domain.Document, error) {
|
||||
q := "%" + strings.ToLower(strings.TrimSpace(query)) + "%"
|
||||
|
||||
tRows, 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
|
||||
FROM thoughts WHERE user_id = ? AND LOWER(content) LIKE ? ORDER BY created_at DESC LIMIT 30`,
|
||||
userID, q,
|
||||
)
|
||||
thoughts := make([]domain.Thought, 0)
|
||||
if err == nil {
|
||||
for tRows.Next() {
|
||||
if t, err := scanThought(tRows); err == nil {
|
||||
thoughts = append(thoughts, *t)
|
||||
}
|
||||
}
|
||||
_ = tRows.Close()
|
||||
res, err := s.SearchGlobal(userID, query)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
dRows, err := s.db.Query(
|
||||
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
|
||||
CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
|
||||
d.is_archived, d.created_at, d.updated_at,
|
||||
d.shared_folder_id, d.sub_folder_path
|
||||
FROM documents d
|
||||
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
|
||||
WHERE d.user_id = ? AND (LOWER(d.title) LIKE ? OR LOWER(d.content) LIKE ?) ORDER BY is_pinned DESC, d.updated_at DESC LIMIT 20`,
|
||||
userID, userID, q, q,
|
||||
)
|
||||
docs := make([]domain.Document, 0)
|
||||
if err == nil {
|
||||
for dRows.Next() {
|
||||
if d, err := scanDocument(dRows); err == nil {
|
||||
docs = append(docs, *d)
|
||||
}
|
||||
}
|
||||
_ = dRows.Close()
|
||||
}
|
||||
|
||||
return thoughts, docs, nil
|
||||
return res.Thoughts, res.Documents, nil
|
||||
}
|
||||
|
||||
func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) {
|
||||
|
||||
Reference in new issue
Block a user