which-dialect 0.2.0 → 0.3.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 +51 -1
- package/dist/check.d.ts +1 -1
- package/dist/check.js +29 -51
- package/dist/index.d.ts +6 -1
- package/dist/index.js +13 -0
- package/dist/phrasebook.d.ts +80 -0
- package/dist/phrasebook.js +254 -0
- package/dist/review.d.ts +56 -0
- package/dist/review.js +201 -0
- package/dist/text.d.ts +31 -0
- package/dist/text.js +57 -0
- package/dist/types.d.ts +58 -0
- package/dist/types.js +13 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -143,12 +143,60 @@ The checks:
|
|
|
143
143
|
- **`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*). Words with an untagged main meaning are never flagged (*má* is "cheek" everywhere).
|
|
144
144
|
- **`pronoun-relationship`**: pronouns that don't fit the listener (*tôi* to a parent → *con*; to a teacher → *em*).
|
|
145
145
|
- **`pronoun-pair`**: "I" and "you" pronouns that never go together (*tao* goes with *mày*, not *bạn*), when no listener is given.
|
|
146
|
+
- **`pronoun-consistency`**: switching words for the same person within one text (*tôi* in one sentence, *tui* in the next): keep the first.
|
|
146
147
|
- **`polite-ending`**: a suggestion to end sentences with *ạ* when talking to parents, elders and teachers (not for exclamations).
|
|
147
148
|
|
|
148
149
|
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**. It doesn't check word order, classifiers or whether a sentence sounds natural yet. Only reliable pronouns are checked: kinship words like *con* (also "child", and a classifier in *con chó*) aren't, and *bạn* (also "friend") only gets suggestions.
|
|
149
150
|
|
|
150
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 gets the `dialect` check.
|
|
151
152
|
|
|
153
|
+
## Sentence frames
|
|
154
|
+
|
|
155
|
+
Common sentences with slots, filled for a region, a listener and a register: *Where is {place}?* is *{place} ở **đâu**?*, and in Central Vietnamese *{place} ở **mô**?*; *Have you eaten yet?* to a parent in the South is *Ba ăn cơm chưa ạ?*, to a friend *Bạn ăn cơm chưa?*.
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
import { createPhrasebook } from 'which-dialect'
|
|
159
|
+
|
|
160
|
+
const pb = createPhrasebook()
|
|
161
|
+
await pb.frames({ lang: 'vi', region: 'Southern', topic: 'questions' }) // every frame, slots open
|
|
162
|
+
await pb.render('ask-where', { lang: 'vi', region: 'Central', slots: { place: 'bathroom' } })
|
|
163
|
+
// → { text: 'Phòng tắm ở mô?', parts: [{ text: 'Phòng tắm', kind: 'content', from: 'bathroom' }, …,
|
|
164
|
+
// { text: 'mô', kind: 'word', why: 'where (Central)' }, …], complete: true }
|
|
165
|
+
await pb.match('Do you want coffee?', { lang: 'vi' }) // → 'Bạn có muốn cà phê không?'
|
|
166
|
+
await pb.matchFrames('Bạn có đi?', { lang: 'vi' }) // → 'Bạn có đi không?' (missing "không")
|
|
167
|
+
```
|
|
168
|
+
|
|
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 suggests: 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
|
+
|
|
171
|
+
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
|
+
|
|
173
|
+
## Reviewing journal entries
|
|
174
|
+
|
|
175
|
+
`createReviewer().review(entry, { lang, base?, region?, listener?, speaker?, register? })` 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 sentence by sentence:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
import { createReviewer } from 'which-dialect'
|
|
179
|
+
|
|
180
|
+
const review = await createReviewer().review(
|
|
181
|
+
'Hôm nay tôi đi market với má. Tôi muốn ăn thịt lợn but it was expensive. Ngày mai tui đi lại khong?',
|
|
182
|
+
{ lang: 'vi', region: 'Southern' },
|
|
183
|
+
)
|
|
184
|
+
review.corrected
|
|
185
|
+
// '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?'
|
|
186
|
+
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
|
+
// { from: 'lợn', to: 'heo', kind: 'dialect', why: '“lợn” is Northern; in Southern Vietnamese, say “heo”.' }]
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
For each sentence:
|
|
192
|
+
|
|
193
|
+
1. **Each word is marked as the target or the base language** (`parts`), from the two dictionaries alone. Words that could be either (*an*, *to*) take their neighbors' language; an English word that's Vietnamese only with accents added (*but*: *bút*) stays English, while Vietnamese typed without accents (*khong*) stays Vietnamese.
|
|
194
|
+
2. **Base-language parts are replaced:** a whole sentence or clause that follows a sentence frame is filled in (*but it was expensive* → *nhưng mắc quá*; *Where is the bathroom?* → *Phòng tắm ở đâu?*), and words or short phrases (up to 3 words) are translated in the region (*market* → *chợ*). Anything longer is left as written and listed in `unchecked`: the review doesn't guess.
|
|
195
|
+
3. **The grammar checker runs on the whole entry**, so consistency checks see every sentence (*tôi* earlier, *tui* later). Its errors and warnings are applied (`changes`); its suggestions, like ending with *ạ*, are returned as `hints`.
|
|
196
|
+
4. **The corrected sentence is checked against sentence frames** (*Bạn có đi chợ?* → *Bạn có đi chợ không?*), and the frames it follows are listed (`frames`), for suggesting patterns to reuse.
|
|
197
|
+
|
|
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`. For a journal, leave `listener` out: people written about (*má*, *thầy*) aren't treated as the listener. Rules can't fix everything: scrambled word order, missing verbs, or English sentences no frame covers come back as written, and plain-letter words that are also Vietnamese words (*toi*, *di*) are only fixed when a sentence frame shows what they should be.
|
|
199
|
+
|
|
152
200
|
## Looking words up
|
|
153
201
|
|
|
154
202
|
### `createDictionary({ lang, baseUrl?, load? })`
|
|
@@ -157,6 +205,7 @@ One language's dictionary. `baseUrl` defaults to `https://cdn.jsdelivr.net/npm/w
|
|
|
157
205
|
|
|
158
206
|
- `dictionary.lookup(word)`: all entries for a word, with every sense's definitions, regions, labels, synonyms and variant links (`altOf`).
|
|
159
207
|
- `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
|
+
- `dictionary.frames()`: the language's sentence frames and frame words, unfilled (`{ frames, words }`; empty without frames).
|
|
160
209
|
- `dictionary.checker()` / `dictionary.syllables()`: the language's checker settings and syllable list (with frequencies), used by `createChecker` (`{}` for languages without them).
|
|
161
210
|
- `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).
|
|
162
211
|
- `dictionary.meta()`: the language's name, regions, region groups, source, license, build date and counts.
|
|
@@ -255,7 +304,8 @@ Generated data isn't committed (it would bloat git history); it's built before p
|
|
|
255
304
|
3. Add the language's code to `translationLangs` in `languages/en.ts` (so English keeps its translation tables for it) and rebuild English.
|
|
256
305
|
4. `npm run build:data -- <code>`, check the results, add a few real-data tests and evaluation cases, and publish.
|
|
257
306
|
5. Optional: [checker](#checking-text) settings (`checker` in `languages/<code>.ts`): `units: 'syllables'` for languages written in syllables (also builds the syllable list for accent suggestions), `maxWordUnits`, the reliable `pronouns` per pronoun-table column, `ambiguousPronouns`, and `politeEndings`. Without them, the language is read as space-separated words and gets the region check.
|
|
258
|
-
6. Optional
|
|
307
|
+
6. Optional: [sentence frames](#sentence-frames): `frames` (how the language says each frame in `languages/frames.ts`) and `frameWords` (the words that change by region, each from a definition or an override with a note). Frames reach other languages through the shared catalog; a new frame goes in the catalog once.
|
|
308
|
+
7. Optional, later: [hand-picked words](#hand-picked-words). A speaker runs `npm run picks-sheet -- <code>`, fills in `your_pick` only where the first choice is wrong, and pastes the `--apply` output into `picks`.
|
|
259
309
|
|
|
260
310
|
Languages in non-Latin scripts (Chinese, Arabic, Russian, …) will need a script-aware version of `shardKey` first: today files are split by the first two Latin letters.
|
|
261
311
|
|
package/dist/check.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type TranslatorOptions } from './translate.ts';
|
|
2
|
-
export type CheckRule = 'spelling' | 'dialect' | 'pronoun-relationship' | 'pronoun-pair' | 'polite-ending';
|
|
2
|
+
export type CheckRule = 'spelling' | 'dialect' | 'pronoun-relationship' | 'pronoun-pair' | 'pronoun-consistency' | 'polite-ending';
|
|
3
3
|
export type CheckIssue = {
|
|
4
4
|
rule: CheckRule;
|
|
5
5
|
/**
|
package/dist/check.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// The checker only speaks up when it's confident and stays silent otherwise, so no issues means none of
|
|
10
10
|
// the checks found anything, not that the text is correct.
|
|
11
11
|
import { createDictionary, resolveRegion } from "./index.js";
|
|
12
|
+
import { joined, matchCase, plain, segment, sentencesOf, units } from "./text.js";
|
|
12
13
|
import { createTranslator } from "./translate.js";
|
|
13
14
|
import { PRONOUN_PERSONS, } from "./types.js";
|
|
14
15
|
// How each pronoun-table column is named in messages.
|
|
@@ -19,61 +20,10 @@ const PERSON_NAMES = {
|
|
|
19
20
|
const ABOUT_PERSONS = new Set(['third', 'thirdPlural']);
|
|
20
21
|
const SELF_PERSONS = new Set(['self', 'selfPlural']);
|
|
21
22
|
const ADDRESSEE_PERSONS = new Set(['addressee', 'addresseePlural']);
|
|
22
|
-
// A run of letters (with their accents), digits and apostrophes: a word, or a syllable in syllable languages.
|
|
23
|
-
const UNIT = /[\p{L}\p{M}\p{N}'’]+/gu;
|
|
24
|
-
const SENTENCE_END = /[.!?…]+|\n+/g;
|
|
25
23
|
const quote = (s) => `“${s}”`;
|
|
26
24
|
const lowerFirst = (s) => s.charAt(0).toLowerCase() + s.slice(1);
|
|
27
25
|
const listOf = (items, conjunction = 'and') => items.length <= 1 ? items.join('') : `${items.slice(0, -1).join(', ')} ${conjunction} ${items[items.length - 1]}`;
|
|
28
|
-
// Keeps a capital first letter when replacing a capitalized word ("Tôi" → "Con").
|
|
29
|
-
const matchCase = (original, replacement) => original.charAt(0) !== original.charAt(0).toLowerCase() ? replacement.charAt(0).toUpperCase() + replacement.slice(1) : replacement;
|
|
30
|
-
// Without accents, for finding the accented forms of a syllable: "không" → "khong", "đi" → "di".
|
|
31
|
-
const plain = (s) => s.normalize('NFD').replace(/\p{M}/gu, '').replace(/đ/g, 'd').replace(/Đ/g, 'D').normalize('NFC');
|
|
32
26
|
const unique = (items) => [...new Set(items)];
|
|
33
|
-
function units(text) {
|
|
34
|
-
return [...text.matchAll(UNIT)].map((m) => ({ text: m[0], start: m.index, end: m.index + m[0].length }));
|
|
35
|
-
}
|
|
36
|
-
function sentencesOf(text, all) {
|
|
37
|
-
const ends = [...text.matchAll(SENTENCE_END)].map((m) => m.index);
|
|
38
|
-
const out = [[]];
|
|
39
|
-
let next = 0;
|
|
40
|
-
for (const u of all) {
|
|
41
|
-
while (next < ends.length && ends[next] < u.start) {
|
|
42
|
-
if (out[out.length - 1].length)
|
|
43
|
-
out.push([]);
|
|
44
|
-
next++;
|
|
45
|
-
}
|
|
46
|
-
out[out.length - 1].push(u);
|
|
47
|
-
}
|
|
48
|
-
return out.filter((s) => s.length);
|
|
49
|
-
}
|
|
50
|
-
// Units join into one word only when just spaces separate them ("thịt heo", not "thịt, heo").
|
|
51
|
-
const joined = (text, a, b) => /^[^\S\n]+$/.test(text.slice(a.end, b.start));
|
|
52
|
-
// Longest match against the dictionary: at each position, the longest run of units that's a headword
|
|
53
|
-
// ("kết quả" is one word, so its "quả" isn't checked on its own). Unknown single units become words with
|
|
54
|
-
// no entries.
|
|
55
|
-
async function segment(text, list, maxUnits, lookup) {
|
|
56
|
-
const words = [];
|
|
57
|
-
for (let i = 0; i < list.length;) {
|
|
58
|
-
let taken = null;
|
|
59
|
-
for (let k = Math.min(maxUnits, list.length - i); k >= 1; k--) {
|
|
60
|
-
const span = list.slice(i, i + k);
|
|
61
|
-
if (span.some((u, j) => j > 0 && !joined(text, span[j - 1], u)))
|
|
62
|
-
continue;
|
|
63
|
-
const phrase = span.map((u) => u.text).join(' ');
|
|
64
|
-
const entries = /\d/.test(phrase) ? [] : await lookup(phrase);
|
|
65
|
-
if (entries.length || k === 1) {
|
|
66
|
-
const start = span[0].start;
|
|
67
|
-
const end = span[k - 1].end;
|
|
68
|
-
taken = { text: text.slice(start, end), lower: phrase.toLowerCase(), start, end, units: span, entries };
|
|
69
|
-
break;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
words.push(taken);
|
|
73
|
-
i += taken.units.length;
|
|
74
|
-
}
|
|
75
|
-
return words;
|
|
76
|
-
}
|
|
77
27
|
// --- Rules -----------------------------------------------------------------------------------------------
|
|
78
28
|
// Spelling and accents, for languages written in syllables with a syllable list: a syllable that isn't in
|
|
79
29
|
// the language is flagged, with the accented forms it could be ("khong" → "không"). Plain-letter
|
|
@@ -233,6 +183,33 @@ async function pronounPair(ctx) {
|
|
|
233
183
|
}
|
|
234
184
|
return issues;
|
|
235
185
|
}
|
|
186
|
+
// Switching words for the same person within one text ("tôi" in one sentence, "tui" in the next): the first
|
|
187
|
+
// one used is kept. Useful for journals and longer messages. Ambiguous pronouns ("bạn": also "friend") are
|
|
188
|
+
// never the ones flagged as a warning.
|
|
189
|
+
async function pronounConsistency(ctx) {
|
|
190
|
+
const first = new Map();
|
|
191
|
+
const issues = [];
|
|
192
|
+
for (const m of ctx.pronounMatches()) {
|
|
193
|
+
const earlier = first.get(m.person);
|
|
194
|
+
if (!earlier) {
|
|
195
|
+
first.set(m.person, m);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (earlier.lower === m.lower)
|
|
199
|
+
continue;
|
|
200
|
+
const ambiguous = ctx.config.ambiguousPronouns?.includes(m.lower) || ctx.config.ambiguousPronouns?.includes(earlier.lower);
|
|
201
|
+
issues.push({
|
|
202
|
+
rule: 'pronoun-consistency',
|
|
203
|
+
severity: ambiguous ? 'suggestion' : 'warning',
|
|
204
|
+
start: m.start,
|
|
205
|
+
end: m.end,
|
|
206
|
+
text: m.text,
|
|
207
|
+
message: `You used ${quote(earlier.text)} for “${PERSON_NAMES[m.person]}” earlier; keep one word: ${quote(earlier.lower)}.`,
|
|
208
|
+
suggestions: [matchCase(m.text, earlier.lower)],
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
return issues;
|
|
212
|
+
}
|
|
236
213
|
// Polite endings when speaking up (the listener's row is marked `respect`): Vietnamese sentences to parents,
|
|
237
214
|
// elders and teachers usually end in "ạ". A suggestion, not an error: it isn't always needed.
|
|
238
215
|
async function politeEnding(ctx) {
|
|
@@ -267,6 +244,7 @@ const RULES = {
|
|
|
267
244
|
dialect,
|
|
268
245
|
'pronoun-relationship': pronounRelationship,
|
|
269
246
|
'pronoun-pair': pronounPair,
|
|
247
|
+
'pronoun-consistency': pronounConsistency,
|
|
270
248
|
'polite-ending': politeEnding,
|
|
271
249
|
};
|
|
272
250
|
const SEVERITY_ORDER = { error: 0, warning: 1, suggestion: 2 };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type CheckerConfig, type PronounRow, type Entry, type Hit, type LanguageMeta, type PickRow } from './types.ts';
|
|
1
|
+
import { type CheckerConfig, type FramesData, type PronounRow, type Entry, type Hit, type LanguageMeta, type PickRow } from './types.ts';
|
|
2
2
|
export * from './types.ts';
|
|
3
3
|
export * from './pos.ts';
|
|
4
4
|
/**
|
|
@@ -71,6 +71,8 @@ export type Dictionary = {
|
|
|
71
71
|
checker(): Promise<CheckerConfig>;
|
|
72
72
|
/** Syllable → Zipf frequency (0 when unknown), for languages written in syllables; `{}` otherwise. */
|
|
73
73
|
syllables(): Promise<Record<string, number>>;
|
|
74
|
+
/** The language's sentence frames and frame words, unfilled (see FramesData); empty without frames. */
|
|
75
|
+
frames(): Promise<FramesData>;
|
|
74
76
|
};
|
|
75
77
|
/**
|
|
76
78
|
* The regions a region or region-group name stands for (e.g. English "North America" -> US, Canada),
|
|
@@ -81,3 +83,6 @@ export declare function dataUrl(lang: string): string;
|
|
|
81
83
|
export declare function createDictionary(options: DictionaryOptions): Dictionary;
|
|
82
84
|
export * from './translate.ts';
|
|
83
85
|
export * from './check.ts';
|
|
86
|
+
export * from './text.ts';
|
|
87
|
+
export * from './phrasebook.ts';
|
|
88
|
+
export * from './review.ts';
|
package/dist/index.js
CHANGED
|
@@ -115,6 +115,16 @@ export function createDictionary(options) {
|
|
|
115
115
|
const m = await meta();
|
|
116
116
|
return m.syllables ? loadOnce('syllables.json') : {};
|
|
117
117
|
},
|
|
118
|
+
async frames() {
|
|
119
|
+
const m = await meta();
|
|
120
|
+
if (!m.frames)
|
|
121
|
+
return { frames: [], words: {} };
|
|
122
|
+
const stored = await loadOnce('frames.json');
|
|
123
|
+
return {
|
|
124
|
+
frames: stored.frames,
|
|
125
|
+
words: Object.fromEntries(Object.entries(stored.words).map(([k, w]) => [k, { ...w, choices: w.choices.map((c) => fromStored(c, m.regions)) }])),
|
|
126
|
+
};
|
|
127
|
+
},
|
|
118
128
|
async pronouns({ listener, region, speaker, exclude = DEFAULT_EXCLUDED_LABELS } = {}) {
|
|
119
129
|
const m = await meta();
|
|
120
130
|
if (!m.pronouns)
|
|
@@ -141,3 +151,6 @@ export function createDictionary(options) {
|
|
|
141
151
|
}
|
|
142
152
|
export * from "./translate.js";
|
|
143
153
|
export * from "./check.js";
|
|
154
|
+
export * from "./text.js";
|
|
155
|
+
export * from "./phrasebook.js";
|
|
156
|
+
export * from "./review.js";
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { type Translator, type TranslatorOptions } from './translate.ts';
|
|
2
|
+
export type FrameOptions = {
|
|
3
|
+
/** The language to say it in, e.g. "vi". */
|
|
4
|
+
lang: string;
|
|
5
|
+
/** Region or region group; its words come first, and words only used elsewhere are left out. */
|
|
6
|
+
region?: string;
|
|
7
|
+
/** Who you're talking to, as a pronoun-table row id; without it, the table's default row. */
|
|
8
|
+
listener?: string;
|
|
9
|
+
/** The speaker's gender, for pronouns that depend on it. */
|
|
10
|
+
speaker?: 'male' | 'female';
|
|
11
|
+
/** 'casual' allows colloquial words (Southern "hông", "tui"); otherwise they're left out. */
|
|
12
|
+
register?: 'casual' | 'neutral' | 'polite';
|
|
13
|
+
};
|
|
14
|
+
/** One piece of a filled frame, with why it was chosen, for highlighting and explanations. */
|
|
15
|
+
export type FramePart = {
|
|
16
|
+
text: string;
|
|
17
|
+
/** Fixed wording, a frame word ({WHERE}), a pronoun ({I}) or a content slot ({place}). */
|
|
18
|
+
kind: 'fixed' | 'word' | 'pronoun' | 'content';
|
|
19
|
+
slot?: string;
|
|
20
|
+
/** e.g. "where (Central)", "“I”, talking to your parents". */
|
|
21
|
+
why?: string;
|
|
22
|
+
regions?: string[];
|
|
23
|
+
/** For content slots given in English: the English, and whether no translation was found. */
|
|
24
|
+
from?: string;
|
|
25
|
+
untranslated?: boolean;
|
|
26
|
+
};
|
|
27
|
+
export type FilledFrame = {
|
|
28
|
+
id: string;
|
|
29
|
+
topic: string;
|
|
30
|
+
/** The English way to say it, with slots filled when given. */
|
|
31
|
+
en: string;
|
|
32
|
+
/** The sentence; open content slots stay as {name}. */
|
|
33
|
+
text: string;
|
|
34
|
+
parts: FramePart[];
|
|
35
|
+
/** The content slots and their parts of speech. */
|
|
36
|
+
slots: {
|
|
37
|
+
name: string;
|
|
38
|
+
pos: 'noun' | 'verb' | 'adj';
|
|
39
|
+
}[];
|
|
40
|
+
/** true when every content slot is filled (and translated). */
|
|
41
|
+
complete: boolean;
|
|
42
|
+
};
|
|
43
|
+
/** A content slot value: English (translated for you) or `{ text }` in the target language. */
|
|
44
|
+
export type SlotValue = string | {
|
|
45
|
+
text: string;
|
|
46
|
+
};
|
|
47
|
+
/** A frame recognized in a sentence in the language itself, and what the frame says should change. */
|
|
48
|
+
export type FrameMatch = {
|
|
49
|
+
frame: {
|
|
50
|
+
id: string;
|
|
51
|
+
en: string;
|
|
52
|
+
};
|
|
53
|
+
/** The sentence as the frame would have it, for the given region, listener and register. */
|
|
54
|
+
text: string;
|
|
55
|
+
changes: {
|
|
56
|
+
slot: string;
|
|
57
|
+
from: string;
|
|
58
|
+
to: string;
|
|
59
|
+
why: string;
|
|
60
|
+
}[];
|
|
61
|
+
};
|
|
62
|
+
export type Phrasebook = {
|
|
63
|
+
/** The language's frames, filled for the region, listener and register, with content slots open. */
|
|
64
|
+
frames(options: FrameOptions & {
|
|
65
|
+
topic?: string;
|
|
66
|
+
}): Promise<FilledFrame[]>;
|
|
67
|
+
/** One frame with its content slots filled. Unknown ids throw, listing the valid ones. */
|
|
68
|
+
render(id: string, options: FrameOptions & {
|
|
69
|
+
slots?: Record<string, SlotValue>;
|
|
70
|
+
}): Promise<FilledFrame>;
|
|
71
|
+
/** The frame an English sentence (or clause) follows, filled with its slot values; null when none. */
|
|
72
|
+
match(english: string, options: FrameOptions): Promise<FilledFrame | null>;
|
|
73
|
+
/** Frames a sentence in the language itself follows, with the changes the frame suggests. */
|
|
74
|
+
matchFrames(sentence: string, options: FrameOptions): Promise<FrameMatch[]>;
|
|
75
|
+
};
|
|
76
|
+
export type PhrasebookOptions = TranslatorOptions & {
|
|
77
|
+
/** Share a translator (and its dictionaries) instead of creating one. */
|
|
78
|
+
translator?: Translator;
|
|
79
|
+
};
|
|
80
|
+
export declare function createPhrasebook(options?: PhrasebookOptions): Phrasebook;
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// Sentence frames: common sentences with slots ("Where is {place}?" → "{place} ở {WHERE}?"), filled for a
|
|
2
|
+
// region, a listener and a register. The frames, their words and the pronoun table are the language's data
|
|
3
|
+
// (frames.json, pronouns.json), so this file has no language-specific rules: it picks words by region and
|
|
4
|
+
// labels, fills pronouns from the pronoun table, and translates content slots with the translator.
|
|
5
|
+
//
|
|
6
|
+
// Three directions: list and fill frames (`frames`, `render`), recognize an English sentence as a frame
|
|
7
|
+
// (`match`), and recognize a frame in a sentence in the language itself (`matchFrames`), which is how the
|
|
8
|
+
// journal review checks a sentence against frames ("Bạn có đi?" is the yes/no frame missing "không").
|
|
9
|
+
import { createDictionary, resolveRegion } from "./index.js";
|
|
10
|
+
import { matchCase, plain } from "./text.js";
|
|
11
|
+
import { createTranslator } from "./translate.js";
|
|
12
|
+
import { FRAME_PRONOUNS, } from "./types.js";
|
|
13
|
+
const SLOT = /\{([A-Za-z_]+)\}/g;
|
|
14
|
+
// Words for a casual register only.
|
|
15
|
+
const CASUAL = new Set(['colloquial', 'informal', 'familiar', 'slang', 'Internet']);
|
|
16
|
+
const PERSON_NAMES = {
|
|
17
|
+
self: 'I', addressee: 'you', third: 'he/she', selfPlural: 'we', addresseePlural: 'you (plural)', thirdPlural: 'they',
|
|
18
|
+
};
|
|
19
|
+
const lowerFirst = (s) => s.charAt(0).toLowerCase() + s.slice(1);
|
|
20
|
+
const quote = (s) => `“${s}”`;
|
|
21
|
+
// Words and their accent-less forms ("tôi", "toi"), longest first so "chúng tôi" wins over "tôi".
|
|
22
|
+
const withPlain = (words) => [...new Set(words.flatMap((w) => [w, plain(w)]))].sort((a, b) => b.length - a.length);
|
|
23
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
24
|
+
// English sentences compare without case, curly quotes, extra spaces or the final punctuation.
|
|
25
|
+
const normalizeEn = (s) => s.toLowerCase().replace(/[’‘]/g, "'").replace(/\s+/g, ' ').trim().replace(/[.!?…]+$/, '').trim();
|
|
26
|
+
// Spaces after filling: one between words, none before punctuation or after an empty slot.
|
|
27
|
+
const tidy = (s) => s.replace(/\s+/g, ' ').replace(/\s+([.,!?…])/g, '$1').trim();
|
|
28
|
+
function choose(choices, s) {
|
|
29
|
+
return choices
|
|
30
|
+
.filter((c) => !s.wanted || c.regions.some((r) => s.wanted.has(r)))
|
|
31
|
+
.filter((c) => c.when !== 'respect' || s.row?.respect)
|
|
32
|
+
.filter((c) => s.casual || !c.labels.some((l) => CASUAL.has(l)))
|
|
33
|
+
.map((c, i) => ({ c, i, regional: s.wanted && c.regionTagged ? 1 : 0, casual: s.casual && c.labels.some((l) => CASUAL.has(l)) ? 1 : 0 }))
|
|
34
|
+
.sort((a, b) => b.casual - a.casual || b.regional - a.regional || a.i - b.i)[0]?.c;
|
|
35
|
+
}
|
|
36
|
+
function pronounChoices(row, slot) {
|
|
37
|
+
const p = FRAME_PRONOUNS[slot];
|
|
38
|
+
if (!row || !p)
|
|
39
|
+
return [];
|
|
40
|
+
return row[p.person].filter((c) => p.inclusive === undefined || c.inclusive === undefined || c.inclusive === p.inclusive);
|
|
41
|
+
}
|
|
42
|
+
export function createPhrasebook(options = {}) {
|
|
43
|
+
const cache = new Map();
|
|
44
|
+
const dict = (lang) => {
|
|
45
|
+
let d = cache.get(lang);
|
|
46
|
+
if (!d) {
|
|
47
|
+
d = options.dictionary?.(lang) ?? createDictionary({ lang, baseUrl: options.baseUrl?.(lang), load: options.load?.(lang) });
|
|
48
|
+
cache.set(lang, d);
|
|
49
|
+
}
|
|
50
|
+
return d;
|
|
51
|
+
};
|
|
52
|
+
const translator = options.translator ?? createTranslator({ ...options, dictionary: dict });
|
|
53
|
+
async function setting(o) {
|
|
54
|
+
const d = dict(o.lang);
|
|
55
|
+
const [meta, data] = await Promise.all([d.meta(), d.frames()]);
|
|
56
|
+
// An unknown listener throws, listing the valid ones (as the translator does).
|
|
57
|
+
if (meta.pronouns && o.listener)
|
|
58
|
+
await d.pronouns({ listener: o.listener });
|
|
59
|
+
const rows = meta.pronouns ? await d.pronouns({ region: o.region, speaker: o.speaker }) : [];
|
|
60
|
+
const row = o.listener ? rows.find((r) => r.id === o.listener) : rows.find((r) => r.default);
|
|
61
|
+
return { meta, data, rows, row, wanted: resolveRegion(meta, o.region), casual: o.register === 'casual' };
|
|
62
|
+
}
|
|
63
|
+
// The word for a frame word or pronoun slot, with why; empty when the slot has no word here (no "ạ"
|
|
64
|
+
// unless the listener's row is marked respect).
|
|
65
|
+
function slotWord(name, s) {
|
|
66
|
+
const words = s.data.words[name];
|
|
67
|
+
if (words) {
|
|
68
|
+
const c = choose(words.choices, s);
|
|
69
|
+
if (!c)
|
|
70
|
+
return { text: '', kind: 'word', slot: name };
|
|
71
|
+
return {
|
|
72
|
+
text: c.word, kind: 'word', slot: name, regions: c.regionTagged ? c.regions : undefined,
|
|
73
|
+
why: `${words.meaning}${c.regionTagged ? ` (${c.regions.join(', ')})` : ''}${c.note ? `: ${c.note}` : ''}`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const c = choose(pronounChoices(s.row, name), s);
|
|
77
|
+
const person = PERSON_NAMES[FRAME_PRONOUNS[name]?.person ?? ''];
|
|
78
|
+
if (!c)
|
|
79
|
+
return { text: '', kind: 'pronoun', slot: name };
|
|
80
|
+
return {
|
|
81
|
+
text: c.word, kind: 'pronoun', slot: name, regions: c.regionTagged ? c.regions : undefined,
|
|
82
|
+
why: `“${person}”${s.row && !s.row.default ? `, talking to ${lowerFirst(s.row.label)}` : ''}`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
async function content(frame, name, value, o) {
|
|
86
|
+
if (value === undefined)
|
|
87
|
+
return { text: `{${name}}`, kind: 'content', slot: name };
|
|
88
|
+
if (typeof value !== 'string')
|
|
89
|
+
return { text: value.text, kind: 'content', slot: name };
|
|
90
|
+
const english = value.trim().replace(/^(the|a|an|some)\s+/i, '');
|
|
91
|
+
const [group] = await translator.translate(english, {
|
|
92
|
+
from: 'en', to: o.lang, toRegion: o.region, pos: frame.slots[name], register: o.register, limit: 1,
|
|
93
|
+
}).catch(() => []);
|
|
94
|
+
const word = group?.translations[0]?.word;
|
|
95
|
+
return word
|
|
96
|
+
? { text: word, kind: 'content', slot: name, from: value, why: `“${english}”`, regions: group.translations[0].regionTagged ? group.translations[0].regions : undefined }
|
|
97
|
+
: { text: value, kind: 'content', slot: name, from: value, untranslated: true };
|
|
98
|
+
}
|
|
99
|
+
async function fill(frame, o, s, values = {}) {
|
|
100
|
+
const parts = [];
|
|
101
|
+
let last = 0;
|
|
102
|
+
for (const m of frame.text.matchAll(SLOT)) {
|
|
103
|
+
if (m.index > last)
|
|
104
|
+
parts.push({ text: frame.text.slice(last, m.index), kind: 'fixed' });
|
|
105
|
+
const name = m[1];
|
|
106
|
+
parts.push(frame.slots[name] ? await content(frame, name, values[name], o) : slotWord(name, s));
|
|
107
|
+
last = m.index + m[0].length;
|
|
108
|
+
}
|
|
109
|
+
if (last < frame.text.length)
|
|
110
|
+
parts.push({ text: frame.text.slice(last), kind: 'fixed' });
|
|
111
|
+
let text = tidy(parts.map((p) => p.text).join(''));
|
|
112
|
+
if (!frame.clause)
|
|
113
|
+
text = text.charAt(0).toUpperCase() + text.slice(1);
|
|
114
|
+
const slots = Object.entries(frame.slots).map(([name, pos]) => ({ name, pos }));
|
|
115
|
+
const en = Object.keys(frame.slots).reduce((s, name) => (typeof values[name] === 'string' ? s.replace(`{${name}}`, values[name]) : s), frame.en[0]);
|
|
116
|
+
return {
|
|
117
|
+
id: frame.id, topic: frame.topic, en, text,
|
|
118
|
+
parts: parts.filter((p) => p.text),
|
|
119
|
+
slots,
|
|
120
|
+
complete: slots.every(({ name }) => values[name] !== undefined) && !parts.some((p) => p.untranslated),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
async function frameById(id, o) {
|
|
124
|
+
const s = await setting(o);
|
|
125
|
+
const frame = s.data.frames.find((f) => f.id === id);
|
|
126
|
+
if (!frame) {
|
|
127
|
+
throw new Error(`which-dialect: unknown frame "${id}" for ${s.meta.name}. Use one of: ${s.data.frames.map((f) => f.id).join(', ') || '(none)'}`);
|
|
128
|
+
}
|
|
129
|
+
return { frame, s };
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
async frames(o) {
|
|
133
|
+
const s = await setting(o);
|
|
134
|
+
return Promise.all(s.data.frames.filter((f) => !o.topic || f.topic === o.topic).map((f) => fill(f, o, s)));
|
|
135
|
+
},
|
|
136
|
+
async render(id, o) {
|
|
137
|
+
const { frame, s } = await frameById(id, o);
|
|
138
|
+
return fill(frame, o, s, o.slots);
|
|
139
|
+
},
|
|
140
|
+
async match(english, o) {
|
|
141
|
+
const s = await setting(o);
|
|
142
|
+
const sentence = normalizeEn(english);
|
|
143
|
+
for (const frame of s.data.frames) {
|
|
144
|
+
for (const template of frame.en) {
|
|
145
|
+
const names = [];
|
|
146
|
+
const pattern = escape(normalizeEn(template)).replace(/\\\{([A-Za-z_]+)\\\}/g, (_, name) => {
|
|
147
|
+
names.push(name);
|
|
148
|
+
return '(.+?)';
|
|
149
|
+
});
|
|
150
|
+
const m = new RegExp(`^${pattern}$`, 'u').exec(sentence);
|
|
151
|
+
if (!m)
|
|
152
|
+
continue;
|
|
153
|
+
const values = Object.fromEntries(names.map((n, i) => [n, m[i + 1]]));
|
|
154
|
+
const filled = await fill(frame, o, s, values);
|
|
155
|
+
return { ...filled, en: english.trim() };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
},
|
|
160
|
+
async matchFrames(sentence, o) {
|
|
161
|
+
const s = await setting(o);
|
|
162
|
+
const d = dict(o.lang);
|
|
163
|
+
// Every pronoun of a column, in every row and region: a frame position decides it's a pronoun.
|
|
164
|
+
const allRows = s.meta.pronouns ? await d.pronouns({ exclude: [] }) : [];
|
|
165
|
+
const terminal = /[?]\s*$/.test(sentence) ? '?' : '';
|
|
166
|
+
const core = sentence.trim().replace(/[.!?…]+$/, '').trim();
|
|
167
|
+
const out = [];
|
|
168
|
+
for (const frame of s.data.frames) {
|
|
169
|
+
if (frame.text.trim().endsWith('?') !== (terminal === '?'))
|
|
170
|
+
continue;
|
|
171
|
+
const tokens = [...frame.text.replace(/[.!?…]+\s*$/, '').matchAll(/\{([A-Za-z_]+)\}|[^{]+/g)].map((m) => m[1] ? { slot: m[1] } : { fixed: m[0] });
|
|
172
|
+
// Frames without fixed words ("{thing} {HOW}?") match only with every word present; frames with
|
|
173
|
+
// fixed words can spot a missing required word ("Bạn có đi?" → "… không?"). Frames with neither
|
|
174
|
+
// fixed words nor frame words ("{I} {quality}.") would match almost anything, so they're skipped.
|
|
175
|
+
const anchored = tokens.some((t) => t.fixed && /\p{L}/u.test(t.fixed));
|
|
176
|
+
if (!anchored && !tokens.some((t) => t.slot && s.data.words[t.slot]))
|
|
177
|
+
continue;
|
|
178
|
+
const slots = [];
|
|
179
|
+
let pattern = '';
|
|
180
|
+
for (const t of tokens) {
|
|
181
|
+
if (t.fixed !== undefined) {
|
|
182
|
+
pattern += t.fixed.trim() ? `\\s*${escape(t.fixed.trim()).replace(/\s+/g, '\\s+')}\\s*` : '\\s*';
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const name = t.slot;
|
|
186
|
+
if (frame.slots[name]) {
|
|
187
|
+
slots.push({ name, kind: 'content', optional: false });
|
|
188
|
+
pattern += '(.+?)';
|
|
189
|
+
}
|
|
190
|
+
else if (s.data.words[name]) {
|
|
191
|
+
const alts = withPlain(s.data.words[name].choices.map((c) => c.word)).map(escape);
|
|
192
|
+
const optional = Boolean(s.data.words[name].optional) || anchored;
|
|
193
|
+
slots.push({ name, kind: 'word', optional });
|
|
194
|
+
pattern += `(${alts.join('|')})${optional ? '?' : ''}`;
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
const alts = withPlain(allRows.flatMap((r) => pronounChoices(r, name)).map((c) => c.word));
|
|
198
|
+
if (!alts.length)
|
|
199
|
+
continue;
|
|
200
|
+
slots.push({ name, kind: 'pronoun', optional: false });
|
|
201
|
+
pattern += `(${alts.map(escape).join('|')})`;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const m = new RegExp(`^${pattern}$`, 'iu').exec(core);
|
|
205
|
+
if (!m)
|
|
206
|
+
continue;
|
|
207
|
+
const changes = [];
|
|
208
|
+
const filled = {};
|
|
209
|
+
slots.forEach((slot, i) => {
|
|
210
|
+
let captured = m[i + 1] ?? '';
|
|
211
|
+
filled[slot.name] = captured;
|
|
212
|
+
if (slot.kind === 'content')
|
|
213
|
+
return;
|
|
214
|
+
// A word missing its accents in a frame position ("Toi" where the frame has a pronoun) is that word.
|
|
215
|
+
const known = slot.kind === 'word'
|
|
216
|
+
? s.data.words[slot.name].choices.map((c) => c.word)
|
|
217
|
+
: allRows.flatMap((r) => pronounChoices(r, slot.name)).map((c) => c.word);
|
|
218
|
+
if (captured && !known.includes(captured.toLowerCase())) {
|
|
219
|
+
const accented = known.find((w) => plain(w) === captured.toLowerCase());
|
|
220
|
+
if (accented) {
|
|
221
|
+
const fixed = matchCase(captured, accented);
|
|
222
|
+
changes.push({ slot: slot.name, from: captured, to: fixed, why: `${quote(fixed)} needs its accents` });
|
|
223
|
+
filled[slot.name] = captured = fixed;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const expected = slotWord(slot.name, s);
|
|
227
|
+
if (slot.kind === 'pronoun') {
|
|
228
|
+
// Pronouns change only for a chosen listener: in a journal, "Má" may be who you write about.
|
|
229
|
+
const fits = pronounChoices(s.row, slot.name).some((c) => c.word === captured.toLowerCase());
|
|
230
|
+
if (o.listener && expected.text && !fits) {
|
|
231
|
+
filled[slot.name] = matchCase(captured, expected.text);
|
|
232
|
+
changes.push({ slot: slot.name, from: captured, to: filled[slot.name], why: expected.why ?? '' });
|
|
233
|
+
}
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const words = s.data.words[slot.name];
|
|
237
|
+
const allowed = words.choices.filter((c) => choose([c], s));
|
|
238
|
+
if (captured && !allowed.some((c) => c.word === captured.toLowerCase()) && expected.text) {
|
|
239
|
+
filled[slot.name] = matchCase(captured, expected.text);
|
|
240
|
+
changes.push({ slot: slot.name, from: captured, to: filled[slot.name], why: expected.why ?? '' });
|
|
241
|
+
}
|
|
242
|
+
else if (!captured && !words.optional && expected.text) {
|
|
243
|
+
filled[slot.name] = expected.text;
|
|
244
|
+
changes.push({ slot: slot.name, from: '', to: expected.text, why: `${words.meaning}: this frame needs it` });
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
let text = tidy(frame.text.replace(/[.!?…]+\s*$/, '').replace(SLOT, (_, name) => filled[name] ?? ''));
|
|
248
|
+
text = matchCase(core, text) + (terminal || sentence.trim().match(/[.!?…]+$/)?.[0] || '');
|
|
249
|
+
out.push({ frame: { id: frame.id, en: frame.en[0] }, text, changes });
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
}
|
package/dist/review.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type CheckRule } from './check.ts';
|
|
2
|
+
import { type FrameOptions } from './phrasebook.ts';
|
|
3
|
+
import { type TranslatorOptions } from './translate.ts';
|
|
4
|
+
export type ReviewOptions = FrameOptions & {
|
|
5
|
+
/** The learner's own language, for the parts written in it (default "en"). */
|
|
6
|
+
base?: string;
|
|
7
|
+
};
|
|
8
|
+
export type ReviewChange = {
|
|
9
|
+
/** What was written (empty when a missing word was added). */
|
|
10
|
+
from: string;
|
|
11
|
+
to: string;
|
|
12
|
+
/** One sentence for the learner. */
|
|
13
|
+
why: string;
|
|
14
|
+
/** 'translated' (a base-language word), 'frame' (a sentence frame), or the checker rule. */
|
|
15
|
+
kind: 'translated' | 'frame' | CheckRule;
|
|
16
|
+
};
|
|
17
|
+
/** A checker suggestion that wasn't applied ("end with ạ"), for the learner to consider. */
|
|
18
|
+
export type ReviewHint = {
|
|
19
|
+
rule: CheckRule;
|
|
20
|
+
text: string;
|
|
21
|
+
message: string;
|
|
22
|
+
suggestions: string[];
|
|
23
|
+
};
|
|
24
|
+
export type ReviewSentence = {
|
|
25
|
+
/** Offsets of the sentence in Review.text, including its final punctuation. */
|
|
26
|
+
start: number;
|
|
27
|
+
end: number;
|
|
28
|
+
original: string;
|
|
29
|
+
/** The sentence split by language, e.g. [{ text: 'Hôm nay tôi đi', lang: 'vi' }, { text: 'market', lang: 'en' }, …]. */
|
|
30
|
+
parts: {
|
|
31
|
+
text: string;
|
|
32
|
+
lang: string;
|
|
33
|
+
}[];
|
|
34
|
+
corrected: string;
|
|
35
|
+
changes: ReviewChange[];
|
|
36
|
+
hints: ReviewHint[];
|
|
37
|
+
/** Sentence frames the sentence follows or was built from: the frame's English and its pattern. */
|
|
38
|
+
frames: {
|
|
39
|
+
id: string;
|
|
40
|
+
en: string;
|
|
41
|
+
text: string;
|
|
42
|
+
}[];
|
|
43
|
+
/** Parts the review couldn't handle (longer base-language text that follows no frame), left as written. */
|
|
44
|
+
unchecked: string[];
|
|
45
|
+
};
|
|
46
|
+
export type Review = {
|
|
47
|
+
/** The entry, in Unicode NFC form; sentence offsets refer to it. */
|
|
48
|
+
text: string;
|
|
49
|
+
/** The whole entry with every sentence corrected. */
|
|
50
|
+
corrected: string;
|
|
51
|
+
sentences: ReviewSentence[];
|
|
52
|
+
};
|
|
53
|
+
export type Reviewer = {
|
|
54
|
+
review(entry: string, options: ReviewOptions): Promise<Review>;
|
|
55
|
+
};
|
|
56
|
+
export declare function createReviewer(options?: TranslatorOptions): Reviewer;
|
package/dist/review.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
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:
|
|
3
|
+
//
|
|
4
|
+
// 1. Each word is marked as the target or the base language, from the two dictionaries alone (accents and
|
|
5
|
+
// syllables, headwords, inflected forms); words that could be either take their neighbors' language.
|
|
6
|
+
// 2. Base-language parts are replaced: a whole sentence or clause that follows a sentence frame is filled
|
|
7
|
+
// in ("but it was expensive" → "nhưng mắc quá"), short phrases are translated word by word ("market" →
|
|
8
|
+
// "chợ"), and anything longer is listed as unchecked rather than guessed.
|
|
9
|
+
// 3. The grammar checker runs on the whole entry, so consistency checks see every sentence ("tôi" here,
|
|
10
|
+
// "tui" there); its errors and warnings are applied, its suggestions returned as hints.
|
|
11
|
+
// 4. The corrected sentence is checked against sentence frames ("Bạn có đi?" → "Bạn có đi không?").
|
|
12
|
+
//
|
|
13
|
+
// Everything is rule-based and runs on the language's data: no API, and nothing language-specific here.
|
|
14
|
+
import { createChecker } from "./check.js";
|
|
15
|
+
import { createDictionary } from "./index.js";
|
|
16
|
+
import { createPhrasebook } from "./phrasebook.js";
|
|
17
|
+
import { plain, sentencesOf, units } from "./text.js";
|
|
18
|
+
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
|
+
const TERMINAL = /^[.!?…]+/;
|
|
22
|
+
export function createReviewer(options = {}) {
|
|
23
|
+
const cache = new Map();
|
|
24
|
+
const dict = (lang) => {
|
|
25
|
+
let d = cache.get(lang);
|
|
26
|
+
if (!d) {
|
|
27
|
+
d = options.dictionary?.(lang) ?? createDictionary({ lang, baseUrl: options.baseUrl?.(lang), load: options.load?.(lang) });
|
|
28
|
+
cache.set(lang, d);
|
|
29
|
+
}
|
|
30
|
+
return d;
|
|
31
|
+
};
|
|
32
|
+
const translator = createTranslator({ ...options, dictionary: dict });
|
|
33
|
+
const phrasebook = createPhrasebook({ ...options, dictionary: dict, translator });
|
|
34
|
+
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
|
+
return {
|
|
78
|
+
async review(entry, o) {
|
|
79
|
+
const text = entry.normalize('NFC');
|
|
80
|
+
const base = o.base ?? 'en';
|
|
81
|
+
const meta = await dict(o.lang).meta();
|
|
82
|
+
// A frame as shown to the learner: its English and its pattern for these settings, slots open.
|
|
83
|
+
const frameInfo = new Map();
|
|
84
|
+
const frameOf = async (id) => {
|
|
85
|
+
if (!frameInfo.has(id)) {
|
|
86
|
+
const f = await phrasebook.render(id, o);
|
|
87
|
+
frameInfo.set(id, { id, en: f.en, text: f.text });
|
|
88
|
+
}
|
|
89
|
+
return frameInfo.get(id);
|
|
90
|
+
};
|
|
91
|
+
const drafts = [];
|
|
92
|
+
for (const list of sentencesOf(text, units(text))) {
|
|
93
|
+
const start = list[0].start;
|
|
94
|
+
const coreEnd = list[list.length - 1].end;
|
|
95
|
+
const end = coreEnd + (TERMINAL.exec(text.slice(coreEnd))?.[0].length ?? 0);
|
|
96
|
+
const langs = await languages(list, o.lang, base);
|
|
97
|
+
const groups = [];
|
|
98
|
+
list.forEach((u, i) => {
|
|
99
|
+
if (groups.length && groups[groups.length - 1].lang === langs[i])
|
|
100
|
+
groups[groups.length - 1].units.push(u);
|
|
101
|
+
else
|
|
102
|
+
groups.push({ lang: langs[i], units: [u] });
|
|
103
|
+
});
|
|
104
|
+
const draft = {
|
|
105
|
+
start, coreEnd, end, replacements: [], frames: [], unchecked: [],
|
|
106
|
+
parts: groups.map((g) => ({ text: text.slice(g.units[0].start, g.units[g.units.length - 1].end), lang: g.lang })),
|
|
107
|
+
};
|
|
108
|
+
const whole = groups.length === 1 && groups[0].lang === base;
|
|
109
|
+
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
|
+
// A whole sentence is matched with its own punctuation ("Where is the bathroom?").
|
|
113
|
+
const frame = await phrasebook.match(whole ? text.slice(start, end) : from, o);
|
|
114
|
+
if (frame && frame.complete) {
|
|
115
|
+
let to = frame.text.replace(/[.!?…]+$/, '');
|
|
116
|
+
if (!whole && g.units[0].start !== start)
|
|
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);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (!whole && g.units.length <= MAX_TRANSLATED_UNITS) {
|
|
124
|
+
const english = from.replace(/^(the|a|an)\s+/i, '');
|
|
125
|
+
const [group] = await translator.translate(english, { from: base, to: o.lang, toRegion: o.region, register: o.register, limit: 1 }).catch(() => []);
|
|
126
|
+
const word = group?.translations[0]?.word;
|
|
127
|
+
if (word) {
|
|
128
|
+
draft.replacements.push({ ...at, to: word, change: { kind: 'translated', from, to: word, why: `“${english}” in ${meta.name}` } });
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
draft.unchecked.push(from);
|
|
133
|
+
}
|
|
134
|
+
drafts.push(draft);
|
|
135
|
+
}
|
|
136
|
+
// The entry with base-language parts replaced, keeping where each sentence now is.
|
|
137
|
+
let draftText = '';
|
|
138
|
+
let cursor = 0;
|
|
139
|
+
const placed = [];
|
|
140
|
+
for (const d of drafts) {
|
|
141
|
+
draftText += text.slice(cursor, d.start);
|
|
142
|
+
const s = draftText.length;
|
|
143
|
+
let core = text.slice(d.start, d.coreEnd);
|
|
144
|
+
for (const r of [...d.replacements].sort((a, b) => b.start - a.start)) {
|
|
145
|
+
core = core.slice(0, r.start - d.start) + r.to + core.slice(r.end - d.start);
|
|
146
|
+
}
|
|
147
|
+
if (/\p{Lu}/u.test(text.charAt(d.start)))
|
|
148
|
+
core = core.charAt(0).toUpperCase() + core.slice(1);
|
|
149
|
+
draftText += core + text.slice(d.coreEnd, d.end);
|
|
150
|
+
placed.push({ start: s, coreEnd: s + core.length, end: draftText.length });
|
|
151
|
+
cursor = d.end;
|
|
152
|
+
}
|
|
153
|
+
draftText += text.slice(cursor);
|
|
154
|
+
// 3. The checker on the whole entry; 4. frames on each corrected sentence.
|
|
155
|
+
const { issues } = await checker.check(draftText, { lang: o.lang, region: o.region, listener: o.listener, about: undefined, speaker: o.speaker });
|
|
156
|
+
const sentences = [];
|
|
157
|
+
for (const [i, d] of drafts.entries()) {
|
|
158
|
+
const p = placed[i];
|
|
159
|
+
const mine = issues.filter((x) => x.start >= p.start && x.end <= p.coreEnd);
|
|
160
|
+
const changes = d.replacements.sort((a, b) => a.start - b.start).map((r) => r.change);
|
|
161
|
+
const hints = [];
|
|
162
|
+
let core = draftText.slice(p.start, p.coreEnd);
|
|
163
|
+
let lastStart = Infinity;
|
|
164
|
+
for (const x of [...mine].sort((a, b) => b.start - a.start)) {
|
|
165
|
+
const applies = (x.severity === 'error' || x.severity === 'warning') && x.suggestions.length && x.end <= lastStart;
|
|
166
|
+
if (!applies) {
|
|
167
|
+
hints.push({ rule: x.rule, text: x.text, message: x.message, suggestions: x.suggestions });
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
core = core.slice(0, x.start - p.start) + x.suggestions[0] + core.slice(x.end - p.start);
|
|
171
|
+
changes.push({ kind: x.rule, from: x.text, to: x.suggestions[0], why: x.message });
|
|
172
|
+
lastStart = x.start;
|
|
173
|
+
}
|
|
174
|
+
const punctuation = draftText.slice(p.coreEnd, p.end);
|
|
175
|
+
let corrected = core + punctuation;
|
|
176
|
+
const frames = [...d.frames];
|
|
177
|
+
for (const m of await phrasebook.matchFrames(corrected, o)) {
|
|
178
|
+
if (!frames.some((f) => f.id === m.frame.id))
|
|
179
|
+
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
|
+
}
|
|
186
|
+
sentences.push({
|
|
187
|
+
start: d.start, end: d.end, original: text.slice(d.start, d.end), parts: d.parts,
|
|
188
|
+
corrected, changes, hints: hints.reverse(), frames, unchecked: d.unchecked,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
let corrected = '';
|
|
192
|
+
cursor = 0;
|
|
193
|
+
for (const s of sentences) {
|
|
194
|
+
corrected += text.slice(cursor, s.start) + s.corrected;
|
|
195
|
+
cursor = s.end;
|
|
196
|
+
}
|
|
197
|
+
corrected += text.slice(cursor);
|
|
198
|
+
return { text, corrected, sentences };
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
package/dist/text.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Entry } from './types.ts';
|
|
2
|
+
export declare const UNIT: RegExp;
|
|
3
|
+
export declare const SENTENCE_END: RegExp;
|
|
4
|
+
export type Unit = {
|
|
5
|
+
text: string;
|
|
6
|
+
start: number;
|
|
7
|
+
end: number;
|
|
8
|
+
};
|
|
9
|
+
export type Word = {
|
|
10
|
+
text: string;
|
|
11
|
+
lower: string;
|
|
12
|
+
start: number;
|
|
13
|
+
end: number;
|
|
14
|
+
units: Unit[];
|
|
15
|
+
entries: Entry[];
|
|
16
|
+
};
|
|
17
|
+
export declare function units(text: string): Unit[];
|
|
18
|
+
/** Units grouped into sentences, which end at . ! ? … or a line break. */
|
|
19
|
+
export declare function sentencesOf(text: string, all: Unit[]): Unit[][];
|
|
20
|
+
/** Units join into one word only when just spaces separate them ("thịt heo", not "thịt, heo"). */
|
|
21
|
+
export declare const joined: (text: string, a: Unit, b: Unit) => boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Longest match against the dictionary: at each position, the longest run of units that's a headword
|
|
24
|
+
* ("kết quả" is one word, so its "quả" isn't checked on its own). Unknown single units become words with
|
|
25
|
+
* no entries.
|
|
26
|
+
*/
|
|
27
|
+
export declare function segment(text: string, list: Unit[], maxUnits: number, lookup: (phrase: string) => Promise<Entry[]>): Promise<Word[]>;
|
|
28
|
+
/** Without accents: "không" → "khong", "đi" → "di". */
|
|
29
|
+
export declare const plain: (s: string) => string;
|
|
30
|
+
/** Keeps a capital first letter when replacing a capitalized word ("Tôi" → "Con"). */
|
|
31
|
+
export declare const matchCase: (original: string, replacement: string) => string;
|
package/dist/text.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Splitting text into units, sentences and dictionary words: shared by the checker, sentence frames and
|
|
2
|
+
// the journal review. Language-general: units are runs of letters and digits; a language written in
|
|
3
|
+
// syllables groups them into dictionary words by longest match (CheckerConfig.units / maxWordUnits).
|
|
4
|
+
// A run of letters (with their accents), digits and apostrophes: a word, or a syllable in syllable languages.
|
|
5
|
+
export const UNIT = /[\p{L}\p{M}\p{N}'’]+/gu;
|
|
6
|
+
export const SENTENCE_END = /[.!?…]+|\n+/g;
|
|
7
|
+
export function units(text) {
|
|
8
|
+
return [...text.matchAll(UNIT)].map((m) => ({ text: m[0], start: m.index, end: m.index + m[0].length }));
|
|
9
|
+
}
|
|
10
|
+
/** Units grouped into sentences, which end at . ! ? … or a line break. */
|
|
11
|
+
export function sentencesOf(text, all) {
|
|
12
|
+
const ends = [...text.matchAll(SENTENCE_END)].map((m) => m.index);
|
|
13
|
+
const out = [[]];
|
|
14
|
+
let next = 0;
|
|
15
|
+
for (const u of all) {
|
|
16
|
+
while (next < ends.length && ends[next] < u.start) {
|
|
17
|
+
if (out[out.length - 1].length)
|
|
18
|
+
out.push([]);
|
|
19
|
+
next++;
|
|
20
|
+
}
|
|
21
|
+
out[out.length - 1].push(u);
|
|
22
|
+
}
|
|
23
|
+
return out.filter((s) => s.length);
|
|
24
|
+
}
|
|
25
|
+
/** Units join into one word only when just spaces separate them ("thịt heo", not "thịt, heo"). */
|
|
26
|
+
export const joined = (text, a, b) => /^[^\S\n]+$/.test(text.slice(a.end, b.start));
|
|
27
|
+
/**
|
|
28
|
+
* Longest match against the dictionary: at each position, the longest run of units that's a headword
|
|
29
|
+
* ("kết quả" is one word, so its "quả" isn't checked on its own). Unknown single units become words with
|
|
30
|
+
* no entries.
|
|
31
|
+
*/
|
|
32
|
+
export async function segment(text, list, maxUnits, lookup) {
|
|
33
|
+
const words = [];
|
|
34
|
+
for (let i = 0; i < list.length;) {
|
|
35
|
+
let taken = null;
|
|
36
|
+
for (let k = Math.min(maxUnits, list.length - i); k >= 1; k--) {
|
|
37
|
+
const span = list.slice(i, i + k);
|
|
38
|
+
if (span.some((u, j) => j > 0 && !joined(text, span[j - 1], u)))
|
|
39
|
+
continue;
|
|
40
|
+
const phrase = span.map((u) => u.text).join(' ');
|
|
41
|
+
const entries = /\d/.test(phrase) ? [] : await lookup(phrase);
|
|
42
|
+
if (entries.length || k === 1) {
|
|
43
|
+
const start = span[0].start;
|
|
44
|
+
const end = span[k - 1].end;
|
|
45
|
+
taken = { text: text.slice(start, end), lower: phrase.toLowerCase(), start, end, units: span, entries };
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
words.push(taken);
|
|
50
|
+
i += taken.units.length;
|
|
51
|
+
}
|
|
52
|
+
return words;
|
|
53
|
+
}
|
|
54
|
+
/** Without accents: "không" → "khong", "đi" → "di". */
|
|
55
|
+
export const plain = (s) => s.normalize('NFD').replace(/\p{M}/gu, '').replace(/đ/g, 'd').replace(/Đ/g, 'D').normalize('NFC');
|
|
56
|
+
/** Keeps a capital first letter when replacing a capitalized word ("Tôi" → "Con"). */
|
|
57
|
+
export const matchCase = (original, replacement) => original.charAt(0) !== original.charAt(0).toLowerCase() ? replacement.charAt(0).toUpperCase() + replacement.slice(1) : replacement;
|
package/dist/types.d.ts
CHANGED
|
@@ -114,6 +114,64 @@ export type LanguageMeta = {
|
|
|
114
114
|
checker?: boolean;
|
|
115
115
|
/** true when the language has a syllable list (syllables.json: syllable → Zipf frequency, 0 when unknown). */
|
|
116
116
|
syllables?: boolean;
|
|
117
|
+
/** true when the language has sentence frames (frames.json, see FramesData). */
|
|
118
|
+
frames?: boolean;
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Pronoun slots in sentence frames ({I}, {YOU}, …) and the pronoun-table column each one fills from, for
|
|
122
|
+
* the listener (or the table's default row).
|
|
123
|
+
*/
|
|
124
|
+
export declare const FRAME_PRONOUNS: Record<string, {
|
|
125
|
+
person: PronounPerson;
|
|
126
|
+
inclusive?: boolean;
|
|
127
|
+
}>;
|
|
128
|
+
/**
|
|
129
|
+
* One choice for a frame word slot ({WHERE} → "đâu", "mô"): from a dictionary definition (region and
|
|
130
|
+
* labels from that sense) or a hand-written override with a note. `when: 'respect'` choices are used only
|
|
131
|
+
* when the listener's pronoun-table row is marked respect (Vietnamese "ạ").
|
|
132
|
+
*/
|
|
133
|
+
export type FrameWordChoice = {
|
|
134
|
+
word: string;
|
|
135
|
+
gloss?: string;
|
|
136
|
+
source: 'gloss' | 'override';
|
|
137
|
+
regions: string[];
|
|
138
|
+
regionTagged: boolean;
|
|
139
|
+
labels: string[];
|
|
140
|
+
note?: string;
|
|
141
|
+
when?: 'respect';
|
|
142
|
+
};
|
|
143
|
+
/** A frame word slot: what it means, whether a sentence is fine without it, and its choices in order. */
|
|
144
|
+
export type FrameWords = {
|
|
145
|
+
meaning: string;
|
|
146
|
+
optional?: boolean;
|
|
147
|
+
choices: FrameWordChoice[];
|
|
148
|
+
};
|
|
149
|
+
/**
|
|
150
|
+
* A sentence frame in one language: the catalog's id, topic, English ways to say it and slots, and this
|
|
151
|
+
* language's `text` with {slot} placeholders: content slots (lowercase, from `slots`), pronoun slots
|
|
152
|
+
* (FRAME_PRONOUNS) and frame word slots (uppercase, from FramesData.words).
|
|
153
|
+
*/
|
|
154
|
+
export type FrameData = {
|
|
155
|
+
id: string;
|
|
156
|
+
topic: string;
|
|
157
|
+
en: string[];
|
|
158
|
+
slots: Record<string, 'noun' | 'verb' | 'adj'>;
|
|
159
|
+
clause?: boolean;
|
|
160
|
+
text: string;
|
|
161
|
+
};
|
|
162
|
+
export type FramesData = {
|
|
163
|
+
frames: FrameData[];
|
|
164
|
+
words: Record<string, FrameWords>;
|
|
165
|
+
};
|
|
166
|
+
export type StoredFrameWordChoice = Omit<FrameWordChoice, 'regions' | 'regionTagged' | 'labels'> & {
|
|
167
|
+
regions?: string[];
|
|
168
|
+
labels?: string[];
|
|
169
|
+
};
|
|
170
|
+
export type StoredFramesData = {
|
|
171
|
+
frames: FrameData[];
|
|
172
|
+
words: Record<string, Omit<FrameWords, 'choices'> & {
|
|
173
|
+
choices: StoredFrameWordChoice[];
|
|
174
|
+
}>;
|
|
117
175
|
};
|
|
118
176
|
/**
|
|
119
177
|
* How the checker reads one language (languages/<lang>.ts `checker`, stored as data/checker.json). Every
|
package/dist/types.js
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
// Shapes of the data. The build script (scripts/build-language.ts) writes the Stored* types to the
|
|
2
2
|
// data files; the API in index.ts reads them and returns the full Sense/Entry/Hit types.
|
|
3
|
+
/**
|
|
4
|
+
* Pronoun slots in sentence frames ({I}, {YOU}, …) and the pronoun-table column each one fills from, for
|
|
5
|
+
* the listener (or the table's default row).
|
|
6
|
+
*/
|
|
7
|
+
export const FRAME_PRONOUNS = {
|
|
8
|
+
I: { person: 'self' },
|
|
9
|
+
YOU: { person: 'addressee' },
|
|
10
|
+
WE: { person: 'selfPlural', inclusive: false },
|
|
11
|
+
WE_INCL: { person: 'selfPlural', inclusive: true },
|
|
12
|
+
YOU_PL: { person: 'addresseePlural' },
|
|
13
|
+
HE_SHE: { person: 'third' },
|
|
14
|
+
THEY: { person: 'thirdPlural' },
|
|
15
|
+
};
|
|
3
16
|
/** The pronoun table's columns: I, you, he/she, we, you (plural), they. */
|
|
4
17
|
export const PRONOUN_PERSONS = ['self', 'addressee', 'third', 'selfPlural', 'addresseePlural', 'thirdPlural'];
|
|
5
18
|
/** Converts a full sense or hit to its stored form. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "which-dialect",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|