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

@@ -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;
`,
},
}

View File

@@ -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;

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)
}

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
}

View 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
View 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
View 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)
}
}