which-dialect 0.1.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/LICENSE +28 -0
- package/README.md +205 -0
- package/dist/index.d.ts +76 -0
- package/dist/index.js +130 -0
- package/dist/pos.d.ts +16 -0
- package/dist/pos.js +70 -0
- package/dist/translate.d.ts +153 -0
- package/dist/translate.js +731 -0
- package/dist/types.d.ts +227 -0
- package/dist/types.js +96 -0
- package/package.json +38 -0
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
// Translating between any two languages and dialects, meaning by meaning.
|
|
2
|
+
//
|
|
3
|
+
// Every language's data defines its words in English (Wiktionary glosses), so English is the bridge:
|
|
4
|
+
// a source word's senses each give English terms ("bắp" -> "corn"), and the target language is searched
|
|
5
|
+
// for those terms with a compatible part of speech, in the target region. Each source sense is translated
|
|
6
|
+
// on its own, so a word's meanings don't get mixed up ("cool" the temperature vs. "cool" the compliment).
|
|
7
|
+
import { compatiblePos } from "./pos.js";
|
|
8
|
+
import { displayGloss, glossTerms, normalizeEnglish, PRONOUN_PERSONS, } from "./types.js";
|
|
9
|
+
import { createDictionary, DEFAULT_EXCLUDED_LABELS, resolveRegion, } from "./index.js";
|
|
10
|
+
/**
|
|
11
|
+
* Possible base words for a regularly inflected English word, most likely first: "walked" -> walk,
|
|
12
|
+
* "cities" -> city, "making" -> make, "stopped" -> stop. The English data package only stores irregular
|
|
13
|
+
* forms ("said", "went"), so regular ones are undone here.
|
|
14
|
+
*/
|
|
15
|
+
export function regularBaseForms(word) {
|
|
16
|
+
const w = word.toLowerCase();
|
|
17
|
+
const out = [];
|
|
18
|
+
const add = (base) => base.length > 1 && base !== w && !out.includes(base) && out.push(base);
|
|
19
|
+
const undouble = (stem) => (/([bdfgklmnprstvz])\1$/.test(stem) ? stem.slice(0, -1) : null);
|
|
20
|
+
for (const [suffix, replacements] of [
|
|
21
|
+
['ies', ['y']], ['ied', ['y']], ['ier', ['y']], ['iest', ['y']],
|
|
22
|
+
['es', ['', 'e']], ['ing', ['', 'e']], ['ed', ['', 'e']], ['er', ['', 'e']], ['est', ['', 'e']],
|
|
23
|
+
['s', ['']], ['d', ['']],
|
|
24
|
+
]) {
|
|
25
|
+
if (!w.endsWith(suffix) || w.length <= suffix.length + 1)
|
|
26
|
+
continue;
|
|
27
|
+
const stem = w.slice(0, -suffix.length);
|
|
28
|
+
for (const r of replacements)
|
|
29
|
+
add(stem + r);
|
|
30
|
+
const single = undouble(stem);
|
|
31
|
+
if (single)
|
|
32
|
+
add(single);
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
// Base-form rules for languages whose data leaves regular forms out.
|
|
37
|
+
const BASE_FORMS = { en: regularBaseForms };
|
|
38
|
+
// A variant sense whose gloss points at another word ("simple past of say", "misspelling of don't",
|
|
39
|
+
// "Southern Vietnam form of không") rather than defining it. Variants with their own definition are kept.
|
|
40
|
+
const FORM_GLOSS = /\b(of|for)\b/;
|
|
41
|
+
// How much being a common word counts: points per Zipf step above RARE_ZIPF (so "anh", Zipf 6.3,
|
|
42
|
+
// gets about +3.4 and "tía", Zipf 3.9, about +0.5). Words missing from a language's frequency list count
|
|
43
|
+
// as RARE_ZIPF - 1. Languages without frequency data fall back to a small bonus for having many senses.
|
|
44
|
+
export const FREQUENCY_WEIGHT = 1.2;
|
|
45
|
+
const RARE_ZIPF = 3.5;
|
|
46
|
+
// The region bonus is deliberately NOT scaled by frequency: wordfreq counts worldwide text, where
|
|
47
|
+
// regional words are rare even when they're the everyday word in their region.
|
|
48
|
+
// Scaling it was tried (2026-09-27) and lost those words.
|
|
49
|
+
function commonness(hit, hasFrequencies) {
|
|
50
|
+
if (!hasFrequencies)
|
|
51
|
+
return Math.min(hit.senses, 10) * 0.05;
|
|
52
|
+
return ((hit.frequency ?? RARE_ZIPF - 1) - RARE_ZIPF) * FREQUENCY_WEIGHT;
|
|
53
|
+
}
|
|
54
|
+
// Wiktionary's translation tables list the usual translation of each English meaning, per language,
|
|
55
|
+
// with region and register tags ("I" → vi: tôi, tớ [informal], tui [South]). Being listed for the
|
|
56
|
+
// matched meaning is the strongest evidence a word is the right, common translation.
|
|
57
|
+
const TABLE_BONUS = 4;
|
|
58
|
+
const TABLE_REGION_BONUS = 1.5;
|
|
59
|
+
const TABLE_OTHER_REGION_PENALTY = 2;
|
|
60
|
+
const ENGLISH_PERSONS = {
|
|
61
|
+
i: { person: 'self' }, me: { person: 'self' },
|
|
62
|
+
you: { person: 'addressee' },
|
|
63
|
+
he: { person: 'third', gender: 'male' }, him: { person: 'third', gender: 'male' },
|
|
64
|
+
she: { person: 'third', gender: 'female' }, her: { person: 'third', gender: 'female' },
|
|
65
|
+
we: { person: 'selfPlural' }, us: { person: 'selfPlural' },
|
|
66
|
+
they: { person: 'thirdPlural' }, them: { person: 'thirdPlural' },
|
|
67
|
+
"y'all": { person: 'addresseePlural' }, 'you guys': { person: 'addresseePlural' }, yous: { person: 'addresseePlural' },
|
|
68
|
+
};
|
|
69
|
+
// English senses that aren't about people: "It; an animal whose gender is unknown", "A ship or boat",
|
|
70
|
+
// generic "you" ("Anyone, one").
|
|
71
|
+
const NOT_A_PERSON = /^(?:it\b|a genderless|a ship|a country|a thing|anyone\b|a dummy)/i;
|
|
72
|
+
const INCLUSIVE = /\binclusive\b|\bincluding the (?:person|listener)/i;
|
|
73
|
+
const EXCLUSIVE = /\bexclusive\b|\bexcluding the (?:person|listener)/i;
|
|
74
|
+
function personOf(sense, from) {
|
|
75
|
+
if (sense.pos !== 'pron')
|
|
76
|
+
return null;
|
|
77
|
+
const gloss = displayGloss(sense.glosses);
|
|
78
|
+
const clusivity = INCLUSIVE.test(gloss) ? { inclusive: true } : EXCLUSIVE.test(gloss) ? { inclusive: false } : {};
|
|
79
|
+
if (from === 'en') {
|
|
80
|
+
const known = ENGLISH_PERSONS[sense.lemma.toLowerCase()];
|
|
81
|
+
if (!known || NOT_A_PERSON.test(gloss))
|
|
82
|
+
return null;
|
|
83
|
+
if (known.person === 'addressee' && /\bpeople\b|yourselves|\bplural\b/i.test(gloss))
|
|
84
|
+
return { person: 'addresseePlural' };
|
|
85
|
+
// Singular "they": "One whose gender is unknown…".
|
|
86
|
+
if (known.person === 'thirdPlural' && /^one\b/i.test(gloss))
|
|
87
|
+
return { person: 'third' };
|
|
88
|
+
return { ...known, ...(known.person === 'selfPlural' ? clusivity : {}) };
|
|
89
|
+
}
|
|
90
|
+
if (/^(?:we|us)\b/i.test(gloss))
|
|
91
|
+
return { person: 'selfPlural', ...clusivity };
|
|
92
|
+
if (/^you\b.*\b(?:plural|guys|y'all)\b/i.test(gloss))
|
|
93
|
+
return { person: 'addresseePlural' };
|
|
94
|
+
if (/^(?:they|them)\b/i.test(gloss))
|
|
95
|
+
return { person: 'thirdPlural' };
|
|
96
|
+
if (/^(?:I|me)\b|\bfirst[- ]person singular\b/i.test(gloss))
|
|
97
|
+
return { person: 'self' };
|
|
98
|
+
if (/^you\b|\bsecond[- ]person singular\b/i.test(gloss))
|
|
99
|
+
return { person: 'addressee' };
|
|
100
|
+
if (/^(?:he|him)\b/i.test(gloss))
|
|
101
|
+
return { person: 'third', ...(/\b(?:she|her)\b/i.test(gloss) ? {} : { gender: 'male' }) };
|
|
102
|
+
if (/^(?:she|her)\b/i.test(gloss))
|
|
103
|
+
return { person: 'third', ...(/\b(?:he|him)\b/i.test(gloss) ? {} : { gender: 'female' }) };
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
// A pronoun sense whose definition names a relationship ("I/me, your father", "used by children when
|
|
107
|
+
// talking to their parents") isn't a plain "I": the default row's neutral words don't apply to it.
|
|
108
|
+
const RELATIONAL = /\b(?:your|my)\b|\bused (?:by|when|to)\b|\btalking to\b|\btowards?\b/i;
|
|
109
|
+
// Which option picks the row: who you're talking to, or who you're talking about.
|
|
110
|
+
const ABOUT_PERSONS = new Set(['third', 'thirdPlural']);
|
|
111
|
+
const SINGULAR_PERSONS = new Set(['self', 'addressee', 'third']);
|
|
112
|
+
// A row's words for a pronoun sense: its gender (he/she) and inclusiveness (we) filter them.
|
|
113
|
+
function fits(c, p) {
|
|
114
|
+
return (!p.gender || !c.gender || c.gender === p.gender) && (p.inclusive === undefined || c.inclusive === undefined || c.inclusive === p.inclusive);
|
|
115
|
+
}
|
|
116
|
+
// Matches scoring below this are dropped. A real match gets at least ~2 (a main-meaning or first-term match).
|
|
117
|
+
const MIN_SCORE = 1;
|
|
118
|
+
// A variant that's only another spelling of a different word ("yeah": pronunciation spelling of "year")
|
|
119
|
+
// ranks below the word's own meanings.
|
|
120
|
+
const SPELLING_VIA = /pronunciation spelling|misspelling|eye dialect|nonstandard spelling|dated spelling|informal spelling/i;
|
|
121
|
+
// Senses whose labels are all in this set rank after mainstream senses.
|
|
122
|
+
const MARGINAL_LABELS = new Set(['informal', 'slang', 'colloquial', 'dialectal', 'rare', 'nonstandard', 'Internet', 'humorous']);
|
|
123
|
+
const LABEL_PENALTY = new Set(['slang', 'Internet', 'humorous', 'literary', 'dialectal', 'uncommon', 'rare', 'euphemistic']);
|
|
124
|
+
// Register: a polite source word should translate to a polite word, a casual one to a casual one.
|
|
125
|
+
const POLITE = new Set(['polite', 'formal', 'honorific', 'literary']);
|
|
126
|
+
const CASUAL = new Set(['informal', 'colloquial', 'slang', 'familiar', 'vulgar', 'Internet']);
|
|
127
|
+
function register(labels) {
|
|
128
|
+
if (labels.some((l) => POLITE.has(l)))
|
|
129
|
+
return 'polite';
|
|
130
|
+
if (labels.some((l) => CASUAL.has(l)))
|
|
131
|
+
return 'casual';
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
function registerFit(source, target, wanted) {
|
|
135
|
+
const a = wanted ? (wanted === 'neutral' ? null : wanted) : register(source);
|
|
136
|
+
const b = register(target);
|
|
137
|
+
if (!b)
|
|
138
|
+
return 0;
|
|
139
|
+
// A neutral meaning ("cool" the temperature) prefers neutral words over slang ("chất": cool, informal),
|
|
140
|
+
// since frequency is per word, not per meaning, and a common word's slang sense can otherwise win.
|
|
141
|
+
if (!a)
|
|
142
|
+
return b === 'casual' ? -1.5 : 0;
|
|
143
|
+
return a === b ? 1 : -2;
|
|
144
|
+
}
|
|
145
|
+
// Words too common to say anything about a meaning, and dictionary jargon: words that describe how a
|
|
146
|
+
// definition is written ("the speaker or writer, referred to as the grammatical subject") rather than
|
|
147
|
+
// what the word means. Without the jargon, Vietnamese "con" ("used when talking to someone older than
|
|
148
|
+
// the speaker") matched English "I" ("the speaker or writer") on "speaker".
|
|
149
|
+
const STOPWORDS = new Set(('a an the to of or and in on for with by as at from that this be is are was it its used use any some one ' +
|
|
150
|
+
'something someone very more most usually especially often also who which what when being having into ' +
|
|
151
|
+
'speaker speakers writer referred refer refers referring grammatical subject object sentence clause ' +
|
|
152
|
+
'word words form forms term expression phrase talking speaking addressed addressing person people ' +
|
|
153
|
+
'particular certain various generally sense usage meaning').split(' '));
|
|
154
|
+
function contentWords(text) {
|
|
155
|
+
return new Set(text
|
|
156
|
+
.toLowerCase()
|
|
157
|
+
.replace(/[^a-z' ]+/g, ' ')
|
|
158
|
+
.split(' ')
|
|
159
|
+
.filter((w) => w.length > 2 && !STOPWORDS.has(w)));
|
|
160
|
+
}
|
|
161
|
+
function overlap(a, text) {
|
|
162
|
+
let n = 0;
|
|
163
|
+
for (const w of contentWords(text))
|
|
164
|
+
if (a.has(w))
|
|
165
|
+
n++;
|
|
166
|
+
return n;
|
|
167
|
+
}
|
|
168
|
+
function intersects(regions, wanted) {
|
|
169
|
+
return !wanted || regions.some((r) => wanted.has(r));
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* English terms that carry a sense's meaning: for English, the word itself first and its synonyms last
|
|
173
|
+
* ("lift" -> "elevator"); then the terms in its definitions.
|
|
174
|
+
*/
|
|
175
|
+
function bridgeTerms(sense, from) {
|
|
176
|
+
const terms = [];
|
|
177
|
+
// Main terms get the full "main meaning" bonus: the English word itself, and the first term of the
|
|
178
|
+
// definition ("will": "Used to express the future tense" → "future tense", which is what "sẽ" means).
|
|
179
|
+
const main = new Set();
|
|
180
|
+
if (from === 'en') {
|
|
181
|
+
terms.push(normalizeEnglish(sense.lemma));
|
|
182
|
+
main.add(normalizeEnglish(sense.lemma));
|
|
183
|
+
// The base form too, since definitions usually use it ("thanks" -> "thank": "to thank").
|
|
184
|
+
const [base] = regularBaseForms(sense.lemma);
|
|
185
|
+
if (base)
|
|
186
|
+
terms.push(base);
|
|
187
|
+
}
|
|
188
|
+
// Headings ("As a copulative verb:") carry no meaning, so the specific definitions are used.
|
|
189
|
+
const glosses = sense.glosses.filter((g) => !g.trim().endsWith(':'));
|
|
190
|
+
glosses.slice(0, 2).forEach((gloss, gi) => {
|
|
191
|
+
for (const [term, position] of glossTerms(gloss)) {
|
|
192
|
+
terms.push(term);
|
|
193
|
+
if (gi === 0 && position === 0)
|
|
194
|
+
main.add(term);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
const unique = [...new Set(terms)];
|
|
198
|
+
const synonymsFrom = unique.length;
|
|
199
|
+
// Synonyms last: they're looser (English lists "cheers" as a synonym of "thanks"), so they score lower.
|
|
200
|
+
if (from === 'en') {
|
|
201
|
+
for (const syn of sense.synonyms ?? []) {
|
|
202
|
+
const t = normalizeEnglish(syn);
|
|
203
|
+
if (/^[a-z][a-z' -]*$/.test(t) && !unique.includes(t))
|
|
204
|
+
unique.push(t);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return { terms: unique.slice(0, 8), synonymsFrom, main };
|
|
208
|
+
}
|
|
209
|
+
export function createTranslator(options = {}) {
|
|
210
|
+
const cache = new Map();
|
|
211
|
+
const dict = (lang) => {
|
|
212
|
+
let d = cache.get(lang);
|
|
213
|
+
if (!d) {
|
|
214
|
+
d = options.dictionary?.(lang) ?? createDictionary({ lang, baseUrl: options.baseUrl?.(lang), load: options.load?.(lang) });
|
|
215
|
+
cache.set(lang, d);
|
|
216
|
+
}
|
|
217
|
+
return d;
|
|
218
|
+
};
|
|
219
|
+
async function collectSenses(lang, word, depth, via, inherit) {
|
|
220
|
+
const d = dict(lang);
|
|
221
|
+
const w = word.trim();
|
|
222
|
+
const entries = [...(await d.lookup(w)), ...(w !== w.toLowerCase() ? await d.lookup(w.toLowerCase()) : [])];
|
|
223
|
+
// A regular form ("walked", "running") is also read as its base word, after the word's own entries.
|
|
224
|
+
// Of the candidates the dictionary knows, the one with the most senses wins ("running" -> "run", not "runn").
|
|
225
|
+
let base = null;
|
|
226
|
+
const rules = BASE_FORMS[lang];
|
|
227
|
+
if (rules && depth === 0) {
|
|
228
|
+
let best = 0;
|
|
229
|
+
for (const candidate of rules(w)) {
|
|
230
|
+
const found = await d.lookup(candidate);
|
|
231
|
+
const count = found.reduce((n, e) => n + e.senses.length, 0);
|
|
232
|
+
if (count > best) {
|
|
233
|
+
best = count;
|
|
234
|
+
base = { word: candidate, entries: found };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const out = [];
|
|
239
|
+
const sources = [
|
|
240
|
+
...entries.map((entry) => ({ entry, via })),
|
|
241
|
+
...(base?.entries ?? []).map((entry) => ({ entry, via: via ?? `form of ${base.word}` })),
|
|
242
|
+
];
|
|
243
|
+
for (const { entry, via: entryVia } of sources) {
|
|
244
|
+
for (const sense of entry.senses) {
|
|
245
|
+
// Follow variants to the word they belong to (at most two hops: dont -> don't -> do not). A tagged
|
|
246
|
+
// variant keeps its region: "hông" is Southern even though "không" is used everywhere.
|
|
247
|
+
if (sense.altOf && depth < 2 && FORM_GLOSS.test(sense.glosses[0] ?? '')) {
|
|
248
|
+
const followed = await collectSenses(lang, sense.altOf, depth + 1, entryVia ?? sense.glosses[0], sense.regionTagged ? sense.regions : inherit);
|
|
249
|
+
// Prefer the same part of speech: the pronoun "tui" takes the pronoun senses of "tôi".
|
|
250
|
+
const same = followed.filter((f) => f.pos === entry.pos);
|
|
251
|
+
out.push(...(same.length ? same : followed));
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
out.push({
|
|
255
|
+
word: w,
|
|
256
|
+
lemma: entry.word,
|
|
257
|
+
pos: entry.pos,
|
|
258
|
+
glosses: sense.glosses,
|
|
259
|
+
regions: inherit ?? sense.regions,
|
|
260
|
+
regionTagged: inherit ? true : sense.regionTagged,
|
|
261
|
+
labels: sense.labels,
|
|
262
|
+
...(entryVia ? { via: entryVia } : {}),
|
|
263
|
+
...(sense.synonyms ? { synonyms: sense.synonyms } : {}),
|
|
264
|
+
...(sense.examples ? { examples: sense.examples } : {}),
|
|
265
|
+
...(sense.translations ? { translations: sense.translations } : {}),
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// The same sense reached twice (e.g. through two spellings) is kept once. The key uses all the
|
|
270
|
+
// glosses: nested senses share their first line (a heading like "As a copulative verb:"), and keying
|
|
271
|
+
// on it alone dropped every sense after the first under a heading.
|
|
272
|
+
const seen = new Set();
|
|
273
|
+
return out
|
|
274
|
+
.map((s) => ({ ...s, word: w }))
|
|
275
|
+
.filter((s) => {
|
|
276
|
+
const key = `${s.lemma}\u0000${s.pos}\u0000${s.glosses.join('\u0001')}`;
|
|
277
|
+
return !seen.has(key) && seen.add(key);
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
async function senses(word, { from, fromRegion, pos }) {
|
|
281
|
+
const wanted = resolveRegion(await dict(from).meta(), fromRegion);
|
|
282
|
+
return (await collectSenses(from, word, 0, undefined, undefined)).filter((s) => intersects(s.regions, wanted) && (!pos || s.pos === pos));
|
|
283
|
+
}
|
|
284
|
+
// English target: the bridge terms are English words already. Keep those the English dictionary has
|
|
285
|
+
// with a compatible part of speech and used in the target region, plus regional words for them
|
|
286
|
+
// ("truck" in the UK -> "lorry").
|
|
287
|
+
async function englishCandidates(term, posList, toMeta, toRegion, exclude) {
|
|
288
|
+
const en = dict('en');
|
|
289
|
+
const wanted = resolveRegion(toMeta, toRegion);
|
|
290
|
+
const hits = [];
|
|
291
|
+
// Terms are lowercase, so the capitalized word too ("i" is the letter; the pronoun is "I"). When the
|
|
292
|
+
// exact part of speech is there, other compatible ones are left out (the letter "i" for pronoun "I").
|
|
293
|
+
const capitalized = term.charAt(0).toUpperCase() + term.slice(1);
|
|
294
|
+
let entries = [...(await en.lookup(term)), ...(capitalized !== term ? await en.lookup(capitalized) : [])];
|
|
295
|
+
const usable = (s) => !s.altOf && intersects(s.regions, wanted) && !s.labels.some((l) => exclude.includes(l));
|
|
296
|
+
if (entries.some((e) => e.pos === posList[0] && e.senses.some(usable)))
|
|
297
|
+
entries = entries.filter((e) => e.pos === posList[0]);
|
|
298
|
+
for (const entry of entries) {
|
|
299
|
+
if (!posList.includes(entry.pos))
|
|
300
|
+
continue;
|
|
301
|
+
const index = entry.senses.findIndex(usable);
|
|
302
|
+
if (index < 0)
|
|
303
|
+
continue;
|
|
304
|
+
const s = entry.senses[index];
|
|
305
|
+
hits.push({
|
|
306
|
+
word: entry.word,
|
|
307
|
+
pos: entry.pos,
|
|
308
|
+
gloss: s.glosses[0],
|
|
309
|
+
regions: s.regions,
|
|
310
|
+
regionTagged: s.regionTagged,
|
|
311
|
+
labels: s.labels,
|
|
312
|
+
senseIndex: index,
|
|
313
|
+
senses: entry.senses.length,
|
|
314
|
+
...(entry.frequency !== undefined ? { frequency: entry.frequency } : {}),
|
|
315
|
+
primary: true,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
if (toRegion)
|
|
319
|
+
hits.push(...(await en.searchEnglish(term, { region: toRegion, pos: posList, exclude, limit: 10 })));
|
|
320
|
+
return hits;
|
|
321
|
+
}
|
|
322
|
+
// Translating between dialects of one language: the sense's synonyms are candidates themselves
|
|
323
|
+
// ("ngô" -> "bắp"). Each is looked up for its sense closest to the source meaning, and kept if it's
|
|
324
|
+
// used in the target region.
|
|
325
|
+
async function synonymCandidates(sense, lang, posList, wanted, exclude, context) {
|
|
326
|
+
const hits = [];
|
|
327
|
+
for (const syn of sense.synonyms ?? []) {
|
|
328
|
+
let best = null;
|
|
329
|
+
for (const entry of await dict(lang).lookup(syn)) {
|
|
330
|
+
if (!posList.includes(entry.pos))
|
|
331
|
+
continue;
|
|
332
|
+
entry.senses.forEach((s, index) => {
|
|
333
|
+
if (s.altOf || !intersects(s.regions, wanted) || s.labels.some((l) => exclude.includes(l)))
|
|
334
|
+
return;
|
|
335
|
+
// Prefer the sense that lists the source word back, then the one whose definition overlaps most.
|
|
336
|
+
const fit = (s.synonyms?.includes(sense.lemma) ? 5 : 0) + overlap(context, s.glosses.join(' ')) - index * 0.1;
|
|
337
|
+
if (!best || fit > best.fit) {
|
|
338
|
+
best = {
|
|
339
|
+
fit,
|
|
340
|
+
hit: {
|
|
341
|
+
word: entry.word, pos: entry.pos, gloss: s.glosses[0], regions: s.regions, regionTagged: s.regionTagged,
|
|
342
|
+
labels: s.labels, senseIndex: index, senses: entry.senses.length, primary: index === 0,
|
|
343
|
+
...(entry.frequency !== undefined ? { frequency: entry.frequency } : {}),
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
if (best)
|
|
350
|
+
hits.push(best.hit);
|
|
351
|
+
}
|
|
352
|
+
return hits;
|
|
353
|
+
}
|
|
354
|
+
// The translation-table words for a source sense in the target language. English senses carry their
|
|
355
|
+
// table; for other languages, the English sense whose table lists the source word (in the source
|
|
356
|
+
// language) is the same meaning, and its target-language list applies.
|
|
357
|
+
async function tableFor(sense, from, to, bridge) {
|
|
358
|
+
if (from === 'en')
|
|
359
|
+
return to === 'en' ? [] : (sense.translations?.[to] ?? []);
|
|
360
|
+
const found = [];
|
|
361
|
+
const add = (list) => {
|
|
362
|
+
for (const t of list ?? [])
|
|
363
|
+
if (!found.some((f) => f.word === t.word))
|
|
364
|
+
found.push(t);
|
|
365
|
+
};
|
|
366
|
+
for (const term of bridge.slice(0, 3)) {
|
|
367
|
+
for (const entry of await dict('en').lookup(term)) {
|
|
368
|
+
for (const s of entry.senses) {
|
|
369
|
+
const own = s.translations?.[from];
|
|
370
|
+
if (!own?.some((t) => t.word === sense.lemma || t.word === sense.word))
|
|
371
|
+
continue;
|
|
372
|
+
// Into English, the English word whose table lists the source word is itself the translation.
|
|
373
|
+
if (to === 'en')
|
|
374
|
+
add([{ word: entry.word }]);
|
|
375
|
+
else
|
|
376
|
+
add(s.translations?.[to]);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return found;
|
|
381
|
+
}
|
|
382
|
+
// Which of the target's regions a table tag names ("Latin-America" -> its countries), if any.
|
|
383
|
+
function tagRegions(tags, meta) {
|
|
384
|
+
const out = new Set();
|
|
385
|
+
for (const tag of tags ?? []) {
|
|
386
|
+
const name = tag.replace(/-/g, ' ');
|
|
387
|
+
// Tables sometimes shorten the region name ("South" for Vietnamese "Southern"): a tag that begins
|
|
388
|
+
// exactly one region's name counts as that region.
|
|
389
|
+
const prefixed = name.length >= 4 ? meta.regions.filter((r) => r.startsWith(name)) : [];
|
|
390
|
+
if (meta.regions.includes(name))
|
|
391
|
+
out.add(name);
|
|
392
|
+
else if (meta.regionGroups?.[name])
|
|
393
|
+
for (const r of meta.regionGroups[name])
|
|
394
|
+
out.add(r);
|
|
395
|
+
else if (prefixed.length === 1)
|
|
396
|
+
out.add(prefixed[0]);
|
|
397
|
+
}
|
|
398
|
+
return out.size ? out : null;
|
|
399
|
+
}
|
|
400
|
+
// A table word the index didn't find: look it up in the target dictionary for its details. A compatible
|
|
401
|
+
// part of speech is preferred, but the table is evidence for this exact meaning, so any part of speech
|
|
402
|
+
// will do ("thanks" is an interjection; Vietnamese files "cám ơn" as a verb, "to thank"). A usable sense
|
|
403
|
+
// whose definition contains a bridge term comes first, from any entry ("ta" is listed for "I": its
|
|
404
|
+
// "I/me" sense, not its first, "we; us"), then the first usable sense.
|
|
405
|
+
async function tableHit(word, to, posList, wanted, exclude, bridge) {
|
|
406
|
+
const entries = await dict(to).lookup(word);
|
|
407
|
+
const ordered = [...entries.filter((e) => posList.includes(e.pos)), ...entries.filter((e) => !posList.includes(e.pos))];
|
|
408
|
+
const usable = (s) => !s.altOf && intersects(s.regions, wanted) && !s.labels.some((l) => exclude.includes(l));
|
|
409
|
+
const matches = (s) => s.glosses.some((g) => glossTerms(g).some(([t]) => bridge.includes(t)));
|
|
410
|
+
const pick = (test) => {
|
|
411
|
+
for (const entry of ordered) {
|
|
412
|
+
const index = entry.senses.findIndex(test);
|
|
413
|
+
if (index >= 0)
|
|
414
|
+
return { entry, index };
|
|
415
|
+
}
|
|
416
|
+
return null;
|
|
417
|
+
};
|
|
418
|
+
const found = pick((s) => usable(s) && matches(s)) ?? pick(usable) ?? pick(() => true);
|
|
419
|
+
if (!found)
|
|
420
|
+
return null;
|
|
421
|
+
const { entry, index } = found;
|
|
422
|
+
const s = entry.senses[index];
|
|
423
|
+
return {
|
|
424
|
+
word: entry.word, pos: entry.pos, gloss: s.glosses[0], regions: s.regions, regionTagged: s.regionTagged,
|
|
425
|
+
labels: s.labels, senseIndex: index, senses: entry.senses.length, primary: true,
|
|
426
|
+
...(entry.frequency !== undefined ? { frequency: entry.frequency } : {}),
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
// The source word's uses in a pronoun table, with the definition each came from.
|
|
430
|
+
function pronounUses(rows, word) {
|
|
431
|
+
const w = word.trim();
|
|
432
|
+
const out = [];
|
|
433
|
+
for (const r of rows) {
|
|
434
|
+
for (const person of PRONOUN_PERSONS) {
|
|
435
|
+
for (const c of r[person]) {
|
|
436
|
+
if (c.word !== w && c.word !== w.toLowerCase())
|
|
437
|
+
continue;
|
|
438
|
+
out.push({
|
|
439
|
+
gloss: c.gloss,
|
|
440
|
+
use: {
|
|
441
|
+
id: r.id, label: r.label, person, regions: c.regions, regionTagged: c.regionTagged,
|
|
442
|
+
...(c.speaker ? { speaker: c.speaker } : {}), ...(c.gender ? { gender: c.gender } : {}),
|
|
443
|
+
...(c.note ? { note: c.note } : {}), ...(r.warning ? { warning: r.warning } : {}),
|
|
444
|
+
},
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return out;
|
|
450
|
+
}
|
|
451
|
+
// English words for a pronoun-table column (he/him or she/her by gender).
|
|
452
|
+
function englishWords(person, gender) {
|
|
453
|
+
switch (person) {
|
|
454
|
+
case 'self': return ['I', 'me'];
|
|
455
|
+
case 'addressee':
|
|
456
|
+
case 'addresseePlural': return ['you'];
|
|
457
|
+
case 'third': return gender === 'male' ? ['he', 'him'] : gender === 'female' ? ['she', 'her'] : ['he', 'she'];
|
|
458
|
+
case 'selfPlural': return ['we', 'us'];
|
|
459
|
+
case 'thirdPlural': return ['they', 'them'];
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
// The target's words for a pronoun: the English words themselves, else the English word translated
|
|
463
|
+
// (with the caller's listener/about, so Vietnamese gets the right relationship word).
|
|
464
|
+
async function pronounTranslations(person, gender, o) {
|
|
465
|
+
const words = englishWords(person, gender);
|
|
466
|
+
if (o.to === 'en') {
|
|
467
|
+
const hits = (await Promise.all(words.map((w) => englishCandidates(w.toLowerCase(), ['pron'], o.toMeta, o.toRegion, o.exclude)))).flat();
|
|
468
|
+
const seen = new Set();
|
|
469
|
+
return hits
|
|
470
|
+
.filter((h) => h.pos === 'pron' && !seen.has(h.word) && seen.add(h.word))
|
|
471
|
+
.map((h, i) => ({ ...h, score: 10 - i, bridge: 'pronouns' }));
|
|
472
|
+
}
|
|
473
|
+
const groups = await api.translate(words[0], {
|
|
474
|
+
from: 'en', to: o.to, toRegion: o.toRegion, register: o.register, listener: o.listener, about: o.about, speaker: o.speaker, exclude: o.exclude, limit: o.limit,
|
|
475
|
+
});
|
|
476
|
+
const found = (groups.find((g) => personOf(g.source, 'en')?.person === person) ?? groups[0])?.translations ?? [];
|
|
477
|
+
// Pronouns only, when there are any (English "I" also finds letter names, like Vietnamese "i ngắn").
|
|
478
|
+
const pronouns = found.filter((t) => t.pos === 'pron');
|
|
479
|
+
return pronouns.length ? pronouns : found;
|
|
480
|
+
}
|
|
481
|
+
const api = {
|
|
482
|
+
senses,
|
|
483
|
+
async translate(word, { from, to, fromRegion, toRegion, pos, meaning, register: wantedRegister, listener, about, speaker, exclude = DEFAULT_EXCLUDED_LABELS, limit = 5, allSenses = false }) {
|
|
484
|
+
const [sourceSenses, toMeta, fromMeta] = await Promise.all([senses(word, { from, fromRegion, pos }), dict(to).meta(), dict(from).meta()]);
|
|
485
|
+
// The target's pronoun table, for personal-pronoun senses (throws for an unknown listener/about).
|
|
486
|
+
const pronounRows = toMeta.pronouns && sourceSenses.some((s) => personOf(s, from))
|
|
487
|
+
? await dict(to).pronouns({ region: toRegion, speaker, exclude })
|
|
488
|
+
: [];
|
|
489
|
+
if (listener && toMeta.pronouns)
|
|
490
|
+
await dict(to).pronouns({ listener });
|
|
491
|
+
if (about && toMeta.pronouns)
|
|
492
|
+
await dict(to).pronouns({ listener: about });
|
|
493
|
+
const toWanted = resolveRegion(toMeta, toRegion);
|
|
494
|
+
const hasFrequencies = Boolean(toMeta.frequencySource);
|
|
495
|
+
const meaningWords = meaning ? contentWords(meaning) : null;
|
|
496
|
+
const groups = [];
|
|
497
|
+
for (const sense of sourceSenses) {
|
|
498
|
+
const { terms: bridge, synonymsFrom, main } = bridgeTerms(sense, from);
|
|
499
|
+
if (bridge.length === 0) {
|
|
500
|
+
if (allSenses)
|
|
501
|
+
groups.push({ source: sense, bridge, translations: [] });
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
const posList = compatiblePos(sense.pos);
|
|
505
|
+
const context = contentWords(`${sense.glosses.join(' ')} ${meaning ?? ''}`);
|
|
506
|
+
const scored = new Map();
|
|
507
|
+
const keep = (hit, score, bridgeTerm) => {
|
|
508
|
+
const existing = scored.get(hit.word);
|
|
509
|
+
if (!existing || existing.score < score)
|
|
510
|
+
scored.set(hit.word, { ...hit, score, bridge: bridgeTerm });
|
|
511
|
+
};
|
|
512
|
+
if (to === from) {
|
|
513
|
+
for (const hit of await synonymCandidates(sense, to, posList, toWanted, exclude, context)) {
|
|
514
|
+
if (hit.word === sense.lemma)
|
|
515
|
+
continue;
|
|
516
|
+
// A synonym is a direct equivalent, so it starts ahead of words found through English.
|
|
517
|
+
keep(hit, 5 + (toWanted && hit.regionTagged ? 3 : 0) + commonness(hit, hasFrequencies) +
|
|
518
|
+
registerFit(sense.labels, hit.labels, wantedRegister) -
|
|
519
|
+
hit.labels.filter((l) => LABEL_PENALTY.has(l)).length * 0.5, 'synonym');
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
for (const [termIndex, term] of bridge.entries()) {
|
|
523
|
+
// The term's own words say nothing about which meaning matched, so they don't count as overlap.
|
|
524
|
+
const termWords = contentWords(term);
|
|
525
|
+
const ctx = new Set([...context].filter((w) => !termWords.has(w)));
|
|
526
|
+
const hits = to === 'en'
|
|
527
|
+
? await englishCandidates(term, posList, toMeta, toRegion, exclude)
|
|
528
|
+
// A generous limit: the dictionary's quick ranking puts region-tagged words first, and the
|
|
529
|
+
// full scoring below should decide (English "I" has 40+ Vietnamese pronoun candidates, and a
|
|
530
|
+
// limit of 25 cut off the most common, "tôi").
|
|
531
|
+
// Every matching sense, so each word is scored by its best-fitting one (plain "tôi" is formal
|
|
532
|
+
// or neutral; its first-listed "I" sense is informal and Northern).
|
|
533
|
+
: await dict(to).searchEnglish(term, { region: toRegion, pos: posList, exclude, limit: 100, allSenses: true });
|
|
534
|
+
// Rank is the word's place in the dictionary's ranking, not the sense's.
|
|
535
|
+
const wordRank = new Map();
|
|
536
|
+
for (const h of hits)
|
|
537
|
+
if (!wordRank.has(h.word))
|
|
538
|
+
wordRank.set(h.word, wordRank.size);
|
|
539
|
+
hits.forEach((hit) => {
|
|
540
|
+
const rank = wordRank.get(hit.word);
|
|
541
|
+
// Translating between dialects of one language: the word itself only counts if it's tagged for the target region.
|
|
542
|
+
if (to === from && hit.word === sense.lemma && toWanted && !hit.regionTagged)
|
|
543
|
+
return;
|
|
544
|
+
// A word found through a secondary sense (the bridge term is only a side meaning of it) is a weaker
|
|
545
|
+
// match than one whose main sense is the bridge term. With frequency data this matters
|
|
546
|
+
// less (common words win anyway), and kinship words list "you" after "I/me" (Vietnamese "anh"),
|
|
547
|
+
// so the penalty is small then.
|
|
548
|
+
const score = 3 * overlap(ctx, hit.gloss) +
|
|
549
|
+
(main.has(term) || termIndex === 0 ? 2 : termIndex === 1 ? 1 : 0) +
|
|
550
|
+
(hit.primary ? 1 : 0) +
|
|
551
|
+
// Tagged for the target region: a full bonus when the match is the word's main meaning, a
|
|
552
|
+
// small one otherwise ("bá cháy" is Southern for "awesome", not for "cool" the temperature).
|
|
553
|
+
(toWanted && hit.regionTagged && intersects(hit.regions, toWanted) ? (hit.primary ? 2 : 0.5) : 0) +
|
|
554
|
+
(hit.pos === sense.pos ? 0.5 : 0) +
|
|
555
|
+
commonness(hit, hasFrequencies) -
|
|
556
|
+
Math.min(hit.senseIndex, 4) * (hasFrequencies ? 0.15 : 0.4) +
|
|
557
|
+
registerFit(sense.labels, hit.labels, wantedRegister) -
|
|
558
|
+
(termIndex >= synonymsFrom ? 1 : 0) -
|
|
559
|
+
hit.labels.filter((l) => LABEL_PENALTY.has(l)).length * 0.5 -
|
|
560
|
+
// A tiebreaker only, capped: the dictionary's quick ranking puts every labelled word after the
|
|
561
|
+
// unlabelled ones, and labels are already scored above (formal "tôi" came ~30th for "I").
|
|
562
|
+
Math.min(rank, 10) * 0.1;
|
|
563
|
+
keep(hit, score, term);
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
// Translation-table evidence: boost words listed as the usual translation of this meaning, more
|
|
567
|
+
// when the table tags them for the target region, less when it tags them only for other regions.
|
|
568
|
+
const table = await tableFor(sense, from, to, bridge);
|
|
569
|
+
for (const t of table) {
|
|
570
|
+
if (to === from && t.word === sense.lemma && !tagRegions(t.tags, toMeta)?.size)
|
|
571
|
+
continue;
|
|
572
|
+
const places = tagRegions(t.tags, toMeta);
|
|
573
|
+
const otherRegion = Boolean(toWanted && places && ![...places].some((r) => toWanted.has(r)));
|
|
574
|
+
let hit = scored.get(t.word);
|
|
575
|
+
// Another region's word that the dictionary didn't find for this region isn't looked up: its
|
|
576
|
+
// senses here are likely something else (a word can mean one thing in one region, another
|
|
577
|
+
// elsewhere).
|
|
578
|
+
if (!hit && !otherRegion)
|
|
579
|
+
hit = await tableHit(t.word, to, posList, toWanted, exclude, bridge);
|
|
580
|
+
if (!hit)
|
|
581
|
+
continue;
|
|
582
|
+
const regionFit = !toWanted || !places ? 0 : otherRegion ? -TABLE_OTHER_REGION_PENALTY : TABLE_REGION_BONUS;
|
|
583
|
+
// The table's register tags count when the dictionary sense has no register of its own (the
|
|
584
|
+
// table marks "ta" and "tớ" informal for "I"; their dictionary senses don't say).
|
|
585
|
+
const tagFit = register(hit.labels) ? 0 : registerFit(sense.labels, t.tags ?? [], wantedRegister);
|
|
586
|
+
// A word only the table found (e.g. filed under another part of speech: "thanks" → "cám ơn", a
|
|
587
|
+
// verb) starts like a main-meaning match from the first bridge term (2 + 1).
|
|
588
|
+
const base = scored.get(t.word)?.score ?? 3 + commonness(hit, hasFrequencies) + registerFit(sense.labels, hit.labels, wantedRegister);
|
|
589
|
+
scored.set(t.word, { ...hit, score: base + TABLE_BONUS + regionFit + tagFit, bridge: 'table' });
|
|
590
|
+
}
|
|
591
|
+
// Nothing matched a whole term: try the last word of phrases ("fresh ear of corn" -> "corn"), scored lower.
|
|
592
|
+
if (scored.size === 0) {
|
|
593
|
+
const heads = [...new Set(bridge.filter((t) => t.includes(' ')).map((t) => t.split(' ').pop()))];
|
|
594
|
+
for (const head of heads.filter((h) => h.length > 2 && !STOPWORDS.has(h))) {
|
|
595
|
+
const hits = to === 'en'
|
|
596
|
+
? await englishCandidates(head, posList, toMeta, toRegion, exclude)
|
|
597
|
+
: await dict(to).searchEnglish(head, { region: toRegion, pos: posList, exclude, limit: 10 });
|
|
598
|
+
hits.forEach((hit, rank) => keep(hit, 1 + (hit.primary ? 1 : 0) + (toWanted && hit.regionTagged ? 1 : 0) - rank * 0.1, head));
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
// Within one language, a candidate that lists the source word as a synonym is a direct
|
|
602
|
+
// equivalent even when the source doesn't list it back ("dạ" lists "vâng").
|
|
603
|
+
if (to === from) {
|
|
604
|
+
const top = [...scored.values()].sort((a, b) => b.score - a.score).slice(0, 10);
|
|
605
|
+
for (const t of top) {
|
|
606
|
+
const reverse = (await dict(to).lookup(t.word)).some((e) => e.senses.some((s) => s.synonyms?.includes(sense.lemma)));
|
|
607
|
+
if (reverse && t.bridge !== 'synonym')
|
|
608
|
+
scored.set(t.word, { ...t, score: t.score + 4, bridge: 'synonym' });
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
// Personal pronouns: the chosen row's words go first (the `listener`'s for I/you/we, the `about`'s
|
|
612
|
+
// for he/she/they, else the default row's), fitted to the pronoun's gender (he/she) and
|
|
613
|
+
// inclusiveness (we). Words tagged for the target region, then those fitting the register, lead.
|
|
614
|
+
const person = personOf(sense, from);
|
|
615
|
+
const rowId = person ? (ABOUT_PERSONS.has(person.person) ? about : listener) : undefined;
|
|
616
|
+
const plain = !RELATIONAL.test(displayGloss(sense.glosses));
|
|
617
|
+
const row = person ? (rowId ? pronounRows.find((r) => r.id === rowId) : plain ? pronounRows.find((r) => r.default) : undefined) : undefined;
|
|
618
|
+
if (person && row) {
|
|
619
|
+
const words = row[person.person]
|
|
620
|
+
.filter((c) => fits(c, person))
|
|
621
|
+
.map((c, i) => ({ c, i, region: toWanted && c.regionTagged && intersects(c.regions, toWanted) ? 1 : 0, fit: registerFit(sense.labels, c.labels, wantedRegister) }))
|
|
622
|
+
.sort((a, b) => b.region - a.region || b.fit - a.fit || a.i - b.i)
|
|
623
|
+
.map((x) => x.c);
|
|
624
|
+
const top = Math.max(0, ...[...scored.values()].map((t) => t.score));
|
|
625
|
+
words.forEach((c, i) => {
|
|
626
|
+
const found = scored.get(c.word);
|
|
627
|
+
scored.set(c.word, {
|
|
628
|
+
word: c.word, pos: found?.pos ?? 'pron', gloss: c.gloss ?? c.note ?? row.label,
|
|
629
|
+
regions: c.regions, regionTagged: c.regionTagged, labels: c.labels,
|
|
630
|
+
senseIndex: found?.senseIndex ?? 0, senses: found?.senses ?? 1, primary: true,
|
|
631
|
+
...(found?.frequency !== undefined ? { frequency: found.frequency } : {}),
|
|
632
|
+
score: top + 1 + (words.length - i) * 0.1, bridge: 'pronouns', relationship: row.label,
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
const relationships = person
|
|
637
|
+
? pronounRows
|
|
638
|
+
.map((r) => ({ id: r.id, label: r.label, words: r[person.person].filter((c) => fits(c, person)), ...(r.warning ? { warning: r.warning } : {}) }))
|
|
639
|
+
.filter((r) => r.words.length)
|
|
640
|
+
: [];
|
|
641
|
+
// Below this score a match is noise (an unrelated word that happened to share a term).
|
|
642
|
+
const translations = [...scored.values()]
|
|
643
|
+
.filter((t) => t.score >= MIN_SCORE)
|
|
644
|
+
.sort((a, b) => b.score - a.score)
|
|
645
|
+
.slice(0, limit);
|
|
646
|
+
// Examples for the translations that made the cut: from the matched sense of each word.
|
|
647
|
+
for (const t of translations) {
|
|
648
|
+
const entry = (await dict(to).lookup(t.word)).find((e) => e.pos === t.pos && e.senses[t.senseIndex]?.glosses[0] === t.gloss);
|
|
649
|
+
const examples = entry?.senses[t.senseIndex]?.examples;
|
|
650
|
+
if (examples)
|
|
651
|
+
t.examples = examples;
|
|
652
|
+
}
|
|
653
|
+
if (translations.length || allSenses) {
|
|
654
|
+
groups.push({ source: sense, bridge, translations, ...(relationships.length ? { relationships } : {}) });
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
// The source word's uses in its own language's pronoun table ("em": I to someone older, you to
|
|
658
|
+
// someone younger). A use goes to the group of the definition it came from; the rest get a group
|
|
659
|
+
// per person, translated as the target's "I", "you", "he"….
|
|
660
|
+
const uses = fromMeta.pronouns && (!pos || pos === 'pron') ? pronounUses(await dict(from).pronouns({ region: fromRegion, speaker, exclude }), word) : [];
|
|
661
|
+
for (const person of PRONOUN_PERSONS) {
|
|
662
|
+
const leftover = [];
|
|
663
|
+
for (const { gloss, use } of uses.filter((x) => x.use.person === person)) {
|
|
664
|
+
const home = groups.filter((g) => gloss && personOf(g.source, from)?.person === person && g.source.glosses.includes(gloss));
|
|
665
|
+
for (const g of home)
|
|
666
|
+
g.pronounUses = [...(g.pronounUses ?? []), use];
|
|
667
|
+
if (!home.length)
|
|
668
|
+
leftover.push(use);
|
|
669
|
+
}
|
|
670
|
+
if (!leftover.length)
|
|
671
|
+
continue;
|
|
672
|
+
const genders = new Set(leftover.map((u) => u.gender));
|
|
673
|
+
const gender = genders.size === 1 ? leftover[0].gender : undefined;
|
|
674
|
+
const tagged = leftover.every((u) => u.regionTagged);
|
|
675
|
+
const who = leftover.map((u) => u.label.charAt(0).toLowerCase() + u.label.slice(1) + (u.speaker ? ` (said by a ${u.speaker === 'male' ? 'man' : 'woman'})` : ''));
|
|
676
|
+
const source = {
|
|
677
|
+
word, lemma: word.trim(), pos: 'pron',
|
|
678
|
+
glosses: [`${englishWords(person, gender).join('/')}${person === 'addresseePlural' ? ' (plural)' : ''}, when talking ${ABOUT_PERSONS.has(person) ? 'about' : 'to'}: ${who.join('; ')}`],
|
|
679
|
+
regions: tagged ? [...new Set(leftover.flatMap((u) => u.regions))] : fromMeta.regions,
|
|
680
|
+
regionTagged: tagged,
|
|
681
|
+
labels: [],
|
|
682
|
+
};
|
|
683
|
+
const translations = await pronounTranslations(person, gender, { to, toMeta, toRegion, register: wantedRegister, listener, about, speaker, exclude, limit });
|
|
684
|
+
groups.push({ source, bridge: [], translations, pronounUses: leftover });
|
|
685
|
+
}
|
|
686
|
+
// Most relevant sense first: one matching `meaning`; then one tagged for the source region (the
|
|
687
|
+
// regional sense is what makes the word worth asking about); then mainstream before slang;
|
|
688
|
+
// otherwise the dictionary's order. Pass `pos` or `meaning` when you know the sense you want.
|
|
689
|
+
// Translation scores aren't used to order senses: definition overlap inflates minor senses (a rare
|
|
690
|
+
// sense can share many words with its translation), so the dictionary's order (main meanings
|
|
691
|
+
// first) decides.
|
|
692
|
+
const ownSenses = sourceSenses.some((s) => !s.via);
|
|
693
|
+
// `meaning` is matched against the sense's own definitions, and also against its top translation's
|
|
694
|
+
// definition: English words the source definition doesn't use can still describe the target word
|
|
695
|
+
// ("cool": "Fashionable; trendy; hip" never says "awesome", but a translation's definition can).
|
|
696
|
+
// Personal pronouns into a language with a pronoun table: a listener/about says the caller means
|
|
697
|
+
// that person. A word with both singular and plural person senses ("you": Wiktionary lists the
|
|
698
|
+
// plural first) puts the plural ones after the others. Singular ones aren't raised: "bố" is
|
|
699
|
+
// "father" first, "I/me, your father" after.
|
|
700
|
+
const personGroups = groups.map((g) => (g.relationships ? personOf(g.source, from) : null));
|
|
701
|
+
const hasSingular = personGroups.some((p) => p && SINGULAR_PERSONS.has(p.person));
|
|
702
|
+
const pronounRelevance = (g) => {
|
|
703
|
+
const p = personGroups[groups.indexOf(g)];
|
|
704
|
+
if (!p)
|
|
705
|
+
return 0;
|
|
706
|
+
const chosen = ABOUT_PERSONS.has(p.person) ? about : listener;
|
|
707
|
+
return (chosen ? 10 : 0) - (hasSingular && !SINGULAR_PERSONS.has(p.person) ? 3 : 0);
|
|
708
|
+
};
|
|
709
|
+
const relevance = (g) => (meaningWords
|
|
710
|
+
? 10 * overlap(meaningWords, g.source.glosses.join(' ')) + 5 * overlap(meaningWords, g.translations[0]?.gloss ?? '')
|
|
711
|
+
: 0) -
|
|
712
|
+
(ownSenses && g.source.via && SPELLING_VIA.test(g.source.via) ? 2 : 0) +
|
|
713
|
+
(fromRegion && g.source.regionTagged ? 3 : 0) +
|
|
714
|
+
pronounRelevance(g) -
|
|
715
|
+
(g.source.labels.length && g.source.labels.every((l) => MARGINAL_LABELS.has(l)) ? 1 : 0);
|
|
716
|
+
const ordered = groups
|
|
717
|
+
.map((g, i) => ({ g, i, r: relevance(g) }))
|
|
718
|
+
.sort((a, b) => b.r - a.r || a.i - b.i)
|
|
719
|
+
.map((x) => x.g);
|
|
720
|
+
if (allSenses)
|
|
721
|
+
return ordered;
|
|
722
|
+
// Senses that translate to the same words add nothing; keep the first.
|
|
723
|
+
const seen = new Set();
|
|
724
|
+
return ordered.filter((g) => {
|
|
725
|
+
const key = g.translations.slice(0, 3).map((t) => t.word).join('\u0000');
|
|
726
|
+
return !seen.has(key) && seen.add(key);
|
|
727
|
+
});
|
|
728
|
+
},
|
|
729
|
+
};
|
|
730
|
+
return api;
|
|
731
|
+
}
|