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,283 @@
|
|
|
1
|
+
// Person helpers: names, life spans, relatives, and resolving a reference
|
|
2
|
+
// ("P0039", "Antonín Víšek", "visek antonin") to exactly one person.
|
|
3
|
+
import { AmbiguousError, UsageError } from "./errors.js";
|
|
4
|
+
import { dateYears, yearLabel } from "./gdate.js";
|
|
5
|
+
import { foldText, tokens } from "./text.js";
|
|
6
|
+
/**
|
|
7
|
+
* Parse "Jan /Novák/", "Jan Novák" (last word = surname) or "/Novák/". The
|
|
8
|
+
* surname runs from the first slash to the last: "/⟨K/Č⟩emenská/" keeps its
|
|
9
|
+
* inner slash for the caller to refuse (actions) or replace (import).
|
|
10
|
+
*/
|
|
11
|
+
export function parseName(input) {
|
|
12
|
+
const s = input.trim().replace(/\s+/g, " ");
|
|
13
|
+
const m = /^([^/]*?)\s*\/(.*)\/\s*([^/]*)$/.exec(s);
|
|
14
|
+
if (m) {
|
|
15
|
+
const given = [m[1], m[3]].filter(Boolean).join(" ").trim();
|
|
16
|
+
return { given, surname: (m[2] ?? "").trim() };
|
|
17
|
+
}
|
|
18
|
+
const parts = s.split(" ");
|
|
19
|
+
if (parts.length === 1)
|
|
20
|
+
return { given: s, surname: "" };
|
|
21
|
+
return { given: parts.slice(0, -1).join(" "), surname: parts[parts.length - 1] };
|
|
22
|
+
}
|
|
23
|
+
/** Words that describe a person instead of naming them (folded): stillborn, unbaptised, N.N. */
|
|
24
|
+
const DESCRIPTIONS = new Set([
|
|
25
|
+
"nn", "n n", "nomen nescio", "unnamed", "unknown", "stillborn", "still born", "infant", "child", "son", "daughter",
|
|
26
|
+
"mrtve narozeny", "mrtve narozena", "mrtve narozene", "mrtvy narozen", "mrtva narozena", "mrtvorozeny", "mrtvorozena", "mrtvorozene",
|
|
27
|
+
"nepokrteny", "nepokrtena", "nepokrtene", "nekrtenec", "bez jmena", "neznamy", "neznama", "dite", "syn", "dcera",
|
|
28
|
+
"totgeboren", "totgeborenes kind", "ungetauft", "namenlos", "kind", "sohn", "tochter",
|
|
29
|
+
"martwo urodzony", "martwo urodzona", "martwo urodzone", "nieochrzczony", "nieochrzczona", "dziecko",
|
|
30
|
+
"sans nom", "mort ne", "mort nee", "inconnu", "inconnue", "enfant", "proles", "infans", "filius", "filia",
|
|
31
|
+
]);
|
|
32
|
+
/**
|
|
33
|
+
* Why a given name is not a name, or undefined: a description in brackets or
|
|
34
|
+
* a word like "stillborn" is what the record says ABOUT the person — it goes
|
|
35
|
+
* to a fact or a note, the name stays empty ("/Novák/").
|
|
36
|
+
*/
|
|
37
|
+
export function notAName(given) {
|
|
38
|
+
const g = given.trim();
|
|
39
|
+
if (!g)
|
|
40
|
+
return undefined;
|
|
41
|
+
if (/^[(\[{].*[)\]}]$/su.test(g))
|
|
42
|
+
return `"${g}" is a description in brackets, not a name`;
|
|
43
|
+
const words = foldText(g).replace(/[^\p{L}\p{N}]+/gu, " ").trim();
|
|
44
|
+
if (DESCRIPTIONS.has(words))
|
|
45
|
+
return `"${g}" describes the person, it is not a name`;
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The same name: letter for letter (case aside), whatever its kind. "Višek"
|
|
50
|
+
* and "Víšek" are two spellings — a variant worth keeping, not one name.
|
|
51
|
+
*/
|
|
52
|
+
export function sameName(a, b) {
|
|
53
|
+
const key = (n) => formatName(n).normalize("NFC").toLowerCase();
|
|
54
|
+
return key(a) === key(b);
|
|
55
|
+
}
|
|
56
|
+
export function formatName(n) {
|
|
57
|
+
return [n.given, n.surname].filter(Boolean).join(" ") || "?";
|
|
58
|
+
}
|
|
59
|
+
/** GEDCOM keeps the surname between two slashes: a slash inside a name part is written as "|". */
|
|
60
|
+
export function gedcomName(n) {
|
|
61
|
+
const part = (s) => s.replace(/\//g, "|");
|
|
62
|
+
return `${part(n.given)} /${part(n.surname)}/`.trim();
|
|
63
|
+
}
|
|
64
|
+
/** Why a name cannot be kept as it is, or undefined: a slash inside it ("/⟨K/Č⟩emenská/", "Jan /Novák"). */
|
|
65
|
+
export function slashInName(n) {
|
|
66
|
+
const bad = [n.given, n.surname].find((s) => s.includes("/"));
|
|
67
|
+
return bad === undefined ? undefined : `"${bad}": a slash inside a name — only the surname goes between two slashes ("Jan /Novák/")`;
|
|
68
|
+
}
|
|
69
|
+
export function primaryName(p) {
|
|
70
|
+
return p.names.find((n) => !n.kind || n.kind === "birth") ?? p.names[0] ?? { given: "", surname: "" };
|
|
71
|
+
}
|
|
72
|
+
export function displayName(p) {
|
|
73
|
+
return formatName(primaryName(p));
|
|
74
|
+
}
|
|
75
|
+
const STATUS_RANK = { proven: 0, probable: 1, possible: 2, lead: 3 };
|
|
76
|
+
/** Best-supported first: status, then the number of citations, then the order they were added. */
|
|
77
|
+
export function byPreference(a, b) {
|
|
78
|
+
return (STATUS_RANK[a.status] ?? 9) - (STATUS_RANK[b.status] ?? 9) || b.citations.length - a.citations.length;
|
|
79
|
+
}
|
|
80
|
+
/** The fact of a kind that counts: the best-supported one that is not retracted or disproven. */
|
|
81
|
+
export function preferred(events, kind) {
|
|
82
|
+
return events.filter((e) => e.kind === kind && !e.retracted && e.status !== "disproven").sort(byPreference)[0];
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Events in the order a GEDCOM reader should see them: for each kind the
|
|
86
|
+
* preferred one first (readers take the first BIRT as the birth), kinds in
|
|
87
|
+
* the order they first appear.
|
|
88
|
+
*/
|
|
89
|
+
export function preferredOrder(events) {
|
|
90
|
+
const kinds = [...new Set(events.map((e) => e.kind))];
|
|
91
|
+
return kinds.flatMap((k) => events.filter((e) => e.kind === k).sort(byPreference));
|
|
92
|
+
}
|
|
93
|
+
function firstEvent(p, kinds) {
|
|
94
|
+
for (const k of kinds) {
|
|
95
|
+
const e = preferred(p.events, k);
|
|
96
|
+
if (e)
|
|
97
|
+
return e;
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
export function birthEvent(p) {
|
|
102
|
+
return firstEvent(p, ["BIRT", "CHR", "BAPM"]);
|
|
103
|
+
}
|
|
104
|
+
export function deathEvent(p) {
|
|
105
|
+
return firstEvent(p, ["DEAT", "BURI", "CREM"]);
|
|
106
|
+
}
|
|
107
|
+
/** "1811–1892", "*1811", "†1892" or "". */
|
|
108
|
+
export function lifespan(p) {
|
|
109
|
+
const b = birthEvent(p)?.date;
|
|
110
|
+
const d = deathEvent(p)?.date;
|
|
111
|
+
if (b && d)
|
|
112
|
+
return `${yearLabel(b)}–${yearLabel(d)}`;
|
|
113
|
+
if (b)
|
|
114
|
+
return `*${yearLabel(b)}`;
|
|
115
|
+
if (d)
|
|
116
|
+
return `†${yearLabel(d)}`;
|
|
117
|
+
return "";
|
|
118
|
+
}
|
|
119
|
+
export function label(p) {
|
|
120
|
+
const span = lifespan(p);
|
|
121
|
+
return `${p.id} ${displayName(p)}${span ? ` (${span})` : ""}`;
|
|
122
|
+
}
|
|
123
|
+
const relCache = new WeakMap();
|
|
124
|
+
function relIndex(tree) {
|
|
125
|
+
const cached = relCache.get(tree);
|
|
126
|
+
if (cached && cached.version === tree.version)
|
|
127
|
+
return cached;
|
|
128
|
+
const idx = { version: tree.version, asChild: new Map(), asPartner: new Map() };
|
|
129
|
+
for (const f of tree.list("family")) {
|
|
130
|
+
if (f.retracted)
|
|
131
|
+
continue;
|
|
132
|
+
for (const c of f.children)
|
|
133
|
+
idx.asChild.set(c.person, [...(idx.asChild.get(c.person) ?? []), f]);
|
|
134
|
+
for (const p of f.partners)
|
|
135
|
+
idx.asPartner.set(p, [...(idx.asPartner.get(p) ?? []), f]);
|
|
136
|
+
}
|
|
137
|
+
relCache.set(tree, idx);
|
|
138
|
+
return idx;
|
|
139
|
+
}
|
|
140
|
+
/** How a child is related to one partner of its family: its own relation to them, else the family's. */
|
|
141
|
+
export function relationTo(link, partner) {
|
|
142
|
+
return link.relations?.[partner] ?? link.relation;
|
|
143
|
+
}
|
|
144
|
+
/** The family a child was born into: born to every partner of it. */
|
|
145
|
+
export function isBirthFamily(f, child) {
|
|
146
|
+
const link = f.children.find((c) => c.person === child);
|
|
147
|
+
if (!link)
|
|
148
|
+
return false;
|
|
149
|
+
return f.partners.length ? f.partners.every((p) => relationTo(link, p) === "birth") : link.relation === "birth";
|
|
150
|
+
}
|
|
151
|
+
export function familiesAsChild(tree, id) {
|
|
152
|
+
return relIndex(tree).asChild.get(id) ?? [];
|
|
153
|
+
}
|
|
154
|
+
export function familiesAsPartner(tree, id) {
|
|
155
|
+
return relIndex(tree).asPartner.get(id) ?? [];
|
|
156
|
+
}
|
|
157
|
+
export function parentsOf(tree, id) {
|
|
158
|
+
const out = [];
|
|
159
|
+
for (const f of familiesAsChild(tree, id)) {
|
|
160
|
+
const link = f.children.find((c) => c.person === id);
|
|
161
|
+
for (const pid of f.partners) {
|
|
162
|
+
const rel = relationTo(link, pid);
|
|
163
|
+
if (rel !== "birth" && rel !== "unknown")
|
|
164
|
+
continue;
|
|
165
|
+
const p = tree.get(pid);
|
|
166
|
+
if (p && !p.retracted && !out.includes(p))
|
|
167
|
+
out.push(p);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
/** Generation numbers of ancestors: focus = 1, parents = 2, ... */
|
|
173
|
+
export function ancestorGenerations(tree, focus, max = 50) {
|
|
174
|
+
const gen = new Map([[focus, 1]]);
|
|
175
|
+
let frontier = [focus];
|
|
176
|
+
for (let g = 2; g <= max && frontier.length > 0; g++) {
|
|
177
|
+
const next = [];
|
|
178
|
+
for (const id of frontier)
|
|
179
|
+
for (const parent of parentsOf(tree, id))
|
|
180
|
+
if (!gen.has(parent.id)) {
|
|
181
|
+
gen.set(parent.id, g);
|
|
182
|
+
next.push(parent.id);
|
|
183
|
+
}
|
|
184
|
+
frontier = next;
|
|
185
|
+
}
|
|
186
|
+
return gen;
|
|
187
|
+
}
|
|
188
|
+
// ── resolving references ───────────────────────────────────────────────────
|
|
189
|
+
function candidateLabel(tree, p) {
|
|
190
|
+
const parents = parentsOf(tree, p.id).map(displayName);
|
|
191
|
+
const place = birthEvent(p)?.place;
|
|
192
|
+
return [label(p), place ? `born ${place}` : "", parents.length ? `child of ${parents.join(" & ")}` : ""].filter(Boolean).join(", ");
|
|
193
|
+
}
|
|
194
|
+
export function findPersons(tree, query) {
|
|
195
|
+
const q = tokens(query);
|
|
196
|
+
if (q.length === 0)
|
|
197
|
+
return [];
|
|
198
|
+
return tree
|
|
199
|
+
.list("person")
|
|
200
|
+
.filter((p) => !p.retracted)
|
|
201
|
+
.filter((p) => {
|
|
202
|
+
const hay = p.names.flatMap((n) => tokens(`${n.given} ${n.surname}`));
|
|
203
|
+
return q.every((t) => hay.some((h) => h === t || h.startsWith(t)));
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
export function resolvePerson(tree, ref) {
|
|
207
|
+
const id = ref.trim().toUpperCase();
|
|
208
|
+
if (/^P\d+$/.test(id)) {
|
|
209
|
+
const p = tree.get(id.length < 5 ? "P" + id.slice(1).padStart(4, "0") : id);
|
|
210
|
+
if (!p)
|
|
211
|
+
throw new UsageError(`no person ${ref}`, { hint: "strom person list" });
|
|
212
|
+
if (p.mergedInto)
|
|
213
|
+
throw new UsageError(`${p.id} was merged into ${p.mergedInto}`, { hint: `strom person show ${p.mergedInto}` });
|
|
214
|
+
return p;
|
|
215
|
+
}
|
|
216
|
+
const hits = findPersons(tree, ref);
|
|
217
|
+
if (hits.length === 1)
|
|
218
|
+
return hits[0];
|
|
219
|
+
if (hits.length === 0)
|
|
220
|
+
throw new UsageError(`no person matches "${ref}"`, { hint: `strom person list ${foldText(ref).split(" ").pop() ?? ""}`.trim() });
|
|
221
|
+
// Exact full-name match wins over prefix matches.
|
|
222
|
+
const exact = hits.filter((p) => p.names.some((n) => foldText(formatName(n)) === foldText(ref)));
|
|
223
|
+
if (exact.length === 1)
|
|
224
|
+
return exact[0];
|
|
225
|
+
throw new AmbiguousError(ref, hits.map((p) => ({ id: p.id, label: candidateLabel(tree, p) })));
|
|
226
|
+
}
|
|
227
|
+
/** Surnames that may be one family name: "Víšek"/"Víšková", "Nowak"/"Nowakowa", "Novotný"/"Novotná"; an unknown one fits any. */
|
|
228
|
+
export function sameSurname(a, b) {
|
|
229
|
+
const fa = foldText(a);
|
|
230
|
+
const fb = foldText(b);
|
|
231
|
+
if (!fa || !fb || fa === fb)
|
|
232
|
+
return true;
|
|
233
|
+
const stem = (x) => x.replace(/(ova|owa|ovna|owna|ina)$/, "").replace(/([a-z])(a|y|i)$/, "$1");
|
|
234
|
+
return stem(fa) === stem(fb);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* People of `ids` (an imported tree) who are probably people already in the
|
|
238
|
+
* tree: the same first given name, a fitting surname in any of their names,
|
|
239
|
+
* the same sex and birth years at most three apart. A hint to check — never
|
|
240
|
+
* merged without a look.
|
|
241
|
+
*/
|
|
242
|
+
export function likelyDuplicates(tree, ids) {
|
|
243
|
+
const out = [];
|
|
244
|
+
const inSet = new Set(ids);
|
|
245
|
+
const others = tree.list("person").filter((p) => !p.retracted && !inSet.has(p.id));
|
|
246
|
+
const first = (n) => foldText(n.given).split(" ")[0] ?? "";
|
|
247
|
+
const year = (p) => dateYears(birthEvent(p)?.date ?? "")[0];
|
|
248
|
+
for (const id of ids) {
|
|
249
|
+
const p = tree.get(id);
|
|
250
|
+
if (!p || p.retracted)
|
|
251
|
+
continue;
|
|
252
|
+
for (const o of others) {
|
|
253
|
+
if (p.sex !== "U" && o.sex !== "U" && p.sex !== o.sex)
|
|
254
|
+
continue;
|
|
255
|
+
const names = p.names.some((n) => o.names.some((m) => first(n) && first(n) === first(m) && sameSurname(n.surname, m.surname)));
|
|
256
|
+
if (!names)
|
|
257
|
+
continue;
|
|
258
|
+
const [a, b] = [year(p), year(o)];
|
|
259
|
+
if (a !== undefined && b !== undefined && Math.abs(a - b) > 3)
|
|
260
|
+
continue;
|
|
261
|
+
out.push({ person: p, same: o });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// A wife under her married name is found through her husband: the partner of a
|
|
265
|
+
// likely duplicate, with the same first given name, is likely the same too.
|
|
266
|
+
const found = new Set(out.map((d) => d.person.id));
|
|
267
|
+
for (const d of [...out])
|
|
268
|
+
for (const f of familiesAsPartner(tree, d.person.id))
|
|
269
|
+
for (const pid of f.partners.filter((x) => x !== d.person.id && inSet.has(x) && !found.has(x))) {
|
|
270
|
+
const p = tree.get(pid);
|
|
271
|
+
for (const g of familiesAsPartner(tree, d.same.id))
|
|
272
|
+
for (const oid of g.partners.filter((x) => x !== d.same.id)) {
|
|
273
|
+
const o = tree.get(oid);
|
|
274
|
+
if (!o || o.retracted || (p.sex !== "U" && o.sex !== "U" && p.sex !== o.sex))
|
|
275
|
+
continue;
|
|
276
|
+
if (!p.names.some((n) => o.names.some((m) => first(n) && first(n) === first(m))))
|
|
277
|
+
continue;
|
|
278
|
+
out.push({ person: p, same: o });
|
|
279
|
+
found.add(pid);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return out;
|
|
283
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Texts strom itself writes into a research (the tasks it proposes: frontier,
|
|
2
|
+
// intake) are in the research language, like everything the agent writes.
|
|
3
|
+
// English is built in; a language adds assets/lang/<code>.json with the same
|
|
4
|
+
// keys (any it leaves out stay English). {name} marks a value filled in.
|
|
5
|
+
import { readAsset } from "./assets.js";
|
|
6
|
+
export const PHRASES = {
|
|
7
|
+
"estimate.marriage": "their marriage {year}",
|
|
8
|
+
"estimate.child": "the eldest child known, {year}",
|
|
9
|
+
"reason.unknown": "parents unknown",
|
|
10
|
+
"reason.unproven": "parents not proven by a record",
|
|
11
|
+
"born.about": " (born ~{year}, estimated from {from})",
|
|
12
|
+
"the.birthplace": "the birthplace",
|
|
13
|
+
"whole.book": "the whole book",
|
|
14
|
+
"link.what": "Baptism of {name}{place}{about}",
|
|
15
|
+
"link.place": ", {place}",
|
|
16
|
+
"link.why": "{reason}; the baptism entry names both parents (generation {generation} of {research})",
|
|
17
|
+
"link.done": "entry found and recorded with its parents — or {years} searched without it",
|
|
18
|
+
"locate.what": "Where are the baptisms of {place}{around}? (for {name}{about})",
|
|
19
|
+
"locate.around": " around {year}",
|
|
20
|
+
"locate.where": "jurisdiction of {place}",
|
|
21
|
+
"locate.where.unknown": "birthplace unknown — find it first",
|
|
22
|
+
"locate.years": ", years {from}–{to}",
|
|
23
|
+
"locate.why": "{reason}; no record set is known for the place and time",
|
|
24
|
+
"locate.done": "a record set with access (URL or call number) is recorded and a link task points at it",
|
|
25
|
+
"request.what": "Ask for the records of {name}: {tasks} found nothing",
|
|
26
|
+
"request.where": "the archive or parish holding the records of {place}, or the family",
|
|
27
|
+
"request.why": "{reason}; the record sets and archives known so far are exhausted",
|
|
28
|
+
"request.done": "a request is sent (strom task wait … --on \"<reply>\") or the user decides the line ends here",
|
|
29
|
+
"story.what": "Write the story of {name}",
|
|
30
|
+
"story.more.what": "Add to the story of {name}: {count} new facts",
|
|
31
|
+
"story.where": "the facts recorded (strom person show {id})",
|
|
32
|
+
"story.why": "{count} facts from records tell the life (generation {generation} of {research}); the family reads it in the Strom app's family book",
|
|
33
|
+
"story.done": "strom story set {id} with every fact it rests on (--fact) — a draft until the user approves it",
|
|
34
|
+
"session.user": "stopped by the user",
|
|
35
|
+
"session.run": "the run stopped before the session ended (interrupted, or the computer went down)",
|
|
36
|
+
"session.chat": "the conversation ended before the session was closed",
|
|
37
|
+
"session.agent": "the agent stopped ({outcome}) without closing the session",
|
|
38
|
+
"intake.files": "Analyse {count} files from {folder} ({first}–{last})",
|
|
39
|
+
"intake.the.material": "the material",
|
|
40
|
+
"intake.tree.imported": "Review imported tree {id} ({name})",
|
|
41
|
+
"intake.tree.read": "Read the family tree {id} ({name}), not imported",
|
|
42
|
+
"intake.text": "Process text {id}",
|
|
43
|
+
"intake.text.file": "Process text {id} ({name})",
|
|
44
|
+
"intake.other": "Analyse {id} ({name})",
|
|
45
|
+
"intake.why.imported": "{persons} persons came in as leads{matched}: they guide the research, they prove nothing.",
|
|
46
|
+
"intake.why.matched": ", {matched} matched existing persons",
|
|
47
|
+
"intake.why.read": "Registered without importing it: nothing of it is in the tree; what it says can guide the research, it proves nothing.",
|
|
48
|
+
"intake.why": "Material from the user: find out who and what it is about, and what it can prove.",
|
|
49
|
+
"intake.done.imported": "the leads are checked for obvious mistakes (duplicates, impossible dates) and the first locate/link tasks exist",
|
|
50
|
+
"intake.done.read": "what it adds to the tree is entered as leads citing it (strom source add … --kind family-tree --form authored --input {id}) — or the input is skipped with a reason (strom input skip {id} --reason …)",
|
|
51
|
+
"intake.done": "every person and fact in it is recorded (a source if it is an original document, leads otherwise) and cited — or the input is skipped with a reason (strom input skip I… --reason …)",
|
|
52
|
+
};
|
|
53
|
+
const catalogs = new Map();
|
|
54
|
+
/**
|
|
55
|
+
* The catalog of a language (assets/lang/<code>.json); empty when there is none.
|
|
56
|
+
* It holds these phrases and the texts a person reads in the guided menu (ui.*, src/cli/ui.ts).
|
|
57
|
+
*/
|
|
58
|
+
export function catalog(lang) {
|
|
59
|
+
if (!/^[a-z]{2,3}$/.test(lang) || lang === "en")
|
|
60
|
+
return {};
|
|
61
|
+
if (!catalogs.has(lang)) {
|
|
62
|
+
let parsed = {};
|
|
63
|
+
try {
|
|
64
|
+
parsed = JSON.parse(readAsset("lang", `${lang}.json`) ?? "{}");
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// a broken catalog must not stop the research: English it is
|
|
68
|
+
}
|
|
69
|
+
catalogs.set(lang, parsed);
|
|
70
|
+
}
|
|
71
|
+
return catalogs.get(lang);
|
|
72
|
+
}
|
|
73
|
+
/** A template with its {values} filled in (an unknown {name} stays as it is). */
|
|
74
|
+
export function fill(template, values) {
|
|
75
|
+
return template.replace(/\{(\w+)\}/g, (m, name) => (name in values ? String(values[name]) : m));
|
|
76
|
+
}
|
|
77
|
+
/** A text of a language's catalog, or the English one where the catalog has none. */
|
|
78
|
+
export function localized(lang, key, english, values = {}) {
|
|
79
|
+
const own = catalog(lang)[key];
|
|
80
|
+
return fill(typeof own === "string" && own ? own : english, values);
|
|
81
|
+
}
|
|
82
|
+
/** A text in the research language, its {values} filled in. */
|
|
83
|
+
export function phrase(lang, key, values = {}) {
|
|
84
|
+
return localized(lang, key, PHRASES[key], values);
|
|
85
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// The order of the task queue. A research spreads out: the nearest ancestors
|
|
2
|
+
// first, but no line takes every session — a line that just had sessions waits
|
|
3
|
+
// while the others get theirs, a task tried again and again sinks instead of
|
|
4
|
+
// being taken forever, and work that needs a download comes after what can be
|
|
5
|
+
// done now. Strategies (setting queue.strategy): balanced (the default), depth
|
|
6
|
+
// (stay on the line of the last sessions), priority (strict priority).
|
|
7
|
+
import { parentsOf } from "./people.js";
|
|
8
|
+
import { taskRecordsets } from "./frontier.js";
|
|
9
|
+
import { now } from "./tree.js";
|
|
10
|
+
import { subjectPeople } from "./records.js";
|
|
11
|
+
export const LEVEL_ORDER = { intake: 0, locate: 1, link: 2, verify: 3, enrich: 4, request: 5, narrate: 6 };
|
|
12
|
+
/** Levels that read records: without images of their record sets they can only ask the user for them. */
|
|
13
|
+
const NEEDS_IMAGES = new Set(["link", "verify"]);
|
|
14
|
+
/** How many of the last sessions count as "recent" when spreading the work over the lines. */
|
|
15
|
+
const RECENT = 4;
|
|
16
|
+
/** A generation for work about no ancestor (a sibling, a side line, a book): after the close ancestors. */
|
|
17
|
+
const OFF_LINE_GENERATION = 4;
|
|
18
|
+
/** A task that needs images none of its record sets has yet. */
|
|
19
|
+
export function lacksImages(tree, t, withImages = new Set(tree.list("media").map((m) => m.recordset))) {
|
|
20
|
+
return NEEDS_IMAGES.has(t.level) && !taskRecordsets(tree, t).sets.some((b) => withImages.has(b.id));
|
|
21
|
+
}
|
|
22
|
+
export function effectiveState(t, today) {
|
|
23
|
+
if (t.state === "parked" && t.parkedUntil && t.parkedUntil <= today)
|
|
24
|
+
return "open";
|
|
25
|
+
return t.state;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Every ancestor of the focus with its generation and its line: the focus, each
|
|
29
|
+
* parent and each grandparent are lines of their own; everyone further back
|
|
30
|
+
* belongs to the line of the grandparent they descend through.
|
|
31
|
+
*/
|
|
32
|
+
export function ancestorLines(tree, focus, max = 50) {
|
|
33
|
+
const out = new Map([[focus, { gen: 1, line: focus }]]);
|
|
34
|
+
let current = [focus];
|
|
35
|
+
for (let g = 2; g <= max && current.length; g++) {
|
|
36
|
+
const next = [];
|
|
37
|
+
for (const id of current)
|
|
38
|
+
for (const p of parentsOf(tree, id))
|
|
39
|
+
if (!out.has(p.id)) {
|
|
40
|
+
out.set(p.id, { gen: g, line: g <= 3 ? p.id : out.get(id).line });
|
|
41
|
+
next.push(p.id);
|
|
42
|
+
}
|
|
43
|
+
current = next;
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
/** The open tasks in the order to work on them, each with the reason. */
|
|
48
|
+
export function rankTasks(tree, tasks, strategy = "balanced") {
|
|
49
|
+
const today = now().slice(0, 10);
|
|
50
|
+
const open = tasks.filter((t) => ["open", "doing"].includes(effectiveState(t, today)));
|
|
51
|
+
const withImages = new Set(tree.list("media").map((m) => m.recordset));
|
|
52
|
+
const researches = tree.list("research");
|
|
53
|
+
const lines = new Map();
|
|
54
|
+
const linesOf = (researchId) => {
|
|
55
|
+
const r = researches.find((x) => x.id === researchId) ?? (researches.length === 1 ? researches[0] : undefined);
|
|
56
|
+
if (!r || r.direction !== "ancestors")
|
|
57
|
+
return undefined;
|
|
58
|
+
if (!lines.has(r.id))
|
|
59
|
+
lines.set(r.id, ancestorLines(tree, r.focus));
|
|
60
|
+
return lines.get(r.id);
|
|
61
|
+
};
|
|
62
|
+
/** The nearest ancestor a task is about: its generation and line. */
|
|
63
|
+
const place = (t) => {
|
|
64
|
+
const map = linesOf(t.research);
|
|
65
|
+
const found = [...t.subject, ...subjectPeople(tree, t.subject)].map((s) => map?.get(s)).filter((x) => !!x);
|
|
66
|
+
return found.sort((a, b) => a.gen - b.gen)[0];
|
|
67
|
+
};
|
|
68
|
+
const tasksById = new Map(tree.list("task").map((t) => [t.id, t]));
|
|
69
|
+
const ended = tree
|
|
70
|
+
.list("session")
|
|
71
|
+
.filter((s) => s.state !== "open" && s.task)
|
|
72
|
+
.sort((a, b) => (b.ended ?? b.updated).localeCompare(a.ended ?? a.updated));
|
|
73
|
+
const recentLines = ended.slice(0, RECENT).map((s) => {
|
|
74
|
+
const t = tasksById.get(s.task);
|
|
75
|
+
return t ? (place(t)?.line ?? `task:${t.id}`) : undefined;
|
|
76
|
+
});
|
|
77
|
+
const tries = new Map();
|
|
78
|
+
for (const s of ended)
|
|
79
|
+
tries.set(s.task, (tries.get(s.task) ?? 0) + 1);
|
|
80
|
+
const rows = open.map((t) => {
|
|
81
|
+
const at = place(t);
|
|
82
|
+
const gen = at?.gen ?? OFF_LINE_GENERATION;
|
|
83
|
+
const line = at?.line ?? `task:${t.id}`;
|
|
84
|
+
const recent = recentLines.filter((l) => l === line).length;
|
|
85
|
+
const tried = tries.get(t.id) ?? 0;
|
|
86
|
+
const intake = t.level === "intake" ? 3 : 0;
|
|
87
|
+
const score = strategy === "depth"
|
|
88
|
+
? 2 * t.priority + intake + 2 * recent - 0.5 * (gen - 1) - 1.5 * tried
|
|
89
|
+
: strategy === "priority"
|
|
90
|
+
? 0
|
|
91
|
+
: 2 * t.priority + intake - (gen - 1) - 2 * recent - 1.5 * tried;
|
|
92
|
+
const blocked = lacksImages(tree, t, withImages);
|
|
93
|
+
const why = [
|
|
94
|
+
t.state === "doing" ? "in progress" : undefined,
|
|
95
|
+
blocked ? "needs images the user has to download" : undefined,
|
|
96
|
+
`p${t.priority}`,
|
|
97
|
+
t.level === "intake" ? "the user's material" : at ? `generation ${gen}` : "not about an ancestor",
|
|
98
|
+
recent ? `its line had ${recent} of the last ${RECENT} sessions` : undefined,
|
|
99
|
+
tried ? `tried ${tried}×` : undefined,
|
|
100
|
+
]
|
|
101
|
+
.filter(Boolean)
|
|
102
|
+
.join(" · ");
|
|
103
|
+
return { task: t, why, doing: t.state === "doing" ? 0 : 1, blocked: blocked ? 1 : 0, score, gen };
|
|
104
|
+
});
|
|
105
|
+
return rows
|
|
106
|
+
.sort((a, b) => a.doing - b.doing ||
|
|
107
|
+
// work that can be done now comes before work that waits for the user's download
|
|
108
|
+
a.blocked - b.blocked ||
|
|
109
|
+
(strategy === "priority" ? b.task.priority - a.task.priority || a.gen - b.gen : b.score - a.score) ||
|
|
110
|
+
(LEVEL_ORDER[a.task.level] ?? 9) - (LEVEL_ORDER[b.task.level] ?? 9) ||
|
|
111
|
+
a.task.created.localeCompare(b.task.created))
|
|
112
|
+
.map(({ task, why }) => ({ task, why }));
|
|
113
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Readers: an agent with a clean context that looks at a batch of images and
|
|
2
|
+
// writes down what is on them — and nothing else. The main researcher gets
|
|
3
|
+
// back a short report instead of a context full of images.
|
|
4
|
+
//
|
|
5
|
+
// From measurements in earlier research: an image stays in the context of whoever
|
|
6
|
+
// opened it and is paid for on every later turn, so the cost of a reader grows
|
|
7
|
+
// with the square of its batch; ten images per reader cost a third per image
|
|
8
|
+
// of fifty. Readers never write to the research; the researcher records what
|
|
9
|
+
// they found (and what they did not: a search "by reader").
|
|
10
|
+
export const BATCH = 10;
|
|
11
|
+
export const BATCH_MAX = 12;
|
|
12
|
+
/** Split into batches of at most `size` images (never more than BATCH_MAX). */
|
|
13
|
+
export function batches(items, size = BATCH) {
|
|
14
|
+
const n = Math.max(1, Math.min(BATCH_MAX, size));
|
|
15
|
+
const out = [];
|
|
16
|
+
for (let i = 0; i < items.length; i += n)
|
|
17
|
+
out.push(items.slice(i, i + n));
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
export function readerPrompt(o) {
|
|
21
|
+
return `You are a READER for a genealogical research. You are not the researcher: you do not decide
|
|
22
|
+
anything and you do not write anywhere except your report file. Do not run any commands.
|
|
23
|
+
|
|
24
|
+
THE QUESTION
|
|
25
|
+
${o.question}
|
|
26
|
+
${o.context ? `\nWhat the researcher knows (to recognise the family — never to fill in what you cannot read):\n${o.context}\n` : ""}${o.blind ? "\nThis is a BLIND reading: transcribe what is written, exactly; do not interpret or complete names from expectations.\n" : ""}
|
|
27
|
+
THE IMAGES (open each file with your image reader, one at a time, in this order)
|
|
28
|
+
${o.images
|
|
29
|
+
.map((i) => {
|
|
30
|
+
const head = `- ${i.id}${i.image !== undefined ? ` · image ${i.image}` : ""}${i.page ? ` · page ${i.page}` : ""}`;
|
|
31
|
+
return i.parts?.length
|
|
32
|
+
? `${head} — ${i.parts.length} overlapping parts at full resolution, read them together:\n${i.parts.map((p) => ` ${p.label}: ${p.view}`).join("\n")}`
|
|
33
|
+
: `${head}: ${i.view}`;
|
|
34
|
+
})
|
|
35
|
+
.join("\n")}
|
|
36
|
+
|
|
37
|
+
THE REPORT
|
|
38
|
+
Write your report to ${o.report} AS YOU GO — after every image, not at the end.
|
|
39
|
+
For every image, this block (in ${o.lang === "en" ? "English" : `the research language (${o.lang})`}; transcriptions in the language of the record;
|
|
40
|
+
the words "Image", "result", "found", "nothing", "unclear" and "done" stay in English — strom reads them):
|
|
41
|
+
|
|
42
|
+
## Image <image number> · <M… id>
|
|
43
|
+
result: found | nothing | unclear
|
|
44
|
+
entries: every entry that answers the question — word for word as written (the original
|
|
45
|
+
language and spelling), with the column it stands in: date, house, names, parents,
|
|
46
|
+
godparents/witnesses, remarks; one entry per line
|
|
47
|
+
illegible: what you could not read, and where
|
|
48
|
+
hand: the writing (ink, script, language, abbreviations)
|
|
49
|
+
certainty: for each name in an entry, how sure you are (%)
|
|
50
|
+
|
|
51
|
+
"nothing" is a valid and valuable result: say what you checked. "Illegible" beats a guess.
|
|
52
|
+
When all images are done, finish with one line: "done: <n> images, found on <image numbers or none>".`;
|
|
53
|
+
}
|
|
54
|
+
/** The result of each image in a report ("## Image 114 · M0012" … "result: found"). */
|
|
55
|
+
export function parseReport(text) {
|
|
56
|
+
const out = [];
|
|
57
|
+
const blocks = text.split(/^##\s+/m).slice(1);
|
|
58
|
+
for (const b of blocks) {
|
|
59
|
+
const head = b.split("\n")[0] ?? "";
|
|
60
|
+
const id = /\b(M\d{4,})\b/.exec(head)?.[1] ?? /Image\s+(\d+)/i.exec(head)?.[1];
|
|
61
|
+
if (!id)
|
|
62
|
+
continue;
|
|
63
|
+
const r = /^result:\s*(\p{L}+)/imu.exec(b)?.[1]?.toLowerCase();
|
|
64
|
+
out.push({ image: id, result: r === "found" ? "found" : r === "nothing" ? "nothing" : "unclear" });
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
/** Claude Code settings for a reader: it may read these views and write its report — nothing else. */
|
|
69
|
+
export function readerSettings(views, report, toPermission) {
|
|
70
|
+
return {
|
|
71
|
+
permissions: {
|
|
72
|
+
allow: [...views.map((v) => `Read(${toPermission(v)})`), `Write(${toPermission(report)})`, `Edit(${toPermission(report)})`],
|
|
73
|
+
deny: ["Bash", "WebFetch", "WebSearch"],
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Generic create / update for every record type, so commands stay small.
|
|
2
|
+
// All writes still go through Tree.put (validation + seal).
|
|
3
|
+
import { UsageError } from "./errors.js";
|
|
4
|
+
import { RECORD_TYPES } from "./model.js";
|
|
5
|
+
import { now, typeOfId } from "./tree.js";
|
|
6
|
+
import { makeNote } from "./actions.js";
|
|
7
|
+
/** Create a record of `type` with a fresh ID. */
|
|
8
|
+
export function create(tree, type, fields, summary, targets = []) {
|
|
9
|
+
return tree.withTreeLock(() => {
|
|
10
|
+
const t = now();
|
|
11
|
+
const { note, ...rest } = fields;
|
|
12
|
+
const rec = {
|
|
13
|
+
id: tree.allocate(RECORD_TYPES[type].prefix),
|
|
14
|
+
type,
|
|
15
|
+
...stripUndefined(rest),
|
|
16
|
+
notes: note ? [makeNote(tree, note)] : [],
|
|
17
|
+
created: t,
|
|
18
|
+
updated: t,
|
|
19
|
+
};
|
|
20
|
+
tree.put(rec, { op: `${type}.add`, targets: [rec.id, ...targets], summary: summary(rec.id) });
|
|
21
|
+
return rec;
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
/** Load, change and write a record. `reason` is required when facts change meaning. */
|
|
25
|
+
export function update(tree, id, expect, change, op) {
|
|
26
|
+
if (typeOfId(id) !== expect)
|
|
27
|
+
throw new UsageError(`${id} is not a ${expect}`);
|
|
28
|
+
return tree.withTreeLock(() => {
|
|
29
|
+
const cur = tree.get(id);
|
|
30
|
+
if (!cur)
|
|
31
|
+
throw new UsageError(`no ${expect} ${id}`, { hint: `strom ${expect === "repository" ? "repo" : expect} list` });
|
|
32
|
+
const next = { ...change(structuredClone(cur)), updated: now() };
|
|
33
|
+
tree.put(next, {
|
|
34
|
+
op: op.op,
|
|
35
|
+
targets: [id, ...(op.targets ?? [])],
|
|
36
|
+
summary: op.summary,
|
|
37
|
+
...(op.reason ? { reason: op.reason } : {}),
|
|
38
|
+
});
|
|
39
|
+
return next;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
export function stripUndefined(o) {
|
|
43
|
+
const out = {};
|
|
44
|
+
for (const [k, v] of Object.entries(o)) {
|
|
45
|
+
if (v === undefined)
|
|
46
|
+
continue;
|
|
47
|
+
if (Array.isArray(v) || v === null || typeof v !== "object")
|
|
48
|
+
out[k] = v;
|
|
49
|
+
else
|
|
50
|
+
out[k] = stripUndefined(v);
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
/** Normalize an ID the user typed: "s12" -> "S0012". */
|
|
55
|
+
export function normId(ref, type) {
|
|
56
|
+
const m = /^([A-Za-z])(\d+)$/.exec(ref.trim());
|
|
57
|
+
if (!m)
|
|
58
|
+
return ref.trim();
|
|
59
|
+
const id = m[1].toUpperCase() + m[2].padStart(4, "0");
|
|
60
|
+
if (type && typeOfId(id) !== type)
|
|
61
|
+
throw new UsageError(`${ref} is not a ${type} ID (${RECORD_TYPES[type].prefix}0001)`);
|
|
62
|
+
return id;
|
|
63
|
+
}
|
|
64
|
+
export function requireRecord(tree, ref, type) {
|
|
65
|
+
const id = normId(ref, type);
|
|
66
|
+
const rec = tree.get(id);
|
|
67
|
+
if (!rec)
|
|
68
|
+
throw new UsageError(`no ${type} ${id}`, { hint: `strom ${type === "repository" ? "repo" : type} list` });
|
|
69
|
+
if (rec.mergedInto)
|
|
70
|
+
throw new UsageError(`${id} was merged into ${rec.mergedInto}`, { hint: `strom ${type === "repository" ? "repo" : type} show ${rec.mergedInto}` });
|
|
71
|
+
return rec;
|
|
72
|
+
}
|
|
73
|
+
/** Repeatable option → array. Values are kept whole: free text may contain commas. */
|
|
74
|
+
export function listOpt(v) {
|
|
75
|
+
if (v === undefined || v === false)
|
|
76
|
+
return [];
|
|
77
|
+
const arr = Array.isArray(v) ? v : [v];
|
|
78
|
+
return arr.map((x) => String(x).trim()).filter(Boolean);
|
|
79
|
+
}
|
|
80
|
+
/** Repeatable option whose values may also be comma separated: IDs, kinds, place lists. */
|
|
81
|
+
export function csvOpt(v) {
|
|
82
|
+
return listOpt(v).flatMap((x) => x.split(",")).map((x) => x.trim()).filter(Boolean);
|
|
83
|
+
}
|
|
84
|
+
/** Read "@file" as the file's text, otherwise the value itself. */
|
|
85
|
+
export function textOpt(v, readFile) {
|
|
86
|
+
if (typeof v !== "string")
|
|
87
|
+
return undefined;
|
|
88
|
+
if (v.startsWith("@"))
|
|
89
|
+
return readFile(v.slice(1));
|
|
90
|
+
return v;
|
|
91
|
+
}
|
|
92
|
+
/** The people a list of subjects names: the persons in it, and the people of the conflicts and hypotheses in it. */
|
|
93
|
+
export function subjectPeople(tree, ids) {
|
|
94
|
+
const out = new Set();
|
|
95
|
+
for (const id of ids) {
|
|
96
|
+
const type = typeOfId(id);
|
|
97
|
+
if (type === "person")
|
|
98
|
+
out.add(id);
|
|
99
|
+
else if (type === "conflict" || type === "hypothesis")
|
|
100
|
+
for (const s of tree.get(id)?.subject ?? [])
|
|
101
|
+
if (typeOfId(s) === "person")
|
|
102
|
+
out.add(s);
|
|
103
|
+
}
|
|
104
|
+
return [...out];
|
|
105
|
+
}
|