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:
@@ -199,6 +199,67 @@ CREATE TABLE document_versions (
|
||||
);
|
||||
|
||||
CREATE INDEX idx_versions_doc_id ON document_versions(doc_id, created_at DESC);
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Organization & search (Phase 10). Two parts:
|
||||
//
|
||||
// 1. Tags. A small, user-scoped label set; `color` holds a palette key
|
||||
// (rose/mint/peach/lavender/sky/honey) the frontend maps to CSS.
|
||||
// document_tags is the many-to-many join; both sides cascade so
|
||||
// deleting a doc or a tag cleans up its assignments.
|
||||
//
|
||||
// 2. Full-text search. A standalone FTS5 virtual table over title +
|
||||
// content_text using the `trigram` tokenizer so search works for
|
||||
// both English and space-free Chinese (the default tokenizer treats a
|
||||
// CJK run as one token). It carries an UNINDEXED doc_id to map hits
|
||||
// back to documents, kept in sync by AFTER INSERT/UPDATE/DELETE
|
||||
// triggers, and is back-filled from the existing documents here.
|
||||
// (Trigram needs ≥3 chars to MATCH; the search handler falls back to
|
||||
// LIKE for shorter queries — common for 2-character Chinese words.)
|
||||
name: "0004_tags_and_search",
|
||||
stmt: `
|
||||
CREATE TABLE tags (
|
||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT 'rose',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE document_tags (
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (doc_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_document_tags_tag ON document_tags(tag_id);
|
||||
|
||||
CREATE VIRTUAL TABLE documents_fts USING fts5(
|
||||
doc_id UNINDEXED,
|
||||
title,
|
||||
content_text,
|
||||
tokenize='trigram'
|
||||
);
|
||||
|
||||
CREATE TRIGGER documents_ai AFTER INSERT ON documents BEGIN
|
||||
INSERT INTO documents_fts (doc_id, title, content_text)
|
||||
VALUES (new.id, new.title, new.content_text);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER documents_ad AFTER DELETE ON documents BEGIN
|
||||
DELETE FROM documents_fts WHERE doc_id = old.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER documents_au AFTER UPDATE ON documents BEGIN
|
||||
UPDATE documents_fts
|
||||
SET title = new.title, content_text = new.content_text
|
||||
WHERE doc_id = old.id;
|
||||
END;
|
||||
|
||||
INSERT INTO documents_fts (doc_id, title, content_text)
|
||||
SELECT id, title, content_text FROM documents;
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -51,6 +51,29 @@ const (
|
||||
VersionKindPreRestore = "pre_restore" // safety copy taken just before a restore
|
||||
)
|
||||
|
||||
// Tag is a user-scoped label for organizing documents. `Color` is a palette key
|
||||
// (rose, mint, peach, lavender, sky, honey) the frontend maps to a CSS color;
|
||||
// storing the key (not a hex value) keeps tags in step with the design tokens.
|
||||
// `DocCount` is populated only by the tag-list endpoint (how many documents wear
|
||||
// the tag); it's omitted from per-document tag lists.
|
||||
type Tag struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
DocCount int `json:"doc_count,omitempty"`
|
||||
}
|
||||
|
||||
// Tag color palette keys, mirrored on the frontend. Kept small and aligned with
|
||||
// the existing design tokens; unknown values fall back to rose client-side.
|
||||
const (
|
||||
TagColorRose = "rose"
|
||||
TagColorMint = "mint"
|
||||
TagColorPeach = "peach"
|
||||
TagColorLavender = "lavender"
|
||||
TagColorSky = "sky"
|
||||
TagColorHoney = "honey"
|
||||
)
|
||||
|
||||
// Suggestion is a single LLM-proposed edit anchored to a span of the document.
|
||||
//
|
||||
// FromPos/ToPos are plaintext offsets into ContentText for server-side use only;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
111
internal/docs/search_test.go
Normal file
111
internal/docs/search_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
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]})
|
||||
}
|
||||
303
internal/docs/tags.go
Normal file
303
internal/docs/tags.go
Normal file
@@ -0,0 +1,303 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
)
|
||||
|
||||
// validTagColors is the palette a tag may use, mirrored from the design tokens.
|
||||
// An unknown color on input is coerced to rose so the UI always has a token.
|
||||
var validTagColors = map[string]bool{
|
||||
db.TagColorRose: true, db.TagColorMint: true, db.TagColorPeach: true,
|
||||
db.TagColorLavender: true, db.TagColorSky: true, db.TagColorHoney: true,
|
||||
}
|
||||
|
||||
func normalizeColor(c string) string {
|
||||
if validTagColors[c] {
|
||||
return c
|
||||
}
|
||||
return db.TagColorRose
|
||||
}
|
||||
|
||||
// TagRoutes returns the router mounted at /api/tags for tag management (the
|
||||
// roster the writer creates and recolors), separate from the per-document
|
||||
// assignment routes registered on the docs router.
|
||||
func (h *Handler) TagRoutes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/", h.listTags)
|
||||
r.Post("/", h.createTag)
|
||||
r.Patch("/{id}", h.updateTag)
|
||||
r.Delete("/{id}", h.deleteTag)
|
||||
return r
|
||||
}
|
||||
|
||||
// registerTagRoutes adds the per-document tag assignment routes onto the docs
|
||||
// sub-router so they resolve under /api/docs/{id}/tags.
|
||||
func (h *Handler) registerTagRoutes(r chi.Router) {
|
||||
r.Post("/{id}/tags", h.assignTag)
|
||||
r.Delete("/{id}/tags/{tagId}", h.unassignTag)
|
||||
}
|
||||
|
||||
// listTags returns the user's tags alphabetically, each with the number of
|
||||
// documents that wear it (so the sidebar can show counts and hide empties later
|
||||
// if desired).
|
||||
func (h *Handler) listTags(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := h.DB.Query(
|
||||
`SELECT t.id, t.name, t.color, COUNT(dt.doc_id)
|
||||
FROM tags t
|
||||
LEFT JOIN document_tags dt ON dt.tag_id = t.id
|
||||
WHERE t.user_id = ?
|
||||
GROUP BY t.id
|
||||
ORDER BY t.name COLLATE NOCASE`,
|
||||
db.LocalUserID,
|
||||
)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []db.Tag{} // non-nil so an empty roster serializes as []
|
||||
for rows.Next() {
|
||||
var t db.Tag
|
||||
if err := rows.Scan(&t.ID, &t.Name, &t.Color, &t.DocCount); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
type tagRequest struct {
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
// createTag adds a tag. It's idempotent on (user, name): re-creating an existing
|
||||
// tag returns it unchanged rather than erroring, so the client can "create or
|
||||
// reuse" in one call. Recoloring is done via updateTag.
|
||||
func (h *Handler) createTag(w http.ResponseWriter, r *http.Request) {
|
||||
var req tagRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
badRequest(w, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
badRequest(w, "tag name is required")
|
||||
return
|
||||
}
|
||||
|
||||
var t db.Tag
|
||||
err := h.DB.QueryRow(
|
||||
`INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, name) DO UPDATE SET name = excluded.name
|
||||
RETURNING id, name, color`,
|
||||
db.LocalUserID, name, normalizeColor(req.Color),
|
||||
).Scan(&t.ID, &t.Name, &t.Color)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, t)
|
||||
}
|
||||
|
||||
// updateTag renames and/or recolors a tag. Both fields optional via pointers so
|
||||
// a recolor needn't resend the name.
|
||||
func (h *Handler) updateTag(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
var req struct {
|
||||
Name *string `json:"name"`
|
||||
Color *string `json:"color"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
badRequest(w, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
var namePtr, colorPtr any
|
||||
if req.Name != nil {
|
||||
n := strings.TrimSpace(*req.Name)
|
||||
if n == "" {
|
||||
badRequest(w, "tag name cannot be empty")
|
||||
return
|
||||
}
|
||||
namePtr = n
|
||||
}
|
||||
if req.Color != nil {
|
||||
colorPtr = normalizeColor(*req.Color)
|
||||
}
|
||||
|
||||
res, err := h.DB.Exec(
|
||||
`UPDATE tags
|
||||
SET name = COALESCE(?, name),
|
||||
color = COALESCE(?, color)
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
namePtr, colorPtr, id, db.LocalUserID,
|
||||
)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
notFoundMsg(w, "tag not found")
|
||||
return
|
||||
}
|
||||
|
||||
var t db.Tag
|
||||
if err := h.DB.QueryRow(
|
||||
`SELECT id, name, color FROM tags WHERE id = ? AND user_id = ?`,
|
||||
id, db.LocalUserID,
|
||||
).Scan(&t.ID, &t.Name, &t.Color); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
// deleteTag removes a tag; its document assignments cascade away via the FK.
|
||||
func (h *Handler) deleteTag(w http.ResponseWriter, r *http.Request) {
|
||||
res, err := h.DB.Exec(
|
||||
`DELETE FROM tags WHERE id = ? AND user_id = ?`,
|
||||
chi.URLParam(r, "id"), db.LocalUserID,
|
||||
)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
notFoundMsg(w, "tag not found")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// assignTag attaches a tag to a document. Both must belong to the local user;
|
||||
// the assignment is idempotent (re-assigning is a no-op, not an error).
|
||||
func (h *Handler) assignTag(w http.ResponseWriter, r *http.Request) {
|
||||
docID := chi.URLParam(r, "id")
|
||||
|
||||
var req struct {
|
||||
TagID string `json:"tag_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
badRequest(w, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if req.TagID == "" {
|
||||
badRequest(w, "tag_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify both the doc and the tag belong to the user before linking, so a
|
||||
// stray id can't cross-link another account's rows.
|
||||
if !h.ownsDoc(docID) {
|
||||
notFound(w)
|
||||
return
|
||||
}
|
||||
if !h.ownsTag(req.TagID) {
|
||||
notFoundMsg(w, "tag not found")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.DB.Exec(
|
||||
`INSERT INTO document_tags (doc_id, tag_id) VALUES (?, ?)
|
||||
ON CONFLICT(doc_id, tag_id) DO NOTHING`,
|
||||
docID, req.TagID,
|
||||
); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// unassignTag detaches a tag from a document.
|
||||
func (h *Handler) unassignTag(w http.ResponseWriter, r *http.Request) {
|
||||
docID := chi.URLParam(r, "id")
|
||||
tagID := chi.URLParam(r, "tagId")
|
||||
|
||||
if !h.ownsDoc(docID) {
|
||||
notFound(w)
|
||||
return
|
||||
}
|
||||
if _, err := h.DB.Exec(
|
||||
`DELETE FROM document_tags WHERE doc_id = ? AND tag_id = ?`,
|
||||
docID, tagID,
|
||||
); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ownsDoc reports whether a document belongs to the local user.
|
||||
func (h *Handler) ownsDoc(docID string) bool {
|
||||
var exists bool
|
||||
_ = h.DB.QueryRow(
|
||||
`SELECT EXISTS(SELECT 1 FROM documents WHERE id = ? AND user_id = ?)`,
|
||||
docID, db.LocalUserID,
|
||||
).Scan(&exists)
|
||||
return exists
|
||||
}
|
||||
|
||||
// ownsTag reports whether a tag belongs to the local user.
|
||||
func (h *Handler) ownsTag(tagID string) bool {
|
||||
var exists bool
|
||||
_ = h.DB.QueryRow(
|
||||
`SELECT EXISTS(SELECT 1 FROM tags WHERE id = ? AND user_id = ?)`,
|
||||
tagID, db.LocalUserID,
|
||||
).Scan(&exists)
|
||||
return exists
|
||||
}
|
||||
|
||||
// tagsByDoc loads the tags for a set of documents in one query and groups them
|
||||
// by doc id. Used to decorate the document list and search results without an
|
||||
// N+1 of per-doc queries. Returns an empty (non-nil) map when ids is empty.
|
||||
func (h *Handler) tagsByDoc(ids []string) (map[string][]db.Tag, error) {
|
||||
out := map[string][]db.Tag{}
|
||||
if len(ids) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Build the IN (?, ?, …) placeholder list.
|
||||
ph := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")
|
||||
args := make([]any, 0, len(ids)+1)
|
||||
for _, id := range ids {
|
||||
args = append(args, id)
|
||||
}
|
||||
args = append(args, db.LocalUserID)
|
||||
|
||||
rows, err := h.DB.Query(
|
||||
`SELECT dt.doc_id, t.id, t.name, t.color
|
||||
FROM document_tags dt
|
||||
JOIN tags t ON t.id = dt.tag_id
|
||||
WHERE dt.doc_id IN (`+ph+`) AND t.user_id = ?
|
||||
ORDER BY t.name COLLATE NOCASE`,
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var docID string
|
||||
var t db.Tag
|
||||
if err := rows.Scan(&docID, &t.ID, &t.Name, &t.Color); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[docID] = append(out[docID], t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
167
internal/docs/tags_test.go
Normal file
167
internal/docs/tags_test.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
)
|
||||
|
||||
// newFullServer mounts the document, tag, and search routers under the same base
|
||||
// paths as the real server (/docs, /tags, /search) so tests can exercise the
|
||||
// cross-router flows (create a doc, tag it, list, search).
|
||||
func newFullServer(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() })
|
||||
|
||||
h := New(database)
|
||||
r := chi.NewRouter()
|
||||
r.Mount("/docs", h.Routes())
|
||||
r.Mount("/tags", h.TagRoutes())
|
||||
r.Mount("/search", h.SearchRoutes())
|
||||
return r
|
||||
}
|
||||
|
||||
// createDoc makes a document with the given title/body and returns its id.
|
||||
func createDoc(t *testing.T, srv http.Handler, title, text string) string {
|
||||
t.Helper()
|
||||
rec := do(t, srv, http.MethodPost, "/docs", "")
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create doc: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
var d db.Document
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &d)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"title": title, "content": "{}", "content_text": text, "word_count": 1,
|
||||
})
|
||||
rec = do(t, srv, http.MethodPut, "/docs/"+d.ID, string(body))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("save doc: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
return d.ID
|
||||
}
|
||||
|
||||
func TestTagLifecycle(t *testing.T) {
|
||||
srv := newFullServer(t)
|
||||
|
||||
// Empty roster serializes as [].
|
||||
rec := do(t, srv, http.MethodGet, "/tags", "")
|
||||
if rec.Code != http.StatusOK || rec.Body.String() == "null\n" {
|
||||
t.Fatalf("empty tags: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
// Create a tag.
|
||||
rec = do(t, srv, http.MethodPost, "/tags", `{"name":"日记","color":"lavender"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create tag: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
var tag db.Tag
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &tag)
|
||||
if tag.ID == "" || tag.Name != "日记" || tag.Color != "lavender" {
|
||||
t.Fatalf("bad created tag: %+v", tag)
|
||||
}
|
||||
|
||||
// Re-creating the same name is idempotent (returns the same id, keeps color).
|
||||
rec = do(t, srv, http.MethodPost, "/tags", `{"name":"日记","color":"sky"}`)
|
||||
var again db.Tag
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &again)
|
||||
if again.ID != tag.ID || again.Color != "lavender" {
|
||||
t.Fatalf("create not idempotent: %+v vs %+v", again, tag)
|
||||
}
|
||||
|
||||
// Unknown color is coerced to rose.
|
||||
rec = do(t, srv, http.MethodPost, "/tags", `{"name":"work","color":"chartreuse"}`)
|
||||
var work db.Tag
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &work)
|
||||
if work.Color != "rose" {
|
||||
t.Fatalf("unknown color not coerced: %+v", work)
|
||||
}
|
||||
|
||||
// Empty name is rejected.
|
||||
if rec = do(t, srv, http.MethodPost, "/tags", `{"name":" "}`); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("empty name should be 400: %d", rec.Code)
|
||||
}
|
||||
|
||||
// Recolor + rename via PATCH.
|
||||
rec = do(t, srv, http.MethodPatch, "/tags/"+tag.ID, `{"color":"honey"}`)
|
||||
var recolored db.Tag
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &recolored)
|
||||
if recolored.Color != "honey" || recolored.Name != "日记" {
|
||||
t.Fatalf("patch clobbered/failed: %+v", recolored)
|
||||
}
|
||||
|
||||
// Assign both tags to a document.
|
||||
docID := createDoc(t, srv, "My Journal", "today was a good day")
|
||||
if rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/tags", `{"tag_id":"`+tag.ID+`"}`); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("assign tag: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
// Re-assigning is idempotent.
|
||||
if rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/tags", `{"tag_id":"`+tag.ID+`"}`); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("re-assign tag: %d", rec.Code)
|
||||
}
|
||||
if rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/tags", `{"tag_id":"`+work.ID+`"}`); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("assign 2nd tag: %d", rec.Code)
|
||||
}
|
||||
|
||||
// The doc list now carries both tags, ordered by name.
|
||||
rec = do(t, srv, http.MethodGet, "/docs", "")
|
||||
var docs []docSummary
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &docs)
|
||||
if len(docs) != 1 || len(docs[0].Tags) != 2 {
|
||||
t.Fatalf("doc list tags: %+v", docs)
|
||||
}
|
||||
|
||||
// Tag list reports doc counts.
|
||||
rec = do(t, srv, http.MethodGet, "/tags", "")
|
||||
var roster []db.Tag
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &roster)
|
||||
var found bool
|
||||
for _, rt := range roster {
|
||||
if rt.ID == tag.ID {
|
||||
found = true
|
||||
if rt.DocCount != 1 {
|
||||
t.Fatalf("expected doc_count 1, got %d", rt.DocCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("tag missing from roster: %+v", roster)
|
||||
}
|
||||
|
||||
// Unassign one tag.
|
||||
if rec = do(t, srv, http.MethodDelete, "/docs/"+docID+"/tags/"+work.ID, ""); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("unassign: %d", rec.Code)
|
||||
}
|
||||
rec = do(t, srv, http.MethodGet, "/docs", "")
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &docs)
|
||||
if len(docs[0].Tags) != 1 || docs[0].Tags[0].ID != tag.ID {
|
||||
t.Fatalf("after unassign: %+v", docs[0].Tags)
|
||||
}
|
||||
|
||||
// Deleting a tag cascades its assignments away.
|
||||
if rec = do(t, srv, http.MethodDelete, "/tags/"+tag.ID, ""); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete tag: %d", rec.Code)
|
||||
}
|
||||
rec = do(t, srv, http.MethodGet, "/docs", "")
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &docs)
|
||||
if len(docs[0].Tags) != 0 {
|
||||
t.Fatalf("tag delete did not cascade: %+v", docs[0].Tags)
|
||||
}
|
||||
|
||||
// Assigning a non-existent tag 404s; tagging a non-existent doc 404s.
|
||||
if rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/tags", `{"tag_id":"nope"}`); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("assign unknown tag: %d", rec.Code)
|
||||
}
|
||||
if rec = do(t, srv, http.MethodPost, "/docs/nope/tags", `{"tag_id":"`+work.ID+`"}`); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("assign to unknown doc: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user