Compare commits
5 Commits
0d59b645bb
...
phase-10-o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e141e4169 | ||
|
|
60eba25fee | ||
|
|
8e1111d768 | ||
|
|
cf7720ea77 | ||
|
|
4c288834c0 |
@@ -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.
|
- [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).
|
- 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)
|
### 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
|
- [ ] Copyleaks Tier-2 + webhook HMAC
|
||||||
- [ ] Dockerfile, docker-compose, Traefik, deploy to write.parodia.dev
|
- [ ] 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
|
## Session log
|
||||||
|
- 2026-06-26: **Phase 10 complete** (organization & polish). Scope confirmed with user: all four areas, **tags** (not folders), **FTS5** search. Backend: migration `0004_tags_and_search` (tags + document_tags + `documents_fts` trigram virtual table with sync triggers + back-fill); `db.Tag` model + color constants; `internal/docs/tags.go` (tag CRUD + idempotent assignment + `tagsByDoc` helper, doc list now carries tags); `internal/docs/search.go` (`GET /api/search`, FTS for ≥3 runes + LIKE fallback for 1-2, Go-built sentinel-highlighted rune-aware snippets, owner-scoped). Mounted `/api/tags` + `/api/search` in main.go. Frontend: `useTags`, `TagChip`/`TagPicker`/`SearchBox`, rewritten `DocList`/`DocListItem` (chips + filter bar + search), `api.search`/tag methods + `splitSnippet`/`tagColorVar`; responsive sidebar drawer (hamburger + scrim, <768px) + `pointer:coarse` tap-target/affordance CSS; tap-to-open + outside-pointerdown-close for suggestion cards (touch); `useCheckpoint` `llmDown` flag → warm bilingual "小助手在休息" StatusBar note. Tests: `tags_test.go`, `search_test.go` (incl. update-trigger re-index). All builds/tests/vet/tsc/vite clean; live smoke vs binary on :8061 (dead LLM host) verified search EN/CJK/2-char, full tag lifecycle, check→502 warm path, bundle contents; FTS backfill of pre-existing docs verified. **All v1 phases (0–7) + post-v1 product (8–10) done.** Remaining: deferred bucket (auth/Copyleaks/deploy), on hold per user.
|
||||||
|
- 2026-06-26: **Phase 9 complete** (ESL superpowers: inline Chinese gloss + tone-rewrite). Decisions confirmed with user: gloss is an **offline EC dictionary** (instant, LLM-down-proof, fits the embedded-lexicon ethos), rewrite is a **selection bubble**. Data: `scripts/build_gloss.py` builds `internal/lexicon/data/gloss.json.gz` from ECDICT (66MB csv → 1.3MB gz, 57k freq-≤50k words, cleaned/trimmed). Backend: `lexicon` gloss map + `Gloss()`/`Result.Gloss` + `GET /api/gloss/{word}`; `llm.RunRewrite` + rewrite prompt/`styleGuidance`; `internal/suggestions/rewrite.go` (`POST /api/docs/:id/rewrite`, stateless, owner-scoped). Frontend: `GlossTip` hover tooltip (350ms delay, reuses `wordAt`, CJK-safe) + gloss line in `WordCard`; `SelectionBubble` + `RewritePreview` wired through `EditorCore` (onMouseMove/onSelectionUpdate, request-token guards, clears on edit/doc-switch); `api.glossWord`/`api.rewriteSelection`; CSS for the three new surfaces (+ print-hidden). Tests added in `lexicon` and `suggestions`. All builds/tests/vet/tsc/vite clean; live smoke vs fake vLLM on :8055 verified gloss + rewrite + 400/404/502 paths. Next: **Phase 10 (organization & polish).**
|
||||||
|
- 2026-06-26: **Phase 8 complete** (Trust foundation: version history + export) + empty-doc fix. Backend: migration `0003_document_versions`; `internal/docs/versions.go` (throttled auto-snapshot wired into `update`, manual snapshot, restore-with-pre_restore, prune to 40 auto, owner-scoped via join) and `internal/docs/export.go` (pure-Go Tiptap-JSON → md/html/txt/docx, no deps, CJK-safe filenames via RFC 5987). `DocumentVersion` model + kind constants. Tests: `versions_test.go`, `export_test.go` (incl. valid-zip docx assertion). Frontend: `api.client` version/export methods; `ExportMenu` + `HistoryPanel` components wired into the title row; `@media print` stylesheet + `.petal-no-print` for the browser PDF path; `editorEpoch` remount on restore. Empty-doc fix in `App.tsx` (blank drafts reuse-on-create + discard-on-leave via refs to dodge stale closures); deleted 2 orphan empties from the live :8099 DB. Multi-session plan agreed: this session = Phase 8; **Phase 9 (ESL gloss + tone-rewrite)** next, then **Phase 10 (search/folders/polish)**; **auth/deploy (was Phase 11) shelved** until user's foundational work lands. All builds/tests/vet clean; live smoke verified the full version+export+restore flow end-to-end. Next: **Phase 9.**
|
||||||
- 2026-06-25: Spec reviewed & amended (voice/grammar decoupled, ctx cap, routes, voice DB type, honey color, string-anchoring). Build plan created.
|
- 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 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.
|
- 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.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"gitea.parodia.dev/drwily/petal/internal/config"
|
"gitea.parodia.dev/drwily/petal/internal/config"
|
||||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
"gitea.parodia.dev/drwily/petal/internal/docs"
|
"gitea.parodia.dev/drwily/petal/internal/docs"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/lexicon"
|
||||||
"gitea.parodia.dev/drwily/petal/internal/llm"
|
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||||
"gitea.parodia.dev/drwily/petal/internal/suggestions"
|
"gitea.parodia.dev/drwily/petal/internal/suggestions"
|
||||||
"gitea.parodia.dev/drwily/petal/web"
|
"gitea.parodia.dev/drwily/petal/web"
|
||||||
@@ -61,12 +62,24 @@ func main() {
|
|||||||
|
|
||||||
// Document CRUD plus the doc-scoped checkpoint/list suggestion routes,
|
// Document CRUD plus the doc-scoped checkpoint/list suggestion routes,
|
||||||
// both under /api/docs.
|
// both under /api/docs.
|
||||||
docsRouter := docs.New(database).Routes()
|
docsHandler := docs.New(database)
|
||||||
|
docsRouter := docsHandler.Routes()
|
||||||
sug.RegisterDocRoutes(docsRouter)
|
sug.RegisterDocRoutes(docsRouter)
|
||||||
api.Mount("/docs", docsRouter)
|
api.Mount("/docs", docsRouter)
|
||||||
|
|
||||||
|
// Tag management (the roster) and cross-document full-text search.
|
||||||
|
api.Mount("/tags", docsHandler.TagRoutes())
|
||||||
|
api.Mount("/search", docsHandler.SearchRoutes())
|
||||||
|
|
||||||
// Per-suggestion actions (accept/dismiss) under /api/suggestions.
|
// Per-suggestion actions (accept/dismiss) under /api/suggestions.
|
||||||
api.Mount("/suggestions", sug.Routes())
|
api.Mount("/suggestions", sug.Routes())
|
||||||
|
|
||||||
|
// 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).
|
// Everything else: serve the embedded SPA (with index.html fallback for client routing).
|
||||||
|
|||||||
@@ -167,6 +167,99 @@ CREATE TABLE plagiarism_reports (
|
|||||||
|
|
||||||
CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);
|
CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);
|
||||||
CREATE INDEX idx_plagiarism_doc_id ON plagiarism_reports(doc_id);
|
CREATE INDEX idx_plagiarism_doc_id ON plagiarism_reports(doc_id);
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Per-document tone: guides the grammar-checkpoint LLM so advice fits
|
||||||
|
// the writer's target register (academic essay vs casual journal).
|
||||||
|
// 'general' means no specific tone steering.
|
||||||
|
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;
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,11 +21,59 @@ type Document struct {
|
|||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Content string `json:"content"` // Tiptap JSON
|
Content string `json:"content"` // Tiptap JSON
|
||||||
ContentText string `json:"content_text"` // plain text for the LLM
|
ContentText string `json:"content_text"` // plain text for the LLM
|
||||||
|
Tone string `json:"tone"` // target writing tone; steers LLM advice
|
||||||
WordCount int `json:"word_count"`
|
WordCount int `json:"word_count"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
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.
|
// Suggestion is a single LLM-proposed edit anchored to a span of the document.
|
||||||
//
|
//
|
||||||
// FromPos/ToPos are plaintext offsets into ContentText for server-side use only;
|
// FromPos/ToPos are plaintext offsets into ContentText for server-side use only;
|
||||||
|
|||||||
629
internal/docs/export.go
Normal file
629
internal/docs/export.go
Normal 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("&", "&", "<", "<", ">", ">", `"`, """, "'", "'")
|
||||||
|
return r.Replace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func xmlEscape(s string) string {
|
||||||
|
r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """)
|
||||||
|
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()
|
||||||
|
}
|
||||||
135
internal/docs/export_test.go
Normal file
135
internal/docs/export_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -33,19 +34,25 @@ func (h *Handler) Routes() chi.Router {
|
|||||||
r.Get("/{id}", h.get)
|
r.Get("/{id}", h.get)
|
||||||
r.Put("/{id}", h.update)
|
r.Put("/{id}", h.update)
|
||||||
r.Delete("/{id}", h.delete)
|
r.Delete("/{id}", h.delete)
|
||||||
|
h.versionRoutes(r)
|
||||||
|
h.exportRoutes(r)
|
||||||
|
h.registerTagRoutes(r)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
// docSummary is the lightweight shape returned by the list endpoint — enough to
|
// docSummary is the lightweight shape returned by the list endpoint — enough to
|
||||||
// render the DocList sidebar without shipping every document's full body.
|
// render the DocList sidebar without shipping every document's full body. Tags
|
||||||
|
// ride along so the sidebar can show chips and filter without a second request.
|
||||||
type docSummary struct {
|
type docSummary struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
WordCount int `json:"word_count"`
|
WordCount int `json:"word_count"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
Tags []db.Tag `json:"tags"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// list returns the local user's documents, most-recently-updated first.
|
// list returns the local user's documents, most-recently-updated first, each
|
||||||
|
// decorated with its tags.
|
||||||
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
|
||||||
rows, err := h.DB.Query(
|
rows, err := h.DB.Query(
|
||||||
`SELECT id, title, word_count, updated_at
|
`SELECT id, title, word_count, updated_at
|
||||||
@@ -61,6 +68,7 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
|
|||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
out := []docSummary{} // non-nil so an empty list serializes as [] not null
|
out := []docSummary{} // non-nil so an empty list serializes as [] not null
|
||||||
|
ids := []string{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var d docSummary
|
var d docSummary
|
||||||
if err := rows.Scan(&d.ID, &d.Title, &d.WordCount, &d.UpdatedAt); err != nil {
|
if err := rows.Scan(&d.ID, &d.Title, &d.WordCount, &d.UpdatedAt); err != nil {
|
||||||
@@ -68,11 +76,24 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
out = append(out, d)
|
out = append(out, d)
|
||||||
|
ids = append(ids, d.ID)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
serverError(w, err)
|
serverError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
byDoc, err := h.tagsByDoc(ids)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range out {
|
||||||
|
out[i].Tags = byDoc[out[i].ID] // nil → JSON null is fine; client treats as none
|
||||||
|
if out[i].Tags == nil {
|
||||||
|
out[i].Tags = []db.Tag{}
|
||||||
|
}
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,11 +102,11 @@ func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
|
|||||||
var doc db.Document
|
var doc db.Document
|
||||||
err := h.DB.QueryRow(
|
err := h.DB.QueryRow(
|
||||||
`INSERT INTO documents (user_id) VALUES (?)
|
`INSERT INTO documents (user_id) VALUES (?)
|
||||||
RETURNING id, user_id, title, content, content_text, word_count, created_at, updated_at`,
|
RETURNING id, user_id, title, content, content_text, tone, word_count, created_at, updated_at`,
|
||||||
db.LocalUserID,
|
db.LocalUserID,
|
||||||
).Scan(
|
).Scan(
|
||||||
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
|
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
|
||||||
&doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
|
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
serverError(w, err)
|
||||||
@@ -115,6 +136,7 @@ type updateRequest struct {
|
|||||||
Title *string `json:"title"`
|
Title *string `json:"title"`
|
||||||
Content *string `json:"content"`
|
Content *string `json:"content"`
|
||||||
ContentText *string `json:"content_text"`
|
ContentText *string `json:"content_text"`
|
||||||
|
Tone *string `json:"tone"`
|
||||||
WordCount *int `json:"word_count"`
|
WordCount *int `json:"word_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,10 +156,11 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
|
|||||||
SET title = COALESCE(?, title),
|
SET title = COALESCE(?, title),
|
||||||
content = COALESCE(?, content),
|
content = COALESCE(?, content),
|
||||||
content_text = COALESCE(?, content_text),
|
content_text = COALESCE(?, content_text),
|
||||||
|
tone = COALESCE(?, tone),
|
||||||
word_count = COALESCE(?, word_count),
|
word_count = COALESCE(?, word_count),
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ? AND user_id = ?`,
|
WHERE id = ? AND user_id = ?`,
|
||||||
req.Title, req.Content, req.ContentText, req.WordCount, id, db.LocalUserID,
|
req.Title, req.Content, req.ContentText, req.Tone, req.WordCount, id, db.LocalUserID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
serverError(w, err)
|
||||||
@@ -153,6 +176,16 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
|
|||||||
serverError(w, err)
|
serverError(w, err)
|
||||||
return
|
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)
|
writeJSON(w, http.StatusOK, doc)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,13 +210,13 @@ func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (h *Handler) fetch(id string) (db.Document, error) {
|
func (h *Handler) fetch(id string) (db.Document, error) {
|
||||||
var doc db.Document
|
var doc db.Document
|
||||||
err := h.DB.QueryRow(
|
err := h.DB.QueryRow(
|
||||||
`SELECT id, user_id, title, content, content_text, word_count, created_at, updated_at
|
`SELECT id, user_id, title, content, content_text, tone, word_count, created_at, updated_at
|
||||||
FROM documents
|
FROM documents
|
||||||
WHERE id = ? AND user_id = ?`,
|
WHERE id = ? AND user_id = ?`,
|
||||||
id, db.LocalUserID,
|
id, db.LocalUserID,
|
||||||
).Scan(
|
).Scan(
|
||||||
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
|
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
|
||||||
&doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
|
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
|
||||||
)
|
)
|
||||||
return doc, err
|
return doc, err
|
||||||
}
|
}
|
||||||
@@ -206,3 +239,4 @@ func serverError(w http.ResponseWriter, err error) {
|
|||||||
|
|
||||||
func badRequest(w http.ResponseWriter, msg string) { errorJSON(w, http.StatusBadRequest, msg) }
|
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 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
261
internal/docs/search.go
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
package docs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Search snippet shaping.
|
||||||
|
const (
|
||||||
|
// ftsMinRunes is the shortest query the trigram FTS index can match. Shorter
|
||||||
|
// queries (common for 2-character Chinese words) fall back to a LIKE scan.
|
||||||
|
ftsMinRunes = 3
|
||||||
|
|
||||||
|
// snippetContext is how many runes of context to show on each side of the
|
||||||
|
// matched term in a result snippet.
|
||||||
|
snippetContext = 28
|
||||||
|
|
||||||
|
// maxSearchResults caps how many hits we return — plenty for a personal
|
||||||
|
// corpus, and keeps the response small.
|
||||||
|
maxSearchResults = 50
|
||||||
|
|
||||||
|
// hlStart/hlEnd wrap the matched span in a snippet. They're control-character
|
||||||
|
// sentinels that never occur in real text, so the client can split on them to
|
||||||
|
// highlight the match without escaping user content.
|
||||||
|
hlStart = "\x01"
|
||||||
|
hlEnd = "\x02"
|
||||||
|
)
|
||||||
|
|
||||||
|
// searchResult is one hit: a document summary plus a highlighted snippet showing
|
||||||
|
// where the query matched.
|
||||||
|
type searchResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
WordCount int `json:"word_count"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
Snippet string `json:"snippet"`
|
||||||
|
Tags []db.Tag `json:"tags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchRoutes returns the router mounted at /api/search.
|
||||||
|
func (h *Handler) SearchRoutes() chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/", h.search)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// search runs a cross-document full-text search for the local user. Queries of
|
||||||
|
// three or more runes use the trigram FTS index (fast, ranked); shorter queries
|
||||||
|
// fall back to a LIKE scan so 2-character Chinese words still resolve. Either way
|
||||||
|
// the snippet is built in Go from the original text, for clean word boundaries
|
||||||
|
// and a uniform highlight format.
|
||||||
|
func (h *Handler) search(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
if q == "" {
|
||||||
|
writeJSON(w, http.StatusOK, []searchResult{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
id, title, contentText, updatedAt string
|
||||||
|
wordCount int
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
|
||||||
|
if utf8.RuneCountInString(q) >= ftsMinRunes {
|
||||||
|
// Wrap the whole query as one FTS phrase (doubling embedded quotes), so
|
||||||
|
// special characters are treated literally and trigram does a contiguous
|
||||||
|
// substring match.
|
||||||
|
phrase := `"` + strings.ReplaceAll(q, `"`, `""`) + `"`
|
||||||
|
sqlRows, err := h.DB.Query(
|
||||||
|
`SELECT d.id, d.title, d.content_text, d.word_count, d.updated_at
|
||||||
|
FROM documents_fts f
|
||||||
|
JOIN documents d ON d.id = f.doc_id
|
||||||
|
WHERE documents_fts MATCH ? AND d.user_id = ?
|
||||||
|
ORDER BY rank
|
||||||
|
LIMIT ?`,
|
||||||
|
phrase, db.LocalUserID, maxSearchResults,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer sqlRows.Close()
|
||||||
|
for sqlRows.Next() {
|
||||||
|
var rw row
|
||||||
|
if err := sqlRows.Scan(&rw.id, &rw.title, &rw.contentText, &rw.wordCount, &rw.updatedAt); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows = append(rows, rw)
|
||||||
|
}
|
||||||
|
if err := sqlRows.Err(); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Short query: LIKE scan over title + body. Escape LIKE wildcards so a
|
||||||
|
// literal % or _ in the query matches itself.
|
||||||
|
like := "%" + escapeLike(q) + "%"
|
||||||
|
sqlRows, err := h.DB.Query(
|
||||||
|
`SELECT id, title, content_text, word_count, updated_at
|
||||||
|
FROM documents
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND (title LIKE ? ESCAPE '\' OR content_text LIKE ? ESCAPE '\')
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
db.LocalUserID, like, like, maxSearchResults,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer sqlRows.Close()
|
||||||
|
for sqlRows.Next() {
|
||||||
|
var rw row
|
||||||
|
if err := sqlRows.Scan(&rw.id, &rw.title, &rw.contentText, &rw.wordCount, &rw.updatedAt); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows = append(rows, rw)
|
||||||
|
}
|
||||||
|
if err := sqlRows.Err(); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]searchResult, 0, len(rows))
|
||||||
|
ids := make([]string, 0, len(rows))
|
||||||
|
for _, rw := range rows {
|
||||||
|
out = append(out, searchResult{
|
||||||
|
ID: rw.id,
|
||||||
|
Title: rw.title,
|
||||||
|
WordCount: rw.wordCount,
|
||||||
|
UpdatedAt: rw.updatedAt,
|
||||||
|
Snippet: buildSnippet(rw.title, rw.contentText, q),
|
||||||
|
})
|
||||||
|
ids = append(ids, rw.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
byDoc, err := h.tagsByDoc(ids)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range out {
|
||||||
|
out[i].Tags = byDoc[out[i].ID]
|
||||||
|
if out[i].Tags == nil {
|
||||||
|
out[i].Tags = []db.Tag{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// escapeLike escapes the LIKE metacharacters (% and _) and the escape character
|
||||||
|
// itself so the query is matched literally. Pairs with `ESCAPE '\'`.
|
||||||
|
func escapeLike(s string) string {
|
||||||
|
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
|
||||||
|
return r.Replace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSnippet returns a short excerpt around the first case-insensitive match of
|
||||||
|
// query, with the matched span wrapped in the hl sentinels. It prefers a body
|
||||||
|
// match (with surrounding context); if the query only appears in the title it
|
||||||
|
// highlights the title instead; otherwise it shows the body's opening so a result
|
||||||
|
// always shows something. Windowing is rune-aware so CJK is never split
|
||||||
|
// mid-character.
|
||||||
|
func buildSnippet(title, body, query string) string {
|
||||||
|
runes := []rune(body)
|
||||||
|
qLen := utf8.RuneCountInString(query)
|
||||||
|
if idx := runeIndexFold(runes, query); idx >= 0 {
|
||||||
|
start := idx - snippetContext
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
end := idx + qLen + snippetContext
|
||||||
|
if end > len(runes) {
|
||||||
|
end = len(runes)
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
if start > 0 {
|
||||||
|
b.WriteString("…")
|
||||||
|
}
|
||||||
|
b.WriteString(string(runes[start:idx]))
|
||||||
|
b.WriteString(hlStart)
|
||||||
|
b.WriteString(string(runes[idx : idx+qLen]))
|
||||||
|
b.WriteString(hlEnd)
|
||||||
|
b.WriteString(string(runes[idx+qLen : end]))
|
||||||
|
if end < len(runes) {
|
||||||
|
b.WriteString("…")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body had no literal match (title-only hit, or an FTS span the literal scan
|
||||||
|
// can't reproduce). Highlight the title if the query is there.
|
||||||
|
tRunes := []rune(title)
|
||||||
|
if idx := runeIndexFold(tRunes, query); idx >= 0 {
|
||||||
|
return string(tRunes[:idx]) + hlStart + string(tRunes[idx:idx+qLen]) + hlEnd + string(tRunes[idx+qLen:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: the body's opening as plain context.
|
||||||
|
return clip(runes, 0, 2*snippetContext+qLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clip returns runes[start:start+n] (bounded), with a trailing ellipsis when the
|
||||||
|
// body continues. Used for the no-direct-match fallback.
|
||||||
|
func clip(runes []rune, start, n int) string {
|
||||||
|
if start >= len(runes) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
end := start + n
|
||||||
|
trailing := ""
|
||||||
|
if end < len(runes) {
|
||||||
|
trailing = "…"
|
||||||
|
} else {
|
||||||
|
end = len(runes)
|
||||||
|
}
|
||||||
|
return string(runes[start:end]) + trailing
|
||||||
|
}
|
||||||
|
|
||||||
|
// runeIndexFold finds the first index (in runes) where query occurs in runes,
|
||||||
|
// case-insensitively. Returns -1 if absent. Simple O(n*m) scan — fine for
|
||||||
|
// single-document snippet building.
|
||||||
|
func runeIndexFold(runes []rune, query string) int {
|
||||||
|
q := []rune(strings.ToLower(query))
|
||||||
|
if len(q) == 0 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
lower := make([]rune, len(runes))
|
||||||
|
for i, r := range runes {
|
||||||
|
lower[i] = toLowerRune(r)
|
||||||
|
}
|
||||||
|
for i := 0; i+len(q) <= len(lower); i++ {
|
||||||
|
match := true
|
||||||
|
for j := 0; j < len(q); j++ {
|
||||||
|
if lower[i+j] != q[j] {
|
||||||
|
match = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if match {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// toLowerRune lowercases ASCII letters (the only case-bearing script here); CJK
|
||||||
|
// and other runes pass through unchanged.
|
||||||
|
func toLowerRune(r rune) rune {
|
||||||
|
if r >= 'A' && r <= 'Z' {
|
||||||
|
return r + ('a' - 'A')
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
111
internal/docs/search_test.go
Normal file
111
internal/docs/search_test.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package docs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSearch(t *testing.T) {
|
||||||
|
srv := newFullServer(t)
|
||||||
|
|
||||||
|
createDoc(t, srv, "My Essay", "The quick brown fox jumps over the lazy dog")
|
||||||
|
createDoc(t, srv, "我的日记", "今天天气很好,我去公园散步,心情非常愉快")
|
||||||
|
createDoc(t, srv, "Notes", "groceries and errands for the weekend")
|
||||||
|
|
||||||
|
search := func(q string) []searchResult {
|
||||||
|
t.Helper()
|
||||||
|
rec := do(t, srv, http.MethodGet, "/search?q="+urlQuery(q), "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("search %q: %d %s", q, rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var out []searchResult
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatalf("decode search %q: %v", q, err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// English, ≥3 chars → FTS path. Snippet wraps the match in sentinels.
|
||||||
|
res := search("quick")
|
||||||
|
if len(res) != 1 || res[0].Title != "My Essay" {
|
||||||
|
t.Fatalf("english search: %+v", res)
|
||||||
|
}
|
||||||
|
if !strings.Contains(res[0].Snippet, hlStart+"quick"+hlEnd) {
|
||||||
|
t.Fatalf("snippet not highlighted: %q", res[0].Snippet)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chinese, ≥3 chars → FTS path (trigram handles CJK).
|
||||||
|
res = search("天气很好")
|
||||||
|
if len(res) != 1 || res[0].Title != "我的日记" {
|
||||||
|
t.Fatalf("chinese fts search: %+v", res)
|
||||||
|
}
|
||||||
|
if !strings.Contains(res[0].Snippet, hlStart+"天气很好"+hlEnd) {
|
||||||
|
t.Fatalf("cjk snippet not highlighted: %q", res[0].Snippet)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2-character Chinese word → LIKE fallback (below the trigram minimum).
|
||||||
|
res = search("公园")
|
||||||
|
if len(res) != 1 || res[0].Title != "我的日记" {
|
||||||
|
t.Fatalf("cjk short (LIKE) search: %+v", res)
|
||||||
|
}
|
||||||
|
if !strings.Contains(res[0].Snippet, hlStart+"公园"+hlEnd) {
|
||||||
|
t.Fatalf("short cjk snippet not highlighted: %q", res[0].Snippet)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title-only match still returns the doc, highlighting the title.
|
||||||
|
res = search("Notes")
|
||||||
|
if len(res) != 1 || res[0].Title != "Notes" {
|
||||||
|
t.Fatalf("title search: %+v", res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case-insensitive.
|
||||||
|
if got := search("QUICK"); len(got) != 1 {
|
||||||
|
t.Fatalf("case-insensitive search: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No match → empty (non-nil) array.
|
||||||
|
if got := search("zzzznotthere"); len(got) != 0 {
|
||||||
|
t.Fatalf("expected no results, got %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty query → empty array, no error.
|
||||||
|
rec := do(t, srv, http.MethodGet, "/search?q=", "")
|
||||||
|
if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != "[]" {
|
||||||
|
t.Fatalf("empty query: %d %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An edit re-indexes via the update trigger: the old term stops matching, the
|
||||||
|
// new term starts.
|
||||||
|
res = search("quick")
|
||||||
|
docID := res[0].ID
|
||||||
|
body := `{"content":"{}","content_text":"the slow purple turtle ambles along","word_count":6}`
|
||||||
|
do(t, srv, http.MethodPut, "/docs/"+docID, body)
|
||||||
|
if got := search("quick"); len(got) != 0 {
|
||||||
|
t.Fatalf("stale term still matches after edit: %+v", got)
|
||||||
|
}
|
||||||
|
if got := search("purple turtle"); len(got) != 1 || got[0].ID != docID {
|
||||||
|
t.Fatalf("new term not indexed after edit: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// urlQuery percent-encodes a search term for the query string.
|
||||||
|
func urlQuery(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range []byte(s) {
|
||||||
|
if r == ' ' {
|
||||||
|
b.WriteByte('+')
|
||||||
|
} else if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
||||||
|
b.WriteByte(r)
|
||||||
|
} else {
|
||||||
|
b.WriteString(percentByte(r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func percentByte(b byte) string {
|
||||||
|
const hex = "0123456789ABCDEF"
|
||||||
|
return string([]byte{'%', hex[b>>4], hex[b&0xf]})
|
||||||
|
}
|
||||||
303
internal/docs/tags.go
Normal file
303
internal/docs/tags.go
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
package docs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// validTagColors is the palette a tag may use, mirrored from the design tokens.
|
||||||
|
// An unknown color on input is coerced to rose so the UI always has a token.
|
||||||
|
var validTagColors = map[string]bool{
|
||||||
|
db.TagColorRose: true, db.TagColorMint: true, db.TagColorPeach: true,
|
||||||
|
db.TagColorLavender: true, db.TagColorSky: true, db.TagColorHoney: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeColor(c string) string {
|
||||||
|
if validTagColors[c] {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
return db.TagColorRose
|
||||||
|
}
|
||||||
|
|
||||||
|
// TagRoutes returns the router mounted at /api/tags for tag management (the
|
||||||
|
// roster the writer creates and recolors), separate from the per-document
|
||||||
|
// assignment routes registered on the docs router.
|
||||||
|
func (h *Handler) TagRoutes() chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/", h.listTags)
|
||||||
|
r.Post("/", h.createTag)
|
||||||
|
r.Patch("/{id}", h.updateTag)
|
||||||
|
r.Delete("/{id}", h.deleteTag)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerTagRoutes adds the per-document tag assignment routes onto the docs
|
||||||
|
// sub-router so they resolve under /api/docs/{id}/tags.
|
||||||
|
func (h *Handler) registerTagRoutes(r chi.Router) {
|
||||||
|
r.Post("/{id}/tags", h.assignTag)
|
||||||
|
r.Delete("/{id}/tags/{tagId}", h.unassignTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listTags returns the user's tags alphabetically, each with the number of
|
||||||
|
// documents that wear it (so the sidebar can show counts and hide empties later
|
||||||
|
// if desired).
|
||||||
|
func (h *Handler) listTags(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := h.DB.Query(
|
||||||
|
`SELECT t.id, t.name, t.color, COUNT(dt.doc_id)
|
||||||
|
FROM tags t
|
||||||
|
LEFT JOIN document_tags dt ON dt.tag_id = t.id
|
||||||
|
WHERE t.user_id = ?
|
||||||
|
GROUP BY t.id
|
||||||
|
ORDER BY t.name COLLATE NOCASE`,
|
||||||
|
db.LocalUserID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []db.Tag{} // non-nil so an empty roster serializes as []
|
||||||
|
for rows.Next() {
|
||||||
|
var t db.Tag
|
||||||
|
if err := rows.Scan(&t.ID, &t.Name, &t.Color, &t.DocCount); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
type tagRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTag adds a tag. It's idempotent on (user, name): re-creating an existing
|
||||||
|
// tag returns it unchanged rather than erroring, so the client can "create or
|
||||||
|
// reuse" in one call. Recoloring is done via updateTag.
|
||||||
|
func (h *Handler) createTag(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req tagRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
badRequest(w, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(req.Name)
|
||||||
|
if name == "" {
|
||||||
|
badRequest(w, "tag name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var t db.Tag
|
||||||
|
err := h.DB.QueryRow(
|
||||||
|
`INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(user_id, name) DO UPDATE SET name = excluded.name
|
||||||
|
RETURNING id, name, color`,
|
||||||
|
db.LocalUserID, name, normalizeColor(req.Color),
|
||||||
|
).Scan(&t.ID, &t.Name, &t.Color)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateTag renames and/or recolors a tag. Both fields optional via pointers so
|
||||||
|
// a recolor needn't resend the name.
|
||||||
|
func (h *Handler) updateTag(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Color *string `json:"color"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
badRequest(w, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var namePtr, colorPtr any
|
||||||
|
if req.Name != nil {
|
||||||
|
n := strings.TrimSpace(*req.Name)
|
||||||
|
if n == "" {
|
||||||
|
badRequest(w, "tag name cannot be empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
namePtr = n
|
||||||
|
}
|
||||||
|
if req.Color != nil {
|
||||||
|
colorPtr = normalizeColor(*req.Color)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := h.DB.Exec(
|
||||||
|
`UPDATE tags
|
||||||
|
SET name = COALESCE(?, name),
|
||||||
|
color = COALESCE(?, color)
|
||||||
|
WHERE id = ? AND user_id = ?`,
|
||||||
|
namePtr, colorPtr, id, db.LocalUserID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
notFoundMsg(w, "tag not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var t db.Tag
|
||||||
|
if err := h.DB.QueryRow(
|
||||||
|
`SELECT id, name, color FROM tags WHERE id = ? AND user_id = ?`,
|
||||||
|
id, db.LocalUserID,
|
||||||
|
).Scan(&t.ID, &t.Name, &t.Color); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteTag removes a tag; its document assignments cascade away via the FK.
|
||||||
|
func (h *Handler) deleteTag(w http.ResponseWriter, r *http.Request) {
|
||||||
|
res, err := h.DB.Exec(
|
||||||
|
`DELETE FROM tags WHERE id = ? AND user_id = ?`,
|
||||||
|
chi.URLParam(r, "id"), db.LocalUserID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
notFoundMsg(w, "tag not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// assignTag attaches a tag to a document. Both must belong to the local user;
|
||||||
|
// the assignment is idempotent (re-assigning is a no-op, not an error).
|
||||||
|
func (h *Handler) assignTag(w http.ResponseWriter, r *http.Request) {
|
||||||
|
docID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
TagID string `json:"tag_id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
badRequest(w, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.TagID == "" {
|
||||||
|
badRequest(w, "tag_id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify both the doc and the tag belong to the user before linking, so a
|
||||||
|
// stray id can't cross-link another account's rows.
|
||||||
|
if !h.ownsDoc(docID) {
|
||||||
|
notFound(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !h.ownsTag(req.TagID) {
|
||||||
|
notFoundMsg(w, "tag not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := h.DB.Exec(
|
||||||
|
`INSERT INTO document_tags (doc_id, tag_id) VALUES (?, ?)
|
||||||
|
ON CONFLICT(doc_id, tag_id) DO NOTHING`,
|
||||||
|
docID, req.TagID,
|
||||||
|
); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// unassignTag detaches a tag from a document.
|
||||||
|
func (h *Handler) unassignTag(w http.ResponseWriter, r *http.Request) {
|
||||||
|
docID := chi.URLParam(r, "id")
|
||||||
|
tagID := chi.URLParam(r, "tagId")
|
||||||
|
|
||||||
|
if !h.ownsDoc(docID) {
|
||||||
|
notFound(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := h.DB.Exec(
|
||||||
|
`DELETE FROM document_tags WHERE doc_id = ? AND tag_id = ?`,
|
||||||
|
docID, tagID,
|
||||||
|
); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownsDoc reports whether a document belongs to the local user.
|
||||||
|
func (h *Handler) ownsDoc(docID string) bool {
|
||||||
|
var exists bool
|
||||||
|
_ = h.DB.QueryRow(
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM documents WHERE id = ? AND user_id = ?)`,
|
||||||
|
docID, db.LocalUserID,
|
||||||
|
).Scan(&exists)
|
||||||
|
return exists
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownsTag reports whether a tag belongs to the local user.
|
||||||
|
func (h *Handler) ownsTag(tagID string) bool {
|
||||||
|
var exists bool
|
||||||
|
_ = h.DB.QueryRow(
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM tags WHERE id = ? AND user_id = ?)`,
|
||||||
|
tagID, db.LocalUserID,
|
||||||
|
).Scan(&exists)
|
||||||
|
return exists
|
||||||
|
}
|
||||||
|
|
||||||
|
// tagsByDoc loads the tags for a set of documents in one query and groups them
|
||||||
|
// by doc id. Used to decorate the document list and search results without an
|
||||||
|
// N+1 of per-doc queries. Returns an empty (non-nil) map when ids is empty.
|
||||||
|
func (h *Handler) tagsByDoc(ids []string) (map[string][]db.Tag, error) {
|
||||||
|
out := map[string][]db.Tag{}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the IN (?, ?, …) placeholder list.
|
||||||
|
ph := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")
|
||||||
|
args := make([]any, 0, len(ids)+1)
|
||||||
|
for _, id := range ids {
|
||||||
|
args = append(args, id)
|
||||||
|
}
|
||||||
|
args = append(args, db.LocalUserID)
|
||||||
|
|
||||||
|
rows, err := h.DB.Query(
|
||||||
|
`SELECT dt.doc_id, t.id, t.name, t.color
|
||||||
|
FROM document_tags dt
|
||||||
|
JOIN tags t ON t.id = dt.tag_id
|
||||||
|
WHERE dt.doc_id IN (`+ph+`) AND t.user_id = ?
|
||||||
|
ORDER BY t.name COLLATE NOCASE`,
|
||||||
|
args...,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var docID string
|
||||||
|
var t db.Tag
|
||||||
|
if err := rows.Scan(&docID, &t.ID, &t.Name, &t.Color); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[docID] = append(out[docID], t)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
167
internal/docs/tags_test.go
Normal file
167
internal/docs/tags_test.go
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
package docs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newFullServer mounts the document, tag, and search routers under the same base
|
||||||
|
// paths as the real server (/docs, /tags, /search) so tests can exercise the
|
||||||
|
// cross-router flows (create a doc, tag it, list, search).
|
||||||
|
func newFullServer(t *testing.T) http.Handler {
|
||||||
|
t.Helper()
|
||||||
|
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { database.Close() })
|
||||||
|
|
||||||
|
h := New(database)
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Mount("/docs", h.Routes())
|
||||||
|
r.Mount("/tags", h.TagRoutes())
|
||||||
|
r.Mount("/search", h.SearchRoutes())
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// createDoc makes a document with the given title/body and returns its id.
|
||||||
|
func createDoc(t *testing.T, srv http.Handler, title, text string) string {
|
||||||
|
t.Helper()
|
||||||
|
rec := do(t, srv, http.MethodPost, "/docs", "")
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create doc: %d %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var d db.Document
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &d)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"title": title, "content": "{}", "content_text": text, "word_count": 1,
|
||||||
|
})
|
||||||
|
rec = do(t, srv, http.MethodPut, "/docs/"+d.ID, string(body))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("save doc: %d %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
return d.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagLifecycle(t *testing.T) {
|
||||||
|
srv := newFullServer(t)
|
||||||
|
|
||||||
|
// Empty roster serializes as [].
|
||||||
|
rec := do(t, srv, http.MethodGet, "/tags", "")
|
||||||
|
if rec.Code != http.StatusOK || rec.Body.String() == "null\n" {
|
||||||
|
t.Fatalf("empty tags: %d %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a tag.
|
||||||
|
rec = do(t, srv, http.MethodPost, "/tags", `{"name":"日记","color":"lavender"}`)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create tag: %d %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var tag db.Tag
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &tag)
|
||||||
|
if tag.ID == "" || tag.Name != "日记" || tag.Color != "lavender" {
|
||||||
|
t.Fatalf("bad created tag: %+v", tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-creating the same name is idempotent (returns the same id, keeps color).
|
||||||
|
rec = do(t, srv, http.MethodPost, "/tags", `{"name":"日记","color":"sky"}`)
|
||||||
|
var again db.Tag
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &again)
|
||||||
|
if again.ID != tag.ID || again.Color != "lavender" {
|
||||||
|
t.Fatalf("create not idempotent: %+v vs %+v", again, tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown color is coerced to rose.
|
||||||
|
rec = do(t, srv, http.MethodPost, "/tags", `{"name":"work","color":"chartreuse"}`)
|
||||||
|
var work db.Tag
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &work)
|
||||||
|
if work.Color != "rose" {
|
||||||
|
t.Fatalf("unknown color not coerced: %+v", work)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty name is rejected.
|
||||||
|
if rec = do(t, srv, http.MethodPost, "/tags", `{"name":" "}`); rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("empty name should be 400: %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recolor + rename via PATCH.
|
||||||
|
rec = do(t, srv, http.MethodPatch, "/tags/"+tag.ID, `{"color":"honey"}`)
|
||||||
|
var recolored db.Tag
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &recolored)
|
||||||
|
if recolored.Color != "honey" || recolored.Name != "日记" {
|
||||||
|
t.Fatalf("patch clobbered/failed: %+v", recolored)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign both tags to a document.
|
||||||
|
docID := createDoc(t, srv, "My Journal", "today was a good day")
|
||||||
|
if rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/tags", `{"tag_id":"`+tag.ID+`"}`); rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("assign tag: %d %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
// Re-assigning is idempotent.
|
||||||
|
if rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/tags", `{"tag_id":"`+tag.ID+`"}`); rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("re-assign tag: %d", rec.Code)
|
||||||
|
}
|
||||||
|
if rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/tags", `{"tag_id":"`+work.ID+`"}`); rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("assign 2nd tag: %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The doc list now carries both tags, ordered by name.
|
||||||
|
rec = do(t, srv, http.MethodGet, "/docs", "")
|
||||||
|
var docs []docSummary
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &docs)
|
||||||
|
if len(docs) != 1 || len(docs[0].Tags) != 2 {
|
||||||
|
t.Fatalf("doc list tags: %+v", docs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag list reports doc counts.
|
||||||
|
rec = do(t, srv, http.MethodGet, "/tags", "")
|
||||||
|
var roster []db.Tag
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &roster)
|
||||||
|
var found bool
|
||||||
|
for _, rt := range roster {
|
||||||
|
if rt.ID == tag.ID {
|
||||||
|
found = true
|
||||||
|
if rt.DocCount != 1 {
|
||||||
|
t.Fatalf("expected doc_count 1, got %d", rt.DocCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("tag missing from roster: %+v", roster)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unassign one tag.
|
||||||
|
if rec = do(t, srv, http.MethodDelete, "/docs/"+docID+"/tags/"+work.ID, ""); rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("unassign: %d", rec.Code)
|
||||||
|
}
|
||||||
|
rec = do(t, srv, http.MethodGet, "/docs", "")
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &docs)
|
||||||
|
if len(docs[0].Tags) != 1 || docs[0].Tags[0].ID != tag.ID {
|
||||||
|
t.Fatalf("after unassign: %+v", docs[0].Tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleting a tag cascades its assignments away.
|
||||||
|
if rec = do(t, srv, http.MethodDelete, "/tags/"+tag.ID, ""); rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("delete tag: %d", rec.Code)
|
||||||
|
}
|
||||||
|
rec = do(t, srv, http.MethodGet, "/docs", "")
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &docs)
|
||||||
|
if len(docs[0].Tags) != 0 {
|
||||||
|
t.Fatalf("tag delete did not cascade: %+v", docs[0].Tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assigning a non-existent tag 404s; tagging a non-existent doc 404s.
|
||||||
|
if rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/tags", `{"tag_id":"nope"}`); rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("assign unknown tag: %d", rec.Code)
|
||||||
|
}
|
||||||
|
if rec = do(t, srv, http.MethodPost, "/docs/nope/tags", `{"tag_id":"`+work.ID+`"}`); rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("assign to unknown doc: %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
245
internal/docs/versions.go
Normal file
245
internal/docs/versions.go
Normal 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
|
||||||
|
}
|
||||||
107
internal/docs/versions_test.go
Normal file
107
internal/docs/versions_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
29
internal/lexicon/data.go
Normal file
29
internal/lexicon/data.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// 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"
|
||||||
|
|
||||||
|
// definitionsGz is the gzipped Wordset definitions map: word → [[pos, def,
|
||||||
|
// example], …], lowercase keys. Built by the data-prep step (see commit notes).
|
||||||
|
//
|
||||||
|
//go:embed data/definitions.json.gz
|
||||||
|
var definitionsGz []byte
|
||||||
|
|
||||||
|
// synonymsGz is the gzipped Moby thesaurus map: headword → [synonym, …],
|
||||||
|
// lowercase keys, capped per word to keep the popover (and the binary) small.
|
||||||
|
//
|
||||||
|
//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
|
||||||
BIN
internal/lexicon/data/definitions.json.gz
Normal file
BIN
internal/lexicon/data/definitions.json.gz
Normal file
Binary file not shown.
BIN
internal/lexicon/data/gloss.json.gz
Normal file
BIN
internal/lexicon/data/gloss.json.gz
Normal file
Binary file not shown.
BIN
internal/lexicon/data/synonyms.json.gz
Normal file
BIN
internal/lexicon/data/synonyms.json.gz
Normal file
Binary file not shown.
81
internal/lexicon/handlers.go
Normal file
81
internal/lexicon/handlers.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package lexicon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler serves the word-lookup endpoint backed by a single shared Lexicon.
|
||||||
|
type Handler struct {
|
||||||
|
Lex *Lexicon
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a Handler with a fresh (lazily-loaded) Lexicon.
|
||||||
|
func NewHandler() *Handler { return &Handler{Lex: New()} }
|
||||||
|
|
||||||
|
// Routes returns the router mounted at /api/word. The word is a path segment so
|
||||||
|
// "/api/word/happy" reads naturally; it's URL-decoded to tolerate the rare
|
||||||
|
// punctuated token.
|
||||||
|
func (h *Handler) Routes() chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/{word}", h.lookup)
|
||||||
|
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.
|
||||||
|
func (h *Handler) lookup(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.Lookup(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")
|
||||||
|
// Word lookups are static for the life of the build; let the browser cache
|
||||||
|
// them so repeated right-clicks on the same word are instant.
|
||||||
|
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)
|
||||||
|
}
|
||||||
249
internal/lexicon/lexicon.go
Normal file
249
internal/lexicon/lexicon.go
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
package lexicon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Meaning is one sense of a word: its part of speech, the gloss, and an optional
|
||||||
|
// usage example. The frontend renders a few of these in the word popover.
|
||||||
|
type Meaning struct {
|
||||||
|
PartOfSpeech string `json:"part_of_speech"`
|
||||||
|
Definition string `json:"definition"`
|
||||||
|
Example string `json:"example,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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. 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.
|
||||||
|
const maxSynonyms = 16
|
||||||
|
|
||||||
|
// maxDefinitions caps the senses shown so the popover stays a glance, not an
|
||||||
|
// essay.
|
||||||
|
const maxDefinitions = 4
|
||||||
|
|
||||||
|
// Lexicon holds the lazily-loaded datasets. The maps are populated once, on the
|
||||||
|
// first Lookup, behind a sync.Once so startup stays instant and a load error is
|
||||||
|
// remembered rather than retried on every request.
|
||||||
|
type Lexicon struct {
|
||||||
|
once sync.Once
|
||||||
|
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.
|
||||||
|
func New() *Lexicon { return &Lexicon{} }
|
||||||
|
|
||||||
|
func (l *Lexicon) load() {
|
||||||
|
l.once.Do(func() {
|
||||||
|
if err := gunzipJSON(definitionsGz, &l.defs); err != nil {
|
||||||
|
l.loadErr = fmt.Errorf("load definitions: %w", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := gunzipJSON(synonymsGz, &l.synonyms); err != nil {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup returns the definition senses and synonyms for word. It first tries the
|
||||||
|
// word as written (lowercased), then a few simple morphological reductions
|
||||||
|
// (plurals, -ed/-ing/-ly) so "running" or "happily" still resolve. A word found
|
||||||
|
// in neither dataset yields a Result with empty lists (not an error).
|
||||||
|
func (l *Lexicon) Lookup(word string) (Result, error) {
|
||||||
|
l.load()
|
||||||
|
if l.loadErr != nil {
|
||||||
|
return Result{}, l.loadErr
|
||||||
|
}
|
||||||
|
|
||||||
|
norm := strings.ToLower(strings.TrimSpace(word))
|
||||||
|
res := Result{Word: word, Definitions: []Meaning{}, Synonyms: []string{}}
|
||||||
|
if norm == "" {
|
||||||
|
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))
|
||||||
|
if len(res.Definitions) >= maxDefinitions {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if syns := lookupSyns(l.synonyms, norm); syns != nil {
|
||||||
|
if len(syns) > maxSynonyms {
|
||||||
|
syns = syns[:maxSynonyms]
|
||||||
|
}
|
||||||
|
res.Synonyms = append(res.Synonyms, syns...)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
func lookupDefs(m map[string][][]string, word string) [][]string {
|
||||||
|
for _, c := range candidates(word) {
|
||||||
|
if v, ok := m[c]; ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupSyns(m map[string][]string, word string) []string {
|
||||||
|
for _, c := range candidates(word) {
|
||||||
|
if v, ok := m[c]; ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// candidates returns the lemma forms to try, in priority order: the word itself,
|
||||||
|
// then conservative de-inflections. This is deliberately lightweight — a full
|
||||||
|
// stemmer would over-reduce ("business" → "busy") and surface wrong entries; a
|
||||||
|
// handful of common English suffix rules covers the everyday cases without a
|
||||||
|
// dependency. Duplicates are fine (map lookup is cheap); order is what matters.
|
||||||
|
func candidates(word string) []string {
|
||||||
|
out := []string{word}
|
||||||
|
add := func(s string) {
|
||||||
|
if len(s) >= 2 && s != word {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(word, "ies"): // studies → study
|
||||||
|
add(word[:len(word)-3] + "y")
|
||||||
|
case strings.HasSuffix(word, "es"): // boxes → box, wishes → wish
|
||||||
|
add(word[:len(word)-2])
|
||||||
|
add(word[:len(word)-1])
|
||||||
|
case strings.HasSuffix(word, "s"): // cats → cat
|
||||||
|
add(word[:len(word)-1])
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(word, "ing") { // running → run, making → make
|
||||||
|
stem := word[:len(word)-3]
|
||||||
|
add(stem)
|
||||||
|
add(stem + "e")
|
||||||
|
add(undouble(stem))
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(word, "ed") { // hoped → hope, stopped → stop
|
||||||
|
stem := word[:len(word)-2]
|
||||||
|
add(stem)
|
||||||
|
add(word[:len(word)-1])
|
||||||
|
add(undouble(stem))
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(word, "ly") { // happily handled above via ies path? no — quickly → quick
|
||||||
|
add(word[:len(word)-2])
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(word, "ily") { // happily → happy
|
||||||
|
add(word[:len(word)-3] + "y")
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// undouble collapses a doubled final consonant (stopp → stop, runn → run) so the
|
||||||
|
// -ed/-ing stems of doubled-consonant verbs resolve to their base form.
|
||||||
|
func undouble(stem string) string {
|
||||||
|
n := len(stem)
|
||||||
|
if n >= 2 && stem[n-1] == stem[n-2] {
|
||||||
|
return stem[:n-1]
|
||||||
|
}
|
||||||
|
return stem
|
||||||
|
}
|
||||||
|
|
||||||
|
// toMeaning maps a compact [pos, def, example] triple from the dataset onto the
|
||||||
|
// JSON-friendly Meaning. The dataset always stores three elements, but we guard
|
||||||
|
// the length so a malformed row can't panic.
|
||||||
|
func toMeaning(m []string) Meaning {
|
||||||
|
var out Meaning
|
||||||
|
if len(m) > 0 {
|
||||||
|
out.PartOfSpeech = m[0]
|
||||||
|
}
|
||||||
|
if len(m) > 1 {
|
||||||
|
out.Definition = m[1]
|
||||||
|
}
|
||||||
|
if len(m) > 2 {
|
||||||
|
out.Example = m[2]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// gunzipJSON decompresses gz and decodes the JSON into v.
|
||||||
|
func gunzipJSON(gz []byte, v any) error {
|
||||||
|
r, err := gzip.NewReader(bytes.NewReader(gz))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
data, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return json.Unmarshal(data, v)
|
||||||
|
}
|
||||||
118
internal/lexicon/lexicon_test.go
Normal file
118
internal/lexicon/lexicon_test.go
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package lexicon
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestLookupKnownWord(t *testing.T) {
|
||||||
|
l := New()
|
||||||
|
res, err := l.Lookup("happy")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Lookup: %v", err)
|
||||||
|
}
|
||||||
|
if len(res.Definitions) == 0 {
|
||||||
|
t.Errorf("expected definitions for %q, got none", "happy")
|
||||||
|
}
|
||||||
|
if len(res.Synonyms) == 0 {
|
||||||
|
t.Errorf("expected synonyms for %q, got none", "happy")
|
||||||
|
}
|
||||||
|
if len(res.Synonyms) > maxSynonyms {
|
||||||
|
t.Errorf("synonyms not capped: got %d, want <= %d", len(res.Synonyms), maxSynonyms)
|
||||||
|
}
|
||||||
|
if len(res.Definitions) > maxDefinitions {
|
||||||
|
t.Errorf("definitions not capped: got %d, want <= %d", len(res.Definitions), maxDefinitions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupMorphology(t *testing.T) {
|
||||||
|
l := New()
|
||||||
|
// Inflected forms should resolve to their base entry via candidates().
|
||||||
|
for _, w := range []string{"running", "studies", "boxes", "quickly", "stopped"} {
|
||||||
|
res, err := l.Lookup(w)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Lookup(%q): %v", w, err)
|
||||||
|
}
|
||||||
|
if len(res.Definitions) == 0 && len(res.Synonyms) == 0 {
|
||||||
|
t.Errorf("expected some result for inflected %q, got nothing", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupUnknownWord(t *testing.T) {
|
||||||
|
l := New()
|
||||||
|
res, err := l.Lookup("zzzxqqq")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Lookup: %v", err)
|
||||||
|
}
|
||||||
|
if len(res.Definitions) != 0 || len(res.Synonyms) != 0 {
|
||||||
|
t.Errorf("expected empty result for nonsense word, got %+v", res)
|
||||||
|
}
|
||||||
|
// Empty result must still serialize as [] not null for the frontend.
|
||||||
|
if res.Definitions == nil || res.Synonyms == nil {
|
||||||
|
t.Errorf("empty slices must be non-nil for JSON []: %+v", res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
"studies": "study",
|
||||||
|
"running": "run",
|
||||||
|
"hoped": "hope",
|
||||||
|
"quickly": "quick",
|
||||||
|
"happily": "happy",
|
||||||
|
}
|
||||||
|
for word, want := range cases {
|
||||||
|
got := candidates(word)
|
||||||
|
found := false
|
||||||
|
for _, c := range got {
|
||||||
|
if c == want {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("candidates(%q) = %v, missing expected base %q", word, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,9 +31,9 @@ type checkpointResponse struct {
|
|||||||
// RunCheckpoint sends the grammar checkpoint and parses the JSON result. It
|
// RunCheckpoint sends the grammar checkpoint and parses the JSON result. It
|
||||||
// applies the latency-guard truncation and the checkpoint sampling parameters
|
// applies the latency-guard truncation and the checkpoint sampling parameters
|
||||||
// from the spec.
|
// from the spec.
|
||||||
func RunCheckpoint(ctx context.Context, client LLMClient, contentText string) ([]RawSuggestion, error) {
|
func RunCheckpoint(ctx context.Context, client LLMClient, contentText, tone string) ([]RawSuggestion, error) {
|
||||||
raw, err := client.Complete(ctx, CompletionRequest{
|
raw, err := client.Complete(ctx, CompletionRequest{
|
||||||
Messages: CheckpointMessages(TruncateDoc(contentText)),
|
Messages: CheckpointMessages(TruncateDoc(contentText), tone),
|
||||||
MaxTokens: 1024,
|
MaxTokens: 1024,
|
||||||
Temperature: 0.3,
|
Temperature: 0.3,
|
||||||
RepetitionPenalty: 1.15,
|
RepetitionPenalty: 1.15,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const checkpointSystemPrompt = `You are a warm, encouraging writing assistant he
|
|||||||
`Analyze the text below and identify up to 5 issues: grammar errors, unnatural phrasing, ` +
|
`Analyze the text below and identify up to 5 issues: grammar errors, unnatural phrasing, ` +
|
||||||
`incorrect idiom usage, or unclear sentences that are common ESL patterns.
|
`incorrect idiom usage, or unclear sentences that are common ESL patterns.
|
||||||
|
|
||||||
Be specific, friendly, and explain WHY each suggestion improves the writing.
|
Be specific, friendly, and explain WHY each suggestion improves the writing.%s
|
||||||
|
|
||||||
Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
|
Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
|
||||||
{
|
{
|
||||||
@@ -24,11 +24,32 @@ Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
|
|||||||
|
|
||||||
If the writing looks good, return: {"suggestions": []}`
|
If the writing looks good, return: {"suggestions": []}`
|
||||||
|
|
||||||
|
// toneGuidance returns a sentence steering the checkpoint toward the writer's
|
||||||
|
// chosen tone, or "" for the neutral default. The clause is appended to the
|
||||||
|
// checkpoint instructions so the model's phrasing suggestions fit the target
|
||||||
|
// register (e.g. an academic essay vs a casual journal). Unknown values fall
|
||||||
|
// back to no steering, so a stray tone string is harmless.
|
||||||
|
func toneGuidance(tone string) string {
|
||||||
|
clause, ok := map[string]string{
|
||||||
|
"academic": "formal, academic, and objective — suited to a school essay or research paper",
|
||||||
|
"professional": "polished and professional — suited to a workplace email or report",
|
||||||
|
"casual": "relaxed, friendly, and conversational",
|
||||||
|
"humorous": "light, playful, and good-humored",
|
||||||
|
"creative": "vivid, expressive, and imaginative — suited to a story or personal narrative",
|
||||||
|
"persuasive": "confident and persuasive — suited to an argument or opinion piece",
|
||||||
|
}[tone]
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "\n\nThe writer wants this document to read as " + clause + ". When phrasing could " +
|
||||||
|
"be improved, prefer suggestions that fit that tone, and gently flag wording that clashes with it."
|
||||||
|
}
|
||||||
|
|
||||||
// CheckpointMessages builds the message array for a grammar checkpoint over the
|
// CheckpointMessages builds the message array for a grammar checkpoint over the
|
||||||
// given (already-truncated) document text.
|
// given (already-truncated) document text, steered toward the document's tone.
|
||||||
func CheckpointMessages(contentText string) []Message {
|
func CheckpointMessages(contentText, tone string) []Message {
|
||||||
return []Message{
|
return []Message{
|
||||||
{Role: "system", Content: checkpointSystemPrompt},
|
{Role: "system", Content: fmt.Sprintf(checkpointSystemPrompt, toneGuidance(tone))},
|
||||||
{Role: "user", Content: contentText},
|
{Role: "user", Content: contentText},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,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 {
|
func AskPetalSystemPrompt(original, replacement, suggestionType, explanation, paragraph string) string {
|
||||||
return fmt.Sprintf(askPetalSystemTemplate, original, replacement, suggestionType, explanation, paragraph)
|
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
46
internal/llm/rewrite.go
Normal 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
|
||||||
|
}
|
||||||
@@ -16,7 +16,10 @@ const VoiceInterval = 20 * time.Second
|
|||||||
// for a Tier-1 voice pass and parses the JSON result. It reuses the checkpoint's
|
// for a Tier-1 voice pass and parses the JSON result. It reuses the checkpoint's
|
||||||
// tolerant parser and a larger token budget, since one pass may flag several
|
// tolerant parser and a larger token budget, since one pass may flag several
|
||||||
// passages. Each flag carries a null replacement (awareness-only).
|
// passages. Each flag carries a null replacement (awareness-only).
|
||||||
func RunVoice(ctx context.Context, client LLMClient, contentText string) ([]RawSuggestion, error) {
|
// The tone argument is accepted for a uniform pass signature but ignored: voice
|
||||||
|
// consistency is judged against the document's own established voice, not an
|
||||||
|
// externally-chosen register.
|
||||||
|
func RunVoice(ctx context.Context, client LLMClient, contentText, _ string) ([]RawSuggestion, error) {
|
||||||
raw, err := client.Complete(ctx, CompletionRequest{
|
raw, err := client.Complete(ctx, CompletionRequest{
|
||||||
Messages: VoiceMessages(contentText),
|
Messages: VoiceMessages(contentText),
|
||||||
MaxTokens: 2048,
|
MaxTokens: 2048,
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ func New(database *db.DB, client llm.LLMClient) *Handler {
|
|||||||
func (h *Handler) RegisterDocRoutes(r chi.Router) {
|
func (h *Handler) RegisterDocRoutes(r chi.Router) {
|
||||||
r.Post("/{id}/check", h.check)
|
r.Post("/{id}/check", h.check)
|
||||||
r.Post("/{id}/voice", h.voice)
|
r.Post("/{id}/voice", h.voice)
|
||||||
|
r.Post("/{id}/rewrite", h.rewrite)
|
||||||
r.Get("/{id}/suggestions", h.listForDoc)
|
r.Get("/{id}/suggestions", h.listForDoc)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,8 +70,9 @@ func (h *Handler) voice(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// pass is the signature shared by the grammar checkpoint and the voice pass:
|
// pass is the signature shared by the grammar checkpoint and the voice pass:
|
||||||
// given the document text it returns the model's raw suggestions.
|
// given the document text and the document's tone it returns the model's raw
|
||||||
type pass func(ctx context.Context, client llm.LLMClient, contentText string) ([]llm.RawSuggestion, error)
|
// suggestions. The voice pass ignores tone (see llm.RunVoice).
|
||||||
|
type pass func(ctx context.Context, client llm.LLMClient, contentText, tone string) ([]llm.RawSuggestion, error)
|
||||||
|
|
||||||
// runPass is the shared body for both LLM passes. It loads the document text,
|
// runPass is the shared body for both LLM passes. It loads the document text,
|
||||||
// enforces the pass's per-document rate limit, runs the model, swaps in the
|
// enforces the pass's per-document rate limit, runs the model, swaps in the
|
||||||
@@ -79,11 +81,11 @@ type pass func(ctx context.Context, client llm.LLMClient, contentText string) ([
|
|||||||
func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.RateLimiter, run pass, scope pendingScope) {
|
func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.RateLimiter, run pass, scope pendingScope) {
|
||||||
docID := chi.URLParam(r, "id")
|
docID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
var contentText string
|
var contentText, tone string
|
||||||
err := h.DB.QueryRow(
|
err := h.DB.QueryRow(
|
||||||
`SELECT content_text FROM documents WHERE id = ? AND user_id = ?`,
|
`SELECT content_text, tone FROM documents WHERE id = ? AND user_id = ?`,
|
||||||
docID, db.LocalUserID,
|
docID, db.LocalUserID,
|
||||||
).Scan(&contentText)
|
).Scan(&contentText, &tone)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
errorJSON(w, http.StatusNotFound, "document not found")
|
errorJSON(w, http.StatusNotFound, "document not found")
|
||||||
return
|
return
|
||||||
@@ -111,7 +113,7 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, err := run(r.Context(), h.Client, contentText)
|
raw, err := run(r.Context(), h.Client, contentText, tone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorJSON(w, http.StatusBadGateway, "llm pass failed: "+err.Error())
|
errorJSON(w, http.StatusBadGateway, "llm pass failed: "+err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
76
internal/suggestions/rewrite.go
Normal file
76
internal/suggestions/rewrite.go
Normal 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})
|
||||||
|
}
|
||||||
79
internal/suggestions/rewrite_test.go
Normal file
79
internal/suggestions/rewrite_test.go
Normal 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
106
scripts/build_gloss.py
Normal 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])
|
||||||
238
web/src/App.tsx
238
web/src/App.tsx
@@ -1,10 +1,14 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { api, type DocSummary, type Document, type Suggestion } from './api/client'
|
import { api, type DocSummary, type Document, type Suggestion, type Tag, type TagColor } from './api/client'
|
||||||
import { useAutoSave } from './hooks/useAutoSave'
|
import { useAutoSave } from './hooks/useAutoSave'
|
||||||
import { useCheckpoint } from './hooks/useCheckpoint'
|
import { useCheckpoint } from './hooks/useCheckpoint'
|
||||||
import { useSpellChecker } from './hooks/useSpellChecker'
|
import { useSpellChecker } from './hooks/useSpellChecker'
|
||||||
|
import { useTags } from './hooks/useTags'
|
||||||
import { DocList } from './components/DocList/DocList'
|
import { DocList } from './components/DocList/DocList'
|
||||||
import { EditorCore, type EditorChange } from './components/Editor/EditorCore'
|
import { EditorCore, type EditorChange } from './components/Editor/EditorCore'
|
||||||
|
import { ToneSelect } from './components/Editor/ToneSelect'
|
||||||
|
import { ExportMenu } from './components/Export/ExportMenu'
|
||||||
|
import { HistoryPanel } from './components/History/HistoryPanel'
|
||||||
import { StatusBar } from './components/StatusBar/StatusBar'
|
import { StatusBar } from './components/StatusBar/StatusBar'
|
||||||
import { PetalCompanion } from './components/Companion/PetalCompanion'
|
import { PetalCompanion } from './components/Companion/PetalCompanion'
|
||||||
import { UpdateBanner } from './components/UpdateBanner/UpdateBanner'
|
import { UpdateBanner } from './components/UpdateBanner/UpdateBanner'
|
||||||
@@ -16,41 +20,128 @@ export default function App() {
|
|||||||
const [currentDoc, setCurrentDoc] = useState<Document | null>(null)
|
const [currentDoc, setCurrentDoc] = useState<Document | null>(null)
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [wordCount, setWordCount] = useState(0)
|
const [wordCount, setWordCount] = useState(0)
|
||||||
|
// The current document's target tone (steers checkpoint advice) and its live
|
||||||
|
// plain text (drives the expanded writing-stats panel in the StatusBar).
|
||||||
|
const [tone, setTone] = useState('general')
|
||||||
|
const [docText, setDocText] = useState('')
|
||||||
const [ready, setReady] = useState(false)
|
const [ready, setReady] = useState(false)
|
||||||
// Distraction-free mode: entered on editor focus, collapses the doc-list
|
// Distraction-free mode: entered on editor focus, collapses the doc-list
|
||||||
// sidebar. Escape or a click outside the editor canvas restores it.
|
// sidebar. Escape or a click outside the editor canvas restores it.
|
||||||
const [focusMode, setFocusMode] = useState(false)
|
const [focusMode, setFocusMode] = useState(false)
|
||||||
|
// Mobile drawer: below the tablet breakpoint the sidebar is an overlay toggled
|
||||||
|
// by the header hamburger. Ignored on wide screens (sidebar is always in-flow).
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||||
const canvasRef = useRef<HTMLDivElement>(null)
|
const canvasRef = useRef<HTMLDivElement>(null)
|
||||||
// Monotonic counters the companion watches to react to writing + accepts.
|
// Monotonic counters the companion watches to react to writing + accepts.
|
||||||
const [editTick, setEditTick] = useState(0)
|
const [editTick, setEditTick] = useState(0)
|
||||||
const [acceptTick, setAcceptTick] = 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 { status, schedule, saveNow } = useAutoSave(currentDoc?.id ?? null)
|
||||||
const {
|
const {
|
||||||
suggestions,
|
suggestions,
|
||||||
checking,
|
checking,
|
||||||
voicing,
|
voicing,
|
||||||
|
llmDown,
|
||||||
schedule: scheduleCheckpoint,
|
schedule: scheduleCheckpoint,
|
||||||
runVoice,
|
runVoice,
|
||||||
removeSuggestion,
|
removeSuggestion,
|
||||||
} = useCheckpoint(currentDoc?.id ?? null)
|
} = useCheckpoint(currentDoc?.id ?? null)
|
||||||
// Browser-side spell checker — loads the en-US dictionary once per session.
|
// Browser-side spell checker — loads the en-US dictionary once per session.
|
||||||
const { checker: spellChecker, addWord } = useSpellChecker()
|
const { checker: spellChecker, addWord } = useSpellChecker()
|
||||||
|
// The tag roster (with counts). Assignments live on the doc summaries below.
|
||||||
|
const { tags: tagRoster, refresh: refreshTags, createTag } = useTags()
|
||||||
|
|
||||||
// Patch a summary in the sidebar list (optimistic title / word-count updates).
|
// Patch a summary in the sidebar list (optimistic title / word-count updates).
|
||||||
const patchSummary = useCallback((id: string, patch: Partial<DocSummary>) => {
|
const patchSummary = useCallback((id: string, patch: Partial<DocSummary>) => {
|
||||||
setDocs((prev) => prev.map((d) => (d.id === id ? { ...d, ...patch } : d)))
|
setDocs((prev) => prev.map((d) => (d.id === id ? { ...d, ...patch } : d)))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Attach or detach a tag on a document, updating the sidebar optimistically and
|
||||||
|
// refreshing the roster so its counts stay current. Tags are kept sorted by
|
||||||
|
// name to match the server's ordering.
|
||||||
|
const setDocTag = useCallback(
|
||||||
|
async (docId: string, tag: Tag, attach: boolean) => {
|
||||||
|
setDocs((prev) =>
|
||||||
|
prev.map((d) => {
|
||||||
|
if (d.id !== docId) return d
|
||||||
|
const without = d.tags.filter((t) => t.id !== tag.id)
|
||||||
|
const next = attach ? [...without, tag] : without
|
||||||
|
next.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }))
|
||||||
|
return { ...d, tags: next }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
if (attach) await api.assignTag(docId, tag.id)
|
||||||
|
else await api.unassignTag(docId, tag.id)
|
||||||
|
void refreshTags()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('tag assignment failed', err)
|
||||||
|
void refreshTags()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[refreshTags],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleToggleTag = useCallback(
|
||||||
|
(docId: string, tag: Tag) => {
|
||||||
|
const doc = docs.find((d) => d.id === docId)
|
||||||
|
const has = doc?.tags.some((t) => t.id === tag.id) ?? false
|
||||||
|
void setDocTag(docId, tag, !has)
|
||||||
|
},
|
||||||
|
[docs, setDocTag],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleCreateTag = useCallback(
|
||||||
|
async (docId: string, name: string, color: TagColor) => {
|
||||||
|
const tag = await createTag(name, color)
|
||||||
|
if (tag) void setDocTag(docId, tag, true)
|
||||||
|
},
|
||||||
|
[createTag, setDocTag],
|
||||||
|
)
|
||||||
|
|
||||||
const openDoc = useCallback(
|
const openDoc = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
|
setDrawerOpen(false) // close the mobile drawer when a doc is chosen
|
||||||
|
// Decide the leaving doc's fate before any await mutates state.
|
||||||
|
const leaving = currentDocRef.current
|
||||||
|
const leavingBlank = isBlankDraft()
|
||||||
await saveNow() // flush any pending edits to the doc we're leaving
|
await saveNow() // flush any pending edits to the doc we're leaving
|
||||||
const doc = await api.getDoc(id)
|
const doc = await api.getDoc(id)
|
||||||
setCurrentDoc(doc)
|
setCurrentDoc(doc)
|
||||||
setTitle(doc.title)
|
setTitle(doc.title)
|
||||||
setWordCount(doc.word_count)
|
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).
|
// Initial load: fetch the list, opening the first doc (or creating one).
|
||||||
@@ -63,11 +154,13 @@ export default function App() {
|
|||||||
let list = await api.listDocs()
|
let list = await api.listDocs()
|
||||||
if (list.length === 0) {
|
if (list.length === 0) {
|
||||||
const fresh = await api.createDoc()
|
const fresh = await api.createDoc()
|
||||||
list = [{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at }]
|
list = [{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at, tags: [] }]
|
||||||
setDocs(list)
|
setDocs(list)
|
||||||
setCurrentDoc(fresh)
|
setCurrentDoc(fresh)
|
||||||
setTitle(fresh.title)
|
setTitle(fresh.title)
|
||||||
setWordCount(0)
|
setWordCount(0)
|
||||||
|
setTone(fresh.tone || 'general')
|
||||||
|
setDocText('')
|
||||||
} else {
|
} else {
|
||||||
setDocs(list)
|
setDocs(list)
|
||||||
await openDoc(list[0].id)
|
await openDoc(list[0].id)
|
||||||
@@ -81,16 +174,21 @@ export default function App() {
|
|||||||
}, [openDoc])
|
}, [openDoc])
|
||||||
|
|
||||||
const handleCreate = useCallback(async () => {
|
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()
|
await saveNow()
|
||||||
const fresh = await api.createDoc()
|
const fresh = await api.createDoc()
|
||||||
setDocs((prev) => [
|
setDocs((prev) => [
|
||||||
{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at },
|
{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at, tags: [] },
|
||||||
...prev,
|
...prev,
|
||||||
])
|
])
|
||||||
setCurrentDoc(fresh)
|
setCurrentDoc(fresh)
|
||||||
setTitle(fresh.title)
|
setTitle(fresh.title)
|
||||||
setWordCount(0)
|
setWordCount(0)
|
||||||
}, [saveNow])
|
setTone(fresh.tone || 'general')
|
||||||
|
setDocText('')
|
||||||
|
}, [saveNow, isBlankDraft])
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
@@ -104,6 +202,8 @@ export default function App() {
|
|||||||
setCurrentDoc(null)
|
setCurrentDoc(null)
|
||||||
setTitle('')
|
setTitle('')
|
||||||
setWordCount(0)
|
setWordCount(0)
|
||||||
|
setTone('general')
|
||||||
|
setDocText('')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return remaining
|
return remaining
|
||||||
@@ -126,6 +226,7 @@ export default function App() {
|
|||||||
const handleEditorChange = useCallback(
|
const handleEditorChange = useCallback(
|
||||||
(change: EditorChange) => {
|
(change: EditorChange) => {
|
||||||
setWordCount(change.word_count)
|
setWordCount(change.word_count)
|
||||||
|
setDocText(change.content_text)
|
||||||
setEditTick((n) => n + 1)
|
setEditTick((n) => n + 1)
|
||||||
if (currentDoc) {
|
if (currentDoc) {
|
||||||
patchSummary(currentDoc.id, { word_count: change.word_count })
|
patchSummary(currentDoc.id, { word_count: change.word_count })
|
||||||
@@ -136,6 +237,20 @@ export default function App() {
|
|||||||
[currentDoc, patchSummary, schedule, scheduleCheckpoint],
|
[currentDoc, patchSummary, schedule, scheduleCheckpoint],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Changing the tone persists it and re-runs the checkpoint so Petal's advice
|
||||||
|
// re-tunes to the new register. The auto-save (1.5s) lands before the
|
||||||
|
// checkpoint debounce (4s), so the server reads the updated tone.
|
||||||
|
const handleToneChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setTone(value)
|
||||||
|
if (currentDoc) {
|
||||||
|
schedule({ tone: value })
|
||||||
|
scheduleCheckpoint()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentDoc, schedule, scheduleCheckpoint],
|
||||||
|
)
|
||||||
|
|
||||||
// Accept applies the replacement in the editor (handled in EditorCore) and
|
// Accept applies the replacement in the editor (handled in EditorCore) and
|
||||||
// marks the suggestion accepted; dismiss just rejects it. Both drop it locally.
|
// marks the suggestion accepted; dismiss just rejects it. Both drop it locally.
|
||||||
const handleAccept = useCallback(
|
const handleAccept = useCallback(
|
||||||
@@ -151,6 +266,21 @@ export default function App() {
|
|||||||
[removeSuggestion],
|
[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.
|
// Escape always restores the sidebar while in distraction-free mode.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!focusMode) return
|
if (!focusMode) return
|
||||||
@@ -183,24 +313,47 @@ export default function App() {
|
|||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<header
|
<header
|
||||||
onMouseDown={handleChromeDown}
|
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)' }}
|
style={{ borderBottom: '1px solid var(--color-border)' }}
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Toggle document list"
|
||||||
|
onClick={() => {
|
||||||
|
setFocusMode(false)
|
||||||
|
setDrawerOpen((v) => !v)
|
||||||
|
}}
|
||||||
|
className="petal-sidebar-toggle petal-tap-sm -ml-2 mr-1 items-center justify-center text-xl"
|
||||||
|
style={{ borderRadius: 'var(--radius-pill)', width: 36, height: 36, color: 'var(--color-plum)' }}
|
||||||
|
>
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
<span className="text-xl">🌸</span>
|
<span className="text-xl">🌸</span>
|
||||||
<span className="text-lg font-extrabold text-plum">Petal</span>
|
<span className="text-lg font-extrabold text-plum">Petal</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1">
|
<div className="relative flex min-h-0 flex-1">
|
||||||
<div className={`petal-sidebar shrink-0${focusMode ? ' petal-sidebar-hidden' : ''}`}>
|
<div
|
||||||
|
className={`petal-no-print petal-sidebar shrink-0${focusMode ? ' petal-sidebar-hidden' : ''}${drawerOpen ? ' petal-drawer-open' : ''}`}
|
||||||
|
>
|
||||||
<DocList
|
<DocList
|
||||||
docs={docs}
|
docs={docs}
|
||||||
|
roster={tagRoster}
|
||||||
selectedId={currentDoc?.id ?? null}
|
selectedId={currentDoc?.id ?? null}
|
||||||
onSelect={openDoc}
|
onSelect={openDoc}
|
||||||
onCreate={handleCreate}
|
onCreate={handleCreate}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onToggleTag={handleToggleTag}
|
||||||
|
onCreateTag={handleCreateTag}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`petal-no-print petal-scrim${drawerOpen ? ' petal-scrim-show' : ''}`}
|
||||||
|
onClick={() => setDrawerOpen(false)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
|
||||||
<main className="flex min-w-0 flex-1 flex-col">
|
<main className="flex min-w-0 flex-1 flex-col">
|
||||||
{currentDoc ? (
|
{currentDoc ? (
|
||||||
<>
|
<>
|
||||||
@@ -209,16 +362,41 @@ export default function App() {
|
|||||||
className="flex flex-1 flex-col overflow-y-auto px-6 py-8"
|
className="flex flex-1 flex-col overflow-y-auto px-6 py-8"
|
||||||
>
|
>
|
||||||
<div ref={canvasRef} className="mx-auto flex w-full max-w-[720px] flex-1 flex-col">
|
<div ref={canvasRef} className="mx-auto flex w-full max-w-[720px] flex-1 flex-col">
|
||||||
<input
|
<div className="mb-5 flex items-center gap-3">
|
||||||
value={title}
|
<input
|
||||||
onChange={(e) => handleTitleChange(e.target.value)}
|
value={title}
|
||||||
placeholder="Untitled"
|
onChange={(e) => handleTitleChange(e.target.value)}
|
||||||
aria-label="Document title"
|
placeholder="Untitled"
|
||||||
className="mb-5 w-full bg-transparent text-3xl font-extrabold text-plum focus:outline-none"
|
aria-label="Document title"
|
||||||
style={{ fontFamily: 'var(--font-ui)' }}
|
className="min-w-0 flex-1 bg-transparent text-3xl font-extrabold text-plum focus:outline-none"
|
||||||
/>
|
style={{ fontFamily: 'var(--font-ui)' }}
|
||||||
|
/>
|
||||||
|
<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
|
<EditorCore
|
||||||
key={currentDoc.id}
|
key={`${currentDoc.id}:${editorEpoch}`}
|
||||||
docId={currentDoc.id}
|
docId={currentDoc.id}
|
||||||
initialContent={currentDoc.content}
|
initialContent={currentDoc.content}
|
||||||
onChange={handleEditorChange}
|
onChange={handleEditorChange}
|
||||||
@@ -233,12 +411,14 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div onMouseDown={handleChromeDown}>
|
<div className="petal-no-print" onMouseDown={handleChromeDown}>
|
||||||
<StatusBar
|
<StatusBar
|
||||||
wordCount={wordCount}
|
wordCount={wordCount}
|
||||||
|
text={docText}
|
||||||
saveStatus={status}
|
saveStatus={status}
|
||||||
checking={checking}
|
checking={checking}
|
||||||
voicing={voicing}
|
voicing={voicing}
|
||||||
|
llmDown={llmDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -253,14 +433,24 @@ export default function App() {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{historyOpen && currentDoc && (
|
||||||
|
<HistoryPanel
|
||||||
|
docId={currentDoc.id}
|
||||||
|
onClose={() => setHistoryOpen(false)}
|
||||||
|
onRestored={handleRestored}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{updateAvailable && <UpdateBanner />}
|
{updateAvailable && <UpdateBanner />}
|
||||||
|
|
||||||
<PetalCompanion
|
<div className="petal-no-print">
|
||||||
wordCount={wordCount}
|
<PetalCompanion
|
||||||
saveStatus={status}
|
wordCount={wordCount}
|
||||||
editTick={editTick}
|
saveStatus={status}
|
||||||
acceptTick={acceptTick}
|
editTick={editTick}
|
||||||
/>
|
acceptTick={acceptTick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,34 @@
|
|||||||
// Thin fetch wrappers for the Petal backend. Everything lives under /api, which
|
// Thin fetch wrappers for the Petal backend. Everything lives under /api, which
|
||||||
// Vite proxies to the Go server in dev and the binary serves directly in prod.
|
// Vite proxies to the Go server in dev and the binary serves directly in prod.
|
||||||
|
|
||||||
|
// A tag's palette key, mapped to a CSS color on the frontend. Mirrors the
|
||||||
|
// backend TagColor* constants; unknown values render as rose.
|
||||||
|
export type TagColor = 'rose' | 'mint' | 'peach' | 'lavender' | 'sky' | 'honey'
|
||||||
|
|
||||||
|
export interface Tag {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
color: TagColor
|
||||||
|
doc_count?: number // present only in the tag-roster listing
|
||||||
|
}
|
||||||
|
|
||||||
export interface DocSummary {
|
export interface DocSummary {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
word_count: number
|
word_count: number
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
tags: Tag[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// One cross-document search hit. `snippet` is plain text with the matched span
|
||||||
|
// wrapped in the … sentinels (see splitSnippet) for highlighting.
|
||||||
|
export interface SearchResult {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
word_count: number
|
||||||
|
updated_at: string
|
||||||
|
snippet: string
|
||||||
|
tags: Tag[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Document {
|
export interface Document {
|
||||||
@@ -14,6 +37,7 @@ export interface Document {
|
|||||||
title: string
|
title: string
|
||||||
content: string // Tiptap JSON (stringified)
|
content: string // Tiptap JSON (stringified)
|
||||||
content_text: string // flattened plain text
|
content_text: string // flattened plain text
|
||||||
|
tone: string // target writing tone; steers LLM advice
|
||||||
word_count: number
|
word_count: number
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
@@ -25,11 +49,51 @@ export interface DocUpdate {
|
|||||||
title?: string
|
title?: string
|
||||||
content?: string
|
content?: string
|
||||||
content_text?: string
|
content_text?: string
|
||||||
|
tone?: string
|
||||||
word_count?: number
|
word_count?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One sense of a word from the offline dictionary.
|
||||||
|
export interface WordMeaning {
|
||||||
|
part_of_speech: string
|
||||||
|
definition: string
|
||||||
|
example?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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'
|
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 —
|
// 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
|
// 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
|
// Note #6); from_pos/to_pos are server-side advisory only. `replacement` is
|
||||||
@@ -83,11 +147,109 @@ export const api = {
|
|||||||
dismissSuggestion: (id: string) =>
|
dismissSuggestion: (id: string) =>
|
||||||
req<void>(`/suggestions/${id}/dismiss`, { method: 'POST' }),
|
req<void>(`/suggestions/${id}/dismiss`, { method: 'POST' }),
|
||||||
|
|
||||||
|
// 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
|
// Current deployed build id — changes whenever a new frontend ships. The
|
||||||
// app polls this to offer a refresh. Bypasses any cache so the answer is live.
|
// app polls this to offer a refresh. Bypasses any cache so the answer is live.
|
||||||
version: () => req<{ version: string }>('/version', { cache: 'no-store' }),
|
version: () => req<{ version: string }>('/version', { cache: 'no-store' }),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The sentinel characters the server wraps a search match in (\x01 … \x02). Used
|
||||||
|
// by splitSnippet to render the matched span highlighted.
|
||||||
|
export const SNIPPET_HL_START = String.fromCharCode(1)
|
||||||
|
export const SNIPPET_HL_END = String.fromCharCode(2)
|
||||||
|
|
||||||
|
// splitSnippet breaks a server snippet into ordered { text, hit } segments so the
|
||||||
|
// UI can bold the matched span(s) without dangerously setting innerHTML.
|
||||||
|
export function splitSnippet(snippet: string): { text: string; hit: boolean }[] {
|
||||||
|
const out: { text: string; hit: boolean }[] = []
|
||||||
|
let rest = snippet
|
||||||
|
for (;;) {
|
||||||
|
const start = rest.indexOf(SNIPPET_HL_START)
|
||||||
|
if (start < 0) {
|
||||||
|
if (rest) out.push({ text: rest, hit: false })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (start > 0) out.push({ text: rest.slice(0, start), hit: false })
|
||||||
|
const end = rest.indexOf(SNIPPET_HL_END, start + 1)
|
||||||
|
if (end < 0) {
|
||||||
|
// Unterminated (shouldn't happen) — emit the remainder plainly.
|
||||||
|
out.push({ text: rest.slice(start + 1), hit: false })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out.push({ text: rest.slice(start + 1, end), hit: true })
|
||||||
|
rest = rest.slice(end + 1)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// tagColorVar maps a tag color key to its CSS custom property. Unknown keys fall
|
||||||
|
// back to the rose accent, matching the backend's coercion.
|
||||||
|
export function tagColorVar(color: TagColor | string): string {
|
||||||
|
switch (color) {
|
||||||
|
case 'mint':
|
||||||
|
return 'var(--color-mint)'
|
||||||
|
case 'peach':
|
||||||
|
return 'var(--color-peach)'
|
||||||
|
case 'lavender':
|
||||||
|
return 'var(--color-lavender)'
|
||||||
|
case 'sky':
|
||||||
|
return 'var(--color-sky)'
|
||||||
|
case 'honey':
|
||||||
|
return 'var(--color-honey)'
|
||||||
|
default:
|
||||||
|
return 'var(--color-accent)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// One turn in an Ask Petal conversation. History lives only in the component —
|
// One turn in an Ask Petal conversation. History lives only in the component —
|
||||||
// the server is stateless and re-injects the suggestion context every request.
|
// the server is stateless and re-injects the suggestion context every request.
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
|
|||||||
@@ -1,26 +1,71 @@
|
|||||||
import type { DocSummary } from '../../api/client'
|
import { useMemo, useState } from 'react'
|
||||||
|
import type { DocSummary, Tag, TagColor } from '../../api/client'
|
||||||
import { DocListItem } from './DocListItem'
|
import { DocListItem } from './DocListItem'
|
||||||
|
import { SearchBox } from './SearchBox'
|
||||||
|
import { TagChip } from './TagChip'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
docs: DocSummary[]
|
docs: DocSummary[]
|
||||||
|
roster: Tag[]
|
||||||
selectedId: string | null
|
selectedId: string | null
|
||||||
onSelect: (id: string) => void
|
onSelect: (id: string) => void
|
||||||
onCreate: () => void
|
onCreate: () => void
|
||||||
onDelete: (id: string) => void
|
onDelete: (id: string) => void
|
||||||
|
onToggleTag: (docId: string, tag: Tag) => void
|
||||||
|
onCreateTag: (docId: string, name: string, color: TagColor) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// DocList is the 260px sidebar: the document browser plus a New button.
|
// DocList is the sidebar: a cross-document search box, a tag filter bar, the New
|
||||||
export function DocList({ docs, selectedId, onSelect, onCreate, onDelete }: Props) {
|
// button, and the document browser (each row showing its tag chips).
|
||||||
|
export function DocList({
|
||||||
|
docs,
|
||||||
|
roster,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
onCreate,
|
||||||
|
onDelete,
|
||||||
|
onToggleTag,
|
||||||
|
onCreateTag,
|
||||||
|
}: Props) {
|
||||||
|
// Active tag filter (null = show all). Cleared automatically if the tag
|
||||||
|
// disappears from the roster.
|
||||||
|
const [filterId, setFilterId] = useState<string | null>(null)
|
||||||
|
const activeFilter = filterId && roster.some((t) => t.id === filterId) ? filterId : null
|
||||||
|
|
||||||
|
const filtered = useMemo(
|
||||||
|
() => (activeFilter ? docs.filter((d) => d.tags.some((t) => t.id === activeFilter)) : docs),
|
||||||
|
[docs, activeFilter],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Only surface tags that are actually in use, so the filter bar stays tidy.
|
||||||
|
const usedTags = useMemo(() => roster.filter((t) => (t.doc_count ?? 0) > 0), [roster])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className="flex h-full w-[260px] flex-col gap-2 p-3"
|
className="flex h-full w-[280px] flex-col gap-2 p-3"
|
||||||
style={{ borderRight: '1px solid var(--color-border)' }}
|
style={{ borderRight: '1px solid var(--color-border)' }}
|
||||||
>
|
>
|
||||||
|
<SearchBox onSelect={onSelect} />
|
||||||
|
|
||||||
|
{usedTags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 pb-0.5">
|
||||||
|
{usedTags.map((t) => (
|
||||||
|
<TagChip
|
||||||
|
key={t.id}
|
||||||
|
tag={t}
|
||||||
|
count={t.doc_count}
|
||||||
|
active={activeFilter === t.id}
|
||||||
|
onClick={() => setFilterId((cur) => (cur === t.id ? null : t.id))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCreate}
|
onClick={onCreate}
|
||||||
className="flex items-center justify-center gap-2 py-2.5 text-sm font-bold text-white"
|
className="petal-tap flex items-center justify-center gap-2 text-sm font-bold text-white"
|
||||||
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)' }}
|
style={{ minHeight: 44, borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)' }}
|
||||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||||
>
|
>
|
||||||
@@ -28,18 +73,21 @@ export function DocList({ docs, selectedId, onSelect, onCreate, onDelete }: Prop
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col gap-0.5 overflow-y-auto">
|
<div className="flex flex-1 flex-col gap-0.5 overflow-y-auto">
|
||||||
{docs.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<p className="px-3 py-6 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
|
<p className="px-3 py-6 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||||
No documents yet.
|
{activeFilter ? 'No documents with this tag.' : 'No documents yet.'}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
docs.map((doc) => (
|
filtered.map((doc) => (
|
||||||
<DocListItem
|
<DocListItem
|
||||||
key={doc.id}
|
key={doc.id}
|
||||||
doc={doc}
|
doc={doc}
|
||||||
active={doc.id === selectedId}
|
active={doc.id === selectedId}
|
||||||
|
roster={roster}
|
||||||
onSelect={() => onSelect(doc.id)}
|
onSelect={() => onSelect(doc.id)}
|
||||||
onDelete={() => onDelete(doc.id)}
|
onDelete={() => onDelete(doc.id)}
|
||||||
|
onToggleTag={onToggleTag}
|
||||||
|
onCreateTag={onCreateTag}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 {
|
interface Props {
|
||||||
doc: DocSummary
|
doc: DocSummary
|
||||||
active: boolean
|
active: boolean
|
||||||
|
roster: Tag[]
|
||||||
onSelect: () => void
|
onSelect: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
|
onToggleTag: (docId: string, tag: Tag) => void
|
||||||
|
onCreateTag: (docId: string, name: string, color: TagColor) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// One row in the sidebar: title + word count, a delete affordance on hover, and
|
// One row in the sidebar: title + word count, its tag chips, a tag affordance and
|
||||||
// a rose wash when it's the open document.
|
// a delete affordance on hover, and a rose wash when it's the open document.
|
||||||
export function DocListItem({ doc, active, onSelect, onDelete }: Props) {
|
export function DocListItem({
|
||||||
|
doc,
|
||||||
|
active,
|
||||||
|
roster,
|
||||||
|
onSelect,
|
||||||
|
onDelete,
|
||||||
|
onToggleTag,
|
||||||
|
onCreateTag,
|
||||||
|
}: Props) {
|
||||||
|
const [picking, setPicking] = useState(false)
|
||||||
|
const assignedIds = new Set(doc.tags.map((t) => t.id))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
className="group flex cursor-pointer items-center gap-2 px-3 py-2.5"
|
className="group relative flex cursor-pointer flex-col gap-1 px-3 py-2.5"
|
||||||
style={{
|
style={{
|
||||||
borderRadius: 'var(--radius-card)',
|
borderRadius: 'var(--radius-card)',
|
||||||
background: active ? 'var(--color-surface)' : 'transparent',
|
background: active ? 'var(--color-surface)' : 'transparent',
|
||||||
boxShadow: active ? 'var(--shadow-soft)' : 'none',
|
boxShadow: active ? 'var(--shadow-soft)' : 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div className="min-w-0 flex-1">
|
||||||
className="truncate text-sm font-semibold"
|
<div
|
||||||
style={{ color: active ? 'var(--color-plum)' : 'var(--color-muted)' }}
|
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>
|
</button>
|
||||||
<div className="text-xs" style={{ color: 'var(--color-muted)' }}>
|
<button
|
||||||
{doc.word_count} {doc.word_count === 1 ? 'word' : 'words'}
|
type="button"
|
||||||
</div>
|
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>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
{doc.tags.length > 0 && (
|
||||||
aria-label="Delete document"
|
<div className="flex flex-wrap gap-1">
|
||||||
onClick={(e) => {
|
{doc.tags.map((t) => (
|
||||||
e.stopPropagation()
|
<TagChip key={t.id} tag={t} onRemove={() => onToggleTag(doc.id, t)} />
|
||||||
onDelete()
|
))}
|
||||||
}}
|
</div>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
145
web/src/components/DocList/SearchBox.tsx
Normal file
145
web/src/components/DocList/SearchBox.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
75
web/src/components/DocList/TagChip.tsx
Normal file
75
web/src/components/DocList/TagChip.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
139
web/src/components/DocList/TagPicker.tsx
Normal file
139
web/src/components/DocList/TagPicker.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,7 +10,11 @@ import { SuggestionCard } from './SuggestionCard'
|
|||||||
import { SuggestionHighlight, setSuggestions, findRange } from './SuggestionHighlight'
|
import { SuggestionHighlight, setSuggestions, findRange } from './SuggestionHighlight'
|
||||||
import { SpellCheck, setSpellChecker, wordAt } from './SpellCheck'
|
import { SpellCheck, setSpellChecker, wordAt } from './SpellCheck'
|
||||||
import { MisspellCard } from './MisspellCard'
|
import { MisspellCard } from './MisspellCard'
|
||||||
import type { Suggestion } from '../../api/client'
|
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'
|
import type { SpellChecker } from '../../hooks/useSpellChecker'
|
||||||
|
|
||||||
export interface EditorChange {
|
export interface EditorChange {
|
||||||
@@ -50,6 +54,19 @@ interface MisspellState {
|
|||||||
left: number
|
left: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The open right-click word popover (definition + synonyms), or null. `info` is
|
||||||
|
// null while the offline lookup is in flight (`loading`); the card shows a
|
||||||
|
// looking-up state until it resolves.
|
||||||
|
interface WordInfoState {
|
||||||
|
word: string
|
||||||
|
from: number
|
||||||
|
to: number
|
||||||
|
top: number
|
||||||
|
left: number
|
||||||
|
loading: boolean
|
||||||
|
info: WordInfo | null
|
||||||
|
}
|
||||||
|
|
||||||
// A tiny CSS-only confetti burst played at an accept. Four palette-colored dots
|
// A tiny CSS-only confetti burst played at an accept. Four palette-colored dots
|
||||||
// spray up-and-out from a point; each reads its direction from --dx/--dy.
|
// spray up-and-out from a point; each reads its direction from --dx/--dy.
|
||||||
const CONFETTI_DOTS = [
|
const CONFETTI_DOTS = [
|
||||||
@@ -92,6 +109,39 @@ interface HoverState {
|
|||||||
left: number
|
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
|
// EditorCore is the Tiptap instance: StarterKit formatting plus underline, text
|
||||||
// alignment, a placeholder, character counting, and the suggestion-highlight
|
// alignment, a placeholder, character counting, and the suggestion-highlight
|
||||||
// decoration layer. Hovering a highlight opens its SuggestionCard.
|
// decoration layer. Hovering a highlight opens its SuggestionCard.
|
||||||
@@ -112,6 +162,10 @@ export function EditorCore({
|
|||||||
const [hover, setHover] = useState<HoverState | null>(null)
|
const [hover, setHover] = useState<HoverState | null>(null)
|
||||||
// The open spelling popover (click a red-underlined word), or null.
|
// The open spelling popover (click a red-underlined word), or null.
|
||||||
const [misspell, setMisspell] = useState<MisspellState | null>(null)
|
const [misspell, setMisspell] = useState<MisspellState | null>(null)
|
||||||
|
// The open right-click word popover (definition + synonyms), or null.
|
||||||
|
const [wordInfo, setWordInfo] = useState<WordInfoState | null>(null)
|
||||||
|
// Token to discard a word lookup whose popover has since closed/changed.
|
||||||
|
const wordReqRef = useRef(0)
|
||||||
// Transient confetti burst played at the last accept location.
|
// Transient confetti burst played at the last accept location.
|
||||||
const [confetti, setConfetti] = useState<{ top: number; left: number } | null>(null)
|
const [confetti, setConfetti] = useState<{ top: number; left: number } | null>(null)
|
||||||
const confettiTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
const confettiTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
@@ -120,6 +174,15 @@ export function EditorCore({
|
|||||||
// While the Ask Petal panel is expanded the card is pinned: the hover-close
|
// 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.
|
// timer is suppressed so chatting doesn't dismiss it. A click outside closes.
|
||||||
const [pinned, setPinned] = useState(false)
|
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({
|
const editor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
@@ -137,14 +200,40 @@ export function EditorCore({
|
|||||||
},
|
},
|
||||||
onFocus: () => onFocusMode?.(),
|
onFocus: () => onFocusMode?.(),
|
||||||
onUpdate: ({ editor }) => {
|
onUpdate: ({ editor }) => {
|
||||||
// Any edit shifts positions, stranding the spelling popover's anchor.
|
// Any edit shifts positions, stranding the popover anchors.
|
||||||
setMisspell(null)
|
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({
|
onChange({
|
||||||
content: JSON.stringify(editor.getJSON()),
|
content: JSON.stringify(editor.getJSON()),
|
||||||
content_text: editor.getText(),
|
content_text: editor.getText(),
|
||||||
word_count: editor.storage.characterCount.words(),
|
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
|
// When the selected document changes, swap in its content without emitting an
|
||||||
@@ -154,6 +243,11 @@ export function EditorCore({
|
|||||||
editor.commands.setContent(parseDoc(initialContent) ?? '', false)
|
editor.commands.setContent(parseDoc(initialContent) ?? '', false)
|
||||||
setHover(null)
|
setHover(null)
|
||||||
setMisspell(null)
|
setMisspell(null)
|
||||||
|
setWordInfo(null)
|
||||||
|
setGloss(null)
|
||||||
|
setSelection(null)
|
||||||
|
rewriteReqRef.current++
|
||||||
|
setRewrite(null)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [docId, editor])
|
}, [docId, editor])
|
||||||
|
|
||||||
@@ -186,6 +280,8 @@ export function EditorCore({
|
|||||||
Math.min(elRect.left - wrapRect.left, wrapper.clientWidth - cardWidth),
|
Math.min(elRect.left - wrapRect.left, wrapper.clientWidth - cardWidth),
|
||||||
)
|
)
|
||||||
const top = elRect.bottom - wrapRect.top + 6
|
const top = elRect.bottom - wrapRect.top + 6
|
||||||
|
// A suggestion card and the word popover shouldn't stack.
|
||||||
|
setWordInfo(null)
|
||||||
setHover((prev) => {
|
setHover((prev) => {
|
||||||
// Moving to a different highlight resets any Ask Petal pin.
|
// Moving to a different highlight resets any Ask Petal pin.
|
||||||
if (prev && prev.suggestion.id !== suggestion.id) setPinned(false)
|
if (prev && prev.suggestion.id !== suggestion.id) setPinned(false)
|
||||||
@@ -266,8 +362,17 @@ export function EditorCore({
|
|||||||
// Click a red-underlined word to open its spelling popover, anchored under the
|
// Click a red-underlined word to open its spelling popover, anchored under the
|
||||||
// word. posAtCoords→wordAt resolves the exact PM span (robust to the same
|
// word. posAtCoords→wordAt resolves the exact PM span (robust to the same
|
||||||
// misspelling appearing elsewhere); nspell supplies the corrections.
|
// misspelling appearing elsewhere); nspell supplies the corrections.
|
||||||
|
//
|
||||||
|
// Tapping an AI-suggestion highlight also opens its card here — on touch there's
|
||||||
|
// no hover, so the tap is the only way in (mouse users still get hover).
|
||||||
const handleSpellClick = useCallback(
|
const handleSpellClick = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
|
const suggestionEl = (e.target as HTMLElement).closest('.petal-suggestion') as HTMLElement | null
|
||||||
|
if (suggestionEl) {
|
||||||
|
const id = suggestionEl.getAttribute('data-suggestion-id')
|
||||||
|
if (id) openCardFor(id, suggestionEl)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!editor || !spellChecker) return
|
if (!editor || !spellChecker) return
|
||||||
const target = (e.target as HTMLElement).closest('.petal-misspelling') as HTMLElement | null
|
const target = (e.target as HTMLElement).closest('.petal-misspelling') as HTMLElement | null
|
||||||
if (!target) return
|
if (!target) return
|
||||||
@@ -284,9 +389,10 @@ export function EditorCore({
|
|||||||
const top = elRect.bottom - wrapRect.top + 6
|
const top = elRect.bottom - wrapRect.top + 6
|
||||||
// Opening a spelling popover supersedes any AI-suggestion hover card.
|
// Opening a spelling popover supersedes any AI-suggestion hover card.
|
||||||
closeCard()
|
closeCard()
|
||||||
|
setWordInfo(null)
|
||||||
setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
|
setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
|
||||||
},
|
},
|
||||||
[editor, spellChecker, closeCard],
|
[editor, spellChecker, closeCard, openCardFor],
|
||||||
)
|
)
|
||||||
|
|
||||||
const replaceMisspelling = useCallback(
|
const replaceMisspelling = useCallback(
|
||||||
@@ -304,6 +410,209 @@ export function EditorCore({
|
|||||||
setMisspell(null)
|
setMisspell(null)
|
||||||
}, [misspell, onAddWord])
|
}, [misspell, onAddWord])
|
||||||
|
|
||||||
|
// Right-click a word to look it up: resolve the exact word span under the
|
||||||
|
// pointer, anchor a popover beneath it, and kick off the offline lookup. The
|
||||||
|
// card opens immediately in a loading state and fills in when the (local)
|
||||||
|
// lookup returns. Right-clicking off any word falls through to the native menu.
|
||||||
|
const handleContextMenu = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
if (!editor) return
|
||||||
|
const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY })
|
||||||
|
if (!coords) return
|
||||||
|
const range = wordAt(editor.state.doc, coords.pos)
|
||||||
|
if (!range) return
|
||||||
|
const wrapper = wrapperRef.current
|
||||||
|
if (!wrapper) return
|
||||||
|
e.preventDefault()
|
||||||
|
// Anchor under the word itself (not the click point) so the card lines up
|
||||||
|
// with the text the way the spelling popover does.
|
||||||
|
const start = editor.view.coordsAtPos(range.from)
|
||||||
|
const end = editor.view.coordsAtPos(range.to)
|
||||||
|
const wrapRect = wrapper.getBoundingClientRect()
|
||||||
|
const cardWidth = 300
|
||||||
|
const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - cardWidth))
|
||||||
|
const top = end.bottom - wrapRect.top + 6
|
||||||
|
// Opening a word lookup supersedes any suggestion/spelling card.
|
||||||
|
closeCard()
|
||||||
|
setMisspell(null)
|
||||||
|
const token = ++wordReqRef.current
|
||||||
|
setWordInfo({ word: range.word, from: range.from, to: range.to, top, left, loading: true, info: null })
|
||||||
|
api
|
||||||
|
.lookupWord(range.word)
|
||||||
|
.then((info) => {
|
||||||
|
if (token === wordReqRef.current) {
|
||||||
|
setWordInfo((w) => (w ? { ...w, loading: false, info } : null))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('word lookup failed', err)
|
||||||
|
if (token === wordReqRef.current) {
|
||||||
|
setWordInfo((w) => (w ? { ...w, loading: false, info: null } : null))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[editor, closeCard],
|
||||||
|
)
|
||||||
|
|
||||||
|
const replaceWord = useCallback(
|
||||||
|
(synonym: string) => {
|
||||||
|
if (editor && wordInfo) {
|
||||||
|
editor.chain().focus().insertContentAt({ from: wordInfo.from, to: wordInfo.to }, synonym).run()
|
||||||
|
}
|
||||||
|
setWordInfo(null)
|
||||||
|
},
|
||||||
|
[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(() => {
|
||||||
|
if (!wordInfo) return
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
if ((e.target as HTMLElement).closest('.petal-word-card')) return
|
||||||
|
setWordInfo(null)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDown)
|
||||||
|
return () => document.removeEventListener('mousedown', onDown)
|
||||||
|
}, [wordInfo])
|
||||||
|
|
||||||
// A pointer-down outside the popover (and not on another misspelling, which
|
// A pointer-down outside the popover (and not on another misspelling, which
|
||||||
// would reopen it) closes the spelling card.
|
// would reopen it) closes the spelling card.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -320,6 +629,7 @@ export function EditorCore({
|
|||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
clearTimeout(closeTimer.current)
|
clearTimeout(closeTimer.current)
|
||||||
clearTimeout(confettiTimer.current)
|
clearTimeout(confettiTimer.current)
|
||||||
|
clearTimeout(glossTimer.current)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// While pinned (Ask Petal open), a pointer-down outside the card closes it —
|
// While pinned (Ask Petal open), a pointer-down outside the card closes it —
|
||||||
@@ -333,6 +643,20 @@ export function EditorCore({
|
|||||||
return () => document.removeEventListener('mousedown', onDown)
|
return () => document.removeEventListener('mousedown', onDown)
|
||||||
}, [pinned, closeCard])
|
}, [pinned, closeCard])
|
||||||
|
|
||||||
|
// Touch has no hover, so the card is opened by a tap and can't close itself on
|
||||||
|
// mouse-leave. Whenever a card is open, a pointer-down outside both the card and
|
||||||
|
// any highlight dismisses it. Excluding `.petal-suggestion` lets a tap on
|
||||||
|
// another highlight reopen for that one (via the click handler) without flicker.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hover) return
|
||||||
|
const onDown = (e: PointerEvent) => {
|
||||||
|
const t = e.target as HTMLElement
|
||||||
|
if (!t.closest('.petal-suggestion-card') && !t.closest('.petal-suggestion')) closeCard()
|
||||||
|
}
|
||||||
|
document.addEventListener('pointerdown', onDown)
|
||||||
|
return () => document.removeEventListener('pointerdown', onDown)
|
||||||
|
}, [hover, closeCard])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
<Toolbar editor={editor} onVoiceCheck={onVoiceCheck} voicing={voicing} />
|
<Toolbar editor={editor} onVoiceCheck={onVoiceCheck} voicing={voicing} />
|
||||||
@@ -341,10 +665,41 @@ export function EditorCore({
|
|||||||
className="relative flex-1"
|
className="relative flex-1"
|
||||||
onMouseOver={handleMouseOver}
|
onMouseOver={handleMouseOver}
|
||||||
onMouseOut={handleMouseOut}
|
onMouseOut={handleMouseOut}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
onClick={handleSpellClick}
|
onClick={handleSpellClick}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
>
|
>
|
||||||
<EditorContent editor={editor} className="h-full" />
|
<EditorContent editor={editor} className="h-full" />
|
||||||
{confetti && <Confetti top={confetti.top} left={confetti.left} />}
|
{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}
|
||||||
|
info={wordInfo.info}
|
||||||
|
loading={wordInfo.loading}
|
||||||
|
style={{ top: wordInfo.top, left: wordInfo.left }}
|
||||||
|
onReplace={replaceWord}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{misspell && (
|
{misspell && (
|
||||||
<MisspellCard
|
<MisspellCard
|
||||||
word={misspell.word}
|
word={misspell.word}
|
||||||
|
|||||||
32
web/src/components/Editor/GlossTip.tsx
Normal file
32
web/src/components/Editor/GlossTip.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
136
web/src/components/Editor/RewritePreview.tsx
Normal file
136
web/src/components/Editor/RewritePreview.tsx
Normal 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)' }}>
|
||||||
|
改写失败,请再试一次 · Couldn’t 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
85
web/src/components/Editor/SelectionBubble.tsx
Normal file
85
web/src/components/Editor/SelectionBubble.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
119
web/src/components/Editor/ToneSelect.tsx
Normal file
119
web/src/components/Editor/ToneSelect.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
// ToneSelect lets the writer set the document's target tone, which steers the
|
||||||
|
// grammar-checkpoint LLM toward the right register (an academic essay vs a casual
|
||||||
|
// journal). A small custom dropdown (not a native <select>) so it can carry the
|
||||||
|
// bilingual zh·en labels and emoji that match Petal's chrome — the writer uses
|
||||||
|
// Mandarin and English. The `value` strings mirror the backend's tone keys.
|
||||||
|
|
||||||
|
export interface ToneOption {
|
||||||
|
value: string
|
||||||
|
emoji: string
|
||||||
|
zh: string
|
||||||
|
en: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep these `value`s in sync with llm.toneGuidance on the server. 'general'
|
||||||
|
// means no steering (Petal's default friendly ESL advice).
|
||||||
|
export const TONES: ToneOption[] = [
|
||||||
|
{ value: 'general', emoji: '🌸', zh: '通用', en: 'General' },
|
||||||
|
{ 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 {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToneSelect({ value, onChange }: Props) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
const current = TONES.find((t) => t.value === value) ?? TONES[0]
|
||||||
|
|
||||||
|
// Click outside closes the menu.
|
||||||
|
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="Document tone"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
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="Set the tone — Petal tailors its advice to match"
|
||||||
|
>
|
||||||
|
<span aria-hidden>{current.emoji}</span>
|
||||||
|
<span>{current.zh}</span>
|
||||||
|
<span style={{ color: 'var(--color-muted)' }}>· {current.en}</span>
|
||||||
|
<span aria-hidden style={{ color: 'var(--color-muted)' }}>
|
||||||
|
⌄
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
role="listbox"
|
||||||
|
className="petal-word-card absolute right-0 z-30 mt-1.5 p-1.5"
|
||||||
|
style={{
|
||||||
|
width: 200,
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-card)',
|
||||||
|
boxShadow: 'var(--shadow-soft)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{TONES.map((t) => {
|
||||||
|
const active = t.value === value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.value}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={active}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(t.value)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-2 rounded-xl px-2.5 py-1.5 text-left text-sm font-semibold"
|
||||||
|
style={{
|
||||||
|
background: active ? 'var(--color-surface-alt)' : 'transparent',
|
||||||
|
color: 'var(--color-plum)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
||||||
|
onMouseLeave={(e) =>
|
||||||
|
(e.currentTarget.style.background = active ? 'var(--color-surface-alt)' : 'transparent')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span aria-hidden>{t.emoji}</span>
|
||||||
|
<span>{t.zh}</span>
|
||||||
|
<span className="font-normal" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
{t.en}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
130
web/src/components/Editor/WordCard.tsx
Normal file
130
web/src/components/Editor/WordCard.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import type { WordInfo } from '../../api/client'
|
||||||
|
|
||||||
|
// WordCard is the right-click popover for any word: its dictionary definition(s)
|
||||||
|
// on top and tappable synonym pills below. Clicking a synonym replaces the word
|
||||||
|
// in place. Both datasets are offline, so this opens instantly and fills in as
|
||||||
|
// the (local) lookup returns. Labels are bilingual (zh-first, en subtitle) to
|
||||||
|
// match the rest of Petal's chrome — the writer uses Mandarin and English.
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
word: string
|
||||||
|
info: WordInfo | null
|
||||||
|
loading: boolean
|
||||||
|
style: React.CSSProperties
|
||||||
|
onReplace: (synonym: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WordCard({ word, info, loading, style, onReplace }: Props) {
|
||||||
|
const definitions = info?.definitions ?? []
|
||||||
|
const synonyms = info?.synonyms ?? []
|
||||||
|
const gloss = info?.gloss ?? ''
|
||||||
|
const empty = !loading && !gloss && definitions.length === 0 && synonyms.length === 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-label={`Definition and synonyms for ${word}`}
|
||||||
|
className="petal-word-card absolute z-20 p-3.5 text-sm"
|
||||||
|
style={{
|
||||||
|
width: 300,
|
||||||
|
maxHeight: 340,
|
||||||
|
overflowY: 'auto',
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-card)',
|
||||||
|
boxShadow: 'var(--shadow-soft)',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-bold"
|
||||||
|
style={{ background: 'var(--color-lavender)', color: 'var(--color-plum)' }}
|
||||||
|
>
|
||||||
|
词语 · Word
|
||||||
|
</span>
|
||||||
|
<span className="font-bold" style={{ color: 'var(--color-plum)' }}>
|
||||||
|
{word}
|
||||||
|
</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
|
||||||
|
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
|
||||||
|
style={{ background: 'var(--color-accent)' }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
查找中… · Looking up…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{definitions.length > 0 && (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<p className="text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
释义 · Definition
|
||||||
|
</p>
|
||||||
|
<ol className="space-y-1.5">
|
||||||
|
{definitions.map((m, i) => (
|
||||||
|
<li key={i} className="leading-snug" style={{ color: 'var(--color-plum)' }}>
|
||||||
|
{m.part_of_speech && (
|
||||||
|
<span className="mr-1 italic" style={{ color: 'var(--color-accent-hover)' }}>
|
||||||
|
{m.part_of_speech}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{m.definition}
|
||||||
|
{m.example && (
|
||||||
|
<span className="mt-0.5 block italic" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
“{m.example}”
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{synonyms.length > 0 && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<p className="mb-1.5 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
近义词 · Synonyms <span className="font-normal">(点击替换 · tap to swap)</span>
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{synonyms.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onReplace(s)}
|
||||||
|
className="rounded-full px-3 py-1 text-xs font-semibold"
|
||||||
|
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{empty && (
|
||||||
|
<p className="mt-3 leading-snug" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
没有找到这个词 · Nothing found for this word
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
120
web/src/components/Export/ExportMenu.tsx
Normal file
120
web/src/components/Export/ExportMenu.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
230
web/src/components/History/HistoryPanel.tsx
Normal file
230
web/src/components/History/HistoryPanel.tsx
Normal 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)' }}>
|
||||||
|
Couldn’t 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
81
web/src/components/StatusBar/StatsPanel.tsx
Normal file
81
web/src/components/StatusBar/StatsPanel.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { computeStats, gradeBand } from './stats'
|
||||||
|
|
||||||
|
// StatsPanel is the popover that opens above the word count: a small grid of
|
||||||
|
// writing statistics computed from the live document. Bilingual zh·en labels to
|
||||||
|
// match Petal's chrome. Reading level shows a friendly band, not just a number.
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
text: string
|
||||||
|
wordCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
zh: string
|
||||||
|
en: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatsPanel({ text, wordCount }: Props) {
|
||||||
|
const rows = useMemo<Row[]>(() => {
|
||||||
|
const s = computeStats(text, wordCount)
|
||||||
|
const band = gradeBand(s.gradeLevel)
|
||||||
|
const fmt = (n: number, d = 0) =>
|
||||||
|
n.toLocaleString(undefined, { minimumFractionDigits: d, maximumFractionDigits: d })
|
||||||
|
return [
|
||||||
|
{ zh: '字数', en: 'Words', value: fmt(s.words) },
|
||||||
|
{ zh: '字符', en: 'Characters', value: fmt(s.characters) },
|
||||||
|
{ zh: '句子', en: 'Sentences', value: fmt(s.sentences) },
|
||||||
|
{ zh: '段落', en: 'Paragraphs', value: fmt(s.paragraphs) },
|
||||||
|
{ zh: '页数', en: 'Pages', value: `~${fmt(Math.max(s.pages, s.words > 0 ? 0.1 : 0), 1)}` },
|
||||||
|
{ zh: '阅读时间', en: 'Reading time', value: readingTime(s.readingTimeMin) },
|
||||||
|
{ zh: '平均词长', en: 'Avg word length', value: `${fmt(s.avgWordLength, 1)}` },
|
||||||
|
{ zh: '词汇丰富度', en: 'Word variety', value: `${fmt(s.variety * 100)}%` },
|
||||||
|
{
|
||||||
|
zh: '阅读难度',
|
||||||
|
en: 'Reading level',
|
||||||
|
value: s.words > 0 ? `${band.en} · ${fmt(s.gradeLevel, 1)}` : '—',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}, [text, wordCount])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Writing statistics"
|
||||||
|
className="petal-word-card absolute bottom-7 left-0 z-30 p-3.5"
|
||||||
|
style={{
|
||||||
|
width: 268,
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-card)',
|
||||||
|
boxShadow: 'var(--shadow-soft)',
|
||||||
|
color: 'var(--color-plum)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="mb-2 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
写作统计 · Writing stats
|
||||||
|
</p>
|
||||||
|
<dl className="space-y-1.5">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<div key={r.en} className="flex items-baseline justify-between gap-3 text-sm">
|
||||||
|
<dt style={{ color: 'var(--color-muted)' }}>
|
||||||
|
<span className="font-semibold" style={{ color: 'var(--color-plum)' }}>
|
||||||
|
{r.zh}
|
||||||
|
</span>{' '}
|
||||||
|
{r.en}
|
||||||
|
</dt>
|
||||||
|
<dd className="font-bold tabular-nums">{r.value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readingTime renders minutes as a friendly "< 1 min" / "N min" string.
|
||||||
|
function readingTime(min: number): string {
|
||||||
|
if (min <= 0) return '0 min'
|
||||||
|
if (min < 1) return '< 1 min'
|
||||||
|
return `${Math.round(min)} min`
|
||||||
|
}
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import type { SaveStatus } from '../../hooks/useAutoSave'
|
import type { SaveStatus } from '../../hooks/useAutoSave'
|
||||||
|
import { StatsPanel } from './StatsPanel'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
wordCount: number
|
wordCount: number
|
||||||
|
// Live plain text of the document, for the expanded stats panel.
|
||||||
|
text: string
|
||||||
saveStatus: SaveStatus
|
saveStatus: SaveStatus
|
||||||
// True while a grammar checkpoint is in flight — shows the breathing rose dot.
|
// True while a grammar checkpoint is in flight — shows the breathing rose dot.
|
||||||
checking: boolean
|
checking: boolean
|
||||||
// True while a whole-document voice pass runs — shows a breathing honey dot.
|
// True while a whole-document voice pass runs — shows a breathing honey dot.
|
||||||
voicing: boolean
|
voicing: boolean
|
||||||
|
// True when Petal can't reach its LLM helper — shows a gentle, reassuring note
|
||||||
|
// (the writing still saves locally, so this is awareness, not an error).
|
||||||
|
llmDown: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const SAVE_LABEL: Record<SaveStatus, string> = {
|
const SAVE_LABEL: Record<SaveStatus, string> = {
|
||||||
@@ -20,16 +27,44 @@ const SAVE_LABEL: Record<SaveStatus, string> = {
|
|||||||
// StatusBar is the slim footer: word count on the left, save state and the
|
// StatusBar is the slim footer: word count on the left, save state and the
|
||||||
// grammar-checkpoint indicator on the right. The checkpoint dot is a soft rose
|
// grammar-checkpoint indicator on the right. The checkpoint dot is a soft rose
|
||||||
// circle that breathes while a check is in flight (spec → Signature animations).
|
// circle that breathes while a check is in flight (spec → Signature animations).
|
||||||
export function StatusBar({ wordCount, saveStatus, checking, voicing }: Props) {
|
export function StatusBar({ wordCount, text, saveStatus, checking, voicing, llmDown }: Props) {
|
||||||
const label = SAVE_LABEL[saveStatus]
|
const label = SAVE_LABEL[saveStatus]
|
||||||
|
// The expanded stats panel toggles open when the word count is clicked.
|
||||||
|
const [statsOpen, setStatsOpen] = useState(false)
|
||||||
|
const statsRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!statsOpen) return
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
if (!statsRef.current?.contains(e.target as Node)) setStatsOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDown)
|
||||||
|
return () => document.removeEventListener('mousedown', onDown)
|
||||||
|
}, [statsOpen])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer
|
<footer
|
||||||
className="flex h-9 shrink-0 items-center gap-3 px-6 text-xs"
|
className="flex h-9 shrink-0 items-center gap-3 px-6 text-xs"
|
||||||
style={{ borderTop: '1px solid var(--color-border)', color: 'var(--color-muted)' }}
|
style={{ borderTop: '1px solid var(--color-border)', color: 'var(--color-muted)' }}
|
||||||
>
|
>
|
||||||
<span>
|
<div className="relative" ref={statsRef}>
|
||||||
{wordCount} {wordCount === 1 ? 'word' : 'words'}
|
<button
|
||||||
</span>
|
type="button"
|
||||||
|
onClick={() => setStatsOpen((o) => !o)}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={statsOpen}
|
||||||
|
className="rounded-full px-1.5 py-0.5 font-semibold transition-colors"
|
||||||
|
style={{ color: statsOpen ? 'var(--color-accent-hover)' : 'inherit' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-accent-hover)')}
|
||||||
|
onMouseLeave={(e) =>
|
||||||
|
(e.currentTarget.style.color = statsOpen ? 'var(--color-accent-hover)' : 'inherit')
|
||||||
|
}
|
||||||
|
title="Writing stats"
|
||||||
|
>
|
||||||
|
{wordCount} {wordCount === 1 ? 'word' : 'words'}
|
||||||
|
</button>
|
||||||
|
{statsOpen && <StatsPanel text={text} wordCount={wordCount} />}
|
||||||
|
</div>
|
||||||
{checking && (
|
{checking && (
|
||||||
<>
|
<>
|
||||||
<span aria-hidden>·</span>
|
<span aria-hidden>·</span>
|
||||||
@@ -54,6 +89,20 @@ export function StatusBar({ wordCount, saveStatus, checking, voicing }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{llmDown && !checking && !voicing && (
|
||||||
|
<>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5"
|
||||||
|
title="Petal can't reach its writing helper right now — your text is still saved."
|
||||||
|
style={{ color: 'var(--color-honey)' }}
|
||||||
|
>
|
||||||
|
<span aria-hidden>🌙</span>
|
||||||
|
<span>小助手在休息</span>
|
||||||
|
<span style={{ opacity: 0.75 }}>· Petal's helper is resting · 文字已保存</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{label && (
|
{label && (
|
||||||
<>
|
<>
|
||||||
<span aria-hidden>·</span>
|
<span aria-hidden>·</span>
|
||||||
|
|||||||
92
web/src/components/StatusBar/stats.ts
Normal file
92
web/src/components/StatusBar/stats.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// Writing statistics computed from the document's plain text. These power the
|
||||||
|
// expanded stats panel that opens when the writer clicks the word count. The
|
||||||
|
// reading-level formulas are English-centric (Flesch); a document mixing in
|
||||||
|
// Mandarin still gets sensible counts, with the level treated as approximate.
|
||||||
|
|
||||||
|
export interface WritingStats {
|
||||||
|
words: number
|
||||||
|
characters: number
|
||||||
|
charactersNoSpaces: number
|
||||||
|
sentences: number
|
||||||
|
paragraphs: number
|
||||||
|
pages: number
|
||||||
|
avgWordLength: number // letters per English word
|
||||||
|
uniqueWords: number
|
||||||
|
variety: number // type-token ratio, 0–1 (unique ÷ total English words)
|
||||||
|
readingTimeMin: number
|
||||||
|
gradeLevel: number // Flesch–Kincaid grade
|
||||||
|
}
|
||||||
|
|
||||||
|
// English word tokens (letters with internal apostrophes/hyphens) — used for the
|
||||||
|
// letter-length, syllable, and variety measures, which only make sense for
|
||||||
|
// alphabetic words.
|
||||||
|
const ENGLISH_WORD_RE = /[A-Za-z]+(?:['’-][A-Za-z]+)*/g
|
||||||
|
// Sentence terminators, including the CJK fullwidth forms.
|
||||||
|
const SENTENCE_RE = /[.!?。!?]+/g
|
||||||
|
// Words per page (a rough double-spaced manuscript page) and reading speed.
|
||||||
|
const WORDS_PER_PAGE = 250
|
||||||
|
const WORDS_PER_MINUTE = 200
|
||||||
|
|
||||||
|
// countSyllables is the common vowel-group heuristic: count vowel runs, drop a
|
||||||
|
// trailing silent "e"/"es"/"ed", and floor at one. Not perfect, but plenty
|
||||||
|
// accurate for an at-a-glance reading level.
|
||||||
|
function countSyllables(word: string): number {
|
||||||
|
const w = word.toLowerCase().replace(/[^a-z]/g, '')
|
||||||
|
if (w.length === 0) return 0
|
||||||
|
if (w.length <= 3) return 1
|
||||||
|
const trimmed = w.replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, '').replace(/^y/, '')
|
||||||
|
const groups = trimmed.match(/[aeiouy]{1,2}/g)
|
||||||
|
return groups ? groups.length : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeStats(text: string, wordCount: number): WritingStats {
|
||||||
|
const trimmed = text.trim()
|
||||||
|
const characters = [...text].length
|
||||||
|
const charactersNoSpaces = text.replace(/\s/g, '').length
|
||||||
|
|
||||||
|
const sentenceMatches = trimmed.match(SENTENCE_RE)
|
||||||
|
const sentences = sentenceMatches ? sentenceMatches.length : trimmed ? 1 : 0
|
||||||
|
|
||||||
|
const paragraphBlocks = trimmed.split(/\n{2,}/).filter((p) => p.trim().length > 0)
|
||||||
|
const paragraphs = paragraphBlocks.length
|
||||||
|
|
||||||
|
const englishWords = trimmed.match(ENGLISH_WORD_RE) ?? []
|
||||||
|
const letters = englishWords.reduce((sum, w) => sum + w.length, 0)
|
||||||
|
const avgWordLength = englishWords.length > 0 ? letters / englishWords.length : 0
|
||||||
|
|
||||||
|
const unique = new Set(englishWords.map((w) => w.toLowerCase()))
|
||||||
|
const uniqueWords = unique.size
|
||||||
|
const variety = englishWords.length > 0 ? uniqueWords / englishWords.length : 0
|
||||||
|
|
||||||
|
const syllables = englishWords.reduce((sum, w) => sum + countSyllables(w), 0)
|
||||||
|
// Flesch–Kincaid grade level; needs at least one sentence and word to be real.
|
||||||
|
let gradeLevel = 0
|
||||||
|
if (englishWords.length > 0 && sentences > 0) {
|
||||||
|
gradeLevel =
|
||||||
|
0.39 * (englishWords.length / sentences) + 11.8 * (syllables / englishWords.length) - 15.59
|
||||||
|
if (gradeLevel < 0) gradeLevel = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
words: wordCount,
|
||||||
|
characters,
|
||||||
|
charactersNoSpaces,
|
||||||
|
sentences,
|
||||||
|
paragraphs,
|
||||||
|
pages: wordCount / WORDS_PER_PAGE,
|
||||||
|
avgWordLength,
|
||||||
|
uniqueWords,
|
||||||
|
variety,
|
||||||
|
readingTimeMin: wordCount / WORDS_PER_MINUTE,
|
||||||
|
gradeLevel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gradeBand turns a Flesch–Kincaid grade into a friendly bilingual descriptor —
|
||||||
|
// far more useful to an ESL writer than a bare number.
|
||||||
|
export function gradeBand(grade: number): { zh: string; en: string } {
|
||||||
|
if (grade <= 5) return { zh: '简单', en: 'Easy' }
|
||||||
|
if (grade <= 8) return { zh: '标准', en: 'Standard' }
|
||||||
|
if (grade <= 12) return { zh: '偏难', en: 'Fairly hard' }
|
||||||
|
return { zh: '较难', en: 'Advanced' }
|
||||||
|
}
|
||||||
@@ -13,6 +13,11 @@ export function useCheckpoint(docId: string | null) {
|
|||||||
const [checking, setChecking] = useState(false)
|
const [checking, setChecking] = useState(false)
|
||||||
// True while a whole-document voice pass is in flight (explicit user action).
|
// True while a whole-document voice pass is in flight (explicit user action).
|
||||||
const [voicing, setVoicing] = useState(false)
|
const [voicing, setVoicing] = useState(false)
|
||||||
|
// True when the last LLM pass couldn't reach the model (server 502 or network
|
||||||
|
// error). Drives a gentle, reassuring "helper is resting" note — the writing
|
||||||
|
// itself still saves fine, so this is awareness, not an error. Cleared on the
|
||||||
|
// next success or doc switch.
|
||||||
|
const [llmDown, setLlmDown] = useState(false)
|
||||||
|
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
const docIdRef = useRef(docId)
|
const docIdRef = useRef(docId)
|
||||||
@@ -30,9 +35,11 @@ export function useCheckpoint(docId: string | null) {
|
|||||||
const fresh = await api.checkDoc(id)
|
const fresh = await api.checkDoc(id)
|
||||||
if (run === runRef.current && id === docIdRef.current) {
|
if (run === runRef.current && id === docIdRef.current) {
|
||||||
setSuggestions(fresh)
|
setSuggestions(fresh)
|
||||||
|
setLlmDown(false)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('checkpoint failed', err)
|
console.error('checkpoint failed', err)
|
||||||
|
if (run === runRef.current) setLlmDown(true)
|
||||||
} finally {
|
} finally {
|
||||||
if (run === runRef.current) setChecking(false)
|
if (run === runRef.current) setChecking(false)
|
||||||
}
|
}
|
||||||
@@ -50,9 +57,11 @@ export function useCheckpoint(docId: string | null) {
|
|||||||
const full = await api.voiceDoc(id)
|
const full = await api.voiceDoc(id)
|
||||||
if (run === runRef.current && id === docIdRef.current) {
|
if (run === runRef.current && id === docIdRef.current) {
|
||||||
setSuggestions(full)
|
setSuggestions(full)
|
||||||
|
setLlmDown(false)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('voice pass failed', err)
|
console.error('voice pass failed', err)
|
||||||
|
if (run === runRef.current) setLlmDown(true)
|
||||||
} finally {
|
} finally {
|
||||||
if (run === runRef.current) setVoicing(false)
|
if (run === runRef.current) setVoicing(false)
|
||||||
}
|
}
|
||||||
@@ -72,6 +81,7 @@ export function useCheckpoint(docId: string | null) {
|
|||||||
setSuggestions([])
|
setSuggestions([])
|
||||||
setChecking(false)
|
setChecking(false)
|
||||||
setVoicing(false)
|
setVoicing(false)
|
||||||
|
setLlmDown(false)
|
||||||
if (!docId) return
|
if (!docId) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -94,5 +104,5 @@ export function useCheckpoint(docId: string | null) {
|
|||||||
setSuggestions((prev) => prev.filter((s) => s.id !== id))
|
setSuggestions((prev) => prev.filter((s) => s.id !== id))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return { suggestions, checking, voicing, schedule, runVoice, removeSuggestion }
|
return { suggestions, checking, voicing, llmDown, schedule, runVoice, removeSuggestion }
|
||||||
}
|
}
|
||||||
|
|||||||
63
web/src/hooks/useTags.ts
Normal file
63
web/src/hooks/useTags.ts
Normal 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 }
|
||||||
|
}
|
||||||
@@ -156,6 +156,25 @@ button, a, input {
|
|||||||
animation: petal-suggestion-in 200ms ease both;
|
animation: petal-suggestion-in 200ms ease both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Word lookup popover ----------------------------------------------------
|
||||||
|
Right-click a word for its dictionary definition and synonyms. Same soft card
|
||||||
|
treatment as the spelling popover; synonym pills swap the word on click. */
|
||||||
|
.petal-word-card {
|
||||||
|
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 --------------------------------------------------------
|
/* --- Accept confetti --------------------------------------------------------
|
||||||
A tiny CSS-only burst played where a suggestion is accepted: four colored
|
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
|
dots spray up-and-out, then fade. No JS animation — each dot reads its
|
||||||
@@ -262,3 +281,131 @@ button, a, input {
|
|||||||
0%, 100% { opacity: 0.4; }
|
0%, 100% { opacity: 0.4; }
|
||||||
50% { opacity: 1; }
|
50% { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Organization & search (Phase 10) --------------------------------------
|
||||||
|
Tag chips, the search results list, and per-row affordances. The row actions
|
||||||
|
(tag / delete) fade in on hover for pointer users but are always visible on
|
||||||
|
touch (no hover) — see the coarse-pointer block below. */
|
||||||
|
.petal-tag-chip {
|
||||||
|
transition: background 160ms ease, color 160ms ease, transform 160ms ease;
|
||||||
|
}
|
||||||
|
.petal-search-results {
|
||||||
|
animation: petal-suggestion-in 160ms ease both;
|
||||||
|
}
|
||||||
|
.petal-tag-picker {
|
||||||
|
animation: petal-suggestion-in 160ms ease both;
|
||||||
|
}
|
||||||
|
.petal-row-action {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 160ms ease, background 160ms ease;
|
||||||
|
}
|
||||||
|
.group:hover .petal-row-action,
|
||||||
|
.petal-row-action:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.petal-row-action:hover {
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
}
|
||||||
|
/* Clamp a search snippet to two lines. */
|
||||||
|
.line-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Touch & tablet polish --------------------------------------------------
|
||||||
|
On coarse pointers (tablets, phones) there's no hover, so make tap targets
|
||||||
|
comfortable and reveal affordances that would otherwise be hover-only. */
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
.petal-tap {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
.petal-tap-sm {
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
/* Row actions can't rely on hover — keep them visible and roomy. */
|
||||||
|
.petal-row-action {
|
||||||
|
opacity: 1;
|
||||||
|
height: 36px;
|
||||||
|
width: 36px;
|
||||||
|
}
|
||||||
|
/* Bigger, easier-to-hit suggestion/word/tag pills. */
|
||||||
|
.petal-tag-chip {
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Responsive sidebar (narrow screens) ------------------------------------
|
||||||
|
Below the tablet breakpoint the sidebar becomes an overlay drawer toggled by a
|
||||||
|
hamburger in the header, instead of a permanent column. A scrim sits behind it.
|
||||||
|
On wide screens the toggle + scrim are hidden and the sidebar is in-flow. */
|
||||||
|
.petal-sidebar-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.petal-scrim {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.petal-sidebar-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
.petal-sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 48px; /* below the 12-height header */
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 30;
|
||||||
|
background: var(--color-bg);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
}
|
||||||
|
/* On mobile the sidebar is hidden by default; the drawer-open class slides it
|
||||||
|
in. (Distraction-free's petal-sidebar-hidden still wins to keep it closed.) */
|
||||||
|
.petal-sidebar:not(.petal-drawer-open) {
|
||||||
|
width: 0;
|
||||||
|
transform: translateX(-24px);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.petal-scrim.petal-scrim-show {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
inset: 48px 0 0 0;
|
||||||
|
z-index: 20;
|
||||||
|
background: rgba(61, 46, 57, 0.18);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Print / Save-as-PDF: strip every bit of app chrome and editing decoration so
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user