strom-research 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,7 +13,7 @@ import { UsageError } from "../core/errors.js";
13
13
  import { excerptSettings, planExcerpts } from "../core/excerpt.js";
14
14
  import { runGit, tracked } from "../core/git.js";
15
15
  import { Settings } from "../core/config.js";
16
- import { appOpensLinks, appShowsEdges, appShowsStoryDrafts } from "../core/stromapp.js";
16
+ import { appOpensLinks, appShowsCoupleEvents, appShowsEdges, appShowsStoryDrafts } from "../core/stromapp.js";
17
17
  import { EXCERPT_QUALITIES, EXCERPT_SCOPES } from "../core/model.js";
18
18
  function findingRows(f) {
19
19
  return table(f.slice(0, 30).map((x) => [x.level === "error" ? "ERROR" : "warn", `line ${x.line}`, x.message]));
@@ -77,7 +77,8 @@ export function writeTreeGed(tree, opts) {
77
77
  const edges = opts.for === "strom" && appShowsEdges(new Settings(tree.env, {}), opts.stromVersion);
78
78
  // …and the new versions of stories the user approved, for an app that shows them
79
79
  const storyDrafts = opts.for === "strom" && appShowsStoryDrafts(new Settings(tree.env, {}), opts.stromVersion);
80
- const result = exportGedcom(tree, { for: opts.for, stromVersion: opts.stromVersion, ...(persons ? { persons } : {}), ...(images ? { excerpts: images.of } : {}), ...(clips ? { clips } : {}), ...(research ? { research } : {}), ...(edges ? { edges } : {}), ...(storyDrafts ? { storyDrafts } : {}) });
80
+ const coupleResi = opts.for === "strom" && appShowsCoupleEvents(new Settings(tree.env, {}), opts.stromVersion);
81
+ const result = exportGedcom(tree, { for: opts.for, stromVersion: opts.stromVersion, ...(persons ? { persons } : {}), ...(images ? { excerpts: images.of } : {}), ...(clips ? { clips } : {}), ...(research ? { research } : {}), ...(edges ? { edges } : {}), ...(storyDrafts ? { storyDrafts } : {}), ...(coupleResi ? { coupleResi } : {}) });
81
82
  const findings = validateGedcom(result.text, opts.for === "standard" ? { strict: true } : {});
82
83
  const file = opts.out ?? gedFile(tree, opts.for, opts.research);
83
84
  if (!hasGedErrors(findings) && !tree.dryRun) {
@@ -17,16 +17,17 @@ import { applySync, nothingSince, planSync, readTreeFile, receivedSince, undoSyn
17
17
  import { startLive } from "../core/live.js";
18
18
  import { appSendsChanges, installedStromApp, sendAppUrl, stromAppUrl } from "../core/stromapp.js";
19
19
  import { chromiumBrowser, openInBrowser, openWebApp } from "../core/chromium.js";
20
- function factText(f, lang) {
20
+ function factText(f, lang, name = (k) => k) {
21
21
  if (!f)
22
22
  return "—";
23
- return [f.value, humanDate(f.date, lang), humanPlace(f.place, undefined, lang), detailText(f, lang)].filter(Boolean).join(", ") || "—";
23
+ return [f.value, humanDate(f.date, lang), humanPlace(f.place, undefined, lang), detailText(f, lang, name)].filter(Boolean).join(", ") || "—";
24
24
  }
25
- /** What a fact says besides: its house, age and cause. */
26
- function detailText(f, lang) {
25
+ /** What a fact says besides: its house, age (a couple's: each partner's) and cause. */
26
+ function detailText(f, lang, name = (k) => k) {
27
27
  if (!f)
28
28
  return "";
29
- return [f.house && ui(lang, "ui.sync.house", { x: f.house }), f.age && humanAge(f.age, lang), f.cause && ui(lang, "ui.sync.cause", { x: f.cause })].filter(Boolean).join(", ");
29
+ const ages = Object.entries(f.ages ?? {}).map(([who, a]) => `${name(who)} ${humanAge(a, lang)}`);
30
+ return [f.house && ui(lang, "ui.sync.house", { x: f.house }), f.age && humanAge(f.age, lang), ...ages, f.cause && ui(lang, "ui.sync.cause", { x: f.cause })].filter(Boolean).join(", ");
30
31
  }
31
32
  /** A position on the map as a map shows it: degrees, six decimals at most. */
32
33
  const point = (p) => `${+p.lat.toFixed(6)}, ${+p.lon.toFixed(6)}`;
@@ -41,15 +42,17 @@ function changeLine(tree, c, incoming, lang) {
41
42
  return p ? `${(p.names[0] ? `${p.names[0].given} ${p.names[0].surname}` : key).trim()} [${key}]` : key;
42
43
  };
43
44
  const who = c.partners ? c.partners.map(name).join(" & ") || c.family || "?" : name(c.person);
45
+ // a partner in their age: the given name
46
+ const short = (key) => (key.startsWith("x:") ? name(key) : (tree.get(key)?.names[0]?.given ?? key));
44
47
  const kind = (f) => (f ? eventName(f.kind, lang, f.label) : "");
45
48
  const v = {
46
49
  n: c.n,
47
50
  who,
48
51
  name: name(c.person),
49
52
  kind: kind(c.fact ?? c.was),
50
- fact: `${kind(c.fact ?? c.was)} ${factText(c.fact ?? c.was, lang)}`,
51
- was: c.kind === "fact.detail" ? detailText(c.was, lang) || "—" : factText(c.was, lang),
52
- now: c.kind === "fact.detail" ? detailText(c.fact, lang) : factText(c.fact, lang),
53
+ fact: `${kind(c.fact ?? c.was)} ${factText(c.fact ?? c.was, lang, short)}`,
54
+ was: c.kind === "fact.detail" ? detailText(c.was, lang, short) || "—" : factText(c.was, lang, short),
55
+ now: c.kind === "fact.detail" ? detailText(c.fact, lang, short) : factText(c.fact, lang, short),
53
56
  when: [humanDate(c.fact?.date, lang), humanPlace(c.fact?.place, undefined, lang)].filter(Boolean).join(", "),
54
57
  text: truncate(c.text ?? "", 120),
55
58
  child: name(c.child),
@@ -591,7 +591,8 @@ export function editEvent(tree, eventId, edit, reason) {
591
591
  const date = edit.date === undefined ? undefined : parseDate(edit.date);
592
592
  // an empty age takes it away
593
593
  const age = edit.age === undefined ? undefined : edit.age.trim() ? parseAge(edit.age) : "";
594
- const ages = edit.ages ? Object.fromEntries(Object.entries(edit.ages).map(([who, a]) => [who, parseAge(a, who)])) : undefined;
594
+ // an empty age of a partner takes it away
595
+ const ages = edit.ages ? Object.fromEntries(Object.entries(edit.ages).map(([who, a]) => [who, a.trim() ? parseAge(a, who) : ""])) : undefined;
595
596
  return replaceEvent(tree, eventId, (e) => {
596
597
  const changed = [];
597
598
  const set = (k, v) => {
@@ -621,7 +622,11 @@ export function editEvent(tree, eventId, edit, reason) {
621
622
  for (const [who, a] of Object.entries(ages))
622
623
  if (e.ages?.[who] !== undefined && e.ages[who] !== a)
623
624
  changed.push(`age of ${who}`);
624
- next.ages = { ...e.ages, ...ages };
625
+ const merged = Object.fromEntries(Object.entries({ ...e.ages, ...ages }).filter(([, a]) => a));
626
+ if (Object.keys(merged).length)
627
+ next.ages = merged;
628
+ else
629
+ delete next.ages;
625
630
  }
626
631
  const key = (p) => `${p.role}|${p.person ?? foldText(p.name ?? "")}`;
627
632
  if (edit.drop?.length) {
package/dist/core/live.js CHANGED
@@ -49,7 +49,7 @@ import { rankTasks } from "./queue.js";
49
49
  import { humanTask } from "../cli/human.js";
50
50
  import { knownNewerVersion } from "./update.js";
51
51
  import { gitProgram, runGit } from "./git.js";
52
- import { appOpensLinks, appShowsEdges, appShowsStoryDrafts, isStromAppOrigin } from "./stromapp.js";
52
+ import { appOpensLinks, appShowsCoupleEvents, appShowsEdges, appShowsStoryDrafts, isStromAppOrigin } from "./stromapp.js";
53
53
  import { Settings } from "./config.js";
54
54
  import { linkActions, linkHandlerState } from "./links.js";
55
55
  import { stromLauncher } from "./self.js";
@@ -596,11 +596,12 @@ export function serveLive(root, env) {
596
596
  // …and where the tree ends, for an app that shows it
597
597
  const edges = appShowsEdges(new Settings(env, {}));
598
598
  const storyDrafts = appShowsStoryDrafts(new Settings(env, {}));
599
+ const coupleResi = appShowsCoupleEvents(new Settings(env, {}));
599
600
  const offered = opens ? links(env) : [];
600
601
  ged = {
601
602
  head: h,
602
603
  links: offered.join(" "),
603
- text: exportGedcom(tree, { for: "strom", ...(h ? { head: h } : {}), ...(images ? { excerpts: images.of } : {}), ...(opens ? { clips: true, research: true } : {}), ...(edges ? { edges } : {}), ...(storyDrafts ? { storyDrafts } : {}), ...(offered.length ? { links: offered } : {}) }).text,
604
+ text: exportGedcom(tree, { for: "strom", ...(h ? { head: h } : {}), ...(images ? { excerpts: images.of } : {}), ...(opens ? { clips: true, research: true } : {}), ...(edges ? { edges } : {}), ...(storyDrafts ? { storyDrafts } : {}), ...(coupleResi ? { coupleResi } : {}), ...(offered.length ? { links: offered } : {}) }).text,
604
605
  };
605
606
  }
606
607
  res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", "X-Strom-Head": h }).end(ged.text);
@@ -82,6 +82,27 @@ export function appShowsEdges(settings, version) {
82
82
  return (a[i] ?? 0) > (b[i] ?? 0);
83
83
  return true;
84
84
  }
85
+ /**
86
+ * The Strom app keeps a couple's events as events (the app's spec docs/ZADANI_VYZKUM_udalosti-paru.md, its 3.8.0):
87
+ * their residence goes to it as 1 RESI under FAM (before: an EVEN named so — an older app keeps either in the couple's
88
+ * note). stromapp.info from 3.8.0 (2026-10-02); another copy of the app (its beta, its development) is taken as current.
89
+ * An app of unknown version: today's.
90
+ */
91
+ export const APP_SHOWS_COUPLE_EVENTS = "3.8.0";
92
+ export function appShowsCoupleEvents(settings, version) {
93
+ if (stromAppUrl(settings) !== STROM_APP_URL)
94
+ return true;
95
+ if (!APP_SHOWS_COUPLE_EVENTS)
96
+ return false;
97
+ if (!version)
98
+ return true;
99
+ const n = (v) => v.split(".").map((x) => Number.parseInt(x, 10) || 0);
100
+ const [a, b] = [n(version), n(APP_SHOWS_COUPLE_EVENTS)];
101
+ for (let i = 0; i < 3; i++)
102
+ if ((a[i] ?? 0) !== (b[i] ?? 0))
103
+ return (a[i] ?? 0) > (b[i] ?? 0);
104
+ return true;
105
+ }
85
106
  /**
86
107
  * The Strom app shows the new version of a story the user approved, waiting beside it (the app's spec
87
108
  * docs/ZADANI_VYZKUM_vypraveni-zamek.md): 2 _DRAFT under _STORY, and in the bridge's waiting (kind story). stromapp.info
package/dist/core/sync.js CHANGED
@@ -28,6 +28,7 @@ import { fromFlexDate, houseOf, importDate } from "./import.js";
28
28
  import { normalizeAge } from "./age.js";
29
29
  import { create, update } from "./records.js";
30
30
  import { foldText } from "./text.js";
31
+ import { labels } from "../gedcom/labels.js";
31
32
  import { UsageError } from "./errors.js";
32
33
  import * as git from "./git.js";
33
34
  import { now, Tree } from "./tree.js";
@@ -88,8 +89,8 @@ const ALTERNATIVE = {
88
89
  };
89
90
  /** Facts there is one of: an edit of one is a change, not a second fact. */
90
91
  const ONE = new Set(["BIRT", "BAPM", "DEAT", "BURI", "CREM", "MARR", "DIV"]);
91
- function gedFacts(node) {
92
- return cleanFacts(rawFacts(node));
92
+ function gedFacts(node, couple) {
93
+ return cleanFacts(rawFacts(node, couple));
93
94
  }
94
95
  /** What the Strom app adds of its own: an alternative birth or death as an event, a "Birth record" beside a birth, every couple married. */
95
96
  function cleanFacts(facts) {
@@ -109,7 +110,7 @@ function cleanFacts(facts) {
109
110
  // a marriage with nothing known of it: every couple of the Strom app is "married"
110
111
  !(f.kind === "MARR" && !f.date && !f.place && !f.value));
111
112
  }
112
- function rawFacts(node) {
113
+ function rawFacts(node, couple) {
113
114
  const out = [];
114
115
  for (const c of node.children) {
115
116
  if (["NAME", "SEX", "REFN", "NOTE", "FAMC", "FAMS", "HUSB", "WIFE", "CHIL", "SOUR", "OBJE", "ASSO", "_STORY"].includes(c.tag))
@@ -129,7 +130,20 @@ function rawFacts(node) {
129
130
  f.value = value.replace(/\s+/g, " ");
130
131
  if (kind === "EVEN")
131
132
  f.label = val(c, "TYPE") ?? f.value ?? "EVEN";
132
- details(f, val(c, "CAUS"), val(c, "AGE"), val(c, "ADDR"));
133
+ // a couple's residence written as an event named so (for a Strom app before its couple's events): their residence
134
+ if (couple && kind === "EVEN" && COUPLE_RESIDENCE.has(fold(f.label))) {
135
+ f.kind = "RESI";
136
+ delete f.label;
137
+ }
138
+ details(f, val(c, "CAUS"), couple ? undefined : val(c, "AGE"), val(c, "ADDR"));
139
+ if (couple)
140
+ for (const role of ["HUSB", "WIFE"]) {
141
+ const who = couple[role];
142
+ const age = val(c.children.find((x) => x.tag === role), "AGE");
143
+ const a = who && age?.trim() ? normalizeAge(age) : undefined;
144
+ if (a)
145
+ f.ages = { ...f.ages, [who]: a };
146
+ }
133
147
  const id = val(c, "_EID");
134
148
  if (id)
135
149
  f.id = id;
@@ -149,8 +163,21 @@ function details(f, cause, age, house) {
149
163
  if (h)
150
164
  f.house = h;
151
165
  }
152
- /** The details of a fact the research compares (DETAILS of SFact). */
166
+ /** The details of a fact the research compares, besides the partners' ages. */
153
167
  const DETAILS = ["cause", "age", "house"];
168
+ /** A couple's residence as an event of that name (the export's label in any language). */
169
+ const COUPLE_RESIDENCE = new Set(["en", "cs", "de", "pl", "sk"].map((l) => fold(labels(l)("RESI"))));
170
+ /** What a fact says besides, one entry each: cause, age, house, and "ages:<key>" for a partner's age. */
171
+ function detailsOf(f) {
172
+ const out = {};
173
+ for (const d of DETAILS)
174
+ if (f[d])
175
+ out[d] = f[d];
176
+ for (const [who, a] of Object.entries(f.ages ?? {}))
177
+ if (a)
178
+ out[`ages:${who}`] = a;
179
+ return out;
180
+ }
154
181
  /** Every note under a record, its facts' and citations' too, as one text. */
155
182
  function allNotes(n, noteText) {
156
183
  const out = [];
@@ -206,7 +233,7 @@ export function readGedcom(text) {
206
233
  const families = [];
207
234
  for (const r of records.filter((x) => x.tag === "FAM")) {
208
235
  const who = (tag) => children(r, tag).map((c) => keys.get(c.value.trim())).filter((k) => !!k && persons.has(k));
209
- families.push({ partners: [...who("HUSB"), ...who("WIFE")], children: who("CHIL"), facts: gedFacts(r) });
236
+ families.push({ partners: [...who("HUSB"), ...who("WIFE")], children: who("CHIL"), facts: gedFacts(r, { HUSB: who("HUSB")[0], WIFE: who("WIFE")[0] }) });
210
237
  }
211
238
  const treeId = val(head, "_STROM_TREE");
212
239
  const at = val(head, "_STROM_HEAD");
@@ -219,6 +246,11 @@ const APP_EVENTS = {
219
246
  firstCommunion: "FCOM", barMitzvah: "BARM", batMitzvah: "BASM", ordination: "ORDN", adoption: "ADOP", naturalization: "NATU",
220
247
  will: "WILL", probate: "PROB", title: "TITL", nationality: "NATI", cremation: "CREM",
221
248
  };
249
+ /** The Strom app's events of a couple (its 3.8) → GEDCOM tags. */
250
+ const APP_COUPLE_EVENTS = {
251
+ engagement: "ENGA", banns: "MARB", marriageLicence: "MARL", marriageContract: "MARC", marriageSettlement: "MARS",
252
+ residence: "RESI", census: "CENS", divorceFiled: "DIVF", annulment: "ANUL", custom: "EVEN",
253
+ };
222
254
  /** Facts whose value is what they are (the GEDCOM tag's value). */
223
255
  const VALUE_KINDS = new Set(["OCCU", "RELI", "TITL", "NATI"]);
224
256
  /** A tree of the Strom app (its JSON) as a snapshot. */
@@ -277,6 +309,17 @@ export function readStromJson(data) {
277
309
  said: p.notes ?? "",
278
310
  });
279
311
  }
312
+ // a partner's age by the app's person id → by the person's key
313
+ const partnersAges = (ages) => {
314
+ const out = {};
315
+ for (const [id, raw] of Object.entries(ages ?? {})) {
316
+ const key = keys.get(id);
317
+ const a = key && typeof raw === "string" && raw.trim() ? normalizeAge(raw) : undefined;
318
+ if (key && a)
319
+ out[key] = a;
320
+ }
321
+ return Object.keys(out).length ? out : undefined;
322
+ };
280
323
  const families = [];
281
324
  for (const u of Object.values(d.partnerships ?? {})) {
282
325
  const partners = [u.person1Id, u.person2Id].map((x) => (x ? keys.get(x) : undefined)).filter((k) => !!k);
@@ -289,8 +332,31 @@ export function readStromJson(data) {
289
332
  if (u.startPlace?.trim())
290
333
  f.place = u.startPlace.trim();
291
334
  details(f, undefined, undefined, u.address);
335
+ const ages = partnersAges(u.ages);
336
+ if (ages)
337
+ f.ages = ages;
292
338
  facts.push(f);
293
339
  }
340
+ // the couple's other events (the app's 3.8)
341
+ for (const e of u.events ?? []) {
342
+ const kind = APP_COUPLE_EVENTS[e.type];
343
+ if (!kind)
344
+ continue;
345
+ const f = { kind };
346
+ const g = fromFlexDate(e.date);
347
+ if (g)
348
+ f.date = g;
349
+ if (e.place?.trim())
350
+ f.place = e.place.trim().replace(/\s+/g, " ");
351
+ if (kind === "EVEN")
352
+ f.label = e.customLabel || "EVEN";
353
+ details(f, e.cause, undefined, e.address);
354
+ const ages = partnersAges(e.ages);
355
+ if (ages)
356
+ f.ages = ages;
357
+ if (f.date || f.place || f.label || kind !== "EVEN")
358
+ facts.push(f);
359
+ }
294
360
  // the divorce (the app's 3.7 keeps its place too)
295
361
  if (u.status === "divorced" && (u.endDate || u.endPlace)) {
296
362
  const f = { kind: "DIV" };
@@ -612,15 +678,19 @@ export function planSync(tree, incoming, edits, opts = {}) {
612
678
  if (ourKeys.has(k)) {
613
679
  // the research has it: what the file says of it besides (its cause, age, house) may be the user's — paired
614
680
  // with the same details first (one lived in several houses of a place: each its own fact)
615
- const sameDetails = (a, b) => DETAILS.every((d) => !a[d] || exact(a[d]) === exact(b[d]));
681
+ const sameDetails = (a, b) => {
682
+ const [x, y] = [detailsOf(a), detailsOf(b)];
683
+ return Object.keys(x).every((d) => exact(x[d]) === exact(y[d]));
684
+ };
616
685
  const mates = our.filter((x) => factKey(x) === k);
617
686
  if (mates.some((x) => sameDetails(f, x)) || was?.some((x) => factKey(x) === k && sameDetails(f, x)))
618
687
  continue;
619
688
  const o = mates.find((x) => !inc.some((i) => factKey(i) === k && sameDetails(i, x))) ?? mates[0];
620
689
  const b = was?.find((x) => factKey(x) === k && (!x.id || x.id === o.id));
621
- const changed = DETAILS.filter((d) => f[d] && exact(f[d]) !== exact(o[d]) && !(b && exact(f[d]) === exact(b[d])));
690
+ const [fd, od, bd] = [detailsOf(f), detailsOf(o), b ? detailsOf(b) : undefined];
691
+ const changed = Object.keys(fd).filter((d) => exact(fd[d]) !== exact(od[d]) && !(bd && exact(fd[d]) === exact(bd[d])));
622
692
  if (changed.length) {
623
- const added = changed.every((d) => !o[d]);
693
+ const added = changed.every((d) => !od[d]);
624
694
  const action = added ? "add" : !was ? "pick" : recordBacked(tree, eventById(tree, o.id)) ? "conflict" : "correct";
625
695
  if (action !== "conflict" || !openConflict(tree, owner.person ?? owner.family, f))
626
696
  push({ kind: "fact.detail", action, ...owner, fact: f, was: o });
@@ -896,13 +966,17 @@ export function applySync(tree, plan, incoming, source, only) {
896
966
  applied.push({ do: "conflict.add", id: x.id });
897
967
  break;
898
968
  }
899
- const set = Object.fromEntries(DETAILS.filter((d) => c.fact[d] && c.fact[d] !== mine[d]).map((d) => [d, c.fact[d]]));
900
- editEvent(tree, mine.id, set, `${c.action === "add" ? "added from" : "corrected in"} ${reason}`);
969
+ const [fd, md] = [detailsOf(c.fact), detailsOf(mine)];
970
+ const changed = Object.keys(fd).filter((d) => fd[d] !== md[d] && (!d.startsWith("ages:") || !!who(d.slice(5))));
971
+ if (!changed.length)
972
+ break;
973
+ editEvent(tree, mine.id, detailEdit(Object.fromEntries(changed.map((d) => [d.startsWith("ages:") ? `ages:${who(d.slice(5))}` : d, fd[d]]))), `${c.action === "add" ? "added from" : "corrected in"} ${reason}`);
974
+ const set = Object.fromEntries(changed.map((d) => [d, fd[d]]));
901
975
  update(tree, owner, owner.startsWith("F") ? "family" : "person", (o) => ({ ...o, events: o.events.map((e) => (e.id === mine.id && !e.citations.some((x) => x.source === source.id) ? { ...e, citations: [...e.citations, cite(`${c.fact.kind} ${Object.keys(set).join(" ")}`)] } : e)) }), {
902
976
  op: "event.cite",
903
977
  summary: `${mine.id} cites ${source.id}`,
904
978
  });
905
- applied.push({ do: "event.detail", id: mine.id, before: JSON.stringify(Object.fromEntries(DETAILS.map((d) => [d, mine[d] ?? ""]))) });
979
+ applied.push({ do: "event.detail", id: mine.id, before: JSON.stringify(Object.fromEntries(changed.map((d) => [d.startsWith("ages:") ? `ages:${who(d.slice(5))}` : d, md[d] ?? ""]))) });
906
980
  break;
907
981
  }
908
982
  case "name.new": {
@@ -975,8 +1049,20 @@ function nameOf(gedName) {
975
1049
  return { given: gedName.trim(), surname: "" };
976
1050
  return { given: `${m[1].trim()} ${m[3].trim()}`.trim(), surname: m[2].trim() };
977
1051
  }
1052
+ /** Details ("ages:P0001" among them) as an edit of the fact; an empty one takes it away. */
1053
+ function detailEdit(d) {
1054
+ const out = {};
1055
+ for (const [k, v] of Object.entries(d)) {
1056
+ if (k.startsWith("ages:"))
1057
+ out.ages = { ...out.ages, [k.slice(5)]: v };
1058
+ else if (k === "cause" || k === "age" || k === "house")
1059
+ out[k] = v;
1060
+ }
1061
+ return out;
1062
+ }
978
1063
  function describe(f) {
979
- return [f.value, f.date, f.place, f.house && `house ${f.house}`, f.age && `aged ${f.age}`, f.cause && `cause ${f.cause}`].filter(Boolean).join(", ") || "—";
1064
+ const ages = Object.entries(f.ages ?? {}).map(([who, a]) => `${who} aged ${a}`);
1065
+ return [f.value, f.date, f.place, f.house && `house ${f.house}`, f.age && `aged ${f.age}`, ...ages, f.cause && `cause ${f.cause}`].filter(Boolean).join(", ") || "—";
980
1066
  }
981
1067
  /** Undo one sync: what it added withdrawn, what it corrected put back, its conflicts closed — each with the reason. */
982
1068
  export function undoSync(tree, input) {
@@ -998,9 +1084,10 @@ export function undoSync(tree, input) {
998
1084
  // what it was put back: a detail it had, and none where it had none
999
1085
  const e = eventById(tree, a.id);
1000
1086
  const before = JSON.parse(String(a.before ?? "{}"));
1001
- const edit = Object.fromEntries(DETAILS.filter((d) => (before[d] ?? "") !== (e?.[d] ?? "")).map((d) => [d, before[d] ?? ""]));
1002
- if (e && Object.keys(edit).length)
1003
- editEvent(tree, a.id, edit, reason);
1087
+ const now = e ? detailsOf(e) : {};
1088
+ const back = Object.fromEntries(Object.entries(before).filter(([d, v]) => v !== (now[d] ?? "")));
1089
+ if (e && Object.keys(back).length)
1090
+ editEvent(tree, a.id, detailEdit(back), reason);
1004
1091
  break;
1005
1092
  }
1006
1093
  case "event.retract":
package/dist/core/tree.js CHANGED
@@ -23,7 +23,7 @@ import * as git from "./git.js";
23
23
  import { commitSeal, createKey, fingerprint, loadKey, sha256, signOp } from "./seal.js";
24
24
  import { stringifyCanonical } from "./json.js";
25
25
  export const TREE_FILE = "strom.json";
26
- export const VERSION = "1.10.0";
26
+ export const VERSION = "1.11.0";
27
27
  const GITIGNORE = `# Strom Research — local state and large files are not versioned
28
28
  .strom/
29
29
  /media/
@@ -360,8 +360,8 @@ export function exportGedcom(tree, opts = {}) {
360
360
  const allowed = on === "INDI" ? INDI_TAGS : FAM_TAGS;
361
361
  let tag = e.kind;
362
362
  let typeLabel = e.label;
363
- if (on === "FAM" && tag === "RESI" && strict) {
364
- // standard on FAM; Strom does not read it yet, so only in strict
363
+ if (on === "FAM" && tag === "RESI" && (strict || opts.coupleResi)) {
364
+ // standard on FAM; for a Strom app before its couple's events an EVEN named so
365
365
  }
366
366
  else if (AS_EVEN[tag] || !allowed.has(tag)) {
367
367
  typeLabel = e.label ?? (AS_EVEN[tag] ? L(AS_EVEN[tag]) : L(tag) ?? tag);
@@ -11,7 +11,7 @@ const INDI_EVENTS = [
11
11
  "NATU", "RELI", "TITL", "NATI", "ADOP", "WILL", "PROB", "BURI", "CREM", "CENS", "EVEN", "RETI", "BLES", "CAST", "DSCR", "IDNO",
12
12
  "NCHI", "NMR", "PROP", "SSN", "FACT",
13
13
  ];
14
- const FAM_EVENTS = ["MARR", "DIV", "MARB", "MARC", "MARL", "MARS", "ANUL", "DIVF", "ENGA", "CENS", "EVEN", "NCHI"];
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
17
  HEAD: ["SOUR", "DEST", "DATE", "SUBM", "SUBN", "FILE", "COPR", "GEDC", "CHAR", "LANG", "PLAC", "NOTE", "_STROM_TREE", "_STROM_HEAD", "_STROM_LINKS", "_STROM_ASOF"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strom-research",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "description": "Genealogical research toolkit for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {