@littlebigbrain/create 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 +3 -1
- package/lib/create.mjs +26 -4
- package/package.json +1 -1
- package/templates/_shared/testing.ts +17 -4
- package/templates/link/README.md +203 -0
- package/templates/link/candidates.ts +432 -0
- package/templates/link/decide.test.ts +368 -0
- package/templates/link/decide.ts +340 -0
- package/templates/link/fixture.ts +727 -0
- package/templates/link/grade-run.ts +53 -0
- package/templates/link/grade.test.ts +113 -0
- package/templates/link/grade.ts +335 -0
- package/templates/link/items.ts +333 -0
- package/templates/link/names.test.ts +260 -0
- package/templates/link/names.ts +590 -0
- package/templates/link/reads.ts +302 -0
- package/templates/link/samples/gold.json +108 -0
- package/templates/link/samples/records.json +48 -0
- package/templates/link/samples.ts +64 -0
- package/templates/link/settings.ts +162 -0
- package/templates/link/setup.ts +18 -0
- package/templates/link/source-turn.ts +894 -0
- package/templates/link/sources/records.ts +429 -0
- package/templates/link/things-turn.ts +984 -0
- package/templates/link/things.test.ts +204 -0
- package/templates/link/things.ts +426 -0
- package/templates/link/try.ts +28 -0
- package/templates/link/worker.ts +24 -0
- package/templates/link/workflow.test.ts +484 -0
- package/templates/link/workflow.ts +202 -0
- package/templates/versions.json +1 -1
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
// The normal forms of names, keys and codes (general linking 4.1), and the
|
|
2
|
+
// block keys of a mention (4.2). Pure functions: no I/O.
|
|
3
|
+
//
|
|
4
|
+
// A block key is `<kind>:<value>`, for example `name:mira ruiz tannenholt`,
|
|
5
|
+
// `name:initial:tannenholt m` or `email:mira.tannenholt@northbay.example`.
|
|
6
|
+
// Mentions that share a block key are compared. The kind gives the key a
|
|
7
|
+
// weight: a strong key (an email, a phone, a web address, a handle, a code)
|
|
8
|
+
// links at confidence 1 without a model; a medium or a weak key only finds a
|
|
9
|
+
// candidate.
|
|
10
|
+
|
|
11
|
+
/** The kinds of block keys. */
|
|
12
|
+
export type KeyKind =
|
|
13
|
+
| "email"
|
|
14
|
+
| "phone"
|
|
15
|
+
| "web"
|
|
16
|
+
| "handle"
|
|
17
|
+
| "code"
|
|
18
|
+
| "name"
|
|
19
|
+
| "name:initial"
|
|
20
|
+
| "alias"
|
|
21
|
+
| "name:part"
|
|
22
|
+
| "name:sound"
|
|
23
|
+
| "domain";
|
|
24
|
+
|
|
25
|
+
export type KeyWeight = "strong" | "medium" | "weak";
|
|
26
|
+
|
|
27
|
+
/** The weight of each kind, and the value the candidate score adds for it. */
|
|
28
|
+
export const KEY_KINDS: Record<KeyKind, { weight: KeyWeight; value: number }> = {
|
|
29
|
+
email: { weight: "strong", value: 1 },
|
|
30
|
+
phone: { weight: "strong", value: 1 },
|
|
31
|
+
web: { weight: "strong", value: 1 },
|
|
32
|
+
handle: { weight: "strong", value: 1 },
|
|
33
|
+
code: { weight: "strong", value: 1 },
|
|
34
|
+
name: { weight: "medium", value: 0.8 },
|
|
35
|
+
"name:initial": { weight: "medium", value: 0.6 },
|
|
36
|
+
alias: { weight: "medium", value: 0.8 },
|
|
37
|
+
"name:part": { weight: "weak", value: 0.3 },
|
|
38
|
+
"name:sound": { weight: "weak", value: 0.25 },
|
|
39
|
+
domain: { weight: "weak", value: 0.2 },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** The kinds a key may start with, longest first, so `name:initial:x` is not read as `name`. */
|
|
43
|
+
const KIND_PREFIXES = (Object.keys(KEY_KINDS) as KeyKind[]).sort((a, b) => b.length - a.length);
|
|
44
|
+
|
|
45
|
+
/** The kind of a block key; `null` for a string that is no block key. */
|
|
46
|
+
export function keyKind(key: string): KeyKind | null {
|
|
47
|
+
return KIND_PREFIXES.find((kind) => key.startsWith(`${kind}:`)) ?? null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The weight of a block key; `null` for a string that is no block key. */
|
|
51
|
+
export function keyWeight(key: string): KeyWeight | null {
|
|
52
|
+
const kind = keyKind(key);
|
|
53
|
+
return kind ? KEY_KINDS[kind].weight : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const isStrong = (key: string) => keyWeight(key) === "strong";
|
|
57
|
+
|
|
58
|
+
// ---- case, width and diacritics ------------------------------------------------
|
|
59
|
+
|
|
60
|
+
/** Letters that no Unicode decomposition maps to a base letter. */
|
|
61
|
+
const SPECIAL: Record<string, string> = {
|
|
62
|
+
ß: "ss",
|
|
63
|
+
ẞ: "ss",
|
|
64
|
+
æ: "ae",
|
|
65
|
+
Æ: "ae",
|
|
66
|
+
œ: "oe",
|
|
67
|
+
Œ: "oe",
|
|
68
|
+
ø: "o",
|
|
69
|
+
Ø: "o",
|
|
70
|
+
ł: "l",
|
|
71
|
+
Ł: "l",
|
|
72
|
+
đ: "d",
|
|
73
|
+
Đ: "d",
|
|
74
|
+
ð: "d",
|
|
75
|
+
Ð: "d",
|
|
76
|
+
þ: "th",
|
|
77
|
+
Þ: "th",
|
|
78
|
+
ı: "i",
|
|
79
|
+
ħ: "h",
|
|
80
|
+
Ħ: "h",
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Case, width and diacritics: Unicode NFKC, the marks removed, lower case.
|
|
85
|
+
* `MIRA` is `mira`, `Müller` is `muller`, `Søren` is `soren`.
|
|
86
|
+
*/
|
|
87
|
+
export function fold(text: string): string {
|
|
88
|
+
let out = "";
|
|
89
|
+
for (const c of text.normalize("NFKC")) out += SPECIAL[c] ?? c;
|
|
90
|
+
return out
|
|
91
|
+
.normalize("NFD")
|
|
92
|
+
.replace(/\p{M}+/gu, "")
|
|
93
|
+
.toLowerCase()
|
|
94
|
+
.normalize("NFC");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const UMLAUTS: Record<string, string> = { ä: "ae", ö: "oe", ü: "ue", Ä: "Ae", Ö: "Oe", Ü: "Ue" };
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The second form of a text with German umlauts: `Müller` is `mueller`.
|
|
101
|
+
* `null` when the text has no umlaut.
|
|
102
|
+
*/
|
|
103
|
+
export function umlautFold(text: string): string | null {
|
|
104
|
+
const nfc = text.normalize("NFC");
|
|
105
|
+
if (!/[äöüÄÖÜ]/.test(nfc)) return null;
|
|
106
|
+
return fold(nfc.replace(/[äöüÄÖÜ]/g, (c) => UMLAUTS[c]!));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---- names ---------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
/** Titles, dropped from a name that keeps another word. */
|
|
112
|
+
export const TITLES = new Set([
|
|
113
|
+
"mr",
|
|
114
|
+
"mrs",
|
|
115
|
+
"ms",
|
|
116
|
+
"miss",
|
|
117
|
+
"mx",
|
|
118
|
+
"dr",
|
|
119
|
+
"prof",
|
|
120
|
+
"sir",
|
|
121
|
+
"dame",
|
|
122
|
+
"frau",
|
|
123
|
+
"herr",
|
|
124
|
+
"fr",
|
|
125
|
+
"hr",
|
|
126
|
+
"mme",
|
|
127
|
+
"mlle",
|
|
128
|
+
"sr",
|
|
129
|
+
"sra",
|
|
130
|
+
"srta",
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
/** Legal forms, dropped from a name that keeps another word: the list of the name index. */
|
|
134
|
+
export const LEGAL_FORMS = new Set([
|
|
135
|
+
"gmbh",
|
|
136
|
+
"mbh",
|
|
137
|
+
"ag",
|
|
138
|
+
"kg",
|
|
139
|
+
"kgaa",
|
|
140
|
+
"ohg",
|
|
141
|
+
"gbr",
|
|
142
|
+
"ug",
|
|
143
|
+
"ev",
|
|
144
|
+
"se",
|
|
145
|
+
"inc",
|
|
146
|
+
"incorporated",
|
|
147
|
+
"ltd",
|
|
148
|
+
"limited",
|
|
149
|
+
"llc",
|
|
150
|
+
"llp",
|
|
151
|
+
"lp",
|
|
152
|
+
"plc",
|
|
153
|
+
"corp",
|
|
154
|
+
"corporation",
|
|
155
|
+
"co",
|
|
156
|
+
"sa",
|
|
157
|
+
"sas",
|
|
158
|
+
"sarl",
|
|
159
|
+
"srl",
|
|
160
|
+
"spa",
|
|
161
|
+
"bv",
|
|
162
|
+
"nv",
|
|
163
|
+
"ab",
|
|
164
|
+
"oy",
|
|
165
|
+
"oyj",
|
|
166
|
+
"pty",
|
|
167
|
+
"pte",
|
|
168
|
+
]);
|
|
169
|
+
|
|
170
|
+
/** One word of a name, folded: its hyphen parts, and whether it is an initial (`M.`). */
|
|
171
|
+
export interface NameWord {
|
|
172
|
+
/** The parts of a hyphenated word (`tannenholt-ruiz` has `tannenholt` and `ruiz`); one part otherwise. */
|
|
173
|
+
parts: string[];
|
|
174
|
+
initial: boolean;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The words of a name, folded. Punctuation between words separates them;
|
|
179
|
+
* an apostrophe inside a word is removed (`O'Neil` is `oneil`); a hyphen
|
|
180
|
+
* splits a word into parts. Titles and legal forms are dropped unless they
|
|
181
|
+
* are all the name holds.
|
|
182
|
+
*/
|
|
183
|
+
export function nameWords(text: string, form: (text: string) => string = fold): NameWord[] {
|
|
184
|
+
const words = form(text)
|
|
185
|
+
.split(/[^\p{L}\p{N}'’\-‐‑]+/u)
|
|
186
|
+
.map((token) => token.replace(/^['’\-‐‑]+|['’\-‐‑]+$/g, "").replace(/['’]/g, ""))
|
|
187
|
+
.filter(Boolean)
|
|
188
|
+
.map((token) => {
|
|
189
|
+
const parts = token.split(/[-‐‑]+/).filter(Boolean);
|
|
190
|
+
return { parts, initial: parts.length === 1 && [...parts[0]!].length === 1 };
|
|
191
|
+
});
|
|
192
|
+
const kept = words.filter(
|
|
193
|
+
(w) => !(w.parts.length === 1 && (TITLES.has(w.parts[0]!) || LEGAL_FORMS.has(w.parts[0]!))),
|
|
194
|
+
);
|
|
195
|
+
return kept.length ? kept : words;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Given-name variants (`Bob` for `Robert`): each variant to its common form.
|
|
200
|
+
* The aliases people teach (5.5) add to it per thing.
|
|
201
|
+
*/
|
|
202
|
+
export const NICKNAMES: Record<string, string> = {
|
|
203
|
+
bob: "robert",
|
|
204
|
+
bobby: "robert",
|
|
205
|
+
rob: "robert",
|
|
206
|
+
robbie: "robert",
|
|
207
|
+
bert: "robert",
|
|
208
|
+
bill: "william",
|
|
209
|
+
billy: "william",
|
|
210
|
+
will: "william",
|
|
211
|
+
willy: "william",
|
|
212
|
+
liz: "elizabeth",
|
|
213
|
+
lizzie: "elizabeth",
|
|
214
|
+
beth: "elizabeth",
|
|
215
|
+
betty: "elizabeth",
|
|
216
|
+
eliza: "elizabeth",
|
|
217
|
+
kate: "katherine",
|
|
218
|
+
katie: "katherine",
|
|
219
|
+
kathy: "katherine",
|
|
220
|
+
cathy: "katherine",
|
|
221
|
+
catherine: "katherine",
|
|
222
|
+
mike: "michael",
|
|
223
|
+
mick: "michael",
|
|
224
|
+
jim: "james",
|
|
225
|
+
jimmy: "james",
|
|
226
|
+
jamie: "james",
|
|
227
|
+
tom: "thomas",
|
|
228
|
+
tommy: "thomas",
|
|
229
|
+
dick: "richard",
|
|
230
|
+
rick: "richard",
|
|
231
|
+
rich: "richard",
|
|
232
|
+
peggy: "margaret",
|
|
233
|
+
maggie: "margaret",
|
|
234
|
+
meg: "margaret",
|
|
235
|
+
jen: "jennifer",
|
|
236
|
+
jenny: "jennifer",
|
|
237
|
+
dan: "daniel",
|
|
238
|
+
danny: "daniel",
|
|
239
|
+
dave: "david",
|
|
240
|
+
joe: "joseph",
|
|
241
|
+
sam: "samuel",
|
|
242
|
+
ben: "benjamin",
|
|
243
|
+
nick: "nicholas",
|
|
244
|
+
chris: "christopher",
|
|
245
|
+
matt: "matthew",
|
|
246
|
+
andy: "andrew",
|
|
247
|
+
tony: "anthony",
|
|
248
|
+
steve: "steven",
|
|
249
|
+
stephen: "steven",
|
|
250
|
+
ed: "edward",
|
|
251
|
+
eddie: "edward",
|
|
252
|
+
ted: "edward",
|
|
253
|
+
fred: "frederick",
|
|
254
|
+
hans: "johannes",
|
|
255
|
+
sepp: "josef",
|
|
256
|
+
joseph: "josef",
|
|
257
|
+
kathi: "katharina",
|
|
258
|
+
uli: "ulrich",
|
|
259
|
+
susi: "susanne",
|
|
260
|
+
gabi: "gabriele",
|
|
261
|
+
basti: "sebastian",
|
|
262
|
+
flo: "florian",
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/** The common form of a given name, or the word itself. */
|
|
266
|
+
export const commonName = (word: string): string => NICKNAMES[word] ?? word;
|
|
267
|
+
|
|
268
|
+
/** A name in its sorted form: every word part, sorted, joined by spaces. `Tannenholt-Ruiz, Mira` is `mira ruiz tannenholt`. */
|
|
269
|
+
export function sortedName(words: NameWord[]): string {
|
|
270
|
+
return words
|
|
271
|
+
.flatMap((w) => w.parts)
|
|
272
|
+
.sort()
|
|
273
|
+
.join(" ");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The form two names compare by: folded, sorted, each nickname as its common form. */
|
|
277
|
+
export function normalName(text: string): string {
|
|
278
|
+
return sortedName(
|
|
279
|
+
nameWords(text).map((w) => ({ ...w, parts: w.parts.map((p) => commonName(p)) })),
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The initials forms (4.1): a full word part with the first letter of each other word. */
|
|
284
|
+
export function initialForms(words: NameWord[]): string[] {
|
|
285
|
+
if (words.length < 2) return [];
|
|
286
|
+
const out: string[] = [];
|
|
287
|
+
const firstLetter = (w: NameWord) => [...w.parts[0]!][0]!;
|
|
288
|
+
const considered = words.slice(0, 4);
|
|
289
|
+
considered.forEach((word, i) => {
|
|
290
|
+
if (word.initial) return;
|
|
291
|
+
for (const part of word.parts) {
|
|
292
|
+
if ([...part].length < 2) continue;
|
|
293
|
+
considered.forEach((other, j) => {
|
|
294
|
+
if (i !== j) out.push(`${part} ${firstLetter(other)}`);
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
return [...new Set(out)];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Cologne phonetics (Kölner Phonetik) of one word: a digit per sound, a
|
|
303
|
+
* repeated digit once, and no vowel code after the first place.
|
|
304
|
+
* `Müller` and `Mueller` are both `657`.
|
|
305
|
+
*/
|
|
306
|
+
export function cologne(word: string): string {
|
|
307
|
+
const s = fold(word).replace(/[^a-z]/g, "");
|
|
308
|
+
let codes = "";
|
|
309
|
+
for (let i = 0; i < s.length; i++) {
|
|
310
|
+
const c = s[i]!;
|
|
311
|
+
const prev = s[i - 1] ?? "";
|
|
312
|
+
const next = s[i + 1] ?? "";
|
|
313
|
+
const before = (letters: string) => next !== "" && letters.includes(next);
|
|
314
|
+
const after = (letters: string) => prev !== "" && letters.includes(prev);
|
|
315
|
+
if ("aeijouy".includes(c)) codes += "0";
|
|
316
|
+
else if (c === "b") codes += "1";
|
|
317
|
+
else if (c === "p") codes += before("h") ? "3" : "1";
|
|
318
|
+
else if (c === "d" || c === "t") codes += before("csz") ? "8" : "2";
|
|
319
|
+
else if ("fvw".includes(c)) codes += "3";
|
|
320
|
+
else if ("gkq".includes(c)) codes += "4";
|
|
321
|
+
else if (c === "c") {
|
|
322
|
+
if (i === 0) codes += before("ahkloqrux") ? "4" : "8";
|
|
323
|
+
else codes += !after("sz") && before("ahkoqux") ? "4" : "8";
|
|
324
|
+
} else if (c === "x") codes += after("ckq") ? "8" : "48";
|
|
325
|
+
else if (c === "l") codes += "5";
|
|
326
|
+
else if (c === "m" || c === "n") codes += "6";
|
|
327
|
+
else if (c === "r") codes += "7";
|
|
328
|
+
else if (c === "s" || c === "z") codes += "8";
|
|
329
|
+
}
|
|
330
|
+
let out = "";
|
|
331
|
+
for (const code of codes) if (out.at(-1) !== code) out += code;
|
|
332
|
+
return out ? out[0] + out.slice(1).replace(/0/g, "") : "";
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ---- keys, codes and addresses --------------------------------------------------
|
|
336
|
+
|
|
337
|
+
/** An email address: lower case, trimmed (the resolver's rule). `null` for a text that is no address. */
|
|
338
|
+
export function emailForm(text: string): string | null {
|
|
339
|
+
const value = text.trim().toLowerCase();
|
|
340
|
+
return /^[^\s@<>()[\],;:"]+@[^\s@<>()[\],;:"]+\.[a-z0-9-]{2,}$/.test(value) ? value : null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** A web address without the scheme, `www.`, the query and trailing slashes (the resolver's rule). */
|
|
344
|
+
export function webForm(text: string): string | null {
|
|
345
|
+
const value = text
|
|
346
|
+
.trim()
|
|
347
|
+
.toLowerCase()
|
|
348
|
+
.replace(/^[a-z][a-z0-9+.-]*:\/\//, "")
|
|
349
|
+
.replace(/^www\./, "")
|
|
350
|
+
.replace(/[?#].*$/, "")
|
|
351
|
+
.replace(/\/+$/, "");
|
|
352
|
+
return /^[a-z0-9-]+(\.[a-z0-9-]+)+(\/\S*)?$/.test(value) ? value : null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** A phone number: its digits, the last 9. `null` for fewer than 7 digits. */
|
|
356
|
+
export function phoneForm(text: string): string | null {
|
|
357
|
+
const digits = text.replace(/\D/g, "");
|
|
358
|
+
return digits.length >= 7 ? digits.slice(-9) : null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** A handle: lower case, without a leading `@`. */
|
|
362
|
+
export function handleForm(text: string): string | null {
|
|
363
|
+
const value = text.trim().toLowerCase().replace(/^@/, "");
|
|
364
|
+
return /^[a-z0-9_.-]{2,64}$/.test(value) ? value : null;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* The key a code matches by (`identifier_key` of link version 7): no
|
|
369
|
+
* whitespace, lower case, no dot between leading letters and a digit.
|
|
370
|
+
* `P 38`, `P.38` and `P38` are `p38`.
|
|
371
|
+
*/
|
|
372
|
+
export function codeForm(text: string): string | null {
|
|
373
|
+
const squashed = fold(text).replace(/\s+/g, "");
|
|
374
|
+
if (!squashed || !/\d/.test(squashed)) return null;
|
|
375
|
+
return squashed.replace(/^(\p{L}+)\.(?=\d)/u, "$1");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Mail domains many people share: an address there says nothing about an organization. */
|
|
379
|
+
export const FREE_MAIL = new Set([
|
|
380
|
+
"gmail.com",
|
|
381
|
+
"googlemail.com",
|
|
382
|
+
"outlook.com",
|
|
383
|
+
"hotmail.com",
|
|
384
|
+
"live.com",
|
|
385
|
+
"yahoo.com",
|
|
386
|
+
"icloud.com",
|
|
387
|
+
"me.com",
|
|
388
|
+
"aol.com",
|
|
389
|
+
"proton.me",
|
|
390
|
+
"protonmail.com",
|
|
391
|
+
"gmx.de",
|
|
392
|
+
"gmx.net",
|
|
393
|
+
"web.de",
|
|
394
|
+
"t-online.de",
|
|
395
|
+
"mail.com",
|
|
396
|
+
]);
|
|
397
|
+
|
|
398
|
+
/** The domain of an email address or a web address; `null` for a free mail domain. */
|
|
399
|
+
export function domainOf(value: string): string | null {
|
|
400
|
+
const host = value.includes("@") ? value.split("@")[1]! : value.split("/")[0]!;
|
|
401
|
+
const domain = host.replace(/^www\./, "");
|
|
402
|
+
return FREE_MAIL.has(domain) ? null : domain;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ---- block keys ----------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
/** What a span is, for its keys: a name, a key of one kind, or a span of free text read as either. */
|
|
408
|
+
export type SpanKind = "name" | "email" | "phone" | "web" | "handle" | "code" | "text";
|
|
409
|
+
|
|
410
|
+
/** The keys of a name: the sorted name, the initials forms, each part and its sound, in each form of the text. */
|
|
411
|
+
function nameKeys(text: string): string[] {
|
|
412
|
+
const forms = [fold(text), umlautFold(text)].filter((f): f is string => f !== null);
|
|
413
|
+
const keys: string[] = [];
|
|
414
|
+
for (const form of forms) {
|
|
415
|
+
const words = nameWords(form, (t) => t);
|
|
416
|
+
if (!words.length) continue;
|
|
417
|
+
const variants = [words];
|
|
418
|
+
const common = words.map((w) => ({ ...w, parts: w.parts.map((p) => commonName(p)) }));
|
|
419
|
+
if (sortedName(common) !== sortedName(words)) variants.push(common);
|
|
420
|
+
for (const variant of variants) {
|
|
421
|
+
if (!variant.some((w) => w.initial)) keys.push(`name:${sortedName(variant)}`);
|
|
422
|
+
for (const initials of initialForms(variant)) keys.push(`name:initial:${initials}`);
|
|
423
|
+
}
|
|
424
|
+
for (const word of words)
|
|
425
|
+
for (const part of word.parts) {
|
|
426
|
+
if ([...part].length < 3 || !/\p{L}/u.test(part)) continue;
|
|
427
|
+
keys.push(`name:part:${part}`);
|
|
428
|
+
const sound = cologne(part);
|
|
429
|
+
if (sound.length >= 2) keys.push(`name:sound:${sound}`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return [...new Set(keys)];
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Whether a span of free text is written as a key of this kind. */
|
|
436
|
+
export function looksLike(span: string, kind: "email" | "web" | "phone" | "code"): boolean {
|
|
437
|
+
const text = span.trim();
|
|
438
|
+
if (kind === "email") return emailForm(text) !== null;
|
|
439
|
+
if (kind === "web")
|
|
440
|
+
return /^(https?:\/\/|www\.)/i.test(text) && !text.includes("@") && webForm(text) !== null;
|
|
441
|
+
// A phone number in text starts with `+`: a date or an amount is no phone number.
|
|
442
|
+
if (kind === "phone") return /^\+[\d ()./-]{7,}$/.test(text) && phoneForm(text) !== null;
|
|
443
|
+
// A code: letters and digits in one token (`P38`, `PX-200`), or letters and a number (`P 38`).
|
|
444
|
+
return (
|
|
445
|
+
text.length <= 24 && /^(?=.*\d)(?=.*\p{L})[\p{L}\d][\p{L}\d.\-/]*( \d[\d.]*)?$/u.test(text)
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* The block keys of a span. A key field gives its strong key (and an email
|
|
451
|
+
* or a web address its domain); a name gives its name keys; a span of free
|
|
452
|
+
* text is read as an email, a web address, a phone number or a code when it
|
|
453
|
+
* is written as one, else as a name. `aliases` are the names people taught
|
|
454
|
+
* the mention's thing (`alias:<normal name>`).
|
|
455
|
+
*/
|
|
456
|
+
export function blockKeys(span: string, kind: SpanKind, aliases: string[] = []): string[] {
|
|
457
|
+
const keys: string[] = [];
|
|
458
|
+
const add = (key: string | null) => {
|
|
459
|
+
if (key && !keys.includes(key)) keys.push(key);
|
|
460
|
+
};
|
|
461
|
+
const strong = (k: Exclude<SpanKind, "name" | "text">) => {
|
|
462
|
+
const value =
|
|
463
|
+
k === "email"
|
|
464
|
+
? emailForm(span)
|
|
465
|
+
: k === "web"
|
|
466
|
+
? webForm(span)
|
|
467
|
+
: k === "phone"
|
|
468
|
+
? phoneForm(span)
|
|
469
|
+
: k === "handle"
|
|
470
|
+
? handleForm(span)
|
|
471
|
+
: codeForm(span);
|
|
472
|
+
if (!value) return;
|
|
473
|
+
add(`${k}:${value}`);
|
|
474
|
+
if (k === "email" || k === "web") {
|
|
475
|
+
const domain = domainOf(value);
|
|
476
|
+
if (domain) add(`domain:${domain}`);
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
if (kind === "name") nameKeys(span).forEach(add);
|
|
480
|
+
else if (kind === "text") {
|
|
481
|
+
const parsed = (["email", "web", "phone", "code"] as const).find((k) => looksLike(span, k));
|
|
482
|
+
if (parsed) strong(parsed);
|
|
483
|
+
else nameKeys(span).forEach(add);
|
|
484
|
+
} else strong(kind);
|
|
485
|
+
for (const alias of aliases) {
|
|
486
|
+
const name = normalName(alias);
|
|
487
|
+
if (name) add(`alias:${name}`);
|
|
488
|
+
}
|
|
489
|
+
return keys;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** The block keys a mention keeps as graph facts, at most. */
|
|
493
|
+
export const MAX_KEYS = 16;
|
|
494
|
+
|
|
495
|
+
/** The keys of a span to keep: strong first, then medium, then weak, at most {@link MAX_KEYS}. */
|
|
496
|
+
export function keptKeys(keys: string[]): string[] {
|
|
497
|
+
const rank = (key: string) => ({ strong: 0, medium: 1, weak: 2 })[keyWeight(key) ?? "weak"];
|
|
498
|
+
return [...keys].sort((a, b) => rank(a) - rank(b)).slice(0, MAX_KEYS);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// ---- similarity ----------------------------------------------------------------
|
|
502
|
+
|
|
503
|
+
/** Jaro-Winkler similarity of two strings, 0 to 1. */
|
|
504
|
+
export function jaroWinkler(a: string, b: string): number {
|
|
505
|
+
if (a === b) return 1;
|
|
506
|
+
const x = [...a];
|
|
507
|
+
const y = [...b];
|
|
508
|
+
if (!x.length || !y.length) return 0;
|
|
509
|
+
const window = Math.max(0, Math.floor(Math.max(x.length, y.length) / 2) - 1);
|
|
510
|
+
const xMatched = new Array<boolean>(x.length).fill(false);
|
|
511
|
+
const yMatched = new Array<boolean>(y.length).fill(false);
|
|
512
|
+
let matches = 0;
|
|
513
|
+
for (let i = 0; i < x.length; i++)
|
|
514
|
+
for (let j = Math.max(0, i - window); j < Math.min(y.length, i + window + 1); j++)
|
|
515
|
+
if (!yMatched[j] && x[i] === y[j]) {
|
|
516
|
+
xMatched[i] = yMatched[j] = true;
|
|
517
|
+
matches++;
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
520
|
+
if (!matches) return 0;
|
|
521
|
+
let transpositions = 0;
|
|
522
|
+
for (let i = 0, j = 0; i < x.length; i++) {
|
|
523
|
+
if (!xMatched[i]) continue;
|
|
524
|
+
while (!yMatched[j]) j++;
|
|
525
|
+
if (x[i] !== y[j]) transpositions++;
|
|
526
|
+
j++;
|
|
527
|
+
}
|
|
528
|
+
const jaro =
|
|
529
|
+
(matches / x.length + matches / y.length + (matches - transpositions / 2) / matches) / 3;
|
|
530
|
+
let prefix = 0;
|
|
531
|
+
while (prefix < 4 && x[prefix] !== undefined && x[prefix] === y[prefix]) prefix++;
|
|
532
|
+
return jaro + prefix * 0.1 * (1 - jaro);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** 1 for equal strings, 0 for strings with nothing in common: normalized Levenshtein. */
|
|
536
|
+
export function levenshteinRatio(a: string, b: string): number {
|
|
537
|
+
const x = [...a];
|
|
538
|
+
const y = [...b];
|
|
539
|
+
const longest = Math.max(x.length, y.length);
|
|
540
|
+
if (!longest) return 1;
|
|
541
|
+
let previous = Array.from({ length: y.length + 1 }, (_, i) => i);
|
|
542
|
+
for (let i = 1; i <= x.length; i++) {
|
|
543
|
+
const current = [i];
|
|
544
|
+
for (let j = 1; j <= y.length; j++)
|
|
545
|
+
current[j] = Math.min(
|
|
546
|
+
previous[j]! + 1,
|
|
547
|
+
current[j - 1]! + 1,
|
|
548
|
+
previous[j - 1]! + (x[i - 1] === y[j - 1] ? 0 : 1),
|
|
549
|
+
);
|
|
550
|
+
previous = current;
|
|
551
|
+
}
|
|
552
|
+
return 1 - previous[y.length]! / longest;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* How alike two names are, 0 to 1. Each word part of the shorter name is
|
|
557
|
+
* matched to its best part of the other: an initial agrees with a part of
|
|
558
|
+
* that first letter (0.8), a nickname with its common form (0.95), other
|
|
559
|
+
* parts by Jaro-Winkler. The score is the mean of the matches.
|
|
560
|
+
*/
|
|
561
|
+
export function nameSimilarity(a: string, b: string): number {
|
|
562
|
+
const parts = (text: string) =>
|
|
563
|
+
nameWords(text).flatMap((w) => w.parts.map((part) => ({ part, initial: w.initial })));
|
|
564
|
+
let [x, y] = [parts(a), parts(b)];
|
|
565
|
+
if (!x.length || !y.length) return 0;
|
|
566
|
+
if (x.length > y.length) [x, y] = [y, x];
|
|
567
|
+
const used = new Set<number>();
|
|
568
|
+
let total = 0;
|
|
569
|
+
for (const word of x) {
|
|
570
|
+
let best = 0;
|
|
571
|
+
let at = -1;
|
|
572
|
+
y.forEach((other, j) => {
|
|
573
|
+
if (used.has(j)) return;
|
|
574
|
+
const score =
|
|
575
|
+
word.initial || other.initial
|
|
576
|
+
? word.part[0] === other.part[0]
|
|
577
|
+
? 0.8
|
|
578
|
+
: 0
|
|
579
|
+
: word.part === other.part
|
|
580
|
+
? 1
|
|
581
|
+
: commonName(word.part) === commonName(other.part)
|
|
582
|
+
? 0.95
|
|
583
|
+
: jaroWinkler(word.part, other.part);
|
|
584
|
+
if (score > best) [best, at] = [score, j];
|
|
585
|
+
});
|
|
586
|
+
if (at >= 0) used.add(at);
|
|
587
|
+
total += best;
|
|
588
|
+
}
|
|
589
|
+
return total / x.length;
|
|
590
|
+
}
|