Compare commits
45 Commits
feat/tone-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49e84278d5 | ||
|
|
9d2501a625 | ||
|
|
08b801e752 | ||
|
|
dd1dd7879b | ||
|
|
cb8f132d43 | ||
|
|
cea25a3ebc | ||
|
|
96f68a91ee | ||
|
|
3867cca2fa | ||
|
|
8be852ddf2 | ||
|
|
4d544f29b5 | ||
|
|
8c6bc1604b | ||
|
|
4161830da6 | ||
|
|
8aa437ec82 | ||
|
|
20442d1356 | ||
|
|
375e0dde4c | ||
|
|
7824b68ea1 | ||
|
|
631279bd3a | ||
|
|
9576340391 | ||
|
|
aebdc2679a | ||
|
|
8bd2509bc2 | ||
|
|
0f2f753efa | ||
|
|
46db0a3e16 | ||
|
|
1bd38b20e8 | ||
|
|
82e2bcc777 | ||
|
|
035dcf5d25 | ||
|
|
5120b1e5a2 | ||
|
|
adc0cdff1c | ||
|
|
9d6698dcc6 | ||
|
|
6783ce7a51 | ||
|
|
3ac6382696 | ||
|
|
db737fa612 | ||
|
|
6e6e4edce7 | ||
|
|
58c326d0cf | ||
|
|
045ec19b92 | ||
|
|
f6fa73b17b | ||
|
|
8cf78130bf | ||
|
|
2487e73551 | ||
|
|
0a1ea225dd | ||
|
|
69ecb79da1 | ||
|
|
f3c4fe2c22 | ||
|
|
9e141e4169 | ||
|
|
60eba25fee | ||
|
|
8e1111d768 | ||
|
|
cf7720ea77 | ||
| 0d59b645bb |
17
.env.example
17
.env.example
@@ -7,6 +7,9 @@ BASE_URL=http://localhost:8080
|
|||||||
# Database (SQLite, pure-Go modernc — no cgo)
|
# Database (SQLite, pure-Go modernc — no cgo)
|
||||||
DATABASE_PATH=./data/petal.db
|
DATABASE_PATH=./data/petal.db
|
||||||
|
|
||||||
|
# On-disk store for images pasted/dropped/inserted in the editor
|
||||||
|
IMAGE_DIR=./data/images
|
||||||
|
|
||||||
# LLM
|
# LLM
|
||||||
LLM_BACKEND=vllm # vllm | ollama
|
LLM_BACKEND=vllm # vllm | ollama
|
||||||
LLM_ENDPOINT=http://localhost:8000 # vLLM :8000, Ollama :11434
|
LLM_ENDPOINT=http://localhost:8000 # vLLM :8000, Ollama :11434
|
||||||
@@ -14,6 +17,20 @@ LLM_MODEL= # checkpoint model (small/fast). Never hardcod
|
|||||||
LLM_CHAT_MODEL= # Ask Petal model (Mandarin-native). Falls back to LLM_MODEL if empty.
|
LLM_CHAT_MODEL= # Ask Petal model (Mandarin-native). Falls back to LLM_MODEL if empty.
|
||||||
LLM_TIMEOUT=30s
|
LLM_TIMEOUT=30s
|
||||||
|
|
||||||
|
# Read-aloud (TTS). Off unless TTS_ENDPOINT is set — when empty, the /api/tts route
|
||||||
|
# is not mounted and the frontend falls back to the browser's Web Speech API.
|
||||||
|
# Backed by a local Piper HTTP server (python3 -m piper.http_server).
|
||||||
|
# Each Piper HTTP server loads ONE voice, so English and Chinese need separate
|
||||||
|
# instances (different ports). zh is only routed when both TTS_ENDPOINT_ZH and
|
||||||
|
# TTS_VOICE_ZH are set; otherwise Chinese falls back to Web Speech.
|
||||||
|
TTS_ENDPOINT= # e.g. http://127.0.0.1:5005 — empty disables server TTS
|
||||||
|
TTS_ENDPOINT_ZH= # e.g. http://127.0.0.1:5006 — Chinese Piper instance
|
||||||
|
TTS_VOICE_EN=en_US-amy-medium # Piper voice id for English
|
||||||
|
TTS_VOICE_ZH=zh_CN-huayan-medium # Piper voice id for Chinese
|
||||||
|
TTS_CACHE_DIR=./data/tts # on-disk store for synthesized clips (content-addressed)
|
||||||
|
TTS_TIMEOUT=15s
|
||||||
|
TTS_AUDIO_FORMAT=mp3 # mp3 | opus | wav — mp3/opus transcode Piper's WAV via ffmpeg
|
||||||
|
|
||||||
# --- Deferred (not wired in the local-dev build) ---
|
# --- Deferred (not wired in the local-dev build) ---
|
||||||
|
|
||||||
# Auth (Authentik OIDC) — deferred; single hardcoded local user for now
|
# Auth (Authentik OIDC) — deferred; single hardcoded local user for now
|
||||||
|
|||||||
@@ -74,12 +74,85 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
|
|||||||
- [x] `MisspellCard` popover + EditorCore wiring — soft **rose wavy underline** (`.petal-misspelling`, pastel take on the red squiggle, not classic red). Click a flagged word → `posAtCoords`→`wordAt` opens a bilingual card ("拼写 · Spelling") with up to 5 nspell corrections as pills (click to replace via `insertContentAt`) + "添加到词典 · Add to dictionary". Closes on outside-pointer-down, doc edit, or doc switch.
|
- [x] `MisspellCard` popover + EditorCore wiring — soft **rose wavy underline** (`.petal-misspelling`, pastel take on the red squiggle, not classic red). Click a flagged word → `posAtCoords`→`wordAt` opens a bilingual card ("拼写 · Spelling") with up to 5 nspell corrections as pills (click to replace via `insertContentAt`) + "添加到词典 · Add to dictionary". Closes on outside-pointer-down, doc edit, or doc switch.
|
||||||
- Verified: tsc clean, vite build OK (dict in `dist/dictionaries/en/`), go build/vet clean; live server serves both dict files (200, 3086B aff / 551762B dic); nspell smoke (`helllo→hello`, `recieve→receive`, `写作` untokenized, `NASA` ok, `add()` persists).
|
- Verified: tsc clean, vite build OK (dict in `dist/dictionaries/en/`), go build/vet clean; live server serves both dict files (200, 3086B aff / 551762B dic); nspell smoke (`helllo→hello`, `recieve→receive`, `写作` untokenized, `NASA` ok, `add()` persists).
|
||||||
|
|
||||||
|
### Phase 8 — Trust foundation (version history + export) ✅
|
||||||
|
- [x] **Version history** — `document_versions` table (migration `0003`), full-body snapshots that cascade with the doc. Kinds: `auto` (throttled background, ≥3min apart, max 40/doc, pruned), `manual` (explicit restore point), `pre_restore` (safety copy taken before a restore, so restore is undoable). Snapshot taken post-save in `update` only when a real body came through and content changed (empties + bare renames never snapshot). Endpoints: `GET/POST /api/docs/:id/versions`, `GET /api/docs/:id/versions/:vid`, `POST /api/docs/:id/versions/:vid/restore`. All scoped to the owner via a join on `documents`. `versions_test.go` covers lifecycle/throttle/restore/pre_restore/404.
|
||||||
|
- [x] **Export** — pure-Go Tiptap-JSON → Markdown / HTML / plain-text / **docx** (`export.go`), no cgo/pandoc, CJK-safe. docx is a hand-built OOXML zip (marks→run props, headings→built-in styles, lists→prefix). RFC 5987 `filename*=UTF-8''` so Chinese titles download cleanly. `GET /api/docs/:id/export?format=`. **PDF is client-side** via the browser print dialog + a `@media print` stylesheet (uses the reader's fonts → CJK for free, no embedded-font bloat). `export_test.go` asserts every format incl. valid-zip docx with CJK.
|
||||||
|
- [x] **Frontend** — `ExportMenu` (download links + Print/PDF) and `HistoryPanel` (slide-over drawer: snapshot list w/ relative-time + kind badge, preview, restore; restore remounts the editor via an `editorEpoch` bump). Both bilingual zh-first, matching chrome. Wired into the title row; `.petal-no-print` strips all chrome for print.
|
||||||
|
- [x] **Stop saving empty docs** — blank `Untitled` drafts now self-discard: `handleCreate` reuses an existing blank instead of stacking another; `openDoc` deletes the blank doc being left. Cleaned the 2 existing orphan empties from the live DB. (Backend also refuses to snapshot empties.)
|
||||||
|
- Verified: go build/vet/test + tsc + vite all clean; live smoke on a throwaway binary — auto-snapshot on save, throttle holds at 1, manual snapshot, restore brings back exact text + leaves a `pre_restore`, empty doc → no snapshot, md/docx export with CJK+bold+heading+list, docx validates as "Microsoft Word 2007+".
|
||||||
|
|
||||||
|
### Phase 9 — ESL superpowers ✅
|
||||||
|
- [x] **Inline Chinese gloss (offline)** — embedded English→Chinese dictionary (`internal/lexicon/data/gloss.json.gz`, ~1.3MB, 57k common words built from ECDICT via `scripts/build_gloss.py`: frequency-gated to rank ≤50k, `[网络]`/slang/archaic sense-lines dropped, trimmed to ≤3 senses / 80 chars). `Lexicon` gains a `gloss` map + `Gloss(word)` (same `candidates()` de-inflection as defs/syns); `Result` gains a `Gloss` field. Two surfaces: the right-click **WordCard** now leads with the 中文 gloss, and a new lightweight `GET /api/gloss/{word}` (→ `{word, gloss}`, cached) backs the **hover tooltip**. Offline + instant, works with the LLM down (north-star reliability). Frontend: `GlossTip` (dark pointer-events-none bubble under the resting word; 350ms hover delay; reuses `wordAt` so CJK is never glossed — it's the source language), wired into `EditorCore`'s `onMouseMove`/`onMouseLeave` with a request-token guard, suppressed during selection/preview/other popovers.
|
||||||
|
- [x] **"Say it more naturally" / tone-rewrite** — selecting text pops a `SelectionBubble` (✨更自然 + the tone vocabulary 学术/专业/轻松/幽默/创意/说服, mirrored from `ToneSelect`/`styleGuidance`). Picking a style calls `POST /api/docs/:id/rewrite` (`{text, style}` → `{rewrite}`), shown in a `RewritePreview` (original struck-through → rewrite, 用这个/取消, breathing-dot loading, gentle retry on failure). Accept applies it in-editor via `insertContentAt` over the captured PM range. Backend: `llm.RunRewrite` (one-shot Complete, `RewriteMaxRunes` 2000 cap, `cleanRewrite` strips stray wrapping quotes) + `rewriteSystemTemplate`/`styleGuidance` in `prompts.go`; handler in `internal/suggestions/rewrite.go` (owner-scoped 404, 400 on empty/too-long, 502 on LLM-down). **Stateless** — not persisted as a suggestion; the version history captures the resulting doc change.
|
||||||
|
- Tests: `lexicon` gloss + Lookup-includes-gloss + inflection/miss; `suggestions` rewrite happy-path (style steering + de-quote asserted), empty→400, unknown-doc→404. go build/vet/test clean, tsc clean, vite build OK. Live smoke vs a fake vLLM (fresh port 8055, throwaway DB; pre-existing dev servers on :8077/:8099 untouched): gloss for `river`/inflected/CJK-empty/nonsense-empty, `word/happy` carries the gloss, rewrite returns text, empty→400, unknown→404, LLM-down→502; new CSS classes present in the built bundle.
|
||||||
|
- **Known limitation**: `Gloss` tries the literal form first (matching defs/syns ordering), so an inflected word that is *itself* a separate ECDICT headword resolves to that entry rather than de-inflecting (e.g. `rivers` → the proper-noun "Rivers" sense, not `river`). The base form always glosses correctly; acceptable.
|
||||||
|
|
||||||
|
### Phase 10 — Organization & polish ✅
|
||||||
|
- [x] **Cross-document search (FTS5)** — migration `0004` adds a `documents_fts` virtual table over `title` + `content_text` using the **`trigram` tokenizer** (so search works for both English and space-free Chinese; the default unicode61 tokenizer treats a CJK run as one token). Kept in sync by `AFTER INSERT/UPDATE/DELETE` triggers on `documents`, back-filled from existing rows in the migration (verified: pre-existing docs are searchable immediately). `GET /api/search?q=` (`internal/docs/search.go`): queries of ≥3 runes use the FTS index (fast, `ORDER BY rank`); shorter queries fall back to a `LIKE` scan so **2-character Chinese words** (e.g. 公园) still resolve. Snippets are built in **Go** from the original text (clean word boundaries, rune-aware so CJK never splits mid-char), with the match wrapped in `\x01…\x02` sentinels; the client splits on these to highlight without `innerHTML`. Owner-scoped, capped at 50 hits. Frontend `SearchBox` in the sidebar: 220ms-debounced, results with highlighted two-line snippets, click to open.
|
||||||
|
- [x] **Tags (organize)** — migration `0004` adds `tags` (user-scoped, `UNIQUE(user_id, name)`, `color` = palette key) + `document_tags` join (both sides cascade). `internal/docs/tags.go`: `GET/POST/PATCH/DELETE /api/tags` (create is **idempotent** on name; unknown colors coerced to rose) + `POST /api/docs/:id/tags` / `DELETE /api/docs/:id/tags/:tagId` (owner-validated, idempotent assign). The doc-list and search responses carry each doc's tags (loaded in one `tagsByDoc` query, no N+1). Frontend: `useTags` (roster + counts), `TagChip`, `TagPicker` (assign existing / create-and-attach with a color swatch), tag chips on each doc row, a **filter bar** (client-side filter by tag, shows in-use tags with counts). Colors map to the existing design tokens via `tagColorVar`.
|
||||||
|
- [x] **Tablet / touch polish** — responsive sidebar: below 768px it becomes an overlay **drawer** toggled by a header hamburger, with a scrim (auto-closes on doc select). `@media (pointer: coarse)` enlarges tap targets (`.petal-tap` ≥44px, `.petal-tap-sm` ≥36px) and reveals the hover-only row actions (tag/delete). **Tap-to-open** for AI-suggestion cards (no hover on touch): a tap on a `.petal-suggestion` opens its card via the editor click handler, and a `pointerdown` outside the card/highlight dismisses it (mouse users keep the hover bridge).
|
||||||
|
- [x] **Warm LLM-down failure states** — `useCheckpoint` now tracks an `llmDown` flag (set when a check/voice pass hits the server's 502/network path, cleared on the next success or doc switch). The `StatusBar` shows a gentle bilingual note — 🌙 **小助手在休息 · Petal's helper is resting · 文字已保存** — reassuring that the writing still saved locally (saving is independent of the LLM). Rewrite already had a gentle retry from Phase 9.
|
||||||
|
- Tests: `tags_test.go` (full lifecycle — create/idempotent/color-coerce/rename-recolor/assign/unassign/doc-list inclusion/roster counts/delete-cascade/404s), `search_test.go` (EN FTS, CJK FTS, 2-char CJK LIKE fallback, title-only, case-insensitive, empty/no-match, **edit re-indexes via the update trigger**). go build/vet/test clean, tsc clean, vite build OK. Live smoke vs the binary on a throwaway DB (port 8061, LLM pointed at a dead host): search EN/CJK/2-char all highlighted, tag create+assign+roster-counts+doc-list-tags+delete-cascade, check→502 (warm path), new CSS classes (`petal-tag-chip`/`petal-scrim`/`petal-drawer-open`/`pointer:coarse`) and the 小助手在休息 string present in the served bundle. FTS backfill of pre-existing docs verified separately.
|
||||||
|
- **Known limitations**: trigram FTS snippets/ranking treat the query as a contiguous phrase (multi-term relevance is substring, not BM25-per-term) — fine for a personal corpus. Search is title+body only (not tag names). Hover gloss and right-click word lookup remain pointer-oriented (long-press contextmenu on touch is browser-dependent); the spelling/suggestion cards and rewrite bubble are fully touch-reachable.
|
||||||
|
|
||||||
|
### Phase 11 — Writer power-ups ✅
|
||||||
|
- [x] **In-document Find & Replace (Ctrl/Cmd+F)** — `SearchHighlight` ProseMirror extension (decorations, not marks — same anchoring discipline as the suggestion/spell layers; matches recomputed per-textblock on every edit, never stranded). `FindReplace` bar (bilingual zh-first): live match highlighting, ↑/↓ step-through with no-selection DOM scroll-into-view (so it never pops the rewrite bubble), match-case toggle, replace / replace-all (replace-all applies back-to-front so earlier edits don't shift later positions). Honey wash on all matches, rose ring on the active one.
|
||||||
|
- [x] **Read-aloud / TTS** (`web/src/audio/speech.ts`) — Web Speech API, offline, feature-detected. 🔊 in the `WordCard` (pronounce the word) and the selection bubble (read the selection). Pairs with the phonetic line.
|
||||||
|
- [x] **Keyboard + touch access to the ESL helpers** — refactored the right-click word-lookup into a position-based `openWordLookup(pos)`; now also driven by **Ctrl/Cmd+D** (look up the word at the caret) and a **touch long-press** (~500ms, the touch equivalent of right-click). **Ctrl/Cmd+J** rewrites the selection more naturally. (Closes the "pointer-only" limitation noted in Phase 10.)
|
||||||
|
- [x] **Whole-corpus backup** — `GET /api/docs/export-all?format=md|docx|…` zips every doc (reuses the per-doc renderers; de-duplicates same-titled filenames; dated `petal-backup-YYYY-MM-DD.zip`). Static route takes priority over `/{id}` in chi — covered by `TestExportAll`. Sidebar footer "备份 · Back up all: Word / Markdown" download links.
|
||||||
|
- [x] **Smart typography** (`Typography.ts`) — dependency-free input rules: curly quotes, em-dash (`--`), ellipsis (`...`). ASCII-only triggers so CJK fullwidth punctuation is untouched; every rule is plain-Undo-able.
|
||||||
|
- [x] **Org niceties** — duplicate-a-document (App `handleDuplicate` → "… (副本)", copies body/tone, not tags), sidebar **sort** (Recent / Title / Longest), and a **document outline** popover in the toolbar (headings → click to scroll, indented by level).
|
||||||
|
- [x] **English phonetic (pivot from pinyin)** — for a native-Mandarin English learner the useful pronunciation aid is the English IPA, not pinyin (she reads Chinese fluently). `scripts/build_phonetic.py` extracts ECDICT's `phonetic` column (same source/freq-gate as the gloss); `phonetic.json.gz` embedded + lazily loaded; `Result.Phonetic` resolved via the same de-inflecting candidate walk; shown as `/ˈrɪvər/` in the WordCard. **Full dataset built from ECDICT: 46,579 words (361KB gz)**, in line with the other lexicon assets. The script also has a `--seed` mode (71 curated common words) that ships as a fallback / works without the csv. Curated seed entries (clean IPA) override the ECDICT form where both exist.
|
||||||
|
- Verified: go build/vet/test (incl. new `TestExportAll`), tsc, vite build all clean; live smoke vs throwaway binaries — `/word/river|running|serendipity|rivers` all return phonetic (`rivers`→`river` de-inflected), export-all returns a valid 2-entry zip with de-duped CJK filenames + dated name, route doesn't collide with `/{id}`.
|
||||||
|
|
||||||
|
### Phase 12 — Collocation coach ✅ (2026-06-26)
|
||||||
|
**Why:** ESL writers nail grammar but miss *which words go together* — "do a decision" → "make a decision", "strong rain" → "heavy rain". These aren't *wrong*, so the grammar pass won't flag them; they're just non-native. Gentle "natives usually say…" hints are the single highest-leverage upgrade for making her writing sound native. **Build this first** — it's small and de-risks the migration-rebuild pattern Phase 13 also needs.
|
||||||
|
|
||||||
|
**Key insight:** the suggestion pipeline is already generic over a `pass` + a `pendingScope` "family" (`runPass` in `internal/suggestions/handlers.go`; grammar + voice already prove it). Collocation drops in as a **third family** and reuses the entire accept/dismiss/re-anchoring/rail/Mandarin-explanation machinery — no new frontend rendering layer.
|
||||||
|
- [x] `internal/llm/collocation.go` — `CollocationInterval` (25s) + `RunCollocation(...)`, reusing the existing `ParseCheckpoint` parser (same as `RunVoice`). Whole-document (no `TruncateDoc`), tone passed through.
|
||||||
|
- [x] `internal/llm/prompts.go` — `CollocationMessages(contentText, tone)` + `collocationSystemPrompt`. The prompt flags only non-wrong-but-non-native word pairings ("do a decision" → "make a decision"), explicitly defers grammar/spelling to the grammar family, and frames every explanation as warm "Natives usually say…" with a Mandarin gloss — never "error/wrong/mistake".
|
||||||
|
- [x] `internal/db/models.go` (`SuggestionTypeCollocation`) + migration `0005_collocation_suggestion_type` — **rebuilds** the `suggestions` table (new table w/ extended CHECK, copy rows, drop, rename, recreate `idx_suggestions_doc_id`), since SQLite can't `ALTER` a CHECK. Verified against a copy of the live DB (5 migrations apply cleanly, collocation insert accepted, rows preserved).
|
||||||
|
- [x] `internal/suggestions/handlers.go` — `collocationScope` (`deleteWhere: "type = 'collocation'"`, `forceType: collocation`), `CollocationLimit` on `Handler` (+ wired in `New`), `POST /{id}/collocation`, `normalizeType` extended. **Also fixed `grammarScope`** from `type != 'voice'` → `type NOT IN ('voice','collocation')` so a grammar checkpoint no longer wipes the collocation pending flags (the third family must survive like voice does).
|
||||||
|
- [x] Frontend: `suggestionMeta.ts` collocation entry (`--color-blossom` warm pink, "Word pairing" label); `client.ts` `collocationDoc(docId)` + `SuggestionType` extended; `index.css` token + `.petal-suggestion-collocation` decoration; `useCheckpoint` `collocating`/`runCollocation` (mirrors `runVoice`, shares the run-token guard); `Toolbar` blossom **"Make it sound natural 🌸"** pill (→ "Reading…"); `StatusBar` breathing blossom dot "Finding natural phrasing…"; threaded through `EditorCore`/`App`. Renders straight into the existing `SuggestionRail`/`SuggestionCard` (Accept applies the native pairing).
|
||||||
|
- [x] Verified: go build/vet/test (`TestCollocationPassCoexists` — three families coexist, grammar checkpoint doesn't wipe voice/collocation), tsc, vite build, vitest 51/51 all clean; live smoke vs the binary (dead LLM) → collocation route returns the warm 502 like check/voice.
|
||||||
|
|
||||||
|
### Phase 13 — Vocabulary garden (spaced repetition) ✅ (2026-06-26)
|
||||||
|
**Why:** the lexicon (`internal/lexicon`) is a stateless static-dataset lookup — **nothing records which words she's looked up.** Capturing them turns passive lookups into real vocabulary, and the review surface ties straight into the "petal garden" delight idea (words become blossoms; the sleeping kitten naps among them). Build **after** Phase 12.
|
||||||
|
- [x] New `internal/vocab` package + migration `0006_vocab_garden` (0005 was taken by collocation) — `vocab_words` table: `word, gloss, phonetic, example` (sentence captured at lookup), `doc_id` (`ON DELETE SET NULL` so a word outlives its source doc), + SM-2-lite scheduling: `due_at, interval_days, ease, reps, lapses, last_reviewed`; `UNIQUE(user_id, word)` + `idx_vocab_due`.
|
||||||
|
- [x] Auto-capture: `EditorCore.openWordLookup` fires `POST /api/vocab` after a successful lookup — **only for words the dictionary actually knows** (a real gloss or definition), so typos/proper-noun lookups don't clutter the garden. Captures the surrounding sentence (`sentenceAround`) + `doc_id`. Idempotent upsert: re-looking-up a word refreshes its gloss/phonetic/example but never resets its schedule.
|
||||||
|
- [x] Endpoints (`internal/vocab/handlers.go`): `POST /api/vocab` (upsert; new word → `due_at = datetime('now','+1 day')`), `GET /api/vocab/due` (due now, server-side `datetime('now')` comparison — avoids JS local-vs-UTC parsing bugs), `POST /api/vocab/{id}/review` (grade → reschedule via `datetime('now','+N days')`), `GET /api/vocab` (full garden), `DELETE /api/vocab/{id}`. All owner-scoped.
|
||||||
|
- [x] SR scheduler (`internal/vocab/scheduler.go`): gentle SM-2-lite / Leitner ladder (1d → 3d → 7d → 16d → 35d, then geometric by ease). "again" steps back to 1d + counts a lapse + nudges ease down (floored at 1.3) — no harsh wipe; "good" climbs one rung; "easy" climbs a rung and a bit more + raises ease. No streaks to break.
|
||||||
|
- [x] Frontend `GardenPanel` (slide-over drawer, sibling to `HistoryPanel`): each word a blossom that opens further with reps (🌱→🌿→🌷→🌸→🌺); a "复习 N 个词 · Review N due" button; per-word detail (phonetic/example/source-doc/remove); footer "🐱💤 N 朵花在花园里" — the sleepy kitten napping among the blossoms. **Flashcard review**: due queue, the example sentence with the word blanked (`blankOut`), flip to reveal word+phonetic+gloss+sentence, again/good/easy grades; **direction alternates** by cursor parity for recognition (EN→中文) *and* production (中文→EN). A 🤍/💚 "save to garden" toggle on `WordCard` alongside the silent auto-capture. Opened from a global 🌷 词汇花园 button in the app header. All copy bilingual zh-first.
|
||||||
|
- [x] Verified: go build/vet/test (`scheduler_test.go` — ladder/again-gentle/easy-further; `handlers_test.go` — capture/upsert/due/review/delete lifecycle + empty-word 400 + doc-delete SET NULL), tsc, vite build, vitest 51/51 all clean; live smoke vs the binary (throwaway DB) — full capture→list→due→review→delete flow + 400 on bad grade verified end-to-end.
|
||||||
|
|
||||||
|
### Phase 14 — Companion warmth + bedtime nag + night mode ✅
|
||||||
|
**Why:** the companion kitten is the heart of Petal's "built for her" feel. Three additions: (1) a wider, fresher pool of **encouraging phrases** so cheers don't repeat as quickly; (2) when she's still writing **late at night (≥11pm)**, the kitten gently nags her to go to bed; (3) at the same hour the whole app drifts into a calm **night mode** — dark moonlit theme + the falling petals become **falling stars**. Caring, never scolding — the sleepy-cat gag makes "you should be sleeping too 🐱💤" land perfectly. Self-contained, frontend-only.
|
||||||
|
- [x] `web/src/components/Companion/tips.ts` — `ENCOURAGEMENTS` grown from 5→10 bilingual zh-first lines so cheers rotate fresher. New `BEDTIME: Line[]` array — four warm/playful lines (user-supplied English wit: "I bet your bed is missing you right now", "A tired writer is a bad writer", "Sleep is a wondrous enabler", "Hear that? No… everyone is sleeping and you should be too") with gentle Mandarin leads.
|
||||||
|
- [x] `web/src/components/Companion/useCompanion.ts` — bedtime check folded into the existing 10s heartbeat (after the idle-return + break check, before the generic tip): `isBedtime()` = local hour ≥ 23 or < 4 (`new Date().getHours()`, the writer's machine clock). Only fires while actively writing (idle branch returns first). Own `lastBedtime` ref + `BEDTIME_GAP` 30min cooldown; respects `PROACTIVE_GAP`. New `BubbleTone` `'bedtime'` paces `readBubbleMs` (BUBBLE_MS + 4s lingers a touch longer for a wind-down read). Window knobs `BEDTIME_FROM`/`BEDTIME_TO` so the 11pm–4am range is one edit to retune.
|
||||||
|
- [x] No tone-based bubble styling exists, so no CSS needed; the tone is metadata for pacing only. Sound stays the rotating pop.
|
||||||
|
- [x] **Night mode** — `web/src/lib/night.ts` centralizes `isBedtime()` + the `BEDTIME_FROM`/`BEDTIME_TO` window (shared with the companion nag so they always agree). `web/src/hooks/useNightMode.ts` re-checks every 60s and toggles a `petal-night` class on `<html>`. `index.css` adds an `html.petal-night` block that **only re-points the palette tokens** (`--color-bg/surface/border/plum/muted/accent…`) to a dark moonlit set — every Tailwind color utility reads them via `var()`, so the whole UI flips with zero component changes (verified: `.text-plum{color:var(--color-plum)}`). Accent/type colors kept (they pop on dark); 600ms bg/color fade for a gentle dusk transition; print stays white (the `#fff` override is inside `@media print`).
|
||||||
|
- [x] **Falling stars** — `PetalFall` gains a `night` prop. Particles are **chunky cartoon power stars** (`makeCartoonStar`, Mario/Kirby-style: fat 5-point shape, glossy radial fill, puffy round-join colored outline, corner shine + soft glow halo so they pop off the dark sky) in 5 candy colors (`CARTOON_COLORS`), mixed ~70/30 with small four-point twinkle sparkles (`makeStarSprite`/`STAR_PALETTE`) for depth. Every star clearly **spins** (random direction, ~0.5–2.2 rad/s so the quick ones really whirl while slow ones drift for contrast; guaranteed min speed since a 5-point star is symmetric every 72°), **falls straight down** (no sway — that's a petal thing), and shimmers via a shallow alpha pulse (not blink). Effect re-inits on the day↔night flip. App wires `const night = useNightMode()` → `<PetalFall night={night} />`. All sprites are canvas-drawn (offline, no asset files) — `sprites[]` is an image array, so a real PNG/SVG star could drop in later without restructuring.
|
||||||
|
- [x] Verified: tsc clean, vite build OK, companion vitest 45/45. **Real-browser screenshots** (local Playwright + Chromium, clock mocked to 23:30): day = warm cream + pink sakura petals; night = dark plum-indigo + twinkling stars + glowing sleepy kitten. Both pretty (acceptance criterion).
|
||||||
|
|
||||||
### Deferred (post-v1-local)
|
### Deferred (post-v1-local)
|
||||||
- [ ] Authentik OIDC auth + session middleware
|
- [ ] Authentik OIDC auth + session middleware ← **on hold: user doing foundational work first**
|
||||||
- [ ] Copyleaks Tier-2 + webhook HMAC
|
- [ ] Copyleaks Tier-2 + webhook HMAC
|
||||||
- [ ] Dockerfile, docker-compose, Traefik, deploy to write.parodia.dev
|
- [ ] Dockerfile, docker-compose, Traefik, deploy to write.parodia.dev
|
||||||
|
|
||||||
|
### Next-up (post-v1 product, agreed with user 2026-06-26)
|
||||||
|
- [x] **Phase 9 — ESL superpowers**: inline Chinese gloss on hover/select; "say it more naturally" / tone-rewrite. ✅ (see Phase 9 above)
|
||||||
|
- [x] **Phase 10 — organization & polish**: cross-doc search, tags, tablet/touch polish, warm LLM-down failure states. ✅ (see Phase 10 above; "tags only" chosen over folders, FTS5 over LIKE)
|
||||||
|
- [x] **Phase 12 — collocation coach**: gentle "natives usually say…" hints for non-native word pairings, as a third suggestion family. ✅ (see Phase 12 above)
|
||||||
|
- [x] **Phase 13 — vocabulary garden**: spaced-repetition review built from looked-up words, surfaced as a blooming garden. ✅ (see Phase 13 above)
|
||||||
|
- [x] **Phase 14 — companion warmth + bedtime nag + night mode**: more encouraging phrases, a gentle "go to bed" nudge after 11pm, and a calm dark theme + falling stars at night. ✅ (see Phase 14 above)
|
||||||
|
|
||||||
## Session log
|
## Session log
|
||||||
|
- 2026-06-26: **Phases 12 + 13 complete** (collocation coach + vocabulary garden — "finish the rest of the build plan except Authentik/Traefik"). **Phase 12**: collocation drops in as a third suggestion family reusing the whole `runPass`/`pendingScope` machinery — `llm/collocation.go` (`RunCollocation`, 25s floor, reuses `ParseCheckpoint`), `collocationSystemPrompt`/`CollocationMessages` (warm "Natives usually say…" + Mandarin gloss, defers grammar elsewhere), migration `0005` **rebuilds** the suggestions table to extend the `type` CHECK (SQLite can't ALTER a CHECK), `collocationScope` + `CollocationLimit` + `POST /{id}/collocation`. **Caught a latent bug**: `grammarScope` was `type != 'voice'` → would wipe collocation flags; fixed to `type NOT IN ('voice','collocation')`. Frontend: `--color-blossom` pink, "Make it sound natural 🌸" toolbar pill, `collocating`/`runCollocation` in `useCheckpoint`, StatusBar dot — all into the existing rail/card. **Phase 13**: new `internal/vocab` package — migration `0006_vocab_garden` (`vocab_words`, SM-2-lite columns, doc_id `ON DELETE SET NULL`, `UNIQUE(user_id,word)`), `scheduler.go` (Leitner ladder 1/3/7/16/35 → geometric; gentle "again", no streak-shaming), `handlers.go` (capture-upsert/list/due/review/delete, all owner-scoped, time math via SQLite `datetime()` so stored values stay canonical-UTC). Auto-capture wired into `EditorCore.openWordLookup` (only dictionary-known words, captures the surrounding sentence + doc_id) + a 🤍/💚 toggle on `WordCard`. `GardenPanel` slide-over: blossom grid (bloom stage by reps), flashcard review (sentence blanked, flip, again/good/easy, direction alternates recognition↔production), sleepy-kitten footer; opened from a global 🌷 header button. Tests: `TestCollocationPassCoexists`, vocab `scheduler_test.go` + `handlers_test.go`, db CHECK test extended. go build/vet/test + tsc + vite + vitest (51/51) all clean; migration verified against a copy of the live DB; live backend smoke (throwaway DB) walked the full vocab lifecycle + the warm-502 collocation path. **Remaining: only the deferred infra bucket** — Authentik auth, Copyleaks Tier-2 (needs a public webhook), Docker/Traefik/deploy — all on hold per the user's "except Authentik/Traefik".
|
||||||
|
- 2026-06-26: **Phase 14 complete** (companion warmth + bedtime nag + night mode). `tips.ts`: `ENCOURAGEMENTS` 5→10 lines; new `BEDTIME` array (4 lines, user-supplied English wit + gentle Mandarin leads). `useCompanion.ts`: bedtime branch in the 10s heartbeat (after idle-return + break, before the generic tip); only nudges while actively writing; own `lastBedtime` ref + 30min `BEDTIME_GAP`, respects `PROACTIVE_GAP`; new `'bedtime'` `BubbleTone` lingers ~4s longer. **Night mode** (added same session, user request): `lib/night.ts` centralizes `isBedtime()` + window (now shared by the nag too); `hooks/useNightMode.ts` toggles `petal-night` on `<html>` (60s re-check); `index.css` `html.petal-night` re-points only the palette tokens → whole UI flips via `var()` (no component edits), 600ms dusk fade, print stays white; `PetalFall` gains a `night` prop → chunky cartoon power stars (`makeCartoonStar`, Mario/Kirby-style, 5 candy colors) mixed ~70/30 with small twinkle sparkles, gentle spin + shallow shimmer, effect re-inits on flip; App: `useNightMode()` → `<PetalFall night={night}/>`. tsc + vite clean, companion vitest 45/45; verified with real-browser Playwright screenshots (clock mocked to 23:30) — day petals/cream vs night stars/dark-plum, both pretty. Bedtime window is `BEDTIME_FROM`/`BEDTIME_TO` (local clock) for easy retune.
|
||||||
|
- 2026-06-26: **Phase 11 complete** (writer power-ups, batch requested as "do it all"). Seven features: (1) in-doc **Find & Replace** — `SearchHighlight` decoration extension + `FindReplace` bar (Ctrl/Cmd+F, match-case, replace-all back-to-front, DOM scroll that doesn't trigger the selection bubble); (2) **read-aloud** Web Speech util + 🔊 in WordCard & selection bubble; (3) **keyboard/touch access** — Ctrl/Cmd+D caret lookup, Ctrl/Cmd+J rewrite, touch long-press (refactored `handleContextMenu` → shared `openWordLookup(pos)`); (4) **export-all** backup zip (`GET /api/docs/export-all`, `TestExportAll`, sidebar download links); (5) **smart typography** input-rules extension (curly quotes/em-dash/ellipsis, ASCII-only so CJK untouched); (6) **duplicate doc + sidebar sort + outline popover**; (7) **English phonetic** (pivoted from pinyin — IPA is what an English learner needs; pinyin annotates Chinese she already reads) via `scripts/build_phonetic.py` + embedded `phonetic.json.gz` + `Result.Phonetic` + WordCard `/ˈrɪvər/` line — **full 46,579-word dataset built from ECDICT** (the csv re-download worked; `--seed` mode kept as a csv-free fallback). Also folded in this session: the **selection-bubble vs copy/paste fix** (bubble deferred to pointer-up + container `pointer-events:none` so it never sits where you click). go build/vet/test + tsc + vite all clean; live smoke verified word-phonetic (incl. de-inflection) + export-all zip (de-duped CJK names, route priority). Next: deferred bucket (auth/Copyleaks/deploy), still on hold per user.
|
||||||
|
- 2026-06-26: **Phase 10 complete** (organization & polish). Scope confirmed with user: all four areas, **tags** (not folders), **FTS5** search. Backend: migration `0004_tags_and_search` (tags + document_tags + `documents_fts` trigram virtual table with sync triggers + back-fill); `db.Tag` model + color constants; `internal/docs/tags.go` (tag CRUD + idempotent assignment + `tagsByDoc` helper, doc list now carries tags); `internal/docs/search.go` (`GET /api/search`, FTS for ≥3 runes + LIKE fallback for 1-2, Go-built sentinel-highlighted rune-aware snippets, owner-scoped). Mounted `/api/tags` + `/api/search` in main.go. Frontend: `useTags`, `TagChip`/`TagPicker`/`SearchBox`, rewritten `DocList`/`DocListItem` (chips + filter bar + search), `api.search`/tag methods + `splitSnippet`/`tagColorVar`; responsive sidebar drawer (hamburger + scrim, <768px) + `pointer:coarse` tap-target/affordance CSS; tap-to-open + outside-pointerdown-close for suggestion cards (touch); `useCheckpoint` `llmDown` flag → warm bilingual "小助手在休息" StatusBar note. Tests: `tags_test.go`, `search_test.go` (incl. update-trigger re-index). All builds/tests/vet/tsc/vite clean; live smoke vs binary on :8061 (dead LLM host) verified search EN/CJK/2-char, full tag lifecycle, check→502 warm path, bundle contents; FTS backfill of pre-existing docs verified. **All v1 phases (0–7) + post-v1 product (8–10) done.** Remaining: deferred bucket (auth/Copyleaks/deploy), on hold per user.
|
||||||
|
- 2026-06-26: **Phase 9 complete** (ESL superpowers: inline Chinese gloss + tone-rewrite). Decisions confirmed with user: gloss is an **offline EC dictionary** (instant, LLM-down-proof, fits the embedded-lexicon ethos), rewrite is a **selection bubble**. Data: `scripts/build_gloss.py` builds `internal/lexicon/data/gloss.json.gz` from ECDICT (66MB csv → 1.3MB gz, 57k freq-≤50k words, cleaned/trimmed). Backend: `lexicon` gloss map + `Gloss()`/`Result.Gloss` + `GET /api/gloss/{word}`; `llm.RunRewrite` + rewrite prompt/`styleGuidance`; `internal/suggestions/rewrite.go` (`POST /api/docs/:id/rewrite`, stateless, owner-scoped). Frontend: `GlossTip` hover tooltip (350ms delay, reuses `wordAt`, CJK-safe) + gloss line in `WordCard`; `SelectionBubble` + `RewritePreview` wired through `EditorCore` (onMouseMove/onSelectionUpdate, request-token guards, clears on edit/doc-switch); `api.glossWord`/`api.rewriteSelection`; CSS for the three new surfaces (+ print-hidden). Tests added in `lexicon` and `suggestions`. All builds/tests/vet/tsc/vite clean; live smoke vs fake vLLM on :8055 verified gloss + rewrite + 400/404/502 paths. Next: **Phase 10 (organization & polish).**
|
||||||
|
- 2026-06-26: **Phase 8 complete** (Trust foundation: version history + export) + empty-doc fix. Backend: migration `0003_document_versions`; `internal/docs/versions.go` (throttled auto-snapshot wired into `update`, manual snapshot, restore-with-pre_restore, prune to 40 auto, owner-scoped via join) and `internal/docs/export.go` (pure-Go Tiptap-JSON → md/html/txt/docx, no deps, CJK-safe filenames via RFC 5987). `DocumentVersion` model + kind constants. Tests: `versions_test.go`, `export_test.go` (incl. valid-zip docx assertion). Frontend: `api.client` version/export methods; `ExportMenu` + `HistoryPanel` components wired into the title row; `@media print` stylesheet + `.petal-no-print` for the browser PDF path; `editorEpoch` remount on restore. Empty-doc fix in `App.tsx` (blank drafts reuse-on-create + discard-on-leave via refs to dodge stale closures); deleted 2 orphan empties from the live :8099 DB. Multi-session plan agreed: this session = Phase 8; **Phase 9 (ESL gloss + tone-rewrite)** next, then **Phase 10 (search/folders/polish)**; **auth/deploy (was Phase 11) shelved** until user's foundational work lands. All builds/tests/vet clean; live smoke verified the full version+export+restore flow end-to-end. Next: **Phase 9.**
|
||||||
- 2026-06-25: Spec reviewed & amended (voice/grammar decoupled, ctx cap, routes, voice DB type, honey color, string-anchoring). Build plan created.
|
- 2026-06-25: Spec reviewed & amended (voice/grammar decoupled, ctx cap, routes, voice DB type, honey color, string-anchoring). Build plan created.
|
||||||
- 2026-06-25: **Phase 0 complete.** Go module + chi server, config loader, React/Vite/Tailwind-v4 scaffold with full design tokens, frontend embedded & served by the binary, verified end-to-end. Toolchain: Go 1.24.4, Node 22, npm 10. Next: **Phase 1 (data layer)** — SQLite via modernc, models, seed `local` user.
|
- 2026-06-25: **Phase 0 complete.** Go module + chi server, config loader, React/Vite/Tailwind-v4 scaffold with full design tokens, frontend embedded & served by the binary, verified end-to-end. Toolchain: Go 1.24.4, Node 22, npm 10. Next: **Phase 1 (data layer)** — SQLite via modernc, models, seed `local` user.
|
||||||
- 2026-06-25: **Phase 1 complete.** `internal/db` package: modernc.org/sqlite (pulled go toolchain → 1.25), `Open()` does mkdir + WAL/foreign-keys DSN + versioned migration runner + idempotent local-user seed. Models with type/status constants. Wired into `main.go`; tests pass (migrate/seed idempotency, CHECK reject, FK cascade). Verified server boots and writes `petal.db`. Next: **Phase 2 (document CRUD + auto-save)** — first "it works" milestone.
|
- 2026-06-25: **Phase 1 complete.** `internal/db` package: modernc.org/sqlite (pulled go toolchain → 1.25), `Open()` does mkdir + WAL/foreign-keys DSN + versioned migration runner + idempotent local-user seed. Models with type/status constants. Wired into `main.go`; tests pass (migrate/seed idempotency, CHECK reject, FK cascade). Verified server boots and writes `petal.db`. Next: **Phase 2 (document CRUD + auto-save)** — first "it works" milestone.
|
||||||
|
|||||||
@@ -15,9 +15,12 @@ import (
|
|||||||
"gitea.parodia.dev/drwily/petal/internal/config"
|
"gitea.parodia.dev/drwily/petal/internal/config"
|
||||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
"gitea.parodia.dev/drwily/petal/internal/docs"
|
"gitea.parodia.dev/drwily/petal/internal/docs"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/images"
|
||||||
"gitea.parodia.dev/drwily/petal/internal/lexicon"
|
"gitea.parodia.dev/drwily/petal/internal/lexicon"
|
||||||
"gitea.parodia.dev/drwily/petal/internal/llm"
|
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||||
"gitea.parodia.dev/drwily/petal/internal/suggestions"
|
"gitea.parodia.dev/drwily/petal/internal/suggestions"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/tts"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/vocab"
|
||||||
"gitea.parodia.dev/drwily/petal/web"
|
"gitea.parodia.dev/drwily/petal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,6 +48,11 @@ func main() {
|
|||||||
log.Printf("frontend build version %s", version)
|
log.Printf("frontend build version %s", version)
|
||||||
|
|
||||||
r.Route("/api", func(api chi.Router) {
|
r.Route("/api", func(api chi.Router) {
|
||||||
|
// Cap request bodies so a runaway or hostile client can't stream an
|
||||||
|
// unbounded payload into a JSON decoder. Image uploads carry their own
|
||||||
|
// (larger) limit inside the images handler, so they're exempt here.
|
||||||
|
api.Use(limitBody(maxAPIBodyBytes, "/api/images"))
|
||||||
|
|
||||||
api.Get("/health", func(w http.ResponseWriter, _ *http.Request) {
|
api.Get("/health", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||||
@@ -62,15 +70,43 @@ func main() {
|
|||||||
|
|
||||||
// Document CRUD plus the doc-scoped checkpoint/list suggestion routes,
|
// Document CRUD plus the doc-scoped checkpoint/list suggestion routes,
|
||||||
// both under /api/docs.
|
// both under /api/docs.
|
||||||
docsRouter := docs.New(database).Routes()
|
docsHandler := docs.New(database)
|
||||||
|
docsRouter := docsHandler.Routes()
|
||||||
sug.RegisterDocRoutes(docsRouter)
|
sug.RegisterDocRoutes(docsRouter)
|
||||||
api.Mount("/docs", docsRouter)
|
api.Mount("/docs", docsRouter)
|
||||||
|
|
||||||
|
// Tag management (the roster) and cross-document full-text search.
|
||||||
|
api.Mount("/tags", docsHandler.TagRoutes())
|
||||||
|
api.Mount("/search", docsHandler.SearchRoutes())
|
||||||
|
|
||||||
// Per-suggestion actions (accept/dismiss) under /api/suggestions.
|
// Per-suggestion actions (accept/dismiss) under /api/suggestions.
|
||||||
api.Mount("/suggestions", sug.Routes())
|
api.Mount("/suggestions", sug.Routes())
|
||||||
|
|
||||||
// Offline word lookups (definition + synonyms) for the right-click popover.
|
// Offline lexicon: full word lookups (gloss + definition + synonyms) for
|
||||||
api.Mount("/word", lexicon.NewHandler().Routes())
|
// 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())
|
||||||
|
|
||||||
|
// Vocabulary garden: words the writer looks up are captured here and
|
||||||
|
// surfaced for gentle spaced-repetition review.
|
||||||
|
api.Mount("/vocab", vocab.New(database).Routes())
|
||||||
|
|
||||||
|
// Editor image uploads, stored on disk and served back by content hash.
|
||||||
|
imgHandler, err := images.New(cfg.ImageDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("image store: %v", err)
|
||||||
|
}
|
||||||
|
api.Mount("/images", imgHandler.Routes())
|
||||||
|
|
||||||
|
// Read-aloud: proxy short passages to a local Piper TTS server. Only
|
||||||
|
// mounted when TTS_ENDPOINT is configured; otherwise the frontend falls
|
||||||
|
// back to the browser's Web Speech API on its own.
|
||||||
|
if ttsHandler, ok := tts.New(cfg); ok {
|
||||||
|
api.Mount("/tts", ttsHandler.Routes())
|
||||||
|
log.Printf("read-aloud enabled (TTS endpoint=%s)", cfg.TTSEndpoint)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Everything else: serve the embedded SPA (with index.html fallback for client routing).
|
// Everything else: serve the embedded SPA (with index.html fallback for client routing).
|
||||||
@@ -83,6 +119,32 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxAPIBodyBytes caps a JSON API request body at 2 MiB. That's far above any
|
||||||
|
// real document save (the body is text plus lightweight marks; images upload
|
||||||
|
// separately by reference) while still bounding abuse. Exceeding it makes the
|
||||||
|
// handler's json.Decode fail, which surfaces as a 400.
|
||||||
|
const maxAPIBodyBytes = 2 << 20
|
||||||
|
|
||||||
|
// limitBody wraps each request body in an http.MaxBytesReader so handlers can't
|
||||||
|
// be made to read an unbounded payload. Paths under any of exemptPrefixes are
|
||||||
|
// left alone (e.g. image uploads, which set their own, larger limit).
|
||||||
|
func limitBody(max int64, exemptPrefixes ...string) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
for _, p := range exemptPrefixes {
|
||||||
|
if strings.HasPrefix(r.URL.Path, p) {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.Body != nil {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, max)
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// buildVersion derives a short, stable identifier for the currently embedded
|
// buildVersion derives a short, stable identifier for the currently embedded
|
||||||
// frontend by hashing dist/index.html. Vite stamps content-hashed asset names
|
// frontend by hashing dist/index.html. Vite stamps content-hashed asset names
|
||||||
// into that file each build, so the digest is a reliable "did the deploy
|
// into that file each build, so the digest is a reliable "did the deploy
|
||||||
|
|||||||
65
deploy/README.md
Normal file
65
deploy/README.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# Deploying read-aloud (Piper TTS) to millenia
|
||||||
|
|
||||||
|
Petal's read-aloud generates audio with a local **Piper** neural-TTS server and
|
||||||
|
transcodes it to mp3 with **ffmpeg**. Both run on millenia (192.168.1.212);
|
||||||
|
nothing leaves the box. If Piper is down or `TTS_ENDPOINT` is unset, the frontend
|
||||||
|
falls back to the browser's Web Speech API automatically.
|
||||||
|
|
||||||
|
## 1. Piper as a systemd service (one-time)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# from this repo, on your workstation:
|
||||||
|
scp deploy/piper.service deploy/setup-piper.sh 192.168.1.212:/tmp/
|
||||||
|
ssh 192.168.1.212 'cd /tmp && sudo ./setup-piper.sh'
|
||||||
|
```
|
||||||
|
|
||||||
|
`setup-piper.sh` creates `~/piper/venv`, installs `piper-tts[http]`, downloads the
|
||||||
|
`en_US-amy-medium` voice into `~/piper/voices`, installs+enables `piper.service`
|
||||||
|
(loopback :5005), and smoke-tests it. Idempotent.
|
||||||
|
|
||||||
|
Check it any time: `systemctl status piper`, `journalctl -u piper -f`.
|
||||||
|
|
||||||
|
## 2. Point petal at Piper + redeploy the binary
|
||||||
|
|
||||||
|
Add to petal's environment (its `.env` or launch env):
|
||||||
|
|
||||||
|
```
|
||||||
|
TTS_ENDPOINT=http://127.0.0.1:5005
|
||||||
|
TTS_VOICE_EN=en_US-amy-medium
|
||||||
|
TTS_AUDIO_FORMAT=mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
Then ship the rebuilt binary (`go build -o petal ./cmd/server` already done) and
|
||||||
|
restart the petal `:8088` session. Confirm the log line:
|
||||||
|
`read-aloud enabled (TTS endpoint=http://127.0.0.1:5005)`.
|
||||||
|
|
||||||
|
## 3. Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# on millenia — end-to-end through petal, including ffmpeg transcode:
|
||||||
|
curl -sf -X POST localhost:8088/api/tts \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"text":"hello there","lang":"en-US"}' -o /tmp/petal-tts.mp3 \
|
||||||
|
&& file /tmp/petal-tts.mp3 # expect: Audio file ... MPEG ... layer III
|
||||||
|
```
|
||||||
|
|
||||||
|
- Second identical call is served from the cache (`~/petal/.../data/tts/*.mp3`).
|
||||||
|
- A language with no configured instance returns 404 → client uses Web Speech.
|
||||||
|
- Browser check via the uitest harness (`~/petal/uitest`): tap a word in the
|
||||||
|
WordCard / select a sentence and hit speak — expect the natural Piper voice.
|
||||||
|
|
||||||
|
## Chinese voice (live)
|
||||||
|
|
||||||
|
Each Piper HTTP server loads ONE model, so Chinese runs as a **second instance**:
|
||||||
|
`piper-zh.service` on :5006 with `zh_CN-huayan-medium`. Deployed via:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp deploy/piper-zh.service 192.168.1.212:~/.config/systemd/user/
|
||||||
|
ssh 192.168.1.212 'export XDG_RUNTIME_DIR=/run/user/$(id -u)
|
||||||
|
~/piper/venv/bin/python -m piper.download_voices zh_CN-huayan-medium --data-dir ~/piper/voices
|
||||||
|
systemctl --user daemon-reload && systemctl --user enable --now piper-zh.service'
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in petal's `start.sh`: `TTS_ENDPOINT_ZH=http://127.0.0.1:5006` and
|
||||||
|
`TTS_VOICE_ZH=zh_CN-huayan-medium`. The handler maps language → instance from config,
|
||||||
|
so adding more languages is just another instance + env pair (no code change).
|
||||||
17
deploy/piper-zh.service
Normal file
17
deploy/piper-zh.service
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Piper TTS HTTP server — Chinese voice (read-aloud backend for petal)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
# Bound to loopback only — petal proxies to it; never exposed off-box.
|
||||||
|
ExecStart=%h/piper/venv/bin/python -m piper.http_server \
|
||||||
|
-m zh_CN-huayan-medium \
|
||||||
|
--data-dir %h/piper/voices \
|
||||||
|
--host 127.0.0.1 --port 5006
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
17
deploy/piper.service
Normal file
17
deploy/piper.service
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Piper TTS HTTP server (read-aloud backend for petal)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
# Bound to loopback only — petal proxies to it; never exposed off-box.
|
||||||
|
ExecStart=%h/piper/venv/bin/python -m piper.http_server \
|
||||||
|
-m en_US-amy-medium \
|
||||||
|
--data-dir %h/piper/voices \
|
||||||
|
--host 127.0.0.1 --port 5005
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
43
deploy/setup-piper.sh
Executable file
43
deploy/setup-piper.sh
Executable file
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# One-time Piper TTS setup on millenia as a *user* systemd service (no sudo).
|
||||||
|
# Idempotent — safe to re-run. Run on the box:
|
||||||
|
# bash setup-piper.sh
|
||||||
|
# To survive logout/reboot, also (once, needs root):
|
||||||
|
# sudo loginctl enable-linger "$USER"
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PIPER_HOME="$HOME/piper"
|
||||||
|
VENV="$PIPER_HOME/venv"
|
||||||
|
VOICES="$PIPER_HOME/voices"
|
||||||
|
VOICE=en_US-amy-medium
|
||||||
|
UNIT_DIR="$HOME/.config/systemd/user"
|
||||||
|
export XDG_RUNTIME_DIR="/run/user/$(id -u)"
|
||||||
|
|
||||||
|
echo ">> ffmpeg check (petal transcodes Piper WAV -> mp3 with it):"
|
||||||
|
command -v ffmpeg >/dev/null || { echo "ffmpeg not found on PATH"; exit 1; }
|
||||||
|
|
||||||
|
echo ">> venv + piper-tts[http]"
|
||||||
|
[ -d "$VENV" ] || python3 -m venv "$VENV"
|
||||||
|
"$VENV/bin/pip" install --upgrade pip >/dev/null
|
||||||
|
"$VENV/bin/pip" install "piper-tts[http]"
|
||||||
|
|
||||||
|
echo ">> download voice $VOICE"
|
||||||
|
mkdir -p "$VOICES"
|
||||||
|
"$VENV/bin/python" -m piper.download_voices "$VOICE" --data-dir "$VOICES"
|
||||||
|
|
||||||
|
echo ">> install user service"
|
||||||
|
mkdir -p "$UNIT_DIR"
|
||||||
|
install -m 0644 "$(dirname "$0")/piper.service" "$UNIT_DIR/piper.service"
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable --now piper.service
|
||||||
|
|
||||||
|
echo ">> wait + status"
|
||||||
|
sleep 2
|
||||||
|
systemctl --user --no-pager --full status piper.service | head -n 6 || true
|
||||||
|
|
||||||
|
echo ">> smoke test"
|
||||||
|
curl -sf -X POST localhost:5005/ \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"text\":\"hello there\",\"voice\":\"$VOICE\"}" -o /tmp/piper-test.wav \
|
||||||
|
&& echo "OK: /tmp/piper-test.wav ($(stat -c%s /tmp/piper-test.wav) bytes)" \
|
||||||
|
|| { echo "smoke test FAILED"; journalctl --user -u piper.service --no-pager | tail -n 20; exit 1; }
|
||||||
@@ -11,6 +11,7 @@ type Config struct {
|
|||||||
Port string
|
Port string
|
||||||
BaseURL string
|
BaseURL string
|
||||||
DatabasePath string
|
DatabasePath string
|
||||||
|
ImageDir string // on-disk store for editor image uploads
|
||||||
|
|
||||||
// LLM
|
// LLM
|
||||||
LLMBackend string // "vllm" | "ollama"
|
LLMBackend string // "vllm" | "ollama"
|
||||||
@@ -19,6 +20,17 @@ type Config struct {
|
|||||||
LLMChatModel string // Ask Petal model; falls back to LLMModel if empty
|
LLMChatModel string // Ask Petal model; falls back to LLMModel if empty
|
||||||
LLMTimeout time.Duration
|
LLMTimeout time.Duration
|
||||||
|
|
||||||
|
// TTS (read-aloud). Off unless TTSEndpoint is set — when empty, the /api/tts
|
||||||
|
// route isn't mounted and the frontend falls back to the browser's Web Speech
|
||||||
|
// API. Endpoint points at a local Piper HTTP server.
|
||||||
|
TTSEndpoint string // Piper instance serving the English voice
|
||||||
|
TTSEndpointZH string // Piper instance serving the Chinese voice; empty = zh falls back to Web Speech
|
||||||
|
TTSVoiceEN string // Piper voice id for English (e.g. en_US-amy-medium)
|
||||||
|
TTSVoiceZH string // Piper voice id for Chinese (e.g. zh_CN-huayan-medium)
|
||||||
|
TTSCacheDir string // on-disk store for synthesized clips (content-addressed)
|
||||||
|
TTSTimeout time.Duration
|
||||||
|
TTSFormat string // mp3 | opus | wav — mp3/opus transcode Piper's WAV via ffmpeg
|
||||||
|
|
||||||
// Auth (deferred — not wired in the local-dev build, kept for later)
|
// Auth (deferred — not wired in the local-dev build, kept for later)
|
||||||
AuthentikURL string
|
AuthentikURL string
|
||||||
AuthentikClientID string
|
AuthentikClientID string
|
||||||
@@ -32,6 +44,7 @@ func Load() *Config {
|
|||||||
Port: env("PORT", "8080"),
|
Port: env("PORT", "8080"),
|
||||||
BaseURL: env("BASE_URL", "http://localhost:8080"),
|
BaseURL: env("BASE_URL", "http://localhost:8080"),
|
||||||
DatabasePath: env("DATABASE_PATH", "./data/petal.db"),
|
DatabasePath: env("DATABASE_PATH", "./data/petal.db"),
|
||||||
|
ImageDir: env("IMAGE_DIR", "./data/images"),
|
||||||
|
|
||||||
LLMBackend: env("LLM_BACKEND", "vllm"),
|
LLMBackend: env("LLM_BACKEND", "vllm"),
|
||||||
LLMEndpoint: env("LLM_ENDPOINT", "http://localhost:8000"),
|
LLMEndpoint: env("LLM_ENDPOINT", "http://localhost:8000"),
|
||||||
@@ -39,6 +52,14 @@ func Load() *Config {
|
|||||||
LLMChatModel: env("LLM_CHAT_MODEL", ""),
|
LLMChatModel: env("LLM_CHAT_MODEL", ""),
|
||||||
LLMTimeout: envDuration("LLM_TIMEOUT", 30*time.Second),
|
LLMTimeout: envDuration("LLM_TIMEOUT", 30*time.Second),
|
||||||
|
|
||||||
|
TTSEndpoint: env("TTS_ENDPOINT", ""),
|
||||||
|
TTSEndpointZH: env("TTS_ENDPOINT_ZH", ""),
|
||||||
|
TTSVoiceEN: env("TTS_VOICE_EN", "en_US-amy-medium"),
|
||||||
|
TTSVoiceZH: env("TTS_VOICE_ZH", "zh_CN-huayan-medium"),
|
||||||
|
TTSCacheDir: env("TTS_CACHE_DIR", "./data/tts"),
|
||||||
|
TTSTimeout: envDuration("TTS_TIMEOUT", 15*time.Second),
|
||||||
|
TTSFormat: env("TTS_AUDIO_FORMAT", "mp3"),
|
||||||
|
|
||||||
AuthentikURL: env("AUTHENTIK_URL", ""),
|
AuthentikURL: env("AUTHENTIK_URL", ""),
|
||||||
AuthentikClientID: env("AUTHENTIK_CLIENT_ID", ""),
|
AuthentikClientID: env("AUTHENTIK_CLIENT_ID", ""),
|
||||||
AuthentikClientSecret: env("AUTHENTIK_CLIENT_SECRET", ""),
|
AuthentikClientSecret: env("AUTHENTIK_CLIENT_SECRET", ""),
|
||||||
|
|||||||
@@ -176,6 +176,197 @@ CREATE INDEX idx_plagiarism_doc_id ON plagiarism_reports(doc_id);
|
|||||||
name: "0002_document_tone",
|
name: "0002_document_tone",
|
||||||
stmt: `ALTER TABLE documents ADD COLUMN tone TEXT NOT NULL DEFAULT 'general';`,
|
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;
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Collocation coach (Phase 12). Adds a third suggestion family,
|
||||||
|
// 'collocation', for gentle "natives usually say…" hints on
|
||||||
|
// non-native word pairings. The `type` column carries a CHECK
|
||||||
|
// constraint and SQLite cannot ALTER one in place, so we rebuild the
|
||||||
|
// suggestions table with the extended CHECK, copy every row across,
|
||||||
|
// and recreate its index. Nothing references suggestions, so dropping
|
||||||
|
// the old table is safe; the new table keeps the same ON DELETE
|
||||||
|
// CASCADE to documents.
|
||||||
|
name: "0005_collocation_suggestion_type",
|
||||||
|
stmt: `
|
||||||
|
CREATE TABLE suggestions_new (
|
||||||
|
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','collocation')),
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','accepted','rejected')),
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO suggestions_new (id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at)
|
||||||
|
SELECT id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at FROM suggestions;
|
||||||
|
|
||||||
|
DROP TABLE suggestions;
|
||||||
|
ALTER TABLE suggestions_new RENAME TO suggestions;
|
||||||
|
CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Vocabulary garden (Phase 13). Every word the writer looks up is
|
||||||
|
// captured here and put on a gentle spaced-repetition schedule, turning
|
||||||
|
// passive lookups into real vocabulary. `example` holds the sentence the
|
||||||
|
// word appeared in (captured at lookup) for context during review;
|
||||||
|
// `doc_id` links back to where she met the word (nulled if that doc is
|
||||||
|
// deleted — the word stays in the garden). The SM-2-lite scheduling
|
||||||
|
// columns (due_at/interval_days/ease/reps/lapses/last_reviewed) drive a
|
||||||
|
// Leitner-style ladder (see internal/vocab/scheduler.go). UNIQUE on
|
||||||
|
// (user_id, word) makes capture an idempotent upsert.
|
||||||
|
name: "0006_vocab_garden",
|
||||||
|
stmt: `
|
||||||
|
CREATE TABLE vocab_words (
|
||||||
|
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
word TEXT NOT NULL,
|
||||||
|
gloss TEXT NOT NULL DEFAULT '',
|
||||||
|
phonetic TEXT NOT NULL DEFAULT '',
|
||||||
|
example TEXT NOT NULL DEFAULT '',
|
||||||
|
doc_id TEXT REFERENCES documents(id) ON DELETE SET NULL,
|
||||||
|
due_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
interval_days INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ease REAL NOT NULL DEFAULT 2.5,
|
||||||
|
reps INTEGER NOT NULL DEFAULT 0,
|
||||||
|
lapses INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_reviewed DATETIME,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(user_id, word)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_vocab_due ON vocab_words(user_id, due_at);
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Vocabulary garden, follow-up. Some looked-up words have an English
|
||||||
|
// definition but no Chinese gloss; those produced an unanswerable review
|
||||||
|
// card (review reveals only the gloss). `definition` stores a short
|
||||||
|
// English sense captured at lookup as a fallback "meaning" so such words
|
||||||
|
// are still reviewable.
|
||||||
|
name: "0007_vocab_definition",
|
||||||
|
stmt: `
|
||||||
|
ALTER TABLE vocab_words ADD COLUMN definition TEXT NOT NULL DEFAULT '';
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Deterministic mechanics pass. Adds a 'mechanics' suggestion family for
|
||||||
|
// rule-based fixes (doubled words, spacing/punctuation, lowercase "i",
|
||||||
|
// curated confusables) detected in pure Go — no LLM. As with 0005, the
|
||||||
|
// `type` CHECK can't be ALTERed in place, so rebuild the table with the
|
||||||
|
// extended constraint, copy every row across, and recreate the index.
|
||||||
|
name: "0008_mechanics_suggestion_type",
|
||||||
|
stmt: `
|
||||||
|
CREATE TABLE suggestions_new (
|
||||||
|
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','collocation','mechanics')),
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','accepted','rejected')),
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO suggestions_new (id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at)
|
||||||
|
SELECT id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at FROM suggestions;
|
||||||
|
|
||||||
|
DROP TABLE suggestions;
|
||||||
|
ALTER TABLE suggestions_new RENAME TO suggestions;
|
||||||
|
CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);
|
||||||
|
`,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ func TestOpenMigratesAndSeeds(t *testing.T) {
|
|||||||
t.Fatalf("insert voice suggestion: %v", err)
|
t.Fatalf("insert voice suggestion: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The collocation type (added by migration 0005's table rebuild) is permitted.
|
||||||
|
if _, err := d.Exec(
|
||||||
|
`INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type)
|
||||||
|
VALUES ('d1', 0, 9, 'do a decision', 'make a decision', 'natives usually say…', 'collocation')`,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("insert collocation suggestion: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// An invalid type is rejected.
|
// An invalid type is rejected.
|
||||||
if _, err := d.Exec(
|
if _, err := d.Exec(
|
||||||
`INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type)
|
`INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type)
|
||||||
|
|||||||
@@ -27,6 +27,53 @@ type Document struct {
|
|||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DocumentVersion is a point-in-time snapshot of a document's body, captured so
|
||||||
|
// a writer can recover from a bad edit or an unwanted change. `Content` mirrors
|
||||||
|
// the document's Tiptap JSON at snapshot time; `Kind` records why it was taken
|
||||||
|
// (see the kind constants). List responses omit the heavy Content/ContentText
|
||||||
|
// fields (the `omitempty`-friendly zero strings) and load them only on preview
|
||||||
|
// or restore.
|
||||||
|
type DocumentVersion struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DocID string `json:"doc_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content,omitempty"` // Tiptap JSON; omitted in list view
|
||||||
|
ContentText string `json:"content_text,omitempty"` // plain text; omitted in list view
|
||||||
|
WordCount int `json:"word_count"`
|
||||||
|
Kind string `json:"kind"` // auto | manual | pre_restore
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Document version kinds, mirrored from the schema CHECK constraint.
|
||||||
|
const (
|
||||||
|
VersionKindAuto = "auto" // throttled background snapshot on save
|
||||||
|
VersionKindManual = "manual" // explicit "save a restore point"
|
||||||
|
VersionKindPreRestore = "pre_restore" // safety copy taken just before a restore
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tag is a user-scoped label for organizing documents. `Color` is a palette key
|
||||||
|
// (rose, mint, peach, lavender, sky, honey) the frontend maps to a CSS color;
|
||||||
|
// storing the key (not a hex value) keeps tags in step with the design tokens.
|
||||||
|
// `DocCount` is populated only by the tag-list endpoint (how many documents wear
|
||||||
|
// the tag); it's omitted from per-document tag lists.
|
||||||
|
type Tag struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
DocCount int `json:"doc_count,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag color palette keys, mirrored on the frontend. Kept small and aligned with
|
||||||
|
// the existing design tokens; unknown values fall back to rose client-side.
|
||||||
|
const (
|
||||||
|
TagColorRose = "rose"
|
||||||
|
TagColorMint = "mint"
|
||||||
|
TagColorPeach = "peach"
|
||||||
|
TagColorLavender = "lavender"
|
||||||
|
TagColorSky = "sky"
|
||||||
|
TagColorHoney = "honey"
|
||||||
|
)
|
||||||
|
|
||||||
// Suggestion is a single LLM-proposed edit anchored to a span of the document.
|
// Suggestion is a single LLM-proposed edit anchored to a span of the document.
|
||||||
//
|
//
|
||||||
// FromPos/ToPos are plaintext offsets into ContentText for server-side use only;
|
// FromPos/ToPos are plaintext offsets into ContentText for server-side use only;
|
||||||
@@ -41,7 +88,7 @@ type Suggestion struct {
|
|||||||
Original string `json:"original"`
|
Original string `json:"original"`
|
||||||
Replacement string `json:"replacement"`
|
Replacement string `json:"replacement"`
|
||||||
Explanation string `json:"explanation"`
|
Explanation string `json:"explanation"`
|
||||||
Type string `json:"type"` // grammar | phrasing | idiom | clarity | voice
|
Type string `json:"type"` // grammar | phrasing | idiom | clarity | voice | collocation
|
||||||
Status string `json:"status"` // pending | accepted | rejected
|
Status string `json:"status"` // pending | accepted | rejected
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
@@ -53,6 +100,8 @@ const (
|
|||||||
SuggestionTypeIdiom = "idiom"
|
SuggestionTypeIdiom = "idiom"
|
||||||
SuggestionTypeClarity = "clarity"
|
SuggestionTypeClarity = "clarity"
|
||||||
SuggestionTypeVoice = "voice"
|
SuggestionTypeVoice = "voice"
|
||||||
|
SuggestionTypeCollocation = "collocation"
|
||||||
|
SuggestionTypeMechanics = "mechanics" // deterministic rule-based pass (no LLM)
|
||||||
|
|
||||||
SuggestionStatusPending = "pending"
|
SuggestionStatusPending = "pending"
|
||||||
SuggestionStatusAccepted = "accepted"
|
SuggestionStatusAccepted = "accepted"
|
||||||
|
|||||||
901
internal/docs/export.go
Normal file
901
internal/docs/export.go
Normal file
@@ -0,0 +1,901 @@
|
|||||||
|
package docs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// exportRoutes registers the download endpoints: one document
|
||||||
|
// (GET /api/docs/{id}/export?format=…) and a whole-corpus backup zip
|
||||||
|
// (GET /api/docs/export-all?format=…). The static "export-all" segment takes
|
||||||
|
// priority over the {id} param in chi's router, so the two don't collide.
|
||||||
|
func (h *Handler) exportRoutes(r chi.Router) {
|
||||||
|
r.Get("/{id}/export", h.export)
|
||||||
|
r.Get("/export-all", h.exportAll)
|
||||||
|
}
|
||||||
|
|
||||||
|
// exportAll streams every document the user owns, each rendered in the requested
|
||||||
|
// format, bundled into a single zip — a one-click "download all my writing"
|
||||||
|
// backup. Per-doc version history guards against bad edits; this guards against
|
||||||
|
// a lost disk. Reuses the same renderers as the single-document export.
|
||||||
|
func (h *Handler) exportAll(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
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.DB.Query(
|
||||||
|
`SELECT id, user_id, title, content, content_text, tone, word_count, created_at, updated_at
|
||||||
|
FROM documents
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY updated_at DESC`,
|
||||||
|
db.LocalUserID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
zw := zip.NewWriter(&buf)
|
||||||
|
seen := map[string]int{} // de-duplicate filenames from same-titled docs
|
||||||
|
for rows.Next() {
|
||||||
|
var doc db.Document
|
||||||
|
if err := rows.Scan(
|
||||||
|
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
|
||||||
|
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := format.render(doc)
|
||||||
|
if err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base := sanitizeFilename(doc.Title)
|
||||||
|
if base == "" {
|
||||||
|
base = "untitled"
|
||||||
|
}
|
||||||
|
key := base + "." + format.ext
|
||||||
|
name := key
|
||||||
|
if c := seen[key]; c > 0 {
|
||||||
|
name = fmt.Sprintf("%s (%d).%s", base, c, format.ext)
|
||||||
|
}
|
||||||
|
seen[key]++
|
||||||
|
f, err := zw.Create(name)
|
||||||
|
if err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := f.Write(body); err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := zw.Close(); err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := "petal-backup-" + time.Now().Format("2006-01-02") + ".zip"
|
||||||
|
w.Header().Set("Content-Type", "application/zip")
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename*=UTF-8''%s", urlEscapeFilename(filename)))
|
||||||
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", buf.Len()))
|
||||||
|
_, _ = w.Write(buf.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := format.render(doc)
|
||||||
|
if err != nil {
|
||||||
|
httputil.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"`
|
||||||
|
Attrs map[string]any `json:"attrs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// attrStr returns a string-valued attribute (e.g. an image src), or "".
|
||||||
|
func (n pmNode) attrStr(key string) string {
|
||||||
|
if n.Attrs == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if s, ok := n.Attrs[key].(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// markAttr returns a string attribute from the named mark (e.g. a link href).
|
||||||
|
func (n pmNode) markAttr(markType, key string) string {
|
||||||
|
for _, m := range n.Marks {
|
||||||
|
if m.Type == markType && m.Attrs != nil {
|
||||||
|
if s, ok := m.Attrs[key].(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
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 "image":
|
||||||
|
alt := n.attrStr("alt")
|
||||||
|
return fmt.Sprintf("", alt, n.attrStr("src"))
|
||||||
|
case "table":
|
||||||
|
return mdTable(n)
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// mdTable renders a table node as a GitHub-flavored Markdown table. The first
|
||||||
|
// row is used as the header (Markdown tables require one); a separator row is
|
||||||
|
// inserted after it. Cell text is flattened to inline Markdown.
|
||||||
|
func mdTable(n pmNode) string {
|
||||||
|
var rows [][]string
|
||||||
|
for _, row := range n.Content {
|
||||||
|
if row.Type != "tableRow" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var cells []string
|
||||||
|
for _, cell := range row.Content {
|
||||||
|
// A cell holds block children (usually one paragraph); flatten them.
|
||||||
|
var parts []string
|
||||||
|
for _, c := range cell.Content {
|
||||||
|
parts = append(parts, mdInline(c.Content))
|
||||||
|
}
|
||||||
|
// Escape pipes so cell content doesn't break the column layout.
|
||||||
|
cells = append(cells, strings.ReplaceAll(strings.TrimSpace(strings.Join(parts, " ")), "|", "\\|"))
|
||||||
|
}
|
||||||
|
rows = append(rows, cells)
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
cols := 0
|
||||||
|
for _, r := range rows {
|
||||||
|
if len(r) > cols {
|
||||||
|
cols = len(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pad := func(r []string) string {
|
||||||
|
for len(r) < cols {
|
||||||
|
r = append(r, "")
|
||||||
|
}
|
||||||
|
return "| " + strings.Join(r, " | ") + " |"
|
||||||
|
}
|
||||||
|
var lines []string
|
||||||
|
lines = append(lines, pad(rows[0]))
|
||||||
|
sep := make([]string, cols)
|
||||||
|
for i := range sep {
|
||||||
|
sep[i] = "---"
|
||||||
|
}
|
||||||
|
lines = append(lines, "| "+strings.Join(sep, " | ")+" |")
|
||||||
|
for _, r := range rows[1:] {
|
||||||
|
lines = append(lines, pad(r))
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
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>"
|
||||||
|
}
|
||||||
|
if href := n.markAttr("link", "href"); href != "" {
|
||||||
|
t = "[" + t + "](" + href + ")"
|
||||||
|
}
|
||||||
|
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 "image":
|
||||||
|
alt := htmlEscape(n.attrStr("alt"))
|
||||||
|
return fmt.Sprintf("<p><img src=\"%s\" alt=\"%s\"></p>\n", htmlEscape(n.attrStr("src")), alt)
|
||||||
|
case "table":
|
||||||
|
return htmlTable(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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// htmlTable renders a table node as an HTML <table>. tableHeader cells become
|
||||||
|
// <th>, tableCell cells become <td>; each cell's block children are flattened
|
||||||
|
// to inline HTML.
|
||||||
|
func htmlTable(n pmNode) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("<table>\n")
|
||||||
|
for _, row := range n.Content {
|
||||||
|
if row.Type != "tableRow" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString("<tr>")
|
||||||
|
for _, cell := range row.Content {
|
||||||
|
tag := "td"
|
||||||
|
if cell.Type == "tableHeader" {
|
||||||
|
tag = "th"
|
||||||
|
}
|
||||||
|
var inner strings.Builder
|
||||||
|
for _, c := range cell.Content {
|
||||||
|
if c.Type == "paragraph" {
|
||||||
|
inner.WriteString(htmlInline(c.Content))
|
||||||
|
} else {
|
||||||
|
inner.WriteString(htmlBlock(c))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString("<" + tag + ">" + inner.String() + "</" + tag + ">")
|
||||||
|
}
|
||||||
|
b.WriteString("</tr>\n")
|
||||||
|
}
|
||||||
|
b.WriteString("</table>\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
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>"
|
||||||
|
}
|
||||||
|
if n.hasMark("highlight") {
|
||||||
|
t = "<mark>" + t + "</mark>"
|
||||||
|
}
|
||||||
|
if href := n.markAttr("link", "href"); href != "" {
|
||||||
|
t = fmt.Sprintf("<a href=\"%s\">%s</a>", htmlEscape(href), t)
|
||||||
|
}
|
||||||
|
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 "image":
|
||||||
|
// Image bytes aren't embedded (that needs media parts); leave a labeled
|
||||||
|
// placeholder so the reader knows an image belonged here.
|
||||||
|
label := n.attrStr("alt")
|
||||||
|
if label == "" {
|
||||||
|
label = "image"
|
||||||
|
}
|
||||||
|
return docxPara(pmNode{Content: []pmNode{{Type: "text", Text: "[" + label + "]", Marks: []pmMark{{Type: "italic"}}}}}, "")
|
||||||
|
case "table":
|
||||||
|
return docxTable(n)
|
||||||
|
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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// docxTable renders a table node as a Word table (w:tbl) with single-line
|
||||||
|
// borders. Header cells get a shaded background; every cell's block children are
|
||||||
|
// rendered as paragraphs inside the cell.
|
||||||
|
func docxTable(n pmNode) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(`<w:tbl><w:tblPr><w:tblStyle w:val="TableGrid"/><w:tblW w:w="0" w:type="auto"/>` +
|
||||||
|
`<w:tblBorders>` +
|
||||||
|
`<w:top w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
|
||||||
|
`<w:left w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
|
||||||
|
`<w:bottom w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
|
||||||
|
`<w:right w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
|
||||||
|
`<w:insideH w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
|
||||||
|
`<w:insideV w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
|
||||||
|
`</w:tblBorders></w:tblPr>`)
|
||||||
|
for _, row := range n.Content {
|
||||||
|
if row.Type != "tableRow" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString("<w:tr>")
|
||||||
|
for _, cell := range row.Content {
|
||||||
|
b.WriteString("<w:tc><w:tcPr>")
|
||||||
|
if cell.Type == "tableHeader" {
|
||||||
|
b.WriteString(`<w:shd w:val="clear" w:color="auto" w:fill="FFF0F5"/>`)
|
||||||
|
}
|
||||||
|
b.WriteString("</w:tcPr>")
|
||||||
|
// A cell must contain at least one paragraph to be valid.
|
||||||
|
if len(cell.Content) == 0 {
|
||||||
|
b.WriteString("<w:p/>")
|
||||||
|
}
|
||||||
|
for _, c := range cell.Content {
|
||||||
|
b.WriteString(docxBlock(c))
|
||||||
|
}
|
||||||
|
b.WriteString("</w:tc>")
|
||||||
|
}
|
||||||
|
b.WriteString("</w:tr>")
|
||||||
|
}
|
||||||
|
b.WriteString("</w:tbl>")
|
||||||
|
// Word needs a paragraph after a table; otherwise consecutive tables merge.
|
||||||
|
b.WriteString("<w:p/>")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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/>")
|
||||||
|
}
|
||||||
|
if n.hasMark("highlight") {
|
||||||
|
// Word's text highlight only supports a fixed palette of named colors.
|
||||||
|
props.WriteString(`<w:highlight w:val="yellow"/>`)
|
||||||
|
}
|
||||||
|
if n.markAttr("link", "href") != "" {
|
||||||
|
// Without hyperlink relationships, style links as blue underlined text so
|
||||||
|
// they at least read as links (the URL itself is preserved in md/html).
|
||||||
|
props.WriteString(`<w:color w:val="2563EB"/><w:u w:val="single"/>`)
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
}
|
||||||
245
internal/docs/export_test.go
Normal file
245
internal/docs/export_test.go
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
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 TestExportAll(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
// Two docs, one with a duplicate title to exercise filename de-duplication.
|
||||||
|
seedRichDoc(t, srv)
|
||||||
|
id2 := newDoc(t, srv)
|
||||||
|
if rec := do(t, srv, http.MethodPut, "/"+id2, `{"title":"日记 Diary","content":`+jsonString(richDocJSON)+`,"content_text":"x","word_count":1}`); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("seed second doc: %d %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := do(t, srv, http.MethodGet, "/export-all?format=md", "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("export-all: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if ct := rec.Header().Get("Content-Type"); ct != "application/zip" {
|
||||||
|
t.Fatalf("unexpected content-type: %q", ct)
|
||||||
|
}
|
||||||
|
zr, err := zip.NewReader(bytes.NewReader(rec.Body.Bytes()), int64(rec.Body.Len()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("zip open: %v", err)
|
||||||
|
}
|
||||||
|
if len(zr.File) != 2 {
|
||||||
|
t.Fatalf("expected 2 files in backup, got %d", len(zr.File))
|
||||||
|
}
|
||||||
|
names := map[string]bool{}
|
||||||
|
for _, f := range zr.File {
|
||||||
|
names[f.Name] = true
|
||||||
|
rc, _ := f.Open()
|
||||||
|
b, _ := io.ReadAll(rc)
|
||||||
|
rc.Close()
|
||||||
|
if !strings.Contains(string(b), "你好") {
|
||||||
|
t.Fatalf("backup entry %q missing CJK body", f.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The duplicate title must have been disambiguated, not overwritten.
|
||||||
|
if !names["日记 Diary.md"] || !names["日记 Diary (1).md"] {
|
||||||
|
t.Fatalf("expected de-duplicated filenames, got %v", names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// richDocJSON2 exercises the newer node/mark types: links, highlight, an image,
|
||||||
|
// and a table (with a header row). Each export format should carry them through.
|
||||||
|
const richDocJSON2 = `{"type":"doc","content":[` +
|
||||||
|
`{"type":"paragraph","content":[` +
|
||||||
|
`{"type":"text","marks":[{"type":"link","attrs":{"href":"https://petal.test"}}],"text":"site"},` +
|
||||||
|
`{"type":"text","text":" and "},` +
|
||||||
|
`{"type":"text","marks":[{"type":"highlight","attrs":{"color":"#FFF1A8"}}],"text":"lit"}` +
|
||||||
|
`]},` +
|
||||||
|
`{"type":"image","attrs":{"src":"/api/images/abc123.png","alt":"a cat"}},` +
|
||||||
|
`{"type":"table","content":[` +
|
||||||
|
`{"type":"tableRow","content":[` +
|
||||||
|
`{"type":"tableHeader","content":[{"type":"paragraph","content":[{"type":"text","text":"Name"}]}]},` +
|
||||||
|
`{"type":"tableHeader","content":[{"type":"paragraph","content":[{"type":"text","text":"年龄"}]}]}` +
|
||||||
|
`]},` +
|
||||||
|
`{"type":"tableRow","content":[` +
|
||||||
|
`{"type":"tableCell","content":[{"type":"paragraph","content":[{"type":"text","text":"Mei"}]}]},` +
|
||||||
|
`{"type":"tableCell","content":[{"type":"paragraph","content":[{"type":"text","text":"30"}]}]}` +
|
||||||
|
`]}` +
|
||||||
|
`]}` +
|
||||||
|
`]}`
|
||||||
|
|
||||||
|
func seedRichDoc2(t *testing.T, srv http.Handler) string {
|
||||||
|
t.Helper()
|
||||||
|
id := newDoc(t, srv)
|
||||||
|
body := `{"title":"Rich2","content":` + jsonString(richDocJSON2) + `,"content_text":"site and lit\nName 年龄 Mei 30","word_count":6}`
|
||||||
|
if rec := do(t, srv, http.MethodPut, "/"+id, body); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("seed rich doc 2: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportLinksImagesTables(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
id := seedRichDoc2(t, srv)
|
||||||
|
|
||||||
|
md := do(t, srv, http.MethodGet, "/"+id+"/export?format=md", "").Body.String()
|
||||||
|
for _, want := range []string{"[site](https://petal.test)", "", "| Name | 年龄 |", "| --- | --- |", "| Mei | 30 |"} {
|
||||||
|
if !strings.Contains(md, want) {
|
||||||
|
t.Fatalf("markdown missing %q in:\n%s", want, md)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html := do(t, srv, http.MethodGet, "/"+id+"/export?format=html", "").Body.String()
|
||||||
|
for _, want := range []string{`<a href="https://petal.test">site</a>`, "<mark>lit</mark>", `<img src="/api/images/abc123.png" alt="a cat">`, "<th>Name</th>", "<td>Mei</td>"} {
|
||||||
|
if !strings.Contains(html, want) {
|
||||||
|
t.Fatalf("html missing %q in:\n%s", want, html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
docx := do(t, srv, http.MethodGet, "/"+id+"/export?format=docx", "")
|
||||||
|
raw := docx.Body.Bytes()
|
||||||
|
zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("docx not a zip: %v", err)
|
||||||
|
}
|
||||||
|
var docXML string
|
||||||
|
for _, f := range zr.File {
|
||||||
|
if f.Name == "word/document.xml" {
|
||||||
|
rc, _ := f.Open()
|
||||||
|
b, _ := io.ReadAll(rc)
|
||||||
|
rc.Close()
|
||||||
|
docXML = string(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, want := range []string{"<w:tbl>", "Name", "年龄", "[a cat]", "<w:highlight"} {
|
||||||
|
if !strings.Contains(docXML, want) {
|
||||||
|
t.Fatalf("docx document.xml missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,11 +7,13 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler holds the dependencies shared by every document route.
|
// Handler holds the dependencies shared by every document route.
|
||||||
@@ -33,19 +35,25 @@ func (h *Handler) Routes() chi.Router {
|
|||||||
r.Get("/{id}", h.get)
|
r.Get("/{id}", h.get)
|
||||||
r.Put("/{id}", h.update)
|
r.Put("/{id}", h.update)
|
||||||
r.Delete("/{id}", h.delete)
|
r.Delete("/{id}", h.delete)
|
||||||
|
h.versionRoutes(r)
|
||||||
|
h.exportRoutes(r)
|
||||||
|
h.registerTagRoutes(r)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
// docSummary is the lightweight shape returned by the list endpoint — enough to
|
// docSummary is the lightweight shape returned by the list endpoint — enough to
|
||||||
// render the DocList sidebar without shipping every document's full body.
|
// render the DocList sidebar without shipping every document's full body. Tags
|
||||||
|
// ride along so the sidebar can show chips and filter without a second request.
|
||||||
type docSummary struct {
|
type docSummary struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
WordCount int `json:"word_count"`
|
WordCount int `json:"word_count"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
Tags []db.Tag `json:"tags"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// list returns the local user's documents, most-recently-updated first.
|
// list returns the local user's documents, most-recently-updated first, each
|
||||||
|
// decorated with its tags.
|
||||||
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
|
||||||
rows, err := h.DB.Query(
|
rows, err := h.DB.Query(
|
||||||
`SELECT id, title, word_count, updated_at
|
`SELECT id, title, word_count, updated_at
|
||||||
@@ -55,25 +63,39 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
|
|||||||
db.LocalUserID,
|
db.LocalUserID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
out := []docSummary{} // non-nil so an empty list serializes as [] not null
|
out := []docSummary{} // non-nil so an empty list serializes as [] not null
|
||||||
|
ids := []string{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var d docSummary
|
var d docSummary
|
||||||
if err := rows.Scan(&d.ID, &d.Title, &d.WordCount, &d.UpdatedAt); err != nil {
|
if err := rows.Scan(&d.ID, &d.Title, &d.WordCount, &d.UpdatedAt); err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out = append(out, d)
|
out = append(out, d)
|
||||||
|
ids = append(ids, d.ID)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, out)
|
|
||||||
|
byDoc, err := h.tagsByDoc(ids)
|
||||||
|
if err != nil {
|
||||||
|
httputil.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{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
httputil.WriteJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// create inserts a fresh blank document and returns it in full.
|
// create inserts a fresh blank document and returns it in full.
|
||||||
@@ -88,10 +110,10 @@ func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
|
|||||||
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
|
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, doc)
|
httputil.WriteJSON(w, http.StatusCreated, doc)
|
||||||
}
|
}
|
||||||
|
|
||||||
// get returns a single full document by id.
|
// get returns a single full document by id.
|
||||||
@@ -102,10 +124,10 @@ func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, doc)
|
httputil.WriteJSON(w, http.StatusOK, doc)
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateRequest is the auto-save payload. Every field is optional (a pointer) so
|
// updateRequest is the auto-save payload. Every field is optional (a pointer) so
|
||||||
@@ -142,7 +164,7 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
|
|||||||
req.Title, req.Content, req.ContentText, req.Tone, req.WordCount, id, db.LocalUserID,
|
req.Title, req.Content, req.ContentText, req.Tone, req.WordCount, id, db.LocalUserID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if n, _ := res.RowsAffected(); n == 0 {
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
@@ -152,10 +174,20 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
doc, err := h.fetch(id)
|
doc, err := h.fetch(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, doc)
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
httputil.WriteJSON(w, http.StatusOK, doc)
|
||||||
}
|
}
|
||||||
|
|
||||||
// delete removes a document (suggestions cascade via the FK).
|
// delete removes a document (suggestions cascade via the FK).
|
||||||
@@ -165,7 +197,7 @@ func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
|
|||||||
chi.URLParam(r, "id"), db.LocalUserID,
|
chi.URLParam(r, "id"), db.LocalUserID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if n, _ := res.RowsAffected(); n == 0 {
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
@@ -191,20 +223,12 @@ func (h *Handler) fetch(id string) (db.Document, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- small response helpers -------------------------------------------------
|
// --- small response helpers -------------------------------------------------
|
||||||
|
//
|
||||||
|
// The generic JSON/error helpers live in internal/httputil; these are the
|
||||||
|
// document-specific shorthands that carry domain wording.
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
func badRequest(w http.ResponseWriter, msg string) { httputil.BadRequest(w, msg) }
|
||||||
w.Header().Set("Content-Type", "application/json")
|
func notFound(w http.ResponseWriter) {
|
||||||
w.WriteHeader(status)
|
httputil.ErrorJSON(w, http.StatusNotFound, "document not found")
|
||||||
_ = json.NewEncoder(w).Encode(v)
|
|
||||||
}
|
}
|
||||||
|
func notFoundMsg(w http.ResponseWriter, msg string) { httputil.ErrorJSON(w, http.StatusNotFound, msg) }
|
||||||
func errorJSON(w http.ResponseWriter, status int, msg string) {
|
|
||||||
writeJSON(w, status, map[string]string{"error": msg})
|
|
||||||
}
|
|
||||||
|
|
||||||
func serverError(w http.ResponseWriter, err error) {
|
|
||||||
errorJSON(w, http.StatusInternalServerError, 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") }
|
|
||||||
|
|||||||
262
internal/docs/search.go
Normal file
262
internal/docs/search.go
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
package docs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 == "" {
|
||||||
|
httputil.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 {
|
||||||
|
httputil.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 {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows = append(rows, rw)
|
||||||
|
}
|
||||||
|
if err := sqlRows.Err(); err != nil {
|
||||||
|
httputil.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 {
|
||||||
|
httputil.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 {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows = append(rows, rw)
|
||||||
|
}
|
||||||
|
if err := sqlRows.Err(); err != nil {
|
||||||
|
httputil.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 {
|
||||||
|
httputil.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{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
httputil.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]})
|
||||||
|
}
|
||||||
304
internal/docs/tags.go
Normal file
304
internal/docs/tags.go
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
package docs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
httputil.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 {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httputil.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 {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httputil.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 {
|
||||||
|
httputil.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 {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httputil.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 {
|
||||||
|
httputil.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 {
|
||||||
|
httputil.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 {
|
||||||
|
httputil.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
246
internal/docs/versions.go
Normal file
246
internal/docs/versions.go
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
package docs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
httputil.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 {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httputil.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 {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httputil.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 {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
v, err := h.insertVersion(doc, db.VersionKindManual)
|
||||||
|
if err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httputil.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 {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err := h.fetch(docID)
|
||||||
|
if err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := h.insertVersion(current, db.VersionKindPreRestore); err != nil {
|
||||||
|
httputil.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 {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
notFound(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
doc, err := h.fetch(docID)
|
||||||
|
if err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httputil.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
37
internal/httputil/json.go
Normal file
37
internal/httputil/json.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// Package httputil holds the small HTTP response helpers shared by every API
|
||||||
|
// handler package (docs, suggestions, vocab, …). Keeping them in one place means
|
||||||
|
// JSON encoding and — crucially — error handling behave identically everywhere:
|
||||||
|
// internal errors are logged in full but never leaked to the client.
|
||||||
|
package httputil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WriteJSON encodes v as the response body with the given status code.
|
||||||
|
func WriteJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorJSON sends a {"error": msg} body with the given status. The message is
|
||||||
|
// caller-chosen and safe to show the client.
|
||||||
|
func ErrorJSON(w http.ResponseWriter, status int, msg string) {
|
||||||
|
WriteJSON(w, status, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BadRequest is the common 400 shorthand.
|
||||||
|
func BadRequest(w http.ResponseWriter, msg string) {
|
||||||
|
ErrorJSON(w, http.StatusBadRequest, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerError logs the real error (with full detail, for the operator) and
|
||||||
|
// returns a generic 500 to the client — raw database/internal errors must never
|
||||||
|
// reach the browser, where they leak schema and implementation details.
|
||||||
|
func ServerError(w http.ResponseWriter, err error) {
|
||||||
|
log.Printf("internal error: %v", err)
|
||||||
|
ErrorJSON(w, http.StatusInternalServerError, "something went wrong")
|
||||||
|
}
|
||||||
132
internal/images/handler.go
Normal file
132
internal/images/handler.go
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
// Package images implements a tiny content-addressed image store: writers upload
|
||||||
|
// images from the editor, they're saved to disk under the configured directory,
|
||||||
|
// and served back by hashed filename. Content addressing means the same image
|
||||||
|
// pasted twice is stored once, and URLs are stable and cacheable forever.
|
||||||
|
package images
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxUploadBytes caps a single image at 10 MiB — generous for a writing tool,
|
||||||
|
// small enough to keep a careless paste from filling the disk.
|
||||||
|
const maxUploadBytes = 10 << 20
|
||||||
|
|
||||||
|
// extByContentType maps the image types we accept to a canonical extension. The
|
||||||
|
// allowlist doubles as validation: anything not here is rejected.
|
||||||
|
var extByContentType = map[string]string{
|
||||||
|
"image/png": ".png",
|
||||||
|
"image/jpeg": ".jpg",
|
||||||
|
"image/gif": ".gif",
|
||||||
|
"image/webp": ".webp",
|
||||||
|
"image/svg+xml": ".svg",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler serves the upload + fetch endpoints, backed by a directory on disk.
|
||||||
|
type Handler struct {
|
||||||
|
dir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a Handler, ensuring the storage directory exists.
|
||||||
|
func New(dir string) (*Handler, error) {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Handler{dir: dir}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes mounts the image endpoints. Mount under "/images" so the full paths are
|
||||||
|
// POST /api/images (upload) and GET /api/images/{name} (fetch).
|
||||||
|
func (h *Handler) Routes() chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Post("/", h.upload)
|
||||||
|
r.Get("/{name}", h.serve)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// upload accepts a single multipart "image" field, sniffs and validates its
|
||||||
|
// type, and writes it under a content hash so identical images dedupe. Responds
|
||||||
|
// with the served URL the editor inserts.
|
||||||
|
func (h *Handler) upload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
|
||||||
|
file, _, err := r.FormFile("image")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "expected an 'image' file field", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "could not read upload", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trust a sniff over the client-declared type. SVG isn't reliably sniffable
|
||||||
|
// (DetectContentType returns text/plain or text/xml), so fall back to a
|
||||||
|
// lightweight tag check for it.
|
||||||
|
ct := http.DetectContentType(data)
|
||||||
|
ext, ok := extByContentType[ct]
|
||||||
|
if !ok {
|
||||||
|
if looksLikeSVG(data) {
|
||||||
|
ext, ok = ".svg", true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "unsupported image type", http.StatusUnsupportedMediaType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
name := hex.EncodeToString(sum[:])[:32] + ext
|
||||||
|
path := filepath.Join(h.dir, name)
|
||||||
|
|
||||||
|
// Skip the write if this exact content is already stored.
|
||||||
|
if _, statErr := os.Stat(path); errors.Is(statErr, os.ErrNotExist) {
|
||||||
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||||
|
http.Error(w, "could not store image", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"url": "/api/images/" + name})
|
||||||
|
}
|
||||||
|
|
||||||
|
// serve returns a stored image by its hashed filename. The filename is validated
|
||||||
|
// to be a bare name (no path separators) so it can't escape the storage dir, and
|
||||||
|
// served with a long-lived cache header since content-addressed URLs never change.
|
||||||
|
func (h *Handler) serve(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := chi.URLParam(r, "name")
|
||||||
|
if name == "" || name != filepath.Base(name) || strings.ContainsAny(name, `/\`) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := filepath.Join(h.dir, filepath.Base(name))
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
http.ServeFile(w, r, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// looksLikeSVG does a cheap check for an <svg root tag near the start of the
|
||||||
|
// file, since DetectContentType doesn't recognize SVG.
|
||||||
|
func looksLikeSVG(data []byte) bool {
|
||||||
|
head := data
|
||||||
|
if len(head) > 512 {
|
||||||
|
head = head[:512]
|
||||||
|
}
|
||||||
|
return strings.Contains(strings.ToLower(string(head)), "<svg")
|
||||||
|
}
|
||||||
97
internal/images/handler_test.go
Normal file
97
internal/images/handler_test.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package images
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// a 1x1 transparent PNG.
|
||||||
|
var pngBytes = []byte{
|
||||||
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||||
|
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
|
||||||
|
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x62, 0x00, 0x01, 0x00, 0x00,
|
||||||
|
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
|
||||||
|
0x42, 0x60, 0x82,
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadReq(t *testing.T, field string, data []byte) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&buf)
|
||||||
|
fw, err := mw.CreateFormFile(field, "x.png")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fw.Write(data)
|
||||||
|
mw.Close()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", &buf)
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadAndServe(t *testing.T) {
|
||||||
|
h, err := New(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r := h.Routes()
|
||||||
|
|
||||||
|
// Upload a PNG → expect a JSON url under /api/images/.
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, uploadReq(t, "image", pngBytes))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("upload code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var resp struct{ URL string }
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(resp.URL, "/api/images/") || !strings.HasSuffix(resp.URL, ".png") {
|
||||||
|
t.Fatalf("unexpected url %q", resp.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same content uploaded again dedupes to the same URL.
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec2, uploadReq(t, "image", pngBytes))
|
||||||
|
var resp2 struct{ URL string }
|
||||||
|
json.Unmarshal(rec2.Body.Bytes(), &resp2)
|
||||||
|
if resp2.URL != resp.URL {
|
||||||
|
t.Fatalf("expected dedup to same url, got %q vs %q", resp2.URL, resp.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch it back.
|
||||||
|
name := strings.TrimPrefix(resp.URL, "/api/images/")
|
||||||
|
rec3 := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec3, httptest.NewRequest(http.MethodGet, "/"+name, nil))
|
||||||
|
if rec3.Code != http.StatusOK {
|
||||||
|
t.Fatalf("serve code=%d", rec3.Code)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(rec3.Body.Bytes(), pngBytes) {
|
||||||
|
t.Fatal("served bytes differ from uploaded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadRejectsNonImage(t *testing.T) {
|
||||||
|
h, _ := New(t.TempDir())
|
||||||
|
r := h.Routes()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, uploadReq(t, "image", []byte("this is plainly not an image at all")))
|
||||||
|
if rec.Code != http.StatusUnsupportedMediaType {
|
||||||
|
t.Fatalf("expected 415, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeMissing(t *testing.T) {
|
||||||
|
h, _ := New(t.TempDir())
|
||||||
|
r := h.Routes()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/deadbeef.png", nil))
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
// Package lexicon serves offline word lookups — a definition and a list of
|
// Package lexicon serves offline word lookups — a Chinese gloss, a definition,
|
||||||
// synonyms for a single word — from two public-domain datasets compiled into the
|
// and a list of synonyms for a single word — from public datasets compiled into
|
||||||
// binary. Definitions come from the Wordset dictionary (modern, concise glosses
|
// the binary. Definitions come from the Wordset dictionary (modern, concise
|
||||||
// with a part of speech and example, which read kindly for an ESL writer);
|
// glosses with a part of speech and example, which read kindly for an ESL
|
||||||
// synonyms come from the Moby Thesaurus. Both are gzipped JSON, decompressed
|
// writer); synonyms come from the Moby Thesaurus; the Chinese gloss comes from
|
||||||
// lazily on first use so a writer who never right-clicks a word pays nothing.
|
// ECDICT. All are gzipped JSON, decompressed lazily on first use so a writer who
|
||||||
|
// never looks up a word pays nothing.
|
||||||
package lexicon
|
package lexicon
|
||||||
|
|
||||||
import _ "embed"
|
import _ "embed"
|
||||||
@@ -19,3 +20,19 @@ var definitionsGz []byte
|
|||||||
//
|
//
|
||||||
//go:embed data/synonyms.json.gz
|
//go:embed data/synonyms.json.gz
|
||||||
var synonymsGz []byte
|
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
|
||||||
|
|
||||||
|
// phoneticGz is the gzipped English-phonetic map: word → IPA, lowercase keys.
|
||||||
|
// Built from ECDICT's phonetic column (scripts/build_phonetic.py). Shown beside
|
||||||
|
// the read-aloud button so the writer — a Mandarin speaker learning English —
|
||||||
|
// can see how to say the word. Ships with a small common-word seed; a full build
|
||||||
|
// broadens coverage.
|
||||||
|
//
|
||||||
|
//go:embed data/phonetic.json.gz
|
||||||
|
var phoneticGz []byte
|
||||||
|
|||||||
BIN
internal/lexicon/data/gloss.json.gz
Normal file
BIN
internal/lexicon/data/gloss.json.gz
Normal file
Binary file not shown.
BIN
internal/lexicon/data/phonetic.json.gz
Normal file
BIN
internal/lexicon/data/phonetic.json.gz
Normal file
Binary file not shown.
@@ -25,6 +25,15 @@ func (h *Handler) Routes() chi.Router {
|
|||||||
return r
|
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
|
// 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
|
// dataset still returns 200 with empty lists, so the popover can show a friendly
|
||||||
// "nothing found" rather than an error state.
|
// "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")
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||||
_ = json.NewEncoder(w).Encode(res)
|
_ = 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,25 @@ type Meaning struct {
|
|||||||
|
|
||||||
// Result is the full lookup for one word. Either list may be empty (the word
|
// 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
|
// 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 {
|
type Result struct {
|
||||||
Word string `json:"word"`
|
Word string `json:"word"`
|
||||||
|
Gloss string `json:"gloss"`
|
||||||
|
Phonetic string `json:"phonetic"` // IPA for the English word; "" when absent
|
||||||
Definitions []Meaning `json:"definitions"`
|
Definitions []Meaning `json:"definitions"`
|
||||||
Synonyms []string `json:"synonyms"`
|
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
|
// 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
|
// 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.
|
// helps, especially for an ESL reader scanning for the right fit.
|
||||||
@@ -44,6 +56,8 @@ type Lexicon struct {
|
|||||||
loadErr error
|
loadErr error
|
||||||
defs map[string][][]string // word → [[pos, def, example], …]
|
defs map[string][][]string // word → [[pos, def, example], …]
|
||||||
synonyms map[string][]string // word → [synonym, …]
|
synonyms map[string][]string // word → [synonym, …]
|
||||||
|
gloss map[string]string // word → Chinese gloss
|
||||||
|
phonetic map[string]string // word → IPA
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns a Lexicon. The datasets aren't read until the first Lookup.
|
// New returns a Lexicon. The datasets aren't read until the first Lookup.
|
||||||
@@ -59,6 +73,14 @@ func (l *Lexicon) load() {
|
|||||||
l.loadErr = fmt.Errorf("load synonyms: %w", err)
|
l.loadErr = fmt.Errorf("load synonyms: %w", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := gunzipJSON(glossGz, &l.gloss); err != nil {
|
||||||
|
l.loadErr = fmt.Errorf("load gloss: %w", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := gunzipJSON(phoneticGz, &l.phonetic); err != nil {
|
||||||
|
l.loadErr = fmt.Errorf("load phonetic: %w", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +100,11 @@ func (l *Lexicon) Lookup(word string) (Result, error) {
|
|||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
res.Gloss = lookupGloss(l.gloss, norm)
|
||||||
|
// Phonetic is a word→string map like the gloss, so the same de-inflecting
|
||||||
|
// candidate walk resolves "running" → "run", etc.
|
||||||
|
res.Phonetic = lookupGloss(l.phonetic, norm)
|
||||||
|
|
||||||
if raw := lookupDefs(l.defs, norm); raw != nil {
|
if raw := lookupDefs(l.defs, norm); raw != nil {
|
||||||
for _, m := range raw {
|
for _, m := range raw {
|
||||||
res.Definitions = append(res.Definitions, toMeaning(m))
|
res.Definitions = append(res.Definitions, toMeaning(m))
|
||||||
@@ -97,6 +124,32 @@ func (l *Lexicon) Lookup(word string) (Result, error) {
|
|||||||
return res, nil
|
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
|
// lookupDefs / lookupSyns walk the candidate forms of a word and return the
|
||||||
// first dataset hit. They're separate (rather than a generic helper) only
|
// first dataset hit. They're separate (rather than a generic helper) only
|
||||||
// because the two maps have different value types.
|
// 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) {
|
func TestCandidates(t *testing.T) {
|
||||||
cases := map[string]string{
|
cases := map[string]string{
|
||||||
"cats": "cat",
|
"cats": "cat",
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ import (
|
|||||||
// that protects the inference endpoint from rapid repeat checks.
|
// that protects the inference endpoint from rapid repeat checks.
|
||||||
const CheckpointInterval = 30 * time.Second
|
const CheckpointInterval = 30 * time.Second
|
||||||
|
|
||||||
|
// checkpointMaxTokens caps the checkpoint's generated output. Each suggestion
|
||||||
|
// echoes its original sentence, a replacement, and a friendly explanation, so
|
||||||
|
// the full JSON for the prompt's "up to 5 issues" runs ~2,000 tokens on a long
|
||||||
|
// document (measured ~2,007 for a 380-word doc). The old 1,024 cap truncated
|
||||||
|
// the JSON mid-array — done_reason "length" — leaving an object that never
|
||||||
|
// closed, so ParseCheckpoint found no parseable JSON and the pass 502'd on
|
||||||
|
// every long doc. This is a ceiling, not a target: the model stops at its JSON
|
||||||
|
// close well before it, so the headroom costs nothing on shorter docs.
|
||||||
|
const checkpointMaxTokens = 4096
|
||||||
|
|
||||||
// RawSuggestion is one item as the model emits it. Positions are resolved
|
// RawSuggestion is one item as the model emits it. Positions are resolved
|
||||||
// server-side from Original; the frontend re-anchors by string at render time.
|
// server-side from Original; the frontend re-anchors by string at render time.
|
||||||
type RawSuggestion struct {
|
type RawSuggestion struct {
|
||||||
@@ -34,7 +44,7 @@ type checkpointResponse struct {
|
|||||||
func RunCheckpoint(ctx context.Context, client LLMClient, contentText, tone string) ([]RawSuggestion, error) {
|
func RunCheckpoint(ctx context.Context, client LLMClient, contentText, tone string) ([]RawSuggestion, error) {
|
||||||
raw, err := client.Complete(ctx, CompletionRequest{
|
raw, err := client.Complete(ctx, CompletionRequest{
|
||||||
Messages: CheckpointMessages(TruncateDoc(contentText), tone),
|
Messages: CheckpointMessages(TruncateDoc(contentText), tone),
|
||||||
MaxTokens: 1024,
|
MaxTokens: checkpointMaxTokens,
|
||||||
Temperature: 0.3,
|
Temperature: 0.3,
|
||||||
RepetitionPenalty: 1.15,
|
RepetitionPenalty: 1.15,
|
||||||
TopP: 0.9,
|
TopP: 0.9,
|
||||||
@@ -48,36 +58,99 @@ func RunCheckpoint(ctx context.Context, client LLMClient, contentText, tone stri
|
|||||||
|
|
||||||
// ParseCheckpoint extracts the suggestions array from a model response. Smaller
|
// ParseCheckpoint extracts the suggestions array from a model response. Smaller
|
||||||
// models sometimes wrap JSON in prose or markdown fences despite instructions,
|
// models sometimes wrap JSON in prose or markdown fences despite instructions,
|
||||||
// so we salvage the outermost {...} object before decoding.
|
// so we salvage the outermost {...} object before decoding. When the model
|
||||||
|
// truncates mid-array (it ran out of output budget) the outer object never
|
||||||
|
// closes and won't parse — we then recover the suggestion objects that *did*
|
||||||
|
// complete, so a long document still gets feedback instead of a hard failure.
|
||||||
func ParseCheckpoint(raw string) ([]RawSuggestion, error) {
|
func ParseCheckpoint(raw string) ([]RawSuggestion, error) {
|
||||||
jsonText := extractJSONObject(raw)
|
var suggestions []RawSuggestion
|
||||||
if jsonText == "" {
|
parsed := false
|
||||||
|
if jsonText := extractJSONObject(raw); jsonText != "" {
|
||||||
|
var resp checkpointResponse
|
||||||
|
if err := json.Unmarshal([]byte(jsonText), &resp); err == nil {
|
||||||
|
suggestions = resp.Suggestions
|
||||||
|
parsed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !parsed {
|
||||||
|
// No closeable object, or it failed to decode (most often a truncated
|
||||||
|
// array). Salvage the individual suggestion objects that completed.
|
||||||
|
suggestions = salvageSuggestions(raw)
|
||||||
|
if suggestions == nil {
|
||||||
return nil, fmt.Errorf("checkpoint: no JSON object in model output: %q", truncateForError(raw))
|
return nil, fmt.Errorf("checkpoint: no JSON object in model output: %q", truncateForError(raw))
|
||||||
}
|
}
|
||||||
var parsed checkpointResponse
|
|
||||||
if err := json.Unmarshal([]byte(jsonText), &parsed); err != nil {
|
|
||||||
return nil, fmt.Errorf("checkpoint: parse JSON: %w (got %q)", err, truncateForError(jsonText))
|
|
||||||
}
|
}
|
||||||
// Drop items the model returned with an empty original — they can't be
|
// Drop items the model returned with an empty original — they can't be
|
||||||
// anchored — and normalize whitespace the model may have echoed.
|
// anchored — and normalize whitespace the model may have echoed.
|
||||||
out := parsed.Suggestions[:0]
|
out := suggestions[:0]
|
||||||
for _, s := range parsed.Suggestions {
|
for _, s := range suggestions {
|
||||||
s.Original = strings.TrimSpace(s.Original)
|
s.Original = strings.TrimSpace(s.Original)
|
||||||
s.Replacement = strings.TrimSpace(s.Replacement)
|
s.Replacement = strings.TrimSpace(s.Replacement)
|
||||||
if s.Original == "" {
|
if s.Original == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Drop no-ops: the model sometimes "flags" a correct sentence and echoes
|
||||||
|
// it verbatim as the replacement, producing a card whose before/after are
|
||||||
|
// identical and whose explanation says it's already fine — a suggestion
|
||||||
|
// that suggests nothing. Awareness-only families (voice) carry an empty
|
||||||
|
// replacement, so this only ever fires on the edit families.
|
||||||
|
if s.Replacement != "" && s.Original == s.Replacement {
|
||||||
|
continue
|
||||||
|
}
|
||||||
out = append(out, s)
|
out = append(out, s)
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// salvageSuggestions recovers as many complete suggestion objects as it can from
|
||||||
|
// a response whose top-level JSON didn't parse — typically one truncated mid
|
||||||
|
// "suggestions" array. It scans the array region for brace-balanced {...}
|
||||||
|
// objects and keeps each that decodes into a RawSuggestion with a non-empty
|
||||||
|
// original; the final, cut-off object simply never balances and is dropped.
|
||||||
|
// Returns nil when there is no salvageable array (so the caller can error).
|
||||||
|
func salvageSuggestions(raw string) []RawSuggestion {
|
||||||
|
key := strings.Index(raw, `"suggestions"`)
|
||||||
|
if key < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lb := strings.IndexByte(raw[key:], '[')
|
||||||
|
if lb < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rest := raw[key+lb+1:]
|
||||||
|
out := []RawSuggestion{}
|
||||||
|
for {
|
||||||
|
obj, end := firstBalancedObject(rest)
|
||||||
|
if end < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
var s RawSuggestion
|
||||||
|
if err := json.Unmarshal([]byte(obj), &s); err == nil && strings.TrimSpace(s.Original) != "" {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
rest = rest[end:]
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// extractJSONObject returns the substring from the first '{' to its matching
|
// extractJSONObject returns the substring from the first '{' to its matching
|
||||||
// closing '}', or "" if none. Tolerates fences/preamble around the object.
|
// closing '}', or "" if none. Tolerates fences/preamble around the object.
|
||||||
func extractJSONObject(s string) string {
|
func extractJSONObject(s string) string {
|
||||||
|
obj, _ := firstBalancedObject(s)
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstBalancedObject returns the first brace-balanced {...} substring in s and
|
||||||
|
// the index in s just past its closing brace, or ("", -1) if no object closes
|
||||||
|
// (none present, or the only one is truncated). String contents — including
|
||||||
|
// braces inside quoted values — are skipped so they can't unbalance the count.
|
||||||
|
func firstBalancedObject(s string) (string, int) {
|
||||||
start := strings.IndexByte(s, '{')
|
start := strings.IndexByte(s, '{')
|
||||||
if start < 0 {
|
if start < 0 {
|
||||||
return ""
|
return "", -1
|
||||||
}
|
}
|
||||||
depth := 0
|
depth := 0
|
||||||
inString := false
|
inString := false
|
||||||
@@ -98,11 +171,11 @@ func extractJSONObject(s string) string {
|
|||||||
case c == '}':
|
case c == '}':
|
||||||
depth--
|
depth--
|
||||||
if depth == 0 {
|
if depth == 0 {
|
||||||
return s[start : i+1]
|
return s[start : i+1], i + 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return "", -1
|
||||||
}
|
}
|
||||||
|
|
||||||
func truncateForError(s string) string {
|
func truncateForError(s string) string {
|
||||||
@@ -127,17 +200,33 @@ func NewRateLimiter(interval time.Duration) *RateLimiter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Allow reports whether a checkpoint may run for docID now. When allowed it
|
// Allow reports whether a checkpoint may run for docID now. When allowed it
|
||||||
// records the time and returns (true, 0); when throttled it returns
|
// records the time and returns (true, 0, recordedAt); when throttled it returns
|
||||||
// (false, retryAfter) where retryAfter is the wait until the next allowed run.
|
// (false, retryAfter, zero) where retryAfter is the wait until the next allowed
|
||||||
func (rl *RateLimiter) Allow(docID string) (bool, time.Duration) {
|
// run. recordedAt lets a caller whose pass fails hand the exact timestamp to
|
||||||
|
// Release so it rolls back only its own slot.
|
||||||
|
func (rl *RateLimiter) Allow(docID string) (bool, time.Duration, time.Time) {
|
||||||
rl.mu.Lock()
|
rl.mu.Lock()
|
||||||
defer rl.mu.Unlock()
|
defer rl.mu.Unlock()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if last, ok := rl.last[docID]; ok {
|
if last, ok := rl.last[docID]; ok {
|
||||||
if elapsed := now.Sub(last); elapsed < rl.interval {
|
if elapsed := now.Sub(last); elapsed < rl.interval {
|
||||||
return false, rl.interval - elapsed
|
return false, rl.interval - elapsed, time.Time{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rl.last[docID] = now
|
rl.last[docID] = now
|
||||||
return true, 0
|
return true, 0, now
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release rolls back the slot a prior Allow recorded for docID, but only if no
|
||||||
|
// newer Allow has since claimed it. Called when an LLM pass fails: Allow runs
|
||||||
|
// before the model call, so without this a failed checkpoint would hold the
|
||||||
|
// per-document slot for the full interval and a retry (or the frontend's
|
||||||
|
// auto-retry) would hit the throttle path and get the stale/empty set back
|
||||||
|
// instead of re-running. `at` is the time the failed Allow returned.
|
||||||
|
func (rl *RateLimiter) Release(docID string, at time.Time) {
|
||||||
|
rl.mu.Lock()
|
||||||
|
defer rl.mu.Unlock()
|
||||||
|
if last, ok := rl.last[docID]; ok && last.Equal(at) {
|
||||||
|
delete(rl.last, docID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,11 +37,45 @@ func TestParseCheckpoint(t *testing.T) {
|
|||||||
raw: "I could not find any issues!",
|
raw: "I could not find any issues!",
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// A correct sentence echoed verbatim as its own replacement is a card
|
||||||
|
// that suggests nothing — dropped. The second item is a real edit.
|
||||||
|
name: "drops no-op where replacement equals original",
|
||||||
|
raw: `{"suggestions":[{"original":"It wasn't the most graceful morning.","replacement":"It wasn't the most graceful morning.","explanation":"used correctly here","type":"idiom"},{"original":"teh","replacement":"the","explanation":"typo","type":"grammar"}]}`,
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Whitespace-only difference is still a no-op once both are trimmed.
|
||||||
|
name: "drops no-op after whitespace trim",
|
||||||
|
raw: `{"suggestions":[{"original":"the cat sat","replacement":" the cat sat ","explanation":"already fine","type":"grammar"}]}`,
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Awareness-only voice flags carry an empty replacement and must survive.
|
||||||
|
name: "keeps awareness-only flag with empty replacement",
|
||||||
|
raw: `{"suggestions":[{"original":"a long run-on passage","replacement":"","explanation":"this drifts from your voice","type":"voice"}]}`,
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "braces inside string values",
|
name: "braces inside string values",
|
||||||
raw: `{"suggestions":[{"original":"use {x}","replacement":"use x","explanation":"drop the braces","type":"clarity"}]}`,
|
raw: `{"suggestions":[{"original":"use {x}","replacement":"use x","explanation":"drop the braces","type":"clarity"}]}`,
|
||||||
want: 1,
|
want: 1,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// The bug that 502'd every long doc: the model hit num_predict and the
|
||||||
|
// JSON never closed. Two suggestions completed; the third is cut off.
|
||||||
|
// Salvage keeps the two that completed instead of failing outright.
|
||||||
|
name: "truncated mid-array salvages completed objects",
|
||||||
|
raw: `{"suggestions":[{"original":"I has","replacement":"I have","explanation":"subject-verb agreement","type":"grammar"},{"original":"a apple","replacement":"an apple","explanation":"use an before a vowel","type":"grammar"},{"original":"she relised","replacement":"she real`,
|
||||||
|
want: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A truncated array where even the first object is incomplete has
|
||||||
|
// nothing to salvage → still an error (the caller releases + retries).
|
||||||
|
name: "truncated before any object closes",
|
||||||
|
raw: `{"suggestions":[{"original":"It was a thursdy morning when Clara`,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -65,16 +99,31 @@ func TestParseCheckpoint(t *testing.T) {
|
|||||||
func TestRateLimiter(t *testing.T) {
|
func TestRateLimiter(t *testing.T) {
|
||||||
rl := NewRateLimiter(30 * time.Second)
|
rl := NewRateLimiter(30 * time.Second)
|
||||||
|
|
||||||
if ok, _ := rl.Allow("doc1"); !ok {
|
ok, _, at := rl.Allow("doc1")
|
||||||
|
if !ok {
|
||||||
t.Fatal("first call should be allowed")
|
t.Fatal("first call should be allowed")
|
||||||
}
|
}
|
||||||
if ok, retry := rl.Allow("doc1"); ok || retry <= 0 {
|
if ok, retry, _ := rl.Allow("doc1"); ok || retry <= 0 {
|
||||||
t.Fatalf("immediate second call should be throttled, got ok=%v retry=%v", ok, retry)
|
t.Fatalf("immediate second call should be throttled, got ok=%v retry=%v", ok, retry)
|
||||||
}
|
}
|
||||||
// A different document is independent.
|
// A different document is independent.
|
||||||
if ok, _ := rl.Allow("doc2"); !ok {
|
if ok, _, _ := rl.Allow("doc2"); !ok {
|
||||||
t.Fatal("different doc should be allowed")
|
t.Fatal("different doc should be allowed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Releasing doc1's slot (as a failed pass does) lets the next check re-run
|
||||||
|
// immediately instead of waiting out the interval.
|
||||||
|
rl.Release("doc1", at)
|
||||||
|
if ok, _, _ := rl.Allow("doc1"); !ok {
|
||||||
|
t.Fatal("after Release the next call should be allowed again")
|
||||||
|
}
|
||||||
|
// Release only rolls back its own slot: a stale timestamp must not evict the
|
||||||
|
// slot a newer Allow holds (so a late failure can't cancel a fresh in-flight
|
||||||
|
// check). doc2 still holds its slot from above; a zero-time Release is a no-op.
|
||||||
|
rl.Release("doc2", time.Time{})
|
||||||
|
if ok, _, _ := rl.Allow("doc2"); ok {
|
||||||
|
t.Fatal("stale Release must not free doc2's current slot")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTruncateDoc(t *testing.T) {
|
func TestTruncateDoc(t *testing.T) {
|
||||||
|
|||||||
35
internal/llm/collocation.go
Normal file
35
internal/llm/collocation.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CollocationInterval is the minimum gap between collocation passes for one
|
||||||
|
// document. Like the voice pass it runs on an explicit user action ("Make it
|
||||||
|
// sound natural") rather than a typing cadence, so this floor only guards the
|
||||||
|
// inference endpoint against the button being mashed.
|
||||||
|
const CollocationInterval = 25 * time.Second
|
||||||
|
|
||||||
|
// RunCollocation sends the WHOLE document for a collocation pass — gentle
|
||||||
|
// "natives usually say…" hints on non-native word pairings — and parses the JSON
|
||||||
|
// result with the checkpoint's tolerant parser. It is deliberately NOT
|
||||||
|
// TruncateDoc'd: like the voice pass it reads the whole document so the hints
|
||||||
|
// reflect the full piece. Each flag carries a native replacement to apply.
|
||||||
|
//
|
||||||
|
// The tone argument is accepted for a uniform pass signature and passed through
|
||||||
|
// to the prompt so a hint can prefer a register-appropriate pairing.
|
||||||
|
func RunCollocation(ctx context.Context, client LLMClient, contentText, tone string) ([]RawSuggestion, error) {
|
||||||
|
raw, err := client.Complete(ctx, CompletionRequest{
|
||||||
|
Messages: CollocationMessages(contentText, tone),
|
||||||
|
MaxTokens: 2048,
|
||||||
|
Temperature: 0.3,
|
||||||
|
RepetitionPenalty: 1.15,
|
||||||
|
TopP: 0.9,
|
||||||
|
Stop: []string{"```", "\n\n\n\n"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ParseCheckpoint(raw)
|
||||||
|
}
|
||||||
@@ -92,6 +92,54 @@ func VoiceMessages(contentText string) []Message {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// collocationSystemPrompt drives the collocation coach — the single most
|
||||||
|
// valuable polish for an ESL writer. It flags word PAIRINGS that are not wrong,
|
||||||
|
// just non-native ("do a decision" → "make a decision", "strong rain" → "heavy
|
||||||
|
// rain"), and explicitly DEFERS real grammar/spelling errors to the grammar
|
||||||
|
// checkpoint so the two families don't overlap. Every explanation is framed as a
|
||||||
|
// warm "natives usually say…" note with a short Mandarin gloss — never
|
||||||
|
// "error/wrong" — because these are stylistic, not mistakes. It is a distinct
|
||||||
|
// pass from the grammar checkpoint (do not bundle them). `replacement` carries
|
||||||
|
// the natural pairing the writer can accept in one tap.
|
||||||
|
const collocationSystemPrompt = `You are a warm, encouraging writing assistant helping someone who speaks English as a second language. ` +
|
||||||
|
`You are reviewing a COMPLETE document for COLLOCATIONS only — the natural word pairings native speakers use.
|
||||||
|
|
||||||
|
A collocation is a pair or short group of words that native speakers habitually say together. ESL writers ` +
|
||||||
|
`often choose words that are grammatically correct but sound non-native: "do a decision" instead of "make a decision", ` +
|
||||||
|
`"strong rain" instead of "heavy rain", "say a joke" instead of "tell a joke". These are NOT grammar mistakes — they ` +
|
||||||
|
`are just not what a native speaker would naturally say.
|
||||||
|
|
||||||
|
Identify up to 5 such non-native word pairings. For each, give the natural pairing a native speaker would use. ` +
|
||||||
|
`Be gentle and specific. Do NOT flag grammar errors, spelling mistakes, or unclear sentences — those are handled ` +
|
||||||
|
`elsewhere. Only flag word pairings that are correct but sound non-native.%s
|
||||||
|
|
||||||
|
Phrase every explanation warmly as "Natives usually say…" and include a brief Simplified Chinese gloss in parentheses. ` +
|
||||||
|
`Never use the words "error", "wrong", or "mistake" — these are friendly polish, not corrections.
|
||||||
|
|
||||||
|
Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
|
||||||
|
{
|
||||||
|
"suggestions": [
|
||||||
|
{
|
||||||
|
"original": "exact word pairing from the document",
|
||||||
|
"replacement": "the natural native pairing",
|
||||||
|
"explanation": "friendly note, e.g. 'Natives usually say \"make a decision\" rather than \"do a decision\" (native usage / 地道说法).'",
|
||||||
|
"type": "collocation"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
If every pairing already sounds natural, return: {"suggestions": []}`
|
||||||
|
|
||||||
|
// CollocationMessages builds the message array for a collocation pass over the
|
||||||
|
// WHOLE document (no truncation), gently steered toward the document's tone so a
|
||||||
|
// hint can prefer a register-appropriate pairing.
|
||||||
|
func CollocationMessages(contentText, tone string) []Message {
|
||||||
|
return []Message{
|
||||||
|
{Role: "system", Content: fmt.Sprintf(collocationSystemPrompt, toneGuidance(tone))},
|
||||||
|
{Role: "user", Content: contentText},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// askPetalSystemTemplate is the Ask Petal tutor prompt. The suggestion context
|
// askPetalSystemTemplate is the Ask Petal tutor prompt. The suggestion context
|
||||||
// is interpolated in; the user's own messages are appended after this system
|
// is interpolated in; the user's own messages are appended after this system
|
||||||
// turn by the caller.
|
// turn by the caller.
|
||||||
@@ -120,3 +168,69 @@ Keep responses concise (2-4 sentences). This is a chat, not an essay. Be encoura
|
|||||||
func AskPetalSystemPrompt(original, replacement, suggestionType, explanation, paragraph string) string {
|
func AskPetalSystemPrompt(original, replacement, suggestionType, explanation, paragraph string) string {
|
||||||
return fmt.Sprintf(askPetalSystemTemplate, original, replacement, suggestionType, explanation, paragraph)
|
return fmt.Sprintf(askPetalSystemTemplate, original, replacement, suggestionType, explanation, paragraph)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rewriteSystemTemplate drives the "say it more naturally" / tone-rewrite tool.
|
||||||
|
// The writer selects a passage and picks a style; the model rewrites that
|
||||||
|
// passage in place. The instruction is deliberately strict about returning ONLY
|
||||||
|
// the rewritten passage so the result can be dropped straight into the editor —
|
||||||
|
// no quotes, no preamble, no commentary to strip.
|
||||||
|
const rewriteSystemTemplate = `You are Petal, a warm English writing assistant helping someone who speaks English ` +
|
||||||
|
`as a second language. Rewrite the passage the user sends so that it %s, while preserving its original ` +
|
||||||
|
`meaning. Fix any grammar mistakes and awkward phrasing along the way. Keep it about the same length — ` +
|
||||||
|
`do not add new ideas, explanations, or commentary.
|
||||||
|
|
||||||
|
Respond with ONLY the rewritten passage. No quotation marks around it, no preamble, no notes — just the ` +
|
||||||
|
`rewritten English text, ready to drop back into the document.`
|
||||||
|
|
||||||
|
// styleGuidance maps a rewrite style onto the clause describing the target
|
||||||
|
// register. "natural" is the default "say it more naturally" action; the rest
|
||||||
|
// mirror the document-tone vocabulary (see toneGuidance / the ToneSelect UI).
|
||||||
|
// An unknown style falls back to the natural rewrite.
|
||||||
|
func styleGuidance(style string) string {
|
||||||
|
switch style {
|
||||||
|
case "academic":
|
||||||
|
return "reads as formal, academic English suited to a school essay or research paper"
|
||||||
|
case "professional":
|
||||||
|
return "reads as polished, professional English suited to a workplace email or report"
|
||||||
|
case "casual":
|
||||||
|
return "sounds relaxed, friendly, and conversational"
|
||||||
|
case "humorous":
|
||||||
|
return "has a light, playful, good-humored tone"
|
||||||
|
case "creative":
|
||||||
|
return "is vivid, expressive, and imaginative"
|
||||||
|
case "persuasive":
|
||||||
|
return "is confident and persuasive"
|
||||||
|
default: // "natural"
|
||||||
|
return "sounds natural and fluent, the way a native English speaker would naturally say it"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RewriteMessages builds the message array for a tone-rewrite: the styled system
|
||||||
|
// instruction plus the passage to rewrite as the user turn.
|
||||||
|
func RewriteMessages(text, style string) []Message {
|
||||||
|
return []Message{
|
||||||
|
{Role: "system", Content: fmt.Sprintf(rewriteSystemTemplate, styleGuidance(style))},
|
||||||
|
{Role: "user", Content: text},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// translateSystemPrompt drives the explanation translator: it renders a
|
||||||
|
// suggestion's English explanation into Simplified Chinese so an ESL reader sees
|
||||||
|
// the "why" in her first language. Strict about returning ONLY the translation
|
||||||
|
// (no quotes, no pinyin, no English echo) so it can drop straight into the chat
|
||||||
|
// bubble. Kept warm and plain — these are short, friendly one-liners.
|
||||||
|
const translateSystemPrompt = `You are Petal, a warm writing assistant. Translate the English text the user ` +
|
||||||
|
`sends into natural, friendly Simplified Chinese (Mandarin). It is a short explanation of a writing ` +
|
||||||
|
`suggestion, written for a native Chinese speaker learning English.
|
||||||
|
|
||||||
|
Respond with ONLY the Simplified Chinese translation. No quotation marks, no pinyin, no English, no preamble — ` +
|
||||||
|
`just the translated sentence.`
|
||||||
|
|
||||||
|
// TranslateMessages builds the message array for translating one short English
|
||||||
|
// explanation into Simplified Chinese.
|
||||||
|
func TranslateMessages(text string) []Message {
|
||||||
|
return []Message{
|
||||||
|
{Role: "system", Content: translateSystemPrompt},
|
||||||
|
{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
|
||||||
|
}
|
||||||
23
internal/llm/translate.go
Normal file
23
internal/llm/translate.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RunTranslate renders a short English explanation into Simplified Chinese. It
|
||||||
|
// is a one-shot Complete (the result seeds the Ask Petal bubble), kept at a low
|
||||||
|
// temperature so the translation is faithful rather than creative. Output is
|
||||||
|
// trimmed of any stray surrounding quotes the model may add.
|
||||||
|
func RunTranslate(ctx context.Context, client LLMClient, text string) (string, error) {
|
||||||
|
out, err := client.Complete(ctx, CompletionRequest{
|
||||||
|
Messages: TranslateMessages(text),
|
||||||
|
MaxTokens: 512,
|
||||||
|
Temperature: 0.2,
|
||||||
|
TopP: 0.9,
|
||||||
|
RepetitionPenalty: 1.1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return cleanRewrite(out), nil
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||||
"gitea.parodia.dev/drwily/petal/internal/llm"
|
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ func (h *Handler) chat(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
var body chatRequest
|
var body chatRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
errorJSON(w, http.StatusBadRequest, "invalid request body")
|
httputil.ErrorJSON(w, http.StatusBadRequest, "invalid request body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,11 +49,11 @@ func (h *Handler) chat(w http.ResponseWriter, r *http.Request) {
|
|||||||
sugID, db.LocalUserID,
|
sugID, db.LocalUserID,
|
||||||
).Scan(&original, &replacement, &explanation, &typ, &fromPos, &contentText)
|
).Scan(&original, &replacement, &explanation, &typ, &fromPos, &contentText)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
errorJSON(w, http.StatusNotFound, "suggestion not found")
|
httputil.ErrorJSON(w, http.StatusNotFound, "suggestion not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ func (h *Handler) chat(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Flush through; bail with a plain error if somehow they don't.
|
// Flush through; bail with a plain error if somehow they don't.
|
||||||
flusher, ok := w.(http.Flusher)
|
flusher, ok := w.(http.Flusher)
|
||||||
if !ok {
|
if !ok {
|
||||||
serverError(w, errors.New("streaming unsupported"))
|
httputil.ServerError(w, errors.New("streaming unsupported"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +72,7 @@ func (h *Handler) chat(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
// The stream never opened (e.g. LLM unreachable) — a normal JSON error is
|
// The stream never opened (e.g. LLM unreachable) — a normal JSON error is
|
||||||
// still appropriate since we haven't written SSE headers yet.
|
// still appropriate since we haven't written SSE headers yet.
|
||||||
errorJSON(w, http.StatusBadGateway, "chat failed: "+err.Error())
|
httputil.ErrorJSON(w, http.StatusBadGateway, "chat failed: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||||
"gitea.parodia.dev/drwily/petal/internal/llm"
|
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,15 +29,18 @@ type Handler struct {
|
|||||||
Client llm.LLMClient
|
Client llm.LLMClient
|
||||||
Limit *llm.RateLimiter // grammar checkpoint floor
|
Limit *llm.RateLimiter // grammar checkpoint floor
|
||||||
VoiceLimit *llm.RateLimiter // voice-consistency floor
|
VoiceLimit *llm.RateLimiter // voice-consistency floor
|
||||||
|
CollocationLimit *llm.RateLimiter // collocation-coach floor
|
||||||
}
|
}
|
||||||
|
|
||||||
// New constructs a Handler with per-document checkpoint and voice rate limiters.
|
// New constructs a Handler with per-document checkpoint, voice, and collocation
|
||||||
|
// rate limiters.
|
||||||
func New(database *db.DB, client llm.LLMClient) *Handler {
|
func New(database *db.DB, client llm.LLMClient) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
DB: database,
|
DB: database,
|
||||||
Client: client,
|
Client: client,
|
||||||
Limit: llm.NewRateLimiter(llm.CheckpointInterval),
|
Limit: llm.NewRateLimiter(llm.CheckpointInterval),
|
||||||
VoiceLimit: llm.NewRateLimiter(llm.VoiceInterval),
|
VoiceLimit: llm.NewRateLimiter(llm.VoiceInterval),
|
||||||
|
CollocationLimit: llm.NewRateLimiter(llm.CollocationInterval),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +48,10 @@ func New(database *db.DB, client llm.LLMClient) *Handler {
|
|||||||
// the existing /api/docs router so they share its base path.
|
// the existing /api/docs router so they share its base path.
|
||||||
func (h *Handler) RegisterDocRoutes(r chi.Router) {
|
func (h *Handler) RegisterDocRoutes(r chi.Router) {
|
||||||
r.Post("/{id}/check", h.check)
|
r.Post("/{id}/check", h.check)
|
||||||
|
r.Post("/{id}/mechanics", h.mechanics)
|
||||||
r.Post("/{id}/voice", h.voice)
|
r.Post("/{id}/voice", h.voice)
|
||||||
|
r.Post("/{id}/collocation", h.collocation)
|
||||||
|
r.Post("/{id}/rewrite", h.rewrite)
|
||||||
r.Get("/{id}/suggestions", h.listForDoc)
|
r.Get("/{id}/suggestions", h.listForDoc)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,20 +62,136 @@ func (h *Handler) Routes() chi.Router {
|
|||||||
r.Post("/{id}/accept", h.accept)
|
r.Post("/{id}/accept", h.accept)
|
||||||
r.Post("/{id}/dismiss", h.dismiss)
|
r.Post("/{id}/dismiss", h.dismiss)
|
||||||
r.Post("/{id}/chat", h.chat)
|
r.Post("/{id}/chat", h.chat)
|
||||||
|
r.Post("/{id}/translate", h.translate)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
// check runs a grammar checkpoint over the document. Fast, typing-cadence pass.
|
// check runs a grammar checkpoint over the document. Fast, typing-cadence pass.
|
||||||
|
// The deterministic mechanics family is owned by a separate pass (see mechanics),
|
||||||
|
// detected client-side; a grammar checkpoint leaves those flags untouched.
|
||||||
func (h *Handler) check(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) check(w http.ResponseWriter, r *http.Request) {
|
||||||
h.runPass(w, r, h.Limit, llm.RunCheckpoint, grammarScope)
|
h.runPass(w, r, h.Limit, llm.RunCheckpoint, grammarScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mechanicsFinding is one deterministic, rule-based fix detected client-side (see
|
||||||
|
// web Companion/prose.ts). Detection lives in the frontend — the same rules that
|
||||||
|
// power the companion's prose notes — so the server only persists these; it does
|
||||||
|
// not compute them. Offsets are exact plaintext spans from the detector.
|
||||||
|
type mechanicsFinding struct {
|
||||||
|
From int `json:"from"`
|
||||||
|
To int `json:"to"`
|
||||||
|
Original string `json:"original"`
|
||||||
|
Replacement string `json:"replacement"`
|
||||||
|
Explanation string `json:"explanation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxMechanicsFindings caps a single submission so a runaway client can't flood
|
||||||
|
// the table; far above any realistic count for one document.
|
||||||
|
const maxMechanicsFindings = 500
|
||||||
|
|
||||||
|
// mechanics persists the client-detected deterministic fixes as the 'mechanics'
|
||||||
|
// family and returns the document's unified pending set. Free and not rate-
|
||||||
|
// limited — it runs alongside the grammar checkpoint. It mirrors a single LLM
|
||||||
|
// family: it replaces only the pending mechanics rows, honours actioned-
|
||||||
|
// suppression, and (unlike the LLM passes) keeps the detector's exact offsets
|
||||||
|
// rather than re-locating by string, which matters when the same word repeats.
|
||||||
|
func (h *Handler) mechanics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
docID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
// Confirm the document exists (and is the local user's) for clean 404s.
|
||||||
|
var exists bool
|
||||||
|
err := h.DB.QueryRow(
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM documents WHERE id = ? AND user_id = ?)`,
|
||||||
|
docID, db.LocalUserID,
|
||||||
|
).Scan(&exists)
|
||||||
|
if err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
httputil.ErrorJSON(w, http.StatusNotFound, "document not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Findings []mechanicsFinding `json:"findings"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
|
||||||
|
httputil.BadRequest(w, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(body.Findings) > maxMechanicsFindings {
|
||||||
|
body.Findings = body.Findings[:maxMechanicsFindings]
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.replaceMechanics(docID, body.Findings); err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := h.fetchPending(docID)
|
||||||
|
if err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httputil.WriteJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// replaceMechanics swaps the document's pending mechanics rows for the supplied
|
||||||
|
// findings in one transaction, leaving the LLM families and actioned rows
|
||||||
|
// untouched. Findings the user already accepted or dismissed are suppressed (the
|
||||||
|
// detector has no memory between runs), and malformed spans are skipped.
|
||||||
|
func (h *Handler) replaceMechanics(docID string, findings []mechanicsFinding) error {
|
||||||
|
tx, err := h.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`DELETE FROM suggestions WHERE doc_id = ? AND status = ? AND type = ?`,
|
||||||
|
docID, db.SuggestionStatusPending, db.SuggestionTypeMechanics,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sup, err := buildSuppressor(tx, docID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range findings {
|
||||||
|
if f.From < 0 || f.To <= f.From || strings.TrimSpace(f.Original) == "" {
|
||||||
|
continue // malformed span — the client re-anchors by string anyway
|
||||||
|
}
|
||||||
|
if sup.suppressed(f.Original, f.Replacement) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
docID, f.From, f.To, f.Original, f.Replacement, f.Explanation, db.SuggestionTypeMechanics,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
// voice runs a Tier-1 voice-consistency pass over the whole document. Slow,
|
// voice runs a Tier-1 voice-consistency pass over the whole document. Slow,
|
||||||
// explicit-action pass; replaces only the pending voice flags.
|
// explicit-action pass; replaces only the pending voice flags.
|
||||||
func (h *Handler) voice(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) voice(w http.ResponseWriter, r *http.Request) {
|
||||||
h.runPass(w, r, h.VoiceLimit, llm.RunVoice, voiceScope)
|
h.runPass(w, r, h.VoiceLimit, llm.RunVoice, voiceScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// collocation runs the collocation coach over the whole document, flagging
|
||||||
|
// non-native word pairings. Explicit-action pass; replaces only the pending
|
||||||
|
// collocation flags.
|
||||||
|
func (h *Handler) collocation(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.runPass(w, r, h.CollocationLimit, llm.RunCollocation, collocationScope)
|
||||||
|
}
|
||||||
|
|
||||||
// pass is the signature shared by the grammar checkpoint and the voice pass:
|
// pass is the signature shared by the grammar checkpoint and the voice pass:
|
||||||
// given the document text and the document's tone it returns the model's raw
|
// given the document text and the document's tone it returns the model's raw
|
||||||
// suggestions. The voice pass ignores tone (see llm.RunVoice).
|
// suggestions. The voice pass ignores tone (see llm.RunVoice).
|
||||||
@@ -86,40 +210,45 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
|
|||||||
docID, db.LocalUserID,
|
docID, db.LocalUserID,
|
||||||
).Scan(&contentText, &tone)
|
).Scan(&contentText, &tone)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
errorJSON(w, http.StatusNotFound, "document not found")
|
httputil.ErrorJSON(w, http.StatusNotFound, "document not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nothing to analyze on an empty document — skip the LLM round-trip.
|
// Nothing to analyze on an empty document — skip the LLM round-trip.
|
||||||
if strings.TrimSpace(contentText) == "" {
|
if strings.TrimSpace(contentText) == "" {
|
||||||
writeJSON(w, http.StatusOK, []db.Suggestion{})
|
httputil.WriteJSON(w, http.StatusOK, []db.Suggestion{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if ok, _ := limiter.Allow(docID); !ok {
|
ok, _, slotAt := limiter.Allow(docID)
|
||||||
|
if !ok {
|
||||||
// Throttled: return the existing pending set unchanged rather than an
|
// Throttled: return the existing pending set unchanged rather than an
|
||||||
// error, so the frontend keeps showing current suggestions.
|
// error, so the frontend keeps showing current suggestions.
|
||||||
existing, err := h.fetchPending(docID)
|
existing, err := h.fetchPending(docID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, existing)
|
httputil.WriteJSON(w, http.StatusOK, existing)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, err := run(r.Context(), h.Client, contentText, tone)
|
raw, err := run(r.Context(), h.Client, contentText, tone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorJSON(w, http.StatusBadGateway, "llm pass failed: "+err.Error())
|
// Allow ran before the model call, so a failed pass would otherwise hold
|
||||||
|
// the per-document slot for the full interval — stranding the frontend's
|
||||||
|
// auto-retry on the throttle path. Release it so a retry can re-run.
|
||||||
|
limiter.Release(docID, slotAt)
|
||||||
|
httputil.ErrorJSON(w, http.StatusBadGateway, "llm pass failed: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.replacePending(docID, contentText, raw, scope); err != nil {
|
if err := h.replacePending(docID, contentText, raw, scope); err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,10 +257,10 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
|
|||||||
// throttle path above stays consistent with the success path.
|
// throttle path above stays consistent with the success path.
|
||||||
out, err := h.fetchPending(docID)
|
out, err := h.fetchPending(docID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, out)
|
httputil.WriteJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// pendingScope describes how one LLM pass touches the shared suggestions table:
|
// pendingScope describes how one LLM pass touches the shared suggestions table:
|
||||||
@@ -144,15 +273,28 @@ type pendingScope struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// grammarScope owns the grammar/phrasing/idiom/clarity flags (everything but voice).
|
// grammarScope owns the grammar/phrasing/idiom/clarity flags — everything but
|
||||||
grammarScope = pendingScope{deleteWhere: "type != 'voice'", forceType: ""}
|
// the other self-owned families (voice, collocation, mechanics), which run on
|
||||||
|
// their own cadence/pass and must survive a grammar checkpoint. Notably the
|
||||||
|
// deterministic mechanics pass writes its rows in the same /check request just
|
||||||
|
// before this DELETE runs, so excluding it here is what keeps them alive.
|
||||||
|
grammarScope = pendingScope{deleteWhere: "type NOT IN ('voice','collocation','mechanics')", forceType: ""}
|
||||||
// voiceScope owns the voice flags only.
|
// voiceScope owns the voice flags only.
|
||||||
voiceScope = pendingScope{deleteWhere: "type = 'voice'", forceType: db.SuggestionTypeVoice}
|
voiceScope = pendingScope{deleteWhere: "type = 'voice'", forceType: db.SuggestionTypeVoice}
|
||||||
|
// collocationScope owns the collocation flags only.
|
||||||
|
collocationScope = pendingScope{deleteWhere: "type = 'collocation'", forceType: db.SuggestionTypeCollocation}
|
||||||
)
|
)
|
||||||
|
|
||||||
// replacePending swaps a document's pending suggestions within one family for a
|
// replacePending swaps a document's pending suggestions within one family for a
|
||||||
// fresh batch in a single transaction. Accepted/rejected suggestions and the
|
// fresh batch in a single transaction. Accepted/rejected suggestions and the
|
||||||
// other family's pending rows are left untouched.
|
// other family's pending rows are left untouched.
|
||||||
|
//
|
||||||
|
// Suggestions touching a sentence the user already settled are suppressed from
|
||||||
|
// the fresh batch (see suppressor): not just the identical edit re-proposed, but
|
||||||
|
// reversals and re-polishing of the model's own just-accepted output — the
|
||||||
|
// "fickle, keeps going back and forth on a few sentences" behavior. The model has
|
||||||
|
// no memory between passes, so without this it re-opens resolved sentences every
|
||||||
|
// checkpoint.
|
||||||
func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggestion, scope pendingScope) error {
|
func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggestion, scope pendingScope) error {
|
||||||
tx, err := h.DB.Begin()
|
tx, err := h.DB.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -167,7 +309,15 @@ func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggest
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sup, err := buildSuppressor(tx, docID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
for _, s := range raw {
|
for _, s := range raw {
|
||||||
|
if sup.suppressed(s.Original, s.Replacement) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
typ := scope.forceType
|
typ := scope.forceType
|
||||||
if typ == "" {
|
if typ == "" {
|
||||||
typ = normalizeType(s.Type)
|
typ = normalizeType(s.Type)
|
||||||
@@ -185,15 +335,142 @@ func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggest
|
|||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dedupQuoteReplacer folds every straight/curly single- and double-quote variant
|
||||||
|
// (and backtick/acute accent) onto one canonical character. The editor and the
|
||||||
|
// model both rewrite quotes between passes — a sentence accepted with "…" comes
|
||||||
|
// back flagged with '…' — so without folding, byte-identical text reads as a
|
||||||
|
// different edit and the suppression below misses it. (This normalization is for
|
||||||
|
// dedup ONLY; the frontend still anchors on the verbatim `original`.)
|
||||||
|
var dedupQuoteReplacer = strings.NewReplacer(
|
||||||
|
"‘", "'", "’", "'", "‚", "'", "‛", "'", // single curly
|
||||||
|
"“", "'", "”", "'", "„", "'", "″", "'", // double curly
|
||||||
|
"\"", "'", "`", "'", "´", "'", // straight double, backtick, acute
|
||||||
|
)
|
||||||
|
|
||||||
|
// normalizeForDedup canonicalizes a string for suppression comparisons: quotes
|
||||||
|
// folded (above) and runs of whitespace collapsed to single spaces (so a reflowed
|
||||||
|
// paragraph still matches). Used only to decide what to suppress, never to alter
|
||||||
|
// stored or rendered text.
|
||||||
|
func normalizeForDedup(s string) string {
|
||||||
|
return strings.Join(strings.Fields(dedupQuoteReplacer.Replace(s)), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// suppressor decides which fresh suggestions to drop because the user has already
|
||||||
|
// settled the sentence they touch. The model has no memory between passes, so on
|
||||||
|
// every checkpoint it re-examines the current text and proposes edits — including
|
||||||
|
// ones that re-open a sentence the user already resolved. Three families of those
|
||||||
|
// are suppressed (all compared under normalizeForDedup):
|
||||||
|
//
|
||||||
|
// - pairs: the identical edit, re-proposed verbatim (the original "accept it,
|
||||||
|
// then it nags again" case; also covers a silent no-op accept that left the
|
||||||
|
// text unchanged).
|
||||||
|
// - actionedOrig: any edit whose original is a span the user already accepted or
|
||||||
|
// dismissed an edit on — "you already decided about this exact sentence."
|
||||||
|
// - acceptedRepl: any edit whose original is text the user accepted AS a
|
||||||
|
// replacement — i.e. the model re-touching its own just-accepted output, which
|
||||||
|
// is how the reversals and endless re-polishing arise (accept "due to the
|
||||||
|
// rain", next pass proposes changing "due to the rain" back). Guarded to
|
||||||
|
// multi-word spans so word-level fixes aren't swept up as collateral.
|
||||||
|
// - acceptedReplList holds the same accepted replacements for a containment
|
||||||
|
// check: the model evades the exact acceptedRepl match by re-flagging a
|
||||||
|
// *sub-clause* of an accepted sentence (flag "she was…due to the rain" instead
|
||||||
|
// of the whole sentence). When one of the new original / an accepted
|
||||||
|
// replacement contains the other and the shorter side is substantial
|
||||||
|
// (>= minContainWords words), it's the same settled span and is dropped.
|
||||||
|
type suppressor struct {
|
||||||
|
pairs map[string]struct{}
|
||||||
|
actionedOrig map[string]struct{}
|
||||||
|
acceptedRepl map[string]struct{}
|
||||||
|
acceptedReplList []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// minContainWords is the floor for the containment check: the shorter of the two
|
||||||
|
// spans must be at least this many words before a substring relationship counts
|
||||||
|
// as "the same settled text." High enough that an incidental common phrase ("the
|
||||||
|
// rain") can't suppress an unrelated sentence, low enough to catch a re-flagged
|
||||||
|
// clause.
|
||||||
|
const minContainWords = 4
|
||||||
|
|
||||||
|
// suppressed reports whether a fresh suggestion should be dropped as already
|
||||||
|
// settled. An empty original is never suppressed here (it can't anchor anyway and
|
||||||
|
// is dropped upstream).
|
||||||
|
func (s suppressor) suppressed(original, replacement string) bool {
|
||||||
|
o := normalizeForDedup(original)
|
||||||
|
if o == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, ok := s.pairs[o+"\x00"+normalizeForDedup(replacement)]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, ok := s.actionedOrig[o]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.ContainsRune(o, ' ') {
|
||||||
|
if _, ok := s.acceptedRepl[o]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Containment: the model re-flagged a sub-clause of (or a window around) an
|
||||||
|
// accepted span. Suppress when one contains the other and the shorter side is
|
||||||
|
// a substantial multi-word run.
|
||||||
|
for _, r := range s.acceptedReplList {
|
||||||
|
shorter, longer := o, r
|
||||||
|
if len(r) < len(o) {
|
||||||
|
shorter, longer = r, o
|
||||||
|
}
|
||||||
|
if len(strings.Fields(shorter)) >= minContainWords && strings.Contains(longer, shorter) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSuppressor loads the document's accepted/rejected edits and indexes them
|
||||||
|
// into the three suppression families described on suppressor.
|
||||||
|
func buildSuppressor(tx *sql.Tx, docID string) (suppressor, error) {
|
||||||
|
rows, err := tx.Query(
|
||||||
|
`SELECT original, replacement, status FROM suggestions
|
||||||
|
WHERE doc_id = ? AND status IN (?, ?)`,
|
||||||
|
docID, db.SuggestionStatusAccepted, db.SuggestionStatusRejected,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return suppressor{}, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
s := suppressor{
|
||||||
|
pairs: make(map[string]struct{}),
|
||||||
|
actionedOrig: make(map[string]struct{}),
|
||||||
|
acceptedRepl: make(map[string]struct{}),
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var original, replacement, status string
|
||||||
|
if err := rows.Scan(&original, &replacement, &status); err != nil {
|
||||||
|
return suppressor{}, err
|
||||||
|
}
|
||||||
|
o := normalizeForDedup(original)
|
||||||
|
r := normalizeForDedup(replacement)
|
||||||
|
s.pairs[o+"\x00"+r] = struct{}{}
|
||||||
|
if o != "" {
|
||||||
|
s.actionedOrig[o] = struct{}{}
|
||||||
|
}
|
||||||
|
if status == db.SuggestionStatusAccepted && r != "" {
|
||||||
|
s.acceptedRepl[r] = struct{}{}
|
||||||
|
s.acceptedReplList = append(s.acceptedReplList, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// listForDoc returns the document's current pending suggestions (used when the
|
// listForDoc returns the document's current pending suggestions (used when the
|
||||||
// editor loads a document, before any new checkpoint fires).
|
// editor loads a document, before any new checkpoint fires).
|
||||||
func (h *Handler) listForDoc(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) listForDoc(w http.ResponseWriter, r *http.Request) {
|
||||||
out, err := h.fetchPending(chi.URLParam(r, "id"))
|
out, err := h.fetchPending(chi.URLParam(r, "id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, out)
|
httputil.WriteJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) fetchPending(docID string) ([]db.Suggestion, error) {
|
func (h *Handler) fetchPending(docID string) ([]db.Suggestion, error) {
|
||||||
@@ -220,7 +497,51 @@ func (h *Handler) fetchPending(docID string) ([]db.Suggestion, error) {
|
|||||||
}
|
}
|
||||||
out = append(out, s)
|
out = append(out, s)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return dedupeSpans(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dedupeSpans resolves collisions between the deterministic mechanics family and
|
||||||
|
// the LLM families: when a mechanics finding and an LLM suggestion fight over the
|
||||||
|
// same characters, mechanics wins and the LLM card is dropped. Its span is exact
|
||||||
|
// (the detector matched it), whereas the LLM positions are only advisory
|
||||||
|
// (re-anchored by string at render), so the precise fix should own the span.
|
||||||
|
//
|
||||||
|
// This deliberately does NOT dedupe LLM-vs-LLM overlaps: voice (awareness-only,
|
||||||
|
// no replacement) and collocation legitimately co-occupy the same span, and that
|
||||||
|
// is intended. Suggestions that never anchored (from_pos < 0) occupy no real span
|
||||||
|
// and are always kept.
|
||||||
|
func dedupeSpans(in []db.Suggestion) []db.Suggestion {
|
||||||
|
type span struct{ from, to int }
|
||||||
|
var claimed []span
|
||||||
|
for _, s := range in {
|
||||||
|
if s.Type == db.SuggestionTypeMechanics && s.FromPos >= 0 {
|
||||||
|
claimed = append(claimed, span{s.FromPos, s.ToPos})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(claimed) == 0 {
|
||||||
|
return in
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]db.Suggestion, 0, len(in))
|
||||||
|
for _, s := range in {
|
||||||
|
if s.Type != db.SuggestionTypeMechanics && s.FromPos >= 0 {
|
||||||
|
overlaps := false
|
||||||
|
for _, sp := range claimed {
|
||||||
|
if s.FromPos < sp.to && sp.from < s.ToPos {
|
||||||
|
overlaps = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if overlaps {
|
||||||
|
continue // an exact mechanics fix owns these characters
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// accept marks a suggestion accepted (the client applies the replacement text).
|
// accept marks a suggestion accepted (the client applies the replacement text).
|
||||||
@@ -239,11 +560,11 @@ func (h *Handler) setStatus(w http.ResponseWriter, r *http.Request, status strin
|
|||||||
status, chi.URLParam(r, "id"), db.SuggestionStatusPending,
|
status, chi.URLParam(r, "id"), db.SuggestionStatusPending,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if n, _ := res.RowsAffected(); n == 0 {
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
errorJSON(w, http.StatusNotFound, "pending suggestion not found")
|
httputil.ErrorJSON(w, http.StatusNotFound, "pending suggestion not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
@@ -264,25 +585,9 @@ func locate(contentText, original string) (int, int) {
|
|||||||
// defaulting unknown values to grammar so a stray label never trips the CHECK.
|
// defaulting unknown values to grammar so a stray label never trips the CHECK.
|
||||||
func normalizeType(t string) string {
|
func normalizeType(t string) string {
|
||||||
switch strings.ToLower(strings.TrimSpace(t)) {
|
switch strings.ToLower(strings.TrimSpace(t)) {
|
||||||
case db.SuggestionTypeGrammar, db.SuggestionTypePhrasing, db.SuggestionTypeIdiom, db.SuggestionTypeClarity:
|
case db.SuggestionTypeGrammar, db.SuggestionTypePhrasing, db.SuggestionTypeIdiom, db.SuggestionTypeClarity, db.SuggestionTypeCollocation:
|
||||||
return strings.ToLower(strings.TrimSpace(t))
|
return strings.ToLower(strings.TrimSpace(t))
|
||||||
default:
|
default:
|
||||||
return db.SuggestionTypeGrammar
|
return db.SuggestionTypeGrammar
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- response helpers -------------------------------------------------------
|
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
_ = json.NewEncoder(w).Encode(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
func errorJSON(w http.ResponseWriter, status int, msg string) {
|
|
||||||
writeJSON(w, status, map[string]string{"error": msg})
|
|
||||||
}
|
|
||||||
|
|
||||||
func serverError(w http.ResponseWriter, err error) {
|
|
||||||
errorJSON(w, http.StatusInternalServerError, err.Error())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -127,6 +127,86 @@ func TestCheckpointFlow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestResolvedSuggestionsNotReproposed proves a checkpoint won't re-nag a
|
||||||
|
// sentence the user already accepted or dismissed: the model returns the same
|
||||||
|
// raw batch on the next pass (it has no memory), but the resolved edits are
|
||||||
|
// suppressed. This is the "accept it, then it nags again moments later" bug.
|
||||||
|
func TestResolvedSuggestionsNotReproposed(t *testing.T) {
|
||||||
|
client := &stubClient{response: `{"suggestions":[
|
||||||
|
{"original":"I has","replacement":"I have","explanation":"subject-verb agreement","type":"grammar"},
|
||||||
|
{"original":"two apple","replacement":"two apples","explanation":"plural noun","type":"grammar"}
|
||||||
|
]}`}
|
||||||
|
srv, docID, h := newTestServer(t, client)
|
||||||
|
// Zero the checkpoint floor so the second pass runs instead of being throttled.
|
||||||
|
h.Limit = llm.NewRateLimiter(0)
|
||||||
|
|
||||||
|
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||||
|
var got []db.Suggestion
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("first pass: want 2, got %d", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept one, dismiss the other.
|
||||||
|
do(t, srv, http.MethodPost, "/suggestions/"+got[0].ID+"/accept", "")
|
||||||
|
do(t, srv, http.MethodPost, "/suggestions/"+got[1].ID+"/dismiss", "")
|
||||||
|
|
||||||
|
// Second checkpoint returns the identical raw batch — both must be suppressed.
|
||||||
|
rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("second check: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var again []db.Suggestion
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &again); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if len(again) != 0 {
|
||||||
|
t.Fatalf("resolved suggestions should not be re-proposed, got %d: %+v", len(again), again)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFickleEditsSuppressed proves the suppressor kills the "keeps going back and
|
||||||
|
// forth on a few sentences" behavior seen live on the Missing Key doc: a later
|
||||||
|
// pass that (a) reverses an edit the user just accepted — even with the editor's
|
||||||
|
// double→single quote churn that defeats a byte-exact match — or (b) re-polishes
|
||||||
|
// the model's own accepted output. A genuinely new edit on a fresh sentence still
|
||||||
|
// survives.
|
||||||
|
func TestFickleEditsSuppressed(t *testing.T) {
|
||||||
|
client := &stubClient{response: `{"suggestions":[
|
||||||
|
{"original":"He left \"early,\" because of the rain.","replacement":"He left \"early,\" due to the rain.","explanation":"smoother","type":"phrasing"},
|
||||||
|
{"original":"The cat always have a calm face.","replacement":"The cat always has a calm face.","explanation":"agreement","type":"grammar"}
|
||||||
|
]}`}
|
||||||
|
srv, docID, h := newTestServer(t, client)
|
||||||
|
h.Limit = llm.NewRateLimiter(0)
|
||||||
|
|
||||||
|
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||||
|
var got []db.Suggestion
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("first pass: want 2, got %d", len(got))
|
||||||
|
}
|
||||||
|
for _, s := range got {
|
||||||
|
do(t, srv, http.MethodPost, "/suggestions/"+s.ID+"/accept", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reversal of the first accept (note the " → ' quote churn) and a re-polish of
|
||||||
|
// the second accept must both be dropped; only the unrelated edit survives.
|
||||||
|
client.response = `{"suggestions":[
|
||||||
|
{"original":"He left 'early,' due to the rain.","replacement":"He left 'early,' because of the rain.","explanation":"reverts the accepted edit","type":"phrasing"},
|
||||||
|
{"original":"The cat always has a calm face.","replacement":"The cat always seems to have a calm face.","explanation":"re-polishes accepted output","type":"phrasing"},
|
||||||
|
{"original":"due to the rain","replacement":"because of the rain","explanation":"sub-clause reversal of the accepted span","type":"phrasing"},
|
||||||
|
{"original":"She drived home.","replacement":"She drove home.","explanation":"past tense","type":"grammar"}
|
||||||
|
]}`
|
||||||
|
rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||||
|
var again []db.Suggestion
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &again); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if len(again) != 1 || again[0].Original != "She drived home." {
|
||||||
|
t.Fatalf("want only the new edit to survive, got %d: %+v", len(again), again)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestVoicePassCoexists proves the voice pass and the grammar checkpoint own
|
// TestVoicePassCoexists proves the voice pass and the grammar checkpoint own
|
||||||
// disjoint pending families: running one never wipes the other's flags, and
|
// disjoint pending families: running one never wipes the other's flags, and
|
||||||
// each endpoint returns the unified pending set.
|
// each endpoint returns the unified pending set.
|
||||||
@@ -189,6 +269,65 @@ func TestVoicePassCoexists(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCollocationPassCoexists proves the collocation coach is a third
|
||||||
|
// independent family: it never wipes grammar or voice pending flags, a grammar
|
||||||
|
// checkpoint never wipes its flags, and each endpoint returns the unified set.
|
||||||
|
func TestCollocationPassCoexists(t *testing.T) {
|
||||||
|
client := &switchClient{}
|
||||||
|
srv, docID, h := newTestServer(t, client)
|
||||||
|
// Zero the floors so the test can re-run passes without waiting them out.
|
||||||
|
h.Limit = llm.NewRateLimiter(0)
|
||||||
|
h.VoiceLimit = llm.NewRateLimiter(0)
|
||||||
|
h.CollocationLimit = llm.NewRateLimiter(0)
|
||||||
|
|
||||||
|
// Grammar + voice first, so all three families are exercised.
|
||||||
|
client.response = `{"suggestions":[{"original":"I has","replacement":"I have","explanation":"agreement","type":"grammar"}]}`
|
||||||
|
if rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", ""); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("check: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
client.response = `{"suggestions":[{"original":"two apple","replacement":null,"explanation":"sounds formal","type":"voice"}]}`
|
||||||
|
if rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/voice", ""); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("voice: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collocation pass → a third flag (with a replacement). It must keep the
|
||||||
|
// grammar and voice flags; the response is the unified set of all three.
|
||||||
|
client.response = `{"suggestions":[{"original":"two apple","replacement":"two apples","explanation":"Natives usually say…","type":"collocation"}]}`
|
||||||
|
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/collocation", "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("collocation: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var got []db.Suggestion
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
byType := map[string]bool{}
|
||||||
|
for _, s := range got {
|
||||||
|
byType[s.Type] = true
|
||||||
|
}
|
||||||
|
if !byType[db.SuggestionTypeGrammar] || !byType[db.SuggestionTypeVoice] || !byType[db.SuggestionTypeCollocation] {
|
||||||
|
t.Fatalf("collocation response should carry all three families, got %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A grammar checkpoint must NOT wipe the voice or collocation flags.
|
||||||
|
client.response = `{"suggestions":[]}`
|
||||||
|
rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("second check: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||||
|
byType = map[string]bool{}
|
||||||
|
for _, s := range got {
|
||||||
|
byType[s.Type] = true
|
||||||
|
}
|
||||||
|
if byType[db.SuggestionTypeGrammar] {
|
||||||
|
t.Fatalf("grammar flag should have cleared, got %+v", got)
|
||||||
|
}
|
||||||
|
if !byType[db.SuggestionTypeVoice] || !byType[db.SuggestionTypeCollocation] {
|
||||||
|
t.Fatalf("grammar checkpoint wiped a sibling family, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// switchClient returns a response that can be swapped between calls.
|
// switchClient returns a response that can be swapped between calls.
|
||||||
type switchClient struct {
|
type switchClient struct {
|
||||||
response string
|
response string
|
||||||
@@ -219,3 +358,116 @@ func TestCheckpointRateLimit(t *testing.T) {
|
|||||||
t.Fatalf("rate limit should suppress the second model call, got %d calls", client.calls)
|
t.Fatalf("rate limit should suppress the second model call, got %d calls", client.calls)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// postMechanics submits a deterministic-findings batch (as the client's prose
|
||||||
|
// detector would) and returns the unified pending set.
|
||||||
|
func postMechanics(t *testing.T, srv http.Handler, docID, findingsJSON string) []db.Suggestion {
|
||||||
|
t.Helper()
|
||||||
|
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/mechanics", `{"findings":`+findingsJSON+`}`)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("mechanics: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var got []db.Suggestion
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMechanicsPersistsFindings proves the endpoint persists client-detected
|
||||||
|
// findings as a 'mechanics' family with the exact offsets the client supplied,
|
||||||
|
// and that a later grammar checkpoint leaves them untouched (own family).
|
||||||
|
func TestMechanicsPersistsFindings(t *testing.T) {
|
||||||
|
client := &stubClient{response: `{"suggestions":[]}`}
|
||||||
|
srv, docID, _ := newTestServer(t, client)
|
||||||
|
|
||||||
|
got := postMechanics(t, srv, docID, `[
|
||||||
|
{"from":0,"to":1,"original":"i","replacement":"I","explanation":"capitalize I"},
|
||||||
|
{"from":6,"to":13,"original":"the the","replacement":"the","explanation":"doubled word"}
|
||||||
|
]`)
|
||||||
|
var mech []db.Suggestion
|
||||||
|
for _, s := range got {
|
||||||
|
if s.Type == db.SuggestionTypeMechanics {
|
||||||
|
mech = append(mech, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(mech) != 2 {
|
||||||
|
t.Fatalf("want 2 mechanics findings, got %d: %+v", len(mech), got)
|
||||||
|
}
|
||||||
|
// The client's exact offsets are preserved verbatim.
|
||||||
|
for _, s := range mech {
|
||||||
|
if s.Original == "the the" && (s.FromPos != 6 || s.ToPos != 13) {
|
||||||
|
t.Errorf("offsets not preserved: %+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A grammar checkpoint must not wipe the mechanics family.
|
||||||
|
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("check: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var after []db.Suggestion
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &after)
|
||||||
|
count := 0
|
||||||
|
for _, s := range after {
|
||||||
|
if s.Type == db.SuggestionTypeMechanics {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count != 2 {
|
||||||
|
t.Fatalf("grammar checkpoint disturbed the mechanics family: got %d, want 2", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMechanicsWinsSpanCollision proves that when a mechanics finding and an LLM
|
||||||
|
// grammar suggestion claim overlapping characters, the LLM card is dropped from
|
||||||
|
// the response and the exact mechanics fix owns the span.
|
||||||
|
func TestMechanicsWinsSpanCollision(t *testing.T) {
|
||||||
|
// LLM grammar suggestion covers "the the cat", overlapping the mechanics
|
||||||
|
// "the the" finding at [0,7].
|
||||||
|
client := &stubClient{response: `{"suggestions":[
|
||||||
|
{"original":"the the cat","replacement":"the cat","explanation":"wordy","type":"grammar"}
|
||||||
|
]}`}
|
||||||
|
srv, docID, h := newTestServer(t, client)
|
||||||
|
if _, err := h.DB.Exec(`UPDATE documents SET content_text = ? WHERE id = ?`, "the the cat sat", docID); err != nil {
|
||||||
|
t.Fatalf("set content: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist the mechanics finding, then run the grammar pass.
|
||||||
|
postMechanics(t, srv, docID, `[{"from":0,"to":7,"original":"the the","replacement":"the","explanation":"doubled word"}]`)
|
||||||
|
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("check: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var got []db.Suggestion
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
for _, s := range got {
|
||||||
|
if s.Type == db.SuggestionTypeGrammar {
|
||||||
|
t.Fatalf("overlapping grammar card should have been dropped in favor of mechanics, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(got) != 1 || got[0].Type != db.SuggestionTypeMechanics {
|
||||||
|
t.Fatalf("want sole mechanics finding, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMechanicsActionedSuppression proves a dismissed mechanics fix is not
|
||||||
|
// re-proposed on the next submission of the same finding.
|
||||||
|
func TestMechanicsActionedSuppression(t *testing.T) {
|
||||||
|
client := &stubClient{response: `{"suggestions":[]}`}
|
||||||
|
srv, docID, _ := newTestServer(t, client)
|
||||||
|
|
||||||
|
findings := `[{"from":6,"to":13,"original":"the the","replacement":"the","explanation":"doubled word"}]`
|
||||||
|
got := postMechanics(t, srv, docID, findings)
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("want 1 finding, got %+v", got)
|
||||||
|
}
|
||||||
|
// Dismiss it, then resubmit the same finding — it must stay suppressed.
|
||||||
|
do(t, srv, http.MethodPost, "/suggestions/"+got[0].ID+"/dismiss", "")
|
||||||
|
again := postMechanics(t, srv, docID, findings)
|
||||||
|
if len(again) != 0 {
|
||||||
|
t.Fatalf("dismissed mechanics fix should not reappear, got %+v", again)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
77
internal/suggestions/rewrite.go
Normal file
77
internal/suggestions/rewrite.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
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/httputil"
|
||||||
|
"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 {
|
||||||
|
httputil.ErrorJSON(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
text := strings.TrimSpace(body.Text)
|
||||||
|
if text == "" {
|
||||||
|
httputil.ErrorJSON(w, http.StatusBadRequest, "no text to rewrite")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len([]rune(text)) > llm.RewriteMaxRunes {
|
||||||
|
httputil.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) {
|
||||||
|
httputil.ErrorJSON(w, http.StatusNotFound, "document not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := llm.RunRewrite(r.Context(), h.Client, text, body.Style)
|
||||||
|
if err != nil {
|
||||||
|
httputil.ErrorJSON(w, http.StatusBadGateway, "rewrite failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
httputil.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
58
internal/suggestions/translate.go
Normal file
58
internal/suggestions/translate.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package suggestions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type translateResponse struct {
|
||||||
|
Translation string `json:"translation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// translate renders a suggestion's English explanation into Simplified Chinese
|
||||||
|
// for the Ask Petal bubble, so the ESL reader sees the "why" in her first
|
||||||
|
// language instead of a second copy of the same English text. The explanation is
|
||||||
|
// loaded server-side from the suggestion id (scoped to the local user) and never
|
||||||
|
// trusted from the client, mirroring chat (spec Note #10).
|
||||||
|
func (h *Handler) translate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sugID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var explanation string
|
||||||
|
err := h.DB.QueryRow(
|
||||||
|
`SELECT s.explanation
|
||||||
|
FROM suggestions s
|
||||||
|
JOIN documents d ON d.id = s.doc_id
|
||||||
|
WHERE s.id = ? AND d.user_id = ?`,
|
||||||
|
sugID, db.LocalUserID,
|
||||||
|
).Scan(&explanation)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
httputil.ErrorJSON(w, http.StatusNotFound, "suggestion not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
explanation = strings.TrimSpace(explanation)
|
||||||
|
if explanation == "" {
|
||||||
|
httputil.WriteJSON(w, http.StatusOK, translateResponse{Translation: ""})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := llm.RunTranslate(r.Context(), h.Client, explanation)
|
||||||
|
if err != nil {
|
||||||
|
httputil.ErrorJSON(w, http.StatusBadGateway, "translate failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
httputil.WriteJSON(w, http.StatusOK, translateResponse{Translation: out})
|
||||||
|
}
|
||||||
264
internal/tts/handler.go
Normal file
264
internal/tts/handler.go
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
// Package tts implements read-aloud: it proxies short passages to a local Piper
|
||||||
|
// HTTP server (a fast, offline neural TTS), optionally transcodes Piper's WAV to
|
||||||
|
// mp3/opus with ffmpeg, and serves the audio back to the editor. Synthesized
|
||||||
|
// clips are content-addressed on disk so tapping the same word twice is instant
|
||||||
|
// and never re-synthesizes. The feature is entirely optional: when TTS_ENDPOINT
|
||||||
|
// is unset the route isn't mounted and the frontend falls back to the browser's
|
||||||
|
// Web Speech API.
|
||||||
|
package tts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxTextBytes bounds a single synthesis request. Read-aloud is for a word or a
|
||||||
|
// sentence or two, not whole documents; this keeps one tap from pinning Piper.
|
||||||
|
const maxTextBytes = 4000
|
||||||
|
|
||||||
|
// lengthScale slows Piper slightly below natural pace — a learner following along
|
||||||
|
// with the words, mirroring the old utterance.rate = 0.95. Higher = slower.
|
||||||
|
const lengthScale = 1.1
|
||||||
|
|
||||||
|
// audioFormat describes one output encoding: the cache-file extension, the
|
||||||
|
// response Content-Type, and the ffmpeg args that turn Piper's WAV (on stdin)
|
||||||
|
// into this format (on stdout). A nil ffmpegArgs means "serve the WAV as-is".
|
||||||
|
type audioFormat struct {
|
||||||
|
ext string
|
||||||
|
contentType string
|
||||||
|
ffmpegArgs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// formats maps the configured TTS_AUDIO_FORMAT to its encoding. mp3 is the safe
|
||||||
|
// default (every browser plays it); opus is ~half the size for speech but has
|
||||||
|
// patchier Safari support; wav skips ffmpeg entirely.
|
||||||
|
var formats = map[string]audioFormat{
|
||||||
|
"wav": {ext: ".wav", contentType: "audio/wav"},
|
||||||
|
"mp3": {ext: ".mp3", contentType: "audio/mpeg",
|
||||||
|
ffmpegArgs: []string{"-f", "mp3", "-c:a", "libmp3lame", "-b:a", "64k", "-ac", "1"}},
|
||||||
|
"opus": {ext: ".opus", contentType: "audio/ogg",
|
||||||
|
ffmpegArgs: []string{"-f", "ogg", "-c:a", "libopus", "-b:a", "32k", "-ac", "1"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
// route is the Piper instance and voice id serving one language. Each Piper
|
||||||
|
// HTTP server loads exactly one model, so distinct languages mean distinct
|
||||||
|
// endpoints (e.g. English on :5005, Chinese on :5006).
|
||||||
|
type route struct {
|
||||||
|
endpoint string // Piper HTTP base URL (no trailing slash)
|
||||||
|
voice string // Piper voice id (sent in the request; also part of the cache key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler proxies synthesis to Piper and caches the result on disk.
|
||||||
|
type Handler struct {
|
||||||
|
routes map[string]route // base language (e.g. "en", "zh") -> Piper instance
|
||||||
|
cacheDir string
|
||||||
|
format audioFormat
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a Handler from config. It returns (nil, false) when TTS_ENDPOINT is
|
||||||
|
// unset, signalling main to skip mounting the route. An unknown TTS_AUDIO_FORMAT
|
||||||
|
// falls back to mp3 rather than failing the whole server.
|
||||||
|
func New(cfg *config.Config) (*Handler, bool) {
|
||||||
|
if strings.TrimSpace(cfg.TTSEndpoint) == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
format, ok := formats[strings.ToLower(strings.TrimSpace(cfg.TTSFormat))]
|
||||||
|
if !ok {
|
||||||
|
format = formats["mp3"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map by base language so en-US, en-GB, etc. all resolve to the English
|
||||||
|
// instance (the client sends BCP-47 tags like the old Web Speech path did).
|
||||||
|
// A language is only routable when both its endpoint and voice are set;
|
||||||
|
// otherwise the client falls back to Web Speech for that language.
|
||||||
|
routes := map[string]route{}
|
||||||
|
if cfg.TTSVoiceEN != "" {
|
||||||
|
routes["en"] = route{strings.TrimRight(cfg.TTSEndpoint, "/"), cfg.TTSVoiceEN}
|
||||||
|
}
|
||||||
|
if cfg.TTSEndpointZH != "" && cfg.TTSVoiceZH != "" {
|
||||||
|
routes["zh"] = route{strings.TrimRight(cfg.TTSEndpointZH, "/"), cfg.TTSVoiceZH}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(cfg.TTSCacheDir, 0o755); err != nil {
|
||||||
|
// A missing cache dir isn't fatal — synthesis still works, it just won't
|
||||||
|
// cache. Disable the feature only on endpoint absence, not this.
|
||||||
|
fmt.Fprintf(os.Stderr, "tts: cache dir %q: %v\n", cfg.TTSCacheDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Handler{
|
||||||
|
routes: routes,
|
||||||
|
cacheDir: cfg.TTSCacheDir,
|
||||||
|
format: format,
|
||||||
|
client: &http.Client{Timeout: cfg.TTSTimeout},
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes mounts the synthesis endpoint. Mount under "/tts" so the full path is
|
||||||
|
// POST /api/tts.
|
||||||
|
func (h *Handler) Routes() chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Post("/", h.synth)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// synthRequest is the body the editor posts: a passage and the BCP-47 language
|
||||||
|
// tag it's written in (e.g. "en-US", "zh-CN").
|
||||||
|
type synthRequest struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Lang string `json:"lang"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// synth resolves a voice for the requested language, returns cached audio when
|
||||||
|
// present, and otherwise asks Piper to synthesize, transcodes if configured,
|
||||||
|
// caches, and serves. An unconfigured language yields 404 so the client can fall
|
||||||
|
// back to Web Speech without treating it as an error.
|
||||||
|
func (h *Handler) synth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req synthRequest
|
||||||
|
// Cap the raw body generously above maxTextBytes to leave room for JSON
|
||||||
|
// framing and the lang field; the text itself is truncated after decoding.
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, maxTextBytes+1024)).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
text := strings.TrimSpace(req.Text)
|
||||||
|
if text == "" {
|
||||||
|
http.Error(w, "empty text", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(text) > maxTextBytes {
|
||||||
|
// Trim back to the last valid rune boundary so we never hand Piper a
|
||||||
|
// half-encoded multibyte character (common with Chinese, 3 bytes/char).
|
||||||
|
text = text[:maxTextBytes]
|
||||||
|
for len(text) > 0 && !utf8.ValidString(text) {
|
||||||
|
text = text[:len(text)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rt, ok := h.routes[baseLang(req.Lang)]
|
||||||
|
if !ok {
|
||||||
|
// No voice for this language — let the client fall back to Web Speech.
|
||||||
|
http.Error(w, "no voice for language", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content-addressed: identical (voice, text) → identical clip. The format
|
||||||
|
// extension keeps encodings from colliding in the same dir.
|
||||||
|
sum := sha256.Sum256([]byte(rt.voice + "\n" + text))
|
||||||
|
name := hex.EncodeToString(sum[:])[:32] + h.format.ext
|
||||||
|
path := filepath.Join(h.cacheDir, name)
|
||||||
|
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
h.serve(w, r, path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
audio, err := h.synthesize(r.Context(), rt, text)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "synthesis failed", http.StatusBadGateway)
|
||||||
|
fmt.Fprintf(os.Stderr, "tts: synthesize: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort cache write via a temp file + rename so a concurrent reader
|
||||||
|
// never sees a half-written clip. A failed write just means no caching.
|
||||||
|
if h.cacheDir != "" {
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, audio, 0o644); err == nil {
|
||||||
|
_ = os.Rename(tmp, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", h.format.contentType)
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
_, _ = w.Write(audio)
|
||||||
|
}
|
||||||
|
|
||||||
|
// serve streams a cached clip with a long-lived immutable cache header (the URL
|
||||||
|
// is content-addressed, so the bytes never change for a given request).
|
||||||
|
func (h *Handler) serve(w http.ResponseWriter, r *http.Request, path string) {
|
||||||
|
w.Header().Set("Content-Type", h.format.contentType)
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
http.ServeFile(w, r, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// synthesize POSTs to the route's Piper instance, then transcodes the returned
|
||||||
|
// WAV when the configured format calls for it.
|
||||||
|
func (h *Handler) synthesize(ctx context.Context, rt route, text string) ([]byte, error) {
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"text": text,
|
||||||
|
"voice": rt.voice,
|
||||||
|
"length_scale": lengthScale,
|
||||||
|
})
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, rt.endpoint+"/", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := h.client.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||||
|
return nil, fmt.Errorf("piper %d: %s", resp.StatusCode, strings.TrimSpace(string(snippet)))
|
||||||
|
}
|
||||||
|
wav, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.format.ffmpegArgs == nil {
|
||||||
|
return wav, nil
|
||||||
|
}
|
||||||
|
return transcode(ctx, wav, h.format.ffmpegArgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// transcode pipes WAV bytes through ffmpeg (stdin → stdout) into the target
|
||||||
|
// encoding. ffmpeg is assumed on PATH; an error here surfaces as a 502.
|
||||||
|
func transcode(ctx context.Context, wav []byte, args []string) ([]byte, error) {
|
||||||
|
// Guard ffmpeg against a hang independent of the HTTP client timeout.
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
full := append([]string{"-hide_banner", "-loglevel", "error", "-i", "pipe:0"}, args...)
|
||||||
|
full = append(full, "pipe:1")
|
||||||
|
cmd := exec.CommandContext(ctx, "ffmpeg", full...)
|
||||||
|
cmd.Stdin = bytes.NewReader(wav)
|
||||||
|
var out, errBuf bytes.Buffer
|
||||||
|
cmd.Stdout = &out
|
||||||
|
cmd.Stderr = &errBuf
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return nil, fmt.Errorf("ffmpeg: %v: %s", err, strings.TrimSpace(errBuf.String()))
|
||||||
|
}
|
||||||
|
return out.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// baseLang reduces a BCP-47 tag to its primary subtag, lowercased: "en-US" → "en",
|
||||||
|
// "zh-CN" → "zh", "" → "". Mirrors the client's pickVoice base-language matching.
|
||||||
|
func baseLang(tag string) string {
|
||||||
|
tag = strings.ToLower(strings.TrimSpace(tag))
|
||||||
|
if i := strings.IndexAny(tag, "-_"); i >= 0 {
|
||||||
|
return tag[:i]
|
||||||
|
}
|
||||||
|
return tag
|
||||||
|
}
|
||||||
170
internal/tts/handler_test.go
Normal file
170
internal/tts/handler_test.go
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
package tts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newStubPiper returns a fake Piper server that echoes a fixed WAV body and
|
||||||
|
// records how many times it was called and the last request payload.
|
||||||
|
func newStubPiper(t *testing.T, body []byte) (*httptest.Server, *int32, *synthEcho) {
|
||||||
|
t.Helper()
|
||||||
|
var calls int32
|
||||||
|
last := &synthEcho{}
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
atomic.AddInt32(&calls, 1)
|
||||||
|
var req map[string]any
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
last.voice, _ = req["voice"].(string)
|
||||||
|
last.text, _ = req["text"].(string)
|
||||||
|
w.Header().Set("Content-Type", "audio/wav")
|
||||||
|
_, _ = w.Write(body)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv, &calls, last
|
||||||
|
}
|
||||||
|
|
||||||
|
type synthEcho struct{ voice, text string }
|
||||||
|
|
||||||
|
// newHandler builds a wav-format handler (no ffmpeg) pointed at a stub server.
|
||||||
|
func newHandler(t *testing.T, endpoint string) *Handler {
|
||||||
|
t.Helper()
|
||||||
|
return &Handler{
|
||||||
|
routes: map[string]route{"en": {strings.TrimRight(endpoint, "/"), "en_US-amy-medium"}},
|
||||||
|
cacheDir: t.TempDir(),
|
||||||
|
format: formats["wav"],
|
||||||
|
client: http.DefaultClient,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func post(t *testing.T, h *Handler, text, lang string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
b, _ := json.Marshal(synthRequest{Text: text, Lang: lang})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(b))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
h.synth(rr, req)
|
||||||
|
return rr
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSynthSuccessAndVoiceSelection(t *testing.T) {
|
||||||
|
wav := []byte("RIFF....fake-wav")
|
||||||
|
srv, calls, last := newStubPiper(t, wav)
|
||||||
|
h := newHandler(t, srv.URL)
|
||||||
|
|
||||||
|
rr := post(t, h, "hello there", "en-US")
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
if got := rr.Body.Bytes(); !bytes.Equal(got, wav) {
|
||||||
|
t.Fatalf("body = %q, want the piper wav", got)
|
||||||
|
}
|
||||||
|
if ct := rr.Header().Get("Content-Type"); ct != "audio/wav" {
|
||||||
|
t.Fatalf("content-type = %q, want audio/wav", ct)
|
||||||
|
}
|
||||||
|
if last.voice != "en_US-amy-medium" {
|
||||||
|
t.Fatalf("piper voice = %q, want en_US-amy-medium", last.voice)
|
||||||
|
}
|
||||||
|
if *calls != 1 {
|
||||||
|
t.Fatalf("piper calls = %d, want 1", *calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoutesByLanguageToSeparateInstances(t *testing.T) {
|
||||||
|
enSrv, enCalls, _ := newStubPiper(t, []byte("EN-wav"))
|
||||||
|
zhSrv, zhCalls, zhLast := newStubPiper(t, []byte("ZH-wav"))
|
||||||
|
h := &Handler{
|
||||||
|
routes: map[string]route{
|
||||||
|
"en": {strings.TrimRight(enSrv.URL, "/"), "en_US-amy-medium"},
|
||||||
|
"zh": {strings.TrimRight(zhSrv.URL, "/"), "zh_CN-huayan-medium"},
|
||||||
|
},
|
||||||
|
cacheDir: t.TempDir(),
|
||||||
|
format: formats["wav"],
|
||||||
|
client: http.DefaultClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
if rr := post(t, h, "你好世界", "zh-CN"); rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("zh status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
if *zhCalls != 1 || *enCalls != 0 {
|
||||||
|
t.Fatalf("calls en=%d zh=%d, want en=0 zh=1 (zh routed to zh instance)", *enCalls, *zhCalls)
|
||||||
|
}
|
||||||
|
if zhLast.voice != "zh_CN-huayan-medium" {
|
||||||
|
t.Fatalf("zh voice = %q, want zh_CN-huayan-medium", zhLast.voice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownLanguageReturns404(t *testing.T) {
|
||||||
|
srv, calls, _ := newStubPiper(t, []byte("x"))
|
||||||
|
h := newHandler(t, srv.URL)
|
||||||
|
|
||||||
|
rr := post(t, h, "你好", "zh-CN") // only "en" is configured
|
||||||
|
if rr.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404", rr.Code)
|
||||||
|
}
|
||||||
|
if *calls != 0 {
|
||||||
|
t.Fatalf("piper calls = %d, want 0 (should not synthesize)", *calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCacheHitSkipsPiper(t *testing.T) {
|
||||||
|
srv, calls, _ := newStubPiper(t, []byte("RIFF....fake-wav"))
|
||||||
|
h := newHandler(t, srv.URL)
|
||||||
|
|
||||||
|
if rr := post(t, h, "same words", "en-US"); rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("first status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
if rr := post(t, h, "same words", "en-US"); rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("second status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
if *calls != 1 {
|
||||||
|
t.Fatalf("piper calls = %d, want 1 (second served from cache)", *calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptyTextReturns400(t *testing.T) {
|
||||||
|
srv, _, _ := newStubPiper(t, []byte("x"))
|
||||||
|
h := newHandler(t, srv.URL)
|
||||||
|
|
||||||
|
if rr := post(t, h, " ", "en-US"); rr.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTextIsCapped(t *testing.T) {
|
||||||
|
srv, _, last := newStubPiper(t, []byte("x"))
|
||||||
|
h := newHandler(t, srv.URL)
|
||||||
|
|
||||||
|
long := strings.Repeat("a", maxTextBytes+500)
|
||||||
|
if rr := post(t, h, long, "en-US"); rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
if len(last.text) != maxTextBytes {
|
||||||
|
t.Fatalf("piper received %d chars, want capped to %d", len(last.text), maxTextBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBaseLang(t *testing.T) {
|
||||||
|
cases := map[string]string{"en-US": "en", "EN_gb": "en", "zh-CN": "zh", "en": "en", "": ""}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := baseLang(in); got != want {
|
||||||
|
t.Errorf("baseLang(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure the stub's body is fully consumable (guards against the LimitReader cap
|
||||||
|
// accidentally truncating a normal request body in synth()).
|
||||||
|
func TestRequestBodyNotTruncated(t *testing.T) {
|
||||||
|
srv, _, last := newStubPiper(t, []byte("x"))
|
||||||
|
h := newHandler(t, srv.URL)
|
||||||
|
text := strings.Repeat("word ", 200) // ~1000 bytes, under the cap
|
||||||
|
post(t, h, text, "en-US")
|
||||||
|
if last.text != strings.TrimSpace(text) {
|
||||||
|
t.Fatalf("piper text length = %d, want %d", len(last.text), len(strings.TrimSpace(text)))
|
||||||
|
}
|
||||||
|
}
|
||||||
307
internal/vocab/handlers.go
Normal file
307
internal/vocab/handlers.go
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
package vocab
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Word is one entry in the vocabulary garden: the looked-up word with its gloss,
|
||||||
|
// phonetic, and the sentence it was met in, plus its spaced-repetition state.
|
||||||
|
type Word struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Word string `json:"word"`
|
||||||
|
Gloss string `json:"gloss"`
|
||||||
|
Definition string `json:"definition"` // English fallback meaning when there's no Chinese gloss
|
||||||
|
Phonetic string `json:"phonetic"`
|
||||||
|
Example string `json:"example"`
|
||||||
|
DocID *string `json:"doc_id"`
|
||||||
|
DueAt time.Time `json:"due_at"`
|
||||||
|
IntervalDays int `json:"interval_days"`
|
||||||
|
Ease float64 `json:"ease"`
|
||||||
|
Reps int `json:"reps"`
|
||||||
|
Lapses int `json:"lapses"`
|
||||||
|
LastReviewed *time.Time `json:"last_reviewed"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler owns the vocabulary-garden routes. Everything is scoped to the local
|
||||||
|
// user while auth is deferred.
|
||||||
|
type Handler struct {
|
||||||
|
DB *db.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(database *db.DB) *Handler { return &Handler{DB: database} }
|
||||||
|
|
||||||
|
// Routes mounts the garden endpoints under /api/vocab.
|
||||||
|
func (h *Handler) Routes() chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/", h.list) // the whole garden
|
||||||
|
r.Get("/due", h.due) // only the cards due for review now
|
||||||
|
r.Post("/", h.capture) // record/upsert a looked-up word
|
||||||
|
r.Post("/{id}/review", h.review)
|
||||||
|
r.Delete("/{id}", h.remove)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
const vocabColumns = `id, word, gloss, definition, phonetic, example, doc_id,
|
||||||
|
due_at, interval_days, ease, reps, lapses, last_reviewed, created_at`
|
||||||
|
|
||||||
|
func scanWord(s interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}) (Word, error) {
|
||||||
|
var w Word
|
||||||
|
err := s.Scan(
|
||||||
|
&w.ID, &w.Word, &w.Gloss, &w.Definition, &w.Phonetic, &w.Example, &w.DocID,
|
||||||
|
&w.DueAt, &w.IntervalDays, &w.Ease, &w.Reps, &w.Lapses, &w.LastReviewed, &w.CreatedAt,
|
||||||
|
)
|
||||||
|
return w, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// list returns the full garden, newest blossoms first.
|
||||||
|
func (h *Handler) list(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
h.queryList(w, `SELECT `+vocabColumns+` FROM vocab_words
|
||||||
|
WHERE user_id = ? ORDER BY created_at DESC`, db.LocalUserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// due returns only the cards whose review time has arrived, soonest first.
|
||||||
|
func (h *Handler) due(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
h.queryList(w, `SELECT `+vocabColumns+` FROM vocab_words
|
||||||
|
WHERE user_id = ? AND due_at <= datetime('now') ORDER BY due_at ASC`, db.LocalUserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) queryList(w http.ResponseWriter, query string, args ...any) {
|
||||||
|
rows, err := h.DB.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []Word{}
|
||||||
|
for rows.Next() {
|
||||||
|
word, err := scanWord(rows)
|
||||||
|
if err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out = append(out, word)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httputil.WriteJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
type captureRequest struct {
|
||||||
|
Word string `json:"word"`
|
||||||
|
Gloss string `json:"gloss"`
|
||||||
|
Definition string `json:"definition"`
|
||||||
|
Phonetic string `json:"phonetic"`
|
||||||
|
Example string `json:"example"`
|
||||||
|
DocID *string `json:"doc_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field-length caps. The captured fields come from the offline lexicon and the
|
||||||
|
// editor selection, not free-typed prose, so we clamp rather than reject — a
|
||||||
|
// lookup should never fail because a definition ran long. Counts are runes so a
|
||||||
|
// Chinese gloss isn't cut mid-character. The word is the upsert key, so an absurd
|
||||||
|
// "word" is bounded too.
|
||||||
|
const (
|
||||||
|
maxWordLen = 128
|
||||||
|
maxGlossLen = 512
|
||||||
|
maxDefinitionLen = 4096
|
||||||
|
maxPhoneticLen = 256
|
||||||
|
maxExampleLen = 4096
|
||||||
|
)
|
||||||
|
|
||||||
|
// clamp trims s to at most max runes (rune-safe so multibyte glosses survive).
|
||||||
|
func clamp(s string, max int) string {
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(r[:max])
|
||||||
|
}
|
||||||
|
|
||||||
|
// capture records a looked-up word. It's an idempotent upsert keyed on the word:
|
||||||
|
// a new word lands due tomorrow (interval 1 day); an existing word keeps its
|
||||||
|
// schedule untouched but refreshes its gloss/phonetic/example/doc_id so the most
|
||||||
|
// recent context wins. Looking words up IS the data source — no extra effort.
|
||||||
|
func (h *Handler) capture(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req captureRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
httputil.ErrorJSON(w, http.StatusBadRequest, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Normalize the same way the lexicon does (internal/lexicon: lower+trim) so
|
||||||
|
// the UNIQUE(user_id, word) upsert is genuinely idempotent — otherwise
|
||||||
|
// "Apple" at a sentence start and "apple" mid-line would create two separate
|
||||||
|
// cards on independent schedules.
|
||||||
|
word := clamp(strings.ToLower(strings.TrimSpace(req.Word)), maxWordLen)
|
||||||
|
if word == "" {
|
||||||
|
httputil.ErrorJSON(w, http.StatusBadRequest, "word is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Gloss = clamp(req.Gloss, maxGlossLen)
|
||||||
|
req.Definition = clamp(req.Definition, maxDefinitionLen)
|
||||||
|
req.Phonetic = clamp(req.Phonetic, maxPhoneticLen)
|
||||||
|
req.Example = clamp(req.Example, maxExampleLen)
|
||||||
|
|
||||||
|
// A blank doc_id is the same as none; otherwise the word must belong to a
|
||||||
|
// document the user actually owns. Without this check a stale or forged id
|
||||||
|
// would hit the foreign key and leak a raw "FOREIGN KEY constraint" 500
|
||||||
|
// instead of a clean 400 (and, once auth lands, would let a word be attached
|
||||||
|
// to another user's document).
|
||||||
|
if req.DocID != nil {
|
||||||
|
if strings.TrimSpace(*req.DocID) == "" {
|
||||||
|
req.DocID = nil
|
||||||
|
} else {
|
||||||
|
var ok int
|
||||||
|
err := h.DB.QueryRow(
|
||||||
|
`SELECT 1 FROM documents WHERE id = ? AND user_id = ?`,
|
||||||
|
*req.DocID, db.LocalUserID,
|
||||||
|
).Scan(&ok)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
httputil.ErrorJSON(w, http.StatusBadRequest, "unknown doc_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// New rows start due tomorrow; ON CONFLICT refreshes context but leaves the
|
||||||
|
// schedule (due_at/reps/interval/ease) alone so re-looking-up a word never
|
||||||
|
// resets its progress.
|
||||||
|
_, err := h.DB.Exec(
|
||||||
|
`INSERT INTO vocab_words (user_id, word, gloss, definition, phonetic, example, doc_id, due_at, interval_days)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now', '+1 day'), 1)
|
||||||
|
ON CONFLICT(user_id, word) DO UPDATE SET
|
||||||
|
gloss = excluded.gloss,
|
||||||
|
definition = excluded.definition,
|
||||||
|
phonetic = excluded.phonetic,
|
||||||
|
example = CASE WHEN excluded.example != '' THEN excluded.example ELSE vocab_words.example END,
|
||||||
|
doc_id = COALESCE(excluded.doc_id, vocab_words.doc_id)`,
|
||||||
|
db.LocalUserID, word, req.Gloss, req.Definition, req.Phonetic, req.Example, req.DocID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := h.fetch(word)
|
||||||
|
if err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httputil.WriteJSON(w, http.StatusCreated, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch loads one word row by its (user, word) key.
|
||||||
|
func (h *Handler) fetch(word string) (Word, error) {
|
||||||
|
return scanWord(h.DB.QueryRow(
|
||||||
|
`SELECT `+vocabColumns+` FROM vocab_words WHERE user_id = ? AND word = ?`,
|
||||||
|
db.LocalUserID, word,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
type reviewRequest struct {
|
||||||
|
Grade Grade `json:"grade"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// review grades one card and reschedules it. The grade drives the SM-2-lite
|
||||||
|
// scheduler; the new interval is applied as `due_at = now + interval days`.
|
||||||
|
func (h *Handler) review(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
var req reviewRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
httputil.ErrorJSON(w, http.StatusBadRequest, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Grade != GradeAgain && req.Grade != GradeGood && req.Grade != GradeEasy {
|
||||||
|
httputil.ErrorJSON(w, http.StatusBadRequest, "grade must be again, good, or easy")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The grade is computed in Go (the SM-2-lite scheduler), so the read and the
|
||||||
|
// write must be one atomic unit: a bare SELECT-then-UPDATE could interleave
|
||||||
|
// with a concurrent review of the same card and lose an update. Wrap both in a
|
||||||
|
// transaction.
|
||||||
|
tx, err := h.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback() // no-op once committed
|
||||||
|
|
||||||
|
var cur State
|
||||||
|
err = tx.QueryRow(
|
||||||
|
`SELECT reps, interval_days, ease, lapses FROM vocab_words WHERE id = ? AND user_id = ?`,
|
||||||
|
id, db.LocalUserID,
|
||||||
|
).Scan(&cur.Reps, &cur.Interval, &cur.Ease, &cur.Lapses)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
httputil.ErrorJSON(w, http.StatusNotFound, "word not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nxt := cur.next(req.Grade)
|
||||||
|
// `datetime('now', '+N days')` keeps the stored value in SQLite's canonical
|
||||||
|
// text format, matching CURRENT_TIMESTAMP and the due query's comparison.
|
||||||
|
offset := "+" + strconv.Itoa(nxt.Interval) + " days"
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`UPDATE vocab_words SET
|
||||||
|
reps = ?, interval_days = ?, ease = ?, lapses = ?,
|
||||||
|
last_reviewed = datetime('now'), due_at = datetime('now', ?)
|
||||||
|
WHERE id = ? AND user_id = ?`,
|
||||||
|
nxt.Reps, nxt.Interval, nxt.Ease, nxt.Lapses, offset, id, db.LocalUserID,
|
||||||
|
); err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := scanWord(tx.QueryRow(
|
||||||
|
`SELECT `+vocabColumns+` FROM vocab_words WHERE id = ? AND user_id = ?`, id, db.LocalUserID,
|
||||||
|
))
|
||||||
|
if err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httputil.WriteJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove deletes a word from the garden (e.g. the writer already knows it).
|
||||||
|
func (h *Handler) remove(w http.ResponseWriter, r *http.Request) {
|
||||||
|
res, err := h.DB.Exec(
|
||||||
|
`DELETE FROM vocab_words WHERE id = ? AND user_id = ?`,
|
||||||
|
chi.URLParam(r, "id"), db.LocalUserID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
httputil.ErrorJSON(w, http.StatusNotFound, "word not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
249
internal/vocab/handlers_test.go
Normal file
249
internal/vocab/handlers_test.go
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
package vocab
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestServer(t *testing.T) (http.Handler, *db.DB) {
|
||||||
|
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() })
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Mount("/vocab", New(database).Routes())
|
||||||
|
return r, database
|
||||||
|
}
|
||||||
|
|
||||||
|
func do(t *testing.T, srv http.Handler, method, path, body string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
var r *http.Request
|
||||||
|
if body != "" {
|
||||||
|
r = httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||||
|
} else {
|
||||||
|
r = httptest.NewRequest(method, path, nil)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec, r)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCaptureUpsertAndReview walks the garden lifecycle: capture a word (lands
|
||||||
|
// in the future, not yet due), re-capture refreshes context without resetting
|
||||||
|
// schedule, a forced-due word shows up in /due, a review reschedules it, and
|
||||||
|
// delete removes it.
|
||||||
|
func TestCaptureUpsertAndReview(t *testing.T) {
|
||||||
|
srv, database := newTestServer(t)
|
||||||
|
|
||||||
|
// Capture a new word → 201, due tomorrow (interval 1), not in /due yet.
|
||||||
|
rec := do(t, srv, http.MethodPost, "/vocab",
|
||||||
|
`{"word":"serendipity","gloss":"机缘巧合","phonetic":"/ˌserənˈdɪpəti/","example":"What a serendipity."}`)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("capture: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var w Word
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &w)
|
||||||
|
if w.ID == "" || w.Word != "serendipity" || w.Gloss != "机缘巧合" {
|
||||||
|
t.Fatalf("captured word wrong: %+v", w)
|
||||||
|
}
|
||||||
|
if w.IntervalDays != 1 || w.Reps != 0 {
|
||||||
|
t.Fatalf("new word should start at interval 1, reps 0: %+v", w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not due yet (due tomorrow).
|
||||||
|
rec = do(t, srv, http.MethodGet, "/vocab/due", "")
|
||||||
|
var due []Word
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &due)
|
||||||
|
if len(due) != 0 {
|
||||||
|
t.Fatalf("freshly-captured word should not be due yet, got %d", len(due))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-capture with a new gloss → still one row (idempotent upsert), refreshed.
|
||||||
|
rec = do(t, srv, http.MethodPost, "/vocab",
|
||||||
|
`{"word":"serendipity","gloss":"意外的好运","phonetic":"/x/","example":""}`)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("re-capture: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
rec = do(t, srv, http.MethodGet, "/vocab", "")
|
||||||
|
var all []Word
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &all)
|
||||||
|
if len(all) != 1 {
|
||||||
|
t.Fatalf("re-capture should not add a row, got %d", len(all))
|
||||||
|
}
|
||||||
|
if all[0].Gloss != "意外的好运" {
|
||||||
|
t.Fatalf("gloss should refresh on re-capture, got %q", all[0].Gloss)
|
||||||
|
}
|
||||||
|
if all[0].Example != "What a serendipity." {
|
||||||
|
t.Fatalf("empty example should not clobber the prior one, got %q", all[0].Example)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force it due now, then it appears in /due.
|
||||||
|
if _, err := database.Exec(`UPDATE vocab_words SET due_at = datetime('now','-1 hour')`); err != nil {
|
||||||
|
t.Fatalf("force due: %v", err)
|
||||||
|
}
|
||||||
|
rec = do(t, srv, http.MethodGet, "/vocab/due", "")
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &due)
|
||||||
|
if len(due) != 1 {
|
||||||
|
t.Fatalf("forced-due word should be in /due, got %d", len(due))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Review "good" → reschedules forward (interval 3 = second ladder rung, since
|
||||||
|
// reps was 0 and a capture set interval 1 but reps 0; first good → rung 1 = 1).
|
||||||
|
rec = do(t, srv, http.MethodPost, "/vocab/"+w.ID+"/review", `{"grade":"good"}`)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("review: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var reviewed Word
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &reviewed)
|
||||||
|
if reviewed.Reps != 1 || reviewed.IntervalDays != 1 {
|
||||||
|
t.Fatalf("first good review should be reps 1, interval 1: %+v", reviewed)
|
||||||
|
}
|
||||||
|
if reviewed.LastReviewed == nil {
|
||||||
|
t.Fatalf("review should stamp last_reviewed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// After a forward review it's no longer due.
|
||||||
|
rec = do(t, srv, http.MethodGet, "/vocab/due", "")
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &due)
|
||||||
|
if len(due) != 0 {
|
||||||
|
t.Fatalf("reviewed word should leave /due, got %d", len(due))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bad grade → 400.
|
||||||
|
if rec = do(t, srv, http.MethodPost, "/vocab/"+w.ID+"/review", `{"grade":"nope"}`); rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("bad grade: want 400, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete → 204, garden empty.
|
||||||
|
if rec = do(t, srv, http.MethodDelete, "/vocab/"+w.ID, ""); rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("delete: code=%d", rec.Code)
|
||||||
|
}
|
||||||
|
if rec = do(t, srv, http.MethodDelete, "/vocab/"+w.ID, ""); rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("re-delete: want 404, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCaptureValidation rejects an empty word.
|
||||||
|
func TestCaptureValidation(t *testing.T) {
|
||||||
|
srv, _ := newTestServer(t)
|
||||||
|
if rec := do(t, srv, http.MethodPost, "/vocab", `{"word":" "}`); rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("empty word: want 400, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCaptureStoresDefinitionFallback proves a word with an English definition
|
||||||
|
// but no Chinese gloss still round-trips its definition, so review has a meaning
|
||||||
|
// to reveal instead of a blank card.
|
||||||
|
func TestCaptureStoresDefinitionFallback(t *testing.T) {
|
||||||
|
srv, _ := newTestServer(t)
|
||||||
|
rec := do(t, srv, http.MethodPost, "/vocab",
|
||||||
|
`{"word":"ineffable","definition":"too great to be expressed in words"}`)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("capture: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var w Word
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &w)
|
||||||
|
if w.Gloss != "" {
|
||||||
|
t.Fatalf("expected no gloss, got %q", w.Gloss)
|
||||||
|
}
|
||||||
|
if w.Definition != "too great to be expressed in words" {
|
||||||
|
t.Fatalf("definition not stored, got %q", w.Definition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCaptureCaseInsensitive proves "Apple" (sentence start) and "apple"
|
||||||
|
// (mid-line) land in the SAME garden card — capture normalizes to lower+trim
|
||||||
|
// like the lexicon, so the idempotent upsert isn't defeated by casing.
|
||||||
|
func TestCaptureCaseInsensitive(t *testing.T) {
|
||||||
|
srv, _ := newTestServer(t)
|
||||||
|
if rec := do(t, srv, http.MethodPost, "/vocab", `{"word":"Apple","gloss":"苹果"}`); rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("capture Apple: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if rec := do(t, srv, http.MethodPost, "/vocab", `{"word":" apple ","gloss":"苹果"}`); rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("capture apple: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
rec := do(t, srv, http.MethodGet, "/vocab", "")
|
||||||
|
var all []Word
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &all)
|
||||||
|
if len(all) != 1 {
|
||||||
|
t.Fatalf("Apple/apple should be one card, got %d", len(all))
|
||||||
|
}
|
||||||
|
if all[0].Word != "apple" {
|
||||||
|
t.Fatalf("word should be normalized to lowercase, got %q", all[0].Word)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCaptureUnknownDocID rejects an unowned/stale doc_id with a clean 400
|
||||||
|
// rather than letting it hit the foreign key and leak a raw 500.
|
||||||
|
func TestCaptureUnknownDocID(t *testing.T) {
|
||||||
|
srv, _ := newTestServer(t)
|
||||||
|
rec := do(t, srv, http.MethodPost, "/vocab",
|
||||||
|
`{"word":"quixotic","gloss":"不切实际的","doc_id":"does-not-exist"}`)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("unknown doc_id: want 400, got %d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
// A blank doc_id is treated as none, not an error.
|
||||||
|
if rec := do(t, srv, http.MethodPost, "/vocab", `{"word":"limpid","gloss":"清澈的","doc_id":""}`); rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("blank doc_id should be accepted as none: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCaptureClampsLongFields proves over-long fields are clamped (not rejected)
|
||||||
|
// so a lookup never fails on length, and the word key stays bounded.
|
||||||
|
func TestCaptureClampsLongFields(t *testing.T) {
|
||||||
|
srv, _ := newTestServer(t)
|
||||||
|
longWord := strings.Repeat("a", maxWordLen+50)
|
||||||
|
longDef := strings.Repeat("x", maxDefinitionLen+50)
|
||||||
|
rec := do(t, srv, http.MethodPost, "/vocab",
|
||||||
|
`{"word":"`+longWord+`","definition":"`+longDef+`"}`)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("capture: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var w Word
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &w)
|
||||||
|
if len([]rune(w.Word)) != maxWordLen {
|
||||||
|
t.Fatalf("word should clamp to %d runes, got %d", maxWordLen, len([]rune(w.Word)))
|
||||||
|
}
|
||||||
|
if len([]rune(w.Definition)) != maxDefinitionLen {
|
||||||
|
t.Fatalf("definition should clamp to %d runes, got %d", maxDefinitionLen, len([]rune(w.Definition)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDocLinkSurvivesDocDelete proves the ON DELETE SET NULL keeps a word in the
|
||||||
|
// garden when its source document is removed.
|
||||||
|
func TestDocLinkSurvivesDocDelete(t *testing.T) {
|
||||||
|
srv, database := newTestServer(t)
|
||||||
|
var docID string
|
||||||
|
if err := database.QueryRow(
|
||||||
|
`INSERT INTO documents (user_id, content_text) VALUES (?, 'hi') RETURNING id`, db.LocalUserID,
|
||||||
|
).Scan(&docID); err != nil {
|
||||||
|
t.Fatalf("seed doc: %v", err)
|
||||||
|
}
|
||||||
|
rec := do(t, srv, http.MethodPost, "/vocab", `{"word":"ephemeral","gloss":"短暂的","doc_id":"`+docID+`"}`)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("capture: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if _, err := database.Exec(`DELETE FROM documents WHERE id = ?`, docID); err != nil {
|
||||||
|
t.Fatalf("delete doc: %v", err)
|
||||||
|
}
|
||||||
|
rec = do(t, srv, http.MethodGet, "/vocab", "")
|
||||||
|
var all []Word
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &all)
|
||||||
|
if len(all) != 1 {
|
||||||
|
t.Fatalf("word should survive doc deletion, got %d", len(all))
|
||||||
|
}
|
||||||
|
if all[0].DocID != nil {
|
||||||
|
t.Fatalf("doc_id should be nulled after doc delete, got %v", *all[0].DocID)
|
||||||
|
}
|
||||||
|
}
|
||||||
114
internal/vocab/scheduler.go
Normal file
114
internal/vocab/scheduler.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
// Package vocab implements the vocabulary garden: it captures words the writer
|
||||||
|
// looks up and schedules them for gentle spaced-repetition review. The scheduler
|
||||||
|
// is a deliberately forgiving SM-2-lite / Leitner hybrid — no streaks to break,
|
||||||
|
// no harsh resets beyond a single step back — because this is a confidence
|
||||||
|
// builder, not a drill sergeant.
|
||||||
|
package vocab
|
||||||
|
|
||||||
|
import "math"
|
||||||
|
|
||||||
|
// Grade is the writer's self-assessment after seeing a flashcard's answer.
|
||||||
|
type Grade string
|
||||||
|
|
||||||
|
const (
|
||||||
|
GradeAgain Grade = "again" // didn't recall it — show again soon
|
||||||
|
GradeGood Grade = "good" // recalled it — advance one rung
|
||||||
|
GradeEasy Grade = "easy" // knew it instantly — advance a little further
|
||||||
|
)
|
||||||
|
|
||||||
|
// ladder is the Leitner interval ladder in days for the first several successful
|
||||||
|
// reviews: 1 → 3 → 7 → 16 → 35. Beyond the ladder, intervals grow by the card's
|
||||||
|
// ease factor so well-known words drift far into the future.
|
||||||
|
var ladder = []int{1, 3, 7, 16, 35}
|
||||||
|
|
||||||
|
const (
|
||||||
|
minEase = 1.3
|
||||||
|
maxEase = 3.0 // ease ceiling — keeps "easy" from compounding growth without bound
|
||||||
|
startEase = 2.5
|
||||||
|
easyBonus = 1.3 // multiplier applied on top of a "good" step for "easy"
|
||||||
|
easeAgainDelta = -0.20 // ease nudged down on a lapse (still floored at minEase)
|
||||||
|
easeEasyDelta = 0.15 // ease nudged up when a card feels easy
|
||||||
|
|
||||||
|
// maxInterval caps how far a card can drift into the future. Even a word
|
||||||
|
// graded "easy" many times resurfaces about once a year, so nothing silently
|
||||||
|
// leaves the garden forever (the whole point is to keep words in gentle
|
||||||
|
// rotation, not to retire them).
|
||||||
|
maxInterval = 365
|
||||||
|
)
|
||||||
|
|
||||||
|
// State is the spaced-repetition state of one card. It mirrors the scheduling
|
||||||
|
// columns on vocab_words so a review is "load State → next(grade) → persist".
|
||||||
|
type State struct {
|
||||||
|
Reps int
|
||||||
|
Interval int // days until the next review
|
||||||
|
Ease float64
|
||||||
|
Lapses int
|
||||||
|
}
|
||||||
|
|
||||||
|
// next returns the card's state after a review with the given grade. It never
|
||||||
|
// mutates the receiver. "again" steps the card back to a 1-day interval and
|
||||||
|
// counts a lapse (but only nudges ease down, never wipes progress harshly);
|
||||||
|
// "good" advances one rung of the ladder; "easy" advances a rung and a bit more.
|
||||||
|
func (s State) next(grade Grade) State {
|
||||||
|
ease := s.Ease
|
||||||
|
if ease == 0 {
|
||||||
|
ease = startEase
|
||||||
|
}
|
||||||
|
|
||||||
|
switch grade {
|
||||||
|
case GradeAgain:
|
||||||
|
return State{
|
||||||
|
Reps: 0,
|
||||||
|
Interval: 1,
|
||||||
|
Ease: clampEase(ease + easeAgainDelta),
|
||||||
|
Lapses: s.Lapses + 1,
|
||||||
|
}
|
||||||
|
case GradeEasy:
|
||||||
|
ease = clampEase(ease + easeEasyDelta)
|
||||||
|
reps := s.Reps + 1
|
||||||
|
return State{
|
||||||
|
Reps: reps,
|
||||||
|
Interval: clampInterval(int(math.Round(float64(goodInterval(reps, s.Interval, ease)) * easyBonus))),
|
||||||
|
Ease: ease,
|
||||||
|
Lapses: s.Lapses,
|
||||||
|
}
|
||||||
|
default: // GradeGood
|
||||||
|
reps := s.Reps + 1
|
||||||
|
return State{
|
||||||
|
Reps: reps,
|
||||||
|
Interval: clampInterval(goodInterval(reps, s.Interval, ease)),
|
||||||
|
Ease: ease,
|
||||||
|
Lapses: s.Lapses,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// goodInterval returns the day-interval for a successful review: the explicit
|
||||||
|
// ladder while it lasts, then geometric growth by ease once past it.
|
||||||
|
func goodInterval(reps, prevInterval int, ease float64) int {
|
||||||
|
if reps >= 1 && reps <= len(ladder) {
|
||||||
|
return ladder[reps-1]
|
||||||
|
}
|
||||||
|
prev := prevInterval
|
||||||
|
if prev < ladder[len(ladder)-1] {
|
||||||
|
prev = ladder[len(ladder)-1]
|
||||||
|
}
|
||||||
|
return int(math.Round(float64(prev) * ease))
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampEase(e float64) float64 {
|
||||||
|
if e < minEase {
|
||||||
|
return minEase
|
||||||
|
}
|
||||||
|
if e > maxEase {
|
||||||
|
return maxEase
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampInterval(d int) int {
|
||||||
|
if d > maxInterval {
|
||||||
|
return maxInterval
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
88
internal/vocab/scheduler_test.go
Normal file
88
internal/vocab/scheduler_test.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package vocab
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestLadderProgression walks a card up the Leitner ladder on repeated "good"
|
||||||
|
// reviews: 1 → 3 → 7 → 16 → 35 days, then geometric growth by ease.
|
||||||
|
func TestLadderProgression(t *testing.T) {
|
||||||
|
s := State{Ease: startEase}
|
||||||
|
want := []int{1, 3, 7, 16, 35}
|
||||||
|
for i, w := range want {
|
||||||
|
s = s.next(GradeGood)
|
||||||
|
if s.Interval != w {
|
||||||
|
t.Fatalf("rung %d: interval=%d, want %d", i, s.Interval, w)
|
||||||
|
}
|
||||||
|
if s.Reps != i+1 {
|
||||||
|
t.Fatalf("rung %d: reps=%d, want %d", i, s.Reps, i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Past the ladder it grows by ease (35 * 2.5 = 87.5 → 88).
|
||||||
|
s = s.next(GradeGood)
|
||||||
|
if s.Interval != 88 {
|
||||||
|
t.Fatalf("post-ladder interval=%d, want 88", s.Interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAgainStepsBackGently proves a lapse resets to a 1-day interval and counts
|
||||||
|
// a lapse, but only nudges ease down (never below the floor) — no harsh wipe.
|
||||||
|
func TestAgainStepsBackGently(t *testing.T) {
|
||||||
|
s := State{Reps: 4, Interval: 35, Ease: startEase}
|
||||||
|
s = s.next(GradeAgain)
|
||||||
|
if s.Interval != 1 {
|
||||||
|
t.Fatalf("again interval=%d, want 1", s.Interval)
|
||||||
|
}
|
||||||
|
if s.Reps != 0 {
|
||||||
|
t.Fatalf("again reps=%d, want 0", s.Reps)
|
||||||
|
}
|
||||||
|
if s.Lapses != 1 {
|
||||||
|
t.Fatalf("again lapses=%d, want 1", s.Lapses)
|
||||||
|
}
|
||||||
|
if s.Ease != startEase+easeAgainDelta {
|
||||||
|
t.Fatalf("again ease=%v, want %v", s.Ease, startEase+easeAgainDelta)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ease never drops below the floor no matter how many lapses.
|
||||||
|
low := State{Ease: minEase}
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
low = low.next(GradeAgain)
|
||||||
|
}
|
||||||
|
if low.Ease < minEase {
|
||||||
|
t.Fatalf("ease fell below floor: %v", low.Ease)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCapsBoundGrowth proves runaway "easy" grading can't push ease or the
|
||||||
|
// interval past their ceilings, so a word always resurfaces within a year.
|
||||||
|
func TestCapsBoundGrowth(t *testing.T) {
|
||||||
|
s := State{Ease: startEase}
|
||||||
|
for i := 0; i < 40; i++ {
|
||||||
|
s = s.next(GradeEasy)
|
||||||
|
if s.Ease > maxEase {
|
||||||
|
t.Fatalf("step %d: ease %v exceeded cap %v", i, s.Ease, maxEase)
|
||||||
|
}
|
||||||
|
if s.Interval > maxInterval {
|
||||||
|
t.Fatalf("step %d: interval %d exceeded cap %d", i, s.Interval, maxInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// After enough "easy" reviews it should be pinned at the ceilings.
|
||||||
|
if s.Interval != maxInterval {
|
||||||
|
t.Fatalf("interval should saturate at %d, got %d", maxInterval, s.Interval)
|
||||||
|
}
|
||||||
|
if s.Ease != maxEase {
|
||||||
|
t.Fatalf("ease should saturate at %v, got %v", maxEase, s.Ease)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEasyAdvancesFurther proves "easy" both bumps ease and lands a longer
|
||||||
|
// interval than a plain "good" at the same rung.
|
||||||
|
func TestEasyAdvancesFurther(t *testing.T) {
|
||||||
|
base := State{Reps: 2, Interval: 7, Ease: startEase}
|
||||||
|
good := base.next(GradeGood)
|
||||||
|
easy := base.next(GradeEasy)
|
||||||
|
if easy.Interval <= good.Interval {
|
||||||
|
t.Fatalf("easy interval %d should exceed good interval %d", easy.Interval, good.Interval)
|
||||||
|
}
|
||||||
|
if easy.Ease <= startEase {
|
||||||
|
t.Fatalf("easy should raise ease, got %v", easy.Ease)
|
||||||
|
}
|
||||||
|
}
|
||||||
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])
|
||||||
101
scripts/build_phonetic.py
Normal file
101
scripts/build_phonetic.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build the embedded English-phonetic dataset.
|
||||||
|
|
||||||
|
Petal's writer is a native Mandarin speaker learning English, so the useful
|
||||||
|
pronunciation aid is *how to say the English word* — shown in the word popover
|
||||||
|
beside the 🔊 read-aloud button. (Pinyin would annotate Chinese she already
|
||||||
|
reads fluently, so it isn't built.) The phonetic spelling comes from the same
|
||||||
|
ECDICT source as the Chinese gloss, which already carries a `phonetic` column.
|
||||||
|
|
||||||
|
Two modes:
|
||||||
|
|
||||||
|
# Full build from ECDICT (same CSV used by build_gloss.py):
|
||||||
|
curl -sL https://raw.githubusercontent.com/skywind3000/ECDICT/master/ecdict.csv -o ecdict.csv
|
||||||
|
python3 scripts/build_phonetic.py ecdict.csv internal/lexicon/data/phonetic.json.gz
|
||||||
|
|
||||||
|
# Write just the built-in common-word seed (no CSV needed — what ships in-repo
|
||||||
|
# so the feature works out of the box until a full build is run):
|
||||||
|
python3 scripts/build_phonetic.py --seed internal/lexicon/data/phonetic.json.gz
|
||||||
|
"""
|
||||||
|
import csv
|
||||||
|
import gzip
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Same frequency gate as the gloss build, so coverage matches the words an ESL
|
||||||
|
# writer actually meets and the asset stays small.
|
||||||
|
MAX_RANK = 50000
|
||||||
|
|
||||||
|
# Single English word (letters with internal apostrophe/hyphen).
|
||||||
|
WORD_RE = re.compile(r"^[a-z][a-z'\-]*[a-z]$|^[a-z]$")
|
||||||
|
|
||||||
|
# A small, hand-checked seed of common words → IPA. This ships in the repo so the
|
||||||
|
# phonetic line is live immediately; a full ECDICT build overwrites it with broad
|
||||||
|
# coverage. Kept deliberately short and correct rather than large and shaky.
|
||||||
|
SEED = {
|
||||||
|
"hello": "həˈloʊ", "world": "wɜːrld", "people": "ˈpiːpl", "water": "ˈwɔːtər",
|
||||||
|
"river": "ˈrɪvər", "house": "haʊs", "school": "skuːl", "friend": "frɛnd",
|
||||||
|
"family": "ˈfæməli", "mother": "ˈmʌðər", "father": "ˈfɑːðər", "woman": "ˈwʊmən",
|
||||||
|
"language": "ˈlæŋɡwɪdʒ", "english": "ˈɪŋɡlɪʃ", "write": "raɪt", "writing": "ˈraɪtɪŋ",
|
||||||
|
"read": "riːd", "word": "wɜːrd", "sentence": "ˈsɛntəns", "beautiful": "ˈbjuːtɪfl",
|
||||||
|
"happy": "ˈhæpi", "love": "lʌv", "thought": "θɔːt", "through": "θruː",
|
||||||
|
"though": "ðoʊ", "enough": "ɪˈnʌf", "question": "ˈkwɛstʃən", "answer": "ˈænsər",
|
||||||
|
"because": "bɪˈkɔːz", "people's": "ˈpiːplz", "important": "ɪmˈpɔːrtnt",
|
||||||
|
"different": "ˈdɪfərənt", "remember": "rɪˈmɛmbər", "together": "təˈɡɛðər",
|
||||||
|
"morning": "ˈmɔːrnɪŋ", "tonight": "təˈnaɪt", "yesterday": "ˈjɛstərdeɪ",
|
||||||
|
"tomorrow": "təˈmɒroʊ", "weather": "ˈwɛðər", "color": "ˈkʌlər", "colour": "ˈkʌlər",
|
||||||
|
"favourite": "ˈfeɪvərɪt", "favorite": "ˈfeɪvərɪt", "comfortable": "ˈkʌmftəbl",
|
||||||
|
"vegetable": "ˈvɛdʒtəbl", "interesting": "ˈɪntrəstɪŋ", "necessary": "ˈnɛsəsɛri",
|
||||||
|
"february": "ˈfɛbruɛri", "wednesday": "ˈwɛnzdeɪ",
|
||||||
|
"island": "ˈaɪlənd", "knife": "naɪf", "know": "noʊ", "knee": "niː",
|
||||||
|
"hour": "ˈaʊər", "honest": "ˈɒnɪst", "listen": "ˈlɪsn", "castle": "ˈkæsl",
|
||||||
|
"thank": "θæŋk", "think": "θɪŋk", "thing": "θɪŋ", "three": "θriː",
|
||||||
|
"voice": "vɔɪs", "choice": "tʃɔɪs", "nature": "ˈneɪtʃər", "picture": "ˈpɪktʃər",
|
||||||
|
"future": "ˈfjuːtʃər", "culture": "ˈkʌltʃər", "measure": "ˈmɛʒər",
|
||||||
|
"usually": "ˈjuːʒuəli", "decision": "dɪˈsɪʒn", "delicious": "dɪˈlɪʃəs",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def clean_phonetic(p: str) -> str:
|
||||||
|
"""Normalize an ECDICT phonetic field: trim, strip wrapping slashes/brackets."""
|
||||||
|
p = p.strip().strip("/[]").strip()
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def build_from_csv(src: str) -> dict:
|
||||||
|
out = {}
|
||||||
|
with open(src, newline="", encoding="utf-8") as fh:
|
||||||
|
for row in csv.DictReader(fh):
|
||||||
|
word = (row.get("word") or "").strip().lower()
|
||||||
|
phon = clean_phonetic(row.get("phonetic") or "")
|
||||||
|
if not word or not phon or not WORD_RE.match(word):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
rank = int(row.get("frq") or 0) or int(row.get("bnc") or 0)
|
||||||
|
except ValueError:
|
||||||
|
rank = 0
|
||||||
|
if rank <= 0 or rank > MAX_RANK:
|
||||||
|
continue
|
||||||
|
out[word] = phon
|
||||||
|
# Make sure the curated seed is always present (and authoritative).
|
||||||
|
out.update(SEED)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
args = sys.argv[1:]
|
||||||
|
if not args:
|
||||||
|
sys.exit(__doc__)
|
||||||
|
if args[0] == "--seed":
|
||||||
|
data, dest = dict(SEED), args[1]
|
||||||
|
else:
|
||||||
|
src, dest = args[0], args[1]
|
||||||
|
data = build_from_csv(src)
|
||||||
|
with gzip.open(dest, "wt", encoding="utf-8") as fh:
|
||||||
|
json.dump(data, fh, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
print(f"wrote {len(data)} phonetic entries to {dest}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
165
scripts/gen_sounds.py
Normal file
165
scripts/gen_sounds.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate Petal's cute UI sound palette as small WAV assets.
|
||||||
|
|
||||||
|
These are warm, rounded little sounds — bubble pops, water drops, soft bells —
|
||||||
|
played when suggestions and companion bubbles appear. We synthesize them here
|
||||||
|
(rather than ship someone else's clips) so they match the app's gentle aesthetic
|
||||||
|
exactly and stay tiny. Output lands in web/src/assets/sounds/ where Vite bundles
|
||||||
|
and fingerprints them into dist (and so into the embedded Go binary).
|
||||||
|
|
||||||
|
Run: python3 scripts/gen_sounds.py
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import wave
|
||||||
|
|
||||||
|
SR = 44100
|
||||||
|
OUT = os.path.join(os.path.dirname(__file__), "..", "web", "src", "assets", "sounds")
|
||||||
|
|
||||||
|
|
||||||
|
def env(n, attack, decay, total):
|
||||||
|
"""Smooth attack + exponential decay envelope over `total` seconds."""
|
||||||
|
a = max(1, int(attack * SR))
|
||||||
|
out = []
|
||||||
|
for i in range(n):
|
||||||
|
t = i / SR
|
||||||
|
if i < a:
|
||||||
|
amp = i / a
|
||||||
|
else:
|
||||||
|
amp = math.exp(-(t - attack) / decay)
|
||||||
|
out.append(amp)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def sine(freq_at, n):
|
||||||
|
"""Sine with a per-sample frequency function freq_at(t)->Hz (phase-accurate)."""
|
||||||
|
out = []
|
||||||
|
phase = 0.0
|
||||||
|
for i in range(n):
|
||||||
|
t = i / SR
|
||||||
|
f = freq_at(t)
|
||||||
|
phase += 2 * math.pi * f / SR
|
||||||
|
out.append(math.sin(phase))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def mix(*layers):
|
||||||
|
n = max(len(l) for l in layers)
|
||||||
|
out = [0.0] * n
|
||||||
|
for l in layers:
|
||||||
|
for i, v in enumerate(l):
|
||||||
|
out[i] += v
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def soft_clip(s):
|
||||||
|
return [math.tanh(v * 1.2) for v in s]
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(s, peak=0.9):
|
||||||
|
m = max(1e-9, max(abs(v) for v in s))
|
||||||
|
g = peak / m
|
||||||
|
return [v * g for v in s]
|
||||||
|
|
||||||
|
|
||||||
|
def write_wav(name, samples):
|
||||||
|
samples = soft_clip(samples)
|
||||||
|
samples = normalize(samples, 0.85)
|
||||||
|
# 4ms fade-out tail so nothing clicks at the end.
|
||||||
|
tail = int(0.004 * SR)
|
||||||
|
for i in range(tail):
|
||||||
|
samples[-1 - i] *= i / tail
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
path = os.path.join(OUT, name)
|
||||||
|
with wave.open(path, "w") as w:
|
||||||
|
w.setnchannels(1)
|
||||||
|
w.setsampwidth(2)
|
||||||
|
w.setframerate(SR)
|
||||||
|
frames = b"".join(struct.pack("<h", int(max(-1, min(1, v)) * 32767)) for v in samples)
|
||||||
|
w.writeframes(frames)
|
||||||
|
print(f"wrote {name} ({len(samples)/SR*1000:.0f} ms, {len(frames)} bytes)")
|
||||||
|
|
||||||
|
|
||||||
|
def apply_env(sig, e):
|
||||||
|
return [s * a for s, a in zip(sig, e)]
|
||||||
|
|
||||||
|
|
||||||
|
def bubble_pop():
|
||||||
|
"""A cute bubble 'bloop' — pitch swoops up fast then a rounded body."""
|
||||||
|
dur = 0.16
|
||||||
|
n = int(dur * SR)
|
||||||
|
# Rising swoop is the signature of a bubble.
|
||||||
|
body = sine(lambda t: 360 + 720 * (1 - math.exp(-t * 38)), n)
|
||||||
|
body = apply_env(body, env(n, 0.004, 0.05, dur))
|
||||||
|
# A soft octave shimmer on top.
|
||||||
|
shimmer = sine(lambda t: 1080 + 400 * (1 - math.exp(-t * 38)), n)
|
||||||
|
shimmer = apply_env(shimmer, env(n, 0.003, 0.03, dur))
|
||||||
|
shimmer = [v * 0.25 for v in shimmer]
|
||||||
|
return mix(body, shimmer)
|
||||||
|
|
||||||
|
|
||||||
|
def droplet():
|
||||||
|
"""A clean little water-drop ping — high, downward, watery tail."""
|
||||||
|
dur = 0.22
|
||||||
|
n = int(dur * SR)
|
||||||
|
main = sine(lambda t: 1500 * math.exp(-t * 3.2) + 760, n)
|
||||||
|
main = apply_env(main, env(n, 0.003, 0.06, dur))
|
||||||
|
# Tiny resonant echo for a wet feel.
|
||||||
|
echo = sine(lambda t: 980, n)
|
||||||
|
echo = apply_env(echo, env(n, 0.05, 0.07, dur))
|
||||||
|
echo = [v * 0.2 for v in echo]
|
||||||
|
return mix(main, droop_delay(echo, 0.04))
|
||||||
|
|
||||||
|
|
||||||
|
def droop_delay(sig, delay_s):
|
||||||
|
d = int(delay_s * SR)
|
||||||
|
return [0.0] * d + sig
|
||||||
|
|
||||||
|
|
||||||
|
def bell(freq, dur, partials=((1, 1.0), (2.01, 0.5), (2.78, 0.28), (4.1, 0.12))):
|
||||||
|
"""A soft inharmonic bell tone (sum of slightly detuned partials)."""
|
||||||
|
n = int(dur * SR)
|
||||||
|
layers = []
|
||||||
|
for ratio, amp in partials:
|
||||||
|
s = sine(lambda t, f=freq * ratio: f, n)
|
||||||
|
s = apply_env(s, env(n, 0.005, dur * 0.45, dur))
|
||||||
|
layers.append([v * amp for v in s])
|
||||||
|
return mix(*layers)
|
||||||
|
|
||||||
|
|
||||||
|
def chime():
|
||||||
|
"""Two gentle bell notes a soft fifth apart."""
|
||||||
|
a = bell(784, 0.42) # G5
|
||||||
|
b = bell(1175, 0.40) # D6
|
||||||
|
b = droop_delay(b, 0.10)
|
||||||
|
return mix(a, [v * 0.85 for v in b])
|
||||||
|
|
||||||
|
|
||||||
|
def shimmer():
|
||||||
|
"""Three quick ascending sparkles — glockenspiel-ish."""
|
||||||
|
notes = [988, 1319, 1976] # B5, E6, B6
|
||||||
|
layers = []
|
||||||
|
for i, f in enumerate(notes):
|
||||||
|
s = bell(f, 0.30, partials=((1, 1.0), (2.7, 0.3), (5.1, 0.1)))
|
||||||
|
layers.append(droop_delay([v * 0.8 for v in s], 0.06 * i))
|
||||||
|
return mix(*layers)
|
||||||
|
|
||||||
|
|
||||||
|
def cheer():
|
||||||
|
"""A happy little major arpeggio C5-E5-G5-C6 on bells."""
|
||||||
|
notes = [523, 659, 784, 1047]
|
||||||
|
layers = []
|
||||||
|
for i, f in enumerate(notes):
|
||||||
|
s = bell(f, 0.36)
|
||||||
|
layers.append(droop_delay([v * 0.8 for v in s], 0.075 * i))
|
||||||
|
return mix(*layers)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
write_wav("pop.wav", bubble_pop())
|
||||||
|
write_wav("droplet.wav", droplet())
|
||||||
|
write_wav("chime.wav", chime())
|
||||||
|
write_wav("shimmer.wav", shimmer())
|
||||||
|
write_wav("cheer.wav", cheer())
|
||||||
|
print("done →", os.path.normpath(OUT))
|
||||||
488
web/package-lock.json
generated
488
web/package-lock.json
generated
@@ -9,7 +9,15 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tiptap/extension-character-count": "^2.11.5",
|
"@tiptap/extension-character-count": "^2.11.5",
|
||||||
|
"@tiptap/extension-color": "^2.27.2",
|
||||||
|
"@tiptap/extension-highlight": "^2.27.2",
|
||||||
|
"@tiptap/extension-image": "^2.27.2",
|
||||||
|
"@tiptap/extension-link": "^2.27.2",
|
||||||
"@tiptap/extension-placeholder": "^2.11.5",
|
"@tiptap/extension-placeholder": "^2.11.5",
|
||||||
|
"@tiptap/extension-table": "^2.27.2",
|
||||||
|
"@tiptap/extension-table-cell": "^2.27.2",
|
||||||
|
"@tiptap/extension-table-header": "^2.27.2",
|
||||||
|
"@tiptap/extension-table-row": "^2.27.2",
|
||||||
"@tiptap/extension-text-align": "^2.11.5",
|
"@tiptap/extension-text-align": "^2.11.5",
|
||||||
"@tiptap/extension-underline": "^2.11.5",
|
"@tiptap/extension-underline": "^2.11.5",
|
||||||
"@tiptap/pm": "^2.11.5",
|
"@tiptap/pm": "^2.11.5",
|
||||||
@@ -28,7 +36,8 @@
|
|||||||
"dictionary-en": "^4.0.0",
|
"dictionary-en": "^4.0.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"vite": "^6.1.0"
|
"vite": "^6.1.0",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
@@ -1178,6 +1187,13 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/node": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz",
|
||||||
@@ -1560,6 +1576,20 @@
|
|||||||
"@tiptap/pm": "^2.7.0"
|
"@tiptap/pm": "^2.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/extension-color": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-color/-/extension-color-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-sOKCP8/2V3sRM3FdWgMe1lFE5ewsWNCRafiVoujS1+TTHGCj4jw6W+LiumBUk7cRI8kXW/rqGWVC4RVdknYUCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0",
|
||||||
|
"@tiptap/extension-text-style": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tiptap/extension-document": {
|
"node_modules/@tiptap/extension-document": {
|
||||||
"version": "2.27.2",
|
"version": "2.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.27.2.tgz",
|
||||||
@@ -1644,6 +1674,19 @@
|
|||||||
"@tiptap/core": "^2.7.0"
|
"@tiptap/core": "^2.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/extension-highlight": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-ZjlktDdMjruMJFAVz0TbQf0v92Jqkc7Ri1iZJqBXuLid+r+GxUzl2CVAV7qq5yagkGQgvAG+WGsMk880HgR3MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tiptap/extension-history": {
|
"node_modules/@tiptap/extension-history": {
|
||||||
"version": "2.27.2",
|
"version": "2.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.27.2.tgz",
|
||||||
@@ -1672,6 +1715,19 @@
|
|||||||
"@tiptap/pm": "^2.7.0"
|
"@tiptap/pm": "^2.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/extension-image": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-5zL/BY41FIt72azVrCrv3n+2YJ/JyO8wxCcA4Dk1eXIobcgVyIdo4rG39gCqIOiqziAsqnqoj12QHTBtHsJ6mQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tiptap/extension-italic": {
|
"node_modules/@tiptap/extension-italic": {
|
||||||
"version": "2.27.2",
|
"version": "2.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.27.2.tgz",
|
||||||
@@ -1685,6 +1741,23 @@
|
|||||||
"@tiptap/core": "^2.7.0"
|
"@tiptap/core": "^2.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/extension-link": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-bnP61qkr0Kj9Cgnop1hxn2zbOCBzNtmawxr92bVTOE31fJv6FhtCnQiD6tuPQVGMYhcmAj7eihtvuEMFfqEPcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"linkifyjs": "^4.3.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0",
|
||||||
|
"@tiptap/pm": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tiptap/extension-list-item": {
|
"node_modules/@tiptap/extension-list-item": {
|
||||||
"version": "2.27.2",
|
"version": "2.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.2.tgz",
|
||||||
@@ -1751,6 +1824,59 @@
|
|||||||
"@tiptap/core": "^2.7.0"
|
"@tiptap/core": "^2.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/extension-table": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-pDbhOpT5phZkcsyPjGBQlXv0+0hmdrvqHJ+dJjkGcCtlfy2pHiEIhmIItOFagc7wXy8G9iUFZ9Jie4zvDf+brg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0",
|
||||||
|
"@tiptap/pm": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-table-cell": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-9Lk46MjZMFzVZfOj9Kd7VgC6Odt6vmEhlCYVumErShUY7EkFqCw3b2IYoUtQkntfOEx/Afnhff/okNQwPsJeUA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-table-header": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-ZEb6lbG0NbbodWLV0b4BS/QrDIPlUbCcuOsUxzqVvlMUY1Vg6Fj6fKwLaBcsIUDHi8sxZDBEgYEDw3BR/zcO6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-table-row": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-Nw9+tA56Y5HtLVP01NGCZSUuTQhJPtfK9OfmDgGgcxynn2cRVdEtj+9FNZqRhQ1iRVaAI+Rd4xRvX9qYePMOxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tiptap/extension-text": {
|
"node_modules/@tiptap/extension-text": {
|
||||||
"version": "2.27.2",
|
"version": "2.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.2.tgz",
|
||||||
@@ -1934,6 +2060,24 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/chai": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/deep-eql": "*",
|
||||||
|
"assertion-error": "^2.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/deep-eql": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
@@ -2010,12 +2154,135 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitest/expect": {
|
||||||
|
"version": "4.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
|
||||||
|
"integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.1.0",
|
||||||
|
"@types/chai": "^5.2.2",
|
||||||
|
"@vitest/spy": "4.1.9",
|
||||||
|
"@vitest/utils": "4.1.9",
|
||||||
|
"chai": "^6.2.2",
|
||||||
|
"tinyrainbow": "^3.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/mocker": {
|
||||||
|
"version": "4.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
|
||||||
|
"integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/spy": "4.1.9",
|
||||||
|
"estree-walker": "^3.0.3",
|
||||||
|
"magic-string": "^0.30.21"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"msw": "^2.4.9",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"msw": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/pretty-format": {
|
||||||
|
"version": "4.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
|
||||||
|
"integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tinyrainbow": "^3.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/runner": {
|
||||||
|
"version": "4.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
|
||||||
|
"integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/utils": "4.1.9",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/snapshot": {
|
||||||
|
"version": "4.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
|
||||||
|
"integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/pretty-format": "4.1.9",
|
||||||
|
"@vitest/utils": "4.1.9",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/spy": {
|
||||||
|
"version": "4.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
|
||||||
|
"integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/utils": {
|
||||||
|
"version": "4.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
|
||||||
|
"integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/pretty-format": "4.1.9",
|
||||||
|
"convert-source-map": "^2.0.0",
|
||||||
|
"tinyrainbow": "^3.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/argparse": {
|
"node_modules/argparse": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||||
"license": "Python-2.0"
|
"license": "Python-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/assertion-error": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.40",
|
"version": "2.10.40",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
|
||||||
@@ -2084,6 +2351,16 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/chai": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/convert-source-map": {
|
"node_modules/convert-source-map": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
@@ -2176,6 +2453,13 @@
|
|||||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-module-lexer": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
@@ -2240,6 +2524,26 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/estree-walker": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/estree": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/expect-type": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -2642,6 +2946,12 @@
|
|||||||
"uc.micro": "^2.0.0"
|
"uc.micro": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/linkifyjs": {
|
||||||
|
"version": "4.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
|
||||||
|
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lottie-web": {
|
"node_modules/lottie-web": {
|
||||||
"version": "5.13.0",
|
"version": "5.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz",
|
||||||
@@ -2746,12 +3056,33 @@
|
|||||||
"is-buffer": "^2.0.0"
|
"is-buffer": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/obug": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/sxzz",
|
||||||
|
"https://opencollective.com/debug"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/orderedmap": {
|
"node_modules/orderedmap": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||||
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pathe": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -3103,6 +3434,13 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/siginfo": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -3113,6 +3451,20 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stackback": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||||
|
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/std-env": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz",
|
||||||
@@ -3134,6 +3486,23 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinybench": {
|
||||||
|
"version": "2.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
|
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tinyexec": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
@@ -3151,6 +3520,16 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinyrainbow": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tippy.js": {
|
"node_modules/tippy.js": {
|
||||||
"version": "6.3.7",
|
"version": "6.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz",
|
||||||
@@ -3295,12 +3674,119 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vitest": {
|
||||||
|
"version": "4.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
|
||||||
|
"integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/expect": "4.1.9",
|
||||||
|
"@vitest/mocker": "4.1.9",
|
||||||
|
"@vitest/pretty-format": "4.1.9",
|
||||||
|
"@vitest/runner": "4.1.9",
|
||||||
|
"@vitest/snapshot": "4.1.9",
|
||||||
|
"@vitest/spy": "4.1.9",
|
||||||
|
"@vitest/utils": "4.1.9",
|
||||||
|
"es-module-lexer": "^2.0.0",
|
||||||
|
"expect-type": "^1.3.0",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"obug": "^2.1.1",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"picomatch": "^4.0.3",
|
||||||
|
"std-env": "^4.0.0-rc.1",
|
||||||
|
"tinybench": "^2.9.0",
|
||||||
|
"tinyexec": "^1.0.2",
|
||||||
|
"tinyglobby": "^0.2.15",
|
||||||
|
"tinyrainbow": "^3.1.0",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||||
|
"why-is-node-running": "^2.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"vitest": "vitest.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@edge-runtime/vm": "*",
|
||||||
|
"@opentelemetry/api": "^1.9.0",
|
||||||
|
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||||
|
"@vitest/browser-playwright": "4.1.9",
|
||||||
|
"@vitest/browser-preview": "4.1.9",
|
||||||
|
"@vitest/browser-webdriverio": "4.1.9",
|
||||||
|
"@vitest/coverage-istanbul": "4.1.9",
|
||||||
|
"@vitest/coverage-v8": "4.1.9",
|
||||||
|
"@vitest/ui": "4.1.9",
|
||||||
|
"happy-dom": "*",
|
||||||
|
"jsdom": "*",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@edge-runtime/vm": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@opentelemetry/api": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-playwright": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-preview": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-webdriverio": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/coverage-istanbul": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/coverage-v8": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/ui": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"happy-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"jsdom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/w3c-keyname": {
|
"node_modules/w3c-keyname": {
|
||||||
"version": "2.2.8",
|
"version": "2.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/why-is-node-running": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"siginfo": "^2.0.0",
|
||||||
|
"stackback": "0.0.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"why-is-node-running": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -6,11 +6,21 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tiptap/extension-character-count": "^2.11.5",
|
"@tiptap/extension-character-count": "^2.11.5",
|
||||||
|
"@tiptap/extension-color": "^2.27.2",
|
||||||
|
"@tiptap/extension-highlight": "^2.27.2",
|
||||||
|
"@tiptap/extension-image": "^2.27.2",
|
||||||
|
"@tiptap/extension-link": "^2.27.2",
|
||||||
"@tiptap/extension-placeholder": "^2.11.5",
|
"@tiptap/extension-placeholder": "^2.11.5",
|
||||||
|
"@tiptap/extension-table": "^2.27.2",
|
||||||
|
"@tiptap/extension-table-cell": "^2.27.2",
|
||||||
|
"@tiptap/extension-table-header": "^2.27.2",
|
||||||
|
"@tiptap/extension-table-row": "^2.27.2",
|
||||||
"@tiptap/extension-text-align": "^2.11.5",
|
"@tiptap/extension-text-align": "^2.11.5",
|
||||||
"@tiptap/extension-underline": "^2.11.5",
|
"@tiptap/extension-underline": "^2.11.5",
|
||||||
"@tiptap/pm": "^2.11.5",
|
"@tiptap/pm": "^2.11.5",
|
||||||
@@ -29,6 +39,7 @@
|
|||||||
"dictionary-en": "^4.0.0",
|
"dictionary-en": "^4.0.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"vite": "^6.1.0"
|
"vite": "^6.1.0",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
282
web/src/App.tsx
282
web/src/App.tsx
@@ -1,18 +1,29 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { api, type DocSummary, type Document, type Suggestion } from './api/client'
|
import { api, type DocSummary, type Document, type Suggestion, type Tag, type TagColor } from './api/client'
|
||||||
import { useAutoSave } from './hooks/useAutoSave'
|
import { useAutoSave } from './hooks/useAutoSave'
|
||||||
import { useCheckpoint } from './hooks/useCheckpoint'
|
import { useCheckpoint } from './hooks/useCheckpoint'
|
||||||
import { useSpellChecker } from './hooks/useSpellChecker'
|
import { useSpellChecker } from './hooks/useSpellChecker'
|
||||||
|
import { useTags } from './hooks/useTags'
|
||||||
import { DocList } from './components/DocList/DocList'
|
import { DocList } from './components/DocList/DocList'
|
||||||
import { EditorCore, type EditorChange } from './components/Editor/EditorCore'
|
import { EditorCore, type EditorChange } from './components/Editor/EditorCore'
|
||||||
import { ToneSelect } from './components/Editor/ToneSelect'
|
import { ToneSelect } from './components/Editor/ToneSelect'
|
||||||
|
import { ExportMenu } from './components/Export/ExportMenu'
|
||||||
|
import { HistoryPanel } from './components/History/HistoryPanel'
|
||||||
|
import { GardenPanel } from './components/Garden/GardenPanel'
|
||||||
import { StatusBar } from './components/StatusBar/StatusBar'
|
import { StatusBar } from './components/StatusBar/StatusBar'
|
||||||
import { PetalCompanion } from './components/Companion/PetalCompanion'
|
import { PetalCompanion } from './components/Companion/PetalCompanion'
|
||||||
import { UpdateBanner } from './components/UpdateBanner/UpdateBanner'
|
import { UpdateBanner } from './components/UpdateBanner/UpdateBanner'
|
||||||
import { useVersionWatch } from './hooks/useVersionWatch'
|
import { useVersionWatch } from './hooks/useVersionWatch'
|
||||||
|
import { PetalFall } from './effects/PetalFall'
|
||||||
|
import { useNightMode } from './hooks/useNightMode'
|
||||||
|
import { playSuggestionSound } from './audio/sounds'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const updateAvailable = useVersionWatch()
|
const updateAvailable = useVersionWatch()
|
||||||
|
// Late-night calm mode: dark theme + falling stars instead of petals. The hook
|
||||||
|
// toggles the `petal-night` class on <html>; we pass the flag to the ambient
|
||||||
|
// layer so the petals become stars.
|
||||||
|
const night = useNightMode()
|
||||||
const [docs, setDocs] = useState<DocSummary[]>([])
|
const [docs, setDocs] = useState<DocSummary[]>([])
|
||||||
const [currentDoc, setCurrentDoc] = useState<Document | null>(null)
|
const [currentDoc, setCurrentDoc] = useState<Document | null>(null)
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
@@ -25,30 +36,108 @@ export default function App() {
|
|||||||
// Distraction-free mode: entered on editor focus, collapses the doc-list
|
// Distraction-free mode: entered on editor focus, collapses the doc-list
|
||||||
// sidebar. Escape or a click outside the editor canvas restores it.
|
// sidebar. Escape or a click outside the editor canvas restores it.
|
||||||
const [focusMode, setFocusMode] = useState(false)
|
const [focusMode, setFocusMode] = useState(false)
|
||||||
|
// Mobile drawer: below the tablet breakpoint the sidebar is an overlay toggled
|
||||||
|
// by the header hamburger. Ignored on wide screens (sidebar is always in-flow).
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||||
const canvasRef = useRef<HTMLDivElement>(null)
|
const canvasRef = useRef<HTMLDivElement>(null)
|
||||||
// Monotonic counters the companion watches to react to writing + accepts.
|
// Monotonic counters the companion watches to react to writing + accepts.
|
||||||
const [editTick, setEditTick] = useState(0)
|
const [editTick, setEditTick] = useState(0)
|
||||||
const [acceptTick, setAcceptTick] = useState(0)
|
const [acceptTick, setAcceptTick] = useState(0)
|
||||||
|
// History drawer visibility, and an epoch bumped on restore to force the
|
||||||
|
// editor to remount with the restored content (its initialContent is read
|
||||||
|
// only on mount).
|
||||||
|
const [historyOpen, setHistoryOpen] = useState(false)
|
||||||
|
const [gardenOpen, setGardenOpen] = useState(false)
|
||||||
|
const [editorEpoch, setEditorEpoch] = useState(0)
|
||||||
|
|
||||||
|
// Live mirrors of the current doc's editable fields so the "discard blank
|
||||||
|
// drafts on navigation" logic can read the latest values without rebuilding
|
||||||
|
// callbacks on every keystroke.
|
||||||
|
const currentDocRef = useRef(currentDoc)
|
||||||
|
const titleRef = useRef(title)
|
||||||
|
const wordCountRef = useRef(wordCount)
|
||||||
|
currentDocRef.current = currentDoc
|
||||||
|
titleRef.current = title
|
||||||
|
wordCountRef.current = wordCount
|
||||||
|
|
||||||
|
// A throwaway blank draft: no words and still the default/empty title. We
|
||||||
|
// delete these on navigation rather than leave orphan "Untitled" docs behind.
|
||||||
|
const isBlankDraft = useCallback(() => {
|
||||||
|
const t = titleRef.current.trim()
|
||||||
|
return wordCountRef.current === 0 && (t === '' || t === 'Untitled')
|
||||||
|
}, [])
|
||||||
|
|
||||||
const { status, schedule, saveNow } = useAutoSave(currentDoc?.id ?? null)
|
const { status, schedule, saveNow } = useAutoSave(currentDoc?.id ?? null)
|
||||||
const {
|
const {
|
||||||
suggestions,
|
suggestions,
|
||||||
checking,
|
checking,
|
||||||
voicing,
|
voicing,
|
||||||
|
collocating,
|
||||||
|
llmDown,
|
||||||
schedule: scheduleCheckpoint,
|
schedule: scheduleCheckpoint,
|
||||||
runVoice,
|
runVoice,
|
||||||
|
runCollocation,
|
||||||
removeSuggestion,
|
removeSuggestion,
|
||||||
} = useCheckpoint(currentDoc?.id ?? null)
|
} = useCheckpoint(currentDoc?.id ?? null)
|
||||||
// Browser-side spell checker — loads the en-US dictionary once per session.
|
// Browser-side spell checker — loads the en-US dictionary once per session.
|
||||||
const { checker: spellChecker, addWord } = useSpellChecker()
|
const { checker: spellChecker, addWord } = useSpellChecker()
|
||||||
|
// The tag roster (with counts). Assignments live on the doc summaries below.
|
||||||
|
const { tags: tagRoster, refresh: refreshTags, createTag } = useTags()
|
||||||
|
|
||||||
// Patch a summary in the sidebar list (optimistic title / word-count updates).
|
// Patch a summary in the sidebar list (optimistic title / word-count updates).
|
||||||
const patchSummary = useCallback((id: string, patch: Partial<DocSummary>) => {
|
const patchSummary = useCallback((id: string, patch: Partial<DocSummary>) => {
|
||||||
setDocs((prev) => prev.map((d) => (d.id === id ? { ...d, ...patch } : d)))
|
setDocs((prev) => prev.map((d) => (d.id === id ? { ...d, ...patch } : d)))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Attach or detach a tag on a document, updating the sidebar optimistically and
|
||||||
|
// refreshing the roster so its counts stay current. Tags are kept sorted by
|
||||||
|
// name to match the server's ordering.
|
||||||
|
const setDocTag = useCallback(
|
||||||
|
async (docId: string, tag: Tag, attach: boolean) => {
|
||||||
|
setDocs((prev) =>
|
||||||
|
prev.map((d) => {
|
||||||
|
if (d.id !== docId) return d
|
||||||
|
const without = d.tags.filter((t) => t.id !== tag.id)
|
||||||
|
const next = attach ? [...without, tag] : without
|
||||||
|
next.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }))
|
||||||
|
return { ...d, tags: next }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
if (attach) await api.assignTag(docId, tag.id)
|
||||||
|
else await api.unassignTag(docId, tag.id)
|
||||||
|
void refreshTags()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('tag assignment failed', err)
|
||||||
|
void refreshTags()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[refreshTags],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleToggleTag = useCallback(
|
||||||
|
(docId: string, tag: Tag) => {
|
||||||
|
const doc = docs.find((d) => d.id === docId)
|
||||||
|
const has = doc?.tags.some((t) => t.id === tag.id) ?? false
|
||||||
|
void setDocTag(docId, tag, !has)
|
||||||
|
},
|
||||||
|
[docs, setDocTag],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleCreateTag = useCallback(
|
||||||
|
async (docId: string, name: string, color: TagColor) => {
|
||||||
|
const tag = await createTag(name, color)
|
||||||
|
if (tag) void setDocTag(docId, tag, true)
|
||||||
|
},
|
||||||
|
[createTag, setDocTag],
|
||||||
|
)
|
||||||
|
|
||||||
const openDoc = useCallback(
|
const openDoc = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
|
setDrawerOpen(false) // close the mobile drawer when a doc is chosen
|
||||||
|
// Decide the leaving doc's fate before any await mutates state.
|
||||||
|
const leaving = currentDocRef.current
|
||||||
|
const leavingBlank = isBlankDraft()
|
||||||
await saveNow() // flush any pending edits to the doc we're leaving
|
await saveNow() // flush any pending edits to the doc we're leaving
|
||||||
const doc = await api.getDoc(id)
|
const doc = await api.getDoc(id)
|
||||||
setCurrentDoc(doc)
|
setCurrentDoc(doc)
|
||||||
@@ -56,8 +145,14 @@ export default function App() {
|
|||||||
setWordCount(doc.word_count)
|
setWordCount(doc.word_count)
|
||||||
setTone(doc.tone || 'general')
|
setTone(doc.tone || 'general')
|
||||||
setDocText(doc.content_text)
|
setDocText(doc.content_text)
|
||||||
|
// Leaving an untouched blank draft for a different doc? Drop it silently
|
||||||
|
// (best-effort cleanup — a 404 just means it's already gone).
|
||||||
|
if (leaving && leaving.id !== id && leavingBlank) {
|
||||||
|
api.deleteDoc(leaving.id).catch(() => {})
|
||||||
|
setDocs((prev) => prev.filter((d) => d.id !== leaving.id))
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[saveNow],
|
[saveNow, isBlankDraft],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Initial load: fetch the list, opening the first doc (or creating one).
|
// Initial load: fetch the list, opening the first doc (or creating one).
|
||||||
@@ -70,7 +165,7 @@ export default function App() {
|
|||||||
let list = await api.listDocs()
|
let list = await api.listDocs()
|
||||||
if (list.length === 0) {
|
if (list.length === 0) {
|
||||||
const fresh = await api.createDoc()
|
const fresh = await api.createDoc()
|
||||||
list = [{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at }]
|
list = [{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at, tags: [] }]
|
||||||
setDocs(list)
|
setDocs(list)
|
||||||
setCurrentDoc(fresh)
|
setCurrentDoc(fresh)
|
||||||
setTitle(fresh.title)
|
setTitle(fresh.title)
|
||||||
@@ -90,10 +185,13 @@ export default function App() {
|
|||||||
}, [openDoc])
|
}, [openDoc])
|
||||||
|
|
||||||
const handleCreate = useCallback(async () => {
|
const handleCreate = useCallback(async () => {
|
||||||
|
// Already sitting on a fresh blank draft? Reuse it instead of stacking
|
||||||
|
// another empty "Untitled" on top.
|
||||||
|
if (currentDocRef.current && isBlankDraft()) return
|
||||||
await saveNow()
|
await saveNow()
|
||||||
const fresh = await api.createDoc()
|
const fresh = await api.createDoc()
|
||||||
setDocs((prev) => [
|
setDocs((prev) => [
|
||||||
{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at },
|
{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at, tags: [] },
|
||||||
...prev,
|
...prev,
|
||||||
])
|
])
|
||||||
setCurrentDoc(fresh)
|
setCurrentDoc(fresh)
|
||||||
@@ -101,7 +199,39 @@ export default function App() {
|
|||||||
setWordCount(0)
|
setWordCount(0)
|
||||||
setTone(fresh.tone || 'general')
|
setTone(fresh.tone || 'general')
|
||||||
setDocText('')
|
setDocText('')
|
||||||
}, [saveNow])
|
}, [saveNow, isBlankDraft])
|
||||||
|
|
||||||
|
// Duplicate a document: copy its body/tone into a fresh doc titled "… (副本)",
|
||||||
|
// then open the copy. Tags aren't carried over (a fresh start for the copy).
|
||||||
|
const handleDuplicate = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
try {
|
||||||
|
await saveNow() // flush in case we're duplicating the open doc
|
||||||
|
const src = await api.getDoc(id)
|
||||||
|
const fresh = await api.createDoc()
|
||||||
|
const dupTitle = `${src.title?.trim() || 'Untitled'} (副本)`
|
||||||
|
const updated = await api.updateDoc(fresh.id, {
|
||||||
|
title: dupTitle,
|
||||||
|
content: src.content,
|
||||||
|
content_text: src.content_text,
|
||||||
|
tone: src.tone,
|
||||||
|
word_count: src.word_count,
|
||||||
|
})
|
||||||
|
setDocs((prev) => [
|
||||||
|
{ id: updated.id, title: updated.title, word_count: updated.word_count, updated_at: updated.updated_at, tags: [] },
|
||||||
|
...prev,
|
||||||
|
])
|
||||||
|
setCurrentDoc(updated)
|
||||||
|
setTitle(updated.title)
|
||||||
|
setWordCount(updated.word_count)
|
||||||
|
setTone(updated.tone || 'general')
|
||||||
|
setDocText(updated.content_text)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('duplicate failed', err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[saveNow],
|
||||||
|
)
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
@@ -144,7 +274,7 @@ export default function App() {
|
|||||||
if (currentDoc) {
|
if (currentDoc) {
|
||||||
patchSummary(currentDoc.id, { word_count: change.word_count })
|
patchSummary(currentDoc.id, { word_count: change.word_count })
|
||||||
schedule(change)
|
schedule(change)
|
||||||
scheduleCheckpoint()
|
scheduleCheckpoint(change.content_text)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[currentDoc, patchSummary, schedule, scheduleCheckpoint],
|
[currentDoc, patchSummary, schedule, scheduleCheckpoint],
|
||||||
@@ -179,6 +309,21 @@ export default function App() {
|
|||||||
[removeSuggestion],
|
[removeSuggestion],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// After restoring a version, swap the restored doc into the editor. Bumping
|
||||||
|
// editorEpoch remounts EditorCore so it picks up the restored content.
|
||||||
|
const handleRestored = useCallback(
|
||||||
|
(doc: Document) => {
|
||||||
|
setCurrentDoc(doc)
|
||||||
|
setTitle(doc.title)
|
||||||
|
setWordCount(doc.word_count)
|
||||||
|
setTone(doc.tone || 'general')
|
||||||
|
setDocText(doc.content_text)
|
||||||
|
patchSummary(doc.id, { title: doc.title, word_count: doc.word_count })
|
||||||
|
setEditorEpoch((n) => n + 1)
|
||||||
|
},
|
||||||
|
[patchSummary],
|
||||||
|
)
|
||||||
|
|
||||||
// Escape always restores the sidebar while in distraction-free mode.
|
// Escape always restores the sidebar while in distraction-free mode.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!focusMode) return
|
if (!focusMode) return
|
||||||
@@ -207,28 +352,95 @@ export default function App() {
|
|||||||
[removeSuggestion],
|
[removeSuggestion],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Play a soft sound when freshly-checked suggestions arrive — one per distinct
|
||||||
|
// new type, lightly staggered so a batch reads as a little melody rather than
|
||||||
|
// a pile-up. We track which ids we've already chimed for, and only chime for
|
||||||
|
// recently-created suggestions so opening a doc with old pending advice stays
|
||||||
|
// silent (the existing set was created in a past session).
|
||||||
|
const chimedRef = useRef<Set<string>>(new Set())
|
||||||
|
useEffect(() => {
|
||||||
|
const fresh = suggestions.filter((s) => !chimedRef.current.has(s.id))
|
||||||
|
fresh.forEach((s) => chimedRef.current.add(s.id))
|
||||||
|
const justMade = fresh.filter(
|
||||||
|
(s) => Date.now() - new Date(s.created_at).getTime() < 12_000,
|
||||||
|
)
|
||||||
|
if (justMade.length === 0) return
|
||||||
|
const types = [...new Set(justMade.map((s) => s.type))].slice(0, 3)
|
||||||
|
const timers = types.map((t, i) =>
|
||||||
|
window.setTimeout(() => playSuggestionSound(t), i * 150),
|
||||||
|
)
|
||||||
|
return () => timers.forEach((id) => window.clearTimeout(id))
|
||||||
|
}, [suggestions])
|
||||||
|
|
||||||
|
// Forget chimed ids when switching documents so the set can't grow unbounded.
|
||||||
|
useEffect(() => {
|
||||||
|
chimedRef.current = new Set()
|
||||||
|
}, [currentDoc?.id])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
|
<PetalFall night={night} />
|
||||||
<header
|
<header
|
||||||
onMouseDown={handleChromeDown}
|
onMouseDown={handleChromeDown}
|
||||||
className="flex h-12 shrink-0 items-center gap-2 px-5"
|
className="petal-no-print flex h-12 shrink-0 items-center gap-2 px-5"
|
||||||
style={{ borderBottom: '1px solid var(--color-border)' }}
|
style={{ borderBottom: '1px solid var(--color-border)' }}
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Toggle document list"
|
||||||
|
onClick={() => {
|
||||||
|
setFocusMode(false)
|
||||||
|
setDrawerOpen((v) => !v)
|
||||||
|
}}
|
||||||
|
className="petal-sidebar-toggle petal-tap-sm -ml-2 mr-1 items-center justify-center text-xl"
|
||||||
|
style={{ borderRadius: 'var(--radius-pill)', width: 36, height: 36, color: 'var(--color-plum)' }}
|
||||||
|
>
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
<span className="text-xl">🌸</span>
|
<span className="text-xl">🌸</span>
|
||||||
<span className="text-lg font-extrabold text-plum">Petal</span>
|
<span className="text-lg font-extrabold text-plum">Petal</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setGardenOpen(true)}
|
||||||
|
aria-label="Vocabulary garden"
|
||||||
|
title="Words you've looked up, blooming for review"
|
||||||
|
className="petal-tap-sm ml-auto 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)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span aria-hidden>🌷</span>
|
||||||
|
<span>词汇花园</span>
|
||||||
|
<span style={{ color: 'var(--color-muted)' }}>· Garden</span>
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1">
|
<div className="relative flex min-h-0 flex-1">
|
||||||
<div className={`petal-sidebar shrink-0${focusMode ? ' petal-sidebar-hidden' : ''}`}>
|
<div
|
||||||
|
className={`petal-no-print petal-sidebar shrink-0${focusMode ? ' petal-sidebar-hidden' : ''}${drawerOpen ? ' petal-drawer-open' : ''}`}
|
||||||
|
>
|
||||||
<DocList
|
<DocList
|
||||||
docs={docs}
|
docs={docs}
|
||||||
|
roster={tagRoster}
|
||||||
selectedId={currentDoc?.id ?? null}
|
selectedId={currentDoc?.id ?? null}
|
||||||
onSelect={openDoc}
|
onSelect={openDoc}
|
||||||
onCreate={handleCreate}
|
onCreate={handleCreate}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onDuplicate={handleDuplicate}
|
||||||
|
onToggleTag={handleToggleTag}
|
||||||
|
onCreateTag={handleCreateTag}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`petal-no-print petal-scrim${drawerOpen ? ' petal-scrim-show' : ''}`}
|
||||||
|
onClick={() => setDrawerOpen(false)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
|
||||||
<main className="flex min-w-0 flex-1 flex-col">
|
<main className="flex min-w-0 flex-1 flex-col">
|
||||||
{currentDoc ? (
|
{currentDoc ? (
|
||||||
<>
|
<>
|
||||||
@@ -246,10 +458,32 @@ export default function App() {
|
|||||||
className="min-w-0 flex-1 bg-transparent text-3xl font-extrabold text-plum focus:outline-none"
|
className="min-w-0 flex-1 bg-transparent text-3xl font-extrabold text-plum focus:outline-none"
|
||||||
style={{ fontFamily: 'var(--font-ui)' }}
|
style={{ fontFamily: 'var(--font-ui)' }}
|
||||||
/>
|
/>
|
||||||
|
<div className="petal-no-print shrink-0">
|
||||||
<ToneSelect value={tone} onChange={handleToneChange} />
|
<ToneSelect value={tone} onChange={handleToneChange} />
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setHistoryOpen(true)}
|
||||||
|
aria-label="Version history"
|
||||||
|
title="Browse and restore earlier versions"
|
||||||
|
className="petal-no-print inline-flex h-9 shrink-0 items-center gap-1.5 whitespace-nowrap px-3 text-sm font-bold"
|
||||||
|
style={{
|
||||||
|
borderRadius: 'var(--radius-pill)',
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
color: 'var(--color-plum)',
|
||||||
|
boxShadow: 'var(--shadow-soft)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span aria-hidden>🕘</span>
|
||||||
|
<span>历史</span>
|
||||||
|
<span style={{ color: 'var(--color-muted)' }}>· History</span>
|
||||||
|
</button>
|
||||||
|
<div className="petal-no-print">
|
||||||
|
<ExportMenu docId={currentDoc.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<EditorCore
|
<EditorCore
|
||||||
key={currentDoc.id}
|
key={`${currentDoc.id}:${editorEpoch}`}
|
||||||
docId={currentDoc.id}
|
docId={currentDoc.id}
|
||||||
initialContent={currentDoc.content}
|
initialContent={currentDoc.content}
|
||||||
onChange={handleEditorChange}
|
onChange={handleEditorChange}
|
||||||
@@ -258,19 +492,23 @@ export default function App() {
|
|||||||
onDismiss={handleDismiss}
|
onDismiss={handleDismiss}
|
||||||
onVoiceCheck={runVoice}
|
onVoiceCheck={runVoice}
|
||||||
voicing={voicing}
|
voicing={voicing}
|
||||||
|
onCollocationCheck={runCollocation}
|
||||||
|
collocating={collocating}
|
||||||
onFocusMode={() => setFocusMode(true)}
|
onFocusMode={() => setFocusMode(true)}
|
||||||
spellChecker={spellChecker}
|
spellChecker={spellChecker}
|
||||||
onAddWord={addWord}
|
onAddWord={addWord}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div onMouseDown={handleChromeDown}>
|
<div className="petal-no-print" onMouseDown={handleChromeDown}>
|
||||||
<StatusBar
|
<StatusBar
|
||||||
wordCount={wordCount}
|
wordCount={wordCount}
|
||||||
text={docText}
|
text={docText}
|
||||||
saveStatus={status}
|
saveStatus={status}
|
||||||
checking={checking}
|
checking={checking}
|
||||||
voicing={voicing}
|
voicing={voicing}
|
||||||
|
collocating={collocating}
|
||||||
|
llmDown={llmDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -285,14 +523,36 @@ export default function App() {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{historyOpen && currentDoc && (
|
||||||
|
<HistoryPanel
|
||||||
|
docId={currentDoc.id}
|
||||||
|
onClose={() => setHistoryOpen(false)}
|
||||||
|
onRestored={handleRestored}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{gardenOpen && (
|
||||||
|
<GardenPanel
|
||||||
|
onClose={() => setGardenOpen(false)}
|
||||||
|
onOpenDoc={(id) => {
|
||||||
|
setGardenOpen(false)
|
||||||
|
void openDoc(id)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{updateAvailable && <UpdateBanner />}
|
{updateAvailable && <UpdateBanner />}
|
||||||
|
|
||||||
|
<div className="petal-no-print">
|
||||||
<PetalCompanion
|
<PetalCompanion
|
||||||
wordCount={wordCount}
|
wordCount={wordCount}
|
||||||
saveStatus={status}
|
saveStatus={status}
|
||||||
|
llmDown={llmDown}
|
||||||
editTick={editTick}
|
editTick={editTick}
|
||||||
acceptTick={acceptTick}
|
acceptTick={acceptTick}
|
||||||
|
text={docText}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,34 @@
|
|||||||
// Thin fetch wrappers for the Petal backend. Everything lives under /api, which
|
// Thin fetch wrappers for the Petal backend. Everything lives under /api, which
|
||||||
// Vite proxies to the Go server in dev and the binary serves directly in prod.
|
// Vite proxies to the Go server in dev and the binary serves directly in prod.
|
||||||
|
|
||||||
|
// A tag's palette key, mapped to a CSS color on the frontend. Mirrors the
|
||||||
|
// backend TagColor* constants; unknown values render as rose.
|
||||||
|
export type TagColor = 'rose' | 'mint' | 'peach' | 'lavender' | 'sky' | 'honey'
|
||||||
|
|
||||||
|
export interface Tag {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
color: TagColor
|
||||||
|
doc_count?: number // present only in the tag-roster listing
|
||||||
|
}
|
||||||
|
|
||||||
export interface DocSummary {
|
export interface DocSummary {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
word_count: number
|
word_count: number
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
tags: Tag[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// One cross-document search hit. `snippet` is plain text with the matched span
|
||||||
|
// wrapped in the … sentinels (see splitSnippet) for highlighting.
|
||||||
|
export interface SearchResult {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
word_count: number
|
||||||
|
updated_at: string
|
||||||
|
snippet: string
|
||||||
|
tags: Tag[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Document {
|
export interface Document {
|
||||||
@@ -37,15 +60,63 @@ export interface WordMeaning {
|
|||||||
example?: string
|
example?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// The offline lookup for one word: a few definition senses and a list of
|
// The offline lookup for one word: a Chinese gloss, a few definition senses, and
|
||||||
// synonyms. Either list may be empty when the word isn't a headword.
|
// a list of synonyms. Any of these may be empty when the word isn't a headword.
|
||||||
export interface WordInfo {
|
export interface WordInfo {
|
||||||
word: string
|
word: string
|
||||||
|
gloss: string // Chinese translation; '' when the word isn't in the gloss set
|
||||||
|
phonetic: string // IPA for the English word; '' when absent
|
||||||
definitions: WordMeaning[]
|
definitions: WordMeaning[]
|
||||||
synonyms: string[]
|
synonyms: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SuggestionType = 'grammar' | 'phrasing' | 'idiom' | 'clarity' | 'voice'
|
// 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' | 'collocation' | 'mechanics'
|
||||||
|
|
||||||
|
// One word in the vocabulary garden: a looked-up word with its gloss/phonetic,
|
||||||
|
// the sentence it was met in, and its spaced-repetition state. `reps` drives how
|
||||||
|
// "bloomed" its blossom looks; `due_at` decides when it next surfaces for review.
|
||||||
|
export interface VocabWord {
|
||||||
|
id: string
|
||||||
|
word: string
|
||||||
|
gloss: string
|
||||||
|
definition: string // English fallback meaning shown in review when there's no gloss
|
||||||
|
phonetic: string
|
||||||
|
example: string
|
||||||
|
doc_id: string | null
|
||||||
|
due_at: string
|
||||||
|
interval_days: number
|
||||||
|
ease: number
|
||||||
|
reps: number
|
||||||
|
lapses: number
|
||||||
|
last_reviewed: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// The self-assessment grades a flashcard review can record.
|
||||||
|
export type VocabGrade = 'again' | 'good' | 'easy'
|
||||||
|
|
||||||
|
// A point-in-time snapshot of a document. List responses omit the heavy
|
||||||
|
// content/content_text fields; they arrive only on getVersion (preview/restore).
|
||||||
|
export interface DocumentVersion {
|
||||||
|
id: string
|
||||||
|
doc_id: string
|
||||||
|
title: string
|
||||||
|
content?: string
|
||||||
|
content_text?: string
|
||||||
|
word_count: number
|
||||||
|
kind: 'auto' | 'manual' | 'pre_restore'
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Downloadable export formats. PDF is handled client-side via the browser's
|
||||||
|
// print dialog (CJK-safe, no server-side font embedding).
|
||||||
|
export type ExportFormat = 'md' | 'html' | 'txt' | 'docx'
|
||||||
|
|
||||||
// A single LLM-proposed edit. `original` is the source of truth for placement —
|
// A single LLM-proposed edit. `original` is the source of truth for placement —
|
||||||
// the editor re-anchors by matching this string in the live document (spec
|
// the editor re-anchors by matching this string in the live document (spec
|
||||||
@@ -64,6 +135,17 @@ export interface Suggestion {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A deterministic, rule-based fix detected client-side (see Companion/prose.ts).
|
||||||
|
// The frontend owns mechanics detection; the backend only persists these as the
|
||||||
|
// 'mechanics' suggestion family. Spans are exact plaintext offsets.
|
||||||
|
export interface MechanicsFinding {
|
||||||
|
from: number
|
||||||
|
to: number
|
||||||
|
original: string
|
||||||
|
replacement: string
|
||||||
|
explanation: string
|
||||||
|
}
|
||||||
|
|
||||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`/api${path}`, {
|
const res = await fetch(`/api${path}`, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -93,21 +175,169 @@ export const api = {
|
|||||||
// Voice-consistency pass: whole-document, explicit-action, slower. Returns the
|
// Voice-consistency pass: whole-document, explicit-action, slower. Returns the
|
||||||
// unified pending set too. Rate-limited per document server-side.
|
// unified pending set too. Rate-limited per document server-side.
|
||||||
voiceDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/voice`, { method: 'POST' }),
|
voiceDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/voice`, { method: 'POST' }),
|
||||||
|
// Collocation coach: whole-document, explicit-action pass flagging non-native
|
||||||
|
// word pairings ("do a decision" → "make a decision"). Returns the unified
|
||||||
|
// pending set too. Rate-limited per document server-side.
|
||||||
|
collocationDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/collocation`, { method: 'POST' }),
|
||||||
|
// Mechanics pass: persist the client-detected deterministic fixes as the
|
||||||
|
// 'mechanics' family and return the unified pending set. Not rate-limited (it's
|
||||||
|
// free, local detection); runs alongside the grammar checkpoint.
|
||||||
|
submitMechanics: (id: string, findings: MechanicsFinding[]) =>
|
||||||
|
req<Suggestion[]>(`/docs/${id}/mechanics`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ findings }),
|
||||||
|
}),
|
||||||
// Pending suggestions for a doc, loaded when the editor opens it.
|
// Pending suggestions for a doc, loaded when the editor opens it.
|
||||||
listSuggestions: (id: string) => req<Suggestion[]>(`/docs/${id}/suggestions`),
|
listSuggestions: (id: string) => req<Suggestion[]>(`/docs/${id}/suggestions`),
|
||||||
acceptSuggestion: (id: string) =>
|
acceptSuggestion: (id: string) =>
|
||||||
req<void>(`/suggestions/${id}/accept`, { method: 'POST' }),
|
req<void>(`/suggestions/${id}/accept`, { method: 'POST' }),
|
||||||
dismissSuggestion: (id: string) =>
|
dismissSuggestion: (id: string) =>
|
||||||
req<void>(`/suggestions/${id}/dismiss`, { method: 'POST' }),
|
req<void>(`/suggestions/${id}/dismiss`, { method: 'POST' }),
|
||||||
|
// Simplified-Chinese rendering of a suggestion's explanation, for the Ask Petal
|
||||||
|
// opening bubble (the explanation itself stays English in the card body).
|
||||||
|
translateSuggestion: (id: string) =>
|
||||||
|
req<{ translation: string }>(`/suggestions/${id}/translate`, { 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}`,
|
||||||
|
|
||||||
|
// Download URL for a whole-corpus backup: a zip of every document rendered in
|
||||||
|
// the given format. A one-click "download all my writing" safety net.
|
||||||
|
exportAllUrl: (format: ExportFormat) => `/api/docs/export-all?format=${format}`,
|
||||||
|
|
||||||
|
// Offline word lookup (gloss + definition + synonyms) for the right-click popover.
|
||||||
lookupWord: (word: string) => req<WordInfo>(`/word/${encodeURIComponent(word)}`),
|
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)}`),
|
||||||
|
|
||||||
|
// Image upload: posts a single image file as multipart form data and returns
|
||||||
|
// its served URL (e.g. /api/images/<hash>.png). Stored on disk by the binary,
|
||||||
|
// so the document JSON keeps a small URL reference instead of inlined base64.
|
||||||
|
// Doesn't go through req() because the body is FormData, not JSON.
|
||||||
|
uploadImage: async (file: File): Promise<{ url: string }> => {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('image', file)
|
||||||
|
const res = await fetch('/api/images', { method: 'POST', body: form })
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = await res.text().catch(() => '')
|
||||||
|
throw new Error(`${res.status} ${res.statusText}${detail ? `: ${detail}` : ''}`)
|
||||||
|
}
|
||||||
|
return res.json() as Promise<{ url: string }>
|
||||||
|
},
|
||||||
|
|
||||||
|
// 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' }),
|
||||||
|
|
||||||
|
// Vocabulary garden. recordVocab captures (or refreshes) a looked-up word —
|
||||||
|
// idempotent per word, fired automatically on lookup. listVocab is the whole
|
||||||
|
// garden; dueVocab is just the cards ready for review; reviewVocab grades one
|
||||||
|
// card and returns its rescheduled state; deleteVocab removes a word.
|
||||||
|
recordVocab: (body: {
|
||||||
|
word: string
|
||||||
|
gloss?: string
|
||||||
|
definition?: string
|
||||||
|
phonetic?: string
|
||||||
|
example?: string
|
||||||
|
doc_id?: string | null
|
||||||
|
}) => req<VocabWord>('/vocab', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
listVocab: () => req<VocabWord[]>('/vocab'),
|
||||||
|
dueVocab: () => req<VocabWord[]>('/vocab/due'),
|
||||||
|
reviewVocab: (id: string, grade: VocabGrade) =>
|
||||||
|
req<VocabWord>(`/vocab/${id}/review`, { method: 'POST', body: JSON.stringify({ grade }) }),
|
||||||
|
deleteVocab: (id: string) => req<void>(`/vocab/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
// Current deployed build id — changes whenever a new frontend ships. The
|
// Current deployed build id — changes whenever a new frontend ships. The
|
||||||
// app polls this to offer a refresh. Bypasses any cache so the answer is live.
|
// app polls this to offer a refresh. Bypasses any cache so the answer is live.
|
||||||
version: () => req<{ version: string }>('/version', { cache: 'no-store' }),
|
version: () => req<{ version: string }>('/version', { cache: 'no-store' }),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The sentinel characters the server wraps a search match in (\x01 … \x02). Used
|
||||||
|
// by splitSnippet to render the matched span highlighted.
|
||||||
|
export const SNIPPET_HL_START = String.fromCharCode(1)
|
||||||
|
export const SNIPPET_HL_END = String.fromCharCode(2)
|
||||||
|
|
||||||
|
// splitSnippet breaks a server snippet into ordered { text, hit } segments so the
|
||||||
|
// UI can bold the matched span(s) without dangerously setting innerHTML.
|
||||||
|
export function splitSnippet(snippet: string): { text: string; hit: boolean }[] {
|
||||||
|
const out: { text: string; hit: boolean }[] = []
|
||||||
|
let rest = snippet
|
||||||
|
for (;;) {
|
||||||
|
const start = rest.indexOf(SNIPPET_HL_START)
|
||||||
|
if (start < 0) {
|
||||||
|
if (rest) out.push({ text: rest, hit: false })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (start > 0) out.push({ text: rest.slice(0, start), hit: false })
|
||||||
|
const end = rest.indexOf(SNIPPET_HL_END, start + 1)
|
||||||
|
if (end < 0) {
|
||||||
|
// Unterminated (shouldn't happen) — emit the remainder plainly.
|
||||||
|
out.push({ text: rest.slice(start + 1), hit: false })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out.push({ text: rest.slice(start + 1, end), hit: true })
|
||||||
|
rest = rest.slice(end + 1)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// tagColorVar maps a tag color key to its CSS custom property. Unknown keys fall
|
||||||
|
// back to the rose accent, matching the backend's coercion.
|
||||||
|
export function tagColorVar(color: TagColor | string): string {
|
||||||
|
switch (color) {
|
||||||
|
case 'mint':
|
||||||
|
return 'var(--color-mint)'
|
||||||
|
case 'peach':
|
||||||
|
return 'var(--color-peach)'
|
||||||
|
case 'lavender':
|
||||||
|
return 'var(--color-lavender)'
|
||||||
|
case 'sky':
|
||||||
|
return 'var(--color-sky)'
|
||||||
|
case 'honey':
|
||||||
|
return 'var(--color-honey)'
|
||||||
|
default:
|
||||||
|
return 'var(--color-accent)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// One turn in an Ask Petal conversation. History lives only in the component —
|
// One turn in an Ask Petal conversation. History lives only in the component —
|
||||||
// the server is stateless and re-injects the suggestion context every request.
|
// the server is stateless and re-injects the suggestion context every request.
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
|
|||||||
BIN
web/src/assets/sounds/baoding.mp3
Normal file
BIN
web/src/assets/sounds/baoding.mp3
Normal file
Binary file not shown.
BIN
web/src/assets/sounds/block.mp3
Normal file
BIN
web/src/assets/sounds/block.mp3
Normal file
Binary file not shown.
BIN
web/src/assets/sounds/error.mp3
Normal file
BIN
web/src/assets/sounds/error.mp3
Normal file
Binary file not shown.
BIN
web/src/assets/sounds/milestone.mp3
Normal file
BIN
web/src/assets/sounds/milestone.mp3
Normal file
Binary file not shown.
BIN
web/src/assets/sounds/pop1.mp3
Normal file
BIN
web/src/assets/sounds/pop1.mp3
Normal file
Binary file not shown.
BIN
web/src/assets/sounds/pop2.mp3
Normal file
BIN
web/src/assets/sounds/pop2.mp3
Normal file
Binary file not shown.
BIN
web/src/assets/sounds/tok.mp3
Normal file
BIN
web/src/assets/sounds/tok.mp3
Normal file
Binary file not shown.
194
web/src/audio/sounds.ts
Normal file
194
web/src/audio/sounds.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
// Petal's cute sound palette. These are hand-picked mp3 effects (warm bubble
|
||||||
|
// pops, a wooden "tok", rolling baoding balls, a Chinese fanfare for milestones,
|
||||||
|
// and a playful "haiya" for errors) living as assets in ./assets/sounds. Vite
|
||||||
|
// fingerprints and bundles them into dist, so they ride along inside the single
|
||||||
|
// Go binary; here we just fetch + decode them once and play them through a Web
|
||||||
|
// Audio bus with a soft master volume. The whole thing is opt-out-able and the
|
||||||
|
// mute choice persists in localStorage so it survives reloads.
|
||||||
|
|
||||||
|
import pop1Url from '../assets/sounds/pop1.mp3'
|
||||||
|
import pop2Url from '../assets/sounds/pop2.mp3'
|
||||||
|
import tokUrl from '../assets/sounds/tok.mp3'
|
||||||
|
import blockUrl from '../assets/sounds/block.mp3'
|
||||||
|
import baodingUrl from '../assets/sounds/baoding.mp3'
|
||||||
|
import milestoneUrl from '../assets/sounds/milestone.mp3'
|
||||||
|
import errorUrl from '../assets/sounds/error.mp3'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'petal.sound'
|
||||||
|
|
||||||
|
// Master volume — deliberately gentle. These are background delights, not alerts.
|
||||||
|
const MASTER_GAIN = 0.5
|
||||||
|
|
||||||
|
export type SoundName =
|
||||||
|
| 'pop1' // a soft bubble pop
|
||||||
|
| 'pop2' // a second bubble pop, slightly different
|
||||||
|
| 'tok' // a light wooden "tok"
|
||||||
|
| 'block' // a quick Chinese wood-block tap
|
||||||
|
| 'baoding' // rolling baoding-ball shimmer
|
||||||
|
| 'milestone' // a happy Chinese fanfare (word-count milestones)
|
||||||
|
| 'error' // a playful "haiya" — something went wrong
|
||||||
|
|
||||||
|
const SOURCES: Record<SoundName, string> = {
|
||||||
|
pop1: pop1Url,
|
||||||
|
pop2: pop2Url,
|
||||||
|
tok: tokUrl,
|
||||||
|
block: blockUrl,
|
||||||
|
baoding: baodingUrl,
|
||||||
|
milestone: milestoneUrl,
|
||||||
|
error: errorUrl,
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pool of light "something happened" notification sounds. We rotate through
|
||||||
|
// these (skipping the one just played) so the same blip never repeats back to
|
||||||
|
// back — a batch of suggestions or a run of tips stays lively instead of
|
||||||
|
// hammering one note. milestone/error are deliberately NOT in the pool: they're
|
||||||
|
// reserved for their specific moments.
|
||||||
|
const POP_POOL: SoundName[] = ['pop1', 'pop2', 'tok', 'block', 'baoding']
|
||||||
|
let lastPop = -1
|
||||||
|
|
||||||
|
let ctx: AudioContext | null = null
|
||||||
|
let master: GainNode | null = null
|
||||||
|
const buffers = new Map<SoundName, AudioBuffer>()
|
||||||
|
let enabled = readEnabled()
|
||||||
|
const listeners = new Set<(on: boolean) => void>()
|
||||||
|
|
||||||
|
function readEnabled(): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(STORAGE_KEY) !== 'off'
|
||||||
|
} catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSoundEnabled(): boolean {
|
||||||
|
return enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSoundEnabled(on: boolean): void {
|
||||||
|
enabled = on
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, on ? 'on' : 'off')
|
||||||
|
} catch {
|
||||||
|
/* private mode — choice just won't persist */
|
||||||
|
}
|
||||||
|
listeners.forEach((fn) => fn(on))
|
||||||
|
// Touching the context on enable doubles as a user-gesture unlock + warm-up.
|
||||||
|
if (on) void ensureContext()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onSoundEnabledChange(fn: (on: boolean) => void): () => void {
|
||||||
|
listeners.add(fn)
|
||||||
|
return () => listeners.delete(fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazily create the AudioContext (browsers require a user gesture before audio
|
||||||
|
// can play; the first play() after a click will succeed, earlier ones resume).
|
||||||
|
async function ensureContext(): Promise<AudioContext | null> {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
const AC: typeof AudioContext | undefined =
|
||||||
|
window.AudioContext ??
|
||||||
|
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
|
||||||
|
if (!AC) return null
|
||||||
|
if (!ctx) {
|
||||||
|
ctx = new AC()
|
||||||
|
master = ctx.createGain()
|
||||||
|
master.gain.value = MASTER_GAIN
|
||||||
|
// A gentle soft-clip so stacked sounds never crackle.
|
||||||
|
const shaper = ctx.createWaveShaper()
|
||||||
|
shaper.curve = softClipCurve()
|
||||||
|
master.connect(shaper)
|
||||||
|
shaper.connect(ctx.destination)
|
||||||
|
}
|
||||||
|
if (ctx.state === 'suspended') {
|
||||||
|
try {
|
||||||
|
await ctx.resume()
|
||||||
|
} catch {
|
||||||
|
/* will retry on the next gesture */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
function softClipCurve(): Float32Array<ArrayBuffer> {
|
||||||
|
const n = 1024
|
||||||
|
const curve = new Float32Array(new ArrayBuffer(n * 4))
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const x = (i / (n - 1)) * 2 - 1
|
||||||
|
curve[i] = Math.tanh(x * 1.2)
|
||||||
|
}
|
||||||
|
return curve
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch + decode one sound, caching the decoded buffer for instant replay.
|
||||||
|
async function load(name: SoundName, audio: AudioContext): Promise<AudioBuffer | null> {
|
||||||
|
const cached = buffers.get(name)
|
||||||
|
if (cached) return cached
|
||||||
|
try {
|
||||||
|
const res = await fetch(SOURCES[name])
|
||||||
|
const bytes = await res.arrayBuffer()
|
||||||
|
const buf = await audio.decodeAudioData(bytes)
|
||||||
|
buffers.set(name, buf)
|
||||||
|
return buf
|
||||||
|
} catch {
|
||||||
|
return null // asset missing / decode failed — sound just won't play
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warm the cache so the first real play is instant.
|
||||||
|
async function prime(): Promise<void> {
|
||||||
|
const audio = await ensureContext()
|
||||||
|
if (!audio) return
|
||||||
|
await Promise.all((Object.keys(SOURCES) as SoundName[]).map((n) => load(n, audio)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire a sound. No-op when muted or audio is unavailable. Never throws — a
|
||||||
|
// delight that fails should fail silently.
|
||||||
|
export function playSound(name: SoundName): void {
|
||||||
|
if (!enabled) return
|
||||||
|
void (async () => {
|
||||||
|
const audio = await ensureContext()
|
||||||
|
if (!audio || !master || audio.state !== 'running') return
|
||||||
|
const buf = await load(name, audio)
|
||||||
|
if (!buf) return
|
||||||
|
try {
|
||||||
|
const src = audio.createBufferSource()
|
||||||
|
src.buffer = buf
|
||||||
|
src.connect(master)
|
||||||
|
src.start()
|
||||||
|
} catch {
|
||||||
|
/* audio glitch — ignore */
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play the next notification pop from the pool, never repeating the last one, so
|
||||||
|
// consecutive blips always sound different. This is the everyday "something
|
||||||
|
// arrived" voice for suggestions, tips, and gentle cheers.
|
||||||
|
export function playPop(): void {
|
||||||
|
let i = lastPop
|
||||||
|
// Tiny pool, so a short scan to pick anything but the last is plenty.
|
||||||
|
for (let tries = 0; tries < POP_POOL.length && i === lastPop; tries++) {
|
||||||
|
i = Math.floor(Math.random() * POP_POOL.length)
|
||||||
|
}
|
||||||
|
lastPop = i
|
||||||
|
playSound(POP_POOL[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each freshly-surfaced suggestion just gets a rotating pop — the variety comes
|
||||||
|
// from the pool, not from the suggestion type, so a batch of same-type fixes
|
||||||
|
// still sounds lively.
|
||||||
|
export function playSuggestionSound(_type: string): void {
|
||||||
|
playPop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock + warm audio on the first user gesture so the very first sound isn't
|
||||||
|
// dropped by the browser's autoplay policy. Self-removing.
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const unlock = () => {
|
||||||
|
void prime()
|
||||||
|
window.removeEventListener('pointerdown', unlock)
|
||||||
|
window.removeEventListener('keydown', unlock)
|
||||||
|
}
|
||||||
|
window.addEventListener('pointerdown', unlock)
|
||||||
|
window.addEventListener('keydown', unlock)
|
||||||
|
}
|
||||||
120
web/src/audio/speech.ts
Normal file
120
web/src/audio/speech.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
// Read-aloud for an ESL writer: hear how an English word or passage sounds.
|
||||||
|
//
|
||||||
|
// Primary path is server-side neural TTS (Piper, proxied at /api/tts) — natural,
|
||||||
|
// consistent across devices, and far better than the browser's built-in voices
|
||||||
|
// (which fall back to robotic espeak on Chromium). If the server route is absent
|
||||||
|
// (TTS disabled) or unreachable, we fall back to the browser's Web Speech API so
|
||||||
|
// the buttons still do something. No model or network is strictly required.
|
||||||
|
|
||||||
|
// speechSupported reports whether read-aloud can do anything at all. Audio
|
||||||
|
// playback is universal, so as long as we can construct an Audio element OR the
|
||||||
|
// Web Speech API exists, the buttons should show. The server path is tried at
|
||||||
|
// call time and degrades on its own.
|
||||||
|
export function speechSupported(): boolean {
|
||||||
|
if (typeof window === 'undefined') return false
|
||||||
|
return typeof window.Audio === 'function' || 'speechSynthesis' in window
|
||||||
|
}
|
||||||
|
|
||||||
|
// webSpeechSupported gates the fallback path specifically.
|
||||||
|
function webSpeechSupported(): boolean {
|
||||||
|
return typeof window !== 'undefined' && 'speechSynthesis' in window && 'SpeechSynthesisUtterance' in window
|
||||||
|
}
|
||||||
|
|
||||||
|
// current holds the in-flight server-audio element and its object URL so a rapid
|
||||||
|
// re-tap can stop and replace it (mirroring speechSynthesis.cancel()).
|
||||||
|
let current: { audio: HTMLAudioElement; url: string } | null = null
|
||||||
|
|
||||||
|
// requestSeq increments on every speak() call so a slow fetch that resolves after
|
||||||
|
// a newer tap can detect it's stale and bow out instead of double-playing.
|
||||||
|
let requestSeq = 0
|
||||||
|
|
||||||
|
// stopSpeech halts any read-aloud in flight — both the server-audio element and
|
||||||
|
// the Web Speech fallback — and bumps requestSeq so a fetch still in flight bows
|
||||||
|
// out instead of playing late. Exported so a panel can cancel audio on unmount,
|
||||||
|
// keeping a word's pronunciation from outliving the panel that started it.
|
||||||
|
export function stopSpeech(): void {
|
||||||
|
requestSeq++
|
||||||
|
if (current) {
|
||||||
|
current.audio.pause()
|
||||||
|
URL.revokeObjectURL(current.url)
|
||||||
|
current = null
|
||||||
|
}
|
||||||
|
if (webSpeechSupported()) window.speechSynthesis.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickVoice prefers an exact locale match (e.g. en-US), then any voice in the
|
||||||
|
// same language family (en-*). Returns undefined to let the engine default.
|
||||||
|
function pickVoice(lang: string): SpeechSynthesisVoice | undefined {
|
||||||
|
const voices = window.speechSynthesis.getVoices()
|
||||||
|
const base = lang.split('-')[0]
|
||||||
|
return voices.find((v) => v.lang === lang) || voices.find((v) => v.lang?.startsWith(base))
|
||||||
|
}
|
||||||
|
|
||||||
|
// speakWebSpeech is the fallback: the browser's built-in synthesizer. A touch
|
||||||
|
// slower than default so learners can follow along.
|
||||||
|
function speakWebSpeech(text: string, lang: string): void {
|
||||||
|
if (!webSpeechSupported()) return
|
||||||
|
const synth = window.speechSynthesis
|
||||||
|
synth.cancel()
|
||||||
|
const utterance = new SpeechSynthesisUtterance(text)
|
||||||
|
utterance.lang = lang
|
||||||
|
const voice = pickVoice(lang)
|
||||||
|
if (voice) utterance.voice = voice
|
||||||
|
utterance.rate = 0.95
|
||||||
|
synth.speak(utterance)
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectLang guesses a BCP-47 locale from the text so the right Piper voice is
|
||||||
|
// chosen. Any Han/kana character routes to Chinese (zh-CN) — a selection is
|
||||||
|
// essentially never mixed at the level that matters, and reading a Chinese
|
||||||
|
// passage with the English voice spells each character out ("Chinese letter…").
|
||||||
|
// Everything else defaults to US English, the language she's learning.
|
||||||
|
const CJK = /[-ヿ㐀-䶿一-鿿豈-ヲ-゚]/
|
||||||
|
export function detectLang(text: string): string {
|
||||||
|
return CJK.test(text) ? 'zh-CN' : 'en-US'
|
||||||
|
}
|
||||||
|
|
||||||
|
// speak reads `text` aloud, cancelling anything already in flight so rapid taps
|
||||||
|
// don't queue up. `lang` defaults to a guess from the text (Chinese vs English)
|
||||||
|
// so callers can just pass the selection; pass an explicit locale to override.
|
||||||
|
// It tries the server's neural voice first and silently falls back to the browser
|
||||||
|
// voice if that's unavailable (route off, network error, or a 404 for a language
|
||||||
|
// with no configured voice).
|
||||||
|
export function speak(text: string, lang = detectLang(text)): void {
|
||||||
|
if (!text.trim()) return
|
||||||
|
stopSpeech()
|
||||||
|
const seq = ++requestSeq
|
||||||
|
|
||||||
|
fetch('/api/tts', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text, lang }),
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error(`tts ${res.status}`)
|
||||||
|
return res.blob()
|
||||||
|
})
|
||||||
|
.then((blob) => {
|
||||||
|
// A newer tap superseded this one while the fetch was in flight — drop it.
|
||||||
|
if (seq !== requestSeq) return
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const audio = new Audio(url)
|
||||||
|
current = { audio, url }
|
||||||
|
// Release the blob once playback finishes (or errors) to avoid leaking.
|
||||||
|
const cleanup = () => {
|
||||||
|
if (current?.audio === audio) {
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
audio.addEventListener('ended', cleanup)
|
||||||
|
audio.addEventListener('error', cleanup)
|
||||||
|
return audio.play()
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Server TTS unavailable for this request — use the browser voice instead,
|
||||||
|
// unless a newer tap has already superseded this one.
|
||||||
|
if (seq !== requestSeq) return
|
||||||
|
speakWebSpeech(text, lang)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ interface Props {
|
|||||||
animationData?: object
|
animationData?: object
|
||||||
loop?: boolean
|
loop?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
|
// Mirror the animation left↔right (for assets drawn facing the wrong way).
|
||||||
|
flip?: boolean
|
||||||
fallback: React.ReactNode
|
fallback: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +50,7 @@ function fitToContent(anim: AnimationItem, svg: SVGSVGElement) {
|
|||||||
// A thin wrapper over lottie-web (framework-agnostic, no React peer deps, fully
|
// A thin wrapper over lottie-web (framework-agnostic, no React peer deps, fully
|
||||||
// offline). Reloads the animation whenever the data changes — moods swap by
|
// offline). Reloads the animation whenever the data changes — moods swap by
|
||||||
// passing a different `animationData`.
|
// passing a different `animationData`.
|
||||||
export function LottiePlayer({ animationData, loop = true, className, fallback }: Props) {
|
export function LottiePlayer({ animationData, loop = true, className, flip, fallback }: Props) {
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
const anim = useRef<AnimationItem | null>(null)
|
const anim = useRef<AnimationItem | null>(null)
|
||||||
|
|
||||||
@@ -74,5 +76,12 @@ export function LottiePlayer({ animationData, loop = true, className, fallback }
|
|||||||
}, [animationData, loop])
|
}, [animationData, loop])
|
||||||
|
|
||||||
if (!animationData) return <>{fallback}</>
|
if (!animationData) return <>{fallback}</>
|
||||||
return <div ref={ref} className={className} aria-hidden />
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={className}
|
||||||
|
style={flip ? { transform: 'scaleX(-1)' } : undefined}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import { COMPANIONS, DEFAULT_COMPANION } from './companions'
|
|||||||
interface Props {
|
interface Props {
|
||||||
wordCount: number
|
wordCount: number
|
||||||
saveStatus: SaveStatus
|
saveStatus: SaveStatus
|
||||||
|
llmDown: boolean
|
||||||
editTick: number
|
editTick: number
|
||||||
acceptTick: number
|
acceptTick: number
|
||||||
|
text: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emoji placeholder per mood, used for any mood a companion has no Lottie for.
|
// Emoji placeholder per mood, used for any mood a companion has no Lottie for.
|
||||||
@@ -26,8 +28,15 @@ const STORAGE_KEY = 'petal.companion'
|
|||||||
// useCompanion and shows a Mandarin-first speech bubble for cheers, tips, and
|
// useCompanion and shows a Mandarin-first speech bubble for cheers, tips, and
|
||||||
// break reminders. Clicking the mascot opens a picker to switch companions
|
// break reminders. Clicking the mascot opens a picker to switch companions
|
||||||
// (the choice persists in localStorage).
|
// (the choice persists in localStorage).
|
||||||
export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }: Props) {
|
export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, acceptTick, text }: Props) {
|
||||||
const { mood, bubble, dismiss } = useCompanion({ wordCount, saveStatus, editTick, acceptTick })
|
const { mood, bubble, dismiss, holdBubble, releaseBubble } = useCompanion({
|
||||||
|
wordCount,
|
||||||
|
saveStatus,
|
||||||
|
llmDown,
|
||||||
|
editTick,
|
||||||
|
acceptTick,
|
||||||
|
text,
|
||||||
|
})
|
||||||
|
|
||||||
const [companionId, setCompanionId] = useState<string>(() => {
|
const [companionId, setCompanionId] = useState<string>(() => {
|
||||||
try {
|
try {
|
||||||
@@ -136,7 +145,9 @@ export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }:
|
|||||||
<div
|
<div
|
||||||
role="status"
|
role="status"
|
||||||
onClick={dismiss}
|
onClick={dismiss}
|
||||||
className="petal-bubble pointer-events-auto max-w-[260px] cursor-pointer p-3 pr-3.5"
|
onMouseEnter={holdBubble}
|
||||||
|
onMouseLeave={releaseBubble}
|
||||||
|
className="petal-bubble pointer-events-auto max-w-[420px] cursor-pointer p-4 pr-5"
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--color-surface)',
|
background: 'var(--color-surface)',
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
@@ -147,10 +158,16 @@ export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }:
|
|||||||
}}
|
}}
|
||||||
title="Click to dismiss"
|
title="Click to dismiss"
|
||||||
>
|
>
|
||||||
<p className="text-sm font-bold leading-snug" style={{ color: 'var(--color-plum)' }}>
|
<p
|
||||||
|
className="font-bold leading-snug"
|
||||||
|
style={{ color: 'var(--color-plum)', fontSize: '1.4rem' }}
|
||||||
|
>
|
||||||
{bubble.zh}
|
{bubble.zh}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 text-xs leading-snug" style={{ color: 'var(--color-muted)' }}>
|
<p
|
||||||
|
className="mt-0.5 leading-snug"
|
||||||
|
style={{ color: 'var(--color-muted)', fontSize: '1.15rem' }}
|
||||||
|
>
|
||||||
{bubble.en}
|
{bubble.en}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -163,8 +180,9 @@ export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }:
|
|||||||
aria-label="Choose a companion"
|
aria-label="Choose a companion"
|
||||||
className={`petal-companion pointer-events-auto select-none ${napping ? 'petal-companion-sleep' : ''}`}
|
className={`petal-companion pointer-events-auto select-none ${napping ? 'petal-companion-sleep' : ''}`}
|
||||||
style={{
|
style={{
|
||||||
width: 144,
|
// Size scales with the viewport — see --petal-companion-size in index.css.
|
||||||
height: 144,
|
width: 'var(--petal-companion-size)',
|
||||||
|
height: 'var(--petal-companion-size)',
|
||||||
padding: 0,
|
padding: 0,
|
||||||
borderRadius: 'var(--radius-pill)',
|
borderRadius: 'var(--radius-pill)',
|
||||||
background: 'var(--color-surface-alt)',
|
background: 'var(--color-surface-alt)',
|
||||||
@@ -178,8 +196,13 @@ export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }:
|
|||||||
<LottiePlayer
|
<LottiePlayer
|
||||||
key={companion.id}
|
key={companion.id}
|
||||||
animationData={animationData}
|
animationData={animationData}
|
||||||
className="h-32 w-32"
|
flip={companion.flip}
|
||||||
fallback={<span style={{ fontSize: 72, lineHeight: 1 }}>{MOOD_EMOJI[renderMood]}</span>}
|
className="petal-companion-art"
|
||||||
|
fallback={
|
||||||
|
<span style={{ fontSize: 'calc(var(--petal-companion-size) * 0.5)', lineHeight: 1 }}>
|
||||||
|
{MOOD_EMOJI[renderMood]}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{napping && (
|
{napping && (
|
||||||
<span className="petal-zzz absolute" aria-hidden style={{ color: 'var(--color-muted)' }}>
|
<span className="petal-zzz absolute" aria-hidden style={{ color: 'var(--color-muted)' }}>
|
||||||
|
|||||||
1
web/src/components/Companion/animations/butterfly.json
Normal file
1
web/src/components/Companion/animations/butterfly.json
Normal file
File diff suppressed because one or more lines are too long
1
web/src/components/Companion/animations/parrot.json
Normal file
1
web/src/components/Companion/animations/parrot.json
Normal file
File diff suppressed because one or more lines are too long
1
web/src/components/Companion/animations/wiggle-dog.json
Normal file
1
web/src/components/Companion/animations/wiggle-dog.json
Normal file
File diff suppressed because one or more lines are too long
@@ -1,6 +1,9 @@
|
|||||||
import type { Mood } from './useCompanion'
|
import type { Mood } from './useCompanion'
|
||||||
import sleepingCat from './animations/sleeping-cat.json'
|
import sleepingCat from './animations/sleeping-cat.json'
|
||||||
import happyDog from './animations/happy-dog.json'
|
import happyDog from './animations/happy-dog.json'
|
||||||
|
import wiggleDog from './animations/wiggle-dog.json'
|
||||||
|
import butterfly from './animations/butterfly.json'
|
||||||
|
import parrot from './animations/parrot.json'
|
||||||
|
|
||||||
export interface Companion {
|
export interface Companion {
|
||||||
id: string
|
id: string
|
||||||
@@ -12,6 +15,9 @@ export interface Companion {
|
|||||||
// When true the mascot keeps its calm sway + drifting zzz regardless of mood
|
// When true the mascot keeps its calm sway + drifting zzz regardless of mood
|
||||||
// (e.g. a cat that's always asleep but still talks in its sleep).
|
// (e.g. a cat that's always asleep but still talks in its sleep).
|
||||||
alwaysAsleep?: boolean
|
alwaysAsleep?: boolean
|
||||||
|
// When true the rendered animation is mirrored left↔right — for assets drawn
|
||||||
|
// facing the "wrong" way for our bottom-right corner (e.g. the parrot).
|
||||||
|
flip?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// The roster. Add a companion by dropping a pure-vector Lottie JSON into
|
// The roster. Add a companion by dropping a pure-vector Lottie JSON into
|
||||||
@@ -44,6 +50,43 @@ export const COMPANIONS: Companion[] = [
|
|||||||
// no sleeping clip → stays in its idle pose instead of visibly napping
|
// no sleeping clip → stays in its idle pose instead of visibly napping
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'wiggle-dog',
|
||||||
|
name: 'Wiggle Dog',
|
||||||
|
zh: '摇尾狗',
|
||||||
|
emoji: '🐕',
|
||||||
|
animations: {
|
||||||
|
idle: wiggleDog,
|
||||||
|
happy: wiggleDog,
|
||||||
|
talking: wiggleDog,
|
||||||
|
celebrate: wiggleDog,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'butterfly',
|
||||||
|
name: 'Butterfly',
|
||||||
|
zh: '蝴蝶',
|
||||||
|
emoji: '🦋',
|
||||||
|
animations: {
|
||||||
|
idle: butterfly,
|
||||||
|
happy: butterfly,
|
||||||
|
talking: butterfly,
|
||||||
|
celebrate: butterfly,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'parrot',
|
||||||
|
name: 'Parrot',
|
||||||
|
zh: '鹦鹉',
|
||||||
|
emoji: '🦜',
|
||||||
|
flip: true, // asset faces left; mirror it to face into the page
|
||||||
|
animations: {
|
||||||
|
idle: parrot,
|
||||||
|
happy: parrot,
|
||||||
|
talking: parrot,
|
||||||
|
celebrate: parrot,
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export const DEFAULT_COMPANION = 'cat'
|
export const DEFAULT_COMPANION = 'cat'
|
||||||
|
|||||||
366
web/src/components/Companion/prose.test.ts
Normal file
366
web/src/components/Companion/prose.test.ts
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { analyzeProse } from './prose'
|
||||||
|
|
||||||
|
// These tests pin the rules-based prose checker's behavior. For an ESL writer a
|
||||||
|
// *wrong* nudge costs trust faster than a missed one earns it, so every rule is
|
||||||
|
// tested in two directions: a true-positive case that must fire, and at least
|
||||||
|
// one near-miss "guard" case that must stay silent. Sentences are padded past
|
||||||
|
// the 8-word floor that analyzeProse needs before it offers advice.
|
||||||
|
|
||||||
|
const rulesFor = (text: string) => analyzeProse(text).map((h) => h.rule)
|
||||||
|
|
||||||
|
// Assert a given rule appears among the hints for this text.
|
||||||
|
function expectRule(text: string, rule: string) {
|
||||||
|
expect(rulesFor(text), `expected "${rule}" for: ${text}`).toContain(rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert the text produces no hints at all (false-positive guard).
|
||||||
|
function expectClean(text: string) {
|
||||||
|
expect(analyzeProse(text), `expected no hints for: ${text}`).toEqual([])
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('analyzeProse — floor', () => {
|
||||||
|
it('stays quiet on very short drafts (under 8 English words)', () => {
|
||||||
|
expect(analyzeProse('he go now')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns hints highest-priority first', () => {
|
||||||
|
// This 36-word run-on also contains a lowercase "i"; run-on must lead.
|
||||||
|
const hints = analyzeProse(
|
||||||
|
'yesterday i walked all the way to the market and i bought apples and oranges and i saw my friend there and we talked and laughed and walked back home together in the warm afternoon sun.',
|
||||||
|
)
|
||||||
|
expect(hints[0].rule).toBe('runon')
|
||||||
|
expect(hints.map((h) => h.rule)).toContain('cap-i')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('run-ons', () => {
|
||||||
|
it('flags a long, comma-heavy sentence', () => {
|
||||||
|
expectRule(
|
||||||
|
'I went to the market and I bought some apples and then I saw my friend and we talked for a while and after that we went to the cafe and ordered coffee and cake together.',
|
||||||
|
'runon',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves a normal sentence alone', () => {
|
||||||
|
expectClean('The garden was quiet in the morning and the rain fell softly on the leaves.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('comma splices', () => {
|
||||||
|
it('flags two independent clauses joined by a comma', () => {
|
||||||
|
expectRule('I finished the report, I sent it to my boss right away today.', 'splice')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('catches a splice with a verb not on any list', () => {
|
||||||
|
expectRule('The garden was quiet in the morning, she poured a cup of tea slowly.', 'splice')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not flag an introductory transition (However, …)', () => {
|
||||||
|
expectClean('However, we decided to stay home and rest for the entire weekend.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not flag an introductory phrase (After dinner, …)', () => {
|
||||||
|
expectClean('After dinner, I went home and read a book until I fell asleep.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not flag a pronoun list after a comma', () => {
|
||||||
|
expectClean('We invited my brother, you and your sister to the dinner party.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('unclear antecedents', () => {
|
||||||
|
it('flags a vague "This + verb" opener after a prior sentence', () => {
|
||||||
|
expectRule(
|
||||||
|
'We changed the whole schedule last week. This makes everyone confused about the new times.',
|
||||||
|
'antecedent',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves "This" alone when it names its noun', () => {
|
||||||
|
expectClean(
|
||||||
|
'We changed the whole schedule last week. This new schedule confuses everyone about the times.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('oxford comma', () => {
|
||||||
|
it('flags a 3-item noun list missing the serial comma', () => {
|
||||||
|
expectRule('My mother, my father and my sister came to visit us last summer.', 'oxford')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags a verb-phrase list missing the serial comma', () => {
|
||||||
|
expectRule('We cleaned the kitchen, washed the dishes and took out the trash.', 'oxford')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not flag a list that already has the serial comma', () => {
|
||||||
|
expectClean('I bought apples, bananas, and oranges at the store this morning.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not flag a compound predicate as a list', () => {
|
||||||
|
expectClean('After dinner, I went home and read a book until I fell asleep.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('a / an', () => {
|
||||||
|
it('flags "a" before a vowel sound', () => {
|
||||||
|
expectRule('She found a umbrella and a old book on the table by the window.', 'article')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags "an" before a consonant sound', () => {
|
||||||
|
expectRule('He gave me an book and an pencil to use during the long exam.', 'article')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('respects sound exceptions (a university, an hour)', () => {
|
||||||
|
expectClean('He is a university student and it took an hour to arrive here.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('uncountable plurals', () => {
|
||||||
|
it('flags a pluralized mass noun', () => {
|
||||||
|
expectRule('She gave me many informations about the new job opening today.', 'uncountable')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves the singular mass noun alone', () => {
|
||||||
|
expectClean('He shared some useful advice and helped me with my homework tonight.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('capitalization of proper nouns', () => {
|
||||||
|
it('flags lowercase languages and days', () => {
|
||||||
|
expectRule('I am learning english and french during my free time every week.', 'propercap')
|
||||||
|
expectRule('We will meet on monday to discuss the final project together soon.', 'propercap')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not flag ambiguous homographs (may, march)', () => {
|
||||||
|
expectClean('They may visit in the spring and march through the old town square.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves already-capitalized proper nouns alone', () => {
|
||||||
|
expectClean('I will study English and Chinese together this coming summer break here.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('subject–verb agreement', () => {
|
||||||
|
it('flags a bare verb after he/she/it', () => {
|
||||||
|
expectRule('My brother is busy but he go to the gym every single morning.', 'sva')
|
||||||
|
expectRule('She have a lovely garden full of roses behind her small house.', 'sva')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags a sentence-initial subject too', () => {
|
||||||
|
expectRule('It make me happy whenever the sun comes out after the rain.', 'sva')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not flag causative/perception frames (let it go, make it work)', () => {
|
||||||
|
expectClean('Please let it go and try to enjoy the rest of your day.')
|
||||||
|
expectClean('We should make it work before the big meeting tomorrow afternoon.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('plural after a number', () => {
|
||||||
|
it('flags a singular noun after a cardinal number', () => {
|
||||||
|
expectRule('I bought three apple and some bread at the market this morning.', 'plural')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not flag an adjective between number and noun', () => {
|
||||||
|
expectClean('We adopted two small dogs from the shelter near our apartment today.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not flag irregular plurals (five people)', () => {
|
||||||
|
expectClean('There were five people waiting patiently outside in the cold morning rain.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not flag an already-plural noun', () => {
|
||||||
|
expectClean('She has two cats and they sleep all day long on the couch.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('double determiner', () => {
|
||||||
|
it('flags a stacked article + possessive', () => {
|
||||||
|
expectRule('Please put the my book back on the shelf when you finish it.', 'doubledet')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('there is + plural', () => {
|
||||||
|
it('flags "there is" with a plural quantifier', () => {
|
||||||
|
expectRule('There is many reasons to visit the coast during the warm months.', 'thereis')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not flag "there is some" with a mass noun', () => {
|
||||||
|
expectClean('There is some water left in the bottle if you are thirsty now.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('its / it’s', () => {
|
||||||
|
it("flags it's used as a possessive", () => {
|
||||||
|
expectRule("The cat licked it's own paw while sitting quietly by the window.", 'its')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags its used where "it is" is meant', () => {
|
||||||
|
expectRule('I think its a wonderful idea to travel together next year sometime.', 'its')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves correct possessive "its" alone', () => {
|
||||||
|
expectClean('The dog wagged its tail happily when we came home that evening.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('than / then', () => {
|
||||||
|
it('flags a comparative followed by "then"', () => {
|
||||||
|
expectRule('This cake tastes much better then the one we made last weekend.', 'than')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves sequential "then" alone', () => {
|
||||||
|
expectClean('We ate dinner and then we watched a long movie on the sofa.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('comma after a transition', () => {
|
||||||
|
it('flags a sentence-initial transition with no comma', () => {
|
||||||
|
expectRule('However we decided to stay home and rest for the whole weekend.', 'introcomma')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not flag a sequence word (Then …)', () => {
|
||||||
|
expectClean('Then we walked to the park and fed the ducks by the pond.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('spacing around punctuation', () => {
|
||||||
|
it('flags a missing space after a comma', () => {
|
||||||
|
expectRule('I like apples,bananas and grapes from the little shop down the street.', 'space-after')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags a space before punctuation', () => {
|
||||||
|
expectRule('She smiled warmly , then walked away without saying a single word today.', 'space-punct')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not flag thousands separators in numbers', () => {
|
||||||
|
expectClean('The list had 1,000 items and cost us about 2,500 dollars in total.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('capitalization basics', () => {
|
||||||
|
it('flags a lowercase standalone "i"', () => {
|
||||||
|
expectRule('Yesterday i walked to the store and bought milk, eggs, and bread.', 'cap-i')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags a sentence that does not start with a capital', () => {
|
||||||
|
expectRule('The sun set slowly. it was a beautiful evening down by the river.', 'cap-sentence')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
import { mechanicsFindings } from './prose'
|
||||||
|
|
||||||
|
// mechanicsFindings is the applyable subset feeding the suggestion cards. Each
|
||||||
|
// finding must carry an EXACT span (text.slice(from,to) === original) and a
|
||||||
|
// replacement that actually changes the text — these become one-click edits.
|
||||||
|
describe('mechanicsFindings — applyable fixes', () => {
|
||||||
|
// Every finding's span must be exact, regardless of which rule produced it.
|
||||||
|
function expectExactSpans(text: string) {
|
||||||
|
const found = mechanicsFindings(text)
|
||||||
|
for (const f of found) {
|
||||||
|
expect(text.slice(f.from, f.to), `span for ${JSON.stringify(f)}`).toBe(f.original)
|
||||||
|
expect(f.replacement).not.toBe(f.original)
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
const one = (text: string, rulePred: (f: ReturnType<typeof mechanicsFindings>[number]) => boolean) =>
|
||||||
|
expectExactSpans(text).find(rulePred)
|
||||||
|
|
||||||
|
it('doubled word → single word, exact span', () => {
|
||||||
|
const f = one('I saw the the cat in the garden today.', (f) => f.original === 'the the')
|
||||||
|
expect(f?.replacement).toBe('the')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finds BOTH doublings with distinct spans', () => {
|
||||||
|
const found = mechanicsFindings('the the dog and the the cat ran around the yard.')
|
||||||
|
.filter((f) => f.original === 'the the')
|
||||||
|
expect(found).toHaveLength(2)
|
||||||
|
expect(found[0].from).not.toBe(found[1].from)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Spans are widened to a distinctive phrase so they re-anchor by string in the
|
||||||
|
// editor (a bare "a"/"i"/" is" would match the wrong spot). The replacement
|
||||||
|
// carries the whole corrected phrase.
|
||||||
|
|
||||||
|
it('lowercase standalone i is awareness-only (no card)', () => {
|
||||||
|
// It still flags as a companion bubble (see analyzeProse), but a single
|
||||||
|
// character can't be anchored, so it must NOT become a card.
|
||||||
|
expect(mechanicsFindings('Yesterday i walked to the store and came home.').length).toBe(0)
|
||||||
|
expect(rulesFor('Yesterday i walked to the store and came home.')).toContain('cap-i')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a → an: spans the whole "a <noun>" phrase', () => {
|
||||||
|
const f = one('She found a umbrella under the old wooden table.', (f) => f.original === 'a umbrella')
|
||||||
|
expect(f?.replacement).toBe('an umbrella')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uncountable plural → singular', () => {
|
||||||
|
const f = one('She gave me many informations about the new job.', (f) => f.original === 'informations')
|
||||||
|
expect(f?.replacement).toBe('information')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lowercase proper noun → capitalized', () => {
|
||||||
|
const f = one('I am learning english during my free time this year.', (f) => f.original === 'english')
|
||||||
|
expect(f?.replacement).toBe('English')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('subject-verb agreement → corrects the verb, spans subject+verb', () => {
|
||||||
|
const f = one('She have three cats and a dog at her house.', (f) => f.original === 'She have')
|
||||||
|
expect(f?.replacement).toBe('She has')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('singular noun after a number → plural, spans number+noun', () => {
|
||||||
|
const f = one('I bought five apple at the market this morning.', (f) => f.original === 'five apple')
|
||||||
|
expect(f?.replacement).toBe('five apples')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('comparative + then → than, spans the phrase', () => {
|
||||||
|
const f = one('This book is better then the one I read last week.', (f) => f.original === 'better then')
|
||||||
|
expect(f?.replacement).toBe('better than')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('space before punctuation is removed (spans the word)', () => {
|
||||||
|
const f = one('I love it , it makes me very happy indeed.', (f) => f.original === 'it ,')
|
||||||
|
expect(f?.replacement).toBe('it,')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('missing space after a comma is added (spans both words)', () => {
|
||||||
|
const f = one('I bought apples,bananas and pears at the store.', (f) => f.original === 'apples,bananas')
|
||||||
|
expect(f?.replacement).toBe('apples, bananas')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stacked determiner drops the article', () => {
|
||||||
|
const f = one('She left the my book on the kitchen table again.', (f) => f.original === 'the my')
|
||||||
|
expect(f?.replacement).toBe('my')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('there is + plural → there are, spans the phrase', () => {
|
||||||
|
const f = one('There is many people waiting outside in the cold.', (f) => f.original === 'There is many')
|
||||||
|
expect(f?.replacement).toBe('There are many')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("it's own → its own, spans the phrase", () => {
|
||||||
|
const f = one("The cat licked it's own paw very slowly today.", (f) => f.original === "it's own")
|
||||||
|
expect(f?.replacement).toBe('its own')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("its a → it's a, spans the phrase", () => {
|
||||||
|
const f = one('I think its a wonderful day to go outside now.', (f) => f.original === 'its a')
|
||||||
|
expect(f?.replacement).toBe("it's a")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Awareness-only rules never produce cards — they stay companion bubbles.
|
||||||
|
it('run-ons and splices produce NO card findings', () => {
|
||||||
|
const runOn = 'I went to the market and I bought some apples and then I saw my friend and we talked for a while and after that we went to the cafe and ordered coffee and cake together.'
|
||||||
|
expect(mechanicsFindings(runOn).some((f) => f.original.length > 30)).toBe(false)
|
||||||
|
const splice = 'I finished the report, I sent it to my boss right away today.'
|
||||||
|
// no card spans the comma-join; only (if any) small word-level fixes
|
||||||
|
expect(mechanicsFindings(splice).every((f) => f.to - f.from < 12)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clean prose yields no findings', () => {
|
||||||
|
expect(mechanicsFindings('The garden was quiet and the rain fell softly on the leaves.')).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
695
web/src/components/Companion/prose.ts
Normal file
695
web/src/components/Companion/prose.ts
Normal file
@@ -0,0 +1,695 @@
|
|||||||
|
// Rules-based prose checker — NO LLM. This is the companion's "smart" side: it
|
||||||
|
// reads the writer's actual text and surfaces one gentle, context-aware grammar
|
||||||
|
// or style note at a time, the way an attentive tutor leaning over her shoulder
|
||||||
|
// would. Every rule here is intentionally conservative: a wrong nudge erodes
|
||||||
|
// trust far faster than a missed one earns it, so we only speak up when a
|
||||||
|
// pattern is a high-confidence, genuinely-common English mistake.
|
||||||
|
//
|
||||||
|
// Each finding becomes a Mandarin-first bubble Line (see tips.ts), often quoting
|
||||||
|
// a short slice of her own sentence so the advice clearly belongs to *this*
|
||||||
|
// paragraph and not a generic tip jar.
|
||||||
|
//
|
||||||
|
// Two consumers share these rules (one source of truth, no duplication):
|
||||||
|
// • the companion bubbles — awareness notes, shown one at a time (analyzeProse)
|
||||||
|
// • the suggestion cards — applyable one-click fixes (mechanicsFindings)
|
||||||
|
// A rule is "applyable" when it can name an exact span and a single replacement;
|
||||||
|
// it attaches a `fix` and feeds the cards. Awareness-only rules (run-ons, comma
|
||||||
|
// splices, …) carry no `fix` and stay companion bubbles. The companion hides
|
||||||
|
// fix-bearing hints so the same span never appears as both a bubble and a card.
|
||||||
|
|
||||||
|
import type { Line } from './tips'
|
||||||
|
|
||||||
|
export interface ProseHint extends Line {
|
||||||
|
// Stable, content-derived id so the same untouched sentence isn't re-flagged
|
||||||
|
// every cadence; the companion remembers recently-shown ids.
|
||||||
|
id: string
|
||||||
|
// Rule family, used to vary advice (don't show the same kind twice in a row).
|
||||||
|
rule: string
|
||||||
|
// Present only on *applyable* findings: the exact span and the text to swap in.
|
||||||
|
// These become suggestion cards; the companion skips them (see useCompanion).
|
||||||
|
fix?: Fix
|
||||||
|
}
|
||||||
|
|
||||||
|
// An exact, one-click edit: replace text[from:to] with `replacement`.
|
||||||
|
export interface Fix {
|
||||||
|
from: number
|
||||||
|
to: number
|
||||||
|
replacement: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// A deterministic suggestion-card finding, derived from an applyable hint. Mirrors
|
||||||
|
// the backend's card shape (original/replacement/explanation + span) so the card
|
||||||
|
// pipeline can persist it as the 'mechanics' family. `explanation` is the English
|
||||||
|
// note; the card's own translate action renders Mandarin on demand.
|
||||||
|
export interface MechanicsFinding {
|
||||||
|
from: number
|
||||||
|
to: number
|
||||||
|
original: string
|
||||||
|
replacement: string
|
||||||
|
explanation: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── small text helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ENGLISH_WORD_RE = /[A-Za-z]+(?:['’-][A-Za-z]+)*/g
|
||||||
|
|
||||||
|
// Split into sentence-ish chunks (text + leading punctuation trimmed). Mirrors
|
||||||
|
// the SENTENCE_RE terminators used elsewhere, including CJK fullwidth forms.
|
||||||
|
function sentences(text: string): string[] {
|
||||||
|
const out: string[] = []
|
||||||
|
const re = /[^.!?。!?]+[.!?。!?]*/g
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
while ((m = re.exec(text))) {
|
||||||
|
const s = m[0].trim()
|
||||||
|
if (s) out.push(s)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// First few words of a sentence, as an anchor we can quote back to her.
|
||||||
|
function anchor(s: string, n = 6): string {
|
||||||
|
const words = s.split(/\s+/).slice(0, n).join(' ')
|
||||||
|
return words.length < s.length ? `${words}…` : words
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cheap, stable hash → short id suffix, so ids stay stable across recomputes but
|
||||||
|
// change the moment she edits the offending text.
|
||||||
|
function key(s: string): string {
|
||||||
|
return s.toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 48)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capitalize `repl`'s first letter when `original` started uppercase, so fixing a
|
||||||
|
// sentence-initial word doesn't quietly lowercase the line.
|
||||||
|
function matchCase(original: string, repl: string): string {
|
||||||
|
if (!original || !repl) return repl
|
||||||
|
const c = original[0]
|
||||||
|
if (c >= 'A' && c <= 'Z') return repl[0].toUpperCase() + repl.slice(1)
|
||||||
|
return repl
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── individual rules ────────────────────────────────────────────────────────
|
||||||
|
// Each rule pushes its findings; awareness rules cap themselves to a couple so a
|
||||||
|
// single sentence can't flood the bubble, while applyable rules report every
|
||||||
|
// occurrence (each becomes its own card). Applyable rules attach a `fix`.
|
||||||
|
|
||||||
|
// Run-on / overly long sentences — the single most common readability problem
|
||||||
|
// for ESL writers, who often chain clauses that a period would serve better.
|
||||||
|
// Awareness-only: there is no single mechanical fix for "split this sentence".
|
||||||
|
function runOns(text: string, out: ProseHint[]) {
|
||||||
|
let found = 0
|
||||||
|
for (const s of sentences(text)) {
|
||||||
|
if (found >= 2) break
|
||||||
|
const words = s.match(ENGLISH_WORD_RE)?.length ?? 0
|
||||||
|
const commas = (s.match(/,/g) ?? []).length
|
||||||
|
const longRun = words >= 32
|
||||||
|
const commaHeavy = words >= 24 && commas >= 2
|
||||||
|
if (longRun || commaHeavy) {
|
||||||
|
found++
|
||||||
|
out.push({
|
||||||
|
id: `runon:${key(s)}`,
|
||||||
|
rule: 'runon',
|
||||||
|
zh: '这句话有点长啦,分成两三句会更清楚 🌸',
|
||||||
|
en: `This one runs long — try splitting it: “${anchor(s)}”`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comma splice — two independent clauses joined by only a comma. We look for a
|
||||||
|
// comma immediately followed by a subject pronoun + a finite verb, which is a
|
||||||
|
// strong, low-false-positive signal of a spliced second clause.
|
||||||
|
// A comma, then a subject pronoun starting a fresh clause, then its verb. We no
|
||||||
|
// longer whitelist verbs (that lost too many real splices) — instead we take any
|
||||||
|
// word after the pronoun and reject the function words that signal it *isn't* a
|
||||||
|
// new clause (a conjunction/preposition list like “she and I”, “it of course”).
|
||||||
|
const SPLICE_RE = /[a-z]\w*,\s+(I|we|they|he|she|it|you)\s+([a-z]+)\b/g
|
||||||
|
const SPLICE_NONVERB = new Set([
|
||||||
|
'and', 'or', 'but', 'nor', 'yet', 'so', 'to', 'too', 'also', 'of', 'in', 'on',
|
||||||
|
'at', 'for', 'with', 'as', 'who', 'whom', 'which', 'that', 'than', 'then',
|
||||||
|
'will', // “…, I will, …” is usually an aside, not a clean splice
|
||||||
|
])
|
||||||
|
|
||||||
|
// First words that signal the lead is an *introductory* element (transition,
|
||||||
|
// subordinate clause, or adverbial phrase), where a following “, subject verb”
|
||||||
|
// is correct punctuation — not a splice. Guards against “However, we left.” and
|
||||||
|
// “After dinner, I went home.”
|
||||||
|
const INTRO_LEAD = new Set([
|
||||||
|
'after', 'before', 'when', 'while', 'although', 'though', 'because', 'since',
|
||||||
|
'if', 'as', 'until', 'unless', 'whenever', 'wherever', 'whereas', 'however',
|
||||||
|
'therefore', 'moreover', 'furthermore', 'nevertheless', 'nonetheless',
|
||||||
|
'meanwhile', 'consequently', 'otherwise', 'besides', 'instead', 'finally',
|
||||||
|
'first', 'firstly', 'second', 'secondly', 'next', 'then', 'later', 'eventually',
|
||||||
|
'afterwards', 'anyway', 'yesterday', 'today', 'tomorrow', 'honestly',
|
||||||
|
'actually', 'suddenly', 'sometimes', 'usually', 'often', 'also', 'still',
|
||||||
|
'now', 'here', 'there', 'well', 'yes', 'no', 'fortunately', 'unfortunately',
|
||||||
|
'surprisingly', 'clearly', 'obviously', 'basically', 'generally', 'initially',
|
||||||
|
'recently', 'currently', 'sadly', 'luckily', 'hopefully', 'frankly',
|
||||||
|
'personally', 'overall', 'soon', 'once', 'during', 'in', 'on', 'at', 'for',
|
||||||
|
'with', 'without', 'by', 'from', 'despite', 'throughout', 'given', 'regarding',
|
||||||
|
])
|
||||||
|
|
||||||
|
// A genuine comma splice has an *independent clause* before the comma. We
|
||||||
|
// approximate that by requiring the lead to be ≥3 words and not begin with an
|
||||||
|
// introductory word — short or transition-led leads are intro phrases, not
|
||||||
|
// spliced clauses. Precision over recall: a wrong nudge costs more than a miss.
|
||||||
|
// Awareness-only: the fix is ambiguous (period vs. a joining word).
|
||||||
|
function commaSplices(text: string, out: ProseHint[]) {
|
||||||
|
let found = 0
|
||||||
|
for (const s of sentences(text)) {
|
||||||
|
if (found >= 2) break
|
||||||
|
SPLICE_RE.lastIndex = 0
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
while ((m = SPLICE_RE.exec(s)) && found < 2) {
|
||||||
|
const commaIdx = s.indexOf(',', m.index)
|
||||||
|
const lead = s.slice(0, commaIdx).trim()
|
||||||
|
const words = lead.split(/\s+/).filter(Boolean)
|
||||||
|
const firstWord = words[0]?.toLowerCase().replace(/[^a-z]/g, '') ?? ''
|
||||||
|
if (words.length < 3 || INTRO_LEAD.has(firstWord)) continue
|
||||||
|
if (SPLICE_NONVERB.has(m[2].toLowerCase())) continue
|
||||||
|
found++
|
||||||
|
out.push({
|
||||||
|
id: `splice:${key(m[0])}`,
|
||||||
|
rule: 'splice',
|
||||||
|
zh: '这里用逗号连了两句话,可以改成句号,或加个 “and / but”。',
|
||||||
|
en: `Two sentences joined by a comma: “…${m[0].trim()}…” — use a period or add a joining word.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unclear antecedent — a sentence that opens with This/That/These/Those riding
|
||||||
|
// straight into a verb, with no noun naming what it points back to. Only flag
|
||||||
|
// when there's a prior sentence (so an antecedent is actually in question).
|
||||||
|
// Awareness-only: naming the referent needs the writer.
|
||||||
|
const ANTECEDENT_RE =
|
||||||
|
/^(This|That|These|Those)\s+(is|are|was|were|will|would|can|could|should|makes?|made|means?|shows?|showed|gives?|gave|causes?|caused|creates?|created|leads?|led|results?|happens?|happened|helps?|helped)\b/
|
||||||
|
|
||||||
|
function antecedents(text: string, out: ProseHint[]) {
|
||||||
|
const list = sentences(text)
|
||||||
|
let found = 0
|
||||||
|
for (let i = 1; i < list.length && found < 2; i++) {
|
||||||
|
const s = list[i]
|
||||||
|
const m = s.match(ANTECEDENT_RE)
|
||||||
|
if (m) {
|
||||||
|
found++
|
||||||
|
out.push({
|
||||||
|
id: `antecedent:${key(s)}`,
|
||||||
|
rule: 'antecedent',
|
||||||
|
zh: `“${m[1]}” 指代不太清楚,最好点明它指的是什么(比如 “${m[1]} idea / change…”)。`,
|
||||||
|
en: `“${m[1]}” here is a little vague — name what it refers to.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oxford comma — a 3+ item list ending “… X and Y” with no comma before the
|
||||||
|
// conjunction. Guarded by a clause-starter stoplist so we don't mistake a
|
||||||
|
// compound clause (“…, but she and I…”) for a list. Awareness-only: inserting
|
||||||
|
// the serial comma is borderline-stylistic, so we nudge rather than auto-edit.
|
||||||
|
const OXFORD_RE = /(\w+),\s+([\w'’-]+(?:\s+[\w'’-]+){0,2})\s+(and|or)\s+[\w'’-]+/g
|
||||||
|
const CLAUSE_STARTERS = new Set([
|
||||||
|
'but', 'so', 'because', 'which', 'who', 'that', 'when', 'while', 'if',
|
||||||
|
'although', 'though', 'since', 'as', 'and', 'or', 'nor', 'yet', 'then',
|
||||||
|
'however', 'whereas',
|
||||||
|
// Subject pronouns signal a clause, not a list item (“…, I went home and…”).
|
||||||
|
'i', 'you', 'he', 'she', 'it', 'we', 'they',
|
||||||
|
])
|
||||||
|
|
||||||
|
function oxford(text: string, out: ProseHint[]) {
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
let found = 0
|
||||||
|
OXFORD_RE.lastIndex = 0
|
||||||
|
while ((m = OXFORD_RE.exec(text)) && found < 1) {
|
||||||
|
const firstWord = m[2].split(/\s+/)[0]?.toLowerCase() ?? ''
|
||||||
|
if (CLAUSE_STARTERS.has(firstWord)) continue
|
||||||
|
found++
|
||||||
|
out.push({
|
||||||
|
id: `oxford:${key(m[0])}`,
|
||||||
|
rule: 'oxford',
|
||||||
|
zh: '列举三样以上时,在 “and / or” 前也加个逗号会更清楚(牛津逗号)。',
|
||||||
|
en: `In a list, a comma before “${m[3]}” keeps it clear: “a, b, ${m[3]} c”.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A transition word opening a sentence with no comma after it (“However we…”).
|
||||||
|
// Limited to conjunctive adverbs that strongly want the comma — sequence words
|
||||||
|
// like “Then/First/Finally” are left out (their comma is optional). Awareness-
|
||||||
|
// only: it fires per-sentence (offsets are sentence-relative), so we nudge.
|
||||||
|
const INTRO_RE =
|
||||||
|
/^(However|Therefore|Moreover|Furthermore|Nevertheless|Nonetheless|Meanwhile|Consequently|In addition|In conclusion|As a result|On the other hand|For example|For instance)\s+[A-Za-z]/
|
||||||
|
|
||||||
|
function introComma(text: string, out: ProseHint[]) {
|
||||||
|
for (const s of sentences(text)) {
|
||||||
|
const m = s.match(INTRO_RE)
|
||||||
|
if (m) {
|
||||||
|
out.push({
|
||||||
|
id: `introcomma:${m[1].toLowerCase()}`,
|
||||||
|
rule: 'introcomma',
|
||||||
|
zh: `开头的过渡词后面加个逗号:“${m[1]}, …”。`,
|
||||||
|
en: `Put a comma after the opening transition: “${m[1]}, …”.`,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sentence not starting with a capital. We look after a terminator, and ignore
|
||||||
|
// CJK sentences (she writes Mandarin too — those don't take Latin capitals).
|
||||||
|
// Awareness-only: a bare “. x” also matches abbreviations (“U.S. then”), so we
|
||||||
|
// nudge rather than auto-capitalize.
|
||||||
|
const LOWER_START_RE = /[.!?]\s+([a-z])/
|
||||||
|
|
||||||
|
function sentenceCaps(text: string, out: ProseHint[]) {
|
||||||
|
if (LOWER_START_RE.test(text)) {
|
||||||
|
out.push({
|
||||||
|
id: 'cap-sentence',
|
||||||
|
rule: 'cap-sentence',
|
||||||
|
zh: '每个句子的开头,用大写字母开始吧。',
|
||||||
|
en: 'Start each new sentence with a capital letter.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── applyable rules (also feed the suggestion cards) ─────────────────────────
|
||||||
|
|
||||||
|
// Doubled word — “the the”, “is is”. Excludes the handful of doublings that can
|
||||||
|
// be legitimate (“the fact that that happened”, “she had had enough”).
|
||||||
|
const DOUBLE_RE = /\b([A-Za-z]+)(\s+)\1\b/gi
|
||||||
|
const LEGIT_DOUBLES = new Set(['that', 'had'])
|
||||||
|
|
||||||
|
function doubles(text: string, out: ProseHint[]) {
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
DOUBLE_RE.lastIndex = 0
|
||||||
|
while ((m = DOUBLE_RE.exec(text))) {
|
||||||
|
const w = m[1].toLowerCase()
|
||||||
|
if (LEGIT_DOUBLES.has(w)) continue
|
||||||
|
const from = m.index
|
||||||
|
const to = from + m[0].length
|
||||||
|
out.push({
|
||||||
|
id: `double:${from}:${key(m[0])}`,
|
||||||
|
rule: 'double',
|
||||||
|
zh: `“${m[1]}” 好像写了两遍,检查一下哦。`,
|
||||||
|
en: `“${m[1]} ${m[1]}” — looks like a word got doubled.`,
|
||||||
|
fix: { from, to, replacement: m[1] },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lowercase standalone “I”. Conservative: only when bounded by spaces / line
|
||||||
|
// edge, to avoid tangling with “i.e.” and the like. Awareness-only: a single
|
||||||
|
// character can't be re-anchored unambiguously by string (every word holds an
|
||||||
|
// “i”), so this stays a companion nudge rather than a one-click card.
|
||||||
|
const LOWER_I_RE = /(^|[^\p{L}\p{N}_])i(?=$|[^\p{L}\p{N}_])/mu
|
||||||
|
|
||||||
|
function pronounI(text: string, out: ProseHint[]) {
|
||||||
|
const m = LOWER_I_RE.exec(text)
|
||||||
|
LOWER_I_RE.lastIndex = 0
|
||||||
|
if (!m) return
|
||||||
|
const at = m.index + m[1].length
|
||||||
|
if (text[at + 1] === '.') return // “i.e.” etc. — precision over recall
|
||||||
|
out.push({
|
||||||
|
id: 'cap-i',
|
||||||
|
rule: 'cap-i',
|
||||||
|
zh: '英文里的 “I”(我)任何时候都要大写哦。',
|
||||||
|
en: 'In English, “I” is always written as a capital letter.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stray space *before* punctuation (a common habit carried from CJK spacing).
|
||||||
|
const SPACE_BEFORE_PUNCT_RE = /([A-Za-z]+) +([,;:!?]|\.(?!\.))/g
|
||||||
|
|
||||||
|
function spaceBeforePunct(text: string, out: ProseHint[]) {
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
SPACE_BEFORE_PUNCT_RE.lastIndex = 0
|
||||||
|
while ((m = SPACE_BEFORE_PUNCT_RE.exec(text))) {
|
||||||
|
const from = m.index
|
||||||
|
const to = from + m[0].length
|
||||||
|
out.push({
|
||||||
|
id: `space-punct:${from}`,
|
||||||
|
rule: 'space-punct',
|
||||||
|
zh: '标点前面不用空格,逗号、句号紧跟在前一个词后面就好。',
|
||||||
|
en: 'No space before punctuation — it tucks right against the word.',
|
||||||
|
fix: { from, to, replacement: m[1] + m[2] },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missing space *after* a comma or sentence period (“apple,banana”, “end.Next”).
|
||||||
|
// The comma case requires letters on both sides so numbers like 1,000 are safe;
|
||||||
|
// the period case requires a real word boundary so “e.g.”/“U.S.” are skipped.
|
||||||
|
const NO_SPACE_COMMA_RE = /([A-Za-z]+,)([A-Za-z]+)/g
|
||||||
|
const NO_SPACE_PERIOD_RE = /([a-z]{2,}\.)([A-Z][a-z]+)/g
|
||||||
|
|
||||||
|
function spaceAfterPunct(text: string, out: ProseHint[]) {
|
||||||
|
for (const re of [NO_SPACE_COMMA_RE, NO_SPACE_PERIOD_RE]) {
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
re.lastIndex = 0
|
||||||
|
while ((m = re.exec(text))) {
|
||||||
|
const from = m.index
|
||||||
|
const to = from + m[0].length
|
||||||
|
out.push({
|
||||||
|
id: `space-after:${from}`,
|
||||||
|
rule: 'space-after',
|
||||||
|
zh: '逗号、句号后面要空一格,再接下一个词。',
|
||||||
|
en: 'Add a space after a comma or period before the next word.',
|
||||||
|
fix: { from, to, replacement: `${m[1]} ${m[2]}` },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// a vs. an — chosen by the *sound* of the next word. We work only on lowercase
|
||||||
|
// words (sidestepping proper nouns / acronyms) and keep sound-exception lists.
|
||||||
|
const A_BEFORE_VOWEL_RE = /\b(a)\s+([aeiou][a-z]+)\b/g
|
||||||
|
// Vowel-spelled but consonant-sounding → “a” is correct, don't flag.
|
||||||
|
const CONSONANT_SOUND_RE = /^(uni|use|usu|util|euro?|eul|ewe|once|one|ubiqu|unanim)/
|
||||||
|
const AN_BEFORE_CONSONANT_RE = /\b(an)\s+([b-df-hj-np-tv-z][a-z]+)\b/g
|
||||||
|
// Consonant-spelled but vowel-sounding (silent h) → “an” is correct, don't flag.
|
||||||
|
const VOWEL_SOUND_RE = /^(hour|honest|honou?r|heir|homage)/
|
||||||
|
|
||||||
|
function articles(text: string, out: ProseHint[]) {
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
A_BEFORE_VOWEL_RE.lastIndex = 0
|
||||||
|
while ((m = A_BEFORE_VOWEL_RE.exec(text))) {
|
||||||
|
if (CONSONANT_SOUND_RE.test(m[2])) continue
|
||||||
|
// Span the whole "a <noun>" so the original is a distinctive, anchorable
|
||||||
|
// phrase (a bare "a" matches everywhere). Replacement swaps only the article.
|
||||||
|
out.push({
|
||||||
|
id: `article-an:${m.index}`,
|
||||||
|
rule: 'article',
|
||||||
|
zh: `元音开头的词前用 “an”:“an ${m[2]}”。`,
|
||||||
|
en: `Before a vowel sound, use “an”: “an ${m[2]}”.`,
|
||||||
|
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], 'an') + m[0].slice(m[1].length) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
AN_BEFORE_CONSONANT_RE.lastIndex = 0
|
||||||
|
while ((m = AN_BEFORE_CONSONANT_RE.exec(text))) {
|
||||||
|
if (VOWEL_SOUND_RE.test(m[2])) continue
|
||||||
|
out.push({
|
||||||
|
id: `article-a:${m.index}`,
|
||||||
|
rule: 'article',
|
||||||
|
zh: `辅音开头的词前用 “a”:“a ${m[2]}”。`,
|
||||||
|
en: `Before a consonant sound, use “a”: “a ${m[2]}”.`,
|
||||||
|
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], 'a') + m[0].slice(m[1].length) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mass nouns that Mandarin speakers very commonly pluralize. These words are
|
||||||
|
// essentially never valid with a trailing “s”, so the list is its own guard.
|
||||||
|
const UNCOUNTABLE_PLURAL_RE =
|
||||||
|
/\b(informations|advices|knowledges|equipments|furnitures|homeworks|softwares|hardwares|luggages|baggages|sceneries|machineries)\b/gi
|
||||||
|
|
||||||
|
function uncountables(text: string, out: ProseHint[]) {
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
UNCOUNTABLE_PLURAL_RE.lastIndex = 0
|
||||||
|
while ((m = UNCOUNTABLE_PLURAL_RE.exec(text))) {
|
||||||
|
const word = m[1]
|
||||||
|
const singular = word.replace(/s$/i, '')
|
||||||
|
out.push({
|
||||||
|
id: `uncountable:${m.index}`,
|
||||||
|
rule: 'uncountable',
|
||||||
|
zh: `“${word}” 是不可数名词,不用加 s,写 “${singular}” 就好。`,
|
||||||
|
en: `“${word}” is uncountable — drop the “s”: just “${singular}”.`,
|
||||||
|
fix: { from: m.index, to: m.index + word.length, replacement: singular },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Languages, nationalities, days, and months are proper nouns in English but
|
||||||
|
// not in Mandarin, so they're routinely left lowercase. We list only the
|
||||||
|
// unambiguous ones — “may/march/august/china/turkey/polish” are skipped because
|
||||||
|
// they're also ordinary lowercase words and would misfire.
|
||||||
|
const PROPER_NOUNS =
|
||||||
|
'monday|tuesday|wednesday|thursday|friday|saturday|sunday|' +
|
||||||
|
'january|february|april|june|july|september|october|november|december|' +
|
||||||
|
'english|chinese|mandarin|cantonese|japanese|korean|french|spanish|german|' +
|
||||||
|
'italian|russian|portuguese|arabic|vietnamese|thai|american|british|canadian|' +
|
||||||
|
'australian|mexican|brazilian|european'
|
||||||
|
// Case-sensitive (lowercase-only) so already-capitalized words aren't flagged.
|
||||||
|
const PROPER_NOUN_RE = new RegExp(`\\b(${PROPER_NOUNS})\\b`, 'g')
|
||||||
|
|
||||||
|
function properCaps(text: string, out: ProseHint[]) {
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
PROPER_NOUN_RE.lastIndex = 0
|
||||||
|
while ((m = PROPER_NOUN_RE.exec(text))) {
|
||||||
|
const word = m[1]
|
||||||
|
const fixed = word[0].toUpperCase() + word.slice(1)
|
||||||
|
out.push({
|
||||||
|
id: `propercap:${m.index}`,
|
||||||
|
rule: 'propercap',
|
||||||
|
zh: `语言、国籍、星期和月份在英文里要大写:“${fixed}”。`,
|
||||||
|
en: `Languages, days, and months are capitalized in English: “${fixed}”.`,
|
||||||
|
fix: { from: m.index, to: m.index + word.length, replacement: fixed },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Third-person singular verb form, for the agreement rule's suggestion.
|
||||||
|
function third(verb: string): string {
|
||||||
|
const v = verb.toLowerCase()
|
||||||
|
const irregular: Record<string, string> = { have: 'has', do: 'does', go: 'goes' }
|
||||||
|
if (irregular[v]) return irregular[v]
|
||||||
|
if (/[^aeiou]y$/.test(v)) return v.slice(0, -1) + 'ies'
|
||||||
|
if (/(s|x|z|ch|sh)$/.test(v)) return v + 'es'
|
||||||
|
return v + 's'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subject–verb agreement after he/she/it: a bare (base-form) verb where the
|
||||||
|
// third-person “-s” form is required. We skip the causative/perception frames
|
||||||
|
// (“let it go”, “make it work”, “help it grow”) where the bare verb is correct.
|
||||||
|
const SVA_BASE =
|
||||||
|
'go|have|do|make|like|want|need|know|think|say|see|feel|get|take|come|give|' +
|
||||||
|
'run|play|work|live|look|seem|become|bring|buy|find|keep|leave|tell|try|call|' +
|
||||||
|
'move|turn|put|mean|show|use|love|hope|wish|believe|understand|remember|enjoy'
|
||||||
|
// The preceding word is optional so a sentence-initial subject (“She have…”)
|
||||||
|
// still matches; when present it lets us skip causative/perception frames.
|
||||||
|
const SVA_RE = new RegExp(`(?:(\\w+)\\s+)?\\b(he|she|it)\\s+(${SVA_BASE})\\b`, 'gi')
|
||||||
|
const CAUSATIVE = new Set([
|
||||||
|
'let', 'lets', 'make', 'makes', 'made', 'help', 'helps', 'helped', 'watch',
|
||||||
|
'watches', 'watched', 'see', 'sees', 'saw', 'hear', 'heard', 'have', 'has',
|
||||||
|
'had', 'let’s', "let's",
|
||||||
|
])
|
||||||
|
|
||||||
|
function subjectVerbAgreement(text: string, out: ProseHint[]) {
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
SVA_RE.lastIndex = 0
|
||||||
|
while ((m = SVA_RE.exec(text))) {
|
||||||
|
if (m[1] && CAUSATIVE.has(m[1].toLowerCase())) continue
|
||||||
|
const verb = m[3]
|
||||||
|
const fixed = third(verb)
|
||||||
|
// Span the whole match (subject + verb, plus any captured lead) so the
|
||||||
|
// original anchors; the replacement corrects only the trailing verb.
|
||||||
|
const head = m[0].slice(0, m[0].length - verb.length)
|
||||||
|
out.push({
|
||||||
|
id: `sva:${m.index}`,
|
||||||
|
rule: 'sva',
|
||||||
|
zh: `主语是 he/she/it 时,动词要加 -s:“${m[2]} ${fixed}”。`,
|
||||||
|
en: `After he/she/it the verb takes “-s”: “${m[2]} ${fixed}”.`,
|
||||||
|
fix: { from: m.index, to: m.index + m[0].length, replacement: head + matchCase(verb, fixed) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singular noun after a cardinal number (“three apple”). Adjectives between the
|
||||||
|
// number and noun would misfire (“two small dogs”), so we only flag when the
|
||||||
|
// candidate is followed by a clause boundary — a verb, preposition, article, or
|
||||||
|
// punctuation — which means it's the noun itself, not a modifier.
|
||||||
|
const NUMBER_NOUN_RE =
|
||||||
|
/\b(two|three|four|five|six|seven|eight|nine|ten)\s+([a-z]{3,})\b(?=\s*(?:[.,!?;:]|$|\s(?:is|are|was|were|and|or|but|that|which|who|in|on|at|to|for|with|will|can|could|would|should|the|a|an)\b))/g
|
||||||
|
// Irregular plurals / mass nouns that are correct without an “s”.
|
||||||
|
const NON_S_PLURAL = new Set([
|
||||||
|
'people', 'children', 'men', 'women', 'fish', 'deer', 'sheep', 'feet', 'teeth',
|
||||||
|
'mice', 'geese', 'police', 'staff', 'series', 'species', 'aircraft', 'cattle',
|
||||||
|
'hundred', 'thousand', 'million', 'billion', 'dozen', 'percent',
|
||||||
|
])
|
||||||
|
|
||||||
|
function pluralAfterNumber(text: string, out: ProseHint[]) {
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
NUMBER_NOUN_RE.lastIndex = 0
|
||||||
|
while ((m = NUMBER_NOUN_RE.exec(text))) {
|
||||||
|
const noun = m[2].toLowerCase()
|
||||||
|
if (noun.endsWith('s') || NON_S_PLURAL.has(noun)) continue
|
||||||
|
// Span "<number> <noun>" so the original anchors; append “s” to the noun.
|
||||||
|
out.push({
|
||||||
|
id: `plural:${m.index}`,
|
||||||
|
rule: 'plural',
|
||||||
|
zh: `“${m[1]}” 后面的名词要用复数:“${m[1]} ${m[2]}s”。`,
|
||||||
|
en: `After “${m[1]}”, the noun is plural: “${m[1]} ${m[2]}s”.`,
|
||||||
|
fix: { from: m.index, to: m.index + m[0].length, replacement: `${m[0]}s` },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two stacked determiners (“the my book”, “a the”) — Mandarin uses a bare
|
||||||
|
// possessive, so the article often gets stacked on top. The article goes.
|
||||||
|
const DOUBLE_DET_RE =
|
||||||
|
/\b(a|an|the)(\s+)(a|an|the|my|your|his|her|its|our|their)\b/gi
|
||||||
|
|
||||||
|
function doubleDeterminer(text: string, out: ProseHint[]) {
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
DOUBLE_DET_RE.lastIndex = 0
|
||||||
|
while ((m = DOUBLE_DET_RE.exec(text))) {
|
||||||
|
// Two *identical* words ("the the") are a doubled word, not stacked
|
||||||
|
// determiners — leave that to the `doubles` rule so we don't double-flag.
|
||||||
|
if (m[1].toLowerCase() === m[3].toLowerCase()) continue
|
||||||
|
out.push({
|
||||||
|
id: `doubledet:${m.index}`,
|
||||||
|
rule: 'doubledet',
|
||||||
|
zh: `“${m[1]} ${m[3]}” 用了两个限定词,留一个就好(比如去掉 “${m[1]}”)。`,
|
||||||
|
en: `“${m[1]} ${m[3]}” stacks two determiners — keep just one.`,
|
||||||
|
// Drop the article (m[1]); keep the second determiner, casing preserved.
|
||||||
|
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], m[3]) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// “there is” with a plural quantifier → should be “there are”. We trigger only
|
||||||
|
// on clearly-plural cues (skip “some/any”, which are fine with singular mass
|
||||||
|
// nouns: “there is some water”).
|
||||||
|
const THERE_IS_RE =
|
||||||
|
/\bthere(\s+is|'s|’s)\s+(many|several|numerous|various|few|two|three|four|five|six|seven|eight|nine|ten)\b/gi
|
||||||
|
|
||||||
|
function thereIsPlural(text: string, out: ProseHint[]) {
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
THERE_IS_RE.lastIndex = 0
|
||||||
|
while ((m = THERE_IS_RE.exec(text))) {
|
||||||
|
// Span the whole "there is <plural>" phrase so it anchors; swap the verb part
|
||||||
|
// (m[1]: “ is” / “'s”) for “ are”, preserving the “there” casing and the cue.
|
||||||
|
const replacement = m[0].slice(0, 5) + ' are' + m[0].slice(5 + m[1].length)
|
||||||
|
out.push({
|
||||||
|
id: `thereis:${m.index}`,
|
||||||
|
rule: 'thereis',
|
||||||
|
zh: `后面是复数时用 “there are”:“there are ${m[2]}…”。`,
|
||||||
|
en: `With a plural, use “there are”: “there are ${m[2]}…”.`,
|
||||||
|
fix: { from: m.index, to: m.index + m[0].length, replacement },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// its / it's — only the two unambiguous directions: “it's own” (always its own)
|
||||||
|
// and “its a/an” (the possessive can't take an article → it's a/an).
|
||||||
|
const ITS_OWN_RE = /\b(it's|it’s)\s+own\b/gi
|
||||||
|
const ITS_ARTICLE_RE = /\bits\s+(a|an)\b/gi
|
||||||
|
|
||||||
|
function itsConfusion(text: string, out: ProseHint[]) {
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
ITS_OWN_RE.lastIndex = 0
|
||||||
|
while ((m = ITS_OWN_RE.exec(text))) {
|
||||||
|
// Span "it's own" so it anchors; swap the leading token to the possessive.
|
||||||
|
out.push({
|
||||||
|
id: `its-own:${m.index}`,
|
||||||
|
rule: 'its',
|
||||||
|
zh: '“it’s” = “it is”;表示“它的”要用 “its”,所以是 “its own”。',
|
||||||
|
en: '“it’s” means “it is” — the possessive is “its”: “its own”.',
|
||||||
|
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], 'its') + m[0].slice(m[1].length) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ITS_ARTICLE_RE.lastIndex = 0
|
||||||
|
while ((m = ITS_ARTICLE_RE.exec(text))) {
|
||||||
|
// Span "its a/an" so it anchors; swap "its" → "it's".
|
||||||
|
out.push({
|
||||||
|
id: `its-article:${m.index}`,
|
||||||
|
rule: 'its',
|
||||||
|
zh: `这里应该是 “it’s ${m[1]}”(it is),“its” 是“它的”。`,
|
||||||
|
en: `Here it should be “it’s ${m[1]}” (it is); “its” means belonging to it.`,
|
||||||
|
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[0][0], "it's") + m[0].slice(3) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comparative + “then” where “than” is meant (“better then”, “more then”). We
|
||||||
|
// use an explicit comparative list rather than a generic “-er” pattern, which
|
||||||
|
// would snag “after then”, “however”, “remember”, etc.
|
||||||
|
const COMPARATIVES =
|
||||||
|
'better|more|less|rather|worse|greater|other|older|younger|bigger|smaller|' +
|
||||||
|
'larger|faster|slower|higher|lower|cheaper|stronger|weaker|easier|harder|' +
|
||||||
|
'earlier|later|sooner|longer|shorter|taller|richer|poorer|happier|safer'
|
||||||
|
const THAN_THEN_RE = new RegExp(`\\b(${COMPARATIVES})(\\s+)then\\b`, 'gi')
|
||||||
|
|
||||||
|
function thanThen(text: string, out: ProseHint[]) {
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
THAN_THEN_RE.lastIndex = 0
|
||||||
|
while ((m = THAN_THEN_RE.exec(text))) {
|
||||||
|
// Span "<comparative> then" so it anchors; swap the trailing “then” → “than”.
|
||||||
|
const thenFrom = m.index + m[0].length - 4
|
||||||
|
out.push({
|
||||||
|
id: `than:${m.index}`,
|
||||||
|
rule: 'than',
|
||||||
|
zh: `比较的时候用 “than”,不是 “then”:“${m[1]} than”。`,
|
||||||
|
en: `For comparisons use “than”, not “then”: “${m[1]} than”.`,
|
||||||
|
fix: { from: m.index, to: m.index + m[0].length, replacement: m[0].slice(0, m[0].length - 4) + matchCase(text[thenFrom], 'than') },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── orchestration ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Rules run in priority order — the ones the writer cares most about first, so
|
||||||
|
// that when several fire at once the companion leads with the weightiest note.
|
||||||
|
const RULES: Array<(text: string, out: ProseHint[]) => void> = [
|
||||||
|
runOns,
|
||||||
|
commaSplices,
|
||||||
|
antecedents,
|
||||||
|
oxford,
|
||||||
|
articles,
|
||||||
|
uncountables,
|
||||||
|
properCaps,
|
||||||
|
subjectVerbAgreement,
|
||||||
|
pluralAfterNumber,
|
||||||
|
doubleDeterminer,
|
||||||
|
thereIsPlural,
|
||||||
|
itsConfusion,
|
||||||
|
thanThen,
|
||||||
|
introComma,
|
||||||
|
doubles,
|
||||||
|
pronounI,
|
||||||
|
sentenceCaps,
|
||||||
|
spaceBeforePunct,
|
||||||
|
spaceAfterPunct,
|
||||||
|
]
|
||||||
|
|
||||||
|
// analyzeProse returns context-aware hints, highest-priority first. It bails on
|
||||||
|
// text too short to advise on (mid-thought drafts shouldn't get picked apart).
|
||||||
|
// Hints that carry a `fix` are applyable (they also surface as suggestion cards);
|
||||||
|
// the companion filters those out so a span isn't both a bubble and a card.
|
||||||
|
export function analyzeProse(text: string): ProseHint[] {
|
||||||
|
const englishWords = text.match(ENGLISH_WORD_RE)?.length ?? 0
|
||||||
|
if (englishWords < 8) return []
|
||||||
|
const out: ProseHint[] = []
|
||||||
|
for (const rule of RULES) rule(text, out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// mechanicsFindings returns every applyable deterministic fix in the text, as
|
||||||
|
// suggestion-card findings with exact spans. No word-count floor: a doubled word
|
||||||
|
// or a stray lowercase “i” is worth fixing even in a short draft, the way a
|
||||||
|
// spell-checker would. The card pipeline persists these as the 'mechanics'
|
||||||
|
// family; collisions with the LLM cards are resolved server-side (mechanics
|
||||||
|
// wins, since its span is exact).
|
||||||
|
export function mechanicsFindings(text: string): MechanicsFinding[] {
|
||||||
|
const hints: ProseHint[] = []
|
||||||
|
for (const rule of RULES) rule(text, hints)
|
||||||
|
const found: MechanicsFinding[] = []
|
||||||
|
for (const h of hints) {
|
||||||
|
if (!h.fix) continue
|
||||||
|
const { from, to, replacement } = h.fix
|
||||||
|
const original = text.slice(from, to)
|
||||||
|
if (!original || original === replacement) continue
|
||||||
|
found.push({ from, to, original, replacement, explanation: h.en })
|
||||||
|
}
|
||||||
|
// Two rules can occasionally claim overlapping spans (e.g. a doubled word that
|
||||||
|
// also reads as stacked determiners). Resolve to one card per stretch of text:
|
||||||
|
// earlier start wins, ties broken by the longer span. The backend resolves the
|
||||||
|
// separate mechanics-vs-LLM collisions; this handles mechanics-vs-mechanics.
|
||||||
|
found.sort((a, b) => (a.from !== b.from ? a.from - b.from : b.to - a.to))
|
||||||
|
const out: MechanicsFinding[] = []
|
||||||
|
let lastEnd = -1
|
||||||
|
for (const f of found) {
|
||||||
|
if (f.from < lastEnd) continue
|
||||||
|
out.push(f)
|
||||||
|
lastEnd = f.to
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -15,9 +15,15 @@ export const ENCOURAGEMENTS: Line[] = [
|
|||||||
{ zh: '我很喜欢这个改法 💕', en: 'I really like that change.' },
|
{ zh: '我很喜欢这个改法 💕', en: 'I really like that change.' },
|
||||||
{ zh: '继续保持,加油!', en: 'Keep going — you’ve got this!' },
|
{ zh: '继续保持,加油!', en: 'Keep going — you’ve got this!' },
|
||||||
{ zh: '嗯嗯,这样清楚多了 👍', en: 'Mm, that’s much clearer.' },
|
{ zh: '嗯嗯,这样清楚多了 👍', en: 'Mm, that’s much clearer.' },
|
||||||
|
{ zh: '这个词用得真好 🌷', en: 'That’s such a good word choice.' },
|
||||||
|
{ zh: '哇,这一段读起来真舒服 ☁️', en: 'Ooh, that paragraph flows so nicely.' },
|
||||||
|
{ zh: '看你越写越有信心,真好 💛', en: 'I love watching you write with more confidence.' },
|
||||||
|
{ zh: '一点点进步,都是了不起的进步 🌱', en: 'Every little bit of progress counts.' },
|
||||||
|
{ zh: '今天的你,文字闪闪发光 ✨', en: 'Your words are sparkling today.' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// Gentle, periodic writing/ESL tips. Surfaced only when nothing else is showing.
|
// Gentle, generic writing/ESL tips — the fallback when the rules-based prose
|
||||||
|
// checker (prose.ts) finds nothing concrete to point at in her current text.
|
||||||
export const TIPS: Line[] = [
|
export const TIPS: Line[] = [
|
||||||
{ zh: '小贴士:英文句子短一点,会更清楚哦。', en: 'Tip: shorter English sentences often read clearer.' },
|
{ zh: '小贴士:英文句子短一点,会更清楚哦。', en: 'Tip: shorter English sentences often read clearer.' },
|
||||||
{ zh: '别忘了冠词 “the” 和 “a” 哦。', en: "Don't forget articles like “the” and “a”." },
|
{ zh: '别忘了冠词 “the” 和 “a” 哦。', en: "Don't forget articles like “the” and “a”." },
|
||||||
@@ -35,6 +41,23 @@ export const BREAKS: Line[] = [
|
|||||||
{ zh: '看看远方,放松一下眼睛 🌿', en: 'Look into the distance for a moment — give your eyes a break. 🌿' },
|
{ zh: '看看远方,放松一下眼睛 🌿', en: 'Look into the distance for a moment — give your eyes a break. 🌿' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Shown when she's still writing late at night (≥11pm). Caring, a little
|
||||||
|
// playful — the kitten is always asleep, so "you should be too" lands as a gag,
|
||||||
|
// never a scold. zh stays gentle; the English subtitle carries the wink.
|
||||||
|
export const BEDTIME: Line[] = [
|
||||||
|
{ zh: '你的床在想你了哦 🛏️', en: 'I bet your bed is missing you right now.' },
|
||||||
|
{ zh: '太累可写不出好文字呀,早点歇着吧 🌙', en: 'A tired writer is a bad writer — get some rest.' },
|
||||||
|
{ zh: '好好睡一觉,灵感自己会来 ✨', en: 'Sleep is a wondrous enabler.' },
|
||||||
|
{ zh: '听见了吗?没有吧——大家都睡了,你也该睡啦 😴', en: "Hear that? No… you don't, because everyone is sleeping and you should be too." },
|
||||||
|
// A few old Chinese proverbs on sleep (zh = a faithful rendering of the
|
||||||
|
// English sense — the verified classical 原文 isn't recoverable; swap in the
|
||||||
|
// exact source text if you have it). They read a touch wittier/wiser than the
|
||||||
|
// gentle lines above, which suits a late-night nudge.
|
||||||
|
{ zh: '一夜不眠,十日不安。', en: "The loss of one night's sleep is followed by ten days of inconvenience." },
|
||||||
|
{ zh: '前半夜醒着想自己的过错,后半夜睡着才想别人的不是。', en: 'Think of your own faults the first part of the night when you are awake, and of the faults of others the latter part of the night when you are asleep.' },
|
||||||
|
{ zh: '黄昏与人相骂,夜半独自难眠。', en: 'Curse your spouse at evening, sleep alone at night.' },
|
||||||
|
]
|
||||||
|
|
||||||
// First hello when the app opens.
|
// First hello when the app opens.
|
||||||
export const GREETING: Line = {
|
export const GREETING: Line = {
|
||||||
zh: '嗨~我在这儿陪你写作哦 🐱',
|
zh: '嗨~我在这儿陪你写作哦 🐱',
|
||||||
@@ -47,8 +70,16 @@ export const WELCOME_BACK: Line = {
|
|||||||
en: 'Welcome back ✨ let’s keep going!',
|
en: 'Welcome back ✨ let’s keep going!',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Word-count milestones worth a little cheer.
|
// Gentle "haiya, something went wrong" lines — paired with the error sound when
|
||||||
export const MILESTONES = [50, 100, 250, 500, 1000, 2000]
|
// the LLM is unreachable or a save fails. Never alarming, always reassuring.
|
||||||
|
export const ERRORS: Line[] = [
|
||||||
|
{ zh: '哎呀~出了点小问题,你的字都还在哦。', en: 'Oops — a little hiccup, but your words are safe.' },
|
||||||
|
{ zh: '哎呀,我这边卡了一下,马上就好。', en: 'Haiya, I got stuck for a sec — back in a moment.' },
|
||||||
|
{ zh: '别担心,等一下再试试看 🍵', en: "Don't worry — let's try again in a bit. 🍵" },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Word-count milestones worth a little cheer — every 100 words, on up.
|
||||||
|
export const MILESTONES = Array.from({ length: 100 }, (_, i) => (i + 1) * 100)
|
||||||
|
|
||||||
export function milestoneLine(n: number): Line {
|
export function milestoneLine(n: number): Line {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import type { SaveStatus } from '../../hooks/useAutoSave'
|
import type { SaveStatus } from '../../hooks/useAutoSave'
|
||||||
import {
|
import {
|
||||||
|
BEDTIME,
|
||||||
BREAKS,
|
BREAKS,
|
||||||
ENCOURAGEMENTS,
|
ENCOURAGEMENTS,
|
||||||
|
ERRORS,
|
||||||
GREETING,
|
GREETING,
|
||||||
MILESTONES,
|
MILESTONES,
|
||||||
TIPS,
|
TIPS,
|
||||||
@@ -11,12 +13,15 @@ import {
|
|||||||
pick,
|
pick,
|
||||||
type Line,
|
type Line,
|
||||||
} from './tips'
|
} from './tips'
|
||||||
|
import { analyzeProse } from './prose'
|
||||||
|
import { playPop, playSound, type SoundName } from '../../audio/sounds'
|
||||||
|
import { isBedtime } from '../../lib/night'
|
||||||
|
|
||||||
// The kitten's expression. Maps to a Lottie animation when assets are present,
|
// The kitten's expression. Maps to a Lottie animation when assets are present,
|
||||||
// otherwise to an emoji placeholder (see PetalCompanion).
|
// otherwise to an emoji placeholder (see PetalCompanion).
|
||||||
export type Mood = 'idle' | 'happy' | 'talking' | 'sleeping' | 'celebrate'
|
export type Mood = 'idle' | 'happy' | 'talking' | 'sleeping' | 'celebrate'
|
||||||
|
|
||||||
export type BubbleTone = 'cheer' | 'tip' | 'break'
|
export type BubbleTone = 'cheer' | 'tip' | 'break' | 'error' | 'bedtime'
|
||||||
export interface Bubble extends Line {
|
export interface Bubble extends Line {
|
||||||
tone: BubbleTone
|
tone: BubbleTone
|
||||||
}
|
}
|
||||||
@@ -24,10 +29,16 @@ export interface Bubble extends Line {
|
|||||||
interface Signals {
|
interface Signals {
|
||||||
wordCount: number
|
wordCount: number
|
||||||
saveStatus: SaveStatus
|
saveStatus: SaveStatus
|
||||||
|
// True while the writing-assist LLM is unreachable (timeout / down). A
|
||||||
|
// false→true flip earns a gentle "haiya".
|
||||||
|
llmDown: boolean
|
||||||
// Monotonic counters bumped by the app on each editor change / accepted
|
// Monotonic counters bumped by the app on each editor change / accepted
|
||||||
// suggestion — lets the companion react without a full event bus.
|
// suggestion — lets the companion react without a full event bus.
|
||||||
editTick: number
|
editTick: number
|
||||||
acceptTick: number
|
acceptTick: number
|
||||||
|
// The document's plain text, used by the rules-based prose checker to offer
|
||||||
|
// context-aware writing notes instead of only generic tips.
|
||||||
|
text: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Timing knobs (ms). Tuned to feel present but never naggy.
|
// Timing knobs (ms). Tuned to feel present but never naggy.
|
||||||
@@ -35,14 +46,32 @@ const IDLE_MS = 75_000 // no edits → kitten naps
|
|||||||
const BREAK_MS = 25 * 60_000 // continuous writing → suggest a break
|
const BREAK_MS = 25 * 60_000 // continuous writing → suggest a break
|
||||||
const TIP_MIN_GAP = 4 * 60_000 // at most one spontaneous tip per this window
|
const TIP_MIN_GAP = 4 * 60_000 // at most one spontaneous tip per this window
|
||||||
const PROACTIVE_GAP = 40_000 // floor between any two unsolicited bubbles
|
const PROACTIVE_GAP = 40_000 // floor between any two unsolicited bubbles
|
||||||
const BUBBLE_MS = 6_500 // how long a bubble lingers
|
const BEDTIME_GAP = 30 * 60_000 // at most one "go to bed" nudge per this window
|
||||||
const CHEER_MS = 4_500 // shorter for quick cheers
|
// The late-night window itself (isBedtime) lives in ../../lib/night so the
|
||||||
|
// companion nag and the night-mode theme/starfall share one definition.
|
||||||
|
// How long a bubble lingers. These are *floors* — readBubbleMs extends them by
|
||||||
|
// how much there is to read, since she reads both the Mandarin and the English
|
||||||
|
// (and tips now quote a slice of her own sentence, so they run longer).
|
||||||
|
const BUBBLE_MS = 14_000 // baseline for a tip / break
|
||||||
|
const CHEER_MS = 9_000 // a short cheer still needs a beat in two languages
|
||||||
|
const READ_MS_PER_CHAR = 55 // ~18 chars/sec, generous for a bilingual ESL read
|
||||||
|
const MAX_BUBBLE_MS = 32_000 // cap so a long quote can't pin the bubble forever
|
||||||
|
const HOVER_GRACE_MS = 3_000 // lingers this long after she stops hovering
|
||||||
const now = () => Date.now()
|
const now = () => Date.now()
|
||||||
|
|
||||||
|
// Reading time for a bubble: a base floor by tone, stretched by the combined
|
||||||
|
// length of the Mandarin + English lines so denser advice stays up long enough
|
||||||
|
// to actually finish reading.
|
||||||
|
function readBubbleMs(b: Bubble): number {
|
||||||
|
const base = b.tone === 'cheer' ? CHEER_MS : b.tone === 'bedtime' ? BUBBLE_MS + 4_000 : BUBBLE_MS
|
||||||
|
const chars = b.zh.length + b.en.length
|
||||||
|
return Math.min(MAX_BUBBLE_MS, base + chars * READ_MS_PER_CHAR)
|
||||||
|
}
|
||||||
|
|
||||||
// useCompanion is the behavior engine: it watches writing signals and decides
|
// useCompanion is the behavior engine: it watches writing signals and decides
|
||||||
// when the kitten speaks, what mood it shows, and how to pace itself so the
|
// when the kitten speaks, what mood it shows, and how to pace itself so the
|
||||||
// companion feels alive without interrupting. UI-agnostic — returns state only.
|
// companion feels alive without interrupting. UI-agnostic — returns state only.
|
||||||
export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Signals) {
|
export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptTick, text }: Signals) {
|
||||||
const [mood, setMood] = useState<Mood>('idle')
|
const [mood, setMood] = useState<Mood>('idle')
|
||||||
const [bubble, setBubble] = useState<Bubble | null>(null)
|
const [bubble, setBubble] = useState<Bubble | null>(null)
|
||||||
|
|
||||||
@@ -51,15 +80,26 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si
|
|||||||
const lastProactive = useRef(0)
|
const lastProactive = useRef(0)
|
||||||
const lastTip = useRef(0)
|
const lastTip = useRef(0)
|
||||||
const lastBreak = useRef(0)
|
const lastBreak = useRef(0)
|
||||||
|
const lastBedtime = useRef(0)
|
||||||
const nextMilestone = useRef(0) // index into MILESTONES
|
const nextMilestone = useRef(0) // index into MILESTONES
|
||||||
const sleeping = useRef(false)
|
const sleeping = useRef(false)
|
||||||
|
|
||||||
|
// Latest text for the prose checker, read lazily by the heartbeat (kept in a
|
||||||
|
// ref so per-keystroke changes don't re-arm the interval).
|
||||||
|
const textRef = useRef(text)
|
||||||
|
textRef.current = text
|
||||||
|
// Recently-surfaced hint ids, so the same untouched sentence isn't re-flagged
|
||||||
|
// each cadence. A small ring — old ids age out and may resurface later.
|
||||||
|
const recentHints = useRef<string[]>([])
|
||||||
|
const lastRule = useRef<string>('')
|
||||||
|
|
||||||
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
const moodTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
const moodTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
|
|
||||||
// Show a bubble + talking mood, then settle back. `proactive` messages respect
|
// Show a bubble + talking mood, then settle back. `proactive` messages respect
|
||||||
// the spacing floor; user-triggered ones (cheers) always go through.
|
// the spacing floor; user-triggered ones (cheers) always go through.
|
||||||
const say = useCallback((b: Bubble, opts?: { proactive?: boolean; celebrate?: boolean }) => {
|
const say = useCallback(
|
||||||
|
(b: Bubble, opts?: { proactive?: boolean; celebrate?: boolean; sound?: SoundName }) => {
|
||||||
const t = now()
|
const t = now()
|
||||||
if (opts?.proactive) {
|
if (opts?.proactive) {
|
||||||
if (bubble) return
|
if (bubble) return
|
||||||
@@ -67,15 +107,42 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si
|
|||||||
lastProactive.current = t
|
lastProactive.current = t
|
||||||
}
|
}
|
||||||
sleeping.current = false
|
sleeping.current = false
|
||||||
|
// A sound to match the bubble: callers can name a specific one (the milestone
|
||||||
|
// fanfare, the "haiya" on errors); everything else gets a rotating pop so the
|
||||||
|
// same blip never repeats back to back.
|
||||||
|
if (opts?.sound) playSound(opts.sound)
|
||||||
|
else playPop()
|
||||||
setBubble(b)
|
setBubble(b)
|
||||||
setMood(opts?.celebrate ? 'celebrate' : 'talking')
|
setMood(opts?.celebrate ? 'celebrate' : 'talking')
|
||||||
clearTimeout(bubbleTimer.current)
|
clearTimeout(bubbleTimer.current)
|
||||||
clearTimeout(moodTimer.current)
|
clearTimeout(moodTimer.current)
|
||||||
const dur = b.tone === 'cheer' ? CHEER_MS : BUBBLE_MS
|
const dur = readBubbleMs(b)
|
||||||
bubbleTimer.current = setTimeout(() => setBubble(null), dur)
|
bubbleTimer.current = setTimeout(() => setBubble(null), dur)
|
||||||
moodTimer.current = setTimeout(() => setMood('idle'), dur)
|
moodTimer.current = setTimeout(() => setMood('idle'), dur)
|
||||||
}, [bubble])
|
}, [bubble])
|
||||||
|
|
||||||
|
// Choose the next spontaneous tip. Prefer a real, context-aware note from the
|
||||||
|
// rules-based prose checker (it's actionable and clearly about *her* writing);
|
||||||
|
// fall back to a generic encouragement-style tip when the text reads clean or
|
||||||
|
// every finding was shown recently. Avoids repeating a finding or the same
|
||||||
|
// rule family back-to-back so the companion never feels like a broken record.
|
||||||
|
const nextTip = useCallback((): Bubble => {
|
||||||
|
// Applyable hints (those with a `fix`) surface as one-click suggestion cards,
|
||||||
|
// so the companion skips them — a span shouldn't be both a bubble and a card.
|
||||||
|
// What's left is the awareness notes (run-ons, splices, …) the kitten alone gives.
|
||||||
|
const hints = analyzeProse(textRef.current).filter((h) => !h.fix)
|
||||||
|
const fresh = hints.find(
|
||||||
|
(h) => !recentHints.current.includes(h.id) && h.rule !== lastRule.current,
|
||||||
|
)
|
||||||
|
const hint = fresh ?? hints.find((h) => !recentHints.current.includes(h.id))
|
||||||
|
if (hint) {
|
||||||
|
lastRule.current = hint.rule
|
||||||
|
recentHints.current = [hint.id, ...recentHints.current].slice(0, 8)
|
||||||
|
return { zh: hint.zh, en: hint.en, tone: 'tip' }
|
||||||
|
}
|
||||||
|
return { ...pick(TIPS), tone: 'tip' }
|
||||||
|
}, [])
|
||||||
|
|
||||||
const dismiss = useCallback(() => {
|
const dismiss = useCallback(() => {
|
||||||
clearTimeout(bubbleTimer.current)
|
clearTimeout(bubbleTimer.current)
|
||||||
clearTimeout(moodTimer.current)
|
clearTimeout(moodTimer.current)
|
||||||
@@ -83,6 +150,21 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si
|
|||||||
setMood('idle')
|
setMood('idle')
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Pause the auto-dismiss while she hovers a bubble — so a longer note never
|
||||||
|
// vanishes mid-read. Releasing leaves it up for a short, fixed grace so it
|
||||||
|
// doesn't linger forever once she looks away.
|
||||||
|
const holdBubble = useCallback(() => {
|
||||||
|
clearTimeout(bubbleTimer.current)
|
||||||
|
clearTimeout(moodTimer.current)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const releaseBubble = useCallback(() => {
|
||||||
|
clearTimeout(bubbleTimer.current)
|
||||||
|
clearTimeout(moodTimer.current)
|
||||||
|
bubbleTimer.current = setTimeout(() => setBubble(null), HOVER_GRACE_MS)
|
||||||
|
moodTimer.current = setTimeout(() => setMood('idle'), HOVER_GRACE_MS)
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Opening hello (once), after a short beat so it doesn't race the first paint.
|
// Opening hello (once), after a short beat so it doesn't race the first paint.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setTimeout(() => say({ ...GREETING, tone: 'tip' }), 1200)
|
const id = setTimeout(() => say({ ...GREETING, tone: 'tip' }), 1200)
|
||||||
@@ -128,11 +210,39 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si
|
|||||||
const n = MILESTONES[nextMilestone.current]
|
const n = MILESTONES[nextMilestone.current]
|
||||||
nextMilestone.current += 1
|
nextMilestone.current += 1
|
||||||
// Skip silently if we're just loading a long doc (no edits yet).
|
// Skip silently if we're just loading a long doc (no edits yet).
|
||||||
if (!firstEdit.current) say({ ...milestoneLine(n), tone: 'cheer' }, { celebrate: true })
|
if (!firstEdit.current)
|
||||||
|
say({ ...milestoneLine(n), tone: 'cheer' }, { celebrate: true, sound: 'milestone' })
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [wordCount])
|
}, [wordCount])
|
||||||
|
|
||||||
|
// Trouble — a save failed or the writing-assist LLM went unreachable. Play the
|
||||||
|
// playful "haiya" and reassure her, but never spam: at most one per gap, and
|
||||||
|
// only on the *transition* into trouble (not while it lingers).
|
||||||
|
const lastError = useRef(0)
|
||||||
|
const ERROR_GAP = 20_000
|
||||||
|
const wasLlmDown = useRef(false)
|
||||||
|
const haiya = useCallback(() => {
|
||||||
|
const t = now()
|
||||||
|
if (t - lastError.current < ERROR_GAP) {
|
||||||
|
playSound('error') // still acknowledge it, just without a fresh bubble
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastError.current = t
|
||||||
|
say({ ...pick(ERRORS), tone: 'error' }, { sound: 'error' })
|
||||||
|
}, [say])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (saveStatus === 'error') haiya()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [saveStatus])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (llmDown && !wasLlmDown.current) haiya()
|
||||||
|
wasLlmDown.current = llmDown
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [llmDown])
|
||||||
|
|
||||||
// Occasional gentle cheer on a successful save (kept rare so it isn't noise).
|
// Occasional gentle cheer on a successful save (kept rare so it isn't noise).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (saveStatus === 'saved' && Math.random() < 0.18) {
|
if (saveStatus === 'saved' && Math.random() < 0.18) {
|
||||||
@@ -161,14 +271,24 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise an occasional tip.
|
// Burning the midnight oil? Gently suggest bed — caring, low-frequency,
|
||||||
|
// and only while she's actually still at it (the idle branch above already
|
||||||
|
// returned if she's away/napping).
|
||||||
|
if (isBedtime() && t - lastBedtime.current > BEDTIME_GAP) {
|
||||||
|
lastBedtime.current = t
|
||||||
|
say({ ...pick(BEDTIME), tone: 'bedtime' }, { proactive: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise an occasional tip — context-aware when her text gives us
|
||||||
|
// something concrete to gently point at, generic warmth otherwise.
|
||||||
if (t - lastTip.current > TIP_MIN_GAP) {
|
if (t - lastTip.current > TIP_MIN_GAP) {
|
||||||
lastTip.current = t
|
lastTip.current = t
|
||||||
say({ ...pick(TIPS), tone: 'tip' }, { proactive: true })
|
say(nextTip(), { proactive: true })
|
||||||
}
|
}
|
||||||
}, 10_000)
|
}, 10_000)
|
||||||
return () => clearInterval(id)
|
return () => clearInterval(id)
|
||||||
}, [bubble, say])
|
}, [bubble, say, nextTip])
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
@@ -178,5 +298,5 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
return { mood, bubble, dismiss }
|
return { mood, bubble, dismiss, holdBubble, releaseBubble }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,154 @@
|
|||||||
import type { DocSummary } from '../../api/client'
|
import { useMemo, useState } from 'react'
|
||||||
|
import { api, type DocSummary, type Tag, type TagColor } from '../../api/client'
|
||||||
import { DocListItem } from './DocListItem'
|
import { DocListItem } from './DocListItem'
|
||||||
|
import { SearchBox } from './SearchBox'
|
||||||
|
import { TagChip } from './TagChip'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
docs: DocSummary[]
|
docs: DocSummary[]
|
||||||
|
roster: Tag[]
|
||||||
selectedId: string | null
|
selectedId: string | null
|
||||||
onSelect: (id: string) => void
|
onSelect: (id: string) => void
|
||||||
onCreate: () => void
|
onCreate: () => void
|
||||||
onDelete: (id: string) => void
|
onDelete: (id: string) => void
|
||||||
|
onDuplicate: (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.
|
// Sidebar sort orders. 'recent' keeps the server's updated_at-desc ordering.
|
||||||
export function DocList({ docs, selectedId, onSelect, onCreate, onDelete }: Props) {
|
type SortMode = 'recent' | 'title' | 'longest'
|
||||||
|
const SORTS: { value: SortMode; label: string }[] = [
|
||||||
|
{ value: 'recent', label: '最近 · Recent' },
|
||||||
|
{ value: 'title', label: '标题 · Title' },
|
||||||
|
{ value: 'longest', label: '字数 · Longest' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
onDuplicate,
|
||||||
|
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 [sort, setSort] = useState<SortMode>('recent')
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const base = activeFilter ? docs.filter((d) => d.tags.some((t) => t.id === activeFilter)) : docs
|
||||||
|
if (sort === 'recent') return base // already updated_at-desc from the server
|
||||||
|
const arr = [...base]
|
||||||
|
if (sort === 'title') {
|
||||||
|
arr.sort((a, b) =>
|
||||||
|
(a.title || 'Untitled').localeCompare(b.title || 'Untitled', undefined, { sensitivity: 'base' }),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
arr.sort((a, b) => b.word_count - a.word_count)
|
||||||
|
}
|
||||||
|
return arr
|
||||||
|
}, [docs, activeFilter, sort])
|
||||||
|
|
||||||
|
// Only surface tags that are actually in use, so the filter bar stays tidy.
|
||||||
|
const usedTags = useMemo(() => roster.filter((t) => (t.doc_count ?? 0) > 0), [roster])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className="flex h-full w-[260px] flex-col gap-2 p-3"
|
className="flex h-full w-[280px] flex-col gap-2 p-3"
|
||||||
style={{ borderRight: '1px solid var(--color-border)' }}
|
style={{ borderRight: '1px solid var(--color-border)' }}
|
||||||
>
|
>
|
||||||
|
<SearchBox onSelect={onSelect} />
|
||||||
|
|
||||||
|
{usedTags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 pb-0.5">
|
||||||
|
{usedTags.map((t) => (
|
||||||
|
<TagChip
|
||||||
|
key={t.id}
|
||||||
|
tag={t}
|
||||||
|
count={t.doc_count}
|
||||||
|
active={activeFilter === t.id}
|
||||||
|
onClick={() => setFilterId((cur) => (cur === t.id ? null : t.id))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCreate}
|
onClick={onCreate}
|
||||||
className="flex items-center justify-center gap-2 py-2.5 text-sm font-bold text-white"
|
className="petal-tap flex items-center justify-center gap-2 text-sm font-bold text-white"
|
||||||
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)' }}
|
style={{ minHeight: 44, borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)' }}
|
||||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||||
>
|
>
|
||||||
<span className="text-base leading-none">+</span> New Doc
|
<span className="text-base leading-none">+</span> New Doc
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{docs.length > 1 && (
|
||||||
|
<div className="flex items-center justify-end gap-1.5 px-1 text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
<span aria-hidden>↕</span>
|
||||||
|
<select
|
||||||
|
aria-label="Sort documents"
|
||||||
|
value={sort}
|
||||||
|
onChange={(e) => setSort(e.target.value as SortMode)}
|
||||||
|
className="bg-transparent text-xs font-semibold focus:outline-none"
|
||||||
|
style={{ color: 'var(--color-plum)' }}
|
||||||
|
>
|
||||||
|
{SORTS.map((s) => (
|
||||||
|
<option key={s.value} value={s.value}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col gap-0.5 overflow-y-auto">
|
<div className="flex flex-1 flex-col gap-0.5 overflow-y-auto">
|
||||||
{docs.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<p className="px-3 py-6 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
|
<p className="px-3 py-6 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||||
No documents yet.
|
{activeFilter ? 'No documents with this tag.' : 'No documents yet.'}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
docs.map((doc) => (
|
filtered.map((doc) => (
|
||||||
<DocListItem
|
<DocListItem
|
||||||
key={doc.id}
|
key={doc.id}
|
||||||
doc={doc}
|
doc={doc}
|
||||||
active={doc.id === selectedId}
|
active={doc.id === selectedId}
|
||||||
|
roster={roster}
|
||||||
onSelect={() => onSelect(doc.id)}
|
onSelect={() => onSelect(doc.id)}
|
||||||
onDelete={() => onDelete(doc.id)}
|
onDelete={() => onDelete(doc.id)}
|
||||||
|
onDuplicate={() => onDuplicate(doc.id)}
|
||||||
|
onToggleTag={onToggleTag}
|
||||||
|
onCreateTag={onCreateTag}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Whole-corpus backup — "download all my writing". Plain download links so
|
||||||
|
the browser saves the zip; no extra app state needed. */}
|
||||||
|
{docs.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-1 pt-1 text-xs"
|
||||||
|
style={{ color: 'var(--color-muted)', borderTop: '1px solid var(--color-border)' }}
|
||||||
|
>
|
||||||
|
<span className="font-semibold">备份 · Back up all:</span>
|
||||||
|
<a href={api.exportAllUrl('docx')} download className="font-bold hover:underline" style={{ color: 'var(--color-accent-hover)' }}>
|
||||||
|
Word
|
||||||
|
</a>
|
||||||
|
<a href={api.exportAllUrl('md')} download className="font-bold hover:underline" style={{ color: 'var(--color-accent-hover)' }}>
|
||||||
|
Markdown
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,45 @@
|
|||||||
import type { DocSummary } from '../../api/client'
|
import { useState } from 'react'
|
||||||
|
import type { DocSummary, Tag, TagColor } from '../../api/client'
|
||||||
|
import { TagChip } from './TagChip'
|
||||||
|
import { TagPicker } from './TagPicker'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
doc: DocSummary
|
doc: DocSummary
|
||||||
active: boolean
|
active: boolean
|
||||||
|
roster: Tag[]
|
||||||
onSelect: () => void
|
onSelect: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
|
onDuplicate: () => void
|
||||||
|
onToggleTag: (docId: string, tag: Tag) => void
|
||||||
|
onCreateTag: (docId: string, name: string, color: TagColor) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// One row in the sidebar: title + word count, a delete affordance on hover, and
|
// One row in the sidebar: title + word count, its tag chips, a tag affordance and
|
||||||
// a rose wash when it's the open document.
|
// a delete affordance on hover, and a rose wash when it's the open document.
|
||||||
export function DocListItem({ doc, active, onSelect, onDelete }: Props) {
|
export function DocListItem({
|
||||||
|
doc,
|
||||||
|
active,
|
||||||
|
roster,
|
||||||
|
onSelect,
|
||||||
|
onDelete,
|
||||||
|
onDuplicate,
|
||||||
|
onToggleTag,
|
||||||
|
onCreateTag,
|
||||||
|
}: Props) {
|
||||||
|
const [picking, setPicking] = useState(false)
|
||||||
|
const assignedIds = new Set(doc.tags.map((t) => t.id))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
className="group flex cursor-pointer items-center gap-2 px-3 py-2.5"
|
className="group relative flex cursor-pointer flex-col gap-1 px-3 py-2.5"
|
||||||
style={{
|
style={{
|
||||||
borderRadius: 'var(--radius-card)',
|
borderRadius: 'var(--radius-card)',
|
||||||
background: active ? 'var(--color-surface)' : 'transparent',
|
background: active ? 'var(--color-surface)' : 'transparent',
|
||||||
boxShadow: active ? 'var(--shadow-soft)' : 'none',
|
boxShadow: active ? 'var(--shadow-soft)' : 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div
|
<div
|
||||||
className="truncate text-sm font-semibold"
|
className="truncate text-sm font-semibold"
|
||||||
@@ -31,6 +51,35 @@ export function DocListItem({ doc, active, onSelect, onDelete }: Props) {
|
|||||||
{doc.word_count} {doc.word_count === 1 ? 'word' : 'words'}
|
{doc.word_count} {doc.word_count === 1 ? 'word' : 'words'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tag + delete affordances: always reachable on touch (no hover), fade in
|
||||||
|
on hover for the pointer experience. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Tag document"
|
||||||
|
title="Tags"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setPicking((v) => !v)
|
||||||
|
}}
|
||||||
|
className="petal-row-action flex h-7 w-7 shrink-0 items-center justify-center text-sm"
|
||||||
|
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
|
||||||
|
>
|
||||||
|
🏷️
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Duplicate document"
|
||||||
|
title="副本 · Duplicate"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onDuplicate()
|
||||||
|
}}
|
||||||
|
className="petal-row-action flex h-7 w-7 shrink-0 items-center justify-center text-sm"
|
||||||
|
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
|
||||||
|
>
|
||||||
|
⧉
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Delete document"
|
aria-label="Delete document"
|
||||||
@@ -38,11 +87,30 @@ export function DocListItem({ doc, active, onSelect, onDelete }: Props) {
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onDelete()
|
onDelete()
|
||||||
}}
|
}}
|
||||||
className="flex h-6 w-6 shrink-0 items-center justify-center text-base opacity-0 group-hover:opacity-100"
|
className="petal-row-action flex h-7 w-7 shrink-0 items-center justify-center text-base"
|
||||||
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
|
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{picking && (
|
||||||
|
<TagPicker
|
||||||
|
roster={roster}
|
||||||
|
assignedIds={assignedIds}
|
||||||
|
onToggle={(t) => onToggleTag(doc.id, t)}
|
||||||
|
onCreate={(name, color) => onCreateTag(doc.id, name, color)}
|
||||||
|
onClose={() => setPicking(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{doc.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{doc.tags.map((t) => (
|
||||||
|
<TagChip key={t.id} tag={t} onRemove={() => onToggleTag(doc.id, t)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { streamSuggestionChat, type ChatMessage } from '../../api/client'
|
import { api, streamSuggestionChat, type ChatMessage } from '../../api/client'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
suggestionId: string
|
suggestionId: string
|
||||||
// Pre-populates Petal's first bubble so the conversation opens with context.
|
// The English explanation (shown in the card body). Petal's opening bubble is
|
||||||
|
// its Simplified-Chinese translation, fetched on open — so the panel doesn't
|
||||||
|
// just repeat the same English text twice. Falls back to this on failure.
|
||||||
explanation: string
|
explanation: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,9 +19,10 @@ const CHAT_FONT = "'Nunito', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC
|
|||||||
// the card (unmounting) clears it. Each send streams Petal's reply token-by-
|
// the card (unmounting) clears it. Each send streams Petal's reply token-by-
|
||||||
// token into the latest assistant bubble.
|
// token into the latest assistant bubble.
|
||||||
export function AskPetal({ suggestionId, explanation }: Props) {
|
export function AskPetal({ suggestionId, explanation }: Props) {
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([
|
// Opening bubble starts empty (caret-only) and fills with the Mandarin
|
||||||
{ role: 'assistant', content: explanation },
|
// translation once it lands; `seeding` drives that loading caret.
|
||||||
])
|
const [messages, setMessages] = useState<ChatMessage[]>([{ role: 'assistant', content: '' }])
|
||||||
|
const [seeding, setSeeding] = useState(true)
|
||||||
const [input, setInput] = useState('')
|
const [input, setInput] = useState('')
|
||||||
const [streaming, setStreaming] = useState(false)
|
const [streaming, setStreaming] = useState(false)
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -31,11 +34,39 @@ export function AskPetal({ suggestionId, explanation }: Props) {
|
|||||||
if (el) el.scrollTop = el.scrollHeight
|
if (el) el.scrollTop = el.scrollHeight
|
||||||
}, [messages])
|
}, [messages])
|
||||||
|
|
||||||
// Focus the input when the panel opens.
|
// Focus the input when the panel opens. preventScroll: the card is already on
|
||||||
|
// screen as an absolutely-positioned overlay, and a default focus() would make
|
||||||
|
// the browser scroll its ancestor to "reveal" the input — jumping the document
|
||||||
|
// to the top.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
inputRef.current?.focus()
|
inputRef.current?.focus({ preventScroll: true })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Fetch the Chinese translation of the explanation to seed the first bubble.
|
||||||
|
// Only replaces the seed bubble if the user hasn't started chatting yet (the
|
||||||
|
// conversation always opens with this one assistant turn). Falls back to the
|
||||||
|
// English explanation if the translation can't be fetched.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
api
|
||||||
|
.translateSuggestion(suggestionId)
|
||||||
|
.then((res) => {
|
||||||
|
if (cancelled) return
|
||||||
|
const text = res.translation.trim() || explanation
|
||||||
|
setMessages((prev) => (prev.length === 1 ? [{ role: 'assistant', content: text }] : prev))
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) return
|
||||||
|
setMessages((prev) => (prev.length === 1 ? [{ role: 'assistant', content: explanation }] : prev))
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setSeeding(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [suggestionId, explanation])
|
||||||
|
|
||||||
async function send() {
|
async function send() {
|
||||||
const text = input.trim()
|
const text = input.trim()
|
||||||
if (!text || streaming) return
|
if (!text || streaming) return
|
||||||
@@ -67,7 +98,7 @@ export function AskPetal({ suggestionId, explanation }: Props) {
|
|||||||
console.error('Ask Petal chat failed:', err)
|
console.error('Ask Petal chat failed:', err)
|
||||||
} finally {
|
} finally {
|
||||||
setStreaming(false)
|
setStreaming(false)
|
||||||
inputRef.current?.focus()
|
inputRef.current?.focus({ preventScroll: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +117,12 @@ export function AskPetal({ suggestionId, explanation }: Props) {
|
|||||||
style={{ maxHeight: 220 }}
|
style={{ maxHeight: 220 }}
|
||||||
>
|
>
|
||||||
{messages.map((m, i) => (
|
{messages.map((m, i) => (
|
||||||
<Bubble key={i} role={m.role} content={m.content} streaming={streaming && i === messages.length - 1} />
|
<Bubble
|
||||||
|
key={i}
|
||||||
|
role={m.role}
|
||||||
|
content={m.content}
|
||||||
|
streaming={(streaming && i === messages.length - 1) || (seeding && i === 0)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,33 @@ import Underline from '@tiptap/extension-underline'
|
|||||||
import TextAlign from '@tiptap/extension-text-align'
|
import TextAlign from '@tiptap/extension-text-align'
|
||||||
import Placeholder from '@tiptap/extension-placeholder'
|
import Placeholder from '@tiptap/extension-placeholder'
|
||||||
import CharacterCount from '@tiptap/extension-character-count'
|
import CharacterCount from '@tiptap/extension-character-count'
|
||||||
|
import Link from '@tiptap/extension-link'
|
||||||
|
import { Color } from '@tiptap/extension-color'
|
||||||
|
import TextStyle from '@tiptap/extension-text-style'
|
||||||
|
import Highlight from '@tiptap/extension-highlight'
|
||||||
|
import Image from '@tiptap/extension-image'
|
||||||
|
import Table from '@tiptap/extension-table'
|
||||||
|
import TableRow from '@tiptap/extension-table-row'
|
||||||
|
import TableHeader from '@tiptap/extension-table-header'
|
||||||
|
import TableCell from '@tiptap/extension-table-cell'
|
||||||
|
import { FontSize } from './FontSize'
|
||||||
|
import type { EditorView } from '@tiptap/pm/view'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { Toolbar } from '../Toolbar/Toolbar'
|
import { Toolbar } from '../Toolbar/Toolbar'
|
||||||
import { SuggestionCard } from './SuggestionCard'
|
import { SuggestionCard } from './SuggestionCard'
|
||||||
import { SuggestionHighlight, setSuggestions, findRange } from './SuggestionHighlight'
|
import { SuggestionRail, type RailItem } from './SuggestionRail'
|
||||||
|
import { SuggestionHighlight, setSuggestions, setActiveSuggestion, findRange } from './SuggestionHighlight'
|
||||||
import { SpellCheck, setSpellChecker, wordAt } from './SpellCheck'
|
import { SpellCheck, setSpellChecker, wordAt } from './SpellCheck'
|
||||||
import { MisspellCard } from './MisspellCard'
|
import { MisspellCard } from './MisspellCard'
|
||||||
import { WordCard } from './WordCard'
|
import { WordCard } from './WordCard'
|
||||||
|
import { GlossTip } from './GlossTip'
|
||||||
|
import { SelectionBubble } from './SelectionBubble'
|
||||||
|
import { SearchHighlight } from './SearchHighlight'
|
||||||
|
import { FindReplace } from './FindReplace'
|
||||||
|
import { Typography } from './Typography'
|
||||||
|
import { RewritePreview, type RewriteStatus } from './RewritePreview'
|
||||||
import { api, type Suggestion, type WordInfo } from '../../api/client'
|
import { api, type Suggestion, type WordInfo } from '../../api/client'
|
||||||
|
import { speak, speechSupported } from '../../audio/speech'
|
||||||
import type { SpellChecker } from '../../hooks/useSpellChecker'
|
import type { SpellChecker } from '../../hooks/useSpellChecker'
|
||||||
|
|
||||||
export interface EditorChange {
|
export interface EditorChange {
|
||||||
@@ -34,6 +53,10 @@ interface Props {
|
|||||||
// it runs (drives the toolbar button's loading state).
|
// it runs (drives the toolbar button's loading state).
|
||||||
onVoiceCheck: () => void
|
onVoiceCheck: () => void
|
||||||
voicing: boolean
|
voicing: boolean
|
||||||
|
// Triggers the whole-document collocation coach; `collocating` is true while it
|
||||||
|
// runs (drives the toolbar button's loading state).
|
||||||
|
onCollocationCheck: () => void
|
||||||
|
collocating: boolean
|
||||||
// Fired when the editor gains focus, so the app can enter distraction-free mode.
|
// Fired when the editor gains focus, so the app can enter distraction-free mode.
|
||||||
onFocusMode?: () => void
|
onFocusMode?: () => void
|
||||||
// Browser-side spell checker (null until the dictionary loads). Adding a word
|
// Browser-side spell checker (null until the dictionary loads). Adding a word
|
||||||
@@ -62,6 +85,33 @@ interface WordInfoState {
|
|||||||
left: number
|
left: number
|
||||||
loading: boolean
|
loading: boolean
|
||||||
info: WordInfo | null
|
info: WordInfo | null
|
||||||
|
// Garden state: the captured word's id (null until the auto-capture returns or
|
||||||
|
// after it's removed) and whether it's currently in the garden.
|
||||||
|
vocabId: string | null
|
||||||
|
saved: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// sentenceAround pulls the sentence containing `word` out of a block of text, so
|
||||||
|
// a captured vocab word carries the context it was met in. Falls back to the
|
||||||
|
// whole (trimmed, length-capped) text when no sentence boundary is found.
|
||||||
|
function sentenceAround(text: string, wordStart: number): string {
|
||||||
|
const stops = /[.!?。!?\n]/
|
||||||
|
let start = 0
|
||||||
|
for (let i = wordStart - 1; i >= 0; i--) {
|
||||||
|
if (stops.test(text[i])) {
|
||||||
|
start = i + 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let end = text.length
|
||||||
|
for (let i = wordStart; i < text.length; i++) {
|
||||||
|
if (stops.test(text[i])) {
|
||||||
|
end = i + 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const s = text.slice(start, end).trim()
|
||||||
|
return s.length > 240 ? s.slice(0, 240).trim() + '…' : s
|
||||||
}
|
}
|
||||||
|
|
||||||
// A tiny CSS-only confetti burst played at an accept. Four palette-colored dots
|
// A tiny CSS-only confetti burst played at an accept. Four palette-colored dots
|
||||||
@@ -100,12 +150,60 @@ function parseDoc(raw: string): object | undefined {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// uploadImageInto sends an image file to the store and inserts the returned URL
|
||||||
|
// as an image node — at `pos` if given (a drop point), otherwise at the current
|
||||||
|
// selection (a paste). Shared by the paste/drop handlers and the toolbar button.
|
||||||
|
export async function uploadImageInto(view: EditorView, file: File, pos?: number) {
|
||||||
|
try {
|
||||||
|
const { url } = await api.uploadImage(file)
|
||||||
|
const { schema } = view.state
|
||||||
|
const node = schema.nodes.image.create({ src: url })
|
||||||
|
const at = pos ?? view.state.selection.from
|
||||||
|
view.dispatch(view.state.tr.insert(at, node))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('image upload failed', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface HoverState {
|
interface HoverState {
|
||||||
suggestion: Suggestion
|
suggestion: Suggestion
|
||||||
top: number
|
top: number
|
||||||
left: number
|
left: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The inline hover gloss: the word under the resting pointer, its Chinese
|
||||||
|
// translation, the PM range it covers (to dedupe re-fetches), and where to anchor.
|
||||||
|
interface GlossState {
|
||||||
|
word: string
|
||||||
|
gloss: string
|
||||||
|
from: number
|
||||||
|
to: number
|
||||||
|
top: number
|
||||||
|
left: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-empty text selection that the rewrite bubble hovers over.
|
||||||
|
interface SelectionState {
|
||||||
|
from: number
|
||||||
|
to: number
|
||||||
|
text: string
|
||||||
|
top: number
|
||||||
|
left: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// An in-flight or completed tone-rewrite preview, pinned over the selection it
|
||||||
|
// came from. `from`/`to` are the PM range the accepted rewrite replaces.
|
||||||
|
interface RewriteState {
|
||||||
|
from: number
|
||||||
|
to: number
|
||||||
|
original: string
|
||||||
|
style: string
|
||||||
|
top: number
|
||||||
|
left: number
|
||||||
|
status: RewriteStatus
|
||||||
|
rewrite: string
|
||||||
|
}
|
||||||
|
|
||||||
// EditorCore is the Tiptap instance: StarterKit formatting plus underline, text
|
// EditorCore is the Tiptap instance: StarterKit formatting plus underline, text
|
||||||
// alignment, a placeholder, character counting, and the suggestion-highlight
|
// alignment, a placeholder, character counting, and the suggestion-highlight
|
||||||
// decoration layer. Hovering a highlight opens its SuggestionCard.
|
// decoration layer. Hovering a highlight opens its SuggestionCard.
|
||||||
@@ -118,6 +216,8 @@ export function EditorCore({
|
|||||||
onDismiss,
|
onDismiss,
|
||||||
onVoiceCheck,
|
onVoiceCheck,
|
||||||
voicing,
|
voicing,
|
||||||
|
onCollocationCheck,
|
||||||
|
collocating,
|
||||||
onFocusMode,
|
onFocusMode,
|
||||||
spellChecker,
|
spellChecker,
|
||||||
onAddWord,
|
onAddWord,
|
||||||
@@ -138,31 +238,118 @@ export function EditorCore({
|
|||||||
// While the Ask Petal panel is expanded the card is pinned: the hover-close
|
// While the Ask Petal panel is expanded the card is pinned: the hover-close
|
||||||
// timer is suppressed so chatting doesn't dismiss it. A click outside closes.
|
// timer is suppressed so chatting doesn't dismiss it. A click outside closes.
|
||||||
const [pinned, setPinned] = useState(false)
|
const [pinned, setPinned] = useState(false)
|
||||||
|
// Inline hover gloss (rest the pointer on a word → its Chinese meaning).
|
||||||
|
const [gloss, setGloss] = useState<GlossState | null>(null)
|
||||||
|
const glossTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
|
const glossReqRef = useRef(0)
|
||||||
|
// The rewrite affordances: a bubble over the current selection, and the
|
||||||
|
// preview that replaces it once a style is chosen.
|
||||||
|
const [selection, setSelection] = useState<SelectionState | null>(null)
|
||||||
|
// True while a pointer is held down (a drag-select in progress). The rewrite
|
||||||
|
// bubble stays hidden until release so it never appears — or re-anchors — under
|
||||||
|
// a still-moving cursor, which is where the user is trying to click.
|
||||||
|
const [dragging, setDragging] = useState(false)
|
||||||
|
const [rewrite, setRewrite] = useState<RewriteState | null>(null)
|
||||||
|
const rewriteReqRef = useRef(0)
|
||||||
|
// The in-document Find & Replace bar (Ctrl/Cmd+F).
|
||||||
|
const [findOpen, setFindOpen] = useState(false)
|
||||||
|
// The right-margin comment rail. `railItems` carries each anchored suggestion's
|
||||||
|
// vertical offset; `railEnabled` is true only when the viewport has room for the
|
||||||
|
// column beside the editor (otherwise we fall back to the inline hover cards).
|
||||||
|
// `railExpandedId` is the card showing its full explanation + Ask Petal, and
|
||||||
|
// `activeId` is the suggestion currently emphasized (hovered text or card).
|
||||||
|
const [railItems, setRailItems] = useState<RailItem[]>([])
|
||||||
|
const [railEnabled, setRailEnabled] = useState(false)
|
||||||
|
const [railExpandedId, setRailExpandedId] = useState<string | null>(null)
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null)
|
||||||
|
// A stable handle to the latest recompute so the editor's onUpdate (captured
|
||||||
|
// once at construction) can trigger a re-measure without stale closures.
|
||||||
|
const recomputeRailRef = useRef<() => void>(() => {})
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit,
|
StarterKit,
|
||||||
Underline,
|
Underline,
|
||||||
|
TextStyle,
|
||||||
|
Color,
|
||||||
|
FontSize,
|
||||||
|
Highlight.configure({ multicolor: true }),
|
||||||
|
Link.configure({ openOnClick: false, autolink: true, HTMLAttributes: { rel: 'noopener noreferrer nofollow' } }),
|
||||||
|
Image.configure({ inline: false, HTMLAttributes: { class: 'petal-image' } }),
|
||||||
|
Table.configure({ resizable: true, HTMLAttributes: { class: 'petal-table' } }),
|
||||||
|
TableRow,
|
||||||
|
TableHeader,
|
||||||
|
TableCell,
|
||||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||||
Placeholder.configure({ placeholder: 'Start writing…' }),
|
Placeholder.configure({ placeholder: 'Start writing…' }),
|
||||||
CharacterCount,
|
CharacterCount,
|
||||||
SuggestionHighlight,
|
SuggestionHighlight,
|
||||||
SpellCheck,
|
SpellCheck,
|
||||||
|
SearchHighlight,
|
||||||
|
Typography,
|
||||||
],
|
],
|
||||||
content: parseDoc(initialContent),
|
content: parseDoc(initialContent),
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: { class: 'petal-prose focus:outline-none' },
|
attributes: { class: 'petal-prose focus:outline-none' },
|
||||||
|
// Dropping or pasting an image file uploads it and inserts it at the drop
|
||||||
|
// point (or the cursor for a paste). Returns true to consume the event so
|
||||||
|
// ProseMirror doesn't also try to handle the raw file. Non-image pastes
|
||||||
|
// fall through to the default handler.
|
||||||
|
handleDrop: (view, event) => {
|
||||||
|
const files = (event as DragEvent).dataTransfer?.files
|
||||||
|
const image = files && Array.from(files).find((f) => f.type.startsWith('image/'))
|
||||||
|
if (!image) return false
|
||||||
|
event.preventDefault()
|
||||||
|
const coords = view.posAtCoords({ left: (event as DragEvent).clientX, top: (event as DragEvent).clientY })
|
||||||
|
uploadImageInto(view, image, coords?.pos)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
handlePaste: (view, event) => {
|
||||||
|
const files = event.clipboardData?.files
|
||||||
|
const image = files && Array.from(files).find((f) => f.type.startsWith('image/'))
|
||||||
|
if (!image) return false
|
||||||
|
event.preventDefault()
|
||||||
|
uploadImageInto(view, image)
|
||||||
|
return true
|
||||||
|
},
|
||||||
},
|
},
|
||||||
onFocus: () => onFocusMode?.(),
|
onFocus: () => onFocusMode?.(),
|
||||||
onUpdate: ({ editor }) => {
|
onUpdate: ({ editor }) => {
|
||||||
// Any edit shifts positions, stranding the popover anchors.
|
// Any edit shifts positions, stranding the popover anchors.
|
||||||
setMisspell(null)
|
setMisspell(null)
|
||||||
setWordInfo(null)
|
setWordInfo(null)
|
||||||
|
setGloss(null)
|
||||||
|
setSelection(null)
|
||||||
|
// A stale rewrite preview points at a range that just moved — drop it.
|
||||||
|
rewriteReqRef.current++
|
||||||
|
setRewrite(null)
|
||||||
onChange({
|
onChange({
|
||||||
content: JSON.stringify(editor.getJSON()),
|
content: JSON.stringify(editor.getJSON()),
|
||||||
content_text: editor.getText(),
|
content_text: editor.getText(),
|
||||||
word_count: editor.storage.characterCount.words(),
|
word_count: editor.storage.characterCount.words(),
|
||||||
})
|
})
|
||||||
|
// Edits reflow the text, so the rail anchors need re-measuring.
|
||||||
|
recomputeRailRef.current()
|
||||||
|
},
|
||||||
|
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 })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -174,6 +361,10 @@ export function EditorCore({
|
|||||||
setHover(null)
|
setHover(null)
|
||||||
setMisspell(null)
|
setMisspell(null)
|
||||||
setWordInfo(null)
|
setWordInfo(null)
|
||||||
|
setGloss(null)
|
||||||
|
setSelection(null)
|
||||||
|
rewriteReqRef.current++
|
||||||
|
setRewrite(null)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [docId, editor])
|
}, [docId, editor])
|
||||||
|
|
||||||
@@ -190,8 +381,87 @@ export function EditorCore({
|
|||||||
setSuggestions(editor.state, editor.view.dispatch, suggestions)
|
setSuggestions(editor.state, editor.view.dispatch, suggestions)
|
||||||
// Close the card if its suggestion is gone.
|
// Close the card if its suggestion is gone.
|
||||||
setHover((h) => (h && suggestions.some((s) => s.id === h.suggestion.id) ? h : null))
|
setHover((h) => (h && suggestions.some((s) => s.id === h.suggestion.id) ? h : null))
|
||||||
|
// Drop any rail expand/emphasis that points at a now-removed suggestion.
|
||||||
|
setRailExpandedId((id) => (id && suggestions.some((s) => s.id === id) ? id : null))
|
||||||
|
setActiveId((id) => (id && suggestions.some((s) => s.id === id) ? id : null))
|
||||||
}, [editor, suggestions])
|
}, [editor, suggestions])
|
||||||
|
|
||||||
|
// Re-measure the margin rail: whether there's room for the column beside the
|
||||||
|
// editor, and where each suggestion's highlight sits vertically. Anchors are
|
||||||
|
// taken from the live decoration DOM (keyed by data-suggestion-id) relative to
|
||||||
|
// the wrapper — stable under scroll since text and wrapper scroll together.
|
||||||
|
// Deferred a frame so decoration DOM and reflow have settled.
|
||||||
|
const recomputeRail = useCallback(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const wrapper = wrapperRef.current
|
||||||
|
if (!wrapper) return
|
||||||
|
const wrapRect = wrapper.getBoundingClientRect()
|
||||||
|
// Need room for the 300px column + its 32px gutter (see .petal-rail), plus
|
||||||
|
// a little breathing space to the viewport edge.
|
||||||
|
setRailEnabled(window.innerWidth - wrapRect.right >= 348)
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const items: RailItem[] = []
|
||||||
|
wrapper.querySelectorAll<HTMLElement>('.petal-suggestion[data-suggestion-id]').forEach((el) => {
|
||||||
|
const id = el.getAttribute('data-suggestion-id')
|
||||||
|
if (!id || seen.has(id)) return
|
||||||
|
const s = suggestions.find((x) => x.id === id)
|
||||||
|
if (!s) return
|
||||||
|
seen.add(id)
|
||||||
|
items.push({ suggestion: s, anchorTop: el.getBoundingClientRect().top - wrapRect.top })
|
||||||
|
})
|
||||||
|
setRailItems(items)
|
||||||
|
})
|
||||||
|
}, [suggestions])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
recomputeRailRef.current = recomputeRail
|
||||||
|
}, [recomputeRail])
|
||||||
|
|
||||||
|
// Re-anchor when the suggestion set changes (after the decorations repaint),
|
||||||
|
// and keep the rail in sync with viewport/editor width changes (room + reflow).
|
||||||
|
useEffect(() => {
|
||||||
|
recomputeRail()
|
||||||
|
const wrapper = wrapperRef.current
|
||||||
|
const ro = wrapper ? new ResizeObserver(() => recomputeRail()) : null
|
||||||
|
if (wrapper && ro) ro.observe(wrapper)
|
||||||
|
window.addEventListener('resize', recomputeRail)
|
||||||
|
return () => {
|
||||||
|
ro?.disconnect()
|
||||||
|
window.removeEventListener('resize', recomputeRail)
|
||||||
|
}
|
||||||
|
}, [recomputeRail])
|
||||||
|
|
||||||
|
// Emphasize the flagged text for the active suggestion, mirroring the rail
|
||||||
|
// card ↔ text link both ways. Driven through the decoration plugin (not an
|
||||||
|
// imperative DOM class) so it survives the repaints that fire on every edit.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return
|
||||||
|
setActiveSuggestion(editor.state, editor.view.dispatch, railEnabled ? activeId : null)
|
||||||
|
}, [editor, activeId, railEnabled])
|
||||||
|
|
||||||
|
// Scroll a suggestion's highlight to the middle of the viewport (clicking its
|
||||||
|
// rail card jumps the editor to the flagged text).
|
||||||
|
const scrollToSuggestion = useCallback((id: string) => {
|
||||||
|
const el = wrapperRef.current?.querySelector(
|
||||||
|
`.petal-suggestion[data-suggestion-id="${CSS.escape(id)}"]`,
|
||||||
|
)
|
||||||
|
el?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Clicking a rail card's body jumps to the text and toggles its detail panel.
|
||||||
|
const activateRailCard = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
setActiveId(id)
|
||||||
|
scrollToSuggestion(id)
|
||||||
|
setRailExpandedId((cur) => (cur === id ? null : id))
|
||||||
|
},
|
||||||
|
[scrollToSuggestion],
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggleRailExpand = useCallback((id: string) => {
|
||||||
|
setRailExpandedId((cur) => (cur === id ? null : id))
|
||||||
|
}, [])
|
||||||
|
|
||||||
const openCardFor = useCallback(
|
const openCardFor = useCallback(
|
||||||
(id: string, el: HTMLElement) => {
|
(id: string, el: HTMLElement) => {
|
||||||
const wrapper = wrapperRef.current
|
const wrapper = wrapperRef.current
|
||||||
@@ -223,10 +493,17 @@ export function EditorCore({
|
|||||||
if (!target) return
|
if (!target) return
|
||||||
const id = target.getAttribute('data-suggestion-id')
|
const id = target.getAttribute('data-suggestion-id')
|
||||||
if (!id) return
|
if (!id) return
|
||||||
|
// With the rail open the card already lives in the margin — hovering the
|
||||||
|
// text just emphasizes its card (and the highlight) rather than popping a
|
||||||
|
// second, redundant floating card.
|
||||||
|
if (railEnabled) {
|
||||||
|
setActiveId(id)
|
||||||
|
return
|
||||||
|
}
|
||||||
clearTimeout(closeTimer.current)
|
clearTimeout(closeTimer.current)
|
||||||
openCardFor(id, target)
|
openCardFor(id, target)
|
||||||
},
|
},
|
||||||
[openCardFor],
|
[openCardFor, railEnabled],
|
||||||
)
|
)
|
||||||
|
|
||||||
const scheduleClose = useCallback(() => {
|
const scheduleClose = useCallback(() => {
|
||||||
@@ -246,35 +523,53 @@ export function EditorCore({
|
|||||||
// pointer can bridge the small gap from text to card.
|
// pointer can bridge the small gap from text to card.
|
||||||
const handleMouseOut = useCallback(
|
const handleMouseOut = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
if ((e.target as HTMLElement).closest('.petal-suggestion')) scheduleClose()
|
if (!(e.target as HTMLElement).closest('.petal-suggestion')) return
|
||||||
|
if (railEnabled) {
|
||||||
|
setActiveId(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scheduleClose()
|
||||||
},
|
},
|
||||||
[scheduleClose],
|
[scheduleClose, railEnabled],
|
||||||
)
|
)
|
||||||
|
|
||||||
const keepOpen = useCallback(() => clearTimeout(closeTimer.current), [])
|
const keepOpen = useCallback(() => clearTimeout(closeTimer.current), [])
|
||||||
|
|
||||||
// Accept applies the replacement to the document, plays a little confetti
|
// Accept applies the replacement to the document, plays a little confetti
|
||||||
// burst where the card sat, then notifies the parent.
|
// burst over the flagged text, then notifies the parent. The confetti is
|
||||||
|
// anchored to the highlight itself (captured before the replacement removes it),
|
||||||
|
// so it fires in the right spot whether the accept came from the hover card or
|
||||||
|
// the margin rail.
|
||||||
const handleAccept = useCallback(
|
const handleAccept = useCallback(
|
||||||
(s: Suggestion) => {
|
(s: Suggestion) => {
|
||||||
|
const wrapper = wrapperRef.current
|
||||||
|
const el = wrapper?.querySelector(
|
||||||
|
`.petal-suggestion[data-suggestion-id="${CSS.escape(s.id)}"]`,
|
||||||
|
) as HTMLElement | null
|
||||||
|
let burst: { top: number; left: number } | null = null
|
||||||
|
if (wrapper && el) {
|
||||||
|
const wrapRect = wrapper.getBoundingClientRect()
|
||||||
|
const elRect = el.getBoundingClientRect()
|
||||||
|
burst = { top: elRect.top - wrapRect.top, left: elRect.right - wrapRect.left }
|
||||||
|
}
|
||||||
if (editor && s.replacement.trim() !== '') {
|
if (editor && s.replacement.trim() !== '') {
|
||||||
const range = findRange(editor.state.doc, s.original)
|
const range = findRange(editor.state.doc, s.original)
|
||||||
if (range) {
|
if (range) {
|
||||||
editor.chain().focus().insertContentAt(range, s.replacement).run()
|
editor.chain().focus().insertContentAt(range, s.replacement).run()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setHover((h) => {
|
// Fall back to the hover card's position if the highlight wasn't found.
|
||||||
if (h) {
|
if (!burst && hover) burst = { top: hover.top, left: hover.left + 16 }
|
||||||
setConfetti({ top: h.top, left: h.left + 16 })
|
if (burst) {
|
||||||
|
setConfetti(burst)
|
||||||
clearTimeout(confettiTimer.current)
|
clearTimeout(confettiTimer.current)
|
||||||
confettiTimer.current = setTimeout(() => setConfetti(null), 720)
|
confettiTimer.current = setTimeout(() => setConfetti(null), 720)
|
||||||
}
|
}
|
||||||
return h
|
|
||||||
})
|
|
||||||
closeCard()
|
closeCard()
|
||||||
|
setRailExpandedId(null)
|
||||||
onAccept(s)
|
onAccept(s)
|
||||||
},
|
},
|
||||||
[editor, onAccept, closeCard],
|
[editor, onAccept, closeCard, hover],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleDismiss = useCallback(
|
const handleDismiss = useCallback(
|
||||||
@@ -285,33 +580,69 @@ export function EditorCore({
|
|||||||
[onDismiss, closeCard],
|
[onDismiss, closeCard],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Click a red-underlined word to open its spelling popover, anchored under the
|
// openMisspellAt resolves the word at a document position and, if the checker
|
||||||
// word. posAtCoords→wordAt resolves the exact PM span (robust to the same
|
// flags it as misspelled, opens the spelling popover anchored under the word.
|
||||||
// misspelling appearing elsewhere); nspell supplies the corrections.
|
// Returns true if it opened a card. We resolve the word from coordinates and
|
||||||
const handleSpellClick = useCallback(
|
// the checker rather than from the `.petal-misspelling` DOM span: clicking a
|
||||||
(e: React.MouseEvent) => {
|
// word moves the caret into it, which fires the decoration rebuild that
|
||||||
if (!editor || !spellChecker) return
|
// deliberately un-underlines the caret word (see SpellCheck's caret exemption),
|
||||||
const target = (e.target as HTMLElement).closest('.petal-misspelling') as HTMLElement | null
|
// so by the time a click/contextmenu handler runs the span is already gone.
|
||||||
if (!target) return
|
const openMisspellAt = useCallback(
|
||||||
|
(pos: number): boolean => {
|
||||||
|
if (!editor || !spellChecker) return false
|
||||||
|
const range = wordAt(editor.state.doc, pos)
|
||||||
|
if (!range || spellChecker.correct(range.word)) return false
|
||||||
const wrapper = wrapperRef.current
|
const wrapper = wrapperRef.current
|
||||||
if (!wrapper) return
|
if (!wrapper) return false
|
||||||
const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY })
|
const start = editor.view.coordsAtPos(range.from)
|
||||||
if (!coords) return
|
const end = editor.view.coordsAtPos(range.to)
|
||||||
const range = wordAt(editor.state.doc, coords.pos)
|
|
||||||
if (!range) return
|
|
||||||
const elRect = target.getBoundingClientRect()
|
|
||||||
const wrapRect = wrapper.getBoundingClientRect()
|
const wrapRect = wrapper.getBoundingClientRect()
|
||||||
const cardWidth = 240
|
const cardWidth = 240
|
||||||
const left = Math.max(0, Math.min(elRect.left - wrapRect.left, wrapper.clientWidth - cardWidth))
|
const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - cardWidth))
|
||||||
const top = elRect.bottom - wrapRect.top + 6
|
const top = end.bottom - wrapRect.top + 6
|
||||||
// Opening a spelling popover supersedes any AI-suggestion hover card.
|
// Opening a spelling popover supersedes any AI-suggestion or lookup card.
|
||||||
closeCard()
|
closeCard()
|
||||||
setWordInfo(null)
|
setWordInfo(null)
|
||||||
setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
|
setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
|
||||||
|
return true
|
||||||
},
|
},
|
||||||
[editor, spellChecker, closeCard],
|
[editor, spellChecker, closeCard],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Click a misspelled word to open its spelling popover, anchored under the
|
||||||
|
// word; 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) {
|
||||||
|
// With the rail open, a tap emphasizes and expands its margin card
|
||||||
|
// instead of opening a floating one.
|
||||||
|
if (railEnabled) {
|
||||||
|
setActiveId(id)
|
||||||
|
setRailExpandedId(id)
|
||||||
|
} else {
|
||||||
|
openCardFor(id, suggestionEl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!editor) return
|
||||||
|
// Only act on clicks in the editor text itself — the floating cards/popovers
|
||||||
|
// are children of this same wrapper, and a click on one shouldn't resolve a
|
||||||
|
// (hidden) word behind it.
|
||||||
|
if (!(e.target as HTMLElement).closest('.petal-prose')) return
|
||||||
|
const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY })
|
||||||
|
if (!coords) return
|
||||||
|
openMisspellAt(coords.pos)
|
||||||
|
},
|
||||||
|
[editor, openMisspellAt, openCardFor, railEnabled],
|
||||||
|
)
|
||||||
|
|
||||||
const replaceMisspelling = useCallback(
|
const replaceMisspelling = useCallback(
|
||||||
(correction: string) => {
|
(correction: string) => {
|
||||||
if (editor && misspell) {
|
if (editor && misspell) {
|
||||||
@@ -327,20 +658,31 @@ export function EditorCore({
|
|||||||
setMisspell(null)
|
setMisspell(null)
|
||||||
}, [misspell, onAddWord])
|
}, [misspell, onAddWord])
|
||||||
|
|
||||||
// Right-click a word to look it up: resolve the exact word span under the
|
// exampleAt pulls the sentence containing the position out of its block, for
|
||||||
// pointer, anchor a popover beneath it, and kick off the offline lookup. The
|
// review context in the garden. textBetween with a single-char leaf/break
|
||||||
// card opens immediately in a loading state and fills in when the (local)
|
// placeholder keeps the string indices aligned with ProseMirror's parentOffset
|
||||||
// lookup returns. Right-clicking off any word falls through to the native menu.
|
// (so a hard break or inline atom before the word doesn't desync the slice).
|
||||||
const handleContextMenu = useCallback(
|
const exampleAt = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(pos: number): string => {
|
||||||
|
if (!editor) return ''
|
||||||
|
const $pos = editor.state.doc.resolve(pos)
|
||||||
|
const text = $pos.parent.textBetween(0, $pos.parent.content.size, '\n', ' ')
|
||||||
|
return sentenceAround(text, Math.max(0, $pos.parentOffset))
|
||||||
|
},
|
||||||
|
[editor],
|
||||||
|
)
|
||||||
|
|
||||||
|
// openWordLookup resolves the exact word span at a document position, anchors a
|
||||||
|
// popover beneath it, and kicks off the offline lookup. The card opens
|
||||||
|
// immediately in a loading state and fills in when the (local) lookup returns.
|
||||||
|
// Shared by right-click, the keyboard shortcut (caret), and touch long-press.
|
||||||
|
const openWordLookup = useCallback(
|
||||||
|
(pos: number) => {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY })
|
const range = wordAt(editor.state.doc, pos)
|
||||||
if (!coords) return
|
|
||||||
const range = wordAt(editor.state.doc, coords.pos)
|
|
||||||
if (!range) return
|
if (!range) return
|
||||||
const wrapper = wrapperRef.current
|
const wrapper = wrapperRef.current
|
||||||
if (!wrapper) return
|
if (!wrapper) return
|
||||||
e.preventDefault()
|
|
||||||
// Anchor under the word itself (not the click point) so the card lines up
|
// Anchor under the word itself (not the click point) so the card lines up
|
||||||
// with the text the way the spelling popover does.
|
// with the text the way the spelling popover does.
|
||||||
const start = editor.view.coordsAtPos(range.from)
|
const start = editor.view.coordsAtPos(range.from)
|
||||||
@@ -353,13 +695,40 @@ export function EditorCore({
|
|||||||
closeCard()
|
closeCard()
|
||||||
setMisspell(null)
|
setMisspell(null)
|
||||||
const token = ++wordReqRef.current
|
const token = ++wordReqRef.current
|
||||||
setWordInfo({ word: range.word, from: range.from, to: range.to, top, left, loading: true, info: null })
|
setWordInfo({ word: range.word, from: range.from, to: range.to, top, left, loading: true, info: null, vocabId: null, saved: false })
|
||||||
|
// The sentence the word sits in, for review context in the garden.
|
||||||
|
const example = exampleAt(range.from)
|
||||||
api
|
api
|
||||||
.lookupWord(range.word)
|
.lookupWord(range.word)
|
||||||
.then((info) => {
|
.then((info) => {
|
||||||
if (token === wordReqRef.current) {
|
if (token !== wordReqRef.current) return
|
||||||
setWordInfo((w) => (w ? { ...w, loading: false, info } : null))
|
// Auto-capture into the vocabulary garden — only words the dictionary
|
||||||
}
|
// actually knows (a real gloss or definition), so accidental lookups of
|
||||||
|
// typos or proper nouns don't clutter the garden. Looking words up IS
|
||||||
|
// the data source; this costs the writer nothing.
|
||||||
|
const known = !!info.gloss || info.definitions.length > 0
|
||||||
|
// Reflect the saved state optimistically so the heart shows 💚 the
|
||||||
|
// moment a known word loads, rather than flashing 🤍 until the capture
|
||||||
|
// round-trips. vocabId is filled in when recordVocab returns.
|
||||||
|
setWordInfo((w) => (w ? { ...w, loading: false, info, saved: known } : null))
|
||||||
|
if (!known) return
|
||||||
|
api
|
||||||
|
.recordVocab({
|
||||||
|
word: range.word,
|
||||||
|
gloss: info.gloss,
|
||||||
|
definition: info.definitions[0]?.definition ?? '',
|
||||||
|
phonetic: info.phonetic,
|
||||||
|
example,
|
||||||
|
doc_id: docId,
|
||||||
|
})
|
||||||
|
.then((row) => {
|
||||||
|
// Discard a late capture if the card has since been superseded (a
|
||||||
|
// new lookup, navigation, or an explicit remove all bump the token),
|
||||||
|
// so it can't resurrect a word the writer just removed.
|
||||||
|
if (token !== wordReqRef.current) return
|
||||||
|
setWordInfo((w) => (w && w.word === range.word ? { ...w, vocabId: row.id, saved: true } : w))
|
||||||
|
})
|
||||||
|
.catch((err) => console.error('vocab capture failed', err))
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error('word lookup failed', err)
|
console.error('word lookup failed', err)
|
||||||
@@ -368,9 +737,79 @@ export function EditorCore({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[editor, closeCard],
|
[editor, closeCard, docId],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Toggle a looked-up word in/out of the vocabulary garden from the WordCard
|
||||||
|
// heart. Auto-capture saves it on lookup; this lets the writer remove a word
|
||||||
|
// she already knows (or re-add one she removed by mistake).
|
||||||
|
const toggleSaveWord = useCallback(() => {
|
||||||
|
// Read the current card and do the network side effects OUTSIDE the state
|
||||||
|
// updater — an updater must be pure (React StrictMode double-invokes it,
|
||||||
|
// which would otherwise fire each request twice).
|
||||||
|
const w = wordInfo
|
||||||
|
if (!w || !w.info) return
|
||||||
|
if (w.vocabId) {
|
||||||
|
// Already in the garden — remove it, and invalidate any in-flight capture
|
||||||
|
// for this card so a late auto-capture can't resurrect the removed word.
|
||||||
|
const id = w.vocabId
|
||||||
|
wordReqRef.current++
|
||||||
|
setWordInfo((cur) => (cur ? { ...cur, saved: false, vocabId: null } : cur))
|
||||||
|
api.deleteVocab(id).catch((err) => console.error('vocab remove failed', err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Not in the garden yet — save it (idempotent upsert keyed on the word).
|
||||||
|
const word = w.word
|
||||||
|
const example = exampleAt(w.from)
|
||||||
|
setWordInfo((cur) => (cur ? { ...cur, saved: true } : cur))
|
||||||
|
api
|
||||||
|
.recordVocab({
|
||||||
|
word,
|
||||||
|
gloss: w.info.gloss,
|
||||||
|
definition: w.info.definitions[0]?.definition ?? '',
|
||||||
|
phonetic: w.info.phonetic,
|
||||||
|
example,
|
||||||
|
doc_id: docId,
|
||||||
|
})
|
||||||
|
.then((row) => setWordInfo((cur) => (cur && cur.word === word ? { ...cur, vocabId: row.id, saved: true } : cur)))
|
||||||
|
.catch((err) => console.error('vocab save failed', err))
|
||||||
|
}, [wordInfo, exampleAt, docId])
|
||||||
|
|
||||||
|
// Right-click a misspelled word for spelling corrections (the familiar "did you
|
||||||
|
// mean" gesture); right-click any other word to look it up. Right-clicking off
|
||||||
|
// any word falls through to the native menu (so copy/paste-by-menu still works
|
||||||
|
// — see the Selection fix).
|
||||||
|
const handleContextMenu = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
if (!editor) return
|
||||||
|
const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY })
|
||||||
|
if (!coords) return
|
||||||
|
if (!wordAt(editor.state.doc, coords.pos)) return
|
||||||
|
e.preventDefault()
|
||||||
|
// A misspelled word offers corrections first; otherwise look it up.
|
||||||
|
if (openMisspellAt(coords.pos)) return
|
||||||
|
openWordLookup(coords.pos)
|
||||||
|
},
|
||||||
|
[editor, openMisspellAt, openWordLookup],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Touch has no hover or right-click, so a long-press (~500ms without moving)
|
||||||
|
// opens the word lookup at the finger — the touch equivalent of right-click.
|
||||||
|
const longPressTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
|
const handleTouchStart = useCallback(
|
||||||
|
(e: React.TouchEvent) => {
|
||||||
|
if (!editor || e.touches.length !== 1) return
|
||||||
|
const { clientX, clientY } = e.touches[0]
|
||||||
|
clearTimeout(longPressTimer.current)
|
||||||
|
longPressTimer.current = setTimeout(() => {
|
||||||
|
const coords = editor.view.posAtCoords({ left: clientX, top: clientY })
|
||||||
|
if (coords) openWordLookup(coords.pos)
|
||||||
|
}, 500)
|
||||||
|
},
|
||||||
|
[editor, openWordLookup],
|
||||||
|
)
|
||||||
|
const cancelLongPress = useCallback(() => clearTimeout(longPressTimer.current), [])
|
||||||
|
|
||||||
const replaceWord = useCallback(
|
const replaceWord = useCallback(
|
||||||
(synonym: string) => {
|
(synonym: string) => {
|
||||||
if (editor && wordInfo) {
|
if (editor && wordInfo) {
|
||||||
@@ -381,6 +820,143 @@ export function EditorCore({
|
|||||||
[editor, wordInfo],
|
[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
|
// A pointer-down outside the word popover (and not on another word, which would
|
||||||
// reopen it via the context menu) closes it.
|
// reopen it via the context menu) closes it.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -406,9 +982,52 @@ export function EditorCore({
|
|||||||
return () => document.removeEventListener('mousedown', onDown)
|
return () => document.removeEventListener('mousedown', onDown)
|
||||||
}, [misspell])
|
}, [misspell])
|
||||||
|
|
||||||
|
// A pointer-down in the editor starts a (possible) drag-select, which keeps the
|
||||||
|
// rewrite bubble hidden until release. A pointer-down on the bubble itself is
|
||||||
|
// excluded so its buttons stay clickable. Release always re-arms the bubble.
|
||||||
|
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
||||||
|
if ((e.target as HTMLElement).closest('.petal-selection-bubble')) return
|
||||||
|
setDragging(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onUp = () => setDragging(false)
|
||||||
|
document.addEventListener('pointerup', onUp)
|
||||||
|
return () => document.removeEventListener('pointerup', onUp)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Editor keyboard shortcuts, so the ESL helpers aren't mouse-only:
|
||||||
|
// Ctrl/Cmd+F — Find & Replace (overrides the browser find, which can't see
|
||||||
|
// into the editor's content anyway)
|
||||||
|
// Ctrl/Cmd+D — look up the word at the caret (the keyboard "right-click")
|
||||||
|
// Ctrl/Cmd+J — rewrite the current selection more naturally (✨更自然)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (!(e.ctrlKey || e.metaKey) || e.altKey) return
|
||||||
|
const k = e.key.toLowerCase()
|
||||||
|
if (k === 'f') {
|
||||||
|
e.preventDefault()
|
||||||
|
setFindOpen(true)
|
||||||
|
} else if (k === 'd') {
|
||||||
|
e.preventDefault()
|
||||||
|
openWordLookup(editor.state.selection.from)
|
||||||
|
} else if (k === 'j') {
|
||||||
|
if (!editor.state.selection.empty) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleRewrite('natural')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [editor, openWordLookup, handleRewrite])
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
clearTimeout(closeTimer.current)
|
clearTimeout(closeTimer.current)
|
||||||
clearTimeout(confettiTimer.current)
|
clearTimeout(confettiTimer.current)
|
||||||
|
clearTimeout(glossTimer.current)
|
||||||
|
clearTimeout(longPressTimer.current)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// While pinned (Ask Petal open), a pointer-down outside the card closes it —
|
// While pinned (Ask Petal open), a pointer-down outside the card closes it —
|
||||||
@@ -422,24 +1041,73 @@ export function EditorCore({
|
|||||||
return () => document.removeEventListener('mousedown', onDown)
|
return () => document.removeEventListener('mousedown', onDown)
|
||||||
}, [pinned, closeCard])
|
}, [pinned, closeCard])
|
||||||
|
|
||||||
|
// Touch has no hover, so the card is opened by a tap and can't close itself on
|
||||||
|
// mouse-leave. Whenever a card is open, a pointer-down outside both the card and
|
||||||
|
// any highlight dismisses it. Excluding `.petal-suggestion` lets a tap on
|
||||||
|
// another highlight reopen for that one (via the click handler) without flicker.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hover) return
|
||||||
|
const onDown = (e: PointerEvent) => {
|
||||||
|
const t = e.target as HTMLElement
|
||||||
|
if (!t.closest('.petal-suggestion-card') && !t.closest('.petal-suggestion')) closeCard()
|
||||||
|
}
|
||||||
|
document.addEventListener('pointerdown', onDown)
|
||||||
|
return () => document.removeEventListener('pointerdown', onDown)
|
||||||
|
}, [hover, closeCard])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
<Toolbar editor={editor} onVoiceCheck={onVoiceCheck} voicing={voicing} />
|
<Toolbar
|
||||||
|
editor={editor}
|
||||||
|
onVoiceCheck={onVoiceCheck}
|
||||||
|
voicing={voicing}
|
||||||
|
onCollocationCheck={onCollocationCheck}
|
||||||
|
collocating={collocating}
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
ref={wrapperRef}
|
ref={wrapperRef}
|
||||||
className="relative flex-1"
|
className="relative flex-1"
|
||||||
onMouseOver={handleMouseOver}
|
onMouseOver={handleMouseOver}
|
||||||
onMouseOut={handleMouseOut}
|
onMouseOut={handleMouseOut}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
onClick={handleSpellClick}
|
onClick={handleSpellClick}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={cancelLongPress}
|
||||||
|
onTouchEnd={cancelLongPress}
|
||||||
>
|
>
|
||||||
<EditorContent editor={editor} className="h-full" />
|
<EditorContent editor={editor} className="h-full" />
|
||||||
|
{findOpen && editor && <FindReplace editor={editor} onClose={() => setFindOpen(false)} />}
|
||||||
{confetti && <Confetti top={confetti.top} left={confetti.left} />}
|
{confetti && <Confetti top={confetti.top} left={confetti.left} />}
|
||||||
|
{gloss && <GlossTip gloss={gloss.gloss} style={{ top: gloss.top, left: gloss.left }} />}
|
||||||
|
{selection && !rewrite && !dragging && (
|
||||||
|
<SelectionBubble
|
||||||
|
style={{ top: selection.top, left: selection.left, transform: 'translateY(calc(-100% - 8px))' }}
|
||||||
|
onRewrite={handleRewrite}
|
||||||
|
onSpeak={speechSupported() ? () => speak(selection.text) : null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{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 && (
|
{wordInfo && (
|
||||||
<WordCard
|
<WordCard
|
||||||
word={wordInfo.word}
|
word={wordInfo.word}
|
||||||
info={wordInfo.info}
|
info={wordInfo.info}
|
||||||
loading={wordInfo.loading}
|
loading={wordInfo.loading}
|
||||||
|
saved={wordInfo.saved}
|
||||||
|
onToggleSave={toggleSaveWord}
|
||||||
style={{ top: wordInfo.top, left: wordInfo.left }}
|
style={{ top: wordInfo.top, left: wordInfo.left }}
|
||||||
onReplace={replaceWord}
|
onReplace={replaceWord}
|
||||||
/>
|
/>
|
||||||
@@ -453,7 +1121,7 @@ export function EditorCore({
|
|||||||
onAdd={addMisspellingToDict}
|
onAdd={addMisspellingToDict}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{hover && (
|
{hover && !railEnabled && (
|
||||||
<SuggestionCard
|
<SuggestionCard
|
||||||
suggestion={hover.suggestion}
|
suggestion={hover.suggestion}
|
||||||
style={{ top: hover.top, left: hover.left }}
|
style={{ top: hover.top, left: hover.left }}
|
||||||
@@ -464,6 +1132,18 @@ export function EditorCore({
|
|||||||
onExpandChange={setPinned}
|
onExpandChange={setPinned}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{railEnabled && railItems.length > 0 && (
|
||||||
|
<SuggestionRail
|
||||||
|
items={railItems}
|
||||||
|
activeId={activeId}
|
||||||
|
expandedId={railExpandedId}
|
||||||
|
onAccept={handleAccept}
|
||||||
|
onDismiss={handleDismiss}
|
||||||
|
onHover={setActiveId}
|
||||||
|
onActivate={activateRailCard}
|
||||||
|
onToggleExpand={toggleRailExpand}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
238
web/src/components/Editor/FindReplace.tsx
Normal file
238
web/src/components/Editor/FindReplace.tsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import type { Editor } from '@tiptap/react'
|
||||||
|
import { clearSearch, getSearchState, setActive, setSearch } from './SearchHighlight'
|
||||||
|
|
||||||
|
// FindReplace is the in-document search bar (Ctrl/Cmd+F). It drives the
|
||||||
|
// SearchHighlight decoration layer: typing updates the highlighted matches, the
|
||||||
|
// arrows step through them (scrolling each into view), and replace / replace-all
|
||||||
|
// edit the document in place. Bilingual, zh-first, matching the editor chrome.
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
editor: Editor
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CJK = "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
|
||||||
|
|
||||||
|
// Scroll the active match into the middle of the viewport without moving the
|
||||||
|
// selection (which would otherwise pop the rewrite bubble).
|
||||||
|
function scrollToActive(editor: Editor) {
|
||||||
|
const st = getSearchState(editor.state)
|
||||||
|
if (st.active < 0) return
|
||||||
|
const m = st.matches[st.active]
|
||||||
|
const dom = editor.view.domAtPos(m.from)
|
||||||
|
const el = dom.node.nodeType === Node.TEXT_NODE ? dom.node.parentElement : (dom.node as HTMLElement)
|
||||||
|
el?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FindReplace({ editor, onClose }: Props) {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [replacement, setReplacement] = useState('')
|
||||||
|
const [caseSensitive, setCaseSensitive] = useState(false)
|
||||||
|
const [showReplace, setShowReplace] = useState(false)
|
||||||
|
// Mirror of the plugin's match count + active index for the "n / m" readout.
|
||||||
|
const [{ count, active }, setCounts] = useState({ count: 0, active: -1 })
|
||||||
|
const findRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const sync = useCallback(() => {
|
||||||
|
const st = getSearchState(editor.state)
|
||||||
|
setCounts({ count: st.matches.length, active: st.active })
|
||||||
|
}, [editor])
|
||||||
|
|
||||||
|
// Push the query into the decoration layer whenever it (or case-sensitivity)
|
||||||
|
// changes, then jump to the first match.
|
||||||
|
useEffect(() => {
|
||||||
|
setSearch(editor.state, editor.view.dispatch, query, caseSensitive)
|
||||||
|
sync()
|
||||||
|
scrollToActive(editor)
|
||||||
|
}, [editor, query, caseSensitive, sync])
|
||||||
|
|
||||||
|
// Tear the highlight layer down when the bar closes.
|
||||||
|
useEffect(() => () => clearSearch(editor.state, editor.view.dispatch), [editor])
|
||||||
|
|
||||||
|
// Focus the find field on open.
|
||||||
|
useEffect(() => {
|
||||||
|
findRef.current?.focus()
|
||||||
|
findRef.current?.select()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const go = useCallback(
|
||||||
|
(delta: number) => {
|
||||||
|
const st = getSearchState(editor.state)
|
||||||
|
if (!st.matches.length) return
|
||||||
|
setActive(editor.state, editor.view.dispatch, st.active + delta)
|
||||||
|
sync()
|
||||||
|
scrollToActive(editor)
|
||||||
|
},
|
||||||
|
[editor, sync],
|
||||||
|
)
|
||||||
|
|
||||||
|
const replaceActive = useCallback(() => {
|
||||||
|
const st = getSearchState(editor.state)
|
||||||
|
if (st.active < 0) return
|
||||||
|
const m = st.matches[st.active]
|
||||||
|
const tr = editor.state.tr
|
||||||
|
if (replacement) tr.insertText(replacement, m.from, m.to)
|
||||||
|
else tr.delete(m.from, m.to)
|
||||||
|
editor.view.dispatch(tr)
|
||||||
|
sync()
|
||||||
|
scrollToActive(editor)
|
||||||
|
}, [editor, replacement, sync])
|
||||||
|
|
||||||
|
const replaceAll = useCallback(() => {
|
||||||
|
const st = getSearchState(editor.state)
|
||||||
|
if (!st.matches.length) return
|
||||||
|
const tr = editor.state.tr
|
||||||
|
// Apply back-to-front so earlier edits don't shift later match positions.
|
||||||
|
for (let i = st.matches.length - 1; i >= 0; i--) {
|
||||||
|
const m = st.matches[i]
|
||||||
|
if (replacement) tr.insertText(replacement, m.from, m.to)
|
||||||
|
else tr.delete(m.from, m.to)
|
||||||
|
}
|
||||||
|
editor.view.dispatch(tr)
|
||||||
|
sync()
|
||||||
|
}, [editor, replacement, sync])
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
borderRadius: 'var(--radius-input)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
color: 'var(--color-plum)',
|
||||||
|
fontFamily: CJK,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="petal-no-print absolute right-2 top-2 z-40 flex flex-col gap-1.5 p-2"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Find and replace"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: 320,
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-card)',
|
||||||
|
boxShadow: 'var(--shadow-soft)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowReplace((v) => !v)}
|
||||||
|
aria-label={showReplace ? 'Hide replace' : 'Show replace'}
|
||||||
|
title={showReplace ? 'Hide replace' : 'Show replace'}
|
||||||
|
className="flex h-8 w-6 items-center justify-center text-xs"
|
||||||
|
style={{ color: 'var(--color-muted)' }}
|
||||||
|
>
|
||||||
|
{showReplace ? '▾' : '▸'}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={findRef}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
go(e.shiftKey ? -1 : 1)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="查找 · Find"
|
||||||
|
className="min-w-0 flex-1 px-2 py-1.5 text-sm focus:outline-none"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="w-14 shrink-0 text-center text-xs tabular-nums"
|
||||||
|
style={{ color: 'var(--color-muted)' }}
|
||||||
|
>
|
||||||
|
{count ? `${active + 1} / ${count}` : query ? '无 · 0' : ''}
|
||||||
|
</span>
|
||||||
|
<FindBtn label="Previous match" disabled={!count} onClick={() => go(-1)}>↑</FindBtn>
|
||||||
|
<FindBtn label="Next match" disabled={!count} onClick={() => go(1)}>↓</FindBtn>
|
||||||
|
<FindBtn
|
||||||
|
label="Match case"
|
||||||
|
active={caseSensitive}
|
||||||
|
onClick={() => setCaseSensitive((v) => !v)}
|
||||||
|
title="Match case · 区分大小写"
|
||||||
|
>
|
||||||
|
Aa
|
||||||
|
</FindBtn>
|
||||||
|
<FindBtn label="Close" onClick={onClose} title="Close · 关闭">✕</FindBtn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showReplace && (
|
||||||
|
<div className="flex items-center gap-1.5 pl-7">
|
||||||
|
<input
|
||||||
|
value={replacement}
|
||||||
|
onChange={(e) => setReplacement(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
replaceActive()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="替换为 · Replace"
|
||||||
|
className="min-w-0 flex-1 px-2 py-1.5 text-sm focus:outline-none"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={replaceActive}
|
||||||
|
disabled={!count}
|
||||||
|
className="h-8 shrink-0 whitespace-nowrap px-2.5 text-xs font-semibold disabled:opacity-40"
|
||||||
|
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
||||||
|
>
|
||||||
|
替换
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={replaceAll}
|
||||||
|
disabled={!count}
|
||||||
|
className="h-8 shrink-0 whitespace-nowrap px-2.5 text-xs font-bold disabled:opacity-40"
|
||||||
|
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
全部
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FindBtn({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
label,
|
||||||
|
title,
|
||||||
|
active,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
onClick: () => void
|
||||||
|
label: string
|
||||||
|
title?: string
|
||||||
|
active?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
title={title ?? label}
|
||||||
|
aria-pressed={active}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
className="flex h-8 min-w-8 items-center justify-center px-1.5 text-sm font-semibold disabled:opacity-40"
|
||||||
|
style={{
|
||||||
|
borderRadius: 'var(--radius-input)',
|
||||||
|
color: active ? 'var(--color-accent-hover)' : 'var(--color-muted)',
|
||||||
|
background: active ? 'var(--color-surface-alt)' : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
51
web/src/components/Editor/FontSize.ts
Normal file
51
web/src/components/Editor/FontSize.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Extension } from '@tiptap/core'
|
||||||
|
|
||||||
|
// FontSize adds a `fontSize` attribute to the textStyle mark so a writer can
|
||||||
|
// pick a size preset (Small / Normal / Large / Title) from the toolbar. It rides
|
||||||
|
// on TextStyle (already loaded) rather than introducing a new mark, so it stacks
|
||||||
|
// cleanly with color and other inline styling.
|
||||||
|
declare module '@tiptap/core' {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
fontSize: {
|
||||||
|
setFontSize: (size: string) => ReturnType
|
||||||
|
unsetFontSize: () => ReturnType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FontSize = Extension.create({
|
||||||
|
name: 'fontSize',
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return { types: ['textStyle'] }
|
||||||
|
},
|
||||||
|
|
||||||
|
addGlobalAttributes() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
types: this.options.types,
|
||||||
|
attributes: {
|
||||||
|
fontSize: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => element.style.fontSize || null,
|
||||||
|
renderHTML: (attributes) =>
|
||||||
|
attributes.fontSize ? { style: `font-size: ${attributes.fontSize}` } : {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
setFontSize:
|
||||||
|
(size) =>
|
||||||
|
({ chain }) =>
|
||||||
|
chain().setMark('textStyle', { fontSize: size }).run(),
|
||||||
|
unsetFontSize:
|
||||||
|
() =>
|
||||||
|
({ chain }) =>
|
||||||
|
chain().setMark('textStyle', { fontSize: null }).removeEmptyTextStyle().run(),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
143
web/src/components/Editor/SearchHighlight.ts
Normal file
143
web/src/components/Editor/SearchHighlight.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { Extension } from '@tiptap/core'
|
||||||
|
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||||
|
import type { EditorState, Transaction } from '@tiptap/pm/state'
|
||||||
|
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||||
|
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||||
|
import { mapOffset } from './SuggestionHighlight'
|
||||||
|
|
||||||
|
// SearchHighlight powers the in-document Find & Replace bar. Like the suggestion
|
||||||
|
// layer it uses ProseMirror *decorations* (not stored marks), so matches are
|
||||||
|
// recomputed from the live document on every edit and never leave a stale
|
||||||
|
// highlight behind. Matches are found per-textblock (a query never spans a
|
||||||
|
// paragraph break), mirroring how the rest of the editor anchors text.
|
||||||
|
|
||||||
|
export interface Match {
|
||||||
|
from: number
|
||||||
|
to: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginState {
|
||||||
|
query: string
|
||||||
|
caseSensitive: boolean
|
||||||
|
matches: Match[]
|
||||||
|
active: number // index into matches, or -1 when there are none
|
||||||
|
decorations: DecorationSet
|
||||||
|
}
|
||||||
|
|
||||||
|
export const searchPluginKey = new PluginKey<PluginState>('petalSearch')
|
||||||
|
|
||||||
|
// findMatches collects every occurrence of `query` across the document's
|
||||||
|
// textblocks and maps each to an absolute ProseMirror range.
|
||||||
|
function findMatches(doc: PMNode, query: string, caseSensitive: boolean): Match[] {
|
||||||
|
if (!query) return []
|
||||||
|
const needle = caseSensitive ? query : query.toLowerCase()
|
||||||
|
const matches: Match[] = []
|
||||||
|
doc.descendants((node, pos) => {
|
||||||
|
if (!node.isTextblock) return true
|
||||||
|
const haystackRaw = node.textContent
|
||||||
|
const haystack = caseSensitive ? haystackRaw : haystackRaw.toLowerCase()
|
||||||
|
let idx = haystack.indexOf(needle)
|
||||||
|
while (idx >= 0) {
|
||||||
|
matches.push({
|
||||||
|
from: mapOffset(node, pos, idx),
|
||||||
|
to: mapOffset(node, pos, idx + query.length),
|
||||||
|
})
|
||||||
|
idx = haystack.indexOf(needle, idx + query.length)
|
||||||
|
}
|
||||||
|
return false // don't descend into inline children
|
||||||
|
})
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(doc: PMNode, query: string, caseSensitive: boolean, preferred: number): PluginState {
|
||||||
|
const matches = findMatches(doc, query, caseSensitive)
|
||||||
|
const active = matches.length === 0 ? -1 : Math.max(0, Math.min(preferred, matches.length - 1))
|
||||||
|
const decos = matches.map((m, i) =>
|
||||||
|
Decoration.inline(m.from, m.to, {
|
||||||
|
class: i === active ? 'petal-find-match petal-find-match-active' : 'petal-find-match',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return { query, caseSensitive, matches, active, decorations: DecorationSet.create(doc, decos) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY: PluginState = {
|
||||||
|
query: '',
|
||||||
|
caseSensitive: false,
|
||||||
|
matches: [],
|
||||||
|
active: -1,
|
||||||
|
decorations: DecorationSet.empty,
|
||||||
|
}
|
||||||
|
|
||||||
|
// setSearch updates the query / case-sensitivity and recomputes matches. Passing
|
||||||
|
// an empty query clears the layer.
|
||||||
|
export function setSearch(
|
||||||
|
state: EditorState,
|
||||||
|
dispatch: (tr: Transaction) => void,
|
||||||
|
query: string,
|
||||||
|
caseSensitive: boolean,
|
||||||
|
) {
|
||||||
|
dispatch(state.tr.setMeta(searchPluginKey, { kind: 'search', query, caseSensitive }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// setActive moves the highlighted (current) match by index.
|
||||||
|
export function setActive(state: EditorState, dispatch: (tr: Transaction) => void, index: number) {
|
||||||
|
dispatch(state.tr.setMeta(searchPluginKey, { kind: 'active', index }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearSearch tears the layer down (on close).
|
||||||
|
export function clearSearch(state: EditorState, dispatch: (tr: Transaction) => void) {
|
||||||
|
dispatch(state.tr.setMeta(searchPluginKey, { kind: 'clear' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSearchState reads the live plugin state (match list + active index) so the
|
||||||
|
// Find bar can render "n / m" and drive navigation/replacement.
|
||||||
|
export function getSearchState(state: EditorState): PluginState {
|
||||||
|
return searchPluginKey.getState(state) ?? EMPTY
|
||||||
|
}
|
||||||
|
|
||||||
|
type Meta =
|
||||||
|
| { kind: 'search'; query: string; caseSensitive: boolean }
|
||||||
|
| { kind: 'active'; index: number }
|
||||||
|
| { kind: 'clear' }
|
||||||
|
|
||||||
|
export const SearchHighlight = Extension.create({
|
||||||
|
name: 'searchHighlight',
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
new Plugin<PluginState>({
|
||||||
|
key: searchPluginKey,
|
||||||
|
state: {
|
||||||
|
init: () => EMPTY,
|
||||||
|
apply(tr, value, _oldState, newState): PluginState {
|
||||||
|
const meta = tr.getMeta(searchPluginKey) as Meta | undefined
|
||||||
|
if (meta?.kind === 'search') {
|
||||||
|
return build(newState.doc, meta.query, meta.caseSensitive, value.active < 0 ? 0 : value.active)
|
||||||
|
}
|
||||||
|
if (meta?.kind === 'active') {
|
||||||
|
if (value.matches.length === 0) return value
|
||||||
|
const active = ((meta.index % value.matches.length) + value.matches.length) % value.matches.length
|
||||||
|
const decos = value.matches.map((m, i) =>
|
||||||
|
Decoration.inline(m.from, m.to, {
|
||||||
|
class: i === active ? 'petal-find-match petal-find-match-active' : 'petal-find-match',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return { ...value, active, decorations: DecorationSet.create(newState.doc, decos) }
|
||||||
|
}
|
||||||
|
if (meta?.kind === 'clear') return EMPTY
|
||||||
|
// Re-anchor on any document change so highlights track edits/replaces.
|
||||||
|
if (tr.docChanged && value.query) {
|
||||||
|
return build(newState.doc, value.query, value.caseSensitive, value.active)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
},
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
decorations(state) {
|
||||||
|
return searchPluginKey.getState(state)?.decorations
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
107
web/src/components/Editor/SelectionBubble.tsx
Normal file
107
web/src/components/Editor/SelectionBubble.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
// 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
|
||||||
|
// Read the selected text aloud (null when speech isn't available — the button
|
||||||
|
// is then hidden).
|
||||||
|
onSpeak: (() => void) | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const CJK = "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
|
||||||
|
|
||||||
|
export function SelectionBubble({ style, onRewrite, onSpeak }: 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"
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-pill)',
|
||||||
|
boxShadow: 'var(--shadow-soft)',
|
||||||
|
fontFamily: CJK,
|
||||||
|
// Only the buttons capture the pointer; clicks landing on the bubble's
|
||||||
|
// padding/gaps fall through to the text underneath so it never blocks
|
||||||
|
// where the user is trying to click.
|
||||||
|
pointerEvents: 'none',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => e.preventDefault()} // keep the editor selection
|
||||||
|
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)', pointerEvents: 'auto' }}
|
||||||
|
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>
|
||||||
|
|
||||||
|
{onSpeak && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => e.preventDefault()} // keep the editor selection
|
||||||
|
onClick={onSpeak}
|
||||||
|
className="inline-flex h-8 items-center justify-center px-2 text-sm"
|
||||||
|
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)', color: 'var(--color-plum)', pointerEvents: 'auto' }}
|
||||||
|
title="朗读所选 · Read selection aloud"
|
||||||
|
aria-label="Read selection aloud"
|
||||||
|
>
|
||||||
|
🔊
|
||||||
|
</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"
|
||||||
|
onMouseDown={(e) => e.preventDefault()} // keep the editor selection
|
||||||
|
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)', pointerEvents: 'auto' }}
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,15 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { Suggestion, SuggestionType } from '../../api/client'
|
import type { Suggestion } from '../../api/client'
|
||||||
import { AskPetal } from './AskPetal'
|
import { AskPetal } from './AskPetal'
|
||||||
|
import { TYPE_META } from './suggestionMeta'
|
||||||
// Per-type accent color + human label, mirroring the design tokens.
|
|
||||||
const TYPE_META: Record<SuggestionType, { color: string; label: string }> = {
|
|
||||||
grammar: { color: 'var(--color-mint)', label: 'Grammar' },
|
|
||||||
phrasing: { color: 'var(--color-peach)', label: 'Phrasing' },
|
|
||||||
idiom: { color: 'var(--color-lavender)', label: 'Idiom' },
|
|
||||||
clarity: { color: 'var(--color-sky)', label: 'Clarity' },
|
|
||||||
voice: { color: 'var(--color-honey)', label: 'Voice' },
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
suggestion: Suggestion
|
suggestion: Suggestion
|
||||||
|
|||||||
54
web/src/components/Editor/SuggestionHighlight.test.ts
Normal file
54
web/src/components/Editor/SuggestionHighlight.test.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { foldTypography, findRange } from './SuggestionHighlight'
|
||||||
|
import { Schema, type Node as PMNode } from '@tiptap/pm/model'
|
||||||
|
|
||||||
|
// A minimal doc/paragraph/text schema, enough to exercise findRange's textblock
|
||||||
|
// walk without pulling in the full editor.
|
||||||
|
const schema = new Schema({
|
||||||
|
nodes: {
|
||||||
|
doc: { content: 'block+' },
|
||||||
|
paragraph: { group: 'block', content: 'inline*', toDOM: () => ['p', 0] },
|
||||||
|
text: { group: 'inline' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build a single-paragraph doc with the given plaintext.
|
||||||
|
const para = (text: string): PMNode => schema.node('doc', null, [schema.node('paragraph', null, text ? [schema.text(text)] : [])])
|
||||||
|
|
||||||
|
describe('foldTypography', () => {
|
||||||
|
it('folds curly quotes back to straight ASCII', () => {
|
||||||
|
expect(foldTypography('“He’s here,” she said').folded).toBe('"He\'s here," she said')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('folds dashes and ellipsis, keeping a source-index map across length changes', () => {
|
||||||
|
const { folded, map } = foldTypography('wait—really...')
|
||||||
|
expect(folded).toBe('wait—really…')
|
||||||
|
// The em dash is already one glyph; the `...` collapsed 3 source chars to 1,
|
||||||
|
// so the trailing sentinel still points just past the last source char.
|
||||||
|
expect(map[map.length - 1]).toBe('wait—really...'.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats `--` and `...` typed-but-unconverted as their canonical glyphs', () => {
|
||||||
|
expect(foldTypography('a--b...c').folded).toBe('a—b…c')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('findRange typographic anchoring', () => {
|
||||||
|
it('anchors a model echo with straight quotes onto curly-quote document text', () => {
|
||||||
|
const doc = para('She said “hello” to me')
|
||||||
|
const range = findRange(doc, '“hello”'.replace(/[“”]/g, '"')) // model echoed "hello"
|
||||||
|
expect(range).not.toBeNull()
|
||||||
|
// The matched span must cover the curly-quoted region in the real document.
|
||||||
|
expect(doc.textBetween(range!.from - 1, range!.to - 1)).toContain('hello')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('anchors an ASCII `--` echo onto an em-dash in the document', () => {
|
||||||
|
const doc = para('I waited—forever')
|
||||||
|
const range = findRange(doc, 'waited--forever')
|
||||||
|
expect(range).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still returns null when the text genuinely is not present', () => {
|
||||||
|
expect(findRange(para('nothing to see'), 'absent phrase')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -16,9 +16,17 @@ export const suggestionPluginKey = new PluginKey<PluginState>('petalSuggestions'
|
|||||||
|
|
||||||
interface PluginState {
|
interface PluginState {
|
||||||
suggestions: Suggestion[]
|
suggestions: Suggestion[]
|
||||||
|
// The suggestion currently emphasized from its margin card / a hover, or null.
|
||||||
|
// Carried in plugin state (not an imperative DOM class) so it survives the
|
||||||
|
// decoration repaints that fire on every document change.
|
||||||
|
activeId: string | null
|
||||||
decorations: DecorationSet
|
decorations: DecorationSet
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Meta carried on a transaction to update the plugin: either a fresh suggestion
|
||||||
|
// list or a change to which suggestion is emphasized.
|
||||||
|
type SuggestionMeta = { suggestions: Suggestion[] } | { activeId: string | null }
|
||||||
|
|
||||||
// mapOffset converts a character offset within a textblock's flattened text into
|
// mapOffset converts a character offset within a textblock's flattened text into
|
||||||
// an absolute ProseMirror position, accounting for inline atoms (e.g. hard
|
// an absolute ProseMirror position, accounting for inline atoms (e.g. hard
|
||||||
// breaks) that occupy a position but contribute no text.
|
// breaks) that occupy a position but contribute no text.
|
||||||
@@ -42,21 +50,87 @@ export function mapOffset(block: PMNode, blockPos: number, targetOffset: number)
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// foldTypography canonicalizes the typographic variants that the editor's
|
||||||
|
// Typography input rules introduce (curly quotes, em/en dashes, single-glyph
|
||||||
|
// ellipsis) and the unicode spaces a paste can carry. The model echoes a
|
||||||
|
// suggestion's `original` from the same text but often normalizes these back to
|
||||||
|
// plain ASCII — straight quotes, `--`, `...` — so an exact match would miss and
|
||||||
|
// the suggestion could be neither highlighted nor applied.
|
||||||
|
//
|
||||||
|
// It returns the folded string plus `map`, where map[k] is the source index at
|
||||||
|
// which folded character k begins. A multi-character source run (`...`, `--`)
|
||||||
|
// folds to one glyph, so lengths differ; map projects a match in folded space
|
||||||
|
// back onto real document offsets. map has a trailing sentinel (= source length)
|
||||||
|
// so map[start + folded.length] yields the offset just past the match.
|
||||||
|
export function foldTypography(text: string): { folded: string; map: number[] } {
|
||||||
|
let folded = ''
|
||||||
|
const map: number[] = []
|
||||||
|
let i = 0
|
||||||
|
while (i < text.length) {
|
||||||
|
// Multi-character ASCII sequences the Typography rules would have replaced.
|
||||||
|
if (text.startsWith('...', i)) {
|
||||||
|
folded += '…'
|
||||||
|
map.push(i)
|
||||||
|
i += 3
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (text[i] === '-' && text[i + 1] === '-') {
|
||||||
|
folded += '—'
|
||||||
|
map.push(i)
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
folded += foldChar(text[i])
|
||||||
|
map.push(i)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
map.push(text.length)
|
||||||
|
return { folded, map }
|
||||||
|
}
|
||||||
|
|
||||||
|
// foldChar maps a single character onto its canonical form (length-preserving).
|
||||||
|
function foldChar(c: string): string {
|
||||||
|
switch (c) {
|
||||||
|
case '‘':
|
||||||
|
case '’':
|
||||||
|
case '‚':
|
||||||
|
case '‛':
|
||||||
|
return "'"
|
||||||
|
case '“':
|
||||||
|
case '”':
|
||||||
|
case '„':
|
||||||
|
case '‟':
|
||||||
|
return '"'
|
||||||
|
case '–': // en dash → em dash, the canonical form `--` also folds to
|
||||||
|
return '—'
|
||||||
|
case ' ': // non-breaking space
|
||||||
|
case ' ': // thin space
|
||||||
|
case ' ': // narrow no-break space
|
||||||
|
return ' '
|
||||||
|
default:
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// findRange locates the first occurrence of `search` within a single textblock
|
// findRange locates the first occurrence of `search` within a single textblock
|
||||||
// and returns its ProseMirror range, or null if the string isn't present (the
|
// and returns its ProseMirror range, or null if the string isn't present (the
|
||||||
// user may have edited or removed it since the checkpoint ran). Exported so the
|
// user may have edited or removed it since the checkpoint ran). Matching is done
|
||||||
// accept flow can resolve the same span to replace.
|
// in canonical typographic space (see foldTypography) so curly-quote/dash/ellipsis
|
||||||
|
// differences between the model's echo and the live document don't strand the
|
||||||
|
// anchor. Exported so the accept flow can resolve the same span to replace.
|
||||||
export function findRange(doc: PMNode, search: string): { from: number; to: number } | null {
|
export function findRange(doc: PMNode, search: string): { from: number; to: number } | null {
|
||||||
if (!search) return null
|
const needle = foldTypography(search).folded
|
||||||
|
if (!needle) return null
|
||||||
let result: { from: number; to: number } | null = null
|
let result: { from: number; to: number } | null = null
|
||||||
doc.descendants((node, pos) => {
|
doc.descendants((node, pos) => {
|
||||||
if (result) return false
|
if (result) return false
|
||||||
if (!node.isTextblock) return true // keep descending to the textblock
|
if (!node.isTextblock) return true // keep descending to the textblock
|
||||||
const idx = node.textContent.indexOf(search)
|
const { folded, map } = foldTypography(node.textContent)
|
||||||
|
const idx = folded.indexOf(needle)
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
result = {
|
result = {
|
||||||
from: mapOffset(node, pos, idx),
|
from: mapOffset(node, pos, map[idx]),
|
||||||
to: mapOffset(node, pos, idx + search.length),
|
to: mapOffset(node, pos, map[idx + needle.length]),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false // never descend into a textblock's inline children
|
return false // never descend into a textblock's inline children
|
||||||
@@ -64,14 +138,15 @@ export function findRange(doc: PMNode, search: string): { from: number; to: numb
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDecorations(doc: PMNode, suggestions: Suggestion[]): DecorationSet {
|
function buildDecorations(doc: PMNode, suggestions: Suggestion[], activeId: string | null): DecorationSet {
|
||||||
const decos: Decoration[] = []
|
const decos: Decoration[] = []
|
||||||
for (const s of suggestions) {
|
for (const s of suggestions) {
|
||||||
const range = findRange(doc, s.original)
|
const range = findRange(doc, s.original)
|
||||||
if (!range) continue
|
if (!range) continue
|
||||||
|
const active = s.id === activeId ? ' petal-suggestion-active' : ''
|
||||||
decos.push(
|
decos.push(
|
||||||
Decoration.inline(range.from, range.to, {
|
Decoration.inline(range.from, range.to, {
|
||||||
class: `petal-suggestion petal-suggestion-${s.type}`,
|
class: `petal-suggestion petal-suggestion-${s.type}${active}`,
|
||||||
'data-suggestion-id': s.id,
|
'data-suggestion-id': s.id,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -82,10 +157,22 @@ function buildDecorations(doc: PMNode, suggestions: Suggestion[]): DecorationSet
|
|||||||
// setSuggestions pushes a new suggestion list into the plugin. Decorations are
|
// setSuggestions pushes a new suggestion list into the plugin. Decorations are
|
||||||
// rebuilt against the current document immediately.
|
// rebuilt against the current document immediately.
|
||||||
export function setSuggestions(state: EditorState, dispatch: (tr: Transaction) => void, suggestions: Suggestion[]) {
|
export function setSuggestions(state: EditorState, dispatch: (tr: Transaction) => void, suggestions: Suggestion[]) {
|
||||||
const tr = state.tr.setMeta(suggestionPluginKey, suggestions)
|
const tr = state.tr.setMeta(suggestionPluginKey, { suggestions } satisfies SuggestionMeta)
|
||||||
dispatch(tr)
|
dispatch(tr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setActiveSuggestion marks one suggestion (or none) as emphasized, rebuilding
|
||||||
|
// the decorations so the flagged text gets the active wash. Driven by the margin
|
||||||
|
// rail's hover/selection so the card↔text link reads both ways.
|
||||||
|
export function setActiveSuggestion(
|
||||||
|
state: EditorState,
|
||||||
|
dispatch: (tr: Transaction) => void,
|
||||||
|
activeId: string | null,
|
||||||
|
) {
|
||||||
|
if (suggestionPluginKey.getState(state)?.activeId === activeId) return
|
||||||
|
dispatch(state.tr.setMeta(suggestionPluginKey, { activeId } satisfies SuggestionMeta))
|
||||||
|
}
|
||||||
|
|
||||||
export const SuggestionHighlight = Extension.create({
|
export const SuggestionHighlight = Extension.create({
|
||||||
name: 'suggestionHighlight',
|
name: 'suggestionHighlight',
|
||||||
|
|
||||||
@@ -94,17 +181,29 @@ export const SuggestionHighlight = Extension.create({
|
|||||||
new Plugin<PluginState>({
|
new Plugin<PluginState>({
|
||||||
key: suggestionPluginKey,
|
key: suggestionPluginKey,
|
||||||
state: {
|
state: {
|
||||||
init: () => ({ suggestions: [], decorations: DecorationSet.empty }),
|
init: () => ({ suggestions: [], activeId: null, decorations: DecorationSet.empty }),
|
||||||
apply(tr, value, _oldState, newState) {
|
apply(tr, value, _oldState, newState) {
|
||||||
const meta = tr.getMeta(suggestionPluginKey) as Suggestion[] | undefined
|
const meta = tr.getMeta(suggestionPluginKey) as SuggestionMeta | undefined
|
||||||
if (meta) {
|
if (meta && 'suggestions' in meta) {
|
||||||
return { suggestions: meta, decorations: buildDecorations(newState.doc, meta) }
|
return {
|
||||||
|
suggestions: meta.suggestions,
|
||||||
|
activeId: value.activeId,
|
||||||
|
decorations: buildDecorations(newState.doc, meta.suggestions, value.activeId),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (meta && 'activeId' in meta) {
|
||||||
|
return {
|
||||||
|
suggestions: value.suggestions,
|
||||||
|
activeId: meta.activeId,
|
||||||
|
decorations: buildDecorations(newState.doc, value.suggestions, meta.activeId),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// On any document change, re-anchor by string against the new doc.
|
// On any document change, re-anchor by string against the new doc.
|
||||||
if (tr.docChanged) {
|
if (tr.docChanged) {
|
||||||
return {
|
return {
|
||||||
suggestions: value.suggestions,
|
suggestions: value.suggestions,
|
||||||
decorations: buildDecorations(newState.doc, value.suggestions),
|
activeId: value.activeId,
|
||||||
|
decorations: buildDecorations(newState.doc, value.suggestions, value.activeId),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
|
|||||||
211
web/src/components/Editor/SuggestionRail.tsx
Normal file
211
web/src/components/Editor/SuggestionRail.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import { forwardRef, useLayoutEffect, useRef, useState } from 'react'
|
||||||
|
import type { Suggestion } from '../../api/client'
|
||||||
|
import { AskPetal } from './AskPetal'
|
||||||
|
import { TYPE_META } from './suggestionMeta'
|
||||||
|
|
||||||
|
// Vertical breathing room kept between stacked cards when their natural anchors
|
||||||
|
// would otherwise collide.
|
||||||
|
const CARD_GAP = 12
|
||||||
|
|
||||||
|
export interface RailItem {
|
||||||
|
suggestion: Suggestion
|
||||||
|
// Vertical offset (px) of the suggestion's highlight, relative to the editor
|
||||||
|
// wrapper — the position the card wants to sit beside.
|
||||||
|
anchorTop: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: RailItem[]
|
||||||
|
// The suggestion currently emphasized (its highlight hovered, or this card
|
||||||
|
// hovered) — gets a lifted, ring-accented treatment so the text↔card link reads.
|
||||||
|
activeId: string | null
|
||||||
|
// The card expanded to show the full explanation + Ask Petal, or null.
|
||||||
|
expandedId: string | null
|
||||||
|
onAccept: (s: Suggestion) => void
|
||||||
|
onDismiss: (s: Suggestion) => void
|
||||||
|
// Pointer entering/leaving a card, so the matching highlight can light up.
|
||||||
|
onHover: (id: string | null) => void
|
||||||
|
// A card's body was clicked — scroll its highlight into view and toggle expand.
|
||||||
|
onActivate: (id: string) => void
|
||||||
|
onToggleExpand: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// SuggestionRail is the right-margin "comment column": every outstanding
|
||||||
|
// suggestion as a card, vertically aligned to the text it flags, so the writer
|
||||||
|
// sees the whole queue at once instead of hunting highlight by highlight. Cards
|
||||||
|
// are anchored to their highlight's vertical position and pushed down only as far
|
||||||
|
// as needed to avoid overlapping their neighbor above (a la doc-editor comments).
|
||||||
|
export function SuggestionRail({
|
||||||
|
items,
|
||||||
|
activeId,
|
||||||
|
expandedId,
|
||||||
|
onAccept,
|
||||||
|
onDismiss,
|
||||||
|
onHover,
|
||||||
|
onActivate,
|
||||||
|
onToggleExpand,
|
||||||
|
}: Props) {
|
||||||
|
// Measured resolved tops keyed by suggestion id (after collision avoidance).
|
||||||
|
const [tops, setTops] = useState<Record<string, number>>({})
|
||||||
|
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map())
|
||||||
|
// Bumped whenever a card's own height changes (Ask Petal streaming in, wrapping
|
||||||
|
// text) so the stack re-packs and cards below don't get overlapped.
|
||||||
|
const [measureTick, setMeasureTick] = useState(0)
|
||||||
|
const observerRef = useRef<ResizeObserver | null>(null)
|
||||||
|
if (!observerRef.current && typeof ResizeObserver !== 'undefined') {
|
||||||
|
observerRef.current = new ResizeObserver(() => setMeasureTick((t) => t + 1))
|
||||||
|
}
|
||||||
|
useLayoutEffect(() => () => observerRef.current?.disconnect(), [])
|
||||||
|
|
||||||
|
// Sort by anchor, then greedily stack: each card sits at its anchor unless that
|
||||||
|
// would overlap the previous card, in which case it slides down just enough.
|
||||||
|
// Re-runs whenever the anchors or the expanded card (which changes a height)
|
||||||
|
// shift. Reads live offsetHeight so variable-length explanations pack tightly.
|
||||||
|
const ordered = [...items].sort((a, b) => a.anchorTop - b.anchorTop)
|
||||||
|
const layoutKey = ordered.map((i) => `${i.suggestion.id}:${Math.round(i.anchorTop)}`).join('|')
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
let cursor = -Infinity
|
||||||
|
const next: Record<string, number> = {}
|
||||||
|
for (const { suggestion, anchorTop } of ordered) {
|
||||||
|
const h = cardRefs.current.get(suggestion.id)?.offsetHeight ?? 96
|
||||||
|
const top = Math.max(anchorTop, cursor)
|
||||||
|
next[suggestion.id] = top
|
||||||
|
cursor = top + h + CARD_GAP
|
||||||
|
}
|
||||||
|
setTops((prev) => {
|
||||||
|
const ids = Object.keys(next)
|
||||||
|
if (ids.length === Object.keys(prev).length && ids.every((id) => prev[id] === next[id])) return prev
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
// layoutKey captures anchor positions; expandedId/measureTick capture height
|
||||||
|
// changes (toggling detail, Ask Petal streaming in).
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [layoutKey, expandedId, measureTick])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="petal-rail petal-no-print" aria-label="Suggestions">
|
||||||
|
{ordered.map(({ suggestion }) => (
|
||||||
|
<RailCard
|
||||||
|
key={suggestion.id}
|
||||||
|
ref={(el) => {
|
||||||
|
const prev = cardRefs.current.get(suggestion.id)
|
||||||
|
if (prev && prev !== el) observerRef.current?.unobserve(prev)
|
||||||
|
if (el) {
|
||||||
|
cardRefs.current.set(suggestion.id, el)
|
||||||
|
observerRef.current?.observe(el)
|
||||||
|
} else {
|
||||||
|
cardRefs.current.delete(suggestion.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
suggestion={suggestion}
|
||||||
|
top={tops[suggestion.id] ?? 0}
|
||||||
|
active={activeId === suggestion.id}
|
||||||
|
expanded={expandedId === suggestion.id}
|
||||||
|
onAccept={onAccept}
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
onHover={onHover}
|
||||||
|
onActivate={onActivate}
|
||||||
|
onToggleExpand={onToggleExpand}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
suggestion: Suggestion
|
||||||
|
top: number
|
||||||
|
active: boolean
|
||||||
|
expanded: boolean
|
||||||
|
onAccept: (s: Suggestion) => void
|
||||||
|
onDismiss: (s: Suggestion) => void
|
||||||
|
onHover: (id: string | null) => void
|
||||||
|
onActivate: (id: string) => void
|
||||||
|
onToggleExpand: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const RailCard = forwardRef<HTMLDivElement, CardProps>(function RailCard(
|
||||||
|
{ suggestion, top, active, expanded, onAccept, onDismiss, onHover, onActivate, onToggleExpand },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const meta = TYPE_META[suggestion.type]
|
||||||
|
const hasReplacement = suggestion.replacement.trim() !== ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="group"
|
||||||
|
aria-label={`${meta.label} suggestion`}
|
||||||
|
onMouseEnter={() => onHover(suggestion.id)}
|
||||||
|
onMouseLeave={() => onHover(null)}
|
||||||
|
className={`petal-rail-card${active ? ' petal-rail-card-active' : ''}`}
|
||||||
|
style={{ top, borderLeftColor: meta.color }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center rounded-full px-2 py-0.5 text-[0.68rem] font-bold"
|
||||||
|
style={{ background: meta.color, color: 'var(--color-plum)' }}
|
||||||
|
>
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Dismiss suggestion"
|
||||||
|
onClick={() => onDismiss(suggestion)}
|
||||||
|
className="petal-rail-x"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The body toggles the expanded explanation + Ask Petal and points the
|
||||||
|
editor at the flagged text. */}
|
||||||
|
<button type="button" onClick={() => onActivate(suggestion.id)} className="mt-2 block w-full text-left">
|
||||||
|
{hasReplacement && (
|
||||||
|
<span className="flex flex-col gap-0.5" style={{ fontFamily: 'var(--font-body)' }}>
|
||||||
|
<span className="truncate text-[0.9rem] line-through" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
{suggestion.original}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-[0.9rem] font-medium" style={{ color: 'var(--color-plum)' }}>
|
||||||
|
{suggestion.replacement}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`mt-1.5 block text-[0.82rem] leading-snug${expanded ? '' : ' line-clamp-2'}`}
|
||||||
|
style={{ color: 'var(--color-plum)' }}
|
||||||
|
>
|
||||||
|
{suggestion.explanation}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded && <AskPetal suggestionId={suggestion.id} explanation={suggestion.explanation} />}
|
||||||
|
|
||||||
|
<div className="mt-2.5 flex items-center gap-2">
|
||||||
|
{hasReplacement && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAccept(suggestion)}
|
||||||
|
className="rounded-full px-3 py-1 text-xs font-bold text-white"
|
||||||
|
style={{ background: 'var(--color-accent)' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||||
|
>
|
||||||
|
Accept
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggleExpand(suggestion.id)}
|
||||||
|
className="rounded-full px-2.5 py-1 text-xs font-bold transition-colors"
|
||||||
|
style={{
|
||||||
|
background: expanded ? 'var(--color-surface-alt)' : 'transparent',
|
||||||
|
color: 'var(--color-accent-hover)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expanded ? 'Hide Petal' : 'Ask Petal ✨'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
29
web/src/components/Editor/Typography.ts
Normal file
29
web/src/components/Editor/Typography.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Extension, textInputRule } from '@tiptap/core'
|
||||||
|
|
||||||
|
// Typography adds the small print-shop niceties as you type: curly quotes,
|
||||||
|
// em-dashes, and a single-character ellipsis. Implemented as input rules (no
|
||||||
|
// dependency, offline-friendly — matching FontSize.ts's hand-rolled approach),
|
||||||
|
// and every one is plain Undo-able if it ever fires when you didn't want it.
|
||||||
|
//
|
||||||
|
// CJK is untouched: these rules only rewrite ASCII quotes/dashes/dots, never the
|
||||||
|
// fullwidth forms a Mandarin sentence uses.
|
||||||
|
export const Typography = Extension.create({
|
||||||
|
name: 'petalTypography',
|
||||||
|
|
||||||
|
addInputRules() {
|
||||||
|
return [
|
||||||
|
// “ opening double quote — after start, whitespace, or an opening bracket.
|
||||||
|
textInputRule({ find: /(?:^|[\s{[(<'"‘“])(")$/, replace: '“' }),
|
||||||
|
// ” closing double quote — any other time you type a ".
|
||||||
|
textInputRule({ find: /"$/, replace: '”' }),
|
||||||
|
// ‘ opening single quote.
|
||||||
|
textInputRule({ find: /(?:^|[\s{[(<'"‘“])(')$/, replace: '‘' }),
|
||||||
|
// ’ closing single quote / apostrophe.
|
||||||
|
textInputRule({ find: /'$/, replace: '’' }),
|
||||||
|
// — em dash from a double hyphen.
|
||||||
|
textInputRule({ find: /--$/, replace: '—' }),
|
||||||
|
// … ellipsis from three dots.
|
||||||
|
textInputRule({ find: /\.\.\.$/, replace: '…' }),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { WordInfo } from '../../api/client'
|
import type { WordInfo } from '../../api/client'
|
||||||
|
import { speak, speechSupported } from '../../audio/speech'
|
||||||
|
|
||||||
// WordCard is the right-click popover for any word: its dictionary definition(s)
|
// WordCard is the right-click popover for any word: its dictionary definition(s)
|
||||||
// on top and tappable synonym pills below. Clicking a synonym replaces the word
|
// on top and tappable synonym pills below. Clicking a synonym replaces the word
|
||||||
@@ -10,14 +11,20 @@ interface Props {
|
|||||||
word: string
|
word: string
|
||||||
info: WordInfo | null
|
info: WordInfo | null
|
||||||
loading: boolean
|
loading: boolean
|
||||||
|
// Whether the word is in the vocabulary garden (auto-saved on lookup). The
|
||||||
|
// heart toggles it; `onToggleSave` removes/re-adds it.
|
||||||
|
saved: boolean
|
||||||
|
onToggleSave: () => void
|
||||||
style: React.CSSProperties
|
style: React.CSSProperties
|
||||||
onReplace: (synonym: string) => void
|
onReplace: (synonym: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WordCard({ word, info, loading, style, onReplace }: Props) {
|
export function WordCard({ word, info, loading, saved, onToggleSave, style, onReplace }: Props) {
|
||||||
const definitions = info?.definitions ?? []
|
const definitions = info?.definitions ?? []
|
||||||
const synonyms = info?.synonyms ?? []
|
const synonyms = info?.synonyms ?? []
|
||||||
const empty = !loading && definitions.length === 0 && synonyms.length === 0
|
const gloss = info?.gloss ?? ''
|
||||||
|
const phonetic = info?.phonetic ?? ''
|
||||||
|
const empty = !loading && !gloss && definitions.length === 0 && synonyms.length === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -45,7 +52,57 @@ export function WordCard({ word, info, loading, style, onReplace }: Props) {
|
|||||||
<span className="font-bold" style={{ color: 'var(--color-plum)' }}>
|
<span className="font-bold" style={{ color: 'var(--color-plum)' }}>
|
||||||
{word}
|
{word}
|
||||||
</span>
|
</span>
|
||||||
|
<div className="ml-auto flex items-center gap-1.5">
|
||||||
|
{!empty && !loading && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleSave}
|
||||||
|
aria-label={saved ? 'Remove from vocabulary garden' : 'Save to vocabulary garden'}
|
||||||
|
aria-pressed={saved}
|
||||||
|
title={saved ? '已在词汇花园 · In your garden (tap to remove)' : '加入词汇花园 · Save to garden'}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-full text-sm transition-transform"
|
||||||
|
style={{
|
||||||
|
background: saved ? 'var(--color-accent)' : 'var(--color-surface-alt)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saved ? '💚' : '🤍'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{speechSupported() && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => speak(word)}
|
||||||
|
aria-label={`Pronounce ${word}`}
|
||||||
|
title="朗读 · Read aloud"
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-full text-sm"
|
||||||
|
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
||||||
|
>
|
||||||
|
🔊
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* How to say it — the pronunciation aid for an English learner, paired
|
||||||
|
with the 🔊 button above. */}
|
||||||
|
{phonetic && (
|
||||||
|
<p className="mt-1.5 text-sm" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
/{phonetic}/
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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 && (
|
{loading && (
|
||||||
<div className="mt-3 inline-flex items-center gap-1.5" style={{ color: 'var(--color-muted)' }}>
|
<div className="mt-3 inline-flex items-center gap-1.5" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
|||||||
14
web/src/components/Editor/suggestionMeta.ts
Normal file
14
web/src/components/Editor/suggestionMeta.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { SuggestionType } from '../../api/client'
|
||||||
|
|
||||||
|
// Per-type accent color + human label, mirroring the design tokens. Shared by the
|
||||||
|
// inline hover SuggestionCard and the margin SuggestionRail so a given suggestion
|
||||||
|
// type reads the same color/name wherever it surfaces.
|
||||||
|
export const TYPE_META: Record<SuggestionType, { color: string; label: string }> = {
|
||||||
|
grammar: { color: 'var(--color-mint)', label: 'Grammar' },
|
||||||
|
phrasing: { color: 'var(--color-peach)', label: 'Phrasing' },
|
||||||
|
idiom: { color: 'var(--color-lavender)', label: 'Idiom' },
|
||||||
|
clarity: { color: 'var(--color-sky)', label: 'Clarity' },
|
||||||
|
voice: { color: 'var(--color-honey)', label: 'Voice' },
|
||||||
|
collocation: { color: 'var(--color-blossom)', label: 'Word pairing' },
|
||||||
|
mechanics: { color: 'var(--color-sage)', label: 'Tidy-up' },
|
||||||
|
}
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
521
web/src/components/Garden/GardenPanel.tsx
Normal file
521
web/src/components/Garden/GardenPanel.tsx
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { api, type VocabGrade, type VocabWord } from '../../api/client'
|
||||||
|
import { speak, speechSupported, stopSpeech } from '../../audio/speech'
|
||||||
|
import { useFocusTrap } from '../../hooks/useFocusTrap'
|
||||||
|
|
||||||
|
// GardenPanel is the vocabulary garden: every word the writer has looked up,
|
||||||
|
// grown into a blossom that opens further the more she remembers it, plus a
|
||||||
|
// gentle spaced-repetition review. Words are captured automatically on lookup
|
||||||
|
// (zero effort), so the garden fills itself as she writes. The sleepy kitten
|
||||||
|
// naps among the blossoms — the same companion gag, at rest in her little
|
||||||
|
// meadow. Bilingual, zh-first, to match Petal's chrome.
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void
|
||||||
|
// Open the document a word was met in (so "where did I see this?" is one tap).
|
||||||
|
onOpenDoc?: (docId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// blossom maps a word's successful-review count to how bloomed its flower looks:
|
||||||
|
// a fresh seedling opens into a full blossom as it's remembered. No wilting — a
|
||||||
|
// forgotten word just stops climbing, never shames.
|
||||||
|
function blossom(reps: number): string {
|
||||||
|
if (reps <= 0) return '🌱'
|
||||||
|
if (reps <= 2) return '🌿'
|
||||||
|
if (reps <= 4) return '🌷'
|
||||||
|
if (reps <= 6) return '🌸'
|
||||||
|
return '🌺'
|
||||||
|
}
|
||||||
|
|
||||||
|
// blankOut hides the target word in its example sentence with a soft blank, so a
|
||||||
|
// flashcard can quiz recall in context. Whole-word, case-insensitive.
|
||||||
|
function blankOut(sentence: string, word: string): string {
|
||||||
|
if (!sentence) return ''
|
||||||
|
try {
|
||||||
|
const re = new RegExp(`\\b${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'gi')
|
||||||
|
return sentence.replace(re, '____')
|
||||||
|
} catch {
|
||||||
|
return sentence
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GardenPanel({ onClose, onOpenDoc }: Props) {
|
||||||
|
const [words, setWords] = useState<VocabWord[] | null>(null)
|
||||||
|
const [due, setDue] = useState<VocabWord[]>([])
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
// Review session state: the queue (snapshot of due at start), a cursor, and
|
||||||
|
// whether the current card's answer is revealed.
|
||||||
|
const [queue, setQueue] = useState<VocabWord[] | null>(null)
|
||||||
|
const [cursor, setCursor] = useState(0)
|
||||||
|
const [revealed, setRevealed] = useState(false)
|
||||||
|
const [expanded, setExpanded] = useState<string | null>(null)
|
||||||
|
const panelRef = useFocusTrap<HTMLElement>()
|
||||||
|
|
||||||
|
// Read-aloud is fire-and-forget, so a word she tapped could still be speaking
|
||||||
|
// when the panel closes. Cancel any in-flight audio on unmount so it can't
|
||||||
|
// outlive the garden.
|
||||||
|
useEffect(() => stopSpeech, [])
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setError(false)
|
||||||
|
// Settle the two requests independently: a failed /due shouldn't blank the
|
||||||
|
// whole garden when the word list loaded fine. Only listVocab failing is a
|
||||||
|
// true error state; a dueVocab failure just hides the review button.
|
||||||
|
const [allRes, dueRes] = await Promise.allSettled([api.listVocab(), api.dueVocab()])
|
||||||
|
if (allRes.status === 'fulfilled') setWords(allRes.value)
|
||||||
|
else setError(true)
|
||||||
|
if (dueRes.status === 'fulfilled') setDue(dueRes.value)
|
||||||
|
else setDue([])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
// Escape closes the panel (or ends a review session back to the garden).
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (queue) setQueue(null)
|
||||||
|
else onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [onClose, queue])
|
||||||
|
|
||||||
|
const startReview = useCallback(() => {
|
||||||
|
if (due.length === 0) return
|
||||||
|
setQueue(due)
|
||||||
|
setCursor(0)
|
||||||
|
setRevealed(false)
|
||||||
|
}, [due])
|
||||||
|
|
||||||
|
const grade = useCallback(
|
||||||
|
async (g: VocabGrade) => {
|
||||||
|
if (!queue) return
|
||||||
|
const card = queue[cursor]
|
||||||
|
if (card) {
|
||||||
|
try {
|
||||||
|
await api.reviewVocab(card.id, g)
|
||||||
|
} catch {
|
||||||
|
/* keep going — a failed grade just won't reschedule */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const nextCursor = cursor + 1
|
||||||
|
if (nextCursor >= queue.length) {
|
||||||
|
// Session done — refresh the garden and drop back to it.
|
||||||
|
setQueue(null)
|
||||||
|
void load()
|
||||||
|
} else {
|
||||||
|
setCursor(nextCursor)
|
||||||
|
setRevealed(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[queue, cursor, load],
|
||||||
|
)
|
||||||
|
|
||||||
|
const removeWord = useCallback(async (id: string) => {
|
||||||
|
setWords((prev) => (prev ? prev.filter((w) => w.id !== id) : prev))
|
||||||
|
setDue((prev) => prev.filter((w) => w.id !== id))
|
||||||
|
try {
|
||||||
|
await api.deleteVocab(id)
|
||||||
|
} catch {
|
||||||
|
void load()
|
||||||
|
}
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="petal-no-print fixed inset-0 z-40 flex justify-end">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{ background: 'rgba(61, 46, 57, 0.18)' }}
|
||||||
|
onClick={() => (queue ? setQueue(null) : onClose())}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
ref={panelRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="词汇花园 · Vocabulary Garden"
|
||||||
|
tabIndex={-1}
|
||||||
|
className="relative flex h-full w-full max-w-[420px] 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">🌷 词汇花园 · Vocabulary Garden</div>
|
||||||
|
<div className="text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
{queue ? '复习中 · Reviewing — recall, then grade yourself' : 'Words you looked up, blooming as you learn them'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close garden"
|
||||||
|
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>
|
||||||
|
|
||||||
|
{queue ? (
|
||||||
|
<ReviewSession
|
||||||
|
queue={queue}
|
||||||
|
cursor={cursor}
|
||||||
|
revealed={revealed}
|
||||||
|
onReveal={() => setRevealed(true)}
|
||||||
|
onGrade={grade}
|
||||||
|
onQuit={() => setQueue(null)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<GardenView
|
||||||
|
words={words}
|
||||||
|
due={due}
|
||||||
|
error={error}
|
||||||
|
expanded={expanded}
|
||||||
|
onToggleExpand={(id) => setExpanded((cur) => (cur === id ? null : id))}
|
||||||
|
onStartReview={startReview}
|
||||||
|
onRemove={removeWord}
|
||||||
|
onOpenDoc={onOpenDoc}
|
||||||
|
onRetry={load}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- garden grid ------------------------------------------------------------
|
||||||
|
|
||||||
|
function GardenView({
|
||||||
|
words,
|
||||||
|
due,
|
||||||
|
error,
|
||||||
|
expanded,
|
||||||
|
onToggleExpand,
|
||||||
|
onStartReview,
|
||||||
|
onRemove,
|
||||||
|
onOpenDoc,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
words: VocabWord[] | null
|
||||||
|
due: VocabWord[]
|
||||||
|
error: boolean
|
||||||
|
expanded: string | null
|
||||||
|
onToggleExpand: (id: string) => void
|
||||||
|
onStartReview: () => void
|
||||||
|
onRemove: (id: string) => void
|
||||||
|
onOpenDoc?: (docId: string) => void
|
||||||
|
onRetry: () => void
|
||||||
|
}) {
|
||||||
|
// Index the due cards once so the per-word "due" check below is O(1), not a
|
||||||
|
// linear scan of `due` for every word in the garden.
|
||||||
|
const dueIds = useMemo(() => new Set(due.map((d) => d.id)), [due])
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{due.length > 0 && (
|
||||||
|
<div className="px-4 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onStartReview}
|
||||||
|
className="w-full rounded-full py-3 text-sm font-extrabold text-white"
|
||||||
|
style={{ background: 'var(--color-accent)' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||||
|
>
|
||||||
|
复习 {due.length} 个词 · Review {due.length} due 🌸
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
||||||
|
{error ? (
|
||||||
|
<div className="px-2 py-6 text-center text-sm" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
Couldn’t load your garden just now.
|
||||||
|
<button onClick={onRetry} className="ml-1 font-bold text-plum underline">
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : words === null ? (
|
||||||
|
<div className="px-2 py-6 text-center text-sm" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
) : words.length === 0 ? (
|
||||||
|
<div className="px-3 py-10 text-center" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
<div className="mb-2 text-4xl">🌱🐱💤</div>
|
||||||
|
<p className="text-sm leading-relaxed">
|
||||||
|
你的花园还空着。<br />
|
||||||
|
右键点一个英文单词查它的意思——它就会在这里发芽。
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs">
|
||||||
|
Your garden is empty. Look up an English word (right-click it) and it’ll sprout here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1.5">
|
||||||
|
{words.map((w) => {
|
||||||
|
const open = expanded === w.id
|
||||||
|
const isDue = dueIds.has(w.id)
|
||||||
|
return (
|
||||||
|
<li key={w.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggleExpand(w.id)}
|
||||||
|
className="flex w-full items-center gap-2.5 rounded-2xl px-3 py-2.5 text-left"
|
||||||
|
style={{
|
||||||
|
background: open ? 'var(--color-surface-alt)' : 'transparent',
|
||||||
|
border: `1px solid ${open ? 'var(--color-border)' : 'transparent'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-xl" aria-hidden>
|
||||||
|
{blossom(w.reps)}
|
||||||
|
</span>
|
||||||
|
<span className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span className="truncate text-sm font-bold text-plum">{w.word}</span>
|
||||||
|
{(w.gloss || w.definition) && (
|
||||||
|
<span
|
||||||
|
className="truncate text-xs"
|
||||||
|
style={{
|
||||||
|
color: 'var(--color-muted)',
|
||||||
|
fontFamily: w.gloss
|
||||||
|
? "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
|
||||||
|
: 'var(--font-body)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{w.gloss || w.definition}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{isDue && (
|
||||||
|
<span
|
||||||
|
className="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold"
|
||||||
|
style={{ background: 'var(--color-accent)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
待复习 · due
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="mb-1 ml-9 mr-2 mt-1 flex flex-col gap-2 text-xs" style={{ color: 'var(--color-plum)' }}>
|
||||||
|
{w.phonetic && <span style={{ color: 'var(--color-muted)' }}>/{w.phonetic}/</span>}
|
||||||
|
{w.example && (
|
||||||
|
<p className="italic leading-snug" style={{ fontFamily: 'var(--font-body)' }}>
|
||||||
|
“{w.example}”
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="text-[11px]" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
复习 {w.reps} 次 · seen {w.reps}× · 间隔 {w.interval_days}d
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{speechSupported() && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => speak(w.word)}
|
||||||
|
className="rounded-full px-2.5 py-1 text-xs font-semibold"
|
||||||
|
style={{ background: 'var(--color-surface-alt)' }}
|
||||||
|
>
|
||||||
|
🔊 朗读
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{w.doc_id && onOpenDoc && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenDoc(w.doc_id as string)}
|
||||||
|
className="rounded-full px-2.5 py-1 text-xs font-semibold"
|
||||||
|
style={{ background: 'var(--color-surface-alt)' }}
|
||||||
|
>
|
||||||
|
📄 出处 · Source
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemove(w.id)}
|
||||||
|
className="ml-auto rounded-full px-2.5 py-1 text-xs font-semibold"
|
||||||
|
style={{ color: 'var(--color-muted)' }}
|
||||||
|
>
|
||||||
|
🗑 移除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!error && words && words.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="shrink-0 px-4 py-2.5 text-center text-[11px]"
|
||||||
|
style={{ borderTop: '1px solid var(--color-border)', color: 'var(--color-muted)' }}
|
||||||
|
>
|
||||||
|
🐱💤 {words.length} 朵花在花园里 · {words.length} blossom{words.length > 1 ? 's' : ''} growing
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- flashcard review -------------------------------------------------------
|
||||||
|
|
||||||
|
function ReviewSession({
|
||||||
|
queue,
|
||||||
|
cursor,
|
||||||
|
revealed,
|
||||||
|
onReveal,
|
||||||
|
onGrade,
|
||||||
|
onQuit,
|
||||||
|
}: {
|
||||||
|
queue: VocabWord[]
|
||||||
|
cursor: number
|
||||||
|
revealed: boolean
|
||||||
|
onReveal: () => void
|
||||||
|
onGrade: (g: VocabGrade) => void
|
||||||
|
onQuit: () => void
|
||||||
|
}) {
|
||||||
|
const card = queue[cursor]
|
||||||
|
// The meaning shown/asked is the Chinese gloss, or the English definition when
|
||||||
|
// a word has no gloss — so definition-only words are still reviewable.
|
||||||
|
const meaning = card?.gloss || card?.definition || ''
|
||||||
|
// Alternate the quiz direction so she practices both recognition (see the
|
||||||
|
// English, recall the meaning) and production (see the meaning, recall the
|
||||||
|
// English word). Parity of the cursor keeps it deterministic within a session.
|
||||||
|
const production = cursor % 2 === 1 && !!meaning
|
||||||
|
|
||||||
|
const prompt = useMemo(() => {
|
||||||
|
if (!card) return ''
|
||||||
|
if (production) return meaning
|
||||||
|
return card.example ? blankOut(card.example, card.word) : card.word
|
||||||
|
}, [card, production, meaning])
|
||||||
|
|
||||||
|
if (!card) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col px-5 py-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
<span>
|
||||||
|
{cursor + 1} / {queue.length}
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={onQuit} className="font-semibold underline">
|
||||||
|
结束 · End
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The card */}
|
||||||
|
<div
|
||||||
|
className="flex flex-1 flex-col items-center justify-center rounded-3xl px-5 py-8 text-center"
|
||||||
|
style={{ background: 'var(--color-surface-alt)', border: '1px solid var(--color-border)' }}
|
||||||
|
>
|
||||||
|
<div className="mb-2 text-3xl" aria-hidden>
|
||||||
|
{blossom(card.reps)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-xl font-extrabold leading-snug text-plum"
|
||||||
|
style={{
|
||||||
|
fontFamily: production
|
||||||
|
? "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
|
||||||
|
: 'var(--font-body)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{prompt}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
{production ? '这个中文意思的英文单词是?· Which English word?' : '这个词什么意思?· What does this mean?'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{revealed && (
|
||||||
|
<div className="mt-5 w-full border-t pt-4" style={{ borderColor: 'var(--color-border)' }}>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<span className="text-lg font-extrabold text-plum">{card.word}</span>
|
||||||
|
{speechSupported() && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => speak(card.word)}
|
||||||
|
aria-label={`Pronounce ${card.word}`}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-full text-xs"
|
||||||
|
style={{ background: 'var(--color-surface)' }}
|
||||||
|
>
|
||||||
|
🔊
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{card.phonetic && (
|
||||||
|
<div className="mt-0.5 text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
/{card.phonetic}/
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{meaning && (
|
||||||
|
<div
|
||||||
|
className="mt-1 text-sm font-semibold"
|
||||||
|
style={{
|
||||||
|
color: 'var(--color-accent-hover)',
|
||||||
|
// Chinese gloss gets the CJK stack; an English definition fallback
|
||||||
|
// reads better in the body font.
|
||||||
|
fontFamily: card.gloss
|
||||||
|
? "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
|
||||||
|
: 'var(--font-body)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{meaning}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{card.example && (
|
||||||
|
<p className="mt-2 text-xs italic leading-snug" style={{ color: 'var(--color-muted)', fontFamily: 'var(--font-body)' }}>
|
||||||
|
“{card.example}”
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="mt-4">
|
||||||
|
{!revealed ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onReveal}
|
||||||
|
className="w-full rounded-full py-3 text-sm font-extrabold text-white"
|
||||||
|
style={{ background: 'var(--color-accent)' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||||
|
>
|
||||||
|
翻看答案 · Show answer
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<GradeButton color="var(--color-peach)" zh="再来" en="Again" onClick={() => onGrade('again')} />
|
||||||
|
<GradeButton color="var(--color-mint)" zh="记得" en="Good" onClick={() => onGrade('good')} />
|
||||||
|
<GradeButton color="var(--color-honey)" zh="太简单" en="Easy" onClick={() => onGrade('easy')} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GradeButton({ color, zh, en, onClick }: { color: string; zh: string; en: string; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="flex flex-col items-center rounded-2xl py-2.5 text-plum"
|
||||||
|
style={{ background: color }}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-extrabold">{zh}</span>
|
||||||
|
<span className="text-[11px] font-semibold opacity-80">{en}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
237
web/src/components/History/HistoryPanel.tsx
Normal file
237
web/src/components/History/HistoryPanel.tsx
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { api, type Document, type DocumentVersion } from '../../api/client'
|
||||||
|
import { useFocusTrap } from '../../hooks/useFocusTrap'
|
||||||
|
|
||||||
|
// 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 panelRef = useFocusTrap<HTMLElement>()
|
||||||
|
|
||||||
|
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
|
||||||
|
ref={panelRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="历史 · History"
|
||||||
|
tabIndex={-1}
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
web/src/components/StatusBar/PetalsToggle.tsx
Normal file
37
web/src/components/StatusBar/PetalsToggle.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { isPetalsEnabled, onPetalsEnabledChange, setPetalsEnabled } from '../../effects/petals'
|
||||||
|
|
||||||
|
// A tiny toggle for Petal's ambient falling-blossom layer, sitting just left of
|
||||||
|
// the sound toggle in the status bar. Some people find the drifting petals
|
||||||
|
// distracting, so this turns them off entirely. Bilingual tooltip (she reads
|
||||||
|
// Mandarin first), and the choice persists across reloads.
|
||||||
|
export function PetalsToggle() {
|
||||||
|
const [on, setOn] = useState(isPetalsEnabled)
|
||||||
|
|
||||||
|
// Stay in sync if the setting is flipped elsewhere.
|
||||||
|
useEffect(() => onPetalsEnabledChange(setOn), [])
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
const next = !on
|
||||||
|
setPetalsEnabled(next)
|
||||||
|
setOn(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
aria-pressed={on}
|
||||||
|
title={on ? '花瓣开 · Petals on' : '花瓣关 · Petals off'}
|
||||||
|
aria-label={on ? 'Hide falling petals' : 'Show falling petals'}
|
||||||
|
className="flex items-center justify-center rounded-full px-2 py-1 text-xl leading-none transition-colors"
|
||||||
|
style={{ color: on ? 'var(--color-accent)' : 'var(--color-muted)', lineHeight: 1 }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-accent-hover)')}
|
||||||
|
onMouseLeave={(e) =>
|
||||||
|
(e.currentTarget.style.color = on ? 'var(--color-accent)' : 'var(--color-muted)')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span aria-hidden>{on ? '🌸' : '🍃'}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
web/src/components/StatusBar/SoundToggle.tsx
Normal file
37
web/src/components/StatusBar/SoundToggle.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { isSoundEnabled, onSoundEnabledChange, playPop, setSoundEnabled } from '../../audio/sounds'
|
||||||
|
|
||||||
|
// A tiny mute toggle for Petal's cute sounds, tucked at the right of the status
|
||||||
|
// bar. Bilingual tooltip (she reads Mandarin first), and a soft confirming pop
|
||||||
|
// when sounds are turned back on so the choice is audible.
|
||||||
|
export function SoundToggle() {
|
||||||
|
const [on, setOn] = useState(isSoundEnabled)
|
||||||
|
|
||||||
|
// Stay in sync if the setting is flipped elsewhere.
|
||||||
|
useEffect(() => onSoundEnabledChange(setOn), [])
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
const next = !on
|
||||||
|
setSoundEnabled(next)
|
||||||
|
setOn(next)
|
||||||
|
if (next) playPop() // a little hello when re-enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
aria-pressed={on}
|
||||||
|
title={on ? '声音开 · Sounds on' : '声音关 · Sounds off'}
|
||||||
|
aria-label={on ? 'Mute Petal sounds' : 'Unmute Petal sounds'}
|
||||||
|
className="flex items-center justify-center rounded-full px-2 py-1 text-xl leading-none transition-colors"
|
||||||
|
style={{ color: on ? 'var(--color-accent)' : 'var(--color-muted)', lineHeight: 1 }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-accent-hover)')}
|
||||||
|
onMouseLeave={(e) =>
|
||||||
|
(e.currentTarget.style.color = on ? 'var(--color-accent)' : 'var(--color-muted)')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span aria-hidden>{on ? '🔔' : '🔕'}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { Fragment, useEffect, useRef, useState } from 'react'
|
||||||
import type { SaveStatus } from '../../hooks/useAutoSave'
|
import type { SaveStatus } from '../../hooks/useAutoSave'
|
||||||
import { StatsPanel } from './StatsPanel'
|
import { StatsPanel } from './StatsPanel'
|
||||||
|
import { PetalsToggle } from './PetalsToggle'
|
||||||
|
import { SoundToggle } from './SoundToggle'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
wordCount: number
|
wordCount: number
|
||||||
@@ -11,6 +13,11 @@ interface Props {
|
|||||||
checking: boolean
|
checking: boolean
|
||||||
// True while a whole-document voice pass runs — shows a breathing honey dot.
|
// True while a whole-document voice pass runs — shows a breathing honey dot.
|
||||||
voicing: boolean
|
voicing: boolean
|
||||||
|
// True while a collocation pass runs — shows a breathing blossom dot.
|
||||||
|
collocating: boolean
|
||||||
|
// True when Petal can't reach its LLM helper — shows a gentle, reassuring note
|
||||||
|
// (the writing still saves locally, so this is awareness, not an error).
|
||||||
|
llmDown: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const SAVE_LABEL: Record<SaveStatus, string> = {
|
const SAVE_LABEL: Record<SaveStatus, string> = {
|
||||||
@@ -24,8 +31,40 @@ const SAVE_LABEL: Record<SaveStatus, string> = {
|
|||||||
// StatusBar is the slim footer: word count on the left, save state and the
|
// StatusBar is the slim footer: word count on the left, save state and the
|
||||||
// grammar-checkpoint indicator on the right. The checkpoint dot is a soft rose
|
// grammar-checkpoint indicator on the right. The checkpoint dot is a soft rose
|
||||||
// circle that breathes while a check is in flight (spec → Signature animations).
|
// circle that breathes while a check is in flight (spec → Signature animations).
|
||||||
export function StatusBar({ wordCount, text, saveStatus, checking, voicing }: Props) {
|
// The live "Petal is working" indicators. Each is a breathing dot + label shown
|
||||||
|
// while its pass is in flight; driving them from one array keeps the markup (and
|
||||||
|
// the "nothing in flight" check below) in lockstep as passes are added.
|
||||||
|
interface Indicator {
|
||||||
|
active: boolean
|
||||||
|
color: string
|
||||||
|
title: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBar({ wordCount, text, saveStatus, checking, voicing, collocating, llmDown }: Props) {
|
||||||
const label = SAVE_LABEL[saveStatus]
|
const label = SAVE_LABEL[saveStatus]
|
||||||
|
|
||||||
|
const indicators: Indicator[] = [
|
||||||
|
{
|
||||||
|
active: checking,
|
||||||
|
color: 'var(--color-accent)',
|
||||||
|
title: 'Petal is reading your writing…',
|
||||||
|
label: 'Checking…',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
active: voicing,
|
||||||
|
color: 'var(--color-honey)',
|
||||||
|
title: 'Petal is reading your voice…',
|
||||||
|
label: 'Reading your voice…',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
active: collocating,
|
||||||
|
color: 'var(--color-blossom)',
|
||||||
|
title: 'Petal is looking for more natural word pairings…',
|
||||||
|
label: 'Finding natural phrasing…',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const anyBusy = indicators.some((i) => i.active)
|
||||||
// The expanded stats panel toggles open when the word count is clicked.
|
// The expanded stats panel toggles open when the word count is clicked.
|
||||||
const [statsOpen, setStatsOpen] = useState(false)
|
const [statsOpen, setStatsOpen] = useState(false)
|
||||||
const statsRef = useRef<HTMLDivElement>(null)
|
const statsRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -41,7 +80,7 @@ export function StatusBar({ wordCount, text, saveStatus, checking, voicing }: Pr
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<footer
|
<footer
|
||||||
className="flex h-9 shrink-0 items-center gap-3 px-6 text-xs"
|
className="flex h-11 shrink-0 items-center gap-3 px-6 text-sm"
|
||||||
style={{ borderTop: '1px solid var(--color-border)', color: 'var(--color-muted)' }}
|
style={{ borderTop: '1px solid var(--color-border)', color: 'var(--color-muted)' }}
|
||||||
>
|
>
|
||||||
<div className="relative" ref={statsRef}>
|
<div className="relative" ref={statsRef}>
|
||||||
@@ -50,7 +89,7 @@ export function StatusBar({ wordCount, text, saveStatus, checking, voicing }: Pr
|
|||||||
onClick={() => setStatsOpen((o) => !o)}
|
onClick={() => setStatsOpen((o) => !o)}
|
||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
aria-expanded={statsOpen}
|
aria-expanded={statsOpen}
|
||||||
className="rounded-full px-1.5 py-0.5 font-semibold transition-colors"
|
className="rounded-full px-2 py-0.5 text-[0.95rem] font-bold transition-colors"
|
||||||
style={{ color: statsOpen ? 'var(--color-accent-hover)' : 'inherit' }}
|
style={{ color: statsOpen ? 'var(--color-accent-hover)' : 'inherit' }}
|
||||||
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-accent-hover)')}
|
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-accent-hover)')}
|
||||||
onMouseLeave={(e) =>
|
onMouseLeave={(e) =>
|
||||||
@@ -62,27 +101,31 @@ export function StatusBar({ wordCount, text, saveStatus, checking, voicing }: Pr
|
|||||||
</button>
|
</button>
|
||||||
{statsOpen && <StatsPanel text={text} wordCount={wordCount} />}
|
{statsOpen && <StatsPanel text={text} wordCount={wordCount} />}
|
||||||
</div>
|
</div>
|
||||||
{checking && (
|
{indicators
|
||||||
<>
|
.filter((i) => i.active)
|
||||||
|
.map((i) => (
|
||||||
|
<Fragment key={i.label}>
|
||||||
<span aria-hidden>·</span>
|
<span aria-hidden>·</span>
|
||||||
<span className="inline-flex items-center gap-1.5" title="Petal is reading your writing…">
|
<span className="inline-flex items-center gap-1.5" title={i.title}>
|
||||||
<span
|
<span
|
||||||
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
|
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
|
||||||
style={{ background: 'var(--color-accent)' }}
|
style={{ background: i.color }}
|
||||||
/>
|
/>
|
||||||
Checking…
|
{i.label}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</Fragment>
|
||||||
)}
|
))}
|
||||||
{voicing && (
|
{llmDown && !anyBusy && (
|
||||||
<>
|
<>
|
||||||
<span aria-hidden>·</span>
|
<span aria-hidden>·</span>
|
||||||
<span className="inline-flex items-center gap-1.5" title="Petal is reading your voice…">
|
|
||||||
<span
|
<span
|
||||||
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
|
className="inline-flex items-center gap-1.5"
|
||||||
style={{ background: 'var(--color-honey)' }}
|
title="Petal can't reach its writing helper right now — your text is still saved."
|
||||||
/>
|
style={{ color: 'var(--color-honey)' }}
|
||||||
Reading your voice…
|
>
|
||||||
|
<span aria-hidden>🌙</span>
|
||||||
|
<span>小助手在休息</span>
|
||||||
|
<span style={{ opacity: 0.75 }}>· Petal's helper is resting · 文字已保存</span>
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -103,6 +146,10 @@ export function StatusBar({ wordCount, text, saveStatus, checking, voicing }: Pr
|
|||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<div className="ml-auto flex items-center">
|
||||||
|
<PetalsToggle />
|
||||||
|
<SoundToggle />
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user