Editor: Find & Replace, read-aloud, backup, typography, phonetic, org niceties

Writer power-ups (Phase 11), plus the selection-bubble vs copy/paste fix.

- Find & Replace (Ctrl/Cmd+F): SearchHighlight decoration extension +
  FindReplace bar (match-case, replace-all back-to-front, scroll without
  popping the selection bubble).
- Read-aloud (Web Speech, offline) on the word card and selection bubble.
- Keyboard/touch access to the ESL helpers: Ctrl/Cmd+D look up word at caret,
  Ctrl/Cmd+J rewrite selection, touch long-press lookup. Refactored the
  right-click handler into a shared openWordLookup(pos).
- Whole-corpus backup: GET /api/docs/export-all zips every doc (md/docx),
  de-dupes filenames, dated name; sidebar download links. TestExportAll.
- Smart typography input rules (curly quotes/em-dash/ellipsis), ASCII-only so
  CJK is untouched.
- Duplicate doc, sidebar sort (Recent/Title/Longest), toolbar outline popover.
- English phonetic (chosen over pinyin for an English learner): ECDICT-built
  phonetic.json.gz (46,579 words) + Result.Phonetic + WordCard IPA line;
  scripts/build_phonetic.py (full build + --seed fallback).
- Selection bubble no longer blocks copy/paste: deferred to pointer-up and made
  click-through except on its buttons.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-26 09:47:26 -07:00
parent 6e6e4edce7
commit db737fa612
20 changed files with 1039 additions and 22 deletions

101
scripts/build_phonetic.py Normal file
View 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": "ˈloʊ", "world": "ːrld", "people": "ˈpiːpl", "water": "ˈː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ɪ", "english": "ˈɪŋɡlɪʃ", "write": "raɪt", "writing": "ˈraɪtɪŋ",
"read": "riːd", "word": "ː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ɪˈːz", "people's": "ˈpiːplz", "important": "ɪmˈːrtnt",
"different": "ˈdɪfərənt", "remember": "rɪˈmɛmbər", "together": "ˈɡɛðər",
"morning": "ˈːrnɪŋ", "tonight": "ˈnaɪt", "yesterday": "ˈjɛstərdeɪ",
"tomorrow": "ˈ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": "ɪ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()