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
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:
1 parent
558fc46cf4
commit
994f6dc554
12 files changed
+522
-40
No files matched your search
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user