diff --git a/BUILD_PLAN.md b/BUILD_PLAN.md index 9592c8b..e2d4c2d 100644 --- a/BUILD_PLAN.md +++ b/BUILD_PLAN.md @@ -95,6 +95,16 @@ Multi-session build. **Source of truth for what's done and what's next.** Update - 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}`. + ### Deferred (post-v1-local) - [ ] Authentik OIDC auth + session middleware ← **on hold: user doing foundational work first** - [ ] Copyleaks Tier-2 + webhook HMAC @@ -105,6 +115,7 @@ Multi-session build. **Source of truth for what's done and what's next.** Update - [x] **Phase 10 — organization & polish**: cross-doc search, tags, tablet/touch polish, warm LLM-down failure states. ✅ (see Phase 10 above; "tags only" chosen over folders, FTS5 over LIKE) ## Session log +- 2026-06-26: **Phase 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.** diff --git a/internal/docs/export.go b/internal/docs/export.go index fd4622e..a9c34e9 100644 --- a/internal/docs/export.go +++ b/internal/docs/export.go @@ -9,15 +9,101 @@ import ( "fmt" "net/http" "strings" + "time" "github.com/go-chi/chi/v5" "gitea.parodia.dev/drwily/petal/internal/db" ) -// exportRoutes registers the download endpoint: GET /api/docs/{id}/export?format=md|html|txt|docx +// 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 { + 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 { + serverError(w, err) + return + } + body, err := format.render(doc) + if err != nil { + 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 { + serverError(w, err) + return + } + if _, err := f.Write(body); err != nil { + serverError(w, err) + return + } + } + if err := rows.Err(); err != nil { + serverError(w, err) + return + } + if err := zw.Close(); err != nil { + 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 diff --git a/internal/docs/export_test.go b/internal/docs/export_test.go index 25fa459..2aec8b7 100644 --- a/internal/docs/export_test.go +++ b/internal/docs/export_test.go @@ -61,6 +61,45 @@ func TestExportMarkdown(t *testing.T) { } } +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) diff --git a/internal/lexicon/data.go b/internal/lexicon/data.go index 3856d97..087415d 100644 --- a/internal/lexicon/data.go +++ b/internal/lexicon/data.go @@ -27,3 +27,12 @@ var synonymsGz []byte // //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 diff --git a/internal/lexicon/data/phonetic.json.gz b/internal/lexicon/data/phonetic.json.gz new file mode 100644 index 0000000..9877700 Binary files /dev/null and b/internal/lexicon/data/phonetic.json.gz differ diff --git a/internal/lexicon/lexicon.go b/internal/lexicon/lexicon.go index a6b4cab..2cf52c2 100644 --- a/internal/lexicon/lexicon.go +++ b/internal/lexicon/lexicon.go @@ -26,6 +26,7 @@ type Meaning struct { type Result struct { Word string `json:"word"` Gloss string `json:"gloss"` + Phonetic string `json:"phonetic"` // IPA for the English word; "" when absent Definitions []Meaning `json:"definitions"` Synonyms []string `json:"synonyms"` } @@ -56,6 +57,7 @@ type Lexicon struct { defs map[string][][]string // word → [[pos, def, example], …] 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. @@ -75,6 +77,10 @@ func (l *Lexicon) load() { 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 + } }) } @@ -95,6 +101,9 @@ func (l *Lexicon) Lookup(word string) (Result, error) { } 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 { for _, m := range raw { diff --git a/scripts/build_phonetic.py b/scripts/build_phonetic.py new file mode 100644 index 0000000..8a27be5 --- /dev/null +++ b/scripts/build_phonetic.py @@ -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() diff --git a/web/src/App.tsx b/web/src/App.tsx index fb61874..ed2e715 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -192,6 +192,38 @@ export default function App() { setDocText('') }, [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( async (id: string) => { await api.deleteDoc(id) @@ -371,6 +403,7 @@ export default function App() { onSelect={openDoc} onCreate={handleCreate} onDelete={handleDelete} + onDuplicate={handleDuplicate} onToggleTag={handleToggleTag} onCreateTag={handleCreateTag} /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 08304f6..0ec3dbe 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -65,6 +65,7 @@ export interface WordMeaning { export interface WordInfo { 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[] synonyms: string[] } @@ -164,6 +165,10 @@ export const api = { 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(`/word/${encodeURIComponent(word)}`), // Lightweight Chinese-only gloss for the inline hover/select tooltip — instant diff --git a/web/src/audio/speech.ts b/web/src/audio/speech.ts new file mode 100644 index 0000000..fd9bf66 --- /dev/null +++ b/web/src/audio/speech.ts @@ -0,0 +1,32 @@ +// Read-aloud via the browser's Web Speech API — an offline pronunciation aid for +// an ESL writer: hear how an English word or passage sounds. No model, no +// network, no extra weight in the binary. Feature-detected so the buttons that +// use it can hide where speech isn't available. + +export function speechSupported(): boolean { + return typeof window !== 'undefined' && 'speechSynthesis' in window && 'SpeechSynthesisUtterance' in window +} + +// 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)) +} + +// speak reads `text` aloud, cancelling anything already in flight so rapid taps +// don't queue up. `lang` defaults to US English (the language she's learning); +// pass a zh locale to hear a Chinese passage. A touch slower than default so +// learners can follow along. +export function speak(text: string, lang = 'en-US'): void { + if (!speechSupported() || !text.trim()) 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) +} diff --git a/web/src/components/DocList/DocList.tsx b/web/src/components/DocList/DocList.tsx index c7e6bb1..17cff8c 100644 --- a/web/src/components/DocList/DocList.tsx +++ b/web/src/components/DocList/DocList.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from 'react' -import type { DocSummary, Tag, TagColor } from '../../api/client' +import { api, type DocSummary, type Tag, type TagColor } from '../../api/client' import { DocListItem } from './DocListItem' import { SearchBox } from './SearchBox' import { TagChip } from './TagChip' @@ -11,10 +11,19 @@ interface Props { onSelect: (id: string) => void onCreate: () => void onDelete: (id: string) => void + onDuplicate: (id: string) => void onToggleTag: (docId: string, tag: Tag) => void onCreateTag: (docId: string, name: string, color: TagColor) => void } +// Sidebar sort orders. 'recent' keeps the server's updated_at-desc ordering. +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({ @@ -24,6 +33,7 @@ export function DocList({ onSelect, onCreate, onDelete, + onDuplicate, onToggleTag, onCreateTag, }: Props) { @@ -31,11 +41,21 @@ export function DocList({ // disappears from the roster. const [filterId, setFilterId] = useState(null) const activeFilter = filterId && roster.some((t) => t.id === filterId) ? filterId : null + const [sort, setSort] = useState('recent') - const filtered = useMemo( - () => (activeFilter ? docs.filter((d) => d.tags.some((t) => t.id === activeFilter)) : docs), - [docs, activeFilter], - ) + 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]) @@ -72,6 +92,25 @@ export function DocList({ New Doc + {docs.length > 1 && ( +
+ + +
+ )} +
{filtered.length === 0 ? (

@@ -86,12 +125,30 @@ export function DocList({ roster={roster} onSelect={() => onSelect(doc.id)} onDelete={() => onDelete(doc.id)} + onDuplicate={() => onDuplicate(doc.id)} onToggleTag={onToggleTag} onCreateTag={onCreateTag} /> )) )}

+ + {/* Whole-corpus backup — "download all my writing". Plain download links so + the browser saves the zip; no extra app state needed. */} + {docs.length > 0 && ( +
+ 备份 · Back up all: + + Word + + + Markdown + +
+ )} ) } diff --git a/web/src/components/DocList/DocListItem.tsx b/web/src/components/DocList/DocListItem.tsx index 0692015..1ffa527 100644 --- a/web/src/components/DocList/DocListItem.tsx +++ b/web/src/components/DocList/DocListItem.tsx @@ -9,6 +9,7 @@ interface Props { roster: Tag[] onSelect: () => void onDelete: () => void + onDuplicate: () => void onToggleTag: (docId: string, tag: Tag) => void onCreateTag: (docId: string, name: string, color: TagColor) => void } @@ -21,6 +22,7 @@ export function DocListItem({ roster, onSelect, onDelete, + onDuplicate, onToggleTag, onCreateTag, }: Props) { @@ -65,6 +67,19 @@ export function DocListItem({ > 🏷️ + + 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} + /> + + {count ? `${active + 1} / ${count}` : query ? '无 · 0' : ''} + + go(-1)}>↑ + go(1)}>↓ + setCaseSensitive((v) => !v)} + title="Match case · 区分大小写" + > + Aa + + + + + {showReplace && ( +
+ 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} + /> + + +
+ )} + + ) +} + +function FindBtn({ + children, + onClick, + label, + title, + active, + disabled, +}: { + children: React.ReactNode + onClick: () => void + label: string + title?: string + active?: boolean + disabled?: boolean +}) { + return ( + + ) +} diff --git a/web/src/components/Editor/SearchHighlight.ts b/web/src/components/Editor/SearchHighlight.ts new file mode 100644 index 0000000..3ff79e9 --- /dev/null +++ b/web/src/components/Editor/SearchHighlight.ts @@ -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('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({ + 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 + }, + }, + }), + ] + }, +}) diff --git a/web/src/components/Editor/SelectionBubble.tsx b/web/src/components/Editor/SelectionBubble.tsx index f06873f..65840c8 100644 --- a/web/src/components/Editor/SelectionBubble.tsx +++ b/web/src/components/Editor/SelectionBubble.tsx @@ -27,11 +27,14 @@ export const REWRITE_STYLES: RewriteStyle[] = [ 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 }: Props) { +export function SelectionBubble({ style, onRewrite, onSpeak }: Props) { const [natural, ...tones] = REWRITE_STYLES return ( @@ -39,21 +42,25 @@ export function SelectionBubble({ style, onRewrite }: Props) { className="petal-selection-bubble absolute z-30 flex max-w-[360px] flex-wrap items-center gap-1.5 p-2" role="toolbar" aria-label="Rewrite the selection" - onMouseDown={(e) => e.preventDefault()} // keep the editor selection style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-pill)', boxShadow: 'var(--shadow-soft)', fontFamily: CJK, + // 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, }} > + {onSpeak && ( + + )} + {tones.map((t) => ( + )} + {/* How to say it — the pronunciation aid for an English learner, paired + with the 🔊 button above. */} + {phonetic && ( +

+ /{phonetic}/ +

+ )} + {/* Chinese gloss first — it's what the Mandarin-speaking writer reaches for. */} {gloss && (

(null) + const [menu, setMenu] = useState<'color' | 'highlight' | 'size' | 'link' | 'table' | 'outline' | null>(null) const [linkUrl, setLinkUrl] = useState('') const fileInputRef = useRef(null) @@ -222,6 +222,26 @@ export function Toolbar({ editor, onVoiceCheck, voicing }: Props) { close() } + // Collect the document's headings (with their positions) for the outline. Read + // fresh each time the popover opens, so it always reflects the current doc. + const headings: { level: number; text: string; pos: number }[] = [] + if (menu === 'outline') { + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'heading') { + headings.push({ level: (node.attrs.level as number) || 1, text: node.textContent || '(无标题)', pos }) + } + }) + } + + // Scroll a heading into view without moving the selection (which would pop the + // rewrite bubble). domAtPos resolves the heading's DOM node to scroll to. + const gotoHeading = (pos: number) => { + const dom = editor.view.domAtPos(pos + 1) + const el = dom.node.nodeType === Node.TEXT_NODE ? dom.node.parentElement : (dom.node as HTMLElement) + el?.scrollIntoView({ block: 'start', behavior: 'smooth' }) + close() + } + const onPickImage = (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (file) uploadImageInto(editor.view, file) @@ -507,6 +527,50 @@ export function Toolbar({ editor, onVoiceCheck, voicing }: Props) { /> )} + + {/* Outline / document map */} + setMenu(menu === 'outline' ? null : 'outline')}> + ☰ + + } + > +

+

+ 大纲 · Outline +

+ {headings.length === 0 ? ( +

+ 用 H1/H2/H3 添加标题,这里就会出现导航。
+ Add headings to navigate them here. +

+ ) : ( +
+ {headings.map((h, i) => ( + + ))} +
+ )} +
+