which-dialect 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -29
- package/dist/check.d.ts +28 -3
- package/dist/check.js +214 -37
- package/dist/detect.d.ts +10 -0
- package/dist/detect.js +65 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +51 -13
- package/dist/phrasebook.d.ts +2 -0
- package/dist/phrasebook.js +1 -1
- package/dist/review.d.ts +8 -5
- package/dist/review.js +64 -90
- package/dist/text.d.ts +13 -0
- package/dist/text.js +40 -0
- package/dist/types.d.ts +5 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -111,44 +111,47 @@ The cases involving Spanish were removed with it. The held-out set has since bee
|
|
|
111
111
|
|
|
112
112
|
### `createChecker({ baseUrl?, load?, dictionary? })` → `checker.check(text, options)`
|
|
113
113
|
|
|
114
|
-
|
|
114
|
+
A spellchecker for learners: it gives a correction for every word it can, with the exact place in the text and a short reason. Words written in the learner's own language (English by default) get the target-language word. Further checks (regions, pronouns, politeness) can be turned on. It runs in the browser like the rest of the library: no API calls, no cost.
|
|
115
115
|
|
|
116
116
|
```ts
|
|
117
117
|
import { createChecker } from 'which-dialect'
|
|
118
118
|
|
|
119
119
|
const checker = createChecker()
|
|
120
|
-
const { text, issues } = await checker.check('
|
|
121
|
-
lang: 'vi', region: 'Southern',
|
|
120
|
+
const { text, issues, parts } = await checker.check('Toi muon an the ice cream but it was very expensive.', {
|
|
121
|
+
lang: 'vi', region: 'Southern',
|
|
122
122
|
})
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
// “khong”: Talking to your parents, end the sentence with “ạ” to sound polite. (suggestion)
|
|
123
|
+
// Toi → Tôi · muon → muốn · an → ăn (accents)
|
|
124
|
+
// the → (left out: Vietnamese has no "the") · ice cream → kem · but → nhưng · it → nó
|
|
125
|
+
// was → là · very → rất · expensive → mắc (English words, in Southern Vietnamese)
|
|
127
126
|
```
|
|
128
127
|
|
|
129
128
|
| Option | Meaning |
|
|
130
129
|
|---|---|
|
|
131
|
-
| `lang` | The text's language. |
|
|
132
|
-
| `
|
|
133
|
-
| `
|
|
134
|
-
| `
|
|
135
|
-
| `
|
|
136
|
-
| `
|
|
130
|
+
| `lang` | The text's language (the target). |
|
|
131
|
+
| `base` | The learner's own language, whose words get translated (default `'en'`). |
|
|
132
|
+
| `region` | The region's words are used for translations (*expensive* → Southern *mắc*, Northern *đắt*), and the `dialect` check, when on, flags other regions' words. |
|
|
133
|
+
| `register` | `'casual'` allows colloquial translations; otherwise plain ones come first. |
|
|
134
|
+
| `rules` | Turn checks on or off. By default only `spelling` and `foreign-word` run; e.g. `{ dialect: true, 'pronoun-relationship': true }`. |
|
|
135
|
+
| `listener`, `about`, `speaker` | For the pronoun and polite-ending checks: who you're talking to and about (pronoun-table row ids), and the speaker's gender. |
|
|
137
136
|
|
|
138
|
-
Each issue has `rule`, `severity` (`'error'`: not a word of the language; `'warning'`: wrong for the
|
|
137
|
+
The result has `text` (the input in Unicode NFC form), `issues` and `parts` (the text split by language). Each issue has `rule`, `severity` (`'error'`: not a word of the language; `'warning'`: probably wrong, or wrong for the region or relationship; `'suggestion'`: often better), `start`/`end` (offsets into `text`), `text`, `message` and `suggestions` (replacements for `text`, best first; `''` means leave it out).
|
|
139
138
|
|
|
140
|
-
|
|
139
|
+
**On by default (the spellchecker):**
|
|
141
140
|
|
|
142
|
-
- **`spelling`**: syllables that aren't in the language
|
|
143
|
-
- **`
|
|
141
|
+
- **`spelling`**: a doubtful syllable is first tried with its neighbors: if fixing its accents makes a dictionary word with the syllables next to it, that word is suggested (*hom nay* → *hôm nay* "today", where *nay* alone would become *này* "this"; *an com* → *ăn cơm*; *Chung toi* → *Chúng tôi*), changing only the doubtful syllables. Otherwise, syllables that aren't in the language get the accented forms they could be (*khong* → *không*); rare plain-letter words next to a far more common accented form too (*toi* → *tôi*, *hom* → *hôm*), and, in a sentence typed without accents, any more common accented form (*an* → *ăn*, *di* → *đi*; not *con* → *còn*, about as common). Names (capitalized mid-sentence), the language's listed pronouns (*tui*) and plain-letter words with no accented form (*email*) are left alone. Languages written in syllables only (Vietnamese).
|
|
142
|
+
- **`foreign-word`**: words in the learner's own language get the target-language word, in the region and register (*market* → *chợ*, *ice cream* → *kem*). Phrases the base dictionary knows are translated as one, falling back to single words. Words the language has no word for are suggested to be left out (`leaveOut` in its checker settings: Vietnamese has no *the*, *a*, *an*). Which language each word is in comes from the two dictionaries: an English word that's Vietnamese only with accents added (*but*: *bút*) is English; Vietnamese typed without accents (*khong*) is Vietnamese; words both languages have go by how common they are in each (*em*, *di*, *tui* are Vietnamese even between English words; *the* is English), and close calls (*an*, *to*) follow their neighbors, leaning to the target language.
|
|
143
|
+
|
|
144
|
+
**Opt-in (`rules`):**
|
|
145
|
+
|
|
146
|
+
- **`dialect`**: words whose main meaning is tagged for another region (*lợn* in Southern text → *heo*; *muỗng* in Northern → *thìa*; in English, US *apartment* in UK text → *flat*). Without `region`, words that don't fit the rest of the text's region are pointed out. Words with an untagged main meaning are never flagged (*má* is "cheek" everywhere).
|
|
144
147
|
- **`pronoun-relationship`**: pronouns that don't fit the listener (*tôi* to a parent → *con*; to a teacher → *em*).
|
|
145
148
|
- **`pronoun-pair`**: "I" and "you" pronouns that never go together (*tao* goes with *mày*, not *bạn*), when no listener is given.
|
|
146
149
|
- **`pronoun-consistency`**: switching words for the same person within one text (*tôi* in one sentence, *tui* in the next): keep the first.
|
|
147
150
|
- **`polite-ending`**: a suggestion to end sentences with *ạ* when talking to parents, elders and teachers (not for exclamations).
|
|
148
151
|
|
|
149
|
-
|
|
152
|
+
Corrections are word by word: they don't fix word order or grammar, so English translated word by word reads like English in Vietnamese words (*it was very expensive* → *nó là rất mắc*). Whole English sentences or clauses that follow a [sentence frame](#sentence-frames) get the frame's natural wording instead in the [journal review](#reviewing-journal-entries). The checker only speaks up when it's confident, so **no issues means none of the checks found anything, not that the text is correct**.
|
|
150
153
|
|
|
151
|
-
**Every language uses the same checks.** They read only the language's data and its `checker` settings in `languages/<code>.ts` (how text splits into words, which pronouns are reliable, polite endings), so a check whose data a language doesn't have is skipped. A language without checker settings still
|
|
154
|
+
**Every language uses the same checks.** They read only the language's data and its `checker` settings in `languages/<code>.ts` (how text splits into words, which pronouns are reliable, polite endings, words with no equivalent), so a check whose data a language doesn't have is skipped. A language without checker settings is read as space-separated words: English words in it are still translated, and the `dialect` check works.
|
|
152
155
|
|
|
153
156
|
## Sentence frames
|
|
154
157
|
|
|
@@ -166,13 +169,13 @@ await pb.match('Do you want coffee?', { lang: 'vi' }) //
|
|
|
166
169
|
await pb.matchFrames('Bạn có đi?', { lang: 'vi' }) // → 'Bạn có đi không?' (missing "không")
|
|
167
170
|
```
|
|
168
171
|
|
|
169
|
-
Options: `lang`, `region`, `listener` (a pronoun-table row id; without it, the neutral default row), `speaker`, `register` (`'casual'` allows colloquial words like Southern *hông*, *tui*). Content slots take English (translated for you, in the region) or `{ text }` in the language itself. `parts` says why each word was chosen, for highlighting. `matchFrames` recognizes a frame in a sentence in the language itself and returns the changes the frame
|
|
172
|
+
Options: `lang`, `region`, `listener` (a pronoun-table row id; without it, the neutral default row), `speaker`, `register` (`'casual'` allows colloquial words like Southern *hông*, *tui*). Content slots take English (translated for you, in the region) or `{ text }` in the language itself. `parts` says why each word was chosen, for highlighting. `matchFrames` recognizes a frame in a sentence in the language itself (the journal review uses it to list the frames a sentence follows) and returns the changes the frame would suggest: a missing required word (*không*), a word from another region (*mô* in Southern text → *đâu*), missing accents in a frame position (*Toi* → *Tôi*), and, only when a listener is given, a pronoun that doesn't fit them.
|
|
170
173
|
|
|
171
174
|
The frames themselves are written once, in English, in [`languages/frames.ts`](languages/frames.ts) (34 so far: journal sentences, feelings, questions, requests, greetings). Each language says how it expresses them (`frames` in `languages/<code>.ts`), with pronoun slots (`{I}`, `{YOU}`, `{WE_INCL}`, …, filled from the pronoun table) and **frame words** for what changes by region (`frameWords`: Vietnamese `{WHERE}` *đâu* / Central *mô*, `{HOW}` Northern *thế nào* / *sao* / Central *răng*, `{SOFT}` Northern *nhé* / Central and Southern *nha*, `{Q_END}` *không* / Southern casual *hông*, `{POLITE}` *ạ* when speaking up). Each frame word comes from a dictionary definition, keeping its region and labels, or from an override with a note, like the pronoun table. A language without frames returns none.
|
|
172
175
|
|
|
173
176
|
## Reviewing journal entries
|
|
174
177
|
|
|
175
|
-
`createReviewer().review(entry, { lang, base?, region?, listener?, speaker
|
|
178
|
+
`createReviewer().review(entry, { lang, base?, region?, register?, checks?, listener?, speaker? })` reviews an entry written in the target language, the learner's own language (`base`, default `'en'`), or a mix of both, and returns the corrected entry, in the target language, sentence by sentence:
|
|
176
179
|
|
|
177
180
|
```ts
|
|
178
181
|
import { createReviewer } from 'which-dialect'
|
|
@@ -182,20 +185,23 @@ const review = await createReviewer().review(
|
|
|
182
185
|
{ lang: 'vi', region: 'Southern' },
|
|
183
186
|
)
|
|
184
187
|
review.corrected
|
|
185
|
-
// 'Hôm nay tôi đi chợ với má. Tôi muốn ăn thịt
|
|
188
|
+
// 'Hôm nay tôi đi chợ với má. Tôi muốn ăn thịt lợn nhưng mắc quá. Ngày mai tui đi lại không?'
|
|
186
189
|
review.sentences[1].changes
|
|
187
|
-
// [{ from: 'but it was expensive', to: 'nhưng mắc quá', kind: 'frame', why: 'Sentence frame “but it was {quality}”' }
|
|
188
|
-
|
|
190
|
+
// [{ from: 'but it was expensive', to: 'nhưng mắc quá', kind: 'frame', why: 'Sentence frame “but it was {quality}”' }]
|
|
191
|
+
|
|
192
|
+
// With the regional and consistency checks on:
|
|
193
|
+
await createReviewer().review(entry, { lang: 'vi', region: 'Southern', checks: { dialect: true, 'pronoun-consistency': true } })
|
|
194
|
+
// 'Hôm nay tôi đi chợ với má. Tôi muốn ăn thịt heo nhưng mắc quá. Ngày mai tôi đi lại không?'
|
|
189
195
|
```
|
|
190
196
|
|
|
191
197
|
For each sentence:
|
|
192
198
|
|
|
193
|
-
1. **Each word is marked as the target or the base language** (`parts`),
|
|
194
|
-
2. **
|
|
195
|
-
3. **The
|
|
196
|
-
4. **The
|
|
199
|
+
1. **Each word is marked as the target or the base language** (`parts`), as in the checker's `foreign-word` check.
|
|
200
|
+
2. **A whole base-language sentence or clause that follows a sentence frame is filled in** with the frame's wording (*but it was expensive* → *nhưng mắc quá*; *Where is the bathroom?* → *Phòng tắm ở đâu?*).
|
|
201
|
+
3. **The checker runs on the whole entry**: by default the spellchecker, so every remaining English word gets its translation and every misspelled word its accents; `checks` turns on more (regions, pronouns, consistency across sentences, polite endings). Its errors and warnings are applied (`changes`); its suggestions, like ending with *ạ*, are returned as `hints`.
|
|
202
|
+
4. **The frames the corrected sentence follows are listed** (`frames`), for suggesting patterns to reuse. Frames don't change the sentence.
|
|
197
203
|
|
|
198
|
-
Each sentence has `original`, `parts`, `corrected`, `changes` (`{ from, to, why, kind }`), `hints`, `frames` (`{ id, en, text }`, the frame's English and its pattern) and `unchecked
|
|
204
|
+
Each sentence has `original`, `parts`, `corrected`, `changes` (`{ from, to, why, kind }`; `to` is `''` when a word is left out), `hints`, `frames` (`{ id, en, text }`, the frame's English and its pattern) and `unchecked` (English words with no translation, left as written). For a journal, leave `listener` out: people written about (*má*, *thầy*) aren't treated as the listener. Corrections are word by word except for frames: word order and grammar aren't fixed (*I am very happy* → *Tôi là rất mừng*).
|
|
199
205
|
|
|
200
206
|
## Looking words up
|
|
201
207
|
|
|
@@ -206,6 +212,8 @@ One language's dictionary. `baseUrl` defaults to `https://cdn.jsdelivr.net/npm/w
|
|
|
206
212
|
- `dictionary.lookup(word)`: all entries for a word, with every sense's definitions, regions, labels, synonyms and variant links (`altOf`).
|
|
207
213
|
- `dictionary.searchEnglish(term, { region?, pos?, exclude?, limit?, allSenses? })`: words for an English term in this language, one per word, best first (no meaning handling: use the translator for that). `allSenses: true` returns every matching sense of those words instead.
|
|
208
214
|
- `dictionary.frames()`: the language's sentence frames and frame words, unfilled (`{ frames, words }`; empty without frames).
|
|
215
|
+
- `dictionary.segment(text)`: the text split into the language's words, longest dictionary match first (up to the checker's `maxWordUnits`, 3 for Vietnamese): `Hôm nay tôi ăn cơm.` → *Hôm nay* · *tôi* · *ăn cơm*, each `{ text, start, end, entries }` (offsets into the NFC text; `entries` empty for words the dictionary doesn't have). Use it to treat a word of several syllables as one, e.g. to show its meaning.
|
|
216
|
+
- `dictionary.variants(word, { limit? })`: other words with the same letters but other accents that the dictionary has, most common first, each `{ word, entries, frequency? }`: *muộn* → *muốn*, *mượn*, *muôn*…; for words of several syllables every syllable varies (*hom nay* → *hôm nay*). For apps that let learners pick the word they meant: a real word with the wrong tone passes the spellchecker. Languages written in syllables only (`[]` otherwise).
|
|
209
217
|
- `dictionary.checker()` / `dictionary.syllables()`: the language's checker settings and syllable list (with frequencies), used by `createChecker` (`{}` for languages without them).
|
|
210
218
|
- `dictionary.picks()`: the language's [hand-picked words](#hand-picked-words), `{ word, pos, gloss, picks: [{ word, tags? }], first? }` per English meaning (empty for languages without any).
|
|
211
219
|
- `dictionary.meta()`: the language's name, regions, region groups, source, license, build date and counts.
|
package/dist/check.d.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { type TranslatorOptions } from './translate.ts';
|
|
2
|
-
export type CheckRule = 'spelling' | 'dialect' | 'pronoun-relationship' | 'pronoun-pair' | 'pronoun-consistency' | 'polite-ending';
|
|
2
|
+
export type CheckRule = 'spelling' | 'foreign-word' | 'dialect' | 'pronoun-relationship' | 'pronoun-pair' | 'pronoun-consistency' | 'polite-ending';
|
|
3
|
+
/**
|
|
4
|
+
* Which checks run unless `rules` says otherwise: the spellchecker (spelling, and words in the learner's
|
|
5
|
+
* own language, with their translation). The rest are opt-in, e.g. `rules: { dialect: true }`.
|
|
6
|
+
*/
|
|
7
|
+
export declare const DEFAULT_CHECK_RULES: Record<CheckRule, boolean>;
|
|
3
8
|
export type CheckIssue = {
|
|
4
9
|
rule: CheckRule;
|
|
5
10
|
/**
|
|
@@ -20,15 +25,28 @@ export type CheckIssue = {
|
|
|
20
25
|
export type CheckOptions = {
|
|
21
26
|
/** Language code of the text, e.g. "vi". */
|
|
22
27
|
lang: string;
|
|
23
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* The learner's own language, whose words in the text are translated (`foreign-word`). Default "en";
|
|
30
|
+
* none when it's the text's language.
|
|
31
|
+
*/
|
|
32
|
+
base?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Region or region group the text should be in, e.g. "Southern": translations use its words, and the
|
|
35
|
+
* `dialect` check flags other regions' words (without it, mixed regions).
|
|
36
|
+
*/
|
|
24
37
|
region?: string;
|
|
38
|
+
/** 'casual' allows colloquial translations (Southern "tui"); otherwise plain ones come first. */
|
|
39
|
+
register?: 'casual' | 'neutral' | 'polite';
|
|
25
40
|
/** Who you're talking to, as a pronoun-table row id (see Dictionary.pronouns), e.g. "parent". */
|
|
26
41
|
listener?: string;
|
|
27
42
|
/** Who you're talking about, as a pronoun-table row id, for "he/she" and "they" pronouns. */
|
|
28
43
|
about?: string;
|
|
29
44
|
/** The speaker's gender, for pronouns that depend on it. */
|
|
30
45
|
speaker?: 'male' | 'female';
|
|
31
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Turn checks on or off. By default only the spellchecker runs (DEFAULT_CHECK_RULES: `spelling` and
|
|
48
|
+
* `foreign-word`); opt in to others, e.g. { dialect: true, 'pronoun-relationship': true }.
|
|
49
|
+
*/
|
|
32
50
|
rules?: Partial<Record<CheckRule, boolean>>;
|
|
33
51
|
};
|
|
34
52
|
export type CheckResult = {
|
|
@@ -36,6 +54,13 @@ export type CheckResult = {
|
|
|
36
54
|
text: string;
|
|
37
55
|
/** Sorted by position. */
|
|
38
56
|
issues: CheckIssue[];
|
|
57
|
+
/** The text split by language (the target and the base), in order; one part when no base is given. */
|
|
58
|
+
parts: {
|
|
59
|
+
text: string;
|
|
60
|
+
lang: string;
|
|
61
|
+
start: number;
|
|
62
|
+
end: number;
|
|
63
|
+
}[];
|
|
39
64
|
};
|
|
40
65
|
export type Checker = {
|
|
41
66
|
check(text: string, options: CheckOptions): Promise<CheckResult>;
|
package/dist/check.js
CHANGED
|
@@ -1,17 +1,33 @@
|
|
|
1
|
-
// Checking text in one language
|
|
2
|
-
//
|
|
1
|
+
// Checking text in one language. By default it's a spellchecker that gives a correction for every word it
|
|
2
|
+
// can: syllables that aren't in the language get their accented forms ("khong" → "không"), and words in the
|
|
3
|
+
// learner's own language (English by default) get the target-language word ("market" → "chợ"). Further
|
|
4
|
+
// checks are opt-in (`rules`): words from another region, pronouns and polite endings that fit who you're
|
|
5
|
+
// talking to, and consistency within the text.
|
|
3
6
|
//
|
|
4
7
|
// Every rule reads only the language's data: its dictionary, pronoun table, checker.json and
|
|
5
8
|
// syllables.json (see CheckerConfig). Adding a language means adding data and settings in
|
|
6
|
-
// languages/<lang>.ts, not code here. A rule whose data a language doesn't have is skipped
|
|
7
|
-
// without checker settings is still checked for words from another region.
|
|
9
|
+
// languages/<lang>.ts, not code here. A rule whose data a language doesn't have is skipped.
|
|
8
10
|
//
|
|
9
11
|
// The checker only speaks up when it's confident and stays silent otherwise, so no issues means none of
|
|
10
12
|
// the checks found anything, not that the text is correct.
|
|
11
13
|
import { createDictionary, resolveRegion } from "./index.js";
|
|
12
|
-
import { joined, matchCase, plain, segment, sentencesOf, units } from "./text.js";
|
|
14
|
+
import { accentCombinations, joined, matchCase, plain, segment, sentencesOf, syllablesByPlain, units } from "./text.js";
|
|
15
|
+
import { markLanguages } from "./detect.js";
|
|
13
16
|
import { createTranslator } from "./translate.js";
|
|
14
17
|
import { PRONOUN_PERSONS, } from "./types.js";
|
|
18
|
+
/**
|
|
19
|
+
* Which checks run unless `rules` says otherwise: the spellchecker (spelling, and words in the learner's
|
|
20
|
+
* own language, with their translation). The rest are opt-in, e.g. `rules: { dialect: true }`.
|
|
21
|
+
*/
|
|
22
|
+
export const DEFAULT_CHECK_RULES = {
|
|
23
|
+
spelling: true,
|
|
24
|
+
'foreign-word': true,
|
|
25
|
+
dialect: false,
|
|
26
|
+
'pronoun-relationship': false,
|
|
27
|
+
'pronoun-pair': false,
|
|
28
|
+
'pronoun-consistency': false,
|
|
29
|
+
'polite-ending': false,
|
|
30
|
+
};
|
|
15
31
|
// How each pronoun-table column is named in messages.
|
|
16
32
|
const PERSON_NAMES = {
|
|
17
33
|
self: 'I', addressee: 'you', third: 'he/she', selfPlural: 'we', addresseePlural: 'you (plural)', thirdPlural: 'they',
|
|
@@ -25,42 +41,180 @@ const lowerFirst = (s) => s.charAt(0).toLowerCase() + s.slice(1);
|
|
|
25
41
|
const listOf = (items, conjunction = 'and') => items.length <= 1 ? items.join('') : `${items.slice(0, -1).join(', ')} ${conjunction} ${items[items.length - 1]}`;
|
|
26
42
|
const unique = (items) => [...new Set(items)];
|
|
27
43
|
// --- Rules -----------------------------------------------------------------------------------------------
|
|
28
|
-
// Spelling and accents, for languages written in syllables with a syllable list
|
|
29
|
-
// the language is flagged, with the accented forms it could be ("khong" →
|
|
30
|
-
// syllables with no accented form are left alone
|
|
44
|
+
// Spelling and accents, for languages written in syllables with a syllable list (with frequencies):
|
|
45
|
+
// - a syllable that isn't in the language is flagged, with the accented forms it could be ("khong" →
|
|
46
|
+
// "không"); plain-letter syllables with no accented form are left alone (names, foreign words: "email");
|
|
47
|
+
// - a plain-letter syllable that is a word but a rare one, next to a much more common accented form, is
|
|
48
|
+
// probably missing its accents ("toi" → "tôi", "hom" → "hôm"). In a sentence typed without accents (at
|
|
49
|
+
// least half plain letters and a missing-accents error already, or 3+ words with no accent at all; half,
|
|
50
|
+
// because filled-in sentence frames add accented words), any accented form that's more common
|
|
51
|
+
// counts ("an" → "ăn"; but not "con" → "còn", about as common). Left alone: capitalized words
|
|
52
|
+
// mid-sentence (names) and the language's listed pronouns, some written without accents ("tui").
|
|
53
|
+
// First, a doubtful syllable (one of the above) is tried with its neighbors: when fixing its accents makes a
|
|
54
|
+
// dictionary word with them, that word is suggested for the run ("hom nay" → "hôm nay", not "hôm này").
|
|
55
|
+
// Units inside a known word, or in the learner's own language, aren't checked.
|
|
56
|
+
// Zipf points by which an accented form must be more common than a plain-letter word to be suggested.
|
|
57
|
+
const ACCENTS_GAP = 1.5;
|
|
58
|
+
const ACCENTS_GAP_UNACCENTED_TEXT = 0.2;
|
|
59
|
+
// Accent forms tried per doubtful syllable when looking for a word with its neighbors, and how many
|
|
60
|
+
// combinations are looked up at most.
|
|
61
|
+
const COMPOUND_FORMS_PER_SYLLABLE = 5;
|
|
62
|
+
const COMPOUND_MAX_COMBINATIONS = 64;
|
|
31
63
|
async function spelling(ctx) {
|
|
32
64
|
if (ctx.config.units !== 'syllables' || !ctx.meta.syllables)
|
|
33
65
|
return [];
|
|
34
66
|
const syllables = await ctx.dict.syllables();
|
|
35
|
-
const byPlain =
|
|
36
|
-
for (const s of Object.keys(syllables))
|
|
37
|
-
byPlain.set(plain(s), [...(byPlain.get(plain(s)) ?? []), s]);
|
|
38
|
-
for (const list of byPlain.values())
|
|
39
|
-
list.sort((a, b) => syllables[b] - syllables[a]);
|
|
67
|
+
const byPlain = syllablesByPlain(syllables);
|
|
40
68
|
const issues = [];
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
69
|
+
const suggest = (u, candidates, severity, message) => issues.push({ rule: 'spelling', severity, start: u.start, end: u.end, text: u.text, message, suggestions: candidates.map((c) => matchCase(u.text, c)) });
|
|
70
|
+
const options = (u, candidates) => listOf(candidates.map((c) => quote(matchCase(u.text, c))), 'or');
|
|
71
|
+
const pronouns = new Set(Object.values(ctx.config.pronouns ?? {}).flat().map((w) => w.toLowerCase()));
|
|
72
|
+
for (const sentence of ctx.sentences) {
|
|
73
|
+
const lone = sentence.words.filter((w) => !w.entries.length || w.units.length === 1).flatMap((w) => w.units).filter((u) => !ctx.inBase(u));
|
|
74
|
+
const target = sentence.units.filter((u) => !ctx.inBase(u) && /\p{L}/u.test(u.text));
|
|
75
|
+
const isPlain = (u) => u.text.toLowerCase() === plain(u.text.toLowerCase());
|
|
76
|
+
const mostlyPlain = target.filter(isPlain).length >= target.length * 0.5;
|
|
77
|
+
const lowerOf = (u) => u.text.toLowerCase().replace(/[’']/g, '');
|
|
78
|
+
const inWord = (u) => sentence.words.some((w) => w.entries.length && w.units.length > 1 && w.units.includes(u));
|
|
79
|
+
// What each lone unit is: not a syllable of the language (with forms it could be), or a plain-letter
|
|
80
|
+
// syllable that could be missing its accents.
|
|
81
|
+
const checked = lone.filter((u) => !/\d/.test(lowerOf(u)) && !inWord(u));
|
|
82
|
+
const unknown = new Set(checked.filter((u) => !(lowerOf(u) in syllables) && ((byPlain.get(plain(lowerOf(u))) ?? []).length || !isPlain(u))));
|
|
83
|
+
const plainWords = new Set(checked.filter((u) => {
|
|
84
|
+
const lower = lowerOf(u);
|
|
85
|
+
return lower in syllables && lower === plain(lower) && !pronouns.has(lower) && (u === sentence.units[0] || !/\p{Lu}/u.test(u.text.charAt(0)));
|
|
86
|
+
}));
|
|
87
|
+
const missingAccents = [...unknown].some(isPlain);
|
|
88
|
+
// Typed without accents: mostly plain letters, and an accent error already, or 3+ words with no accent at all.
|
|
89
|
+
const unaccented = mostlyPlain && (missingAccents || (target.length >= 3 && target.every(isPlain)));
|
|
90
|
+
const gap = unaccented ? ACCENTS_GAP_UNACCENTED_TEXT : ACCENTS_GAP;
|
|
91
|
+
const betterForms = (u) => {
|
|
92
|
+
const lower = lowerOf(u);
|
|
93
|
+
return (byPlain.get(lower) ?? []).filter((c) => c !== lower && syllables[c] >= syllables[lower] + gap - 1e-9).slice(0, 3);
|
|
94
|
+
};
|
|
95
|
+
const suspect = (u) => unknown.has(u) || (plainWords.has(u) && betterForms(u).length > 0);
|
|
96
|
+
// First, runs of syllables with a doubtful one that make a word once their accents are fixed: "hom nay"
|
|
97
|
+
// → "hôm nay", "an com" → "ăn cơm", "hôm nây" → "hôm nay". Only the doubtful syllables change. The word
|
|
98
|
+
// decides between forms a syllable alone can't ("nay" alone could be này, nảy…).
|
|
99
|
+
const covered = new Set();
|
|
100
|
+
const loneSet = new Set(checked);
|
|
101
|
+
const maxUnits = ctx.config.maxWordUnits ?? 3;
|
|
102
|
+
for (let i = 0; i < sentence.units.length; i++) {
|
|
103
|
+
for (let k = Math.min(maxUnits, sentence.units.length - i); k >= 2; k--) {
|
|
104
|
+
const span = sentence.units.slice(i, i + k);
|
|
105
|
+
if (span.some((u, j) => !loneSet.has(u) || covered.has(u) || (j > 0 && !joined(ctx.text, span[j - 1], u))))
|
|
106
|
+
continue;
|
|
107
|
+
if (!span.some(suspect))
|
|
108
|
+
continue;
|
|
109
|
+
const written = span.map(lowerOf);
|
|
110
|
+
const combos = accentCombinations(written, byPlain, {
|
|
111
|
+
fixed: span.map((u) => !suspect(u)),
|
|
112
|
+
perUnit: COMPOUND_FORMS_PER_SYLLABLE,
|
|
113
|
+
max: COMPOUND_MAX_COMBINATIONS,
|
|
114
|
+
}).filter((c) => c !== written.join(' '));
|
|
115
|
+
const found = (await Promise.all(combos.map(async (c) => ({ word: c, entries: await ctx.dict.lookup(c) }))))
|
|
116
|
+
.filter((f) => f.entries.length)
|
|
117
|
+
.map((f) => ({ word: f.word, frequency: Math.max(-1, ...f.entries.map((e) => e.frequency ?? -1)) }))
|
|
118
|
+
.sort((a, b) => b.frequency - a.frequency)
|
|
119
|
+
.slice(0, 3);
|
|
120
|
+
if (!found.length)
|
|
121
|
+
continue;
|
|
122
|
+
const at = { start: span[0].start, end: span[k - 1].end, text: ctx.text.slice(span[0].start, span[k - 1].end) };
|
|
123
|
+
const suggestions = found.map((f) => matchCase(at.text, f.word));
|
|
124
|
+
const quoted = listOf(suggestions.map(quote), 'or');
|
|
125
|
+
issues.push({
|
|
126
|
+
rule: 'spelling', severity: 'warning', ...at, suggestions,
|
|
127
|
+
message: span.filter(suspect).every(isPlain)
|
|
128
|
+
? `${quote(at.text)} looks typed without accents. Did you mean ${quoted}?`
|
|
129
|
+
: `${quote(at.text)} isn't spelled right. Did you mean ${quoted}?`,
|
|
130
|
+
});
|
|
131
|
+
span.forEach((u) => covered.add(u));
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Then each remaining syllable on its own.
|
|
136
|
+
for (const u of checked) {
|
|
137
|
+
if (covered.has(u))
|
|
51
138
|
continue;
|
|
52
|
-
|
|
53
|
-
|
|
139
|
+
const lower = lowerOf(u);
|
|
140
|
+
if (unknown.has(u)) {
|
|
141
|
+
const candidates = (byPlain.get(plain(lower)) ?? []).slice(0, 3);
|
|
142
|
+
const accented = !isPlain(u);
|
|
54
143
|
// Missing accents on plain letters is the common learner slip; a wrong accent is more surely wrong.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
144
|
+
suggest(u, candidates, accented ? 'error' : 'warning', candidates.length
|
|
145
|
+
? `${quote(u.text)} isn't a ${ctx.meta.name} syllable. Did you mean ${options(u, candidates)}?`
|
|
146
|
+
: `${quote(u.text)} isn't a ${ctx.meta.name} syllable. Check the spelling and accents.`);
|
|
147
|
+
}
|
|
148
|
+
else if (plainWords.has(u)) {
|
|
149
|
+
const better = betterForms(u);
|
|
150
|
+
if (!better.length)
|
|
151
|
+
continue;
|
|
152
|
+
suggest(u, better, 'warning', unaccented
|
|
153
|
+
? `${quote(u.text)} looks typed without accents. Did you mean ${options(u, better)}?`
|
|
154
|
+
: `${quote(u.text)} is a rare word; did you mean ${options(u, better)}?`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return issues;
|
|
159
|
+
}
|
|
160
|
+
// Words in the learner's own language (English by default), with the target-language word to use: "market"
|
|
161
|
+
// → "chợ". Runs of base units are split into the longest phrases the base dictionary knows ("ice cream"),
|
|
162
|
+
// each translated in the region and register. Words the language usually has no word for
|
|
163
|
+
// (CheckerConfig.leaveOut: Vietnamese has no articles) are suggested to be left out.
|
|
164
|
+
async function foreignWord(ctx) {
|
|
165
|
+
const base = ctx.base;
|
|
166
|
+
if (!base)
|
|
167
|
+
return [];
|
|
168
|
+
const leaveOut = new Set((ctx.config.leaveOut ?? []).map((w) => w.toLowerCase()));
|
|
169
|
+
const issues = [];
|
|
170
|
+
for (const s of ctx.sentences) {
|
|
171
|
+
const runs = [];
|
|
172
|
+
s.units.forEach((u, i) => {
|
|
173
|
+
if (!ctx.inBase(u))
|
|
174
|
+
return;
|
|
175
|
+
const last = runs[runs.length - 1];
|
|
176
|
+
if (last && last[last.length - 1] === s.units[i - 1] && joined(ctx.text, s.units[i - 1], u))
|
|
177
|
+
last.push(u);
|
|
178
|
+
else
|
|
179
|
+
runs.push([u]);
|
|
180
|
+
});
|
|
181
|
+
for (const run of runs) {
|
|
182
|
+
for (let i = 0; i < run.length;) {
|
|
183
|
+
// The longest phrase the base dictionary knows and the language has a word for; single words
|
|
184
|
+
// always give an issue, with or without a translation.
|
|
185
|
+
let found;
|
|
186
|
+
for (let k = Math.min(MAX_FOREIGN_UNITS, run.length - i); k >= 1 && !found; k--) {
|
|
187
|
+
const phrase = ctx.text.slice(run[i].start, run[i + k - 1].end);
|
|
188
|
+
if (k === 1 && leaveOut.has(phrase.toLowerCase())) {
|
|
189
|
+
found = { k, words: [], leave: true };
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
if (k > 1 && !(await ctx.translator.senses(phrase, { from: base }).catch(() => [])).length)
|
|
193
|
+
continue;
|
|
194
|
+
const [group] = await ctx.translator.translate(phrase, {
|
|
195
|
+
from: base, to: ctx.options.lang, toRegion: ctx.options.region, register: ctx.options.register, listener: ctx.options.listener, limit: 3,
|
|
196
|
+
}).catch(() => []);
|
|
197
|
+
const words = unique((group?.translations ?? []).map((t) => t.word));
|
|
198
|
+
if (words.length || k === 1)
|
|
199
|
+
found = { k, words, leave: false };
|
|
200
|
+
}
|
|
201
|
+
const { k, words, leave } = found;
|
|
202
|
+
const span = run.slice(i, i + k);
|
|
203
|
+
const text = ctx.text.slice(span[0].start, span[k - 1].end);
|
|
204
|
+
i += k;
|
|
205
|
+
const at = { rule: 'foreign-word', severity: 'error', start: span[0].start, end: span[k - 1].end, text };
|
|
206
|
+
if (leave) {
|
|
207
|
+
issues.push({ ...at, message: `${quote(text)} isn't ${ctx.meta.name}; ${ctx.meta.name} usually has no word for it, so leave it out.`, suggestions: [''] });
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
issues.push({
|
|
211
|
+
...at,
|
|
212
|
+
message: words.length
|
|
213
|
+
? `${quote(text)} isn't ${ctx.meta.name}: say ${listOf(words.map(quote), 'or')}.`
|
|
214
|
+
: `${quote(text)} isn't ${ctx.meta.name}, and no translation was found.`,
|
|
215
|
+
suggestions: words.map((w) => (span[0].start === s.units[0].start ? matchCase(text, w) : w)),
|
|
216
|
+
});
|
|
217
|
+
}
|
|
64
218
|
}
|
|
65
219
|
}
|
|
66
220
|
return issues;
|
|
@@ -73,6 +227,7 @@ async function dialect(ctx) {
|
|
|
73
227
|
const all = new Set(ctx.meta.regions);
|
|
74
228
|
const regional = ctx.sentences
|
|
75
229
|
.flatMap((s) => s.words)
|
|
230
|
+
.filter((word) => !word.units.some((u) => ctx.inBase(u)))
|
|
76
231
|
.map((word) => ({ word, sense: word.entries[0]?.senses[0] }))
|
|
77
232
|
.filter((x) => Boolean(x.sense?.regionTagged && x.sense.regions.length < all.size));
|
|
78
233
|
if (!regional.length)
|
|
@@ -239,8 +394,11 @@ async function politeEnding(ctx) {
|
|
|
239
394
|
}
|
|
240
395
|
return issues;
|
|
241
396
|
}
|
|
397
|
+
// The longest base-language phrase looked up as one ("ice cream", "take care of").
|
|
398
|
+
const MAX_FOREIGN_UNITS = 3;
|
|
242
399
|
const RULES = {
|
|
243
400
|
spelling,
|
|
401
|
+
'foreign-word': foreignWord,
|
|
244
402
|
dialect,
|
|
245
403
|
'pronoun-relationship': pronounRelationship,
|
|
246
404
|
'pronoun-pair': pronounPair,
|
|
@@ -281,10 +439,13 @@ export function createChecker(options = {}) {
|
|
|
281
439
|
}
|
|
282
440
|
return pending;
|
|
283
441
|
};
|
|
442
|
+
const base = (opts.base ?? 'en') === opts.lang ? undefined : (opts.base ?? 'en');
|
|
284
443
|
const sentences = [];
|
|
285
444
|
for (const list of sentencesOf(text, units(text))) {
|
|
286
|
-
|
|
445
|
+
const langs = base ? await markLanguages(list, { lang: opts.lang, base, target: d, baseDictionary: dict(base), translator }) : list.map(() => opts.lang);
|
|
446
|
+
sentences.push({ units: list, langs, words: await segment(text, list, config.maxWordUnits ?? 3, lookup) });
|
|
287
447
|
}
|
|
448
|
+
const baseUnits = new Set(sentences.flatMap((s) => s.units.filter((_, i) => s.langs[i] !== opts.lang).map((u) => u.start)));
|
|
288
449
|
let rows;
|
|
289
450
|
let matches;
|
|
290
451
|
const ctx = {
|
|
@@ -298,6 +459,9 @@ export function createChecker(options = {}) {
|
|
|
298
459
|
// The language's reliable pronouns in the text, longest first ("chúng tôi" before "tôi"), matched on
|
|
299
460
|
// units directly so pronouns the dictionary doesn't list as one word ("tụi mày") are found too.
|
|
300
461
|
pronounMatches: () => (matches ??= findPronouns(text, sentences, config)),
|
|
462
|
+
base,
|
|
463
|
+
inBase: (u) => baseUnits.has(u.start),
|
|
464
|
+
translator,
|
|
301
465
|
suggestRegional: async (word, region) => {
|
|
302
466
|
try {
|
|
303
467
|
const [group] = await translator.translate(word.lower, {
|
|
@@ -310,9 +474,22 @@ export function createChecker(options = {}) {
|
|
|
310
474
|
}
|
|
311
475
|
},
|
|
312
476
|
};
|
|
313
|
-
const results = await Promise.all(Object.keys(RULES).filter((r) => opts.rules?.[r]
|
|
477
|
+
const results = await Promise.all(Object.keys(RULES).filter((r) => opts.rules?.[r] ?? DEFAULT_CHECK_RULES[r]).map((r) => RULES[r](ctx)));
|
|
314
478
|
const issues = results.flat().sort((a, b) => a.start - b.start || SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
|
|
315
|
-
|
|
479
|
+
// The text split by language: consecutive units in one language, with the text between them.
|
|
480
|
+
const parts = [];
|
|
481
|
+
for (const s of sentences) {
|
|
482
|
+
s.units.forEach((u, i) => {
|
|
483
|
+
const last = parts[parts.length - 1];
|
|
484
|
+
if (last && last.lang === s.langs[i])
|
|
485
|
+
last.end = u.end;
|
|
486
|
+
else
|
|
487
|
+
parts.push({ text: '', lang: s.langs[i], start: u.start, end: u.end });
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
for (const p of parts)
|
|
491
|
+
p.text = text.slice(p.start, p.end);
|
|
492
|
+
return { text, issues, parts };
|
|
316
493
|
},
|
|
317
494
|
};
|
|
318
495
|
}
|
package/dist/detect.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Dictionary } from './index.ts';
|
|
2
|
+
import { type Unit } from './text.ts';
|
|
3
|
+
import type { Translator } from './translate.ts';
|
|
4
|
+
export declare function markLanguages(list: Unit[], o: {
|
|
5
|
+
lang: string;
|
|
6
|
+
base: string;
|
|
7
|
+
target: Dictionary;
|
|
8
|
+
baseDictionary: Dictionary;
|
|
9
|
+
translator: Translator;
|
|
10
|
+
}): Promise<string[]>;
|
package/dist/detect.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Which language each word of a mixed text is in: the target language or the learner's own (the base,
|
|
2
|
+
// English by default), from the two languages' data alone. Shared by the checker and the journal review.
|
|
3
|
+
import { plain } from "./text.js";
|
|
4
|
+
/**
|
|
5
|
+
* Marks each unit (of one sentence) with `lang` or `base`. A unit the target knows and the base doesn't
|
|
6
|
+
* is the target's, and the other way round. A unit both know ("em", "an", "to") goes by how common it is
|
|
7
|
+
* in each: clearly more common in the target, the target's (Vietnamese "em", "di" next to English
|
|
8
|
+
* "hello", "shopping": English has rare "em", "di"); far more common in the base, the base's ("the");
|
|
9
|
+
* otherwise, and for units neither knows (names), the nearest decided neighbors' language when they agree,
|
|
10
|
+
* else the target's (the text is mainly the target language: "an" next to English "the"). In languages written in syllables, a base
|
|
11
|
+
* word that's a target syllable only once accents are added ("but": bút) is the base's; a word only the
|
|
12
|
+
* target knows without its accents ("khong") is still the target's. The base is known through the translator, so
|
|
13
|
+
* inflected forms count ("went", "cooked").
|
|
14
|
+
*/
|
|
15
|
+
// Zipf points by which a word both languages know must be more common in one to count as that language's
|
|
16
|
+
// whatever its neighbors: less for the target, since the text is mainly the target language.
|
|
17
|
+
const TARGET_MARGIN = 1;
|
|
18
|
+
const BASE_MARGIN = 2;
|
|
19
|
+
export async function markLanguages(list, o) {
|
|
20
|
+
const syllables = await o.target.syllables();
|
|
21
|
+
const syllableLanguage = Object.keys(syllables).length > 0;
|
|
22
|
+
const plainSyllables = syllableLanguage ? new Set(Object.keys(syllables).map(plain)) : new Set();
|
|
23
|
+
// 'exact': a target word as written; 'accents': one only with accents added ("khong" → "không").
|
|
24
|
+
const targetKnows = async (u) => {
|
|
25
|
+
const lower = u.text.toLowerCase();
|
|
26
|
+
if (!syllableLanguage)
|
|
27
|
+
return (await o.target.lookup(lower)).length > 0 ? 'exact' : false;
|
|
28
|
+
if (lower in syllables || lower !== plain(lower))
|
|
29
|
+
return 'exact';
|
|
30
|
+
return plainSyllables.has(lower) ? 'accents' : false;
|
|
31
|
+
};
|
|
32
|
+
const baseKnows = async (u) => (await o.translator.senses(u.text, { from: o.base }).catch(() => [])).length > 0;
|
|
33
|
+
const marks = await Promise.all(list.map(async (u) => {
|
|
34
|
+
if (/\d/.test(u.text))
|
|
35
|
+
return '?';
|
|
36
|
+
const [t, b] = await Promise.all([targetKnows(u), baseKnows(u)]);
|
|
37
|
+
if (t === 'exact')
|
|
38
|
+
return b ? 'both' : o.lang;
|
|
39
|
+
if (b)
|
|
40
|
+
return o.base;
|
|
41
|
+
return t === 'accents' ? o.lang : '?';
|
|
42
|
+
}));
|
|
43
|
+
// How common a word is in each language (Zipf; 0 when unknown).
|
|
44
|
+
const targetFrequency = async (u) => {
|
|
45
|
+
const lower = u.text.toLowerCase();
|
|
46
|
+
if (syllableLanguage)
|
|
47
|
+
return syllables[lower] ?? 0;
|
|
48
|
+
return (await o.target.lookup(lower))[0]?.frequency ?? 0;
|
|
49
|
+
};
|
|
50
|
+
const baseFrequency = async (u) => (await o.baseDictionary.lookup(u.text.toLowerCase()))[0]?.frequency ?? 0;
|
|
51
|
+
// Words both languages know, decided by frequency when it's clear.
|
|
52
|
+
const decided = await Promise.all(marks.map(async (m, i) => {
|
|
53
|
+
if (m !== 'both')
|
|
54
|
+
return m;
|
|
55
|
+
const [t, b] = await Promise.all([targetFrequency(list[i]), baseFrequency(list[i])]);
|
|
56
|
+
return t - b >= TARGET_MARGIN ? o.lang : b - t >= BASE_MARGIN ? o.base : '?';
|
|
57
|
+
}));
|
|
58
|
+
return decided.map((m, i) => {
|
|
59
|
+
if (m !== '?')
|
|
60
|
+
return m;
|
|
61
|
+
const left = decided.slice(0, i).reverse().find((x) => x !== '?');
|
|
62
|
+
const right = decided.slice(i + 1).find((x) => x !== '?');
|
|
63
|
+
return left && right && left !== right ? o.lang : left ?? right ?? o.lang;
|
|
64
|
+
});
|
|
65
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -49,6 +49,22 @@ export type PronounOptions = {
|
|
|
49
49
|
/** Labels to leave out. Defaults to DEFAULT_EXCLUDED_LABELS; pass [] to include everything. */
|
|
50
50
|
exclude?: string[];
|
|
51
51
|
};
|
|
52
|
+
/** One word of a text, as the dictionary splits it (see Dictionary.segment). */
|
|
53
|
+
export type WordSegment = {
|
|
54
|
+
/** The word as written; offsets are into the text in Unicode NFC form. */
|
|
55
|
+
text: string;
|
|
56
|
+
start: number;
|
|
57
|
+
end: number;
|
|
58
|
+
/** The word's dictionary entries; empty for words the dictionary doesn't have (names, typos). */
|
|
59
|
+
entries: Entry[];
|
|
60
|
+
};
|
|
61
|
+
/** Another way to write a word with the same letters (see Dictionary.variants). */
|
|
62
|
+
export type WordVariant = {
|
|
63
|
+
word: string;
|
|
64
|
+
entries: Entry[];
|
|
65
|
+
/** Zipf frequency (see Entry.frequency), or the syllable's for one syllable; undefined when unknown. */
|
|
66
|
+
frequency?: number;
|
|
67
|
+
};
|
|
52
68
|
export type Dictionary = {
|
|
53
69
|
/** The language's name, regions, source, license and counts. */
|
|
54
70
|
meta(): Promise<LanguageMeta>;
|
|
@@ -73,6 +89,22 @@ export type Dictionary = {
|
|
|
73
89
|
syllables(): Promise<Record<string, number>>;
|
|
74
90
|
/** The language's sentence frames and frame words, unfilled (see FramesData); empty without frames. */
|
|
75
91
|
frames(): Promise<FramesData>;
|
|
92
|
+
/**
|
|
93
|
+
* The text split into the language's words: at each point, the longest run of units (syllables or
|
|
94
|
+
* words, up to the checker's `maxWordUnits`, default 3) that's a headword, so Vietnamese "hôm nay" is one
|
|
95
|
+
* word ("today"), not "hôm" + "nay". Units join only across spaces. Units the dictionary doesn't know
|
|
96
|
+
* are words of their own with no entries. Punctuation isn't returned.
|
|
97
|
+
*/
|
|
98
|
+
segment(text: string): Promise<WordSegment[]>;
|
|
99
|
+
/**
|
|
100
|
+
* Other words written with the same letters but other accents, that the dictionary has, most common
|
|
101
|
+
* first: "muộn" → muốn, mượn, …; for words of several syllables, every syllable varies ("hom nay" →
|
|
102
|
+
* "hôm nay"). For languages written in syllables (with a syllable list); `[]` otherwise. The word
|
|
103
|
+
* itself isn't included.
|
|
104
|
+
*/
|
|
105
|
+
variants(word: string, options?: {
|
|
106
|
+
limit?: number;
|
|
107
|
+
}): Promise<WordVariant[]>;
|
|
76
108
|
};
|
|
77
109
|
/**
|
|
78
110
|
* The regions a region or region-group name stands for (e.g. English "North America" -> US, Canada),
|
|
@@ -86,3 +118,4 @@ export * from './check.ts';
|
|
|
86
118
|
export * from './text.ts';
|
|
87
119
|
export * from './phrasebook.ts';
|
|
88
120
|
export * from './review.ts';
|
|
121
|
+
export * from './detect.ts';
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { PRONOUN_PERSONS, fromStored, normalizeEnglish, shardKey, } from "./types.js";
|
|
2
|
+
import { accentCombinations, segment as segmentUnits, syllablesByPlain, units } from "./text.js";
|
|
2
3
|
export * from "./types.js";
|
|
3
4
|
export * from "./pos.js";
|
|
4
5
|
/**
|
|
@@ -14,6 +15,11 @@ export const DEFAULT_EXCLUDED_LABELS = [
|
|
|
14
15
|
// just a particular register); the second set costs more (specialized, playful or uncommon).
|
|
15
16
|
const MILD_LABELS = new Set(['colloquial', 'informal', 'familiar', 'polite', 'formal', 'endearing', 'honorific']);
|
|
16
17
|
const LABEL_PENALTY = (h) => h.labels.reduce((sum, l) => sum + (MILD_LABELS.has(l) ? 1 : 2), 0);
|
|
18
|
+
// Accent forms tried per syllable: more for one syllable, fewer for longer words, where every
|
|
19
|
+
// combination is looked up (at most MAX_VARIANT_COMBINATIONS).
|
|
20
|
+
const VARIANT_FORMS_ONE_SYLLABLE = 12;
|
|
21
|
+
const VARIANT_FORMS_PER_SYLLABLE = 5;
|
|
22
|
+
const MAX_VARIANT_COMBINATIONS = 64;
|
|
17
23
|
/**
|
|
18
24
|
* The regions a region or region-group name stands for (e.g. English "North America" -> US, Canada),
|
|
19
25
|
* or null when no region is given. Throws for names the language doesn't have, listing the valid ones.
|
|
@@ -63,13 +69,52 @@ export function createDictionary(options) {
|
|
|
63
69
|
const key = shardKey(term, m.shardLength);
|
|
64
70
|
return m.shards[kind].includes(key) ? loadOnce(`${kind}/${key}.json`) : null;
|
|
65
71
|
}
|
|
72
|
+
async function lookup(word) {
|
|
73
|
+
const w = word.trim();
|
|
74
|
+
const [m, data] = await Promise.all([meta(), shard('words', w)]);
|
|
75
|
+
const stored = data?.[w] ?? data?.[w.toLowerCase()] ?? [];
|
|
76
|
+
return stored.map((e) => ({ ...e, senses: e.senses.map((s) => fromStored(s, m.regions)) }));
|
|
77
|
+
}
|
|
78
|
+
const checker = async () => {
|
|
79
|
+
const m = await meta();
|
|
80
|
+
return m.checker ? loadOnce('checker.json') : {};
|
|
81
|
+
};
|
|
82
|
+
const syllables = async () => {
|
|
83
|
+
const m = await meta();
|
|
84
|
+
return m.syllables ? loadOnce('syllables.json') : {};
|
|
85
|
+
};
|
|
66
86
|
return {
|
|
67
87
|
meta,
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
88
|
+
lookup,
|
|
89
|
+
checker,
|
|
90
|
+
syllables,
|
|
91
|
+
async segment(input) {
|
|
92
|
+
const text = input.normalize('NFC');
|
|
93
|
+
const config = await checker();
|
|
94
|
+
const words = await segmentUnits(text, units(text), config.maxWordUnits ?? 3, lookup);
|
|
95
|
+
return words.map(({ text: t, start, end, entries }) => ({ text: t, start, end, entries }));
|
|
96
|
+
},
|
|
97
|
+
async variants(word, { limit = 5 } = {}) {
|
|
98
|
+
const list = await syllables();
|
|
99
|
+
if (!Object.keys(list).length)
|
|
100
|
+
return [];
|
|
101
|
+
const written = word.trim().normalize('NFC').toLowerCase();
|
|
102
|
+
const parts = written.split(/\s+/);
|
|
103
|
+
const combos = accentCombinations(parts, syllablesByPlain(list), {
|
|
104
|
+
perUnit: parts.length === 1 ? VARIANT_FORMS_ONE_SYLLABLE : VARIANT_FORMS_PER_SYLLABLE,
|
|
105
|
+
max: MAX_VARIANT_COMBINATIONS,
|
|
106
|
+
}).filter((c) => c !== written);
|
|
107
|
+
const found = await Promise.all(combos.map(async (c) => ({ word: c, entries: await lookup(c) })));
|
|
108
|
+
const frequency = (v) => {
|
|
109
|
+
const known = v.entries.map((e) => e.frequency).filter((f) => f !== undefined);
|
|
110
|
+
return known.length ? Math.max(...known) : parts.length === 1 ? list[v.word] || undefined : undefined;
|
|
111
|
+
};
|
|
112
|
+
// Most common first; ties (and unknown frequencies) keep the order of more common syllable forms.
|
|
113
|
+
return found
|
|
114
|
+
.filter((v) => v.entries.length)
|
|
115
|
+
.map((v) => ({ ...v, frequency: frequency(v) }))
|
|
116
|
+
.sort((a, b) => (b.frequency ?? -1) - (a.frequency ?? -1))
|
|
117
|
+
.slice(0, limit);
|
|
73
118
|
},
|
|
74
119
|
async searchEnglish(term, { region, pos, exclude = DEFAULT_EXCLUDED_LABELS, limit = 10, allSenses = false } = {}) {
|
|
75
120
|
const key = normalizeEnglish(term);
|
|
@@ -107,14 +152,6 @@ export function createDictionary(options) {
|
|
|
107
152
|
const m = await meta();
|
|
108
153
|
return m.picks ? loadOnce('picks.json') : [];
|
|
109
154
|
},
|
|
110
|
-
async checker() {
|
|
111
|
-
const m = await meta();
|
|
112
|
-
return m.checker ? loadOnce('checker.json') : {};
|
|
113
|
-
},
|
|
114
|
-
async syllables() {
|
|
115
|
-
const m = await meta();
|
|
116
|
-
return m.syllables ? loadOnce('syllables.json') : {};
|
|
117
|
-
},
|
|
118
155
|
async frames() {
|
|
119
156
|
const m = await meta();
|
|
120
157
|
if (!m.frames)
|
|
@@ -154,3 +191,4 @@ export * from "./check.js";
|
|
|
154
191
|
export * from "./text.js";
|
|
155
192
|
export * from "./phrasebook.js";
|
|
156
193
|
export * from "./review.js";
|
|
194
|
+
export * from "./detect.js";
|
package/dist/phrasebook.d.ts
CHANGED
|
@@ -39,6 +39,8 @@ export type FilledFrame = {
|
|
|
39
39
|
}[];
|
|
40
40
|
/** true when every content slot is filled (and translated). */
|
|
41
41
|
complete: boolean;
|
|
42
|
+
/** true for frames that can be part of a sentence ("but it was {quality}"). */
|
|
43
|
+
clause?: boolean;
|
|
42
44
|
};
|
|
43
45
|
/** A content slot value: English (translated for you) or `{ text }` in the target language. */
|
|
44
46
|
export type SlotValue = string | {
|
package/dist/phrasebook.js
CHANGED
|
@@ -114,7 +114,7 @@ export function createPhrasebook(options = {}) {
|
|
|
114
114
|
const slots = Object.entries(frame.slots).map(([name, pos]) => ({ name, pos }));
|
|
115
115
|
const en = Object.keys(frame.slots).reduce((s, name) => (typeof values[name] === 'string' ? s.replace(`{${name}}`, values[name]) : s), frame.en[0]);
|
|
116
116
|
return {
|
|
117
|
-
id: frame.id, topic: frame.topic, en, text,
|
|
117
|
+
id: frame.id, topic: frame.topic, en, text, ...(frame.clause ? { clause: true } : {}),
|
|
118
118
|
parts: parts.filter((p) => p.text),
|
|
119
119
|
slots,
|
|
120
120
|
complete: slots.every(({ name }) => values[name] !== undefined) && !parts.some((p) => p.untranslated),
|
package/dist/review.d.ts
CHANGED
|
@@ -2,17 +2,20 @@ import { type CheckRule } from './check.ts';
|
|
|
2
2
|
import { type FrameOptions } from './phrasebook.ts';
|
|
3
3
|
import { type TranslatorOptions } from './translate.ts';
|
|
4
4
|
export type ReviewOptions = FrameOptions & {
|
|
5
|
-
/** The learner's own language,
|
|
5
|
+
/** The learner's own language, whose words are translated into the target language (default "en"). */
|
|
6
6
|
base?: string;
|
|
7
|
+
/** Checks to add to the spellchecker, e.g. { dialect: true, 'pronoun-consistency': true } (see CheckRule). */
|
|
8
|
+
checks?: Partial<Record<CheckRule, boolean>>;
|
|
7
9
|
};
|
|
8
10
|
export type ReviewChange = {
|
|
9
|
-
/** What was written
|
|
11
|
+
/** What was written. */
|
|
10
12
|
from: string;
|
|
13
|
+
/** The correction; empty when the word is left out (Vietnamese has no "the"). */
|
|
11
14
|
to: string;
|
|
12
15
|
/** One sentence for the learner. */
|
|
13
16
|
why: string;
|
|
14
|
-
/** '
|
|
15
|
-
kind: '
|
|
17
|
+
/** 'frame' (a base-language sentence or clause filled into a sentence frame), or the checker rule. */
|
|
18
|
+
kind: 'frame' | CheckRule;
|
|
16
19
|
};
|
|
17
20
|
/** A checker suggestion that wasn't applied ("end with ạ"), for the learner to consider. */
|
|
18
21
|
export type ReviewHint = {
|
|
@@ -40,7 +43,7 @@ export type ReviewSentence = {
|
|
|
40
43
|
en: string;
|
|
41
44
|
text: string;
|
|
42
45
|
}[];
|
|
43
|
-
/**
|
|
46
|
+
/** Words the review couldn't correct (a base-language word with no translation), left as written. */
|
|
44
47
|
unchecked: string[];
|
|
45
48
|
};
|
|
46
49
|
export type Review = {
|
package/dist/review.js
CHANGED
|
@@ -1,24 +1,26 @@
|
|
|
1
1
|
// Reviewing a journal entry written in the target language, the learner's own language, or a mix of both
|
|
2
|
-
// ("Hôm nay tôi đi market với má"). For each sentence:
|
|
2
|
+
// ("Hôm nay tôi đi market với má"), and giving the corrected entry in the target language. For each sentence:
|
|
3
3
|
//
|
|
4
|
-
// 1. Each word is marked as the target or the base language, from the two dictionaries alone
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// "
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// 4. The
|
|
4
|
+
// 1. Each word is marked as the target or the base language (detect.ts), from the two dictionaries alone.
|
|
5
|
+
// 2. A whole base-language sentence or clause that follows a sentence frame is filled in ("but it was
|
|
6
|
+
// expensive" → "nhưng mắc quá"; "Where is the bathroom?" → "Phòng tắm ở đâu?").
|
|
7
|
+
// 3. The checker runs on the whole entry. By default that's the spellchecker: every misspelled word gets
|
|
8
|
+
// its accents ("khong" → "không") and every remaining base-language word its translation ("market" →
|
|
9
|
+
// "chợ"); opt-in checks (`checks`: regions, pronouns, consistency, polite endings) join it. Errors and
|
|
10
|
+
// warnings are applied; suggestions are returned as hints.
|
|
11
|
+
// 4. The sentence frames the corrected sentence follows are listed, for the learner to reuse. Frames don't
|
|
12
|
+
// change the sentence.
|
|
12
13
|
//
|
|
13
14
|
// Everything is rule-based and runs on the language's data: no API, and nothing language-specific here.
|
|
14
15
|
import { createChecker } from "./check.js";
|
|
16
|
+
import { markLanguages } from "./detect.js";
|
|
15
17
|
import { createDictionary } from "./index.js";
|
|
16
18
|
import { createPhrasebook } from "./phrasebook.js";
|
|
17
|
-
import {
|
|
19
|
+
import { sentencesOf, units } from "./text.js";
|
|
18
20
|
import { createTranslator } from "./translate.js";
|
|
19
|
-
// The longest base-language phrase translated word by word; longer ones need a frame.
|
|
20
|
-
const MAX_TRANSLATED_UNITS = 3;
|
|
21
21
|
const TERMINAL = /^[.!?…]+/;
|
|
22
|
+
// Spaces after a word was left out: one between words, none before punctuation.
|
|
23
|
+
const tidy = (s) => s.replace(/[^\S\n]+/g, ' ').replace(/ ([.,!?…])/g, '$1').trim();
|
|
22
24
|
export function createReviewer(options = {}) {
|
|
23
25
|
const cache = new Map();
|
|
24
26
|
const dict = (lang) => {
|
|
@@ -32,53 +34,10 @@ export function createReviewer(options = {}) {
|
|
|
32
34
|
const translator = createTranslator({ ...options, dictionary: dict });
|
|
33
35
|
const phrasebook = createPhrasebook({ ...options, dictionary: dict, translator });
|
|
34
36
|
const checker = createChecker({ ...options, dictionary: dict });
|
|
35
|
-
// Which language each unit is in. A unit the target knows and the base doesn't is the target's, and the
|
|
36
|
-
// other way round; units both or neither know ("an", "to", names) take the nearest decided neighbor's.
|
|
37
|
-
// In syllable languages, a base-language word that's a target syllable only once accents are added
|
|
38
|
-
// ("but": bút) is the base language's; a word only the target knows without its accents ("khong") is
|
|
39
|
-
// still the target's.
|
|
40
|
-
async function languages(list, lang, base) {
|
|
41
|
-
const target = dict(lang);
|
|
42
|
-
const syllables = await target.syllables();
|
|
43
|
-
const syllableLanguage = Object.keys(syllables).length > 0;
|
|
44
|
-
const plainSyllables = syllableLanguage ? new Set(Object.keys(syllables).map(plain)) : new Set();
|
|
45
|
-
// 'exact': a target word as written; 'accents': one only with accents added ("khong" → "không").
|
|
46
|
-
const targetKnows = async (u) => {
|
|
47
|
-
const lower = u.text.toLowerCase();
|
|
48
|
-
if (!syllableLanguage)
|
|
49
|
-
return (await target.lookup(lower)).length > 0 ? 'exact' : false;
|
|
50
|
-
if (lower in syllables || lower !== plain(lower))
|
|
51
|
-
return 'exact';
|
|
52
|
-
return plainSyllables.has(lower) ? 'accents' : false;
|
|
53
|
-
};
|
|
54
|
-
const baseKnows = async (u) => (await translator.senses(u.text, { from: base }).catch(() => [])).length > 0;
|
|
55
|
-
const marks = await Promise.all(list.map(async (u) => {
|
|
56
|
-
if (/\d/.test(u.text))
|
|
57
|
-
return '?';
|
|
58
|
-
const [t, b] = await Promise.all([targetKnows(u), baseKnows(u)]);
|
|
59
|
-
if (t === 'exact')
|
|
60
|
-
return b ? '?' : lang;
|
|
61
|
-
if (b)
|
|
62
|
-
return base;
|
|
63
|
-
return t === 'accents' ? lang : '?';
|
|
64
|
-
}));
|
|
65
|
-
return marks.map((m, i) => {
|
|
66
|
-
if (m !== '?')
|
|
67
|
-
return m;
|
|
68
|
-
for (let d = 1; d < marks.length; d++) {
|
|
69
|
-
if (i - d >= 0 && marks[i - d] !== '?')
|
|
70
|
-
return marks[i - d];
|
|
71
|
-
if (i + d < marks.length && marks[i + d] !== '?')
|
|
72
|
-
return marks[i + d];
|
|
73
|
-
}
|
|
74
|
-
return lang;
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
37
|
return {
|
|
78
38
|
async review(entry, o) {
|
|
79
39
|
const text = entry.normalize('NFC');
|
|
80
40
|
const base = o.base ?? 'en';
|
|
81
|
-
const meta = await dict(o.lang).meta();
|
|
82
41
|
// A frame as shown to the learner: its English and its pattern for these settings, slots open.
|
|
83
42
|
const frameInfo = new Map();
|
|
84
43
|
const frameOf = async (id) => {
|
|
@@ -93,7 +52,7 @@ export function createReviewer(options = {}) {
|
|
|
93
52
|
const start = list[0].start;
|
|
94
53
|
const coreEnd = list[list.length - 1].end;
|
|
95
54
|
const end = coreEnd + (TERMINAL.exec(text.slice(coreEnd))?.[0].length ?? 0);
|
|
96
|
-
const langs = await
|
|
55
|
+
const langs = base === o.lang ? list.map(() => o.lang) : await markLanguages(list, { lang: o.lang, base, target: dict(o.lang), baseDictionary: dict(base), translator });
|
|
97
56
|
const groups = [];
|
|
98
57
|
list.forEach((u, i) => {
|
|
99
58
|
if (groups.length && groups[groups.length - 1].lang === langs[i])
|
|
@@ -102,38 +61,45 @@ export function createReviewer(options = {}) {
|
|
|
102
61
|
groups.push({ lang: langs[i], units: [u] });
|
|
103
62
|
});
|
|
104
63
|
const draft = {
|
|
105
|
-
start, coreEnd, end, replacements: [], frames: [],
|
|
64
|
+
start, coreEnd, end, replacements: [], frames: [],
|
|
106
65
|
parts: groups.map((g) => ({ text: text.slice(g.units[0].start, g.units[g.units.length - 1].end), lang: g.lang })),
|
|
107
66
|
};
|
|
108
67
|
const whole = groups.length === 1 && groups[0].lang === base;
|
|
68
|
+
const fill = async (span, frame) => {
|
|
69
|
+
const from = text.slice(span[0].start, span[span.length - 1].end);
|
|
70
|
+
const info = await frameOf(frame.id);
|
|
71
|
+
let to = frame.text.replace(/[.!?…]+$/, '');
|
|
72
|
+
if (span[0].start !== start)
|
|
73
|
+
to = to.charAt(0).toLowerCase() + to.slice(1);
|
|
74
|
+
draft.replacements.push({
|
|
75
|
+
start: span[0].start, end: span[span.length - 1].end, to,
|
|
76
|
+
change: { kind: 'frame', from, to, why: `Sentence frame “${info.en}”` },
|
|
77
|
+
});
|
|
78
|
+
draft.frames.push(info);
|
|
79
|
+
};
|
|
109
80
|
for (const g of groups.filter((g) => g.lang === base)) {
|
|
110
|
-
const from = text.slice(g.units[0].start, g.units[g.units.length - 1].end);
|
|
111
|
-
const at = { start: g.units[0].start, end: g.units[g.units.length - 1].end };
|
|
112
81
|
// A whole sentence is matched with its own punctuation ("Where is the bathroom?").
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
to = to.charAt(0).toLowerCase() + to.slice(1);
|
|
118
|
-
const info = await frameOf(frame.id);
|
|
119
|
-
draft.replacements.push({ ...at, to, change: { kind: 'frame', from, to, why: `Sentence frame “${info.en}”` } });
|
|
120
|
-
draft.frames.push(info);
|
|
82
|
+
const whole_ = whole ? text.slice(start, end) : text.slice(g.units[0].start, g.units[g.units.length - 1].end);
|
|
83
|
+
const frame = await phrasebook.match(whole_, o);
|
|
84
|
+
if (frame?.complete) {
|
|
85
|
+
await fill(g.units, frame);
|
|
121
86
|
continue;
|
|
122
87
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
88
|
+
// Otherwise a clause frame ending the part, longest first ("the ice cream but it was expensive":
|
|
89
|
+
// "but it was expensive"); the rest is translated word by word by the checker. Only spans ending
|
|
90
|
+
// with the part, so a clause isn't cut short ("but it was very" with "expensive" left over).
|
|
91
|
+
for (let len = g.units.length - 1; len >= 2; len--) {
|
|
92
|
+
const span = g.units.slice(g.units.length - len);
|
|
93
|
+
const m = await phrasebook.match(text.slice(span[0].start, span[len - 1].end), o);
|
|
94
|
+
if (!m?.complete || !m.clause)
|
|
129
95
|
continue;
|
|
130
|
-
|
|
96
|
+
await fill(span, m);
|
|
97
|
+
break;
|
|
131
98
|
}
|
|
132
|
-
draft.unchecked.push(from);
|
|
133
99
|
}
|
|
134
100
|
drafts.push(draft);
|
|
135
101
|
}
|
|
136
|
-
// The entry with
|
|
102
|
+
// The entry with those parts filled in, keeping where each sentence now is.
|
|
137
103
|
let draftText = '';
|
|
138
104
|
let cursor = 0;
|
|
139
105
|
const placed = [];
|
|
@@ -151,41 +117,49 @@ export function createReviewer(options = {}) {
|
|
|
151
117
|
cursor = d.end;
|
|
152
118
|
}
|
|
153
119
|
draftText += text.slice(cursor);
|
|
154
|
-
// 3. The checker on the whole entry; 4. frames
|
|
155
|
-
const { issues } = await checker.check(draftText, {
|
|
120
|
+
// 3. The checker on the whole entry; 4. the frames each corrected sentence follows.
|
|
121
|
+
const { issues } = await checker.check(draftText, {
|
|
122
|
+
lang: o.lang, base, region: o.region, listener: o.listener, speaker: o.speaker, register: o.register, rules: o.checks,
|
|
123
|
+
});
|
|
156
124
|
const sentences = [];
|
|
157
125
|
for (const [i, d] of drafts.entries()) {
|
|
158
126
|
const p = placed[i];
|
|
159
127
|
const mine = issues.filter((x) => x.start >= p.start && x.end <= p.coreEnd);
|
|
160
128
|
const changes = d.replacements.sort((a, b) => a.start - b.start).map((r) => r.change);
|
|
161
129
|
const hints = [];
|
|
130
|
+
const unchecked = [];
|
|
131
|
+
const applied = [];
|
|
162
132
|
let core = draftText.slice(p.start, p.coreEnd);
|
|
163
133
|
let lastStart = Infinity;
|
|
164
134
|
for (const x of [...mine].sort((a, b) => b.start - a.start)) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
135
|
+
if (!x.suggestions.length) {
|
|
136
|
+
if (x.rule === 'foreign-word')
|
|
137
|
+
unchecked.unshift(x.text);
|
|
138
|
+
else
|
|
139
|
+
hints.unshift({ rule: x.rule, text: x.text, message: x.message, suggestions: x.suggestions });
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (x.severity === 'suggestion' || x.end > lastStart) {
|
|
143
|
+
hints.unshift({ rule: x.rule, text: x.text, message: x.message, suggestions: x.suggestions });
|
|
168
144
|
continue;
|
|
169
145
|
}
|
|
170
146
|
core = core.slice(0, x.start - p.start) + x.suggestions[0] + core.slice(x.end - p.start);
|
|
171
|
-
|
|
147
|
+
applied.unshift({ kind: x.rule, from: x.text, to: x.suggestions[0], why: x.message });
|
|
172
148
|
lastStart = x.start;
|
|
173
149
|
}
|
|
174
|
-
|
|
175
|
-
|
|
150
|
+
changes.push(...applied);
|
|
151
|
+
core = tidy(core);
|
|
152
|
+
if (/\p{Lu}/u.test(draftText.charAt(p.start)))
|
|
153
|
+
core = core.charAt(0).toUpperCase() + core.slice(1);
|
|
154
|
+
const corrected = core + draftText.slice(p.coreEnd, p.end);
|
|
176
155
|
const frames = [...d.frames];
|
|
177
156
|
for (const m of await phrasebook.matchFrames(corrected, o)) {
|
|
178
157
|
if (!frames.some((f) => f.id === m.frame.id))
|
|
179
158
|
frames.push(await frameOf(m.frame.id));
|
|
180
|
-
if (m.changes.length && corrected === core + punctuation) {
|
|
181
|
-
corrected = m.text;
|
|
182
|
-
for (const c of m.changes)
|
|
183
|
-
changes.push({ kind: 'frame', from: c.from, to: c.to, why: `${c.why} (sentence frame “${m.frame.en}”)` });
|
|
184
|
-
}
|
|
185
159
|
}
|
|
186
160
|
sentences.push({
|
|
187
161
|
start: d.start, end: d.end, original: text.slice(d.start, d.end), parts: d.parts,
|
|
188
|
-
corrected, changes, hints
|
|
162
|
+
corrected, changes, hints, frames, unchecked,
|
|
189
163
|
});
|
|
190
164
|
}
|
|
191
165
|
let corrected = '';
|
package/dist/text.d.ts
CHANGED
|
@@ -29,3 +29,16 @@ export declare function segment(text: string, list: Unit[], maxUnits: number, lo
|
|
|
29
29
|
export declare const plain: (s: string) => string;
|
|
30
30
|
/** Keeps a capital first letter when replacing a capitalized word ("Tôi" → "Con"). */
|
|
31
31
|
export declare const matchCase: (original: string, replacement: string) => string;
|
|
32
|
+
/** A language's syllables grouped by their form without accents, most common first. */
|
|
33
|
+
export declare function syllablesByPlain(syllables: Record<string, number>): Map<string, string[]>;
|
|
34
|
+
/**
|
|
35
|
+
* Ways to write a run of syllables with other accents ("hom nay" → "hôm nay", "hơm nay", …): each
|
|
36
|
+
* syllable's forms that share its letters (the most common `perUnit`, plus the syllable as written), in
|
|
37
|
+
* every combination, the ones made of more common forms first, at most `max`. Syllables that mustn't
|
|
38
|
+
* change (`fixed[i]`) keep their written form. The written run itself is included.
|
|
39
|
+
*/
|
|
40
|
+
export declare function accentCombinations(written: string[], byPlain: Map<string, string[]>, { fixed, perUnit, max }: {
|
|
41
|
+
fixed?: boolean[];
|
|
42
|
+
perUnit: number;
|
|
43
|
+
max: number;
|
|
44
|
+
}): string[];
|
package/dist/text.js
CHANGED
|
@@ -55,3 +55,43 @@ export async function segment(text, list, maxUnits, lookup) {
|
|
|
55
55
|
export const plain = (s) => s.normalize('NFD').replace(/\p{M}/gu, '').replace(/đ/g, 'd').replace(/Đ/g, 'D').normalize('NFC');
|
|
56
56
|
/** Keeps a capital first letter when replacing a capitalized word ("Tôi" → "Con"). */
|
|
57
57
|
export const matchCase = (original, replacement) => original.charAt(0) !== original.charAt(0).toLowerCase() ? replacement.charAt(0).toUpperCase() + replacement.slice(1) : replacement;
|
|
58
|
+
// Syllables grouped by their plain form, most common first: "khong" → ["không", "khống", …]. Built once
|
|
59
|
+
// per syllable list.
|
|
60
|
+
const byPlainCache = new WeakMap();
|
|
61
|
+
/** A language's syllables grouped by their form without accents, most common first. */
|
|
62
|
+
export function syllablesByPlain(syllables) {
|
|
63
|
+
let byPlain = byPlainCache.get(syllables);
|
|
64
|
+
if (!byPlain) {
|
|
65
|
+
byPlain = new Map();
|
|
66
|
+
for (const s of Object.keys(syllables))
|
|
67
|
+
byPlain.set(plain(s), [...(byPlain.get(plain(s)) ?? []), s]);
|
|
68
|
+
for (const list of byPlain.values())
|
|
69
|
+
list.sort((a, b) => syllables[b] - syllables[a]);
|
|
70
|
+
byPlainCache.set(syllables, byPlain);
|
|
71
|
+
}
|
|
72
|
+
return byPlain;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Ways to write a run of syllables with other accents ("hom nay" → "hôm nay", "hơm nay", …): each
|
|
76
|
+
* syllable's forms that share its letters (the most common `perUnit`, plus the syllable as written), in
|
|
77
|
+
* every combination, the ones made of more common forms first, at most `max`. Syllables that mustn't
|
|
78
|
+
* change (`fixed[i]`) keep their written form. The written run itself is included.
|
|
79
|
+
*/
|
|
80
|
+
export function accentCombinations(written, byPlain, { fixed = [], perUnit, max }) {
|
|
81
|
+
const choices = written.map((w, i) => {
|
|
82
|
+
const lower = w.toLowerCase();
|
|
83
|
+
if (fixed[i])
|
|
84
|
+
return [lower];
|
|
85
|
+
const forms = (byPlain.get(plain(lower)) ?? []).slice(0, perUnit);
|
|
86
|
+
return forms.includes(lower) ? forms : [...forms, lower];
|
|
87
|
+
});
|
|
88
|
+
// Every combination with the sum of its forms' ranks: lower sums are made of more common forms.
|
|
89
|
+
let combos = [{ words: [], rank: 0 }];
|
|
90
|
+
for (const options of choices) {
|
|
91
|
+
combos = combos.flatMap((c) => options.map((o, r) => ({ words: [...c.words, o], rank: c.rank + r })));
|
|
92
|
+
}
|
|
93
|
+
return combos
|
|
94
|
+
.sort((a, b) => a.rank - b.rank)
|
|
95
|
+
.slice(0, max)
|
|
96
|
+
.map((c) => c.words.join(' '));
|
|
97
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -195,6 +195,11 @@ export type CheckerConfig = {
|
|
|
195
195
|
ambiguousPronouns?: string[];
|
|
196
196
|
/** Sentence endings expected when speaking up (pronoun-table rows marked `respect`), e.g. Vietnamese "ạ". */
|
|
197
197
|
politeEndings?: string[];
|
|
198
|
+
/**
|
|
199
|
+
* English words (English is the bridge) this language usually has no word for, so the checker suggests
|
|
200
|
+
* leaving them out rather than translating them: Vietnamese has no articles ("the", "a", "an").
|
|
201
|
+
*/
|
|
202
|
+
leaveOut?: string[];
|
|
198
203
|
};
|
|
199
204
|
/**
|
|
200
205
|
* Hand-picked first choices for one English meaning, in one target language (picks.json). A speaker's
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "which-dialect",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Translate words between languages and dialects, meaning by meaning, starting with Vietnamese (Northern, Central, Southern) and English, with Wiktionary data loaded on demand. More languages are planned.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|