@mailwoman/record 7.2.0 → 7.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/address.ts ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * The canonical address record — the matcher's unit of address identity, and the canonical record
7
+ * the organization and contact records build on.
8
+ *
9
+ * It is plain data: parser components + the formatter's match key + an optional resolved geocode,
10
+ * composed into one object. No ORM, no decorators, no schema-generation machinery — if we need a
11
+ * database we reach for Kysely at the call site, not a model layer here.
12
+ *
13
+ * The geocode fields mirror mailwoman's `GeocodeResult` (tier + calibrated uncertainty + hierarchy)
14
+ * on purpose: that is the location signal the Fellegi-Sunter scorer weights its distance evidence
15
+ * by — two records sharing a `address_point` coordinate is strong agreement; sharing an
16
+ * `interpolated` centroid is weak; a PO-box / multi-unit coordinate is barely location agreement
17
+ * at all (the NAACCR precedent, see the geocode-first record-matching concept doc).
18
+ */
19
+
20
+ import { type ComponentDict, type FormatAddressOptions, canonicalKey, formatAddress } from "@mailwoman/formatter"
21
+
22
+ /** A geographic coordinate (WGS84 decimal degrees). */
23
+ export interface GeoCoordinate {
24
+ latitude: number
25
+ longitude: number
26
+ }
27
+
28
+ /**
29
+ * The resolution tier that produced a coordinate, mirroring mailwoman's geocoder (`address_point` > `interpolated` >
30
+ * `street` > `admin`). Kept as a local plain union so this package stays decoupled from the heavy geocoder runtime; a
31
+ * `GeocodeResult.resolution_tier` maps in directly. (`street` = a street centroid for a street-only query, #1042 —
32
+ * coarser than a house-number estimate, finer than an admin centroid.)
33
+ */
34
+ export type ResolutionTier = "address_point" | "interpolated" | "street" | "admin"
35
+
36
+ /** One resolved admin-hierarchy ancestor (most specific first), for spelling-invariant blocking. */
37
+ export interface HierarchyNode {
38
+ tag: string
39
+ value: string
40
+ placeID?: string
41
+ }
42
+
43
+ /** A resolved geocode attached to an address record — the location signal the matcher scores on. */
44
+ export interface AddressGeocode {
45
+ coordinate: GeoCoordinate
46
+ tier: ResolutionTier
47
+ /** Calibrated uncertainty radius in meters; `null` for the admin tier (no sub-locality estimate). */
48
+ uncertaintyMeters: number | null
49
+ /** Resolved admin hierarchy, locality → country (most specific first). */
50
+ hierarchy?: HierarchyNode[]
51
+ /** A delivery point, not a building — weakens location agreement even at a precise coordinate. */
52
+ poBox?: boolean
53
+ /** A multi-unit building where many records share one coordinate — weakens unit-level agreement. */
54
+ multiUnit?: boolean
55
+ }
56
+
57
+ /**
58
+ * The canonical address record. Composes the parser's components, the formatter's match key, an optional human-readable
59
+ * form, and an optional resolved geocode. Plain data — no behavior.
60
+ */
61
+ export interface PostalAddress {
62
+ /** Parsed address components (`ComponentTag`-keyed). */
63
+ components: ComponentDict
64
+ /** Normalized, deterministic match key for blocking (from `@mailwoman/formatter`). */
65
+ canonicalKey: string
66
+ /** Optional human-readable single-line form, for display. */
67
+ formatted?: string
68
+ /** Resolved location, when geocoded. */
69
+ geocode?: AddressGeocode
70
+ /** The original free-text input, when known (provenance). */
71
+ raw?: string
72
+ }
73
+
74
+ /** Options for {@linkcode toPostalAddress}. */
75
+ export interface ToPostalAddressOptions {
76
+ /** Country (ISO-2 or name) for formatting. Defaults to the `country` component, else unset. */
77
+ country?: string
78
+ /** The original free-text input to retain as provenance. */
79
+ raw?: string
80
+ /** Also compute a human-readable `formatted` string. Default `true`. */
81
+ format?: boolean
82
+ /** Formatting options forwarded to the formatter. Defaults to single-line (`", "`). */
83
+ formatOptions?: FormatAddressOptions
84
+ }
85
+
86
+ /**
87
+ * Build a canonical {@linkcode PostalAddress} from parsed components: fills the match key (always) and a human-readable
88
+ * form (unless disabled). Attach a geocode separately with {@linkcode withGeocode} once the address is resolved.
89
+ */
90
+ export function toPostalAddress(components: ComponentDict, opts: ToPostalAddressOptions = {}): PostalAddress {
91
+ const country = opts.country ?? components.country ?? ""
92
+
93
+ const record: PostalAddress = {
94
+ components,
95
+ canonicalKey: canonicalKey(components),
96
+ }
97
+
98
+ if (opts.raw !== undefined) {
99
+ record.raw = opts.raw
100
+ }
101
+
102
+ if (opts.format !== false) {
103
+ const formatted = formatAddress(components, country, opts.formatOptions ?? { separator: ", " })
104
+
105
+ if (formatted) {
106
+ record.formatted = formatted
107
+ }
108
+ }
109
+
110
+ return record
111
+ }
112
+
113
+ /** Attach (or replace) a resolved geocode on an address record, returning a new record. */
114
+ export function withGeocode(record: PostalAddress, geocode: AddressGeocode): PostalAddress {
115
+ return { ...record, geocode }
116
+ }
package/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `@mailwoman/record` — the canonicalize layer for the geocode-first matcher.
7
+ *
8
+ * Address-first: {@linkcode PostalAddress} is the canonical record. The per-field normalizers
9
+ * ({@linkcode parsePersonName}, {@linkcode canonicalizeOrganizationName}) build on the same
10
+ * plain-data pattern. Contact records and the comparator/Fellegi-Sunter layer land in the
11
+ * matcher.
12
+ */
13
+
14
+ export * from "./address.ts"
15
+ export * from "./name.ts"
16
+ export * from "./organization.ts"
package/name.ts ADDED
@@ -0,0 +1,303 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Person-name parsing — split a full name into components for the matcher to canonicalize and
7
+ * compare.
8
+ *
9
+ * A rule-based positional parser, the portable recipe from `python-nameparser`: split on a comma to
10
+ * detect `Last, First` inversion, then classify tokens by position against configurable title /
11
+ * suffix / particle lists. We deliberately store the surname **particle** (`van`, `de la`, `von`)
12
+ * separately from the bare surname (the `theiconic/name-parser` pattern) so the matcher can
13
+ * compare `Vega` independent of `de la` — sources that drop or vary the particle still match.
14
+ *
15
+ * Scope + honesty (per the name-canonicalization research pass):
16
+ *
17
+ * - Western / romanized names only. Cultural given-family ORDER variation (East-Asian family-first)
18
+ * and transliteration are not handled here — a documented follow-up.
19
+ * - Nickname → canonical-root mapping is intentionally NOT done at parse time: it is lossy and
20
+ * gendered (Bobbie → Robert _or_ Roberta), so equivalence belongs in the matcher as a fuzzy
21
+ * agreement level, not a destructive rewrite. We only _extract_ a parenthetical/quoted
22
+ * nickname.
23
+ * - A CRF parser (probablepeople) is the gold-standard reference but too heavy to port; this
24
+ * positional parser covers the documented hard cases (inversion, particles, generational +
25
+ * professional suffixes) without a model.
26
+ */
27
+
28
+ /** A parsed person name. All fields optional — the parser fills what it can identify. */
29
+ export interface PersonName {
30
+ /** Title / salutation that preceded the name (`Dr`, `Mr`, `Capt`). */
31
+ prefix?: string
32
+ /** First / given name. */
33
+ given?: string
34
+ /** Middle name(s) or initial. */
35
+ middle?: string
36
+ /** Surname, _without_ any particle (`Vega`, not `de la Vega`). */
37
+ family?: string
38
+ /** Surname particle, stored separately (`de la`, `van der`, `von`). */
39
+ familyParticle?: string
40
+ /** Generational or professional suffix (`Jr`, `III`, `PhD`, `MD`). */
41
+ suffix?: string
42
+ /** A parenthetical or quoted nickname (`"Gob"` in `George "Gob" Bluth`). */
43
+ nickname?: string
44
+ }
45
+
46
+ /** Titles / salutations that lead a name. Matched case-insensitively, trailing `.` ignored. */
47
+ const TITLES = new Set([
48
+ "airman",
49
+ "br",
50
+ "brig",
51
+ "brigadier",
52
+ "capt",
53
+ "captain",
54
+ "cmdr",
55
+ "col",
56
+ "colonel",
57
+ "commander",
58
+ "commissioner",
59
+ "cpl",
60
+ "cpt",
61
+ "dep",
62
+ "deputy",
63
+ "doctor",
64
+ "dr",
65
+ "father",
66
+ "fr",
67
+ "gen",
68
+ "general",
69
+ "hon",
70
+ "honorable",
71
+ "judge",
72
+ "lt",
73
+ "ltcol",
74
+ "ltgen",
75
+ "maj",
76
+ "major",
77
+ "master",
78
+ "miss",
79
+ "mr",
80
+ "mrs",
81
+ "ms",
82
+ "mx",
83
+ "pastor",
84
+ "pfc",
85
+ "pres",
86
+ "president",
87
+ "private",
88
+ "prof",
89
+ "professor",
90
+ "pvt",
91
+ "rabbi",
92
+ "rep",
93
+ "representative",
94
+ "rev",
95
+ "reverend",
96
+ "sen",
97
+ "senator",
98
+ "sgt",
99
+ "sir",
100
+ "sister",
101
+ ])
102
+
103
+ /** Generational + professional suffixes that trail a name. */
104
+ const SUFFIXES = new Set([
105
+ // generational
106
+ "jr",
107
+ "sr",
108
+ "i",
109
+ "ii",
110
+ "iii",
111
+ "iv",
112
+ "v",
113
+ "vi",
114
+ "vii",
115
+ "viii",
116
+ // professional / honorific
117
+ "phd",
118
+ "md",
119
+ "do",
120
+ "dds",
121
+ "dmd",
122
+ "dvm",
123
+ "esq",
124
+ "esquire",
125
+ "jd",
126
+ "llm",
127
+ "cpa",
128
+ "rn",
129
+ "lpn",
130
+ "pa",
131
+ "pe",
132
+ "od",
133
+ "dc",
134
+ "dpm",
135
+ "psyd",
136
+ "edd",
137
+ "mba",
138
+ "mfa",
139
+ "msw",
140
+ "pharmd",
141
+ ])
142
+
143
+ /**
144
+ * Surname particles. Consecutive particles fold together (`de` + `la` → `de la`), and the next non-particle token
145
+ * begins the bare surname.
146
+ */
147
+ const PARTICLES = new Set([
148
+ "al",
149
+ "bin",
150
+ "da",
151
+ "das",
152
+ "de",
153
+ "del",
154
+ "della",
155
+ "den",
156
+ "der",
157
+ "di",
158
+ "do",
159
+ "dos",
160
+ "du",
161
+ "el",
162
+ "ibn",
163
+ "la",
164
+ "le",
165
+ "lo",
166
+ "mac",
167
+ "mc",
168
+ "san",
169
+ "santa",
170
+ "st",
171
+ "ter",
172
+ "van",
173
+ "vande",
174
+ "vanden",
175
+ "vander",
176
+ "vere",
177
+ "von",
178
+ "zu",
179
+ "zur",
180
+ ])
181
+
182
+ const isPresent = (s: string | undefined | null): s is string => typeof s === "string" && s.trim().length > 0
183
+ const norm = (token: string): string => token.replace(/\.$/, "").toLowerCase()
184
+ const countChar = (s: string, c: string): number => s.split(c).length - 1
185
+
186
+ /**
187
+ * Parse a full name into components. Returns `null` for empty input. Best-effort and non-throwing — ambiguous input
188
+ * degrades gracefully rather than erroring.
189
+ */
190
+ export function parsePersonName(input: string | null | undefined): PersonName | null {
191
+ if (!isPresent(input)) return null
192
+
193
+ const result: PersonName = {}
194
+
195
+ // 1. Extract a parenthetical "(Jim)" or quoted "Jim" nickname, then strip it out.
196
+ let working = input
197
+ .replace(/\s*\(([^)]+)\)\s*/g, (_m, n: string) => {
198
+ if (!result.nickname) {
199
+ result.nickname = n.trim()
200
+ }
201
+
202
+ return " "
203
+ })
204
+ .replace(/\s*"([^"]+)"\s*/g, (_m, n: string) => {
205
+ if (!result.nickname) {
206
+ result.nickname = n.trim()
207
+ }
208
+
209
+ return " "
210
+ })
211
+ .replace(/\s+/g, " ")
212
+ .trim()
213
+
214
+ // 2. Resolve a single comma: "Last, First" inversion, unless the tail is a known suffix
215
+ // ("John Smith, Jr."), in which case keep order and treat the tail as a suffix.
216
+ if (countChar(working, ",") === 1) {
217
+ const [head, tail] = working.split(",").map((p) => p.trim())
218
+
219
+ if (tail && tail.split(/\s+/).every((t) => SUFFIXES.has(norm(t)))) {
220
+ result.suffix = tail
221
+ working = head!
222
+ } else if (head && tail) {
223
+ working = `${tail} ${head}`
224
+ }
225
+ }
226
+
227
+ const tokens = working.split(/\s+/).filter(Boolean)
228
+
229
+ if (tokens.length === 0) return Object.keys(result).length ? result : null
230
+
231
+ // 3. Leading titles → prefix.
232
+ const prefixParts: string[] = []
233
+
234
+ while (tokens.length > 1 && TITLES.has(norm(tokens[0]!))) {
235
+ prefixParts.push(tokens.shift()!)
236
+ }
237
+
238
+ if (prefixParts.length) {
239
+ result.prefix = prefixParts.join(" ")
240
+ }
241
+
242
+ // 4. Trailing suffixes → suffix (a single name token must remain).
243
+ const suffixParts: string[] = []
244
+
245
+ while (tokens.length > 1 && SUFFIXES.has(norm(tokens[tokens.length - 1]!))) {
246
+ suffixParts.unshift(tokens.pop()!)
247
+ }
248
+
249
+ if (suffixParts.length) {
250
+ result.suffix = isPresent(result.suffix) ? `${suffixParts.join(" ")} ${result.suffix}` : suffixParts.join(" ")
251
+ }
252
+
253
+ if (tokens.length === 0) return result
254
+
255
+ // 5. Locate the surname particle run; everything from it onward is the (particled) surname.
256
+ let particleStart = -1
257
+
258
+ for (let i = 0; i < tokens.length; i++) {
259
+ // A particle only starts a surname if a bare-surname token follows it.
260
+ if (PARTICLES.has(norm(tokens[i]!)) && i < tokens.length - 1) {
261
+ particleStart = i
262
+ break
263
+ }
264
+ }
265
+
266
+ if (particleStart >= 0) {
267
+ let i = particleStart
268
+ const particleParts: string[] = []
269
+
270
+ while (i < tokens.length - 1 && PARTICLES.has(norm(tokens[i]!))) {
271
+ particleParts.push(tokens[i]!)
272
+ i++
273
+ }
274
+ result.familyParticle = particleParts.join(" ")
275
+ result.family = tokens.slice(i).join(" ")
276
+ const before = tokens.slice(0, particleStart)
277
+
278
+ if (before.length) {
279
+ result.given = before[0]
280
+ }
281
+
282
+ if (before.length > 1) {
283
+ result.middle = before.slice(1).join(" ")
284
+ }
285
+
286
+ return result
287
+ }
288
+
289
+ // 6. No particle: last token is the surname, first is given, the rest is middle.
290
+ if (tokens.length === 1) {
291
+ result.given = tokens[0]
292
+
293
+ return result
294
+ }
295
+ result.given = tokens[0]
296
+ result.family = tokens[tokens.length - 1]
297
+
298
+ if (tokens.length > 2) {
299
+ result.middle = tokens.slice(1, -1).join(" ")
300
+ }
301
+
302
+ return result
303
+ }
@@ -0,0 +1,267 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Organization-name canonicalization — reduce a company name to a stable, comparable key.
7
+ *
8
+ * Winkler's record-linkage recipe: words of little distinguishing power (the legal designation —
9
+ * `Corporation`, `Limited`, `LLC`) are normalized away before matching, so `Acme Corp` and `Acme
10
+ * Corporation, LLC` collapse to the same key. We also split off a `doing business as` clause and
11
+ * normalize connectives (`&` → `and`), punctuation, accents, and a leading `The`.
12
+ *
13
+ * **The collision problem (#668).** A legal-form token in one jurisdiction is a meaningful word in
14
+ * another domain. `PT` is Indonesia's `Perseroan Terbatas` (its LLC) — and US-healthcare
15
+ * shorthand for _Physical Therapy_. `SCA` / `SCS` are French/Belgian/Luxembourg commandite forms
16
+ * — and, in a clinic's name, _Sudden Cardiac Arrest_ / _Spinal Cord Stimulator_. A single
17
+ * universal strip-list can't be right for both: strip `PT` and you corrupt `Lakeside PT`; keep it
18
+ * and you leave the legal form on an Indonesian company. So the strip-set is computed on **two
19
+ * axes**:
20
+ *
21
+ * - **jurisdiction** (ISO 3166-1 alpha-2, e.g. from the resolved address country) — _adds_ the legal
22
+ * forms valid in that country. Collision-prone forms (`pt`, `sca`, `scs`) live here, gated
23
+ * behind a known jurisdiction, NOT in the universal base.
24
+ * - **domain** (an ingest-config tag, e.g. `healthcare`) — _protects_ domain-meaningful tokens from
25
+ * ever being stripped, even when a jurisdiction pack would add them. Domain protection wins.
26
+ *
27
+ * `effective = (base ∪ jurisdiction-pack) − domain-protect-pack`. With no options the set is the
28
+ * universal base and behavior is byte-for-byte unchanged — the new axes are strictly opt-in.
29
+ *
30
+ * Evidence honesty (per the name-canonicalization research pass): the PERSON-name side is well
31
+ * sourced; the ORGANIZATION side is a known evidence gap. This is a solid _canonicalization_
32
+ * baseline (the strip-designations principle is Winkler-grounded; the designation list draws on
33
+ * the ISO 20275 Entity Legal Forms register and `cleanco`). The jurisdiction/domain packs below
34
+ * are grounded seeds, not exhaustive — extend them per ISO 20275 as locales are added. The harder
35
+ * org-_matching_ problems — acronym ↔ expansion (`IBM` ↔ `International Business Machines`),
36
+ * DBA/alias resolution beyond the simple clause, subsidiary/parent, and TF-IDF n-gram token
37
+ * matching — are deferred to a follow-up (a dedicated org-matching research pass + the matcher
38
+ * epic).
39
+ */
40
+
41
+ /** A canonicalized organization name. */
42
+ export interface OrganizationName {
43
+ /** The original input, verbatim. */
44
+ raw: string
45
+ /** Normalized, designation-stripped key for blocking and comparison. */
46
+ canonical: string
47
+ /** Legal designations that were stripped (`llc`, `inc`, `gmbh`), in encounter order. */
48
+ designations: string[]
49
+ /** The `doing business as` / trade-name clause, canonicalized, when one was present. */
50
+ dba?: string
51
+ }
52
+
53
+ /**
54
+ * A domain pack name. Each protects the abbreviations that are meaningful in that domain from being stripped as legal
55
+ * forms (see {@link DOMAIN_PROTECTED}). `general` protects nothing — the explicit "no domain" choice. Add a pack here
56
+ * (and to {@link DOMAIN_PROTECTED}) per ingest domain.
57
+ */
58
+ export type DesignationDomain = "general" | "healthcare"
59
+
60
+ /**
61
+ * Context for {@link canonicalizeOrganizationName}. Omit both fields for the universal base behavior.
62
+ */
63
+ export interface CanonicalizeOptions {
64
+ /**
65
+ * ISO 3166-1 alpha-2 country code of the org's jurisdiction (typically the resolved address country). Adds that
66
+ * country's legal forms — including collision-prone ones gated out of the base — to the strip-set. Case-insensitive;
67
+ * unknown codes add nothing.
68
+ */
69
+ jurisdiction?: string
70
+ /**
71
+ * Ingest domain. Protects domain-meaningful abbreviations (e.g. `healthcare` protects `pt` / `sca` / `scs`) from
72
+ * being stripped, overriding any jurisdiction pack that would add them.
73
+ */
74
+ domain?: DesignationDomain
75
+ }
76
+
77
+ /**
78
+ * Universal legal-entity designations — the forms that are safe to strip regardless of jurisdiction or domain because
79
+ * they don't collide with common domain abbreviations. Normalized to lowercase with punctuation removed (so `L.L.C.` →
80
+ * `llc`). Drawn from the ISO 20275 register + `cleanco`'s common set. Stripped as whole tokens wherever they occur.
81
+ * Deliberately excludes name-meaningful words (`group`, `holdings`, `partners`, `associates`) AND the collision-prone
82
+ * forms (`pt`, `sca`, `scs`) — those last live in {@link JURISDICTION_DESIGNATIONS}, gated behind a known
83
+ * jurisdiction.
84
+ */
85
+ const BASE_DESIGNATIONS = new Set([
86
+ "inc",
87
+ "incorporated",
88
+ "corp",
89
+ "corporation",
90
+ "co",
91
+ "company",
92
+ "llc",
93
+ "lllp",
94
+ "llp",
95
+ "pllc",
96
+ "lp",
97
+ "ltd",
98
+ "limited",
99
+ "plc",
100
+ "pc",
101
+ "pa",
102
+ "ag",
103
+ "sa",
104
+ "sas",
105
+ "sarl",
106
+ "sl",
107
+ "gmbh",
108
+ "mbh",
109
+ "ug",
110
+ "bv",
111
+ "nv",
112
+ "oy",
113
+ "oyj",
114
+ "ab",
115
+ "as",
116
+ "asa",
117
+ "spa",
118
+ "srl",
119
+ "kg",
120
+ "kgaa",
121
+ "kk",
122
+ "pty",
123
+ "proprietary",
124
+ "bhd",
125
+ "sdn",
126
+ "cc",
127
+ "cv",
128
+ "ulc",
129
+ "aps",
130
+ "kft",
131
+ "zrt",
132
+ "doo",
133
+ "ood",
134
+ "ead",
135
+ // Belgian forms — safe to add to the base (no domain collision).
136
+ "bvba",
137
+ "sprl",
138
+ ])
139
+
140
+ /**
141
+ * Jurisdiction-gated legal forms (ISO 3166-1 alpha-2 → forms), added only when the jurisdiction is known. This is where
142
+ * the collision-prone tokens live: `pt` (Indonesia), `sca` / `scs` (French/Belgian/Luxembourg commandite forms).
143
+ * Stripping these is correct ONLY when we know the org's country — never in the universal base. Grounded seeds, not
144
+ * exhaustive; extend per ISO 20275.
145
+ */
146
+ const JURISDICTION_DESIGNATIONS: Record<string, readonly string[]> = {
147
+ ID: ["pt", "tbk", "ud"], // Perseroan Terbatas / Terbuka (listed) / Usaha Dagang
148
+ FR: ["sca", "scs", "sci", "eurl", "sasu", "snc"],
149
+ BE: ["sca", "scs"],
150
+ LU: ["sca", "scs"],
151
+ ES: ["scs"], // Sociedad en Comandita Simple
152
+ IT: ["sapa", "snc"], // S.a.p.a. (commandite par actions) / società in nome collettivo
153
+ }
154
+
155
+ /**
156
+ * Domain protect-sets (domain → tokens never stripped). Overrides any jurisdiction pack: a token here stays in the name
157
+ * even if the org's jurisdiction would treat it as a legal form. `healthcare` guards the clinical abbreviations that
158
+ * collide with gated legal forms — `pt` (Physical Therapy), `sca` (Sudden Cardiac Arrest), `scs` (Spinal Cord
159
+ * Stimulator) — plus a couple of always-clinical ones for future-proofing.
160
+ */
161
+ const DOMAIN_PROTECTED: Record<DesignationDomain, readonly string[]> = {
162
+ general: [],
163
+ healthcare: ["pt", "sca", "scs", "ot", "dpt"],
164
+ }
165
+
166
+ /**
167
+ * Compute the effective designation strip-set for the given context: `(base ∪ jurisdiction-pack) −
168
+ * domain-protect-pack`. Returns the shared base set unchanged when no context is given (the byte-stable default), so
169
+ * the common path allocates nothing.
170
+ */
171
+ function resolveDesignations(options?: CanonicalizeOptions): ReadonlySet<string> {
172
+ const jurisdiction = options?.jurisdiction?.trim().toUpperCase()
173
+ const jurisdictionPack = jurisdiction ? JURISDICTION_DESIGNATIONS[jurisdiction] : undefined
174
+ const protectPack = options?.domain ? DOMAIN_PROTECTED[options.domain] : undefined
175
+
176
+ if (!jurisdictionPack && !protectPack?.length) return BASE_DESIGNATIONS
177
+
178
+ const set = new Set(BASE_DESIGNATIONS)
179
+
180
+ if (jurisdictionPack) {
181
+ for (const token of jurisdictionPack) {
182
+ set.add(token)
183
+ }
184
+ }
185
+
186
+ if (protectPack) {
187
+ for (const token of protectPack) {
188
+ set.delete(token)
189
+ }
190
+ }
191
+
192
+ return set
193
+ }
194
+
195
+ /** Splits a `doing business as` / trade-name clause from a legal name. */
196
+ const DBA_PATTERN = /\s+(?:d\/b\/a|dba|doing business as|t\/a|trading as|a\/k\/a|aka|fka|f\/k\/a)\s+/i
197
+
198
+ /**
199
+ * Canonicalize one name fragment: lowercase, strip accents, connectives → `and`, drop punctuation, remove a leading
200
+ * `the`, strip legal designations, collapse whitespace. Returns the key plus the designations it removed.
201
+ */
202
+ function canonicalizeFragment(
203
+ fragment: string,
204
+ designationSet: ReadonlySet<string>
205
+ ): { canonical: string; designations: string[] } {
206
+ const normalized = fragment
207
+ .normalize("NFKD")
208
+ .replace(/[̀-ͯ]/g, "")
209
+ .toLowerCase()
210
+ // connective punctuation joins words rather than vanishing: "AT&T" → "at and t"
211
+ .replace(/&/g, " and ")
212
+ .replace(/\+/g, " and ")
213
+ // periods + apostrophes are intra-token, so remove (not space): "S.A." → "sa", "Macy's" → "macys"
214
+ .replace(/[.'’]/g, "")
215
+ .replace(/[^a-z0-9\s]/g, " ")
216
+ .replace(/\s+/g, " ")
217
+ .trim()
218
+ .replace(/^the\s+/, "")
219
+
220
+ const designations: string[] = []
221
+ const kept: string[] = []
222
+
223
+ for (const token of normalized.split(" ")) {
224
+ if (!token) continue
225
+
226
+ if (designationSet.has(token)) {
227
+ designations.push(token)
228
+ } else {
229
+ kept.push(token)
230
+ }
231
+ }
232
+
233
+ return { canonical: kept.join(" "), designations }
234
+ }
235
+
236
+ /**
237
+ * Canonicalize an organization name: split off any `doing business as` clause, then reduce the legal name to a
238
+ * designation-stripped key. Returns `null` for empty input.
239
+ *
240
+ * Pass {@link CanonicalizeOptions} to resolve the jurisdiction × domain collision (#668): a `jurisdiction` adds that
241
+ * country's legal forms, a `domain` protects its meaningful abbreviations. With no options the universal base set is
242
+ * used and the result is byte-for-byte the legacy behavior.
243
+ */
244
+ export function canonicalizeOrganizationName(
245
+ input: string | null | undefined,
246
+ options?: CanonicalizeOptions
247
+ ): OrganizationName | null {
248
+ if (typeof input !== "string" || !input.trim()) return null
249
+
250
+ const raw = input
251
+ const designationSet = resolveDesignations(options)
252
+ const [legalPart, ...dbaParts] = input.split(DBA_PATTERN)
253
+
254
+ const { canonical, designations } = canonicalizeFragment(legalPart ?? "", designationSet)
255
+
256
+ const result: OrganizationName = { raw, canonical, designations }
257
+
258
+ if (dbaParts.length) {
259
+ const dba = canonicalizeFragment(dbaParts.join(" "), designationSet).canonical
260
+
261
+ if (dba) {
262
+ result.dba = dba
263
+ }
264
+ }
265
+
266
+ return result
267
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mailwoman/record",
3
- "version": "7.2.0",
3
+ "version": "7.3.0",
4
4
  "description": "Lean, plain-TypeScript record schema + per-field normalizers for the geocode-first matcher. Address-first: the canonical PostalAddress record composes parser components, the formatter's match key, and a resolved geocode; organization + contact records build on the same canonical record.",
5
5
  "license": "AGPL-3.0-only OR LicenseRef-Commercial",
6
6
  "repository": {
@@ -12,37 +12,60 @@
12
12
  "out/**/*.js",
13
13
  "out/**/*.js.map",
14
14
  "out/**/*.d.ts",
15
- "out/**/*.d.ts.map"
15
+ "out/**/*.d.ts.map",
16
+ "*.ts",
17
+ "*.tsx",
18
+ "**/*.ts",
19
+ "**/*.tsx",
20
+ "!*.test.ts",
21
+ "!*.test.tsx",
22
+ "!**/*.test.ts",
23
+ "!**/*.test.tsx"
16
24
  ],
17
25
  "type": "module",
18
26
  "exports": {
19
27
  "./package.json": "./package.json",
20
28
  ".": {
21
- "node": "./index.ts",
22
- "default": "./out/index.js",
23
- "types": "./out/index.d.ts"
29
+ "types": "./out/index.d.ts",
30
+ "default": "./out/index.js"
24
31
  },
25
32
  "./address": {
26
- "node": "./address.ts",
27
- "default": "./out/address.js",
28
- "types": "./out/address.d.ts"
33
+ "types": "./out/address.d.ts",
34
+ "default": "./out/address.js"
29
35
  },
30
36
  "./name": {
31
- "node": "./name.ts",
32
- "default": "./out/name.js",
33
- "types": "./out/name.d.ts"
37
+ "types": "./out/name.d.ts",
38
+ "default": "./out/name.js"
34
39
  },
35
40
  "./organization": {
36
- "node": "./organization.ts",
37
- "default": "./out/organization.js",
38
- "types": "./out/organization.d.ts"
41
+ "types": "./out/organization.d.ts",
42
+ "default": "./out/organization.js"
39
43
  }
40
44
  },
41
45
  "publishConfig": {
42
- "access": "public"
46
+ "access": "public",
47
+ "exports": {
48
+ "./package.json": "./package.json",
49
+ ".": {
50
+ "types": "./out/index.d.ts",
51
+ "default": "./out/index.js"
52
+ },
53
+ "./address": {
54
+ "types": "./out/address.d.ts",
55
+ "default": "./out/address.js"
56
+ },
57
+ "./name": {
58
+ "types": "./out/name.d.ts",
59
+ "default": "./out/name.js"
60
+ },
61
+ "./organization": {
62
+ "types": "./out/organization.d.ts",
63
+ "default": "./out/organization.js"
64
+ }
65
+ }
43
66
  },
44
67
  "dependencies": {
45
- "@mailwoman/formatter": "7.2.0"
68
+ "@mailwoman/formatter": "7.3.0"
46
69
  },
47
70
  "devDependencies": {
48
71
  "@types/node": ">=26.1.1"