strom-research 1.0.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.
Files changed (130) hide show
  1. package/LICENSE +373 -0
  2. package/README.md +142 -0
  3. package/assets/lang/cs.json +302 -0
  4. package/assets/lang/de.json +302 -0
  5. package/assets/method/core.md +43 -0
  6. package/assets/method/enrich.md +11 -0
  7. package/assets/method/intake.md +30 -0
  8. package/assets/method/link.md +28 -0
  9. package/assets/method/locate.md +28 -0
  10. package/assets/method/narrate.md +13 -0
  11. package/assets/method/reading.md +62 -0
  12. package/assets/method/recording.md +59 -0
  13. package/assets/method/request.md +10 -0
  14. package/assets/method/verify.md +17 -0
  15. package/assets/plugins/README.md +23 -0
  16. package/assets/plugins/connectors/DISCOVERY.md +159 -0
  17. package/assets/plugins/connectors/README.md +376 -0
  18. package/assets/plugins/connectors/sdk.ts +168 -0
  19. package/assets/plugins/connectors/template.ts +38 -0
  20. package/assets/plugins/gitignore +4 -0
  21. package/dist/agents/files.js +313 -0
  22. package/dist/agents/global.js +257 -0
  23. package/dist/agents/launch.js +36 -0
  24. package/dist/agents/profiles.js +95 -0
  25. package/dist/brief/brief.js +345 -0
  26. package/dist/cli/commit.js +44 -0
  27. package/dist/cli/context.js +311 -0
  28. package/dist/cli/execute.js +154 -0
  29. package/dist/cli/fixes.js +78 -0
  30. package/dist/cli/format.js +53 -0
  31. package/dist/cli/help.js +59 -0
  32. package/dist/cli/main.js +152 -0
  33. package/dist/cli/menu.js +212 -0
  34. package/dist/cli/registry.js +96 -0
  35. package/dist/cli/ui.js +266 -0
  36. package/dist/cli/wizard.js +142 -0
  37. package/dist/cli.js +14 -0
  38. package/dist/commands/analysis.js +622 -0
  39. package/dist/commands/batch.js +181 -0
  40. package/dist/commands/checks.js +153 -0
  41. package/dist/commands/connectors.js +1377 -0
  42. package/dist/commands/guide.js +160 -0
  43. package/dist/commands/index.js +19 -0
  44. package/dist/commands/intake.js +234 -0
  45. package/dist/commands/media.js +406 -0
  46. package/dist/commands/meta.js +195 -0
  47. package/dist/commands/output.js +117 -0
  48. package/dist/commands/people.js +664 -0
  49. package/dist/commands/read.js +199 -0
  50. package/dist/commands/research.js +139 -0
  51. package/dist/commands/session.js +605 -0
  52. package/dist/commands/setup.js +465 -0
  53. package/dist/commands/sources.js +634 -0
  54. package/dist/commands/start.js +383 -0
  55. package/dist/commands/story.js +75 -0
  56. package/dist/commands/tasks.js +436 -0
  57. package/dist/commands/trees.js +128 -0
  58. package/dist/core/actions.js +852 -0
  59. package/dist/core/age.js +95 -0
  60. package/dist/core/apps.js +74 -0
  61. package/dist/core/assets.js +34 -0
  62. package/dist/core/awake.js +33 -0
  63. package/dist/core/browser.js +281 -0
  64. package/dist/core/calibration.js +48 -0
  65. package/dist/core/check.js +112 -0
  66. package/dist/core/chromium.js +88 -0
  67. package/dist/core/config.js +348 -0
  68. package/dist/core/connector.js +811 -0
  69. package/dist/core/deps.js +73 -0
  70. package/dist/core/dialog.js +61 -0
  71. package/dist/core/errors.js +89 -0
  72. package/dist/core/evidence.js +58 -0
  73. package/dist/core/frontier.js +219 -0
  74. package/dist/core/gdate.js +77 -0
  75. package/dist/core/git.js +300 -0
  76. package/dist/core/guard.js +124 -0
  77. package/dist/core/http2.js +76 -0
  78. package/dist/core/import.js +541 -0
  79. package/dist/core/install.js +28 -0
  80. package/dist/core/integrity.js +219 -0
  81. package/dist/core/json.js +87 -0
  82. package/dist/core/lang.js +70 -0
  83. package/dist/core/live.js +244 -0
  84. package/dist/core/lock.js +112 -0
  85. package/dist/core/logins.js +67 -0
  86. package/dist/core/media.js +223 -0
  87. package/dist/core/model.js +101 -0
  88. package/dist/core/net.js +366 -0
  89. package/dist/core/open.js +29 -0
  90. package/dist/core/paths.js +84 -0
  91. package/dist/core/people.js +283 -0
  92. package/dist/core/phrases.js +85 -0
  93. package/dist/core/queue.js +113 -0
  94. package/dist/core/reader.js +76 -0
  95. package/dist/core/records.js +105 -0
  96. package/dist/core/roles.js +30 -0
  97. package/dist/core/schema.js +261 -0
  98. package/dist/core/seal.js +77 -0
  99. package/dist/core/self.js +40 -0
  100. package/dist/core/session.js +155 -0
  101. package/dist/core/shortcut.js +90 -0
  102. package/dist/core/stories.js +61 -0
  103. package/dist/core/stromapp.js +138 -0
  104. package/dist/core/text.js +104 -0
  105. package/dist/core/tree.js +507 -0
  106. package/dist/core/uninstall.js +128 -0
  107. package/dist/core/update.js +193 -0
  108. package/dist/core/validate.js +260 -0
  109. package/dist/core/views.js +164 -0
  110. package/dist/core/which.js +51 -0
  111. package/dist/core/workers.js +42 -0
  112. package/dist/gedcom/export.js +454 -0
  113. package/dist/gedcom/labels.js +103 -0
  114. package/dist/gedcom/lines.js +91 -0
  115. package/dist/gedcom/parse.js +53 -0
  116. package/dist/gedcom/validate.js +183 -0
  117. package/dist/image/image.js +223 -0
  118. package/dist/image/index.js +114 -0
  119. package/dist/image/jpeg-decode.js +552 -0
  120. package/dist/image/jpeg-encode.js +254 -0
  121. package/dist/image/png.js +241 -0
  122. package/dist/runners/antigravity.js +70 -0
  123. package/dist/runners/claude.js +179 -0
  124. package/dist/runners/codex.js +45 -0
  125. package/dist/runners/index.js +13 -0
  126. package/dist/runners/jsonl.js +86 -0
  127. package/dist/runners/opencode.js +50 -0
  128. package/dist/runners/runner.js +63 -0
  129. package/dist/runners/script.js +58 -0
  130. package/package.json +44 -0
@@ -0,0 +1,852 @@
1
+ // Domain write operations. Every change to the evidence goes through here,
2
+ // under the tree lock, validated and sealed by Tree.put.
3
+ import { UsageError } from "./errors.js";
4
+ import { normalizeDate } from "./gdate.js";
5
+ import { normalizeAge } from "./age.js";
6
+ import { checkStatus, defaultStatus } from "./evidence.js";
7
+ import { eventKind, FAMILY_EVENT_KINDS, NOTE_MAX, STATUSES, PARTICIPANT_ROLES, NAME_KINDS, RECORD_TYPES, } from "./model.js";
8
+ import { displayName, familiesAsChild, familiesAsPartner, gedcomName, isBirthFamily, notAName, parseName, primaryName, sameName, slashInName } from "./people.js";
9
+ import { foldText } from "./text.js";
10
+ import { roleWord } from "./roles.js";
11
+ import { now, typeOfId } from "./tree.js";
12
+ /** Kinds that only make sense on a couple (a reader drops them on a person). */
13
+ const FAMILY_ONLY = new Set(["MARR", "MARB", "MARC", "MARL", "MARS", "DIV", "DIVF", "ANUL", "ENGA"]);
14
+ export function makeNote(tree, text) {
15
+ const t = text.trim();
16
+ if (!t)
17
+ throw new UsageError("the note is empty");
18
+ if (t.length > NOTE_MAX)
19
+ throw new UsageError(`note is ${t.length} characters (max ${NOTE_MAX})`, {
20
+ hint: "keep notes short; longer reasoning belongs in a conflict or hypothesis, the words of a record in the source transcript",
21
+ });
22
+ return { text: t, at: now(), by: tree.actor };
23
+ }
24
+ export function parseStatus(v, fallback) {
25
+ if (v === undefined)
26
+ return fallback;
27
+ if (!STATUSES.includes(v) || v === "retracted")
28
+ throw new UsageError(`invalid status "${v}"`, { hint: "use proven, probable, possible, lead or disproven" });
29
+ return v;
30
+ }
31
+ export function parseDate(v, what = "date") {
32
+ if (v === undefined)
33
+ return undefined;
34
+ const d = normalizeDate(v);
35
+ if (!d)
36
+ throw new UsageError(`invalid ${what} "${v}"`, {
37
+ hint: 'use GEDCOM form: "24 JUN 1783", "JUN 1783", "1783", "ABT 1783", "BEF 1850", "BET 1811 AND 1812" (or ISO 1783-06-24)',
38
+ });
39
+ return d;
40
+ }
41
+ export function parseSex(v) {
42
+ if (v === undefined)
43
+ return "U";
44
+ const s = v.toUpperCase();
45
+ if (s === "M" || s === "MALE")
46
+ return "M";
47
+ if (s === "F" || s === "FEMALE")
48
+ return "F";
49
+ if (s === "U" || s === "UNKNOWN")
50
+ return "U";
51
+ throw new UsageError(`invalid sex "${v}"`, { hint: "use M, F or U" });
52
+ }
53
+ /** An age as the record gives it → GEDCOM form, or a clear error. */
54
+ export function parseAge(v, whose = "") {
55
+ const age = normalizeAge(v);
56
+ if (!age)
57
+ throw new UsageError(`cannot read the age "${v}"${whose ? ` of ${whose}` : ""}`, {
58
+ hint: 'give it as years/months/days: "27", "27 let", "27y 3m", "3 months", "<1y", INFANT, STILLBORN — the exact words go in --quote',
59
+ });
60
+ return age;
61
+ }
62
+ /** "godparent:Marie Dvořáková" or "witness:P0012" → participant. */
63
+ export function parseParticipant(tree, spec) {
64
+ const m = /^([\p{L}-]+)\s*:\s*(.+)$/u.exec(spec.trim());
65
+ if (!m)
66
+ throw new UsageError(`invalid participant "${spec}"`, { hint: `use role:name or role:P0012 — roles: ${PARTICIPANT_ROLES.join(", ")}` });
67
+ // "kmotr:…", "svědek:…", "Hebamme:…" are understood too
68
+ const role = roleWord(m[1]);
69
+ if (!role)
70
+ throw new UsageError(`invalid role "${m[1]}"`, { hint: PARTICIPANT_ROLES.join(", ") });
71
+ const who = m[2].trim();
72
+ if (/^P\d+$/i.test(who)) {
73
+ const id = "P" + who.slice(1).padStart(4, "0");
74
+ if (!tree.get(id))
75
+ throw new UsageError(`no person ${who}`);
76
+ return { role, person: id };
77
+ }
78
+ return { role, name: who };
79
+ }
80
+ function checkCitation(tree, c) {
81
+ if (tree.get(c.source)?.type !== "source")
82
+ throw new UsageError(`no source ${c.source}`, { hint: 'strom source add "<title>" …' });
83
+ }
84
+ /** A list of citations with one more; the same place of the same source twice is refused. */
85
+ function withCitation(list, c, what) {
86
+ if ((list ?? []).some((x) => x.source === c.source && x.locator === c.locator))
87
+ throw new UsageError(`${what} already cites ${c.source} there`);
88
+ return [...(list ?? []), c];
89
+ }
90
+ /** Build an event with a fresh ID. Enforces: proven/probable need a citation. */
91
+ export function makeEvent(tree, input, forFamily = false) {
92
+ const kind = eventKind(input.kind);
93
+ if (!kind)
94
+ throw new UsageError(`unknown event kind "${input.kind}"`, { hint: "use a GEDCOM tag like BIRT, CHR, DEAT, BURI, MARR, OCCU, RESI (strom help event add)" });
95
+ if (forFamily && !FAMILY_EVENT_KINDS.has(kind))
96
+ throw new UsageError(`${kind} is a personal event, not a family event`, { hint: "add it to a person: strom event add <person> " + kind });
97
+ if (!forFamily && FAMILY_ONLY.has(kind))
98
+ throw new UsageError(`${kind} belongs to a family, not to one person`, {
99
+ hint: `strom event add <F…> ${kind} — or strom family add --partner … --married <date>; if the spouse is unknown use EVEN --label "Marriage"`,
100
+ });
101
+ const citations = input.citations ?? [];
102
+ for (const c of citations)
103
+ if (tree.get(c.source)?.type !== "source")
104
+ throw new UsageError(`no source ${c.source}`, { hint: 'strom source add "<title>" …' });
105
+ const status = parseStatus(input.status, defaultStatus(tree, citations));
106
+ checkStatus(tree, status, citations);
107
+ if (forFamily && input.age?.trim())
108
+ throw new UsageError("a family event has the age of each partner", { hint: '--age P0001:25 --age P0002:22 (or husband:25 / wife:22)' });
109
+ if (kind === "EVEN" && !input.label?.trim() && !input.value?.trim())
110
+ throw new UsageError("EVEN needs --label (what happened, e.g. \"Fire\")");
111
+ if (["OCCU", "RELI", "TITL", "NATI"].includes(kind) && !input.value?.trim())
112
+ throw new UsageError(`${kind} needs --value (the fact itself, e.g. "blacksmith")`);
113
+ const ev = { id: tree.allocate("E"), kind, status, citations };
114
+ const date = parseDate(input.date);
115
+ if (date)
116
+ ev.date = date;
117
+ if (input.place?.trim())
118
+ ev.place = input.place.trim();
119
+ if (input.house?.trim())
120
+ ev.house = input.house.trim();
121
+ if (input.cause?.trim())
122
+ ev.cause = input.cause.trim();
123
+ if (input.value?.trim())
124
+ ev.value = input.value.trim();
125
+ if (input.label?.trim())
126
+ ev.label = input.label.trim();
127
+ if (input.age?.trim())
128
+ ev.age = parseAge(input.age);
129
+ if (input.ages && Object.keys(input.ages).length)
130
+ ev.ages = Object.fromEntries(Object.entries(input.ages).map(([who, a]) => [who, parseAge(a, who)]));
131
+ if (input.participants?.length)
132
+ ev.participants = input.participants;
133
+ if (input.note?.trim())
134
+ ev.note = makeNote(tree, input.note).text;
135
+ return ev;
136
+ }
137
+ /** "E0002 MARR 1910 [lead]" — how a new fact is reported, so its ID can be cited at once. */
138
+ export function eventSummary(e) {
139
+ return `${e.id} ${e.kind}${e.date ? ` ${e.date}` : ""}${e.place ? ` ${e.place}` : ""} [${e.status}]`;
140
+ }
141
+ /** A name the record gives — never a description of the person in its place, and one GEDCOM can hold. */
142
+ function checkGiven(name) {
143
+ const slash = slashInName(name);
144
+ if (slash)
145
+ throw new UsageError(slash, {
146
+ hint: 'a letter not read for sure: "Anna /[?]emenská/", and each reading as an alias — strom name add P… "Anna /Kemenská/" --kind alias',
147
+ });
148
+ const why = notAName(name.given);
149
+ if (why)
150
+ throw new UsageError(why, {
151
+ hint: `the record does not name the person: "/${name.surname || "Surname"}/" with an empty given name; what it says goes to the facts — a stillborn child: strom event add P… DEAT --date … --age stillborn; unbaptised or unnamed: a --note`,
152
+ });
153
+ }
154
+ export function addPerson(tree, input) {
155
+ const name = parseName(input.name);
156
+ if (!name.given && !name.surname)
157
+ throw new UsageError("the person needs a name");
158
+ checkGiven(name);
159
+ const sex = parseSex(input.sex);
160
+ // Without a birth or death to carry it, the record is evidence of the person's name.
161
+ const facts = input.born || input.bornPlace || input.died || input.diedPlace;
162
+ if (input.citation && !facts) {
163
+ checkCitation(tree, input.citation);
164
+ name.citations = [input.citation];
165
+ }
166
+ // Validate dates before allocating IDs.
167
+ parseDate(input.born, "birth date");
168
+ parseDate(input.died, "death date");
169
+ return tree.withTreeLock(() => {
170
+ const t = now();
171
+ const events = [];
172
+ const cited = { citations: input.citation ? [input.citation] : [], status: input.status };
173
+ if (input.born || input.bornPlace)
174
+ events.push(makeEvent(tree, { kind: "BIRT", date: input.born, place: input.bornPlace, ...cited }));
175
+ if (input.died || input.diedPlace)
176
+ events.push(makeEvent(tree, { kind: "DEAT", date: input.died, place: input.diedPlace, ...cited }));
177
+ const person = {
178
+ id: tree.allocate("P"),
179
+ type: "person",
180
+ names: [name],
181
+ sex,
182
+ events,
183
+ notes: input.note ? [makeNote(tree, input.note)] : [],
184
+ created: t,
185
+ updated: t,
186
+ };
187
+ tree.put(person, {
188
+ op: "person.add",
189
+ targets: [person.id],
190
+ summary: [`+${person.id} ${gedcomName(name)}${name.citations ? ` ← ${name.citations[0].source}` : ""}`, ...events.map(eventSummary)].join(" · "),
191
+ });
192
+ return person;
193
+ });
194
+ }
195
+ function requirePerson(tree, id) {
196
+ const p = tree.get(id);
197
+ if (!p || p.type !== "person")
198
+ throw new UsageError(`no person ${id}`, { hint: "strom person list" });
199
+ if (p.mergedInto)
200
+ throw new UsageError(`${id} was merged into ${p.mergedInto}`, { hint: `use ${p.mergedInto}` });
201
+ return p;
202
+ }
203
+ const RELATIONS = ["birth", "adopted", "step", "foster", "unknown"];
204
+ export function addFamily(tree, input) {
205
+ if (input.partners.length > 2)
206
+ throw new UsageError("a family has at most two partners");
207
+ if (input.partners.length + input.children.length === 0)
208
+ throw new UsageError("give at least one --partner or --child");
209
+ const relation = (input.relation ?? "birth");
210
+ if (!RELATIONS.includes(relation))
211
+ throw new UsageError(`invalid relation "${input.relation}"`, { hint: RELATIONS.join(", ") });
212
+ for (const id of [...input.partners, ...input.children])
213
+ requirePerson(tree, id);
214
+ if (new Set([...input.partners, ...input.children]).size !== input.partners.length + input.children.length)
215
+ throw new UsageError("the same person is listed twice");
216
+ if (relation === "birth")
217
+ for (const c of input.children) {
218
+ const fam = familiesAsChild(tree, c).find((f) => isBirthFamily(f, c));
219
+ if (fam)
220
+ throw new UsageError(`${c} already has birth parents in ${fam.id}`, { hint: `strom family show ${fam.id} — or, if these are other parents: --relation adopted|step|foster` });
221
+ }
222
+ parseDate(input.married, "marriage date");
223
+ const married = Boolean(input.married || input.marriedPlace);
224
+ if (input.citation && !married)
225
+ checkCitation(tree, input.citation);
226
+ return tree.withTreeLock(() => {
227
+ const t = now();
228
+ const events = [];
229
+ if (married)
230
+ events.push(makeEvent(tree, { kind: "MARR", date: input.married, place: input.marriedPlace, citations: input.citation ? [input.citation] : [], status: input.status, ages: input.ages }, true));
231
+ const family = {
232
+ id: tree.allocate("F"),
233
+ type: "family",
234
+ partners: input.partners,
235
+ children: input.children.map((person) => ({ person, relation })),
236
+ events,
237
+ // Without a marriage the record is evidence of the family itself (a child's parents).
238
+ ...(input.citation && !married ? { citations: [input.citation] } : {}),
239
+ notes: input.note ? [makeNote(tree, input.note)] : [],
240
+ created: t,
241
+ updated: t,
242
+ };
243
+ const names = input.partners.map((id) => displayName(requirePerson(tree, id))).join(" & ");
244
+ tree.put(family, {
245
+ op: "family.add",
246
+ targets: [family.id, ...input.partners, ...input.children],
247
+ summary: [
248
+ `+${family.id} ${names || "family"}${input.children.length ? ` (${input.children.length} child${input.children.length > 1 ? "ren" : ""})` : ""}${family.citations ? ` ← ${family.citations[0].source}` : ""}`,
249
+ ...events.map(eventSummary),
250
+ ].join(" · "),
251
+ });
252
+ return family;
253
+ });
254
+ }
255
+ /** Add a child to an existing family — with the record that names the child's parents, if given. */
256
+ export function addChild(tree, familyId, child, relationInput, citation) {
257
+ const relation = (relationInput ?? "birth");
258
+ if (!RELATIONS.includes(relation))
259
+ throw new UsageError(`invalid relation "${relationInput}"`, { hint: RELATIONS.join(", ") });
260
+ requirePerson(tree, child);
261
+ if (citation)
262
+ checkCitation(tree, citation);
263
+ return tree.withTreeLock(() => {
264
+ const fam = tree.get(familyId);
265
+ if (!fam || fam.type !== "family")
266
+ throw new UsageError(`no family ${familyId}`);
267
+ if (fam.children.some((c) => c.person === child) || fam.partners.includes(child))
268
+ throw new UsageError(`${child} is already in ${familyId}`);
269
+ if (relation === "birth") {
270
+ const other = familiesAsChild(tree, child).find((f) => isBirthFamily(f, child));
271
+ if (other)
272
+ throw new UsageError(`${child} already has birth parents in ${other.id}`);
273
+ }
274
+ // One entry names the parents and each child: the family may cite it already.
275
+ const cited = citation && (fam.citations ?? []).some((x) => x.source === citation.source && x.locator === citation.locator);
276
+ const updated = {
277
+ ...fam,
278
+ children: [...fam.children, { person: child, relation }],
279
+ ...(citation && !cited ? { citations: withCitation(fam.citations, citation, fam.id) } : {}),
280
+ updated: now(),
281
+ };
282
+ tree.put(updated, { op: "family.child", targets: [fam.id, child], summary: `${fam.id} +child ${child}${citation ? ` ← ${citation.source}` : ""}` });
283
+ return updated;
284
+ });
285
+ }
286
+ /**
287
+ * Change who belongs to a family and how: the partner found later, a child
288
+ * that is a stepchild of one of them, a person linked by mistake. Everything
289
+ * but adding the partner changes what is known: it needs a reason.
290
+ */
291
+ export function editFamily(tree, familyId, edit, reason) {
292
+ if (!edit.partner && !edit.child && !edit.remove)
293
+ throw new UsageError("nothing to change", { hint: `strom family edit ${familyId} --partner <who> · --child <who> --relation step [--parent <who>] · --remove <who>` });
294
+ if ((edit.child || edit.remove) && !reason?.trim())
295
+ throw new UsageError("changing who belongs to a family, or how, needs --reason", { hint: 'e.g. --reason "the baptism names Jan as stepfather"' });
296
+ if (edit.child && !edit.relation)
297
+ throw new UsageError("--child needs --relation", { hint: RELATIONS.join(", ") });
298
+ if (edit.relation && !edit.child)
299
+ throw new UsageError("--relation belongs to a --child");
300
+ if (edit.parent && !edit.child)
301
+ throw new UsageError("--parent belongs to a --child");
302
+ const relation = edit.relation;
303
+ if (relation && !RELATIONS.includes(relation))
304
+ throw new UsageError(`invalid relation "${edit.relation}"`, { hint: RELATIONS.join(", ") });
305
+ return tree.withTreeLock(() => {
306
+ const fam = tree.get(familyId);
307
+ if (!fam || fam.type !== "family")
308
+ throw new UsageError(`no family ${familyId}`);
309
+ const next = structuredClone(fam);
310
+ const what = [];
311
+ if (edit.partner) {
312
+ requirePerson(tree, edit.partner);
313
+ if (next.partners.includes(edit.partner) || next.children.some((c) => c.person === edit.partner))
314
+ throw new UsageError(`${edit.partner} is already in ${familyId}`);
315
+ if (next.partners.length >= 2)
316
+ throw new UsageError(`${familyId} has two partners`, { hint: "another couple is another family: strom family add …" });
317
+ next.partners.push(edit.partner);
318
+ what.push(`+partner ${edit.partner}`);
319
+ }
320
+ if (edit.child) {
321
+ const link = next.children.find((c) => c.person === edit.child);
322
+ if (!link)
323
+ throw new UsageError(`${edit.child} is not a child of ${familyId}`, { hint: `add the child: strom family child ${familyId} ${edit.child}` });
324
+ if (edit.parent) {
325
+ if (!next.partners.includes(edit.parent))
326
+ throw new UsageError(`${edit.parent} is not a partner of ${familyId}`, { hint: `partners: ${next.partners.join(", ") || "none"}` });
327
+ const relations = { ...link.relations, [edit.parent]: relation };
328
+ if (relations[edit.parent] === link.relation)
329
+ delete relations[edit.parent];
330
+ if (Object.keys(relations).length)
331
+ link.relations = relations;
332
+ else
333
+ delete link.relations;
334
+ what.push(`${edit.child} ${relation} of ${edit.parent}`);
335
+ }
336
+ else {
337
+ link.relation = relation;
338
+ delete link.relations;
339
+ what.push(`${edit.child} ${relation}`);
340
+ }
341
+ if (isBirthFamily(next, edit.child)) {
342
+ const other = familiesAsChild(tree, edit.child).find((f) => f.id !== familyId && isBirthFamily(f, edit.child));
343
+ if (other)
344
+ throw new UsageError(`${edit.child} already has birth parents in ${other.id}`);
345
+ }
346
+ }
347
+ if (edit.remove) {
348
+ const was = next.partners.includes(edit.remove) || next.children.some((c) => c.person === edit.remove);
349
+ if (!was)
350
+ throw new UsageError(`${edit.remove} is not in ${familyId}`);
351
+ next.partners = next.partners.filter((p) => p !== edit.remove);
352
+ next.children = next.children.filter((c) => c.person !== edit.remove);
353
+ for (const c of next.children)
354
+ if (c.relations?.[edit.remove]) {
355
+ delete c.relations[edit.remove];
356
+ if (!Object.keys(c.relations).length)
357
+ delete c.relations;
358
+ }
359
+ for (const e of next.events)
360
+ if (e.ages?.[edit.remove])
361
+ delete e.ages[edit.remove];
362
+ if (next.partners.length + next.children.length === 0)
363
+ throw new UsageError(`${familyId} would be empty`, { hint: "a family recorded by mistake stays: say so in a note (strom note add …)" });
364
+ what.push(`-${edit.remove}`);
365
+ }
366
+ next.updated = now();
367
+ tree.put(next, {
368
+ op: "family.edit",
369
+ targets: [familyId, ...[edit.partner, edit.child, edit.parent, edit.remove].filter((x) => !!x)],
370
+ summary: `${familyId} ${what.join(", ")}`,
371
+ ...(reason ? { reason } : {}),
372
+ });
373
+ return next;
374
+ });
375
+ }
376
+ /** Facts a person or couple has once: a second one is a duplicate or a conflict. */
377
+ export const SINGLE_KINDS = new Set(["BIRT", "CHR", "BAPM", "DEAT", "BURI", "CREM", "MARR", "DIV"]);
378
+ export function addEvent(tree, ownerId, input) {
379
+ const type = typeOfId(ownerId);
380
+ if (type !== "person" && type !== "family")
381
+ throw new UsageError(`${ownerId} is not a person or a family`);
382
+ parseDate(input.date);
383
+ return tree.withTreeLock(() => {
384
+ const owner = tree.get(ownerId);
385
+ if (!owner)
386
+ throw new UsageError(`no ${type} ${ownerId}`);
387
+ const event = makeEvent(tree, input, type === "family");
388
+ const updated = { ...owner, events: [...owner.events, event], updated: now() };
389
+ tree.put(updated, {
390
+ op: "event.add",
391
+ targets: [owner.id, event.id],
392
+ summary: `+${event.id} ${event.kind} ${owner.id}${event.date ? ` ${event.date}` : ""} [${event.status}]`,
393
+ });
394
+ // One birth, one death: a second one is usually the same fact from another record.
395
+ const same = SINGLE_KINDS.has(event.kind) ? owner.events.find((e) => e.kind === event.kind && !e.retracted && e.status !== "disproven") : undefined;
396
+ return { owner: updated, event, ...(same ? { same } : {}) };
397
+ });
398
+ }
399
+ export function addNote(tree, id, text) {
400
+ const type = typeOfId(id);
401
+ if (!type)
402
+ throw new UsageError(`unknown ID ${id}`);
403
+ const note = makeNote(tree, text);
404
+ tree.withTreeLock(() => {
405
+ const rec = tree.get(id);
406
+ if (!rec)
407
+ throw new UsageError(`no record ${id}`);
408
+ const updated = { ...rec, notes: [...rec.notes, note], updated: now() };
409
+ tree.put(updated, { op: "note.add", targets: [id], summary: `${id} +note` });
410
+ });
411
+ return note;
412
+ }
413
+ export function addResearch(tree, input) {
414
+ const direction = (input.direction ?? "ancestors");
415
+ if (!["ancestors", "descendants", "person", "question"].includes(direction))
416
+ throw new UsageError(`invalid direction "${input.direction}"`, { hint: "ancestors, descendants, person or question" });
417
+ requirePerson(tree, input.focus);
418
+ if (direction === "question" && !input.question?.trim())
419
+ throw new UsageError("--question is required for direction question");
420
+ return tree.withTreeLock(() => {
421
+ const t = now();
422
+ const r = {
423
+ id: tree.allocate("G"),
424
+ type: "research",
425
+ name: input.name.trim(),
426
+ focus: input.focus,
427
+ direction,
428
+ state: "active",
429
+ priority: input.priority ?? 3,
430
+ notes: input.note ? [makeNote(tree, input.note)] : [],
431
+ created: t,
432
+ updated: t,
433
+ };
434
+ const limits = {};
435
+ if (input.generations)
436
+ limits.generations = input.generations;
437
+ if (input.before)
438
+ limits.before = input.before;
439
+ if (Object.keys(limits).length)
440
+ r.limits = limits;
441
+ if (input.question?.trim())
442
+ r.question = input.question.trim();
443
+ tree.put(r, { op: "research.add", targets: [r.id, input.focus], summary: `+${r.id} research "${r.name}"` });
444
+ return r;
445
+ });
446
+ }
447
+ /** Find the person or family that holds an event. */
448
+ export function findEventOwner(tree, eventId) {
449
+ const id = eventId.toUpperCase();
450
+ for (const t of ["person", "family"])
451
+ for (const r of tree.list(t)) {
452
+ const event = r.events.find((e) => e.id === id);
453
+ if (event)
454
+ return { owner: r, event };
455
+ }
456
+ throw new UsageError(`no event ${eventId}`, { hint: "event IDs are listed in strom person show <who>" });
457
+ }
458
+ function replaceEvent(tree, eventId, change, op) {
459
+ return tree.withTreeLock(() => {
460
+ const { owner, event } = findEventOwner(tree, eventId);
461
+ const next = change(structuredClone(event));
462
+ const updated = { ...owner, events: owner.events.map((e) => (e.id === event.id ? next : e)), updated: now() };
463
+ tree.put(updated, { op: op.op, targets: [owner.id, event.id], summary: op.summary(next), ...(op.reason ? { reason: op.reason } : {}) });
464
+ return next;
465
+ });
466
+ }
467
+ /** Attach a citation to a fact; optionally raise its status. */
468
+ export function citeEvent(tree, eventId, citation, status) {
469
+ const src = tree.get(citation.source);
470
+ if (!src || src.type !== "source")
471
+ throw new UsageError(`no source ${citation.source}`, { hint: 'strom source add "<title>" …' });
472
+ const newStatus = status === undefined ? undefined : parseStatus(status, "lead");
473
+ return replaceEvent(tree, eventId, (e) => {
474
+ if (e.citations.some((c) => c.source === citation.source && c.locator === citation.locator))
475
+ throw new UsageError(`${e.id} already cites ${citation.source} there`);
476
+ const next = { ...e, citations: [...e.citations, citation] };
477
+ // A record raises a lead to probable; a family tree or a memory does not.
478
+ if (newStatus)
479
+ next.status = newStatus;
480
+ else if (next.status === "lead")
481
+ next.status = defaultStatus(tree, next.citations);
482
+ checkStatus(tree, next.status, next.citations);
483
+ return next;
484
+ }, { op: "event.cite", summary: (e) => `${e.id} ${e.kind} ← ${citation.source} [${e.status}]` });
485
+ }
486
+ /** Cite a person (the name they are shown by) or a family itself. */
487
+ export function citeRecord(tree, id, citation) {
488
+ checkCitation(tree, citation);
489
+ return tree.withTreeLock(() => {
490
+ const rec = tree.get(id);
491
+ if (rec?.type === "person") {
492
+ const primary = primaryName(rec);
493
+ const names = rec.names.map((n) => (n === primary ? { ...n, citations: withCitation(n.citations, citation, `${id} ${gedcomName(n)}`) } : n));
494
+ const updated = { ...rec, names, updated: now() };
495
+ tree.put(updated, { op: "person.cite", targets: [id], summary: `${id} ${gedcomName(primary)} ← ${citation.source}` });
496
+ return updated;
497
+ }
498
+ if (rec?.type === "family") {
499
+ const updated = { ...rec, citations: withCitation(rec.citations, citation, id), updated: now() };
500
+ tree.put(updated, { op: "family.cite", targets: [id], summary: `${id} ← ${citation.source}` });
501
+ return updated;
502
+ }
503
+ throw new UsageError(`no person or family ${id}`);
504
+ });
505
+ }
506
+ /**
507
+ * Another name of a person: the birth surname a record reveals, a married
508
+ * name, a spelling of another record. The same name again only adds its
509
+ * citation. A name whose surname was unknown gives way to the full one.
510
+ */
511
+ export function addName(tree, id, input) {
512
+ const name = parseName(input.name);
513
+ if (!name.given && !name.surname)
514
+ throw new UsageError("the name is empty");
515
+ checkGiven(name);
516
+ if (input.kind !== undefined) {
517
+ if (!NAME_KINDS.includes(input.kind))
518
+ throw new UsageError(`invalid --kind "${input.kind}"`, { hint: NAME_KINDS.join(", ") });
519
+ name.kind = input.kind;
520
+ }
521
+ if (input.primary && name.kind && name.kind !== "birth")
522
+ throw new UsageError(`a person is shown by a birth name, not a ${name.kind} one`, { hint: "drop --primary, or --kind birth" });
523
+ if (input.citation)
524
+ checkCitation(tree, input.citation);
525
+ return tree.withTreeLock(() => {
526
+ const p = requirePerson(tree, id);
527
+ const i = p.names.findIndex((n) => sameName(n, name));
528
+ let names;
529
+ let added;
530
+ let completes = false;
531
+ if (i >= 0) {
532
+ // The same words: one name — the record adds its citation, --kind says what kind of name it is.
533
+ const cur = p.names[i];
534
+ const rekind = name.kind !== undefined && name.kind !== cur.kind;
535
+ if (!input.citation && !input.primary && !rekind)
536
+ throw new UsageError(`${id} already has the name ${gedcomName(name)}`, { hint: `cite it: strom name add ${id} "${gedcomName(name)}" --cite S…` });
537
+ added = { ...cur, ...(rekind ? { kind: name.kind } : {}), ...(input.citation ? { citations: withCitation(cur.citations, input.citation, `${id} ${gedcomName(name)}`) } : {}) };
538
+ names = p.names.map((n, j) => (j === i ? added : n));
539
+ if (input.primary)
540
+ names = [added, ...names.filter((n) => n !== added)];
541
+ }
542
+ else {
543
+ added = input.citation ? { ...name, citations: [input.citation] } : name;
544
+ const primary = primaryName(p);
545
+ const plain = !name.kind || name.kind === "birth";
546
+ // "Markéta" of the family memory is "Markéta /Růžičková/" of the register: one name, now complete.
547
+ completes = Boolean(plain && !primary.surname && name.surname && foldText(primary.given) === foldText(name.given) && !primary.citations?.length);
548
+ const rest = completes ? p.names.filter((n) => n !== primary) : p.names;
549
+ names = input.primary || (plain && !primary.surname && name.surname) ? [added, ...rest] : [...rest, added];
550
+ }
551
+ const updated = { ...p, names, updated: now() };
552
+ tree.put(updated, {
553
+ op: "person.name",
554
+ targets: [id],
555
+ summary: `${id} name ${gedcomName(added)}${added.kind ? ` (${added.kind})` : ""}${input.citation ? ` ← ${input.citation.source}` : ""}`,
556
+ ...(completes ? { reason: `the name without a surname is completed: ${gedcomName(added)}` } : {}),
557
+ });
558
+ return { person: updated, name: added };
559
+ });
560
+ }
561
+ /** Change a person's sex or correct the spelling of their name — with a reason, unless an unknown sex is filled in. */
562
+ export function editPerson(tree, id, edit, reason) {
563
+ const sex = edit.sex === undefined ? undefined : parseSex(edit.sex);
564
+ const name = edit.name === undefined ? undefined : parseName(edit.name);
565
+ if (name && !name.given && !name.surname)
566
+ throw new UsageError("the name is empty");
567
+ if (name)
568
+ checkGiven(name);
569
+ if (sex === undefined && !name)
570
+ throw new UsageError("nothing to change", { hint: `strom person edit ${id} --sex M|F · --name "<Given /Surname/>"` });
571
+ return tree.withTreeLock(() => {
572
+ const p = requirePerson(tree, id);
573
+ const primary = primaryName(p);
574
+ // Filling in an unknown sex is no change; another sex or another name is.
575
+ const overwrites = (sex !== undefined && p.sex !== "U" && p.sex !== sex) || name;
576
+ if (overwrites && !reason?.trim())
577
+ throw new UsageError("changing a name or a known sex needs --reason", { hint: 'e.g. --reason "misread: the register has Víšek, not Višek"' });
578
+ const names = name ? p.names.map((n) => (n === primary ? { ...n, given: name.given, surname: name.surname } : n)) : p.names;
579
+ const updated = { ...p, ...(sex !== undefined ? { sex } : {}), names, updated: now() };
580
+ const what = [sex !== undefined ? `sex ${sex}` : "", name ? `name ${gedcomName(name)}` : ""].filter(Boolean).join(", ");
581
+ tree.put(updated, { op: "person.edit", targets: [id], summary: `${id} ${what}`, ...(reason ? { reason } : {}) });
582
+ return updated;
583
+ });
584
+ }
585
+ /**
586
+ * Change a fact, or add what another record says about it (ages, the house,
587
+ * godparents, witnesses). Filling in what was unknown is free; changing what
588
+ * was known — or the status — needs a reason.
589
+ */
590
+ export function editEvent(tree, eventId, edit, reason) {
591
+ const date = edit.date === undefined ? undefined : parseDate(edit.date);
592
+ const age = edit.age === undefined ? undefined : parseAge(edit.age);
593
+ const ages = edit.ages ? Object.fromEntries(Object.entries(edit.ages).map(([who, a]) => [who, parseAge(a, who)])) : undefined;
594
+ return replaceEvent(tree, eventId, (e) => {
595
+ const changed = [];
596
+ const set = (k, v) => {
597
+ if (v === undefined)
598
+ return;
599
+ const nv = v.trim();
600
+ if (e[k] !== undefined && e[k] !== nv)
601
+ changed.push(k);
602
+ if (nv)
603
+ next[k] = nv;
604
+ else
605
+ delete next[k];
606
+ };
607
+ const next = { ...e };
608
+ set("date", date);
609
+ set("place", edit.place);
610
+ set("house", edit.house);
611
+ set("cause", edit.cause);
612
+ set("value", edit.value);
613
+ set("label", edit.label);
614
+ if (age !== undefined) {
615
+ if (typeOfId(findEventOwner(tree, e.id).owner.id) === "family")
616
+ throw new UsageError("a family event has the age of each partner", { hint: "--age husband:25 --age wife:22" });
617
+ set("age", age);
618
+ }
619
+ if (ages) {
620
+ for (const [who, a] of Object.entries(ages))
621
+ if (e.ages?.[who] !== undefined && e.ages[who] !== a)
622
+ changed.push(`age of ${who}`);
623
+ next.ages = { ...e.ages, ...ages };
624
+ }
625
+ const key = (p) => `${p.role}|${p.person ?? foldText(p.name ?? "")}`;
626
+ if (edit.drop?.length) {
627
+ const gone = new Set(edit.drop.map(key));
628
+ for (const d of edit.drop)
629
+ if (!(e.participants ?? []).some((p) => key(p) === key(d)))
630
+ throw new UsageError(`${e.id} has no ${d.role} ${d.person ?? d.name}`, { hint: `its participants: ${(e.participants ?? []).map((p) => `${p.role}:${p.person ?? p.name}`).join(", ") || "none"}` });
631
+ next.participants = (e.participants ?? []).filter((p) => !gone.has(key(p)));
632
+ if (!next.participants.length)
633
+ delete next.participants;
634
+ changed.push("participants");
635
+ }
636
+ if (edit.participants?.length) {
637
+ const known = new Set((next.participants ?? []).map(key));
638
+ next.participants = [...(next.participants ?? []), ...edit.participants.filter((p) => !known.has(key(p)))];
639
+ }
640
+ if (edit.note !== undefined)
641
+ next.note = makeNote(tree, edit.note).text;
642
+ if (edit.status !== undefined) {
643
+ next.status = parseStatus(edit.status, e.status);
644
+ if (next.status !== e.status)
645
+ changed.push("status");
646
+ checkStatus(tree, next.status, next.citations);
647
+ }
648
+ if (changed.length && !reason?.trim())
649
+ throw new UsageError(`changing the ${changed.join(", ")} of ${e.id} needs --reason`, { hint: 'e.g. --reason "record S0012 gives 1811, not 1813"' });
650
+ return next;
651
+ }, { op: "event.edit", summary: (e) => `${e.id} ${e.kind} edited`, reason });
652
+ }
653
+ /** Withdraw a fact without deleting it. */
654
+ export function retractEvent(tree, eventId, reason) {
655
+ if (!reason?.trim())
656
+ throw new UsageError("retracting needs --reason");
657
+ return replaceEvent(tree, eventId, (e) => ({ ...e, status: "retracted", retracted: { at: now(), reason: reason.trim() } }), {
658
+ op: "event.retract",
659
+ summary: (e) => `${e.id} ${e.kind} retracted`,
660
+ reason,
661
+ });
662
+ }
663
+ /**
664
+ * A person who turned out not to exist (a misreading, a wrong link): kept,
665
+ * retracted with the reason; no longer anyone's parent, child or partner in
666
+ * the listings and the GEDCOM. A duplicate is merged instead.
667
+ */
668
+ export function retractPerson(tree, id, reason) {
669
+ if (!reason?.trim())
670
+ throw new UsageError("retracting needs --reason", { hint: 'e.g. --reason "misread: the entry names Anna, not Anton"' });
671
+ return tree.withTreeLock(() => {
672
+ const p = requirePerson(tree, id);
673
+ if (p.retracted)
674
+ throw new UsageError(`${id} is already retracted`);
675
+ const focus = tree.list("research").find((r) => r.focus === id);
676
+ if (focus)
677
+ throw new UsageError(`${id} is the focus of ${focus.id}`, { hint: "a research needs its person" });
678
+ const updated = { ...p, retracted: { at: now(), reason: reason.trim() }, updated: now() };
679
+ tree.put(updated, { op: "person.retract", targets: [id], summary: `${id} ${gedcomName(primaryName(p))} retracted`, reason: reason.trim() });
680
+ return updated;
681
+ });
682
+ }
683
+ // ── merging duplicates ─────────────────────────────────────────────────────
684
+ /** `value` with every reference `from` replaced by `to` (a list keeps it once). */
685
+ function replaceId(value, from, to) {
686
+ if (value === from)
687
+ return to;
688
+ if (Array.isArray(value)) {
689
+ const out = value.map((v) => replaceId(v, from, to));
690
+ return value.includes(from) ? [...new Set(out)] : out;
691
+ }
692
+ if (value && typeof value === "object")
693
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, replaceId(v, from, to)]));
694
+ return value;
695
+ }
696
+ /** Point every other record that names `from` at `to`: tasks, hypotheses, researches, participants, families. */
697
+ function repoint(tree, from, to, skip, op) {
698
+ let n = 0;
699
+ for (const type of Object.keys(RECORD_TYPES)) {
700
+ for (const rec of tree.list(type)) {
701
+ // a merged or retracted record is history: it keeps what it said
702
+ if (skip.has(rec.id) || rec.retracted)
703
+ continue;
704
+ const text = JSON.stringify(rec);
705
+ if (!text.includes(`"${from}"`))
706
+ continue;
707
+ const next = replaceId(rec, from, to);
708
+ if (next.type === "family") {
709
+ // the same child twice: the first link (with its relation) stays
710
+ const seen = new Set();
711
+ next.children = next.children.filter((c) => !seen.has(c.person) && seen.add(c.person));
712
+ }
713
+ tree.put({ ...next, updated: now() }, { op: op.op, targets: [rec.id, from, to], summary: `${rec.id}: ${from} → ${to}`, reason: op.reason });
714
+ n++;
715
+ }
716
+ }
717
+ return n;
718
+ }
719
+ /**
720
+ * Two records of one person: everything of `other` goes to `keep` (names,
721
+ * facts, notes, families, every reference); `other` stays, retracted, as
722
+ * "merged into". Different birth parents or sexes are refused.
723
+ */
724
+ export function mergePersons(tree, keepId, otherId, reason) {
725
+ if (!reason?.trim())
726
+ throw new UsageError("merging needs --reason", { hint: 'e.g. --reason "same baptism entry: B0003:114"' });
727
+ if (keepId === otherId)
728
+ throw new UsageError("that is the same person");
729
+ return tree.withTreeLock(() => {
730
+ const keep = requirePerson(tree, keepId);
731
+ const other = requirePerson(tree, otherId);
732
+ if (keep.retracted || other.retracted)
733
+ throw new UsageError(`${keep.retracted ? keepId : otherId} is retracted`);
734
+ if (keep.sex !== "U" && other.sex !== "U" && keep.sex !== other.sex)
735
+ throw new UsageError(`${keepId} is ${keep.sex}, ${otherId} is ${other.sex}`, { hint: "correct the wrong one first: strom person edit … --sex … --reason …" });
736
+ const together = familiesAsPartner(tree, keepId).find((f) => f.partners.includes(otherId));
737
+ if (together)
738
+ throw new UsageError(`${keepId} and ${otherId} are the partners of ${together.id}`);
739
+ const birth = (id) => familiesAsChild(tree, id).find((f) => isBirthFamily(f, id));
740
+ const kb = birth(keepId);
741
+ const ob = birth(otherId);
742
+ if (kb && ob && kb.id !== ob.id)
743
+ throw new UsageError(`${keepId} and ${otherId} have different birth parents (${kb.id}, ${ob.id})`, {
744
+ hint: `if they are the same parents, merge the families first: strom family merge ${kb.id} ${ob.id} --reason "…"`,
745
+ });
746
+ // names: the other's variants join; the same name brings its citations
747
+ const names = keep.names.map((n) => {
748
+ const same = other.names.find((o) => sameName(o, n));
749
+ const cites = [...(n.citations ?? []), ...(same?.citations ?? []).filter((c) => !(n.citations ?? []).some((x) => x.source === c.source && x.locator === c.locator))];
750
+ return cites.length ? { ...n, citations: cites } : n;
751
+ });
752
+ for (const o of other.names)
753
+ if (!names.some((n) => sameName(n, o)))
754
+ names.push(o);
755
+ const refs = [...(keep.refs ?? []), ...(other.refs ?? []).filter((r) => !(keep.refs ?? []).some((k) => k.system === r.system && k.id === r.id))];
756
+ const why = reason.trim();
757
+ const merged = {
758
+ ...keep,
759
+ names,
760
+ sex: keep.sex === "U" ? other.sex : keep.sex,
761
+ events: [...keep.events, ...other.events],
762
+ notes: [...keep.notes, ...other.notes],
763
+ ...(refs.length ? { refs } : {}),
764
+ updated: now(),
765
+ };
766
+ tree.put(merged, {
767
+ op: "person.merge",
768
+ targets: [keepId, otherId, ...other.events.map((e) => e.id)],
769
+ summary: `${otherId} merged into ${keepId}: ${other.events.length} fact(s), ${other.names.length} name(s)`,
770
+ reason: why,
771
+ });
772
+ const stub = { ...other, events: [], retracted: { at: now(), reason: `merged into ${keepId}: ${why}` }, mergedInto: keepId, updated: now() };
773
+ tree.put(stub, { op: "person.merge", targets: [otherId, keepId], summary: `${otherId} → ${keepId}`, reason: why });
774
+ const repointed = repoint(tree, otherId, keepId, new Set([keepId, otherId]), { op: "person.merge", reason: why });
775
+ return { person: merged, repointed };
776
+ });
777
+ }
778
+ /** Two records of one family (the same couple): partners, children, facts and citations go to `keep`. */
779
+ export function mergeFamilies(tree, keepId, otherId, reason) {
780
+ if (!reason?.trim())
781
+ throw new UsageError("merging needs --reason", { hint: 'e.g. --reason "the same couple: Antonín Víšek and Markéta Růžičková"' });
782
+ if (keepId === otherId)
783
+ throw new UsageError("that is the same family");
784
+ return tree.withTreeLock(() => {
785
+ const get = (id) => {
786
+ const f = tree.get(id);
787
+ if (!f || f.type !== "family")
788
+ throw new UsageError(`no family ${id}`);
789
+ if (f.retracted)
790
+ throw new UsageError(`${id} is retracted`);
791
+ return f;
792
+ };
793
+ const keep = get(keepId);
794
+ const other = get(otherId);
795
+ const partners = [...new Set([...keep.partners, ...other.partners])];
796
+ if (partners.length > 2)
797
+ throw new UsageError(`${keepId} and ${otherId} are different couples (${partners.join(", ")})`, { hint: "if a partner is recorded twice, merge those persons first: strom person merge …" });
798
+ const children = [...keep.children, ...other.children.filter((c) => !keep.children.some((k) => k.person === c.person))];
799
+ const citations = [...(keep.citations ?? []), ...(other.citations ?? []).filter((c) => !(keep.citations ?? []).some((x) => x.source === c.source && x.locator === c.locator))];
800
+ const refs = [...(keep.refs ?? []), ...(other.refs ?? []).filter((r) => !(keep.refs ?? []).some((k) => k.system === r.system && k.id === r.id))];
801
+ const why = reason.trim();
802
+ const merged = {
803
+ ...keep,
804
+ partners,
805
+ children,
806
+ events: [...keep.events, ...other.events],
807
+ ...(citations.length ? { citations } : {}),
808
+ notes: [...keep.notes, ...other.notes],
809
+ ...(refs.length ? { refs } : {}),
810
+ updated: now(),
811
+ };
812
+ tree.put(merged, {
813
+ op: "family.merge",
814
+ targets: [keepId, otherId, ...partners, ...children.map((c) => c.person)],
815
+ summary: `${otherId} merged into ${keepId}: ${other.children.length} child link(s), ${other.events.length} fact(s)`,
816
+ reason: why,
817
+ });
818
+ const stub = { ...other, events: [], retracted: { at: now(), reason: `merged into ${keepId}: ${why}` }, mergedInto: keepId, updated: now() };
819
+ tree.put(stub, { op: "family.merge", targets: [otherId, keepId], summary: `${otherId} → ${keepId}`, reason: why });
820
+ const repointed = repoint(tree, otherId, keepId, new Set([keepId, otherId]), { op: "family.merge", reason: why });
821
+ return { family: merged, repointed };
822
+ });
823
+ }
824
+ /** Write (or rewrite) the story of a person or a couple. The facts it leans on must exist. */
825
+ export function setStory(tree, id, input) {
826
+ const text = input.text.replace(/\r\n?/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
827
+ if (!text)
828
+ throw new UsageError("the story is empty");
829
+ const facts = [...new Set((input.facts ?? []).map((f) => f.trim().toUpperCase()))];
830
+ for (const f of facts)
831
+ findEventOwner(tree, f);
832
+ return tree.withTreeLock(() => {
833
+ const rec = tree.get(id);
834
+ if (!rec || (rec.type !== "person" && rec.type !== "family"))
835
+ throw new UsageError(`no person or family ${id}`);
836
+ if (rec.mergedInto)
837
+ throw new UsageError(`${id} was merged into ${rec.mergedInto}`);
838
+ const story = {
839
+ ...(input.title?.trim() ? { title: input.title.trim() } : {}),
840
+ status: input.final ? "final" : "draft",
841
+ text,
842
+ facts,
843
+ ...(input.note?.trim() ? { note: input.note.trim() } : {}),
844
+ at: now(),
845
+ by: tree.actor,
846
+ };
847
+ const updated = { ...rec, story, updated: now() };
848
+ const words = text.split(/\s+/).length;
849
+ tree.put(updated, { op: "story.set", targets: [id], summary: `${id} story${rec.story ? " rewritten" : ""}: ${words} words, ${story.status}` });
850
+ return updated;
851
+ });
852
+ }