which-dialect 0.3.0 → 0.4.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 +35 -29
- package/dist/check.d.ts +28 -3
- package/dist/check.js +160 -29
- package/dist/detect.d.ts +10 -0
- package/dist/detect.js +58 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- 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/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`**: 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 that could be either (*an*, *tui*) 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
|
|
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
14
|
import { joined, matchCase, plain, segment, sentencesOf, 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,9 +41,19 @@ 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
|
+
// Units inside a known word, or in the learner's own language, aren't checked.
|
|
54
|
+
// Zipf points by which an accented form must be more common than a plain-letter word to be suggested.
|
|
55
|
+
const ACCENTS_GAP = 1.5;
|
|
56
|
+
const ACCENTS_GAP_UNACCENTED_TEXT = 0.2;
|
|
31
57
|
async function spelling(ctx) {
|
|
32
58
|
if (ctx.config.units !== 'syllables' || !ctx.meta.syllables)
|
|
33
59
|
return [];
|
|
@@ -38,29 +64,111 @@ async function spelling(ctx) {
|
|
|
38
64
|
for (const list of byPlain.values())
|
|
39
65
|
list.sort((a, b) => syllables[b] - syllables[a]);
|
|
40
66
|
const issues = [];
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
67
|
+
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)) });
|
|
68
|
+
const options = (u, candidates) => listOf(candidates.map((c) => quote(matchCase(u.text, c))), 'or');
|
|
69
|
+
const pronouns = new Set(Object.values(ctx.config.pronouns ?? {}).flat().map((w) => w.toLowerCase()));
|
|
70
|
+
for (const sentence of ctx.sentences) {
|
|
71
|
+
const lone = sentence.words.filter((w) => !w.entries.length || w.units.length === 1).flatMap((w) => w.units).filter((u) => !ctx.inBase(u));
|
|
72
|
+
const target = sentence.units.filter((u) => !ctx.inBase(u) && /\p{L}/u.test(u.text));
|
|
73
|
+
const mostlyPlain = target.filter((u) => u.text.toLowerCase() === plain(u.text.toLowerCase())).length >= target.length * 0.5;
|
|
74
|
+
let missingAccents = false;
|
|
75
|
+
const rare = [];
|
|
76
|
+
for (const u of lone) {
|
|
45
77
|
const lower = u.text.toLowerCase().replace(/[’']/g, '');
|
|
46
|
-
if (/\d/.test(lower)
|
|
78
|
+
if (/\d/.test(lower))
|
|
47
79
|
continue;
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
if (!candidates.length && !accented)
|
|
80
|
+
const inWord = sentence.words.some((w) => w.entries.length && w.units.length > 1 && w.units.includes(u));
|
|
81
|
+
if (inWord)
|
|
51
82
|
continue;
|
|
52
|
-
|
|
53
|
-
|
|
83
|
+
if (!(lower in syllables)) {
|
|
84
|
+
const candidates = (byPlain.get(plain(lower)) ?? []).slice(0, 3);
|
|
85
|
+
const accented = lower !== plain(lower);
|
|
86
|
+
if (!candidates.length && !accented)
|
|
87
|
+
continue;
|
|
88
|
+
if (!accented)
|
|
89
|
+
missingAccents = true;
|
|
54
90
|
// 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
|
-
|
|
91
|
+
suggest(u, candidates, accented ? 'error' : 'warning', candidates.length
|
|
92
|
+
? `${quote(u.text)} isn't a ${ctx.meta.name} syllable. Did you mean ${options(u, candidates)}?`
|
|
93
|
+
: `${quote(u.text)} isn't a ${ctx.meta.name} syllable. Check the spelling and accents.`);
|
|
94
|
+
}
|
|
95
|
+
else if (lower === plain(lower) && !pronouns.has(lower) && (u === sentence.units[0] || !/\p{Lu}/u.test(u.text.charAt(0)))) {
|
|
96
|
+
rare.push(u);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Typed without accents: mostly plain letters, and an accent error already, or 3+ words with no accent at all.
|
|
100
|
+
const unaccented = mostlyPlain && (missingAccents || (target.length >= 3 && target.every((u) => u.text.toLowerCase() === plain(u.text.toLowerCase()))));
|
|
101
|
+
for (const u of rare) {
|
|
102
|
+
const lower = u.text.toLowerCase();
|
|
103
|
+
const gap = unaccented ? ACCENTS_GAP_UNACCENTED_TEXT : ACCENTS_GAP;
|
|
104
|
+
const better = (byPlain.get(lower) ?? []).filter((c) => c !== lower && syllables[c] >= syllables[lower] + gap - 1e-9).slice(0, 3);
|
|
105
|
+
if (!better.length)
|
|
106
|
+
continue;
|
|
107
|
+
suggest(u, better, 'warning', unaccented
|
|
108
|
+
? `${quote(u.text)} looks typed without accents. Did you mean ${options(u, better)}?`
|
|
109
|
+
: `${quote(u.text)} is a rare word; did you mean ${options(u, better)}?`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return issues;
|
|
113
|
+
}
|
|
114
|
+
// Words in the learner's own language (English by default), with the target-language word to use: "market"
|
|
115
|
+
// → "chợ". Runs of base units are split into the longest phrases the base dictionary knows ("ice cream"),
|
|
116
|
+
// each translated in the region and register. Words the language usually has no word for
|
|
117
|
+
// (CheckerConfig.leaveOut: Vietnamese has no articles) are suggested to be left out.
|
|
118
|
+
async function foreignWord(ctx) {
|
|
119
|
+
const base = ctx.base;
|
|
120
|
+
if (!base)
|
|
121
|
+
return [];
|
|
122
|
+
const leaveOut = new Set((ctx.config.leaveOut ?? []).map((w) => w.toLowerCase()));
|
|
123
|
+
const issues = [];
|
|
124
|
+
for (const s of ctx.sentences) {
|
|
125
|
+
const runs = [];
|
|
126
|
+
s.units.forEach((u, i) => {
|
|
127
|
+
if (!ctx.inBase(u))
|
|
128
|
+
return;
|
|
129
|
+
const last = runs[runs.length - 1];
|
|
130
|
+
if (last && last[last.length - 1] === s.units[i - 1] && joined(ctx.text, s.units[i - 1], u))
|
|
131
|
+
last.push(u);
|
|
132
|
+
else
|
|
133
|
+
runs.push([u]);
|
|
134
|
+
});
|
|
135
|
+
for (const run of runs) {
|
|
136
|
+
for (let i = 0; i < run.length;) {
|
|
137
|
+
// The longest phrase the base dictionary knows and the language has a word for; single words
|
|
138
|
+
// always give an issue, with or without a translation.
|
|
139
|
+
let found;
|
|
140
|
+
for (let k = Math.min(MAX_FOREIGN_UNITS, run.length - i); k >= 1 && !found; k--) {
|
|
141
|
+
const phrase = ctx.text.slice(run[i].start, run[i + k - 1].end);
|
|
142
|
+
if (k === 1 && leaveOut.has(phrase.toLowerCase())) {
|
|
143
|
+
found = { k, words: [], leave: true };
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
if (k > 1 && !(await ctx.translator.senses(phrase, { from: base }).catch(() => [])).length)
|
|
147
|
+
continue;
|
|
148
|
+
const [group] = await ctx.translator.translate(phrase, {
|
|
149
|
+
from: base, to: ctx.options.lang, toRegion: ctx.options.region, register: ctx.options.register, listener: ctx.options.listener, limit: 3,
|
|
150
|
+
}).catch(() => []);
|
|
151
|
+
const words = unique((group?.translations ?? []).map((t) => t.word));
|
|
152
|
+
if (words.length || k === 1)
|
|
153
|
+
found = { k, words, leave: false };
|
|
154
|
+
}
|
|
155
|
+
const { k, words, leave } = found;
|
|
156
|
+
const span = run.slice(i, i + k);
|
|
157
|
+
const text = ctx.text.slice(span[0].start, span[k - 1].end);
|
|
158
|
+
i += k;
|
|
159
|
+
const at = { rule: 'foreign-word', severity: 'error', start: span[0].start, end: span[k - 1].end, text };
|
|
160
|
+
if (leave) {
|
|
161
|
+
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: [''] });
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
issues.push({
|
|
165
|
+
...at,
|
|
166
|
+
message: words.length
|
|
167
|
+
? `${quote(text)} isn't ${ctx.meta.name}: say ${listOf(words.map(quote), 'or')}.`
|
|
168
|
+
: `${quote(text)} isn't ${ctx.meta.name}, and no translation was found.`,
|
|
169
|
+
suggestions: words.map((w) => (span[0].start === s.units[0].start ? matchCase(text, w) : w)),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
64
172
|
}
|
|
65
173
|
}
|
|
66
174
|
return issues;
|
|
@@ -73,6 +181,7 @@ async function dialect(ctx) {
|
|
|
73
181
|
const all = new Set(ctx.meta.regions);
|
|
74
182
|
const regional = ctx.sentences
|
|
75
183
|
.flatMap((s) => s.words)
|
|
184
|
+
.filter((word) => !word.units.some((u) => ctx.inBase(u)))
|
|
76
185
|
.map((word) => ({ word, sense: word.entries[0]?.senses[0] }))
|
|
77
186
|
.filter((x) => Boolean(x.sense?.regionTagged && x.sense.regions.length < all.size));
|
|
78
187
|
if (!regional.length)
|
|
@@ -239,8 +348,11 @@ async function politeEnding(ctx) {
|
|
|
239
348
|
}
|
|
240
349
|
return issues;
|
|
241
350
|
}
|
|
351
|
+
// The longest base-language phrase looked up as one ("ice cream", "take care of").
|
|
352
|
+
const MAX_FOREIGN_UNITS = 3;
|
|
242
353
|
const RULES = {
|
|
243
354
|
spelling,
|
|
355
|
+
'foreign-word': foreignWord,
|
|
244
356
|
dialect,
|
|
245
357
|
'pronoun-relationship': pronounRelationship,
|
|
246
358
|
'pronoun-pair': pronounPair,
|
|
@@ -281,10 +393,13 @@ export function createChecker(options = {}) {
|
|
|
281
393
|
}
|
|
282
394
|
return pending;
|
|
283
395
|
};
|
|
396
|
+
const base = (opts.base ?? 'en') === opts.lang ? undefined : (opts.base ?? 'en');
|
|
284
397
|
const sentences = [];
|
|
285
398
|
for (const list of sentencesOf(text, units(text))) {
|
|
286
|
-
|
|
399
|
+
const langs = base ? await markLanguages(list, { lang: opts.lang, base, target: d, baseDictionary: dict(base), translator }) : list.map(() => opts.lang);
|
|
400
|
+
sentences.push({ units: list, langs, words: await segment(text, list, config.maxWordUnits ?? 3, lookup) });
|
|
287
401
|
}
|
|
402
|
+
const baseUnits = new Set(sentences.flatMap((s) => s.units.filter((_, i) => s.langs[i] !== opts.lang).map((u) => u.start)));
|
|
288
403
|
let rows;
|
|
289
404
|
let matches;
|
|
290
405
|
const ctx = {
|
|
@@ -298,6 +413,9 @@ export function createChecker(options = {}) {
|
|
|
298
413
|
// The language's reliable pronouns in the text, longest first ("chúng tôi" before "tôi"), matched on
|
|
299
414
|
// units directly so pronouns the dictionary doesn't list as one word ("tụi mày") are found too.
|
|
300
415
|
pronounMatches: () => (matches ??= findPronouns(text, sentences, config)),
|
|
416
|
+
base,
|
|
417
|
+
inBase: (u) => baseUnits.has(u.start),
|
|
418
|
+
translator,
|
|
301
419
|
suggestRegional: async (word, region) => {
|
|
302
420
|
try {
|
|
303
421
|
const [group] = await translator.translate(word.lower, {
|
|
@@ -310,9 +428,22 @@ export function createChecker(options = {}) {
|
|
|
310
428
|
}
|
|
311
429
|
},
|
|
312
430
|
};
|
|
313
|
-
const results = await Promise.all(Object.keys(RULES).filter((r) => opts.rules?.[r]
|
|
431
|
+
const results = await Promise.all(Object.keys(RULES).filter((r) => opts.rules?.[r] ?? DEFAULT_CHECK_RULES[r]).map((r) => RULES[r](ctx)));
|
|
314
432
|
const issues = results.flat().sort((a, b) => a.start - b.start || SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
|
|
315
|
-
|
|
433
|
+
// The text split by language: consecutive units in one language, with the text between them.
|
|
434
|
+
const parts = [];
|
|
435
|
+
for (const s of sentences) {
|
|
436
|
+
s.units.forEach((u, i) => {
|
|
437
|
+
const last = parts[parts.length - 1];
|
|
438
|
+
if (last && last.lang === s.langs[i])
|
|
439
|
+
last.end = u.end;
|
|
440
|
+
else
|
|
441
|
+
parts.push({ text: '', lang: s.langs[i], start: u.start, end: u.end });
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
for (const p of parts)
|
|
445
|
+
p.text = text.slice(p.start, p.end);
|
|
446
|
+
return { text, issues, parts };
|
|
316
447
|
},
|
|
317
448
|
};
|
|
318
449
|
}
|
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,58 @@
|
|
|
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; units both or neither know ("an", "to", names) take the
|
|
7
|
+
* nearest decided neighbors' language when they agree. When they don't, the target's, unless the word is
|
|
8
|
+
* far more common in the base (the text is mainly the target language: Vietnamese "an" stays Vietnamese
|
|
9
|
+
* next to English "the", and "tui" between "but" and "không"). In languages written in syllables, a base
|
|
10
|
+
* word that's a target syllable only once accents are added ("but": bút) is the base's; a word only the
|
|
11
|
+
* target knows without its accents ("khong") is still the target's. The base is known through the translator, so
|
|
12
|
+
* inflected forms count ("went", "cooked").
|
|
13
|
+
*/
|
|
14
|
+
// Zipf points by which a word between the two languages must be more common in the base to count as base.
|
|
15
|
+
const BASE_MARGIN = 2;
|
|
16
|
+
export async function markLanguages(list, o) {
|
|
17
|
+
const syllables = await o.target.syllables();
|
|
18
|
+
const syllableLanguage = Object.keys(syllables).length > 0;
|
|
19
|
+
const plainSyllables = syllableLanguage ? new Set(Object.keys(syllables).map(plain)) : new Set();
|
|
20
|
+
// 'exact': a target word as written; 'accents': one only with accents added ("khong" → "không").
|
|
21
|
+
const targetKnows = async (u) => {
|
|
22
|
+
const lower = u.text.toLowerCase();
|
|
23
|
+
if (!syllableLanguage)
|
|
24
|
+
return (await o.target.lookup(lower)).length > 0 ? 'exact' : false;
|
|
25
|
+
if (lower in syllables || lower !== plain(lower))
|
|
26
|
+
return 'exact';
|
|
27
|
+
return plainSyllables.has(lower) ? 'accents' : false;
|
|
28
|
+
};
|
|
29
|
+
const baseKnows = async (u) => (await o.translator.senses(u.text, { from: o.base }).catch(() => [])).length > 0;
|
|
30
|
+
const marks = await Promise.all(list.map(async (u) => {
|
|
31
|
+
if (/\d/.test(u.text))
|
|
32
|
+
return '?';
|
|
33
|
+
const [t, b] = await Promise.all([targetKnows(u), baseKnows(u)]);
|
|
34
|
+
if (t === 'exact')
|
|
35
|
+
return b ? '?' : o.lang;
|
|
36
|
+
if (b)
|
|
37
|
+
return o.base;
|
|
38
|
+
return t === 'accents' ? o.lang : '?';
|
|
39
|
+
}));
|
|
40
|
+
// How common a word is in each language (Zipf; 0 when unknown).
|
|
41
|
+
const targetFrequency = async (u) => {
|
|
42
|
+
const lower = u.text.toLowerCase();
|
|
43
|
+
if (syllableLanguage)
|
|
44
|
+
return syllables[lower] ?? 0;
|
|
45
|
+
return (await o.target.lookup(lower))[0]?.frequency ?? 0;
|
|
46
|
+
};
|
|
47
|
+
const baseFrequency = async (u) => (await o.baseDictionary.lookup(u.text.toLowerCase()))[0]?.frequency ?? 0;
|
|
48
|
+
return Promise.all(marks.map(async (m, i) => {
|
|
49
|
+
if (m !== '?')
|
|
50
|
+
return m;
|
|
51
|
+
const left = marks.slice(0, i).reverse().find((x) => x !== '?');
|
|
52
|
+
const right = marks.slice(i + 1).find((x) => x !== '?');
|
|
53
|
+
if (left && right && left !== right) {
|
|
54
|
+
return (await baseFrequency(list[i])) - (await targetFrequency(list[i])) >= BASE_MARGIN ? o.base : o.lang;
|
|
55
|
+
}
|
|
56
|
+
return left ?? right ?? o.lang;
|
|
57
|
+
}));
|
|
58
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
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/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.4.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",
|