Paket B: Globale Suche mit SQLite FTS5, BM25-Ranking, Zugriffsprüfung und Cmd+K-Palette
This commit is contained in:
1 parent
633bd6f1ef
commit
71fa53ead3
10 files changed
+1025
-50
No files matched your search
+73
-2
@@ -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 {
|
||||||
@@ -55,6 +56,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 =
|
||||||
@@ -135,6 +137,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);
|
||||||
|
|
||||||
@@ -593,6 +597,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) {
|
||||||
@@ -709,6 +743,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={() => {
|
||||||
@@ -749,13 +796,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}>
|
||||||
@@ -1020,7 +1078,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' && (
|
||||||
@@ -1555,6 +1617,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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -19,6 +19,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): {
|
||||||
@@ -78,10 +79,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('');
|
||||||
@@ -127,7 +134,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 {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
TaskListShare,
|
TaskListShare,
|
||||||
Thought,
|
Thought,
|
||||||
SubTask,
|
SubTask,
|
||||||
|
SearchResults,
|
||||||
TagDetails,
|
TagDetails,
|
||||||
TagSummary,
|
TagSummary,
|
||||||
User,
|
User,
|
||||||
@@ -1059,8 +1060,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)}`
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -297,6 +297,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;
|
||||||
|
|||||||
@@ -2039,15 +2039,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 ---
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sandrodach/mindsift/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SearchHit represents a single ranked match across documents, tasks/thoughts, and lists.
|
||||||
|
type SearchHit struct {
|
||||||
|
Type string `json:"type"` // "document", "thought", "list_item"
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Snippet string `json:"snippet"`
|
||||||
|
Score float64 `json:"score,omitempty"`
|
||||||
|
DocID string `json:"docId,omitempty"`
|
||||||
|
BlockID string `json:"blockId,omitempty"`
|
||||||
|
ListID string `json:"listId,omitempty"`
|
||||||
|
Icon string `json:"icon,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchResults encapsulates all results for backwards-compatible API serialization.
|
||||||
|
type SearchResults struct {
|
||||||
|
Thoughts []domain.Thought `json:"thoughts"`
|
||||||
|
Documents []domain.Document `json:"documents"`
|
||||||
|
ListItems []domain.ListItem `json:"listItems"`
|
||||||
|
Hits []SearchHit `json:"hits"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var wordTokenRegex = regexp.MustCompile(`[\p{L}\p{N}]+`)
|
||||||
|
|
||||||
|
// PrepareFTSQuery extracts valid words from user input and formats them as prefix match tokens.
|
||||||
|
// E.g. "einkau" -> `"einkau"*`, "Hello World!" -> `"Hello"* "World"*`.
|
||||||
|
func PrepareFTSQuery(raw string) string {
|
||||||
|
matches := wordTokenRegex.FindAllString(strings.TrimSpace(raw), -1)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
tokens := make([]string, 0, len(matches))
|
||||||
|
for _, m := range matches {
|
||||||
|
clean := strings.ReplaceAll(m, `"`, `""`)
|
||||||
|
tokens = append(tokens, fmt.Sprintf(`"%s"*`, clean))
|
||||||
|
}
|
||||||
|
return strings.Join(tokens, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrateSearchFTS creates the virtual FTS5 tables and synchronization triggers.
|
||||||
|
func (s *Store) migrateSearchFTS() {
|
||||||
|
tables := []string{
|
||||||
|
`CREATE VIRTUAL TABLE IF NOT EXISTS thoughts_fts USING fts5(
|
||||||
|
thought_id UNINDEXED,
|
||||||
|
content,
|
||||||
|
notes,
|
||||||
|
subtasks,
|
||||||
|
tokenize='unicode61'
|
||||||
|
);`,
|
||||||
|
`CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5(
|
||||||
|
doc_id UNINDEXED,
|
||||||
|
block_id UNINDEXED,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
tokenize='unicode61'
|
||||||
|
);`,
|
||||||
|
`CREATE VIRTUAL TABLE IF NOT EXISTS list_items_fts USING fts5(
|
||||||
|
item_id UNINDEXED,
|
||||||
|
list_id UNINDEXED,
|
||||||
|
content,
|
||||||
|
category,
|
||||||
|
tokenize='unicode61'
|
||||||
|
);`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, stmt := range tables {
|
||||||
|
if _, err := s.db.Exec(stmt); err != nil {
|
||||||
|
slog.Error("Failed to create FTS virtual table", "error", err, "statement", stmt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
triggers := []string{
|
||||||
|
// Thoughts triggers
|
||||||
|
`CREATE TRIGGER IF NOT EXISTS trg_thoughts_ai AFTER INSERT ON thoughts BEGIN
|
||||||
|
INSERT INTO thoughts_fts(thought_id, content, notes, subtasks)
|
||||||
|
VALUES (new.id, new.content, new.notes, new.subtasks);
|
||||||
|
END;`,
|
||||||
|
`CREATE TRIGGER IF NOT EXISTS trg_thoughts_au AFTER UPDATE ON thoughts BEGIN
|
||||||
|
DELETE FROM thoughts_fts WHERE thought_id = old.id;
|
||||||
|
INSERT INTO thoughts_fts(thought_id, content, notes, subtasks)
|
||||||
|
VALUES (new.id, new.content, new.notes, new.subtasks);
|
||||||
|
END;`,
|
||||||
|
`CREATE TRIGGER IF NOT EXISTS trg_thoughts_ad AFTER DELETE ON thoughts BEGIN
|
||||||
|
DELETE FROM thoughts_fts WHERE thought_id = old.id;
|
||||||
|
END;`,
|
||||||
|
|
||||||
|
// Documents triggers
|
||||||
|
`CREATE TRIGGER IF NOT EXISTS trg_documents_ai AFTER INSERT ON documents BEGIN
|
||||||
|
INSERT INTO documents_fts(doc_id, block_id, title, content)
|
||||||
|
VALUES (new.id, '', new.title, '');
|
||||||
|
END;`,
|
||||||
|
`CREATE TRIGGER IF NOT EXISTS trg_documents_au AFTER UPDATE OF title, is_archived ON documents BEGIN
|
||||||
|
DELETE FROM documents_fts WHERE doc_id = old.id AND block_id = '';
|
||||||
|
INSERT INTO documents_fts(doc_id, block_id, title, content)
|
||||||
|
SELECT new.id, '', new.title, '' WHERE new.is_archived = 0;
|
||||||
|
END;`,
|
||||||
|
`CREATE TRIGGER IF NOT EXISTS trg_documents_ad AFTER DELETE ON documents BEGIN
|
||||||
|
DELETE FROM documents_fts WHERE doc_id = old.id;
|
||||||
|
END;`,
|
||||||
|
|
||||||
|
// Blocks triggers (excluding tombstones/deleted blocks)
|
||||||
|
`CREATE TRIGGER IF NOT EXISTS trg_blocks_ai AFTER INSERT ON blocks BEGIN
|
||||||
|
INSERT INTO documents_fts(doc_id, block_id, title, content)
|
||||||
|
SELECT new.document_id, new.id, '', new.content
|
||||||
|
WHERE new.deleted = 0 AND (new.deleted_at = '' OR new.deleted_at IS NULL);
|
||||||
|
END;`,
|
||||||
|
`CREATE TRIGGER IF NOT EXISTS trg_blocks_au AFTER UPDATE OF content, deleted, deleted_at, document_id ON blocks BEGIN
|
||||||
|
DELETE FROM documents_fts WHERE doc_id = old.document_id AND block_id = old.id;
|
||||||
|
INSERT INTO documents_fts(doc_id, block_id, title, content)
|
||||||
|
SELECT new.document_id, new.id, '', new.content
|
||||||
|
WHERE new.deleted = 0 AND (new.deleted_at = '' OR new.deleted_at IS NULL);
|
||||||
|
END;`,
|
||||||
|
`CREATE TRIGGER IF NOT EXISTS trg_blocks_ad AFTER DELETE ON blocks BEGIN
|
||||||
|
DELETE FROM documents_fts WHERE doc_id = old.document_id AND block_id = old.id;
|
||||||
|
END;`,
|
||||||
|
|
||||||
|
// List items triggers
|
||||||
|
`CREATE TRIGGER IF NOT EXISTS trg_list_items_ai AFTER INSERT ON list_items BEGIN
|
||||||
|
INSERT INTO list_items_fts(item_id, list_id, content, category)
|
||||||
|
VALUES (new.id, new.list_id, new.content, new.category);
|
||||||
|
END;`,
|
||||||
|
`CREATE TRIGGER IF NOT EXISTS trg_list_items_au AFTER UPDATE ON list_items BEGIN
|
||||||
|
DELETE FROM list_items_fts WHERE item_id = old.id;
|
||||||
|
INSERT INTO list_items_fts(item_id, list_id, content, category)
|
||||||
|
VALUES (new.id, new.list_id, new.content, new.category);
|
||||||
|
END;`,
|
||||||
|
`CREATE TRIGGER IF NOT EXISTS trg_list_items_ad AFTER DELETE ON list_items BEGIN
|
||||||
|
DELETE FROM list_items_fts WHERE item_id = old.id;
|
||||||
|
END;`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, trg := range triggers {
|
||||||
|
if _, err := s.db.Exec(trg); err != nil {
|
||||||
|
slog.Error("Failed to create FTS trigger", "error", err, "statement", trg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.backfillSearchFTS()
|
||||||
|
}
|
||||||
|
|
||||||
|
// backfillSearchFTS populates empty FTS indices once on startup.
|
||||||
|
func (s *Store) backfillSearchFTS() {
|
||||||
|
var thoughtCount int
|
||||||
|
_ = s.db.QueryRow(`SELECT count(*) FROM thoughts_fts`).Scan(&thoughtCount)
|
||||||
|
if thoughtCount == 0 {
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
INSERT INTO thoughts_fts(thought_id, content, notes, subtasks)
|
||||||
|
SELECT id, content, notes, subtasks FROM thoughts;
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to backfill thoughts_fts", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var docCount int
|
||||||
|
_ = s.db.QueryRow(`SELECT count(*) FROM documents_fts`).Scan(&docCount)
|
||||||
|
if docCount == 0 {
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
INSERT INTO documents_fts(doc_id, block_id, title, content)
|
||||||
|
SELECT id, '', title, '' FROM documents WHERE is_archived = 0;
|
||||||
|
|
||||||
|
INSERT INTO documents_fts(doc_id, block_id, title, content)
|
||||||
|
SELECT document_id, id, '', content FROM blocks
|
||||||
|
WHERE deleted = 0 AND (deleted_at = '' OR deleted_at IS NULL);
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to backfill documents_fts", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var listCount int
|
||||||
|
_ = s.db.QueryRow(`SELECT count(*) FROM list_items_fts`).Scan(&listCount)
|
||||||
|
if listCount == 0 {
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
INSERT INTO list_items_fts(item_id, list_id, content, category)
|
||||||
|
SELECT id, list_id, content, category FROM list_items;
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to backfill list_items_fts", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchGlobal executes an FTS5 full-text search across documents, thoughts/tasks and list items,
|
||||||
|
// filtering results strictly to what the user is authorized to view.
|
||||||
|
func (s *Store) SearchGlobal(userID, query string) (*SearchResults, error) {
|
||||||
|
ftsQuery := PrepareFTSQuery(query)
|
||||||
|
res := &SearchResults{
|
||||||
|
Thoughts: make([]domain.Thought, 0),
|
||||||
|
Documents: make([]domain.Document, 0),
|
||||||
|
ListItems: make([]domain.ListItem, 0),
|
||||||
|
Hits: make([]SearchHit, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
if ftsQuery == "" {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Search Documents and Blocks
|
||||||
|
// Weights: 10.0 for title, 1.0 for block content
|
||||||
|
docRows, err := s.db.Query(`
|
||||||
|
SELECT f.doc_id, f.block_id,
|
||||||
|
snippet(documents_fts, -1, '<mark>', '</mark>', '…', 14) AS snip,
|
||||||
|
bm25(documents_fts, 10.0, 1.0) AS score,
|
||||||
|
d.title, d.icon, d.user_id, d.is_archived
|
||||||
|
FROM documents_fts f
|
||||||
|
JOIN documents d ON d.id = f.doc_id
|
||||||
|
WHERE documents_fts MATCH ? AND d.is_archived = 0
|
||||||
|
ORDER BY score ASC
|
||||||
|
LIMIT 40
|
||||||
|
`, ftsQuery)
|
||||||
|
if err == nil {
|
||||||
|
seenDocIDs := make(map[string]bool)
|
||||||
|
for docRows.Next() {
|
||||||
|
var docID, blockID, snip, title, icon, ownerID string
|
||||||
|
var score float64
|
||||||
|
var isArchived int
|
||||||
|
if scanErr := docRows.Scan(&docID, &blockID, &snip, &score, &title, &icon, &ownerID, &isArchived); scanErr == nil {
|
||||||
|
// Access check via DocRole
|
||||||
|
if s.DocRole(userID, docID) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
hitID := docID
|
||||||
|
if blockID != "" {
|
||||||
|
hitID = docID + ":" + blockID
|
||||||
|
}
|
||||||
|
|
||||||
|
res.Hits = append(res.Hits, SearchHit{
|
||||||
|
Type: "document",
|
||||||
|
ID: hitID,
|
||||||
|
Title: title,
|
||||||
|
Snippet: snip,
|
||||||
|
Score: score,
|
||||||
|
DocID: docID,
|
||||||
|
BlockID: blockID,
|
||||||
|
Icon: icon,
|
||||||
|
})
|
||||||
|
|
||||||
|
if !seenDocIDs[docID] {
|
||||||
|
seenDocIDs[docID] = true
|
||||||
|
if doc, err := s.GetDocumentByID(userID, docID); err == nil && doc != nil {
|
||||||
|
res.Documents = append(res.Documents, *doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = docRows.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Search Thoughts & Tasks
|
||||||
|
// Weights: 5.0 for content, 1.0 for notes, 1.0 for subtasks
|
||||||
|
thoughtRows, err := s.db.Query(`
|
||||||
|
SELECT f.thought_id,
|
||||||
|
snippet(thoughts_fts, -1, '<mark>', '</mark>', '…', 14) AS snip,
|
||||||
|
bm25(thoughts_fts, 5.0, 1.0, 1.0) AS score,
|
||||||
|
t.id, t.user_id, t.content, t.tags, t.priority, t.source, t.status,
|
||||||
|
t.is_completed, t.converted_type, t.converted_ref_id, t.due_date,
|
||||||
|
t.task_list_id, t.kanban_status, t.notes, t.alarm_sent, t.created_at,
|
||||||
|
t.triaged_at, t.subtasks, t.sort_order
|
||||||
|
FROM thoughts_fts f
|
||||||
|
JOIN thoughts t ON t.id = f.thought_id
|
||||||
|
WHERE thoughts_fts MATCH ?
|
||||||
|
AND (t.user_id = ? OR t.task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
|
||||||
|
ORDER BY score ASC
|
||||||
|
LIMIT 40
|
||||||
|
`, ftsQuery, userID, userID, userID, userID)
|
||||||
|
if err == nil {
|
||||||
|
for thoughtRows.Next() {
|
||||||
|
var thoughtID, snip string
|
||||||
|
var score float64
|
||||||
|
var t domain.Thought
|
||||||
|
var tagsStr, subtasksStr string
|
||||||
|
var isCompleted, alarmSent int
|
||||||
|
var triagedAt, dueDate, convertedType, convertedRefId sql.NullString
|
||||||
|
if scanErr := thoughtRows.Scan(
|
||||||
|
&thoughtID, &snip, &score,
|
||||||
|
&t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source, &t.Status,
|
||||||
|
&isCompleted, &convertedType, &convertedRefId, &dueDate,
|
||||||
|
&t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent, &t.CreatedAt,
|
||||||
|
&triagedAt, &subtasksStr, &t.SortOrder,
|
||||||
|
); scanErr == nil {
|
||||||
|
t.IsCompleted = isCompleted != 0
|
||||||
|
t.AlarmSent = alarmSent != 0
|
||||||
|
t.DueDate = dueDate.String
|
||||||
|
t.ConvertedType = convertedType.String
|
||||||
|
t.ConvertedRefID = convertedRefId.String
|
||||||
|
t.TriagedAt = triagedAt.String
|
||||||
|
|
||||||
|
res.Hits = append(res.Hits, SearchHit{
|
||||||
|
Type: "thought",
|
||||||
|
ID: t.ID,
|
||||||
|
Title: t.Content,
|
||||||
|
Snippet: snip,
|
||||||
|
Score: score,
|
||||||
|
})
|
||||||
|
res.Thoughts = append(res.Thoughts, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = thoughtRows.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Search List Items
|
||||||
|
// Weights: 2.0 for content, 1.0 for category
|
||||||
|
accessibleLists, _ := s.GetAllListsForUser(userID)
|
||||||
|
accessibleListIDs := make(map[string]bool, len(accessibleLists))
|
||||||
|
for _, l := range accessibleLists {
|
||||||
|
accessibleListIDs[l.ID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(accessibleListIDs) > 0 {
|
||||||
|
itemRows, err := s.db.Query(`
|
||||||
|
SELECT f.item_id, f.list_id,
|
||||||
|
snippet(list_items_fts, -1, '<mark>', '</mark>', '…', 14) AS snip,
|
||||||
|
bm25(list_items_fts, 2.0, 1.0) AS score,
|
||||||
|
li.id, li.list_id, li.content, li.category, li.quantity,
|
||||||
|
li.is_completed, li.completed_by, li.completed_by_name,
|
||||||
|
li.sort_order, li.created_at, li.completed_at
|
||||||
|
FROM list_items_fts f
|
||||||
|
JOIN list_items li ON li.id = f.item_id
|
||||||
|
WHERE list_items_fts MATCH ?
|
||||||
|
ORDER BY score ASC
|
||||||
|
LIMIT 40
|
||||||
|
`, ftsQuery)
|
||||||
|
if err == nil {
|
||||||
|
for itemRows.Next() {
|
||||||
|
var itemID, listID, snip string
|
||||||
|
var score float64
|
||||||
|
var li domain.ListItem
|
||||||
|
var isCompleted int
|
||||||
|
if scanErr := itemRows.Scan(
|
||||||
|
&itemID, &listID, &snip, &score,
|
||||||
|
&li.ID, &li.ListID, &li.Content, &li.Category, &li.Quantity,
|
||||||
|
&isCompleted, &li.CompletedBy, &li.CompletedByName,
|
||||||
|
&li.SortOrder, &li.CreatedAt, &li.CompletedAt,
|
||||||
|
); scanErr == nil {
|
||||||
|
if !accessibleListIDs[listID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
li.IsCompleted = isCompleted != 0
|
||||||
|
|
||||||
|
res.Hits = append(res.Hits, SearchHit{
|
||||||
|
Type: "list_item",
|
||||||
|
ID: li.ID,
|
||||||
|
Title: li.Content,
|
||||||
|
Snippet: snip,
|
||||||
|
Score: score,
|
||||||
|
ListID: listID,
|
||||||
|
})
|
||||||
|
res.ListItems = append(res.ListItems, li)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = itemRows.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sandrodach/mindsift/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupTestStore(t *testing.T) (*Store, *domain.User, *domain.User) {
|
||||||
|
t.Helper()
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
dbPath := filepath.Join(tmpDir, "test_search.db")
|
||||||
|
|
||||||
|
store, err := NewStore(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewStore failed: %v", err)
|
||||||
|
}
|
||||||
|
openSharing(t, store)
|
||||||
|
|
||||||
|
userA, err := store.CreateUserWithUsername("alice@test.local", "alice", "hash123", "Alice", "member")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser Alice failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userB, err := store.CreateUserWithUsername("bob@test.local", "bob", "hash123", "Bob", "member")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser Bob failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return store, userA, userB
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchBlockContent(t *testing.T) {
|
||||||
|
store, userA, _ := setupTestStore(t)
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
// Create document with block content
|
||||||
|
doc, err := store.CreateDocument(userA.ID, "", "Rezeptbuch", "📖", "", nil, nil, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateDocument failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
blockID := domain.NewID("blk")
|
||||||
|
pos := 1.0
|
||||||
|
_, err = store.db.Exec(`
|
||||||
|
INSERT INTO blocks (id, document_id, content, level, position, type, deleted, deleted_at)
|
||||||
|
VALUES (?, ?, ?, 0, ?, 'bullet', 0, '')
|
||||||
|
`, blockID, doc.ID, "Zutaten: 500g Mehl und Hefe für Pizza", pos)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Insert block failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for block content
|
||||||
|
res, err := store.SearchGlobal(userA.ID, "Hefe")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SearchGlobal failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for _, hit := range res.Hits {
|
||||||
|
if hit.DocID == doc.ID && hit.BlockID == blockID {
|
||||||
|
found = true
|
||||||
|
if hit.Snippet == "" {
|
||||||
|
t.Errorf("expected snippet with marked match, got empty string")
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("expected to find hit for block content 'Hefe', hits: %+v", res.Hits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchTitleRanking(t *testing.T) {
|
||||||
|
store, userA, _ := setupTestStore(t)
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
// Doc 1 has "Architektur" in block content
|
||||||
|
docContent, err := store.CreateDocument(userA.ID, "", "Notizen zu Systemen", "📝", "", nil, nil, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateDocument failed: %v", err)
|
||||||
|
}
|
||||||
|
_, err = store.db.Exec(`
|
||||||
|
INSERT INTO blocks (id, document_id, content, level, position, type, deleted, deleted_at)
|
||||||
|
VALUES (?, ?, 'Hier wird die Architektur im Detail besprochen', 0, 1.0, 'bullet', 0, '')
|
||||||
|
`, domain.NewID("blk"), docContent.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Insert block failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Doc 2 has "Architektur" in title
|
||||||
|
docTitle, err := store.CreateDocument(userA.ID, "", "Architektur Leitfaden", "🏛️", "", nil, nil, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateDocument failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := store.SearchGlobal(userA.ID, "Architektur")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SearchGlobal failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(res.Hits) < 2 {
|
||||||
|
t.Fatalf("expected at least 2 hits, got %d", len(res.Hits))
|
||||||
|
}
|
||||||
|
|
||||||
|
// First hit must be the document with the title match
|
||||||
|
if res.Hits[0].DocID != docTitle.ID {
|
||||||
|
t.Errorf("expected title match (doc %s) to rank first, got doc %s", docTitle.ID, res.Hits[0].DocID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchPermissionsAndUnshared(t *testing.T) {
|
||||||
|
store, userA, userB := setupTestStore(t)
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
// Alice creates private doc, private thought, and private list
|
||||||
|
privateDoc, err := store.CreateDocument(userA.ID, "", "Geheimes Projekt", "🔒", "", nil, nil, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateDocument failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
privateThought, err := store.CreateThought(userA.ID, "Geheimer Gedanke für Alice", "high", "manual", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateThought failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
privateList, err := store.CreateList(userA.ID, "Geheime Einkaufsliste", "🛒", "shopping")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateList failed: %v", err)
|
||||||
|
}
|
||||||
|
_, err = store.CreateListItem(privateList.ID, "Geheimer Kuchen", "", "", 0, userA.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateListItem failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bob searches for "Geheim"
|
||||||
|
resBob, err := store.SearchGlobal(userB.ID, "Geheim")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SearchGlobal for Bob failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resBob.Hits) != 0 || len(resBob.Thoughts) != 0 || len(resBob.Documents) != 0 || len(resBob.ListItems) != 0 {
|
||||||
|
t.Fatalf("expected Bob to find 0 private hits of Alice, got hits=%+v thoughts=%+v docs=%+v",
|
||||||
|
resBob.Hits, resBob.Thoughts, resBob.Documents)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now share privateDoc with Bob
|
||||||
|
if _, err := store.CreateDocShare(userA.ID, privateDoc.ID, userB.Email, "read"); err != nil {
|
||||||
|
t.Fatalf("CreateDocShare failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bob searches again
|
||||||
|
resBobShared, err := store.SearchGlobal(userB.ID, "Geheim")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SearchGlobal for Bob after share failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resBobShared.Documents) != 1 || resBobShared.Documents[0].ID != privateDoc.ID {
|
||||||
|
t.Fatalf("expected Bob to find shared document %s, got %+v", privateDoc.ID, resBobShared.Documents)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alice searches and finds her own items
|
||||||
|
resAlice, err := store.SearchGlobal(userA.ID, "Geheim")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SearchGlobal for Alice failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(resAlice.Documents) != 1 || len(resAlice.Thoughts) != 1 || len(resAlice.ListItems) != 1 {
|
||||||
|
t.Fatalf("expected Alice to find her items, got docs=%d thoughts=%d lists=%d",
|
||||||
|
len(resAlice.Documents), len(resAlice.Thoughts), len(resAlice.ListItems))
|
||||||
|
}
|
||||||
|
_ = privateThought
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchPrefixAndSpecialCharacters(t *testing.T) {
|
||||||
|
store, userA, _ := setupTestStore(t)
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
_, err := store.CreateThought(userA.ID, "Großer Wocheneinkauf im Supermarkt", "normal", "manual", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateThought failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefix search: "einkau" should find "Wocheneinkauf" or "Einkauf"
|
||||||
|
// Note: FTS5 prefix matches word prefixes: "Wochen..." vs "einkauf"
|
||||||
|
_, err = store.CreateThought(userA.ID, "Einkaufszettel schreiben", "normal", "manual", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateThought failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := store.SearchGlobal(userA.ID, "einkau")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SearchGlobal prefix failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(res.Hits) == 0 {
|
||||||
|
t.Errorf("expected prefix match for 'einkau', got 0 hits")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special characters should not cause syntax errors
|
||||||
|
crazyQueries := []string{
|
||||||
|
`"unclosed quote`,
|
||||||
|
`test: foo AND OR NOT (bar)^`,
|
||||||
|
`!@#$%^&*()_+=-[]{}\|;:'",.<>/?`,
|
||||||
|
`* * *`,
|
||||||
|
``,
|
||||||
|
` `,
|
||||||
|
}
|
||||||
|
for _, cq := range crazyQueries {
|
||||||
|
_, err := store.SearchGlobal(userA.ID, cq)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("query %q caused error: %v", cq, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -514,6 +514,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
|
||||||
@@ -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) {
|
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) {
|
||||||
|
|||||||
Reference in new issue
Block a user