feat(search): kind chips, large view with filters and context, tick tasks and list items right in the results
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m37s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-01 18:53:10 +01:00
1 parent 558fc46cf4
commit 994f6dc554
12 files changed
+522 -40

No files matched your search

+11 -1
View File
@@ -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
+1 -1
View File
@@ -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/<Liste>.md` im Format des Tasks-Plugins (`📅`, `⏫`/`🔺`), `Listen/<Liste>.md` |
| GET/POST | `/themes` | Eigene CSS-Themes; anlegen oder mit `id` ändern (`name`, `css`, max. 200 KB) |
+2 -1
View File
@@ -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.
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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": {
+1
View File
@@ -1896,6 +1896,7 @@ export const App: React.FC = () => {
onOpenDocument={handleOpenDocument}
onOpenTask={handleOpenTask}
onOpenList={handleOpenList}
lists={lists}
/>
{showAbout && <AboutModal onClose={() => setShowAbout(false)} />}
+291 -15
View File
@@ -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<string, string> = { 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<CommandPaletteProps> = ({
isOpen,
@@ -27,11 +49,22 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
onOpenDocument,
onOpenTask,
onOpenList,
lists = [],
}) => {
const [query, setQuery] = useState('');
const [hits, setHits] = useState<SearchHit[]>([]);
const [thoughts, setThoughts] = useState<Thought[]>([]);
const [listItems, setListItems] = useState<ListItem[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [loading, setLoading] = useState(false);
const [kind, setKind] = useState<Kind>('all');
// Large view (desktop): bigger window, filters per kind and context for every hit
const [expanded, setExpanded] = useState(false);
const [filter, setFilter] = useState<SearchFilter>({});
// 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<TaskList[]>([]);
const [folders, setFolders] = useState<string[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const debounceTimerRef = useRef<number | null>(null);
@@ -40,7 +73,7 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
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<CommandPaletteProps> = ({
} 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<CommandPaletteProps> = ({
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<CommandPaletteProps> = ({
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<CommandPaletteProps> = ({
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<CommandPaletteProps> = ({
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<CommandPaletteProps> = ({
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<CommandPaletteProps> = ({
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 (
<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"
@@ -162,10 +292,9 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
>
{/* 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
"
className={`w-full h-full bg-panel flex flex-col overflow-hidden shadow-2xl sm:rounded-card sm:border sm:border-border ${
expanded ? 'sm:h-[85vh] sm:max-w-5xl' : 'sm:h-auto sm:max-h-[80vh] sm:max-w-2xl'
}`}
onClick={(e) => e.stopPropagation()}
>
{/* Top Input Bar */}
@@ -198,6 +327,21 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
</button>
) : null}
</span>
{/* Large view with filters (desktop; on phones the search fills the screen anyway) */}
<button
type="button"
onClick={() => {
setExpanded((v) => !v);
if (expanded) setFilter({});
inputRef.current?.focus();
}}
className="hidden sm:flex w-7 h-7 items-center justify-center rounded-btn text-muted hover:text-main hover:bg-panel-elevated shrink-0"
title={expanded ? 'Kleine Ansicht' : 'Große Ansicht mit Filtern'}
aria-label={expanded ? 'Kleine Ansicht' : 'Große Ansicht mit Filtern'}
aria-pressed={expanded}
>
{expanded ? <Minimize2 className="w-3.5 h-3.5" /> : <Maximize2 className="w-3.5 h-3.5" />}
</button>
<button
type="button"
onClick={onClose}
@@ -207,6 +351,96 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
</button>
</div>
{/* Kinds as chips, with the number of hits */}
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-border overflow-x-auto shrink-0" role="tablist" aria-label="Art der Treffer">
{KINDS.map((k) => {
const active = kind === k.kind;
const n = countLabel(k.kind);
return (
<button
key={k.kind}
type="button"
role="tab"
aria-selected={active}
onClick={() => {
setKind(k.kind);
inputRef.current?.focus();
}}
className={`h-9 sm:h-7 px-3 rounded-pill border text-xs flex items-center gap-1.5 shrink-0 transition-colors ${
active ? 'bg-accent/10 border-accent text-main font-semibold' : 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<span>{k.label}</span>
{n !== null && <span className="text-[10px] font-mono opacity-70">{n}</span>}
</button>
);
})}
</div>
{/* Large view: filters of the chosen kind */}
{expanded && kind !== 'all' && (
<div className="flex flex-wrap items-center gap-2 px-3 py-2 border-b border-border bg-canvas/40 shrink-0 text-xs text-muted">
{kind === 'thought' && (
<>
<select className={selectClass} value={filter.taskStatus || ''} onChange={(e) => setFilterValue('taskStatus', e.target.value)} aria-label="Status">
<option value="">Offen und erledigt</option>
<option value="open">Nur offene</option>
<option value="done">Nur erledigte</option>
</select>
<select className={selectClass} value={filter.taskListId || ''} onChange={(e) => setFilterValue('taskListId', e.target.value)} aria-label="Aufgabenliste">
<option value="">Alle Listen</option>
<option value="personal">Persönlich (ohne Liste)</option>
{taskLists.map((l) => (
<option key={l.id} value={l.id}>
{[l.icon, l.title].filter(Boolean).join(' ')}
</option>
))}
</select>
<select className={selectClass} value={filter.priority || ''} onChange={(e) => setFilterValue('priority', e.target.value)} aria-label="Priorität">
<option value="">Jede Priorität</option>
<option value="urgent">Dringend</option>
<option value="high">Wichtig</option>
<option value="normal">Normal</option>
<option value="low">Niedrig</option>
</select>
<select className={selectClass} value={filter.due || ''} onChange={(e) => setFilterValue('due', e.target.value)} aria-label="Fälligkeit">
<option value="">Jede Fälligkeit</option>
<option value="overdue">Überfällig</option>
<option value="today">Heute fällig</option>
<option value="week">Fällig in 7 Tagen</option>
</select>
</>
)}
{kind === 'document' && (
<select className={selectClass} value={filter.folder || ''} onChange={(e) => setFilterValue('folder', e.target.value)} aria-label="Ordner">
<option value="">Alle Ordner</option>
{folders.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
)}
{kind === 'list_item' && (
<>
<select className={selectClass} value={filter.listId || ''} onChange={(e) => setFilterValue('listId', e.target.value)} aria-label="Liste">
<option value="">Alle Listen</option>
{lists.map((l) => (
<option key={l.id} value={l.id}>
{[l.icon, l.title].filter(Boolean).join(' ')}
</option>
))}
</select>
<select className={selectClass} value={filter.itemStatus || ''} onChange={(e) => setFilterValue('itemStatus', e.target.value)} aria-label="Status">
<option value="">Offen und abgehakt</option>
<option value="open">Nur offene</option>
<option value="done">Nur abgehakte</option>
</select>
</>
)}
</div>
)}
{/* Results Container */}
<div className="flex-1 overflow-y-auto p-2 space-y-4 min-h-[160px]">
{query.trim() === '' ? (
@@ -233,6 +467,9 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
{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 (
<div
key={item.id}
@@ -247,13 +484,30 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
}
`}
>
<div className="mt-0.5 shrink-0 text-base leading-none">
{item.icon || (item.type === 'document' ? '🗒️' : item.type === 'thought' ? '✅' : '🛒')}
</div>
{task || listItem ? (
// Tasks and list items: their real state, ticked off right here
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void toggleDone(item);
}}
className={`mt-0.5 w-4 h-4 shrink-0 rounded-sm border flex items-center justify-center transition-colors before:absolute before:-inset-3 relative ${
finished ? 'bg-status-ok border-status-ok text-on-accent' : 'border-strong hover:border-accent text-transparent'
}`}
title={finished ? 'Wieder öffnen' : 'Abhaken'}
aria-label={finished ? 'Wieder öffnen' : 'Abhaken'}
aria-pressed={finished}
>
<Check className="w-3 h-3 stroke-[3]" />
</button>
) : (
<div className="mt-0.5 shrink-0 text-base leading-none">{item.icon || '🗒️'}</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">
<span className={`font-semibold text-xs truncate ${finished ? 'line-through text-muted' : ''}`}>
{item.title || 'Unbenannt'}
</span>
{isSelected && (
@@ -264,11 +518,30 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
)}
</div>
{/* Large view: the bullet before the hit */}
{expanded && item.before && <p className="text-[11px] text-faint truncate">{item.before}</p>}
{item.snippet && (
<div>
<SnippetRenderer snippet={item.snippet} />
</div>
)}
{expanded && item.after && <p className="text-[11px] text-faint truncate">{item.after}</p>}
{/* Large view: where a task or list item belongs and its state */}
{expanded && task && (
<p className="text-[11px] text-faint flex flex-wrap gap-x-2">
<span>{taskListTitle(task.taskListId) || 'Aufgabenliste'}</span>
{task.dueDate && <span>fällig {formatDue(task.dueDate)}</span>}
{task.priority && task.priority !== 'normal' && <span>{PRIORITY_LABELS[task.priority]}</span>}
{task.isCompleted && <span>erledigt</span>}
</p>
)}
{expanded && listItem && (
<p className="text-[11px] text-faint flex flex-wrap gap-x-2">
<span>{listTitle(listItem.listId) || 'Liste'}</span>
{listItem.isCompleted && <span>abgehakt</span>}
</p>
)}
</div>
</div>
);
@@ -290,6 +563,9 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
<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">Tab</kbd> Art wechseln
</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>
+7 -4
View File
@@ -35,6 +35,7 @@ import {
Thought,
SubTask,
SearchResults,
SearchFilter,
TagDetails,
TagSummary,
User,
@@ -1340,10 +1341,12 @@ export const api = {
});
},
async searchAll(q: string): Promise<SearchResults> {
return request<SearchResults>(
`/api/v1/search?q=${encodeURIComponent(q)}`
);
async searchAll(q: string, filter: SearchFilter = {}): Promise<SearchResults> {
const params = new URLSearchParams({ q });
for (const [k, v] of Object.entries(filter)) {
if (v !== undefined && v !== '') params.set(k, String(v));
}
return request<SearchResults>(`/api/v1/search?${params.toString()}`);
},
async exportAll(): Promise<any> {
+17
View File
@@ -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<Record<'document' | 'thought' | 'list_item', number>>;
}
export interface SpaceMember {
+26 -2
View File
@@ -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
+128 -13
View File
@@ -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, '<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
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, '<mark>', '</mark>', '…', 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, '<mark>', '</mark>', '…', 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
}
+35
View File
@@ -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)
}
}