diff --git a/CHANGELOG.md b/CHANGELOG.md index 6169ebb..cd2f1f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +## [0.50.0] - 2026-11-01 + +### Added +- Globale Suche: Chips „Alle · Notizen · Aufgaben · Listen“ mit Trefferzahl unter dem Suchfeld; wechseln per Klick, `Tab`/`Shift+Tab` oder `Strg+1` bis `4`. Auf eine Art eingeschränkt, zeigt die Suche bis zu 100 Treffer. +- Große Ansicht der Suche (Knopf mit den diagonalen Pfeilen, am PC): breiteres, höheres Fenster mit bis zu 200 Treffern und Filtern je Art – Aufgaben nach Status, Aufgabenliste, Priorität und Fälligkeit (überfällig, heute, in 7 Tagen), Notizen nach Ordner, Listeneinträge nach Liste und Status. Dazu Kontext je Treffer: bei Punkten der Punkt davor und danach, bei Aufgaben Liste, Fälligkeit und Priorität, bei Listeneinträgen die Liste. + +### Fixed +- Globale Suche: Aufgaben trugen vorne immer ein grünes Häkchen, auch wenn sie offen waren. Jetzt steht dort eine Checkbox mit dem echten Zustand, mit der sich Aufgaben und Listeneinträge direkt in der Suche abhaken oder wieder öffnen lassen. + ## [0.49.2] - 2026-11-01 ### Fixed @@ -770,7 +779,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V - Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken. - Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung. -[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.49.2...dev +[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.50.0...dev +[0.50.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.49.2...v0.50.0 [0.49.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.49.1...v0.49.2 [0.49.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.49.0...v0.49.1 [0.49.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.48.0...v0.49.0 diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 3930780..d6abda1 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -38,7 +38,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | POST | `/auth/alarms/test` | Test-Alarm (`channel`: `email`/`discord`/`webhook`, optional `target`) | | GET | `/users/directory`, `/collaborators` | Personen, die man sehen und mit denen man teilen kann (je nach `sharingMode`, [sharing-and-permissions.md](../sharing-and-permissions.md)) | | GET | `/stats` | Zähler fürs Dashboard (u. a. `inboxCount`, `tasksOpenCount`, `listItemsOpenCount` = offene Einträge aller sichtbaren Listen) | -| GET | `/search?q=` | Globale FTS5-Suche in zugänglichen Notizen (Titel & Blöcke), Aufgaben und Listen → `{thoughts, documents, listItems, hits}` | +| GET | `/search?q=` | Globale FTS5-Suche in zugänglichen Notizen (Titel & Blöcke), Aufgaben und Listen → `{thoughts, documents, listItems, hits, counts}`. Offene Aufgaben und Listeneinträge vor erledigten. Optional: `type` (`document`/`thought`/`list_item`), `limit` (Treffer je Art, Standard 40, höchstens 200), für Aufgaben `taskStatus` (`open`/`done`), `taskListId` (`personal` = ohne Liste), `priority`, `due` (`today`/`overdue`/`week`), für Notizen `folder` (samt Unterordnern), für Listen `listId`, `itemStatus` (`open`/`done`). Treffer in Punkten tragen `before`/`after` (Text der Nachbarpunkte); `counts` = Treffer je Art in dieser Antwort | | GET | `/export` | Datenexport als JSON; mit `?format=zip` dieselben Daten als `mindsift-backup.json` in einer ZIP (so lädt die Oberfläche das Backup) | | GET | `/export/markdown` | Obsidian-kompatibles ZIP (`mindsift-vault-DATUM.zip`), direkt gestreamt: Notizen als `.md` mit YAML-Frontmatter in der Ordnerstruktur des Nutzers (inkl. geteilter), `Aufgaben/.md` im Format des Tasks-Plugins (`📅`, `⏫`/`🔺`), `Listen/.md` | | GET/POST | `/themes` | Eigene CSS-Themes; anlegen oder mit `id` ändern (`name`, `css`, max. 200 KB) | diff --git a/README.md b/README.md index 9bd8d33..abe2b4d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) -[![Version](https://img.shields.io/badge/Version-v0.49.2-16a34a?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-v0.50.0-16a34a?style=flat-square)](CHANGELOG.md) **MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server. @@ -75,6 +75,7 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn * **Link-Vorschau:** Vorschaukarte (Titel, Beschreibung, Bild) für Punkte, die nur aus einer Web-Adresse bestehen, z. B. über `/link`; die Seite ruft der Server ab, nicht dein Browser. Pro Gerät abschaltbar. * **Ast auslagern & Notizen zusammenführen:** Bulletpoints samt Unterbaum in eigenständige Notizen auslagern (`/extract`) oder Notizen per Drag & Drop bzw. Menü zusammenführen (jeweils mit vollständiger Rückgängig-Funktion). * Wiki-Links `[[Notiz]]`, auch auf **einzelne Punkte** (`[[Notiz#^id|Text]]`, kopierbar an jedem Punkt), Links und `#Hashtags`; Tag-Seiten bündeln alle Fundstellen. +* **Globale Suche** (`Strg+K`) über Notizen, einzelne Punkte, Aufgaben und Listen: Filter nach Art per Klick oder `Tab`, Aufgaben und Einträge direkt abhaken; die große Ansicht filtert zusätzlich nach Status, Liste, Priorität, Fälligkeit oder Ordner und zeigt den Zusammenhang jedes Treffers. * Ordner, Favoriten, **To-Do-Rollup** über alle Notizen und Export einer Notiz als `.md`. Mehrere Notizen mit `Strg`/`Shift`+Klick markieren und gemeinsam in einen Ordner oder auf den Papierkorb ziehen. Notizen und Listen bekommen ein Emoji als Symbol, oder keins. * **Aufgaben aus Notizen:** Ein Häkchen lässt sich zur Aufgabe erweitern (und eine Aufgabe zur Notiz vertiefen); Haken, Titel und Unteraufgaben bleiben in beide Richtungen abgeglichen. * Eingefügtes Markdown wird zur Gliederung: Überschriften werden zu Abschnitten mit eingerücktem Inhalt, Listen (auch nummeriert) behalten ihre Ebenen, Absätze werden eigene Punkte. Kürzel beim Tippen: `- `, `1. `, `[] `, `# `, `Strg+B`/`I`/`K`. `Strg+Z` / `Strg+Umschalt+Z` nimmt eigene Änderungen zurück bzw. stellt sie wieder her. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a8f2cfa..5865fec 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.49.2", + "version": "0.50.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.49.2", + "version": "0.50.0", "license": "AGPL-3.0-only", "dependencies": { "@fontsource-variable/inter": "5.3.0", diff --git a/frontend/package.json b/frontend/package.json index bee07d3..a091297 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.49.2", + "version": "0.50.0", "license": "AGPL-3.0-only", "type": "module", "scripts": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8fdcb04..448f33e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1896,6 +1896,7 @@ export const App: React.FC = () => { onOpenDocument={handleOpenDocument} onOpenTask={handleOpenTask} onOpenList={handleOpenList} + lists={lists} /> {showAbout && setShowAbout(false)} />} diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx index 6c8fd88..797038b 100644 --- a/frontend/src/components/CommandPalette.tsx +++ b/frontend/src/components/CommandPalette.tsx @@ -7,9 +7,13 @@ import { X, Loader2, CornerDownLeft, + Maximize2, + Minimize2, + Check, } from 'lucide-react'; -import { SearchHit } from '../lib/types'; +import { ListItem, SearchFilter, SearchHit, SearchResults, SharedList, TaskList, Thought } from '../lib/types'; import { api } from '../lib/api'; +import { formatDue } from '../lib/due'; import { SnippetRenderer } from './SnippetRenderer'; interface CommandPaletteProps { @@ -18,8 +22,26 @@ interface CommandPaletteProps { onOpenDocument: (docId: string, blockId?: string) => void; onOpenTask: (taskId: string) => void; onOpenList: (listId: string) => void; + // For list names and the list filter of the large view + lists?: SharedList[]; } +type Kind = 'all' | 'document' | 'thought' | 'list_item'; + +// The filter chips, in this order (Tab and Strg+1–4 follow it) +const KINDS: { kind: Kind; label: string }[] = [ + { kind: 'all', label: 'Alle' }, + { kind: 'document', label: 'Notizen' }, + { kind: 'thought', label: 'Aufgaben' }, + { kind: 'list_item', label: 'Listen' }, +]; + +const PRIORITY_LABELS: Record = { urgent: 'Dringend', high: 'Wichtig', normal: 'Normal', low: 'Niedrig' }; + +// Hits per kind: a few for the overview, more once narrowed to one kind or in the large view +const LIMIT_ALL = 40; +const LIMIT_ONE = 100; +const LIMIT_LARGE = 200; export const CommandPalette: React.FC = ({ isOpen, @@ -27,11 +49,22 @@ export const CommandPalette: React.FC = ({ onOpenDocument, onOpenTask, onOpenList, + lists = [], }) => { const [query, setQuery] = useState(''); const [hits, setHits] = useState([]); + const [thoughts, setThoughts] = useState([]); + const [listItems, setListItems] = useState([]); const [selectedIndex, setSelectedIndex] = useState(0); const [loading, setLoading] = useState(false); + const [kind, setKind] = useState('all'); + // Large view (desktop): bigger window, filters per kind and context for every hit + const [expanded, setExpanded] = useState(false); + const [filter, setFilter] = useState({}); + // Hits per kind of the current text, for the chips (also while narrowed to one kind) + const [counts, setCounts] = useState<{ query: string; limit: number; values: SearchResults['counts'] }>({ query: '', limit: LIMIT_ALL, values: {} }); + const [taskLists, setTaskLists] = useState([]); + const [folders, setFolders] = useState([]); const inputRef = useRef(null); const debounceTimerRef = useRef(null); @@ -40,7 +73,7 @@ export const CommandPalette: React.FC = ({ const groupedHits = useMemo(() => { const docs: SearchHit[] = []; const tasks: SearchHit[] = []; - const lists: SearchHit[] = []; + const listHits: SearchHit[] = []; for (const h of hits) { if (h.type === 'document') { @@ -48,14 +81,14 @@ export const CommandPalette: React.FC = ({ } else if (h.type === 'thought') { tasks.push(h); } else if (h.type === 'list_item') { - lists.push(h); + listHits.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 }, + { key: 'lists', label: 'Listen', icon: ShoppingCart, items: listHits }, ].filter((g) => g.items.length > 0); }, [hits]); @@ -64,16 +97,50 @@ export const CommandPalette: React.FC = ({ return groupedHits.flatMap((g) => g.items); }, [groupedHits]); + const thoughtById = useMemo(() => new Map(thoughts.map((t) => [t.id, t])), [thoughts]); + const itemById = useMemo(() => new Map(listItems.map((li) => [li.id, li])), [listItems]); + const listTitle = (id?: string) => lists.find((l) => l.id === id)?.title; + const taskListTitle = (id?: string) => (id ? taskLists.find((l) => l.id === id)?.title : 'Persönlich'); + // Focus input and reset on open useEffect(() => { if (isOpen) { setQuery(''); setHits([]); setSelectedIndex(0); + setKind('all'); + setFilter({}); setTimeout(() => inputRef.current?.focus(), 50); } }, [isOpen]); + // Task lists and folders for the filters of the large view + useEffect(() => { + if (!isOpen || !expanded) return; + api.getTaskLists().then(setTaskLists).catch(() => {}); + api.getFolders().then(setFolders).catch(() => {}); + }, [isOpen, expanded]); + + // Filters only count for the kind they belong to, and only in the large view + const activeFilter = useMemo((): SearchFilter => { + const limit = expanded ? LIMIT_LARGE : kind === 'all' ? LIMIT_ALL : LIMIT_ONE; + const out: SearchFilter = { limit }; + if (kind !== 'all') out.type = kind; + if (!expanded) return out; + if (kind === 'thought') { + out.taskStatus = filter.taskStatus; + out.taskListId = filter.taskListId; + out.priority = filter.priority; + out.due = filter.due; + } else if (kind === 'document') { + out.folder = filter.folder; + } else if (kind === 'list_item') { + out.listId = filter.listId; + out.itemStatus = filter.itemStatus; + } + return out; + }, [kind, expanded, filter]); + // 150ms debounce search useEffect(() => { if (debounceTimerRef.current !== null) { @@ -91,8 +158,18 @@ export const CommandPalette: React.FC = ({ setLoading(true); debounceTimerRef.current = window.setTimeout(async () => { try { - const res = await api.searchAll(trimmed); + const countLimit = expanded ? LIMIT_LARGE : LIMIT_ALL; + // Narrowed to one kind: the chips still need the numbers of all kinds for this text + const needCounts = kind !== 'all' && (counts.query !== trimmed || counts.limit !== countLimit); + const [res, all] = await Promise.all([ + api.searchAll(trimmed, activeFilter), + needCounts ? api.searchAll(trimmed, { limit: countLimit }) : Promise.resolve(null), + ]); setHits(res.hits || []); + setThoughts(res.thoughts || []); + setListItems(res.listItems || []); + if (kind === 'all') setCounts({ query: trimmed, limit: countLimit, values: res.counts || {} }); + else if (all) setCounts({ query: trimmed, limit: countLimit, values: all.counts || {} }); setSelectedIndex(0); } catch { setHits([]); @@ -106,9 +183,11 @@ export const CommandPalette: React.FC = ({ window.clearTimeout(debounceTimerRef.current); } }; - }, [query]); + // counts is only read to skip a second request + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [query, activeFilter]); - // Keyboard navigation: ↑, ↓, Enter, Escape + // Keyboard navigation: ↑, ↓, Enter, Escape; Tab and Strg+1–4 switch the kind useEffect(() => { if (!isOpen) return; @@ -119,6 +198,20 @@ export const CommandPalette: React.FC = ({ return; } + if (e.key === 'Tab' && document.activeElement === inputRef.current) { + e.preventDefault(); + setKind((cur) => { + const i = KINDS.findIndex((k) => k.kind === cur); + return KINDS[(i + (e.shiftKey ? KINDS.length - 1 : 1)) % KINDS.length].kind; + }); + return; + } + if ((e.ctrlKey || e.metaKey) && ['1', '2', '3', '4'].includes(e.key)) { + e.preventDefault(); + setKind(KINDS[Number(e.key) - 1].kind); + return; + } + if (flatHits.length === 0) return; if (e.key === 'ArrowDown') { @@ -140,6 +233,31 @@ export const CommandPalette: React.FC = ({ return () => window.removeEventListener('keydown', handleKeyDown); }, [isOpen, flatHits, selectedIndex]); + // Tick a task or list item off (or open it again) without leaving the search + const toggleDone = async (hit: SearchHit) => { + if (hit.type === 'thought') { + const t = thoughtById.get(hit.id); + if (!t) return; + const next = !t.isCompleted; + setThoughts((prev) => prev.map((x) => (x.id === t.id ? { ...x, isCompleted: next } : x))); + try { + await api.updateThought(t.id, { isCompleted: next }); + } catch { + setThoughts((prev) => prev.map((x) => (x.id === t.id ? { ...x, isCompleted: !next } : x))); + } + } else if (hit.type === 'list_item') { + const li = itemById.get(hit.id); + if (!li) return; + const next = !li.isCompleted; + setListItems((prev) => prev.map((x) => (x.id === li.id ? { ...x, isCompleted: next } : x))); + try { + await api.toggleListItem(li.listId, li.id, next); + } catch { + setListItems((prev) => prev.map((x) => (x.id === li.id ? { ...x, isCompleted: !next } : x))); + } + } + }; + const executeSelection = (hit: SearchHit) => { onClose(); if (hit.type === 'document') { @@ -155,6 +273,18 @@ export const CommandPalette: React.FC = ({ if (!isOpen) return null; + const countLabel = (k: Kind) => { + if (!query.trim() || counts.query !== query.trim()) return null; + const v = counts.values || {}; + const n = k === 'all' ? (v.document || 0) + (v.thought || 0) + (v.list_item || 0) : v[k] || 0; + const capped = k !== 'all' && n >= counts.limit; + return capped ? `${n}+` : String(n); + }; + + const setFilterValue = (key: keyof SearchFilter, value: string) => + setFilter((prev) => ({ ...prev, [key]: value || undefined })); + const selectClass = 'h-8 px-2 input-field text-xs bg-panel max-w-[12rem]'; + return (
= ({ > {/* Search Modal Container */}
e.stopPropagation()} > {/* Top Input Bar */} @@ -198,6 +327,21 @@ export const CommandPalette: React.FC = ({ ) : null} + {/* Large view with filters (desktop; on phones the search fills the screen anyway) */} +
+ {/* Kinds as chips, with the number of hits */} +
+ {KINDS.map((k) => { + const active = kind === k.kind; + const n = countLabel(k.kind); + return ( + + ); + })} +
+ + {/* Large view: filters of the chosen kind */} + {expanded && kind !== 'all' && ( +
+ {kind === 'thought' && ( + <> + + + + + + )} + {kind === 'document' && ( + + )} + {kind === 'list_item' && ( + <> + + + + )} +
+ )} + {/* Results Container */}
{query.trim() === '' ? ( @@ -233,6 +467,9 @@ export const CommandPalette: React.FC = ({ {group.items.map((item) => { const overallIndex = flatHits.indexOf(item); const isSelected = overallIndex === selectedIndex; + const task = item.type === 'thought' ? thoughtById.get(item.id) : undefined; + const listItem = item.type === 'list_item' ? itemById.get(item.id) : undefined; + const finished = Boolean(task?.isCompleted || listItem?.isCompleted); return (
= ({ } `} > -
- {item.icon || (item.type === 'document' ? '🗒️' : item.type === 'thought' ? '✅' : '🛒')} -
+ {task || listItem ? ( + // Tasks and list items: their real state, ticked off right here + + ) : ( +
{item.icon || '🗒️'}
+ )}
- + {item.title || 'Unbenannt'} {isSelected && ( @@ -264,11 +518,30 @@ export const CommandPalette: React.FC = ({ )}
+ {/* Large view: the bullet before the hit */} + {expanded && item.before &&

{item.before}

} {item.snippet && (
)} + {expanded && item.after &&

{item.after}

} + + {/* Large view: where a task or list item belongs and its state */} + {expanded && task && ( +

+ {taskListTitle(task.taskListId) || 'Aufgabenliste'} + {task.dueDate && fällig {formatDue(task.dueDate)}} + {task.priority && task.priority !== 'normal' && {PRIORITY_LABELS[task.priority]}} + {task.isCompleted && erledigt} +

+ )} + {expanded && listItem && ( +

+ {listTitle(listItem.listId) || 'Liste'} + {listItem.isCompleted && abgehakt} +

+ )}
); @@ -290,6 +563,9 @@ export const CommandPalette: React.FC = ({ ↵ Auswählen + + Tab Art wechseln + Esc Schließen diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a30b7ce..f115317 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -35,6 +35,7 @@ import { Thought, SubTask, SearchResults, + SearchFilter, TagDetails, TagSummary, User, @@ -1340,10 +1341,12 @@ export const api = { }); }, - async searchAll(q: string): Promise { - return request( - `/api/v1/search?q=${encodeURIComponent(q)}` - ); + async searchAll(q: string, filter: SearchFilter = {}): Promise { + const params = new URLSearchParams({ q }); + for (const [k, v] of Object.entries(filter)) { + if (v !== undefined && v !== '') params.set(k, String(v)); + } + return request(`/api/v1/search?${params.toString()}`); }, async exportAll(): Promise { diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index b688bf7..a852e4b 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -511,6 +511,22 @@ export interface SearchHit { blockId?: string; listId?: string; icon?: string; + // Bullets: the bullet before and after, for context + before?: string; + after?: string; +} + +/** Narrowing of the global search (GET /search); empty fields do not filter. */ +export interface SearchFilter { + type?: 'document' | 'thought' | 'list_item'; + limit?: number; + taskStatus?: 'open' | 'done'; + taskListId?: string; // 'personal' = without list + priority?: string; + due?: 'today' | 'overdue' | 'week'; + folder?: string; + listId?: string; + itemStatus?: 'open' | 'done'; } export interface SearchResults { @@ -518,6 +534,7 @@ export interface SearchResults { documents: Document[]; listItems: ListItem[]; hits: SearchHit[]; + counts?: Partial>; } export interface SpaceMember { diff --git a/internal/api/handlers_thoughts.go b/internal/api/handlers_thoughts.go index 660d459..fc0106a 100644 --- a/internal/api/handlers_thoughts.go +++ b/internal/api/handlers_thoughts.go @@ -4,6 +4,8 @@ import ( "encoding/json" "errors" "net/http" + "slices" + "strconv" "strings" "time" @@ -397,10 +399,32 @@ func (s *Server) handlePurgeDiscarded(w http.ResponseWriter, r *http.Request) { // --- SEARCH & EXPORT --- +// handleSearch: GET /search?q= – everything, or narrowed by type (document|thought|list_item), limit +// (hits per kind, up to 200) and filters: taskStatus, taskListId, priority, due (today|overdue|week), +// folder, listId, itemStatus. func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) - q := r.URL.Query().Get("q") - res, err := s.store.SearchGlobal(userID, q) + qs := r.URL.Query() + q := qs.Get("q") + opts := storage.SearchOptions{ + Type: qs.Get("type"), + TaskStatus: qs.Get("taskStatus"), + TaskListID: qs.Get("taskListId"), + Priority: qs.Get("priority"), + Due: qs.Get("due"), + Today: time.Now().In(time.Local).Format("2006-01-02"), + Folder: qs.Get("folder"), + ListID: qs.Get("listId"), + ItemStatus: qs.Get("itemStatus"), + } + if opts.Type != "" && !slices.Contains(storage.SearchTypes, opts.Type) { + writeError(w, http.StatusBadRequest, "type: document, thought oder list_item") + return + } + if n, err := strconv.Atoi(qs.Get("limit")); err == nil { + opts.Limit = n + } + res, err := s.store.Search(userID, q, opts) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return diff --git a/internal/storage/search.go b/internal/storage/search.go index d9e4c3b..a5ae808 100644 --- a/internal/storage/search.go +++ b/internal/storage/search.go @@ -2,9 +2,11 @@ package storage import ( "database/sql" + "errors" "fmt" "log/slog" "regexp" + "strconv" "strings" "github.com/sandrodach/mindsift/internal/domain" @@ -21,14 +23,36 @@ type SearchHit struct { BlockID string `json:"blockId,omitempty"` ListID string `json:"listId,omitempty"` Icon string `json:"icon,omitempty"` + // Bullets: the text of the bullet before and after, for context in the large search view + Before string `json:"before,omitempty"` + After string `json:"after,omitempty"` } +// SearchOptions narrow the global search; the zero value searches everything like before. +type SearchOptions struct { + Type string // "" (all), "document", "thought" or "list_item" + Limit int // hits per kind; 0 = 40, at most 200 + // Tasks: status open|done, task list ("personal" = without list), priority, due today|overdue|week + TaskStatus, TaskListID, Priority, Due string + Today string // YYYY-MM-DD in the server's time zone, for Due + Folder string // notes in this folder or below + ListID, ItemStatus string // list items: list, open|done +} + +// errSkip marks a part of the search that was not asked for. +var errSkip = errors.New("skipped") + +// SearchTypes are the kinds a search can be narrowed to. +var SearchTypes = []string{"document", "thought", "list_item"} + // 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"` + // Counts: hits per kind in this answer (at most the limit each) + Counts map[string]int `json:"counts"` } var wordTokenRegex = regexp.MustCompile(`[\p{L}\p{N}]+`) @@ -195,12 +219,26 @@ func (s *Store) backfillSearchFTS() { // 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) { + return s.Search(userID, query, SearchOptions{}) +} + +// Search is SearchGlobal with options: one kind only, more hits, filters per kind. +func (s *Store) Search(userID, query string, opts SearchOptions) (*SearchResults, error) { + limit := opts.Limit + if limit <= 0 { + limit = 40 + } + if limit > 200 { + limit = 200 + } + want := func(kind string) bool { return opts.Type == "" || opts.Type == kind } ftsQuery := PrepareFTSQuery(query) res := &SearchResults{ Thoughts: make([]domain.Thought, 0), Documents: make([]domain.Document, 0), ListItems: make([]domain.ListItem, 0), Hits: make([]SearchHit, 0), + Counts: map[string]int{}, } if ftsQuery == "" { @@ -209,19 +247,28 @@ func (s *Store) SearchGlobal(userID, query string) (*SearchResults, error) { // 1. Search Documents and Blocks // Weights: 10.0 for title, 1.0 for block content - docRows, err := s.db.Query(` + docWhere, docArgs := "", []any{ftsQuery} + if opts.Folder != "" { + docWhere = ` AND (d.folder_path = ? OR d.folder_path LIKE ?)` + docArgs = append(docArgs, opts.Folder, opts.Folder+"/%") + } + var docRows *sql.Rows + var err error + if want("document") { + 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 + WHERE documents_fts MATCH ? AND d.is_archived = 0`+docWhere+` ORDER BY score ASC - LIMIT 40 - `, ftsQuery) - if err == nil { + LIMIT `+strconv.Itoa(limit), docArgs...) + } + if want("document") && err == nil { seenDocIDs := make(map[string]bool) + docBlocks := map[string][]domain.DocumentBlock{} for docRows.Next() { var docID, blockID, snip, title, icon, ownerID string var score float64 @@ -252,16 +299,70 @@ func (s *Store) SearchGlobal(userID, query string) (*SearchResults, error) { seenDocIDs[docID] = true if doc, err := s.GetDocumentByID(userID, docID); err == nil && doc != nil { res.Documents = append(res.Documents, *doc) + docBlocks[docID] = doc.Blocks } } } } _ = docRows.Close() + // Context of a bullet: the bullets before and after it in the note + for i := range res.Hits { + h := &res.Hits[i] + if h.Type != "document" || h.BlockID == "" { + continue + } + blocks := docBlocks[h.DocID] + for j, b := range blocks { + if b.ID != h.BlockID { + continue + } + if j > 0 { + h.Before = blocks[j-1].Content + } + if j+1 < len(blocks) { + h.After = blocks[j+1].Content + } + break + } + } } // 2. Search Thoughts & Tasks // Weights: 5.0 for content, 1.0 for notes, 1.0 for subtasks - thoughtRows, err := s.db.Query(` + taskWhere, taskArgs := "", []any{} + switch opts.TaskStatus { + case "open": + taskWhere += ` AND t.is_completed = 0 AND t.status != 'discarded'` + case "done": + taskWhere += ` AND t.is_completed = 1` + } + if opts.TaskListID == "personal" { + taskWhere += ` AND t.task_list_id = ''` + } else if opts.TaskListID != "" { + taskWhere += ` AND t.task_list_id = ?` + taskArgs = append(taskArgs, opts.TaskListID) + } + if opts.Priority != "" { + taskWhere += ` AND t.priority = ?` + taskArgs = append(taskArgs, opts.Priority) + } + if opts.Due != "" && opts.Today != "" { + switch opts.Due { + case "today": + taskWhere += ` AND substr(t.due_date, 1, 10) = ?` + taskArgs = append(taskArgs, opts.Today) + case "overdue": + taskWhere += ` AND t.due_date != '' AND substr(t.due_date, 1, 10) < ? AND t.is_completed = 0` + taskArgs = append(taskArgs, opts.Today) + case "week": + taskWhere += ` AND t.due_date != '' AND substr(t.due_date, 1, 10) <= date(?, '+7 days') AND t.is_completed = 0` + taskArgs = append(taskArgs, opts.Today) + } + } + var thoughtRows *sql.Rows + err = errSkip + if want("thought") { + 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, @@ -272,11 +373,12 @@ func (s *Store) SearchGlobal(userID, query string) (*SearchResults, error) { 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()+`)) + AND (t.user_id = ? OR t.task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`+taskWhere+` -- Open before completed, discarded thoughts last; within each by relevance ORDER BY t.status = 'discarded' ASC, t.is_completed ASC, score ASC - LIMIT 40 - `, ftsQuery, userID, userID, userID, userID) + LIMIT `+strconv.Itoa(limit), + append([]any{ftsQuery, userID, userID, userID, userID}, taskArgs...)...) + } if err == nil { for thoughtRows.Next() { var thoughtID, snip string @@ -320,7 +422,18 @@ func (s *Store) SearchGlobal(userID, query string) (*SearchResults, error) { accessibleListIDs[l.ID] = true } - if len(accessibleListIDs) > 0 { + itemWhere, itemArgs := "", []any{ftsQuery} + if opts.ListID != "" { + itemWhere += ` AND li.list_id = ?` + itemArgs = append(itemArgs, opts.ListID) + } + switch opts.ItemStatus { + case "open": + itemWhere += ` AND li.is_completed = 0` + case "done": + itemWhere += ` AND li.is_completed = 1` + } + if want("list_item") && len(accessibleListIDs) > 0 { itemRows, err := s.db.Query(` SELECT f.item_id, f.list_id, snippet(list_items_fts, -1, '', '', '…', 14) AS snip, @@ -330,10 +443,9 @@ func (s *Store) SearchGlobal(userID, query string) (*SearchResults, error) { 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 ? + WHERE list_items_fts MATCH ?`+itemWhere+` ORDER BY li.is_completed ASC, score ASC - LIMIT 40 - `, ftsQuery) + LIMIT `+strconv.Itoa(limit), itemArgs...) if err == nil { for itemRows.Next() { var itemID, listID, snip string @@ -366,5 +478,8 @@ func (s *Store) SearchGlobal(userID, query string) (*SearchResults, error) { } } + for _, h := range res.Hits { + res.Counts[h.Type]++ + } return res, nil } diff --git a/internal/storage/search_test.go b/internal/storage/search_test.go index 391e05b..be91f93 100644 --- a/internal/storage/search_test.go +++ b/internal/storage/search_test.go @@ -236,3 +236,38 @@ func TestSearchListsOpenTasksFirst(t *testing.T) { t.Fatalf("open task should come first: %v (open %s, done %s)", order, open.ID, done) } } + +// Narrowing by kind and filters: tasks of one status, list items of one list, notes in a folder. +func TestSearchOptions(t *testing.T) { + store, userA, _ := setupTestStore(t) + defer store.Close() + + doc, _ := store.CreateDocument(userA.ID, "", "Garten", "", "Haus", nil, nil, false) + _, _ = store.CreateDocument(userA.ID, "", "Garten Büro", "", "Arbeit", nil, nil, false) + done := doneTask(t, store, userA.ID, "", "Garten winterfest machen", "") + open, _ := store.CreateThoughtWithDetails(userA.ID, "Garten Hecke schneiden", "high", "manual", "", "", "todo", "") + _, _ = store.UpdateThoughtTriage(userA.ID, open.ID, "keep", "task", "", "", "") + lst, _ := store.CreateList(userA.ID, "Baumarkt", "", "shopping") + _, _ = store.CreateListItem(lst.ID, "Gartenschere", "", "", 0, userA.ID) + + only, err := store.Search(userA.ID, "Garten", SearchOptions{Type: "thought"}) + if err != nil || len(only.Hits) != 2 || only.Counts["document"] != 0 { + t.Fatalf("type filter: %+v %v", only.Counts, err) + } + openOnly, _ := store.Search(userA.ID, "Garten", SearchOptions{Type: "thought", TaskStatus: "open"}) + if len(openOnly.Hits) != 1 || openOnly.Hits[0].ID != open.ID { + t.Fatalf("open tasks only: %+v", openOnly.Hits) + } + doneOnly, _ := store.Search(userA.ID, "Garten", SearchOptions{Type: "thought", TaskStatus: "done", Priority: "normal"}) + if len(doneOnly.Hits) != 1 || doneOnly.Hits[0].ID != done { + t.Fatalf("done + priority: %+v", doneOnly.Hits) + } + inFolder, _ := store.Search(userA.ID, "Garten", SearchOptions{Type: "document", Folder: "Haus"}) + if len(inFolder.Hits) != 1 || inFolder.Hits[0].DocID != doc.ID { + t.Fatalf("folder filter: %+v", inFolder.Hits) + } + all, _ := store.Search(userA.ID, "Garten", SearchOptions{}) + if all.Counts["thought"] != 2 || all.Counts["document"] != 2 { + t.Fatalf("counts: %+v", all.Counts) + } +}