Merge branch 'feature/search-emoji-demo' into dev

Gemini: full emoji picker (desktop + mobile bottom sheet), global search
with SQLite FTS5 and Cmd+K palette (access-checked), demo mode resets the
database on every start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-28 23:27:22 +01:00
commit a044620cec
22 files changed
+1512 -197

No files matched your search

+26 -1
View File
@@ -29,13 +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`) | | 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 | `/users/directory`, `/collaborators` | Personen, die man sehen und mit denen man teilen kann (je nach `sharingMode`, [sharing-and-permissions.md](../sharing-and-permissions.md)) |
| GET | `/stats` | Zähler fürs Dashboard | | GET | `/stats` | Zähler fürs Dashboard |
| GET | `/search?q=` | 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` | 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 | `/export/markdown` | Obsidian-kompatibles ZIP (`mindsift-vault-DATUM.zip`), direkt gestreamt: Notizen als `.md` mit YAML-Frontmatter in der Ordnerstruktur des Nutzers (inkl. geteilter), `Aufgaben/<Liste>.md` im Format des Tasks-Plugins (`📅`, `⏫`/`🔺`), `Listen/<Liste>.md` |
| GET/POST | `/themes` | Eigene CSS-Themes; anlegen oder mit `id` ändern (`name`, `css`, max. 200 KB) | | GET/POST | `/themes` | Eigene CSS-Themes; anlegen oder mit `id` ändern (`name`, `css`, max. 200 KB) |
| DELETE | `/themes/{id}` | Eigenes Theme löschen | | DELETE | `/themes/{id}` | Eigenes Theme löschen |
| GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung) | | GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung) |
### Globale Suche (`GET /api/v1/search?q=`)
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 ## Gedanken, Triage, Aufgaben
| Methode | Pfad | Zweck | | Methode | Pfad | Zweck |
+3 -4
View File
@@ -27,7 +27,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
| FLAW-15 | Realtime | ~~`WriteTimeout` von 30 s beendet SSE-Streams~~ | **Behoben** | | 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-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-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-19 | Qualität | Frontend-Tests nur für den Outliner, `lint` ist ein Platzhalter | Niedrig |
| FLAW-20 | Qualität | Sehr große Dateien, duplizierte Zugriffslogik | Niedrig | | FLAW-20 | Qualität | Sehr große Dateien, duplizierte Zugriffslogik | Niedrig |
| FLAW-21 | Repo | `LICENSE` fehlt, obwohl README MIT angibt | Niedrig | | FLAW-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. - **Maßnahme:** Entweder Schreibfreigaben mit Konfliktbehandlung ergänzen oder den toten Code und das Schema bereinigen.
### FLAW-18: Suche ### FLAW-18: Suche
- **Fundstelle:** `Store.SearchAll`. - **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` / `/`).
- **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. - **Fundstellen:** `internal/storage/search.go`, `internal/api/server.go`, `frontend/src/components/CommandPalette.tsx`.
- **Maßnahme:** FTS5-Index über Blöcke/Gedanken; Berechtigungen mitberücksichtigen.
### FLAW-19: Geringe Frontend-Testabdeckung ### 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). - **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).
+2 -1
View File
@@ -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“. - **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. - **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. - `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 ## Explorer, Tags, To-Dos
@@ -50,7 +51,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
- Keine gleichzeitige Mehrbenutzerbearbeitung, keine Cursor-Anzeige, keine Versionshistorie. - 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)*. - 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 ## Export als Markdown-Archiv
+6 -6
View File
@@ -37,18 +37,18 @@ func main() {
} }
defer store.Close() 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 { if cfg.DemoMode {
slog.Info("🧪 Demo-Modus aktiv (MINDA_DEMO=true): Richte Demo-Benutzer admin/admin und user/user ein...") slog.Info("🧪 Demo-Modus aktiv: Setze Datenbank zurück und richte Demo-Accounts ein...")
if err := store.EnsureDemoAccounts(); err != nil { if err := store.ResetAndSeedDemo(); err != nil {
slog.Warn("⚠️ Fehler beim Anlegen der Demo-Accounts", "error", err) slog.Warn("⚠️ Fehler beim Einrichten des Demo-Modus", "error", err)
} else { } else {
slog.Info("✅ Demo-Accounts bereit: admin / admin (Admin-Rolle) und user / user (Standard-Rolle)") 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() wsHub := ws.NewHub()
staticFS, err := fs.Sub(embeddedDist, "dist") staticFS, err := fs.Sub(embeddedDist, "dist")
+22
View File
@@ -9,6 +9,7 @@
"version": "0.2.0", "version": "0.2.0",
"dependencies": { "dependencies": {
"@types/prismjs": "^1.26.6", "@types/prismjs": "^1.26.6",
"emoji-picker-react": "^4.12.0",
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
@@ -2101,6 +2102,21 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/entities": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
@@ -2302,6 +2318,12 @@
"node": ">=8" "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": { "node_modules/form-data": {
"version": "4.0.6", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
+1
View File
@@ -13,6 +13,7 @@
}, },
"dependencies": { "dependencies": {
"@types/prismjs": "^1.26.6", "@types/prismjs": "^1.26.6",
"emoji-picker-react": "4.12.0",
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
+73 -2
View File
@@ -23,6 +23,7 @@ import {
Flame, Flame,
Mic, Mic,
MicOff, MicOff,
Search,
} from 'lucide-react'; } from 'lucide-react';
import { AuthStatus, DashboardStats, PriorityLevel, SharedList, Thought, User, UserTheme } from './lib/types'; import { AuthStatus, DashboardStats, PriorityLevel, SharedList, Thought, User, UserTheme } from './lib/types';
import { import {
@@ -56,6 +57,7 @@ import { GroupsView } from './features/teams/GroupsView';
import { useMobileActions } from './context/MobileActionContext'; import { useMobileActions } from './context/MobileActionContext';
import { MobileHeaderMenu } from './components/MobileHeaderMenu'; import { MobileHeaderMenu } from './components/MobileHeaderMenu';
import { UserAvatar } from './components/UserAvatar'; import { UserAvatar } from './components/UserAvatar';
import { CommandPalette } from './components/CommandPalette';
import { APP_VERSION } from './version'; import { APP_VERSION } from './version';
type ActiveTab = type ActiveTab =
@@ -136,6 +138,8 @@ export const App: React.FC = () => {
const [selectedDocId, setSelectedDocId] = useState<string | null>(null); const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null); const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
const [selectedTaskId, setSelectedTaskId] = 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 [captureFocusCount, setCaptureFocusCount] = useState(0);
const [refreshRevision, setRefreshRevision] = useState(0); const [refreshRevision, setRefreshRevision] = useState(0);
@@ -594,6 +598,36 @@ export const App: React.FC = () => {
setActiveTab('tasks'); 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 = () => { const handleTriggerAddButton = () => {
triggerHaptic(20); triggerHaptic(20);
if (window.innerWidth < 640) { if (window.innerWidth < 640) {
@@ -710,6 +744,19 @@ export const App: React.FC = () => {
</button> </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 <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -750,13 +797,24 @@ export const App: React.FC = () => {
</div> </div>
{/* Quick-Capture Input Bubble directly in Header (above Tabs) */} {/* Quick-Capture Input Bubble directly in Header (above Tabs) */}
<div className="flex-1 max-w-xl mx-auto"> <div className="flex-1 max-w-xl mx-auto flex items-center gap-2">
<div className="flex-1">
<InstantCaptureBar <InstantCaptureBar
onCaptured={handleCaptured} onCaptured={handleCaptured}
lists={lists} lists={lists}
autoFocusTrigger={captureFocusCount} autoFocusTrigger={captureFocusCount}
/> />
</div> </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 */} {/* User Profile Pill & Dropdown Menu */}
<div className="relative h-9 flex items-center gap-2 flex-shrink-0" ref={dropdownRef}> <div className="relative h-9 flex items-center gap-2 flex-shrink-0" ref={dropdownRef}>
@@ -1010,7 +1068,11 @@ export const App: React.FC = () => {
)} )}
{activeTab === 'lists' && ( {activeTab === 'lists' && (
<SharedListsView onRefreshGlobal={refreshAll} currentUser={user} /> <SharedListsView
onRefreshGlobal={refreshAll}
currentUser={user}
initialListId={selectedListId}
/>
)} )}
{activeTab === 'groups' && ( {activeTab === 'groups' && (
@@ -1533,6 +1595,15 @@ export const App: React.FC = () => {
</div> </div>
</div> </div>
)} )}
{/* Global Command Palette (Cmd+K / Ctrl+K / '/') */}
<CommandPalette
isOpen={showCommandPalette}
onClose={() => setShowCommandPalette(false)}
onOpenDocument={handleOpenDocument}
onOpenTask={handleOpenTask}
onOpenList={handleOpenList}
/>
</div> </div>
); );
}; };
+327
View File
@@ -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/25 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>
);
};
@@ -103,7 +103,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
el.tagName === 'TEXTAREA' || el.tagName === 'TEXTAREA' ||
el.isContentEditable; el.isContentEditable;
if (!isEditing && (e.key === 'c' || e.key === 'C' || e.key === '/')) { if (!isEditing && (e.key === 'c' || e.key === 'C')) {
e.preventDefault(); e.preventDefault();
inputRef.current?.focus(); inputRef.current?.focus();
} else if (e.key === 'Escape' && document.activeElement === inputRef.current) { } else if (e.key === 'Escape' && document.activeElement === inputRef.current) {
+38 -1
View File
@@ -343,6 +343,42 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
</div> </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 // TRIAGE & QUICK DUMP
{ {
@@ -658,7 +694,8 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
// Keyboard shortcuts table data // Keyboard shortcuts table data
const shortcuts = useMemo( 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: 'Enter', scope: 'Schnellerfassung', desc: 'Gedanken blitzschnell speichern' },
{ key: 'Esc', scope: 'Schnellerfassung', desc: 'Eingabe leeren & Optionen schließen' }, { 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)' }, { key: 'Enter', scope: 'Workspace Outliner', desc: 'Neuen Bullet-Point anlegen (oder leeren Block ausrücken)' },
@@ -20,6 +20,7 @@ import { useMobileActions } from '../../context/MobileActionContext';
interface SharedListsViewProps { interface SharedListsViewProps {
onRefreshGlobal: () => void; onRefreshGlobal: () => void;
currentUser?: User | null; currentUser?: User | null;
initialListId?: string | null;
} }
export function parseListItemInput(rawText: string, defaultQty?: string): { export function parseListItemInput(rawText: string, defaultQty?: string): {
@@ -79,10 +80,16 @@ export function deduplicateItems(items: ListItem[]): ListItem[] {
return result; return result;
} }
export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal, currentUser }) => { export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal, currentUser, initialListId }) => {
const { setGroups, setPrimaryAction } = useMobileActions(); const { setGroups, setPrimaryAction } = useMobileActions();
const [lists, setLists] = useState<SharedList[]>([]); 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 [newItemText, setNewItemText] = useState('');
const [newItemQty, setNewItemQty] = useState(''); const [newItemQty, setNewItemQty] = useState('');
@@ -128,7 +135,9 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
items: deduplicateItems(l.items || []), items: deduplicateItems(l.items || []),
})); }));
setLists(deduplicated); 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); setActiveListId(deduplicated[0].id);
} }
} catch { } catch {
@@ -44,63 +44,7 @@ export interface DocumentEditorProps {
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void; onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
} }
interface EmojiItem { import { EmojiPickerModal } from './EmojiPickerModal';
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' },
];
function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): DocumentBlock[] { function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): DocumentBlock[] {
if (!blocks || blocks.length === 0) { if (!blocks || blocks.length === 0) {
@@ -250,21 +194,12 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
const [loading, setLoading] = useState<boolean>(!initialDoc); const [loading, setLoading] = useState<boolean>(!initialDoc);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false); const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
const [emojiSearch, setEmojiSearch] = useState<string>('');
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null); const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
// Sync engine (operation log): stores every change locally and syncs it with the server // Sync engine (operation log): stores every change locally and syncs it with the server
const engineRef = useRef<DocSyncEngine | null>(null); const engineRef = useRef<DocSyncEngine | null>(null);
const [engineState, setEngineState] = useState<EngineState | 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 titleInputRef = useRef<HTMLTextAreaElement | HTMLInputElement | null>(null);
const outlinerTreeRef = useRef<OutlinerTreeHandle | null>(null); const outlinerTreeRef = useRef<OutlinerTreeHandle | null>(null);
@@ -1044,72 +979,11 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
{doc?.icon || '📝'} {doc?.icon || '📝'}
</button> </button>
{showEmojiPicker && canEdit && ( {showEmojiPicker && canEdit && (
<div <EmojiPickerModal
className="absolute left-0 mt-1 p-2 panel-surface shadow-lg border border-border rounded-card z-30 w-72 space-y-2" currentIcon={doc?.icon}
onClick={(e) => e.stopPropagation()} onSelectEmoji={handleSelectIcon}
> onClose={() => setShowEmojiPicker(false)}
<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>
)} )}
</div> </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>
</>
);
};
+27
View File
@@ -461,3 +461,30 @@ body {
color: #f97316; 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;
}
}
+3 -2
View File
@@ -24,6 +24,7 @@ import {
TaskListShare, TaskListShare,
Thought, Thought,
SubTask, SubTask,
SearchResults,
TagDetails, TagDetails,
TagSummary, TagSummary,
User, User,
@@ -1065,8 +1066,8 @@ export const api = {
); );
}, },
async searchAll(q: string): Promise<{ thoughts: Thought[]; documents: Document[] }> { async searchAll(q: string): Promise<SearchResults> {
return request<{ thoughts: Thought[]; documents: Document[] }>( return request<SearchResults>(
`/api/v1/search?q=${encodeURIComponent(q)}` `/api/v1/search?q=${encodeURIComponent(q)}`
); );
}, },
+19
View File
@@ -304,6 +304,25 @@ export interface TagDetails {
totalMatches: number; 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 { export interface SpaceMember {
spaceId: string; spaceId: string;
userId: string; userId: string;
+2 -5
View File
@@ -2064,15 +2064,12 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context()) userID := auth.GetUserID(r.Context())
q := r.URL.Query().Get("q") q := r.URL.Query().Get("q")
thoughts, docs, err := s.store.SearchAll(userID, q) res, err := s.store.SearchGlobal(userID, q)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
writeJSON(w, http.StatusOK, map[string]interface{}{ writeJSON(w, http.StatusOK, res)
"thoughts": thoughts,
"documents": docs,
})
} }
// --- CUSTOM THEMES --- // --- CUSTOM THEMES ---
+21
View File
@@ -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
}
+104
View File
@@ -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)
}
}
+369
View File
@@ -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
}
+214
View File
@@ -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
View File
@@ -70,6 +70,11 @@ func (s *Store) resetOutdatedSchema() error {
if version >= schemaVersion { if version >= schemaVersion {
return nil 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()) conn, err := s.db.Conn(context.Background())
if err != nil { if err != nil {
return err return err
@@ -88,9 +93,7 @@ func (s *Store) resetOutdatedSchema() error {
} }
} }
_ = rows.Close() _ = 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 { if _, err := conn.ExecContext(ctx, `PRAGMA foreign_keys = OFF`); err != nil {
return err return err
} }
@@ -514,6 +517,7 @@ func (s *Store) migrate() error {
_ = s.reindexAllDocBlockTags() _ = s.reindexAllDocBlockTags()
} }
s.migrateAnchorTags() s.migrateAnchorTags()
s.migrateSearchFTS()
_, err := s.db.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, schemaVersion)) _, err := s.db.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, schemaVersion))
return err 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) { func (s *Store) SearchAll(userID, query string) ([]domain.Thought, []domain.Document, error) {
q := "%" + strings.ToLower(strings.TrimSpace(query)) + "%" res, err := s.SearchGlobal(userID, query)
if err != nil {
tRows, err := s.db.Query( return nil, nil, err
`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)
} }
} return res.Thoughts, res.Documents, nil
_ = tRows.Close()
}
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
} }
func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) { func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) {