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

View File

@@ -36,19 +36,23 @@ func (h *Handler) Routes() chi.Router {
r.Delete("/{id}", h.delete)
h.versionRoutes(r)
h.exportRoutes(r)
h.registerTagRoutes(r)
return r
}
// docSummary is the lightweight shape returned by the list endpoint — enough to
// render the DocList sidebar without shipping every document's full body.
// render the DocList sidebar without shipping every document's full body. Tags
// ride along so the sidebar can show chips and filter without a second request.
type docSummary struct {
ID string `json:"id"`
Title string `json:"title"`
WordCount int `json:"word_count"`
UpdatedAt string `json:"updated_at"`
ID string `json:"id"`
Title string `json:"title"`
WordCount int `json:"word_count"`
UpdatedAt string `json:"updated_at"`
Tags []db.Tag `json:"tags"`
}
// list returns the local user's documents, most-recently-updated first.
// list returns the local user's documents, most-recently-updated first, each
// decorated with its tags.
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
rows, err := h.DB.Query(
`SELECT id, title, word_count, updated_at
@@ -64,6 +68,7 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
defer rows.Close()
out := []docSummary{} // non-nil so an empty list serializes as [] not null
ids := []string{}
for rows.Next() {
var d docSummary
if err := rows.Scan(&d.ID, &d.Title, &d.WordCount, &d.UpdatedAt); err != nil {
@@ -71,11 +76,24 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
return
}
out = append(out, d)
ids = append(ids, d.ID)
}
if err := rows.Err(); err != nil {
serverError(w, err)
return
}
byDoc, err := h.tagsByDoc(ids)
if err != nil {
serverError(w, err)
return
}
for i := range out {
out[i].Tags = byDoc[out[i].ID] // nil → JSON null is fine; client treats as none
if out[i].Tags == nil {
out[i].Tags = []db.Tag{}
}
}
writeJSON(w, http.StatusOK, out)
}