Merge dev into main (v0.50.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 26s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 26s
This commit is contained in:
commit
1f47733f16
12 files changed
+522
-40
No files matched your search
+11
-1
@@ -6,6 +6,15 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.49.2] - 2026-11-01
|
||||||
|
|
||||||
### Fixed
|
### 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.
|
- 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.
|
- 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.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.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
|
[0.49.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.48.0...v0.49.0
|
||||||
|
|||||||
+1
-1
@@ -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`) |
|
| POST | `/auth/alarms/test` | Test-Alarm (`channel`: `email`/`discord`/`webhook`, optional `target`) |
|
||||||
| GET | `/users/directory`, `/collaborators` | Personen, die man sehen und mit denen man teilen kann (je nach `sharingMode`, [sharing-and-permissions.md](../sharing-and-permissions.md)) |
|
| GET | `/users/directory`, `/collaborators` | Personen, die man sehen und mit denen man teilen kann (je nach `sharingMode`, [sharing-and-permissions.md](../sharing-and-permissions.md)) |
|
||||||
| GET | `/stats` | Zähler fürs Dashboard (u. a. `inboxCount`, `tasksOpenCount`, `listItemsOpenCount` = offene Einträge aller sichtbaren Listen) |
|
| 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` | 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 | `/export/markdown` | Obsidian-kompatibles ZIP (`mindsift-vault-DATUM.zip`), direkt gestreamt: Notizen als `.md` mit YAML-Frontmatter in der Ordnerstruktur des Nutzers (inkl. geteilter), `Aufgaben/<Liste>.md` im Format des Tasks-Plugins (`📅`, `⏫`/`🔺`), `Listen/<Liste>.md` |
|
||||||
| GET/POST | `/themes` | Eigene CSS-Themes; anlegen oder mit `id` ändern (`name`, `css`, max. 200 KB) |
|
| GET/POST | `/themes` | Eigene CSS-Themes; anlegen oder mit `id` ändern (`name`, `css`, max. 200 KB) |
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
[](#)
|
[](#)
|
||||||
[](#-schnellstart)
|
[](#-schnellstart)
|
||||||
[](#-deine-daten-gehören-dir)
|
[](#-deine-daten-gehören-dir)
|
||||||
[](CHANGELOG.md)
|
[](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.
|
**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.
|
* **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).
|
* **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.
|
* 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.
|
* 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.
|
* **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.
|
* 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.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.49.2",
|
"version": "0.50.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.49.2",
|
"version": "0.50.0",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "5.3.0",
|
"@fontsource-variable/inter": "5.3.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.49.2",
|
"version": "0.50.0",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1896,6 +1896,7 @@ export const App: React.FC = () => {
|
|||||||
onOpenDocument={handleOpenDocument}
|
onOpenDocument={handleOpenDocument}
|
||||||
onOpenTask={handleOpenTask}
|
onOpenTask={handleOpenTask}
|
||||||
onOpenList={handleOpenList}
|
onOpenList={handleOpenList}
|
||||||
|
lists={lists}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showAbout && <AboutModal onClose={() => setShowAbout(false)} />}
|
{showAbout && <AboutModal onClose={() => setShowAbout(false)} />}
|
||||||
|
|||||||
@@ -7,9 +7,13 @@ import {
|
|||||||
X,
|
X,
|
||||||
Loader2,
|
Loader2,
|
||||||
CornerDownLeft,
|
CornerDownLeft,
|
||||||
|
Maximize2,
|
||||||
|
Minimize2,
|
||||||
|
Check,
|
||||||
} from 'lucide-react';
|
} 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 { api } from '../lib/api';
|
||||||
|
import { formatDue } from '../lib/due';
|
||||||
import { SnippetRenderer } from './SnippetRenderer';
|
import { SnippetRenderer } from './SnippetRenderer';
|
||||||
|
|
||||||
interface CommandPaletteProps {
|
interface CommandPaletteProps {
|
||||||
@@ -18,8 +22,26 @@ interface CommandPaletteProps {
|
|||||||
onOpenDocument: (docId: string, blockId?: string) => void;
|
onOpenDocument: (docId: string, blockId?: string) => void;
|
||||||
onOpenTask: (taskId: string) => void;
|
onOpenTask: (taskId: string) => void;
|
||||||
onOpenList: (listId: 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> = ({
|
export const CommandPalette: React.FC<CommandPaletteProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -27,11 +49,22 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
|
|||||||
onOpenDocument,
|
onOpenDocument,
|
||||||
onOpenTask,
|
onOpenTask,
|
||||||
onOpenList,
|
onOpenList,
|
||||||
|
lists = [],
|
||||||
}) => {
|
}) => {
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [hits, setHits] = useState<SearchHit[]>([]);
|
const [hits, setHits] = useState<SearchHit[]>([]);
|
||||||
|
const [thoughts, setThoughts] = useState<Thought[]>([]);
|
||||||
|
const [listItems, setListItems] = useState<ListItem[]>([]);
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
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 inputRef = useRef<HTMLInputElement>(null);
|
||||||
const debounceTimerRef = useRef<number | null>(null);
|
const debounceTimerRef = useRef<number | null>(null);
|
||||||
@@ -40,7 +73,7 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
|
|||||||
const groupedHits = useMemo(() => {
|
const groupedHits = useMemo(() => {
|
||||||
const docs: SearchHit[] = [];
|
const docs: SearchHit[] = [];
|
||||||
const tasks: SearchHit[] = [];
|
const tasks: SearchHit[] = [];
|
||||||
const lists: SearchHit[] = [];
|
const listHits: SearchHit[] = [];
|
||||||
|
|
||||||
for (const h of hits) {
|
for (const h of hits) {
|
||||||
if (h.type === 'document') {
|
if (h.type === 'document') {
|
||||||
@@ -48,14 +81,14 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
|
|||||||
} else if (h.type === 'thought') {
|
} else if (h.type === 'thought') {
|
||||||
tasks.push(h);
|
tasks.push(h);
|
||||||
} else if (h.type === 'list_item') {
|
} else if (h.type === 'list_item') {
|
||||||
lists.push(h);
|
listHits.push(h);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ key: 'documents', label: 'Notizen', icon: FileText, items: docs },
|
{ key: 'documents', label: 'Notizen', icon: FileText, items: docs },
|
||||||
{ key: 'tasks', label: 'Aufgaben', icon: CheckSquare, items: tasks },
|
{ 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);
|
].filter((g) => g.items.length > 0);
|
||||||
}, [hits]);
|
}, [hits]);
|
||||||
|
|
||||||
@@ -64,16 +97,50 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
|
|||||||
return groupedHits.flatMap((g) => g.items);
|
return groupedHits.flatMap((g) => g.items);
|
||||||
}, [groupedHits]);
|
}, [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
|
// Focus input and reset on open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
setQuery('');
|
setQuery('');
|
||||||
setHits([]);
|
setHits([]);
|
||||||
setSelectedIndex(0);
|
setSelectedIndex(0);
|
||||||
|
setKind('all');
|
||||||
|
setFilter({});
|
||||||
setTimeout(() => inputRef.current?.focus(), 50);
|
setTimeout(() => inputRef.current?.focus(), 50);
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [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
|
// 150ms debounce search
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (debounceTimerRef.current !== null) {
|
if (debounceTimerRef.current !== null) {
|
||||||
@@ -91,8 +158,18 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
debounceTimerRef.current = window.setTimeout(async () => {
|
debounceTimerRef.current = window.setTimeout(async () => {
|
||||||
try {
|
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 || []);
|
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);
|
setSelectedIndex(0);
|
||||||
} catch {
|
} catch {
|
||||||
setHits([]);
|
setHits([]);
|
||||||
@@ -106,9 +183,11 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
|
|||||||
window.clearTimeout(debounceTimerRef.current);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
|
|
||||||
@@ -119,6 +198,20 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
|
|||||||
return;
|
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 (flatHits.length === 0) return;
|
||||||
|
|
||||||
if (e.key === 'ArrowDown') {
|
if (e.key === 'ArrowDown') {
|
||||||
@@ -140,6 +233,31 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
|
|||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [isOpen, flatHits, selectedIndex]);
|
}, [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) => {
|
const executeSelection = (hit: SearchHit) => {
|
||||||
onClose();
|
onClose();
|
||||||
if (hit.type === 'document') {
|
if (hit.type === 'document') {
|
||||||
@@ -155,6 +273,18 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
|
|||||||
|
|
||||||
if (!isOpen) return null;
|
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 (
|
return (
|
||||||
<div
|
<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"
|
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 */}
|
{/* Search Modal Container */}
|
||||||
<div
|
<div
|
||||||
className="
|
className={`w-full h-full bg-panel flex flex-col overflow-hidden shadow-2xl sm:rounded-card sm:border sm:border-border ${
|
||||||
w-full h-full bg-panel flex flex-col overflow-hidden shadow-2xl
|
expanded ? 'sm:h-[85vh] sm:max-w-5xl' : 'sm:h-auto sm:max-h-[80vh] sm:max-w-2xl'
|
||||||
sm:h-auto sm:max-h-[80vh] sm:max-w-2xl sm:rounded-card sm:border sm:border-border
|
}`}
|
||||||
"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Top Input Bar */}
|
{/* Top Input Bar */}
|
||||||
@@ -198,6 +327,21 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -207,6 +351,96 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 */}
|
{/* Results Container */}
|
||||||
<div className="flex-1 overflow-y-auto p-2 space-y-4 min-h-[160px]">
|
<div className="flex-1 overflow-y-auto p-2 space-y-4 min-h-[160px]">
|
||||||
{query.trim() === '' ? (
|
{query.trim() === '' ? (
|
||||||
@@ -233,6 +467,9 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
|
|||||||
{group.items.map((item) => {
|
{group.items.map((item) => {
|
||||||
const overallIndex = flatHits.indexOf(item);
|
const overallIndex = flatHits.indexOf(item);
|
||||||
const isSelected = overallIndex === selectedIndex;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
@@ -247,13 +484,30 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
|
|||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<div className="mt-0.5 shrink-0 text-base leading-none">
|
{task || listItem ? (
|
||||||
{item.icon || (item.type === 'document' ? '🗒️' : item.type === 'thought' ? '✅' : '🛒')}
|
// Tasks and list items: their real state, ticked off right here
|
||||||
</div>
|
<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-1 min-w-0 space-y-0.5">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<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'}
|
{item.title || 'Unbenannt'}
|
||||||
</span>
|
</span>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
@@ -264,11 +518,30 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Large view: the bullet before the hit */}
|
||||||
|
{expanded && item.before && <p className="text-[11px] text-faint truncate">{item.before}</p>}
|
||||||
{item.snippet && (
|
{item.snippet && (
|
||||||
<div>
|
<div>
|
||||||
<SnippetRenderer snippet={item.snippet} />
|
<SnippetRenderer snippet={item.snippet} />
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -290,6 +563,9 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
|
|||||||
<span>
|
<span>
|
||||||
<kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px] text-accent">↵</kbd> Auswählen
|
<kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px] text-accent">↵</kbd> Auswählen
|
||||||
</span>
|
</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>
|
<span>
|
||||||
<kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px] text-accent">Esc</kbd> Schließen
|
<kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px] text-accent">Esc</kbd> Schließen
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
Thought,
|
Thought,
|
||||||
SubTask,
|
SubTask,
|
||||||
SearchResults,
|
SearchResults,
|
||||||
|
SearchFilter,
|
||||||
TagDetails,
|
TagDetails,
|
||||||
TagSummary,
|
TagSummary,
|
||||||
User,
|
User,
|
||||||
@@ -1340,10 +1341,12 @@ export const api = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async searchAll(q: string): Promise<SearchResults> {
|
async searchAll(q: string, filter: SearchFilter = {}): Promise<SearchResults> {
|
||||||
return request<SearchResults>(
|
const params = new URLSearchParams({ q });
|
||||||
`/api/v1/search?q=${encodeURIComponent(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> {
|
async exportAll(): Promise<any> {
|
||||||
|
|||||||
@@ -511,6 +511,22 @@ export interface SearchHit {
|
|||||||
blockId?: string;
|
blockId?: string;
|
||||||
listId?: string;
|
listId?: string;
|
||||||
icon?: 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 {
|
export interface SearchResults {
|
||||||
@@ -518,6 +534,7 @@ export interface SearchResults {
|
|||||||
documents: Document[];
|
documents: Document[];
|
||||||
listItems: ListItem[];
|
listItems: ListItem[];
|
||||||
hits: SearchHit[];
|
hits: SearchHit[];
|
||||||
|
counts?: Partial<Record<'document' | 'thought' | 'list_item', number>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpaceMember {
|
export interface SpaceMember {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -397,10 +399,32 @@ func (s *Server) handlePurgeDiscarded(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// --- SEARCH & EXPORT ---
|
// --- 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) {
|
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")
|
qs := r.URL.Query()
|
||||||
res, err := s.store.SearchGlobal(userID, q)
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
+128
-13
@@ -2,9 +2,11 @@ package storage
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/sandrodach/mindsift/internal/domain"
|
"github.com/sandrodach/mindsift/internal/domain"
|
||||||
@@ -21,14 +23,36 @@ type SearchHit struct {
|
|||||||
BlockID string `json:"blockId,omitempty"`
|
BlockID string `json:"blockId,omitempty"`
|
||||||
ListID string `json:"listId,omitempty"`
|
ListID string `json:"listId,omitempty"`
|
||||||
Icon string `json:"icon,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.
|
// SearchResults encapsulates all results for backwards-compatible API serialization.
|
||||||
type SearchResults struct {
|
type SearchResults struct {
|
||||||
Thoughts []domain.Thought `json:"thoughts"`
|
Thoughts []domain.Thought `json:"thoughts"`
|
||||||
Documents []domain.Document `json:"documents"`
|
Documents []domain.Document `json:"documents"`
|
||||||
ListItems []domain.ListItem `json:"listItems"`
|
ListItems []domain.ListItem `json:"listItems"`
|
||||||
Hits []SearchHit `json:"hits"`
|
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}]+`)
|
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,
|
// 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.
|
// filtering results strictly to what the user is authorized to view.
|
||||||
func (s *Store) SearchGlobal(userID, query string) (*SearchResults, error) {
|
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)
|
ftsQuery := PrepareFTSQuery(query)
|
||||||
res := &SearchResults{
|
res := &SearchResults{
|
||||||
Thoughts: make([]domain.Thought, 0),
|
Thoughts: make([]domain.Thought, 0),
|
||||||
Documents: make([]domain.Document, 0),
|
Documents: make([]domain.Document, 0),
|
||||||
ListItems: make([]domain.ListItem, 0),
|
ListItems: make([]domain.ListItem, 0),
|
||||||
Hits: make([]SearchHit, 0),
|
Hits: make([]SearchHit, 0),
|
||||||
|
Counts: map[string]int{},
|
||||||
}
|
}
|
||||||
|
|
||||||
if ftsQuery == "" {
|
if ftsQuery == "" {
|
||||||
@@ -209,19 +247,28 @@ func (s *Store) SearchGlobal(userID, query string) (*SearchResults, error) {
|
|||||||
|
|
||||||
// 1. Search Documents and Blocks
|
// 1. Search Documents and Blocks
|
||||||
// Weights: 10.0 for title, 1.0 for block content
|
// 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,
|
SELECT f.doc_id, f.block_id,
|
||||||
snippet(documents_fts, -1, '<mark>', '</mark>', '…', 14) AS snip,
|
snippet(documents_fts, -1, '<mark>', '</mark>', '…', 14) AS snip,
|
||||||
bm25(documents_fts, 10.0, 1.0) AS score,
|
bm25(documents_fts, 10.0, 1.0) AS score,
|
||||||
d.title, d.icon, d.user_id, d.is_archived
|
d.title, d.icon, d.user_id, d.is_archived
|
||||||
FROM documents_fts f
|
FROM documents_fts f
|
||||||
JOIN documents d ON d.id = f.doc_id
|
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
|
ORDER BY score ASC
|
||||||
LIMIT 40
|
LIMIT `+strconv.Itoa(limit), docArgs...)
|
||||||
`, ftsQuery)
|
}
|
||||||
if err == nil {
|
if want("document") && err == nil {
|
||||||
seenDocIDs := make(map[string]bool)
|
seenDocIDs := make(map[string]bool)
|
||||||
|
docBlocks := map[string][]domain.DocumentBlock{}
|
||||||
for docRows.Next() {
|
for docRows.Next() {
|
||||||
var docID, blockID, snip, title, icon, ownerID string
|
var docID, blockID, snip, title, icon, ownerID string
|
||||||
var score float64
|
var score float64
|
||||||
@@ -252,16 +299,70 @@ func (s *Store) SearchGlobal(userID, query string) (*SearchResults, error) {
|
|||||||
seenDocIDs[docID] = true
|
seenDocIDs[docID] = true
|
||||||
if doc, err := s.GetDocumentByID(userID, docID); err == nil && doc != nil {
|
if doc, err := s.GetDocumentByID(userID, docID); err == nil && doc != nil {
|
||||||
res.Documents = append(res.Documents, *doc)
|
res.Documents = append(res.Documents, *doc)
|
||||||
|
docBlocks[docID] = doc.Blocks
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = docRows.Close()
|
_ = 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
|
// 2. Search Thoughts & Tasks
|
||||||
// Weights: 5.0 for content, 1.0 for notes, 1.0 for subtasks
|
// 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,
|
SELECT f.thought_id,
|
||||||
snippet(thoughts_fts, -1, '<mark>', '</mark>', '…', 14) AS snip,
|
snippet(thoughts_fts, -1, '<mark>', '</mark>', '…', 14) AS snip,
|
||||||
bm25(thoughts_fts, 5.0, 1.0, 1.0) AS score,
|
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
|
FROM thoughts_fts f
|
||||||
JOIN thoughts t ON t.id = f.thought_id
|
JOIN thoughts t ON t.id = f.thought_id
|
||||||
WHERE thoughts_fts MATCH ?
|
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
|
-- Open before completed, discarded thoughts last; within each by relevance
|
||||||
ORDER BY t.status = 'discarded' ASC, t.is_completed ASC, score ASC
|
ORDER BY t.status = 'discarded' ASC, t.is_completed ASC, score ASC
|
||||||
LIMIT 40
|
LIMIT `+strconv.Itoa(limit),
|
||||||
`, ftsQuery, userID, userID, userID, userID)
|
append([]any{ftsQuery, userID, userID, userID, userID}, taskArgs...)...)
|
||||||
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for thoughtRows.Next() {
|
for thoughtRows.Next() {
|
||||||
var thoughtID, snip string
|
var thoughtID, snip string
|
||||||
@@ -320,7 +422,18 @@ func (s *Store) SearchGlobal(userID, query string) (*SearchResults, error) {
|
|||||||
accessibleListIDs[l.ID] = true
|
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(`
|
itemRows, err := s.db.Query(`
|
||||||
SELECT f.item_id, f.list_id,
|
SELECT f.item_id, f.list_id,
|
||||||
snippet(list_items_fts, -1, '<mark>', '</mark>', '…', 14) AS snip,
|
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
|
li.sort_order, li.created_at, li.completed_at
|
||||||
FROM list_items_fts f
|
FROM list_items_fts f
|
||||||
JOIN list_items li ON li.id = f.item_id
|
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
|
ORDER BY li.is_completed ASC, score ASC
|
||||||
LIMIT 40
|
LIMIT `+strconv.Itoa(limit), itemArgs...)
|
||||||
`, ftsQuery)
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for itemRows.Next() {
|
for itemRows.Next() {
|
||||||
var itemID, listID, snip string
|
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
|
return res, nil
|
||||||
}
|
}
|
||||||
@@ -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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user