diff --git a/BUILD_PLAN.md b/BUILD_PLAN.md index 27cca05..9592c8b 100644 --- a/BUILD_PLAN.md +++ b/BUILD_PLAN.md @@ -87,6 +87,14 @@ Multi-session build. **Source of truth for what's done and what's next.** Update - Tests: `lexicon` gloss + Lookup-includes-gloss + inflection/miss; `suggestions` rewrite happy-path (style steering + de-quote asserted), empty→400, unknown-doc→404. go build/vet/test clean, tsc clean, vite build OK. Live smoke vs a fake vLLM (fresh port 8055, throwaway DB; pre-existing dev servers on :8077/:8099 untouched): gloss for `river`/inflected/CJK-empty/nonsense-empty, `word/happy` carries the gloss, rewrite returns text, empty→400, unknown→404, LLM-down→502; new CSS classes present in the built bundle. - **Known limitation**: `Gloss` tries the literal form first (matching defs/syns ordering), so an inflected word that is *itself* a separate ECDICT headword resolves to that entry rather than de-inflecting (e.g. `rivers` → the proper-noun "Rivers" sense, not `river`). The base form always glosses correctly; acceptable. +### Phase 10 — Organization & polish ✅ +- [x] **Cross-document search (FTS5)** — migration `0004` adds a `documents_fts` virtual table over `title` + `content_text` using the **`trigram` tokenizer** (so search works for both English and space-free Chinese; the default unicode61 tokenizer treats a CJK run as one token). Kept in sync by `AFTER INSERT/UPDATE/DELETE` triggers on `documents`, back-filled from existing rows in the migration (verified: pre-existing docs are searchable immediately). `GET /api/search?q=` (`internal/docs/search.go`): queries of ≥3 runes use the FTS index (fast, `ORDER BY rank`); shorter queries fall back to a `LIKE` scan so **2-character Chinese words** (e.g. 公园) still resolve. Snippets are built in **Go** from the original text (clean word boundaries, rune-aware so CJK never splits mid-char), with the match wrapped in `\x01…\x02` sentinels; the client splits on these to highlight without `innerHTML`. Owner-scoped, capped at 50 hits. Frontend `SearchBox` in the sidebar: 220ms-debounced, results with highlighted two-line snippets, click to open. +- [x] **Tags (organize)** — migration `0004` adds `tags` (user-scoped, `UNIQUE(user_id, name)`, `color` = palette key) + `document_tags` join (both sides cascade). `internal/docs/tags.go`: `GET/POST/PATCH/DELETE /api/tags` (create is **idempotent** on name; unknown colors coerced to rose) + `POST /api/docs/:id/tags` / `DELETE /api/docs/:id/tags/:tagId` (owner-validated, idempotent assign). The doc-list and search responses carry each doc's tags (loaded in one `tagsByDoc` query, no N+1). Frontend: `useTags` (roster + counts), `TagChip`, `TagPicker` (assign existing / create-and-attach with a color swatch), tag chips on each doc row, a **filter bar** (client-side filter by tag, shows in-use tags with counts). Colors map to the existing design tokens via `tagColorVar`. +- [x] **Tablet / touch polish** — responsive sidebar: below 768px it becomes an overlay **drawer** toggled by a header hamburger, with a scrim (auto-closes on doc select). `@media (pointer: coarse)` enlarges tap targets (`.petal-tap` ≥44px, `.petal-tap-sm` ≥36px) and reveals the hover-only row actions (tag/delete). **Tap-to-open** for AI-suggestion cards (no hover on touch): a tap on a `.petal-suggestion` opens its card via the editor click handler, and a `pointerdown` outside the card/highlight dismisses it (mouse users keep the hover bridge). +- [x] **Warm LLM-down failure states** — `useCheckpoint` now tracks an `llmDown` flag (set when a check/voice pass hits the server's 502/network path, cleared on the next success or doc switch). The `StatusBar` shows a gentle bilingual note — 🌙 **小助手在休息 · Petal's helper is resting · 文字已保存** — reassuring that the writing still saved locally (saving is independent of the LLM). Rewrite already had a gentle retry from Phase 9. +- Tests: `tags_test.go` (full lifecycle — create/idempotent/color-coerce/rename-recolor/assign/unassign/doc-list inclusion/roster counts/delete-cascade/404s), `search_test.go` (EN FTS, CJK FTS, 2-char CJK LIKE fallback, title-only, case-insensitive, empty/no-match, **edit re-indexes via the update trigger**). go build/vet/test clean, tsc clean, vite build OK. Live smoke vs the binary on a throwaway DB (port 8061, LLM pointed at a dead host): search EN/CJK/2-char all highlighted, tag create+assign+roster-counts+doc-list-tags+delete-cascade, check→502 (warm path), new CSS classes (`petal-tag-chip`/`petal-scrim`/`petal-drawer-open`/`pointer:coarse`) and the 小助手在休息 string present in the served bundle. FTS backfill of pre-existing docs verified separately. +- **Known limitations**: trigram FTS snippets/ranking treat the query as a contiguous phrase (multi-term relevance is substring, not BM25-per-term) — fine for a personal corpus. Search is title+body only (not tag names). Hover gloss and right-click word lookup remain pointer-oriented (long-press contextmenu on touch is browser-dependent); the spelling/suggestion cards and rewrite bubble are fully touch-reachable. + ### Deferred (post-v1-local) - [ ] Authentik OIDC auth + session middleware ← **on hold: user doing foundational work first** - [ ] Copyleaks Tier-2 + webhook HMAC @@ -94,9 +102,10 @@ Multi-session build. **Source of truth for what's done and what's next.** Update ### Next-up (post-v1 product, agreed with user 2026-06-26) - [x] **Phase 9 — ESL superpowers**: inline Chinese gloss on hover/select; "say it more naturally" / tone-rewrite. ✅ (see Phase 9 above) -- [ ] **Phase 10 — organization & polish**: cross-doc search, folders/tags, tablet/touch polish, warm LLM-down failure states. +- [x] **Phase 10 — organization & polish**: cross-doc search, tags, tablet/touch polish, warm LLM-down failure states. ✅ (see Phase 10 above; "tags only" chosen over folders, FTS5 over LIKE) ## Session log +- 2026-06-26: **Phase 10 complete** (organization & polish). Scope confirmed with user: all four areas, **tags** (not folders), **FTS5** search. Backend: migration `0004_tags_and_search` (tags + document_tags + `documents_fts` trigram virtual table with sync triggers + back-fill); `db.Tag` model + color constants; `internal/docs/tags.go` (tag CRUD + idempotent assignment + `tagsByDoc` helper, doc list now carries tags); `internal/docs/search.go` (`GET /api/search`, FTS for ≥3 runes + LIKE fallback for 1-2, Go-built sentinel-highlighted rune-aware snippets, owner-scoped). Mounted `/api/tags` + `/api/search` in main.go. Frontend: `useTags`, `TagChip`/`TagPicker`/`SearchBox`, rewritten `DocList`/`DocListItem` (chips + filter bar + search), `api.search`/tag methods + `splitSnippet`/`tagColorVar`; responsive sidebar drawer (hamburger + scrim, <768px) + `pointer:coarse` tap-target/affordance CSS; tap-to-open + outside-pointerdown-close for suggestion cards (touch); `useCheckpoint` `llmDown` flag → warm bilingual "小助手在休息" StatusBar note. Tests: `tags_test.go`, `search_test.go` (incl. update-trigger re-index). All builds/tests/vet/tsc/vite clean; live smoke vs binary on :8061 (dead LLM host) verified search EN/CJK/2-char, full tag lifecycle, check→502 warm path, bundle contents; FTS backfill of pre-existing docs verified. **All v1 phases (0–7) + post-v1 product (8–10) done.** Remaining: deferred bucket (auth/Copyleaks/deploy), on hold per user. - 2026-06-26: **Phase 9 complete** (ESL superpowers: inline Chinese gloss + tone-rewrite). Decisions confirmed with user: gloss is an **offline EC dictionary** (instant, LLM-down-proof, fits the embedded-lexicon ethos), rewrite is a **selection bubble**. Data: `scripts/build_gloss.py` builds `internal/lexicon/data/gloss.json.gz` from ECDICT (66MB csv → 1.3MB gz, 57k freq-≤50k words, cleaned/trimmed). Backend: `lexicon` gloss map + `Gloss()`/`Result.Gloss` + `GET /api/gloss/{word}`; `llm.RunRewrite` + rewrite prompt/`styleGuidance`; `internal/suggestions/rewrite.go` (`POST /api/docs/:id/rewrite`, stateless, owner-scoped). Frontend: `GlossTip` hover tooltip (350ms delay, reuses `wordAt`, CJK-safe) + gloss line in `WordCard`; `SelectionBubble` + `RewritePreview` wired through `EditorCore` (onMouseMove/onSelectionUpdate, request-token guards, clears on edit/doc-switch); `api.glossWord`/`api.rewriteSelection`; CSS for the three new surfaces (+ print-hidden). Tests added in `lexicon` and `suggestions`. All builds/tests/vet/tsc/vite clean; live smoke vs fake vLLM on :8055 verified gloss + rewrite + 400/404/502 paths. Next: **Phase 10 (organization & polish).** - 2026-06-26: **Phase 8 complete** (Trust foundation: version history + export) + empty-doc fix. Backend: migration `0003_document_versions`; `internal/docs/versions.go` (throttled auto-snapshot wired into `update`, manual snapshot, restore-with-pre_restore, prune to 40 auto, owner-scoped via join) and `internal/docs/export.go` (pure-Go Tiptap-JSON → md/html/txt/docx, no deps, CJK-safe filenames via RFC 5987). `DocumentVersion` model + kind constants. Tests: `versions_test.go`, `export_test.go` (incl. valid-zip docx assertion). Frontend: `api.client` version/export methods; `ExportMenu` + `HistoryPanel` components wired into the title row; `@media print` stylesheet + `.petal-no-print` for the browser PDF path; `editorEpoch` remount on restore. Empty-doc fix in `App.tsx` (blank drafts reuse-on-create + discard-on-leave via refs to dodge stale closures); deleted 2 orphan empties from the live :8099 DB. Multi-session plan agreed: this session = Phase 8; **Phase 9 (ESL gloss + tone-rewrite)** next, then **Phase 10 (search/folders/polish)**; **auth/deploy (was Phase 11) shelved** until user's foundational work lands. All builds/tests/vet clean; live smoke verified the full version+export+restore flow end-to-end. Next: **Phase 9.** - 2026-06-25: Spec reviewed & amended (voice/grammar decoupled, ctx cap, routes, voice DB type, honey color, string-anchoring). Build plan created. diff --git a/cmd/server/main.go b/cmd/server/main.go index 7242b94..bea33cc 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -62,10 +62,15 @@ func main() { // Document CRUD plus the doc-scoped checkpoint/list suggestion routes, // both under /api/docs. - docsRouter := docs.New(database).Routes() + docsHandler := docs.New(database) + docsRouter := docsHandler.Routes() sug.RegisterDocRoutes(docsRouter) api.Mount("/docs", docsRouter) + // Tag management (the roster) and cross-document full-text search. + api.Mount("/tags", docsHandler.TagRoutes()) + api.Mount("/search", docsHandler.SearchRoutes()) + // Per-suggestion actions (accept/dismiss) under /api/suggestions. api.Mount("/suggestions", sug.Routes()) diff --git a/internal/db/db.go b/internal/db/db.go index 3802c1b..f52d644 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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; `, }, } diff --git a/internal/db/models.go b/internal/db/models.go index fee29c1..82f5423 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -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; diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go index 81597aa..ab4bd81 100644 --- a/internal/docs/handlers.go +++ b/internal/docs/handlers.go @@ -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) } diff --git a/internal/docs/search.go b/internal/docs/search.go new file mode 100644 index 0000000..1663222 --- /dev/null +++ b/internal/docs/search.go @@ -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 +} diff --git a/internal/docs/search_test.go b/internal/docs/search_test.go new file mode 100644 index 0000000..3d1e56c --- /dev/null +++ b/internal/docs/search_test.go @@ -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]}) +} diff --git a/internal/docs/tags.go b/internal/docs/tags.go new file mode 100644 index 0000000..15df7ef --- /dev/null +++ b/internal/docs/tags.go @@ -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() +} diff --git a/internal/docs/tags_test.go b/internal/docs/tags_test.go new file mode 100644 index 0000000..52c4aa5 --- /dev/null +++ b/internal/docs/tags_test.go @@ -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) + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 4fbf6c7..07aacbb 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,8 +1,9 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { api, type DocSummary, type Document, type Suggestion } from './api/client' +import { api, type DocSummary, type Document, type Suggestion, type Tag, type TagColor } from './api/client' import { useAutoSave } from './hooks/useAutoSave' import { useCheckpoint } from './hooks/useCheckpoint' import { useSpellChecker } from './hooks/useSpellChecker' +import { useTags } from './hooks/useTags' import { DocList } from './components/DocList/DocList' import { EditorCore, type EditorChange } from './components/Editor/EditorCore' import { ToneSelect } from './components/Editor/ToneSelect' @@ -27,6 +28,9 @@ export default function App() { // Distraction-free mode: entered on editor focus, collapses the doc-list // sidebar. Escape or a click outside the editor canvas restores it. const [focusMode, setFocusMode] = useState(false) + // Mobile drawer: below the tablet breakpoint the sidebar is an overlay toggled + // by the header hamburger. Ignored on wide screens (sidebar is always in-flow). + const [drawerOpen, setDrawerOpen] = useState(false) const canvasRef = useRef(null) // Monotonic counters the companion watches to react to writing + accepts. const [editTick, setEditTick] = useState(0) @@ -59,20 +63,67 @@ export default function App() { suggestions, checking, voicing, + llmDown, schedule: scheduleCheckpoint, runVoice, removeSuggestion, } = useCheckpoint(currentDoc?.id ?? null) // Browser-side spell checker — loads the en-US dictionary once per session. const { checker: spellChecker, addWord } = useSpellChecker() + // The tag roster (with counts). Assignments live on the doc summaries below. + const { tags: tagRoster, refresh: refreshTags, createTag } = useTags() // Patch a summary in the sidebar list (optimistic title / word-count updates). const patchSummary = useCallback((id: string, patch: Partial) => { setDocs((prev) => prev.map((d) => (d.id === id ? { ...d, ...patch } : d))) }, []) + // Attach or detach a tag on a document, updating the sidebar optimistically and + // refreshing the roster so its counts stay current. Tags are kept sorted by + // name to match the server's ordering. + const setDocTag = useCallback( + async (docId: string, tag: Tag, attach: boolean) => { + setDocs((prev) => + prev.map((d) => { + if (d.id !== docId) return d + const without = d.tags.filter((t) => t.id !== tag.id) + const next = attach ? [...without, tag] : without + next.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })) + return { ...d, tags: next } + }), + ) + try { + if (attach) await api.assignTag(docId, tag.id) + else await api.unassignTag(docId, tag.id) + void refreshTags() + } catch (err) { + console.error('tag assignment failed', err) + void refreshTags() + } + }, + [refreshTags], + ) + + const handleToggleTag = useCallback( + (docId: string, tag: Tag) => { + const doc = docs.find((d) => d.id === docId) + const has = doc?.tags.some((t) => t.id === tag.id) ?? false + void setDocTag(docId, tag, !has) + }, + [docs, setDocTag], + ) + + const handleCreateTag = useCallback( + async (docId: string, name: string, color: TagColor) => { + const tag = await createTag(name, color) + if (tag) void setDocTag(docId, tag, true) + }, + [createTag, setDocTag], + ) + const openDoc = useCallback( async (id: string) => { + setDrawerOpen(false) // close the mobile drawer when a doc is chosen // Decide the leaving doc's fate before any await mutates state. const leaving = currentDocRef.current const leavingBlank = isBlankDraft() @@ -103,7 +154,7 @@ export default function App() { let list = await api.listDocs() if (list.length === 0) { const fresh = await api.createDoc() - list = [{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at }] + list = [{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at, tags: [] }] setDocs(list) setCurrentDoc(fresh) setTitle(fresh.title) @@ -129,7 +180,7 @@ export default function App() { await saveNow() const fresh = await api.createDoc() setDocs((prev) => [ - { id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at }, + { id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at, tags: [] }, ...prev, ]) setCurrentDoc(fresh) @@ -265,23 +316,44 @@ export default function App() { className="petal-no-print flex h-12 shrink-0 items-center gap-2 px-5" style={{ borderBottom: '1px solid var(--color-border)' }} > + 🌸 Petal -
+
+
setDrawerOpen(false)} + aria-hidden + /> +
{currentDoc ? ( <> @@ -346,6 +418,7 @@ export default function App() { saveStatus={status} checking={checking} voicing={voicing} + llmDown={llmDown} />
diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 99edcb4..318db17 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,11 +1,34 @@ // Thin fetch wrappers for the Petal backend. Everything lives under /api, which // Vite proxies to the Go server in dev and the binary serves directly in prod. +// A tag's palette key, mapped to a CSS color on the frontend. Mirrors the +// backend TagColor* constants; unknown values render as rose. +export type TagColor = 'rose' | 'mint' | 'peach' | 'lavender' | 'sky' | 'honey' + +export interface Tag { + id: string + name: string + color: TagColor + doc_count?: number // present only in the tag-roster listing +} + export interface DocSummary { id: string title: string word_count: number updated_at: string + tags: Tag[] +} + +// One cross-document search hit. `snippet` is plain text with the matched span +// wrapped in the … sentinels (see splitSnippet) for highlighting. +export interface SearchResult { + id: string + title: string + word_count: number + updated_at: string + snippet: string + tags: Tag[] } export interface Document { @@ -155,11 +178,78 @@ export const api = { body: JSON.stringify({ text, style }), }), + // Cross-document full-text search (title + body). Returns hits with a + // highlighted snippet. Empty query returns []. Encodes the term for the URL. + search: (q: string) => req(`/search?q=${encodeURIComponent(q)}`), + + // Tags. listTags returns the roster with per-tag document counts; createTag is + // idempotent on name (returns the existing tag if it already exists); + // updateTag recolors/renames; deleteTag removes it (assignments cascade). + // assignTag/unassignTag link a tag to one document. + listTags: () => req('/tags'), + createTag: (name: string, color: TagColor) => + req('/tags', { method: 'POST', body: JSON.stringify({ name, color }) }), + updateTag: (id: string, patch: { name?: string; color?: TagColor }) => + req(`/tags/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }), + deleteTag: (id: string) => req(`/tags/${id}`, { method: 'DELETE' }), + assignTag: (docId: string, tagId: string) => + req(`/docs/${docId}/tags`, { method: 'POST', body: JSON.stringify({ tag_id: tagId }) }), + unassignTag: (docId: string, tagId: string) => + req(`/docs/${docId}/tags/${tagId}`, { method: 'DELETE' }), + // Current deployed build id — changes whenever a new frontend ships. The // app polls this to offer a refresh. Bypasses any cache so the answer is live. version: () => req<{ version: string }>('/version', { cache: 'no-store' }), } +// The sentinel characters the server wraps a search match in (\x01 … \x02). Used +// by splitSnippet to render the matched span highlighted. +export const SNIPPET_HL_START = String.fromCharCode(1) +export const SNIPPET_HL_END = String.fromCharCode(2) + +// splitSnippet breaks a server snippet into ordered { text, hit } segments so the +// UI can bold the matched span(s) without dangerously setting innerHTML. +export function splitSnippet(snippet: string): { text: string; hit: boolean }[] { + const out: { text: string; hit: boolean }[] = [] + let rest = snippet + for (;;) { + const start = rest.indexOf(SNIPPET_HL_START) + if (start < 0) { + if (rest) out.push({ text: rest, hit: false }) + break + } + if (start > 0) out.push({ text: rest.slice(0, start), hit: false }) + const end = rest.indexOf(SNIPPET_HL_END, start + 1) + if (end < 0) { + // Unterminated (shouldn't happen) — emit the remainder plainly. + out.push({ text: rest.slice(start + 1), hit: false }) + break + } + out.push({ text: rest.slice(start + 1, end), hit: true }) + rest = rest.slice(end + 1) + } + return out +} + +// tagColorVar maps a tag color key to its CSS custom property. Unknown keys fall +// back to the rose accent, matching the backend's coercion. +export function tagColorVar(color: TagColor | string): string { + switch (color) { + case 'mint': + return 'var(--color-mint)' + case 'peach': + return 'var(--color-peach)' + case 'lavender': + return 'var(--color-lavender)' + case 'sky': + return 'var(--color-sky)' + case 'honey': + return 'var(--color-honey)' + default: + return 'var(--color-accent)' + } +} + // One turn in an Ask Petal conversation. History lives only in the component — // the server is stateless and re-injects the suggestion context every request. export interface ChatMessage { diff --git a/web/src/components/DocList/DocList.tsx b/web/src/components/DocList/DocList.tsx index f4c86b7..c7e6bb1 100644 --- a/web/src/components/DocList/DocList.tsx +++ b/web/src/components/DocList/DocList.tsx @@ -1,26 +1,71 @@ -import type { DocSummary } from '../../api/client' +import { useMemo, useState } from 'react' +import type { DocSummary, Tag, TagColor } from '../../api/client' import { DocListItem } from './DocListItem' +import { SearchBox } from './SearchBox' +import { TagChip } from './TagChip' interface Props { docs: DocSummary[] + roster: Tag[] selectedId: string | null onSelect: (id: string) => void onCreate: () => void onDelete: (id: string) => void + onToggleTag: (docId: string, tag: Tag) => void + onCreateTag: (docId: string, name: string, color: TagColor) => void } -// DocList is the 260px sidebar: the document browser plus a New button. -export function DocList({ docs, selectedId, onSelect, onCreate, onDelete }: Props) { +// DocList is the sidebar: a cross-document search box, a tag filter bar, the New +// button, and the document browser (each row showing its tag chips). +export function DocList({ + docs, + roster, + selectedId, + onSelect, + onCreate, + onDelete, + onToggleTag, + onCreateTag, +}: Props) { + // Active tag filter (null = show all). Cleared automatically if the tag + // disappears from the roster. + const [filterId, setFilterId] = useState(null) + const activeFilter = filterId && roster.some((t) => t.id === filterId) ? filterId : null + + const filtered = useMemo( + () => (activeFilter ? docs.filter((d) => d.tags.some((t) => t.id === activeFilter)) : docs), + [docs, activeFilter], + ) + + // Only surface tags that are actually in use, so the filter bar stays tidy. + const usedTags = useMemo(() => roster.filter((t) => (t.doc_count ?? 0) > 0), [roster]) + return (