strom-research 1.22.0 → 1.23.0-beta.1

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.cs.md CHANGED
@@ -5,6 +5,27 @@
5
5
  Změny každé verze ve zkratce. Úplné poznámky a instalační řádky jsou na
6
6
  [GitHub Releases](https://github.com/ACiDekCZ/strom-research/releases) (anglicky).
7
7
 
8
+ ## 1.23.0
9
+
10
+ *Připravuje se: bety do 1.23.0-beta.1. Oddíl bude úplný s vydáním.*
11
+
12
+ - **Osoba ukázaná zvoleným jménem.** Žena narozená pod jedním jménem, provdaná pod druhým a po rozvodu zase u prvního
13
+ se dá ukazovat tímto jménem znovu, aniž by se přidávalo podruhé: `strom name primary <osoba> "<jméno>"` (a
14
+ `--clear`), nebo `strom name add --primary`. Volba patří jen uživateli, nikdy se nečte ze záznamu; opravy, citace
15
+ i synchronizace s aplikací Strom se dál řídí rodným jménem. Soubor pro Strom zvolené jméno označí, standardní soubor
16
+ GEDCOM ho dá na první místo.
17
+ - **Soubor pro aplikaci Strom bez varování.** Export pro aplikaci Strom varoval před nestandardní značkou u každé
18
+ osoby se zvoleným jménem a u několika značek zdrojů; kontrola teď zná všechny vlastní značky Stromu.
19
+
20
+ ## 1.22.1
21
+
22
+ *29. 10. 2026 · patří k aplikaci Strom 3.17.0.*
23
+
24
+ - **Událost místa se u regionu nezapíše znovu.** Když se dějiny místa hledaly dřív než dějiny regionů, do kterých
25
+ patří, mohly dějiny regionu přidat tutéž událost ještě jednou (stejný druh, stejné roky). Region teď vynechá, co už
26
+ má některé z jeho míst; událost, která se týkala celého regionu, se zapíše dál. To, co agent k místu čte, říká
27
+ i to, které z jeho regionů se ještě neprošly.
28
+
8
29
  ## 1.22.0
9
30
 
10
31
  *29. 10. 2026 · patří k aplikaci Strom 3.17.0.*
package/CHANGELOG.de.md CHANGED
@@ -5,6 +5,30 @@
5
5
  Die Änderungen jeder Version in Kürze. Die vollständigen Hinweise und die Installationszeilen stehen bei
6
6
  [GitHub Releases](https://github.com/ACiDekCZ/strom-research/releases) (auf Englisch).
7
7
 
8
+ ## 1.23.0
9
+
10
+ *In Vorbereitung: die Betas bis 1.23.0-beta.1. Der Abschnitt ist mit dem Release vollständig.*
11
+
12
+ - **Eine Person unter dem gewählten Namen.** Eine Frau, unter einem Namen geboren, unter einem anderen verheiratet und
13
+ nach einer Scheidung wieder beim ersten, lässt sich wieder unter diesem Namen zeigen, ohne dass er ein zweites Mal
14
+ hinzukommt: `strom name primary <Person> "<Name>"` (und `--clear`) oder `strom name add --primary`. Die Wahl trifft
15
+ allein der Nutzer, nie wird sie aus einem Eintrag gelesen; Korrekturen, Zitate und der Abgleich mit der Strom-App
16
+ richten sich weiter nach dem Geburtsnamen. Die Strom-Datei markiert den gewählten Namen, die Standard-GEDCOM-Datei
17
+ setzt ihn an die erste Stelle.
18
+ - **Die Datei für die Strom-App ohne Warnungen.** Der Export für die Strom-App warnte bei jeder Person mit gewähltem
19
+ Namen und bei mehreren Kennzeichen von Quellen vor einem nicht standardisierten Tag; die Prüfung kennt jetzt alle
20
+ eigenen Tags von Strom.
21
+
22
+ ## 1.22.1
23
+
24
+ *29.10.2026 · gehört zur Strom-App 3.17.0.*
25
+
26
+ - **Ein Ereignis eines Ortes wird bei seiner Region nicht noch einmal geschrieben.** Wurde die Geschichte eines Ortes
27
+ vor den Regionen gesucht, zu denen er gehört, konnte die Geschichte der Region dasselbe Ereignis noch einmal
28
+ hinzufügen (dieselbe Art, dieselben Jahre). Die Region lässt jetzt aus, was einer ihrer Orte schon hat; ein
29
+ Ereignis, das die ganze Region betraf, wird weiter geschrieben. Was der Agent zu einem Ort liest, sagt auch, welche
30
+ seiner Regionen noch nicht durchgesehen sind.
31
+
8
32
  ## 1.22.0
9
33
 
10
34
  *29.10.2026 · gehört zur Strom-App 3.17.0.*
package/CHANGELOG.md CHANGED
@@ -5,6 +5,27 @@ English · [Čeština](CHANGELOG.cs.md) · [Deutsch](CHANGELOG.de.md)
5
5
  The changes of each version in short. The full notes and the install lines are on
6
6
  [GitHub Releases](https://github.com/ACiDekCZ/strom-research/releases).
7
7
 
8
+ ## 1.23.0
9
+
10
+ *In preparation: the betas up to 1.23.0-beta.1. The section is complete with the release.*
11
+
12
+ - **A person shown by the name chosen.** A woman born under one name, married under another and back at the first
13
+ after a divorce can be shown by that name again without it being added twice: `strom name primary <person>
14
+ "<name>"` (and `--clear`), or `strom name add --primary`. The choice is the user's alone, never read from a record;
15
+ corrections, citations and the sync with the Strom app still go by the birth name. The Strom file marks the chosen
16
+ name, the standard GEDCOM file gives it first.
17
+ - **The file for the Strom app without warnings.** Exporting for the Strom app warned of a non-standard tag on each
18
+ person with a chosen name and on several marks of sources; the check now knows every tag of Strom's own.
19
+
20
+ ## 1.22.1
21
+
22
+ *2026-10-29 · goes with the Strom app 3.17.0.*
23
+
24
+ - **An event of a place is not written again for its region.** When the history of a place was looked for before the
25
+ regions it lies in, the region's history could add the same event once more (the same kind, the same years). The
26
+ region now leaves out what one of its places has already; an event that touched the whole region is still written.
27
+ What the agent reads for a place also says which of its regions are not looked through yet.
28
+
8
29
  ## 1.22.0
9
30
 
10
31
  *2026-10-29 · goes with the Strom app 3.17.0.*
@@ -1060,6 +1060,7 @@
1060
1060
  "ui.show.name.married": "po sňatku",
1061
1061
  "ui.show.name.religious": "řeholní",
1062
1062
  "ui.show.name.alias": "jinak též",
1063
+ "ui.show.name.current": "hlavní",
1063
1064
  "ui.show.role.godparent": "kmotr",
1064
1065
  "ui.show.role.witness": "svědek",
1065
1066
  "ui.show.role.officiant": "duchovní",
@@ -1348,7 +1349,10 @@
1348
1349
  "ui.sync.do.name.gone": "odebere se (měly ho jen úpravy v aplikaci nebo jiné rodokmeny)",
1349
1350
  "ui.sync.do.name.kept": "zůstává: dokládá ho záznam",
1350
1351
  "ui.sync.do.name.user": "platí úprava z aplikace: osoba se ukazuje pod novým jménem, jméno ze záznamu zůstane vedle něj",
1352
+ "ui.sync.do.name.shown": "osoba se ukazuje tímto jménem",
1353
+ "ui.sync.do.name.shown.conflict": "rozpor k rozhodnutí (výzkum ho změnil také)",
1351
1354
  "ui.sync.name.title": "{n}. {who}: {title} — {old} → {new}",
1355
+ "ui.sync.name.shown": "{n}. {who}: hlavní jméno — {old} → {new}",
1352
1356
  "ui.sync.do.title": "doplní se ke jménu s odkazem na strom z aplikace",
1353
1357
  "ui.sync.do.title.user": "platí úprava z aplikace (verze výzkumu zůstane v historii)",
1354
1358
  "ui.sync.sex.changed": "{n}. {who}: pohlaví {text}",
@@ -2242,6 +2246,8 @@
2242
2246
  "log.note.edit": "Opravená poznámka: {name}",
2243
2247
  "log.name.retract": "Odvolané jméno: {name} – {who}",
2244
2248
  "log.name.restore": "Vrácené jméno: {name} – {who}",
2249
+ "log.name.primary": "Hlavní jméno: {name} – {who}",
2250
+ "log.name.primary.clear": "Hlavní jméno zase rodné: {name} – {who}",
2245
2251
  "log.note.retract": "Odvolaná poznámka: {name}",
2246
2252
  "log.note.restore": "Vrácená poznámka: {name}",
2247
2253
  "log.event.status": "Změněná jistota údaje: {who} – {fact}",
@@ -2507,6 +2513,8 @@
2507
2513
  "ui.conflict.sex": "pohlaví",
2508
2514
  "ui.conflict.sex.U": "neznámé",
2509
2515
  "ui.conflict.name": "jméno",
2516
+ "ui.conflict.primary": "hlavní jméno",
2517
+ "ui.conflict.primary.none": "nezvoleno",
2510
2518
  "ui.conflict.titleBefore": "titul před jménem",
2511
2519
  "ui.conflict.titleAfter": "titul za jménem",
2512
2520
  "ui.conflict.parents": "rodiče",
@@ -1060,6 +1060,7 @@
1060
1060
  "ui.show.name.married": "nach der Heirat",
1061
1061
  "ui.show.name.religious": "Ordensname",
1062
1062
  "ui.show.name.alias": "auch genannt",
1063
+ "ui.show.name.current": "Hauptname",
1063
1064
  "ui.show.role.godparent": "Pate",
1064
1065
  "ui.show.role.witness": "Zeuge",
1065
1066
  "ui.show.role.officiant": "Geistlicher",
@@ -1348,7 +1349,10 @@
1348
1349
  "ui.sync.do.name.gone": "wird entfernt (nur Änderungen in der App oder andere Stammbäume gaben ihn)",
1349
1350
  "ui.sync.do.name.kept": "bleibt: ein Eintrag belegt ihn",
1350
1351
  "ui.sync.do.name.user": "Die eigene Änderung gilt: die Person erscheint unter dem neuen Namen, der Name aus dem Eintrag bleibt daneben",
1352
+ "ui.sync.do.name.shown": "die Person wird so angezeigt",
1353
+ "ui.sync.do.name.shown.conflict": "ein Widerspruch zum Entscheiden (auch die Forschung hat es geändert)",
1351
1354
  "ui.sync.name.title": "{n}. {who}: {title} — {old} → {new}",
1355
+ "ui.sync.name.shown": "{n}. {who}: Hauptname — {old} → {new}",
1352
1356
  "ui.sync.do.title": "wird dem Namen hinzugefügt, mit Verweis auf den eigenen Stammbaum",
1353
1357
  "ui.sync.do.title.user": "Die eigene Änderung gilt (die der Forschung bleibt in ihrer Geschichte)",
1354
1358
  "ui.sync.sex.changed": "{n}. {who}: Geschlecht {text}",
@@ -2242,6 +2246,8 @@
2242
2246
  "log.note.edit": "Notiz berichtigt: {name}",
2243
2247
  "log.name.retract": "Name zurückgezogen: {name} – {who}",
2244
2248
  "log.name.restore": "Name wiederhergestellt: {name} – {who}",
2249
+ "log.name.primary": "Hauptname: {name} – {who}",
2250
+ "log.name.primary.clear": "Hauptname wieder der Geburtsname: {name} – {who}",
2245
2251
  "log.note.retract": "Notiz zurückgezogen: {name}",
2246
2252
  "log.note.restore": "Notiz wiederhergestellt: {name}",
2247
2253
  "log.event.status": "Sicherheit einer Angabe geändert: {who} – {fact}",
@@ -2507,6 +2513,8 @@
2507
2513
  "ui.conflict.sex": "Geschlecht",
2508
2514
  "ui.conflict.sex.U": "unbekannt",
2509
2515
  "ui.conflict.name": "Name",
2516
+ "ui.conflict.primary": "Hauptname",
2517
+ "ui.conflict.primary.none": "nicht gewählt",
2510
2518
  "ui.conflict.titleBefore": "Titel vor dem Namen",
2511
2519
  "ui.conflict.titleAfter": "Titel nach dem Namen",
2512
2520
  "ui.conflict.parents": "Eltern",
@@ -116,7 +116,7 @@ function personBlock(tree, p, depth, short = false) {
116
116
  const out = [` ${label(p)} ${p.sex}`];
117
117
  // other names, and where any name comes from
118
118
  if (p.names.length > 1 || p.names.some((n) => n.citations?.length))
119
- out.push(` names: ${p.names.map((n) => `${formatName(n)}${n.kind ? ` (${n.kind})` : ""}${n.citations?.length ? ` ← ${citesOf(n.citations)}` : ""}`).join("; ")}`);
119
+ out.push(` names: ${p.names.map((n) => `${formatName(n)}${n.kind ? ` (${n.kind})` : ""}${n.primary ? " (shown by it)" : ""}${n.citations?.length ? ` ← ${citesOf(n.citations)}` : ""}`).join("; ")}`);
120
120
  for (const e of p.events.filter((x) => !x.retracted))
121
121
  out.push(eventLine(e));
122
122
  const parents = parentsOf(tree, p.id);
@@ -200,6 +200,9 @@ function placeSections(tree, id, shared, alone) {
200
200
  const events = c?.events ?? [];
201
201
  const live = events.filter((e) => !e.retracted);
202
202
  const theirs = regionEventsAt(tree, shared, p.id, at);
203
+ // its regions not looked through yet (K7.1a): what touched the whole region is theirs, written when they are
204
+ const maps = readPlaces(tree);
205
+ const waiting = [...new Set((at?.keys ?? []).flatMap((k) => maps[k]?.regions ?? []).map((g) => g.id))].filter((g) => !shared || !readCatalog(shared, g)?.done);
203
206
  const out = [];
204
207
  out.push({
205
208
  name: "history",
@@ -237,6 +240,9 @@ function placeSections(tree, id, shared, alone) {
237
240
  : [" none yet"]),
238
241
  theirs.length ? `The regions' events at this place in its years (${theirs.length}) — the Strom app shows them here already, never written for the place (refused):` : "No region's event at this place yet.",
239
242
  ...theirs.slice(0, EVENTS_SHOWN).map(({ event: e, region }) => ` ${e.id} ${years(e.from, e.to)} ${e.kind} (${region.region.id}) "${short(e.text[lang] ?? e.text.en ?? Object.values(e.text)[0] ?? "")}"`),
243
+ waiting.length
244
+ ? `Regions of this place not looked through yet (${waiting.join(", ")}): what touched the whole region (a war of the land, its epidemic, a reform of the state) is theirs, written when they are — here only what happened at the place or near enough to touch its people`
245
+ : "",
240
246
  c?.tried.length ? `Pages strom could not read (never tried again): ${c.tried.slice(-20).map((t) => `${t.url} (${short(t.why, 40)})`).join(" · ")}` : "",
241
247
  ].filter(Boolean).join("\n"),
242
248
  });
package/dist/cli/ui.js CHANGED
@@ -884,6 +884,7 @@ export const UI = {
884
884
  "ui.show.name.married": "married",
885
885
  "ui.show.name.religious": "religious",
886
886
  "ui.show.name.alias": "alias",
887
+ "ui.show.name.current": "current",
887
888
  "ui.show.role.godparent": "godparent",
888
889
  "ui.show.role.witness": "witness",
889
890
  "ui.show.role.officiant": "officiant",
@@ -1313,7 +1314,10 @@ export const UI = {
1313
1314
  "ui.sync.do.name.gone": "taken off (only edits in the app or other family trees gave it)",
1314
1315
  "ui.sync.do.name.kept": "kept: a record gives it",
1315
1316
  "ui.sync.do.name.user": "the edit wins: shown by the new name, the record's kept beside it",
1317
+ "ui.sync.do.name.shown": "the person shown by it",
1318
+ "ui.sync.do.name.shown.conflict": "a conflict to decide (the research changed it too)",
1316
1319
  "ui.sync.name.title": "{n}. {who}: {title} — {old} → {new}",
1320
+ "ui.sync.name.shown": "{n}. {who}: name shown — {old} → {new}",
1317
1321
  "ui.sync.do.title": "added to the name, citing the tree sent",
1318
1322
  "ui.sync.do.title.user": "the edit wins (the research's in its history)",
1319
1323
  "ui.sync.sex.changed": "{n}. {who}: sex {text}",
@@ -2432,6 +2436,8 @@ export const UI = {
2432
2436
  "ui.conflict.sex": "sex",
2433
2437
  "ui.conflict.sex.U": "not known",
2434
2438
  "ui.conflict.name": "name",
2439
+ "ui.conflict.primary": "name shown",
2440
+ "ui.conflict.primary.none": "none chosen",
2435
2441
  "ui.conflict.titleBefore": "title before the name",
2436
2442
  "ui.conflict.titleAfter": "title after the name",
2437
2443
  "ui.conflict.parents": "parents",
@@ -15,7 +15,7 @@ import { resolvePerson } from "../core/people.js";
15
15
  import { checkQuotes, cleanText, findPassages, mainQuote, pageParts, PageError, quotesShape, readPage, PART_CHARS } from "../core/pagecheck.js";
16
16
  import { currentSession } from "../core/session.js";
17
17
  import { addLocalEvent, addRegionEvent, askFirst, assignPlace, checkKind, checkTexts, checkYears, deleteEvent, ensureRegion, factPlaceKey, hideEvent, historyAnswer, historyStatus, forGaps, forHistory, gapsOfTask, gapText, historyOrder, historyHeld, historyTurn, mapPlace, raiseHistoryTasks, markDone, donePeriod, noteTried, readCatalog, redoRegion, refuseBeforePage, REGION_LANGS, regionIdOk, regionOfTask, requireHistoryOn, requireRegionId, retractEvent, skipHistory, EVENT_KINDS, yearsToSearch, whoSees, readPlaces, inRegion, regionName, placeRecordOf, } from "../core/pastevents.js";
18
- import { addPlaceEvent, askPlace, refusePlaceAsk, ensurePlaceCatalog, markPlaceDone, factsByRecord, notePlaceTried, placeEstimate, placeKeyOf, placeLabel, placeOfTask, readPlaceCatalog, refusePlaceBeforePage, requirePlace, setArea, yearsAt, } from "../core/placehistory.js";
18
+ import { addPlaceEvent, askPlace, refusePlaceAsk, ensurePlaceCatalog, markPlaceDone, factsByRecord, notePlaceTried, placeEstimate, placeKeyOf, placeLabel, placeOfTask, readPlaceCatalog, refusePlaceBeforePage, refusePlaceSame, requirePlace, setArea, yearsAt, } from "../core/placehistory.js";
19
19
  // strom history list
20
20
  import { moreLine, paginate } from "../cli/format.js";
21
21
  import { displayName } from "../core/people.js";
@@ -336,8 +336,11 @@ register({
336
336
  }
337
337
  if (place)
338
338
  refusePlaceBeforePage(tree, sh, place, { kind, from, to }, s, url);
339
- else
339
+ else {
340
340
  refuseBeforePage(sh, region, { kind, from, to }, s, url);
341
+ if (!s.sameAs && !s.distinct)
342
+ refusePlaceSame(tree, sh, region, { kind, from, to });
343
+ }
341
344
  let checked;
342
345
  try {
343
346
  checked = await checkQuotes(url, quotes, { from, to }, sh, ctx.env);
@@ -363,6 +366,9 @@ register({
363
366
  sha: checked.page.sha,
364
367
  };
365
368
  const event = { from, to, ...(date ? { date } : {}), kind, text, ...(why ? { why } : {}), ...(tip ? { tip } : {}), ...(opts.sensitive ? { sensitive: true } : {}), source };
369
+ // a place looked for before its region (K7.1a): its own events checked again once the page is read
370
+ if (!place && !s.sameAs && !s.distinct)
371
+ refusePlaceSame(tree, sh, region, { kind, from, to });
366
372
  const r = place ? addPlaceEvent(tree, sh, place, event, s) : addRegionEvent(sh, tree.config.id, { region: region, ...event }, s);
367
373
  const where = place ? `at ${placeLabel(place, "en")} (${place.id}) and its surroundings` : `in ${region}`;
368
374
  // who it shows at (their person in the Strom app, their story): for the agent to say to the person who asked
@@ -3,13 +3,13 @@ import { register } from "../cli/registry.js";
3
3
  import { lines, moreLine, paginate, table, truncate } from "../cli/format.js";
4
4
  import { suggestionLines, suggestPeople } from "../core/participantlinks.js";
5
5
  import { checkStreetPlace } from "../core/streets.js";
6
- import { addChild, addEvent, addFamily, addName, addNote, addPerson, citeEvent, citeRecord, editEvent, editFamily, editPerson, findEventOwner, mergeFamilies, notRetracted, mergePersons, parseParticipant, retractEvent, retractPerson } from "../core/actions.js";
6
+ import { addChild, addEvent, addFamily, addName, addNote, addPerson, citeEvent, citeRecord, editEvent, editFamily, editPerson, findEventOwner, markPrimaryName, mergeFamilies, notRetracted, mergePersons, parseParticipant, retractEvent, retractPerson } from "../core/actions.js";
7
7
  import { listOpt, normId, requireRecord } from "../core/records.js";
8
8
  import { SINGLE_KINDS } from "../core/actions.js";
9
9
  import { UsageError } from "../core/errors.js";
10
10
  import { INFORMATION } from "../core/evidence.js";
11
11
  import { EVENT_KINDS, NAME_KINDS } from "../core/model.js";
12
- import { ancestorGenerations, displayName, familiesAsChild, familiesAsPartner, findPersons, formatName, gedcomName, label, lifespan, parseName, primaryName, relationTo, resolvePerson, sameName, titledName, } from "../core/people.js";
12
+ import { ancestorGenerations, currentName, displayName, familiesAsChild, familiesAsPartner, findPersons, formatName, gedcomName, label, lifespan, parseName, primaryName, relationTo, resolvePerson, sameName, titledName, } from "../core/people.js";
13
13
  import { resolveResearch } from "./research.js";
14
14
  import { editNote, notesOf, restoreName, restoreNote, retractName, retractNote } from "../core/retractions.js";
15
15
  import { DEATH_AFTER_YEARS, unprovenPeople } from "../core/review.js";
@@ -126,7 +126,7 @@ function citesOf(citations, w = ENGLISH) {
126
126
  }
127
127
  function nameLine(n, w = ENGLISH) {
128
128
  const cites = citesOf(n.citations, w);
129
- return `${titledName(n)}${n.kind ? ` (${w.word("name", n.kind)})` : ""}${cites ? ` ← ${cites}` : ""}`;
129
+ return `${titledName(n)}${n.kind ? ` (${w.word("name", n.kind)})` : ""}${n.primary ? ` · ${w.word("name", "current")}` : ""}${cites ? ` ← ${cites}` : ""}`;
130
130
  }
131
131
  function historyOf(p) {
132
132
  const out = [];
@@ -305,7 +305,7 @@ register({
305
305
  const who = (x) => (shown.add(x), relative(x, w));
306
306
  const out = [
307
307
  ...(removed ? [removed.line, ""] : []),
308
- `${p.id} ${titledName(primaryName(p))}${lifespan(p) ? ` (${lifespan(p)})` : ""} ${w.lang ? w.word("sex", p.sex) : `sex ${p.sex}`}`,
308
+ `${p.id} ${titledName(currentName(p))}${lifespan(p) ? ` (${lifespan(p)})` : ""} ${w.lang ? w.word("sex", p.sex) : `sex ${p.sex}`}`,
309
309
  ...(p.names.length > 1 || p.names.some((n) => n.citations?.length) ? ["", w.say("ui.show.names", "names"), ...p.names.map((n) => ` ${nameLine(n, w)}`)] : []),
310
310
  "",
311
311
  w.say("ui.show.facts", "facts"),
@@ -426,14 +426,15 @@ register({
426
426
  tree: true,
427
427
  writes: true,
428
428
  description: "A name without a surname (\"Markéta\") is completed by the full one and the person is shown by it; the same\n" +
429
- "name again only adds its citation. A woman is shown by her birth name; --kind married keeps the married one aside.",
429
+ "name again only adds its citation. A woman is shown by her birth name; --kind married keeps the married one aside.\n" +
430
+ "--primary shows the person by this name from now on (strom name primary marks a name they have already).",
430
431
  args: [
431
432
  { name: "person", description: "ID (P0001) or name", required: true },
432
433
  { name: "name", description: 'the name, surname in slashes: "Markéta /Růžičková/"', required: true },
433
434
  ],
434
435
  options: [
435
436
  { name: "kind", type: "string", value: "<kind>", description: `${NAME_KINDS.join(", ")} (default: plain)` },
436
- { name: "primary", type: "boolean", description: "show the person by this name" },
437
+ { name: "primary", type: "boolean", description: "show the person by this name now (their identity stays the birth name)" },
437
438
  ...TITLE_OPTIONS,
438
439
  ...CITE_OPTIONS.filter((o) => o.name !== "status"),
439
440
  ],
@@ -454,6 +455,34 @@ register({
454
455
  const r = addName(tree, p.id, { name: args[1], kind: opts.kind, citation: citationOf(opts), primary: Boolean(opts.primary), prefix: opts.prefix, suffix: opts.suffix });
455
456
  return { text: written(tree), data: { person: r.person, name: r.name } };
456
457
  },
458
+ }, {
459
+ path: ["name", "primary"],
460
+ summary: "Show a person by one of their names now — a divorced woman back at her birth surname — without adding it again",
461
+ group: "people",
462
+ tree: true,
463
+ writes: true,
464
+ description: "The user's choice, never told from a record (a divorce does not say it): the name is marked, every other name\n" +
465
+ "stays as it is. person show, lists, the brief, the Strom app show the person by it; their identity — what a\n" +
466
+ "correction (person edit --name), a citation of the person and pairing go by — stays the birth name. --clear takes\n" +
467
+ "the mark off: shown by the birth name again (the Strom app: by the married surname, as before).",
468
+ args: [
469
+ { name: "person", description: "ID (P0001) or name", required: true },
470
+ { name: "name", description: 'one of their names, surname in slashes: "Jana /Lhotová/" (none with --clear)' },
471
+ ],
472
+ options: [
473
+ { name: "clear", type: "boolean", description: "take the mark off: no name chosen" },
474
+ ],
475
+ examples: ['strom name primary P0002 "Josef /Nowak/"', "strom name primary P0002 --clear"],
476
+ run(ctx, { args, opts }) {
477
+ const tree = ctx.tree();
478
+ const p = resolvePerson(tree, args[0]);
479
+ if (opts.clear && args[1] !== undefined)
480
+ throw new UsageError("a name or --clear, not both", { hint: `strom name primary ${p.id} "<Given /Surname/>" · strom name primary ${p.id} --clear` });
481
+ if (!opts.clear && args[1] === undefined)
482
+ throw new UsageError("which name?", { hint: `its names: ${p.names.map((n) => `"${gedcomName(n)}"`).join(", ")} · strom name primary ${p.id} "<Given /Surname/>" · --clear` });
483
+ const r = markPrimaryName(tree, p.id, opts.clear ? undefined : args[1], opts.reason);
484
+ return { text: written(tree), data: { person: r.person, name: r.name, ...(r.was ? { was: r.was } : {}) } };
485
+ },
457
486
  }, {
458
487
  path: ["name", "retract"],
459
488
  summary: "Take back a name of a person that turned out wrong (kept with the reason, shown nowhere but in the history)",
@@ -110,8 +110,8 @@ function changeLine(tree, c, incoming, lang, readings) {
110
110
  now: c.kind === "fact.detail" ? detailText(c.fact, lang, short) : factText(c.fact, lang, short),
111
111
  when: [humanDate(c.fact?.date, lang), humanPlace(c.fact?.place, undefined, lang)].filter(Boolean).join(", "),
112
112
  text: truncate(c.text ?? "", 120),
113
- old: c.note ? truncate(c.note.was, 80) : c.title ? truncate(c.title.was, 80) || "—" : c.wasTitle !== undefined ? truncate(c.wasTitle, 80) : shownName(c.wasName),
114
- new: c.note ? truncate(c.text ?? "", 80) : c.title || c.wasTitle !== undefined ? truncate(c.text ?? "", 80) || "—" : shownName(c.text),
113
+ old: c.note ? truncate(c.note.was, 80) : c.title ? truncate(c.title.was, 80) || "—" : c.wasTitle !== undefined ? truncate(c.wasTitle, 80) : c.shown ? shownName(c.wasName) || ui(lang, "ui.conflict.primary.none") : shownName(c.wasName),
114
+ new: c.note ? truncate(c.text ?? "", 80) : c.title || c.wasTitle !== undefined ? truncate(c.text ?? "", 80) || "—" : c.shown ? shownName(c.text) || ui(lang, "ui.conflict.primary.none") : shownName(c.text),
115
115
  title: c.title ? ui(lang, c.title.part === "before" ? "ui.conflict.titleBefore" : "ui.conflict.titleAfter") : "",
116
116
  child: name(c.child),
117
117
  // a child's tie to each parent: one word for both, else each parent's
@@ -148,30 +148,32 @@ function changeLine(tree, c, incoming, lang, readings) {
148
148
  ? `ui.sync.do.removed${c.action === "conflict" ? ".conflict" : ""}`
149
149
  : c.kind === "name.changed" && c.action === "user"
150
150
  ? "ui.sync.do.name.user"
151
- : c.kind === "name.gone"
152
- ? `ui.sync.do.name.${c.action === "remove" ? "gone" : "kept"}`
153
- : c.kind === "name.title" && (c.action === "add" || c.action === "user")
154
- ? `ui.sync.do.title${c.action === "user" ? ".user" : ""}`
155
- : c.kind === "note.changed" && c.action !== "pick"
156
- ? "ui.sync.do.note.changed"
157
- : c.kind === "family.union" && c.action !== "report"
158
- ? "ui.sync.do.family.union"
159
- : c.kind === "fact.detail" && c.action === "add"
160
- ? "ui.sync.do.detail"
161
- : c.kind === "fact.part" && (c.action === "add" || c.action === "user")
162
- ? `ui.sync.do.part${c.action === "user" ? ".user" : ""}`
163
- : c.kind === "source.title"
164
- ? `ui.sync.do.source.title${c.action === "add" ? ".beside" : c.action === "user" ? ".user" : ""}`
165
- : c.kind === "source.new" && c.reading?.ok
166
- ? `ui.sync.do.source.new.reading${c.reading.edited ? ".edited" : ""}`
167
- : c.kind.startsWith("source.") || c.kind === "fact.cite" || c.kind === "person.cite" || c.kind === "family.cite"
168
- ? `ui.sync.do.${c.kind}${c.reads || (c.kind === "source.new" && newReads(c, incoming)) ? ".read" : ""}`
169
- : c.kind === "fact.new" && c.reads
170
- ? "ui.sync.do.reading"
171
- // a fact on an entry of the research's reading of a document: as sure as its readers read it
172
- : c.kind === "fact.new" && c.fact?.cites?.some((x) => readings?.get(x.source)?.ok)
173
- ? "ui.sync.do.reading.document"
174
- : `ui.sync.do.${c.action}`;
151
+ : c.kind === "name.shown"
152
+ ? `ui.sync.do.name.shown${c.action === "conflict" ? ".conflict" : ""}`
153
+ : c.kind === "name.gone"
154
+ ? `ui.sync.do.name.${c.action === "remove" ? "gone" : "kept"}`
155
+ : c.kind === "name.title" && (c.action === "add" || c.action === "user")
156
+ ? `ui.sync.do.title${c.action === "user" ? ".user" : ""}`
157
+ : c.kind === "note.changed" && c.action !== "pick"
158
+ ? "ui.sync.do.note.changed"
159
+ : c.kind === "family.union" && c.action !== "report"
160
+ ? "ui.sync.do.family.union"
161
+ : c.kind === "fact.detail" && c.action === "add"
162
+ ? "ui.sync.do.detail"
163
+ : c.kind === "fact.part" && (c.action === "add" || c.action === "user")
164
+ ? `ui.sync.do.part${c.action === "user" ? ".user" : ""}`
165
+ : c.kind === "source.title"
166
+ ? `ui.sync.do.source.title${c.action === "add" ? ".beside" : c.action === "user" ? ".user" : ""}`
167
+ : c.kind === "source.new" && c.reading?.ok
168
+ ? `ui.sync.do.source.new.reading${c.reading.edited ? ".edited" : ""}`
169
+ : c.kind.startsWith("source.") || c.kind === "fact.cite" || c.kind === "person.cite" || c.kind === "family.cite"
170
+ ? `ui.sync.do.${c.kind}${c.reads || (c.kind === "source.new" && newReads(c, incoming)) ? ".read" : ""}`
171
+ : c.kind === "fact.new" && c.reads
172
+ ? "ui.sync.do.reading"
173
+ // a fact on an entry of the research's reading of a document: as sure as its readers read it
174
+ : c.kind === "fact.new" && c.fact?.cites?.some((x) => readings?.get(x.source)?.ok)
175
+ ? "ui.sync.do.reading.document"
176
+ : `ui.sync.do.${c.action}`;
175
177
  // an archive: nobody reads a record there, nor is an agent told — said without it
176
178
  const said = actsAsArchive(tree) && `${does}.archive` in UI ? `${does}.archive` : does;
177
179
  return `${ui(lang, key, v)} → ${ui(lang, said)}`;
@@ -5,7 +5,7 @@ import { normalizeDate, reversedRange } from "./gdate.js";
5
5
  import { normalizeAge } from "./age.js";
6
6
  import { checkStatus, defaultStatus } from "./evidence.js";
7
7
  import { eventKind, FAMILY_EVENT_KINDS, NOTE_MAX, STATUSES, PARTICIPANT_ROLES, NAME_KINDS, RECORD_TYPES, } from "./model.js";
8
- import { cleanTitle, displayName, familiesAsChild, familiesAsPartner, gedcomName, gedcomTitledName, closesCircle, isAncestorOf, isBirthFamily, keepSides, notAName, parseName, primaryName, sameName, slashInName } from "./people.js";
8
+ import { cleanTitle, displayName, familiesAsChild, familiesAsPartner, gedcomName, gedcomTitledName, closesCircle, isAncestorOf, isBirthFamily, keepSides, notAName, parseName, primaryName, sameName, slashInName, withoutMark } from "./people.js";
9
9
  import { foldText } from "./text.js";
10
10
  import { roleWord } from "./roles.js";
11
11
  import { checkParticipant, trailingNote } from "./participants.js";
@@ -651,8 +651,8 @@ export function addName(tree, id, input) {
651
651
  throw new UsageError(`invalid --kind "${input.kind}"`, { hint: NAME_KINDS.join(", ") });
652
652
  name.kind = input.kind;
653
653
  }
654
- if (input.primary && name.kind && name.kind !== "birth")
655
- throw new UsageError(`a person is shown by a birth name, not a ${name.kind} one`, { hint: "drop --primary, or --kind birth" });
654
+ if (input.first && name.kind && name.kind !== "birth")
655
+ throw new UsageError(`a person's identity is a birth name, not a ${name.kind} one`);
656
656
  if (input.citation)
657
657
  checkCitation(tree, input.citation);
658
658
  return tree.withTreeLock(() => {
@@ -667,11 +667,12 @@ export function addName(tree, id, input) {
667
667
  const rekind = name.kind !== undefined && name.kind !== cur.kind;
668
668
  const retitled = withTitles(cur, input.prefix, input.suffix);
669
669
  const titles = retitled.prefix !== cur.prefix || retitled.suffix !== cur.suffix;
670
- if (!input.citation && !input.primary && !rekind && !titles)
671
- throw new UsageError(`${id} already has the name ${gedcomName(name)}`, { hint: `cite it: strom name add ${id} "${gedcomName(name)}" --cite S…` });
672
- added = { ...retitled, ...(rekind ? { kind: name.kind } : {}), ...(input.citation ? { citations: withCitation(cur.citations, input.citation, `${id} ${gedcomName(name)}`) } : {}) };
670
+ const marks = !!input.primary && !cur.primary;
671
+ if (!input.citation && !marks && !input.first && !rekind && !titles)
672
+ throw new UsageError(input.primary ? `${id} is shown by ${gedcomName(cur)} already` : `${id} already has the name ${gedcomName(name)}`, { hint: `cite it: strom name add ${id} "${gedcomName(name)}" --cite S…` });
673
+ added = { ...retitled, ...(rekind ? { kind: name.kind } : {}), ...(input.primary ? { primary: true } : {}), ...(input.citation ? { citations: withCitation(cur.citations, input.citation, `${id} ${gedcomName(name)}`) } : {}) };
673
674
  names = p.names.map((n, j) => (j === i ? added : n));
674
- if (input.primary)
675
+ if (input.first)
675
676
  names = [added, ...names.filter((n) => n !== added)];
676
677
  }
677
678
  else {
@@ -681,19 +682,62 @@ export function addName(tree, id, input) {
681
682
  const plain = !name.kind || name.kind === "birth";
682
683
  // "Markéta" of the family memory is "Markéta /Růžičková/" of the register: one name, now complete.
683
684
  completes = Boolean(!input.other && plain && !primary.surname && name.surname && foldText(primary.given) === foldText(name.given) && !primary.citations?.length);
685
+ // the name completed was the one the person is shown by: the full one is
686
+ if (input.primary || (completes && primary.primary))
687
+ added = { ...added, primary: true };
684
688
  const rest = completes ? p.names.filter((n) => n !== primary) : p.names;
685
- names = !input.other && (input.primary || (plain && !primary.surname && name.surname)) ? [added, ...rest] : [...rest, added];
689
+ names = !input.other && (input.first || (plain && !primary.surname && name.surname)) ? [added, ...rest] : [...rest, added];
686
690
  }
691
+ // one name the person is shown by: the one marked now
692
+ if (added.primary)
693
+ names = names.map((n) => (n === added || !n.primary ? n : withoutMark(n)));
687
694
  const updated = { ...p, names, updated: now() };
688
695
  tree.put(updated, {
689
696
  op: "person.name",
690
697
  targets: [id],
691
- summary: `${id} name ${gedcomTitledName(added)}${added.kind ? ` (${added.kind})` : ""}${input.citation ? ` ← ${input.citation.source}` : ""}`,
698
+ summary: `${id} name ${gedcomTitledName(added)}${added.kind ? ` (${added.kind})` : ""}${input.citation ? ` ← ${input.citation.source}` : ""}${input.primary ? "; shown by it" : ""}`,
692
699
  ...(completes ? { reason: `the name without a surname is completed: ${gedcomName(added)}` } : {}),
693
700
  });
694
701
  return { person: updated, name: added };
695
702
  });
696
703
  }
704
+ /**
705
+ * Show a person by one of their names (the mark Name.primary: a divorced woman back at her birth surname, spec 024),
706
+ * without adding it again — or, with no name, by the name of their identity again (the mark taken off). The user's
707
+ * choice: never told from a record, never needs one.
708
+ */
709
+ export function markPrimaryName(tree, id, text, reason) {
710
+ const wanted = text === undefined ? undefined : parseName(text);
711
+ if (wanted && !wanted.given && !wanted.surname)
712
+ throw new UsageError("the name is empty");
713
+ return tree.withTreeLock(() => {
714
+ const p = requirePerson(tree, id);
715
+ const was = p.names.find((n) => n.primary);
716
+ let name;
717
+ if (wanted) {
718
+ const found = p.names.find((n) => sameName(n, wanted));
719
+ if (!found) {
720
+ const gone = p.retractedNames?.find((n) => sameName(n, wanted));
721
+ if (gone)
722
+ throw new UsageError(`the name ${gedcomName(gone)} of ${id} was retracted: ${gone.retracted.reason}`, { hint: `right after all: strom name restore ${id} "${gedcomName(gone)}" --reason "…"`, code: "name.retracted", params: { name: gedcomName(gone), id, reason: gone.retracted.reason } });
723
+ throw new UsageError(`${id} has no name ${gedcomName(wanted)}`, { hint: `its names: ${p.names.map((n) => `"${gedcomName(n)}"`).join(", ")} · a new one: strom name add ${id} "${gedcomName(wanted)}" --primary`, code: "name.none" });
724
+ }
725
+ if (found.primary)
726
+ throw new UsageError(`${id} is shown by ${gedcomName(found)} already`, { hint: `back to the name of their identity: strom name primary ${id} --clear` });
727
+ name = { ...found, primary: true };
728
+ const names = p.names.map((n) => (n === found ? name : n.primary ? withoutMark(n) : n));
729
+ const updated = { ...p, names, updated: now() };
730
+ tree.put(updated, { op: "name.primary", targets: [id], summary: `${id} shown by ${gedcomName(name)}${was ? ` (was ${gedcomName(was)})` : ""}`, ...(reason?.trim() ? { reason: reason.trim() } : {}) });
731
+ return { person: updated, name, ...(was ? { was } : {}) };
732
+ }
733
+ if (!was)
734
+ throw new UsageError(`no name of ${id} is marked: shown by ${gedcomName(primaryName(p))}`, { hint: `strom name primary ${id} "<Given /Surname/>"` });
735
+ const updated = { ...p, names: p.names.map((n) => (n === was ? withoutMark(n) : n)), updated: now() };
736
+ name = primaryName(updated);
737
+ tree.put(updated, { op: "name.primary", targets: [id], summary: `${id} shown by ${gedcomName(name)} again (the mark of ${gedcomName(was)} taken off)`, ...(reason?.trim() ? { reason: reason.trim() } : {}) });
738
+ return { person: updated, name, was };
739
+ });
740
+ }
697
741
  /** Change a person's sex or correct the spelling of their name — with a reason, unless an unknown sex is filled in. */
698
742
  export function editPerson(tree, id, edit, reason) {
699
743
  const sex = edit.sex === undefined ? undefined : parseSex(edit.sex);
@@ -925,13 +969,20 @@ export function mergePersons(tree, keepId, otherId, reason) {
925
969
  const names = keep.names.map((n) => {
926
970
  const same = other.names.find((o) => sameName(o, n));
927
971
  const cites = [...(n.citations ?? []), ...(same?.citations ?? []).filter((c) => !(n.citations ?? []).some((x) => x.source === c.source && x.locator === c.locator))];
928
- return cites.length ? { ...n, citations: cites } : n;
972
+ // the other shown by this same name, the kept one by none marked: shown by it
973
+ const mark = same?.primary && !keep.names.some((k) => k.primary) ? { primary: true } : {};
974
+ return cites.length ? { ...n, ...mark, citations: cites } : { ...n, ...mark };
929
975
  });
930
976
  // a name the person's research took back stays taken back (the other's retracted come along, for the history)
931
977
  const retractedNames = [...(keep.retractedNames ?? []), ...(other.retractedNames ?? [])];
932
978
  for (const o of other.names)
933
979
  if (!names.some((n) => sameName(n, o)) && !retractedNames.some((n) => sameName(n, o)))
934
980
  names.push(o);
981
+ // the name they are shown by: the kept person's, else the other's — one
982
+ const marked = names.find((n) => n.primary);
983
+ for (let i = 0; i < names.length; i++)
984
+ if (names[i].primary && names[i] !== marked)
985
+ names[i] = withoutMark(names[i]);
935
986
  const retractedNotes = [...(keep.retractedNotes ?? []), ...(other.retractedNotes ?? [])];
936
987
  const refs = [...(keep.refs ?? []), ...(other.refs ?? []).filter((r) => !(keep.refs ?? []).some((k) => k.system === r.system && k.id === r.id))];
937
988
  const why = reason.trim();
@@ -365,6 +365,11 @@ export function changeLines(tree, ops, subject, lang, was, tasks) {
365
365
  say(m ? phrase(lang, o.op === "name.retract" ? "log.name.retract" : "log.name.restore", { name: m[1].replace(/\//g, " ").replace(/\s+/g, " ").trim(), who: who(first) }) : changed(o));
366
366
  continue;
367
367
  }
368
+ if (o.op === "name.primary") {
369
+ const m = /^\S+ shown by (.+?)( again \(|(?: \(was |$))/.exec(o.summary);
370
+ say(m ? phrase(lang, m[2] === " again (" ? "log.name.primary.clear" : "log.name.primary", { name: m[1].replace(/\//g, " ").replace(/\s+/g, " ").trim(), who: who(first) }) : changed(o));
371
+ continue;
372
+ }
368
373
  if (o.op === "note.remove") {
369
374
  const m = /^\S+ note "(.*)" removed:/.exec(o.summary);
370
375
  say(m ? phrase(lang, "log.note.remove", { note: m[1], name: name(first) }) : changed(o));
@@ -12,7 +12,7 @@ import { roleWord } from "./roles.js";
12
12
  import { validateRecord } from "./validate.js";
13
13
  import { normalizeDate } from "./gdate.js";
14
14
  import { isRetractedName } from "./retractions.js";
15
- import { appNoSurname, cleanTitle, gedcomName, noName, notAName, parseName, sameName, surnameAfterGiven, withoutTitles } from "./people.js";
15
+ import { appNoSurname, cleanTitle, gedcomName, noName, notAName, parseName, sameName, surnameAfterGiven, withoutMark, withoutTitles } from "./people.js";
16
16
  import { update } from "./records.js";
17
17
  import { now } from "./tree.js";
18
18
  import { appUnknownIsNoSurname, appWritesSexUnknown } from "./stromapp.js";
@@ -373,9 +373,14 @@ export function importGedcom(tree, text, opts) {
373
373
  if (notAName(found.given))
374
374
  found.given = "?";
375
375
  // a slash inside a name ("/⟨K/Č⟩emenská/") is kept as "|": the name stays one GEDCOM can write
376
- return { ...found, given: found.given.replace(/\//g, "|"), surname: found.surname.replace(/\//g, "|") };
376
+ const name = { ...found, given: found.given.replace(/\//g, "|"), surname: found.surname.replace(/\//g, "|") };
377
+ // the name the person is shown by now (the Strom file's mark, the user's choice): the first one marked
378
+ if (/^y(es)?$/i.test(val(n, "_STROM_PRIMARY")?.trim() ?? ""))
379
+ name.primary = true;
380
+ return name;
377
381
  })
378
- .map((n) => (n.given || n.surname ? n : { ...n, given: "?", surname: "" }));
382
+ .map((n) => (n.given || n.surname ? n : { ...n, given: "?", surname: "" }))
383
+ .map((n, i, all) => (n.primary && all.findIndex((m) => m.primary) !== i ? withoutMark(n) : n));
379
384
  // a married name other programs give apart (_MARNM): a name of the married kind, unless the person has it already
380
385
  return [...names, ...importedMarnm(r, fromApp).filter((m) => !names.some((n) => sameName(n, m)))];
381
386
  };
@@ -407,7 +412,8 @@ export function importGedcom(tree, text, opts) {
407
412
  const facts = incoming.filter((e) => !knownFact(cur.events, e));
408
413
  result.events -= incoming.length - facts.length;
409
414
  // a name the research took back is not taken again from a file that still has it
410
- const names = namesOf(r).filter((n) => n.given !== "?" && !cur.names.some((x) => sameName(x, n)) && !isRetractedName(cur, n));
415
+ // the names another file adds: never the one the person is shown by (theirs stands)
416
+ const names = namesOf(r).filter((n) => n.given !== "?" && !cur.names.some((x) => sameName(x, n)) && !isRetractedName(cur, n)).map(withoutMark);
411
417
  if (!facts.length && !names.length)
412
418
  continue;
413
419
  const next = { ...cur, names: [...cur.names, ...names], events: [...cur.events, ...facts], refs: [...(cur.refs ?? []), { system, id: r.xref }], updated: now() };
package/dist/core/live.js CHANGED
@@ -917,7 +917,7 @@ function idsOf(applied, known) {
917
917
  return Object.keys(ids.persons).length || Object.keys(ids.sources).length ? { ids } : {};
918
918
  }
919
919
  /** What the bridge does that an app may ask about (each added once, never taken away). */
920
- export const BRIDGE_FEATURES = ["sync.again", "sync.undoneSince", "sync.takenBack", "sync.conflictEdit", "sync.since", "sync.ids", "family.noCouple", "family.alone", "adopt.transfer", "adopt.empty", "material.list", "person.titles", "media.codes", "hypothesis.links", "status.recent", "spend.readers", "hypothesis.intree", "archive.permission", "task.check", "waiting.groups", "waiting.sent", "waiting.note", "batch.kept", "live.end", "whats.new", "terminal.choice", "person.marriedSurname", "phone", "read.now", "read.sync", "excerpt.get", "log.sources", "material.assign", "material.thumb", "seal.foreign", "tree.state", "disk.full", "tree.notDownloaded", "sync.removed", "tree.kind", "experiments", "experiments.off", "experiments.link", "experiments.split", "history.events", "history.hide", "experiments.pace", "history.first", "history.regions", "history.hide.all", "history.skip", "history.map", "history.delete", "history.delete.restore", "stories.history", "history.place", "surnames.suggest", "surnames.read", "duplicates.judge", "quality.dismiss", "quality.read"];
920
+ export const BRIDGE_FEATURES = ["sync.again", "sync.undoneSince", "sync.takenBack", "sync.conflictEdit", "sync.since", "sync.ids", "family.noCouple", "family.alone", "adopt.transfer", "adopt.empty", "material.list", "person.titles", "media.codes", "hypothesis.links", "status.recent", "spend.readers", "hypothesis.intree", "archive.permission", "task.check", "waiting.groups", "waiting.sent", "waiting.note", "batch.kept", "live.end", "whats.new", "terminal.choice", "person.marriedSurname", "phone", "read.now", "read.sync", "excerpt.get", "log.sources", "material.assign", "material.thumb", "seal.foreign", "tree.state", "disk.full", "tree.notDownloaded", "sync.removed", "tree.kind", "experiments", "experiments.off", "experiments.link", "experiments.split", "history.events", "history.hide", "experiments.pace", "history.first", "history.regions", "history.hide.all", "history.skip", "history.map", "history.delete", "history.delete.restore", "stories.history", "history.place", "surnames.suggest", "surnames.read", "duplicates.judge", "quality.dismiss", "quality.read", "person.primary"];
921
921
  /** /status recent, when it is ready. */
922
922
  function recentOf(root, env, head) {
923
923
  const recent = head ? recentNow(root, env, head) : undefined;
@@ -2,7 +2,7 @@
2
2
  // ancestors of one person (pedigree), one person's life (card), and what came
3
3
  // in lately (recent). Read only, from the records and the operation logs; the
4
4
  // commands in commands/browse.ts show them in the research language.
5
- import { ancestorGenerations, birthEvent, displayName, familiesAsPartner, formatName, lifespan, parentsOf, primaryName, sameName, titledName } from "./people.js";
5
+ import { ancestorGenerations, birthEvent, currentName, displayName, familiesAsPartner, formatName, lifespan, parentsOf, sameName, titledName } from "./people.js";
6
6
  import { dateYears } from "./gdate.js";
7
7
  import { informationOf } from "./evidence.js";
8
8
  import { imagesIndex } from "./mediaindex.js";
@@ -159,7 +159,8 @@ export function cardFact(tree, e) {
159
159
  };
160
160
  }
161
161
  export function personCard(tree, p) {
162
- const main = primaryName(p);
162
+ // the name they are shown by (the one the user chose, else their birth name): the others beside it
163
+ const main = currentName(p);
163
164
  const families = familiesAsPartner(tree, p.id).map((f) => {
164
165
  const other = f.partners.filter((x) => x !== p.id).map((x) => tree.get(x)).find((x) => x && !x.retracted);
165
166
  return {
@@ -179,8 +179,21 @@ export function slashInName(n) {
179
179
  export function primaryName(p) {
180
180
  return p.names.find((n) => !n.kind || n.kind === "birth") ?? p.names[0] ?? { given: "", surname: "" };
181
181
  }
182
+ /**
183
+ * The name the person is shown by now: the one marked (Name.primary, the user's choice — a divorced woman back at her
184
+ * birth surname), else the name of their identity (primaryName). Only for showing them: the identity a correction, a
185
+ * citation, pairing and a sync go by stays primaryName.
186
+ */
187
+ export function currentName(p) {
188
+ return p.names.find((n) => n.primary) ?? primaryName(p);
189
+ }
190
+ /** A name without the mark of the one the person is shown by (Name.primary). */
191
+ export function withoutMark(n) {
192
+ const { primary: _p, ...rest } = n;
193
+ return rest;
194
+ }
182
195
  export function displayName(p) {
183
- return formatName(primaryName(p));
196
+ return formatName(currentName(p));
184
197
  }
185
198
  const STATUS_RANK = { proven: 0, probable: 1, possible: 2, lead: 3 };
186
199
  /** Best-supported first: status, then the number of citations, then the order they were added. */
@@ -271,6 +271,8 @@ export const PHRASES = {
271
271
  "log.note.edit": "Note corrected: {name}",
272
272
  "log.name.retract": "Name retracted: {name} – {who}",
273
273
  "log.name.restore": "Name put back: {name} – {who}",
274
+ "log.name.primary": "Shown by the name: {name} – {who}",
275
+ "log.name.primary.clear": "Shown by the birth name again: {name} – {who}",
274
276
  "log.note.retract": "Note retracted: {name}",
275
277
  "log.note.restore": "Note put back: {name}",
276
278
  "log.event.status": "How sure a fact is, changed: {who} – {fact}",
@@ -218,6 +218,39 @@ export function regionEventsAt(tree, shared, id, at = factsByRecord(tree).get(id
218
218
  }
219
219
  return [...out.values()].sort((a, b) => a.event.from - b.event.from || a.event.id.localeCompare(b.event.id));
220
220
  }
221
+ /**
222
+ * The own events of the tree's places that belong to a region in the years of an event (their facts' places mapped to
223
+ * it): the mirror of regionEventsAt, for a place looked for before its region (spec 020 K7.1a). Not taken back.
224
+ */
225
+ export function placeEventsIn(tree, shared, region, e) {
226
+ if (!shared)
227
+ return [];
228
+ const maps = readPlaces(tree);
229
+ const folded = new Map(Object.entries(maps).map(([k, v]) => [foldText(k), v]));
230
+ const out = [];
231
+ for (const [id, at] of factsByRecord(tree)) {
232
+ const inIt = at.keys.some((key) => ((maps[key] ?? folded.get(foldText(key)))?.regions ?? []).some((g) => g.id === region && inRegion(e, g)));
233
+ const p = inIt ? placeRecord(tree, id) : undefined;
234
+ if (!p)
235
+ continue;
236
+ for (const x of readPlaceCatalog(shared, placeKeyOf(tree, p))?.events ?? [])
237
+ if (!x.retracted)
238
+ out.push({ event: x, place: p });
239
+ }
240
+ return out.sort((a, b) => a.event.from - b.event.from || a.event.id.localeCompare(b.event.id));
241
+ }
242
+ /** An event of one of the region's places of the same kind and years: the region's history never writes it again. */
243
+ export function refusePlaceSame(tree, shared, region, e) {
244
+ const same = placeEventsIn(tree, shared, region, e).filter((x) => x.event.kind === e.kind && x.event.from <= e.to && e.from <= x.event.to);
245
+ if (!same.length)
246
+ return;
247
+ const ids = same.map((x) => `${x.event.id} (${x.place.id})`);
248
+ throw new UsageError(`an event of a place of ${region} of the same kind and years is there already: ${ids.join(", ")} — nothing written`, {
249
+ code: "history.same.place",
250
+ params: { ids: same.map((x) => x.event.id).join(", ") },
251
+ hint: `the place's event shows there already — leave it out; an event of the whole region (it touched its other places too): --distinct "<why it is another>"`,
252
+ });
253
+ }
221
254
  // ── the person's asks ──────────────────────────────────────────────────────
222
255
  export function readPlaceAsks(tree) {
223
256
  try {
@@ -5,7 +5,7 @@
5
5
  import { UsageError } from "./errors.js";
6
6
  import { findEventOwner, makeNote } from "./actions.js";
7
7
  import { EMBEDDED_PREFIXES } from "./model.js";
8
- import { gedcomName, parseName, primaryName, sameName } from "./people.js";
8
+ import { currentName, gedcomName, parseName, primaryName, sameName, withoutMark } from "./people.js";
9
9
  import { now, typeOfId } from "./tree.js";
10
10
  function needsReason(reason, what) {
11
11
  const why = reason?.trim();
@@ -48,8 +48,8 @@ export function retractName(tree, id, text, reason) {
48
48
  });
49
49
  const gone = { ...name, retracted: retraction(tree, why) };
50
50
  const updated = { ...p, names: p.names.filter((n) => n !== name), retractedNames: [...(p.retractedNames ?? []), gone], updated: now() };
51
- const shown = primaryName(updated);
52
- const moved = name === primaryName(p);
51
+ const shown = currentName(updated);
52
+ const moved = name === currentName(p);
53
53
  tree.put(updated, {
54
54
  op: "name.retract",
55
55
  targets: [id],
@@ -69,7 +69,9 @@ export function restoreName(tree, id, text, reason) {
69
69
  throw new UsageError(`${id} has no retracted name ${gedcomName(wanted)}`, { hint: `its retracted names: ${nameList(p.retractedNames ?? [])}`, code: "name.none" });
70
70
  if (p.names.some((n) => sameName(n, gone)))
71
71
  throw new UsageError(`${id} has the name ${gedcomName(gone)} again`, { hint: `nothing to restore: strom person show ${id}` });
72
- const { retracted: _was, ...name } = gone;
72
+ // the name they were shown by, back as that one unless another is marked since
73
+ const { retracted: _was, ...back } = gone;
74
+ const name = back.primary && p.names.some((n) => n.primary) ? withoutMark(back) : back;
73
75
  const rest = (p.retractedNames ?? []).filter((n) => n !== gone);
74
76
  const { retractedNames: _all, ...base } = p;
75
77
  const updated = { ...base, names: [...p.names, name], ...(rest.length ? { retractedNames: rest } : {}), updated: now() };
@@ -537,6 +537,27 @@ export function appReadsSurnameGroups(settings, version, gate = APP_READS_SURNAM
537
537
  return compareVersions(v, APP_READS_SURNAME_MARKERS) >= 0;
538
538
  return !!gate && compareVersions(v, gate) >= 0;
539
539
  }
540
+ /**
541
+ * The first Strom app that knows which name a person is shown by (spec 024, the app's ZADANI_VYZKUM_soucasne-prijmeni.md):
542
+ * it writes 2 _STROM_PRIMARY Y under the NAME the user chose (its Person.primaryName birth | married, the JSON's field
543
+ * of that name) and none where the user chose none. The research writes the mark to every app (an older one skips it in
544
+ * silence and loses nothing); a mark an app sends is the user's choice whatever its version — but a mark missing is
545
+ * the user's "none" only from an app of this version on: from an older one, or one that says no version, nothing
546
+ * changed. Not released yet (undefined): set to the version the app's "je venku" names; another copy of the app (its
547
+ * beta, its development) gets it at once from APP_PRIMARY_NAME_BETA once that is known too.
548
+ */
549
+ export const APP_KNOWS_PRIMARY_NAME = undefined;
550
+ /** The first beta of the app that writes the mark (another copy of the app gets it from it); undefined: none yet. */
551
+ export const APP_PRIMARY_NAME_BETA = undefined;
552
+ /** Does a file of the Strom app of this version say every choice of the name a person is shown by — a mark missing the user's "none"? */
553
+ export function appKnowsPrimaryName(settings, version) {
554
+ const v = version?.trim();
555
+ if (!v || !isAppVersion(v))
556
+ return false;
557
+ if (anotherCopy(settings) && APP_PRIMARY_NAME_BETA)
558
+ return compareVersions(v, APP_PRIMARY_NAME_BETA) >= 0;
559
+ return !!APP_KNOWS_PRIMARY_NAME && compareVersions(v, APP_KNOWS_PRIMARY_NAME) >= 0;
560
+ }
540
561
  /** The address that opens the Strom app to send the user's edited tree to the bridge. */
541
562
  export function sendAppUrl(bridge, settings) {
542
563
  return `${stromAppUrl(settings)}?send=${encodeURIComponent(bridge)}`;
package/dist/core/sync.js CHANGED
@@ -24,7 +24,7 @@ import path from "node:path";
24
24
  import { CHILD_RELATIONS, UNIONS, eventKind } from "./model.js";
25
25
  import { isWeak } from "./evidence.js";
26
26
  import { phrase } from "./phrases.js";
27
- import { addChild, addEvent, addFamily, addName, addNote, editEvent, editPerson, retractEvent, retractPerson } from "./actions.js";
27
+ import { addChild, addEvent, addFamily, addName, addNote, editEvent, editPerson, markPrimaryName, retractEvent, retractPerson } from "./actions.js";
28
28
  import { exportGedcom, REFN_TYPE } from "../gedcom/export.js";
29
29
  import { children, parseGedcomText, val } from "../gedcom/parse.js";
30
30
  import { fromFlexDate, houseOf, importDate, marnmNames, readGedName, readGedSex, stromAppFile } from "./import.js";
@@ -42,14 +42,14 @@ import * as git from "./git.js";
42
42
  import { now, Tree, typeOfId } from "./tree.js";
43
43
  import { actsAsArchive } from "./state.js";
44
44
  import { appSides } from "./conflicts.js";
45
- import { APP_DATA_SEX_UNKNOWN, appKeepsPeriods, appReadsSurnameGroups, isAppVersion } from "./stromapp.js";
45
+ import { APP_DATA_SEX_UNKNOWN, appKeepsPeriods, appKnowsPrimaryName, appReadsSurnameGroups, isAppVersion } from "./stromapp.js";
46
46
  import { Settings } from "./config.js";
47
47
  import { applySurnameChange, formsSaid, planSurnames, readMarkLines, readSurnames, surnamesAt, undoSurnameChange } from "./surnames.js";
48
48
  import { SURNAME_GROUPS_MARK, SURNAME_REJECTIONS_MARK } from "../gedcom/export.js";
49
49
  import { bestGrade, checkReading, readingGrade, readingResult, readingSource, surer, takeReadingImages } from "./readsync.js";
50
50
  import { earlierNoteTexts } from "./retractions.js";
51
51
  import { mendAll } from "./damaged.js";
52
- import { cleanTitle, familiesAsPartner, formatName, foundTitles, gedcomTitledName, parseName, primaryName, sameName as sameWords, titledName } from "./people.js";
52
+ import { cleanTitle, currentName, familiesAsPartner, formatName, foundTitles, gedcomTitledName, parseName, primaryName, sameName as sameWords, titledName } from "./people.js";
53
53
  /** The words of a _STAT (the Strom app's own, also Legacy's and FTM's) as a union, or nothing it names. */
54
54
  function unionOfStat(value) {
55
55
  const v = (value ?? "").trim().toLowerCase();
@@ -480,6 +480,8 @@ export function readGedcom(text) {
480
480
  const head = records.find((r) => r.tag === "HEAD");
481
481
  /** A file of the Strom app (its HEAD: 1 SOUR STROM): its own ways of writing are read as it means them. */
482
482
  const fromApp = stromAppFile(head);
483
+ // the research's own file (what it gave the app, the research now): it says every choice of a name shown by
484
+ const research = val(head, "SOUR")?.trim() === "STROM_RESEARCH";
483
485
  const notes = new Map(records.filter((r) => r.tag === "NOTE" && r.xref).map((r) => [r.xref, r.value]));
484
486
  const noteText = (n) => (/^@[^@]+@$/.test(n.value.trim()) ? (notes.get(n.value.trim()) ?? "") : n.value);
485
487
  // the sources: ours by their REFN, the file's own by their xref
@@ -567,6 +569,12 @@ export function readGedcom(text) {
567
569
  // a placeholder of the Strom app (an unknown parent drawn in the tree): nobody
568
570
  if (!ours && !names.length)
569
571
  continue;
572
+ // the name they are shown by (spec 024) as the app sees it: its birth name (the first), its one name after a marriage
573
+ // (the last TYPE married, else the last _MARNM), else none — a mark missing says it only in the research's own file
574
+ const mi = kept.findIndex(({ n }) => /^y(es)?$/i.test(val(n, "_STROM_PRIMARY")?.trim() ?? ""));
575
+ const typed = names.filter((_, i) => i > 0 && i < kept.length && kept[i].name.kind === "married");
576
+ const appMarried = typed.at(-1) ?? married.at(-1);
577
+ const primary = mi === 0 ? "birth" : mi > 0 && names[mi] === appMarried ? `married|${nameKey(names[mi])}` : mi > 0 || research ? "none" : undefined;
570
578
  const facts = gedFacts(r, undefined, read);
571
579
  // a godparent other programs give the person (1 ASSO): theirs at the baptism, else the birth
572
580
  const host = facts.find((f) => kindOf(f.kind) === "BAPM") ?? facts.find((f) => f.kind === "BIRT");
@@ -587,6 +595,7 @@ export function readGedcom(text) {
587
595
  ...(cites.length ? { cites } : {}),
588
596
  ...(titles ? { titles } : {}),
589
597
  ...(married.length ? { married } : {}),
598
+ ...(primary ? { primary } : {}),
590
599
  });
591
600
  }
592
601
  // how a child belongs to a family (the child's FAMC + PEDI: adopted, foster, step): none said is birth
@@ -826,8 +835,11 @@ export function readStromJson(data) {
826
835
  const marriedSurname = typeof p.marriedSurname === "string" ? p.marriedSurname.replace(/[/\s]+/g, " ").trim() : "";
827
836
  const married = marriedSurname ? `${p.firstName ?? ""} /${marriedSurname}/`.trim() : undefined;
828
837
  const names = [name, ...(married ? [married] : []), ...(p.nameVariants ?? [])].filter((n) => n.replace(/[/?\s]/g, ""));
838
+ // the name the user chose to show them by (spec 024): the birth one, the one after the marriage; none said: nothing
839
+ const primary = p.primaryName === "birth" ? "birth" : p.primaryName === "married" && married ? `married|${nameKey(married)}` : undefined;
829
840
  persons.set(key, {
830
841
  key,
842
+ ...(primary ? { primary } : {}),
831
843
  ...(titles ? { titles } : {}),
832
844
  names: names.filter((n, i) => n !== married || names.findIndex((m) => nameKey(m) === nameKey(n)) === i),
833
845
  ...(married && names.findIndex((n) => nameKey(n) === nameKey(married)) > 0 ? { married: [married] } : {}),
@@ -1917,6 +1929,8 @@ export function planSync(tree, incoming, edits, opts = {}) {
1917
1929
  });
1918
1930
  }
1919
1931
  const partial = !!base && incoming.persons.size < base.persons.size / 2;
1932
+ // a file that says every choice of the name a person is shown by: its mark missing is the user's "none" (spec 024)
1933
+ const knowsPrimary = appKnowsPrimaryName(new Settings(tree.env, {}), incoming.appVersion);
1920
1934
  const changes = [];
1921
1935
  const push = (c) => changes.push({ ...c, n: changes.length + 1 });
1922
1936
  /** What the file no longer has: an archive takes it away — unless the window that sends never had it (kept, said). */
@@ -2220,6 +2234,34 @@ export function planSync(tree, incoming, edits, opts = {}) {
2220
2234
  push({ kind: "name.gone", action: nameBacked(tree, mine) ? "report" : "remove", person: id, name, text: gedcomName(mine) });
2221
2235
  }
2222
2236
  };
2237
+ /**
2238
+ * The name the person is shown by (spec 024): the user's choice, never a record's — compared as the app sees it
2239
+ * (SPerson.primary). Changed in the app only: taken; in the research only: the research's, given the app again; both,
2240
+ * apart: the user's to decide (or their word: sync.edits user, an archive). A mark missing from an app before
2241
+ * APP_KNOWS_PRIMARY_NAME (or one that says no version) changes nothing; without the state it was given only a choice
2242
+ * where the research has none is taken.
2243
+ */
2244
+ const primaryDiff = (id, name, p, o, b) => {
2245
+ const theirs = p.primary ?? (knowsPrimary ? "none" : undefined);
2246
+ const mine = o.primary ?? "none";
2247
+ if (theirs === undefined || theirs === mine)
2248
+ return;
2249
+ let action;
2250
+ if (b) {
2251
+ const given = b.primary ?? "none";
2252
+ if (theirs === given)
2253
+ return;
2254
+ action = mine !== given ? (edits === "user" ? "user" : "conflict") : "correct";
2255
+ }
2256
+ else {
2257
+ if (mine !== "none" || theirs === "none")
2258
+ return;
2259
+ action = "add";
2260
+ }
2261
+ // as the line says it: the name each side shows them by ("" none chosen)
2262
+ const line = (x, view) => (view === "birth" ? (x.names[0] ?? "") : (x.names.find((n) => `married|${nameKey(n)}` === view) ?? ""));
2263
+ push({ kind: "name.shown", action, person: id, name, shown: { theirs, mine }, text: line(p, theirs), wasName: line(o, mine) });
2264
+ };
2223
2265
  /** The user's edits of a person: the file's (`p`) against the research's (`o`) and what the app was given (`b`). */
2224
2266
  const personDiff = (id, name, p, o, b) => {
2225
2267
  factsDiff({ person: id, name }, p.facts, o.facts, b?.facts, true);
@@ -2247,6 +2289,7 @@ export function planSync(tree, incoming, edits, opts = {}) {
2247
2289
  if (!theirMarried.has(n) && !((renamed || both) && n === now) && !ourNames.has(nameKey(n)) && !baseNames.has(nameKey(n)) && !goneNames.has(nameKey(n)))
2248
2290
  push({ kind: "name.new", action: "add", person: id, name, text: n });
2249
2291
  marriedDiff(id, name, p, o, b);
2292
+ primaryDiff(id, name, p, o, b);
2250
2293
  // the titles of the name they are shown by (T07), each apart and never as a change of the name itself: the user's
2251
2294
  // edit where the file's differs from what the app was given — one the name lacks added, a lead's corrected, a
2252
2295
  // record's name the user's to decide (or their word: sync.edits user, an archive); the research's changed since
@@ -3927,7 +3970,7 @@ export function applySync(tree, plan, incoming, source, only) {
3927
3970
  }
3928
3971
  else if (c.action === "user") {
3929
3972
  // the user's word wins: shown by the new name, the record's kept beside it
3930
- const { name } = addName(tree, owner, { name: c.text, citation: cite("name"), primary: true });
3973
+ const { name } = addName(tree, owner, { name: c.text, citation: cite("name"), first: true });
3931
3974
  applied.push({ do: "name.primary", id: owner, before: JSON.stringify({ added: exact(`${name.given}|${name.surname}`), shown: exact(`${shown.given}|${shown.surname}`) }) });
3932
3975
  }
3933
3976
  else {
@@ -3946,6 +3989,28 @@ export function applySync(tree, plan, incoming, source, only) {
3946
3989
  }
3947
3990
  break;
3948
3991
  }
3992
+ case "name.shown": {
3993
+ // the name the user chose to show the person by (spec 024): marked, or the mark taken off
3994
+ if (!owner || !c.shown)
3995
+ break;
3996
+ const p = tree.get(owner);
3997
+ const target = nameOfView(p, c.shown.theirs);
3998
+ const was = p.names.find((n) => n.primary);
3999
+ if (target === undefined || (target === null ? !was : target.primary))
4000
+ break;
4001
+ if (c.action === "conflict") {
4002
+ const who = gedcomName(primaryName(p)).replace(/\//g, "").replace(/\s+/g, " ").trim();
4003
+ const said = (n) => (n ? formatName(n) : ui(tree.lang, "ui.conflict.primary.none"));
4004
+ conflictOfEdit(tree, applied, owner, "NAME", editTitle(who, ui(tree.lang, "ui.conflict.primary"), said(was), said(target)), [
4005
+ { value: was ? gedcomName(was) : "", note: "the research: the name it shows the person by", text: said(was) },
4006
+ { source: source.id, value: target ? gedcomName(target) : "", note: "the user's edit", text: said(target) },
4007
+ ], undefined, undefined, true);
4008
+ break;
4009
+ }
4010
+ markPrimaryName(tree, owner, target ? gedcomName(target) : undefined, `the user's edit: ${reason}`);
4011
+ applied.push({ do: "name.mark", id: owner, before: was ? gedcomName(was) : "" });
4012
+ break;
4013
+ }
3949
4014
  case "name.title": {
3950
4015
  if (!owner || !c.title)
3951
4016
  break;
@@ -4274,6 +4339,20 @@ function claimValue(f) {
4274
4339
  * The title of a conflict of the user's edit, in the research's language — the Strom app shows it in what the research
4275
4340
  * knows (found on Mac: "Petr Svoboda: SEX — U × F" in a Czech research): "Petr Svoboda: Pohlaví — neznámé × žena".
4276
4341
  */
4342
+ /**
4343
+ * The research's name of a person the app's view of the name they are shown by names (SPerson.primary): "birth" the
4344
+ * name of their identity, "married|<key>" their name of that key (not the identity's), "none" null; undefined where the
4345
+ * research has no such name.
4346
+ */
4347
+ function nameOfView(p, view) {
4348
+ if (view === "none")
4349
+ return null;
4350
+ const first = primaryName(p);
4351
+ if (view === "birth")
4352
+ return first;
4353
+ const key = view.startsWith("married|") ? view.slice("married|".length) : undefined;
4354
+ return key === undefined ? undefined : p.names.find((n) => n !== first && nameKey(gedcomName(n)) === key);
4355
+ }
4277
4356
  function editTitle(who, what, ours, theirs) {
4278
4357
  return `${who.replace(/\//g, "").replace(/\s+/g, " ").trim()}: ${what.charAt(0).toUpperCase()}${what.slice(1)} — ${ours} × ${theirs}`;
4279
4358
  }
@@ -4363,6 +4442,19 @@ export function takeSide(tree, x, side, reason) {
4363
4442
  setTitle(tree, p.id, part, user.value, from, `the user's edit (${x.id}): ${reason}`);
4364
4443
  return `${p.id}: ${titledName(primaryName(tree.get(p.id)))}`;
4365
4444
  }
4445
+ // the name the user chose in the Strom app to show the person by (spec 024): marked, or none marked
4446
+ if (x.primary) {
4447
+ const user = x.claims.find((c) => c.note === "the user's edit");
4448
+ const p = x.subject[0] ? tree.get(x.subject[0]) : undefined;
4449
+ if (!p || p.retracted || !user)
4450
+ throw new UsageError(`the person of ${x.id} is no longer in the research`, { hint: `strom conflict show ${x.id}` });
4451
+ const target = user.value ? p.names.find((n) => sameWords(n, parseName(user.value))) : null;
4452
+ if (target === undefined)
4453
+ throw new UsageError(`${p.id} has no name ${user.value} any more`, { hint: `strom person show ${p.id}` });
4454
+ if (target ? !target.primary : p.names.some((n) => n.primary))
4455
+ markPrimaryName(tree, p.id, target ? gedcomName(target) : undefined, `the user's edit (${x.id}): ${reason}`);
4456
+ return `${p.id} shown as ${gedcomName(currentName(tree.get(p.id)))}`;
4457
+ }
4366
4458
  // a name or a sex the user gave in the Strom app (found on Mac: --take refused for them, only for facts and parents)
4367
4459
  if (!x.edit && (x.fact === "NAME" || x.fact === "SEX")) {
4368
4460
  const owner = x.subject[0];
@@ -4378,7 +4470,7 @@ export function takeSide(tree, x, side, reason) {
4378
4470
  const words = (v) => foldText(v.replace(/\//g, " ")).replace(/\s+/g, " ").trim();
4379
4471
  const kept = p.names.find((n) => words(`${n.given} ${n.surname}`) === words(user.value));
4380
4472
  const from = user.source && tree.get(user.source)?.type === "source" && !kept?.citations?.some((c) => c.source === user.source) ? { source: user.source, locator: "name" } : undefined;
4381
- const { name } = addName(tree, p.id, { name: user.value, primary: true, ...(from ? { citation: from } : {}) });
4473
+ const { name } = addName(tree, p.id, { name: user.value, first: true, ...(from ? { citation: from } : {}) });
4382
4474
  return `${p.id} shown as ${gedcomName(name)}`;
4383
4475
  }
4384
4476
  if (!x.edit)
@@ -4415,11 +4507,11 @@ function parentsText(tree, incoming, keys) {
4415
4507
  };
4416
4508
  return keys.map(shown).join(" & ") || "?";
4417
4509
  }
4418
- function conflictOfEdit(tree, applied, owner, fact, title, claims, edit, parents) {
4510
+ function conflictOfEdit(tree, applied, owner, fact, title, claims, edit, parents, primary) {
4419
4511
  const [ours, theirs] = claims;
4420
4512
  const open = tree
4421
4513
  .list("conflict")
4422
- .find((x) => x.state === "open" && x.fact === fact && x.subject[0] === owner && x.claims.length === 2 && x.claims[0].note === ours.note && x.claims[0].value === ours.value && x.claims[1].note === theirs.note);
4514
+ .find((x) => x.state === "open" && x.fact === fact && !!x.primary === !!primary && x.subject[0] === owner && x.claims.length === 2 && x.claims[0].note === ours.note && x.claims[0].value === ours.value && x.claims[1].note === theirs.note);
4423
4515
  if (open) {
4424
4516
  if (open.claims[1].value === theirs.value)
4425
4517
  return { open: open.id };
@@ -4430,7 +4522,7 @@ function conflictOfEdit(tree, applied, owner, fact, title, claims, edit, parents
4430
4522
  applied.push({ do: "conflict.edit", id: open.id, before: JSON.stringify({ title: open.title, claims: open.claims }) });
4431
4523
  return { was: open.claims[1].value };
4432
4524
  }
4433
- const x = create(tree, "conflict", { title: title.slice(0, 200), fact, subject: [owner], claims, ...(edit ? { edit } : {}), ...(parents ? { parents } : {}), state: "open" }, (id) => `+${id} conflict "${title.slice(0, 60)}"`, [owner]);
4525
+ const x = create(tree, "conflict", { title: title.slice(0, 200), fact, subject: [owner], claims, ...(edit ? { edit } : {}), ...(parents ? { parents } : {}), ...(primary ? { primary } : {}), state: "open" }, (id) => `+${id} conflict "${title.slice(0, 60)}"`, [owner]);
4434
4526
  applied.push({ do: "conflict.add", id: x.id });
4435
4527
  return {};
4436
4528
  }
@@ -4643,6 +4735,16 @@ export function undoSync(tree, input) {
4643
4735
  });
4644
4736
  break;
4645
4737
  }
4738
+ case "name.mark": {
4739
+ // the name the person was shown by before the user's choice (none: no mark)
4740
+ const p = tree.get(a.id);
4741
+ const was = String(a.before ?? "");
4742
+ const back = was ? p?.names.find((n) => sameWords(n, parseName(was))) : null;
4743
+ if (!p || back === undefined || (back === null ? !p.names.some((n) => n.primary) : back.primary))
4744
+ break;
4745
+ markPrimaryName(tree, a.id, back ? gedcomName(back) : undefined, reason);
4746
+ break;
4747
+ }
4646
4748
  case "name.title": {
4647
4749
  // the title as it was, and the citation of the sync it added taken off
4648
4750
  const b = JSON.parse(String(a.before ?? "{}"));
package/dist/core/tree.js CHANGED
@@ -29,7 +29,7 @@ export const TREE_FILE = "strom.json";
29
29
  export const SEAL_FOREIGN = "seal.foreign";
30
30
  // between releases the candidate being made (a bridge run from the sources says what it is: the Strom app goes by it);
31
31
  // at a release the release's own (package.json)
32
- export const VERSION = "1.22.0";
32
+ export const VERSION = "1.23.0-beta.1";
33
33
  const GITIGNORE = `# Strom Research — local state and large files are not versioned
34
34
  .strom/
35
35
  /media/
@@ -200,8 +200,12 @@ function checkPerson(p, out) {
200
200
  out.push({ path: `names[${i}].${t}`, message: "must be a title (a string, not empty)" });
201
201
  if (n.kind !== undefined && !NAME_KINDS.includes(n.kind))
202
202
  out.push({ path: `names[${i}].kind`, message: `must be one of ${NAME_KINDS.join(", ")}` });
203
+ if (n.primary !== undefined && n.primary !== true)
204
+ out.push({ path: `names[${i}].primary`, message: "must be true, or left out" });
203
205
  checkCitations(n.citations, `names[${i}].citations`, out);
204
206
  });
207
+ if (Array.isArray(p.names) && p.names.filter((n) => isObj(n) && n.primary).length > 1)
208
+ out.push({ path: "names", message: "at most one name is the one the person is shown by (primary)" });
205
209
  if (p.retractedNames !== undefined) {
206
210
  if (!Array.isArray(p.retractedNames))
207
211
  out.push({ path: "retractedNames", message: "must be an array" });
@@ -27,7 +27,7 @@
27
27
  import { GedWriter, clipChars, lineValue } from "./lines.js";
28
28
  import { labels, RELA } from "./labels.js";
29
29
  import { linkInTree, recordedLinks } from "../core/hypolinks.js";
30
- import { birthEvent, claimText, conflictTitle, coupleSides, displayName, familySides, formatName, gedcomTitledName, noName, preferredOrder, primaryName, relationTo } from "../core/people.js";
30
+ import { birthEvent, claimText, conflictTitle, coupleSides, currentName, displayName, familySides, formatName, gedcomTitledName, noName, preferredOrder, primaryName, relationTo } from "../core/people.js";
31
31
  import { foldText } from "../core/text.js";
32
32
  import { dateYears } from "../core/gdate.js";
33
33
  import { humanAge, isGedcomAge, normalizeAge } from "../core/age.js";
@@ -258,7 +258,17 @@ export function exportGedcom(tree, opts = {}) {
258
258
  w.record(x(p.id), "INDI");
259
259
  const primary = primaryName(p);
260
260
  const nameQuotes = [];
261
- for (const n of [primary, ...p.names.filter((n) => n !== primary)]) {
261
+ // the name the person is shown by now (Name.primary): first for any program (GEDCOM 7: the preferred name first);
262
+ // the Strom file keeps the name of their identity first (the app's birth surname) and says the mark — a married
263
+ // name marked last of them (the app keeps one name after a marriage: the last), so even an app that reads no mark
264
+ // shows it
265
+ const current = currentName(p);
266
+ const rest = p.names.filter((n) => n !== primary && n !== current);
267
+ const order = current === primary ? [primary, ...rest]
268
+ : strict ? [current, primary, ...rest]
269
+ : current.kind === "married" ? [primary, ...rest, current]
270
+ : [primary, ...p.names.filter((n) => n !== primary)];
271
+ for (const n of order) {
262
272
  // a name with a title says it in the line (any program shows it) and spells its parts out below: NPFX, GIVN, SURN,
263
273
  // NSFX — for any program (GEDCOM 5.5.1's own tags), for the Strom app from its 3.10 (titles; an older one reads
264
274
  // the line). A part holding a comma gets no GIVN / SURN (GEDCOM reads commas there as a list); the line has it.
@@ -280,6 +290,8 @@ export function exportGedcom(tree, opts = {}) {
280
290
  // "birth" says something only next to another name
281
291
  if (n.kind && (n.kind !== "birth" || p.names.length > 1))
282
292
  w.line(2, "TYPE", NAME_TYPE[n.kind]);
293
+ if (!strict && n.primary)
294
+ w.line(2, "_STROM_PRIMARY", "Y");
283
295
  for (const c of n.citations ?? []) {
284
296
  const q = citation(2, c);
285
297
  if (q)
@@ -5,7 +5,7 @@ import { normalizeDate } from "../core/gdate.js";
5
5
  import { isGedcomAge } from "../core/age.js";
6
6
  import { MAX_LINE_BYTES } from "./lines.js";
7
7
  const LINE = /^(\d{1,2}) (?:(@[^@ ]+@) )?([A-Za-z0-9_]+)(?: (.*))?$/;
8
- const EVENT_DETAIL = ["TYPE", "DATE", "PLAC", "AGE", "SOUR", "NOTE", "ASSO", "_WITN", "CAUS", "ADDR", "AGNC", "RELI", "OBJE", "HUSB", "WIFE"];
8
+ const EVENT_DETAIL = ["TYPE", "DATE", "PLAC", "AGE", "SOUR", "NOTE", "ASSO", "_WITN", "CAUS", "ADDR", "AGNC", "RELI", "OBJE", "HUSB", "WIFE", "_STROM_STATUS"];
9
9
  const INDI_EVENTS = [
10
10
  "BIRT", "DEAT", "BAPM", "CHR", "CHRA", "CONF", "FCOM", "BARM", "BASM", "ORDN", "EDUC", "GRAD", "OCCU", "RESI", "EMIG", "IMMI",
11
11
  "NATU", "RELI", "TITL", "NATI", "ADOP", "WILL", "PROB", "BURI", "CREM", "CENS", "EVEN", "RETI", "BLES", "CAST", "DSCR", "IDNO",
@@ -14,7 +14,7 @@ const INDI_EVENTS = [
14
14
  const FAM_EVENTS = ["MARR", "DIV", "MARB", "MARC", "MARL", "MARS", "ANUL", "DIVF", "ENGA", "CENS", "RESI", "EVEN", "NCHI"];
15
15
  /** Allowed child tags by parent context ("INDI", "INDI.BIRT", …). CONC/CONT are allowed under anything with text. */
16
16
  const CHILDREN = {
17
- HEAD: ["SOUR", "DEST", "DATE", "SUBM", "SUBN", "FILE", "COPR", "GEDC", "CHAR", "LANG", "PLAC", "NOTE", "_STROM_TREE", "_STROM_HEAD", "_STROM_LINKS", "_STROM_ASOF"],
17
+ HEAD: ["SOUR", "DEST", "DATE", "SUBM", "SUBN", "FILE", "COPR", "GEDC", "CHAR", "LANG", "PLAC", "NOTE", "_STROM_TREE", "_STROM_HEAD", "_STROM_LINKS", "_STROM_ASOF", "_STROM_MODE"],
18
18
  "HEAD.SOUR": ["VERS", "NAME", "CORP", "DATA"],
19
19
  "HEAD._STROM_LINKS": ["_SCHEME"],
20
20
  "HEAD.GEDC": ["VERS", "FORM"],
@@ -33,14 +33,16 @@ const CHILDREN = {
33
33
  "_STROM_EDGE._JOIN": ["_VAR", "_ISLAND", "_HELD"],
34
34
  // a value of a conflict the app decides by side: its side, a sex as M, F or U
35
35
  _STROM_VALUE: ["SOUR", "_STROM_SIDE", "_STROM_RAW"],
36
- "INDI.NAME": ["TYPE", "GIVN", "SURN", "NPFX", "NSFX", "NICK", "SPFX", "SOUR", "NOTE"],
36
+ "INDI.NAME": ["TYPE", "GIVN", "SURN", "NPFX", "NSFX", "NICK", "SPFX", "SOUR", "NOTE", "_STROM_PRIMARY"],
37
37
  "INDI.FAMC": ["PEDI", "NOTE"],
38
38
  "INDI.FAMS": ["NOTE"],
39
39
  "FAM.CHIL": ["_FREL", "_MREL"],
40
40
  "INDI.ASSO": ["RELA", "SOUR", "NOTE"],
41
- FAM: ["HUSB", "WIFE", "CHIL", "NOTE", "SOUR", "REFN", "OBJE", "RIN", "CHAN", "_STORY", ...FAM_EVENTS],
41
+ FAM: ["HUSB", "WIFE", "CHIL", "NOTE", "SOUR", "REFN", "OBJE", "RIN", "CHAN", "_STORY", "_STAT", "_STROM_NO_COUPLE", ...FAM_EVENTS],
42
42
  // the Strom profile also writes PAGE on the record: where in the book the source is (the Strom app's reference)
43
- SOUR: ["TITL", "AUTH", "ABBR", "PUBL", "TEXT", "REPO", "NOTE", "REFN", "DATA", "OBJE", "RIN", "CHAN"],
43
+ SOUR: ["TITL", "AUTH", "ABBR", "PUBL", "TEXT", "REPO", "NOTE", "REFN", "DATA", "OBJE", "RIN", "CHAN", "_STROM_READ", "_STROM_VERIFIED", "_STROM_READING", "_STROM_IMG_OMITTED"],
44
+ "SOUR._STROM_READING": ["_STROM_ENTRY"],
45
+ "SOUR._STROM_IMG_OMITTED": ["_STROM_CLIP"],
44
46
  "SOUR.REPO": ["CALN", "NOTE"],
45
47
  REPO: ["NAME", "ADDR", "NOTE", "REFN", "RIN", "CHAN"],
46
48
  SUBM: ["NAME", "ADDR", "LANG", "NOTE", "RIN", "CHAN"],
@@ -73,6 +75,12 @@ const EXTENSIONS = new Set([
73
75
  "_VAR", "_LINK", "_PERS", "_FAM", "_PAR", "_CHOSEN", "_INTREE",
74
76
  // a source's excerpts the file leaves out, and why
75
77
  "_STROM_IMG_OMITTED",
78
+ // the name a person is shown by; a couple's state; parents who are no couple; an archive
79
+ "_STROM_PRIMARY", "_STAT", "_STROM_NO_COUPLE", "_STROM_MODE",
80
+ // who read a source, the user's verification, the reading it is an entry of; how sure a fact is
81
+ "_STROM_READ", "_STROM_VERIFIED", "_STROM_READING", "_STROM_ENTRY", "_STROM_STATUS",
82
+ // an excerpt's original by its content, and how its picture lies
83
+ "_STROM_SHA", "_STROM_ORIENT",
76
84
  ]);
77
85
  /** Context of the children of the last tag in `anc` (the ancestors of a line). */
78
86
  function contextOf(anc) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strom-research",
3
- "version": "1.22.0",
3
+ "version": "1.23.0-beta.1",
4
4
  "description": "Genealogical research toolkit for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -57,5 +57,8 @@
57
57
  "devDependencies": {
58
58
  "@types/node": "^22.20.4",
59
59
  "typescript": "^7.0.2"
60
+ },
61
+ "publishConfig": {
62
+ "tag": "beta"
60
63
  }
61
64
  }