strom-research 1.4.1 → 1.5.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.
@@ -0,0 +1,786 @@
1
+ // strom sync: a family tree coming back — from the Strom app, or another
2
+ // program's file — compared with the research, and what the user changed taken
3
+ // in without ever losing what the research rests on.
4
+ //
5
+ // Three states are compared, each read the same way (a GEDCOM snapshot):
6
+ // base what the research gave the app — its export at the commit the
7
+ // file names (_STROM_HEAD), rebuilt from the tree's history;
8
+ // incoming the file;
9
+ // ours the research now.
10
+ // The user's edits are where incoming differs from base; what the research did
11
+ // since is never taken for the user undoing it. Without a base (another
12
+ // program, an older export) only additions are taken, differences are shown.
13
+ //
14
+ // Nothing is overwritten or deleted without a trace: additions go in as leads
15
+ // citing one source ("edits in the Strom app"), a lead is corrected with the
16
+ // reason, a fact a record proves is either kept with a conflict to decide or —
17
+ // the user's choice (sync.edits user) — withdrawn with the reason and replaced
18
+ // by the user's word. What the app no longer has is only reported. One sync is
19
+ // one input, one source and one commit, and can be undone (strom sync undo).
20
+ import fs from "node:fs";
21
+ import os from "node:os";
22
+ import path from "node:path";
23
+ import { eventKind } from "./model.js";
24
+ import { addChild, addEvent, addFamily, addName, addNote, editEvent, editPerson, retractEvent, retractPerson } from "./actions.js";
25
+ import { exportGedcom, REFN_TYPE } from "../gedcom/export.js";
26
+ import { children, parseGedcomText, val } from "../gedcom/parse.js";
27
+ import { fromFlexDate, importDate } from "./import.js";
28
+ import { create, update } from "./records.js";
29
+ import { foldText } from "./text.js";
30
+ import { UsageError } from "./errors.js";
31
+ import * as git from "./git.js";
32
+ import { now, Tree } from "./tree.js";
33
+ const OUR_ID = /^P\d{4,}$/;
34
+ const fold = (s) => (s ? foldText(s).replace(/\s+/g, " ").trim() : "");
35
+ /** Lower case, accents kept: "Dvořak" is not "Dvořák" — a corrected accent is an edit. */
36
+ const exact = (s) => (s ? s.normalize("NFC").toLocaleLowerCase().replace(/\s+/g, " ").trim() : "");
37
+ /** Christening and baptism are one fact here, as in the import. */
38
+ const kindOf = (k) => (k === "CHR" ? "BAPM" : k);
39
+ /** A fact for a comparison. An event's label only where nothing else tells it apart: it is in the language of whoever wrote it. */
40
+ export const factKey = (f) => [kindOf(f.kind), f.date ?? "", exact(f.place), exact(f.value), f.date || f.place ? "" : fold(f.label)].join("|");
41
+ /** A name for a comparison: "? /Novák/", "Jan //" and "Jan /?/" are the names the research has. */
42
+ const nameKey = (n) => exact(n.replace(/[/?]/g, " "));
43
+ /** The Strom app's "Birth (alternative record)": a second birth or death of ours coming back as an event of its own. */
44
+ const ALTERNATIVE = {
45
+ "birth (alternative record)": "BIRT", "death (alternative record)": "DEAT",
46
+ "narozeni (alternativni zaznam)": "BIRT", "umrti (alternativni zaznam)": "DEAT",
47
+ "geburt (alternativer eintrag)": "BIRT", "tod (alternativer eintrag)": "DEAT",
48
+ };
49
+ /** Facts there is one of: an edit of one is a change, not a second fact. */
50
+ const ONE = new Set(["BIRT", "BAPM", "DEAT", "BURI", "CREM", "MARR", "DIV"]);
51
+ function gedFacts(node) {
52
+ return cleanFacts(rawFacts(node));
53
+ }
54
+ /** 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. */
55
+ function cleanFacts(facts) {
56
+ for (const f of facts) {
57
+ const alt = f.kind === "EVEN" && f.label ? ALTERNATIVE[fold(f.label)] : undefined;
58
+ if (alt) {
59
+ f.kind = alt;
60
+ if (f.value === f.label)
61
+ delete f.value;
62
+ delete f.label;
63
+ }
64
+ }
65
+ const out = facts;
66
+ // the Strom app's "Birth record" (an event only to carry a birth's godparents): the same date and place as a fact
67
+ const at = (f) => `${f.date ?? ""}|${fold(f.place)}`;
68
+ return out.filter((f) => (f.kind !== "EVEN" || !(f.date || f.place) || !out.some((o) => o !== f && o.kind !== "EVEN" && at(o) === at(f))) &&
69
+ // a marriage with nothing known of it: every couple of the Strom app is "married"
70
+ !(f.kind === "MARR" && !f.date && !f.place && !f.value));
71
+ }
72
+ function rawFacts(node) {
73
+ const out = [];
74
+ for (const c of node.children) {
75
+ if (["NAME", "SEX", "REFN", "NOTE", "FAMC", "FAMS", "HUSB", "WIFE", "CHIL", "SOUR", "OBJE", "ASSO", "_STORY"].includes(c.tag))
76
+ continue;
77
+ const kind = eventKind(c.tag);
78
+ if (!kind)
79
+ continue;
80
+ const f = { kind };
81
+ const date = importDate(val(c, "DATE"));
82
+ if (date)
83
+ f.date = date;
84
+ const place = val(c, "PLAC");
85
+ if (place)
86
+ f.place = place.replace(/\s+/g, " ");
87
+ const value = c.value.trim();
88
+ if (value && value !== "Y")
89
+ f.value = value.replace(/\s+/g, " ");
90
+ if (kind === "EVEN")
91
+ f.label = val(c, "TYPE") ?? f.value ?? "EVEN";
92
+ const id = val(c, "_EID");
93
+ if (id)
94
+ f.id = id;
95
+ out.push(f);
96
+ }
97
+ return out;
98
+ }
99
+ /** Every note under a record, its facts' and citations' too, as one text. */
100
+ function allNotes(n, noteText) {
101
+ const out = [];
102
+ const walk = (x) => {
103
+ for (const c of x.children) {
104
+ if (c.tag === "NOTE" || c.tag === "TEXT" || c.tag === "PAGE" || c.tag === "PLAC" || c.tag === "ADDR")
105
+ out.push(c.tag === "NOTE" ? noteText(c) : c.value);
106
+ walk(c);
107
+ }
108
+ };
109
+ walk(n);
110
+ return out.join("\n");
111
+ }
112
+ /** Words of a text, folded; numbers too (house numbers, years). */
113
+ const wordsOf = (t) => foldText(t).match(/[\p{L}\p{M}\p{N}]{2,}/gu) ?? [];
114
+ /**
115
+ * A note the research has said already, folded into another shape by the app ("Birth: Address: čp. 35 …" of a
116
+ * fact's notes): hardly a word of it that the research has not.
117
+ */
118
+ function knownNote(text, said) {
119
+ const w = new Set(wordsOf(text));
120
+ const unknown = [...w].filter((x) => !said.has(x));
121
+ return unknown.length < 3 || unknown.length <= w.size * 0.2;
122
+ }
123
+ /** A GEDCOM file (or one of ours, in memory) as a snapshot; an unreadable one throws. */
124
+ export function readGedcom(text) {
125
+ const { records, problems } = parseGedcomText(text);
126
+ const head = records.find((r) => r.tag === "HEAD");
127
+ const notes = new Map(records.filter((r) => r.tag === "NOTE" && r.xref).map((r) => [r.xref, r.value]));
128
+ const noteText = (n) => (/^@[^@]+@$/.test(n.value.trim()) ? (notes.get(n.value.trim()) ?? "") : n.value);
129
+ const keys = new Map();
130
+ const persons = new Map();
131
+ for (const r of records.filter((x) => x.tag === "INDI" && x.xref)) {
132
+ const refn = r.children.find((c) => c.tag === "REFN");
133
+ const type = refn ? val(refn, "TYPE") : undefined;
134
+ const ours = refn && OUR_ID.test(refn.value.trim()) && (!type || type === REFN_TYPE) ? refn.value.trim() : undefined;
135
+ const key = ours && !persons.has(ours) ? ours : `x:${r.xref.replace(/@/g, "")}`;
136
+ keys.set(r.xref, key);
137
+ const sex = val(r, "SEX");
138
+ const names = children(r, "NAME").map((n) => n.value.trim()).filter((n) => n.replace(/[/?\s]/g, ""));
139
+ // a placeholder of the Strom app (an unknown parent drawn in the tree): nobody
140
+ if (!ours && !names.length)
141
+ continue;
142
+ persons.set(key, {
143
+ key,
144
+ names,
145
+ ...(sex === "M" || sex === "F" ? { sex } : {}),
146
+ facts: gedFacts(r),
147
+ notes: children(r, "NOTE").map(noteText).map((t) => t.trim()).filter(Boolean),
148
+ said: allNotes(r, noteText),
149
+ });
150
+ }
151
+ const families = [];
152
+ for (const r of records.filter((x) => x.tag === "FAM")) {
153
+ const who = (tag) => children(r, tag).map((c) => keys.get(c.value.trim())).filter((k) => !!k && persons.has(k));
154
+ families.push({ partners: [...who("HUSB"), ...who("WIFE")], children: who("CHIL"), facts: gedFacts(r) });
155
+ }
156
+ const treeId = val(head, "_STROM_TREE");
157
+ const at = val(head, "_STROM_HEAD");
158
+ return { format: "gedcom", ...(treeId ? { treeId } : {}), ...(at ? { head: at } : {}), persons, families, problems };
159
+ }
160
+ /** The Strom app's life events → our kinds. */
161
+ const APP_EVENTS = {
162
+ birth: "BIRT", death: "DEAT", baptism: "BAPM", burial: "BURI", occupation: "OCCU", residence: "RESI", military: "MILI",
163
+ emigration: "EMIG", immigration: "IMMI", education: "EDUC", religion: "RELI", custom: "EVEN", confirmation: "CONF",
164
+ firstCommunion: "FCOM", barMitzvah: "BARM", batMitzvah: "BASM", ordination: "ORDN", adoption: "ADOP", naturalization: "NATU",
165
+ will: "WILL", probate: "PROB", title: "TITL", nationality: "NATI", cremation: "CREM",
166
+ };
167
+ /** Facts whose value is what they are (the GEDCOM tag's value). */
168
+ const VALUE_KINDS = new Set(["OCCU", "RELI", "TITL", "NATI"]);
169
+ /** A tree of the Strom app (its JSON) as a snapshot. */
170
+ export function readStromJson(data) {
171
+ const d = data;
172
+ if (!d || typeof d !== "object" || !d.persons || typeof d.persons !== "object")
173
+ throw new UsageError("not a family tree of the Strom app: no persons in it");
174
+ const keys = new Map();
175
+ const persons = new Map();
176
+ for (const p of Object.values(d.persons)) {
177
+ // a placeholder of the Strom app (an unknown parent drawn in the tree): nobody
178
+ if (!p?.id || p.isPlaceholder)
179
+ continue;
180
+ const refn = p.refn?.trim();
181
+ const key = refn && OUR_ID.test(refn) && (!p.refnType || p.refnType === REFN_TYPE) && !persons.has(refn) ? refn : `x:${p.id}`;
182
+ keys.set(p.id, key);
183
+ const facts = [];
184
+ const fact = (kind, date, place, label, value) => {
185
+ const f = { kind };
186
+ const g = fromFlexDate(date);
187
+ if (g)
188
+ f.date = g;
189
+ if (place?.trim())
190
+ f.place = place.trim().replace(/\s+/g, " ");
191
+ if (label)
192
+ f.label = label;
193
+ if (value?.trim())
194
+ f.value = value.trim().replace(/\s+/g, " ");
195
+ if (f.date || f.place || label || kind !== "EVEN")
196
+ facts.push(f);
197
+ };
198
+ if (p.birthDate || p.birthPlace)
199
+ fact("BIRT", p.birthDate, p.birthPlace);
200
+ if (p.deathDate || p.deathPlace)
201
+ fact("DEAT", p.deathDate, p.deathPlace);
202
+ for (const e of p.events ?? []) {
203
+ const kind = APP_EVENTS[e.type];
204
+ if (!kind || ((kind === "BIRT" || kind === "DEAT") && facts.some((f) => f.kind === kind)))
205
+ continue;
206
+ // an occupation, a religion, a title: the app keeps what it is as the event's note (its first line)
207
+ const value = VALUE_KINDS.has(kind) ? e.note?.split("\n")[0] : undefined;
208
+ fact(kind === "MILI" ? "EVEN" : kind, e.date, e.place, kind === "EVEN" ? e.customLabel || "EVEN" : undefined, value);
209
+ }
210
+ const name = `${p.firstName ?? ""} /${p.lastName ?? ""}/`.trim();
211
+ if (!key.startsWith("P") && !`${p.firstName ?? ""}${p.lastName ?? ""}`.replace(/[?\s]/g, "")) {
212
+ keys.delete(p.id);
213
+ continue;
214
+ }
215
+ persons.set(key, {
216
+ key,
217
+ names: [name, ...(p.nameVariants ?? [])].filter((n) => n.replace(/[/?\s]/g, "")),
218
+ ...(p.gender === "male" ? { sex: "M" } : p.gender === "female" ? { sex: "F" } : {}),
219
+ facts: cleanFacts(facts),
220
+ notes: p.notes?.trim() ? [p.notes.trim()] : [],
221
+ said: p.notes ?? "",
222
+ });
223
+ }
224
+ const families = [];
225
+ for (const u of Object.values(d.partnerships ?? {})) {
226
+ const partners = [u.person1Id, u.person2Id].map((x) => (x ? keys.get(x) : undefined)).filter((k) => !!k);
227
+ const facts = [];
228
+ if ((u.status === "married" || u.status === "divorced") && (u.startDate || u.startPlace)) {
229
+ const f = { kind: "MARR" };
230
+ const g = fromFlexDate(u.startDate);
231
+ if (g)
232
+ f.date = g;
233
+ if (u.startPlace?.trim())
234
+ f.place = u.startPlace.trim();
235
+ facts.push(f);
236
+ }
237
+ families.push({ partners, children: (u.childIds ?? []).map((c) => keys.get(c)).filter((k) => !!k), facts });
238
+ }
239
+ const r = d.research;
240
+ return { format: "strom-json", ...(r?.id ? { treeId: r.id } : {}), ...(r?.head ? { head: r.head } : {}), persons, families, problems: [] };
241
+ }
242
+ /** A file coming back: GEDCOM or the Strom app's JSON; empty or unreadable throws. */
243
+ export function readTreeFile(file) {
244
+ const text = fs.readFileSync(file, "utf8");
245
+ if (!text.trim())
246
+ throw new UsageError(`${path.basename(file)} is empty`);
247
+ let snap;
248
+ if (/^?\s*[{[]/.test(text)) {
249
+ let data;
250
+ try {
251
+ data = JSON.parse(text);
252
+ }
253
+ catch (e) {
254
+ throw new UsageError(`${path.basename(file)} is not readable JSON: ${e.message}`);
255
+ }
256
+ snap = readStromJson(data);
257
+ }
258
+ else if (/^?\s*0\s+HEAD/m.test(text))
259
+ snap = readGedcom(text);
260
+ else
261
+ throw new UsageError(`${path.basename(file)} is neither a GEDCOM file nor a family tree of the Strom app`);
262
+ if (!snap.persons.size)
263
+ throw new UsageError(`${path.basename(file)} has no people in it`, { hint: "a file exported from the Strom app or another family-tree program" });
264
+ return snap;
265
+ }
266
+ /** Our records as the app is given them: the export for it, read like any file — with the facts' IDs. */
267
+ export function snapshotOfTree(tree) {
268
+ return readGedcom(exportGedcom(tree, { for: "strom", ids: true }).text);
269
+ }
270
+ /** The folders of data/ the export for the app reads. */
271
+ const EXPORTED = ["persons", "families", "sources", "repositories", "places", "recordsets"];
272
+ /** The research at a commit, as it was given the app — undefined when the history does not have it. */
273
+ export function snapshotAt(tree, head) {
274
+ if (!/^[0-9a-f]{7,64}$/i.test(head))
275
+ return undefined;
276
+ if (git.runGit(tree.root, ["cat-file", "-e", `${head}^{commit}`]).status !== 0)
277
+ return undefined;
278
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "strom-sync-"));
279
+ try {
280
+ const config = git.showFile(tree.root, head, "strom.json");
281
+ if (!config)
282
+ return undefined;
283
+ fs.writeFileSync(path.join(dir, "strom.json"), config);
284
+ // what the export reads: not the images, tasks, sessions … of the research
285
+ const files = EXPORTED.flatMap((d) => git.listFiles(tree.root, head, `data/${d}`)).concat("data/_counters.json");
286
+ for (const [file, text] of git.showFiles(tree.root, head, files)) {
287
+ if (text === undefined)
288
+ continue;
289
+ fs.mkdirSync(path.join(dir, path.dirname(file)), { recursive: true });
290
+ fs.writeFileSync(path.join(dir, file), text);
291
+ }
292
+ return snapshotOfTree(Tree.open(dir, tree.env));
293
+ }
294
+ finally {
295
+ fs.rmSync(dir, { recursive: true, force: true });
296
+ }
297
+ }
298
+ // ── from the Strom app, through the bridge ──────────────────────────────────
299
+ /** Where the trees the Strom app sends wait, in the tree folder but outside its data (never committed). */
300
+ export const SYNC_INBOX = path.join(".strom", "sync");
301
+ /** At most so large a tree the bridge takes (a GEDCOM with images of every entry is tens of MB). */
302
+ export const SYNC_MAX_BYTES = 200 * 1024 * 1024;
303
+ /**
304
+ * A tree the Strom app sent (POST <bridge>/sync): checked as any file, kept in the inbox to be shown and written on
305
+ * the user's word — nothing of the research changes here. Returns the file and how many changes it brings.
306
+ */
307
+ export function receiveTree(root, env, text) {
308
+ if (!/^\uFEFF?\s*0\s+HEAD/.test(text))
309
+ throw new UsageError("not a GEDCOM file");
310
+ const dir = path.join(root, SYNC_INBOX);
311
+ fs.mkdirSync(dir, { recursive: true });
312
+ const file = path.join(dir, `strom-app-${new Date().toISOString().replace(/[:.]/g, "-")}.ged`);
313
+ fs.writeFileSync(file, text);
314
+ try {
315
+ const tree = Tree.open(root, env);
316
+ return { file, changes: planSync(tree, readTreeFile(file), "conflict").changes.length };
317
+ }
318
+ catch (e) {
319
+ fs.rmSync(file, { force: true });
320
+ throw e;
321
+ }
322
+ }
323
+ /** Why the Strom app sends nothing: the tree unchanged since the research gave it, the user said no, no tree of this research in it. */
324
+ export const SEND_REASONS = ["unchanged", "cancelled", "no-tree"];
325
+ /** The Strom app says it sends nothing (POST <bridge>/cancel): noted for strom sync --app, which stops waiting. */
326
+ export function noteNothingSent(root, reason) {
327
+ const why = SEND_REASONS.includes(reason) ? reason : "cancelled";
328
+ const dir = path.join(root, SYNC_INBOX);
329
+ fs.mkdirSync(dir, { recursive: true });
330
+ fs.writeFileSync(path.join(dir, `nothing-${Date.now()}.json`), JSON.stringify({ reason: why, at: new Date().toISOString() }));
331
+ return why;
332
+ }
333
+ /** The app said it sends nothing since `since` (ms): why. */
334
+ export function nothingSince(root, since) {
335
+ const dir = path.join(root, SYNC_INBOX);
336
+ if (!fs.existsSync(dir))
337
+ return undefined;
338
+ const last = fs
339
+ .readdirSync(dir)
340
+ .filter((f) => f.startsWith("nothing-") && f.endsWith(".json"))
341
+ .map((f) => path.join(dir, f))
342
+ .filter((f) => fs.statSync(f).mtimeMs >= since)
343
+ .sort()
344
+ .pop();
345
+ if (!last)
346
+ return undefined;
347
+ try {
348
+ return JSON.parse(fs.readFileSync(last, "utf8")).reason;
349
+ }
350
+ catch {
351
+ return "cancelled";
352
+ }
353
+ }
354
+ /** The newest tree the app sent since `since` (ms), if any. */
355
+ export function receivedSince(root, since) {
356
+ const dir = path.join(root, SYNC_INBOX);
357
+ if (!fs.existsSync(dir))
358
+ return undefined;
359
+ return fs
360
+ .readdirSync(dir)
361
+ .filter((f) => f.endsWith(".ged"))
362
+ .map((f) => path.join(dir, f))
363
+ .filter((f) => fs.statSync(f).mtimeMs >= since)
364
+ .sort()
365
+ .pop();
366
+ }
367
+ /**
368
+ * Refuse a file that is not this research: another research's (its tree ID), or one where few people are ours.
369
+ * Our IDs with another person's name are not matched (another research's IDs, a file edited by hand).
370
+ */
371
+ export function checkIdentity(tree, incoming, force = false) {
372
+ if (incoming.treeId && incoming.treeId !== tree.config.id)
373
+ throw new UsageError(`this file is of another research (${incoming.treeId}), not of "${tree.config.name}"`, { hint: "open that research, or add the file as leads: strom intake <file>" });
374
+ const strangers = [];
375
+ let matched = 0;
376
+ for (const [key, p] of incoming.persons) {
377
+ if (key.startsWith("x:"))
378
+ continue;
379
+ const ours = resolve(tree, key);
380
+ const alike = ours && ours.names.some((n) => p.names.some((m) => sharesWord(`${n.given} ${n.surname}`, m)));
381
+ if (ours && alike)
382
+ matched++;
383
+ else
384
+ strangers.push(key);
385
+ }
386
+ for (const key of strangers) {
387
+ const p = incoming.persons.get(key);
388
+ incoming.persons.delete(key);
389
+ const other = `x:${key}`;
390
+ incoming.persons.set(other, { ...p, key: other });
391
+ for (const f of incoming.families) {
392
+ f.partners = f.partners.map((k) => (k === key ? other : k));
393
+ f.children = f.children.map((k) => (k === key ? other : k));
394
+ }
395
+ }
396
+ if (!force && !incoming.treeId && matched < Math.max(1, incoming.persons.size / 2))
397
+ throw new UsageError(`this does not look like the family tree of "${tree.config.name}": ${matched} of its ${incoming.persons.size} people are the research's`, { hint: "another family tree goes in as leads: strom intake <file> — it is yours after all: strom sync <file> --force" });
398
+ return { matched, strangers };
399
+ }
400
+ function sharesWord(a, b) {
401
+ const words = (s) => new Set(fold(s.replace(/\//g, " ")).split(" ").filter((w) => w.length > 1));
402
+ const x = words(a);
403
+ return [...words(b)].some((w) => x.has(w));
404
+ }
405
+ /** Our person by ID, through merges. */
406
+ function resolve(tree, id) {
407
+ let p = tree.get(id);
408
+ for (let hops = 0; p?.mergedInto && hops < 10; hops++)
409
+ p = tree.get(p.mergedInto);
410
+ return p && p.type === "person" && !p.retracted ? p : undefined;
411
+ }
412
+ /** Does a record prove it: proven or probable, from a record — not a family tree, not someone's word. */
413
+ function recordBacked(tree, e) {
414
+ if (!e || (e.status !== "proven" && e.status !== "probable"))
415
+ return false;
416
+ return e.citations.some((c) => {
417
+ const s = tree.get(c.source);
418
+ return !!s && s.kind !== "family-tree" && s.kind !== "family-memory" && s.form !== "authored";
419
+ });
420
+ }
421
+ function eventById(tree, id) {
422
+ if (!id)
423
+ return undefined;
424
+ for (const o of [...tree.list("person"), ...tree.list("family")]) {
425
+ const e = o.events.find((x) => x.id === id);
426
+ if (e)
427
+ return e;
428
+ }
429
+ return undefined;
430
+ }
431
+ const famKey = (partners) => [...partners].sort().join("+");
432
+ /**
433
+ * The changes a file brings, in the order they are applied. `edits`: what a change of a fact a record proves
434
+ * becomes. Without a base only additions are taken; differences wait to be picked.
435
+ */
436
+ export function planSync(tree, incoming, edits, opts = {}) {
437
+ const identity = checkIdentity(tree, incoming, opts.force);
438
+ const ours = snapshotOfTree(tree);
439
+ knownNewcomers(incoming, ours);
440
+ const base = incoming.head ? snapshotAt(tree, incoming.head) : undefined;
441
+ const partial = !!base && incoming.persons.size < base.persons.size / 2;
442
+ const changes = [];
443
+ const push = (c) => changes.push({ ...c, n: changes.length + 1 });
444
+ // the kinds of facts the file carries at all: one it never has is not "gone" (the app does not keep it)
445
+ const carried = new Set([...incoming.persons.values()].flatMap((p) => p.facts.map((f) => kindOf(f.kind))));
446
+ for (const f of incoming.families)
447
+ for (const x of f.facts)
448
+ carried.add(kindOf(x.kind));
449
+ const factsDiff = (owner, inc, our, was, isOurs) => {
450
+ const ourKeys = new Set(our.map(factKey));
451
+ const baseKeys = new Set((was ?? []).map(factKey));
452
+ const incKeys = new Set(inc.map(factKey));
453
+ const used = new Set();
454
+ for (const f of inc) {
455
+ const k = factKey(f);
456
+ if (ourKeys.has(k))
457
+ continue; // the research has it
458
+ if (our.some((o) => knowsMore(o, f)))
459
+ continue; // the file knows less of it (the date lost on the way)
460
+ if (was && baseKeys.has(k))
461
+ continue; // as it was given: the research changed it since
462
+ const kind = kindOf(f.kind);
463
+ // an edit of a fact there is one of: what it was
464
+ const before = ONE.has(kind) ? (was ?? our).find((b) => kindOf(b.kind) === kind && !incKeys.has(factKey(b)) && !used.has(factKey(b))) : undefined;
465
+ if (before)
466
+ used.add(factKey(before));
467
+ const mine = before ? our.find((o) => kindOf(o.kind) === kind && (was ? factKey(o) === factKey(before) : true)) : undefined;
468
+ if (!isOurs || !before) {
469
+ push({ kind: "fact.new", action: "add", ...owner, fact: f });
470
+ continue;
471
+ }
472
+ if (!was) {
473
+ push({ kind: "fact.differs", action: "pick", ...owner, fact: f, ...(mine ? { was: mine } : {}) });
474
+ continue;
475
+ }
476
+ if (!mine) {
477
+ // the research changed or withdrew it since it was given: the user decides
478
+ const now = our.find((o) => kindOf(o.kind) === kind);
479
+ push({ kind: "fact.changed", action: now ? "conflict" : "add", ...owner, fact: f, ...(now ? { was: now } : {}) });
480
+ continue;
481
+ }
482
+ const e = eventById(tree, mine.id);
483
+ const action = recordBacked(tree, e) ? edits : "correct";
484
+ // asked already: the conflict of this very edit is open
485
+ if (action === "conflict" && openConflict(tree, owner.person ?? owner.family, f))
486
+ continue;
487
+ push({ kind: "fact.changed", action, ...owner, fact: f, was: mine });
488
+ }
489
+ if (was && !partial)
490
+ for (const b of was)
491
+ if (!incKeys.has(factKey(b)) && !used.has(factKey(b)) && carried.has(kindOf(b.kind)) && ourKeys.has(factKey(b)))
492
+ push({ kind: "fact.gone", action: "report", ...owner, was: b });
493
+ };
494
+ // people
495
+ for (const [key, p] of incoming.persons) {
496
+ const name = p.names[0] ?? "?";
497
+ if (key.startsWith("x:")) {
498
+ push({ kind: "person.new", action: "add", person: key, name });
499
+ factsDiff({ person: key, name }, p.facts, [], undefined, false);
500
+ for (const n of p.names.slice(1))
501
+ push({ kind: "name.new", action: "add", person: key, name, text: n });
502
+ for (const t of p.notes)
503
+ push({ kind: "note.new", action: "add", person: key, name, text: t });
504
+ continue;
505
+ }
506
+ const id = resolve(tree, key).id;
507
+ const o = ours.persons.get(id);
508
+ const b = base?.persons.get(key);
509
+ if (!o)
510
+ continue;
511
+ factsDiff({ person: id, name }, p.facts, o.facts, b?.facts, true);
512
+ const ourNames = new Set(o.names.map(nameKey));
513
+ const baseNames = new Set((b?.names ?? []).map(nameKey));
514
+ for (const n of p.names)
515
+ if (!ourNames.has(nameKey(n)) && !baseNames.has(nameKey(n)))
516
+ push({ kind: "name.new", action: "add", person: id, name, text: n });
517
+ if (p.sex && o.sex && p.sex !== o.sex && (!b || b.sex !== p.sex))
518
+ push({ kind: "sex.changed", action: b ? (edits === "user" ? "user" : "report") : "report", person: id, name, text: p.sex });
519
+ const said = new Set(wordsOf([o.said, b?.said ?? "", ...o.names, ...o.facts.map((f) => [f.place, f.value, f.label].join(" "))].join("\n")));
520
+ // a note coming back holds the research's too (the app joins them): only its lines the research has not
521
+ for (const t of p.notes) {
522
+ const fresh = t.split(/\n+/).filter((line) => line.trim() && !knownNote(line, said));
523
+ if (fresh.length && !knownNote(t, said))
524
+ push({ kind: "note.new", action: "add", person: id, name, text: fresh.join("\n") });
525
+ }
526
+ }
527
+ if (base && !partial)
528
+ for (const [key, b] of base.persons)
529
+ if (!incoming.persons.has(key) && ours.persons.has(key))
530
+ push({ kind: "person.gone", action: "report", person: key, name: b.names[0] ?? key });
531
+ // families: by their partners; a family of one parent by its children
532
+ const ourFam = new Map(ours.families.map((f) => [famKey(f.partners), f]));
533
+ const baseFam = new Map((base?.families ?? []).map((f) => [famKey(f.partners), f]));
534
+ const ourFamilies = tree.list("family").filter((f) => !f.retracted);
535
+ const idOf = (key) => (key.startsWith("x:") ? key : (resolve(tree, key)?.id ?? key));
536
+ for (const f of incoming.families) {
537
+ const partners = f.partners.map(idOf);
538
+ const k = famKey(partners);
539
+ const o = ourFam.get(k);
540
+ const b = baseFam.get(famKey(f.partners));
541
+ const fam = o ? ourFamilies.find((x) => famKey(x.partners) === k) : undefined;
542
+ if (!o || !fam) {
543
+ if (partners.length || f.children.length)
544
+ push({ kind: "family.new", action: "add", partners, fact: f.facts[0], text: f.children.map(idOf).join(" ") });
545
+ continue;
546
+ }
547
+ const known = new Set([...o.children, ...(b?.children ?? [])]);
548
+ for (const c of f.children.map(idOf))
549
+ if (!known.has(c))
550
+ push({ kind: "child.new", action: "add", family: fam.id, partners, child: c });
551
+ factsDiff({ family: fam.id, partners }, f.facts, o.facts, b?.facts, true);
552
+ if (b && !partial) {
553
+ const now = new Set(f.children.map(idOf));
554
+ for (const c of b.children)
555
+ if (!now.has(c) && o.children.includes(c) && incoming.persons.has(c))
556
+ push({ kind: "child.gone", action: "report", family: fam.id, partners, child: c });
557
+ }
558
+ }
559
+ return { changes, base: !!base, ...(incoming.head ? { head: incoming.head } : {}), partial, identity };
560
+ }
561
+ /** Does the research's fact say all the file's fact says, and more: the same kind, and each of its date, place, value the same or missing in the file. */
562
+ function knowsMore(ours, file) {
563
+ if (kindOf(ours.kind) !== kindOf(file.kind))
564
+ return false;
565
+ const same = (a, b) => !b || exact(a) === exact(b);
566
+ return same(ours.date, file.date) && same(ours.place, file.place) && same(ours.value, file.value) && (!file.date || !!ours.date) && !(file.kind === "EVEN" && file.label && ours.label && !file.date && !file.place && fold(ours.label) !== fold(file.label));
567
+ }
568
+ /** An open conflict whose claims hold this value for the person or family already. */
569
+ function openConflict(tree, owner, f) {
570
+ if (!owner)
571
+ return false;
572
+ return tree.list("conflict").some((x) => x.state === "open" && x.subject.includes(owner) && x.claims.some((c) => c.value === describe(f)));
573
+ }
574
+ /**
575
+ * People the file brings without our ID whom the research has already — a sync took them in before the app got
576
+ * them back with their ID: the same name and the same birth, or the same name and the same parents. They are ours.
577
+ */
578
+ function knownNewcomers(incoming, ours) {
579
+ const birth = (p) => p.facts.find((f) => kindOf(f.kind) === "BIRT" || kindOf(f.kind) === "BAPM");
580
+ const parents = (snap, key) => new Set(snap.families.filter((f) => f.children.includes(key)).flatMap((f) => f.partners));
581
+ for (const [key, p] of [...incoming.persons]) {
582
+ if (!key.startsWith("x:"))
583
+ continue;
584
+ const names = new Set(p.names.map(nameKey));
585
+ const b = birth(p);
586
+ const theirParents = parents(incoming, key);
587
+ const hit = [...ours.persons.values()].find((o) => {
588
+ if (!o.names.some((n) => names.has(nameKey(n))))
589
+ return false;
590
+ const ob = birth(o);
591
+ if (b && ob)
592
+ return factKey(b) === factKey(ob);
593
+ const op = parents(ours, o.key);
594
+ return theirParents.size > 0 && [...theirParents].every((x) => op.has(x));
595
+ });
596
+ if (!hit || incoming.persons.has(hit.key))
597
+ continue;
598
+ incoming.persons.delete(key);
599
+ incoming.persons.set(hit.key, { ...p, key: hit.key });
600
+ for (const f of incoming.families) {
601
+ f.partners = f.partners.map((k) => (k === key ? hit.key : k));
602
+ f.children = f.children.map((k) => (k === key ? hit.key : k));
603
+ }
604
+ }
605
+ }
606
+ /**
607
+ * Write the changes (all but those only reported; with `only`, those numbers — differences only when picked).
608
+ * Returns what was written. The caller holds the tree lock and commits.
609
+ */
610
+ export function applySync(tree, plan, incoming, source, only) {
611
+ const applied = [];
612
+ const cite = (locator) => ({ source: source.id, locator });
613
+ const reason = `${source.title} (${source.id})`;
614
+ const taken = (c) => (only ? only.has(c.n) : c.action !== "pick") && c.action !== "report";
615
+ const changes = plan.changes.filter(taken).length;
616
+ const ids = new Map(); // a new person's key → their ID
617
+ const who = (key) => (key ? (ids.get(key) ?? (key.startsWith("x:") ? undefined : key)) : undefined);
618
+ const fields = (f) => ({ kind: f.kind, date: f.date, place: f.place, value: f.value, label: f.label });
619
+ // new people first: facts and families point at them
620
+ for (const c of plan.changes.filter((x) => x.kind === "person.new" && taken(x))) {
621
+ const p = incoming.persons.get(c.person);
622
+ const primary = p.names[0] && p.names[0].replace(/\//g, "").trim() ? p.names[0] : "? /?/";
623
+ const person = create(tree, "person", { names: [{ ...nameOf(primary), citations: [cite(`name`)] }], sex: p.sex ?? "U", events: [], notes: [] }, (id) => `+${id} person "${primary.replace(/\//g, "").trim()}" (from ${source.id})`);
624
+ ids.set(c.person, person.id);
625
+ applied.push({ do: "person.add", id: person.id });
626
+ }
627
+ for (const c of plan.changes) {
628
+ if (!taken(c) || c.kind === "person.new")
629
+ continue;
630
+ const owner = c.family ?? who(c.person);
631
+ switch (c.kind) {
632
+ case "fact.new":
633
+ case "fact.differs":
634
+ case "fact.changed": {
635
+ if (!owner || !c.fact)
636
+ break;
637
+ const mine = c.was?.id ? eventById(tree, c.was.id) : undefined;
638
+ if (c.kind === "fact.new" || !mine || (c.action === "add" && !mine)) {
639
+ const { event } = addEvent(tree, owner, { ...fields(c.fact), status: "lead", citations: [cite(c.fact.kind)] });
640
+ applied.push({ do: "event.add", id: event.id });
641
+ }
642
+ else if (c.action === "correct" || (c.kind === "fact.differs" && !recordBacked(tree, mine))) {
643
+ editEvent(tree, mine.id, { date: c.fact.date ?? "", place: c.fact.place ?? "", ...(c.fact.value !== undefined ? { value: c.fact.value } : {}) }, `corrected in ${reason}`);
644
+ 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)] } : e)) }), {
645
+ op: "event.cite",
646
+ summary: `${mine.id} cites ${source.id}`,
647
+ });
648
+ applied.push({ do: "event.edit", id: mine.id, before: { kind: mine.kind, ...(mine.date ? { date: mine.date } : {}), ...(mine.place ? { place: mine.place } : {}), ...(mine.value ? { value: mine.value } : {}) } });
649
+ }
650
+ else if (c.action === "user") {
651
+ retractEvent(tree, mine.id, `the user's edit wins: ${reason}`);
652
+ applied.push({ do: "event.retract", id: mine.id, before: mine.status });
653
+ // the user's word: no record makes it probable
654
+ const { event } = addEvent(tree, owner, { ...fields(c.fact), status: "possible", citations: [cite(c.fact.kind)] });
655
+ applied.push({ do: "event.add", id: event.id });
656
+ }
657
+ else {
658
+ const title = `${(c.name ?? owner).replace(/\//g, "").replace(/\s+/g, " ").trim()}: ${c.fact.kind} — ${describe(mine)} × ${describe(c.fact)}`;
659
+ const x = create(tree, "conflict", {
660
+ title: title.slice(0, 200),
661
+ subject: [owner],
662
+ claims: [
663
+ { ...(mine.citations[0] ? { source: mine.citations[0].source } : {}), value: describe(mine), note: `the research: ${mine.id}` },
664
+ { source: source.id, value: describe(c.fact), note: "the user's edit" },
665
+ ],
666
+ state: "open",
667
+ }, (id) => `+${id} conflict "${title.slice(0, 60)}"`, [owner]);
668
+ applied.push({ do: "conflict.add", id: x.id });
669
+ }
670
+ break;
671
+ }
672
+ case "name.new": {
673
+ if (!owner || !c.text)
674
+ break;
675
+ const { name } = addName(tree, owner, { name: c.text, citation: cite("name") });
676
+ applied.push({ do: "name.add", id: owner, before: exact(`${name.given}|${name.surname}`) });
677
+ break;
678
+ }
679
+ case "note.new": {
680
+ if (!owner || !c.text)
681
+ break;
682
+ const n = addNote(tree, owner, c.text);
683
+ applied.push({ do: "note.add", id: owner, before: n.at });
684
+ break;
685
+ }
686
+ case "sex.changed": {
687
+ if (!owner || !c.text)
688
+ break;
689
+ const p = tree.get(owner);
690
+ editPerson(tree, owner, { sex: c.text }, `the user's edit: ${reason}`);
691
+ applied.push({ do: "sex.edit", id: owner, before: p.sex });
692
+ break;
693
+ }
694
+ case "family.new": {
695
+ const partners = (c.partners ?? []).map(who).filter((x) => !!x);
696
+ const kids = (c.text ?? "").split(" ").filter(Boolean).map(who).filter((x) => !!x);
697
+ if (!partners.length && !kids.length)
698
+ break;
699
+ const f = addFamily(tree, { partners, children: kids, citation: cite("family"), ...(c.fact?.date ? { married: c.fact.date } : {}), ...(c.fact?.place ? { marriedPlace: c.fact.place } : {}), ...(c.fact ? { status: "lead" } : {}) });
700
+ applied.push({ do: "family.add", id: f.id });
701
+ break;
702
+ }
703
+ case "child.new": {
704
+ const child = who(c.child);
705
+ if (!c.family || !child)
706
+ break;
707
+ addChild(tree, c.family, child, undefined, cite("child"));
708
+ applied.push({ do: "child.add", id: c.family, before: child });
709
+ break;
710
+ }
711
+ default:
712
+ break;
713
+ }
714
+ }
715
+ return { applied, changes };
716
+ }
717
+ function nameOf(gedName) {
718
+ const m = /^(.*?)\/(.*?)\/(.*)$/.exec(gedName);
719
+ if (!m)
720
+ return { given: gedName.trim(), surname: "" };
721
+ return { given: `${m[1].trim()} ${m[3].trim()}`.trim(), surname: m[2].trim() };
722
+ }
723
+ function describe(f) {
724
+ return [f.value, f.date, f.place].filter(Boolean).join(", ") || "—";
725
+ }
726
+ /** Undo one sync: what it added withdrawn, what it corrected put back, its conflicts closed — each with the reason. */
727
+ export function undoSync(tree, input) {
728
+ const done = input.sync?.applied ?? [];
729
+ const reason = `sync ${input.id} undone`;
730
+ let n = 0;
731
+ for (const a of [...done].reverse()) {
732
+ switch (a.do) {
733
+ case "event.add":
734
+ if (!eventById(tree, a.id)?.retracted)
735
+ retractEvent(tree, a.id, reason);
736
+ break;
737
+ case "event.edit": {
738
+ const b = a.before;
739
+ editEvent(tree, a.id, { date: b.date ?? "", place: b.place ?? "", ...(b.value !== undefined ? { value: b.value } : {}) }, reason);
740
+ break;
741
+ }
742
+ case "event.retract":
743
+ restoreEvent(tree, a.id, String(a.before ?? "lead"), reason);
744
+ break;
745
+ case "conflict.add":
746
+ update(tree, a.id, "conflict", (x) => ({ ...x, state: "resolved", resolution: reason }), { op: "conflict.resolve", summary: `${a.id} resolved: ${reason}` });
747
+ break;
748
+ case "name.add":
749
+ update(tree, a.id, "person", (p) => {
750
+ // the name it added: the last of that spelling
751
+ const at = p.names.map((m) => exact(`${m.given}|${m.surname}`)).lastIndexOf(String(a.before));
752
+ return at > 0 ? { ...p, names: p.names.filter((_, i) => i !== at) } : p;
753
+ }, { op: "name.remove", summary: `${a.id} name removed: ${reason}` });
754
+ break;
755
+ case "note.add":
756
+ update(tree, a.id, a.id.startsWith("F") ? "family" : "person", (o) => ({ ...o, notes: o.notes.filter((x) => x.at !== a.before) }), { op: "note.remove", summary: `${a.id} note removed: ${reason}` });
757
+ break;
758
+ case "sex.edit":
759
+ editPerson(tree, a.id, { sex: String(a.before ?? "U") }, reason);
760
+ break;
761
+ case "child.add":
762
+ update(tree, a.id, "family", (f) => ({ ...f, children: f.children.filter((c) => c.person !== a.before) }), { op: "family.edit", summary: `${a.id} child ${String(a.before)} removed: ${reason}` });
763
+ break;
764
+ case "family.add":
765
+ update(tree, a.id, "family", (f) => ({ ...f, retracted: { at: now(), reason } }), { op: "family.retract", summary: `${a.id} retracted: ${reason}` });
766
+ break;
767
+ case "person.add":
768
+ if (!tree.get(a.id)?.retracted)
769
+ retractPerson(tree, a.id, reason);
770
+ break;
771
+ }
772
+ n++;
773
+ }
774
+ return n;
775
+ }
776
+ /** A fact withdrawn by a sync, back as it was. */
777
+ function restoreEvent(tree, id, status, reason) {
778
+ for (const o of [...tree.list("person"), ...tree.list("family")]) {
779
+ const e = o.events.find((x) => x.id === id);
780
+ if (!e?.retracted)
781
+ continue;
782
+ const { retracted: _gone, ...rest } = e;
783
+ update(tree, o.id, o.type, (x) => ({ ...x, events: x.events.map((y) => (y.id === id ? { ...rest, status } : y)) }), { op: "event.restore", summary: `${id} restored: ${reason}` });
784
+ return;
785
+ }
786
+ }