Backend: - Extract shared internal/httputil (WriteJSON/ErrorJSON/BadRequest/ ServerError); drop the triple-duplicated helpers in docs, suggestions, vocab. ServerError now logs the real error and returns a generic 500 so raw DB/internal errors never reach the client. - vocab capture: validate doc_id ownership (blank -> none, unknown -> 400 instead of a leaked FK 500); rune-safe clamp word/gloss/definition/ phonetic/example. - vocab review(): wrap the read-modify-write in a transaction (TOCTOU). - /api request-size cap via MaxBytesReader middleware (2 MiB), exempting /api/images (own 10 MiB limit). Frontend: - StatusBar: drive the checking/voicing/collocating indicators from one array; llmDown uses !anyBusy. - Slide-overs: new useFocusTrap hook (focus-in, Tab trap, focus-restore) on GardenPanel + HistoryPanel, both role=dialog/aria-modal/aria-label. - speech.ts: export stopSpeech(); GardenPanel cancels audio on unmount. Tests: add doc_id-validation and field-clamp coverage; full suite green. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
263 lines
7.4 KiB
Go
263 lines
7.4 KiB
Go
package docs
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
|
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
|
)
|
|
|
|
// 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 == "" {
|
|
httputil.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 {
|
|
httputil.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 {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
rows = append(rows, rw)
|
|
}
|
|
if err := sqlRows.Err(); err != nil {
|
|
httputil.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 {
|
|
httputil.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 {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
rows = append(rows, rw)
|
|
}
|
|
if err := sqlRows.Err(); err != nil {
|
|
httputil.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 {
|
|
httputil.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{}
|
|
}
|
|
}
|
|
httputil.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
|
|
}
|