diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dc7b0b2..ac145db 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,6 +23,7 @@ import { Flame, Mic, MicOff, + Search, } from 'lucide-react'; import { AuthStatus, DashboardStats, PriorityLevel, SharedList, Thought, User, UserTheme } from './lib/types'; import { @@ -55,6 +56,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 = @@ -135,6 +137,8 @@ export const App: React.FC = () => { const [selectedDocId, setSelectedDocId] = useState(null); const [selectedBlockId, setSelectedBlockId] = useState(null); const [selectedTaskId, setSelectedTaskId] = useState(null); + const [selectedListId, setSelectedListId] = useState(null); + const [showCommandPalette, setShowCommandPalette] = useState(false); const [captureFocusCount, setCaptureFocusCount] = useState(0); const [refreshRevision, setRefreshRevision] = useState(0); @@ -593,6 +597,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) { @@ -709,6 +743,19 @@ export const App: React.FC = () => { )} + + {/* User Profile Pill & Dropdown Menu */} @@ -1020,7 +1078,11 @@ export const App: React.FC = () => { )} {activeTab === 'lists' && ( - + )} {activeTab === 'groups' && ( @@ -1555,6 +1617,15 @@ export const App: React.FC = () => { )} + + {/* Global Command Palette (Cmd+K / Ctrl+K / '/') */} + setShowCommandPalette(false)} + onOpenDocument={handleOpenDocument} + onOpenTask={handleOpenTask} + onOpenList={handleOpenList} + /> ); }; diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx new file mode 100644 index 0000000..ed521c3 --- /dev/null +++ b/frontend/src/components/CommandPalette.tsx @@ -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 ... without dangerouslySetInnerHTML +const SnippetRenderer: React.FC<{ snippet: string }> = ({ snippet }) => { + const parts = useMemo(() => { + if (!snippet) return []; + return snippet.split(/([\s\S]*?<\/mark>)/g); + }, [snippet]); + + return ( + + {parts.map((part, i) => { + if (part.startsWith('') && part.endsWith('')) { + const text = part.slice(6, -7); + return ( + + {text} + + ); + } + return {part}; + })} + + ); +}; + +export const CommandPalette: React.FC = ({ + isOpen, + onClose, + onOpenDocument, + onOpenTask, + onOpenList, +}) => { + const [query, setQuery] = useState(''); + const [hits, setHits] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(0); + const [loading, setLoading] = useState(false); + + const inputRef = useRef(null); + const debounceTimerRef = useRef(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 ( +
+ {/* Search Modal Container */} +
e.stopPropagation()} + > + {/* Top Input Bar */} +
+ + 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 && } + {query && !loading && ( + + )} + +
+ + {/* Results Container */} +
+ {query.trim() === '' ? ( +
+

MindSift Globale Suche

+

Tippe ein Stichwort ein, um alle Inhalte in Sekundenschnelle zu durchsuchen.

+
+ ) : flatHits.length === 0 && !loading ? ( +
+

Keine Treffer gefunden

+

Keine Notizen, Aufgaben oder Listen-Einträge stimmen mit „{query}“ überein.

+
+ ) : ( + groupedHits.map((group) => { + const GroupIcon = group.icon; + return ( +
+
+ + {group.label} +
+ +
+ {group.items.map((item) => { + const overallIndex = flatHits.indexOf(item); + const isSelected = overallIndex === selectedIndex; + return ( +
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' + } + `} + > +
+ {item.icon || (item.type === 'document' ? '📝' : item.type === 'thought' ? '✅' : '🛒')} +
+ +
+
+ + {item.title || 'Unbenannt'} + + {isSelected && ( + + Öffnen + + + )} +
+ + {item.snippet && ( +
+ +
+ )} +
+
+ ); + })} +
+
+ ); + }) + )} +
+ + {/* Footer Shortcut Bar (Desktop) */} +
+
+ + ↑{' '} + ↓ Navigieren + + + ↵ Auswählen + + + Esc Schließen + +
+ +
+ {flatHits.length} Treffer +
+
+
+
+ ); +}; diff --git a/frontend/src/features/capture/InstantCaptureBar.tsx b/frontend/src/features/capture/InstantCaptureBar.tsx index 5bf73ef..cffc170 100644 --- a/frontend/src/features/capture/InstantCaptureBar.tsx +++ b/frontend/src/features/capture/InstantCaptureBar.tsx @@ -103,7 +103,7 @@ export const InstantCaptureBar: React.FC = ({ 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) { diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index 1a3dfc5..eb61d47 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -19,6 +19,7 @@ import { useMobileActions } from '../../context/MobileActionContext'; interface SharedListsViewProps { onRefreshGlobal: () => void; currentUser?: User | null; + initialListId?: string | null; } export function parseListItemInput(rawText: string, defaultQty?: string): { @@ -78,10 +79,16 @@ export function deduplicateItems(items: ListItem[]): ListItem[] { return result; } -export const SharedListsView: React.FC = ({ onRefreshGlobal, currentUser }) => { +export const SharedListsView: React.FC = ({ onRefreshGlobal, currentUser, initialListId }) => { const { setGroups, setPrimaryAction } = useMobileActions(); const [lists, setLists] = useState([]); - const [activeListId, setActiveListId] = useState(''); + const [activeListId, setActiveListId] = useState(initialListId || ''); + + useEffect(() => { + if (initialListId) { + setActiveListId(initialListId); + } + }, [initialListId]); const [newItemText, setNewItemText] = useState(''); const [newItemQty, setNewItemQty] = useState(''); @@ -127,7 +134,9 @@ export const SharedListsView: React.FC = ({ 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 { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 36e0b0d..27e0426 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -24,6 +24,7 @@ import { TaskListShare, Thought, SubTask, + SearchResults, TagDetails, TagSummary, User, @@ -1059,8 +1060,8 @@ export const api = { ); }, - async searchAll(q: string): Promise<{ thoughts: Thought[]; documents: Document[] }> { - return request<{ thoughts: Thought[]; documents: Document[] }>( + async searchAll(q: string): Promise { + return request( `/api/v1/search?q=${encodeURIComponent(q)}` ); }, diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index bc54175..a1e652e 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -297,6 +297,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; diff --git a/internal/api/server.go b/internal/api/server.go index 146d355..25f4291 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -2039,15 +2039,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 --- diff --git a/internal/storage/search.go b/internal/storage/search.go new file mode 100644 index 0000000..8c5ce46 --- /dev/null +++ b/internal/storage/search.go @@ -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, '', '', '…', 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, '', '', '…', 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, '', '', '…', 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 +} diff --git a/internal/storage/search_test.go b/internal/storage/search_test.go new file mode 100644 index 0000000..412aa08 --- /dev/null +++ b/internal/storage/search_test.go @@ -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) + } + } +} diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 4d026f4..d3b68fe 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -514,6 +514,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 @@ -4122,44 +4123,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) {