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:
prosolis
2026-06-26 06:29:56 -07:00
parent 60eba25fee
commit 9e141e4169
21 changed files with 1841 additions and 51 deletions

261
internal/docs/search.go Normal file
View 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
}