Phase 10: organization & polish — cross-doc search, tags, touch, warm failures
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
This commit is contained in:
261
internal/docs/search.go
Normal file
261
internal/docs/search.go
Normal file
@@ -0,0 +1,261 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
)
|
||||
|
||||
// Search snippet shaping.
|
||||
const (
|
||||
// ftsMinRunes is the shortest query the trigram FTS index can match. Shorter
|
||||
// queries (common for 2-character Chinese words) fall back to a LIKE scan.
|
||||
ftsMinRunes = 3
|
||||
|
||||
// snippetContext is how many runes of context to show on each side of the
|
||||
// matched term in a result snippet.
|
||||
snippetContext = 28
|
||||
|
||||
// maxSearchResults caps how many hits we return — plenty for a personal
|
||||
// corpus, and keeps the response small.
|
||||
maxSearchResults = 50
|
||||
|
||||
// hlStart/hlEnd wrap the matched span in a snippet. They're control-character
|
||||
// sentinels that never occur in real text, so the client can split on them to
|
||||
// highlight the match without escaping user content.
|
||||
hlStart = "\x01"
|
||||
hlEnd = "\x02"
|
||||
)
|
||||
|
||||
// searchResult is one hit: a document summary plus a highlighted snippet showing
|
||||
// where the query matched.
|
||||
type searchResult struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
WordCount int `json:"word_count"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Snippet string `json:"snippet"`
|
||||
Tags []db.Tag `json:"tags"`
|
||||
}
|
||||
|
||||
// SearchRoutes returns the router mounted at /api/search.
|
||||
func (h *Handler) SearchRoutes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/", h.search)
|
||||
return r
|
||||
}
|
||||
|
||||
// search runs a cross-document full-text search for the local user. Queries of
|
||||
// three or more runes use the trigram FTS index (fast, ranked); shorter queries
|
||||
// fall back to a LIKE scan so 2-character Chinese words still resolve. Either way
|
||||
// the snippet is built in Go from the original text, for clean word boundaries
|
||||
// and a uniform highlight format.
|
||||
func (h *Handler) search(w http.ResponseWriter, r *http.Request) {
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
if q == "" {
|
||||
writeJSON(w, http.StatusOK, []searchResult{})
|
||||
return
|
||||
}
|
||||
|
||||
type row struct {
|
||||
id, title, contentText, updatedAt string
|
||||
wordCount int
|
||||
}
|
||||
var rows []row
|
||||
|
||||
if utf8.RuneCountInString(q) >= ftsMinRunes {
|
||||
// Wrap the whole query as one FTS phrase (doubling embedded quotes), so
|
||||
// special characters are treated literally and trigram does a contiguous
|
||||
// substring match.
|
||||
phrase := `"` + strings.ReplaceAll(q, `"`, `""`) + `"`
|
||||
sqlRows, err := h.DB.Query(
|
||||
`SELECT d.id, d.title, d.content_text, d.word_count, d.updated_at
|
||||
FROM documents_fts f
|
||||
JOIN documents d ON d.id = f.doc_id
|
||||
WHERE documents_fts MATCH ? AND d.user_id = ?
|
||||
ORDER BY rank
|
||||
LIMIT ?`,
|
||||
phrase, db.LocalUserID, maxSearchResults,
|
||||
)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
defer sqlRows.Close()
|
||||
for sqlRows.Next() {
|
||||
var rw row
|
||||
if err := sqlRows.Scan(&rw.id, &rw.title, &rw.contentText, &rw.wordCount, &rw.updatedAt); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
rows = append(rows, rw)
|
||||
}
|
||||
if err := sqlRows.Err(); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Short query: LIKE scan over title + body. Escape LIKE wildcards so a
|
||||
// literal % or _ in the query matches itself.
|
||||
like := "%" + escapeLike(q) + "%"
|
||||
sqlRows, err := h.DB.Query(
|
||||
`SELECT id, title, content_text, word_count, updated_at
|
||||
FROM documents
|
||||
WHERE user_id = ?
|
||||
AND (title LIKE ? ESCAPE '\' OR content_text LIKE ? ESCAPE '\')
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ?`,
|
||||
db.LocalUserID, like, like, maxSearchResults,
|
||||
)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
defer sqlRows.Close()
|
||||
for sqlRows.Next() {
|
||||
var rw row
|
||||
if err := sqlRows.Scan(&rw.id, &rw.title, &rw.contentText, &rw.wordCount, &rw.updatedAt); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
rows = append(rows, rw)
|
||||
}
|
||||
if err := sqlRows.Err(); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]searchResult, 0, len(rows))
|
||||
ids := make([]string, 0, len(rows))
|
||||
for _, rw := range rows {
|
||||
out = append(out, searchResult{
|
||||
ID: rw.id,
|
||||
Title: rw.title,
|
||||
WordCount: rw.wordCount,
|
||||
UpdatedAt: rw.updatedAt,
|
||||
Snippet: buildSnippet(rw.title, rw.contentText, q),
|
||||
})
|
||||
ids = append(ids, rw.id)
|
||||
}
|
||||
|
||||
byDoc, err := h.tagsByDoc(ids)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
for i := range out {
|
||||
out[i].Tags = byDoc[out[i].ID]
|
||||
if out[i].Tags == nil {
|
||||
out[i].Tags = []db.Tag{}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// escapeLike escapes the LIKE metacharacters (% and _) and the escape character
|
||||
// itself so the query is matched literally. Pairs with `ESCAPE '\'`.
|
||||
func escapeLike(s string) string {
|
||||
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
// buildSnippet returns a short excerpt around the first case-insensitive match of
|
||||
// query, with the matched span wrapped in the hl sentinels. It prefers a body
|
||||
// match (with surrounding context); if the query only appears in the title it
|
||||
// highlights the title instead; otherwise it shows the body's opening so a result
|
||||
// always shows something. Windowing is rune-aware so CJK is never split
|
||||
// mid-character.
|
||||
func buildSnippet(title, body, query string) string {
|
||||
runes := []rune(body)
|
||||
qLen := utf8.RuneCountInString(query)
|
||||
if idx := runeIndexFold(runes, query); idx >= 0 {
|
||||
start := idx - snippetContext
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := idx + qLen + snippetContext
|
||||
if end > len(runes) {
|
||||
end = len(runes)
|
||||
}
|
||||
var b strings.Builder
|
||||
if start > 0 {
|
||||
b.WriteString("…")
|
||||
}
|
||||
b.WriteString(string(runes[start:idx]))
|
||||
b.WriteString(hlStart)
|
||||
b.WriteString(string(runes[idx : idx+qLen]))
|
||||
b.WriteString(hlEnd)
|
||||
b.WriteString(string(runes[idx+qLen : end]))
|
||||
if end < len(runes) {
|
||||
b.WriteString("…")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Body had no literal match (title-only hit, or an FTS span the literal scan
|
||||
// can't reproduce). Highlight the title if the query is there.
|
||||
tRunes := []rune(title)
|
||||
if idx := runeIndexFold(tRunes, query); idx >= 0 {
|
||||
return string(tRunes[:idx]) + hlStart + string(tRunes[idx:idx+qLen]) + hlEnd + string(tRunes[idx+qLen:])
|
||||
}
|
||||
|
||||
// Last resort: the body's opening as plain context.
|
||||
return clip(runes, 0, 2*snippetContext+qLen)
|
||||
}
|
||||
|
||||
// clip returns runes[start:start+n] (bounded), with a trailing ellipsis when the
|
||||
// body continues. Used for the no-direct-match fallback.
|
||||
func clip(runes []rune, start, n int) string {
|
||||
if start >= len(runes) {
|
||||
return ""
|
||||
}
|
||||
end := start + n
|
||||
trailing := ""
|
||||
if end < len(runes) {
|
||||
trailing = "…"
|
||||
} else {
|
||||
end = len(runes)
|
||||
}
|
||||
return string(runes[start:end]) + trailing
|
||||
}
|
||||
|
||||
// runeIndexFold finds the first index (in runes) where query occurs in runes,
|
||||
// case-insensitively. Returns -1 if absent. Simple O(n*m) scan — fine for
|
||||
// single-document snippet building.
|
||||
func runeIndexFold(runes []rune, query string) int {
|
||||
q := []rune(strings.ToLower(query))
|
||||
if len(q) == 0 {
|
||||
return -1
|
||||
}
|
||||
lower := make([]rune, len(runes))
|
||||
for i, r := range runes {
|
||||
lower[i] = toLowerRune(r)
|
||||
}
|
||||
for i := 0; i+len(q) <= len(lower); i++ {
|
||||
match := true
|
||||
for j := 0; j < len(q); j++ {
|
||||
if lower[i+j] != q[j] {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// toLowerRune lowercases ASCII letters (the only case-bearing script here); CJK
|
||||
// and other runes pass through unchanged.
|
||||
func toLowerRune(r rune) rune {
|
||||
if r >= 'A' && r <= 'Z' {
|
||||
return r + ('a' - 'A')
|
||||
}
|
||||
return r
|
||||
}
|
||||
Reference in New Issue
Block a user