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.
- package/LICENSE +373 -0
- package/README.md +142 -0
- package/assets/lang/cs.json +302 -0
- package/assets/lang/de.json +302 -0
- package/assets/method/core.md +43 -0
- package/assets/method/enrich.md +11 -0
- package/assets/method/intake.md +30 -0
- package/assets/method/link.md +28 -0
- package/assets/method/locate.md +28 -0
- package/assets/method/narrate.md +13 -0
- package/assets/method/reading.md +62 -0
- package/assets/method/recording.md +59 -0
- package/assets/method/request.md +10 -0
- package/assets/method/verify.md +17 -0
- package/assets/plugins/README.md +23 -0
- package/assets/plugins/connectors/DISCOVERY.md +159 -0
- package/assets/plugins/connectors/README.md +376 -0
- package/assets/plugins/connectors/sdk.ts +168 -0
- package/assets/plugins/connectors/template.ts +38 -0
- package/assets/plugins/gitignore +4 -0
- package/dist/agents/files.js +313 -0
- package/dist/agents/global.js +257 -0
- package/dist/agents/launch.js +36 -0
- package/dist/agents/profiles.js +95 -0
- package/dist/brief/brief.js +345 -0
- package/dist/cli/commit.js +44 -0
- package/dist/cli/context.js +311 -0
- package/dist/cli/execute.js +154 -0
- package/dist/cli/fixes.js +78 -0
- package/dist/cli/format.js +53 -0
- package/dist/cli/help.js +59 -0
- package/dist/cli/main.js +152 -0
- package/dist/cli/menu.js +212 -0
- package/dist/cli/registry.js +96 -0
- package/dist/cli/ui.js +266 -0
- package/dist/cli/wizard.js +142 -0
- package/dist/cli.js +14 -0
- package/dist/commands/analysis.js +622 -0
- package/dist/commands/batch.js +181 -0
- package/dist/commands/checks.js +153 -0
- package/dist/commands/connectors.js +1377 -0
- package/dist/commands/guide.js +160 -0
- package/dist/commands/index.js +19 -0
- package/dist/commands/intake.js +234 -0
- package/dist/commands/media.js +406 -0
- package/dist/commands/meta.js +195 -0
- package/dist/commands/output.js +117 -0
- package/dist/commands/people.js +664 -0
- package/dist/commands/read.js +199 -0
- package/dist/commands/research.js +139 -0
- package/dist/commands/session.js +605 -0
- package/dist/commands/setup.js +465 -0
- package/dist/commands/sources.js +634 -0
- package/dist/commands/start.js +383 -0
- package/dist/commands/story.js +75 -0
- package/dist/commands/tasks.js +436 -0
- package/dist/commands/trees.js +128 -0
- package/dist/core/actions.js +852 -0
- package/dist/core/age.js +95 -0
- package/dist/core/apps.js +74 -0
- package/dist/core/assets.js +34 -0
- package/dist/core/awake.js +33 -0
- package/dist/core/browser.js +281 -0
- package/dist/core/calibration.js +48 -0
- package/dist/core/check.js +112 -0
- package/dist/core/chromium.js +88 -0
- package/dist/core/config.js +348 -0
- package/dist/core/connector.js +811 -0
- package/dist/core/deps.js +73 -0
- package/dist/core/dialog.js +61 -0
- package/dist/core/errors.js +89 -0
- package/dist/core/evidence.js +58 -0
- package/dist/core/frontier.js +219 -0
- package/dist/core/gdate.js +77 -0
- package/dist/core/git.js +300 -0
- package/dist/core/guard.js +124 -0
- package/dist/core/http2.js +76 -0
- package/dist/core/import.js +541 -0
- package/dist/core/install.js +28 -0
- package/dist/core/integrity.js +219 -0
- package/dist/core/json.js +87 -0
- package/dist/core/lang.js +70 -0
- package/dist/core/live.js +244 -0
- package/dist/core/lock.js +112 -0
- package/dist/core/logins.js +67 -0
- package/dist/core/media.js +223 -0
- package/dist/core/model.js +101 -0
- package/dist/core/net.js +366 -0
- package/dist/core/open.js +29 -0
- package/dist/core/paths.js +84 -0
- package/dist/core/people.js +283 -0
- package/dist/core/phrases.js +85 -0
- package/dist/core/queue.js +113 -0
- package/dist/core/reader.js +76 -0
- package/dist/core/records.js +105 -0
- package/dist/core/roles.js +30 -0
- package/dist/core/schema.js +261 -0
- package/dist/core/seal.js +77 -0
- package/dist/core/self.js +40 -0
- package/dist/core/session.js +155 -0
- package/dist/core/shortcut.js +90 -0
- package/dist/core/stories.js +61 -0
- package/dist/core/stromapp.js +138 -0
- package/dist/core/text.js +104 -0
- package/dist/core/tree.js +507 -0
- package/dist/core/uninstall.js +128 -0
- package/dist/core/update.js +193 -0
- package/dist/core/validate.js +260 -0
- package/dist/core/views.js +164 -0
- package/dist/core/which.js +51 -0
- package/dist/core/workers.js +42 -0
- package/dist/gedcom/export.js +454 -0
- package/dist/gedcom/labels.js +103 -0
- package/dist/gedcom/lines.js +91 -0
- package/dist/gedcom/parse.js +53 -0
- package/dist/gedcom/validate.js +183 -0
- package/dist/image/image.js +223 -0
- package/dist/image/index.js +114 -0
- package/dist/image/jpeg-decode.js +552 -0
- package/dist/image/jpeg-encode.js +254 -0
- package/dist/image/png.js +241 -0
- package/dist/runners/antigravity.js +70 -0
- package/dist/runners/claude.js +179 -0
- package/dist/runners/codex.js +45 -0
- package/dist/runners/index.js +13 -0
- package/dist/runners/jsonl.js +86 -0
- package/dist/runners/opencode.js +50 -0
- package/dist/runners/runner.js +63 -0
- package/dist/runners/script.js +58 -0
- package/package.json +44 -0
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
// Import of existing family trees (GEDCOM, Strom app JSON) as LEADS.
|
|
2
|
+
//
|
|
3
|
+
// A family tree is somebody's conclusions, not a record: everything becomes
|
|
4
|
+
// status "lead", citing one source "family tree <file>", and every person
|
|
5
|
+
// keeps a reference to where it came from. Persons whose REFN is one of our
|
|
6
|
+
// IDs (a tree coming back from the Strom app) are matched, not duplicated.
|
|
7
|
+
import { eventKind, FAMILY_EVENT_KINDS, NOTE_MAX } from "./model.js";
|
|
8
|
+
import { normalizeAge } from "./age.js";
|
|
9
|
+
import { foldText } from "./text.js";
|
|
10
|
+
import { roleWord } from "./roles.js";
|
|
11
|
+
import { validateRecord } from "./validate.js";
|
|
12
|
+
import { normalizeDate } from "./gdate.js";
|
|
13
|
+
import { parseName, sameName } from "./people.js";
|
|
14
|
+
import { now } from "./tree.js";
|
|
15
|
+
import { children, parseGedcomText, val } from "../gedcom/parse.js";
|
|
16
|
+
/** Christenings and baptisms are one fact for a comparison. */
|
|
17
|
+
const SAME_KIND = { CHR: "BAPM", BAPM: "BAPM" };
|
|
18
|
+
/** Does the record already have this fact? The same kind and date (a fact without a date: any of that kind), the same value and place when given. */
|
|
19
|
+
function knownFact(have, e) {
|
|
20
|
+
const kind = (k) => SAME_KIND[k] ?? k;
|
|
21
|
+
const same = (a, b) => !b || !a || foldText(a) === foldText(b);
|
|
22
|
+
return have.some((x) => !x.retracted && kind(x.kind) === kind(e.kind) && (!e.date || x.date === e.date) && same(x.value, e.value) && same(x.place, e.place) && (e.kind !== "EVEN" || same(x.label, e.label)));
|
|
23
|
+
}
|
|
24
|
+
function clip(text) {
|
|
25
|
+
const t = text.replace(/\s+\n/g, "\n").trim();
|
|
26
|
+
return [...t].length <= NOTE_MAX ? t : [...t].slice(0, NOTE_MAX - 1).join("") + "…";
|
|
27
|
+
}
|
|
28
|
+
/** Normalize a date written by any program; undefined when it cannot be read. */
|
|
29
|
+
export function importDate(raw) {
|
|
30
|
+
if (!raw)
|
|
31
|
+
return undefined;
|
|
32
|
+
const direct = normalizeDate(raw);
|
|
33
|
+
if (direct)
|
|
34
|
+
return direct;
|
|
35
|
+
const cleaned = raw
|
|
36
|
+
.toUpperCase()
|
|
37
|
+
.replace(/\b(ABT|CAL|EST|BEF|AFT)\./g, "$1")
|
|
38
|
+
.replace(/^(C\.|CA\.?|CIRCA|ABOUT|~)\s*/, "ABT ")
|
|
39
|
+
.replace(/^(BEFORE|<)\s*/, "BEF ")
|
|
40
|
+
.replace(/^(AFTER|>)\s*/, "AFT ")
|
|
41
|
+
.replace(/\?$/, "")
|
|
42
|
+
.trim();
|
|
43
|
+
return normalizeDate(cleaned);
|
|
44
|
+
}
|
|
45
|
+
function makeSource(tree, title, input) {
|
|
46
|
+
const t = now();
|
|
47
|
+
const s = {
|
|
48
|
+
id: tree.allocate("S"),
|
|
49
|
+
type: "source",
|
|
50
|
+
kind: "family-tree",
|
|
51
|
+
title,
|
|
52
|
+
input,
|
|
53
|
+
information: "secondary",
|
|
54
|
+
form: "authored",
|
|
55
|
+
notes: [],
|
|
56
|
+
created: t,
|
|
57
|
+
updated: t,
|
|
58
|
+
};
|
|
59
|
+
tree.put(s, { op: "source.add", targets: [s.id, input], summary: `+${s.id} source "${title}"` });
|
|
60
|
+
return s;
|
|
61
|
+
}
|
|
62
|
+
function note(tree, text) {
|
|
63
|
+
return { text: clip(text), at: now(), by: tree.actor };
|
|
64
|
+
}
|
|
65
|
+
// ── GEDCOM ─────────────────────────────────────────────────────────────────
|
|
66
|
+
/** NAME TYPE → our kind of name. */
|
|
67
|
+
function nameKind(type) {
|
|
68
|
+
const t = (type ?? "").toLowerCase();
|
|
69
|
+
if (t === "married")
|
|
70
|
+
return "married";
|
|
71
|
+
if (["aka", "nickname", "alias", "immigrant"].includes(t))
|
|
72
|
+
return "alias";
|
|
73
|
+
if (["birth", "maiden"].includes(t))
|
|
74
|
+
return "birth";
|
|
75
|
+
if (t === "religious")
|
|
76
|
+
return "religious";
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
export function importGedcom(tree, text, opts) {
|
|
80
|
+
const { records, problems } = parseGedcomText(text);
|
|
81
|
+
const system = `gedcom:${opts.sha.slice(0, 12)}`;
|
|
82
|
+
const result = { source: "", persons: 0, families: 0, matched: 0, events: 0, extended: [], problems };
|
|
83
|
+
const notes = new Map(records.filter((r) => r.tag === "NOTE" && r.xref).map((r) => [r.xref, r.value]));
|
|
84
|
+
const noteText = (n) => (/^@[^@]+@$/.test(n.value.trim()) ? (notes.get(n.value.trim()) ?? "") : n.value);
|
|
85
|
+
// The sources of that tree: what its author cited is what the leads can be checked against.
|
|
86
|
+
const foreignSources = new Map(records
|
|
87
|
+
.filter((r) => r.tag === "SOUR" && r.xref)
|
|
88
|
+
.map((r) => {
|
|
89
|
+
const repo = val(r, "REPO");
|
|
90
|
+
const repoName = repo ? records.find((x) => x.xref === repo)?.children.find((c) => c.tag === "NAME")?.value : undefined;
|
|
91
|
+
const caln = r.children.find((c) => c.tag === "REPO")?.children.find((c) => c.tag === "CALN")?.value;
|
|
92
|
+
return [r.xref, [val(r, "TITL"), val(r, "AUTH"), [repoName, caln].filter(Boolean).join(", ")].filter(Boolean).join(" · ")];
|
|
93
|
+
}));
|
|
94
|
+
const bare = (xref) => xref.replace(/@/g, "");
|
|
95
|
+
tree.withTreeLock(() => {
|
|
96
|
+
const source = makeSource(tree, `${importLabel(tree.config.lang)} ${opts.name}`, opts.input);
|
|
97
|
+
result.source = source.id;
|
|
98
|
+
const xrefToId = new Map();
|
|
99
|
+
const pedi = new Map(); // person xref → family xref → PEDI
|
|
100
|
+
const individuals = records.filter((x) => x.tag === "INDI" && x.xref);
|
|
101
|
+
// IDs first, so that associations (a godparent's ASSO) point at the right person.
|
|
102
|
+
const fresh = new Set();
|
|
103
|
+
for (const r of individuals) {
|
|
104
|
+
const refn = val(r, "REFN");
|
|
105
|
+
// one of ours — merged into another since the export: that one
|
|
106
|
+
let ours = refn && /^P\d{4,}$/.test(refn) ? tree.get(refn) : undefined;
|
|
107
|
+
for (let hops = 0; ours?.mergedInto && hops < 10; hops++)
|
|
108
|
+
ours = tree.get(ours.mergedInto);
|
|
109
|
+
if (ours && !ours.retracted) {
|
|
110
|
+
xrefToId.set(r.xref, ours.id);
|
|
111
|
+
result.matched++;
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
const id = tree.allocate("P");
|
|
115
|
+
xrefToId.set(r.xref, id);
|
|
116
|
+
fresh.add(r.xref);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const events = (node, xref, forFamily, couple = {}) => {
|
|
120
|
+
const out = [];
|
|
121
|
+
for (const c of node.children) {
|
|
122
|
+
const kind = eventKind(c.tag);
|
|
123
|
+
if (!kind || ["NAME", "SEX"].includes(c.tag))
|
|
124
|
+
continue;
|
|
125
|
+
if (forFamily !== FAMILY_EVENT_KINDS.has(kind) && kind !== "EVEN" && kind !== "RESI" && kind !== "CENS")
|
|
126
|
+
continue;
|
|
127
|
+
const ev = {
|
|
128
|
+
id: tree.allocate("E"),
|
|
129
|
+
kind,
|
|
130
|
+
status: "lead",
|
|
131
|
+
citations: [{ source: source.id, locator: `${bare(xref)} ${c.tag}` }],
|
|
132
|
+
};
|
|
133
|
+
const extra = [];
|
|
134
|
+
const rawDate = val(c, "DATE");
|
|
135
|
+
const date = importDate(rawDate);
|
|
136
|
+
if (date)
|
|
137
|
+
ev.date = date;
|
|
138
|
+
else if (rawDate)
|
|
139
|
+
extra.push(`date as written: ${rawDate}`);
|
|
140
|
+
const place = val(c, "PLAC");
|
|
141
|
+
if (place)
|
|
142
|
+
ev.place = place.replace(/\s+/g, " ");
|
|
143
|
+
// an address in the place (ADDR / ADR1) is the house
|
|
144
|
+
const addr = c.children.find((x) => x.tag === "ADDR");
|
|
145
|
+
const house = addr ? houseOf(val(addr, "ADR1") ?? addr.value) : "";
|
|
146
|
+
if (house)
|
|
147
|
+
ev.house = house;
|
|
148
|
+
const cause = val(c, "CAUS")?.replace(/\s+/g, " ").trim();
|
|
149
|
+
if (cause)
|
|
150
|
+
ev.cause = cause;
|
|
151
|
+
const value = c.value.trim();
|
|
152
|
+
if (value && value !== "Y")
|
|
153
|
+
ev.value = value.replace(/\s+/g, " ");
|
|
154
|
+
const type = val(c, "TYPE");
|
|
155
|
+
// A bare "1 EVEN" with neither TYPE nor value still is an event: name it after its tag.
|
|
156
|
+
if (kind === "EVEN")
|
|
157
|
+
ev.label = type || value || "EVEN";
|
|
158
|
+
if (["OCCU", "RELI", "TITL", "NATI"].includes(kind) && !ev.value)
|
|
159
|
+
ev.value = type || "?";
|
|
160
|
+
const rawAge = val(c, "AGE");
|
|
161
|
+
if (rawAge && !forFamily) {
|
|
162
|
+
const age = normalizeAge(rawAge);
|
|
163
|
+
if (age)
|
|
164
|
+
ev.age = age;
|
|
165
|
+
else
|
|
166
|
+
extra.push(`age as written: ${rawAge}`);
|
|
167
|
+
}
|
|
168
|
+
if (forFamily) {
|
|
169
|
+
const ages = {};
|
|
170
|
+
for (const role of ["HUSB", "WIFE"]) {
|
|
171
|
+
const raw = val(c.children.find((x) => x.tag === role), "AGE");
|
|
172
|
+
const who = couple[role];
|
|
173
|
+
const age = raw ? normalizeAge(raw) : undefined;
|
|
174
|
+
if (raw && who && age)
|
|
175
|
+
ages[who] = age;
|
|
176
|
+
else if (raw)
|
|
177
|
+
extra.push(`${role === "HUSB" ? "husband" : "wife"} aged ${raw}`);
|
|
178
|
+
}
|
|
179
|
+
if (Object.keys(ages).length)
|
|
180
|
+
ev.ages = ages;
|
|
181
|
+
if (rawAge)
|
|
182
|
+
extra.push(`age as written: ${rawAge}`);
|
|
183
|
+
}
|
|
184
|
+
const participants = [];
|
|
185
|
+
// "RELA Present" + a note with the role in words (the Strom app for a midwife, an informant …)
|
|
186
|
+
const roleIn = (n, fallback) => {
|
|
187
|
+
const note = children(n, "NOTE").map(noteText).join(" ").trim();
|
|
188
|
+
const role = roleWord(val(n, "RELA")) ?? roleWord(note) ?? fallback;
|
|
189
|
+
return { role, ...(note && !roleWord(note) ? { note } : {}) };
|
|
190
|
+
};
|
|
191
|
+
for (const w of children(c, "_WITN").concat(children(c, "WITN"))) {
|
|
192
|
+
const name = w.value.trim();
|
|
193
|
+
if (name)
|
|
194
|
+
participants.push({ ...roleIn(w, "witness"), name });
|
|
195
|
+
}
|
|
196
|
+
for (const a of children(c, "ASSO")) {
|
|
197
|
+
const id = xrefToId.get(a.value.trim());
|
|
198
|
+
if (id)
|
|
199
|
+
participants.push({ ...roleIn(a, "other"), person: id });
|
|
200
|
+
}
|
|
201
|
+
if (participants.length)
|
|
202
|
+
ev.participants = participants;
|
|
203
|
+
// What the author of that tree cited for this fact.
|
|
204
|
+
for (const sc of children(c, "SOUR")) {
|
|
205
|
+
const title = foreignSources.get(sc.value.trim()) ?? (/^@/.test(sc.value) ? "" : sc.value.trim());
|
|
206
|
+
const cited = [title, val(sc, "PAGE")].filter(Boolean).join(", ");
|
|
207
|
+
if (cited)
|
|
208
|
+
extra.push(`source there: ${cited}`);
|
|
209
|
+
}
|
|
210
|
+
// The Strom app returns age, cause and address as labelled lines of the note ("Věk: 61 let").
|
|
211
|
+
const noteLines = [];
|
|
212
|
+
for (const line of children(c, "NOTE").map(noteText).join("\n").split("\n"))
|
|
213
|
+
if (!detailInto(ev, line, forFamily, couple))
|
|
214
|
+
noteLines.push(line);
|
|
215
|
+
const parts = [...extra, noteLines.join("\n").trim()].filter(Boolean);
|
|
216
|
+
if (parts.length)
|
|
217
|
+
ev.note = clip(parts.join("\n"));
|
|
218
|
+
out.push(ev);
|
|
219
|
+
result.events++;
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
};
|
|
223
|
+
/** Write a record; an event that is not valid is left out (and reported), never the whole import. */
|
|
224
|
+
const save = (rec, op, label) => {
|
|
225
|
+
let problemsNow = validateRecord(rec);
|
|
226
|
+
const bad = new Set(problemsNow.map((p) => /^events\[(\d+)\]/.exec(p.path)?.[1]).filter((x) => x !== undefined).map(Number));
|
|
227
|
+
if (bad.size) {
|
|
228
|
+
for (const i of bad)
|
|
229
|
+
result.problems.push(`${label}: left out ${rec.events[i]?.kind ?? "a fact"} — ${problemsNow.find((p) => p.path.startsWith(`events[${i}]`))?.message}`);
|
|
230
|
+
rec.events = rec.events.filter((_, i) => !bad.has(i));
|
|
231
|
+
problemsNow = validateRecord(rec);
|
|
232
|
+
}
|
|
233
|
+
if (problemsNow.length) {
|
|
234
|
+
result.problems.push(`${label}: skipped — ${problemsNow.map((p) => `${p.path} ${p.message}`).join("; ")}`);
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
tree.put(rec, op);
|
|
238
|
+
return true;
|
|
239
|
+
};
|
|
240
|
+
const namesOf = (r) => children(r, "NAME")
|
|
241
|
+
.map((n) => {
|
|
242
|
+
const parsed = parseName(n.value.replace(/\s+/g, " "));
|
|
243
|
+
const found = !/\//.test(n.value) && (val(n, "SURN") || val(n, "GIVN")) ? { given: val(n, "GIVN") ?? "", surname: val(n, "SURN") ?? "" } : parsed;
|
|
244
|
+
// a slash inside a name ("/⟨K/Č⟩emenská/") is kept as "|": the name stays one GEDCOM can write
|
|
245
|
+
const name = { ...found, given: found.given.replace(/\//g, "|"), surname: found.surname.replace(/\//g, "|") };
|
|
246
|
+
const kind = nameKind(val(n, "TYPE"));
|
|
247
|
+
return kind ? { ...name, kind } : name;
|
|
248
|
+
})
|
|
249
|
+
.map((n) => (n.given || n.surname ? n : { ...n, given: "?", surname: "" }));
|
|
250
|
+
/** The Strom app keeps what a birth or death entry adds in the person's note ("Úmrtí: Věk: 61 let"): back to the fact; the other lines are returned. */
|
|
251
|
+
const factNotes = (r, personEvents) => {
|
|
252
|
+
const personNotes = [];
|
|
253
|
+
for (const text of children(r, "NOTE").map(noteText)) {
|
|
254
|
+
const keep = [];
|
|
255
|
+
for (const line of text.split("\n")) {
|
|
256
|
+
const m = /^\s*(narozeni|birth|geburt|umrti|death|tod)\s*:\s*(.+)$/iu.exec(foldText(line)) ? /^\s*[^:]+:\s*(.+)$/u.exec(line) : null;
|
|
257
|
+
const kind = m ? (/^\s*(narozeni|birth|geburt)/iu.test(foldText(line)) ? "BIRT" : "DEAT") : undefined;
|
|
258
|
+
const ev = kind ? personEvents.find((e) => e.kind === kind) : undefined;
|
|
259
|
+
if (!m || !ev || !detailInto(ev, m[1], false))
|
|
260
|
+
keep.push(line);
|
|
261
|
+
}
|
|
262
|
+
if (keep.join("").trim())
|
|
263
|
+
personNotes.push(keep.join("\n"));
|
|
264
|
+
}
|
|
265
|
+
return personNotes;
|
|
266
|
+
};
|
|
267
|
+
// Individuals
|
|
268
|
+
for (const r of individuals) {
|
|
269
|
+
if (!fresh.has(r.xref)) {
|
|
270
|
+
// One of ours coming back (the Strom app): what it did not know yet is added as a lead; nothing is replaced.
|
|
271
|
+
// Its notes are ours coming back too — they are not taken again.
|
|
272
|
+
const cur = tree.get(xrefToId.get(r.xref));
|
|
273
|
+
const incoming = events(r, r.xref, false);
|
|
274
|
+
factNotes(r, incoming);
|
|
275
|
+
const facts = incoming.filter((e) => !knownFact(cur.events, e));
|
|
276
|
+
result.events -= incoming.length - facts.length;
|
|
277
|
+
const names = namesOf(r).filter((n) => n.given !== "?" && !cur.names.some((x) => sameName(x, n)));
|
|
278
|
+
if (!facts.length && !names.length)
|
|
279
|
+
continue;
|
|
280
|
+
const next = { ...cur, names: [...cur.names, ...names], events: [...cur.events, ...facts], refs: [...(cur.refs ?? []), { system, id: r.xref }], updated: now() };
|
|
281
|
+
const what = [...facts.map((e) => [e.kind, e.date].filter(Boolean).join(" ")), ...names.map((n) => `name ${[n.given, n.surname].filter(Boolean).join(" ")}`)].join(", ");
|
|
282
|
+
if (save(next, { op: "person.import", targets: [cur.id], summary: `${cur.id} + ${what} (lead)` }, bare(r.xref)))
|
|
283
|
+
result.extended.push(`${cur.id}: ${what}`);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
const refn = val(r, "REFN");
|
|
287
|
+
const names = namesOf(r);
|
|
288
|
+
// The birth name leads: a married name is a variant.
|
|
289
|
+
names.sort((a, b) => (a.kind === "married" || a.kind === "alias" ? 1 : 0) - (b.kind === "married" || b.kind === "alias" ? 1 : 0));
|
|
290
|
+
const sex = val(r, "SEX")?.toUpperCase();
|
|
291
|
+
const refs = [{ system, id: r.xref }];
|
|
292
|
+
if (refn)
|
|
293
|
+
refs.push({ system: "refn", id: refn });
|
|
294
|
+
const t = now();
|
|
295
|
+
// The Strom app keeps what a birth or death entry adds in the person's note:
|
|
296
|
+
// "Úmrtí: Věk: 61 let", "Narození: Adresa: čp. 13" — back to the fact.
|
|
297
|
+
const personEvents = events(r, r.xref, false);
|
|
298
|
+
const personNotes = factNotes(r, personEvents);
|
|
299
|
+
const p = {
|
|
300
|
+
id: xrefToId.get(r.xref),
|
|
301
|
+
type: "person",
|
|
302
|
+
names: names.length ? names : [{ given: "?", surname: "" }],
|
|
303
|
+
sex: sex === "M" || sex === "F" ? sex : "U",
|
|
304
|
+
events: personEvents,
|
|
305
|
+
notes: personNotes.filter((x) => x.trim()).map((x) => note(tree, x)),
|
|
306
|
+
refs,
|
|
307
|
+
created: t,
|
|
308
|
+
updated: t,
|
|
309
|
+
};
|
|
310
|
+
if (!save(p, { op: "person.import", targets: [p.id], summary: `+${p.id} ${names[0]?.given ?? ""} /${names[0]?.surname ?? ""}/ (lead)` }, bare(r.xref))) {
|
|
311
|
+
xrefToId.delete(r.xref);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
result.persons++;
|
|
315
|
+
for (const fc of children(r, "FAMC")) {
|
|
316
|
+
const pd = val(fc, "PEDI")?.toLowerCase();
|
|
317
|
+
if (pd && pd !== "birth")
|
|
318
|
+
pedi.set(r.xref, (pedi.get(r.xref) ?? new Map()).set(fc.value.trim(), pd));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
// Families
|
|
322
|
+
for (const r of records.filter((x) => x.tag === "FAM" && x.xref)) {
|
|
323
|
+
const couple = {};
|
|
324
|
+
for (const role of ["HUSB", "WIFE"]) {
|
|
325
|
+
const id = xrefToId.get(val(r, role) ?? "");
|
|
326
|
+
if (id)
|
|
327
|
+
couple[role] = id;
|
|
328
|
+
}
|
|
329
|
+
const partners = [couple.HUSB, couple.WIFE].filter((x) => !!x);
|
|
330
|
+
const kids = [];
|
|
331
|
+
for (const c of children(r, "CHIL")) {
|
|
332
|
+
const id = xrefToId.get(c.value.trim());
|
|
333
|
+
if (!id)
|
|
334
|
+
continue;
|
|
335
|
+
const pd = pedi.get(c.value.trim())?.get(r.xref);
|
|
336
|
+
const relation = pd === "adopted" ? "adopted" : pd === "foster" ? "foster" : pd === "step" ? "step" : "birth";
|
|
337
|
+
const link = { person: id, relation };
|
|
338
|
+
// the relation to each parent (Legacy, RootsMagic, FTM)
|
|
339
|
+
const relations = {};
|
|
340
|
+
for (const [tag, who] of [["_FREL", couple.HUSB], ["_MREL", couple.WIFE]]) {
|
|
341
|
+
const v = (val(c, tag) ?? "").toLowerCase();
|
|
342
|
+
const rel = /natural|birth|biolog/.test(v) ? "birth" : /adopt/.test(v) ? "adopted" : /step/.test(v) ? "step" : /foster/.test(v) ? "foster" : v ? "unknown" : undefined;
|
|
343
|
+
if (who && rel && rel !== relation)
|
|
344
|
+
relations[who] = rel;
|
|
345
|
+
}
|
|
346
|
+
if (Object.keys(relations).length)
|
|
347
|
+
link.relations = relations;
|
|
348
|
+
kids.push(link);
|
|
349
|
+
}
|
|
350
|
+
if (partners.length + kids.length === 0)
|
|
351
|
+
continue;
|
|
352
|
+
// Strom keeps the partners' ages at the wedding in the family's note: "Věk ženicha: 27 let".
|
|
353
|
+
const famEvents = events(r, r.xref, true, couple);
|
|
354
|
+
const marr = famEvents.find((e) => e.kind === "MARR");
|
|
355
|
+
const famNotes = [];
|
|
356
|
+
for (const text of children(r, "NOTE").map(noteText)) {
|
|
357
|
+
const keep = text.split("\n").filter((line) => !(marr && detailInto(marr, line, true, couple)));
|
|
358
|
+
if (keep.join("").trim())
|
|
359
|
+
famNotes.push(keep.join("\n"));
|
|
360
|
+
}
|
|
361
|
+
// A family between matched persons that already exists is not created twice — it gets
|
|
362
|
+
// what it did not know: children added in the other program, facts as leads.
|
|
363
|
+
const exists = tree
|
|
364
|
+
.list("family")
|
|
365
|
+
.find((f) => !f.retracted && !f.mergedInto && partners.length > 0 && partners.every((p) => f.partners.includes(p)) && f.partners.length === partners.length);
|
|
366
|
+
if (exists) {
|
|
367
|
+
const kidsNew = kids.filter((k) => !exists.children.some((c) => c.person === k.person));
|
|
368
|
+
const facts = famEvents.filter((e) => !knownFact(exists.events, e));
|
|
369
|
+
result.events -= famEvents.length - facts.length;
|
|
370
|
+
if (!kidsNew.length && !facts.length)
|
|
371
|
+
continue;
|
|
372
|
+
const next = { ...exists, children: [...exists.children, ...kidsNew], events: [...exists.events, ...facts], updated: now() };
|
|
373
|
+
const what = [...kidsNew.map((k) => `child ${k.person}`), ...facts.map((e) => [e.kind, e.date].filter(Boolean).join(" "))].join(", ");
|
|
374
|
+
if (save(next, { op: "family.import", targets: [exists.id, ...kidsNew.map((k) => k.person)], summary: `${exists.id} + ${what} (lead)` }, bare(r.xref)))
|
|
375
|
+
result.extended.push(`${exists.id}: ${what}`);
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
const t = now();
|
|
379
|
+
const f = {
|
|
380
|
+
id: tree.allocate("F"),
|
|
381
|
+
type: "family",
|
|
382
|
+
partners,
|
|
383
|
+
children: kids,
|
|
384
|
+
events: famEvents,
|
|
385
|
+
notes: famNotes.map((x) => note(tree, x)),
|
|
386
|
+
refs: [{ system, id: r.xref }],
|
|
387
|
+
created: t,
|
|
388
|
+
updated: t,
|
|
389
|
+
};
|
|
390
|
+
if (save(f, { op: "family.import", targets: [f.id, ...partners, ...kids.map((k) => k.person)], summary: `+${f.id} family (lead)` }, bare(r.xref)))
|
|
391
|
+
result.families++;
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
return result;
|
|
395
|
+
}
|
|
396
|
+
/** The title of the source an imported tree becomes, in the research language. */
|
|
397
|
+
function importLabel(lang) {
|
|
398
|
+
return { cs: "Rodokmen", de: "Stammbaum", pl: "Drzewo genealogiczne", sk: "Rodokmeň" }[lang] ?? "Family tree";
|
|
399
|
+
}
|
|
400
|
+
/** Is this parsed JSON a Strom app tree? */
|
|
401
|
+
export function isStromJson(data) {
|
|
402
|
+
return !!data && typeof data === "object" && "persons" in data && "partnerships" in data;
|
|
403
|
+
}
|
|
404
|
+
/** Strom flexible date ("~1900", "<1900-05", "1900-05-03") → GEDCOM. */
|
|
405
|
+
export function fromFlexDate(flex) {
|
|
406
|
+
if (!flex)
|
|
407
|
+
return undefined;
|
|
408
|
+
const m = /^([~<>])?(\d{3,4})(?:-(\d{2}))?(?:-(\d{2}))?$/.exec(flex.trim());
|
|
409
|
+
if (!m)
|
|
410
|
+
return importDate(flex);
|
|
411
|
+
const months = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
|
|
412
|
+
const core = [m[4] ? String(Number(m[4])) : "", m[3] ? months[Number(m[3]) - 1] ?? "" : "", m[2]].filter(Boolean).join(" ");
|
|
413
|
+
const q = m[1] === "~" ? "ABT " : m[1] === "<" ? "BEF " : m[1] === ">" ? "AFT " : "";
|
|
414
|
+
return normalizeDate(q + core);
|
|
415
|
+
}
|
|
416
|
+
export function importStromJson(tree, data, opts) {
|
|
417
|
+
const result = { source: "", persons: 0, families: 0, matched: 0, events: 0, extended: [], problems: [] };
|
|
418
|
+
tree.withTreeLock(() => {
|
|
419
|
+
const source = makeSource(tree, `${importLabel(tree.config.lang)} ${opts.name} (Strom)`, opts.input);
|
|
420
|
+
result.source = source.id;
|
|
421
|
+
const idMap = new Map();
|
|
422
|
+
const cite = (sp, what) => [{ source: source.id, locator: `${sp.id} ${what}` }];
|
|
423
|
+
for (const sp of Object.values(data.persons)) {
|
|
424
|
+
if (sp.refn && /^P\d{4,}$/.test(sp.refn) && tree.get(sp.refn)) {
|
|
425
|
+
idMap.set(sp.id, sp.refn);
|
|
426
|
+
result.matched++;
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
const events = [];
|
|
430
|
+
const add = (kind, date, place) => {
|
|
431
|
+
const d = fromFlexDate(date);
|
|
432
|
+
if (!d && !place)
|
|
433
|
+
return;
|
|
434
|
+
const ev = { id: tree.allocate("E"), kind, status: "lead", citations: cite(sp, kind) };
|
|
435
|
+
if (d)
|
|
436
|
+
ev.date = d;
|
|
437
|
+
if (place)
|
|
438
|
+
ev.place = place;
|
|
439
|
+
if (date && !d)
|
|
440
|
+
ev.note = `date as written: ${date}`;
|
|
441
|
+
events.push(ev);
|
|
442
|
+
result.events++;
|
|
443
|
+
};
|
|
444
|
+
add("BIRT", sp.birthDate, sp.birthPlace);
|
|
445
|
+
add("DEAT", sp.deathDate, sp.deathPlace);
|
|
446
|
+
const t = now();
|
|
447
|
+
const p = {
|
|
448
|
+
id: tree.allocate("P"),
|
|
449
|
+
type: "person",
|
|
450
|
+
names: [{ given: sp.firstName?.trim() || (sp.lastName ? "" : "?"), surname: sp.lastName?.trim() ?? "" }, ...(sp.nameVariants ?? []).map((v) => parseName(v))],
|
|
451
|
+
sex: sp.gender === "male" ? "M" : sp.gender === "female" ? "F" : "U",
|
|
452
|
+
events,
|
|
453
|
+
notes: sp.notes?.trim() ? [note(tree, sp.notes)] : [],
|
|
454
|
+
refs: [{ system: "strom-app", id: sp.id }, ...(sp.refn ? [{ system: "refn", id: sp.refn }] : [])],
|
|
455
|
+
created: t,
|
|
456
|
+
updated: t,
|
|
457
|
+
};
|
|
458
|
+
tree.put(p, { op: "person.import", targets: [p.id], summary: `+${p.id} ${p.names[0].given} /${p.names[0].surname}/ (lead)` });
|
|
459
|
+
idMap.set(sp.id, p.id);
|
|
460
|
+
result.persons++;
|
|
461
|
+
}
|
|
462
|
+
for (const sp of Object.values(data.partnerships)) {
|
|
463
|
+
const partners = [sp.person1Id, sp.person2Id].map((x) => (x ? idMap.get(x) : undefined)).filter((x) => !!x);
|
|
464
|
+
const kids = (sp.childIds ?? []).map((c) => idMap.get(c)).filter((x) => !!x);
|
|
465
|
+
if (partners.length + kids.length === 0)
|
|
466
|
+
continue;
|
|
467
|
+
if (tree.list("family").some((f) => partners.length > 0 && partners.every((p) => f.partners.includes(p)) && f.partners.length === partners.length))
|
|
468
|
+
continue;
|
|
469
|
+
const t = now();
|
|
470
|
+
const events = [];
|
|
471
|
+
const d = fromFlexDate(sp.startDate);
|
|
472
|
+
if (d || sp.startPlace) {
|
|
473
|
+
const ev = { id: tree.allocate("E"), kind: "MARR", status: "lead", citations: [{ source: source.id, locator: `${sp.id} MARR` }] };
|
|
474
|
+
if (d)
|
|
475
|
+
ev.date = d;
|
|
476
|
+
if (sp.startPlace)
|
|
477
|
+
ev.place = sp.startPlace;
|
|
478
|
+
events.push(ev);
|
|
479
|
+
result.events++;
|
|
480
|
+
}
|
|
481
|
+
const f = {
|
|
482
|
+
id: tree.allocate("F"),
|
|
483
|
+
type: "family",
|
|
484
|
+
partners,
|
|
485
|
+
children: kids.map((person) => ({ person, relation: "birth" })),
|
|
486
|
+
events,
|
|
487
|
+
notes: [],
|
|
488
|
+
refs: [{ system: "strom-app", id: sp.id }],
|
|
489
|
+
created: t,
|
|
490
|
+
updated: t,
|
|
491
|
+
};
|
|
492
|
+
tree.put(f, { op: "family.import", targets: [f.id, ...partners, ...kids], summary: `+${f.id} family (lead)` });
|
|
493
|
+
result.families++;
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
return result;
|
|
497
|
+
}
|
|
498
|
+
/** "čp. 13", "č. p. 13", "Nr. 13", "House No. 13" → "13": the house, without the word for it. */
|
|
499
|
+
function houseOf(address) {
|
|
500
|
+
return address
|
|
501
|
+
.replace(/\s+/g, " ")
|
|
502
|
+
.trim()
|
|
503
|
+
.replace(/^(?:č\.\s?p\.?|čp\.?|c\.\s?p\.|cp\.|house\s+no\.?|no\.|nr\.|haus-?\s?n(?:r|ro)\.?)\s*/iu, "");
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* A labelled detail line of a fact, as the Strom app writes it back (in
|
|
507
|
+
* Czech, English or German): "Věk: 61 let", "Příčina: …", "Adresa: čp. 13",
|
|
508
|
+
* "Věk ženicha: 27 let". Put into the fact; false when the line is no such
|
|
509
|
+
* detail (it stays in the note).
|
|
510
|
+
*/
|
|
511
|
+
function detailInto(ev, line, family, couple = {}) {
|
|
512
|
+
const m = /^\s*([^:]{2,30}):\s*(.+)$/u.exec(line);
|
|
513
|
+
if (!m)
|
|
514
|
+
return false;
|
|
515
|
+
const label = foldText(m[1]);
|
|
516
|
+
const value = m[2].trim();
|
|
517
|
+
if (/^(vek zenicha|age of the husband|alter des brautigams)$/.test(label) || /^(vek nevesty|age of the wife|alter der braut)$/.test(label)) {
|
|
518
|
+
const who = /zenicha|husband|brautigams/.test(label) ? couple.HUSB : couple.WIFE;
|
|
519
|
+
const age = normalizeAge(value);
|
|
520
|
+
if (!family || !who || !age || ev.ages?.[who])
|
|
521
|
+
return false;
|
|
522
|
+
ev.ages = { ...ev.ages, [who]: age };
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
525
|
+
if (/^(vek|age|alter)$/.test(label)) {
|
|
526
|
+
const age = normalizeAge(value);
|
|
527
|
+
if (family || ev.age || !age)
|
|
528
|
+
return false;
|
|
529
|
+
ev.age = age;
|
|
530
|
+
return true;
|
|
531
|
+
}
|
|
532
|
+
if (/^(pricina|cause|ursache)$/.test(label) && !ev.cause) {
|
|
533
|
+
ev.cause = value;
|
|
534
|
+
return true;
|
|
535
|
+
}
|
|
536
|
+
if (/^(adresa|address|adresse)$/.test(label) && !ev.house) {
|
|
537
|
+
ev.house = houseOf(value);
|
|
538
|
+
return true;
|
|
539
|
+
}
|
|
540
|
+
return false;
|
|
541
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Installing an AI agent with the user's yes (Claude Code, by its own official
|
|
2
|
+
// installer), visibly in the user's terminal. Git: core/deps.ts.
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { userHome } from "./paths.js";
|
|
6
|
+
/** Claude Code's official installer. */
|
|
7
|
+
export function claudeInstaller(platform = process.platform) {
|
|
8
|
+
if (platform === "win32")
|
|
9
|
+
return { command: "powershell.exe", args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", "irm https://claude.ai/install.ps1 | iex"] };
|
|
10
|
+
return { command: "/bin/sh", args: ["-c", "curl -fsSL https://claude.ai/install.sh | bash"] };
|
|
11
|
+
}
|
|
12
|
+
/** Run an installer in the user's terminal; true when it ended well. */
|
|
13
|
+
export function runInstaller(inst, env) {
|
|
14
|
+
if (env.STROM_NO_INSTALL === "1")
|
|
15
|
+
return false;
|
|
16
|
+
const r = spawnSync(inst.command, inst.args, { stdio: "inherit", env: env });
|
|
17
|
+
return !r.error && r.status === 0;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Folders where agent installers put their program, which the PATH of this
|
|
21
|
+
* very process may not have yet (the installer changed it for new terminals).
|
|
22
|
+
*/
|
|
23
|
+
export function agentBinDirs(env, platform = process.platform) {
|
|
24
|
+
const home = userHome(env);
|
|
25
|
+
if (platform === "win32")
|
|
26
|
+
return [path.join(home, ".local", "bin"), path.join(env.APPDATA ?? path.join(home, "AppData", "Roaming"), "npm")];
|
|
27
|
+
return [path.join(home, ".local", "bin"), path.join(home, ".claude", "local")];
|
|
28
|
+
}
|