Cross-document FTS5 search (trigram tokenizer for EN + space-free CJK, kept in sync by triggers, back-filled from existing docs). GET /api/search uses the FTS index for queries >=3 runes and a LIKE fallback for 1-2 (so 2-char Chinese words resolve); snippets are built in Go with rune-aware boundaries and sentinel highlights. Tags: user-scoped tags + document_tags join (both cascade), idempotent create/assign, per-tag doc counts. Doc list and search carry each doc's tags (one tagsByDoc query). Frontend: useTags, TagChip/TagPicker/SearchBox, rewritten DocList with chips + filter bar + search. Tablet/touch: responsive sidebar drawer (hamburger + scrim <768px), coarse- pointer tap targets, tap-to-open + outside-pointerdown-close for suggestion cards. Warm LLM-down state: useCheckpoint llmDown flag drives a gentle bilingual StatusBar note (writing still saves locally). Migration 0004 (tags + FTS). Tests: tags lifecycle, search EN/CJK/LIKE/update- reindex. go build/vet/test, tsc, vite all clean; verified live on deployment host. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
112 lines
3.3 KiB
Go
112 lines
3.3 KiB
Go
package docs
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestSearch(t *testing.T) {
|
|
srv := newFullServer(t)
|
|
|
|
createDoc(t, srv, "My Essay", "The quick brown fox jumps over the lazy dog")
|
|
createDoc(t, srv, "我的日记", "今天天气很好,我去公园散步,心情非常愉快")
|
|
createDoc(t, srv, "Notes", "groceries and errands for the weekend")
|
|
|
|
search := func(q string) []searchResult {
|
|
t.Helper()
|
|
rec := do(t, srv, http.MethodGet, "/search?q="+urlQuery(q), "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("search %q: %d %s", q, rec.Code, rec.Body)
|
|
}
|
|
var out []searchResult
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
|
t.Fatalf("decode search %q: %v", q, err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// English, ≥3 chars → FTS path. Snippet wraps the match in sentinels.
|
|
res := search("quick")
|
|
if len(res) != 1 || res[0].Title != "My Essay" {
|
|
t.Fatalf("english search: %+v", res)
|
|
}
|
|
if !strings.Contains(res[0].Snippet, hlStart+"quick"+hlEnd) {
|
|
t.Fatalf("snippet not highlighted: %q", res[0].Snippet)
|
|
}
|
|
|
|
// Chinese, ≥3 chars → FTS path (trigram handles CJK).
|
|
res = search("天气很好")
|
|
if len(res) != 1 || res[0].Title != "我的日记" {
|
|
t.Fatalf("chinese fts search: %+v", res)
|
|
}
|
|
if !strings.Contains(res[0].Snippet, hlStart+"天气很好"+hlEnd) {
|
|
t.Fatalf("cjk snippet not highlighted: %q", res[0].Snippet)
|
|
}
|
|
|
|
// 2-character Chinese word → LIKE fallback (below the trigram minimum).
|
|
res = search("公园")
|
|
if len(res) != 1 || res[0].Title != "我的日记" {
|
|
t.Fatalf("cjk short (LIKE) search: %+v", res)
|
|
}
|
|
if !strings.Contains(res[0].Snippet, hlStart+"公园"+hlEnd) {
|
|
t.Fatalf("short cjk snippet not highlighted: %q", res[0].Snippet)
|
|
}
|
|
|
|
// Title-only match still returns the doc, highlighting the title.
|
|
res = search("Notes")
|
|
if len(res) != 1 || res[0].Title != "Notes" {
|
|
t.Fatalf("title search: %+v", res)
|
|
}
|
|
|
|
// Case-insensitive.
|
|
if got := search("QUICK"); len(got) != 1 {
|
|
t.Fatalf("case-insensitive search: %+v", got)
|
|
}
|
|
|
|
// No match → empty (non-nil) array.
|
|
if got := search("zzzznotthere"); len(got) != 0 {
|
|
t.Fatalf("expected no results, got %+v", got)
|
|
}
|
|
|
|
// Empty query → empty array, no error.
|
|
rec := do(t, srv, http.MethodGet, "/search?q=", "")
|
|
if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != "[]" {
|
|
t.Fatalf("empty query: %d %s", rec.Code, rec.Body)
|
|
}
|
|
|
|
// An edit re-indexes via the update trigger: the old term stops matching, the
|
|
// new term starts.
|
|
res = search("quick")
|
|
docID := res[0].ID
|
|
body := `{"content":"{}","content_text":"the slow purple turtle ambles along","word_count":6}`
|
|
do(t, srv, http.MethodPut, "/docs/"+docID, body)
|
|
if got := search("quick"); len(got) != 0 {
|
|
t.Fatalf("stale term still matches after edit: %+v", got)
|
|
}
|
|
if got := search("purple turtle"); len(got) != 1 || got[0].ID != docID {
|
|
t.Fatalf("new term not indexed after edit: %+v", got)
|
|
}
|
|
}
|
|
|
|
// urlQuery percent-encodes a search term for the query string.
|
|
func urlQuery(s string) string {
|
|
var b strings.Builder
|
|
for _, r := range []byte(s) {
|
|
if r == ' ' {
|
|
b.WriteByte('+')
|
|
} else if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
|
b.WriteByte(r)
|
|
} else {
|
|
b.WriteString(percentByte(r))
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func percentByte(b byte) string {
|
|
const hex = "0123456789ABCDEF"
|
|
return string([]byte{'%', hex[b>>4], hex[b&0xf]})
|
|
}
|