gedcom-ts 2026.5.0 → 2026.5.2

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/CHANGELOG.md CHANGED
@@ -2,7 +2,27 @@
2
2
 
3
3
  All notable changes of gedcom-ts
4
4
 
5
- ## [Unreleased]
5
+ ## [2026.5.2] - 2026-05-16
6
+
7
+ ### Added
8
+
9
+ - **Regroupement des lieux** : `groupActsBySimilarCity`, `actsNeedingGeocodeForCity` (alias géoloc), `similarCityKey` ; harmonisation et carte alignées sur `citiesAreSimilar` (plus de divergence `normalizeCityKey` seul).
10
+
11
+ ### Changed
12
+
13
+ - **`findHarmonizationClustersFromActs`** : union-find sur `citiesAreSimilar` (corrige le cas Pleurtuit 29+11 actes après géoloc complète).
14
+ - **`clusterKeyForCity`** : clé carte basée sur la localité principale (segment avant la virgule), alignée avec le regroupement par similarité.
15
+ - **README** : guide d’intégration géoloc restructuré (section dédiée, tableaux par cas d’usage) ; retrait de la documentation développement interne.
16
+
17
+ ## [2026.5.1] - 2026-05-15
18
+
19
+ ### Added
20
+
21
+ - **`src/geocode/`** : géocodage Nominatim (`GET /search?q=…&format=jsonv2`), contexte arbre (`inferGeocodeContext`), ranking (`rankGeocodeCandidates`), harmonisation des lieux (`findHarmonizationClusters`, `applyGeocodeCandidateToActs`), utilitaires ville (`normalizeCityKey`, `findCanonicalCityLabel`). `fetch` et `User-Agent` configurables pour les tests.
22
+
23
+ ### Changed
24
+
25
+ - **`getCityCoordinates`** : marqué `@deprecated` ; utilise la nouvelle API Nominatim (plus `search.php?city=` ni `XMLHttpRequest`).
6
26
 
7
27
  ## [2026.5.0] - 2026-05-15
8
28
 
package/README.md CHANGED
@@ -11,11 +11,18 @@
11
11
 
12
12
  A graphical demo showcasing the public API (import a `.ged` / `.zip`, browse the typed model, export back) is available at **[https://gedcomts.jaunet.me](https://gedcomts.jaunet.me)**
13
13
 
14
- ## Project
14
+ ## Contents
15
15
 
16
- - NPM package: [gedcom-ts](https://www.npmjs.com/package/gedcom-ts)
17
- - **Versioning (CalVer)** : releases use **`AAAA.M.micro`** (e.g. `2026.5.0` = May 2026). The npm `version`, `GEDCOM_LIBRARY_VERSION` (export `HEAD`.`SOUR`.`VERS`), and release branches share the same label. Older changelog entries still refer to semver (`2.1.0`, …). See [CHANGELOG.md](CHANGELOG.md).
18
- - **GEDCOM 7 roadmap** (spec gedcom.io, coverage matrix, prioritized gaps): [docs/GEDCOM7-roadmap.md](docs/GEDCOM7-roadmap.md)
16
+ - [Installation](#installation)
17
+ - [Quick start](#quick-start)
18
+ - [Geocoding places](#geocoding-places)
19
+ - [API reference](#api-reference)
20
+ - [Error handling](#error-handling)
21
+
22
+ ## Package
23
+
24
+ - NPM: [gedcom-ts](https://www.npmjs.com/package/gedcom-ts)
25
+ - Version format **CalVer** `AAAA.M.micro` (e.g. `2026.5.2` = May 2026). See [CHANGELOG.md](CHANGELOG.md).
19
26
 
20
27
  ## Installation
21
28
 
@@ -51,9 +58,134 @@ Typical workflow:
51
58
  2. read / mutate the typed `Person`, `Act`, `Place`, `Note`, `MultimediaFile` objects (directly or through `editPerson` / `editAct` / `editPlace` / …)
52
59
  3. export as `.ged` (`ExportGedcomFile`) or `.zip` (`ExportGedzipFile`)
53
60
 
54
- ## Public API reference
61
+ ## Geocoding places
62
+
63
+ Use this section when your app needs **GPS coordinates** on event places, a **list of cities still without coordinates**, **map markers**, or a **data-quality** view for inconsistent place names.
64
+
65
+ You work with a `ReadGed` (after import) and a flat `Act[]` built from every person’s events (see [Prepare act list](#prepare-act-list)).
66
+
67
+ ### How city names are matched
68
+
69
+ The library groups variants of the same place with **`citiesAreSimilar`**, not exact string equality.
70
+
71
+
72
+ | Label A | Label B | Same place? |
73
+ | ----------- | ------------------------------------ | --------------------------- |
74
+ | `Pleurtuit` | `Pleurtuit, Ille-et-Vilaine, France` | Yes |
75
+ | `Paris` | `Paris, TX, USA` | Depends on similarity rules |
76
+
77
+
78
+ Use the functions in the tables below for grouping, geocoding, and maps. Do **not** compare raw strings yourself, and do **not** use `normalizeCityKey` for grouping (spelling normalization only).
79
+
80
+ ### Prepare act list
81
+
82
+ ```ts
83
+ import type { ReadGed, Act } from "gedcom-ts";
84
+
85
+ function collectAllActs(ged: ReadGed): Act[] {
86
+ const acts: Act[] = [];
87
+ for (const person of ged.persons) {
88
+ for (const act of person.acts.list) acts.push(act);
89
+ }
90
+ return acts;
91
+ }
92
+ ```
93
+
94
+ ### Geocode one city (typical flow)
95
+
96
+ When the user picks a city and a Nominatim result, update **every similar act that still has no GPS** — not only acts with the exact same label.
97
+
98
+
99
+ | Step | Function |
100
+ | ---------------------------------------------- | ----------------------------------------------------------- |
101
+ | 1. Context from the tree (countries, centroid) | `inferGeocodeContext(ged)` |
102
+ | 2. Search OpenStreetMap | `searchPlacesWithContext(cityName, context)` |
103
+ | 3. Acts to update | `actsNeedingGeocodeForCity(allActs, cityName)` |
104
+ | 4. Write coordinates | `applyGeocodeCandidateToActs(targets, candidate, cityName)` |
105
+
106
+
107
+ ```ts
108
+ import {
109
+ inferGeocodeContext,
110
+ searchPlacesWithContext,
111
+ actsNeedingGeocodeForCity,
112
+ applyGeocodeCandidateToActs,
113
+ } from "gedcom-ts";
114
+ import type { ReadGed, Act } from "gedcom-ts";
115
+
116
+ async function geocodeCity(ged: ReadGed, allActs: Act[], cityName: string) {
117
+ const context = inferGeocodeContext(ged);
118
+ const candidates = await searchPlacesWithContext(cityName, context);
119
+ const chosen = candidates[0];
120
+ if (!chosen) return;
121
+
122
+ const targets = actsNeedingGeocodeForCity(allActs, cityName);
123
+ applyGeocodeCandidateToActs(targets, chosen, cityName);
124
+ }
125
+ ```
126
+
127
+ > **Tip:** Do not pass only acts from a harmonization cluster to `applyGeocodeCandidateToActs`. Longer labels (e.g. `Pleurtuit, Ille-et-Vilaine, France`) would stay without GPS. Always use `actsNeedingGeocodeForCity`.
128
+
129
+ ### List cities missing coordinates
130
+
131
+ One row per city; `withoutCoordCount` is how many acts still need GPS.
132
+
133
+ ```ts
134
+ import { groupActsBySimilarCity } from "gedcom-ts";
135
+
136
+ const groups = groupActsBySimilarCity(allActs, { onlyWithoutCoordinates: true });
137
+
138
+ for (const group of groups) {
139
+ console.log(group.cityLabel, group.withoutCoordCount);
140
+ // group.acts — acts in this group without GPS
141
+ }
142
+ ```
143
+
144
+ Largest groups first (`group.acts.length`).
145
+
146
+ ### Map: one marker per city
147
+
148
+ ```ts
149
+ import { clusterKeyForCity } from "gedcom-ts";
150
+
151
+ const markerKey = clusterKeyForCity(act.place?.city ?? "");
152
+ // merge markers that share the same markerKey
153
+ ```
154
+
155
+ ### Harmonization (data quality, optional)
156
+
157
+ Use when spellings, GPS positions, or “some acts with / without GPS” disagree for the same similar city.
158
+
159
+ ```ts
160
+ import { findHarmonizationClusters } from "gedcom-ts";
161
+
162
+ for (const cluster of findHarmonizationClusters(ged)) {
163
+ console.log(cluster.labels, cluster.coordVariants, cluster.actsWithoutCoord);
164
+ }
165
+ ```
166
+
167
+ After a full geocode via `actsNeedingGeocodeForCity`, you should not get a cluster that only reports missing GPS for that city.
168
+
169
+ ### Geocoding API cheat sheet
170
+
171
+
172
+ | Goal | Call |
173
+ | ------------------------- | ---------------------------------------------------------------- |
174
+ | Search | `searchPlacesWithContext(city, inferGeocodeContext(ged))` |
175
+ | Acts to update on confirm | `actsNeedingGeocodeForCity(acts, city)` |
176
+ | Apply lat/lng | `applyGeocodeCandidateToActs(targets, candidate, city)` |
177
+ | Cities without GPS | `groupActsBySimilarCity(acts, { onlyWithoutCoordinates: true })` |
178
+ | Map marker id | `clusterKeyForCity(city)` |
179
+ | Inconsistencies | `findHarmonizationClusters(ged)` |
55
180
 
56
- Everything exported from `gedcom-ts` is documented below with a short description and a minimal usage snippet. The full list mirrors the public exports of `src/index.ts`.
181
+
182
+ Network: `GET https://nominatim.openstreetmap.org/search?q=…&format=jsonv2` with a `User-Agent` (`gedcom-ts/<version> (genealogy library)`). For offline or mocked search, pass `fetchFn` in `searchPlaces` / `searchPlacesWithContext` options.
183
+
184
+ The legacy callback `getCityCoordinates` is deprecated — use the flow above.
185
+
186
+ ## API reference
187
+
188
+ Short description and a minimal snippet for each public export.
57
189
 
58
190
  ### Importing a file
59
191
 
@@ -104,22 +236,24 @@ Result of an import. Top-level GEDCOM records not modeled into the typed graph (
104
236
 
105
237
  Notable members:
106
238
 
107
- | Member | Description |
108
- | --- | --- |
109
- | `persons: Person[]` | Imported individuals. |
110
- | `mapPersons: Map<number, Person>` | `INDI` (integer) → `Person`. |
111
- | `partnersMap: Map<number, Person[]>` | Family idspouses. |
112
- | `childsMap: Map<number, Person[]>` | Family id → children. |
113
- | `placesMap: Map<string, Place>` | City name`Place` (first occurrence wins). |
114
- | `mapFiles: Map<string, File>` | Relative pathmedia `File` (for ZIP imports). |
115
- | `datasetVersion: GedcomDatasetVersion` | `"7.0"`, `"5.5"` or `"unknown"`. |
116
- | `preservedTopLevelRecords: string[]` | Raw blocks for partial round-trip. |
117
- | `resolveIndividualPointer(raw)` | Resolves `@I12@`, `I12`, `@Homer_Simpson@`, `Homer_Simpson` to a `Person`. |
118
- | `getChildrenForParent(parent)` | All children attached to a parent (via `FAMS`). |
119
- | `getChildrenOfFamily(familyId)` | Children of a single family. |
120
- | `groupPartners()` | Rebuilds `partnersMap` / `childsMap` after editing links. |
121
- | `generateUniqueIndi()` | Next free `INDI` number for new persons. |
122
- | `rehydratePlacesFromActs()` | Rebuilds `placesMap` from act places (e.g. after manual graph edits). Use `editReadGed(readGed).addPerson(...)` to register persons so maps stay coherent. |
239
+
240
+ | Member | Description |
241
+ | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
242
+ | `persons: Person[]` | Imported individuals. |
243
+ | `mapPersons: Map<number, Person>` | `INDI` (integer)`Person`. |
244
+ | `partnersMap: Map<number, Person[]>` | Family id → spouses. |
245
+ | `childsMap: Map<number, Person[]>` | Family idchildren. |
246
+ | `placesMap: Map<string, Place>` | City name → `Place` (first occurrence wins). |
247
+ | `mapFiles: Map<string, File>` | Relative path → media `File` (for ZIP imports). |
248
+ | `datasetVersion: GedcomDatasetVersion` | `"7.0"`, `"5.5"` or `"unknown"`. |
249
+ | `preservedTopLevelRecords: string[]` | Raw blocks for partial round-trip. |
250
+ | `resolveIndividualPointer(raw)` | Resolves `@I12@`, `I12`, `@Homer_Simpson@`, `Homer_Simpson` to a `Person`. |
251
+ | `getChildrenForParent(parent)` | All children attached to a parent (via `FAMS`). |
252
+ | `getChildrenOfFamily(familyId)` | Children of a single family. |
253
+ | `groupPartners()` | Rebuilds `partnersMap` / `childsMap` after editing links. |
254
+ | `generateUniqueIndi()` | Next free `INDI` number for new persons. |
255
+ | `rehydratePlacesFromActs()` | Rebuilds `placesMap` from act places (e.g. after manual graph edits). Use `editReadGed(readGed).addPerson(...)` to register persons so maps stay coherent. |
256
+
123
257
 
124
258
  ```ts
125
259
  import { ReadGed } from "gedcom-ts";
@@ -177,13 +311,15 @@ async function exportZip(persons: Person[]) {
177
311
 
178
312
  Options shared by both exporters:
179
313
 
180
- | Option | Effect |
181
- | --- | --- |
314
+
315
+ | Option | Effect |
316
+ | ---------------------- | -------------------------------------------------------------------------------------------------------- |
182
317
  | `extraTopLevelRecords` | Raw `0 …` blocks re-emitted before `SUBM` / `TRLR` (round-trip with `readGed.preservedTopLevelRecords`). |
183
- | `headLanguageTag` | BCP 47 tag for `HEAD`.`LANG` (defaults to `en-US`). |
184
- | `headCopyright` | One-line `1 COPR` notice. |
185
- | `headDestination` | Value of `HEAD`.`DEST` (target app / URI). |
186
- | `headSchemaTagDefs` | Extension tag definitions emitted as `HEAD`.`SCHMA` / `2 TAG …`. |
318
+ | `headLanguageTag` | BCP 47 tag for `HEAD`.`LANG` (defaults to `en-US`). |
319
+ | `headCopyright` | One-line `1 COPR` notice. |
320
+ | `headDestination` | Value of `HEAD`.`DEST` (target app / URI). |
321
+ | `headSchemaTagDefs` | Extension tag definitions emitted as `HEAD`.`SCHMA` / `2 TAG …`. |
322
+
187
323
 
188
324
  ### Domain model
189
325
 
@@ -394,14 +530,16 @@ editPerson(person)
394
530
  editDateAct(person.acts.list[0].dateAct!).setExactDate(1900, "JAN", 1);
395
531
  ```
396
532
 
397
- | Helper / class | Purpose |
398
- | --- | --- |
399
- | `editPerson(person)` / `PersonEdit` | Identity, `FAMS` / `FAMC` / `removeFams*`, `nameVariants()` / `attributes()` / `multimedia()`, bulk `clear*`, entry points to `acts()` and `notes()`. |
400
- | `editActs(acts)` / `ActsEdit` | `add`, `addNew`, `insertAt`, `insertNewAt`, `replaceAt`, `removeAt`, `removeAct`, `removeLast`, `clear`, `sortByDate`, `at`, `indexOfAct`. |
401
- | `editAct(act)` / `ActEdit` | `setType`, `setIndis`, dates, place, EVEN fields, preserved lines, `notes()`, `multimedia()`, `clearNotes`, `clearMultimedia`. |
402
- | `editDateAct(dateAct)` / `DateActEdit` | `clear`, `applyGedcomPayload`, `setExactDate`, `setQualified`, `setBetween`, `setFromTo`, `setTime`, `setDatePhrase` / `appendDatePhrase`, `setVerbatimPayload`. |
403
- | `editPlace(place)` / `PlaceEdit` | `setFromGedcom7Payload`, `setCity`, `setCounty`, `setState`, `setCountry`, `setPlacPhrase` / `appendPlacPhrase` / `clearPlacPhrase`, `setCoordinates` / `clearCoordinates` / `replaceCoordinateModel`, `clearStructured`. |
404
- | `editNotes(notes)` / `NotesEdit` | `add`, `addNew`, `insertAt`, `insertNewAt`, `replaceAt`, `removeAt`, `removeNote`, `removeLast`, `clear`, `at`, `indexOfNote`. |
533
+
534
+ | Helper / class | Purpose |
535
+ | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
536
+ | `editPerson(person)` / `PersonEdit` | Identity, `FAMS` / `FAMC` / `removeFams*`, `nameVariants()` / `attributes()` / `multimedia()`, bulk `clear*`, entry points to `acts()` and `notes()`. |
537
+ | `editActs(acts)` / `ActsEdit` | `add`, `addNew`, `insertAt`, `insertNewAt`, `replaceAt`, `removeAt`, `removeAct`, `removeLast`, `clear`, `sortByDate`, `at`, `indexOfAct`. |
538
+ | `editAct(act)` / `ActEdit` | `setType`, `setIndis`, dates, place, EVEN fields, preserved lines, `notes()`, `multimedia()`, `clearNotes`, `clearMultimedia`. |
539
+ | `editDateAct(dateAct)` / `DateActEdit` | `clear`, `applyGedcomPayload`, `setExactDate`, `setQualified`, `setBetween`, `setFromTo`, `setTime`, `setDatePhrase` / `appendDatePhrase`, `setVerbatimPayload`. |
540
+ | `editPlace(place)` / `PlaceEdit` | `setFromGedcom7Payload`, `setCity`, `setCounty`, `setState`, `setCountry`, `setPlacPhrase` / `appendPlacPhrase` / `clearPlacPhrase`, `setCoordinates` / `clearCoordinates` / `replaceCoordinateModel`, `clearStructured`. |
541
+ | `editNotes(notes)` / `NotesEdit` | `add`, `addNew`, `insertAt`, `insertNewAt`, `replaceAt`, `removeAt`, `removeNote`, `removeLast`, `clear`, `at`, `indexOfNote`. |
542
+
405
543
 
406
544
  ### Dataset editing: `ReadGed`, graph, clone, export options, validation
407
545
 
@@ -459,15 +597,17 @@ validateReadGed(readGed, {
459
597
 
460
598
  `tryAddPersonToReadGed`, `tryRemovePersonFromReadGedByIndi`, `tryLinkChildToFamily`, `tryUnlinkChildFromFamily`, and `tryCreateMarriageFamily` return a `CommandResult` (`ok` + `issues` or `value` + optional `warnings`). `editReadGed(...).addPerson` uses these checks internally (throws on blocking errors). For UI or transactional flows, prefer the `try*` APIs and inspect `commandBlockingIssues`.
461
599
 
462
- | Export | Role |
463
- | --- | --- |
464
- | `editReadGed(readGed)` / `ReadGedEdit` | `addPerson`, `removePersonByIndi`, `preserved()` → `PreservedTopLevelEdit` (`append`, `insertAt`, `replaceAt`, `removeAt`, `clear`) on `preservedTopLevelRecords`. Low-level helpers: `addPersonToReadGed`, `removePersonFromReadGedByIndi`. |
465
- | `editGedcomExportOptions(opts)` / `GedcomExportOptionsEdit` | Fluent setters for `extraTopLevelRecords`, `headLanguageTag`, `headCopyright`, `headDestination`, `headSchemaTagDefs`. |
466
- | `clonePerson(person, newIndi)` / `cloneAct(act)` / `person.clone` / `act.clone` | Deep copies for templates or undo stacks. |
467
- | `nextFamilyId(persons)` | Next internal family id `F`. |
468
- | `createMarriageFamily`, `linkChildToFamily`, `unlinkChildFromFamily`, `removeFamilyReferencesFromDataset` | Crée une `F` et des actes sur les deux conjoints. 5ᵉ argument : `{ eventTag }` (défaut `MARR`) pour `ENGA`, bans, contrat, `EVEN`+`CreateActInit`, etc. Voir `GEDCOM_7_PAIR_UNION_EVENT_TAGS`. |
469
- | `validateReadGed`, `assertReadGedConsistent`, `validatePerson`, `assertPersonConsistent` | Typed `code` / `severity` (`error` \| `warn`). Options: `checkMarrParticipants`, `checkDuplicateIndis`, `checkFamcWithoutSpouses`, `checkFamsWithoutSpouses`, `checkDuplicateFamsEntries`, `checkAncestorCycles`. Assertions: `failOn` (default: `error`). |
470
- | `tryAddPersonToReadGed`, `tryRemovePersonFromReadGedByIndi`, `tryLinkChildToFamily`, `tryUnlinkChildFromFamily`, `tryCreateMarriageFamily`, `commandBlockingIssues` | Command layer with `CommandResult` / `validate*Command` prechecks. |
600
+
601
+ | Export | Role |
602
+ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
603
+ | `editReadGed(readGed)` / `ReadGedEdit` | `addPerson`, `removePersonByIndi`, `preserved()` `PreservedTopLevelEdit` (`append`, `insertAt`, `replaceAt`, `removeAt`, `clear`) on `preservedTopLevelRecords`. Low-level helpers: `addPersonToReadGed`, `removePersonFromReadGedByIndi`. |
604
+ | `editGedcomExportOptions(opts)` / `GedcomExportOptionsEdit` | Fluent setters for `extraTopLevelRecords`, `headLanguageTag`, `headCopyright`, `headDestination`, `headSchemaTagDefs`. |
605
+ | `clonePerson(person, newIndi)` / `cloneAct(act)` / `person.clone` / `act.clone` | Deep copies for templates or undo stacks. |
606
+ | `nextFamilyId(persons)` | Next internal family id `F`. |
607
+ | `createMarriageFamily`, `linkChildToFamily`, `unlinkChildFromFamily`, `removeFamilyReferencesFromDataset` | Create family `F` and spouse acts; optional `{ eventTag }` (default `MARR`, also `ENGA`, banns, contract, `EVEN` + `CreateActInit`, …). See `GEDCOM_7_PAIR_UNION_EVENT_TAGS`. |
608
+ | `validateReadGed`, `assertReadGedConsistent`, `validatePerson`, `assertPersonConsistent` | Typed `code` / `severity` (`error` \| `warn`). Options: `checkMarrParticipants`, `checkDuplicateIndis`, `checkFamcWithoutSpouses`, `checkFamsWithoutSpouses`, `checkDuplicateFamsEntries`, `checkAncestorCycles`. Assertions: `failOn` (default: `error`). |
609
+ | `tryAddPersonToReadGed`, `tryRemovePersonFromReadGedByIndi`, `tryLinkChildToFamily`, `tryUnlinkChildFromFamily`, `tryCreateMarriageFamily`, `commandBlockingIssues` | Command layer with `CommandResult` / `validate*Command` prechecks. |
610
+
471
611
 
472
612
  ### Utilities
473
613
 
@@ -492,17 +632,9 @@ import { remainingTypesAct, Acts } from "gedcom-ts";
492
632
  const available = remainingTypesAct(new Acts());
493
633
  ```
494
634
 
495
- #### `getCityCoordinates(cityName, callback)`
635
+ #### `getCityCoordinates(cityName, callback)` (deprecated)
496
636
 
497
- Queries OpenStreetMap’s Nominatim API and returns a list of candidate `Place` objects with coordinates. Browser-only (uses `XMLHttpRequest`).
498
-
499
- ```ts
500
- import { getCityCoordinates } from "gedcom-ts";
501
-
502
- getCityCoordinates("Paris", (places) => {
503
- console.log(places[0]?.coordinate.latitude, places[0]?.coordinate.longitude);
504
- });
505
- ```
637
+ Legacy callback API. Use [Geocoding places](#geocoding-places) instead.
506
638
 
507
639
  #### `resolveDatasetVersion(headerLines)` / `GedcomDatasetVersion`
508
640
 
@@ -547,7 +679,7 @@ const primary = selectPrimaryNameVariant(person.nameVariants);
547
679
 
548
680
  #### `GEDCOM_LIBRARY_VERSION`
549
681
 
550
- CalVer string embedded in the exported `HEAD`.`SOUR`.`VERS` (same value as `package.json` `version`, e.g. `2026.5.0`). A test (`version-package-sync`) enforces the match on every CI run.
682
+ CalVer string written in exported `HEAD`.`SOUR`.`VERS` (same value as the npm package version, e.g. `2026.5.2`).
551
683
 
552
684
  ```ts
553
685
  import { GEDCOM_LIBRARY_VERSION } from "gedcom-ts";
@@ -555,56 +687,6 @@ import { GEDCOM_LIBRARY_VERSION } from "gedcom-ts";
555
687
  console.log(`gedcom-ts ${GEDCOM_LIBRARY_VERSION}`);
556
688
  ```
557
689
 
558
- ## End-to-end example
559
-
560
- ```ts
561
- import {
562
- importGedFile,
563
- ExportGedzipFile,
564
- editPerson,
565
- DateAct,
566
- Identifier,
567
- } from "gedcom-ts";
568
-
569
- async function importModifyExport(file: File) {
570
- const readGed = await importGedFile(file);
571
- const persons = readGed.persons;
572
-
573
- if (persons.length > 0) {
574
- editPerson(persons[0])
575
- .setLastname(persons[0].lastname.toUpperCase())
576
- .acts()
577
- .at(0)
578
- .setDateAct(new DateAct("1 JAN 1900"));
579
- }
580
-
581
- await new ExportGedzipFile("updated-tree", persons).download();
582
- }
583
- ```
584
-
585
- ## Development
586
-
587
- ### Local scripts
588
-
589
- | Script | Role |
590
- | --- | --- |
591
- | `npm run lint` | ESLint on the codebase |
592
- | `npm run test` | Vitest test suite |
593
- | `npm run build` | Production bundle + `.d.ts` |
594
- | `npm run tgz` | `build` then `npm pack` (local `.tgz`) |
595
-
596
- ### GitLab CI (`.gitlab-ci.yml`)
597
-
598
- Every pipeline runs **`InstallDependencies`** (`npm ci`, `node_modules` artifact) — required by all other jobs.
599
-
600
- | Job | When it runs |
601
- | --- | --- |
602
- | `InstallDependencies` | Always |
603
- | `Lint`, `Test` | Merge requests and branch pushes (not on `master` after a merge commit titled `Merge…`) |
604
- | `BuildTgz`, `PublishNpm` | Push to `master` after merge only (`Merge…` commit title) |
605
-
606
- Typical flow: open an MR from a release branch (e.g. `2026.5.0`) → lint + test run there → after merge to `master`, only package build and npm publish run (quality checks are not repeated).
607
-
608
690
  ## Error handling
609
691
 
610
692
  ```ts
@@ -621,3 +703,4 @@ try {
621
703
  }
622
704
  }
623
705
  ```
706
+
@@ -0,0 +1,3 @@
1
+ export { normalizeCityKey, tidyCityDisplay, findCanonicalCityLabel, } from "./place-city-utils";
2
+ export { type GeocodeCandidate, type GeocodeContext, type GeocodeFetchFn, type GeocodeSearchOptions, NOMINATIM_SEARCH_URL, defaultGeocodeUserAgent, parseCityQuery, countryLabelToIso, isoToCountryLabel, inferGeocodeContext, buildGeocodeQuery, geocodeContextWithHint, rankGeocodeCandidates, searchPlaces, searchPlacesWithContext, } from "./place-geocode";
3
+ export { type CoordVariant, type CityHarmonizationCluster, type SimilarCityActGroup, type GroupActsBySimilarCityOptions, type FilterActsBySimilarCityOptions, levenshteinDistance, citiesAreSimilar, clusterKeyForCity, similarCityKey, groupActsBySimilarCity, filterActsBySimilarCity, actsNeedingGeocodeForCity, findHarmonizationClustersFromActs, findHarmonizationClusters, applyCoordinatesToActs, applyGeocodeCandidateToActs, } from "./place-clusters";
@@ -0,0 +1,15 @@
1
+ import type { Act } from "../commons/Act";
2
+ import type { ReadGed } from "../import/ReadGed";
3
+ /**
4
+ * Égalité stricte de libellé (casse / espaces / NFKC uniquement).
5
+ * Pour regrouper des variantes (« Pleurtuit » vs « Pleurtuit, Ille-et-Vilaine »),
6
+ * utiliser {@link citiesAreSimilar}, {@link clusterKeyForCity} ou {@link groupActsBySimilarCity}.
7
+ */
8
+ export declare function normalizeCityKey(city: string): string;
9
+ /** Libellé affiché : trim + espaces internes unifiés (sans forcer la casse). */
10
+ export declare function tidyCityDisplay(city: string): string;
11
+ /**
12
+ * Libellé canonique par **égalité stricte** {@link normalizeCityKey} (pas `citiesAreSimilar`).
13
+ * Pour une ville « proche », préférer le `cityLabel` d’un {@link groupActsBySimilarCity}.
14
+ */
15
+ export declare function findCanonicalCityLabel(ged: ReadGed, cityInput: string, excludeAct: Act | null): string;
@@ -0,0 +1,73 @@
1
+ import type { Act } from "../commons/Act";
2
+ import type { ReadGed } from "../import/ReadGed";
3
+ import { type GeocodeCandidate } from "./place-geocode";
4
+ export interface CoordVariant {
5
+ readonly lat: number;
6
+ readonly lng: number;
7
+ readonly actCount: number;
8
+ }
9
+ /** Groupe de lieux similaires à harmoniser (libellés ou coordonnées divergents). */
10
+ export interface CityHarmonizationCluster {
11
+ readonly clusterKey: string;
12
+ readonly labels: readonly string[];
13
+ readonly coordVariants: readonly CoordVariant[];
14
+ readonly acts: readonly Act[];
15
+ readonly actsWithoutCoord: number;
16
+ }
17
+ /** Groupe d’actes partageant une même ville au sens {@link citiesAreSimilar}. */
18
+ export interface SimilarCityActGroup {
19
+ readonly cityLabel: string;
20
+ /** Même clé que {@link clusterKeyForCity} / carte. */
21
+ readonly clusterKey: string;
22
+ readonly acts: readonly Act[];
23
+ readonly withoutCoordCount: number;
24
+ }
25
+ export interface GroupActsBySimilarCityOptions {
26
+ /** Ne retient que les actes sans coordonnées GPS utilisables. */
27
+ readonly onlyWithoutCoordinates?: boolean;
28
+ }
29
+ export interface FilterActsBySimilarCityOptions {
30
+ readonly onlyWithoutCoordinates?: boolean;
31
+ }
32
+ /** Distance de Levenshtein (petites chaînes de noms de villes). */
33
+ export declare function levenshteinDistance(a: string, b: string): number;
34
+ /**
35
+ * Ville proche : même clé décorée, faute légère, ou inclusion évidente (Saint-X / St-X).
36
+ * **Règle unique** pour regrouper actes, carte et harmonisation.
37
+ */
38
+ export declare function citiesAreSimilar(a: string, b: string): boolean;
39
+ /**
40
+ * Clé stable pour la carte et les listes « par ville ».
41
+ * Utilise la localité principale (segment avant la première virgule), comme le regroupement
42
+ * par {@link citiesAreSimilar}, afin que « Pleurtuit » et « Pleurtuit, Ille-et-Vilaine, France »
43
+ * partagent la même clé.
44
+ */
45
+ export declare function clusterKeyForCity(city: string): string;
46
+ /** Alias explicite de {@link clusterKeyForCity}. */
47
+ export declare const similarCityKey: typeof clusterKeyForCity;
48
+ /**
49
+ * Regroupe les actes par ville similaire (tri décroissant par nombre d’actes).
50
+ * Utiliser pour une UI « lieux sans position » (`onlyWithoutCoordinates: true`).
51
+ */
52
+ export declare function groupActsBySimilarCity(acts: readonly Act[], options?: GroupActsBySimilarCityOptions): SimilarCityActGroup[];
53
+ /**
54
+ * Actes dont le lieu est similaire à `cityRef` (même logique que l’harmonisation / la carte).
55
+ */
56
+ export declare function filterActsBySimilarCity(acts: readonly Act[], cityRef: string, options?: FilterActsBySimilarCityOptions): Act[];
57
+ /**
58
+ * Actes similaires à `cityRef` sans coordonnées GPS — à géolocaliser en une fois.
59
+ * Alias de `filterActsBySimilarCity(..., { onlyWithoutCoordinates: true })`.
60
+ */
61
+ export declare function actsNeedingGeocodeForCity(acts: readonly Act[], cityRef: string): Act[];
62
+ /**
63
+ * Conflits d’harmonisation : libellés multiples, positions GPS divergentes,
64
+ * ou mixte avec/sans coordonnées sur la même ville similaire.
65
+ */
66
+ export declare function findHarmonizationClustersFromActs(acts: readonly Act[]): CityHarmonizationCluster[];
67
+ /** Analyse toute l’arbre (préférer {@link findHarmonizationClustersFromActs} sur un sous-ensemble). */
68
+ export declare function findHarmonizationClusters(ged: ReadGed): CityHarmonizationCluster[];
69
+ export declare function applyCoordinatesToActs(acts: readonly Act[], lat: number, lng: number, patch?: {
70
+ city?: string;
71
+ country?: string | null;
72
+ }): void;
73
+ export declare function applyGeocodeCandidateToActs(acts: readonly Act[], candidate: GeocodeCandidate, preferredCityLabel?: string): void;
@@ -0,0 +1,50 @@
1
+ import type { ReadGed } from "../import/ReadGed";
2
+ /** Résultat Nominatim présenté à l’utilisateur. */
3
+ export interface GeocodeCandidate {
4
+ readonly label: string;
5
+ readonly shortLabel: string;
6
+ readonly lat: number;
7
+ readonly lng: number;
8
+ readonly country: string | null;
9
+ readonly countryCode: string | null;
10
+ readonly region: string | null;
11
+ readonly kind: string | null;
12
+ readonly importance: number;
13
+ }
14
+ export interface GeocodeContext {
15
+ /** Codes ISO 3166-1 alpha-2 (ex. `fr`), du plus au moins probable. */
16
+ readonly countryCodes: readonly string[];
17
+ readonly defaultCountryLabel: string | null;
18
+ readonly centroid: {
19
+ lat: number;
20
+ lng: number;
21
+ } | null;
22
+ }
23
+ export type GeocodeFetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
24
+ export interface GeocodeSearchOptions {
25
+ readonly countryCodes?: readonly string[];
26
+ readonly limit?: number;
27
+ /** Défaut : `gedcom-ts/<version> (genealogy library)` */
28
+ readonly userAgent?: string;
29
+ /** Client HTTP injectable (tests, Node sans fetch global). */
30
+ readonly fetchFn?: GeocodeFetchFn;
31
+ }
32
+ export declare const NOMINATIM_SEARCH_URL = "https://nominatim.openstreetmap.org/search";
33
+ export declare function defaultGeocodeUserAgent(): string;
34
+ /** Parse « Valence », « Valence, Drôme », « Valence, France ». */
35
+ export declare function parseCityQuery(raw: string): {
36
+ city: string;
37
+ countryHint: string | null;
38
+ };
39
+ export declare function countryLabelToIso(label: string): string | null;
40
+ export declare function isoToCountryLabel(code: string | null | undefined): string | null;
41
+ /** Contexte géographique déduit de l’arbre (pays et centroïde des lieux déjà géolocalisés). */
42
+ export declare function inferGeocodeContext(ged: ReadGed | null): GeocodeContext;
43
+ export declare function buildGeocodeQuery(cityInput: string, context: GeocodeContext, extraCountryHint?: string | null): string;
44
+ /** Priorise le pays saisi par l’utilisateur pour filtre Nominatim et classement. */
45
+ export declare function geocodeContextWithHint(context: GeocodeContext, extraCountryHint?: string | null): GeocodeContext;
46
+ /** Trie les candidats : pays de l’arbre, proximité du centroïde, correspondance du nom. */
47
+ export declare function rankGeocodeCandidates(candidates: GeocodeCandidate[], cityInput: string, context: GeocodeContext): GeocodeCandidate[];
48
+ export declare function searchPlaces(query: string, options?: GeocodeSearchOptions): Promise<GeocodeCandidate[]>;
49
+ /** Recherche avec repli sans filtre pays si trop peu de résultats. */
50
+ export declare function searchPlacesWithContext(cityInput: string, context: GeocodeContext, extraCountryHint?: string | null, options?: GeocodeSearchOptions): Promise<GeocodeCandidate[]>;