Compare commits
5 Commits
feat/tone-
...
phase-10-o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e141e4169 | ||
|
|
60eba25fee | ||
|
|
8e1111d768 | ||
|
|
cf7720ea77 | ||
| 0d59b645bb |
@@ -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"`
|
||||
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)
|
||||
}
|
||||
}
|
||||
778
petal-spec.md
778
petal-spec.md
@@ -1,778 +0,0 @@
|
||||
# Petal — AI Writing Assistant
|
||||
## Project Specification for Claude Code
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Petal is a self-hosted, privacy-first writing editor for an ESL user. It replaces Grammarly with a warm, bubbly web app that combines Tiptap rich text editing, auto-save cloud storage, and periodic AI-powered grammar/ESL suggestions backed by a local vLLM inference endpoint.
|
||||
|
||||
Single-user initially (wife's Authentik account). Deployed to `write.parodia.dev`.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Choice | Why |
|
||||
|---|---|---|
|
||||
| Frontend | React + Vite | Tiptap is React-native; best extension ecosystem |
|
||||
| Editor | Tiptap v2 | ProseMirror with sane API; supports custom marks/decorations |
|
||||
| Styling | Tailwind v4 | Utility-first; pairs well with custom design tokens |
|
||||
| Backend | Go + chi | Consistent with rest of stack (PGX Comics, Veola) |
|
||||
| Database | SQLite (modernc — pure Go, no cgo) | Simple, single-binary friendly |
|
||||
| Spell check | nspell (JS, browser-side) | Zero latency, no backend call, loads hunspell dictionaries |
|
||||
| AI suggestions | vLLM HTTP endpoint | Already running; configurable model + base URL |
|
||||
| Auth | Authentik OIDC | Already running on parodia.dev |
|
||||
| Deployment | Docker, Traefik | Consistent with existing infra |
|
||||
|
||||
---
|
||||
|
||||
## Design System
|
||||
|
||||
**Aesthetic:** soft, warm, bubbly. The UI should feel like a cozy stationery app, not a productivity tool.
|
||||
|
||||
### Color Palette
|
||||
```
|
||||
--color-bg: #FDF6F0 /* warm cream canvas */
|
||||
--color-surface: #FFFFFF /* card/panel surfaces */
|
||||
--color-surface-alt: #FFF0F5 /* rose-tinted alt surface */
|
||||
--color-border: #F0E0EB /* soft pink-grey border */
|
||||
--color-text: #3D2E39 /* warm dark plum, not harsh black */
|
||||
--color-text-muted: #9B8CA3 /* muted lavender-grey */
|
||||
--color-accent: #E8A0BF /* soft rose — primary interactive */
|
||||
--color-accent-hover: #D98AAF /* slightly deeper on hover */
|
||||
--color-mint: #A8D8C8 /* suggestion type: grammar */
|
||||
--color-peach: #F4B8A0 /* suggestion type: phrasing */
|
||||
--color-lavender: #C5B4E8 /* suggestion type: idiom */
|
||||
--color-sky: #A8CCE8 /* suggestion type: clarity */
|
||||
--color-honey: #CE9B4F /* suggestion type: voice — deepened amber for underline contrast on cream */
|
||||
--color-success: #8FCFA8 /* saved, accepted */
|
||||
--color-shadow: rgba(180, 130, 160, 0.12) /* warm-tinted shadow */
|
||||
```
|
||||
|
||||
### Typography
|
||||
- **UI / Headings:** Nunito (Google Fonts) — rounded, bubbly, friendly
|
||||
- **Editor body:** Lora (Google Fonts) — warm serif, reads beautifully at paragraph length
|
||||
- **Monospace / data:** JetBrains Mono (used sparingly for word counts etc.)
|
||||
|
||||
### Shape Language
|
||||
- Border radius: `16px` on cards, `24px` on modals, `999px` on buttons/pills, `12px` on input fields
|
||||
- Shadows: `0 4px 20px var(--color-shadow)` — soft, warm, lifted
|
||||
- Transitions: `200ms ease` across all interactive elements
|
||||
|
||||
### Signature Element
|
||||
Suggestion decorations animate in with a gentle fade + slight upward float (translateY 4px → 0). The acceptance animation plays a tiny confetti burst (CSS only, 3–4 colored dots). The pulsing LLM checkpoint indicator is a soft rose dot that breathes (opacity 0.4 → 1 → 0.4 in 2s).
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
petal/
|
||||
├── cmd/
|
||||
│ └── server/
|
||||
│ └── main.go # Entry point; embeds /web/dist
|
||||
├── internal/
|
||||
│ ├── config/
|
||||
│ │ └── config.go # Env var loading
|
||||
│ ├── db/
|
||||
│ │ ├── db.go # SQLite init + migrations
|
||||
│ │ └── models.go # Document, Suggestion, User types
|
||||
│ ├── auth/
|
||||
│ │ ├── oidc.go # Authentik OIDC flow
|
||||
│ │ └── middleware.go # Session auth middleware
|
||||
│ ├── docs/
|
||||
│ │ └── handlers.go # Document CRUD handlers
|
||||
│ ├── plagiarism/
|
||||
│ │ ├── copyleaks.go # Copyleaks API client
|
||||
│ │ └── voice.go # Local LLM voice consistency check
|
||||
│ └── llm/
|
||||
│ ├── client.go # LLMClient interface + factory function
|
||||
│ ├── vllm.go # vLLM concrete implementation (OpenAI-compat)
|
||||
│ ├── ollama.go # Ollama concrete implementation (native API)
|
||||
│ ├── checkpoint.go # Checkpoint trigger + debounce logic
|
||||
│ └── prompts.go # System prompts
|
||||
├── web/
|
||||
│ ├── src/
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── Editor/
|
||||
│ │ │ │ ├── EditorCore.tsx # Tiptap instance + config
|
||||
│ │ │ │ ├── SuggestionMark.tsx # Custom Tiptap mark extension
|
||||
│ │ │ │ ├── SuggestionCard.tsx # Hover card (suggestion + explanation)
|
||||
│ │ │ │ └── CheckpointIndicator.tsx
|
||||
│ │ │ ├── DocList/
|
||||
│ │ │ │ ├── DocList.tsx # Document browser sidebar
|
||||
│ │ │ │ └── DocListItem.tsx
|
||||
│ │ │ ├── Toolbar/
|
||||
│ │ │ │ └── Toolbar.tsx # Formatting toolbar
|
||||
│ │ │ └── StatusBar/
|
||||
│ │ │ └── StatusBar.tsx # Word count, save status, checkpoint dot
|
||||
│ │ ├── hooks/
|
||||
│ │ │ ├── useAutoSave.ts # Debounced save (1.5s)
|
||||
│ │ │ └── useCheckpoint.ts # LLM checkpoint trigger (4s pause)
|
||||
│ │ ├── api/
|
||||
│ │ │ └── client.ts # Fetch wrappers for all API routes
|
||||
│ │ ├── App.tsx
|
||||
│ │ └── main.tsx
|
||||
│ ├── index.html
|
||||
│ ├── package.json
|
||||
│ ├── vite.config.ts
|
||||
│ └── tailwind.config.ts
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── .env.example
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY, -- Authentik subject claim
|
||||
email TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE documents (
|
||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
title TEXT NOT NULL DEFAULT 'Untitled',
|
||||
content TEXT NOT NULL DEFAULT '{}', -- Tiptap JSON
|
||||
content_text TEXT NOT NULL DEFAULT '', -- Plain text for LLM
|
||||
word_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE suggestions (
|
||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
from_pos INTEGER NOT NULL,
|
||||
to_pos INTEGER NOT NULL,
|
||||
original TEXT NOT NULL,
|
||||
replacement TEXT NOT NULL,
|
||||
explanation TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('grammar','phrasing','idiom','clarity','voice')),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','accepted','rejected')),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE plagiarism_reports (
|
||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
backend TEXT NOT NULL DEFAULT 'copyleaks',
|
||||
similarity_pct REAL, -- overall similarity score 0–100
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK(status IN ('pending','complete','error')),
|
||||
result_json TEXT, -- full Copyleaks response, stored raw
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_plagiarism_doc_id ON plagiarism_reports(doc_id);
|
||||
CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Routes
|
||||
|
||||
All routes under `/api/`. Auth middleware validates session cookie on all except `/api/auth/*`.
|
||||
|
||||
```
|
||||
GET /api/me → current user info
|
||||
POST /api/auth/login → redirect to Authentik
|
||||
GET /api/auth/callback → OIDC callback, set session cookie
|
||||
POST /api/auth/logout → clear session
|
||||
|
||||
GET /api/docs → list user's documents (id, title, word_count, updated_at)
|
||||
POST /api/docs → create new document → returns full document
|
||||
GET /api/docs/:id → get document + pending suggestions
|
||||
PUT /api/docs/:id → update document content (auto-save endpoint)
|
||||
DELETE /api/docs/:id → delete document
|
||||
|
||||
POST /api/docs/:id/check → run grammar checkpoint (returns new suggestions)
|
||||
POST /api/docs/:id/voice → run voice-consistency pass (returns voice suggestions)
|
||||
|
||||
POST /api/docs/:id/plagiarism → trigger Copyleaks check (returns report id)
|
||||
GET /api/docs/:id/plagiarism/latest → get latest report for document
|
||||
PUT /api/suggestions/:id → update suggestion status (accepted/rejected)
|
||||
DELETE /api/docs/:id/suggestions → clear all suggestions for a doc
|
||||
POST /api/suggestions/:id/chat → Ask Petal conversational follow-up (SSE stream)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Plagiarism Detection
|
||||
|
||||
### Architecture: Two Tiers
|
||||
|
||||
**Tier 1 — Voice Consistency Check (local, LLM, separate pass)**
|
||||
The LLM reviews the document for passages that feel tonally inconsistent with the rest of her writing — formulaic phrasing, suspiciously polished sentences, register shifts. Surfaces these as a new suggestion type (`type: "voice"`) with a warm honey-amber decoration (`--color-honey`, distinct from the lavender used for `idiom`; deepened from a pale yellow so the underline stays legible against the cream canvas). This is not plagiarism detection; it's a writing consistency tool. It runs locally with no privacy cost and is useful for ESL writers who may have paraphrased too closely from a source.
|
||||
|
||||
**This is a distinct pass from the grammar checkpoint — do not bundle them.** Voice-consistency detection is inherently whole-document (it compares a passage against the established voice *everywhere else*), so it needs the full document as context and the full window is available (see Context Window Management). That makes it too expensive to fire on the fast 4s checkpoint cadence. Run it instead either:
|
||||
- on a slow cadence (every 30–60s of idle), separate from the grammar checkpoint debounce, or
|
||||
- on an explicit "Check my voice" action in the toolbar.
|
||||
|
||||
Send the full `content_text` (no trailing-window truncation — the model window is 256K, the document will fit). Use a higher `max_tokens` than the grammar checkpoint since it may flag several passages. The grammar checkpoint stays fast and local-context; voice runs slow and whole-document. Each uses the context size and cadence appropriate to its job.
|
||||
|
||||
**Tier 2 — Academic Plagiarism Check (Copyleaks, opt-in, manual)**
|
||||
A dedicated "Check for plagiarism" button in the editor toolbar. On first use, shows a consent modal explaining that document text will be sent to Copyleaks servers for comparison against web and academic databases. User must explicitly confirm. After confirmation, preference is saved per-user and the modal does not reappear.
|
||||
|
||||
Copyleaks checks against web content and academic paper databases — the same corpus type schools use with Turnitin. Not identical to Turnitin, but meaningful for pre-submission review.
|
||||
|
||||
### Copyleaks Integration
|
||||
|
||||
**Env vars:**
|
||||
```
|
||||
COPYLEAKS_ENABLED=false # off by default
|
||||
COPYLEAKS_API_KEY=
|
||||
COPYLEAKS_EMAIL= # account email, required by their API
|
||||
```
|
||||
|
||||
**API flow:**
|
||||
Copyleaks uses an async model — you submit text, get a scan ID, then poll or receive webhook callback when complete.
|
||||
|
||||
```
|
||||
1. POST https://api.copyleaks.com/v3/education/submit/url/{scanId}
|
||||
(or text submission endpoint)
|
||||
→ 201 Accepted, scanId stored in plagiarism_reports
|
||||
|
||||
2. Copyleaks calls webhook: POST /api/webhooks/copyleaks
|
||||
→ Go handler updates plagiarism_reports with result_json + similarity_pct + status=complete
|
||||
|
||||
3. Frontend polls GET /api/docs/:id/plagiarism/latest every 5s
|
||||
while status=pending, renders results when complete
|
||||
```
|
||||
|
||||
**Webhook endpoint:** `POST /api/webhooks/copyleaks` — must be publicly reachable (it is, via write.parodia.dev). No auth middleware on this route; validate using Copyleaks HMAC signature header instead.
|
||||
|
||||
**Results display (`PlagiarismPanel` component):**
|
||||
- Slide-in right panel (same pattern as AskPetal expanded card but full height)
|
||||
- Top: large similarity percentage with color coding (green <15%, amber 15–30%, red >30%)
|
||||
- Below: list of matched sources with URL, matched percentage, and matched passage
|
||||
- Matched passages highlighted in the editor with a new red-tinted decoration type
|
||||
- "Last checked: X minutes ago" + "Re-check" button
|
||||
- Privacy reminder at panel footer: "Text was sent to Copyleaks for this check"
|
||||
|
||||
### Voice Consistency Prompt Addition
|
||||
|
||||
Add to the checkpoint system prompt (after existing ESL instructions):
|
||||
|
||||
```
|
||||
Also identify any passages (2+ sentences) that feel tonally inconsistent with
|
||||
the surrounding writing — unusually formal, unusually polished, or phrased in
|
||||
a way that differs from the writer's established voice elsewhere in the document.
|
||||
Flag these with type "voice". Do not flag the first paragraph (no baseline yet).
|
||||
```
|
||||
|
||||
Return format addition:
|
||||
```json
|
||||
{ "original": "...", "replacement": null, "explanation": "This passage sounds more formal than the rest of your writing — worth reviewing.", "type": "voice" }
|
||||
```
|
||||
|
||||
`replacement` is `null` for voice flags — there is no correction, just awareness. The frontend SuggestionCard handles `null` replacement by hiding the replacement row and showing only the explanation + Dismiss.
|
||||
|
||||
---
|
||||
|
||||
## LLM Integration
|
||||
|
||||
### Client Config (env vars)
|
||||
```
|
||||
LLM_BACKEND=vllm # vllm | ollama — default: vllm
|
||||
LLM_ENDPOINT=http://192.168.x.x:8000 # vLLM default port 8000, Ollama default 11434
|
||||
LLM_MODEL=gemma4 # Checkpoint model — small, fast. Treat as config, never hardcode.
|
||||
LLM_CHAT_MODEL=gemma4 # Ask Petal model — can be much larger. Defaults to LLM_MODEL if unset.
|
||||
LLM_TIMEOUT=30s
|
||||
```
|
||||
|
||||
**Model sizing guidance:**
|
||||
Checkpoint runs every 4 seconds while the user types — latency is UX. Keep `LLM_MODEL` at 4B–7B. Gemma4 is a solid default.
|
||||
|
||||
Ask Petal is a deliberate conversational turn with small input context (~2,000 tokens) and short output (2–4 sentences). Since the user asks questions in Mandarin, `LLM_CHAT_MODEL` should be a Chinese-native model — **Qwen3.5 9B** is the right pick. Native Mandarin capability rather than learned multilingual coverage makes a real difference for grammar explanation quality. With 64GB VRAM you have plenty of headroom for a larger Qwen3 variant.
|
||||
|
||||
If `LLM_CHAT_MODEL` is unset, fall back to `LLM_MODEL`. Single-model setups work fine.
|
||||
|
||||
### Backend Abstraction
|
||||
|
||||
Define a `LLMClient` interface in `internal/llm/client.go`. All handler code calls the interface only — never a concrete type directly.
|
||||
|
||||
```go
|
||||
// internal/llm/client.go
|
||||
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type CompletionRequest struct {
|
||||
Messages []Message
|
||||
MaxTokens int
|
||||
Temperature float64
|
||||
RepetitionPenalty float64
|
||||
TopP float64
|
||||
Stop []string
|
||||
Stream bool
|
||||
}
|
||||
|
||||
type LLMClient interface {
|
||||
// Complete returns the full response in one shot (used for checkpoint JSON)
|
||||
Complete(ctx context.Context, req CompletionRequest) (string, error)
|
||||
// Stream returns a channel of text chunks (used for Ask Petal SSE)
|
||||
Stream(ctx context.Context, req CompletionRequest) (<-chan string, error)
|
||||
}
|
||||
|
||||
// Factory — selects backend from config
|
||||
func NewLLMClient(cfg *config.Config) LLMClient {
|
||||
// LLM_CHAT_MODEL falls back to LLM_MODEL if unset
|
||||
chatModel := cfg.LLMChatModel
|
||||
if chatModel == "" {
|
||||
chatModel = cfg.LLMModel
|
||||
}
|
||||
switch cfg.LLMBackend {
|
||||
case "ollama":
|
||||
return &OllamaClient{
|
||||
endpoint: cfg.LLMEndpoint,
|
||||
checkpointModel: cfg.LLMModel,
|
||||
chatModel: chatModel,
|
||||
timeout: cfg.LLMTimeout,
|
||||
}
|
||||
default: // "vllm"
|
||||
return &VLLMClient{
|
||||
endpoint: cfg.LLMEndpoint,
|
||||
checkpointModel: cfg.LLMModel,
|
||||
chatModel: chatModel,
|
||||
timeout: cfg.LLMTimeout,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`internal/llm/vllm.go`** — OpenAI-compatible endpoint:
|
||||
```
|
||||
POST {endpoint}/v1/chat/completions
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"model": "{model}",
|
||||
"messages": [...],
|
||||
"max_tokens": {MaxTokens},
|
||||
"temperature": {Temperature},
|
||||
"repetition_penalty": {RepetitionPenalty},
|
||||
"top_p": {TopP},
|
||||
"stop": [...],
|
||||
"stream": {Stream}
|
||||
}
|
||||
```
|
||||
Streaming: SSE, parse `data:` lines, extract `choices[0].delta.content`, stop on `data: [DONE]`.
|
||||
|
||||
**`internal/llm/ollama.go`** — Ollama native API:
|
||||
```
|
||||
POST {endpoint}/api/chat
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"model": "{model}",
|
||||
"messages": [...],
|
||||
"stream": {Stream},
|
||||
"options": {
|
||||
"num_predict": {MaxTokens},
|
||||
"temperature": {Temperature},
|
||||
"repeat_penalty": {RepetitionPenalty},
|
||||
"top_p": {TopP},
|
||||
"stop": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
Streaming: newline-delimited JSON objects, extract `message.content`, stop when `done: true`.
|
||||
|
||||
**Parameter mapping cheatsheet:**
|
||||
|
||||
| CompletionRequest field | vLLM key | Ollama key |
|
||||
|---|---|---|
|
||||
| `MaxTokens` | `max_tokens` | `options.num_predict` |
|
||||
| `Temperature` | `temperature` | `options.temperature` |
|
||||
| `RepetitionPenalty` | `repetition_penalty` | `options.repeat_penalty` |
|
||||
| `TopP` | `top_p` | `options.top_p` |
|
||||
| `Stop` | `stop` | `options.stop` |
|
||||
|
||||
Both backends live behind the same interface — checkpoint.go and the Ask Petal handler call `client.Complete()` and `client.Stream()` with no awareness of which backend is active.
|
||||
|
||||
### Checkpoint Logic
|
||||
- **Trigger:** user stops typing for 4 seconds (debounced in frontend via `useCheckpoint.ts`)
|
||||
- **Rate limit:** backend enforces minimum 30s between checks per document
|
||||
- **Payload:** full `content_text` of the document (not just the changed section — context matters for ESL)
|
||||
- **Max tokens:** 1024 (suggestions are structured JSON, should be compact)
|
||||
- **Visual feedback:** pulsing rose dot in StatusBar while check in flight
|
||||
|
||||
### System Prompt
|
||||
```
|
||||
You are a warm, encouraging writing assistant helping someone who speaks English as a second language.
|
||||
Analyze the text below and identify up to 5 issues: grammar errors, unnatural phrasing,
|
||||
incorrect idiom usage, or unclear sentences that are common ESL patterns.
|
||||
|
||||
Be specific, friendly, and explain WHY each suggestion improves the writing.
|
||||
|
||||
Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
|
||||
{
|
||||
"suggestions": [
|
||||
{
|
||||
"original": "exact text from the document that needs fixing",
|
||||
"replacement": "corrected version",
|
||||
"explanation": "friendly one-sentence explanation",
|
||||
"type": "grammar|phrasing|idiom|clarity"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
If the writing looks good, return: {"suggestions": []}
|
||||
```
|
||||
|
||||
### Ask Petal: Conversational Follow-Up
|
||||
|
||||
When a user doesn't understand a suggestion, they tap **"Ask Petal ✨"** inside the suggestion card. This opens a mini chat panel anchored to the suggestion card (expands downward, max-height 320px, scrollable). The conversation is maintained in React state — not persisted to the DB. Closing the card clears the history.
|
||||
|
||||
**API route:** `POST /api/suggestions/:id/chat`
|
||||
|
||||
**Request payload:**
|
||||
```json
|
||||
{
|
||||
"messages": [
|
||||
{ "role": "user", "content": "why is this wrong?" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Server behavior:** The Go handler fetches the suggestion record (original, replacement, explanation, type) and the surrounding paragraph from the parent document's `content_text`. It injects these as system context, then appends the user's message history and streams the response back via SSE.
|
||||
|
||||
**Response:** SSE stream, `data:` events containing response text chunks. Frontend accumulates into assistant message bubble.
|
||||
|
||||
**Ask Petal System Prompt:**
|
||||
```
|
||||
You are Petal, a warm and patient English writing tutor helping someone who is learning English
|
||||
as a second language. You are currently discussing a specific writing suggestion.
|
||||
|
||||
Suggestion context:
|
||||
- Original text: "{{original}}"
|
||||
- Suggested replacement: "{{replacement}}"
|
||||
- Issue type: {{type}}
|
||||
- Initial explanation: "{{explanation}}"
|
||||
- Surrounding paragraph: "{{paragraph}}"
|
||||
|
||||
The user wants to understand this suggestion better. Detect the language of the user's message
|
||||
and respond in that same language. If they write in Mandarin Chinese, respond entirely in
|
||||
Mandarin. If they write in English, respond in English. Never mix languages in a single response.
|
||||
|
||||
Explain clearly and kindly. Use simple language appropriate to the user's message. Give examples
|
||||
when helpful. If they ask "why" (or "为什么"), explain the grammar rule or idiom behind it.
|
||||
If they suggest an alternative phrasing, evaluate it honestly.
|
||||
|
||||
Keep responses concise (2-4 sentences). This is a chat, not an essay. Be encouraging —
|
||||
learning a language is hard and they're doing great.
|
||||
```
|
||||
|
||||
**AskPetal Component (`web/src/components/Editor/AskPetal.tsx`):**
|
||||
- Renders inside the expanded SuggestionCard
|
||||
- Input field at bottom, message bubbles above (Petal messages: rose-tinted left-aligned; user messages: right-aligned lavender)
|
||||
- "Ask Petal ✨" trigger button is a small pill link below the suggestion explanation
|
||||
- Petal's first message pre-populates with the suggestion's explanation so context is immediate
|
||||
- Streaming response renders token-by-token into the latest assistant bubble
|
||||
- No conversation persistence — state lives in the SuggestionCard component
|
||||
|
||||
### LLM Sampling Parameters & Stability
|
||||
|
||||
vLLM is stateless per request. There are no server-side sessions — conversation history for Ask Petal is managed entirely at the app layer (client sends full message array each request). This is correct behavior; do not attempt to implement vLLM-side session persistence.
|
||||
|
||||
**Checkpoint requests (structured JSON output):**
|
||||
```json
|
||||
{
|
||||
"model": "${LLM_MODEL}",
|
||||
"messages": [...],
|
||||
"max_tokens": 1024,
|
||||
"temperature": 0.3,
|
||||
"repetition_penalty": 1.15,
|
||||
"top_p": 0.9,
|
||||
"stop": ["```", "\n\n\n\n"]
|
||||
}
|
||||
```
|
||||
|
||||
**Ask Petal chat requests (conversational):**
|
||||
```json
|
||||
{
|
||||
"model": "${LLM_MODEL}",
|
||||
"messages": [...],
|
||||
"max_tokens": 512,
|
||||
"temperature": 0.7,
|
||||
"repetition_penalty": 1.15,
|
||||
"top_p": 0.92,
|
||||
"stop": ["\n\n\n"]
|
||||
}
|
||||
```
|
||||
|
||||
**Why these values:**
|
||||
- `temperature: 0.3` for checkpoint — low enough to produce structured JSON reliably, but not zero. Zero temperature means greedy decoding, which is paradoxically *more* prone to repetition loops in smaller models, not less.
|
||||
- `temperature: 0.7` for chat — conversational range; allows natural variation without going incoherent.
|
||||
- `repetition_penalty: 1.15` on both — primary defense against repetition breakdown. Applies a multiplicative penalty to already-seen tokens. 1.1–1.2 is the safe range; above 1.3 starts degrading output quality.
|
||||
- `stop` sequences — catches runaway generation before it fills the output buffer. Triple newline is a reliable signal the model has looped past the end of a coherent response.
|
||||
- `max_tokens: 512` for chat — enforces conciseness and prevents the model from rambling into a degraded state mid-response.
|
||||
|
||||
### Context Window Management
|
||||
|
||||
The model window is **not** the binding constraint here — Qwen 3.5 ships a 256K context window, and on a 64GB dual-GPU setup there is ample KV-cache headroom. The truncation caps below exist to protect **checkpoint latency** (prefill time scales with input length, and the grammar checkpoint must feel responsive), not memory. Documents will almost always fit uncut; the cap only fires on unusually long ones.
|
||||
|
||||
**Grammar checkpoint** (fast, latency-sensitive — keep input modest):
|
||||
|
||||
| Component | Token Budget |
|
||||
|---|---|
|
||||
| System prompt | ~300 |
|
||||
| Document text | ~10,000 (hard cap; trailing window) |
|
||||
| Response (JSON) | ~1,024 |
|
||||
| Buffer | rest of window (ample) |
|
||||
|
||||
**Voice-consistency pass** (slow cadence — send the whole document, no trailing-window truncation; see Plagiarism Detection → Tier 1).
|
||||
|
||||
**Ask Petal chat:**
|
||||
|
||||
| Component | Token Budget |
|
||||
|---|---|
|
||||
| System prompt + suggestion context | ~600 |
|
||||
| Conversation history (rolling) | ~3,000 |
|
||||
| Latest user message | ~200 |
|
||||
| Response | ~512 |
|
||||
| Buffer | ~3,880 |
|
||||
|
||||
**Truncation logic (implement in `internal/llm/client.go`):**
|
||||
|
||||
```go
|
||||
const maxDocChars = 40000 // ~10000 tokens at ~4 chars/token — grammar checkpoint only
|
||||
const maxHistoryMsgs = 10 // 5 turns; drop oldest pairs first
|
||||
|
||||
// Document truncation (grammar checkpoint) — keep the recent end (user is actively writing there).
|
||||
// The cap is a latency guard, not a window limit; the model window (256K) is far larger.
|
||||
// The voice-consistency pass does NOT apply this truncation — it sends the full content_text.
|
||||
if len(contentText) > maxDocChars {
|
||||
contentText = contentText[len(contentText)-maxDocChars:]
|
||||
}
|
||||
|
||||
// Conversation history truncation — drop oldest messages first
|
||||
if len(messages) > maxHistoryMsgs {
|
||||
messages = messages[len(messages)-maxHistoryMsgs:]
|
||||
}
|
||||
```
|
||||
|
||||
Always log a `WARN` when truncation fires so it's visible in production. Never fail silently.
|
||||
|
||||
The Go handler receives the LLM suggestions (which reference `original` text strings) and records the plain-text offsets via `strings.Index(content_text, original)` as `from_pos` / `to_pos`. **These stored offsets are for server-side use only** (paragraph extraction for Ask Petal context) and are *not* ProseMirror positions — see Note #6. The frontend anchors decorations by searching the live document for the `original` string in ProseMirror coordinates at render time, not by trusting a numeric position. This is the single most important correctness detail in the suggestion pipeline; get it wrong and every multi-block document mis-renders.
|
||||
|
||||
---
|
||||
|
||||
## Frontend: Tiptap Configuration
|
||||
|
||||
### Extensions to enable
|
||||
```typescript
|
||||
StarterKit, // Bold, italic, headings, lists, paragraphs
|
||||
Underline,
|
||||
TextAlign,
|
||||
Placeholder.configure({ placeholder: 'Start writing...' }),
|
||||
SpellChecker, // Custom extension wrapping nspell
|
||||
SuggestionMark, // Custom extension for AI suggestion decorations
|
||||
CharacterCount, // For word count in StatusBar
|
||||
```
|
||||
|
||||
### SuggestionMark Extension
|
||||
Custom Tiptap mark that:
|
||||
- Accepts `{ suggestionId, type }` attributes
|
||||
- **Anchors by string match, not stored position** — when suggestions arrive, walk the live ProseMirror document, find the `original` text in PM coordinates, and apply the mark over that range. Do not use the server's `from_pos`/`to_pos` (those are plain-text offsets, not PM positions — see Note #6). If `original` isn't found in the current document (already edited), skip that suggestion silently.
|
||||
- Renders as a colored underline (color varies by type using CSS vars)
|
||||
- On hover, shows `<SuggestionCard>` positioned absolutely above the text
|
||||
- SuggestionCard shows: original → replacement, explanation, Accept button, Dismiss button
|
||||
- Accept → calls `PUT /api/suggestions/:id` with `status: accepted`, applies replacement via Tiptap command
|
||||
- Dismiss → calls `PUT /api/suggestions/:id` with `status: rejected`, removes mark
|
||||
|
||||
### useAutoSave Hook
|
||||
```typescript
|
||||
// Debounce 1500ms after last content change
|
||||
// Calls PUT /api/docs/:id with { content, content_text, word_count }
|
||||
// StatusBar shows: "Saving..." → "Saved just now" → fades to nothing after 3s
|
||||
```
|
||||
|
||||
### useCheckpoint Hook
|
||||
```typescript
|
||||
// Debounce 4000ms after last content change
|
||||
// Fires POST /api/docs/:id/check
|
||||
// On response: dispatch suggestion decorations to Tiptap editor
|
||||
// Sets checkpointActive state → StatusBar indicator
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ [🌸 Petal] [New Doc] [User Avatar] │ ← Header (48px)
|
||||
├──────────────┬──────────────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ Doc List │ Editor Canvas │
|
||||
│ (260px) │ (centered, max-width 720px) │
|
||||
│ │ │
|
||||
│ [Doc 1] │ ┌─────────────────────────────┐ │
|
||||
│ [Doc 2] ← │ │ [B] [I] [U] [H1] [H2] [≡] │ │ ← Toolbar
|
||||
│ [Doc 3] │ └─────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ + New │ Title (editable, large Nunito) │
|
||||
│ │ │
|
||||
│ │ Body text in Lora... │
|
||||
│ │ ~~~~~~ suggestion underline ~~~~~~ │
|
||||
│ │ │
|
||||
│ │ │
|
||||
├──────────────┴──────────────────────────────────────────────┤
|
||||
│ 342 words · ● Checking... · Saved just now │ ← StatusBar (36px)
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Distraction-free mode: clicking into the editor collapses the doc list sidebar (slides left), expands editor canvas full width. Click outside canvas or press Escape to restore.
|
||||
|
||||
---
|
||||
|
||||
## Environment Config
|
||||
|
||||
```bash
|
||||
# .env.example
|
||||
|
||||
# Server
|
||||
PORT=8080
|
||||
BASE_URL=https://write.parodia.dev
|
||||
SESSION_SECRET=change-me-to-random-64-char-string
|
||||
|
||||
# Database
|
||||
DATABASE_PATH=/data/petal.db
|
||||
|
||||
# Authentik OIDC
|
||||
AUTHENTIK_URL=https://auth.parodia.dev
|
||||
AUTHENTIK_CLIENT_ID=petal
|
||||
AUTHENTIK_CLIENT_SECRET=
|
||||
|
||||
# LLM
|
||||
LLM_BACKEND=vllm # vllm | ollama
|
||||
LLM_ENDPOINT=http://192.168.x.x:8000
|
||||
LLM_MODEL=gemma4 # Checkpoint: small and fast (4B–7B)
|
||||
LLM_CHAT_MODEL=qwen3.5:9b # Ask Petal: Qwen3.5 9B recommended for native Mandarin support
|
||||
LLM_TIMEOUT=30s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dockerfile
|
||||
|
||||
```dockerfile
|
||||
# Stage 1: Build frontend
|
||||
FROM node:22-alpine AS frontend
|
||||
WORKDIR /app/web
|
||||
COPY web/package*.json ./
|
||||
RUN npm ci
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Build Go binary
|
||||
FROM golang:1.23-alpine AS backend
|
||||
WORKDIR /app
|
||||
COPY go.* ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
COPY --from=frontend /app/web/dist ./web/dist
|
||||
RUN CGO_ENABLED=0 go build -o petal ./cmd/server
|
||||
|
||||
# Stage 3: Runtime
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
WORKDIR /app
|
||||
COPY --from=backend /app/petal .
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 8080
|
||||
CMD ["./petal"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## docker-compose.yml
|
||||
|
||||
```yaml
|
||||
services:
|
||||
petal:
|
||||
image: petal:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- petal_data:/data
|
||||
env_file: .env
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.petal.rule=Host(`write.parodia.dev`)"
|
||||
- "traefik.http.routers.petal.entrypoints=web-secure"
|
||||
- "traefik.http.routers.petal.tls.certresolver=default"
|
||||
- "traefik.http.services.petal.loadbalancer.server.port=8080"
|
||||
networks:
|
||||
- traefik
|
||||
|
||||
volumes:
|
||||
petal_data:
|
||||
|
||||
networks:
|
||||
traefik:
|
||||
external: true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## v1 Scope (Ship This)
|
||||
|
||||
- [x] Tiptap rich text editor (bold, italic, underline, headings H1/H2, ordered/unordered lists)
|
||||
- [x] Document list sidebar (create, rename, delete)
|
||||
- [x] Auto-save to SQLite (1.5s debounce)
|
||||
- [x] LLM checkpoint every 4s idle → inline suggestion decorations
|
||||
- [x] Suggestion hover card (replacement + explanation + accept/dismiss)
|
||||
- [x] nspell browser-side spell check (English dictionary)
|
||||
- [x] Word count + save status + checkpoint indicator in StatusBar
|
||||
- [x] Authentik OIDC auth
|
||||
- [x] Plagiarism check (two-tier: local voice consistency + opt-in Copyleaks academic check)
|
||||
- [x] Ask Petal conversational follow-up chat on any suggestion
|
||||
- [x] Distraction-free mode
|
||||
- [x] Soft/bubbly pastel design system
|
||||
- [x] Single Docker binary deployment to write.parodia.dev
|
||||
|
||||
## Out of Scope for v1
|
||||
|
||||
- Export to DOCX / PDF (v1.1)
|
||||
- Mobile layout (desktop first)
|
||||
- Real-time collaboration
|
||||
- Version history / change tracking
|
||||
- Multiple language support (English first)
|
||||
- Offline mode
|
||||
|
||||
---
|
||||
|
||||
## Notes for Claude Code
|
||||
|
||||
1. **Model names are always config values** — `LLM_MODEL` drives checkpoint, `LLM_CHAT_MODEL` drives Ask Petal. Both read from env vars. Never hardcode either. If `LLM_CHAT_MODEL` is unset, fall back to `LLM_MODEL` — handle this in the factory, not in individual handlers.
|
||||
2. **modernc sqlite** (`modernc.org/sqlite`) — pure Go, no cgo, no build friction.
|
||||
3. **Tiptap JSON format** is stored verbatim in `documents.content`. `content_text` is the plain text extracted for the LLM — keep these in sync on every save.
|
||||
4. **nspell dictionaries** — load `en-US` dictionary files from CDN or vendor them into `web/public/dictionaries/`. Don't shell out to hunspell binary.
|
||||
5. **Go embeds frontend** — use `//go:embed web/dist` in `cmd/server/main.go`. Single binary deployment.
|
||||
6. **Suggestion anchoring — resolve by string, not by stored position.** The `original` text string is the source of truth for where a suggestion belongs, **not** a stored numeric position. Two reasons:
|
||||
- **Coordinate mismatch:** `strings.Index(contentText, original)` returns a *plain-text* character offset. Tiptap/ProseMirror positions are **not** plain-text offsets — every node boundary (paragraph, heading, list item) consumes position units, so a plaintext offset of N does not equal ProseMirror position N. Handing a plaintext offset to a Tiptap decoration places it in the wrong spot, and the error grows with each block in the document.
|
||||
- **Staleness:** the user keeps typing (auto-save every 1.5s) after a checkpoint fires. Any position captured at checkpoint time is stale by the time the suggestion is stored and rendered.
|
||||
|
||||
**Therefore:** the frontend resolves each suggestion at *render time* by searching the live ProseMirror document for the `original` string (in PM coordinates) and applying the decoration there. The Go handler still runs `strings.Index(contentText, original)` and stores `from_pos`/`to_pos`, but these are **plain-text offsets for server-side use only** (e.g. extracting the surrounding paragraph for Ask Petal context — see Note #10), never shipped to the client as ProseMirror positions. If `original` appears multiple times, take the first occurrence; if not found at all, discard the suggestion rather than erroring. On the client, if `original` is no longer present in the live document (the user already edited that text), silently drop the suggestion — it's obsolete.
|
||||
7. **Session management** — use `gorilla/sessions` with a cookie store. Session key is `petal_session`. Store `user_id` in session after OIDC callback.
|
||||
8. **Authentik app** — needs to be created in Authentik with redirect URI `https://write.parodia.dev/api/auth/callback` and the OIDC client credentials added to `.env`.
|
||||
9. **Ask Petal SSE streaming** — use `text/event-stream` response in Go, flush after each token chunk. Frontend uses `fetch` with `ReadableStream` (not `EventSource` — needs POST) to accumulate tokens into the assistant bubble in real time. Don't buffer the full response.
|
||||
10. **Ask Petal context injection** — the Go handler for `/api/suggestions/:id/chat` fetches the suggestion + parent document in one query, extracts the paragraph containing `from_pos` from `content_text`, and injects all of it into the system prompt before the user messages. Never trust the client to send this context — always load it server-side.
|
||||
11. **LLM backend abstraction** — `NewLLMClient` is the only place that references `LLM_BACKEND`. Every other package receives an `LLMClient` interface value. `checkpoint.go` and the Ask Petal handler must import only the interface, not either concrete type. This keeps adding a third backend (e.g. LM Studio) to a single new file in the future.
|
||||
12. **Ollama streaming format differs from vLLM** — Ollama streams newline-delimited JSON objects (`{"message":{"content":"..."},"done":false}`), not SSE `data:` lines. The `OllamaClient.Stream()` method must handle this format; do not reuse the vLLM SSE parser for Ollama.
|
||||
13. **Copyleaks async flow** — submission returns immediately with a scan ID. Results arrive via webhook, not the submission response. Store the scan ID in `plagiarism_reports` immediately on submission, update the row when the webhook fires. Frontend polls `/api/docs/:id/plagiarism/latest` every 5s while `status=pending` — don't try to make this synchronous.
|
||||
14. **Copyleaks webhook HMAC** — validate the `X-Copyleaks-Signature` header on every webhook call. Reject without processing if invalid. Never skip this in production.
|
||||
15. **`replacement: null` in voice suggestions** — the SuggestionCard must check for null replacement and conditionally render. Don't assume replacement is always a string.
|
||||
17. **CJK font fallback in Ask Petal chat** — Nunito has no CJK coverage. The chat bubble `font-family` must include system CJK fallbacks: `'Nunito', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', sans-serif`. Apply this specifically to the AskPetal message bubbles, not the editor body.
|
||||
18. **Mandarin detection is the model's job** — do not attempt language detection in Go or the frontend. The prompt instructs the model to detect and match. This works fine with Gemma4 which has solid Mandarin capability. If a future model swap degrades Mandarin quality, that's a prompt/model problem, not an architecture problem.
|
||||
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])
|
||||
178
web/src/App.tsx
178
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)' }}
|
||||
/>
|
||||
<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,8 +433,17 @@ export default function App() {
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{historyOpen && currentDoc && (
|
||||
<HistoryPanel
|
||||
docId={currentDoc.id}
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
onRestored={handleRestored}
|
||||
/>
|
||||
)}
|
||||
|
||||
{updateAvailable && <UpdateBanner />}
|
||||
|
||||
<div className="petal-no-print">
|
||||
<PetalCompanion
|
||||
wordCount={wordCount}
|
||||
saveStatus={status}
|
||||
@@ -294,5 +451,6 @@ export default function App() {
|
||||
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,25 +1,43 @@
|
||||
import type { DocSummary } from '../../api/client'
|
||||
import { useState } from 'react'
|
||||
import type { DocSummary, Tag, TagColor } from '../../api/client'
|
||||
import { TagChip } from './TagChip'
|
||||
import { TagPicker } from './TagPicker'
|
||||
|
||||
interface Props {
|
||||
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="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className="truncate text-sm font-semibold"
|
||||
@@ -31,6 +49,22 @@ export function DocListItem({ doc, active, onSelect, onDelete }: Props) {
|
||||
{doc.word_count} {doc.word_count === 1 ? 'word' : 'words'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tag + delete affordances: always reachable on touch (no hover), fade in
|
||||
on hover for the pointer experience. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Tag document"
|
||||
title="Tags"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setPicking((v) => !v)
|
||||
}}
|
||||
className="petal-row-action flex h-7 w-7 shrink-0 items-center justify-center text-sm"
|
||||
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
|
||||
>
|
||||
🏷️
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Delete document"
|
||||
@@ -38,11 +72,30 @@ export function DocListItem({ doc, active, onSelect, onDelete }: Props) {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center text-base opacity-0 group-hover:opacity-100"
|
||||
className="petal-row-action flex h-7 w-7 shrink-0 items-center justify-center text-base"
|
||||
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
{picking && (
|
||||
<TagPicker
|
||||
roster={roster}
|
||||
assignedIds={assignedIds}
|
||||
onToggle={(t) => onToggleTag(doc.id, t)}
|
||||
onCreate={(name, color) => onCreateTag(doc.id, name, color)}
|
||||
onClose={() => setPicking(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{doc.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{doc.tags.map((t) => (
|
||||
<TagChip key={t.id} tag={t} onRemove={() => onToggleTag(doc.id, t)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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