which-dialect 0.1.1 → 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 +123 -7
- package/dist/check.d.ts +45 -0
- package/dist/check.js +347 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.js +22 -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 +87 -0
- package/dist/types.js +13 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -107,6 +107,24 @@ export function createDictionary(options) {
|
|
|
107
107
|
const m = await meta();
|
|
108
108
|
return m.picks ? loadOnce('picks.json') : [];
|
|
109
109
|
},
|
|
110
|
+
async checker() {
|
|
111
|
+
const m = await meta();
|
|
112
|
+
return m.checker ? loadOnce('checker.json') : {};
|
|
113
|
+
},
|
|
114
|
+
async syllables() {
|
|
115
|
+
const m = await meta();
|
|
116
|
+
return m.syllables ? loadOnce('syllables.json') : {};
|
|
117
|
+
},
|
|
118
|
+
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
|
+
},
|
|
110
128
|
async pronouns({ listener, region, speaker, exclude = DEFAULT_EXCLUDED_LABELS } = {}) {
|
|
111
129
|
const m = await meta();
|
|
112
130
|
if (!m.pronouns)
|
|
@@ -132,3 +150,7 @@ export function createDictionary(options) {
|
|
|
132
150
|
};
|
|
133
151
|
}
|
|
134
152
|
export * from "./translate.js";
|
|
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;
|