strom-research 1.22.2 → 1.23.0-beta.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.
@@ -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() };
@@ -64,6 +64,26 @@ function parseOps(text, file, findings, startLine = 0) {
64
64
  */
65
65
  export const OTHER_KEY = "other-key";
66
66
  const OTHER_KEY_HINT = "the user runs in their terminal: strom seal adopt (unites the keys — never strom repair, which would undo that work)";
67
+ /**
68
+ * Two keys of this computer and changes outside strom at once (a commit made with git on top of the other key's work):
69
+ * one way, never two that contradict each other — strom repair first (it undoes only what was done outside strom, the
70
+ * work of both keys stays: it restores to the newest commit any key of this computer sealed), then strom seal adopt.
71
+ * Never the other way round: uniting commits what is there, a record changed outside strom with it. `hidden`: started
72
+ * by an app packaged for Windows, the terminal's key out of its sight — strom repair there too, in the terminal.
73
+ */
74
+ export function oneWay(findings, hidden) {
75
+ const repairs = findings.filter((f) => f.level === "error" && f.code !== OTHER_KEY && f.hint?.startsWith("strom repair"));
76
+ if (!repairs.length)
77
+ return findings;
78
+ const where = hidden() ? "the user runs in their terminal: " : "";
79
+ for (const f of repairs)
80
+ f.hint = `${where}strom repair (undoes only what was done outside strom — the work of both keys of this computer stays), then strom seal adopt`;
81
+ for (const f of findings)
82
+ if (f.code === OTHER_KEY)
83
+ f.hint = AFTER_REPAIR_HINT;
84
+ return findings;
85
+ }
86
+ export const AFTER_REPAIR_HINT = "after strom repair, the user runs in their terminal: strom seal adopt (unites the keys)";
67
87
  function keysOf(tree, trust = [], united = new Map()) {
68
88
  let others;
69
89
  let hidden;
@@ -279,10 +299,11 @@ export function verifyFast(tree, snap = snapshot(tree)) {
279
299
  const tampered = new Set();
280
300
  if (!git.hasHead(tree.root))
281
301
  return { findings, tampered: [] };
282
- headSeal(tree, findings);
302
+ const keys = keysOf(tree);
303
+ headSeal(tree, findings, keys);
283
304
  const { changes, head: committed } = snap;
284
305
  if (changes.length === 0)
285
- return { findings, tampered: [] };
306
+ return { findings: united(findings, keys), tampered: [] };
286
307
  const opsChanged = changes.filter((c) => c.path.startsWith("data/ops/") && c.path.endsWith(".jsonl"));
287
308
  const latest = new Map();
288
309
  const cloud = new Set();
@@ -304,7 +325,7 @@ export function verifyFast(tree, snap = snapshot(tree)) {
304
325
  }
305
326
  const prior = parseOps(before, c.path, findings);
306
327
  const added = parseOps(now.slice(before.length), c.path, findings, prior.length);
307
- checkOps(tree, added, prior.at(-1)?.sig ?? "", c.path, findings);
328
+ checkOps(tree, added, prior.at(-1)?.sig ?? "", c.path, findings, keys);
308
329
  for (const op of added)
309
330
  for (const f of op.files ?? [])
310
331
  latest.set(f.path, f.sha);
@@ -346,7 +367,11 @@ export function verifyFast(tree, snap = snapshot(tree)) {
346
367
  }
347
368
  if (cloud.size)
348
369
  findings.unshift(notDownloadedFinding([...cloud].sort()));
349
- return { findings, tampered: [...tampered] };
370
+ return { findings: united(findings, keys), tampered: [...tampered] };
371
+ }
372
+ /** Another key of this computer among the findings: one way with what strom repair undoes (oneWay). */
373
+ function united(findings, keys) {
374
+ return findings.some((f) => f.code === OTHER_KEY) ? oneWay(findings, keys.hidden) : findings;
350
375
  }
351
376
  /**
352
377
  * The other keys of this computer a seal.unite of the tree's own key took, each with when (its operations until then
@@ -442,7 +467,7 @@ export function verifyFull(tree, o = {}) {
442
467
  tampered.add("strom.json");
443
468
  findings.push({ level: "error", code: "config-edited", file: "strom.json", message: "strom.json was changed outside strom", hint: "strom repair" });
444
469
  }
445
- return { findings, tampered: [...tampered] };
470
+ return { findings: united(findings, keys), tampered: [...tampered] };
446
471
  }
447
472
  /**
448
473
  * Whether strom may write here unasked (a settle: nobody asked, nobody to ask): the tree sealed on this computer (not
package/dist/core/live.js CHANGED
@@ -918,7 +918,7 @@ function idsOf(applied, known) {
918
918
  return Object.keys(ids.persons).length || Object.keys(ids.sources).length ? { ids } : {};
919
919
  }
920
920
  /** What the bridge does that an app may ask about (each added once, never taken away). */
921
- 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"];
921
+ 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"];
922
922
  /** /status recent, when it is ready. */
923
923
  function recentOf(root, env, head) {
924
924
  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}",
@@ -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 ?? "{}"));