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

@@ -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. - 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. - **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) ### Deferred (post-v1-local)
- [ ] Authentik OIDC auth + session middleware ← **on hold: user doing foundational work first** - [ ] Authentik OIDC auth + session middleware ← **on hold: user doing foundational work first**
- [ ] Copyleaks Tier-2 + webhook HMAC - [ ] 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) ### 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) - [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 ## 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 (07) + post-v1 product (810) 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 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-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. - 2026-06-25: Spec reviewed & amended (voice/grammar decoupled, ctx cap, routes, voice DB type, honey color, string-anchoring). Build plan created.

View File

@@ -62,10 +62,15 @@ func main() {
// Document CRUD plus the doc-scoped checkpoint/list suggestion routes, // Document CRUD plus the doc-scoped checkpoint/list suggestion routes,
// both under /api/docs. // both under /api/docs.
docsRouter := docs.New(database).Routes() docsHandler := docs.New(database)
docsRouter := docsHandler.Routes()
sug.RegisterDocRoutes(docsRouter) sug.RegisterDocRoutes(docsRouter)
api.Mount("/docs", 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. // Per-suggestion actions (accept/dismiss) under /api/suggestions.
api.Mount("/suggestions", sug.Routes()) api.Mount("/suggestions", sug.Routes())

View File

@@ -199,6 +199,67 @@ CREATE TABLE document_versions (
); );
CREATE INDEX idx_versions_doc_id ON document_versions(doc_id, created_at DESC); 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 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. // 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; // 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) r.Delete("/{id}", h.delete)
h.versionRoutes(r) h.versionRoutes(r)
h.exportRoutes(r) h.exportRoutes(r)
h.registerTagRoutes(r)
return r return r
} }
// docSummary is the lightweight shape returned by the list endpoint — enough to // 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 { type docSummary struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`
WordCount int `json:"word_count"` WordCount int `json:"word_count"`
UpdatedAt string `json:"updated_at"` 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) { func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
rows, err := h.DB.Query( rows, err := h.DB.Query(
`SELECT id, title, word_count, updated_at `SELECT id, title, word_count, updated_at
@@ -64,6 +68,7 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
defer rows.Close() defer rows.Close()
out := []docSummary{} // non-nil so an empty list serializes as [] not null out := []docSummary{} // non-nil so an empty list serializes as [] not null
ids := []string{}
for rows.Next() { for rows.Next() {
var d docSummary var d docSummary
if err := rows.Scan(&d.ID, &d.Title, &d.WordCount, &d.UpdatedAt); err != nil { 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 return
} }
out = append(out, d) out = append(out, d)
ids = append(ids, d.ID)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
serverError(w, err) serverError(w, err)
return 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) 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)
}
}

View File

@@ -1,8 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react' 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 { useAutoSave } from './hooks/useAutoSave'
import { useCheckpoint } from './hooks/useCheckpoint' import { useCheckpoint } from './hooks/useCheckpoint'
import { useSpellChecker } from './hooks/useSpellChecker' import { useSpellChecker } from './hooks/useSpellChecker'
import { useTags } from './hooks/useTags'
import { DocList } from './components/DocList/DocList' import { DocList } from './components/DocList/DocList'
import { EditorCore, type EditorChange } from './components/Editor/EditorCore' import { EditorCore, type EditorChange } from './components/Editor/EditorCore'
import { ToneSelect } from './components/Editor/ToneSelect' 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 // Distraction-free mode: entered on editor focus, collapses the doc-list
// sidebar. Escape or a click outside the editor canvas restores it. // sidebar. Escape or a click outside the editor canvas restores it.
const [focusMode, setFocusMode] = useState(false) 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<HTMLDivElement>(null) const canvasRef = useRef<HTMLDivElement>(null)
// Monotonic counters the companion watches to react to writing + accepts. // Monotonic counters the companion watches to react to writing + accepts.
const [editTick, setEditTick] = useState(0) const [editTick, setEditTick] = useState(0)
@@ -59,20 +63,67 @@ export default function App() {
suggestions, suggestions,
checking, checking,
voicing, voicing,
llmDown,
schedule: scheduleCheckpoint, schedule: scheduleCheckpoint,
runVoice, runVoice,
removeSuggestion, removeSuggestion,
} = useCheckpoint(currentDoc?.id ?? null) } = useCheckpoint(currentDoc?.id ?? null)
// Browser-side spell checker — loads the en-US dictionary once per session. // Browser-side spell checker — loads the en-US dictionary once per session.
const { checker: spellChecker, addWord } = useSpellChecker() 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). // Patch a summary in the sidebar list (optimistic title / word-count updates).
const patchSummary = useCallback((id: string, patch: Partial<DocSummary>) => { const patchSummary = useCallback((id: string, patch: Partial<DocSummary>) => {
setDocs((prev) => prev.map((d) => (d.id === id ? { ...d, ...patch } : d))) 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( const openDoc = useCallback(
async (id: string) => { 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. // Decide the leaving doc's fate before any await mutates state.
const leaving = currentDocRef.current const leaving = currentDocRef.current
const leavingBlank = isBlankDraft() const leavingBlank = isBlankDraft()
@@ -103,7 +154,7 @@ export default function App() {
let list = await api.listDocs() let list = await api.listDocs()
if (list.length === 0) { if (list.length === 0) {
const fresh = await api.createDoc() 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) setDocs(list)
setCurrentDoc(fresh) setCurrentDoc(fresh)
setTitle(fresh.title) setTitle(fresh.title)
@@ -129,7 +180,7 @@ export default function App() {
await saveNow() await saveNow()
const fresh = await api.createDoc() const fresh = await api.createDoc()
setDocs((prev) => [ 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, ...prev,
]) ])
setCurrentDoc(fresh) 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" className="petal-no-print flex h-12 shrink-0 items-center gap-2 px-5"
style={{ borderBottom: '1px solid var(--color-border)' }} style={{ borderBottom: '1px solid var(--color-border)' }}
> >
<button
type="button"
aria-label="Toggle document list"
onClick={() => {
setFocusMode(false)
setDrawerOpen((v) => !v)
}}
className="petal-sidebar-toggle petal-tap-sm -ml-2 mr-1 items-center justify-center text-xl"
style={{ borderRadius: 'var(--radius-pill)', width: 36, height: 36, color: 'var(--color-plum)' }}
>
</button>
<span className="text-xl">🌸</span> <span className="text-xl">🌸</span>
<span className="text-lg font-extrabold text-plum">Petal</span> <span className="text-lg font-extrabold text-plum">Petal</span>
</header> </header>
<div className="flex min-h-0 flex-1"> <div className="relative flex min-h-0 flex-1">
<div <div
className={`petal-no-print petal-sidebar shrink-0${focusMode ? ' petal-sidebar-hidden' : ''}`} className={`petal-no-print petal-sidebar shrink-0${focusMode ? ' petal-sidebar-hidden' : ''}${drawerOpen ? ' petal-drawer-open' : ''}`}
> >
<DocList <DocList
docs={docs} docs={docs}
roster={tagRoster}
selectedId={currentDoc?.id ?? null} selectedId={currentDoc?.id ?? null}
onSelect={openDoc} onSelect={openDoc}
onCreate={handleCreate} onCreate={handleCreate}
onDelete={handleDelete} onDelete={handleDelete}
onToggleTag={handleToggleTag}
onCreateTag={handleCreateTag}
/> />
</div> </div>
<div
className={`petal-no-print petal-scrim${drawerOpen ? ' petal-scrim-show' : ''}`}
onClick={() => setDrawerOpen(false)}
aria-hidden
/>
<main className="flex min-w-0 flex-1 flex-col"> <main className="flex min-w-0 flex-1 flex-col">
{currentDoc ? ( {currentDoc ? (
<> <>
@@ -346,6 +418,7 @@ export default function App() {
saveStatus={status} saveStatus={status}
checking={checking} checking={checking}
voicing={voicing} voicing={voicing}
llmDown={llmDown}
/> />
</div> </div>
</> </>

View File

@@ -1,11 +1,34 @@
// Thin fetch wrappers for the Petal backend. Everything lives under /api, which // 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. // 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 { export interface DocSummary {
id: string id: string
title: string title: string
word_count: number word_count: number
updated_at: string 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 { export interface Document {
@@ -155,11 +178,78 @@ export const api = {
body: JSON.stringify({ text, style }), 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<SearchResult[]>(`/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<Tag[]>('/tags'),
createTag: (name: string, color: TagColor) =>
req<Tag>('/tags', { method: 'POST', body: JSON.stringify({ name, color }) }),
updateTag: (id: string, patch: { name?: string; color?: TagColor }) =>
req<Tag>(`/tags/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }),
deleteTag: (id: string) => req<void>(`/tags/${id}`, { method: 'DELETE' }),
assignTag: (docId: string, tagId: string) =>
req<void>(`/docs/${docId}/tags`, { method: 'POST', body: JSON.stringify({ tag_id: tagId }) }),
unassignTag: (docId: string, tagId: string) =>
req<void>(`/docs/${docId}/tags/${tagId}`, { method: 'DELETE' }),
// Current deployed build id — changes whenever a new frontend ships. The // 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. // app polls this to offer a refresh. Bypasses any cache so the answer is live.
version: () => req<{ version: string }>('/version', { cache: 'no-store' }), 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 — // 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. // the server is stateless and re-injects the suggestion context every request.
export interface ChatMessage { export interface ChatMessage {

View File

@@ -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 { DocListItem } from './DocListItem'
import { SearchBox } from './SearchBox'
import { TagChip } from './TagChip'
interface Props { interface Props {
docs: DocSummary[] docs: DocSummary[]
roster: Tag[]
selectedId: string | null selectedId: string | null
onSelect: (id: string) => void onSelect: (id: string) => void
onCreate: () => void onCreate: () => void
onDelete: (id: string) => 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. // DocList is the sidebar: a cross-document search box, a tag filter bar, the New
export function DocList({ docs, selectedId, onSelect, onCreate, onDelete }: Props) { // 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<string | null>(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 ( return (
<aside <aside
className="flex h-full w-[260px] flex-col gap-2 p-3" className="flex h-full w-[280px] flex-col gap-2 p-3"
style={{ borderRight: '1px solid var(--color-border)' }} style={{ borderRight: '1px solid var(--color-border)' }}
> >
<SearchBox onSelect={onSelect} />
{usedTags.length > 0 && (
<div className="flex flex-wrap gap-1 pb-0.5">
{usedTags.map((t) => (
<TagChip
key={t.id}
tag={t}
count={t.doc_count}
active={activeFilter === t.id}
onClick={() => setFilterId((cur) => (cur === t.id ? null : t.id))}
/>
))}
</div>
)}
<button <button
type="button" type="button"
onClick={onCreate} onClick={onCreate}
className="flex items-center justify-center gap-2 py-2.5 text-sm font-bold text-white" className="petal-tap flex items-center justify-center gap-2 text-sm font-bold text-white"
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)' }} style={{ minHeight: 44, borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')} onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')} onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
> >
@@ -28,18 +73,21 @@ export function DocList({ docs, selectedId, onSelect, onCreate, onDelete }: Prop
</button> </button>
<div className="flex flex-1 flex-col gap-0.5 overflow-y-auto"> <div className="flex flex-1 flex-col gap-0.5 overflow-y-auto">
{docs.length === 0 ? ( {filtered.length === 0 ? (
<p className="px-3 py-6 text-center text-xs" style={{ color: 'var(--color-muted)' }}> <p className="px-3 py-6 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
No documents yet. {activeFilter ? 'No documents with this tag.' : 'No documents yet.'}
</p> </p>
) : ( ) : (
docs.map((doc) => ( filtered.map((doc) => (
<DocListItem <DocListItem
key={doc.id} key={doc.id}
doc={doc} doc={doc}
active={doc.id === selectedId} active={doc.id === selectedId}
roster={roster}
onSelect={() => onSelect(doc.id)} onSelect={() => onSelect(doc.id)}
onDelete={() => onDelete(doc.id)} onDelete={() => onDelete(doc.id)}
onToggleTag={onToggleTag}
onCreateTag={onCreateTag}
/> />
)) ))
)} )}

View File

@@ -1,25 +1,43 @@
import type { DocSummary } from '../../api/client' import { useState } from 'react'
import type { DocSummary, Tag, TagColor } from '../../api/client'
import { TagChip } from './TagChip'
import { TagPicker } from './TagPicker'
interface Props { interface Props {
doc: DocSummary doc: DocSummary
active: boolean active: boolean
roster: Tag[]
onSelect: () => void onSelect: () => void
onDelete: () => void onDelete: () => void
onToggleTag: (docId: string, tag: Tag) => void
onCreateTag: (docId: string, name: string, color: TagColor) => void
} }
// One row in the sidebar: title + word count, a delete affordance on hover, and // One row in the sidebar: title + word count, its tag chips, a tag affordance and
// a rose wash when it's the open document. // a delete affordance on hover, and a rose wash when it's the open document.
export function DocListItem({ doc, active, onSelect, onDelete }: Props) { export function DocListItem({
doc,
active,
roster,
onSelect,
onDelete,
onToggleTag,
onCreateTag,
}: Props) {
const [picking, setPicking] = useState(false)
const assignedIds = new Set(doc.tags.map((t) => t.id))
return ( return (
<div <div
onClick={onSelect} onClick={onSelect}
className="group flex cursor-pointer items-center gap-2 px-3 py-2.5" className="group relative flex cursor-pointer flex-col gap-1 px-3 py-2.5"
style={{ style={{
borderRadius: 'var(--radius-card)', borderRadius: 'var(--radius-card)',
background: active ? 'var(--color-surface)' : 'transparent', background: active ? 'var(--color-surface)' : 'transparent',
boxShadow: active ? 'var(--shadow-soft)' : 'none', boxShadow: active ? 'var(--shadow-soft)' : 'none',
}} }}
> >
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div <div
className="truncate text-sm font-semibold" className="truncate text-sm font-semibold"
@@ -31,6 +49,22 @@ export function DocListItem({ doc, active, onSelect, onDelete }: Props) {
{doc.word_count} {doc.word_count === 1 ? 'word' : 'words'} {doc.word_count} {doc.word_count === 1 ? 'word' : 'words'}
</div> </div>
</div> </div>
{/* Tag + delete affordances: always reachable on touch (no hover), fade in
on hover for the pointer experience. */}
<button
type="button"
aria-label="Tag document"
title="Tags"
onClick={(e) => {
e.stopPropagation()
setPicking((v) => !v)
}}
className="petal-row-action flex h-7 w-7 shrink-0 items-center justify-center text-sm"
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
>
🏷
</button>
<button <button
type="button" type="button"
aria-label="Delete document" aria-label="Delete document"
@@ -38,11 +72,30 @@ export function DocListItem({ doc, active, onSelect, onDelete }: Props) {
e.stopPropagation() e.stopPropagation()
onDelete() onDelete()
}} }}
className="flex h-6 w-6 shrink-0 items-center justify-center text-base opacity-0 group-hover:opacity-100" className="petal-row-action flex h-7 w-7 shrink-0 items-center justify-center text-base"
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }} style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
> >
× ×
</button> </button>
{picking && (
<TagPicker
roster={roster}
assignedIds={assignedIds}
onToggle={(t) => onToggleTag(doc.id, t)}
onCreate={(name, color) => onCreateTag(doc.id, name, color)}
onClose={() => setPicking(false)}
/>
)}
</div>
{doc.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{doc.tags.map((t) => (
<TagChip key={t.id} tag={t} onRemove={() => onToggleTag(doc.id, t)} />
))}
</div>
)}
</div> </div>
) )
} }

View File

@@ -0,0 +1,145 @@
import { useEffect, useRef, useState } from 'react'
import { api, splitSnippet, type SearchResult } from '../../api/client'
interface Props {
// Called when a result is chosen — opens that document.
onSelect: (id: string) => void
}
const DEBOUNCE_MS = 220
// SearchBox is the cross-document finder at the top of the sidebar. Typing
// debounces a full-text query; results drop in below with a highlighted snippet.
// Clearing (× or empty) returns the sidebar to the normal document list.
export function SearchBox({ onSelect }: Props) {
const [q, setQ] = useState('')
const [results, setResults] = useState<SearchResult[] | null>(null)
const [busy, setBusy] = useState(false)
const runRef = useRef(0)
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
useEffect(() => {
clearTimeout(debounceRef.current)
const term = q.trim()
if (!term) {
setResults(null)
setBusy(false)
return
}
setBusy(true)
const run = ++runRef.current
debounceRef.current = setTimeout(async () => {
try {
const hits = await api.search(term)
if (run === runRef.current) setResults(hits)
} catch (err) {
console.error('search failed', err)
if (run === runRef.current) setResults([])
} finally {
if (run === runRef.current) setBusy(false)
}
}, DEBOUNCE_MS)
return () => clearTimeout(debounceRef.current)
}, [q])
const clear = () => {
setQ('')
setResults(null)
}
return (
<div className="flex flex-col gap-1.5">
<div className="relative">
<span
aria-hidden
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm"
style={{ color: 'var(--color-muted)' }}
>
🔍
</span>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="搜索 · Search"
aria-label="Search documents"
className="petal-tap w-full bg-transparent pl-9 pr-8 text-sm focus:outline-none"
style={{
height: 40,
borderRadius: 'var(--radius-pill)',
border: '1px solid var(--color-border)',
background: 'var(--color-surface)',
color: 'var(--color-plum)',
}}
/>
{q && (
<button
type="button"
aria-label="Clear search"
onClick={clear}
className="absolute right-2 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center"
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
>
×
</button>
)}
</div>
{results !== null && (
<div className="petal-search-results flex flex-col gap-0.5">
{busy && results.length === 0 ? (
<p className="px-2 py-3 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
· Searching
</p>
) : results.length === 0 ? (
<p className="px-2 py-3 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
· No matches
</p>
) : (
results.map((r) => (
<button
key={r.id}
type="button"
onClick={() => onSelect(r.id)}
className="petal-tap flex flex-col items-start gap-0.5 px-3 py-2 text-left"
style={{ borderRadius: 'var(--radius-card)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<span
className="w-full truncate text-sm font-bold"
style={{ color: 'var(--color-plum)' }}
>
{r.title || 'Untitled'}
</span>
{r.snippet && (
<span
className="line-clamp-2 text-xs"
style={{ color: 'var(--color-muted)', lineHeight: 1.4 }}
>
{splitSnippet(r.snippet).map((seg, i) =>
seg.hit ? (
<mark
key={i}
style={{
background: 'color-mix(in srgb, var(--color-accent) 35%, white)',
color: 'var(--color-plum)',
borderRadius: 3,
padding: '0 1px',
}}
>
{seg.text}
</mark>
) : (
<span key={i}>{seg.text}</span>
),
)}
</span>
)}
</button>
))
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,75 @@
import { tagColorVar, type Tag } from '../../api/client'
interface Props {
tag: Tag
// When set, renders an interactive chip (filter toggle); `active` fills it.
onClick?: () => void
active?: boolean
// When set, shows a small × to remove the tag (used on document rows).
onRemove?: () => void
// Optional trailing count (used in the filter bar).
count?: number
size?: 'sm' | 'md'
}
// TagChip is the pill used everywhere a tag appears: on document rows, in the
// filter bar, and in the assign popover. The palette color tints a soft wash; a
// filled variant marks an active filter. Keeps tap targets comfortable on touch.
export function TagChip({ tag, onClick, active, onRemove, count, size = 'sm' }: Props) {
const color = tagColorVar(tag.color)
const interactive = !!onClick
const pad = size === 'md' ? '0.3rem 0.7rem' : '0.12rem 0.5rem'
const font = size === 'md' ? '0.8rem' : '0.7rem'
return (
<span
onClick={
onClick
? (e) => {
e.stopPropagation()
onClick()
}
: undefined
}
className={`petal-tag-chip inline-flex items-center gap-1 whitespace-nowrap font-bold${interactive ? ' cursor-pointer' : ''}`}
style={{
borderRadius: 'var(--radius-pill)',
padding: pad,
fontSize: font,
lineHeight: 1.2,
background: active ? color : 'color-mix(in srgb, ' + color + ' 22%, white)',
color: active ? 'white' : 'var(--color-plum)',
border: `1px solid ${active ? color : 'color-mix(in srgb, ' + color + ' 45%, white)'}`,
}}
>
<span
aria-hidden
className="inline-block shrink-0"
style={{
width: 7,
height: 7,
borderRadius: 'var(--radius-pill)',
background: active ? 'white' : color,
}}
/>
{tag.name}
{typeof count === 'number' && (
<span style={{ opacity: 0.7, fontWeight: 700 }}>{count}</span>
)}
{onRemove && (
<button
type="button"
aria-label={`Remove ${tag.name}`}
onClick={(e) => {
e.stopPropagation()
onRemove()
}}
className="ml-0.5 inline-flex items-center justify-center"
style={{ color: 'inherit', opacity: 0.65, fontSize: '0.85em', lineHeight: 1 }}
>
×
</button>
)}
</span>
)
}

View File

@@ -0,0 +1,139 @@
import { useEffect, useRef, useState } from 'react'
import { tagColorVar, type Tag, type TagColor } from '../../api/client'
const COLORS: TagColor[] = ['rose', 'mint', 'peach', 'lavender', 'sky', 'honey']
interface Props {
roster: Tag[]
assignedIds: Set<string>
onToggle: (tag: Tag) => void
onCreate: (name: string, color: TagColor) => void
onClose: () => void
}
// TagPicker is the small popover for managing one document's tags: tap an
// existing tag to attach/detach it, or type a new name (with a color swatch) to
// create-and-attach. Closes on outside click or Escape.
export function TagPicker({ roster, assignedIds, onToggle, onCreate, onClose }: Props) {
const [name, setName] = useState('')
const [color, setColor] = useState<TagColor>('rose')
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const onDown = (e: PointerEvent) => {
if (!ref.current?.contains(e.target as Node)) onClose()
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
// Defer so the opening click doesn't immediately close it.
const id = setTimeout(() => document.addEventListener('pointerdown', onDown), 0)
document.addEventListener('keydown', onKey)
return () => {
clearTimeout(id)
document.removeEventListener('pointerdown', onDown)
document.removeEventListener('keydown', onKey)
}
}, [onClose])
const submit = () => {
const n = name.trim()
if (!n) return
onCreate(n, color)
setName('')
}
return (
<div
ref={ref}
onClick={(e) => e.stopPropagation()}
className="petal-tag-picker absolute z-20 flex w-60 flex-col gap-2 p-3"
style={{
top: 'calc(100% + 4px)',
right: 0,
borderRadius: 'var(--radius-card)',
background: 'var(--color-surface)',
boxShadow: 'var(--shadow-soft)',
border: '1px solid var(--color-border)',
}}
>
<div className="text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
· Tags
</div>
{roster.length > 0 && (
<div className="flex max-h-40 flex-wrap gap-1.5 overflow-y-auto">
{roster.map((t) => {
const on = assignedIds.has(t.id)
const c = tagColorVar(t.color)
return (
<button
key={t.id}
type="button"
onClick={() => onToggle(t)}
className="petal-tap inline-flex items-center gap-1 whitespace-nowrap text-xs font-bold"
style={{
borderRadius: 'var(--radius-pill)',
padding: '0.25rem 0.6rem',
background: on ? c : 'color-mix(in srgb, ' + c + ' 18%, white)',
color: on ? 'white' : 'var(--color-plum)',
border: `1px solid ${on ? c : 'color-mix(in srgb, ' + c + ' 40%, white)'}`,
}}
>
{on && <span aria-hidden></span>}
{t.name}
</button>
)
})}
</div>
)}
<div className="flex flex-col gap-1.5 pt-1" style={{ borderTop: '1px solid var(--color-border)' }}>
<div className="flex items-center gap-1.5">
{COLORS.map((c) => (
<button
key={c}
type="button"
aria-label={`Color ${c}`}
onClick={() => setColor(c)}
className="petal-tap-sm"
style={{
width: 18,
height: 18,
borderRadius: 'var(--radius-pill)',
background: tagColorVar(c),
border: color === c ? '2px solid var(--color-plum)' : '2px solid transparent',
}}
/>
))}
</div>
<div className="flex items-center gap-1.5">
<input
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') submit()
}}
placeholder="新标签 · New tag"
aria-label="New tag name"
className="petal-tap min-w-0 flex-1 bg-transparent px-2.5 text-sm focus:outline-none"
style={{
height: 34,
borderRadius: 'var(--radius-input)',
border: '1px solid var(--color-border)',
color: 'var(--color-plum)',
}}
/>
<button
type="button"
onClick={submit}
className="petal-tap-sm shrink-0 px-3 text-sm font-bold text-white"
style={{ height: 34, borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)' }}
>
</button>
</div>
</div>
</div>
)
}

View File

@@ -362,8 +362,17 @@ export function EditorCore({
// Click a red-underlined word to open its spelling popover, anchored under the // Click a red-underlined word to open its spelling popover, anchored under the
// word. posAtCoords→wordAt resolves the exact PM span (robust to the same // word. posAtCoords→wordAt resolves the exact PM span (robust to the same
// misspelling appearing elsewhere); nspell supplies the corrections. // misspelling appearing elsewhere); nspell supplies the corrections.
//
// Tapping an AI-suggestion highlight also opens its card here — on touch there's
// no hover, so the tap is the only way in (mouse users still get hover).
const handleSpellClick = useCallback( const handleSpellClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
const suggestionEl = (e.target as HTMLElement).closest('.petal-suggestion') as HTMLElement | null
if (suggestionEl) {
const id = suggestionEl.getAttribute('data-suggestion-id')
if (id) openCardFor(id, suggestionEl)
return
}
if (!editor || !spellChecker) return if (!editor || !spellChecker) return
const target = (e.target as HTMLElement).closest('.petal-misspelling') as HTMLElement | null const target = (e.target as HTMLElement).closest('.petal-misspelling') as HTMLElement | null
if (!target) return if (!target) return
@@ -383,7 +392,7 @@ export function EditorCore({
setWordInfo(null) setWordInfo(null)
setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left }) setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
}, },
[editor, spellChecker, closeCard], [editor, spellChecker, closeCard, openCardFor],
) )
const replaceMisspelling = useCallback( const replaceMisspelling = useCallback(
@@ -634,6 +643,20 @@ export function EditorCore({
return () => document.removeEventListener('mousedown', onDown) return () => document.removeEventListener('mousedown', onDown)
}, [pinned, closeCard]) }, [pinned, closeCard])
// Touch has no hover, so the card is opened by a tap and can't close itself on
// mouse-leave. Whenever a card is open, a pointer-down outside both the card and
// any highlight dismisses it. Excluding `.petal-suggestion` lets a tap on
// another highlight reopen for that one (via the click handler) without flicker.
useEffect(() => {
if (!hover) return
const onDown = (e: PointerEvent) => {
const t = e.target as HTMLElement
if (!t.closest('.petal-suggestion-card') && !t.closest('.petal-suggestion')) closeCard()
}
document.addEventListener('pointerdown', onDown)
return () => document.removeEventListener('pointerdown', onDown)
}, [hover, closeCard])
return ( return (
<div className="flex flex-1 flex-col"> <div className="flex flex-1 flex-col">
<Toolbar editor={editor} onVoiceCheck={onVoiceCheck} voicing={voicing} /> <Toolbar editor={editor} onVoiceCheck={onVoiceCheck} voicing={voicing} />

View File

@@ -11,6 +11,9 @@ interface Props {
checking: boolean checking: boolean
// True while a whole-document voice pass runs — shows a breathing honey dot. // True while a whole-document voice pass runs — shows a breathing honey dot.
voicing: boolean voicing: boolean
// True when Petal can't reach its LLM helper — shows a gentle, reassuring note
// (the writing still saves locally, so this is awareness, not an error).
llmDown: boolean
} }
const SAVE_LABEL: Record<SaveStatus, string> = { const SAVE_LABEL: Record<SaveStatus, string> = {
@@ -24,7 +27,7 @@ const SAVE_LABEL: Record<SaveStatus, string> = {
// StatusBar is the slim footer: word count on the left, save state and the // StatusBar is the slim footer: word count on the left, save state and the
// grammar-checkpoint indicator on the right. The checkpoint dot is a soft rose // grammar-checkpoint indicator on the right. The checkpoint dot is a soft rose
// circle that breathes while a check is in flight (spec → Signature animations). // circle that breathes while a check is in flight (spec → Signature animations).
export function StatusBar({ wordCount, text, saveStatus, checking, voicing }: Props) { export function StatusBar({ wordCount, text, saveStatus, checking, voicing, llmDown }: Props) {
const label = SAVE_LABEL[saveStatus] const label = SAVE_LABEL[saveStatus]
// The expanded stats panel toggles open when the word count is clicked. // The expanded stats panel toggles open when the word count is clicked.
const [statsOpen, setStatsOpen] = useState(false) const [statsOpen, setStatsOpen] = useState(false)
@@ -86,6 +89,20 @@ export function StatusBar({ wordCount, text, saveStatus, checking, voicing }: Pr
</span> </span>
</> </>
)} )}
{llmDown && !checking && !voicing && (
<>
<span aria-hidden>·</span>
<span
className="inline-flex items-center gap-1.5"
title="Petal can't reach its writing helper right now — your text is still saved."
style={{ color: 'var(--color-honey)' }}
>
<span aria-hidden>🌙</span>
<span></span>
<span style={{ opacity: 0.75 }}>· Petal's helper is resting · 文字已保存</span>
</span>
</>
)}
{label && ( {label && (
<> <>
<span aria-hidden>·</span> <span aria-hidden>·</span>

View File

@@ -13,6 +13,11 @@ export function useCheckpoint(docId: string | null) {
const [checking, setChecking] = useState(false) const [checking, setChecking] = useState(false)
// True while a whole-document voice pass is in flight (explicit user action). // True while a whole-document voice pass is in flight (explicit user action).
const [voicing, setVoicing] = useState(false) const [voicing, setVoicing] = useState(false)
// True when the last LLM pass couldn't reach the model (server 502 or network
// error). Drives a gentle, reassuring "helper is resting" note — the writing
// itself still saves fine, so this is awareness, not an error. Cleared on the
// next success or doc switch.
const [llmDown, setLlmDown] = useState(false)
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined) const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const docIdRef = useRef(docId) const docIdRef = useRef(docId)
@@ -30,9 +35,11 @@ export function useCheckpoint(docId: string | null) {
const fresh = await api.checkDoc(id) const fresh = await api.checkDoc(id)
if (run === runRef.current && id === docIdRef.current) { if (run === runRef.current && id === docIdRef.current) {
setSuggestions(fresh) setSuggestions(fresh)
setLlmDown(false)
} }
} catch (err) { } catch (err) {
console.error('checkpoint failed', err) console.error('checkpoint failed', err)
if (run === runRef.current) setLlmDown(true)
} finally { } finally {
if (run === runRef.current) setChecking(false) if (run === runRef.current) setChecking(false)
} }
@@ -50,9 +57,11 @@ export function useCheckpoint(docId: string | null) {
const full = await api.voiceDoc(id) const full = await api.voiceDoc(id)
if (run === runRef.current && id === docIdRef.current) { if (run === runRef.current && id === docIdRef.current) {
setSuggestions(full) setSuggestions(full)
setLlmDown(false)
} }
} catch (err) { } catch (err) {
console.error('voice pass failed', err) console.error('voice pass failed', err)
if (run === runRef.current) setLlmDown(true)
} finally { } finally {
if (run === runRef.current) setVoicing(false) if (run === runRef.current) setVoicing(false)
} }
@@ -72,6 +81,7 @@ export function useCheckpoint(docId: string | null) {
setSuggestions([]) setSuggestions([])
setChecking(false) setChecking(false)
setVoicing(false) setVoicing(false)
setLlmDown(false)
if (!docId) return if (!docId) return
let cancelled = false let cancelled = false
void (async () => { void (async () => {
@@ -94,5 +104,5 @@ export function useCheckpoint(docId: string | null) {
setSuggestions((prev) => prev.filter((s) => s.id !== id)) setSuggestions((prev) => prev.filter((s) => s.id !== id))
}, []) }, [])
return { suggestions, checking, voicing, schedule, runVoice, removeSuggestion } return { suggestions, checking, voicing, llmDown, schedule, runVoice, removeSuggestion }
} }

63
web/src/hooks/useTags.ts Normal file
View File

@@ -0,0 +1,63 @@
import { useCallback, useEffect, useState } from 'react'
import { api, type Tag, type TagColor } from '../api/client'
// useTags owns the user's tag roster (the full set the writer has created, with
// per-tag document counts). Document↔tag assignments live in App alongside the
// document list; this hook is just the roster plus create/recolor/delete and a
// refresh used to keep counts current after an assignment changes.
export function useTags() {
const [tags, setTags] = useState<Tag[]>([])
const refresh = useCallback(async () => {
try {
setTags(await api.listTags())
} catch (err) {
console.error('failed to load tags', err)
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
// Create (or reuse) a tag, returning it. The roster is refreshed so the new
// tag appears with a zero count.
const createTag = useCallback(
async (name: string, color: TagColor): Promise<Tag | null> => {
try {
const tag = await api.createTag(name, color)
await refresh()
return tag
} catch (err) {
console.error('failed to create tag', err)
return null
}
},
[refresh],
)
const recolorTag = useCallback(
async (id: string, color: TagColor) => {
// Optimistic recolor; refresh reconciles.
setTags((prev) => prev.map((t) => (t.id === id ? { ...t, color } : t)))
try {
await api.updateTag(id, { color })
} catch (err) {
console.error('failed to recolor tag', err)
void refresh()
}
},
[refresh],
)
const deleteTag = useCallback(async (id: string) => {
setTags((prev) => prev.filter((t) => t.id !== id))
try {
await api.deleteTag(id)
} catch (err) {
console.error('failed to delete tag', err)
}
}, [])
return { tags, refresh, createTag, recolorTag, deleteTag }
}

View File

@@ -282,6 +282,102 @@ button, a, input {
50% { opacity: 1; } 50% { opacity: 1; }
} }
/* --- Organization & search (Phase 10) --------------------------------------
Tag chips, the search results list, and per-row affordances. The row actions
(tag / delete) fade in on hover for pointer users but are always visible on
touch (no hover) — see the coarse-pointer block below. */
.petal-tag-chip {
transition: background 160ms ease, color 160ms ease, transform 160ms ease;
}
.petal-search-results {
animation: petal-suggestion-in 160ms ease both;
}
.petal-tag-picker {
animation: petal-suggestion-in 160ms ease both;
}
.petal-row-action {
opacity: 0;
transition: opacity 160ms ease, background 160ms ease;
}
.group:hover .petal-row-action,
.petal-row-action:focus-visible {
opacity: 1;
}
.petal-row-action:hover {
background: var(--color-surface-alt);
}
/* Clamp a search snippet to two lines. */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* --- Touch & tablet polish --------------------------------------------------
On coarse pointers (tablets, phones) there's no hover, so make tap targets
comfortable and reveal affordances that would otherwise be hover-only. */
@media (pointer: coarse) {
.petal-tap {
min-height: 44px;
}
.petal-tap-sm {
min-height: 36px;
}
/* Row actions can't rely on hover — keep them visible and roomy. */
.petal-row-action {
opacity: 1;
height: 36px;
width: 36px;
}
/* Bigger, easier-to-hit suggestion/word/tag pills. */
.petal-tag-chip {
padding-top: 0.25rem;
padding-bottom: 0.25rem;
}
}
/* --- Responsive sidebar (narrow screens) ------------------------------------
Below the tablet breakpoint the sidebar becomes an overlay drawer toggled by a
hamburger in the header, instead of a permanent column. A scrim sits behind it.
On wide screens the toggle + scrim are hidden and the sidebar is in-flow. */
.petal-sidebar-toggle {
display: none;
}
.petal-scrim {
display: none;
}
@media (max-width: 768px) {
.petal-sidebar-toggle {
display: inline-flex;
}
.petal-sidebar {
position: fixed;
top: 48px; /* below the 12-height header */
bottom: 0;
left: 0;
z-index: 30;
background: var(--color-bg);
box-shadow: var(--shadow-soft);
}
/* On mobile the sidebar is hidden by default; the drawer-open class slides it
in. (Distraction-free's petal-sidebar-hidden still wins to keep it closed.) */
.petal-sidebar:not(.petal-drawer-open) {
width: 0;
transform: translateX(-24px);
opacity: 0;
pointer-events: none;
}
.petal-scrim.petal-scrim-show {
display: block;
position: fixed;
inset: 48px 0 0 0;
z-index: 20;
background: rgba(61, 46, 57, 0.18);
}
}
/* Print / Save-as-PDF: strip every bit of app chrome and editing decoration so /* Print / Save-as-PDF: strip every bit of app chrome and editing decoration so
only the title and the writing itself reach the page. This is Petal's PDF only the title and the writing itself reach the page. This is Petal's PDF
path — it uses the browser's own fonts, so CJK renders correctly with no path — it uses the browser's own fonts, so CJK renders correctly with no