4 Commits

Author SHA1 Message Date
prosolis
f3c4fe2c22 Merge Phase 10: organization & polish (search, tags, touch, warm failures)
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 06:30:21 -07:00
prosolis
9e141e4169 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
2026-06-26 06:29:56 -07:00
prosolis
60eba25fee Phase 9: ESL superpowers — Chinese gloss + tone-rewrite
Inline Chinese gloss (offline) and a "say it more naturally" / tone-rewrite,
the two ESL features for the Mandarin-speaking writer.

Gloss: embedded English→Chinese dictionary (gloss.json.gz, 57k common words
built from ECDICT via scripts/build_gloss.py). lexicon gains Gloss()/Result.Gloss
and a lightweight GET /api/gloss/{word}; the right-click WordCard leads with the
中文; GlossTip shows it on a 350ms hover (reuses wordAt, so CJK is never glossed).
Offline + instant, works with the LLM down.

Rewrite: selecting text pops a SelectionBubble (更自然 + the tone vocabulary);
picking a style calls POST /api/docs/:id/rewrite (llm.RunRewrite, stateless,
owner-scoped) and shows a RewritePreview (original→rewrite, accept/cancel/retry).
Accept applies it in-editor.

Tests added in lexicon and suggestions. go build/vet/test, tsc, vite all clean;
live smoke vs a fake vLLM verified gloss + rewrite + 400/404/502 paths.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 00:07:26 -07:00
prosolis
8e1111d768 Phase 8: version history + export (trust foundation)
Version history: new document_versions table (migration 0003) holding
full-body snapshots that cascade with the doc. Throttled auto-snapshots
on save (>=3min apart, max 40/doc, pruned), explicit manual restore
points, and a pre_restore safety copy taken before each restore so
restoring is itself undoable. Endpoints under /api/docs/:id/versions,
all owner-scoped. Empties and bare renames never snapshot.

Export: pure-Go Tiptap-JSON -> Markdown / HTML / plain-text / docx
(no cgo/pandoc, single-binary intact), CJK-safe with RFC 5987
filenames. docx is a hand-built OOXML zip. PDF is handled client-side
via the browser print dialog + an @media print stylesheet so CJK
renders with the reader's own fonts.

Frontend: ExportMenu (downloads + Print/PDF) and HistoryPanel
(snapshot list, preview, restore) wired into the title row; bilingual
zh-first to match chrome. Restore remounts the editor via editorEpoch.

Stop saving empty docs: blank Untitled drafts now reuse-on-create and
self-discard when navigated away from (refs avoid stale closures).

Tests: versions_test.go, export_test.go (incl. valid-zip docx).
go build/vet/test, tsc, vite build all clean; live end-to-end smoke
verified snapshot/throttle/restore/export.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-25 23:44:15 -07:00
42 changed files with 4577 additions and 76 deletions

View File

@@ -74,12 +74,40 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
- [x] `MisspellCard` popover + EditorCore wiring — soft **rose wavy underline** (`.petal-misspelling`, pastel take on the red squiggle, not classic red). Click a flagged word → `posAtCoords``wordAt` opens a bilingual card ("拼写 · Spelling") with up to 5 nspell corrections as pills (click to replace via `insertContentAt`) + "添加到词典 · Add to dictionary". Closes on outside-pointer-down, doc edit, or doc switch.
- Verified: tsc clean, vite build OK (dict in `dist/dictionaries/en/`), go build/vet clean; live server serves both dict files (200, 3086B aff / 551762B dic); nspell smoke (`helllo→hello`, `recieve→receive`, `写作` untokenized, `NASA` ok, `add()` persists).
### Phase 8 — Trust foundation (version history + export) ✅
- [x] **Version history**`document_versions` table (migration `0003`), full-body snapshots that cascade with the doc. Kinds: `auto` (throttled background, ≥3min apart, max 40/doc, pruned), `manual` (explicit restore point), `pre_restore` (safety copy taken before a restore, so restore is undoable). Snapshot taken post-save in `update` only when a real body came through and content changed (empties + bare renames never snapshot). Endpoints: `GET/POST /api/docs/:id/versions`, `GET /api/docs/:id/versions/:vid`, `POST /api/docs/:id/versions/:vid/restore`. All scoped to the owner via a join on `documents`. `versions_test.go` covers lifecycle/throttle/restore/pre_restore/404.
- [x] **Export** — pure-Go Tiptap-JSON → Markdown / HTML / plain-text / **docx** (`export.go`), no cgo/pandoc, CJK-safe. docx is a hand-built OOXML zip (marks→run props, headings→built-in styles, lists→prefix). RFC 5987 `filename*=UTF-8''` so Chinese titles download cleanly. `GET /api/docs/:id/export?format=`. **PDF is client-side** via the browser print dialog + a `@media print` stylesheet (uses the reader's fonts → CJK for free, no embedded-font bloat). `export_test.go` asserts every format incl. valid-zip docx with CJK.
- [x] **Frontend**`ExportMenu` (download links + Print/PDF) and `HistoryPanel` (slide-over drawer: snapshot list w/ relative-time + kind badge, preview, restore; restore remounts the editor via an `editorEpoch` bump). Both bilingual zh-first, matching chrome. Wired into the title row; `.petal-no-print` strips all chrome for print.
- [x] **Stop saving empty docs** — blank `Untitled` drafts now self-discard: `handleCreate` reuses an existing blank instead of stacking another; `openDoc` deletes the blank doc being left. Cleaned the 2 existing orphan empties from the live DB. (Backend also refuses to snapshot empties.)
- Verified: go build/vet/test + tsc + vite all clean; live smoke on a throwaway binary — auto-snapshot on save, throttle holds at 1, manual snapshot, restore brings back exact text + leaves a `pre_restore`, empty doc → no snapshot, md/docx export with CJK+bold+heading+list, docx validates as "Microsoft Word 2007+".
### Phase 9 — ESL superpowers ✅
- [x] **Inline Chinese gloss (offline)** — embedded English→Chinese dictionary (`internal/lexicon/data/gloss.json.gz`, ~1.3MB, 57k common words built from ECDICT via `scripts/build_gloss.py`: frequency-gated to rank ≤50k, `[网络]`/slang/archaic sense-lines dropped, trimmed to ≤3 senses / 80 chars). `Lexicon` gains a `gloss` map + `Gloss(word)` (same `candidates()` de-inflection as defs/syns); `Result` gains a `Gloss` field. Two surfaces: the right-click **WordCard** now leads with the 中文 gloss, and a new lightweight `GET /api/gloss/{word}` (→ `{word, gloss}`, cached) backs the **hover tooltip**. Offline + instant, works with the LLM down (north-star reliability). Frontend: `GlossTip` (dark pointer-events-none bubble under the resting word; 350ms hover delay; reuses `wordAt` so CJK is never glossed — it's the source language), wired into `EditorCore`'s `onMouseMove`/`onMouseLeave` with a request-token guard, suppressed during selection/preview/other popovers.
- [x] **"Say it more naturally" / tone-rewrite** — selecting text pops a `SelectionBubble` (✨更自然 + the tone vocabulary 学术/专业/轻松/幽默/创意/说服, mirrored from `ToneSelect`/`styleGuidance`). Picking a style calls `POST /api/docs/:id/rewrite` (`{text, style}``{rewrite}`), shown in a `RewritePreview` (original struck-through → rewrite, 用这个/取消, breathing-dot loading, gentle retry on failure). Accept applies it in-editor via `insertContentAt` over the captured PM range. Backend: `llm.RunRewrite` (one-shot Complete, `RewriteMaxRunes` 2000 cap, `cleanRewrite` strips stray wrapping quotes) + `rewriteSystemTemplate`/`styleGuidance` in `prompts.go`; handler in `internal/suggestions/rewrite.go` (owner-scoped 404, 400 on empty/too-long, 502 on LLM-down). **Stateless** — not persisted as a suggestion; the version history captures the resulting doc change.
- 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
- [ ] Authentik OIDC auth + session middleware**on hold: user doing foundational work first**
- [ ] Copyleaks Tier-2 + webhook HMAC
- [ ] Dockerfile, docker-compose, Traefik, deploy to write.parodia.dev
### 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 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 (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 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: **Phase 0 complete.** Go module + chi server, config loader, React/Vite/Tailwind-v4 scaffold with full design tokens, frontend embedded & served by the binary, verified end-to-end. Toolchain: Go 1.24.4, Node 22, npm 10. Next: **Phase 1 (data layer)** — SQLite via modernc, models, seed `local` user.
- 2026-06-25: **Phase 1 complete.** `internal/db` package: modernc.org/sqlite (pulled go toolchain → 1.25), `Open()` does mkdir + WAL/foreign-keys DSN + versioned migration runner + idempotent local-user seed. Models with type/status constants. Wired into `main.go`; tests pass (migrate/seed idempotency, CHECK reject, FK cascade). Verified server boots and writes `petal.db`. Next: **Phase 2 (document CRUD + auto-save)** — first "it works" milestone.

View File

@@ -62,15 +62,24 @@ 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())
// Offline word lookups (definition + synonyms) for the right-click popover.
api.Mount("/word", lexicon.NewHandler().Routes())
// Offline lexicon: full word lookups (gloss + definition + synonyms) for
// the right-click popover, and the lightweight Chinese-only gloss for the
// inline hover/select tooltip. One handler so the datasets load once.
lex := lexicon.NewHandler()
api.Mount("/word", lex.Routes())
api.Mount("/gloss", lex.GlossRoutes())
})
// Everything else: serve the embedded SPA (with index.html fallback for client routing).

View File

@@ -176,6 +176,92 @@ CREATE INDEX idx_plagiarism_doc_id ON plagiarism_reports(doc_id);
name: "0002_document_tone",
stmt: `ALTER TABLE documents ADD COLUMN tone TEXT NOT NULL DEFAULT 'general';`,
},
{
// Version history: point-in-time snapshots of a document's body so a
// bad edit or LLM mishap is always recoverable. `kind` distinguishes
// throttled background snapshots ('auto'), explicit user restore
// points ('manual'), and the safety copy taken right before a restore
// ('pre_restore') so restoring is itself undoable. Snapshots cascade
// with the document. Stored fully (content + content_text) so a
// restore is a plain copy with no re-derivation.
name: "0003_document_versions",
stmt: `
CREATE TABLE document_versions (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
title TEXT NOT NULL,
content TEXT NOT NULL,
content_text TEXT NOT NULL,
word_count INTEGER NOT NULL DEFAULT 0,
kind TEXT NOT NULL DEFAULT 'auto'
CHECK(kind IN ('auto','manual','pre_restore')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
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

@@ -27,6 +27,53 @@ type Document struct {
UpdatedAt time.Time `json:"updated_at"`
}
// DocumentVersion is a point-in-time snapshot of a document's body, captured so
// a writer can recover from a bad edit or an unwanted change. `Content` mirrors
// the document's Tiptap JSON at snapshot time; `Kind` records why it was taken
// (see the kind constants). List responses omit the heavy Content/ContentText
// fields (the `omitempty`-friendly zero strings) and load them only on preview
// or restore.
type DocumentVersion struct {
ID string `json:"id"`
DocID string `json:"doc_id"`
Title string `json:"title"`
Content string `json:"content,omitempty"` // Tiptap JSON; omitted in list view
ContentText string `json:"content_text,omitempty"` // plain text; omitted in list view
WordCount int `json:"word_count"`
Kind string `json:"kind"` // auto | manual | pre_restore
CreatedAt time.Time `json:"created_at"`
}
// Document version kinds, mirrored from the schema CHECK constraint.
const (
VersionKindAuto = "auto" // throttled background snapshot on save
VersionKindManual = "manual" // explicit "save a restore point"
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;

629
internal/docs/export.go Normal file
View File

@@ -0,0 +1,629 @@
package docs
import (
"archive/zip"
"bytes"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/db"
)
// exportRoutes registers the download endpoint: GET /api/docs/{id}/export?format=md|html|txt|docx
func (h *Handler) exportRoutes(r chi.Router) {
r.Get("/{id}/export", h.export)
}
// exportFormat describes one downloadable format: how to render it and how to
// label the resulting file.
type exportFormat struct {
ext string
contentType string
render func(doc db.Document) ([]byte, error)
}
// exportFormats is the supported set. PDF is intentionally absent: a faithful,
// CJK-safe PDF needs an embedded Unicode font (~10MB into the single binary) or
// a headless browser (breaks the no-cgo, single-binary story). The frontend
// offers "Print / Save as PDF" via the browser instead, which uses the reader's
// own fonts and renders CJK correctly for free.
var exportFormats = map[string]exportFormat{
"md": {ext: "md", contentType: "text/markdown; charset=utf-8", render: renderMarkdown},
"html": {ext: "html", contentType: "text/html; charset=utf-8", render: renderHTMLFile},
"txt": {ext: "txt", contentType: "text/plain; charset=utf-8", render: renderPlainText},
"docx": {
ext: "docx",
contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
render: renderDocx,
},
}
// export streams the document in the requested format as a download.
func (h *Handler) export(w http.ResponseWriter, r *http.Request) {
formatKey := r.URL.Query().Get("format")
if formatKey == "" {
formatKey = "md"
}
format, ok := exportFormats[formatKey]
if !ok {
badRequest(w, "unsupported export format")
return
}
doc, err := h.fetch(chi.URLParam(r, "id"))
if errors.Is(err, sql.ErrNoRows) {
notFound(w)
return
}
if err != nil {
serverError(w, err)
return
}
body, err := format.render(doc)
if err != nil {
serverError(w, err)
return
}
filename := sanitizeFilename(doc.Title) + "." + format.ext
w.Header().Set("Content-Type", format.contentType)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename*=UTF-8''%s", urlEscapeFilename(filename)))
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
_, _ = w.Write(body)
}
// --- Tiptap document model --------------------------------------------------
// pmNode is one ProseMirror/Tiptap node. The tree is what the editor stores in
// Document.Content; we walk it to render every export format from one source.
type pmNode struct {
Type string `json:"type"`
Attrs map[string]any `json:"attrs"`
Marks []pmMark `json:"marks"`
Text string `json:"text"`
Content []pmNode `json:"content"`
}
type pmMark struct {
Type string `json:"type"`
}
// parseDoc decodes Document.Content into a node tree. On empty or malformed JSON
// it falls back to wrapping the plain-text mirror in paragraphs, so export never
// fails just because the editor state is unusual.
func parseDoc(doc db.Document) pmNode {
var root pmNode
if err := json.Unmarshal([]byte(doc.Content), &root); err != nil || root.Type == "" {
return fallbackDoc(doc.ContentText)
}
if len(root.Content) == 0 && strings.TrimSpace(doc.ContentText) != "" {
return fallbackDoc(doc.ContentText)
}
return root
}
// fallbackDoc builds a minimal doc node from plain text, one paragraph per line.
func fallbackDoc(text string) pmNode {
root := pmNode{Type: "doc"}
for _, line := range strings.Split(text, "\n") {
p := pmNode{Type: "paragraph"}
if line != "" {
p.Content = []pmNode{{Type: "text", Text: line}}
}
root.Content = append(root.Content, p)
}
return root
}
func (n pmNode) hasMark(t string) bool {
for _, m := range n.Marks {
if m.Type == t {
return true
}
}
return false
}
func (n pmNode) level() int {
if n.Attrs == nil {
return 1
}
if l, ok := n.Attrs["level"].(float64); ok && l >= 1 {
return int(l)
}
return 1
}
// --- Markdown ---------------------------------------------------------------
func renderMarkdown(doc db.Document) ([]byte, error) {
root := parseDoc(doc)
var blocks []string
for _, child := range root.Content {
if s := mdBlock(child, 0); s != "" {
blocks = append(blocks, s)
}
}
out := "# " + doc.Title + "\n\n" + strings.Join(blocks, "\n\n") + "\n"
return []byte(out), nil
}
func mdBlock(n pmNode, depth int) string {
switch n.Type {
case "heading":
return strings.Repeat("#", n.level()) + " " + mdInline(n.Content)
case "paragraph":
return mdInline(n.Content)
case "blockquote":
var lines []string
for _, c := range n.Content {
for _, l := range strings.Split(mdBlock(c, depth), "\n") {
lines = append(lines, "> "+l)
}
}
return strings.Join(lines, "\n")
case "codeBlock":
return "```\n" + textContent(n) + "\n```"
case "horizontalRule":
return "---"
case "bulletList", "orderedList":
var items []string
for i, item := range n.Content {
marker := "- "
if n.Type == "orderedList" {
marker = fmt.Sprintf("%d. ", i+1)
}
indent := strings.Repeat(" ", depth)
// A listItem holds block children (usually one paragraph).
var parts []string
for _, c := range item.Content {
parts = append(parts, mdBlock(c, depth+1))
}
items = append(items, indent+marker+strings.TrimSpace(strings.Join(parts, "\n")))
}
return strings.Join(items, "\n")
default:
// Unknown block: render any inline text it carries.
if len(n.Content) > 0 {
return mdInline(n.Content)
}
return ""
}
}
func mdInline(nodes []pmNode) string {
var b strings.Builder
for _, n := range nodes {
switch n.Type {
case "text":
b.WriteString(applyMdMarks(n))
case "hardBreak":
b.WriteString(" \n")
default:
b.WriteString(mdInline(n.Content))
}
}
return b.String()
}
func applyMdMarks(n pmNode) string {
t := n.Text
if n.hasMark("code") {
return "`" + t + "`" // code spans don't combine with other emphasis
}
if n.hasMark("bold") {
t = "**" + t + "**"
}
if n.hasMark("italic") {
t = "*" + t + "*"
}
if n.hasMark("strike") {
t = "~~" + t + "~~"
}
if n.hasMark("underline") {
t = "<u>" + t + "</u>"
}
return t
}
// --- Plain text -------------------------------------------------------------
func renderPlainText(doc db.Document) ([]byte, error) {
root := parseDoc(doc)
var blocks []string
for _, child := range root.Content {
if s := txtBlock(child); s != "" {
blocks = append(blocks, s)
}
}
out := doc.Title + "\n\n" + strings.Join(blocks, "\n\n") + "\n"
return []byte(out), nil
}
func txtBlock(n pmNode) string {
switch n.Type {
case "bulletList", "orderedList":
var items []string
for i, item := range n.Content {
marker := "• "
if n.Type == "orderedList" {
marker = fmt.Sprintf("%d. ", i+1)
}
items = append(items, marker+strings.TrimSpace(textContent(item)))
}
return strings.Join(items, "\n")
case "horizontalRule":
return "----------"
default:
return textContent(n)
}
}
// textContent flattens all descendant text, joining hardBreaks as newlines.
func textContent(n pmNode) string {
if n.Type == "text" {
return n.Text
}
if n.Type == "hardBreak" {
return "\n"
}
var b strings.Builder
for _, c := range n.Content {
b.WriteString(textContent(c))
}
return b.String()
}
// --- HTML -------------------------------------------------------------------
func renderHTMLFile(doc db.Document) ([]byte, error) {
root := parseDoc(doc)
var body strings.Builder
for _, child := range root.Content {
body.WriteString(htmlBlock(child))
}
page := fmt.Sprintf(htmlTemplate, htmlEscape(doc.Title), htmlEscape(doc.Title), body.String())
return []byte(page), nil
}
// htmlTemplate is a standalone, self-contained page with a warm, readable
// stylesheet and a CJK-first font stack so exported writing looks like Petal,
// not a raw dump. No external assets — opens offline anywhere.
const htmlTemplate = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>%s</title>
<style>
:root { color-scheme: light; }
body {
font-family: "Georgia", "Songti SC", "Noto Serif CJK SC", "Source Han Serif SC", serif;
line-height: 1.75; color: #463a3f; background: #fffafb;
max-width: 42rem; margin: 3rem auto; padding: 0 1.5rem;
}
h1, h2, h3 { font-family: "Georgia", "Songti SC", serif; color: #b04a6a; line-height: 1.3; }
h1 { font-size: 1.9rem; border-bottom: 2px solid #f6d6e0; padding-bottom: .4rem; }
blockquote { border-left: 3px solid #f3b6c8; margin: 1rem 0; padding: .2rem 1rem; color: #6b5860; background: #fff2f6; }
code { background: #fdeef3; padding: .1rem .35rem; border-radius: .3rem; font-size: .9em; }
pre { background: #fdeef3; padding: 1rem; border-radius: .6rem; overflow-x: auto; }
pre code { background: none; padding: 0; }
hr { border: none; border-top: 1px solid #f3cdd9; margin: 2rem 0; }
a { color: #b04a6a; }
ul, ol { padding-left: 1.4rem; }
</style>
</head>
<body>
<h1>%s</h1>
%s</body>
</html>
`
func htmlBlock(n pmNode) string {
switch n.Type {
case "heading":
tag := fmt.Sprintf("h%d", clampHeading(n.level()))
return fmt.Sprintf("<%s>%s</%s>\n", tag, htmlInline(n.Content), tag)
case "paragraph":
inner := htmlInline(n.Content)
if inner == "" {
return "<p><br></p>\n"
}
return "<p>" + inner + "</p>\n"
case "blockquote":
var b strings.Builder
for _, c := range n.Content {
b.WriteString(htmlBlock(c))
}
return "<blockquote>" + b.String() + "</blockquote>\n"
case "codeBlock":
return "<pre><code>" + htmlEscape(textContent(n)) + "</code></pre>\n"
case "horizontalRule":
return "<hr>\n"
case "bulletList", "orderedList":
tag := "ul"
if n.Type == "orderedList" {
tag = "ol"
}
var b strings.Builder
b.WriteString("<" + tag + ">\n")
for _, item := range n.Content {
var inner strings.Builder
for _, c := range item.Content {
// Unwrap a lone paragraph so list items aren't double-spaced.
if c.Type == "paragraph" {
inner.WriteString(htmlInline(c.Content))
} else {
inner.WriteString(htmlBlock(c))
}
}
b.WriteString("<li>" + inner.String() + "</li>\n")
}
b.WriteString("</" + tag + ">\n")
return b.String()
default:
if len(n.Content) > 0 {
return "<p>" + htmlInline(n.Content) + "</p>\n"
}
return ""
}
}
func htmlInline(nodes []pmNode) string {
var b strings.Builder
for _, n := range nodes {
switch n.Type {
case "text":
b.WriteString(applyHTMLMarks(n))
case "hardBreak":
b.WriteString("<br>")
default:
b.WriteString(htmlInline(n.Content))
}
}
return b.String()
}
func applyHTMLMarks(n pmNode) string {
t := htmlEscape(n.Text)
if n.hasMark("code") {
return "<code>" + t + "</code>"
}
if n.hasMark("bold") {
t = "<strong>" + t + "</strong>"
}
if n.hasMark("italic") {
t = "<em>" + t + "</em>"
}
if n.hasMark("underline") {
t = "<u>" + t + "</u>"
}
if n.hasMark("strike") {
t = "<s>" + t + "</s>"
}
return t
}
func clampHeading(level int) int {
if level < 1 {
return 1
}
if level > 6 {
return 6
}
return level
}
// --- DOCX -------------------------------------------------------------------
// renderDocx builds a minimal but valid .docx (Office Open XML) in pure Go: a
// zip of the few XML parts Word needs. Bold/italic/underline/strike map to run
// properties; headings use Word's built-in styles; lists are rendered with a
// bullet/number prefix (no numbering.xml dependency). CJK renders with the
// reader's own fonts, so no font embedding is required.
func renderDocx(doc db.Document) ([]byte, error) {
root := parseDoc(doc)
var body strings.Builder
body.WriteString(docxHeading(doc.Title, 1))
for _, child := range root.Content {
body.WriteString(docxBlock(child))
}
document := xmlHeader + `<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">` +
`<w:body>` + body.String() +
`<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/></w:sectPr>` +
`</w:body></w:document>`
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
parts := []struct{ name, content string }{
{"[Content_Types].xml", docxContentTypes},
{"_rels/.rels", docxRootRels},
{"word/_rels/document.xml.rels", docxDocRels},
{"word/document.xml", document},
}
for _, p := range parts {
fw, err := zw.Create(p.name)
if err != nil {
return nil, err
}
if _, err := fw.Write([]byte(p.content)); err != nil {
return nil, err
}
}
if err := zw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
const (
xmlHeader = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` + "\n"
docxContentTypes = xmlHeader + `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
`<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +
`<Default Extension="xml" ContentType="application/xml"/>` +
`<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>` +
`</Types>`
docxRootRels = xmlHeader + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>` +
`</Relationships>`
docxDocRels = xmlHeader + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`
)
func docxBlock(n pmNode) string {
switch n.Type {
case "heading":
return docxHeading(textContent(n), n.level())
case "paragraph":
return docxPara(n, "")
case "blockquote":
var b strings.Builder
for _, c := range n.Content {
b.WriteString(docxPara(c, "Quote"))
}
return b.String()
case "codeBlock":
// One paragraph per line preserves layout without a code style.
var b strings.Builder
for _, line := range strings.Split(textContent(n), "\n") {
b.WriteString(`<w:p><w:r><w:rPr><w:rFonts w:ascii="Consolas" w:hAnsi="Consolas"/></w:rPr>` +
`<w:t xml:space="preserve">` + xmlEscape(line) + `</w:t></w:r></w:p>`)
}
return b.String()
case "horizontalRule":
return `<w:p><w:pPr><w:pBdr><w:bottom w:val="single" w:sz="6" w:space="1" w:color="auto"/></w:pBdr></w:pPr></w:p>`
case "bulletList", "orderedList":
var b strings.Builder
for i, item := range n.Content {
prefix := "• "
if n.Type == "orderedList" {
prefix = fmt.Sprintf("%d. ", i+1)
}
for _, c := range item.Content {
b.WriteString(docxPara(c, "", prefix))
}
}
return b.String()
default:
if len(n.Content) > 0 {
return docxPara(n, "")
}
return ""
}
}
// docxPara renders a block's inline children as a Word paragraph. An optional
// style name (e.g. "Quote") and an optional literal text prefix (for list
// markers) may be supplied.
func docxPara(n pmNode, style string, prefix ...string) string {
var b strings.Builder
b.WriteString("<w:p>")
if style != "" {
b.WriteString(`<w:pPr><w:pStyle w:val="` + style + `"/></w:pPr>`)
}
if len(prefix) > 0 && prefix[0] != "" {
b.WriteString(docxRun(pmNode{Type: "text", Text: prefix[0]}))
}
b.WriteString(docxInline(n.Content))
b.WriteString("</w:p>")
return b.String()
}
func docxHeading(text string, level int) string {
return `<w:p><w:pPr><w:pStyle w:val="Heading` + fmt.Sprintf("%d", clampHeading(level)) + `"/></w:pPr>` +
docxRun(pmNode{Type: "text", Text: text}) + `</w:p>`
}
func docxInline(nodes []pmNode) string {
var b strings.Builder
for _, n := range nodes {
switch n.Type {
case "text":
b.WriteString(docxRun(n))
case "hardBreak":
b.WriteString(`<w:r><w:br/></w:r>`)
default:
b.WriteString(docxInline(n.Content))
}
}
return b.String()
}
func docxRun(n pmNode) string {
var props strings.Builder
if n.hasMark("bold") {
props.WriteString("<w:b/>")
}
if n.hasMark("italic") {
props.WriteString("<w:i/>")
}
if n.hasMark("underline") {
props.WriteString(`<w:u w:val="single"/>`)
}
if n.hasMark("strike") {
props.WriteString("<w:strike/>")
}
rpr := ""
if props.Len() > 0 {
rpr = "<w:rPr>" + props.String() + "</w:rPr>"
}
return `<w:r>` + rpr + `<w:t xml:space="preserve">` + xmlEscape(n.Text) + `</w:t></w:r>`
}
// --- small helpers ----------------------------------------------------------
func htmlEscape(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;", "'", "&#39;")
return r.Replace(s)
}
func xmlEscape(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;")
return r.Replace(s)
}
// sanitizeFilename makes a title safe as a download filename, preserving CJK and
// most letters while dropping path separators and control characters.
func sanitizeFilename(title string) string {
title = strings.TrimSpace(title)
if title == "" {
return "untitled"
}
var b strings.Builder
for _, r := range title {
switch {
case r < 0x20, r == '/', r == '\\', r == ':', r == '*', r == '?', r == '"', r == '<', r == '>', r == '|':
b.WriteRune('-')
default:
b.WriteRune(r)
}
}
out := strings.TrimSpace(b.String())
if out == "" {
return "untitled"
}
return out
}
// urlEscapeFilename percent-encodes a filename for the RFC 5987 filename*=
// Content-Disposition form, which carries UTF-8 (CJK titles) safely.
func urlEscapeFilename(s string) string {
var b strings.Builder
for _, c := range []byte(s) {
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
c == '-' || c == '_' || c == '.' || c == '~' {
b.WriteByte(c)
} else {
fmt.Fprintf(&b, "%%%02X", c)
}
}
return b.String()
}

View File

@@ -0,0 +1,135 @@
package docs
import (
"archive/zip"
"bytes"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
)
// richDocJSON is a Tiptap document exercising headings, marks, and a list —
// including CJK text, which every format must carry through intact.
const richDocJSON = `{"type":"doc","content":[` +
`{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"My Section"}]},` +
`{"type":"paragraph","content":[{"type":"text","text":"Hello "},{"type":"text","marks":[{"type":"bold"}],"text":"world"},{"type":"text","text":" 你好"}]},` +
`{"type":"bulletList","content":[` +
`{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]}]},` +
`{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"second"}]}]}` +
`]}` +
`]}`
func seedRichDoc(t *testing.T, srv http.Handler) string {
t.Helper()
id := newDoc(t, srv)
body := `{"title":"日记 Diary","content":` + jsonString(richDocJSON) + `,"content_text":"My Section\nHello world 你好\nfirst\nsecond","word_count":6}`
if rec := do(t, srv, http.MethodPut, "/"+id, body); rec.Code != http.StatusOK {
t.Fatalf("seed rich doc: code=%d body=%s", rec.Code, rec.Body)
}
return id
}
// jsonString quotes a string as a JSON string literal (so the Tiptap JSON can be
// embedded as the "content" field value).
func jsonString(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
func TestExportMarkdown(t *testing.T) {
srv := newTestServer(t)
id := seedRichDoc(t, srv)
rec := do(t, srv, http.MethodGet, "/"+id+"/export?format=md", "")
if rec.Code != http.StatusOK {
t.Fatalf("export md: code=%d body=%s", rec.Code, rec.Body)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/markdown") {
t.Fatalf("unexpected content-type: %q", ct)
}
out := rec.Body.String()
for _, want := range []string{"## My Section", "**world**", "你好", "- first", "- second"} {
if !strings.Contains(out, want) {
t.Fatalf("markdown missing %q in:\n%s", want, out)
}
}
// CJK filename must survive in the RFC 5987 form.
if cd := rec.Header().Get("Content-Disposition"); !strings.Contains(cd, "filename*=UTF-8''") {
t.Fatalf("expected RFC 5987 filename, got %q", cd)
}
}
func TestExportHTML(t *testing.T) {
srv := newTestServer(t)
id := seedRichDoc(t, srv)
rec := do(t, srv, http.MethodGet, "/"+id+"/export?format=html", "")
out := rec.Body.String()
for _, want := range []string{"<!doctype html>", "<h2>My Section</h2>", "<strong>world</strong>", "你好", "<li>first</li>"} {
if !strings.Contains(out, want) {
t.Fatalf("html missing %q", want)
}
}
}
func TestExportPlainText(t *testing.T) {
srv := newTestServer(t)
id := seedRichDoc(t, srv)
rec := do(t, srv, http.MethodGet, "/"+id+"/export?format=txt", "")
out := rec.Body.String()
for _, want := range []string{"My Section", "Hello world 你好", "• first"} {
if !strings.Contains(out, want) {
t.Fatalf("txt missing %q in:\n%s", want, out)
}
}
}
func TestExportDocx(t *testing.T) {
srv := newTestServer(t)
id := seedRichDoc(t, srv)
rec := do(t, srv, http.MethodGet, "/"+id+"/export?format=docx", "")
if rec.Code != http.StatusOK {
t.Fatalf("export docx: code=%d", rec.Code)
}
raw := rec.Body.Bytes()
zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
if err != nil {
t.Fatalf("docx is not a valid zip: %v", err)
}
want := map[string]bool{"[Content_Types].xml": false, "word/document.xml": false}
var docXML string
for _, f := range zr.File {
if _, ok := want[f.Name]; ok {
want[f.Name] = true
}
if f.Name == "word/document.xml" {
rc, _ := f.Open()
b, _ := io.ReadAll(rc)
rc.Close()
docXML = string(b)
}
}
for name, found := range want {
if !found {
t.Fatalf("docx missing part %q", name)
}
}
for _, w := range []string{"My Section", "你好", "<w:b/>", "Heading2"} {
if !strings.Contains(docXML, w) {
t.Fatalf("document.xml missing %q", w)
}
}
}
func TestExportUnsupportedFormat(t *testing.T) {
srv := newTestServer(t)
id := newDoc(t, srv)
if rec := do(t, srv, http.MethodGet, "/"+id+"/export?format=pdf", ""); rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for unsupported format, got %d", rec.Code)
}
}

View File

@@ -7,6 +7,7 @@ import (
"database/sql"
"encoding/json"
"errors"
"log"
"net/http"
"github.com/go-chi/chi/v5"
@@ -33,19 +34,25 @@ func (h *Handler) Routes() chi.Router {
r.Get("/{id}", h.get)
r.Put("/{id}", h.update)
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
@@ -61,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 {
@@ -68,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)
}
@@ -155,6 +176,16 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
serverError(w, err)
return
}
// Capture a throttled history snapshot when a real body save came through
// (not a bare rename) and the document has content. Best-effort: a failed
// snapshot must never fail the save itself.
if req.Content != nil && doc.ContentText != "" {
if err := h.maybeAutoSnapshot(doc); err != nil {
log.Printf("docs: auto-snapshot for %s failed: %v", id, err)
}
}
writeJSON(w, http.StatusOK, doc)
}
@@ -208,3 +239,4 @@ func serverError(w http.ResponseWriter, err error) {
func badRequest(w http.ResponseWriter, msg string) { errorJSON(w, http.StatusBadRequest, msg) }
func notFound(w http.ResponseWriter) { errorJSON(w, http.StatusNotFound, "document not found") }
func notFoundMsg(w http.ResponseWriter, msg string) { errorJSON(w, http.StatusNotFound, msg) }

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

245
internal/docs/versions.go Normal file
View File

@@ -0,0 +1,245 @@
package docs
import (
"database/sql"
"errors"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/db"
)
// Version-history tuning.
const (
// autoSnapshotInterval is the minimum gap between background ('auto')
// snapshots. Auto-save fires every ~1.5s; without a floor we'd store a
// version per keystroke-burst. A few minutes keeps history useful for
// recovery without unbounded growth.
autoSnapshotInterval = 3 * time.Minute
// maxAutoVersions caps how many 'auto' snapshots we retain per document.
// 'manual' and 'pre_restore' versions are never pruned — they're explicit
// restore points the writer (or a restore) deliberately created.
maxAutoVersions = 40
)
// versionRoutes registers the history endpoints on the docs sub-router. Paths
// resolve to /api/docs/{id}/versions...
func (h *Handler) versionRoutes(r chi.Router) {
r.Get("/{id}/versions", h.listVersions)
r.Post("/{id}/versions", h.createVersion) // explicit "save a restore point"
r.Get("/{id}/versions/{vid}", h.getVersion) // full body for preview
r.Post("/{id}/versions/{vid}/restore", h.restoreVersion)
}
// listVersions returns the document's snapshots, newest first, without the heavy
// content fields (those load on preview/restore).
func (h *Handler) listVersions(w http.ResponseWriter, r *http.Request) {
docID := chi.URLParam(r, "id")
// Scope through the documents table so a snapshot is only visible to the
// owner of its parent document.
rows, err := h.DB.Query(
`SELECT v.id, v.doc_id, v.title, v.word_count, v.kind, v.created_at
FROM document_versions v
JOIN documents d ON d.id = v.doc_id
WHERE v.doc_id = ? AND d.user_id = ?
ORDER BY v.created_at DESC`,
docID, db.LocalUserID,
)
if err != nil {
serverError(w, err)
return
}
defer rows.Close()
out := []db.DocumentVersion{} // non-nil so an empty history serializes as []
for rows.Next() {
var v db.DocumentVersion
if err := rows.Scan(&v.ID, &v.DocID, &v.Title, &v.WordCount, &v.Kind, &v.CreatedAt); err != nil {
serverError(w, err)
return
}
out = append(out, v)
}
if err := rows.Err(); err != nil {
serverError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// getVersion returns one snapshot in full (including content) for preview.
func (h *Handler) getVersion(w http.ResponseWriter, r *http.Request) {
v, err := h.fetchVersion(chi.URLParam(r, "id"), chi.URLParam(r, "vid"))
if errors.Is(err, sql.ErrNoRows) {
notFoundMsg(w, "version not found")
return
}
if err != nil {
serverError(w, err)
return
}
writeJSON(w, http.StatusOK, v)
}
// createVersion takes an explicit, user-requested ('manual') restore point from
// the document's current saved state.
func (h *Handler) createVersion(w http.ResponseWriter, r *http.Request) {
docID := chi.URLParam(r, "id")
doc, err := h.fetch(docID)
if errors.Is(err, sql.ErrNoRows) {
notFound(w)
return
}
if err != nil {
serverError(w, err)
return
}
v, err := h.insertVersion(doc, db.VersionKindManual)
if err != nil {
serverError(w, err)
return
}
writeJSON(w, http.StatusCreated, v)
}
// restoreVersion copies a snapshot back onto the live document. Before
// overwriting, it captures the current state as a 'pre_restore' version so the
// restore is itself undoable. Returns the restored document.
func (h *Handler) restoreVersion(w http.ResponseWriter, r *http.Request) {
docID := chi.URLParam(r, "id")
vid := chi.URLParam(r, "vid")
v, err := h.fetchVersion(docID, vid)
if errors.Is(err, sql.ErrNoRows) {
notFoundMsg(w, "version not found")
return
}
if err != nil {
serverError(w, err)
return
}
current, err := h.fetch(docID)
if err != nil {
serverError(w, err)
return
}
if _, err := h.insertVersion(current, db.VersionKindPreRestore); err != nil {
serverError(w, err)
return
}
res, err := h.DB.Exec(
`UPDATE documents
SET title = ?, content = ?, content_text = ?, word_count = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND user_id = ?`,
v.Title, v.Content, v.ContentText, v.WordCount, docID, db.LocalUserID,
)
if err != nil {
serverError(w, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
notFound(w)
return
}
doc, err := h.fetch(docID)
if err != nil {
serverError(w, err)
return
}
writeJSON(w, http.StatusOK, doc)
}
// maybeAutoSnapshot records a throttled background snapshot of the just-saved
// document. It no-ops when the newest snapshot is younger than
// autoSnapshotInterval, or when the body is unchanged since the last snapshot,
// so an idle or rename-only save costs nothing. Best-effort: callers log but do
// not fail the save if this errors. Prunes old 'auto' versions on success.
func (h *Handler) maybeAutoSnapshot(doc db.Document) error {
var (
lastAt time.Time
lastText string
hasPrev bool
)
err := h.DB.QueryRow(
`SELECT created_at, content_text FROM document_versions
WHERE doc_id = ? ORDER BY created_at DESC LIMIT 1`,
doc.ID,
).Scan(&lastAt, &lastText)
switch {
case errors.Is(err, sql.ErrNoRows):
hasPrev = false
case err != nil:
return err
default:
hasPrev = true
}
if hasPrev {
if lastText == doc.ContentText {
return nil // nothing meaningful changed
}
if time.Since(lastAt) < autoSnapshotInterval {
return nil // too soon; let edits accumulate
}
}
if _, err := h.insertVersion(doc, db.VersionKindAuto); err != nil {
return err
}
return h.pruneAutoVersions(doc.ID)
}
// insertVersion writes a snapshot row of the given kind and returns it (without
// the heavy content fields, matching the list shape).
func (h *Handler) insertVersion(doc db.Document, kind string) (db.DocumentVersion, error) {
var v db.DocumentVersion
err := h.DB.QueryRow(
`INSERT INTO document_versions (doc_id, title, content, content_text, word_count, kind)
VALUES (?, ?, ?, ?, ?, ?)
RETURNING id, doc_id, title, word_count, kind, created_at`,
doc.ID, doc.Title, doc.Content, doc.ContentText, doc.WordCount, kind,
).Scan(&v.ID, &v.DocID, &v.Title, &v.WordCount, &v.Kind, &v.CreatedAt)
return v, err
}
// pruneAutoVersions trims a document's 'auto' snapshots to the newest
// maxAutoVersions, leaving 'manual' and 'pre_restore' restore points intact.
func (h *Handler) pruneAutoVersions(docID string) error {
_, err := h.DB.Exec(
`DELETE FROM document_versions
WHERE doc_id = ? AND kind = 'auto'
AND id NOT IN (
SELECT id FROM document_versions
WHERE doc_id = ? AND kind = 'auto'
ORDER BY created_at DESC LIMIT ?
)`,
docID, docID, maxAutoVersions,
)
return err
}
// fetchVersion loads one full snapshot, scoped to its owner via the parent doc.
func (h *Handler) fetchVersion(docID, vid string) (db.DocumentVersion, error) {
var v db.DocumentVersion
err := h.DB.QueryRow(
`SELECT v.id, v.doc_id, v.title, v.content, v.content_text, v.word_count, v.kind, v.created_at
FROM document_versions v
JOIN documents d ON d.id = v.doc_id
WHERE v.id = ? AND v.doc_id = ? AND d.user_id = ?`,
vid, docID, db.LocalUserID,
).Scan(
&v.ID, &v.DocID, &v.Title, &v.Content, &v.ContentText,
&v.WordCount, &v.Kind, &v.CreatedAt,
)
return v, err
}

View File

@@ -0,0 +1,107 @@
package docs
import (
"encoding/json"
"net/http"
"testing"
"gitea.parodia.dev/drwily/petal/internal/db"
)
// newDoc creates a document and returns its id.
func newDoc(t *testing.T, srv http.Handler) string {
t.Helper()
rec := do(t, srv, http.MethodPost, "/", "")
if rec.Code != http.StatusCreated {
t.Fatalf("create doc: code=%d body=%s", rec.Code, rec.Body)
}
var d db.Document
if err := json.Unmarshal(rec.Body.Bytes(), &d); err != nil {
t.Fatalf("decode doc: %v", err)
}
return d.ID
}
func listVersions(t *testing.T, srv http.Handler, id string) []db.DocumentVersion {
t.Helper()
rec := do(t, srv, http.MethodGet, "/"+id+"/versions", "")
if rec.Code != http.StatusOK {
t.Fatalf("list versions: code=%d body=%s", rec.Code, rec.Body)
}
var vs []db.DocumentVersion
if err := json.Unmarshal(rec.Body.Bytes(), &vs); err != nil {
t.Fatalf("decode versions: %v", err)
}
return vs
}
func TestVersionLifecycle(t *testing.T) {
srv := newTestServer(t)
id := newDoc(t, srv)
// A bare rename (no content) must not snapshot.
do(t, srv, http.MethodPut, "/"+id, `{"title":"Just A Name"}`)
if vs := listVersions(t, srv, id); len(vs) != 0 {
t.Fatalf("rename should not snapshot, got %d", len(vs))
}
// First real body save takes one auto snapshot (no prior version to throttle).
body := `{"content":"{\"type\":\"doc\"}","content_text":"first draft","word_count":2}`
do(t, srv, http.MethodPut, "/"+id, body)
vs := listVersions(t, srv, id)
if len(vs) != 1 || vs[0].Kind != db.VersionKindAuto {
t.Fatalf("expected 1 auto version, got %+v", vs)
}
// List view omits heavy content fields.
if vs[0].Content != "" {
t.Fatalf("list should omit content, got %q", vs[0].Content)
}
// A near-immediate second save is throttled (no new auto version).
do(t, srv, http.MethodPut, "/"+id, `{"content":"{\"type\":\"doc\"}","content_text":"first draft v2","word_count":3}`)
if vs := listVersions(t, srv, id); len(vs) != 1 {
t.Fatalf("second save within interval should be throttled, got %d", len(vs))
}
// Manual snapshot always records, capturing current saved state.
rec := do(t, srv, http.MethodPost, "/"+id+"/versions", "")
if rec.Code != http.StatusCreated {
t.Fatalf("manual snapshot: code=%d body=%s", rec.Code, rec.Body)
}
var manual db.DocumentVersion
_ = json.Unmarshal(rec.Body.Bytes(), &manual)
if manual.Kind != db.VersionKindManual {
t.Fatalf("expected manual kind, got %q", manual.Kind)
}
// Mutate the live doc, then restore the manual snapshot.
do(t, srv, http.MethodPut, "/"+id, `{"content":"{\"type\":\"doc\"}","content_text":"ruined everything","word_count":2}`)
rec = do(t, srv, http.MethodPost, "/"+id+"/versions/"+manual.ID+"/restore", "")
if rec.Code != http.StatusOK {
t.Fatalf("restore: code=%d body=%s", rec.Code, rec.Body)
}
var restored db.Document
_ = json.Unmarshal(rec.Body.Bytes(), &restored)
if restored.ContentText != "first draft v2" {
t.Fatalf("restore did not bring back snapshot text: %q", restored.ContentText)
}
// Restore left a pre_restore safety copy, so the bad state is itself recoverable.
var foundPreRestore bool
for _, v := range listVersions(t, srv, id) {
if v.Kind == db.VersionKindPreRestore {
foundPreRestore = true
}
}
if !foundPreRestore {
t.Fatal("restore should record a pre_restore safety snapshot")
}
}
func TestVersionNotFound(t *testing.T) {
srv := newTestServer(t)
id := newDoc(t, srv)
if rec := do(t, srv, http.MethodGet, "/"+id+"/versions/nope", ""); rec.Code != http.StatusNotFound {
t.Fatalf("expected 404 for unknown version, got %d", rec.Code)
}
}

View File

@@ -1,9 +1,10 @@
// Package lexicon serves offline word lookups — a definition and a list of
// synonyms for a single word — from two public-domain datasets compiled into the
// binary. Definitions come from the Wordset dictionary (modern, concise glosses
// with a part of speech and example, which read kindly for an ESL writer);
// synonyms come from the Moby Thesaurus. Both are gzipped JSON, decompressed
// lazily on first use so a writer who never right-clicks a word pays nothing.
// Package lexicon serves offline word lookups — a Chinese gloss, a definition,
// and a list of synonyms for a single word — from public datasets compiled into
// the binary. Definitions come from the Wordset dictionary (modern, concise
// glosses with a part of speech and example, which read kindly for an ESL
// writer); synonyms come from the Moby Thesaurus; the Chinese gloss comes from
// ECDICT. All are gzipped JSON, decompressed lazily on first use so a writer who
// never looks up a word pays nothing.
package lexicon
import _ "embed"
@@ -19,3 +20,10 @@ var definitionsGz []byte
//
//go:embed data/synonyms.json.gz
var synonymsGz []byte
// glossGz is the gzipped English→Chinese gloss map: word → 中文 gloss, lowercase
// keys. Built from ECDICT (scripts/build_gloss.py), filtered to common words so
// an ESL writer who speaks Mandarin gets an instant translation on hover/lookup.
//
//go:embed data/gloss.json.gz
var glossGz []byte

Binary file not shown.

View File

@@ -25,6 +25,15 @@ func (h *Handler) Routes() chi.Router {
return r
}
// GlossRoutes returns the router mounted at /api/gloss — the lightweight
// Chinese-only lookup behind the inline hover/select gloss. It shares the
// Handler's Lexicon, so the datasets still load just once.
func (h *Handler) GlossRoutes() chi.Router {
r := chi.NewRouter()
r.Get("/{word}", h.gloss)
return r
}
// lookup returns the definition + synonyms for one word. A word found in neither
// dataset still returns 200 with empty lists, so the popover can show a friendly
// "nothing found" rather than an error state.
@@ -48,3 +57,25 @@ func (h *Handler) lookup(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=86400")
_ = json.NewEncoder(w).Encode(res)
}
// gloss returns just the Chinese translation for one word. Like lookup, a miss
// is a 200 with an empty gloss so the hover tooltip can quietly skip rather than
// error.
func (h *Handler) gloss(w http.ResponseWriter, r *http.Request) {
word := chi.URLParam(r, "word")
if decoded, err := url.PathUnescape(word); err == nil {
word = decoded
}
res, err := h.Lex.Gloss(word)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=86400")
_ = json.NewEncoder(w).Encode(res)
}

View File

@@ -20,13 +20,24 @@ type Meaning struct {
// Result is the full lookup for one word. Either list may be empty (the word
// isn't a headword in that dataset); the frontend handles a partial or empty
// result gracefully.
// result gracefully. Gloss is the Chinese translation (empty when the word isn't
// in the gloss dataset) — shown first in the popover for the Mandarin-speaking
// writer.
type Result struct {
Word string `json:"word"`
Gloss string `json:"gloss"`
Definitions []Meaning `json:"definitions"`
Synonyms []string `json:"synonyms"`
}
// GlossResult is the lightweight payload for the inline hover/select gloss: just
// the word and its Chinese translation, no definitions or synonyms. Kept small
// so the hover tooltip is instant and trivially cacheable.
type GlossResult struct {
Word string `json:"word"`
Gloss string `json:"gloss"`
}
// maxSynonyms caps how many synonyms we hand the popover, even though the dataset
// stores up to ~50 per word — a long flat wall of words overwhelms more than it
// helps, especially for an ESL reader scanning for the right fit.
@@ -44,6 +55,7 @@ type Lexicon struct {
loadErr error
defs map[string][][]string // word → [[pos, def, example], …]
synonyms map[string][]string // word → [synonym, …]
gloss map[string]string // word → Chinese gloss
}
// New returns a Lexicon. The datasets aren't read until the first Lookup.
@@ -59,6 +71,10 @@ func (l *Lexicon) load() {
l.loadErr = fmt.Errorf("load synonyms: %w", err)
return
}
if err := gunzipJSON(glossGz, &l.gloss); err != nil {
l.loadErr = fmt.Errorf("load gloss: %w", err)
return
}
})
}
@@ -78,6 +94,8 @@ func (l *Lexicon) Lookup(word string) (Result, error) {
return res, nil
}
res.Gloss = lookupGloss(l.gloss, norm)
if raw := lookupDefs(l.defs, norm); raw != nil {
for _, m := range raw {
res.Definitions = append(res.Definitions, toMeaning(m))
@@ -97,6 +115,32 @@ func (l *Lexicon) Lookup(word string) (Result, error) {
return res, nil
}
// Gloss returns just the Chinese translation for word (empty when absent). This
// is the fast path behind the inline hover/select gloss — it skips the
// definition and synonym datasets entirely.
func (l *Lexicon) Gloss(word string) (GlossResult, error) {
l.load()
if l.loadErr != nil {
return GlossResult{}, l.loadErr
}
norm := strings.ToLower(strings.TrimSpace(word))
return GlossResult{Word: word, Gloss: lookupGloss(l.gloss, norm)}, nil
}
// lookupGloss walks the candidate forms of a word and returns the first gloss
// hit (so "running"/"studies" resolve via the same de-inflection as defs/syns).
func lookupGloss(m map[string]string, word string) string {
if word == "" {
return ""
}
for _, c := range candidates(word) {
if v, ok := m[c]; ok {
return v
}
}
return ""
}
// lookupDefs / lookupSyns walk the candidate forms of a word and return the
// first dataset hit. They're separate (rather than a generic helper) only
// because the two maps have different value types.

View File

@@ -51,6 +51,48 @@ func TestLookupUnknownWord(t *testing.T) {
}
}
func TestGloss(t *testing.T) {
l := New()
// A common word carries a Chinese gloss...
res, err := l.Gloss("river")
if err != nil {
t.Fatalf("Gloss: %v", err)
}
if res.Word != "river" {
t.Errorf("Word = %q, want %q", res.Word, "river")
}
if res.Gloss == "" {
t.Errorf("expected a Chinese gloss for %q, got none", "river")
}
// ...and the inflected form resolves via the same de-inflection.
inflected, err := l.Gloss("rivers")
if err != nil {
t.Fatalf("Gloss(rivers): %v", err)
}
if inflected.Gloss == "" {
t.Errorf("expected a gloss for inflected %q, got none", "rivers")
}
// A nonsense word is a clean empty (not an error).
miss, err := l.Gloss("zzzxqqq")
if err != nil {
t.Fatalf("Gloss(miss): %v", err)
}
if miss.Gloss != "" {
t.Errorf("expected empty gloss for nonsense word, got %q", miss.Gloss)
}
}
func TestLookupIncludesGloss(t *testing.T) {
l := New()
res, err := l.Lookup("happy")
if err != nil {
t.Fatalf("Lookup: %v", err)
}
if res.Gloss == "" {
t.Errorf("expected Lookup to include a Chinese gloss for %q", "happy")
}
}
func TestCandidates(t *testing.T) {
cases := map[string]string{
"cats": "cat",

View File

@@ -120,3 +120,48 @@ Keep responses concise (2-4 sentences). This is a chat, not an essay. Be encoura
func AskPetalSystemPrompt(original, replacement, suggestionType, explanation, paragraph string) string {
return fmt.Sprintf(askPetalSystemTemplate, original, replacement, suggestionType, explanation, paragraph)
}
// rewriteSystemTemplate drives the "say it more naturally" / tone-rewrite tool.
// The writer selects a passage and picks a style; the model rewrites that
// passage in place. The instruction is deliberately strict about returning ONLY
// the rewritten passage so the result can be dropped straight into the editor —
// no quotes, no preamble, no commentary to strip.
const rewriteSystemTemplate = `You are Petal, a warm English writing assistant helping someone who speaks English ` +
`as a second language. Rewrite the passage the user sends so that it %s, while preserving its original ` +
`meaning. Fix any grammar mistakes and awkward phrasing along the way. Keep it about the same length — ` +
`do not add new ideas, explanations, or commentary.
Respond with ONLY the rewritten passage. No quotation marks around it, no preamble, no notes — just the ` +
`rewritten English text, ready to drop back into the document.`
// styleGuidance maps a rewrite style onto the clause describing the target
// register. "natural" is the default "say it more naturally" action; the rest
// mirror the document-tone vocabulary (see toneGuidance / the ToneSelect UI).
// An unknown style falls back to the natural rewrite.
func styleGuidance(style string) string {
switch style {
case "academic":
return "reads as formal, academic English suited to a school essay or research paper"
case "professional":
return "reads as polished, professional English suited to a workplace email or report"
case "casual":
return "sounds relaxed, friendly, and conversational"
case "humorous":
return "has a light, playful, good-humored tone"
case "creative":
return "is vivid, expressive, and imaginative"
case "persuasive":
return "is confident and persuasive"
default: // "natural"
return "sounds natural and fluent, the way a native English speaker would naturally say it"
}
}
// RewriteMessages builds the message array for a tone-rewrite: the styled system
// instruction plus the passage to rewrite as the user turn.
func RewriteMessages(text, style string) []Message {
return []Message{
{Role: "system", Content: fmt.Sprintf(rewriteSystemTemplate, styleGuidance(style))},
{Role: "user", Content: text},
}
}

46
internal/llm/rewrite.go Normal file
View File

@@ -0,0 +1,46 @@
package llm
import (
"context"
"strings"
)
// RewriteMaxRunes caps how much selected text a single rewrite will accept. A
// rewrite is a focused "fix this sentence/paragraph" action, not a whole-doc
// pass — bounding it keeps latency sane and the model on-task. The handler
// rejects longer selections before calling the model.
const RewriteMaxRunes = 2000
// RunRewrite rewrites a selected passage in the requested style (e.g. "natural",
// "academic"). It is a one-shot Complete — the result is shown as a preview the
// writer accepts or discards, so we want the whole rewrite before rendering.
func RunRewrite(ctx context.Context, client LLMClient, text, style string) (string, error) {
out, err := client.Complete(ctx, CompletionRequest{
Messages: RewriteMessages(text, style),
MaxTokens: 1024,
Temperature: 0.7,
TopP: 0.9,
RepetitionPenalty: 1.1,
})
if err != nil {
return "", err
}
return cleanRewrite(out), nil
}
// cleanRewrite trims the model's output down to just the rewritten passage. The
// prompt asks for no quotes or preamble, but small instruct models occasionally
// wrap the answer in matching quotes — strip a single surrounding pair so the
// text drops cleanly into the editor.
func cleanRewrite(s string) string {
s = strings.TrimSpace(s)
if len(s) >= 2 {
first, last := s[0], s[len(s)-1]
if (first == '"' && last == '"') ||
(first == '\'' && last == '\'') ||
(strings.HasPrefix(s, "“") && strings.HasSuffix(s, "”")) {
s = strings.TrimSpace(strings.Trim(s, "\"'“”"))
}
}
return s
}

View File

@@ -44,6 +44,7 @@ func New(database *db.DB, client llm.LLMClient) *Handler {
func (h *Handler) RegisterDocRoutes(r chi.Router) {
r.Post("/{id}/check", h.check)
r.Post("/{id}/voice", h.voice)
r.Post("/{id}/rewrite", h.rewrite)
r.Get("/{id}/suggestions", h.listForDoc)
}

View File

@@ -0,0 +1,76 @@
package suggestions
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/llm"
)
// rewriteRequest is the body the selection bubble posts: the selected passage
// and the target style ("natural", "academic", …). The text is the client's
// live selection — unlike a checkpoint, there is nothing to anchor server-side,
// so the rewrite is stateless and never persisted (the editor applies it
// directly, and the version history captures the resulting document change).
type rewriteRequest struct {
Text string `json:"text"`
Style string `json:"style"`
}
type rewriteResponse struct {
Rewrite string `json:"rewrite"`
}
// rewrite runs a one-shot tone-rewrite over a selected passage and returns the
// rewritten text for the editor to preview. The document id scopes the request
// to the owner (and reserves room to feed document context to the model later),
// even though the passage itself rides in the body.
func (h *Handler) rewrite(w http.ResponseWriter, r *http.Request) {
docID := chi.URLParam(r, "id")
var body rewriteRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
errorJSON(w, http.StatusBadRequest, "invalid request body")
return
}
text := strings.TrimSpace(body.Text)
if text == "" {
errorJSON(w, http.StatusBadRequest, "no text to rewrite")
return
}
if len([]rune(text)) > llm.RewriteMaxRunes {
errorJSON(w, http.StatusBadRequest, "selection too long to rewrite")
return
}
// Scope to the owner so a stray id can't drive rewrites against another
// user's document (and 404 cleanly when it doesn't exist).
var exists int
err := h.DB.QueryRow(
`SELECT 1 FROM documents WHERE id = ? AND user_id = ?`,
docID, db.LocalUserID,
).Scan(&exists)
if errors.Is(err, sql.ErrNoRows) {
errorJSON(w, http.StatusNotFound, "document not found")
return
}
if err != nil {
serverError(w, err)
return
}
out, err := llm.RunRewrite(r.Context(), h.Client, text, body.Style)
if err != nil {
errorJSON(w, http.StatusBadGateway, "rewrite failed: "+err.Error())
return
}
writeJSON(w, http.StatusOK, rewriteResponse{Rewrite: out})
}

View File

@@ -0,0 +1,79 @@
package suggestions
import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"gitea.parodia.dev/drwily/petal/internal/llm"
)
// recordingClient captures the last Complete request so the rewrite test can
// assert the styled system prompt and the selected passage reached the model.
type recordingClient struct {
response string
last llm.CompletionRequest
}
func (c *recordingClient) Complete(_ context.Context, req llm.CompletionRequest) (string, error) {
c.last = req
return c.response, nil
}
func (c *recordingClient) Stream(_ context.Context, _ llm.CompletionRequest) (<-chan string, error) {
ch := make(chan string)
close(ch)
return ch, nil
}
func TestRewrite(t *testing.T) {
// The model wraps its answer in quotes; cleanRewrite should strip them.
client := &recordingClient{response: `"I have two apples."`}
srv, docID, _ := newTestServer(t, client)
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/rewrite",
`{"text":"I has two apple.","style":"academic"}`)
if rec.Code != http.StatusOK {
t.Fatalf("rewrite: code=%d body=%s", rec.Code, rec.Body)
}
var resp rewriteResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Rewrite != "I have two apples." {
t.Fatalf("rewrite = %q, want the de-quoted text", resp.Rewrite)
}
// The passage rode in as the user turn, and the style steered the system turn.
msgs := client.last.Messages
if len(msgs) != 2 || msgs[0].Role != "system" || msgs[1].Role != "user" {
t.Fatalf("unexpected message shape: %+v", msgs)
}
if msgs[1].Content != "I has two apple." {
t.Fatalf("passage not forwarded: %q", msgs[1].Content)
}
if !strings.Contains(msgs[0].Content, "academic") {
t.Fatalf("system prompt missing academic style guidance:\n%s", msgs[0].Content)
}
}
func TestRewriteEmptyText(t *testing.T) {
client := &recordingClient{response: "anything"}
srv, docID, _ := newTestServer(t, client)
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/rewrite", `{"text":" ","style":"natural"}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("empty text: want 400, got %d", rec.Code)
}
}
func TestRewriteUnknownDoc(t *testing.T) {
client := &recordingClient{response: "anything"}
srv, _, _ := newTestServer(t, client)
rec := do(t, srv, http.MethodPost, "/docs/does-not-exist/rewrite",
`{"text":"hello there","style":"natural"}`)
if rec.Code != http.StatusNotFound {
t.Fatalf("unknown doc: want 404, got %d", rec.Code)
}
}

106
scripts/build_gloss.py Normal file
View File

@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Build the embedded English->Chinese gloss dataset from ECDICT.
Petal shows an instant Chinese gloss when the writer (an English-as-a-second-
language user whose first language is Mandarin) hovers or right-clicks a word.
The gloss is served offline from a small map compiled into the Go binary, the
same way the English definitions/synonyms are (see internal/lexicon).
Source: ECDICT (https://github.com/skywind3000/ECDICT), MIT-licensed. We keep
only common single words that carry a Chinese translation, trim each gloss to a
couple of senses, and drop the noisy "[网络]" (internet-slang) lines — the result
gzips to a few MB, in line with the other lexicon assets.
Usage:
curl -sL https://raw.githubusercontent.com/skywind3000/ECDICT/master/ecdict.csv -o ecdict.csv
python3 scripts/build_gloss.py ecdict.csv internal/lexicon/data/gloss.json.gz
"""
import csv
import gzip
import json
import re
import sys
# Frequency gate: keep a word only if ECDICT ranks it in the BNC or COCA
# frequency lists (frq/bnc > 0). That bounds the asset to the words an ESL
# writer actually meets, dropping the long tail of archaic/technical headwords.
# Words below this rank but present nowhere in the frequency lists are skipped.
MAX_RANK = 50000
# A gloss is at most this many sense-lines and characters, so the hover bubble
# stays a glance, not a wall of text.
MAX_SENSES = 3
MAX_CHARS = 80
# Single English word: letters, with internal apostrophe/hyphen (so "don't",
# "well-being" survive but multi-word phrases and codes are dropped).
WORD_RE = re.compile(r"^[a-z][a-z'\-]*[a-z]$|^[a-z]$")
# Tag lines we drop from a translation: "[网络]" is crowd-sourced internet slang,
# "[俚]" slang, "[古]" archaic — none help an ESL writer pick everyday meaning.
DROP_TAG_RE = re.compile(r"^\[(网络|俚|古|罕|废)\]")
def clean_gloss(translation: str) -> str:
"""Trim an ECDICT translation to a compact, glanceable Chinese gloss."""
# ECDICT separates senses with a literal backslash-n; normalize to real
# newlines (and tolerate genuine newlines) before splitting.
translation = translation.replace("\\n", "\n")
senses = []
for line in translation.split("\n"):
line = line.strip()
if not line or DROP_TAG_RE.match(line):
continue
senses.append(line)
if len(senses) >= MAX_SENSES:
break
gloss = "".join(senses)
if len(gloss) > MAX_CHARS:
gloss = gloss[:MAX_CHARS].rstrip(";, ") + ""
return gloss
def rank(row: dict) -> int:
"""Best (lowest, non-zero) frequency rank across COCA and BNC."""
ranks = []
for key in ("frq", "bnc"):
try:
v = int(row.get(key) or 0)
except ValueError:
v = 0
if v > 0:
ranks.append(v)
return min(ranks) if ranks else 0
def main(src: str, dst: str) -> None:
out: dict[str, str] = {}
kept_rank: dict[str, int] = {}
with open(src, newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
word = (row.get("word") or "").strip().lower()
translation = (row.get("translation") or "").strip()
if not word or not translation or not WORD_RE.match(word):
continue
r = rank(row)
if r == 0 or r > MAX_RANK:
continue
gloss = clean_gloss(translation)
if not gloss:
continue
# On a duplicate headword keep the more frequent (lower-rank) entry.
if word in kept_rank and kept_rank[word] <= r:
continue
out[word] = gloss
kept_rank[word] = r
payload = json.dumps(out, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
with gzip.open(dst, "wb", compresslevel=9) as gz:
gz.write(payload)
print(f"{len(out)} words -> {dst} ({len(payload)} bytes json)")
if __name__ == "__main__":
if len(sys.argv) != 3:
sys.exit("usage: build_gloss.py <ecdict.csv> <out.json.gz>")
main(sys.argv[1], sys.argv[2])

View File

@@ -1,11 +1,14 @@
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'
import { ExportMenu } from './components/Export/ExportMenu'
import { HistoryPanel } from './components/History/HistoryPanel'
import { StatusBar } from './components/StatusBar/StatusBar'
import { PetalCompanion } from './components/Companion/PetalCompanion'
import { UpdateBanner } from './components/UpdateBanner/UpdateBanner'
@@ -25,30 +28,105 @@ 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<HTMLDivElement>(null)
// Monotonic counters the companion watches to react to writing + accepts.
const [editTick, setEditTick] = useState(0)
const [acceptTick, setAcceptTick] = useState(0)
// History drawer visibility, and an epoch bumped on restore to force the
// editor to remount with the restored content (its initialContent is read
// only on mount).
const [historyOpen, setHistoryOpen] = useState(false)
const [editorEpoch, setEditorEpoch] = useState(0)
// Live mirrors of the current doc's editable fields so the "discard blank
// drafts on navigation" logic can read the latest values without rebuilding
// callbacks on every keystroke.
const currentDocRef = useRef(currentDoc)
const titleRef = useRef(title)
const wordCountRef = useRef(wordCount)
currentDocRef.current = currentDoc
titleRef.current = title
wordCountRef.current = wordCount
// A throwaway blank draft: no words and still the default/empty title. We
// delete these on navigation rather than leave orphan "Untitled" docs behind.
const isBlankDraft = useCallback(() => {
const t = titleRef.current.trim()
return wordCountRef.current === 0 && (t === '' || t === 'Untitled')
}, [])
const { status, schedule, saveNow } = useAutoSave(currentDoc?.id ?? null)
const {
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<DocSummary>) => {
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()
await saveNow() // flush any pending edits to the doc we're leaving
const doc = await api.getDoc(id)
setCurrentDoc(doc)
@@ -56,8 +134,14 @@ export default function App() {
setWordCount(doc.word_count)
setTone(doc.tone || 'general')
setDocText(doc.content_text)
// Leaving an untouched blank draft for a different doc? Drop it silently
// (best-effort cleanup — a 404 just means it's already gone).
if (leaving && leaving.id !== id && leavingBlank) {
api.deleteDoc(leaving.id).catch(() => {})
setDocs((prev) => prev.filter((d) => d.id !== leaving.id))
}
},
[saveNow],
[saveNow, isBlankDraft],
)
// Initial load: fetch the list, opening the first doc (or creating one).
@@ -70,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)
@@ -90,10 +174,13 @@ export default function App() {
}, [openDoc])
const handleCreate = useCallback(async () => {
// Already sitting on a fresh blank draft? Reuse it instead of stacking
// another empty "Untitled" on top.
if (currentDocRef.current && isBlankDraft()) return
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)
@@ -101,7 +188,7 @@ export default function App() {
setWordCount(0)
setTone(fresh.tone || 'general')
setDocText('')
}, [saveNow])
}, [saveNow, isBlankDraft])
const handleDelete = useCallback(
async (id: string) => {
@@ -179,6 +266,21 @@ export default function App() {
[removeSuggestion],
)
// After restoring a version, swap the restored doc into the editor. Bumping
// editorEpoch remounts EditorCore so it picks up the restored content.
const handleRestored = useCallback(
(doc: Document) => {
setCurrentDoc(doc)
setTitle(doc.title)
setWordCount(doc.word_count)
setTone(doc.tone || 'general')
setDocText(doc.content_text)
patchSummary(doc.id, { title: doc.title, word_count: doc.word_count })
setEditorEpoch((n) => n + 1)
},
[patchSummary],
)
// Escape always restores the sidebar while in distraction-free mode.
useEffect(() => {
if (!focusMode) return
@@ -211,24 +313,47 @@ export default function App() {
<div className="flex h-full flex-col">
<header
onMouseDown={handleChromeDown}
className="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)' }}
>
<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-lg font-extrabold text-plum">Petal</span>
</header>
<div className="flex min-h-0 flex-1">
<div className={`petal-sidebar shrink-0${focusMode ? ' petal-sidebar-hidden' : ''}`}>
<div className="relative flex min-h-0 flex-1">
<div
className={`petal-no-print petal-sidebar shrink-0${focusMode ? ' petal-sidebar-hidden' : ''}${drawerOpen ? ' petal-drawer-open' : ''}`}
>
<DocList
docs={docs}
roster={tagRoster}
selectedId={currentDoc?.id ?? null}
onSelect={openDoc}
onCreate={handleCreate}
onDelete={handleDelete}
onToggleTag={handleToggleTag}
onCreateTag={handleCreateTag}
/>
</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">
{currentDoc ? (
<>
@@ -246,10 +371,32 @@ export default function App() {
className="min-w-0 flex-1 bg-transparent text-3xl font-extrabold text-plum focus:outline-none"
style={{ fontFamily: 'var(--font-ui)' }}
/>
<ToneSelect value={tone} onChange={handleToneChange} />
<div className="petal-no-print shrink-0">
<ToneSelect value={tone} onChange={handleToneChange} />
</div>
<button
type="button"
onClick={() => setHistoryOpen(true)}
aria-label="Version history"
title="Browse and restore earlier versions"
className="petal-no-print inline-flex h-9 shrink-0 items-center gap-1.5 whitespace-nowrap px-3 text-sm font-bold"
style={{
borderRadius: 'var(--radius-pill)',
background: 'var(--color-surface)',
color: 'var(--color-plum)',
boxShadow: 'var(--shadow-soft)',
}}
>
<span aria-hidden>🕘</span>
<span></span>
<span style={{ color: 'var(--color-muted)' }}>· History</span>
</button>
<div className="petal-no-print">
<ExportMenu docId={currentDoc.id} />
</div>
</div>
<EditorCore
key={currentDoc.id}
key={`${currentDoc.id}:${editorEpoch}`}
docId={currentDoc.id}
initialContent={currentDoc.content}
onChange={handleEditorChange}
@@ -264,13 +411,14 @@ export default function App() {
/>
</div>
</div>
<div onMouseDown={handleChromeDown}>
<div className="petal-no-print" onMouseDown={handleChromeDown}>
<StatusBar
wordCount={wordCount}
text={docText}
saveStatus={status}
checking={checking}
voicing={voicing}
llmDown={llmDown}
/>
</div>
</>
@@ -285,14 +433,24 @@ export default function App() {
</main>
</div>
{historyOpen && currentDoc && (
<HistoryPanel
docId={currentDoc.id}
onClose={() => setHistoryOpen(false)}
onRestored={handleRestored}
/>
)}
{updateAvailable && <UpdateBanner />}
<PetalCompanion
wordCount={wordCount}
saveStatus={status}
editTick={editTick}
acceptTick={acceptTick}
/>
<div className="petal-no-print">
<PetalCompanion
wordCount={wordCount}
saveStatus={status}
editTick={editTick}
acceptTick={acceptTick}
/>
</div>
</div>
)
}

View File

@@ -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 {
@@ -37,16 +60,40 @@ export interface WordMeaning {
example?: string
}
// The offline lookup for one word: a few definition senses and a list of
// synonyms. Either list may be empty when the word isn't a headword.
// The offline lookup for one word: a Chinese gloss, a few definition senses, and
// a list of synonyms. Any of these may be empty when the word isn't a headword.
export interface WordInfo {
word: string
gloss: string // Chinese translation; '' when the word isn't in the gloss set
definitions: WordMeaning[]
synonyms: string[]
}
// The lightweight Chinese-only gloss behind the inline hover/select tooltip.
export interface Gloss {
word: string
gloss: string
}
export type SuggestionType = 'grammar' | 'phrasing' | 'idiom' | 'clarity' | 'voice'
// A point-in-time snapshot of a document. List responses omit the heavy
// content/content_text fields; they arrive only on getVersion (preview/restore).
export interface DocumentVersion {
id: string
doc_id: string
title: string
content?: string
content_text?: string
word_count: number
kind: 'auto' | 'manual' | 'pre_restore'
created_at: string
}
// Downloadable export formats. PDF is handled client-side via the browser's
// print dialog (CJK-safe, no server-side font embedding).
export type ExportFormat = 'md' | 'html' | 'txt' | 'docx'
// A single LLM-proposed edit. `original` is the source of truth for placement —
// the editor re-anchors by matching this string in the live document (spec
// Note #6); from_pos/to_pos are server-side advisory only. `replacement` is
@@ -100,14 +147,109 @@ export const api = {
dismissSuggestion: (id: string) =>
req<void>(`/suggestions/${id}/dismiss`, { method: 'POST' }),
// Offline word lookup (definition + synonyms) for the right-click popover.
// Version history. listVersions returns metadata only (no bodies); getVersion
// loads one full snapshot for preview; snapshotDoc takes an explicit restore
// point; restoreVersion copies a snapshot back onto the live doc (capturing a
// pre_restore safety copy server-side first) and returns the restored doc.
listVersions: (id: string) => req<DocumentVersion[]>(`/docs/${id}/versions`),
getVersion: (id: string, vid: string) =>
req<DocumentVersion>(`/docs/${id}/versions/${vid}`),
snapshotDoc: (id: string) =>
req<DocumentVersion>(`/docs/${id}/versions`, { method: 'POST' }),
restoreVersion: (id: string, vid: string) =>
req<Document>(`/docs/${id}/versions/${vid}/restore`, { method: 'POST' }),
// Download URL for an exported document (md/html/txt/docx). Used as an <a
// href download> target so the browser handles the file save.
exportUrl: (id: string, format: ExportFormat) =>
`/api/docs/${id}/export?format=${format}`,
// Offline word lookup (gloss + definition + synonyms) for the right-click popover.
lookupWord: (word: string) => req<WordInfo>(`/word/${encodeURIComponent(word)}`),
// Lightweight Chinese-only gloss for the inline hover/select tooltip — instant
// and offline, so it fires on hover without spinning up the heavier lookup.
glossWord: (word: string) => req<Gloss>(`/gloss/${encodeURIComponent(word)}`),
// Tone-rewrite: rewrites a selected passage in the given style ('natural',
// 'academic', …) and returns the rewritten text for an in-editor preview. Not
// persisted — the editor applies it directly on accept.
rewriteSelection: (docId: string, text: string, style: string) =>
req<{ rewrite: string }>(`/docs/${docId}/rewrite`, {
method: 'POST',
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
// 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 {

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 { 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<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 (
<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)' }}
>
<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
type="button"
onClick={onCreate}
className="flex items-center justify-center gap-2 py-2.5 text-sm font-bold text-white"
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)' }}
className="petal-tap flex items-center justify-center gap-2 text-sm font-bold text-white"
style={{ minHeight: 44, borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
>
@@ -28,18 +73,21 @@ export function DocList({ docs, selectedId, onSelect, onCreate, onDelete }: Prop
</button>
<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)' }}>
No documents yet.
{activeFilter ? 'No documents with this tag.' : 'No documents yet.'}
</p>
) : (
docs.map((doc) => (
filtered.map((doc) => (
<DocListItem
key={doc.id}
doc={doc}
active={doc.id === selectedId}
roster={roster}
onSelect={() => onSelect(doc.id)}
onDelete={() => onDelete(doc.id)}
onToggleTag={onToggleTag}
onCreateTag={onCreateTag}
/>
))
)}

View File

@@ -1,48 +1,101 @@
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 {
doc: DocSummary
active: boolean
roster: Tag[]
onSelect: () => 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
// a rose wash when it's the open document.
export function DocListItem({ doc, active, onSelect, onDelete }: Props) {
// One row in the sidebar: title + word count, its tag chips, a tag affordance and
// a delete affordance on hover, and a rose wash when it's the open document.
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 (
<div
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={{
borderRadius: 'var(--radius-card)',
background: active ? 'var(--color-surface)' : 'transparent',
boxShadow: active ? 'var(--shadow-soft)' : 'none',
}}
>
<div className="min-w-0 flex-1">
<div
className="truncate text-sm font-semibold"
style={{ color: active ? 'var(--color-plum)' : 'var(--color-muted)' }}
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<div
className="truncate text-sm font-semibold"
style={{ color: active ? 'var(--color-plum)' : 'var(--color-muted)' }}
>
{doc.title || 'Untitled'}
</div>
<div className="text-xs" style={{ color: 'var(--color-muted)' }}>
{doc.word_count} {doc.word_count === 1 ? 'word' : 'words'}
</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)' }}
>
{doc.title || 'Untitled'}
</div>
<div className="text-xs" style={{ color: 'var(--color-muted)' }}>
{doc.word_count} {doc.word_count === 1 ? 'word' : 'words'}
</div>
🏷
</button>
<button
type="button"
aria-label="Delete document"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
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)' }}
>
×
</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>
<button
type="button"
aria-label="Delete document"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
className="flex h-6 w-6 shrink-0 items-center justify-center text-base opacity-0 group-hover:opacity-100"
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
>
×
</button>
{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>
)
}

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

@@ -11,6 +11,9 @@ import { SuggestionHighlight, setSuggestions, findRange } from './SuggestionHigh
import { SpellCheck, setSpellChecker, wordAt } from './SpellCheck'
import { MisspellCard } from './MisspellCard'
import { WordCard } from './WordCard'
import { GlossTip } from './GlossTip'
import { SelectionBubble } from './SelectionBubble'
import { RewritePreview, type RewriteStatus } from './RewritePreview'
import { api, type Suggestion, type WordInfo } from '../../api/client'
import type { SpellChecker } from '../../hooks/useSpellChecker'
@@ -106,6 +109,39 @@ interface HoverState {
left: number
}
// The inline hover gloss: the word under the resting pointer, its Chinese
// translation, the PM range it covers (to dedupe re-fetches), and where to anchor.
interface GlossState {
word: string
gloss: string
from: number
to: number
top: number
left: number
}
// A non-empty text selection that the rewrite bubble hovers over.
interface SelectionState {
from: number
to: number
text: string
top: number
left: number
}
// An in-flight or completed tone-rewrite preview, pinned over the selection it
// came from. `from`/`to` are the PM range the accepted rewrite replaces.
interface RewriteState {
from: number
to: number
original: string
style: string
top: number
left: number
status: RewriteStatus
rewrite: string
}
// EditorCore is the Tiptap instance: StarterKit formatting plus underline, text
// alignment, a placeholder, character counting, and the suggestion-highlight
// decoration layer. Hovering a highlight opens its SuggestionCard.
@@ -138,6 +174,15 @@ export function EditorCore({
// While the Ask Petal panel is expanded the card is pinned: the hover-close
// timer is suppressed so chatting doesn't dismiss it. A click outside closes.
const [pinned, setPinned] = useState(false)
// Inline hover gloss (rest the pointer on a word → its Chinese meaning).
const [gloss, setGloss] = useState<GlossState | null>(null)
const glossTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
const glossReqRef = useRef(0)
// The rewrite affordances: a bubble over the current selection, and the
// preview that replaces it once a style is chosen.
const [selection, setSelection] = useState<SelectionState | null>(null)
const [rewrite, setRewrite] = useState<RewriteState | null>(null)
const rewriteReqRef = useRef(0)
const editor = useEditor({
extensions: [
@@ -158,12 +203,37 @@ export function EditorCore({
// Any edit shifts positions, stranding the popover anchors.
setMisspell(null)
setWordInfo(null)
setGloss(null)
setSelection(null)
// A stale rewrite preview points at a range that just moved — drop it.
rewriteReqRef.current++
setRewrite(null)
onChange({
content: JSON.stringify(editor.getJSON()),
content_text: editor.getText(),
word_count: editor.storage.characterCount.words(),
})
},
onSelectionUpdate: ({ editor }) => {
// A selection supersedes the hover gloss; an empty one clears the bubble.
setGloss(null)
const { from, to, empty } = editor.state.selection
const wrapper = wrapperRef.current
if (empty || !wrapper) {
setSelection(null)
return
}
const text = editor.state.doc.textBetween(from, to, ' ').trim()
if (!text) {
setSelection(null)
return
}
const start = editor.view.coordsAtPos(from)
const wrapRect = wrapper.getBoundingClientRect()
const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - 360))
const top = start.top - wrapRect.top
setSelection({ from, to, text, top, left })
},
})
// When the selected document changes, swap in its content without emitting an
@@ -174,6 +244,10 @@ export function EditorCore({
setHover(null)
setMisspell(null)
setWordInfo(null)
setGloss(null)
setSelection(null)
rewriteReqRef.current++
setRewrite(null)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [docId, editor])
@@ -288,8 +362,17 @@ export function EditorCore({
// 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
// 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(
(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
const target = (e.target as HTMLElement).closest('.petal-misspelling') as HTMLElement | null
if (!target) return
@@ -309,7 +392,7 @@ export function EditorCore({
setWordInfo(null)
setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
},
[editor, spellChecker, closeCard],
[editor, spellChecker, closeCard, openCardFor],
)
const replaceMisspelling = useCallback(
@@ -381,6 +464,143 @@ export function EditorCore({
[editor, wordInfo],
)
// Hover gloss: rest the pointer on an English word and, after a short delay,
// show its Chinese meaning from the offline gloss dataset. Suppressed while a
// selection, preview, or another popover is active, or over a word that has its
// own affordance (a suggestion highlight / a misspelling).
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (!editor) return
if (selection || rewrite || misspell || wordInfo || pinned) return
if (!editor.state.selection.empty) return
const t = e.target as HTMLElement
const clear = () => {
clearTimeout(glossTimer.current)
glossReqRef.current++
setGloss(null)
}
if (t.closest('.petal-suggestion') || t.closest('.petal-misspelling')) {
clear()
return
}
const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY })
if (!coords) {
clear()
return
}
const range = wordAt(editor.state.doc, coords.pos)
if (!range) {
clear()
return
}
// Already showing this exact word — leave it be (no flicker on micro-moves).
if (gloss && gloss.from === range.from && gloss.to === range.to) return
clearTimeout(glossTimer.current)
const token = ++glossReqRef.current
glossTimer.current = setTimeout(() => {
api
.glossWord(range.word)
.then((g) => {
if (token !== glossReqRef.current) return
const wrapper = wrapperRef.current
if (!g.gloss || !wrapper) {
setGloss(null)
return
}
const start = editor.view.coordsAtPos(range.from)
const end = editor.view.coordsAtPos(range.to)
const wrapRect = wrapper.getBoundingClientRect()
const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - 280))
const top = end.bottom - wrapRect.top + 6
setGloss({ word: range.word, gloss: g.gloss, from: range.from, to: range.to, top, left })
})
.catch(() => {
if (token === glossReqRef.current) setGloss(null)
})
}, 350)
},
[editor, selection, rewrite, misspell, wordInfo, pinned, gloss],
)
// Leaving the editor surface drops any pending/shown gloss.
const handleMouseLeave = useCallback(() => {
clearTimeout(glossTimer.current)
glossReqRef.current++
setGloss(null)
}, [])
// runRewrite fires the LLM rewrite for a captured range + style and tracks it
// through loading → ready/error. A request token discards a response whose
// preview has since been cancelled or superseded.
const runRewrite = useCallback(
(from: number, to: number, original: string, style: string, top: number, left: number) => {
const token = ++rewriteReqRef.current
setRewrite({ from, to, original, style, top, left, status: 'loading', rewrite: '' })
api
.rewriteSelection(docId, original, style)
.then((res) => {
if (token === rewriteReqRef.current) {
setRewrite((r) => (r ? { ...r, status: 'ready', rewrite: res.rewrite } : null))
}
})
.catch((err) => {
console.error('rewrite failed', err)
if (token === rewriteReqRef.current) {
setRewrite((r) => (r ? { ...r, status: 'error' } : null))
}
})
},
[docId],
)
// Picking a style in the selection bubble: capture the selection's range +
// text, anchor a preview below it, and kick off the rewrite.
const handleRewrite = useCallback(
(style: string) => {
if (!editor || !selection) return
const wrapper = wrapperRef.current
if (!wrapper) return
const start = editor.view.coordsAtPos(selection.from)
const end = editor.view.coordsAtPos(selection.to)
const wrapRect = wrapper.getBoundingClientRect()
const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - 340))
const top = end.bottom - wrapRect.top + 6
setSelection(null)
setGloss(null)
runRewrite(selection.from, selection.to, selection.text, style, top, left)
},
[editor, selection, runRewrite],
)
const acceptRewrite = useCallback(() => {
if (editor && rewrite && rewrite.rewrite) {
editor.chain().focus().insertContentAt({ from: rewrite.from, to: rewrite.to }, rewrite.rewrite).run()
}
rewriteReqRef.current++
setRewrite(null)
}, [editor, rewrite])
const cancelRewrite = useCallback(() => {
rewriteReqRef.current++
setRewrite(null)
}, [])
const retryRewrite = useCallback(() => {
if (rewrite) runRewrite(rewrite.from, rewrite.to, rewrite.original, rewrite.style, rewrite.top, rewrite.left)
}, [rewrite, runRewrite])
// A pointer-down outside the rewrite preview dismisses it.
useEffect(() => {
if (!rewrite) return
const onDown = (e: MouseEvent) => {
if ((e.target as HTMLElement).closest('.petal-rewrite-card')) return
rewriteReqRef.current++
setRewrite(null)
}
document.addEventListener('mousedown', onDown)
return () => document.removeEventListener('mousedown', onDown)
}, [rewrite])
// A pointer-down outside the word popover (and not on another word, which would
// reopen it via the context menu) closes it.
useEffect(() => {
@@ -409,6 +629,7 @@ export function EditorCore({
useEffect(() => () => {
clearTimeout(closeTimer.current)
clearTimeout(confettiTimer.current)
clearTimeout(glossTimer.current)
}, [])
// While pinned (Ask Petal open), a pointer-down outside the card closes it —
@@ -422,6 +643,20 @@ export function EditorCore({
return () => document.removeEventListener('mousedown', onDown)
}, [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 (
<div className="flex flex-1 flex-col">
<Toolbar editor={editor} onVoiceCheck={onVoiceCheck} voicing={voicing} />
@@ -430,11 +665,32 @@ export function EditorCore({
className="relative flex-1"
onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onClick={handleSpellClick}
onContextMenu={handleContextMenu}
>
<EditorContent editor={editor} className="h-full" />
{confetti && <Confetti top={confetti.top} left={confetti.left} />}
{gloss && <GlossTip gloss={gloss.gloss} style={{ top: gloss.top, left: gloss.left }} />}
{selection && !rewrite && (
<SelectionBubble
style={{ top: selection.top, left: selection.left, transform: 'translateY(calc(-100% - 8px))' }}
onRewrite={handleRewrite}
/>
)}
{rewrite && (
<RewritePreview
style={rewrite.style}
status={rewrite.status}
original={rewrite.original}
rewrite={rewrite.rewrite}
cardStyle={{ top: rewrite.top, left: rewrite.left }}
onAccept={acceptRewrite}
onCancel={cancelRewrite}
onRetry={retryRewrite}
/>
)}
{wordInfo && (
<WordCard
word={wordInfo.word}

View File

@@ -0,0 +1,32 @@
// GlossTip is the inline hover gloss: rest the pointer on an English word and a
// small bubble shows its Chinese meaning, pulled instantly from the offline
// gloss dataset. It's a pure reading aid — pointer-events are off so it never
// steals hover or blocks a click, and it sits just under the word. Bilingual
// chrome elsewhere puts zh first; here the gloss IS the content (the word is
// already on screen), so the bubble shows only the 中文.
interface Props {
gloss: string
style: React.CSSProperties
}
export function GlossTip({ gloss, style }: Props) {
return (
<div
className="petal-gloss-tip pointer-events-none absolute z-20 px-2.5 py-1.5 text-sm"
role="tooltip"
style={{
maxWidth: 280,
background: 'var(--color-plum)',
color: 'var(--color-surface)',
borderRadius: 'var(--radius-input)',
boxShadow: 'var(--shadow-soft)',
lineHeight: 1.35,
fontFamily: "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif",
...style,
}}
>
{gloss}
</div>
)
}

View File

@@ -0,0 +1,136 @@
import { REWRITE_STYLES } from './SelectionBubble'
// RewritePreview shows the result of a tone-rewrite before it touches the
// document: the writer's original passage, the model's rewrite beneath it, and
// accept/cancel. It mirrors the SuggestionCard's warm, bilingual chrome. While
// the model runs it shows a breathing dot; on failure it offers a gentle retry.
export type RewriteStatus = 'loading' | 'ready' | 'error'
interface Props {
style: string // the chosen rewrite style key (for the header label)
status: RewriteStatus
original: string
rewrite: string
cardStyle: React.CSSProperties
onAccept: () => void
onCancel: () => void
onRetry: () => void
}
const CJK = "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
export function RewritePreview({
style,
status,
original,
rewrite,
cardStyle,
onAccept,
onCancel,
onRetry,
}: Props) {
const meta = REWRITE_STYLES.find((s) => s.value === style) ?? REWRITE_STYLES[0]
return (
<div
className="petal-rewrite-card absolute z-30 p-3.5 text-sm"
role="dialog"
aria-label="Rewrite preview"
onMouseDown={(e) => e.stopPropagation()}
style={{
width: 340,
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-card)',
boxShadow: 'var(--shadow-soft)',
fontFamily: CJK,
...cardStyle,
}}
>
<div className="flex items-center gap-1.5">
<span
className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-bold"
style={{ background: 'var(--color-accent)', color: 'var(--color-plum)' }}
>
<span aria-hidden>{meta.emoji}</span>
{meta.zh} · {meta.en}
</span>
<span className="text-xs font-semibold" style={{ color: 'var(--color-muted)' }}>
· Rewrite
</span>
</div>
{/* Original passage, dimmed — what's being replaced. */}
<p
className="mt-3 leading-snug"
style={{ color: 'var(--color-muted)', textDecoration: 'line-through', textDecorationColor: 'var(--color-border)' }}
>
{original}
</p>
{status === 'loading' && (
<div className="mt-3 inline-flex items-center gap-1.5" style={{ color: 'var(--color-muted)' }}>
<span
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
style={{ background: 'var(--color-accent)' }}
aria-hidden
/>
· Rewriting
</div>
)}
{status === 'error' && (
<div className="mt-3">
<p className="leading-snug" style={{ color: 'var(--color-muted)' }}>
· Couldnt rewrite try again
</p>
<div className="mt-2.5 flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="rounded-full px-3 py-1 text-xs font-semibold"
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
>
· Cancel
</button>
<button
type="button"
onClick={onRetry}
className="rounded-full px-3 py-1 text-xs font-bold"
style={{ background: 'var(--color-accent)', color: 'var(--color-plum)' }}
>
· Retry
</button>
</div>
</div>
)}
{status === 'ready' && (
<>
<p className="mt-2 leading-snug" style={{ color: 'var(--color-plum)' }}>
{rewrite}
</p>
<div className="mt-3 flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="rounded-full px-3 py-1 text-xs font-semibold"
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
>
· Cancel
</button>
<button
type="button"
onClick={onAccept}
className="rounded-full px-3 py-1 text-xs font-bold"
style={{ background: 'var(--color-accent)', color: 'var(--color-plum)' }}
>
· Use this
</button>
</div>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,85 @@
// SelectionBubble floats above a text selection and offers to rewrite it: a
// prominent "✨ 更自然 Say it naturally" action plus the tone vocabulary (学术,
// 轻松, …) mirrored from the document-tone picker. Picking one hands the style up
// to EditorCore, which calls the LLM and shows a preview. Buttons use
// onMouseDown→preventDefault so clicking them doesn't collapse the selection
// before the handler captures its range.
export interface RewriteStyle {
value: string
emoji: string
zh: string
en: string
}
// 'natural' is the default "say it more naturally" rewrite; the rest mirror the
// llm styleGuidance keys (and the ToneSelect labels) so the two stay in step.
export const REWRITE_STYLES: RewriteStyle[] = [
{ value: 'natural', emoji: '✨', zh: '更自然', en: 'Natural' },
{ value: 'academic', emoji: '🎓', zh: '学术', en: 'Academic' },
{ value: 'professional', emoji: '💼', zh: '专业', en: 'Professional' },
{ value: 'casual', emoji: '☕', zh: '轻松', en: 'Casual' },
{ value: 'humorous', emoji: '😄', zh: '幽默', en: 'Humorous' },
{ value: 'creative', emoji: '🎨', zh: '创意', en: 'Creative' },
{ value: 'persuasive', emoji: '📣', zh: '说服', en: 'Persuasive' },
]
interface Props {
style: React.CSSProperties
onRewrite: (style: string) => void
}
const CJK = "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
export function SelectionBubble({ style, onRewrite }: Props) {
const [natural, ...tones] = REWRITE_STYLES
return (
<div
className="petal-selection-bubble absolute z-30 flex max-w-[360px] flex-wrap items-center gap-1.5 p-2"
role="toolbar"
aria-label="Rewrite the selection"
onMouseDown={(e) => e.preventDefault()} // keep the editor selection
style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-pill)',
boxShadow: 'var(--shadow-soft)',
fontFamily: CJK,
...style,
}}
>
<button
type="button"
onClick={() => onRewrite(natural.value)}
className="inline-flex h-8 items-center gap-1.5 whitespace-nowrap px-3 text-sm font-bold"
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)', color: 'var(--color-plum)' }}
title="Rewrite the selection to sound more natural"
>
<span aria-hidden>{natural.emoji}</span>
<span>{natural.zh}</span>
<span className="font-semibold" style={{ color: 'var(--color-plum)', opacity: 0.7 }}>
{natural.en}
</span>
</button>
<span className="mx-0.5 h-5 w-px shrink-0" style={{ background: 'var(--color-border)' }} />
{tones.map((t) => (
<button
key={t.value}
type="button"
onClick={() => onRewrite(t.value)}
className="inline-flex h-8 items-center gap-1 whitespace-nowrap px-2.5 text-sm font-semibold"
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-lavender)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
title={`Rewrite in a ${t.en.toLowerCase()} tone`}
>
<span aria-hidden>{t.emoji}</span>
<span>{t.zh}</span>
</button>
))}
</div>
)
}

View File

@@ -17,7 +17,8 @@ interface Props {
export function WordCard({ word, info, loading, style, onReplace }: Props) {
const definitions = info?.definitions ?? []
const synonyms = info?.synonyms ?? []
const empty = !loading && definitions.length === 0 && synonyms.length === 0
const gloss = info?.gloss ?? ''
const empty = !loading && !gloss && definitions.length === 0 && synonyms.length === 0
return (
<div
@@ -47,6 +48,19 @@ export function WordCard({ word, info, loading, style, onReplace }: Props) {
</span>
</div>
{/* Chinese gloss first — it's what the Mandarin-speaking writer reaches for. */}
{gloss && (
<p
className="mt-2.5 leading-snug"
style={{
color: 'var(--color-plum)',
fontFamily: "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif",
}}
>
{gloss}
</p>
)}
{loading && (
<div className="mt-3 inline-flex items-center gap-1.5" style={{ color: 'var(--color-muted)' }}>
<span

View File

@@ -0,0 +1,120 @@
import { useEffect, useRef, useState } from 'react'
import { api, type ExportFormat } from '../../api/client'
// ExportMenu is the "get your writing out of Petal" dropdown. File formats are
// plain <a download> links to the server's export endpoint (which sets the
// Content-Disposition filename, CJK and all). "Print / Save as PDF" calls the
// browser print dialog against the print stylesheet, so PDF stays CJK-safe with
// no server-side font embedding. Bilingual zh·en labels match Petal's chrome.
interface FormatOption {
format: ExportFormat
emoji: string
zh: string
en: string
}
const FORMATS: FormatOption[] = [
{ format: 'docx', emoji: '📄', zh: 'Word 文档', en: 'Word (.docx)' },
{ format: 'md', emoji: '📝', zh: 'Markdown', en: 'Markdown (.md)' },
{ format: 'html', emoji: '🌐', zh: '网页', en: 'Web page (.html)' },
{ format: 'txt', emoji: '🧾', zh: '纯文本', en: 'Plain text (.txt)' },
]
interface Props {
docId: string
}
export function ExportMenu({ docId }: Props) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onDown = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', onDown)
return () => document.removeEventListener('mousedown', onDown)
}, [open])
return (
<div ref={ref} className="relative shrink-0">
<button
type="button"
aria-label="Export document"
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
className="inline-flex h-9 items-center gap-1.5 whitespace-nowrap px-3 text-sm font-bold"
style={{
borderRadius: 'var(--radius-pill)',
background: 'var(--color-surface)',
color: 'var(--color-plum)',
boxShadow: 'var(--shadow-soft)',
}}
title="Save or print your writing"
>
<span aria-hidden></span>
<span></span>
<span style={{ color: 'var(--color-muted)' }}>· Export</span>
</button>
{open && (
<div
role="menu"
className="petal-word-card absolute right-0 z-30 mt-1.5 p-1.5"
style={{
width: 220,
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-card)',
boxShadow: 'var(--shadow-soft)',
}}
>
{FORMATS.map((f) => (
<a
key={f.format}
role="menuitem"
href={api.exportUrl(docId, f.format)}
download
onClick={() => setOpen(false)}
className="flex w-full items-center gap-2 rounded-xl px-2.5 py-1.5 text-left text-sm font-semibold no-underline"
style={{ color: 'var(--color-plum)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<span aria-hidden>{f.emoji}</span>
<span>{f.zh}</span>
<span className="font-normal" style={{ color: 'var(--color-muted)' }}>
{f.en}
</span>
</a>
))}
<div className="my-1 h-px" style={{ background: 'var(--color-border)' }} />
<button
type="button"
role="menuitem"
onClick={() => {
setOpen(false)
// Defer so the menu unmounts before the print dialog snapshots.
setTimeout(() => window.print(), 50)
}}
className="flex w-full items-center gap-2 rounded-xl px-2.5 py-1.5 text-left text-sm font-semibold"
style={{ background: 'transparent', color: 'var(--color-plum)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<span aria-hidden>🖨</span>
<span> / PDF</span>
<span className="font-normal" style={{ color: 'var(--color-muted)' }}>
Print / PDF
</span>
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,230 @@
import { useCallback, useEffect, useState } from 'react'
import { api, type Document, type DocumentVersion } from '../../api/client'
// HistoryPanel is the "time machine" drawer: every snapshot Petal kept of this
// document, newest first, with a one-click preview and restore. It's the safety
// net that makes trusting Petal with real writing reasonable — a bad edit or a
// regretted rewrite is always recoverable, and restoring is itself undoable
// (the server keeps a pre_restore copy). Bilingual, zh-first, to match chrome.
interface Props {
docId: string
onClose: () => void
// Called after a successful restore with the freshly-restored document so the
// editor can reload it.
onRestored: (doc: Document) => void
}
// kindLabel maps a snapshot kind to its bilingual badge + accent color.
const KIND: Record<DocumentVersion['kind'], { zh: string; en: string; color: string }> = {
manual: { zh: '保存点', en: 'Saved point', color: 'var(--color-accent)' },
auto: { zh: '自动', en: 'Auto', color: 'var(--color-muted)' },
pre_restore: { zh: '恢复前', en: 'Before restore', color: 'var(--color-lavender)' },
}
// relativeTime renders a UTC timestamp as a gentle "x minutes ago" string.
function relativeTime(iso: string): string {
const then = new Date(iso).getTime()
const secs = Math.max(0, Math.round((Date.now() - then) / 1000))
if (secs < 60) return 'just now · 刚刚'
const mins = Math.round(secs / 60)
if (mins < 60) return `${mins} min ago · ${mins} 分钟前`
const hrs = Math.round(mins / 60)
if (hrs < 24) return `${hrs} hr ago · ${hrs} 小时前`
const days = Math.round(hrs / 24)
return `${days} day${days > 1 ? 's' : ''} ago · ${days} 天前`
}
export function HistoryPanel({ docId, onClose, onRestored }: Props) {
const [versions, setVersions] = useState<DocumentVersion[] | null>(null)
const [error, setError] = useState(false)
const [selected, setSelected] = useState<DocumentVersion | null>(null)
const [preview, setPreview] = useState<DocumentVersion | null>(null)
const [busy, setBusy] = useState(false)
const load = useCallback(async () => {
setError(false)
try {
setVersions(await api.listVersions(docId))
} catch {
setError(true)
}
}, [docId])
useEffect(() => {
void load()
}, [load])
// Escape closes the drawer.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
const choose = useCallback(
async (v: DocumentVersion) => {
setSelected(v)
setPreview(null)
try {
setPreview(await api.getVersion(docId, v.id))
} catch {
setPreview(null)
}
},
[docId],
)
const restore = useCallback(async () => {
if (!selected) return
setBusy(true)
try {
const doc = await api.restoreVersion(docId, selected.id)
onRestored(doc)
onClose()
} catch {
setBusy(false)
}
}, [docId, selected, onRestored, onClose])
return (
<div className="petal-no-print fixed inset-0 z-40 flex justify-end">
{/* Backdrop */}
<div
className="absolute inset-0"
style={{ background: 'rgba(61, 46, 57, 0.18)' }}
onClick={onClose}
/>
<aside
className="relative flex h-full w-full max-w-[380px] flex-col"
style={{
background: 'var(--color-surface)',
borderLeft: '1px solid var(--color-border)',
boxShadow: 'var(--shadow-soft)',
}}
>
<header
className="flex items-center justify-between px-5 py-4"
style={{ borderBottom: '1px solid var(--color-border)' }}
>
<div>
<div className="text-base font-extrabold text-plum"> · History</div>
<div className="text-xs" style={{ color: 'var(--color-muted)' }}>
Every saved moment nothing is ever lost 🌸
</div>
</div>
<button
type="button"
aria-label="Close history"
onClick={onClose}
className="flex h-8 w-8 items-center justify-center rounded-full text-lg"
style={{ color: 'var(--color-muted)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
</button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-3">
{error ? (
<div className="px-2 py-6 text-center text-sm" style={{ color: 'var(--color-muted)' }}>
Couldnt load history just now.
<button onClick={load} className="ml-1 font-bold text-plum underline">
Try again
</button>
</div>
) : versions === null ? (
<div className="px-2 py-6 text-center text-sm" style={{ color: 'var(--color-muted)' }}>
Loading
</div>
) : versions.length === 0 ? (
<div className="px-2 py-8 text-center text-sm" style={{ color: 'var(--color-muted)' }}>
No snapshots yet. Keep writing Petal saves restore points as you go.
</div>
) : (
<ul className="flex flex-col gap-1.5">
{versions.map((v) => {
const k = KIND[v.kind]
const active = selected?.id === v.id
return (
<li key={v.id}>
<button
type="button"
onClick={() => choose(v)}
className="flex w-full flex-col gap-1 rounded-2xl px-3 py-2.5 text-left"
style={{
background: active ? 'var(--color-surface-alt)' : 'transparent',
border: `1px solid ${active ? 'var(--color-border)' : 'transparent'}`,
}}
onMouseEnter={(e) =>
(e.currentTarget.style.background = 'var(--color-surface-alt)')
}
onMouseLeave={(e) =>
(e.currentTarget.style.background = active
? 'var(--color-surface-alt)'
: 'transparent')
}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-bold text-plum">{v.title || 'Untitled'}</span>
<span
className="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold"
style={{ background: k.color, color: '#fff' }}
>
{k.zh} · {k.en}
</span>
</div>
<div className="flex items-center justify-between gap-2 text-xs" style={{ color: 'var(--color-muted)' }}>
<span>{relativeTime(v.created_at)}</span>
<span>{v.word_count} words</span>
</div>
</button>
</li>
)
})}
</ul>
)}
</div>
{selected && (
<div
className="shrink-0 px-4 py-3"
style={{ borderTop: '1px solid var(--color-border)', background: 'var(--color-surface-alt)' }}
>
<div className="mb-2 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
· Preview
</div>
<div
className="mb-3 max-h-32 overflow-y-auto whitespace-pre-wrap rounded-xl px-3 py-2 text-sm"
style={{
background: 'var(--color-surface)',
color: 'var(--color-plum)',
fontFamily: 'var(--font-body)',
}}
>
{preview ? preview.content_text || '(empty)' : 'Loading…'}
</div>
<button
type="button"
onClick={restore}
disabled={busy}
className="w-full rounded-full py-2.5 text-sm font-extrabold text-white disabled:opacity-60"
style={{ background: 'var(--color-accent)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
>
{busy ? 'Restoring…' : '恢复这个版本 · Restore this version'}
</button>
<div className="mt-1.5 text-center text-[11px]" style={{ color: 'var(--color-muted)' }}>
Your current draft is saved first, so this is undoable.
</div>
</div>
)}
</aside>
</div>
)
}

View File

@@ -11,6 +11,9 @@ interface Props {
checking: boolean
// True while a whole-document voice pass runs — shows a breathing honey dot.
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> = {
@@ -24,7 +27,7 @@ const SAVE_LABEL: Record<SaveStatus, string> = {
// 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
// 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]
// The expanded stats panel toggles open when the word count is clicked.
const [statsOpen, setStatsOpen] = useState(false)
@@ -86,6 +89,20 @@ export function StatusBar({ wordCount, text, saveStatus, checking, voicing }: Pr
</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 && (
<>
<span aria-hidden>·</span>

View File

@@ -13,6 +13,11 @@ export function useCheckpoint(docId: string | null) {
const [checking, setChecking] = useState(false)
// True while a whole-document voice pass is in flight (explicit user action).
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 docIdRef = useRef(docId)
@@ -30,9 +35,11 @@ export function useCheckpoint(docId: string | null) {
const fresh = await api.checkDoc(id)
if (run === runRef.current && id === docIdRef.current) {
setSuggestions(fresh)
setLlmDown(false)
}
} catch (err) {
console.error('checkpoint failed', err)
if (run === runRef.current) setLlmDown(true)
} finally {
if (run === runRef.current) setChecking(false)
}
@@ -50,9 +57,11 @@ export function useCheckpoint(docId: string | null) {
const full = await api.voiceDoc(id)
if (run === runRef.current && id === docIdRef.current) {
setSuggestions(full)
setLlmDown(false)
}
} catch (err) {
console.error('voice pass failed', err)
if (run === runRef.current) setLlmDown(true)
} finally {
if (run === runRef.current) setVoicing(false)
}
@@ -72,6 +81,7 @@ export function useCheckpoint(docId: string | null) {
setSuggestions([])
setChecking(false)
setVoicing(false)
setLlmDown(false)
if (!docId) return
let cancelled = false
void (async () => {
@@ -94,5 +104,5 @@ export function useCheckpoint(docId: string | null) {
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

@@ -163,6 +163,18 @@ button, a, input {
animation: petal-suggestion-in 200ms ease both;
}
/* --- ESL superpowers (Phase 9) ----------------------------------------------
Inline Chinese gloss on hover (a small dark tooltip under the word), and the
selection rewrite bubble + its preview card. All share the soft pop-in. The
gloss tip fades in a touch faster — it's a fleeting reading aid, not a panel. */
.petal-gloss-tip {
animation: petal-suggestion-in 140ms ease both;
}
.petal-selection-bubble,
.petal-rewrite-card {
animation: petal-suggestion-in 200ms ease both;
}
/* --- Accept confetti --------------------------------------------------------
A tiny CSS-only burst played where a suggestion is accepted: four colored
dots spray up-and-out, then fade. No JS animation — each dot reads its
@@ -269,3 +281,131 @@ button, a, input {
0%, 100% { opacity: 0.4; }
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
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
server-side font embedding. */
@media print {
.petal-no-print,
.petal-sidebar,
.petal-suggestion-card,
.petal-misspell-card,
.petal-gloss-tip,
.petal-selection-bubble,
.petal-rewrite-card,
.petal-confetti,
.petal-companion {
display: none !important;
}
html, body, #root {
height: auto !important;
overflow: visible !important;
background: #fff !important;
}
/* Drop the in-editor highlight underlines/tints — they're guidance, not ink. */
.petal-suggestion,
.petal-misspelling {
background: none !important;
text-decoration: none !important;
border-bottom: none !important;
}
/* Let the writing use the full page width. */
.max-w-\[720px\] { max-width: none !important; }
}