Compare commits
4 Commits
cf7720ea77
...
f3c4fe2c22
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3c4fe2c22 | ||
|
|
9e141e4169 | ||
|
|
60eba25fee | ||
|
|
8e1111d768 |
@@ -74,12 +74,40 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
|
||||
- [x] `MisspellCard` popover + EditorCore wiring — soft **rose wavy underline** (`.petal-misspelling`, pastel take on the red squiggle, not classic red). Click a flagged word → `posAtCoords`→`wordAt` opens a bilingual card ("拼写 · Spelling") with up to 5 nspell corrections as pills (click to replace via `insertContentAt`) + "添加到词典 · Add to dictionary". Closes on outside-pointer-down, doc edit, or doc switch.
|
||||
- Verified: tsc clean, vite build OK (dict in `dist/dictionaries/en/`), go build/vet clean; live server serves both dict files (200, 3086B aff / 551762B dic); nspell smoke (`helllo→hello`, `recieve→receive`, `写作` untokenized, `NASA` ok, `add()` persists).
|
||||
|
||||
### Phase 8 — Trust foundation (version history + export) ✅
|
||||
- [x] **Version history** — `document_versions` table (migration `0003`), full-body snapshots that cascade with the doc. Kinds: `auto` (throttled background, ≥3min apart, max 40/doc, pruned), `manual` (explicit restore point), `pre_restore` (safety copy taken before a restore, so restore is undoable). Snapshot taken post-save in `update` only when a real body came through and content changed (empties + bare renames never snapshot). Endpoints: `GET/POST /api/docs/:id/versions`, `GET /api/docs/:id/versions/:vid`, `POST /api/docs/:id/versions/:vid/restore`. All scoped to the owner via a join on `documents`. `versions_test.go` covers lifecycle/throttle/restore/pre_restore/404.
|
||||
- [x] **Export** — pure-Go Tiptap-JSON → Markdown / HTML / plain-text / **docx** (`export.go`), no cgo/pandoc, CJK-safe. docx is a hand-built OOXML zip (marks→run props, headings→built-in styles, lists→prefix). RFC 5987 `filename*=UTF-8''` so Chinese titles download cleanly. `GET /api/docs/:id/export?format=`. **PDF is client-side** via the browser print dialog + a `@media print` stylesheet (uses the reader's fonts → CJK for free, no embedded-font bloat). `export_test.go` asserts every format incl. valid-zip docx with CJK.
|
||||
- [x] **Frontend** — `ExportMenu` (download links + Print/PDF) and `HistoryPanel` (slide-over drawer: snapshot list w/ relative-time + kind badge, preview, restore; restore remounts the editor via an `editorEpoch` bump). Both bilingual zh-first, matching chrome. Wired into the title row; `.petal-no-print` strips all chrome for print.
|
||||
- [x] **Stop saving empty docs** — blank `Untitled` drafts now self-discard: `handleCreate` reuses an existing blank instead of stacking another; `openDoc` deletes the blank doc being left. Cleaned the 2 existing orphan empties from the live DB. (Backend also refuses to snapshot empties.)
|
||||
- Verified: go build/vet/test + tsc + vite all clean; live smoke on a throwaway binary — auto-snapshot on save, throttle holds at 1, manual snapshot, restore brings back exact text + leaves a `pre_restore`, empty doc → no snapshot, md/docx export with CJK+bold+heading+list, docx validates as "Microsoft Word 2007+".
|
||||
|
||||
### Phase 9 — ESL superpowers ✅
|
||||
- [x] **Inline Chinese gloss (offline)** — embedded English→Chinese dictionary (`internal/lexicon/data/gloss.json.gz`, ~1.3MB, 57k common words built from ECDICT via `scripts/build_gloss.py`: frequency-gated to rank ≤50k, `[网络]`/slang/archaic sense-lines dropped, trimmed to ≤3 senses / 80 chars). `Lexicon` gains a `gloss` map + `Gloss(word)` (same `candidates()` de-inflection as defs/syns); `Result` gains a `Gloss` field. Two surfaces: the right-click **WordCard** now leads with the 中文 gloss, and a new lightweight `GET /api/gloss/{word}` (→ `{word, gloss}`, cached) backs the **hover tooltip**. Offline + instant, works with the LLM down (north-star reliability). Frontend: `GlossTip` (dark pointer-events-none bubble under the resting word; 350ms hover delay; reuses `wordAt` so CJK is never glossed — it's the source language), wired into `EditorCore`'s `onMouseMove`/`onMouseLeave` with a request-token guard, suppressed during selection/preview/other popovers.
|
||||
- [x] **"Say it more naturally" / tone-rewrite** — selecting text pops a `SelectionBubble` (✨更自然 + the tone vocabulary 学术/专业/轻松/幽默/创意/说服, mirrored from `ToneSelect`/`styleGuidance`). Picking a style calls `POST /api/docs/:id/rewrite` (`{text, style}` → `{rewrite}`), shown in a `RewritePreview` (original struck-through → rewrite, 用这个/取消, breathing-dot loading, gentle retry on failure). Accept applies it in-editor via `insertContentAt` over the captured PM range. Backend: `llm.RunRewrite` (one-shot Complete, `RewriteMaxRunes` 2000 cap, `cleanRewrite` strips stray wrapping quotes) + `rewriteSystemTemplate`/`styleGuidance` in `prompts.go`; handler in `internal/suggestions/rewrite.go` (owner-scoped 404, 400 on empty/too-long, 502 on LLM-down). **Stateless** — not persisted as a suggestion; the version history captures the resulting doc change.
|
||||
- Tests: `lexicon` gloss + Lookup-includes-gloss + inflection/miss; `suggestions` rewrite happy-path (style steering + de-quote asserted), empty→400, unknown-doc→404. go build/vet/test clean, tsc clean, vite build OK. Live smoke vs a fake vLLM (fresh port 8055, throwaway DB; pre-existing dev servers on :8077/:8099 untouched): gloss for `river`/inflected/CJK-empty/nonsense-empty, `word/happy` carries the gloss, rewrite returns text, empty→400, unknown→404, LLM-down→502; new CSS classes present in the built bundle.
|
||||
- **Known limitation**: `Gloss` tries the literal form first (matching defs/syns ordering), so an inflected word that is *itself* a separate ECDICT headword resolves to that entry rather than de-inflecting (e.g. `rivers` → the proper-noun "Rivers" sense, not `river`). The base form always glosses correctly; acceptable.
|
||||
|
||||
### Phase 10 — Organization & polish ✅
|
||||
- [x] **Cross-document search (FTS5)** — migration `0004` adds a `documents_fts` virtual table over `title` + `content_text` using the **`trigram` tokenizer** (so search works for both English and space-free Chinese; the default unicode61 tokenizer treats a CJK run as one token). Kept in sync by `AFTER INSERT/UPDATE/DELETE` triggers on `documents`, back-filled from existing rows in the migration (verified: pre-existing docs are searchable immediately). `GET /api/search?q=` (`internal/docs/search.go`): queries of ≥3 runes use the FTS index (fast, `ORDER BY rank`); shorter queries fall back to a `LIKE` scan so **2-character Chinese words** (e.g. 公园) still resolve. Snippets are built in **Go** from the original text (clean word boundaries, rune-aware so CJK never splits mid-char), with the match wrapped in `\x01…\x02` sentinels; the client splits on these to highlight without `innerHTML`. Owner-scoped, capped at 50 hits. Frontend `SearchBox` in the sidebar: 220ms-debounced, results with highlighted two-line snippets, click to open.
|
||||
- [x] **Tags (organize)** — migration `0004` adds `tags` (user-scoped, `UNIQUE(user_id, name)`, `color` = palette key) + `document_tags` join (both sides cascade). `internal/docs/tags.go`: `GET/POST/PATCH/DELETE /api/tags` (create is **idempotent** on name; unknown colors coerced to rose) + `POST /api/docs/:id/tags` / `DELETE /api/docs/:id/tags/:tagId` (owner-validated, idempotent assign). The doc-list and search responses carry each doc's tags (loaded in one `tagsByDoc` query, no N+1). Frontend: `useTags` (roster + counts), `TagChip`, `TagPicker` (assign existing / create-and-attach with a color swatch), tag chips on each doc row, a **filter bar** (client-side filter by tag, shows in-use tags with counts). Colors map to the existing design tokens via `tagColorVar`.
|
||||
- [x] **Tablet / touch polish** — responsive sidebar: below 768px it becomes an overlay **drawer** toggled by a header hamburger, with a scrim (auto-closes on doc select). `@media (pointer: coarse)` enlarges tap targets (`.petal-tap` ≥44px, `.petal-tap-sm` ≥36px) and reveals the hover-only row actions (tag/delete). **Tap-to-open** for AI-suggestion cards (no hover on touch): a tap on a `.petal-suggestion` opens its card via the editor click handler, and a `pointerdown` outside the card/highlight dismisses it (mouse users keep the hover bridge).
|
||||
- [x] **Warm LLM-down failure states** — `useCheckpoint` now tracks an `llmDown` flag (set when a check/voice pass hits the server's 502/network path, cleared on the next success or doc switch). The `StatusBar` shows a gentle bilingual note — 🌙 **小助手在休息 · Petal's helper is resting · 文字已保存** — reassuring that the writing still saved locally (saving is independent of the LLM). Rewrite already had a gentle retry from Phase 9.
|
||||
- Tests: `tags_test.go` (full lifecycle — create/idempotent/color-coerce/rename-recolor/assign/unassign/doc-list inclusion/roster counts/delete-cascade/404s), `search_test.go` (EN FTS, CJK FTS, 2-char CJK LIKE fallback, title-only, case-insensitive, empty/no-match, **edit re-indexes via the update trigger**). go build/vet/test clean, tsc clean, vite build OK. Live smoke vs the binary on a throwaway DB (port 8061, LLM pointed at a dead host): search EN/CJK/2-char all highlighted, tag create+assign+roster-counts+doc-list-tags+delete-cascade, check→502 (warm path), new CSS classes (`petal-tag-chip`/`petal-scrim`/`petal-drawer-open`/`pointer:coarse`) and the 小助手在休息 string present in the served bundle. FTS backfill of pre-existing docs verified separately.
|
||||
- **Known limitations**: trigram FTS snippets/ranking treat the query as a contiguous phrase (multi-term relevance is substring, not BM25-per-term) — fine for a personal corpus. Search is title+body only (not tag names). Hover gloss and right-click word lookup remain pointer-oriented (long-press contextmenu on touch is browser-dependent); the spelling/suggestion cards and rewrite bubble are fully touch-reachable.
|
||||
|
||||
### Deferred (post-v1-local)
|
||||
- [ ] Authentik OIDC auth + session middleware
|
||||
- [ ] Authentik OIDC auth + session middleware ← **on hold: user doing foundational work first**
|
||||
- [ ] Copyleaks Tier-2 + webhook HMAC
|
||||
- [ ] Dockerfile, docker-compose, Traefik, deploy to write.parodia.dev
|
||||
|
||||
### Next-up (post-v1 product, agreed with user 2026-06-26)
|
||||
- [x] **Phase 9 — ESL superpowers**: inline Chinese gloss on hover/select; "say it more naturally" / tone-rewrite. ✅ (see Phase 9 above)
|
||||
- [x] **Phase 10 — organization & polish**: cross-doc search, tags, tablet/touch polish, warm LLM-down failure states. ✅ (see Phase 10 above; "tags only" chosen over folders, FTS5 over LIKE)
|
||||
|
||||
## Session log
|
||||
- 2026-06-26: **Phase 10 complete** (organization & polish). Scope confirmed with user: all four areas, **tags** (not folders), **FTS5** search. Backend: migration `0004_tags_and_search` (tags + document_tags + `documents_fts` trigram virtual table with sync triggers + back-fill); `db.Tag` model + color constants; `internal/docs/tags.go` (tag CRUD + idempotent assignment + `tagsByDoc` helper, doc list now carries tags); `internal/docs/search.go` (`GET /api/search`, FTS for ≥3 runes + LIKE fallback for 1-2, Go-built sentinel-highlighted rune-aware snippets, owner-scoped). Mounted `/api/tags` + `/api/search` in main.go. Frontend: `useTags`, `TagChip`/`TagPicker`/`SearchBox`, rewritten `DocList`/`DocListItem` (chips + filter bar + search), `api.search`/tag methods + `splitSnippet`/`tagColorVar`; responsive sidebar drawer (hamburger + scrim, <768px) + `pointer:coarse` tap-target/affordance CSS; tap-to-open + outside-pointerdown-close for suggestion cards (touch); `useCheckpoint` `llmDown` flag → warm bilingual "小助手在休息" StatusBar note. Tests: `tags_test.go`, `search_test.go` (incl. update-trigger re-index). All builds/tests/vet/tsc/vite clean; live smoke vs binary on :8061 (dead LLM host) verified search EN/CJK/2-char, full tag lifecycle, check→502 warm path, bundle contents; FTS backfill of pre-existing docs verified. **All v1 phases (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: **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.
|
||||
|
||||
@@ -62,15 +62,24 @@ func main() {
|
||||
|
||||
// Document CRUD plus the doc-scoped checkpoint/list suggestion routes,
|
||||
// both under /api/docs.
|
||||
docsRouter := docs.New(database).Routes()
|
||||
docsHandler := docs.New(database)
|
||||
docsRouter := docsHandler.Routes()
|
||||
sug.RegisterDocRoutes(docsRouter)
|
||||
api.Mount("/docs", docsRouter)
|
||||
|
||||
// Tag management (the roster) and cross-document full-text search.
|
||||
api.Mount("/tags", docsHandler.TagRoutes())
|
||||
api.Mount("/search", docsHandler.SearchRoutes())
|
||||
|
||||
// Per-suggestion actions (accept/dismiss) under /api/suggestions.
|
||||
api.Mount("/suggestions", sug.Routes())
|
||||
|
||||
// Offline word lookups (definition + synonyms) for the right-click popover.
|
||||
api.Mount("/word", lexicon.NewHandler().Routes())
|
||||
// Offline lexicon: full word lookups (gloss + definition + synonyms) for
|
||||
// the right-click popover, and the lightweight Chinese-only gloss for the
|
||||
// inline hover/select tooltip. One handler so the datasets load once.
|
||||
lex := lexicon.NewHandler()
|
||||
api.Mount("/word", lex.Routes())
|
||||
api.Mount("/gloss", lex.GlossRoutes())
|
||||
})
|
||||
|
||||
// Everything else: serve the embedded SPA (with index.html fallback for client routing).
|
||||
|
||||
@@ -176,6 +176,92 @@ CREATE INDEX idx_plagiarism_doc_id ON plagiarism_reports(doc_id);
|
||||
name: "0002_document_tone",
|
||||
stmt: `ALTER TABLE documents ADD COLUMN tone TEXT NOT NULL DEFAULT 'general';`,
|
||||
},
|
||||
{
|
||||
// Version history: point-in-time snapshots of a document's body so a
|
||||
// bad edit or LLM mishap is always recoverable. `kind` distinguishes
|
||||
// throttled background snapshots ('auto'), explicit user restore
|
||||
// points ('manual'), and the safety copy taken right before a restore
|
||||
// ('pre_restore') so restoring is itself undoable. Snapshots cascade
|
||||
// with the document. Stored fully (content + content_text) so a
|
||||
// restore is a plain copy with no re-derivation.
|
||||
name: "0003_document_versions",
|
||||
stmt: `
|
||||
CREATE TABLE document_versions (
|
||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
content_text TEXT NOT NULL,
|
||||
word_count INTEGER NOT NULL DEFAULT 0,
|
||||
kind TEXT NOT NULL DEFAULT 'auto'
|
||||
CHECK(kind IN ('auto','manual','pre_restore')),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_versions_doc_id ON document_versions(doc_id, created_at DESC);
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Organization & search (Phase 10). Two parts:
|
||||
//
|
||||
// 1. Tags. A small, user-scoped label set; `color` holds a palette key
|
||||
// (rose/mint/peach/lavender/sky/honey) the frontend maps to CSS.
|
||||
// document_tags is the many-to-many join; both sides cascade so
|
||||
// deleting a doc or a tag cleans up its assignments.
|
||||
//
|
||||
// 2. Full-text search. A standalone FTS5 virtual table over title +
|
||||
// content_text using the `trigram` tokenizer so search works for
|
||||
// both English and space-free Chinese (the default tokenizer treats a
|
||||
// CJK run as one token). It carries an UNINDEXED doc_id to map hits
|
||||
// back to documents, kept in sync by AFTER INSERT/UPDATE/DELETE
|
||||
// triggers, and is back-filled from the existing documents here.
|
||||
// (Trigram needs ≥3 chars to MATCH; the search handler falls back to
|
||||
// LIKE for shorter queries — common for 2-character Chinese words.)
|
||||
name: "0004_tags_and_search",
|
||||
stmt: `
|
||||
CREATE TABLE tags (
|
||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT 'rose',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE document_tags (
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (doc_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_document_tags_tag ON document_tags(tag_id);
|
||||
|
||||
CREATE VIRTUAL TABLE documents_fts USING fts5(
|
||||
doc_id UNINDEXED,
|
||||
title,
|
||||
content_text,
|
||||
tokenize='trigram'
|
||||
);
|
||||
|
||||
CREATE TRIGGER documents_ai AFTER INSERT ON documents BEGIN
|
||||
INSERT INTO documents_fts (doc_id, title, content_text)
|
||||
VALUES (new.id, new.title, new.content_text);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER documents_ad AFTER DELETE ON documents BEGIN
|
||||
DELETE FROM documents_fts WHERE doc_id = old.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER documents_au AFTER UPDATE ON documents BEGIN
|
||||
UPDATE documents_fts
|
||||
SET title = new.title, content_text = new.content_text
|
||||
WHERE doc_id = old.id;
|
||||
END;
|
||||
|
||||
INSERT INTO documents_fts (doc_id, title, content_text)
|
||||
SELECT id, title, content_text FROM documents;
|
||||
`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,53 @@ type Document struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// DocumentVersion is a point-in-time snapshot of a document's body, captured so
|
||||
// a writer can recover from a bad edit or an unwanted change. `Content` mirrors
|
||||
// the document's Tiptap JSON at snapshot time; `Kind` records why it was taken
|
||||
// (see the kind constants). List responses omit the heavy Content/ContentText
|
||||
// fields (the `omitempty`-friendly zero strings) and load them only on preview
|
||||
// or restore.
|
||||
type DocumentVersion struct {
|
||||
ID string `json:"id"`
|
||||
DocID string `json:"doc_id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content,omitempty"` // Tiptap JSON; omitted in list view
|
||||
ContentText string `json:"content_text,omitempty"` // plain text; omitted in list view
|
||||
WordCount int `json:"word_count"`
|
||||
Kind string `json:"kind"` // auto | manual | pre_restore
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Document version kinds, mirrored from the schema CHECK constraint.
|
||||
const (
|
||||
VersionKindAuto = "auto" // throttled background snapshot on save
|
||||
VersionKindManual = "manual" // explicit "save a restore point"
|
||||
VersionKindPreRestore = "pre_restore" // safety copy taken just before a restore
|
||||
)
|
||||
|
||||
// Tag is a user-scoped label for organizing documents. `Color` is a palette key
|
||||
// (rose, mint, peach, lavender, sky, honey) the frontend maps to a CSS color;
|
||||
// storing the key (not a hex value) keeps tags in step with the design tokens.
|
||||
// `DocCount` is populated only by the tag-list endpoint (how many documents wear
|
||||
// the tag); it's omitted from per-document tag lists.
|
||||
type Tag struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
DocCount int `json:"doc_count,omitempty"`
|
||||
}
|
||||
|
||||
// Tag color palette keys, mirrored on the frontend. Kept small and aligned with
|
||||
// the existing design tokens; unknown values fall back to rose client-side.
|
||||
const (
|
||||
TagColorRose = "rose"
|
||||
TagColorMint = "mint"
|
||||
TagColorPeach = "peach"
|
||||
TagColorLavender = "lavender"
|
||||
TagColorSky = "sky"
|
||||
TagColorHoney = "honey"
|
||||
)
|
||||
|
||||
// Suggestion is a single LLM-proposed edit anchored to a span of the document.
|
||||
//
|
||||
// FromPos/ToPos are plaintext offsets into ContentText for server-side use only;
|
||||
|
||||
629
internal/docs/export.go
Normal file
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"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -33,19 +34,25 @@ func (h *Handler) Routes() chi.Router {
|
||||
r.Get("/{id}", h.get)
|
||||
r.Put("/{id}", h.update)
|
||||
r.Delete("/{id}", h.delete)
|
||||
h.versionRoutes(r)
|
||||
h.exportRoutes(r)
|
||||
h.registerTagRoutes(r)
|
||||
return r
|
||||
}
|
||||
|
||||
// docSummary is the lightweight shape returned by the list endpoint — enough to
|
||||
// render the DocList sidebar without shipping every document's full body.
|
||||
// render the DocList sidebar without shipping every document's full body. Tags
|
||||
// ride along so the sidebar can show chips and filter without a second request.
|
||||
type docSummary struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
WordCount int `json:"word_count"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
WordCount int `json:"word_count"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Tags []db.Tag `json:"tags"`
|
||||
}
|
||||
|
||||
// list returns the local user's documents, most-recently-updated first.
|
||||
// list returns the local user's documents, most-recently-updated first, each
|
||||
// decorated with its tags.
|
||||
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := h.DB.Query(
|
||||
`SELECT id, title, word_count, updated_at
|
||||
@@ -61,6 +68,7 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
|
||||
defer rows.Close()
|
||||
|
||||
out := []docSummary{} // non-nil so an empty list serializes as [] not null
|
||||
ids := []string{}
|
||||
for rows.Next() {
|
||||
var d docSummary
|
||||
if err := rows.Scan(&d.ID, &d.Title, &d.WordCount, &d.UpdatedAt); err != nil {
|
||||
@@ -68,11 +76,24 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
out = append(out, d)
|
||||
ids = append(ids, d.ID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
byDoc, err := h.tagsByDoc(ids)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
for i := range out {
|
||||
out[i].Tags = byDoc[out[i].ID] // nil → JSON null is fine; client treats as none
|
||||
if out[i].Tags == nil {
|
||||
out[i].Tags = []db.Tag{}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
@@ -155,6 +176,16 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Capture a throttled history snapshot when a real body save came through
|
||||
// (not a bare rename) and the document has content. Best-effort: a failed
|
||||
// snapshot must never fail the save itself.
|
||||
if req.Content != nil && doc.ContentText != "" {
|
||||
if err := h.maybeAutoSnapshot(doc); err != nil {
|
||||
log.Printf("docs: auto-snapshot for %s failed: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, doc)
|
||||
}
|
||||
|
||||
@@ -208,3 +239,4 @@ func serverError(w http.ResponseWriter, err error) {
|
||||
|
||||
func badRequest(w http.ResponseWriter, msg string) { errorJSON(w, http.StatusBadRequest, msg) }
|
||||
func notFound(w http.ResponseWriter) { errorJSON(w, http.StatusNotFound, "document not found") }
|
||||
func notFoundMsg(w http.ResponseWriter, msg string) { errorJSON(w, http.StatusNotFound, msg) }
|
||||
|
||||
261
internal/docs/search.go
Normal file
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)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
// Package lexicon serves offline word lookups — a definition and a list of
|
||||
// synonyms for a single word — from two public-domain datasets compiled into the
|
||||
// binary. Definitions come from the Wordset dictionary (modern, concise glosses
|
||||
// with a part of speech and example, which read kindly for an ESL writer);
|
||||
// synonyms come from the Moby Thesaurus. Both are gzipped JSON, decompressed
|
||||
// lazily on first use so a writer who never right-clicks a word pays nothing.
|
||||
// Package lexicon serves offline word lookups — a Chinese gloss, a definition,
|
||||
// and a list of synonyms for a single word — from public datasets compiled into
|
||||
// the binary. Definitions come from the Wordset dictionary (modern, concise
|
||||
// glosses with a part of speech and example, which read kindly for an ESL
|
||||
// writer); synonyms come from the Moby Thesaurus; the Chinese gloss comes from
|
||||
// ECDICT. All are gzipped JSON, decompressed lazily on first use so a writer who
|
||||
// never looks up a word pays nothing.
|
||||
package lexicon
|
||||
|
||||
import _ "embed"
|
||||
@@ -19,3 +20,10 @@ var definitionsGz []byte
|
||||
//
|
||||
//go:embed data/synonyms.json.gz
|
||||
var synonymsGz []byte
|
||||
|
||||
// glossGz is the gzipped English→Chinese gloss map: word → 中文 gloss, lowercase
|
||||
// keys. Built from ECDICT (scripts/build_gloss.py), filtered to common words so
|
||||
// an ESL writer who speaks Mandarin gets an instant translation on hover/lookup.
|
||||
//
|
||||
//go:embed data/gloss.json.gz
|
||||
var glossGz []byte
|
||||
|
||||
BIN
internal/lexicon/data/gloss.json.gz
Normal file
BIN
internal/lexicon/data/gloss.json.gz
Normal file
Binary file not shown.
@@ -25,6 +25,15 @@ func (h *Handler) Routes() chi.Router {
|
||||
return r
|
||||
}
|
||||
|
||||
// GlossRoutes returns the router mounted at /api/gloss — the lightweight
|
||||
// Chinese-only lookup behind the inline hover/select gloss. It shares the
|
||||
// Handler's Lexicon, so the datasets still load just once.
|
||||
func (h *Handler) GlossRoutes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/{word}", h.gloss)
|
||||
return r
|
||||
}
|
||||
|
||||
// lookup returns the definition + synonyms for one word. A word found in neither
|
||||
// dataset still returns 200 with empty lists, so the popover can show a friendly
|
||||
// "nothing found" rather than an error state.
|
||||
@@ -48,3 +57,25 @@ func (h *Handler) lookup(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_ = json.NewEncoder(w).Encode(res)
|
||||
}
|
||||
|
||||
// gloss returns just the Chinese translation for one word. Like lookup, a miss
|
||||
// is a 200 with an empty gloss so the hover tooltip can quietly skip rather than
|
||||
// error.
|
||||
func (h *Handler) gloss(w http.ResponseWriter, r *http.Request) {
|
||||
word := chi.URLParam(r, "word")
|
||||
if decoded, err := url.PathUnescape(word); err == nil {
|
||||
word = decoded
|
||||
}
|
||||
|
||||
res, err := h.Lex.Gloss(word)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_ = json.NewEncoder(w).Encode(res)
|
||||
}
|
||||
|
||||
@@ -20,13 +20,24 @@ type Meaning struct {
|
||||
|
||||
// Result is the full lookup for one word. Either list may be empty (the word
|
||||
// isn't a headword in that dataset); the frontend handles a partial or empty
|
||||
// result gracefully.
|
||||
// result gracefully. Gloss is the Chinese translation (empty when the word isn't
|
||||
// in the gloss dataset) — shown first in the popover for the Mandarin-speaking
|
||||
// writer.
|
||||
type Result struct {
|
||||
Word string `json:"word"`
|
||||
Gloss string `json:"gloss"`
|
||||
Definitions []Meaning `json:"definitions"`
|
||||
Synonyms []string `json:"synonyms"`
|
||||
}
|
||||
|
||||
// GlossResult is the lightweight payload for the inline hover/select gloss: just
|
||||
// the word and its Chinese translation, no definitions or synonyms. Kept small
|
||||
// so the hover tooltip is instant and trivially cacheable.
|
||||
type GlossResult struct {
|
||||
Word string `json:"word"`
|
||||
Gloss string `json:"gloss"`
|
||||
}
|
||||
|
||||
// maxSynonyms caps how many synonyms we hand the popover, even though the dataset
|
||||
// stores up to ~50 per word — a long flat wall of words overwhelms more than it
|
||||
// helps, especially for an ESL reader scanning for the right fit.
|
||||
@@ -44,6 +55,7 @@ type Lexicon struct {
|
||||
loadErr error
|
||||
defs map[string][][]string // word → [[pos, def, example], …]
|
||||
synonyms map[string][]string // word → [synonym, …]
|
||||
gloss map[string]string // word → Chinese gloss
|
||||
}
|
||||
|
||||
// New returns a Lexicon. The datasets aren't read until the first Lookup.
|
||||
@@ -59,6 +71,10 @@ func (l *Lexicon) load() {
|
||||
l.loadErr = fmt.Errorf("load synonyms: %w", err)
|
||||
return
|
||||
}
|
||||
if err := gunzipJSON(glossGz, &l.gloss); err != nil {
|
||||
l.loadErr = fmt.Errorf("load gloss: %w", err)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -78,6 +94,8 @@ func (l *Lexicon) Lookup(word string) (Result, error) {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
res.Gloss = lookupGloss(l.gloss, norm)
|
||||
|
||||
if raw := lookupDefs(l.defs, norm); raw != nil {
|
||||
for _, m := range raw {
|
||||
res.Definitions = append(res.Definitions, toMeaning(m))
|
||||
@@ -97,6 +115,32 @@ func (l *Lexicon) Lookup(word string) (Result, error) {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Gloss returns just the Chinese translation for word (empty when absent). This
|
||||
// is the fast path behind the inline hover/select gloss — it skips the
|
||||
// definition and synonym datasets entirely.
|
||||
func (l *Lexicon) Gloss(word string) (GlossResult, error) {
|
||||
l.load()
|
||||
if l.loadErr != nil {
|
||||
return GlossResult{}, l.loadErr
|
||||
}
|
||||
norm := strings.ToLower(strings.TrimSpace(word))
|
||||
return GlossResult{Word: word, Gloss: lookupGloss(l.gloss, norm)}, nil
|
||||
}
|
||||
|
||||
// lookupGloss walks the candidate forms of a word and returns the first gloss
|
||||
// hit (so "running"/"studies" resolve via the same de-inflection as defs/syns).
|
||||
func lookupGloss(m map[string]string, word string) string {
|
||||
if word == "" {
|
||||
return ""
|
||||
}
|
||||
for _, c := range candidates(word) {
|
||||
if v, ok := m[c]; ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// lookupDefs / lookupSyns walk the candidate forms of a word and return the
|
||||
// first dataset hit. They're separate (rather than a generic helper) only
|
||||
// because the two maps have different value types.
|
||||
|
||||
@@ -51,6 +51,48 @@ func TestLookupUnknownWord(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGloss(t *testing.T) {
|
||||
l := New()
|
||||
// A common word carries a Chinese gloss...
|
||||
res, err := l.Gloss("river")
|
||||
if err != nil {
|
||||
t.Fatalf("Gloss: %v", err)
|
||||
}
|
||||
if res.Word != "river" {
|
||||
t.Errorf("Word = %q, want %q", res.Word, "river")
|
||||
}
|
||||
if res.Gloss == "" {
|
||||
t.Errorf("expected a Chinese gloss for %q, got none", "river")
|
||||
}
|
||||
// ...and the inflected form resolves via the same de-inflection.
|
||||
inflected, err := l.Gloss("rivers")
|
||||
if err != nil {
|
||||
t.Fatalf("Gloss(rivers): %v", err)
|
||||
}
|
||||
if inflected.Gloss == "" {
|
||||
t.Errorf("expected a gloss for inflected %q, got none", "rivers")
|
||||
}
|
||||
// A nonsense word is a clean empty (not an error).
|
||||
miss, err := l.Gloss("zzzxqqq")
|
||||
if err != nil {
|
||||
t.Fatalf("Gloss(miss): %v", err)
|
||||
}
|
||||
if miss.Gloss != "" {
|
||||
t.Errorf("expected empty gloss for nonsense word, got %q", miss.Gloss)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupIncludesGloss(t *testing.T) {
|
||||
l := New()
|
||||
res, err := l.Lookup("happy")
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup: %v", err)
|
||||
}
|
||||
if res.Gloss == "" {
|
||||
t.Errorf("expected Lookup to include a Chinese gloss for %q", "happy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidates(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"cats": "cat",
|
||||
|
||||
@@ -120,3 +120,48 @@ Keep responses concise (2-4 sentences). This is a chat, not an essay. Be encoura
|
||||
func AskPetalSystemPrompt(original, replacement, suggestionType, explanation, paragraph string) string {
|
||||
return fmt.Sprintf(askPetalSystemTemplate, original, replacement, suggestionType, explanation, paragraph)
|
||||
}
|
||||
|
||||
// rewriteSystemTemplate drives the "say it more naturally" / tone-rewrite tool.
|
||||
// The writer selects a passage and picks a style; the model rewrites that
|
||||
// passage in place. The instruction is deliberately strict about returning ONLY
|
||||
// the rewritten passage so the result can be dropped straight into the editor —
|
||||
// no quotes, no preamble, no commentary to strip.
|
||||
const rewriteSystemTemplate = `You are Petal, a warm English writing assistant helping someone who speaks English ` +
|
||||
`as a second language. Rewrite the passage the user sends so that it %s, while preserving its original ` +
|
||||
`meaning. Fix any grammar mistakes and awkward phrasing along the way. Keep it about the same length — ` +
|
||||
`do not add new ideas, explanations, or commentary.
|
||||
|
||||
Respond with ONLY the rewritten passage. No quotation marks around it, no preamble, no notes — just the ` +
|
||||
`rewritten English text, ready to drop back into the document.`
|
||||
|
||||
// styleGuidance maps a rewrite style onto the clause describing the target
|
||||
// register. "natural" is the default "say it more naturally" action; the rest
|
||||
// mirror the document-tone vocabulary (see toneGuidance / the ToneSelect UI).
|
||||
// An unknown style falls back to the natural rewrite.
|
||||
func styleGuidance(style string) string {
|
||||
switch style {
|
||||
case "academic":
|
||||
return "reads as formal, academic English suited to a school essay or research paper"
|
||||
case "professional":
|
||||
return "reads as polished, professional English suited to a workplace email or report"
|
||||
case "casual":
|
||||
return "sounds relaxed, friendly, and conversational"
|
||||
case "humorous":
|
||||
return "has a light, playful, good-humored tone"
|
||||
case "creative":
|
||||
return "is vivid, expressive, and imaginative"
|
||||
case "persuasive":
|
||||
return "is confident and persuasive"
|
||||
default: // "natural"
|
||||
return "sounds natural and fluent, the way a native English speaker would naturally say it"
|
||||
}
|
||||
}
|
||||
|
||||
// RewriteMessages builds the message array for a tone-rewrite: the styled system
|
||||
// instruction plus the passage to rewrite as the user turn.
|
||||
func RewriteMessages(text, style string) []Message {
|
||||
return []Message{
|
||||
{Role: "system", Content: fmt.Sprintf(rewriteSystemTemplate, styleGuidance(style))},
|
||||
{Role: "user", Content: text},
|
||||
}
|
||||
}
|
||||
|
||||
46
internal/llm/rewrite.go
Normal file
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
|
||||
}
|
||||
@@ -44,6 +44,7 @@ func New(database *db.DB, client llm.LLMClient) *Handler {
|
||||
func (h *Handler) RegisterDocRoutes(r chi.Router) {
|
||||
r.Post("/{id}/check", h.check)
|
||||
r.Post("/{id}/voice", h.voice)
|
||||
r.Post("/{id}/rewrite", h.rewrite)
|
||||
r.Get("/{id}/suggestions", h.listForDoc)
|
||||
}
|
||||
|
||||
|
||||
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])
|
||||
192
web/src/App.tsx
192
web/src/App.tsx
@@ -1,11 +1,14 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api, type DocSummary, type Document, type Suggestion } from './api/client'
|
||||
import { api, type DocSummary, type Document, type Suggestion, type Tag, type TagColor } from './api/client'
|
||||
import { useAutoSave } from './hooks/useAutoSave'
|
||||
import { useCheckpoint } from './hooks/useCheckpoint'
|
||||
import { useSpellChecker } from './hooks/useSpellChecker'
|
||||
import { useTags } from './hooks/useTags'
|
||||
import { DocList } from './components/DocList/DocList'
|
||||
import { EditorCore, type EditorChange } from './components/Editor/EditorCore'
|
||||
import { ToneSelect } from './components/Editor/ToneSelect'
|
||||
import { ExportMenu } from './components/Export/ExportMenu'
|
||||
import { HistoryPanel } from './components/History/HistoryPanel'
|
||||
import { StatusBar } from './components/StatusBar/StatusBar'
|
||||
import { PetalCompanion } from './components/Companion/PetalCompanion'
|
||||
import { UpdateBanner } from './components/UpdateBanner/UpdateBanner'
|
||||
@@ -25,30 +28,105 @@ export default function App() {
|
||||
// Distraction-free mode: entered on editor focus, collapses the doc-list
|
||||
// sidebar. Escape or a click outside the editor canvas restores it.
|
||||
const [focusMode, setFocusMode] = useState(false)
|
||||
// Mobile drawer: below the tablet breakpoint the sidebar is an overlay toggled
|
||||
// by the header hamburger. Ignored on wide screens (sidebar is always in-flow).
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const canvasRef = useRef<HTMLDivElement>(null)
|
||||
// Monotonic counters the companion watches to react to writing + accepts.
|
||||
const [editTick, setEditTick] = useState(0)
|
||||
const [acceptTick, setAcceptTick] = useState(0)
|
||||
// History drawer visibility, and an epoch bumped on restore to force the
|
||||
// editor to remount with the restored content (its initialContent is read
|
||||
// only on mount).
|
||||
const [historyOpen, setHistoryOpen] = useState(false)
|
||||
const [editorEpoch, setEditorEpoch] = useState(0)
|
||||
|
||||
// Live mirrors of the current doc's editable fields so the "discard blank
|
||||
// drafts on navigation" logic can read the latest values without rebuilding
|
||||
// callbacks on every keystroke.
|
||||
const currentDocRef = useRef(currentDoc)
|
||||
const titleRef = useRef(title)
|
||||
const wordCountRef = useRef(wordCount)
|
||||
currentDocRef.current = currentDoc
|
||||
titleRef.current = title
|
||||
wordCountRef.current = wordCount
|
||||
|
||||
// A throwaway blank draft: no words and still the default/empty title. We
|
||||
// delete these on navigation rather than leave orphan "Untitled" docs behind.
|
||||
const isBlankDraft = useCallback(() => {
|
||||
const t = titleRef.current.trim()
|
||||
return wordCountRef.current === 0 && (t === '' || t === 'Untitled')
|
||||
}, [])
|
||||
|
||||
const { status, schedule, saveNow } = useAutoSave(currentDoc?.id ?? null)
|
||||
const {
|
||||
suggestions,
|
||||
checking,
|
||||
voicing,
|
||||
llmDown,
|
||||
schedule: scheduleCheckpoint,
|
||||
runVoice,
|
||||
removeSuggestion,
|
||||
} = useCheckpoint(currentDoc?.id ?? null)
|
||||
// Browser-side spell checker — loads the en-US dictionary once per session.
|
||||
const { checker: spellChecker, addWord } = useSpellChecker()
|
||||
// The tag roster (with counts). Assignments live on the doc summaries below.
|
||||
const { tags: tagRoster, refresh: refreshTags, createTag } = useTags()
|
||||
|
||||
// Patch a summary in the sidebar list (optimistic title / word-count updates).
|
||||
const patchSummary = useCallback((id: string, patch: Partial<DocSummary>) => {
|
||||
setDocs((prev) => prev.map((d) => (d.id === id ? { ...d, ...patch } : d)))
|
||||
}, [])
|
||||
|
||||
// Attach or detach a tag on a document, updating the sidebar optimistically and
|
||||
// refreshing the roster so its counts stay current. Tags are kept sorted by
|
||||
// name to match the server's ordering.
|
||||
const setDocTag = useCallback(
|
||||
async (docId: string, tag: Tag, attach: boolean) => {
|
||||
setDocs((prev) =>
|
||||
prev.map((d) => {
|
||||
if (d.id !== docId) return d
|
||||
const without = d.tags.filter((t) => t.id !== tag.id)
|
||||
const next = attach ? [...without, tag] : without
|
||||
next.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }))
|
||||
return { ...d, tags: next }
|
||||
}),
|
||||
)
|
||||
try {
|
||||
if (attach) await api.assignTag(docId, tag.id)
|
||||
else await api.unassignTag(docId, tag.id)
|
||||
void refreshTags()
|
||||
} catch (err) {
|
||||
console.error('tag assignment failed', err)
|
||||
void refreshTags()
|
||||
}
|
||||
},
|
||||
[refreshTags],
|
||||
)
|
||||
|
||||
const handleToggleTag = useCallback(
|
||||
(docId: string, tag: Tag) => {
|
||||
const doc = docs.find((d) => d.id === docId)
|
||||
const has = doc?.tags.some((t) => t.id === tag.id) ?? false
|
||||
void setDocTag(docId, tag, !has)
|
||||
},
|
||||
[docs, setDocTag],
|
||||
)
|
||||
|
||||
const handleCreateTag = useCallback(
|
||||
async (docId: string, name: string, color: TagColor) => {
|
||||
const tag = await createTag(name, color)
|
||||
if (tag) void setDocTag(docId, tag, true)
|
||||
},
|
||||
[createTag, setDocTag],
|
||||
)
|
||||
|
||||
const openDoc = useCallback(
|
||||
async (id: string) => {
|
||||
setDrawerOpen(false) // close the mobile drawer when a doc is chosen
|
||||
// Decide the leaving doc's fate before any await mutates state.
|
||||
const leaving = currentDocRef.current
|
||||
const leavingBlank = isBlankDraft()
|
||||
await saveNow() // flush any pending edits to the doc we're leaving
|
||||
const doc = await api.getDoc(id)
|
||||
setCurrentDoc(doc)
|
||||
@@ -56,8 +134,14 @@ export default function App() {
|
||||
setWordCount(doc.word_count)
|
||||
setTone(doc.tone || 'general')
|
||||
setDocText(doc.content_text)
|
||||
// Leaving an untouched blank draft for a different doc? Drop it silently
|
||||
// (best-effort cleanup — a 404 just means it's already gone).
|
||||
if (leaving && leaving.id !== id && leavingBlank) {
|
||||
api.deleteDoc(leaving.id).catch(() => {})
|
||||
setDocs((prev) => prev.filter((d) => d.id !== leaving.id))
|
||||
}
|
||||
},
|
||||
[saveNow],
|
||||
[saveNow, isBlankDraft],
|
||||
)
|
||||
|
||||
// Initial load: fetch the list, opening the first doc (or creating one).
|
||||
@@ -70,7 +154,7 @@ export default function App() {
|
||||
let list = await api.listDocs()
|
||||
if (list.length === 0) {
|
||||
const fresh = await api.createDoc()
|
||||
list = [{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at }]
|
||||
list = [{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at, tags: [] }]
|
||||
setDocs(list)
|
||||
setCurrentDoc(fresh)
|
||||
setTitle(fresh.title)
|
||||
@@ -90,10 +174,13 @@ export default function App() {
|
||||
}, [openDoc])
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
// Already sitting on a fresh blank draft? Reuse it instead of stacking
|
||||
// another empty "Untitled" on top.
|
||||
if (currentDocRef.current && isBlankDraft()) return
|
||||
await saveNow()
|
||||
const fresh = await api.createDoc()
|
||||
setDocs((prev) => [
|
||||
{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at },
|
||||
{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at, tags: [] },
|
||||
...prev,
|
||||
])
|
||||
setCurrentDoc(fresh)
|
||||
@@ -101,7 +188,7 @@ export default function App() {
|
||||
setWordCount(0)
|
||||
setTone(fresh.tone || 'general')
|
||||
setDocText('')
|
||||
}, [saveNow])
|
||||
}, [saveNow, isBlankDraft])
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (id: string) => {
|
||||
@@ -179,6 +266,21 @@ export default function App() {
|
||||
[removeSuggestion],
|
||||
)
|
||||
|
||||
// After restoring a version, swap the restored doc into the editor. Bumping
|
||||
// editorEpoch remounts EditorCore so it picks up the restored content.
|
||||
const handleRestored = useCallback(
|
||||
(doc: Document) => {
|
||||
setCurrentDoc(doc)
|
||||
setTitle(doc.title)
|
||||
setWordCount(doc.word_count)
|
||||
setTone(doc.tone || 'general')
|
||||
setDocText(doc.content_text)
|
||||
patchSummary(doc.id, { title: doc.title, word_count: doc.word_count })
|
||||
setEditorEpoch((n) => n + 1)
|
||||
},
|
||||
[patchSummary],
|
||||
)
|
||||
|
||||
// Escape always restores the sidebar while in distraction-free mode.
|
||||
useEffect(() => {
|
||||
if (!focusMode) return
|
||||
@@ -211,24 +313,47 @@ export default function App() {
|
||||
<div className="flex h-full flex-col">
|
||||
<header
|
||||
onMouseDown={handleChromeDown}
|
||||
className="flex h-12 shrink-0 items-center gap-2 px-5"
|
||||
className="petal-no-print flex h-12 shrink-0 items-center gap-2 px-5"
|
||||
style={{ borderBottom: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Toggle document list"
|
||||
onClick={() => {
|
||||
setFocusMode(false)
|
||||
setDrawerOpen((v) => !v)
|
||||
}}
|
||||
className="petal-sidebar-toggle petal-tap-sm -ml-2 mr-1 items-center justify-center text-xl"
|
||||
style={{ borderRadius: 'var(--radius-pill)', width: 36, height: 36, color: 'var(--color-plum)' }}
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<span className="text-xl">🌸</span>
|
||||
<span className="text-lg font-extrabold text-plum">Petal</span>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<div className={`petal-sidebar shrink-0${focusMode ? ' petal-sidebar-hidden' : ''}`}>
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
<div
|
||||
className={`petal-no-print petal-sidebar shrink-0${focusMode ? ' petal-sidebar-hidden' : ''}${drawerOpen ? ' petal-drawer-open' : ''}`}
|
||||
>
|
||||
<DocList
|
||||
docs={docs}
|
||||
roster={tagRoster}
|
||||
selectedId={currentDoc?.id ?? null}
|
||||
onSelect={openDoc}
|
||||
onCreate={handleCreate}
|
||||
onDelete={handleDelete}
|
||||
onToggleTag={handleToggleTag}
|
||||
onCreateTag={handleCreateTag}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`petal-no-print petal-scrim${drawerOpen ? ' petal-scrim-show' : ''}`}
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
{currentDoc ? (
|
||||
<>
|
||||
@@ -246,10 +371,32 @@ export default function App() {
|
||||
className="min-w-0 flex-1 bg-transparent text-3xl font-extrabold text-plum focus:outline-none"
|
||||
style={{ fontFamily: 'var(--font-ui)' }}
|
||||
/>
|
||||
<ToneSelect value={tone} onChange={handleToneChange} />
|
||||
<div className="petal-no-print shrink-0">
|
||||
<ToneSelect value={tone} onChange={handleToneChange} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHistoryOpen(true)}
|
||||
aria-label="Version history"
|
||||
title="Browse and restore earlier versions"
|
||||
className="petal-no-print inline-flex h-9 shrink-0 items-center gap-1.5 whitespace-nowrap px-3 text-sm font-bold"
|
||||
style={{
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
background: 'var(--color-surface)',
|
||||
color: 'var(--color-plum)',
|
||||
boxShadow: 'var(--shadow-soft)',
|
||||
}}
|
||||
>
|
||||
<span aria-hidden>🕘</span>
|
||||
<span>历史</span>
|
||||
<span style={{ color: 'var(--color-muted)' }}>· History</span>
|
||||
</button>
|
||||
<div className="petal-no-print">
|
||||
<ExportMenu docId={currentDoc.id} />
|
||||
</div>
|
||||
</div>
|
||||
<EditorCore
|
||||
key={currentDoc.id}
|
||||
key={`${currentDoc.id}:${editorEpoch}`}
|
||||
docId={currentDoc.id}
|
||||
initialContent={currentDoc.content}
|
||||
onChange={handleEditorChange}
|
||||
@@ -264,13 +411,14 @@ export default function App() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div onMouseDown={handleChromeDown}>
|
||||
<div className="petal-no-print" onMouseDown={handleChromeDown}>
|
||||
<StatusBar
|
||||
wordCount={wordCount}
|
||||
text={docText}
|
||||
saveStatus={status}
|
||||
checking={checking}
|
||||
voicing={voicing}
|
||||
llmDown={llmDown}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -285,14 +433,24 @@ export default function App() {
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{historyOpen && currentDoc && (
|
||||
<HistoryPanel
|
||||
docId={currentDoc.id}
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
onRestored={handleRestored}
|
||||
/>
|
||||
)}
|
||||
|
||||
{updateAvailable && <UpdateBanner />}
|
||||
|
||||
<PetalCompanion
|
||||
wordCount={wordCount}
|
||||
saveStatus={status}
|
||||
editTick={editTick}
|
||||
acceptTick={acceptTick}
|
||||
/>
|
||||
<div className="petal-no-print">
|
||||
<PetalCompanion
|
||||
wordCount={wordCount}
|
||||
saveStatus={status}
|
||||
editTick={editTick}
|
||||
acceptTick={acceptTick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,34 @@
|
||||
// Thin fetch wrappers for the Petal backend. Everything lives under /api, which
|
||||
// Vite proxies to the Go server in dev and the binary serves directly in prod.
|
||||
|
||||
// A tag's palette key, mapped to a CSS color on the frontend. Mirrors the
|
||||
// backend TagColor* constants; unknown values render as rose.
|
||||
export type TagColor = 'rose' | 'mint' | 'peach' | 'lavender' | 'sky' | 'honey'
|
||||
|
||||
export interface Tag {
|
||||
id: string
|
||||
name: string
|
||||
color: TagColor
|
||||
doc_count?: number // present only in the tag-roster listing
|
||||
}
|
||||
|
||||
export interface DocSummary {
|
||||
id: string
|
||||
title: string
|
||||
word_count: number
|
||||
updated_at: string
|
||||
tags: Tag[]
|
||||
}
|
||||
|
||||
// One cross-document search hit. `snippet` is plain text with the matched span
|
||||
// wrapped in the … sentinels (see splitSnippet) for highlighting.
|
||||
export interface SearchResult {
|
||||
id: string
|
||||
title: string
|
||||
word_count: number
|
||||
updated_at: string
|
||||
snippet: string
|
||||
tags: Tag[]
|
||||
}
|
||||
|
||||
export interface Document {
|
||||
@@ -37,16 +60,40 @@ export interface WordMeaning {
|
||||
example?: string
|
||||
}
|
||||
|
||||
// The offline lookup for one word: a few definition senses and a list of
|
||||
// synonyms. Either list may be empty when the word isn't a headword.
|
||||
// The offline lookup for one word: a Chinese gloss, a few definition senses, and
|
||||
// a list of synonyms. Any of these may be empty when the word isn't a headword.
|
||||
export interface WordInfo {
|
||||
word: string
|
||||
gloss: string // Chinese translation; '' when the word isn't in the gloss set
|
||||
definitions: WordMeaning[]
|
||||
synonyms: string[]
|
||||
}
|
||||
|
||||
// The lightweight Chinese-only gloss behind the inline hover/select tooltip.
|
||||
export interface Gloss {
|
||||
word: string
|
||||
gloss: string
|
||||
}
|
||||
|
||||
export type SuggestionType = 'grammar' | 'phrasing' | 'idiom' | 'clarity' | 'voice'
|
||||
|
||||
// A point-in-time snapshot of a document. List responses omit the heavy
|
||||
// content/content_text fields; they arrive only on getVersion (preview/restore).
|
||||
export interface DocumentVersion {
|
||||
id: string
|
||||
doc_id: string
|
||||
title: string
|
||||
content?: string
|
||||
content_text?: string
|
||||
word_count: number
|
||||
kind: 'auto' | 'manual' | 'pre_restore'
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// Downloadable export formats. PDF is handled client-side via the browser's
|
||||
// print dialog (CJK-safe, no server-side font embedding).
|
||||
export type ExportFormat = 'md' | 'html' | 'txt' | 'docx'
|
||||
|
||||
// A single LLM-proposed edit. `original` is the source of truth for placement —
|
||||
// the editor re-anchors by matching this string in the live document (spec
|
||||
// Note #6); from_pos/to_pos are server-side advisory only. `replacement` is
|
||||
@@ -100,14 +147,109 @@ export const api = {
|
||||
dismissSuggestion: (id: string) =>
|
||||
req<void>(`/suggestions/${id}/dismiss`, { method: 'POST' }),
|
||||
|
||||
// Offline word lookup (definition + synonyms) for the right-click popover.
|
||||
// Version history. listVersions returns metadata only (no bodies); getVersion
|
||||
// loads one full snapshot for preview; snapshotDoc takes an explicit restore
|
||||
// point; restoreVersion copies a snapshot back onto the live doc (capturing a
|
||||
// pre_restore safety copy server-side first) and returns the restored doc.
|
||||
listVersions: (id: string) => req<DocumentVersion[]>(`/docs/${id}/versions`),
|
||||
getVersion: (id: string, vid: string) =>
|
||||
req<DocumentVersion>(`/docs/${id}/versions/${vid}`),
|
||||
snapshotDoc: (id: string) =>
|
||||
req<DocumentVersion>(`/docs/${id}/versions`, { method: 'POST' }),
|
||||
restoreVersion: (id: string, vid: string) =>
|
||||
req<Document>(`/docs/${id}/versions/${vid}/restore`, { method: 'POST' }),
|
||||
|
||||
// Download URL for an exported document (md/html/txt/docx). Used as an <a
|
||||
// href download> target so the browser handles the file save.
|
||||
exportUrl: (id: string, format: ExportFormat) =>
|
||||
`/api/docs/${id}/export?format=${format}`,
|
||||
|
||||
// Offline word lookup (gloss + definition + synonyms) for the right-click popover.
|
||||
lookupWord: (word: string) => req<WordInfo>(`/word/${encodeURIComponent(word)}`),
|
||||
// Lightweight Chinese-only gloss for the inline hover/select tooltip — instant
|
||||
// and offline, so it fires on hover without spinning up the heavier lookup.
|
||||
glossWord: (word: string) => req<Gloss>(`/gloss/${encodeURIComponent(word)}`),
|
||||
// Tone-rewrite: rewrites a selected passage in the given style ('natural',
|
||||
// 'academic', …) and returns the rewritten text for an in-editor preview. Not
|
||||
// persisted — the editor applies it directly on accept.
|
||||
rewriteSelection: (docId: string, text: string, style: string) =>
|
||||
req<{ rewrite: string }>(`/docs/${docId}/rewrite`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ text, style }),
|
||||
}),
|
||||
|
||||
// Cross-document full-text search (title + body). Returns hits with a
|
||||
// highlighted snippet. Empty query returns []. Encodes the term for the URL.
|
||||
search: (q: string) => req<SearchResult[]>(`/search?q=${encodeURIComponent(q)}`),
|
||||
|
||||
// Tags. listTags returns the roster with per-tag document counts; createTag is
|
||||
// idempotent on name (returns the existing tag if it already exists);
|
||||
// updateTag recolors/renames; deleteTag removes it (assignments cascade).
|
||||
// assignTag/unassignTag link a tag to one document.
|
||||
listTags: () => req<Tag[]>('/tags'),
|
||||
createTag: (name: string, color: TagColor) =>
|
||||
req<Tag>('/tags', { method: 'POST', body: JSON.stringify({ name, color }) }),
|
||||
updateTag: (id: string, patch: { name?: string; color?: TagColor }) =>
|
||||
req<Tag>(`/tags/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }),
|
||||
deleteTag: (id: string) => req<void>(`/tags/${id}`, { method: 'DELETE' }),
|
||||
assignTag: (docId: string, tagId: string) =>
|
||||
req<void>(`/docs/${docId}/tags`, { method: 'POST', body: JSON.stringify({ tag_id: tagId }) }),
|
||||
unassignTag: (docId: string, tagId: string) =>
|
||||
req<void>(`/docs/${docId}/tags/${tagId}`, { method: 'DELETE' }),
|
||||
|
||||
// Current deployed build id — changes whenever a new frontend ships. The
|
||||
// app polls this to offer a refresh. Bypasses any cache so the answer is live.
|
||||
version: () => req<{ version: string }>('/version', { cache: 'no-store' }),
|
||||
}
|
||||
|
||||
// The sentinel characters the server wraps a search match in (\x01 … \x02). Used
|
||||
// by splitSnippet to render the matched span highlighted.
|
||||
export const SNIPPET_HL_START = String.fromCharCode(1)
|
||||
export const SNIPPET_HL_END = String.fromCharCode(2)
|
||||
|
||||
// splitSnippet breaks a server snippet into ordered { text, hit } segments so the
|
||||
// UI can bold the matched span(s) without dangerously setting innerHTML.
|
||||
export function splitSnippet(snippet: string): { text: string; hit: boolean }[] {
|
||||
const out: { text: string; hit: boolean }[] = []
|
||||
let rest = snippet
|
||||
for (;;) {
|
||||
const start = rest.indexOf(SNIPPET_HL_START)
|
||||
if (start < 0) {
|
||||
if (rest) out.push({ text: rest, hit: false })
|
||||
break
|
||||
}
|
||||
if (start > 0) out.push({ text: rest.slice(0, start), hit: false })
|
||||
const end = rest.indexOf(SNIPPET_HL_END, start + 1)
|
||||
if (end < 0) {
|
||||
// Unterminated (shouldn't happen) — emit the remainder plainly.
|
||||
out.push({ text: rest.slice(start + 1), hit: false })
|
||||
break
|
||||
}
|
||||
out.push({ text: rest.slice(start + 1, end), hit: true })
|
||||
rest = rest.slice(end + 1)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tagColorVar maps a tag color key to its CSS custom property. Unknown keys fall
|
||||
// back to the rose accent, matching the backend's coercion.
|
||||
export function tagColorVar(color: TagColor | string): string {
|
||||
switch (color) {
|
||||
case 'mint':
|
||||
return 'var(--color-mint)'
|
||||
case 'peach':
|
||||
return 'var(--color-peach)'
|
||||
case 'lavender':
|
||||
return 'var(--color-lavender)'
|
||||
case 'sky':
|
||||
return 'var(--color-sky)'
|
||||
case 'honey':
|
||||
return 'var(--color-honey)'
|
||||
default:
|
||||
return 'var(--color-accent)'
|
||||
}
|
||||
}
|
||||
|
||||
// One turn in an Ask Petal conversation. History lives only in the component —
|
||||
// the server is stateless and re-injects the suggestion context every request.
|
||||
export interface ChatMessage {
|
||||
|
||||
@@ -1,26 +1,71 @@
|
||||
import type { DocSummary } from '../../api/client'
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { DocSummary, Tag, TagColor } from '../../api/client'
|
||||
import { DocListItem } from './DocListItem'
|
||||
import { SearchBox } from './SearchBox'
|
||||
import { TagChip } from './TagChip'
|
||||
|
||||
interface Props {
|
||||
docs: DocSummary[]
|
||||
roster: Tag[]
|
||||
selectedId: string | null
|
||||
onSelect: (id: string) => void
|
||||
onCreate: () => void
|
||||
onDelete: (id: string) => void
|
||||
onToggleTag: (docId: string, tag: Tag) => void
|
||||
onCreateTag: (docId: string, name: string, color: TagColor) => void
|
||||
}
|
||||
|
||||
// DocList is the 260px sidebar: the document browser plus a New button.
|
||||
export function DocList({ docs, selectedId, onSelect, onCreate, onDelete }: Props) {
|
||||
// DocList is the sidebar: a cross-document search box, a tag filter bar, the New
|
||||
// button, and the document browser (each row showing its tag chips).
|
||||
export function DocList({
|
||||
docs,
|
||||
roster,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onDelete,
|
||||
onToggleTag,
|
||||
onCreateTag,
|
||||
}: Props) {
|
||||
// Active tag filter (null = show all). Cleared automatically if the tag
|
||||
// disappears from the roster.
|
||||
const [filterId, setFilterId] = useState<string | null>(null)
|
||||
const activeFilter = filterId && roster.some((t) => t.id === filterId) ? filterId : null
|
||||
|
||||
const filtered = useMemo(
|
||||
() => (activeFilter ? docs.filter((d) => d.tags.some((t) => t.id === activeFilter)) : docs),
|
||||
[docs, activeFilter],
|
||||
)
|
||||
|
||||
// Only surface tags that are actually in use, so the filter bar stays tidy.
|
||||
const usedTags = useMemo(() => roster.filter((t) => (t.doc_count ?? 0) > 0), [roster])
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="flex h-full w-[260px] flex-col gap-2 p-3"
|
||||
className="flex h-full w-[280px] flex-col gap-2 p-3"
|
||||
style={{ borderRight: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<SearchBox onSelect={onSelect} />
|
||||
|
||||
{usedTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 pb-0.5">
|
||||
{usedTags.map((t) => (
|
||||
<TagChip
|
||||
key={t.id}
|
||||
tag={t}
|
||||
count={t.doc_count}
|
||||
active={activeFilter === t.id}
|
||||
onClick={() => setFilterId((cur) => (cur === t.id ? null : t.id))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreate}
|
||||
className="flex items-center justify-center gap-2 py-2.5 text-sm font-bold text-white"
|
||||
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)' }}
|
||||
className="petal-tap flex items-center justify-center gap-2 text-sm font-bold text-white"
|
||||
style={{ minHeight: 44, borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||
>
|
||||
@@ -28,18 +73,21 @@ export function DocList({ docs, selectedId, onSelect, onCreate, onDelete }: Prop
|
||||
</button>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-0.5 overflow-y-auto">
|
||||
{docs.length === 0 ? (
|
||||
{filtered.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||
No documents yet.
|
||||
{activeFilter ? 'No documents with this tag.' : 'No documents yet.'}
|
||||
</p>
|
||||
) : (
|
||||
docs.map((doc) => (
|
||||
filtered.map((doc) => (
|
||||
<DocListItem
|
||||
key={doc.id}
|
||||
doc={doc}
|
||||
active={doc.id === selectedId}
|
||||
roster={roster}
|
||||
onSelect={() => onSelect(doc.id)}
|
||||
onDelete={() => onDelete(doc.id)}
|
||||
onToggleTag={onToggleTag}
|
||||
onCreateTag={onCreateTag}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -1,48 +1,101 @@
|
||||
import type { DocSummary } from '../../api/client'
|
||||
import { useState } from 'react'
|
||||
import type { DocSummary, Tag, TagColor } from '../../api/client'
|
||||
import { TagChip } from './TagChip'
|
||||
import { TagPicker } from './TagPicker'
|
||||
|
||||
interface Props {
|
||||
doc: DocSummary
|
||||
active: boolean
|
||||
roster: Tag[]
|
||||
onSelect: () => void
|
||||
onDelete: () => void
|
||||
onToggleTag: (docId: string, tag: Tag) => void
|
||||
onCreateTag: (docId: string, name: string, color: TagColor) => void
|
||||
}
|
||||
|
||||
// One row in the sidebar: title + word count, a delete affordance on hover, and
|
||||
// a rose wash when it's the open document.
|
||||
export function DocListItem({ doc, active, onSelect, onDelete }: Props) {
|
||||
// One row in the sidebar: title + word count, its tag chips, a tag affordance and
|
||||
// a delete affordance on hover, and a rose wash when it's the open document.
|
||||
export function DocListItem({
|
||||
doc,
|
||||
active,
|
||||
roster,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onToggleTag,
|
||||
onCreateTag,
|
||||
}: Props) {
|
||||
const [picking, setPicking] = useState(false)
|
||||
const assignedIds = new Set(doc.tags.map((t) => t.id))
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
className="group flex cursor-pointer items-center gap-2 px-3 py-2.5"
|
||||
className="group relative flex cursor-pointer flex-col gap-1 px-3 py-2.5"
|
||||
style={{
|
||||
borderRadius: 'var(--radius-card)',
|
||||
background: active ? 'var(--color-surface)' : 'transparent',
|
||||
boxShadow: active ? 'var(--shadow-soft)' : 'none',
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className="truncate text-sm font-semibold"
|
||||
style={{ color: active ? 'var(--color-plum)' : 'var(--color-muted)' }}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className="truncate text-sm font-semibold"
|
||||
style={{ color: active ? 'var(--color-plum)' : 'var(--color-muted)' }}
|
||||
>
|
||||
{doc.title || 'Untitled'}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||
{doc.word_count} {doc.word_count === 1 ? 'word' : 'words'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tag + delete affordances: always reachable on touch (no hover), fade in
|
||||
on hover for the pointer experience. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Tag document"
|
||||
title="Tags"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setPicking((v) => !v)
|
||||
}}
|
||||
className="petal-row-action flex h-7 w-7 shrink-0 items-center justify-center text-sm"
|
||||
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
|
||||
>
|
||||
{doc.title || 'Untitled'}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||
{doc.word_count} {doc.word_count === 1 ? 'word' : 'words'}
|
||||
</div>
|
||||
🏷️
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Delete document"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
className="petal-row-action flex h-7 w-7 shrink-0 items-center justify-center text-base"
|
||||
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
{picking && (
|
||||
<TagPicker
|
||||
roster={roster}
|
||||
assignedIds={assignedIds}
|
||||
onToggle={(t) => onToggleTag(doc.id, t)}
|
||||
onCreate={(name, color) => onCreateTag(doc.id, name, color)}
|
||||
onClose={() => setPicking(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Delete document"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center text-base opacity-0 group-hover:opacity-100"
|
||||
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
{doc.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{doc.tags.map((t) => (
|
||||
<TagChip key={t.id} tag={t} onRemove={() => onToggleTag(doc.id, t)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,9 @@ import { SuggestionHighlight, setSuggestions, findRange } from './SuggestionHigh
|
||||
import { SpellCheck, setSpellChecker, wordAt } from './SpellCheck'
|
||||
import { MisspellCard } from './MisspellCard'
|
||||
import { WordCard } from './WordCard'
|
||||
import { GlossTip } from './GlossTip'
|
||||
import { SelectionBubble } from './SelectionBubble'
|
||||
import { RewritePreview, type RewriteStatus } from './RewritePreview'
|
||||
import { api, type Suggestion, type WordInfo } from '../../api/client'
|
||||
import type { SpellChecker } from '../../hooks/useSpellChecker'
|
||||
|
||||
@@ -106,6 +109,39 @@ interface HoverState {
|
||||
left: number
|
||||
}
|
||||
|
||||
// The inline hover gloss: the word under the resting pointer, its Chinese
|
||||
// translation, the PM range it covers (to dedupe re-fetches), and where to anchor.
|
||||
interface GlossState {
|
||||
word: string
|
||||
gloss: string
|
||||
from: number
|
||||
to: number
|
||||
top: number
|
||||
left: number
|
||||
}
|
||||
|
||||
// A non-empty text selection that the rewrite bubble hovers over.
|
||||
interface SelectionState {
|
||||
from: number
|
||||
to: number
|
||||
text: string
|
||||
top: number
|
||||
left: number
|
||||
}
|
||||
|
||||
// An in-flight or completed tone-rewrite preview, pinned over the selection it
|
||||
// came from. `from`/`to` are the PM range the accepted rewrite replaces.
|
||||
interface RewriteState {
|
||||
from: number
|
||||
to: number
|
||||
original: string
|
||||
style: string
|
||||
top: number
|
||||
left: number
|
||||
status: RewriteStatus
|
||||
rewrite: string
|
||||
}
|
||||
|
||||
// EditorCore is the Tiptap instance: StarterKit formatting plus underline, text
|
||||
// alignment, a placeholder, character counting, and the suggestion-highlight
|
||||
// decoration layer. Hovering a highlight opens its SuggestionCard.
|
||||
@@ -138,6 +174,15 @@ export function EditorCore({
|
||||
// While the Ask Petal panel is expanded the card is pinned: the hover-close
|
||||
// timer is suppressed so chatting doesn't dismiss it. A click outside closes.
|
||||
const [pinned, setPinned] = useState(false)
|
||||
// Inline hover gloss (rest the pointer on a word → its Chinese meaning).
|
||||
const [gloss, setGloss] = useState<GlossState | null>(null)
|
||||
const glossTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const glossReqRef = useRef(0)
|
||||
// The rewrite affordances: a bubble over the current selection, and the
|
||||
// preview that replaces it once a style is chosen.
|
||||
const [selection, setSelection] = useState<SelectionState | null>(null)
|
||||
const [rewrite, setRewrite] = useState<RewriteState | null>(null)
|
||||
const rewriteReqRef = useRef(0)
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
@@ -158,12 +203,37 @@ export function EditorCore({
|
||||
// Any edit shifts positions, stranding the popover anchors.
|
||||
setMisspell(null)
|
||||
setWordInfo(null)
|
||||
setGloss(null)
|
||||
setSelection(null)
|
||||
// A stale rewrite preview points at a range that just moved — drop it.
|
||||
rewriteReqRef.current++
|
||||
setRewrite(null)
|
||||
onChange({
|
||||
content: JSON.stringify(editor.getJSON()),
|
||||
content_text: editor.getText(),
|
||||
word_count: editor.storage.characterCount.words(),
|
||||
})
|
||||
},
|
||||
onSelectionUpdate: ({ editor }) => {
|
||||
// A selection supersedes the hover gloss; an empty one clears the bubble.
|
||||
setGloss(null)
|
||||
const { from, to, empty } = editor.state.selection
|
||||
const wrapper = wrapperRef.current
|
||||
if (empty || !wrapper) {
|
||||
setSelection(null)
|
||||
return
|
||||
}
|
||||
const text = editor.state.doc.textBetween(from, to, ' ').trim()
|
||||
if (!text) {
|
||||
setSelection(null)
|
||||
return
|
||||
}
|
||||
const start = editor.view.coordsAtPos(from)
|
||||
const wrapRect = wrapper.getBoundingClientRect()
|
||||
const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - 360))
|
||||
const top = start.top - wrapRect.top
|
||||
setSelection({ from, to, text, top, left })
|
||||
},
|
||||
})
|
||||
|
||||
// When the selected document changes, swap in its content without emitting an
|
||||
@@ -174,6 +244,10 @@ export function EditorCore({
|
||||
setHover(null)
|
||||
setMisspell(null)
|
||||
setWordInfo(null)
|
||||
setGloss(null)
|
||||
setSelection(null)
|
||||
rewriteReqRef.current++
|
||||
setRewrite(null)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [docId, editor])
|
||||
|
||||
@@ -288,8 +362,17 @@ export function EditorCore({
|
||||
// Click a red-underlined word to open its spelling popover, anchored under the
|
||||
// word. posAtCoords→wordAt resolves the exact PM span (robust to the same
|
||||
// misspelling appearing elsewhere); nspell supplies the corrections.
|
||||
//
|
||||
// Tapping an AI-suggestion highlight also opens its card here — on touch there's
|
||||
// no hover, so the tap is the only way in (mouse users still get hover).
|
||||
const handleSpellClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const suggestionEl = (e.target as HTMLElement).closest('.petal-suggestion') as HTMLElement | null
|
||||
if (suggestionEl) {
|
||||
const id = suggestionEl.getAttribute('data-suggestion-id')
|
||||
if (id) openCardFor(id, suggestionEl)
|
||||
return
|
||||
}
|
||||
if (!editor || !spellChecker) return
|
||||
const target = (e.target as HTMLElement).closest('.petal-misspelling') as HTMLElement | null
|
||||
if (!target) return
|
||||
@@ -309,7 +392,7 @@ export function EditorCore({
|
||||
setWordInfo(null)
|
||||
setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
|
||||
},
|
||||
[editor, spellChecker, closeCard],
|
||||
[editor, spellChecker, closeCard, openCardFor],
|
||||
)
|
||||
|
||||
const replaceMisspelling = useCallback(
|
||||
@@ -381,6 +464,143 @@ export function EditorCore({
|
||||
[editor, wordInfo],
|
||||
)
|
||||
|
||||
// Hover gloss: rest the pointer on an English word and, after a short delay,
|
||||
// show its Chinese meaning from the offline gloss dataset. Suppressed while a
|
||||
// selection, preview, or another popover is active, or over a word that has its
|
||||
// own affordance (a suggestion highlight / a misspelling).
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!editor) return
|
||||
if (selection || rewrite || misspell || wordInfo || pinned) return
|
||||
if (!editor.state.selection.empty) return
|
||||
const t = e.target as HTMLElement
|
||||
const clear = () => {
|
||||
clearTimeout(glossTimer.current)
|
||||
glossReqRef.current++
|
||||
setGloss(null)
|
||||
}
|
||||
if (t.closest('.petal-suggestion') || t.closest('.petal-misspelling')) {
|
||||
clear()
|
||||
return
|
||||
}
|
||||
const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY })
|
||||
if (!coords) {
|
||||
clear()
|
||||
return
|
||||
}
|
||||
const range = wordAt(editor.state.doc, coords.pos)
|
||||
if (!range) {
|
||||
clear()
|
||||
return
|
||||
}
|
||||
// Already showing this exact word — leave it be (no flicker on micro-moves).
|
||||
if (gloss && gloss.from === range.from && gloss.to === range.to) return
|
||||
clearTimeout(glossTimer.current)
|
||||
const token = ++glossReqRef.current
|
||||
glossTimer.current = setTimeout(() => {
|
||||
api
|
||||
.glossWord(range.word)
|
||||
.then((g) => {
|
||||
if (token !== glossReqRef.current) return
|
||||
const wrapper = wrapperRef.current
|
||||
if (!g.gloss || !wrapper) {
|
||||
setGloss(null)
|
||||
return
|
||||
}
|
||||
const start = editor.view.coordsAtPos(range.from)
|
||||
const end = editor.view.coordsAtPos(range.to)
|
||||
const wrapRect = wrapper.getBoundingClientRect()
|
||||
const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - 280))
|
||||
const top = end.bottom - wrapRect.top + 6
|
||||
setGloss({ word: range.word, gloss: g.gloss, from: range.from, to: range.to, top, left })
|
||||
})
|
||||
.catch(() => {
|
||||
if (token === glossReqRef.current) setGloss(null)
|
||||
})
|
||||
}, 350)
|
||||
},
|
||||
[editor, selection, rewrite, misspell, wordInfo, pinned, gloss],
|
||||
)
|
||||
|
||||
// Leaving the editor surface drops any pending/shown gloss.
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
clearTimeout(glossTimer.current)
|
||||
glossReqRef.current++
|
||||
setGloss(null)
|
||||
}, [])
|
||||
|
||||
// runRewrite fires the LLM rewrite for a captured range + style and tracks it
|
||||
// through loading → ready/error. A request token discards a response whose
|
||||
// preview has since been cancelled or superseded.
|
||||
const runRewrite = useCallback(
|
||||
(from: number, to: number, original: string, style: string, top: number, left: number) => {
|
||||
const token = ++rewriteReqRef.current
|
||||
setRewrite({ from, to, original, style, top, left, status: 'loading', rewrite: '' })
|
||||
api
|
||||
.rewriteSelection(docId, original, style)
|
||||
.then((res) => {
|
||||
if (token === rewriteReqRef.current) {
|
||||
setRewrite((r) => (r ? { ...r, status: 'ready', rewrite: res.rewrite } : null))
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('rewrite failed', err)
|
||||
if (token === rewriteReqRef.current) {
|
||||
setRewrite((r) => (r ? { ...r, status: 'error' } : null))
|
||||
}
|
||||
})
|
||||
},
|
||||
[docId],
|
||||
)
|
||||
|
||||
// Picking a style in the selection bubble: capture the selection's range +
|
||||
// text, anchor a preview below it, and kick off the rewrite.
|
||||
const handleRewrite = useCallback(
|
||||
(style: string) => {
|
||||
if (!editor || !selection) return
|
||||
const wrapper = wrapperRef.current
|
||||
if (!wrapper) return
|
||||
const start = editor.view.coordsAtPos(selection.from)
|
||||
const end = editor.view.coordsAtPos(selection.to)
|
||||
const wrapRect = wrapper.getBoundingClientRect()
|
||||
const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - 340))
|
||||
const top = end.bottom - wrapRect.top + 6
|
||||
setSelection(null)
|
||||
setGloss(null)
|
||||
runRewrite(selection.from, selection.to, selection.text, style, top, left)
|
||||
},
|
||||
[editor, selection, runRewrite],
|
||||
)
|
||||
|
||||
const acceptRewrite = useCallback(() => {
|
||||
if (editor && rewrite && rewrite.rewrite) {
|
||||
editor.chain().focus().insertContentAt({ from: rewrite.from, to: rewrite.to }, rewrite.rewrite).run()
|
||||
}
|
||||
rewriteReqRef.current++
|
||||
setRewrite(null)
|
||||
}, [editor, rewrite])
|
||||
|
||||
const cancelRewrite = useCallback(() => {
|
||||
rewriteReqRef.current++
|
||||
setRewrite(null)
|
||||
}, [])
|
||||
|
||||
const retryRewrite = useCallback(() => {
|
||||
if (rewrite) runRewrite(rewrite.from, rewrite.to, rewrite.original, rewrite.style, rewrite.top, rewrite.left)
|
||||
}, [rewrite, runRewrite])
|
||||
|
||||
// A pointer-down outside the rewrite preview dismisses it.
|
||||
useEffect(() => {
|
||||
if (!rewrite) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('.petal-rewrite-card')) return
|
||||
rewriteReqRef.current++
|
||||
setRewrite(null)
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
return () => document.removeEventListener('mousedown', onDown)
|
||||
}, [rewrite])
|
||||
|
||||
// A pointer-down outside the word popover (and not on another word, which would
|
||||
// reopen it via the context menu) closes it.
|
||||
useEffect(() => {
|
||||
@@ -409,6 +629,7 @@ export function EditorCore({
|
||||
useEffect(() => () => {
|
||||
clearTimeout(closeTimer.current)
|
||||
clearTimeout(confettiTimer.current)
|
||||
clearTimeout(glossTimer.current)
|
||||
}, [])
|
||||
|
||||
// While pinned (Ask Petal open), a pointer-down outside the card closes it —
|
||||
@@ -422,6 +643,20 @@ export function EditorCore({
|
||||
return () => document.removeEventListener('mousedown', onDown)
|
||||
}, [pinned, closeCard])
|
||||
|
||||
// Touch has no hover, so the card is opened by a tap and can't close itself on
|
||||
// mouse-leave. Whenever a card is open, a pointer-down outside both the card and
|
||||
// any highlight dismisses it. Excluding `.petal-suggestion` lets a tap on
|
||||
// another highlight reopen for that one (via the click handler) without flicker.
|
||||
useEffect(() => {
|
||||
if (!hover) return
|
||||
const onDown = (e: PointerEvent) => {
|
||||
const t = e.target as HTMLElement
|
||||
if (!t.closest('.petal-suggestion-card') && !t.closest('.petal-suggestion')) closeCard()
|
||||
}
|
||||
document.addEventListener('pointerdown', onDown)
|
||||
return () => document.removeEventListener('pointerdown', onDown)
|
||||
}, [hover, closeCard])
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<Toolbar editor={editor} onVoiceCheck={onVoiceCheck} voicing={voicing} />
|
||||
@@ -430,11 +665,32 @@ export function EditorCore({
|
||||
className="relative flex-1"
|
||||
onMouseOver={handleMouseOver}
|
||||
onMouseOut={handleMouseOut}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={handleSpellClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<EditorContent editor={editor} className="h-full" />
|
||||
{confetti && <Confetti top={confetti.top} left={confetti.left} />}
|
||||
{gloss && <GlossTip gloss={gloss.gloss} style={{ top: gloss.top, left: gloss.left }} />}
|
||||
{selection && !rewrite && (
|
||||
<SelectionBubble
|
||||
style={{ top: selection.top, left: selection.left, transform: 'translateY(calc(-100% - 8px))' }}
|
||||
onRewrite={handleRewrite}
|
||||
/>
|
||||
)}
|
||||
{rewrite && (
|
||||
<RewritePreview
|
||||
style={rewrite.style}
|
||||
status={rewrite.status}
|
||||
original={rewrite.original}
|
||||
rewrite={rewrite.rewrite}
|
||||
cardStyle={{ top: rewrite.top, left: rewrite.left }}
|
||||
onAccept={acceptRewrite}
|
||||
onCancel={cancelRewrite}
|
||||
onRetry={retryRewrite}
|
||||
/>
|
||||
)}
|
||||
{wordInfo && (
|
||||
<WordCard
|
||||
word={wordInfo.word}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -17,7 +17,8 @@ interface Props {
|
||||
export function WordCard({ word, info, loading, style, onReplace }: Props) {
|
||||
const definitions = info?.definitions ?? []
|
||||
const synonyms = info?.synonyms ?? []
|
||||
const empty = !loading && definitions.length === 0 && synonyms.length === 0
|
||||
const gloss = info?.gloss ?? ''
|
||||
const empty = !loading && !gloss && definitions.length === 0 && synonyms.length === 0
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -47,6 +48,19 @@ export function WordCard({ word, info, loading, style, onReplace }: Props) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Chinese gloss first — it's what the Mandarin-speaking writer reaches for. */}
|
||||
{gloss && (
|
||||
<p
|
||||
className="mt-2.5 leading-snug"
|
||||
style={{
|
||||
color: 'var(--color-plum)',
|
||||
fontFamily: "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif",
|
||||
}}
|
||||
>
|
||||
{gloss}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="mt-3 inline-flex items-center gap-1.5" style={{ color: 'var(--color-muted)' }}>
|
||||
<span
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,9 @@ interface Props {
|
||||
checking: boolean
|
||||
// True while a whole-document voice pass runs — shows a breathing honey dot.
|
||||
voicing: boolean
|
||||
// True when Petal can't reach its LLM helper — shows a gentle, reassuring note
|
||||
// (the writing still saves locally, so this is awareness, not an error).
|
||||
llmDown: boolean
|
||||
}
|
||||
|
||||
const SAVE_LABEL: Record<SaveStatus, string> = {
|
||||
@@ -24,7 +27,7 @@ const SAVE_LABEL: Record<SaveStatus, string> = {
|
||||
// StatusBar is the slim footer: word count on the left, save state and the
|
||||
// grammar-checkpoint indicator on the right. The checkpoint dot is a soft rose
|
||||
// circle that breathes while a check is in flight (spec → Signature animations).
|
||||
export function StatusBar({ wordCount, text, saveStatus, checking, voicing }: Props) {
|
||||
export function StatusBar({ wordCount, text, saveStatus, checking, voicing, llmDown }: Props) {
|
||||
const label = SAVE_LABEL[saveStatus]
|
||||
// The expanded stats panel toggles open when the word count is clicked.
|
||||
const [statsOpen, setStatsOpen] = useState(false)
|
||||
@@ -86,6 +89,20 @@ export function StatusBar({ wordCount, text, saveStatus, checking, voicing }: Pr
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{llmDown && !checking && !voicing && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
title="Petal can't reach its writing helper right now — your text is still saved."
|
||||
style={{ color: 'var(--color-honey)' }}
|
||||
>
|
||||
<span aria-hidden>🌙</span>
|
||||
<span>小助手在休息</span>
|
||||
<span style={{ opacity: 0.75 }}>· Petal's helper is resting · 文字已保存</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{label && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
|
||||
@@ -13,6 +13,11 @@ export function useCheckpoint(docId: string | null) {
|
||||
const [checking, setChecking] = useState(false)
|
||||
// True while a whole-document voice pass is in flight (explicit user action).
|
||||
const [voicing, setVoicing] = useState(false)
|
||||
// True when the last LLM pass couldn't reach the model (server 502 or network
|
||||
// error). Drives a gentle, reassuring "helper is resting" note — the writing
|
||||
// itself still saves fine, so this is awareness, not an error. Cleared on the
|
||||
// next success or doc switch.
|
||||
const [llmDown, setLlmDown] = useState(false)
|
||||
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const docIdRef = useRef(docId)
|
||||
@@ -30,9 +35,11 @@ export function useCheckpoint(docId: string | null) {
|
||||
const fresh = await api.checkDoc(id)
|
||||
if (run === runRef.current && id === docIdRef.current) {
|
||||
setSuggestions(fresh)
|
||||
setLlmDown(false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('checkpoint failed', err)
|
||||
if (run === runRef.current) setLlmDown(true)
|
||||
} finally {
|
||||
if (run === runRef.current) setChecking(false)
|
||||
}
|
||||
@@ -50,9 +57,11 @@ export function useCheckpoint(docId: string | null) {
|
||||
const full = await api.voiceDoc(id)
|
||||
if (run === runRef.current && id === docIdRef.current) {
|
||||
setSuggestions(full)
|
||||
setLlmDown(false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('voice pass failed', err)
|
||||
if (run === runRef.current) setLlmDown(true)
|
||||
} finally {
|
||||
if (run === runRef.current) setVoicing(false)
|
||||
}
|
||||
@@ -72,6 +81,7 @@ export function useCheckpoint(docId: string | null) {
|
||||
setSuggestions([])
|
||||
setChecking(false)
|
||||
setVoicing(false)
|
||||
setLlmDown(false)
|
||||
if (!docId) return
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
@@ -94,5 +104,5 @@ export function useCheckpoint(docId: string | null) {
|
||||
setSuggestions((prev) => prev.filter((s) => s.id !== id))
|
||||
}, [])
|
||||
|
||||
return { suggestions, checking, voicing, schedule, runVoice, removeSuggestion }
|
||||
return { suggestions, checking, voicing, llmDown, schedule, runVoice, removeSuggestion }
|
||||
}
|
||||
|
||||
63
web/src/hooks/useTags.ts
Normal file
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 }
|
||||
}
|
||||
@@ -163,6 +163,18 @@ button, a, input {
|
||||
animation: petal-suggestion-in 200ms ease both;
|
||||
}
|
||||
|
||||
/* --- ESL superpowers (Phase 9) ----------------------------------------------
|
||||
Inline Chinese gloss on hover (a small dark tooltip under the word), and the
|
||||
selection rewrite bubble + its preview card. All share the soft pop-in. The
|
||||
gloss tip fades in a touch faster — it's a fleeting reading aid, not a panel. */
|
||||
.petal-gloss-tip {
|
||||
animation: petal-suggestion-in 140ms ease both;
|
||||
}
|
||||
.petal-selection-bubble,
|
||||
.petal-rewrite-card {
|
||||
animation: petal-suggestion-in 200ms ease both;
|
||||
}
|
||||
|
||||
/* --- Accept confetti --------------------------------------------------------
|
||||
A tiny CSS-only burst played where a suggestion is accepted: four colored
|
||||
dots spray up-and-out, then fade. No JS animation — each dot reads its
|
||||
@@ -269,3 +281,131 @@ button, a, input {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* --- Organization & search (Phase 10) --------------------------------------
|
||||
Tag chips, the search results list, and per-row affordances. The row actions
|
||||
(tag / delete) fade in on hover for pointer users but are always visible on
|
||||
touch (no hover) — see the coarse-pointer block below. */
|
||||
.petal-tag-chip {
|
||||
transition: background 160ms ease, color 160ms ease, transform 160ms ease;
|
||||
}
|
||||
.petal-search-results {
|
||||
animation: petal-suggestion-in 160ms ease both;
|
||||
}
|
||||
.petal-tag-picker {
|
||||
animation: petal-suggestion-in 160ms ease both;
|
||||
}
|
||||
.petal-row-action {
|
||||
opacity: 0;
|
||||
transition: opacity 160ms ease, background 160ms ease;
|
||||
}
|
||||
.group:hover .petal-row-action,
|
||||
.petal-row-action:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
.petal-row-action:hover {
|
||||
background: var(--color-surface-alt);
|
||||
}
|
||||
/* Clamp a search snippet to two lines. */
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* --- Touch & tablet polish --------------------------------------------------
|
||||
On coarse pointers (tablets, phones) there's no hover, so make tap targets
|
||||
comfortable and reveal affordances that would otherwise be hover-only. */
|
||||
@media (pointer: coarse) {
|
||||
.petal-tap {
|
||||
min-height: 44px;
|
||||
}
|
||||
.petal-tap-sm {
|
||||
min-height: 36px;
|
||||
}
|
||||
/* Row actions can't rely on hover — keep them visible and roomy. */
|
||||
.petal-row-action {
|
||||
opacity: 1;
|
||||
height: 36px;
|
||||
width: 36px;
|
||||
}
|
||||
/* Bigger, easier-to-hit suggestion/word/tag pills. */
|
||||
.petal-tag-chip {
|
||||
padding-top: 0.25rem;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Responsive sidebar (narrow screens) ------------------------------------
|
||||
Below the tablet breakpoint the sidebar becomes an overlay drawer toggled by a
|
||||
hamburger in the header, instead of a permanent column. A scrim sits behind it.
|
||||
On wide screens the toggle + scrim are hidden and the sidebar is in-flow. */
|
||||
.petal-sidebar-toggle {
|
||||
display: none;
|
||||
}
|
||||
.petal-scrim {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.petal-sidebar-toggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
.petal-sidebar {
|
||||
position: fixed;
|
||||
top: 48px; /* below the 12-height header */
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
background: var(--color-bg);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
/* On mobile the sidebar is hidden by default; the drawer-open class slides it
|
||||
in. (Distraction-free's petal-sidebar-hidden still wins to keep it closed.) */
|
||||
.petal-sidebar:not(.petal-drawer-open) {
|
||||
width: 0;
|
||||
transform: translateX(-24px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.petal-scrim.petal-scrim-show {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 48px 0 0 0;
|
||||
z-index: 20;
|
||||
background: rgba(61, 46, 57, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
/* Print / Save-as-PDF: strip every bit of app chrome and editing decoration so
|
||||
only the title and the writing itself reach the page. This is Petal's PDF
|
||||
path — it uses the browser's own fonts, so CJK renders correctly with no
|
||||
server-side font embedding. */
|
||||
@media print {
|
||||
.petal-no-print,
|
||||
.petal-sidebar,
|
||||
.petal-suggestion-card,
|
||||
.petal-misspell-card,
|
||||
.petal-gloss-tip,
|
||||
.petal-selection-bubble,
|
||||
.petal-rewrite-card,
|
||||
.petal-confetti,
|
||||
.petal-companion {
|
||||
display: none !important;
|
||||
}
|
||||
html, body, #root {
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
/* Drop the in-editor highlight underlines/tints — they're guidance, not ink. */
|
||||
.petal-suggestion,
|
||||
.petal-misspelling {
|
||||
background: none !important;
|
||||
text-decoration: none !important;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
/* Let the writing use the full page width. */
|
||||
.max-w-\[720px\] { max-width: none !important; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user