Phase 21: Petal learns to be an English+Portuguese pair
The plan said "Hunspell pt-PT vendored like en-US". Measuring that first is what saved it: nspell expands affixes eagerly on construction, and European Portuguese's 1,340 rules over 44,257 stems want over a gigabyte of browser heap — ~340 MB for the first 12,000 entries, and no return at all after three minutes on the whole file. So the expansion runs once at build time instead: 1,039,058 forms, 2.66 MB gzipped, read by the same nspell in 842 ms. The obvious npm package would also have shipped the wrong language. Both dictionary-pt and dictionary-pt-br carry VERO, the Brazilian word list, so vendoring by name puts pt-BR spellings behind a pt-PT label — the drift SUGGESTIONS §3 warns about, arriving through the packaging where no reviewer can see it. The source is Projecto Natura's, and the build script now asserts the fault lines (receção in, recepção out) before writing anything. Spellcheck consults both dictionaries and flags only what both reject, which is the no-detector answer to a pair with no script boundary. The word card does the same in the other direction: "data" is a word in both languages, so Petal shows both readings rather than guessing which she meant. Writing the tests caught the one real bug — extendedAlphabet was a snapshot while correct/suggest read live, and her dictionary arrives after English, so every lookup would have resolved "cora" while the underlines were already right. Not done, and not claimed: the pack has not been read by a pt-PT speaker, and the Piper voice is deferred with the deploy. Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
+16
-6
@@ -235,12 +235,21 @@ Option 3 ratified (import package, read-only `dict.db`).
|
|||||||
### Phase 21 — The pt-PT pair (first Latin pair, proves the model)
|
### Phase 21 — The pt-PT pair (first Latin pair, proves the model)
|
||||||
SUGGESTIONS §1/§3/§3a. French and **Spanish** follow the same groove afterwards — es is no longer gated now that DreamDict has Spanish data (2026-07-26). pt-PT still goes first: it's the pair with a real user behind it, and it's the one that proves the langpack + both-dictionaries model.
|
SUGGESTIONS §1/§3/§3a. French and **Spanish** follow the same groove afterwards — es is no longer gated now that DreamDict has Spanish data (2026-07-26). pt-PT still goes first: it's the pair with a real user behind it, and it's the one that proves the langpack + both-dictionaries model.
|
||||||
Phase 20 left this ready: `dict.db` on the VPS now holds all five languages, and pt-PT gloss coverage of common English words is 62%.
|
Phase 20 left this ready: `dict.db` on the VPS now holds all five languages, and pt-PT gloss coverage of common English words is 62%.
|
||||||
- [ ] Hunspell pt-PT vendored like en-US; **both-dictionaries spellcheck** (flag only if wrong in both; pills from both) — the no-detector stance, Q1 settled
|
**Code half built 2026-07-27** (user: "let's continue the build plan"; scope confirmed: code only, no VPS work; the pack written but flagged unreviewed). Not deployed — no migration, so it is a rebuild whenever the user wants it.
|
||||||
- [ ] Gloss/WordCard both directions; on en/pt collisions show both compactly, never hide either
|
- [x] **pt-PT spelling dictionary — and it could not be "vendored like en-US".** nspell expands affixes *eagerly on construction*: English's ~50k stems and small rule set are fine, European Portuguese's **1,340 affix rules over 44,257 stems** are not. Measured here: ~340 MB of heap for the first 12,000 entries and no return at all after three minutes on the whole file, i.e. comfortably over a gigabyte for a browser to load a spellchecker. So `scripts/build_ptpt_dictionary.py` runs Hunspell's expansion **once at build time** — 1,039,058 surface forms, 15 MB of text, **2.66 MB gzipped**, which nspell then reads with no affix machinery at all in **842 ms / ~120 MB**. The runtime path is byte-for-byte the English one, which is the real prize. The shipped `.aff` keeps only upstream's TRY/KEY/REP/MAP, which shape *corrections* rather than membership — so "telemovel" still corrects to "telemóvel" and "cao" still knows about "ção".
|
||||||
- [ ] Prompts pinned to **European Portuguese, never pt-BR** (explicit in every prompt); pt-PT langpack copy written and **reviewed by a pt-PT speaker before trusted**
|
- [x] **npm's `dictionary-pt` is not European Portuguese.** Both it and `dictionary-pt-br` package VERO (*Verificador Ortográfico Livre*, Brasil) — the obvious vendoring step would have shipped Brazilian spellings under a pt-PT label, which is the §3 drift risk arriving through the packaging rather than through the model. The real source is Projecto Natura's (Universidade do Minho), which LibreOffice ships and Debian packages as `hunspell-pt-pt`; its aff declares `LANG pt_PT`. The build script **asserts the fault lines before it writes anything**: accepts `receção`, `húmido`, `telemóvel`, `autocarro`, `comboio`, `ótimo`, `pensámos`, `escrevêssemos`; rejects `recepção`, `úmido`, `ônibus`, `óptimo`. A source that fails those is not the dictionary it is for.
|
||||||
- [ ] Piper pt-PT voice instance on parodia; read-aloud + L1 voice wired; slow toggle (`length_scale`) while in there (SUGGESTIONS §5e)
|
- [x] **Both-dictionaries spellcheck** (`useSpellChecker`): English always loads; her language loads when a pair ships one; a token is flagged only when **every** loaded dictionary rejects it. Correction pills **interleave** the two rather than concatenating — otherwise English fills all five and a misspelt Portuguese word gets no Portuguese suggestion, which is the one case the second dictionary was loaded for. No dictionary at all accepts everything: a failed fetch must not underline the whole document.
|
||||||
- [ ] Companion tips/cheers/bedtime lines in the pt-PT pack (the kitten speaks pt+en to this user)
|
- [x] **The tokenizer had to become a property of the checker, not a constant.** `[A-Za-z]` cuts "coração" into "cora" and "o", both short enough that `isCheckable` discards them — so the word was silently never checked *and* a right-click would have offered a definition of "cora". The wide alphabet is `[A-Za-zÀ-ÖØ-öø-ÿ]`, deliberately skipping × and ÷, which hide inside that Latin-1 range. It stays **off** for a writer with no Latin second language: widening it there can only find new words to underline (the *café* and *naïve* she borrows) and no mistake she actually made. `wordAt` takes the same flag, so the underline and every lookup agree.
|
||||||
- [ ] Acceptance: a pt-PT-pair user gets the full experience end-to-end with the VPN down except LLM passes; zh-pair user sees zero change
|
- [x] **Gloss/WordCard both directions** — new `lexicon.Reverse` on the lookup and a `reverse` line on the hover tip. A Latin pair has no script boundary: *data*, *sale*, *comum*, *tarde* and *ali* are real words on both sides, and there is no honest way to look at one in a mixed document and know which was meant. Petal asks both directions and shows whatever answers — no detector, so it cannot be wrong about her writing, and for a learner the collision is the interesting part. The English de-inflection walk is deliberately **not** applied in reverse: `candidates` knows -s/-ed/-ing, and running it over Portuguese would be right by accident and wrong by rule.
|
||||||
|
- [x] Prompts pinned to **European Portuguese, never pt-BR** — already done in Phase 19 (`internal/llm/lang.go` spells it out inside the prompt, with *porquê* carried alongside so the tutor recognises her question).
|
||||||
|
- [x] pt-PT langpack written (`web/src/i18n/packs/pt-PT.ts`), and pt-PT is now a real switch rather than a fallback. Post-Acordo spellings with the European lexicon (*ficheiro*, *ecrã*, *guardar*, *sinónimo*, *académico*, *Iniciar sessão*), *estás a escrever* rather than the gerund, and second-person *tu* — a companion in a private notebook, not a form. A test greps the built pack for Brazilian forms, because that is exactly the error nobody reviewing the diff can see.
|
||||||
|
- [ ] ⚠️ **The pack is NOT reviewed by a pt-PT speaker** — SUGGESTIONS §3's own bar, and the one item here I cannot meet. Flagged at the top of the file and left unchecked deliberately; expect a speaker to change the register before the vocabulary.
|
||||||
|
- [x] Companion tips/cheers/bedtime lines in the pt-PT pack. Not a translation of the zh pack: the bedtime proverbs are Portuguese ones and there is a false-friends tip the Mandarin pair had no use for. The English wit in the bedtime lines is the user's own and is kept word for word across packs.
|
||||||
|
- [ ] Piper pt-PT voice instance on parodia; read-aloud + L1 voice wired; slow toggle (`length_scale`) while in there (SUGGESTIONS §5e) — the infra half, deferred with the deploy
|
||||||
|
- [ ] Acceptance: a pt-PT-pair user gets the full experience end-to-end with the VPN down except LLM passes; zh-pair user sees zero change — waits on the voice above and on a real pt-PT account
|
||||||
|
- Tests: `internal/lexicon/dreamdict_test.go` gains a real collision in the fixture (*data*: English facts, Portuguese date) — both readings on a collision, **no** reverse block for an English-only word, the tooltip carrying only the reverse gloss, and the embedded/glossless providers staying silent (a Chinese reading of an English word is worse than none). Frontend: `spellchecker.test.ts` (either-accepts, flag-only-if-both-reject, a Portuguese word never flagged for being unknown to English, no-dictionary-accepts-everything, a dictionary arriving *after* the checker was built, interleaved pills) and `SpellCheck.test.ts` (the narrow alphabet still cutting "coração", the wide one not, CJK never tokenized under either, × and ÷ excluded). The i18n suite now runs its shape assertions over *every* pack — a shape only the first author's pack satisfies is a coincidence, not a shape.
|
||||||
|
- **A bug the test found, not the code review**: `extendedAlphabet` was a value computed when the checker was built while `correct`/`suggest` read live. Her dictionary arrives *after* English, so the underlines would have been right while every lookup was still resolving "cora". It is a getter now.
|
||||||
|
- Verified: go build/vet/test, tsc, vite build, vitest 116/116 clean. The shipped asset loaded in a real nspell (842 ms, 139 MB, pt-PT variants correct both ways). Live smoke on a throwaway DB (:8091): both dictionary files served (577 B aff, 2,661,813 B gz), the gz inflating to 1,039,058 forms with `receção` present, and the zh word lookup unchanged. **Not verified against real data**: this laptop has no `dict.db`, so the reverse-lookup path is exercised by the fixture only — the first real pt-PT collision lookup happens on the VPS.
|
||||||
|
|
||||||
### Phase 22 — Learning loop + code-first layers
|
### Phase 22 — Learning loop + code-first layers
|
||||||
Each item independent and small; order within is free (SUGGESTIONS §5–§6).
|
Each item independent and small; order within is free (SUGGESTIONS §5–§6).
|
||||||
@@ -265,6 +274,7 @@ Each item independent and small; order within is free (SUGGESTIONS §5–§6).
|
|||||||
- [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)
|
- [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-07-27: **Phase 21 (code half) — the pt-PT pair, and the plan's one-line assumption about the dictionary** (user: "let's continue the build plan"; scope confirmed: code only, the Piper voice and the deploy deferred, the pack written but flagged unreviewed). The plan said "Hunspell pt-PT vendored like en-US", and that turned out to be the load-bearing sentence. **nspell expands affixes eagerly on construction** — it materialises every surface form the moment you build it. English survives that; European Portuguese's 1,340 affix rules over 44,257 stems do not. Measured before deciding anything: ~340 MB of heap for the first 12,000 entries, and no return at all after three minutes on the whole file — over a gigabyte, in a browser, on a tablet. So the expansion moved to build time: `scripts/build_ptpt_dictionary.py` writes 1,039,058 forms, 2.66 MB gzipped, which the *same* nspell then reads in 842 ms using ~120 MB, and the runtime path stays byte-for-byte the English one. The `.aff` keeps only TRY/KEY/REP/MAP, which shape corrections rather than membership, so "telemovel" still corrects to "telemóvel". **A second thing the obvious route would have got wrong quietly**: npm's `dictionary-pt` is not European Portuguese — both it and `dictionary-pt-br` package VERO (Brasil), so vendoring the obvious package name ships Brazilian spellings under a pt-PT label. That is §3's pt-BR drift arriving through the *packaging* rather than through the model, and nobody reviewing the diff would see it. The real source is Projecto Natura's, packaged as `hunspell-pt-pt`; the build script now asserts the fault lines (`receção`/`húmido`/`pensámos` in, `recepção`/`úmido`/`ônibus`/`óptimo` out) before it writes a byte, and a vitest greps the built langpack for *sinônimo*, *arquivo*, *tela*, *você*. **Both-dictionaries spellcheck** landed as §3a specifies — flag only what every loaded dictionary rejects, interleave the correction pills so English can't fill all five — and dragged a smaller thing with it: the tokenizer had to become a property of the checker rather than a constant, because `[A-Za-z]` cuts "coração" into "cora", which is both silently unchecked *and* what a right-click would have looked up. The wide alphabet stays off for a writer with no Latin second language, where it could only earn her new squiggles. **Gloss both directions**: a Latin pair has no script boundary, so *data*, *sale* and *comum* are words on both sides and there is no honest way to know which she meant — Petal asks both and shows what answers, which needs no detector and therefore cannot be wrong about her writing. The reverse direction deliberately skips the English de-inflection walk, which over Portuguese would be right by accident and wrong by rule. **Writing the tests found the bug**: `extendedAlphabet` was a snapshot taken when the checker was built while `correct`/`suggest` read live — and her dictionary arrives *after* English, so the underlines would have been right while every lookup still resolved "cora". go build/vet/test, tsc, vite, vitest 116/116 clean; the shipped asset loaded in a real nspell; live smoke on a throwaway DB served both files and left the zh lookup untouched. **Two things outstanding and both said plainly**: the pack has *not* been read by a pt-PT speaker (SUGGESTIONS §3's own bar, and not one I can meet), and this laptop has no `dict.db`, so the reverse-lookup path is covered by a fixture rather than by a real collision — the first of those happens on the VPS.
|
||||||
- 2026-07-27: **dict.db rebuilt with Spanish, and a log line caught lying** (user: "if we need to redeploy DreamDict to add Spanish support, then do so"). Millenia's dreamdict checkout held ~490 lines of uncommitted work; rather than pull over it, comparing file contents showed an earlier draft of the regional-variant work already committed upstream — nothing unique, but not mine to discard, so it was left alone and the rebuild ran from a clean clone pushed over from the laptop (millenia has no GitHub SSH). Import took 6m15s and added **es: 102,971 words**, leaving en/fr/pt-PT/zh byte-identical — the check that distinguishes "added a language" from "quietly changed everything". Coverage measured before shipping: **es 68.6%**, the best of the four; **zh re-measured at 53.2%**, so the ECDICT decision stands on fresh evidence rather than on the earlier number. Shipped direct millenia→parodia over headscale, hashed both ends, kept the April file for rollback. **The rebuild's real find was in Petal, not DreamDict**: the startup line reported `dictionary.Langs()`, a compile-time constant of *supported* languages, so it had been printing a cheerful `[en fr pt-PT es zh]` over a database with no Spanish in it — the exact failure it existed to catch, reported as success, and something I had already claimed as proof the deploy was good. It now counts rows. Chasing a failed SUBTLEX-US download (benign — the loader falls back to `.txt`) also confirmed English "frequency" is mostly SCOWL's commonness bucket, which independently vindicates the band chip reading `difficulty` instead.
|
- 2026-07-27: **dict.db rebuilt with Spanish, and a log line caught lying** (user: "if we need to redeploy DreamDict to add Spanish support, then do so"). Millenia's dreamdict checkout held ~490 lines of uncommitted work; rather than pull over it, comparing file contents showed an earlier draft of the regional-variant work already committed upstream — nothing unique, but not mine to discard, so it was left alone and the rebuild ran from a clean clone pushed over from the laptop (millenia has no GitHub SSH). Import took 6m15s and added **es: 102,971 words**, leaving en/fr/pt-PT/zh byte-identical — the check that distinguishes "added a language" from "quietly changed everything". Coverage measured before shipping: **es 68.6%**, the best of the four; **zh re-measured at 53.2%**, so the ECDICT decision stands on fresh evidence rather than on the earlier number. Shipped direct millenia→parodia over headscale, hashed both ends, kept the April file for rollback. **The rebuild's real find was in Petal, not DreamDict**: the startup line reported `dictionary.Langs()`, a compile-time constant of *supported* languages, so it had been printing a cheerful `[en fr pt-PT es zh]` over a database with no Spanish in it — the exact failure it existed to catch, reported as success, and something I had already claimed as proof the deploy was good. It now counts rows. Chasing a failed SUBTLEX-US download (benign — the loader falls back to `.txt`) also confirmed English "frequency" is mostly SCOWL's commonness bucket, which independently vindicates the band chip reading `difficulty` instead.
|
||||||
- 2026-07-27: **Phase 20 — DreamDict becomes the dictionary for every pair but Chinese** (user: "let's continue the build plan"; scope confirmed: build the seam against the existing April `dict.db`, rebuild it later, code + local verification only). The prerequisite was bigger than the plan recorded: renaming DreamDict's module path was necessary but useless on its own, because the query layer lived in `internal/dictionary` and no module may import another's `internal`. Both fixed upstream — the package is now `dictionary`, with a comment saying why *reading* a built database is public API while the loaders that build one stay internal. In Petal, `Provider` is the two questions the popover already asked, so the embedded `*Lexicon` satisfied it with no changes at all, and `Set.For(lang)` is the one place the choice is made. **The measurement is the story of the phase.** `MULTIUSER_PLAN.md` mapped `Gloss ← Translate(word, "en", L1)` 1:1; against the real 452 MB database that table answers for **17%** of the 2,000 commonest English words into pt-PT. Wiktionary's translation sections are thin in that direction — "ephemeral", "think" and "quickly" have no en→pt-PT row at all. The shared-synset path answers for **61%**, so a new upstream `Equivalents` queries that and falls back to translations for 62% combined. Then the *ordering* was wrong in an instructive way: sorting by target frequency glosses "think" as *lembrar* — "remember" — because lembrar is commoner in Portuguese, even though pensar shares six of think's synsets to lembrar's one. Counting sense agreement first fixes it (think → pensar; write → escrever; garden → jardim). The same measurement is what kept **zh on ECDICT**: DreamDict reaches a Chinese gloss for 53% of those words where ECDICT reaches nearly all — the plan said converge only if quality holds, and it didn't. Two other decisions worth keeping: a missing `dict.db` is **not an error** (a laptop has never had one) but a present-and-unimported one is; and a pt-PT writer without a dictionary falls back to the embedded datasets **with the gloss suppressed**, keeping the English half rather than blanking the popover — an empty field reads as "not found", the wrong language reads as broken. The new fields surface as **three** bands, not five, because the difficulty score can separate "everyday" from "you'll have to explain this" but cannot rank *obfuscate* against *serendipity*, and a finer scale would be a confident-looking lie. Writing the tests found two bugs first: `trimEtymology` sliced by byte, which would have emitted invalid UTF-8 for precisely the Greek and Latin etymologies the feature exists for, and its ellipsis path overran its own cap. go build/vet/test, tsc, vite, vitest 96/96 clean in both repos; live smoke on a throwaway DB against the real dictionary, one instance flipped from zh to pt-PT mid-run. **Then deployed, with Phase 19** (user: "do it"): dreamdict pushed to GitHub, the `replace` swapped for a real pseudo-version, encrypted off-box backup first, `dict.db` copied into the LUKS volume and SHA-256-verified, then a rebuild — no migration in either phase, so `schema_migrations` stayed at 11 and her writing came through untouched (8 documents, 33 versions, 103 suggestions, FTS matching, integrity ok). Both accounts are on the zh pair, so **nothing she sees changed today**; what shipped is the capacity for the next pair. Outstanding: the deployed `dict.db` predates DreamDict's Spanish data and needs rebuilding before the es pair ships.
|
- 2026-07-27: **Phase 20 — DreamDict becomes the dictionary for every pair but Chinese** (user: "let's continue the build plan"; scope confirmed: build the seam against the existing April `dict.db`, rebuild it later, code + local verification only). The prerequisite was bigger than the plan recorded: renaming DreamDict's module path was necessary but useless on its own, because the query layer lived in `internal/dictionary` and no module may import another's `internal`. Both fixed upstream — the package is now `dictionary`, with a comment saying why *reading* a built database is public API while the loaders that build one stay internal. In Petal, `Provider` is the two questions the popover already asked, so the embedded `*Lexicon` satisfied it with no changes at all, and `Set.For(lang)` is the one place the choice is made. **The measurement is the story of the phase.** `MULTIUSER_PLAN.md` mapped `Gloss ← Translate(word, "en", L1)` 1:1; against the real 452 MB database that table answers for **17%** of the 2,000 commonest English words into pt-PT. Wiktionary's translation sections are thin in that direction — "ephemeral", "think" and "quickly" have no en→pt-PT row at all. The shared-synset path answers for **61%**, so a new upstream `Equivalents` queries that and falls back to translations for 62% combined. Then the *ordering* was wrong in an instructive way: sorting by target frequency glosses "think" as *lembrar* — "remember" — because lembrar is commoner in Portuguese, even though pensar shares six of think's synsets to lembrar's one. Counting sense agreement first fixes it (think → pensar; write → escrever; garden → jardim). The same measurement is what kept **zh on ECDICT**: DreamDict reaches a Chinese gloss for 53% of those words where ECDICT reaches nearly all — the plan said converge only if quality holds, and it didn't. Two other decisions worth keeping: a missing `dict.db` is **not an error** (a laptop has never had one) but a present-and-unimported one is; and a pt-PT writer without a dictionary falls back to the embedded datasets **with the gloss suppressed**, keeping the English half rather than blanking the popover — an empty field reads as "not found", the wrong language reads as broken. The new fields surface as **three** bands, not five, because the difficulty score can separate "everyday" from "you'll have to explain this" but cannot rank *obfuscate* against *serendipity*, and a finer scale would be a confident-looking lie. Writing the tests found two bugs first: `trimEtymology` sliced by byte, which would have emitted invalid UTF-8 for precisely the Greek and Latin etymologies the feature exists for, and its ellipsis path overran its own cap. go build/vet/test, tsc, vite, vitest 96/96 clean in both repos; live smoke on a throwaway DB against the real dictionary, one instance flipped from zh to pt-PT mid-run. **Then deployed, with Phase 19** (user: "do it"): dreamdict pushed to GitHub, the `replace` swapped for a real pseudo-version, encrypted off-box backup first, `dict.db` copied into the LUKS volume and SHA-256-verified, then a rebuild — no migration in either phase, so `schema_migrations` stayed at 11 and her writing came through untouched (8 documents, 33 versions, 103 suggestions, FTS matching, integrity ok). Both accounts are on the zh pair, so **nothing she sees changed today**; what shipped is the capacity for the next pair. Outstanding: the deployed `dict.db` predates DreamDict's Spanish data and needs rebuilding before the es pair ships.
|
||||||
- 2026-07-27: **Phase 18 deployed, and Phase 19 — the copy stops being hardcoded Mandarin** (user: "let's continue the build plan"; sequencing confirmed: rehearse + deploy 18, then start 19). The rehearsal the previous session was blocked from running went first: a `VACUUM INTO` snapshot of the live VPS database, migrated locally by the Phase-18 binary, every count unchanged and FTS/integrity/foreign keys clean, `personal_words` created empty — then the deploy itself (off-box encrypted backup, rebuild, all three containers healthy, `0011` applied to the live DB with her writing untouched, `/api/spell/words` 401 without a session over public HTTPS). **One check was refused and not worked around**: minting a probe session row to see the endpoint answer 200 for a real cookie reads as credential fabrication to this session's classifier; the endpoint's lifecycle is covered by tests and the shared middleware governs that last step for every other route. **Phase 19** then lifted every `中文 · English` literal out of ~29 files into `web/src/i18n` — one `Pack` type, a verbatim `zh` pack, and two access paths chosen by *when* copy is built: `usePack()` for components, `pack()` for the companion and prose checker, which compose a line when something happens rather than when something renders. The interesting decisions were about what a pack must be allowed to control: **every string with a value in it is a function** (`reviewDue(n)`, `daysAgo(n)`, even English pluralisation) because word order isn't universal; the roster constants keep only value + emoji so a label can never drift from its key; and `gradeBand` returns a band *name* rather than a label. On the server, `internal/llm/lang.go` replaces "Simplified Chinese" in the three prompts that name her language — with pt-PT spelled **"European Portuguese (pt-PT, never Brazilian Portuguese)"** in the prompt itself, and her word for "why" carried alongside so the tutor still recognises the question. `pair_lang` is read **in the row-scoped query each handler already ran**, not a second lookup that could disagree with it — and the test for that was checked by breaking the join and watching it fail. go build/vet/test, tsc, vite, vitest 90/90 clean; live smoke on a throwaway DB. **Phase 19 is not deployed** — no migration, so it's a rebuild whenever the user wants it.
|
- 2026-07-27: **Phase 18 deployed, and Phase 19 — the copy stops being hardcoded Mandarin** (user: "let's continue the build plan"; sequencing confirmed: rehearse + deploy 18, then start 19). The rehearsal the previous session was blocked from running went first: a `VACUUM INTO` snapshot of the live VPS database, migrated locally by the Phase-18 binary, every count unchanged and FTS/integrity/foreign keys clean, `personal_words` created empty — then the deploy itself (off-box encrypted backup, rebuild, all three containers healthy, `0011` applied to the live DB with her writing untouched, `/api/spell/words` 401 without a session over public HTTPS). **One check was refused and not worked around**: minting a probe session row to see the endpoint answer 200 for a real cookie reads as credential fabrication to this session's classifier; the endpoint's lifecycle is covered by tests and the shared middleware governs that last step for every other route. **Phase 19** then lifted every `中文 · English` literal out of ~29 files into `web/src/i18n` — one `Pack` type, a verbatim `zh` pack, and two access paths chosen by *when* copy is built: `usePack()` for components, `pack()` for the companion and prose checker, which compose a line when something happens rather than when something renders. The interesting decisions were about what a pack must be allowed to control: **every string with a value in it is a function** (`reviewDue(n)`, `daysAgo(n)`, even English pluralisation) because word order isn't universal; the roster constants keep only value + emoji so a label can never drift from its key; and `gradeBand` returns a band *name* rather than a label. On the server, `internal/llm/lang.go` replaces "Simplified Chinese" in the three prompts that name her language — with pt-PT spelled **"European Portuguese (pt-PT, never Brazilian Portuguese)"** in the prompt itself, and her word for "why" carried alongside so the tutor still recognises the question. `pair_lang` is read **in the row-scoped query each handler already ran**, not a second lookup that could disagree with it — and the test for that was checked by breaking the join and watching it fail. go build/vet/test, tsc, vite, vitest 90/90 clean; live smoke on a throwaway DB. **Phase 19 is not deployed** — no migration, so it's a rebuild whenever the user wants it.
|
||||||
|
|||||||
@@ -174,9 +174,63 @@ func (p dreamProvider) Lookup(word string) (Result, error) {
|
|||||||
}
|
}
|
||||||
res.Etymology = trimEtymology(ety)
|
res.Etymology = trimEtymology(ety)
|
||||||
|
|
||||||
|
if res.Reverse, err = p.reverse(norm); err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reverse reads the token as a word of the writer's own language, and returns
|
||||||
|
// nil when it isn't one — which is the answer for almost every word she looks
|
||||||
|
// up, since she is writing English.
|
||||||
|
//
|
||||||
|
// The English de-inflection walk is deliberately *not* applied here. [candidates]
|
||||||
|
// knows about -s, -ed and -ing; running it over Portuguese would turn "vinhas"
|
||||||
|
// into "vinha" by an English rule that happens to be right and "cantava" into
|
||||||
|
// nothing by rules that are simply irrelevant. dict.db stores headwords, so an
|
||||||
|
// inflected Portuguese form finds nothing and the card shows only the English
|
||||||
|
// reading — the same outcome as today, rather than a confidently wrong one.
|
||||||
|
func (p dreamProvider) reverse(norm string) (*Reverse, error) {
|
||||||
|
back, err := p.dict.d.Equivalents(norm, p.native, langEN)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defs, err := p.dict.d.Define(norm, p.native)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(back) == 0 && len(defs) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rev := &Reverse{Lang: p.native}
|
||||||
|
if len(back) > maxGlossSenses {
|
||||||
|
back = back[:maxGlossSenses]
|
||||||
|
}
|
||||||
|
rev.Gloss = strings.Join(back, "; ")
|
||||||
|
for _, d := range defs {
|
||||||
|
rev.Definitions = append(rev.Definitions, Meaning{PartOfSpeech: d.POS, Definition: d.Gloss})
|
||||||
|
if len(rev.Definitions) >= maxReverseDefinitions {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prons, err := p.dict.d.Pronunciation(norm, p.native)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rev.Phonetic = pickIPA(prons)
|
||||||
|
|
||||||
|
return rev, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxReverseDefinitions is smaller than [maxDefinitions]: the reverse reading is
|
||||||
|
// the second half of a card that already has an English one, and it is there to
|
||||||
|
// say "this is also a Portuguese word, and here is what it means" rather than to
|
||||||
|
// be a dictionary entry in its own right.
|
||||||
|
const maxReverseDefinitions = 2
|
||||||
|
|
||||||
// Gloss returns the writer's-language translation alone — the hover tooltip's
|
// Gloss returns the writer's-language translation alone — the hover tooltip's
|
||||||
// fast path, one indexed query per candidate form and nothing else.
|
// fast path, one indexed query per candidate form and nothing else.
|
||||||
func (p dreamProvider) Gloss(word string) (GlossResult, error) {
|
func (p dreamProvider) Gloss(word string) (GlossResult, error) {
|
||||||
@@ -188,7 +242,21 @@ func (p dreamProvider) Gloss(word string) (GlossResult, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return GlossResult{}, err
|
return GlossResult{}, err
|
||||||
}
|
}
|
||||||
return GlossResult{Word: word, Gloss: gloss}, nil
|
res := GlossResult{Word: word, Gloss: gloss}
|
||||||
|
|
||||||
|
// The tooltip carries only the reverse *gloss*, not the whole reading: it is
|
||||||
|
// a one-line bubble under a resting pointer, and the popover is one click
|
||||||
|
// away for anyone who wants the rest.
|
||||||
|
back, err := p.dict.d.Equivalents(norm, p.native, langEN)
|
||||||
|
if err != nil {
|
||||||
|
return GlossResult{}, err
|
||||||
|
}
|
||||||
|
if len(back) > maxGlossSenses {
|
||||||
|
back = back[:maxGlossSenses]
|
||||||
|
}
|
||||||
|
res.Reverse = strings.Join(back, "; ")
|
||||||
|
|
||||||
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxGlossSenses caps how many translations are strung together. One is often
|
// maxGlossSenses caps how many translations are strung together. One is often
|
||||||
|
|||||||
@@ -96,6 +96,24 @@ func writeFixture(t *testing.T, seeded bool) string {
|
|||||||
exec(`INSERT INTO word_synsets (word_id, synset_id, source) VALUES
|
exec(`INSERT INTO word_synsets (word_id, synset_id, source) VALUES
|
||||||
(5, 1, 'wordnet'), (6, 1, 'omw'), (7, 1, 'omw')`)
|
(5, 1, 'wordnet'), (6, 1, 'omw'), (7, 1, 'omw')`)
|
||||||
|
|
||||||
|
// "data" is the collision the Latin pairs create and the zh pair never did:
|
||||||
|
// a real English word and a real Portuguese one, spelled identically and
|
||||||
|
// meaning different things. There is no honest way to look at it in a mixed
|
||||||
|
// document and know which was meant, so Petal shows both readings.
|
||||||
|
exec(`INSERT INTO words (id, word, lang, pos, frequency) VALUES
|
||||||
|
(8, 'data', 'en', 'noun', 800),
|
||||||
|
(9, 'data', 'pt-PT', 'noun', 700),
|
||||||
|
(10, 'date', 'en', 'noun', 750)`)
|
||||||
|
exec(`INSERT INTO definitions (word_id, pos, gloss, source, priority) VALUES
|
||||||
|
(8, 'noun', 'facts collected for reference', 'wordnet', 10),
|
||||||
|
(9, 'noun', 'dia do mês', 'wiktionary', 20),
|
||||||
|
(9, 'noun', 'momento no tempo', 'wiktionary', 30),
|
||||||
|
(9, 'noun', 'um terceiro sentido', 'wiktionary', 40)`)
|
||||||
|
exec(`INSERT INTO translations (word_id, translation, target_lang, source) VALUES
|
||||||
|
(9, 'date', 'en', 'kaikki')`)
|
||||||
|
exec(`INSERT INTO pronunciations (word_id, format, value, source) VALUES
|
||||||
|
(9, 'ipa', '/ˈdatɐ/', 'wiktionary')`)
|
||||||
|
|
||||||
exec(`INSERT INTO etymology (word_id, text, source) VALUES
|
exec(`INSERT INTO etymology (word_id, text, source) VALUES
|
||||||
(1, 'From Medieval Latin ephemerus, from Ancient Greek ἐφήμερος (ephḗmeros, "lasting only a day"), from ἐπί (epí, "upon") and ἡμέρα (hēméra, "day"). The sense of transience is attested in English from the late sixteenth century onwards.', 'wiktionary')`)
|
(1, 'From Medieval Latin ephemerus, from Ancient Greek ἐφήμερος (ephḗmeros, "lasting only a day"), from ἐπί (epí, "upon") and ἡμέρα (hēméra, "day"). The sense of transience is attested in English from the late sixteenth century onwards.', 'wiktionary')`)
|
||||||
|
|
||||||
@@ -541,3 +559,106 @@ func TestContentsCountsRowsNotSupportedLanguages(t *testing.T) {
|
|||||||
t.Error("Contents with no dictionary must still say something")
|
t.Error("Contents with no dictionary must still say something")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Latin+Latin wrinkle (SUGGESTIONS.md §3a). An English+Chinese pair never
|
||||||
|
// had to decide which language a word was in — the script decided. An
|
||||||
|
// English+Portuguese pair has no script boundary, and "data", "sale", "comum"
|
||||||
|
// and "tarde" are real words on both sides of it. Petal asks both directions
|
||||||
|
// and shows whatever answers, which needs no language detector and therefore
|
||||||
|
// cannot be wrong about somebody's writing.
|
||||||
|
func TestDreamLookupShowsBothReadingsOnACollision(t *testing.T) {
|
||||||
|
p := dreamProvider{dict: openFixture(t), native: "pt-PT"}
|
||||||
|
res, err := p.Lookup("data")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Lookup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The English reading is unchanged and still leads.
|
||||||
|
if len(res.Definitions) == 0 || res.Definitions[0].Definition != "facts collected for reference" {
|
||||||
|
t.Fatalf("Definitions = %v, want the English sense first", res.Definitions)
|
||||||
|
}
|
||||||
|
|
||||||
|
if res.Reverse == nil {
|
||||||
|
t.Fatal("Reverse = nil; a word that exists in both languages must carry both readings")
|
||||||
|
}
|
||||||
|
if res.Reverse.Lang != "pt-PT" {
|
||||||
|
t.Errorf("Reverse.Lang = %q, want the writer's language", res.Reverse.Lang)
|
||||||
|
}
|
||||||
|
if res.Reverse.Gloss != "date" {
|
||||||
|
t.Errorf("Reverse.Gloss = %q, want the English meaning of the Portuguese word", res.Reverse.Gloss)
|
||||||
|
}
|
||||||
|
if res.Reverse.Phonetic != "ˈdatɐ" {
|
||||||
|
t.Errorf("Reverse.Phonetic = %q, want the Portuguese IPA without slashes", res.Reverse.Phonetic)
|
||||||
|
}
|
||||||
|
// The reverse reading is a footnote on a card that already has an English
|
||||||
|
// half, so it is capped harder than the main entry.
|
||||||
|
if len(res.Reverse.Definitions) != maxReverseDefinitions {
|
||||||
|
t.Fatalf("Reverse.Definitions = %d, want %d", len(res.Reverse.Definitions), maxReverseDefinitions)
|
||||||
|
}
|
||||||
|
if res.Reverse.Definitions[0].Definition != "dia do mês" {
|
||||||
|
t.Errorf("Reverse.Definitions[0] = %q, want the Portuguese sense",
|
||||||
|
res.Reverse.Definitions[0].Definition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDreamLookupHasNoReverseForAnEnglishOnlyWord(t *testing.T) {
|
||||||
|
// Which is almost every word she looks up: she is writing English. A
|
||||||
|
// second block under every card would make the collision case invisible.
|
||||||
|
p := dreamProvider{dict: openFixture(t), native: "pt-PT"}
|
||||||
|
res, err := p.Lookup("ephemeral")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Lookup: %v", err)
|
||||||
|
}
|
||||||
|
if res.Reverse != nil {
|
||||||
|
t.Fatalf("Reverse = %+v, want none for a word that is only English", res.Reverse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDreamGlossCarriesTheReverseReading(t *testing.T) {
|
||||||
|
// The hover tooltip takes the same both-directions rule in one line less
|
||||||
|
// space: the reverse *gloss* only, never the definitions.
|
||||||
|
p := dreamProvider{dict: openFixture(t), native: "pt-PT"}
|
||||||
|
|
||||||
|
g, err := p.Gloss("data")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Gloss: %v", err)
|
||||||
|
}
|
||||||
|
if g.Reverse != "date" {
|
||||||
|
t.Errorf("Gloss.Reverse = %q, want the English meaning of the Portuguese word", g.Reverse)
|
||||||
|
}
|
||||||
|
|
||||||
|
g, err = p.Gloss("ephemeral")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Gloss: %v", err)
|
||||||
|
}
|
||||||
|
if g.Reverse != "" {
|
||||||
|
t.Errorf("Gloss.Reverse = %q, want none for an English-only word", g.Reverse)
|
||||||
|
}
|
||||||
|
if g.Gloss != "efémero; passageiro" {
|
||||||
|
t.Errorf("Gloss = %q, want the forward gloss untouched", g.Gloss)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReverseIsSilentForTheEmbeddedProviders(t *testing.T) {
|
||||||
|
// The zh pair has no collisions and no DreamDict, and a writer with no
|
||||||
|
// dict.db at all falls through to `glossless`. Neither may start emitting a
|
||||||
|
// reverse block: the card would then claim a Chinese reading of an English
|
||||||
|
// word, which is worse than saying nothing.
|
||||||
|
set := NewSet(nil)
|
||||||
|
|
||||||
|
res, err := set.For(LangZh).Lookup("river")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Lookup: %v", err)
|
||||||
|
}
|
||||||
|
if res.Reverse != nil {
|
||||||
|
t.Errorf("embedded Reverse = %+v, want none", res.Reverse)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err = set.For("pt-PT").Lookup("river")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Lookup: %v", err)
|
||||||
|
}
|
||||||
|
if res.Reverse != nil {
|
||||||
|
t.Errorf("glossless Reverse = %+v, want none", res.Reverse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,6 +47,35 @@ type Result struct {
|
|||||||
// Latin roots with English — "ephemeral" is much easier to keep once you
|
// Latin roots with English — "ephemeral" is much easier to keep once you
|
||||||
// have seen efémero next to it.
|
// have seen efémero next to it.
|
||||||
Etymology string `json:"etymology"`
|
Etymology string `json:"etymology"`
|
||||||
|
|
||||||
|
// Reverse is the same token read as a word of the writer's own language,
|
||||||
|
// present only when it is one. Absent for every writer whose pair is not
|
||||||
|
// Latin-script, and for the overwhelming majority of words in one that is.
|
||||||
|
Reverse *Reverse `json:"reverse,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse is a lookup in the other direction: the token treated as a word of the
|
||||||
|
// writer's language, translated into English.
|
||||||
|
//
|
||||||
|
// It exists because a Latin-script pair has no script boundary to tell the two
|
||||||
|
// halves apart. In English+Chinese, "which language is this word?" answers
|
||||||
|
// itself. In English+Portuguese it does not: *sale*, *casa*, *comum*, *tarde*
|
||||||
|
// and *ali* are all real words on both sides, and *chat* and *pain* are the
|
||||||
|
// French versions of the same trap.
|
||||||
|
//
|
||||||
|
// Petal does not guess. It asks both directions and shows whatever comes back,
|
||||||
|
// which needs no detector, cannot be wrong about someone's writing, and — for a
|
||||||
|
// learner — is more interesting than a correct guess would have been.
|
||||||
|
type Reverse struct {
|
||||||
|
// Lang is the language this reading is in, so the card can label it.
|
||||||
|
Lang string `json:"lang"`
|
||||||
|
// Gloss is the English meaning of the native-language word.
|
||||||
|
Gloss string `json:"gloss"`
|
||||||
|
// Definitions are the word's senses as written in the writer's own
|
||||||
|
// language — the monolingual half, for when the English gloss isn't enough.
|
||||||
|
Definitions []Meaning `json:"definitions,omitempty"`
|
||||||
|
// Phonetic is IPA for the native-language pronunciation; "" when absent.
|
||||||
|
Phonetic string `json:"phonetic,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// unknownDifficulty is the [Result.Difficulty] value meaning "no score",
|
// unknownDifficulty is the [Result.Difficulty] value meaning "no score",
|
||||||
@@ -59,6 +88,10 @@ const unknownDifficulty = -1
|
|||||||
type GlossResult struct {
|
type GlossResult struct {
|
||||||
Word string `json:"word"`
|
Word string `json:"word"`
|
||||||
Gloss string `json:"gloss"`
|
Gloss string `json:"gloss"`
|
||||||
|
// Reverse is the English meaning of the word read as one of the writer's
|
||||||
|
// own language — the tooltip's half of the both-directions rule (see
|
||||||
|
// [Reverse]). Empty unless the token is a word in her language too.
|
||||||
|
Reverse string `json:"reverse,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -8,8 +8,18 @@
|
|||||||
// so it belongs to her account rather than to a browser profile.
|
// so it belongs to her account rather than to a browser profile.
|
||||||
//
|
//
|
||||||
// Everything here is scoped by `lang` as well as by user. That is the language
|
// Everything here is scoped by `lang` as well as by user. That is the language
|
||||||
// of the *dictionary* that flagged the word, not the writer's own language: an
|
// of the *dictionary* the word was accepted against, not the writer's own.
|
||||||
// en-US personal word must not silence a pt-PT flag once the second pair ships.
|
//
|
||||||
|
// Phase 18 justified that key by saying an en-US personal word must not silence
|
||||||
|
// a pt-PT flag once the second pair shipped. Phase 21 shipped it and the
|
||||||
|
// justification did not survive: under the both-dictionaries rule
|
||||||
|
// (SUGGESTIONS.md §3a) a word is only ever flagged when *every* loaded
|
||||||
|
// dictionary rejected it, so there is no such thing as a pt-PT flag an English
|
||||||
|
// exception could silence. What the key is actually good for is narrower and
|
||||||
|
// still worth having — the rows say which dictionary each acceptance was made
|
||||||
|
// against, so a pair that later loses or gains a dictionary keeps a truthful
|
||||||
|
// record instead of one merged list of unknown provenance. The browser writes a
|
||||||
|
// row per loaded dictionary when she accepts a word; see useSpellChecker.
|
||||||
package spell
|
package spell
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -24,8 +34,9 @@ import (
|
|||||||
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultLang is the dictionary assumed when a caller doesn't name one. Only
|
// DefaultLang is the dictionary assumed when a caller doesn't name one. English
|
||||||
// en-US ships today; pt-PT arrives with the first Latin pair.
|
// is in every pair, so it is the safe assumption; pt-PT is named explicitly by
|
||||||
|
// the pt-PT pair's second dictionary.
|
||||||
const DefaultLang = "en"
|
const DefaultLang = "en"
|
||||||
|
|
||||||
// MaxWordLen bounds a single entry. A personal dictionary holds words, and a
|
// MaxWordLen bounds a single entry. A personal dictionary holds words, and a
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build Petal's browser pt-PT spelling dictionary from the LibreOffice Hunspell one.
|
||||||
|
|
||||||
|
Why this script exists at all
|
||||||
|
----------------------------
|
||||||
|
English is vendored the obvious way: `dictionary-en`'s `en.aff` + `en.dic` go
|
||||||
|
into web/public/dictionaries/en and nspell reads them in the browser. The plan
|
||||||
|
for Phase 21 said "Hunspell pt-PT vendored like en-US", and that turns out not to
|
||||||
|
work, for a measured reason.
|
||||||
|
|
||||||
|
nspell expands affixes **eagerly at load time** — it materialises every surface
|
||||||
|
form into a hash the moment you construct it. English gets away with this: ~50k
|
||||||
|
stems and a small rule set. European Portuguese does not. `pt_PT.aff` carries
|
||||||
|
1,340 affix rules (the full verb paradigm: six persons x a dozen tenses, plus
|
||||||
|
diminutives, plus productive prefixes) over 44,257 stems. Measured on this
|
||||||
|
machine, nspell needed ~340 MB of heap for the first 12,000 entries alone and had
|
||||||
|
not returned after three minutes on the whole file; extrapolated, it wants well
|
||||||
|
over a gigabyte. That is not something to hand a browser, still less a tablet.
|
||||||
|
|
||||||
|
So the expansion happens **here**, once, at build time, and the browser gets a
|
||||||
|
flat word list it can load with no affix machinery at all: 1,039,058 forms, 15 MB
|
||||||
|
of text, 2.7 MB gzipped, which nspell reads in ~0.8s using ~120 MB. The runtime
|
||||||
|
code path is then *identical* to English — same nspell, same interface — which is
|
||||||
|
the real prize. The aff we ship alongside keeps only the suggestion-shaping
|
||||||
|
directives (TRY/KEY/REP/MAP), so corrections still know that "cao" wants "ção"
|
||||||
|
and that a missing acute accent is a near miss.
|
||||||
|
|
||||||
|
Choosing the source
|
||||||
|
-------------------
|
||||||
|
npm's `dictionary-pt` is **not** European Portuguese. Both it and
|
||||||
|
`dictionary-pt-br` package VERO ("Verificador Ortográfico Livre", Brasil), so
|
||||||
|
vendoring the obvious npm name would have shipped Brazilian spellings under a
|
||||||
|
pt-PT label — exactly the pt-BR drift SUGGESTIONS.md §3 warns about, arriving
|
||||||
|
through the packaging rather than through the model.
|
||||||
|
|
||||||
|
The authentic dictionary is the Projecto Natura one (Universidade do Minho) that
|
||||||
|
LibreOffice ships and Debian packages as `hunspell-pt-pt`. Its aff declares
|
||||||
|
`LANG pt_PT`. Spot-checked against the built list, it accepts `receção`,
|
||||||
|
`húmido`, `telemóvel`, `autocarro`, `comboio`, `ótimo` and `pensámos`, and
|
||||||
|
rejects `recepção`, `úmido`, `ônibus` and `óptimo` — post-Acordo European
|
||||||
|
Portuguese, which is what a pt-PT writer should be held to.
|
||||||
|
|
||||||
|
Licensing: GPL-2 or LGPL-2.1 or MPL-1.1, (c) José João de Almeida, Rui Vilela,
|
||||||
|
Alberto Simões. The upstream copyright file is vendored beside the output.
|
||||||
|
|
||||||
|
Usage
|
||||||
|
-----
|
||||||
|
apt-get download hunspell-pt-pt # or take pt_PT.aff/.dic from LibreOffice
|
||||||
|
dpkg-deb -x hunspell-pt-pt_*.deb ptpt
|
||||||
|
python3 scripts/build_ptpt_dictionary.py \
|
||||||
|
ptpt/usr/share/hunspell/pt_PT.aff \
|
||||||
|
ptpt/usr/share/hunspell/pt_PT.dic \
|
||||||
|
web/public/dictionaries/pt-PT
|
||||||
|
|
||||||
|
Only the directives pt_PT.aff actually uses are implemented: PFX/SFX with
|
||||||
|
single-character flags, strip/append/condition, and cross-product. It carries no
|
||||||
|
compounding, no flag aliases and no NEEDAFFIX, so there is nothing else to
|
||||||
|
honour — the script asserts that rather than assuming it.
|
||||||
|
"""
|
||||||
|
import gzip
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Directives worth keeping in the shipped aff. These shape *suggestions*, not
|
||||||
|
# membership: TRY orders the alphabet the corrector tries, KEY knows which keys
|
||||||
|
# are adjacent, REP holds Portuguese-specific confusions (cao/ção, ss/ç), and MAP
|
||||||
|
# says an accented vowel and its bare form are the same letter for scoring —
|
||||||
|
# which is most of what an ESL writer gets wrong in Portuguese.
|
||||||
|
KEEP_DIRECTIVES = ("SET", "TRY", "KEY", "REP", "MAP", "WORDCHARS")
|
||||||
|
|
||||||
|
# Directives that would change which words are *accepted*. If a future upstream
|
||||||
|
# release starts using one, this script's output would silently disagree with
|
||||||
|
# the real dictionary, so it stops instead.
|
||||||
|
UNSUPPORTED = (
|
||||||
|
"COMPOUNDFLAG", "COMPOUNDMIN", "COMPOUNDRULE", "COMPOUNDBEGIN",
|
||||||
|
"ONLYINCOMPOUND", "NEEDAFFIX", "PSEUDOROOT", "CIRCUMFIX",
|
||||||
|
"FORBIDDENWORD", "AF", "AM", "FLAG",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_aff(path):
|
||||||
|
"""Return (prefix rules, suffix rules, cross-product flags) keyed by flag."""
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
lines = fh.read().splitlines()
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
head = line.split()[0] if line.split() else ""
|
||||||
|
if head in UNSUPPORTED:
|
||||||
|
raise SystemExit(
|
||||||
|
f"{path}: unsupported directive {head!r} — this expander only "
|
||||||
|
"handles plain PFX/SFX affixation, and honouring it would change "
|
||||||
|
"which words are accepted. Extend the script before shipping."
|
||||||
|
)
|
||||||
|
|
||||||
|
pfx, sfx, cross = {}, {}, {}
|
||||||
|
i = 0
|
||||||
|
while i < len(lines):
|
||||||
|
parts = lines[i].split()
|
||||||
|
if parts and parts[0] in ("PFX", "SFX"):
|
||||||
|
kind, flag, cross_flag, count = parts[0], parts[1], parts[2], int(parts[3])
|
||||||
|
table = pfx if kind == "PFX" else sfx
|
||||||
|
cross[flag] = cross_flag == "Y"
|
||||||
|
rules = table.setdefault(flag, [])
|
||||||
|
for j in range(1, count + 1):
|
||||||
|
p = lines[i + j].split()
|
||||||
|
strip = "" if p[2] == "0" else p[2]
|
||||||
|
# The appended text may carry its own continuation flags after a
|
||||||
|
# slash (append/FLAGS). We drop them: honouring them would mean
|
||||||
|
# affixing an affixed form, which pt_PT.aff does not do.
|
||||||
|
append = "" if p[3] == "0" else p[3].split("/")[0]
|
||||||
|
cond = p[4] if len(p) > 4 else "."
|
||||||
|
anchored = ("^" + cond) if kind == "PFX" else (cond + "$")
|
||||||
|
rules.append((strip, append, re.compile(anchored)))
|
||||||
|
i += count + 1
|
||||||
|
continue
|
||||||
|
i += 1
|
||||||
|
return pfx, sfx, cross
|
||||||
|
|
||||||
|
|
||||||
|
def apply_suffix(word, rules):
|
||||||
|
out = []
|
||||||
|
for strip, append, cond in rules:
|
||||||
|
if strip and not word.endswith(strip):
|
||||||
|
continue
|
||||||
|
if not cond.search(word):
|
||||||
|
continue
|
||||||
|
stem = word[: len(word) - len(strip)] if strip else word
|
||||||
|
out.append(stem + append)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def apply_prefix(word, rules):
|
||||||
|
out = []
|
||||||
|
for strip, append, cond in rules:
|
||||||
|
if strip and not word.startswith(strip):
|
||||||
|
continue
|
||||||
|
if not cond.search(word):
|
||||||
|
continue
|
||||||
|
stem = word[len(strip):] if strip else word
|
||||||
|
out.append(append + stem)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def expand(aff_path, dic_path):
|
||||||
|
pfx, sfx, cross = parse_aff(aff_path)
|
||||||
|
forms = set()
|
||||||
|
with open(dic_path, encoding="utf-8") as fh:
|
||||||
|
fh.readline() # leading entry count, not a word
|
||||||
|
for raw in fh:
|
||||||
|
entry = raw.strip().split("\t")[0] # drop morphological fields
|
||||||
|
if not entry:
|
||||||
|
continue
|
||||||
|
word, _, flagstr = entry.partition("/")
|
||||||
|
word = word.strip()
|
||||||
|
if not word:
|
||||||
|
continue
|
||||||
|
flags = set(flagstr.strip())
|
||||||
|
forms.add(word)
|
||||||
|
|
||||||
|
for f in flags:
|
||||||
|
if f in sfx:
|
||||||
|
forms.update(apply_suffix(word, sfx[f]))
|
||||||
|
|
||||||
|
prefixed = []
|
||||||
|
for f in flags:
|
||||||
|
if f in pfx:
|
||||||
|
prefixed.extend(apply_prefix(word, pfx[f]))
|
||||||
|
forms.update(prefixed)
|
||||||
|
|
||||||
|
# Cross-product: a prefixed form may also take a suffix, but only
|
||||||
|
# when both rules are declared cross-product ("Y").
|
||||||
|
for f in flags:
|
||||||
|
if f in pfx and cross.get(f):
|
||||||
|
for base in apply_prefix(word, pfx[f]):
|
||||||
|
for g in flags:
|
||||||
|
if g in sfx and cross.get(g):
|
||||||
|
forms.update(apply_suffix(base, sfx[g]))
|
||||||
|
return forms
|
||||||
|
|
||||||
|
|
||||||
|
def shipped_aff(aff_path):
|
||||||
|
keep = []
|
||||||
|
for line in open(aff_path, encoding="utf-8").read().splitlines():
|
||||||
|
head = line.split()[0] if line.split() else ""
|
||||||
|
if head in KEEP_DIRECTIVES:
|
||||||
|
keep.append(line)
|
||||||
|
return "\n".join(keep) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
# Words the built list must accept, and must reject, before it is written. These
|
||||||
|
# are the pt-PT/pt-BR fault lines: post-Acordo spellings, the European lexicon,
|
||||||
|
# and the first-person-plural preterite accent that only pt-PT writes. A source
|
||||||
|
# dictionary that fails these is not the one this script is for.
|
||||||
|
MUST_ACCEPT = ("receção", "húmido", "telemóvel", "autocarro", "comboio",
|
||||||
|
"ótimo", "pensámos", "escrevêssemos", "jardim")
|
||||||
|
MUST_REJECT = ("recepção", "úmido", "ônibus", "óptimo")
|
||||||
|
|
||||||
|
|
||||||
|
def main(aff_path, dic_path, out_dir):
|
||||||
|
forms = expand(aff_path, dic_path)
|
||||||
|
|
||||||
|
missing = [w for w in MUST_ACCEPT if w not in forms]
|
||||||
|
present = [w for w in MUST_REJECT if w in forms]
|
||||||
|
if missing or present:
|
||||||
|
raise SystemExit(
|
||||||
|
"this does not look like European Portuguese: "
|
||||||
|
f"missing {missing}, unexpectedly present {present}"
|
||||||
|
)
|
||||||
|
|
||||||
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
|
ordered = sorted(forms)
|
||||||
|
body = f"{len(ordered)}\n" + "\n".join(ordered) + "\n"
|
||||||
|
|
||||||
|
dic_out = os.path.join(out_dir, "pt-PT.dic.gz")
|
||||||
|
# mtime=0 so rebuilding identical input produces an identical file — a
|
||||||
|
# vendored asset that changes on every build is noise in the diff.
|
||||||
|
with gzip.GzipFile(dic_out, "wb", compresslevel=9, mtime=0) as fh:
|
||||||
|
fh.write(body.encode("utf-8"))
|
||||||
|
|
||||||
|
aff_out = os.path.join(out_dir, "pt-PT.aff")
|
||||||
|
with open(aff_out, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(shipped_aff(aff_path))
|
||||||
|
|
||||||
|
print(f"{len(ordered)} forms -> {dic_out} "
|
||||||
|
f"({os.path.getsize(dic_out) / 1e6:.2f} MB gzipped)", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) != 4:
|
||||||
|
raise SystemExit(__doc__.strip().splitlines()[-1])
|
||||||
|
main(*sys.argv[1:4])
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
European Portuguese spelling dictionary
|
||||||
|
=======================================
|
||||||
|
|
||||||
|
The word list in `pt-PT.dic.gz` and the suggestion directives in `pt-PT.aff` are
|
||||||
|
derived from the LibreOffice/Projecto Natura Hunspell dictionary for European
|
||||||
|
Portuguese (`pt_PT.aff` / `pt_PT.dic`), as packaged by Debian/Ubuntu in
|
||||||
|
`hunspell-pt-pt`.
|
||||||
|
|
||||||
|
Copyright (C) 2006-2012 José João de Almeida <jj@di.uminho.pt>
|
||||||
|
Rui Vilela <ruivilela@di.uminho.pt>
|
||||||
|
Alberto Simões <ambs@di.uminho.pt>
|
||||||
|
Universidade do Minho — Projecto Natura
|
||||||
|
|
||||||
|
License: GPL-2 or LGPL-2.1 or MPL-1.1
|
||||||
|
(Petal redistributes it under the MPL-1.1 option.)
|
||||||
|
|
||||||
|
Upstream: https://natura.di.uminho.pt/ — via
|
||||||
|
https://git.libreoffice.org/dictionaries/+/refs/heads/master/pt_PT
|
||||||
|
|
||||||
|
What Petal changed
|
||||||
|
------------------
|
||||||
|
|
||||||
|
Nothing about which words are correct. `scripts/build_ptpt_dictionary.py`
|
||||||
|
applies the upstream affix rules ahead of time — Hunspell's PFX/SFX expansion
|
||||||
|
run once at build time instead of once per browser — and writes the resulting
|
||||||
|
1,039,058 surface forms as a flat word list. The shipped `.aff` keeps only
|
||||||
|
upstream's TRY/KEY/REP/MAP/WORDCHARS lines, which shape *corrections* rather
|
||||||
|
than membership. See that script's header for why the dictionary could not be
|
||||||
|
vendored in its original form.
|
||||||
|
|
||||||
|
Note that npm's `dictionary-pt` is *not* this dictionary: both it and
|
||||||
|
`dictionary-pt-br` package the Brazilian VERO word list.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
SET UTF-8
|
||||||
|
TRY aerisontcdmlupvgbfzáhçqjíxãóéêâúõACMPSBTELGRIFVDkHJONôywUKXZWQÁYÍÉàÓèÂÚ
|
||||||
|
KEY qwertyuiop|asdfghjkl|zxcvbnm
|
||||||
|
WORDCHARS -
|
||||||
|
REP 25
|
||||||
|
REP por pro
|
||||||
|
REP pre per
|
||||||
|
REP damente mente
|
||||||
|
REP mente damente
|
||||||
|
REP iz íz
|
||||||
|
REP cao ção
|
||||||
|
REP ç ss
|
||||||
|
REP ss ç
|
||||||
|
REP c ss
|
||||||
|
REP ss c
|
||||||
|
REP ch x
|
||||||
|
REP x ch
|
||||||
|
REP cç x
|
||||||
|
REP x cç
|
||||||
|
REP k qu
|
||||||
|
REP íti ití
|
||||||
|
REP ití íti
|
||||||
|
REP issí íssi
|
||||||
|
REP ilí íli
|
||||||
|
REP íli ilí
|
||||||
|
REP ífi ifí
|
||||||
|
REP ifí ífi
|
||||||
|
REP nume mune
|
||||||
|
REP coen quen
|
||||||
|
REP concerteza com_certeza
|
||||||
|
MAP 11
|
||||||
|
MAP aá
|
||||||
|
MAP aã
|
||||||
|
MAP aâ
|
||||||
|
MAP eé
|
||||||
|
MAP eê
|
||||||
|
MAP ií
|
||||||
|
MAP cç
|
||||||
|
MAP oó
|
||||||
|
MAP oô
|
||||||
|
MAP oõ
|
||||||
|
MAP uú
|
||||||
Binary file not shown.
@@ -78,12 +78,27 @@ export interface WordInfo {
|
|||||||
frequency: number
|
frequency: number
|
||||||
difficulty: number
|
difficulty: number
|
||||||
etymology: string // free-form, already trimmed to a line by the server; '' when absent
|
etymology: string // free-form, already trimmed to a line by the server; '' when absent
|
||||||
|
// The same token read as a word of the writer's own language, when it is one.
|
||||||
|
// Absent for a zh-pair writer and for almost every word in a Latin pair — see
|
||||||
|
// lexicon.Reverse for why Petal asks both directions instead of guessing.
|
||||||
|
reverse?: WordReverse
|
||||||
|
}
|
||||||
|
|
||||||
|
// A word looked up in the other direction: the writer's language -> English.
|
||||||
|
export interface WordReverse {
|
||||||
|
lang: string
|
||||||
|
gloss: string
|
||||||
|
definitions?: WordMeaning[]
|
||||||
|
phonetic?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// The lightweight Chinese-only gloss behind the inline hover/select tooltip.
|
// The lightweight Chinese-only gloss behind the inline hover/select tooltip.
|
||||||
export interface Gloss {
|
export interface Gloss {
|
||||||
word: string
|
word: string
|
||||||
gloss: string
|
gloss: string
|
||||||
|
// The English meaning of the token read as a word of her own language.
|
||||||
|
// Present only on a collision (Portuguese *sale*, French *chat*).
|
||||||
|
reverse?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SuggestionType = 'grammar' | 'phrasing' | 'idiom' | 'clarity' | 'voice' | 'collocation' | 'mechanics'
|
export type SuggestionType = 'grammar' | 'phrasing' | 'idiom' | 'clarity' | 'voice' | 'collocation' | 'mechanics'
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ 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 { speak, speechSupported } from '../../audio/speech'
|
||||||
import type { SpellChecker } from '../../hooks/useSpellChecker'
|
import type { SpellChecker } from '../../hooks/useSpellChecker'
|
||||||
|
import { usePack } from '../../i18n'
|
||||||
|
|
||||||
export interface EditorChange {
|
export interface EditorChange {
|
||||||
content: string // Tiptap JSON, stringified
|
content: string // Tiptap JSON, stringified
|
||||||
@@ -176,6 +177,8 @@ interface HoverState {
|
|||||||
interface GlossState {
|
interface GlossState {
|
||||||
word: string
|
word: string
|
||||||
gloss: string
|
gloss: string
|
||||||
|
// The other reading, when the token is a word in her language too.
|
||||||
|
reverse?: string
|
||||||
from: number
|
from: number
|
||||||
to: number
|
to: number
|
||||||
top: number
|
top: number
|
||||||
@@ -222,6 +225,9 @@ export function EditorCore({
|
|||||||
spellChecker,
|
spellChecker,
|
||||||
onAddWord,
|
onAddWord,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
// Her pair's copy — the hover tip labels the second reading with the language's
|
||||||
|
// own name, so it says "português" rather than "pt-PT".
|
||||||
|
const pack = usePack()
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
const [hover, setHover] = useState<HoverState | null>(null)
|
const [hover, setHover] = useState<HoverState | null>(null)
|
||||||
// The open spelling popover (click a red-underlined word), or null.
|
// The open spelling popover (click a red-underlined word), or null.
|
||||||
@@ -368,6 +374,13 @@ export function EditorCore({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [docId, editor])
|
}, [docId, editor])
|
||||||
|
|
||||||
|
// Which letters count as part of a word. The spell checker owns the answer,
|
||||||
|
// because it depends on which dictionaries this writer's pair loaded — see
|
||||||
|
// SpellCheck's wordRe. Every surface that resolves "the word under here"
|
||||||
|
// (lookup, gloss, right-click) has to agree with the underline, or the
|
||||||
|
// popover would offer a definition of "cora".
|
||||||
|
const wordAlphabet = spellChecker?.extendedAlphabet ?? false
|
||||||
|
|
||||||
// Push the spell checker into its decoration plugin once the dictionary loads
|
// Push the spell checker into its decoration plugin once the dictionary loads
|
||||||
// (and again whenever the personal dictionary changes its identity).
|
// (and again whenever the personal dictionary changes its identity).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -590,7 +603,7 @@ export function EditorCore({
|
|||||||
const openMisspellAt = useCallback(
|
const openMisspellAt = useCallback(
|
||||||
(pos: number): boolean => {
|
(pos: number): boolean => {
|
||||||
if (!editor || !spellChecker) return false
|
if (!editor || !spellChecker) return false
|
||||||
const range = wordAt(editor.state.doc, pos)
|
const range = wordAt(editor.state.doc, pos, spellChecker.extendedAlphabet)
|
||||||
if (!range || spellChecker.correct(range.word)) return false
|
if (!range || spellChecker.correct(range.word)) return false
|
||||||
const wrapper = wrapperRef.current
|
const wrapper = wrapperRef.current
|
||||||
if (!wrapper) return false
|
if (!wrapper) return false
|
||||||
@@ -679,7 +692,7 @@ export function EditorCore({
|
|||||||
const openWordLookup = useCallback(
|
const openWordLookup = useCallback(
|
||||||
(pos: number) => {
|
(pos: number) => {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
const range = wordAt(editor.state.doc, pos)
|
const range = wordAt(editor.state.doc, pos, wordAlphabet)
|
||||||
if (!range) return
|
if (!range) return
|
||||||
const wrapper = wrapperRef.current
|
const wrapper = wrapperRef.current
|
||||||
if (!wrapper) return
|
if (!wrapper) return
|
||||||
@@ -784,13 +797,13 @@ export function EditorCore({
|
|||||||
if (!editor) return
|
if (!editor) return
|
||||||
const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY })
|
const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY })
|
||||||
if (!coords) return
|
if (!coords) return
|
||||||
if (!wordAt(editor.state.doc, coords.pos)) return
|
if (!wordAt(editor.state.doc, coords.pos, wordAlphabet)) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
// A misspelled word offers corrections first; otherwise look it up.
|
// A misspelled word offers corrections first; otherwise look it up.
|
||||||
if (openMisspellAt(coords.pos)) return
|
if (openMisspellAt(coords.pos)) return
|
||||||
openWordLookup(coords.pos)
|
openWordLookup(coords.pos)
|
||||||
},
|
},
|
||||||
[editor, openMisspellAt, openWordLookup],
|
[editor, wordAlphabet, openMisspellAt, openWordLookup],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Touch has no hover or right-click, so a long-press (~500ms without moving)
|
// Touch has no hover or right-click, so a long-press (~500ms without moving)
|
||||||
@@ -844,7 +857,7 @@ export function EditorCore({
|
|||||||
clear()
|
clear()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const range = wordAt(editor.state.doc, coords.pos)
|
const range = wordAt(editor.state.doc, coords.pos, wordAlphabet)
|
||||||
if (!range) {
|
if (!range) {
|
||||||
clear()
|
clear()
|
||||||
return
|
return
|
||||||
@@ -859,7 +872,9 @@ export function EditorCore({
|
|||||||
.then((g) => {
|
.then((g) => {
|
||||||
if (token !== glossReqRef.current) return
|
if (token !== glossReqRef.current) return
|
||||||
const wrapper = wrapperRef.current
|
const wrapper = wrapperRef.current
|
||||||
if (!g.gloss || !wrapper) {
|
// A token can have only the reverse reading — a Portuguese word she
|
||||||
|
// hovers in her own sentence — and that is still worth a tooltip.
|
||||||
|
if ((!g.gloss && !g.reverse) || !wrapper) {
|
||||||
setGloss(null)
|
setGloss(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -868,14 +883,14 @@ export function EditorCore({
|
|||||||
const wrapRect = wrapper.getBoundingClientRect()
|
const wrapRect = wrapper.getBoundingClientRect()
|
||||||
const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - 280))
|
const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - 280))
|
||||||
const top = end.bottom - wrapRect.top + 6
|
const top = end.bottom - wrapRect.top + 6
|
||||||
setGloss({ word: range.word, gloss: g.gloss, from: range.from, to: range.to, top, left })
|
setGloss({ word: range.word, gloss: g.gloss, reverse: g.reverse, from: range.from, to: range.to, top, left })
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (token === glossReqRef.current) setGloss(null)
|
if (token === glossReqRef.current) setGloss(null)
|
||||||
})
|
})
|
||||||
}, 350)
|
}, 350)
|
||||||
},
|
},
|
||||||
[editor, selection, rewrite, misspell, wordInfo, pinned, gloss],
|
[editor, wordAlphabet, selection, rewrite, misspell, wordInfo, pinned, gloss],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Leaving the editor surface drops any pending/shown gloss.
|
// Leaving the editor surface drops any pending/shown gloss.
|
||||||
@@ -1081,7 +1096,14 @@ export function EditorCore({
|
|||||||
<EditorContent editor={editor} className="h-full" />
|
<EditorContent editor={editor} className="h-full" />
|
||||||
{findOpen && editor && <FindReplace editor={editor} onClose={() => setFindOpen(false)} />}
|
{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 }} />}
|
{gloss && (
|
||||||
|
<GlossTip
|
||||||
|
gloss={gloss.gloss}
|
||||||
|
reverse={gloss.reverse}
|
||||||
|
reverseLang={pack.nativeName}
|
||||||
|
style={{ top: gloss.top, left: gloss.left }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{selection && !rewrite && !dragging && (
|
{selection && !rewrite && !dragging && (
|
||||||
<SelectionBubble
|
<SelectionBubble
|
||||||
style={{ top: selection.top, left: selection.left, transform: 'translateY(calc(-100% - 8px))' }}
|
style={{ top: selection.top, left: selection.left, transform: 'translateY(calc(-100% - 8px))' }}
|
||||||
|
|||||||
@@ -7,10 +7,17 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
gloss: string
|
gloss: string
|
||||||
|
// The English meaning of the same token read as a word of the writer's own
|
||||||
|
// language, when it is one. On a Latin-script pair "sale" is both, and the
|
||||||
|
// bubble shows the two readings stacked rather than picking one — the same
|
||||||
|
// both-directions rule the word card follows, in one line less space.
|
||||||
|
reverse?: string
|
||||||
|
// How the writer's language names itself, to label the second line.
|
||||||
|
reverseLang?: string
|
||||||
style: React.CSSProperties
|
style: React.CSSProperties
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GlossTip({ gloss, style }: Props) {
|
export function GlossTip({ gloss, reverse, reverseLang, style }: Props) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="petal-gloss-tip pointer-events-none absolute z-20 px-2.5 py-1.5 text-sm"
|
className="petal-gloss-tip pointer-events-none absolute z-20 px-2.5 py-1.5 text-sm"
|
||||||
@@ -27,6 +34,12 @@ export function GlossTip({ gloss, style }: Props) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{gloss}
|
{gloss}
|
||||||
|
{reverse && (
|
||||||
|
<span className="mt-0.5 block" style={{ opacity: 0.72, fontSize: '0.85em' }}>
|
||||||
|
{reverseLang ? `${reverseLang}: ` : ''}
|
||||||
|
{reverse}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { Schema, type Node as PMNode } from '@tiptap/pm/model'
|
||||||
|
import { wordAt } from './SpellCheck'
|
||||||
|
|
||||||
|
// Same minimal schema the suggestion-anchoring tests use: enough of a document
|
||||||
|
// to walk textblocks, none of the editor.
|
||||||
|
const schema = new Schema({
|
||||||
|
nodes: {
|
||||||
|
doc: { content: 'block+' },
|
||||||
|
paragraph: { group: 'block', content: 'inline*', toDOM: () => ['p', 0] },
|
||||||
|
text: { group: 'inline' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const para = (text: string): PMNode =>
|
||||||
|
schema.node('doc', null, [schema.node('paragraph', null, text ? [schema.text(text)] : [])])
|
||||||
|
|
||||||
|
// posOf turns a plain-text offset into a ProseMirror position inside the single
|
||||||
|
// paragraph (+1 for the paragraph's opening token).
|
||||||
|
const posOf = (offset: number) => offset + 1
|
||||||
|
|
||||||
|
describe('wordAt and the pair alphabet', () => {
|
||||||
|
it('keeps English-only tokenizing when no accented dictionary is loaded', () => {
|
||||||
|
const doc = para('the river runs')
|
||||||
|
expect(wordAt(doc, posOf(5))?.word).toBe('river')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cuts an accented word into fragments on the narrow alphabet', () => {
|
||||||
|
// Not a hypothetical: this is what every surface did before the pt-PT pair,
|
||||||
|
// and it is why the alphabet had to become a property of the checker rather
|
||||||
|
// than a constant. "coração" tokenized as A-Z yields "cora" — a definition
|
||||||
|
// of which would be worse than no popover.
|
||||||
|
const doc = para('o coração dela')
|
||||||
|
expect(wordAt(doc, posOf(3))?.word).toBe('cora')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves the whole word once the pair widens the alphabet', () => {
|
||||||
|
const doc = para('o coração dela')
|
||||||
|
expect(wordAt(doc, posOf(3), true)?.word).toBe('coração')
|
||||||
|
// …from either side of the accented letters, not just before them.
|
||||||
|
expect(wordAt(doc, posOf(9), true)?.word).toBe('coração')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never tokenizes CJK, whichever alphabet is in force', () => {
|
||||||
|
// The zh pair's guarantee, and it must survive a change made for another
|
||||||
|
// pair entirely: Chinese is the source language, not something to spellcheck.
|
||||||
|
const doc = para('我在写作 today')
|
||||||
|
expect(wordAt(doc, posOf(1))).toBeNull()
|
||||||
|
expect(wordAt(doc, posOf(1), true)).toBeNull()
|
||||||
|
expect(wordAt(doc, posOf(6), true)?.word).toBe('today')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stops the wide alphabet at the maths symbols hiding in Latin-1', () => {
|
||||||
|
// × (U+00D7) and ÷ (U+00F7) sit inside the accented-letter block. A range
|
||||||
|
// written À-ÿ would swallow them and glue "3×4" into one token.
|
||||||
|
const doc = para('3×4 é isso')
|
||||||
|
expect(wordAt(doc, posOf(4), true)?.word).toBe('é')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -22,7 +22,25 @@ interface PluginState {
|
|||||||
|
|
||||||
// A word is a run of Latin letters with optional internal/edge apostrophes
|
// A word is a run of Latin letters with optional internal/edge apostrophes
|
||||||
// (don't, O'Brien). Anything else — digits, punctuation, CJK — terminates a run.
|
// (don't, O'Brien). Anything else — digits, punctuation, CJK — terminates a run.
|
||||||
|
//
|
||||||
|
// Two alphabets, because the writer's pair decides which is right. English needs
|
||||||
|
// only A-Z. European Portuguese needs ç and the accented vowels, and tokenizing
|
||||||
|
// "ação" without them yields "a" and "o" — two fragments short enough that
|
||||||
|
// isCheckable throws them away, so the word is silently never checked at all.
|
||||||
|
//
|
||||||
|
// The narrow alphabet stays the default rather than always widening: for a
|
||||||
|
// writer with no Latin second language, adding accented letters can only find
|
||||||
|
// new words to underline (the "café" and "naïve" she borrows), and finds no
|
||||||
|
// mistakes she has actually made.
|
||||||
const WORD_RE = /[A-Za-z][A-Za-z']*/g
|
const WORD_RE = /[A-Za-z][A-Za-z']*/g
|
||||||
|
const WORD_RE_LATIN = /[A-Za-zÀ-ÖØ-öø-ÿ][A-Za-zÀ-ÖØ-öø-ÿ']*/g
|
||||||
|
|
||||||
|
// wordRe returns a fresh matcher for the alphabet in force. Fresh because these
|
||||||
|
// are /g regexes carrying lastIndex, and two scans sharing one would interleave.
|
||||||
|
function wordRe(extended: boolean): RegExp {
|
||||||
|
const src = extended ? WORD_RE_LATIN : WORD_RE
|
||||||
|
return new RegExp(src.source, 'g')
|
||||||
|
}
|
||||||
|
|
||||||
// isCheckable filters tokens we shouldn't flag: single letters and all-caps
|
// isCheckable filters tokens we shouldn't flag: single letters and all-caps
|
||||||
// acronyms (NASA, USA), which dictionaries reliably miss and which read as noise
|
// acronyms (NASA, USA), which dictionaries reliably miss and which read as noise
|
||||||
@@ -47,12 +65,13 @@ function eachMisspelling(
|
|||||||
checker: SpellChecker,
|
checker: SpellChecker,
|
||||||
visit: (from: number, to: number, word: string) => void,
|
visit: (from: number, to: number, word: string) => void,
|
||||||
) {
|
) {
|
||||||
|
const re = wordRe(checker.extendedAlphabet)
|
||||||
doc.descendants((node, pos) => {
|
doc.descendants((node, pos) => {
|
||||||
if (!node.isTextblock) return true
|
if (!node.isTextblock) return true
|
||||||
const text = node.textContent
|
const text = node.textContent
|
||||||
WORD_RE.lastIndex = 0
|
re.lastIndex = 0
|
||||||
let m: RegExpExecArray | null
|
let m: RegExpExecArray | null
|
||||||
while ((m = WORD_RE.exec(text)) !== null) {
|
while ((m = re.exec(text)) !== null) {
|
||||||
const { core, lead } = coreOf(m[0])
|
const { core, lead } = coreOf(m[0])
|
||||||
if (!isCheckable(core) || checker.correct(core)) continue
|
if (!isCheckable(core) || checker.correct(core)) continue
|
||||||
const from = mapOffset(node, pos, m.index + lead)
|
const from = mapOffset(node, pos, m.index + lead)
|
||||||
@@ -78,16 +97,25 @@ function buildDecorations(doc: PMNode, checker: SpellChecker, cursor: number): D
|
|||||||
// click), returning its range + text so the card can offer corrections and the
|
// click), returning its range + text so the card can offer corrections and the
|
||||||
// replacement can target the exact span — robust to duplicate words anywhere
|
// replacement can target the exact span — robust to duplicate words anywhere
|
||||||
// else in the document. Returns null if the position isn't inside a Latin word.
|
// else in the document. Returns null if the position isn't inside a Latin word.
|
||||||
export function wordAt(doc: PMNode, pos: number): { from: number; to: number; word: string } | null {
|
//
|
||||||
|
// `extended` widens the alphabet the same way the decoration pass does, so that
|
||||||
|
// right-clicking "coração" looks up the whole word rather than "cora". Callers
|
||||||
|
// pass the live checker's flag; the default keeps English-only behaviour.
|
||||||
|
export function wordAt(
|
||||||
|
doc: PMNode,
|
||||||
|
pos: number,
|
||||||
|
extended = false,
|
||||||
|
): { from: number; to: number; word: string } | null {
|
||||||
let found: { from: number; to: number; word: string } | null = null
|
let found: { from: number; to: number; word: string } | null = null
|
||||||
|
const re = wordRe(extended)
|
||||||
doc.descendants((node, nodePos) => {
|
doc.descendants((node, nodePos) => {
|
||||||
if (found) return false
|
if (found) return false
|
||||||
if (!node.isTextblock) return true
|
if (!node.isTextblock) return true
|
||||||
if (pos <= nodePos || pos >= nodePos + node.nodeSize) return false
|
if (pos <= nodePos || pos >= nodePos + node.nodeSize) return false
|
||||||
const text = node.textContent
|
const text = node.textContent
|
||||||
WORD_RE.lastIndex = 0
|
re.lastIndex = 0
|
||||||
let m: RegExpExecArray | null
|
let m: RegExpExecArray | null
|
||||||
while ((m = WORD_RE.exec(text)) !== null) {
|
while ((m = re.exec(text)) !== null) {
|
||||||
const { core, lead } = coreOf(m[0])
|
const { core, lead } = coreOf(m[0])
|
||||||
if (!core) continue
|
if (!core) continue
|
||||||
const from = mapOffset(node, nodePos, m.index + lead)
|
const from = mapOffset(node, nodePos, m.index + lead)
|
||||||
|
|||||||
@@ -28,9 +28,11 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
|
|||||||
const gloss = info?.gloss ?? ''
|
const gloss = info?.gloss ?? ''
|
||||||
const phonetic = info?.phonetic ?? ''
|
const phonetic = info?.phonetic ?? ''
|
||||||
const etymology = info?.etymology ?? ''
|
const etymology = info?.etymology ?? ''
|
||||||
|
// Present only when the token is also a word in her own language.
|
||||||
|
const reverse = info?.reverse ?? null
|
||||||
// Null whenever the dictionary has no opinion — the chip then doesn't render.
|
// Null whenever the dictionary has no opinion — the chip then doesn't render.
|
||||||
const band = info ? wordBand(info.frequency ?? 0, info.difficulty ?? -1) : null
|
const band = info ? wordBand(info.frequency ?? 0, info.difficulty ?? -1) : null
|
||||||
const empty = !loading && !gloss && definitions.length === 0 && synonyms.length === 0
|
const empty = !loading && !gloss && !reverse && definitions.length === 0 && synonyms.length === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -184,6 +186,37 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* The same word read as one of hers. Only a Latin-script pair ever sees
|
||||||
|
this — "sale" is English and Portuguese, "chat" is English and French —
|
||||||
|
and Petal shows both readings rather than deciding which she meant. A
|
||||||
|
detector would be right most of the time and wrong about her writing
|
||||||
|
the rest; two lines are right always, and for a learner the collision
|
||||||
|
is the interesting part. */}
|
||||||
|
{reverse && (
|
||||||
|
<div
|
||||||
|
className="mt-3 rounded-xl px-2.5 py-2"
|
||||||
|
style={{ background: 'var(--color-surface-alt)' }}
|
||||||
|
>
|
||||||
|
<p className="mb-1 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
{t.editor.alsoIn}
|
||||||
|
</p>
|
||||||
|
<p className="leading-snug" style={{ color: 'var(--color-plum)' }}>
|
||||||
|
{reverse.gloss || word}
|
||||||
|
{reverse.phonetic && (
|
||||||
|
<span className="ml-1.5 text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
/{reverse.phonetic}/
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{(reverse.definitions ?? []).map((m, i) => (
|
||||||
|
<p key={i} className="mt-1 text-xs leading-snug" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
{m.part_of_speech && <span className="mr-1 italic">{m.part_of_speech}</span>}
|
||||||
|
{m.definition}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Where the word came from. Last, and in small muted type, because it is
|
{/* Where the word came from. Last, and in small muted type, because it is
|
||||||
the one thing here that is interesting rather than useful — and for a
|
the one thing here that is interesting rather than useful — and for a
|
||||||
writer whose own language shares Latin roots with English, "efémero"
|
writer whose own language shares Latin roots with English, "efémero"
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { combine, interleave, type Loaded } from './useSpellChecker'
|
||||||
|
|
||||||
|
// The both-dictionaries rule (SUGGESTIONS.md §3a), separated from the fetching
|
||||||
|
// so it can be checked without a 15 MB word list. What matters here is not
|
||||||
|
// "does nspell work" but which way the combination is allowed to be wrong.
|
||||||
|
|
||||||
|
// A dictionary that accepts exactly the words it was given.
|
||||||
|
function dict(lang: string, words: string[], corrections: string[] = [], extendedAlphabet = false): Loaded {
|
||||||
|
const set = new Set(words)
|
||||||
|
return {
|
||||||
|
lang,
|
||||||
|
extendedAlphabet,
|
||||||
|
spell: {
|
||||||
|
correct: (w: string) => set.has(w),
|
||||||
|
suggest: () => corrections,
|
||||||
|
add: (w: string) => set.add(w),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const en = dict('en', ['sale', 'the', 'river'], ['sailed', 'salt'])
|
||||||
|
const pt = dict('pt-PT', ['sale', 'coração', 'jardim'], ['salte', 'sala'], true)
|
||||||
|
|
||||||
|
describe('the both-dictionaries rule', () => {
|
||||||
|
it('accepts a word either dictionary knows', () => {
|
||||||
|
const c = combine(() => [en, pt])
|
||||||
|
expect(c.correct('river')).toBe(true) // English only
|
||||||
|
expect(c.correct('jardim')).toBe(true) // Portuguese only
|
||||||
|
expect(c.correct('sale')).toBe(true) // both — the collision case
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags only what every dictionary rejects', () => {
|
||||||
|
expect(combine(() => [en, pt]).correct('qqzzx')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never flags a Portuguese word just because English has not heard of it', () => {
|
||||||
|
// The property the whole design exists for. With English alone, "coração"
|
||||||
|
// is a misspelling; with her own dictionary loaded it is a word she wrote.
|
||||||
|
expect(combine(() => [en]).correct('coração')).toBe(false)
|
||||||
|
expect(combine(() => [en, pt]).correct('coração')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts everything when no dictionary loaded', () => {
|
||||||
|
// A failed fetch must not underline every word in the document. Silence is
|
||||||
|
// the safe failure; a page of red is not.
|
||||||
|
expect(combine(() => []).correct('qqzzx')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sees a dictionary that arrives after the checker was built', () => {
|
||||||
|
// English loads immediately; hers lands a moment later, once /api/me has
|
||||||
|
// named her pair. The checker reads through a getter for exactly this.
|
||||||
|
let loaded: Loaded[] = [en]
|
||||||
|
const c = combine(() => loaded)
|
||||||
|
expect(c.correct('jardim')).toBe(false)
|
||||||
|
expect(c.extendedAlphabet).toBe(false)
|
||||||
|
loaded = [en, pt]
|
||||||
|
expect(c.correct('jardim')).toBe(true)
|
||||||
|
expect(c.extendedAlphabet).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('correction pills', () => {
|
||||||
|
it('interleaves the two dictionaries rather than letting one fill the list', () => {
|
||||||
|
// Five pills fit. Concatenating would spend all of them on English and
|
||||||
|
// leave a misspelt Portuguese word with no Portuguese correction — the one
|
||||||
|
// case the second dictionary was loaded for.
|
||||||
|
expect(combine(() => [en, pt]).suggest('salle')).toEqual(['sailed', 'salte', 'salt', 'sala'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops duplicates, keeping the first dictionary to offer one', () => {
|
||||||
|
expect(interleave([['a', 'b'], ['a', 'c']])).toEqual(['a', 'b', 'c'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps going when one dictionary runs out of ideas', () => {
|
||||||
|
expect(interleave([['a'], ['x', 'y', 'z']])).toEqual(['a', 'x', 'y', 'z'])
|
||||||
|
expect(interleave([[], []])).toEqual([])
|
||||||
|
expect(interleave([])).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,37 +1,85 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import nspell, { type NSpell } from 'nspell'
|
import nspell from 'nspell'
|
||||||
import { api } from '../api/client'
|
import { api } from '../api/client'
|
||||||
|
import { usePack } from '../i18n'
|
||||||
|
import type { PairLang } from '../i18n'
|
||||||
|
|
||||||
// useSpellChecker loads the vendored en-US Hunspell dictionary (served from
|
// useSpellChecker builds the in-browser spell checker — zero backend round-trips
|
||||||
// /dictionaries/en, embedded in the Go binary via web/dist) and builds an
|
// per keystroke, per spec.
|
||||||
// in-browser nspell instance — zero backend round-trips, per spec. The
|
//
|
||||||
// dictionary is ~550KB, so it's fetched as a static asset (kept out of the JS
|
// English is always loaded, because English is always the target half of the
|
||||||
// bundle) once per app session, not per document.
|
// pair. The writer's own language is loaded too when Petal ships a dictionary
|
||||||
|
// for it, and then the two are consulted together: **a token is flagged only if
|
||||||
|
// both dictionaries reject it.**
|
||||||
|
//
|
||||||
|
// That rule is the answer to the one genuinely new problem a Latin-script pair
|
||||||
|
// creates (SUGGESTIONS.md §3a). The zh pair never had to decide which language a
|
||||||
|
// word was in — the script answers it, and CJK is simply never tokenized. In an
|
||||||
|
// English+Portuguese document both halves are Latin letters, and there is no
|
||||||
|
// honest way to look at "sale" and know which language it is. Asking both
|
||||||
|
// dictionaries needs no detector and no guess. It can miss a misspelling that
|
||||||
|
// happens to be a real word in the other language; it can never squiggle
|
||||||
|
// correct writing. That is the gentle direction to be wrong in.
|
||||||
//
|
//
|
||||||
// The personal word list — the words she's told Petal to stop flagging — lives
|
// The personal word list — the words she's told Petal to stop flagging — lives
|
||||||
// on the server, keyed by her account and by the dictionary's language. It used
|
// on the server, keyed by her account and by dictionary language. It is replayed
|
||||||
// to be one localStorage key, which meant two people sharing a device shared a
|
// into each nspell instance on load; adding a word bumps a `version` so consumers
|
||||||
// word list built from one person's private writing, and one person on a laptop
|
// re-run their decorations and the word stops flagging.
|
||||||
// and a tablet had two lists that never met. It is replayed into nspell on load;
|
|
||||||
// adding a word bumps a `version` so consumers re-run their decorations and the
|
|
||||||
// word stops flagging.
|
|
||||||
|
|
||||||
// SpellChecker is the minimal surface the editor decoration layer consumes.
|
// SpellChecker is the minimal surface the editor decoration layer consumes.
|
||||||
export interface SpellChecker {
|
export interface SpellChecker {
|
||||||
correct(word: string): boolean
|
correct(word: string): boolean
|
||||||
suggest(word: string): string[]
|
suggest(word: string): string[]
|
||||||
|
// Whether the loaded dictionaries need letters beyond A-Z. Portuguese words
|
||||||
|
// carry ç and five accented vowels; tokenizing without them would cut "ação"
|
||||||
|
// into fragments. It is a property of the checker rather than a constant
|
||||||
|
// because widening the alphabet for an English-only writer would only earn
|
||||||
|
// her new squiggles under the French and Portuguese words she borrows.
|
||||||
|
extendedAlphabet: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// The dictionary this hook loads. Only en-US ships today; pt-PT arrives with the
|
// A dictionary Petal can load into the browser.
|
||||||
// first Latin pair, and its personal words are a separate list by design — an
|
interface DictSpec {
|
||||||
// English exception must not silence a Portuguese flag.
|
// The key the personal word list is stored under. It is the dictionary's
|
||||||
const DICT_LANG = 'en'
|
// language, not the writer's.
|
||||||
|
lang: string
|
||||||
|
aff: string
|
||||||
|
dic: string
|
||||||
|
// Whether `dic` is gzipped. pt-PT's word list is 15 MB of text, so it ships
|
||||||
|
// compressed and is inflated here; en's 550 KB does not need it.
|
||||||
|
gzipped?: boolean
|
||||||
|
extendedAlphabet?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const EN: DictSpec = {
|
||||||
|
lang: 'en',
|
||||||
|
aff: '/dictionaries/en/en.aff',
|
||||||
|
dic: '/dictionaries/en/en.dic',
|
||||||
|
}
|
||||||
|
|
||||||
|
// The writer's-language dictionary, by pair. zh has no Hunspell dictionary and
|
||||||
|
// needs none: Chinese is not tokenized, so it is never flagged.
|
||||||
|
//
|
||||||
|
// pt-PT's word list is pre-expanded (see scripts/build_ptpt_dictionary.py) —
|
||||||
|
// nspell expands affixes eagerly on load, and doing that to European
|
||||||
|
// Portuguese's 1,340 rules in a browser wants over a gigabyte of heap. The
|
||||||
|
// forms are computed at build time instead, so this is the same nspell reading
|
||||||
|
// a bigger, simpler file.
|
||||||
|
const PAIR_DICTS: Partial<Record<PairLang, DictSpec>> = {
|
||||||
|
'pt-PT': {
|
||||||
|
lang: 'pt-PT',
|
||||||
|
aff: '/dictionaries/pt-PT/pt-PT.aff',
|
||||||
|
dic: '/dictionaries/pt-PT/pt-PT.dic.gz',
|
||||||
|
gzipped: true,
|
||||||
|
extendedAlphabet: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
// Where the list lived before it had an owner (Phase 7). Read once, handed to
|
// Where the list lived before it had an owner (Phase 7). Read once, handed to
|
||||||
// the account, and then removed — see takeLegacyWords.
|
// the account, and then removed — see readLegacyWords.
|
||||||
const LEGACY_KEY = 'petal.spell.personal'
|
const LEGACY_KEY = 'petal.spell.personal'
|
||||||
|
|
||||||
// takeLegacyWords reads the pre-account list without deleting it: the words are
|
// readLegacyWords reads the pre-account list without deleting it: the words are
|
||||||
// only dropped from the browser once the server has actually accepted them, so
|
// only dropped from the browser once the server has actually accepted them, so
|
||||||
// a failed request costs nothing.
|
// a failed request costs nothing.
|
||||||
function readLegacyWords(): string[] {
|
function readLegacyWords(): string[] {
|
||||||
@@ -52,72 +100,197 @@ function clearLegacyWords() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSpellChecker() {
|
// fetchText retrieves a dictionary file, inflating it when it ships gzipped.
|
||||||
const spellRef = useRef<NSpell | null>(null)
|
//
|
||||||
const [ready, setReady] = useState(false)
|
// DecompressionStream is used rather than a bundled inflate because it costs no
|
||||||
// Bumped whenever the personal dictionary changes, to force re-decoration.
|
// bytes and has been in every browser since 2023. A browser without it gets an
|
||||||
const [version, setVersion] = useState(0)
|
// exception, which the caller treats as "this dictionary didn't load" — the same
|
||||||
|
// outcome as a failed fetch.
|
||||||
|
async function fetchText(url: string, gzipped: boolean | undefined): Promise<string> {
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (!res.ok) throw new Error(`${url}: ${res.status}`)
|
||||||
|
if (!gzipped) return res.text()
|
||||||
|
if (!res.body) throw new Error(`${url}: no body to inflate`)
|
||||||
|
const stream = res.body.pipeThrough(new DecompressionStream('gzip'))
|
||||||
|
return new Response(stream).text()
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
// Dictionary is the three methods Petal asks of nspell. Naming them rather than
|
||||||
let cancelled = false
|
// referring to NSpell is what lets the decision logic below be exercised without
|
||||||
;(async () => {
|
// a 15 MB word list behind it; a real nspell instance satisfies this as-is.
|
||||||
try {
|
export interface Dictionary {
|
||||||
// Served from web/dist root (and embedded in the Go binary), same as /api.
|
correct(word: string): boolean
|
||||||
|
suggest(word: string): string[]
|
||||||
|
add(word: string): unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
// A dictionary that finished loading, with the language its personal words
|
||||||
|
// belong to.
|
||||||
|
export interface Loaded {
|
||||||
|
lang: string
|
||||||
|
spell: Dictionary
|
||||||
|
extendedAlphabet: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// load builds one nspell instance and replays this writer's personal words into
|
||||||
|
// it. `adoptLegacy` is passed only for English, and only on first load.
|
||||||
|
async function load(spec: DictSpec, adoptLegacy: boolean): Promise<Loaded> {
|
||||||
const [aff, dic] = await Promise.all([
|
const [aff, dic] = await Promise.all([
|
||||||
fetch('/dictionaries/en/en.aff').then((r) => r.text()),
|
fetchText(spec.aff, false),
|
||||||
fetch('/dictionaries/en/en.dic').then((r) => r.text()),
|
fetchText(spec.dic, spec.gzipped),
|
||||||
])
|
])
|
||||||
if (cancelled) return
|
const spell = nspell(aff, dic)
|
||||||
const sp = nspell(aff, dic)
|
|
||||||
spellRef.current = sp
|
|
||||||
setReady(true)
|
|
||||||
|
|
||||||
// Her own words come from her account. A browser holding a list from
|
// Her own words come from her account. A browser holding a list from before
|
||||||
// before accounts existed hands it over on the way — but only lets go of
|
// accounts existed hands it over on the way — but only lets go of it once the
|
||||||
// it once the server has taken it.
|
// server has taken it.
|
||||||
const legacy = readLegacyWords()
|
const legacy = adoptLegacy ? readLegacyWords() : []
|
||||||
const stored = legacy.length
|
const stored = legacy.length
|
||||||
? await api.addPersonalWords(DICT_LANG, legacy).then((res) => {
|
? await api.addPersonalWords(spec.lang, legacy).then((res) => {
|
||||||
clearLegacyWords()
|
clearLegacyWords()
|
||||||
return res
|
return res
|
||||||
})
|
})
|
||||||
: await api.listPersonalWords(DICT_LANG)
|
: await api.listPersonalWords(spec.lang)
|
||||||
if (cancelled) return
|
for (const w of stored.words) spell.add(w)
|
||||||
for (const w of stored.words) sp.add(w)
|
|
||||||
setVersion((v) => v + 1)
|
return { lang: spec.lang, spell, extendedAlphabet: spec.extendedAlphabet ?? false }
|
||||||
} catch (err) {
|
|
||||||
// A failure here costs correct words being flagged, not writing. The
|
|
||||||
// checker itself stays usable if only the word list failed to arrive.
|
|
||||||
console.error('spell checker failed to load', err)
|
|
||||||
}
|
}
|
||||||
})()
|
|
||||||
|
// interleave merges each dictionary's corrections round-robin. Concatenating
|
||||||
|
// would let English fill all five pills and leave a misspelt Portuguese word
|
||||||
|
// with no Portuguese suggestion, which is the case the second dictionary exists
|
||||||
|
// for.
|
||||||
|
export function interleave(lists: string[][]): string[] {
|
||||||
|
const out: string[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (let i = 0; i < Math.max(...lists.map((l) => l.length), 0); i++) {
|
||||||
|
for (const list of lists) {
|
||||||
|
const word = list[i]
|
||||||
|
if (word && !seen.has(word)) {
|
||||||
|
seen.add(word)
|
||||||
|
out.push(word)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// combine is the both-dictionaries rule itself, separated from the loading so it
|
||||||
|
// can be reasoned about (and tested) on its own. `dicts` is a getter because the
|
||||||
|
// writer's dictionary lands after English and the checker must see it when it
|
||||||
|
// does, without being rebuilt around a stale array.
|
||||||
|
export function combine(dicts: () => Loaded[]): SpellChecker {
|
||||||
|
return {
|
||||||
|
// Flag only what *every* loaded dictionary rejects. With one dictionary this
|
||||||
|
// is exactly the pre-Phase-21 behaviour; with two it is the rule from
|
||||||
|
// SUGGESTIONS.md §3a. No dictionary at all accepts everything — an editor
|
||||||
|
// that underlines every word because a fetch failed is worse than one that
|
||||||
|
// underlines nothing.
|
||||||
|
correct: (w) => {
|
||||||
|
const loaded = dicts()
|
||||||
|
if (loaded.length === 0) return true
|
||||||
|
return loaded.some((d) => d.spell.correct(w))
|
||||||
|
},
|
||||||
|
suggest: (w) => interleave(dicts().map((d) => d.spell.suggest(w))),
|
||||||
|
// A getter, not a snapshot. The alphabet is read by every surface that
|
||||||
|
// resolves a word, and if it were captured when the checker was built it
|
||||||
|
// would still say A-Z after her dictionary arrived — so a pt-PT writer's
|
||||||
|
// first lookups would silently be of "cora" while the underlines were
|
||||||
|
// already right.
|
||||||
|
get extendedAlphabet() {
|
||||||
|
return dicts().some((d) => d.extendedAlphabet)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSpellChecker() {
|
||||||
|
const pack = usePack()
|
||||||
|
const pairSpec = PAIR_DICTS[pack.code]
|
||||||
|
|
||||||
|
const loadedRef = useRef<Loaded[]>([])
|
||||||
|
// Bumped whenever a dictionary arrives or the personal list changes, to force
|
||||||
|
// the editor to re-decorate.
|
||||||
|
const [version, setVersion] = useState(0)
|
||||||
|
const [ready, setReady] = useState(false)
|
||||||
|
|
||||||
|
// English first, once per session — it is never reloaded, whatever the pair.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
load(EN, true)
|
||||||
|
.then((loaded) => {
|
||||||
|
if (cancelled) return
|
||||||
|
loadedRef.current = [...loadedRef.current.filter((l) => l.lang !== EN.lang), loaded]
|
||||||
|
setReady(true)
|
||||||
|
setVersion((v) => v + 1)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
// A failure here costs correct words being flagged, not writing.
|
||||||
|
console.error('spell checker failed to load', err)
|
||||||
|
})
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Recreate the checker's identity on load and on every personal-dict change so
|
// The writer's own dictionary, once her pair is known. `pack.code` is 'zh'
|
||||||
// the editor's effect re-pushes it and rebuilds decorations.
|
// until /api/me answers, so a pt-PT writer loads this a moment after the
|
||||||
|
// editor is already usable — which is the right order: the English half works
|
||||||
|
// immediately and hers fills in.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pairSpec) {
|
||||||
|
// Switching away from a pair (only tests do this today) must not leave the
|
||||||
|
// old language's words still being accepted.
|
||||||
|
loadedRef.current = loadedRef.current.filter((l) => l.lang === EN.lang)
|
||||||
|
setVersion((v) => v + 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
load(pairSpec, false)
|
||||||
|
.then((loaded) => {
|
||||||
|
if (cancelled) return
|
||||||
|
loadedRef.current = [...loadedRef.current.filter((l) => l.lang !== loaded.lang), loaded]
|
||||||
|
setVersion((v) => v + 1)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
// Only her half failed. English still checks, and the consequence is
|
||||||
|
// that correct Portuguese gets underlined — worth a console line, not
|
||||||
|
// worth blocking the editor.
|
||||||
|
console.error(`spell checker failed to load ${pairSpec.lang}`, err)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [pairSpec])
|
||||||
|
|
||||||
|
// The checker's identity changes on every load and every added word, so the
|
||||||
|
// editor's effect re-pushes it and rebuilds decorations.
|
||||||
const checker = useMemo<SpellChecker | null>(() => {
|
const checker = useMemo<SpellChecker | null>(() => {
|
||||||
if (!ready) return null
|
if (!ready) return null
|
||||||
return {
|
// Read through the ref rather than closing over a snapshot: the pair
|
||||||
correct: (w) => spellRef.current?.correct(w) ?? true,
|
// dictionary arrives after English, and `version` is what re-runs this.
|
||||||
suggest: (w) => spellRef.current?.suggest(w) ?? [],
|
return combine(() => loadedRef.current)
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [ready, version])
|
}, [ready, version])
|
||||||
|
|
||||||
// Adding a word takes effect in the editor immediately and is persisted in the
|
// Adding a word takes effect in the editor immediately and is persisted in the
|
||||||
// background: the word stops being underlined the instant she asks, whatever
|
// background: the word stops being underlined the instant she asks, whatever
|
||||||
// the network is doing.
|
// the network is doing.
|
||||||
|
//
|
||||||
|
// It is written to every loaded dictionary's list. Under the both-dictionaries
|
||||||
|
// rule a word is only ever flagged when *all* of them rejected it, so
|
||||||
|
// accepting it is a statement about her pair rather than about one language —
|
||||||
|
// and recording it against only one would silently unaccept it if the other
|
||||||
|
// dictionary is the one still loaded next time.
|
||||||
const addWord = useCallback((word: string) => {
|
const addWord = useCallback((word: string) => {
|
||||||
const sp = spellRef.current
|
const dicts = loadedRef.current
|
||||||
if (!sp) return
|
if (dicts.length === 0) return
|
||||||
sp.add(word)
|
for (const d of dicts) {
|
||||||
setVersion((v) => v + 1)
|
d.spell.add(word)
|
||||||
api.addPersonalWords(DICT_LANG, [word]).catch((err) => {
|
api.addPersonalWords(d.lang, [word]).catch((err) => {
|
||||||
console.error('could not save personal word', err)
|
console.error('could not save personal word', err)
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
setVersion((v) => v + 1)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return { checker, ready, addWord }
|
return { checker, ready, addWord }
|
||||||
|
|||||||
+68
-15
@@ -2,6 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
|
|
||||||
import { onPackChange, pack, resetPackForTests, setPackLang } from './index'
|
import { onPackChange, pack, resetPackForTests, setPackLang } from './index'
|
||||||
import { zh } from './packs/zh'
|
import { zh } from './packs/zh'
|
||||||
|
import { ptPT } from './packs/pt-PT'
|
||||||
|
import type { Pack } from './types'
|
||||||
|
|
||||||
|
// Every pack that ships. Shape assertions run over all of them, because the
|
||||||
|
// point of Phase 19 was that a language is data — and data that only the first
|
||||||
|
// author's pack satisfies isn't a shape, it's a coincidence.
|
||||||
|
const PACKS: Pack[] = [zh, ptPT]
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetPackForTests()
|
resetPackForTests()
|
||||||
@@ -15,10 +22,19 @@ describe('pack selection', () => {
|
|||||||
expect(pack().code).toBe('zh')
|
expect(pack().code).toBe('zh')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('switches to a shipped pack when the session names one', () => {
|
||||||
|
setPackLang('pt-PT')
|
||||||
|
expect(pack()).toBe(ptPT)
|
||||||
|
expect(pack().code).toBe('pt-PT')
|
||||||
|
// And back — a writer moving pairs must not strand the app on the old copy.
|
||||||
|
setPackLang('zh')
|
||||||
|
expect(pack()).toBe(zh)
|
||||||
|
})
|
||||||
|
|
||||||
it('falls back rather than blanking on a pair with no pack yet', () => {
|
it('falls back rather than blanking on a pair with no pack yet', () => {
|
||||||
// A pair_lang the deployment has no copy for is a deployment that got ahead
|
// A pair_lang the deployment has no copy for is a deployment that got ahead
|
||||||
// of its translation. She should still get a working editor.
|
// of its translation. She should still get a working editor.
|
||||||
setPackLang('pt-PT')
|
setPackLang('fr')
|
||||||
expect(pack()).toBe(zh)
|
expect(pack()).toBe(zh)
|
||||||
setPackLang('klingon')
|
setPackLang('klingon')
|
||||||
expect(pack()).toBe(zh)
|
expect(pack()).toBe(zh)
|
||||||
@@ -30,20 +46,19 @@ describe('pack selection', () => {
|
|||||||
expect(pack()).toBe(zh)
|
expect(pack()).toBe(zh)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Only one pack ships today, so a *real* switch can't be exercised yet; what
|
it('notifies readers on a real switch, and only on a real one', () => {
|
||||||
// can be is the other half of that contract — that a no-op never wakes every
|
|
||||||
// reader in the app. The switching path gets its test when pt-PT lands.
|
|
||||||
it('never notifies readers when nothing actually changed', () => {
|
|
||||||
const seen = vi.fn()
|
const seen = vi.fn()
|
||||||
onPackChange(seen)
|
onPackChange(seen)
|
||||||
|
|
||||||
setPackLang('zh') // already the current pack — nothing changed
|
setPackLang('zh') // already the current pack — nothing changed
|
||||||
expect(seen).not.toHaveBeenCalled()
|
expect(seen).not.toHaveBeenCalled()
|
||||||
|
|
||||||
// Standing in for a second pack until one ships: switching to something
|
// An unshipped pair resolves back to zh, which is also not a change.
|
||||||
// unshipped resolves back to zh, which is also not a change.
|
|
||||||
setPackLang('fr')
|
setPackLang('fr')
|
||||||
expect(seen).not.toHaveBeenCalled()
|
expect(seen).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
setPackLang('pt-PT')
|
||||||
|
expect(seen).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('stops notifying after unsubscribe', () => {
|
it('stops notifying after unsubscribe', () => {
|
||||||
@@ -87,7 +102,7 @@ describe('the zh pack', () => {
|
|||||||
// A pack with a hole in it renders an empty label rather than failing, which
|
// A pack with a hole in it renders an empty label rather than failing, which
|
||||||
// is exactly the kind of thing that reaches production. Types catch a missing
|
// is exactly the kind of thing that reaches production. Types catch a missing
|
||||||
// *key*; only this catches an empty *value*.
|
// *key*; only this catches an empty *value*.
|
||||||
it('has no empty strings anywhere', () => {
|
it.each(PACKS)('has no empty strings anywhere ($code)', (p) => {
|
||||||
const empties: string[] = []
|
const empties: string[] = []
|
||||||
const walk = (node: unknown, path: string) => {
|
const walk = (node: unknown, path: string) => {
|
||||||
if (typeof node === 'string') {
|
if (typeof node === 'string') {
|
||||||
@@ -99,28 +114,28 @@ describe('the zh pack', () => {
|
|||||||
for (const [k, v] of Object.entries(node)) walk(v, path ? `${path}.${k}` : k)
|
for (const [k, v] of Object.entries(node)) walk(v, path ? `${path}.${k}` : k)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
walk(zh, '')
|
walk(p, '')
|
||||||
expect(empties).toEqual([])
|
expect(empties).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('labels every companion in the roster and every tone the editor offers', async () => {
|
it.each(PACKS)('labels every companion, tone and style ($code)', async (p) => {
|
||||||
const { COMPANIONS } = await import('../components/Companion/companions')
|
const { COMPANIONS } = await import('../components/Companion/companions')
|
||||||
for (const c of COMPANIONS) {
|
for (const c of COMPANIONS) {
|
||||||
expect(zh.companion.names[c.id], `no name for companion ${c.id}`).toBeTruthy()
|
expect(p.companion.names[c.id], `no name for companion ${c.id}`).toBeTruthy()
|
||||||
}
|
}
|
||||||
|
|
||||||
const { TONES } = await import('../components/Editor/ToneSelect')
|
const { TONES } = await import('../components/Editor/ToneSelect')
|
||||||
for (const tone of TONES) {
|
for (const tone of TONES) {
|
||||||
expect(zh.tones[tone.value], `no label for tone ${tone.value}`).toBeTruthy()
|
expect(p.tones[tone.value], `no label for tone ${tone.value}`).toBeTruthy()
|
||||||
}
|
}
|
||||||
|
|
||||||
const { REWRITE_STYLES } = await import('../components/Editor/SelectionBubble')
|
const { REWRITE_STYLES } = await import('../components/Editor/SelectionBubble')
|
||||||
for (const style of REWRITE_STYLES) {
|
for (const style of REWRITE_STYLES) {
|
||||||
expect(zh.styles[style.value], `no label for style ${style.value}`).toBeTruthy()
|
expect(p.styles[style.value], `no label for style ${style.value}`).toBeTruthy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('labels every word band the popover can show', async () => {
|
it.each(PACKS)('labels every word band the popover can show ($code)', async (p) => {
|
||||||
// wordBand returns a band name, never a label — an unlabelled band would
|
// wordBand returns a band name, never a label — an unlabelled band would
|
||||||
// render as an empty chip, which reads as a bug rather than as no data.
|
// render as an empty chip, which reads as a bug rather than as no data.
|
||||||
const { wordBand } = await import('../components/Editor/wordband')
|
const { wordBand } = await import('../components/Editor/wordband')
|
||||||
@@ -136,7 +151,45 @@ describe('the zh pack', () => {
|
|||||||
)
|
)
|
||||||
expect(bands.size).toBe(3)
|
expect(bands.size).toBe(3)
|
||||||
for (const band of bands) {
|
for (const band of bands) {
|
||||||
expect(zh.editor.wordBands[band], `no label for word band ${band}`).toBeTruthy()
|
expect(p.editor.wordBands[band], `no label for word band ${band}`).toBeTruthy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('the pt-PT pack', () => {
|
||||||
|
// The pack is European Portuguese or it is nothing: a Brazilian form in the
|
||||||
|
// chrome is exactly the drift SUGGESTIONS.md §3 says to guard against, and it
|
||||||
|
// is invisible to anyone who doesn't read Portuguese — including whoever
|
||||||
|
// reviews this diff.
|
||||||
|
it('is European Portuguese, not Brazilian', () => {
|
||||||
|
const text = JSON.stringify(ptPT, (_k, v) => (typeof v === 'function' ? v(1, 'x') : v))
|
||||||
|
|
||||||
|
// Brazilian spellings and vocabulary that would give the pack away.
|
||||||
|
for (const bad of ['sinônimo', 'acadêmico', 'arquivo', 'tela', 'salvar', 'deletar', 'usuário', 'você']) {
|
||||||
|
expect(text, `pt-BR form "${bad}" in the pt-PT pack`).not.toContain(bad)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the European forms that should be there instead.
|
||||||
|
expect(ptPT.editor.synonyms).toContain('Sinónimos')
|
||||||
|
expect(ptPT.styles.academic.native).toBe('Académico')
|
||||||
|
expect(ptPT.auth.signIn).toContain('Iniciar sessão')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders its interpolated lines with the value in place', () => {
|
||||||
|
expect(ptPT.app.duplicateTitle('Primavera')).toBe('Primavera (cópia)')
|
||||||
|
expect(ptPT.companion.milestone(300).native).toContain('300 palavras')
|
||||||
|
// Portuguese agreement is the pack's business, the same way English
|
||||||
|
// pluralisation is — the call site only ever passes a number.
|
||||||
|
expect(ptPT.garden.reviewDue(1)).toContain('1 palavra ·')
|
||||||
|
expect(ptPT.garden.reviewDue(4)).toContain('4 palavras ·')
|
||||||
|
expect(ptPT.garden.growing(1)).toContain('1 flor no jardim')
|
||||||
|
expect(ptPT.garden.growing(3)).toContain('3 flores no jardim')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says the collision line the zh pair never needed', () => {
|
||||||
|
// "sale", "comum" and "tarde" are words on both sides of this pair, so the
|
||||||
|
// word card's second reading is reachable copy here — unlike in zh.
|
||||||
|
expect(ptPT.editor.alsoIn).toBeTruthy()
|
||||||
|
expect(ptPT.editor.alsoIn).not.toBe(zh.editor.alsoIn)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -16,12 +16,13 @@ import { useSyncExternalStore } from 'react'
|
|||||||
|
|
||||||
import type { Pack, PairLang } from './types'
|
import type { Pack, PairLang } from './types'
|
||||||
import { zh } from './packs/zh'
|
import { zh } from './packs/zh'
|
||||||
|
import { ptPT } from './packs/pt-PT'
|
||||||
|
|
||||||
export type { Pack, PairLang, Line } from './types'
|
export type { Pack, PairLang, Line } from './types'
|
||||||
|
|
||||||
// Every pack Petal ships. pt-PT, fr and es land here in Phase 21 — adding one
|
// Every pack Petal ships. fr and es are the same two lines apiece when their
|
||||||
// is this line plus the file, and TypeScript then names every string it owes.
|
// copy is written — TypeScript names every string a new pack still owes.
|
||||||
const PACKS: Partial<Record<PairLang, Pack>> = { zh }
|
const PACKS: Partial<Record<PairLang, Pack>> = { zh, 'pt-PT': ptPT }
|
||||||
|
|
||||||
const DEFAULT_LANG: PairLang = 'zh'
|
const DEFAULT_LANG: PairLang = 'zh'
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
// The European Portuguese pack — the first pair that is not Chinese, and the
|
||||||
|
// one that proves a language really is data.
|
||||||
|
//
|
||||||
|
// ⚠️ WRITTEN BUT NOT YET REVIEWED BY A NATIVE SPEAKER.
|
||||||
|
// SUGGESTIONS.md §3 sets the bar: "the pt-PT pack should be reviewed by a pt-PT
|
||||||
|
// speaker before it's trusted — same standard the zh copy got by being written
|
||||||
|
// for a real reader." That review has not happened. Until it does, treat every
|
||||||
|
// line here as a good-faith draft rather than as shipped copy, and expect a
|
||||||
|
// speaker to change the register long before they change the vocabulary.
|
||||||
|
//
|
||||||
|
// European Portuguese, not Brazilian. That is the single most likely way for
|
||||||
|
// this file to go quietly wrong, so the choices are deliberate throughout:
|
||||||
|
//
|
||||||
|
// * Post-Acordo spellings (ação, direção, ótimo), but the pt-PT lexicon —
|
||||||
|
// ficheiro not arquivo, ecrã not tela, guardar not salvar, eliminar not
|
||||||
|
// deletar, telemóvel not celular, sinónimo not sinônimo, académico not
|
||||||
|
// acadêmico.
|
||||||
|
// * "Estás a escrever", not "está escrevendo". The progressive with *a* +
|
||||||
|
// infinitive is the European construction, and the gerund is the single
|
||||||
|
// clearest tell of a Brazilian text.
|
||||||
|
// * Second person singular *tu*, because Petal is a companion in someone's
|
||||||
|
// private notebook and *você* would put a desk between them.
|
||||||
|
// * "Iniciar sessão" / "Terminar sessão", not "fazer login" / "sair".
|
||||||
|
//
|
||||||
|
// Portuguese first, English underneath — same shape as the zh pack, for the
|
||||||
|
// same reason: she reads her own language faster, and the English half is what
|
||||||
|
// she is here to learn.
|
||||||
|
|
||||||
|
import type { Pack } from '../types'
|
||||||
|
|
||||||
|
export const ptPT: Pack = {
|
||||||
|
code: 'pt-PT',
|
||||||
|
nativeName: 'Português',
|
||||||
|
|
||||||
|
app: {
|
||||||
|
duplicateTitle: (title) => `${title} (cópia)`,
|
||||||
|
garden: 'Jardim de palavras',
|
||||||
|
history: 'Histórico',
|
||||||
|
},
|
||||||
|
|
||||||
|
auth: {
|
||||||
|
title: 'Inicia sessão outra vez',
|
||||||
|
titleEn: 'Please sign in again',
|
||||||
|
bodyWithDraft:
|
||||||
|
'O que acabaste de escrever está guardado neste dispositivo — volta a entrar e guarda-se sozinho.',
|
||||||
|
bodyWithDraftEn: "What you just wrote is safe on this device — it'll save itself once you're back in.",
|
||||||
|
bodyPlain: 'A tua sessão expirou. Tudo o que escreveste já está guardado.',
|
||||||
|
bodyPlainEn: 'Your session expired. Everything you wrote is already saved.',
|
||||||
|
signIn: 'Iniciar sessão · Sign in',
|
||||||
|
},
|
||||||
|
|
||||||
|
companion: {
|
||||||
|
choose: 'Escolhe um companheiro · Choose a companion',
|
||||||
|
|
||||||
|
encouragements: [
|
||||||
|
{ native: 'Que bom! Esta frase ficou bem mais fluida 🌸', en: 'Lovely — that reads so much smoother now.' },
|
||||||
|
{ native: 'Estás a escrever cada vez melhor ✨', en: "You're getting better and better." },
|
||||||
|
{ native: 'Gosto muito desta mudança 💕', en: 'I really like that change.' },
|
||||||
|
{ native: 'Continua assim — tu consegues!', en: 'Keep going — you’ve got this!' },
|
||||||
|
{ native: 'Pois é, assim ficou muito mais claro 👍', en: 'Mm, that’s much clearer.' },
|
||||||
|
{ native: 'Que boa escolha de palavra 🌷', en: 'That’s such a good word choice.' },
|
||||||
|
{ native: 'Ui, este parágrafo lê-se tão bem ☁️', en: 'Ooh, that paragraph flows so nicely.' },
|
||||||
|
{ native: 'Adoro ver-te escrever com mais confiança 💛', en: 'I love watching you write with more confidence.' },
|
||||||
|
{ native: 'Cada bocadinho de progresso conta 🌱', en: 'Every little bit of progress counts.' },
|
||||||
|
{ native: 'Hoje as tuas palavras estão a brilhar ✨', en: 'Your words are sparkling today.' },
|
||||||
|
],
|
||||||
|
|
||||||
|
tips: [
|
||||||
|
{ native: 'Dica: em inglês, frases mais curtas leem-se melhor.', en: 'Tip: shorter English sentences often read clearer.' },
|
||||||
|
{ native: 'Não te esqueças dos artigos “the” e “a”.', en: "Don't forget articles like “the” and “a”." },
|
||||||
|
{ native: 'Para o passado, usa o past tense: go → went.', en: 'For the past, use past tense: go → went.' },
|
||||||
|
{ native: 'Ler em voz alta ajuda a apanhar o que soa estranho.', en: 'Reading aloud helps you catch awkward spots.' },
|
||||||
|
{ native: 'Uma ideia por parágrafo e fica tudo arrumado.', en: 'One idea per paragraph keeps it tidy.' },
|
||||||
|
{ native: 'Se tiveres dúvidas, pergunta-me ✨', en: 'Not sure about something? Just ask me. ✨' },
|
||||||
|
{ native: 'O plural leva “s”: two apples 🍎', en: 'Plurals take an “s”: two apples 🍎' },
|
||||||
|
// One tip the zh pack has no use for: the false friends between
|
||||||
|
// Portuguese and English are a daily hazard for this writer and a
|
||||||
|
// non-problem for the last one.
|
||||||
|
{ native: 'Atenção aos falsos amigos: “pretender” não é *to pretend*.', en: 'Careful with false friends — “pretender” means to intend.' },
|
||||||
|
],
|
||||||
|
|
||||||
|
breaks: [
|
||||||
|
{ native: 'Já escreves há um bom bocado — levanta-te e descansa os olhos 🍵', en: "You've been writing a while — stretch and rest your eyes. 🍵" },
|
||||||
|
{ native: 'Bebe um copo de água e para cinco minutos?', en: 'Sip some water and take five?' },
|
||||||
|
{ native: 'Olha ao longe um momento, dá uma folga aos olhos 🌿', en: 'Look into the distance for a moment — give your eyes a break. 🌿' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// Late-night nudges. The English wit is the user's own and is kept word for
|
||||||
|
// word across every pack; the Portuguese line leads gently into it, exactly
|
||||||
|
// as the Mandarin one does.
|
||||||
|
bedtime: [
|
||||||
|
{ native: 'A tua cama deve estar com saudades 🛏️', en: 'I bet your bed is missing you right now.' },
|
||||||
|
{ native: 'Cansada não se escreve bem — vai descansar 🌙', en: 'A tired writer is a bad writer — get some rest.' },
|
||||||
|
{ native: 'Dorme sobre o assunto, que as ideias vêm sozinhas ✨', en: 'Sleep is a wondrous enabler.' },
|
||||||
|
{ native: 'Ouves? Pois não… está toda a gente a dormir, e tu também devias 😴', en: "Hear that? No… you don't, because everyone is sleeping and you should be too." },
|
||||||
|
// Portuguese proverbs on sleep and haste, in place of the Chinese ones —
|
||||||
|
// a pack is not a translation of another pack.
|
||||||
|
{ native: 'Deitar cedo e cedo erguer dá saúde e faz crescer.', en: 'Early to bed and early to rise makes you healthy and helps you grow.' },
|
||||||
|
{ native: 'Quem dorme sobre o problema, acorda com a solução.', en: 'Sleep on the problem and you wake with the answer.' },
|
||||||
|
{ native: 'Depressa e bem, há pouco quem.', en: 'Fast and good together — few manage that.' },
|
||||||
|
],
|
||||||
|
|
||||||
|
greeting: { native: 'Olá! Estou aqui a fazer-te companhia 🐱', en: "Hi! I'm right here keeping you company. 🐱" },
|
||||||
|
welcomeBack: { native: 'Bem-vinda de volta ✨ vamos continuar!', en: 'Welcome back ✨ let’s keep going!' },
|
||||||
|
|
||||||
|
errors: [
|
||||||
|
{ native: 'Ups — houve um percalço, mas as tuas palavras estão a salvo.', en: 'Oops — a little hiccup, but your words are safe.' },
|
||||||
|
{ native: 'Bolas, encravei por um segundo — já volto.', en: 'Haiya, I got stuck for a sec — back in a moment.' },
|
||||||
|
{ native: 'Não te preocupes, tentamos outra vez daqui a pouco 🍵', en: "Don't worry — let's try again in a bit. 🍵" },
|
||||||
|
],
|
||||||
|
|
||||||
|
milestone: (words: number) => ({
|
||||||
|
native: `Uau! Já vais em ${words} palavras 🎉`,
|
||||||
|
en: `Wow — ${words} words already! Amazing. 🎉`,
|
||||||
|
}),
|
||||||
|
|
||||||
|
names: {
|
||||||
|
cat: 'Gato dorminhoco',
|
||||||
|
dog: 'Cão contente',
|
||||||
|
'wiggle-dog': 'Cão abanão',
|
||||||
|
butterfly: 'Borboleta',
|
||||||
|
parrot: 'Papagaio',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
prose: {
|
||||||
|
longSentence: 'Esta frase está um bocadinho longa — dividi-la em duas ou três fica mais claro 🌸',
|
||||||
|
commaSplice: 'Aqui há duas frases ligadas só por uma vírgula. Podes usar um ponto final, ou juntar “and / but”.',
|
||||||
|
vagueThis: (word) => `Não se percebe bem a que “${word}” se refere — vale a pena dizê-lo (por exemplo, “${word} idea / change…”).`,
|
||||||
|
oxfordComma: 'Numa lista de três ou mais, uma vírgula antes de “and / or” também ajuda a ler (a vírgula de Oxford).',
|
||||||
|
transitionComma: (word) => `Depois de uma palavra de ligação no início, põe uma vírgula: “${word}, …”.`,
|
||||||
|
capitalizeSentence: 'Começa cada frase com letra maiúscula.',
|
||||||
|
repeatedWord: (word) => `“${word}” parece estar escrito duas vezes — dá uma vista de olhos.`,
|
||||||
|
capitalizeI: 'Em inglês, o “I” (eu) escreve-se sempre com maiúscula.',
|
||||||
|
spaceBeforePunct: 'Em inglês não se põe espaço antes da pontuação: a vírgula e o ponto vêm colados à palavra.',
|
||||||
|
spaceAfterPunct: 'Depois da vírgula ou do ponto, deixa um espaço antes da palavra seguinte.',
|
||||||
|
articleAn: (word) => `Antes de som de vogal usa-se “an”: “an ${word}”.`,
|
||||||
|
articleA: (word) => `Antes de som de consoante usa-se “a”: “a ${word}”.`,
|
||||||
|
uncountable: (word, singular) => `“${word}” é incontável em inglês — não leva s, basta “${singular}”.`,
|
||||||
|
capitalizeProper: (fixed) => `Em inglês, línguas, nacionalidades, dias da semana e meses levam maiúscula: “${fixed}”.`,
|
||||||
|
thirdPersonS: (subject, verb) => `Com he/she/it, o verbo leva -s: “${subject} ${verb}”.`,
|
||||||
|
pluralAfter: (determiner, noun) => `Depois de “${determiner}” o nome vai no plural: “${determiner} ${noun}s”.`,
|
||||||
|
doubleDeterminer: (first, second) => `“${first} ${second}” tem dois determinantes — fica só com um (por exemplo, tira “${first}”).`,
|
||||||
|
thereArePlural: (noun) => `Com plural usa-se “there are”: “there are ${noun}…”.`,
|
||||||
|
itsOwn: '“it’s” = “it is”. Para dizer “o seu / dele”, é “its” — portanto “its own”.',
|
||||||
|
itsIs: (rest) => `Aqui é “it’s ${rest}” (it is); “its” é o possessivo.`,
|
||||||
|
thanNotThen: (word) => `Nas comparações usa-se “than”, não “then”: “${word} than”.`,
|
||||||
|
},
|
||||||
|
|
||||||
|
docs: {
|
||||||
|
sortRecent: 'Recentes · Recent',
|
||||||
|
sortTitle: 'Título · Title',
|
||||||
|
sortLongest: 'Mais longos · Longest',
|
||||||
|
backUpAll: 'Cópia de segurança · Back up all:',
|
||||||
|
signOut: 'Terminar sessão · Sign out',
|
||||||
|
duplicate: 'Duplicar · Duplicate',
|
||||||
|
searchPlaceholder: 'Procurar · Search',
|
||||||
|
searching: 'A procurar… · Searching…',
|
||||||
|
noMatches: 'Sem resultados · No matches',
|
||||||
|
tags: 'Etiquetas · Tags',
|
||||||
|
newTagPlaceholder: 'Nova etiqueta · New tag',
|
||||||
|
},
|
||||||
|
|
||||||
|
editor: {
|
||||||
|
askPlaceholder: 'Ask why… / Pergunta porquê…',
|
||||||
|
findPlaceholder: 'Localizar · Find',
|
||||||
|
findNone: 'Nada · 0',
|
||||||
|
matchCase: 'Match case · Maiúsculas/minúsculas',
|
||||||
|
close: 'Close · Fechar',
|
||||||
|
replacePlaceholder: 'Substituir por · Replace',
|
||||||
|
replace: 'Substituir',
|
||||||
|
replaceAll: 'Tudo',
|
||||||
|
spelling: 'Ortografia · Spelling',
|
||||||
|
noSuggestions: 'Sem sugestões · No suggestions',
|
||||||
|
addToDictionary: 'Adicionar ao dicionário · Add to dictionary',
|
||||||
|
readSelection: 'Ler a seleção em voz alta · Read selection aloud',
|
||||||
|
rewrite: 'Reescrever · Rewrite',
|
||||||
|
rewriting: 'A reescrever… · Rewriting…',
|
||||||
|
rewriteFailed: 'Não deu para reescrever — tenta outra vez · Couldn’t rewrite',
|
||||||
|
cancel: 'Cancelar · Cancel',
|
||||||
|
retry: 'Tentar de novo · Retry',
|
||||||
|
useThis: 'Usar esta · Use this',
|
||||||
|
word: 'Palavra · Word',
|
||||||
|
inGarden: 'Já está no jardim · In your garden (tap to remove)',
|
||||||
|
saveToGarden: 'Guardar no jardim · Save to garden',
|
||||||
|
readAloud: 'Ler em voz alta · Read aloud',
|
||||||
|
lookingUp: 'A procurar… · Looking up…',
|
||||||
|
definition: 'Definição · Definition',
|
||||||
|
synonyms: 'Sinónimos · Synonyms',
|
||||||
|
tapToSwap: 'toca para trocar · tap to swap',
|
||||||
|
nothingFound: 'Não encontrei esta palavra · Nothing found for this word',
|
||||||
|
origin: 'Origem · Origin',
|
||||||
|
// This one the pt-PT pair actually sees: sale, comum, tarde, ali, data and
|
||||||
|
// dozens more are words on both sides of the pair.
|
||||||
|
alsoIn: 'Também é palavra em português · Also a word in Portuguese',
|
||||||
|
wordBands: {
|
||||||
|
simple: { native: 'Do dia a dia', en: 'Everyday word' },
|
||||||
|
standard: { native: 'Normal', en: 'Standard' },
|
||||||
|
advanced: { native: 'Avançada', en: 'Advanced' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
styles: {
|
||||||
|
natural: { native: 'Mais natural', en: 'Natural' },
|
||||||
|
academic: { native: 'Académico', en: 'Academic' },
|
||||||
|
professional: { native: 'Profissional', en: 'Professional' },
|
||||||
|
casual: { native: 'Descontraído', en: 'Casual' },
|
||||||
|
humorous: { native: 'Bem-humorado', en: 'Humorous' },
|
||||||
|
creative: { native: 'Criativo', en: 'Creative' },
|
||||||
|
persuasive: { native: 'Persuasivo', en: 'Persuasive' },
|
||||||
|
},
|
||||||
|
|
||||||
|
tones: {
|
||||||
|
general: { native: 'Geral', en: 'General' },
|
||||||
|
academic: { native: 'Académico', en: 'Academic' },
|
||||||
|
professional: { native: 'Profissional', en: 'Professional' },
|
||||||
|
casual: { native: 'Descontraído', en: 'Casual' },
|
||||||
|
humorous: { native: 'Bem-humorado', en: 'Humorous' },
|
||||||
|
creative: { native: 'Criativo', en: 'Creative' },
|
||||||
|
persuasive: { native: 'Persuasivo', en: 'Persuasive' },
|
||||||
|
},
|
||||||
|
|
||||||
|
exports: {
|
||||||
|
label: 'Exportar',
|
||||||
|
print: 'Imprimir / PDF',
|
||||||
|
formats: {
|
||||||
|
md: { native: 'Markdown', en: 'Markdown (.md)' },
|
||||||
|
docx: { native: 'Documento Word', en: 'Word (.docx)' },
|
||||||
|
html: { native: 'Página web', en: 'Web page (.html)' },
|
||||||
|
txt: { native: 'Texto simples', en: 'Plain text (.txt)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
garden: {
|
||||||
|
title: 'Jardim de palavras · Vocabulary Garden',
|
||||||
|
titleWithFlower: '🌷 Jardim de palavras · Vocabulary Garden',
|
||||||
|
reviewing: 'A rever · Reviewing — recall, then grade yourself',
|
||||||
|
subtitle: 'Words you looked up, blooming as you learn them',
|
||||||
|
reviewDue: (n) => `Rever ${n} palavra${n === 1 ? '' : 's'} · Review ${n} due 🌸`,
|
||||||
|
emptyLead: 'O teu jardim ainda está vazio.',
|
||||||
|
emptyHint: 'Clica com o botão direito numa palavra inglesa para a procurar — e ela germina aqui.',
|
||||||
|
due: 'a rever · due',
|
||||||
|
seen: (reps, intervalDays) => `${reps}× revista · seen ${reps}× · intervalo ${intervalDays}d`,
|
||||||
|
readAloud: '🔊 Ler',
|
||||||
|
source: '📄 Origem · Source',
|
||||||
|
remove: '🗑 Remover',
|
||||||
|
growing: (n) => `🐱💤 ${n} flor${n === 1 ? '' : 'es'} no jardim · ${n} blossom${n > 1 ? 's' : ''} growing`,
|
||||||
|
end: 'Terminar · End',
|
||||||
|
promptProduction: 'Qual é a palavra inglesa? · Which English word?',
|
||||||
|
promptRecognition: 'O que significa? · What does this mean?',
|
||||||
|
showAnswer: 'Ver a resposta · Show answer',
|
||||||
|
gradeAgain: { native: 'Outra vez', en: 'Again' },
|
||||||
|
gradeGood: { native: 'Lembro-me', en: 'Good' },
|
||||||
|
gradeEasy: { native: 'Fácil', en: 'Easy' },
|
||||||
|
},
|
||||||
|
|
||||||
|
history: {
|
||||||
|
title: 'Histórico · History',
|
||||||
|
kinds: {
|
||||||
|
manual: { native: 'Ponto guardado', en: 'Saved point' },
|
||||||
|
auto: { native: 'Automático', en: 'Auto' },
|
||||||
|
pre_restore: { native: 'Antes de restaurar', en: 'Before restore' },
|
||||||
|
},
|
||||||
|
justNow: 'just now · agora mesmo',
|
||||||
|
minutesAgo: (n) => `${n} min ago · há ${n} min`,
|
||||||
|
hoursAgo: (n) => `${n} hr ago · há ${n} h`,
|
||||||
|
daysAgo: (n) => `${n} day${n > 1 ? 's' : ''} ago · há ${n} dia${n > 1 ? 's' : ''}`,
|
||||||
|
preview: 'Pré-visualizar · Preview',
|
||||||
|
restoring: 'Restoring…',
|
||||||
|
restoreThis: 'Restaurar esta versão · Restore this version',
|
||||||
|
passport: '📜 Certificado de escrita · Writing passport',
|
||||||
|
keepFullHistory: 'Guardar o histórico completo · Keep full history',
|
||||||
|
},
|
||||||
|
|
||||||
|
status: {
|
||||||
|
savedLocally: 'Guardado neste dispositivo · Kept on this device',
|
||||||
|
helperRestingNative: 'O ajudante está a descansar',
|
||||||
|
helperRestingEn: "· Petal's helper is resting · o teu texto está guardado",
|
||||||
|
soundsOn: 'Som ligado · Sounds on',
|
||||||
|
soundsOff: 'Som desligado · Sounds off',
|
||||||
|
petalsOn: 'Pétalas ligadas · Petals on',
|
||||||
|
petalsOff: 'Pétalas desligadas · Petals off',
|
||||||
|
statsTitle: 'Estatísticas · Writing stats',
|
||||||
|
stats: {
|
||||||
|
words: { native: 'Palavras', en: 'Words' },
|
||||||
|
characters: { native: 'Caracteres', en: 'Characters' },
|
||||||
|
sentences: { native: 'Frases', en: 'Sentences' },
|
||||||
|
paragraphs: { native: 'Parágrafos', en: 'Paragraphs' },
|
||||||
|
pages: { native: 'Páginas', en: 'Pages' },
|
||||||
|
readingTime: { native: 'Tempo de leitura', en: 'Reading time' },
|
||||||
|
avgWordLength: { native: 'Comprimento médio', en: 'Avg word length' },
|
||||||
|
variety: { native: 'Variedade vocabular', en: 'Word variety' },
|
||||||
|
readability: { native: 'Nível de leitura', en: 'Reading level' },
|
||||||
|
},
|
||||||
|
readability: {
|
||||||
|
easy: { native: 'Fácil', en: 'Easy' },
|
||||||
|
standard: { native: 'Normal', en: 'Standard' },
|
||||||
|
fairlyHard: { native: 'Algo difícil', en: 'Fairly hard' },
|
||||||
|
advanced: { native: 'Avançado', en: 'Advanced' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
toolbar: {
|
||||||
|
untitledHeading: '(sem título)',
|
||||||
|
outline: 'Estrutura · Outline',
|
||||||
|
outlineHint: 'Usa H1/H2/H3 para criar títulos e a navegação aparece aqui.',
|
||||||
|
},
|
||||||
|
|
||||||
|
update: {
|
||||||
|
available: 'Há uma versão nova',
|
||||||
|
refresh: 'Atualizar · Refresh',
|
||||||
|
dismiss: 'Mais tarde · Dismiss',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -175,6 +175,10 @@ export const zh: Pack = {
|
|||||||
tapToSwap: '点击替换 · tap to swap',
|
tapToSwap: '点击替换 · tap to swap',
|
||||||
nothingFound: '没有找到这个词 · Nothing found for this word',
|
nothingFound: '没有找到这个词 · Nothing found for this word',
|
||||||
origin: '词源 · Origin',
|
origin: '词源 · Origin',
|
||||||
|
// Never rendered for this pair — English and Chinese share no spellings, so
|
||||||
|
// a word is never both. Written out anyway because the type demands it and
|
||||||
|
// because "unreachable" is a claim about today's data, not a guarantee.
|
||||||
|
alsoIn: '这个词在中文里也有 · Also a word in Chinese',
|
||||||
wordBands: {
|
wordBands: {
|
||||||
simple: { native: '常用词', en: 'Everyday word' },
|
simple: { native: '常用词', en: 'Everyday word' },
|
||||||
standard: { native: '一般难度', en: 'Standard' },
|
standard: { native: '一般难度', en: 'Standard' },
|
||||||
|
|||||||
@@ -137,6 +137,13 @@ export interface Pack {
|
|||||||
// Where the word came from — a real hook for a writer whose own language
|
// Where the word came from — a real hook for a writer whose own language
|
||||||
// shares roots with English.
|
// shares roots with English.
|
||||||
origin: string
|
origin: string
|
||||||
|
// Heading for the other reading of a word that exists in both languages —
|
||||||
|
// Portuguese *sale*, French *chat*. Petal shows both rather than picking
|
||||||
|
// one, so this labels the half that is in her language. The pack names its
|
||||||
|
// own language here rather than being handed a code: only it knows whether
|
||||||
|
// that reads as "em português" or as "葡萄牙语". A pack whose pair has no
|
||||||
|
// such collisions (zh) never sees this rendered.
|
||||||
|
alsoIn: string
|
||||||
// How hard the word is, keyed by the band wordBand() returns.
|
// How hard the word is, keyed by the band wordBand() returns.
|
||||||
wordBands: Record<string, Line>
|
wordBands: Record<string, Line>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user