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,622 @@
|
|
|
1
|
+
// search · searched · conflict · hypothesis · lesson · find · gaps
|
|
2
|
+
import { register } from "../cli/registry.js";
|
|
3
|
+
import { lines, moreLine, paginate, table, truncate } from "../cli/format.js";
|
|
4
|
+
import { UsageError } from "../core/errors.js";
|
|
5
|
+
import { LESSON_MAX, LESSON_SCOPES, RECORD_TYPES, SEARCH_METHODS, SEARCH_RESULTS, } from "../core/model.js";
|
|
6
|
+
import { create, csvOpt, listOpt, normId, requireRecord, update } from "../core/records.js";
|
|
7
|
+
import { ancestorGenerations, birthEvent, deathEvent, displayName, familiesAsPartner, lifespan, parentsOf, primaryName, resolvePerson } from "../core/people.js";
|
|
8
|
+
import { foldText } from "../core/text.js";
|
|
9
|
+
import { makeNote } from "../core/actions.js";
|
|
10
|
+
import { currentSession } from "../core/session.js";
|
|
11
|
+
import { typeOfId } from "../core/tree.js";
|
|
12
|
+
import { resolveResearch } from "./research.js";
|
|
13
|
+
function written(tree) {
|
|
14
|
+
return lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined);
|
|
15
|
+
}
|
|
16
|
+
function oneOf(v, allowed, name) {
|
|
17
|
+
if (!allowed.includes(v))
|
|
18
|
+
throw new UsageError(`${v === undefined ? "missing" : "invalid"} --${name}${v === undefined ? "" : ` "${String(v)}"`}`, { hint: allowed.join(", ") });
|
|
19
|
+
return v;
|
|
20
|
+
}
|
|
21
|
+
function anyRefs(tree, refs) {
|
|
22
|
+
return refs.map((r) => {
|
|
23
|
+
const id = normId(r);
|
|
24
|
+
if (typeOfId(id)) {
|
|
25
|
+
if (!tree.get(id))
|
|
26
|
+
throw new UsageError(`no record ${id}`);
|
|
27
|
+
return id;
|
|
28
|
+
}
|
|
29
|
+
return resolvePerson(tree, r).id;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
/** The tasks that work on a conflict or hypothesis (--about X…/H…). */
|
|
33
|
+
function tasksAbout(tree, id) {
|
|
34
|
+
return tree.list("task").filter((t) => t.subject.includes(id));
|
|
35
|
+
}
|
|
36
|
+
function taskLines(tree, id) {
|
|
37
|
+
const tasks = tasksAbout(tree, id);
|
|
38
|
+
return tasks.length ? `tasks ${tasks.map((t) => `${t.id}${t.state === "open" ? "" : ` [${t.state}]`}`).join(" ")}` : undefined;
|
|
39
|
+
}
|
|
40
|
+
/** After a conflict or hypothesis is settled: the tasks still open on it. */
|
|
41
|
+
function stillOpen(tree, id) {
|
|
42
|
+
const open = tasksAbout(tree, id).filter((t) => !["done", "dropped"].includes(t.state));
|
|
43
|
+
return open.length ? `still open about ${id}: ${open.map((t) => t.id).join(", ")} — close them: strom task done ${open[0].id} --result "…"` : undefined;
|
|
44
|
+
}
|
|
45
|
+
// ── searches ───────────────────────────────────────────────────────────────
|
|
46
|
+
function yearsRange(y) {
|
|
47
|
+
const m = y ? /^(\d{3,4})(?:-(\d{3,4}))?$/.exec(y) : null;
|
|
48
|
+
return m ? [Number(m[1]), Number(m[2] ?? m[1])] : undefined;
|
|
49
|
+
}
|
|
50
|
+
function overlaps(a, b) {
|
|
51
|
+
const x = yearsRange(a);
|
|
52
|
+
const y = yearsRange(b);
|
|
53
|
+
if (!x || !y)
|
|
54
|
+
return true;
|
|
55
|
+
return x[0] <= y[1] && y[0] <= x[1];
|
|
56
|
+
}
|
|
57
|
+
function searchLine(s) {
|
|
58
|
+
return [s.id, s.result, s.method, truncate(s.question, 60), s.scope.years ?? "", (s.scope.surnames ?? []).join(","), s.recordsets.join(" "), s.by === "main" ? "" : `by ${s.by}`];
|
|
59
|
+
}
|
|
60
|
+
register({
|
|
61
|
+
path: ["search", "add"],
|
|
62
|
+
summary: "Record a search — ALSO when nothing was found (negative results are results)",
|
|
63
|
+
group: "analysis",
|
|
64
|
+
tree: true,
|
|
65
|
+
writes: true,
|
|
66
|
+
args: [{ name: "question", description: 'what was looked for: "Baptisms Novák 1903–1907"', required: true }],
|
|
67
|
+
options: [
|
|
68
|
+
{ name: "recordset", type: "string", multiple: true, value: "<B…>", description: "where (repeatable)" },
|
|
69
|
+
{ name: "years", type: "string", value: "<from-to>", description: "years covered" },
|
|
70
|
+
{ name: "surname", type: "string", multiple: true, value: "<name>", description: "surnames looked for (repeatable)" },
|
|
71
|
+
{ name: "place", type: "string", multiple: true, value: "<name>", description: "places covered (repeatable)" },
|
|
72
|
+
{ name: "pages", type: "string", value: "<range>", description: "images/pages covered, e.g. 95-100" },
|
|
73
|
+
{ name: "method", type: "string", value: "<m>", description: SEARCH_METHODS.join(", ") },
|
|
74
|
+
{ name: "result", type: "string", value: "<r>", description: SEARCH_RESULTS.join(", ") },
|
|
75
|
+
{ name: "found", type: "string", multiple: true, value: "<S…>", description: "sources found (repeatable)" },
|
|
76
|
+
{ name: "task", type: "string", value: "<T…>", description: "task this search served (default: the task of the open session)" },
|
|
77
|
+
{ name: "by", type: "string", value: "<who>", description: "main (default), reader, user" },
|
|
78
|
+
{ name: "note", type: "string", value: "<text>", description: "what was illegible, what is left" },
|
|
79
|
+
],
|
|
80
|
+
examples: ['strom search add "Křty Novák 1903–1907" --recordset B0001 --years 1903-1907 --surname Novák --method page-by-page --result negative --task T0001'],
|
|
81
|
+
run(ctx, { args, opts }) {
|
|
82
|
+
const tree = ctx.tree();
|
|
83
|
+
const recordsets = csvOpt(opts.recordset).map((b) => requireRecord(tree, b, "recordset").id);
|
|
84
|
+
const years = opts.years ? String(opts.years) : undefined;
|
|
85
|
+
if (years && !yearsRange(years))
|
|
86
|
+
throw new UsageError(`invalid --years "${years}"`, { hint: "1903 or 1903-1907" });
|
|
87
|
+
const findings = csvOpt(opts.found).map((s) => requireRecord(tree, s, "source").id);
|
|
88
|
+
const result = oneOf(opts.result, SEARCH_RESULTS, "result");
|
|
89
|
+
if (result === "found" && findings.length === 0)
|
|
90
|
+
throw new UsageError("result found needs --found <S…>", { hint: "create the source first: strom source add …" });
|
|
91
|
+
// A search made in a session served that session's task, unless it says otherwise.
|
|
92
|
+
const task = opts.task ? requireRecord(tree, String(opts.task), "task").id : currentSession(tree, ctx.env)?.task;
|
|
93
|
+
const s = create(tree, "search", {
|
|
94
|
+
question: args[0].trim(),
|
|
95
|
+
recordsets,
|
|
96
|
+
scope: {
|
|
97
|
+
...(years ? { years } : {}),
|
|
98
|
+
...(csvOpt(opts.surname).length ? { surnames: csvOpt(opts.surname) } : {}),
|
|
99
|
+
...(listOpt(opts.place).length ? { places: listOpt(opts.place) } : {}),
|
|
100
|
+
...(opts.pages ? { pages: String(opts.pages) } : {}),
|
|
101
|
+
},
|
|
102
|
+
method: oneOf(opts.method, SEARCH_METHODS, "method"),
|
|
103
|
+
result,
|
|
104
|
+
findings,
|
|
105
|
+
task,
|
|
106
|
+
by: opts.by === undefined ? "main" : oneOf(opts.by, ["main", "reader", "user"], "by"),
|
|
107
|
+
note: opts.note,
|
|
108
|
+
}, (id) => `+${id} search [${result}] "${truncate(args[0], 50)}"`, [...recordsets, ...findings]);
|
|
109
|
+
return { text: written(tree), data: { search: s } };
|
|
110
|
+
},
|
|
111
|
+
}, {
|
|
112
|
+
path: ["search", "list"],
|
|
113
|
+
summary: "Searches done so far (newest first)",
|
|
114
|
+
group: "analysis",
|
|
115
|
+
tree: true,
|
|
116
|
+
options: [{ name: "result", type: "string", value: "<r>", description: "only this result" }, { name: "full", type: "boolean", description: "--json: whole records instead of one row each" }],
|
|
117
|
+
run(ctx, { opts }) {
|
|
118
|
+
const tree = ctx.tree();
|
|
119
|
+
const all = tree
|
|
120
|
+
.list("search")
|
|
121
|
+
.filter((s) => !opts.result || s.result === opts.result)
|
|
122
|
+
.reverse();
|
|
123
|
+
const page = paginate(all, ctx.limit, ctx.page);
|
|
124
|
+
const rows = opts.full ? page.items : page.items.map((s) => ({ id: s.id, result: s.result, method: s.method, question: s.question, years: s.scope.years, recordsets: s.recordsets }));
|
|
125
|
+
return { text: all.length ? lines(table(page.items.map(searchLine)), moreLine(page, "strom search list")) : "no searches yet", data: { total: page.total, searches: rows } };
|
|
126
|
+
},
|
|
127
|
+
}, {
|
|
128
|
+
path: ["search", "show"],
|
|
129
|
+
summary: "One search in full",
|
|
130
|
+
group: "analysis",
|
|
131
|
+
tree: true,
|
|
132
|
+
args: [{ name: "search", description: "search ID (Q0001)", required: true }],
|
|
133
|
+
run(ctx, { args }) {
|
|
134
|
+
const s = requireRecord(ctx.tree(), args[0], "search");
|
|
135
|
+
const text = lines(`${s.id} ${s.question}`, `result ${s.result} · method ${s.method} · by ${s.by}${s.task ? ` · task ${s.task}` : ""}`, `where ${s.recordsets.join(" ") || "(no record set)"}${s.scope.pages ? ` · pages ${s.scope.pages}` : ""}`, `scope ${[s.scope.years, (s.scope.surnames ?? []).join(", "), (s.scope.places ?? []).join(", ")].filter(Boolean).join(" · ") || "(unspecified)"}`, s.findings.length ? `found ${s.findings.join(" ")}` : undefined, ...s.notes.map((n) => `note ${n.text}`));
|
|
136
|
+
return { text, data: { search: s } };
|
|
137
|
+
},
|
|
138
|
+
}, {
|
|
139
|
+
path: ["search", "edit"],
|
|
140
|
+
summary: "Correct a search: where it looked (record sets, years, pages), what it found, its result",
|
|
141
|
+
group: "analysis",
|
|
142
|
+
tree: true,
|
|
143
|
+
writes: true,
|
|
144
|
+
description: "Only the options given change; lists (--recordset, --surname, --place, --found) replace the old ones; --note adds a note.\n" +
|
|
145
|
+
"A search is evidence — a negative one above all: changing where it looked, how, or what came out needs --reason;\n" +
|
|
146
|
+
"filling in what was not recorded doesn't.",
|
|
147
|
+
args: [{ name: "search", description: "search ID (Q0001)", required: true }],
|
|
148
|
+
options: [
|
|
149
|
+
{ name: "question", type: "string", value: "<text>", description: "what was looked for" },
|
|
150
|
+
{ name: "recordset", type: "string", multiple: true, value: "<B…>", description: "where (repeatable; replaces)" },
|
|
151
|
+
{ name: "no-recordset", type: "boolean", description: "it was not in a record set (a catalog, the web): take them off" },
|
|
152
|
+
{ name: "years", type: "string", value: "<from-to>", description: "years covered" },
|
|
153
|
+
{ name: "surname", type: "string", multiple: true, value: "<name>", description: "surnames looked for (repeatable; replaces)" },
|
|
154
|
+
{ name: "place", type: "string", multiple: true, value: "<name>", description: "places covered (repeatable; replaces)" },
|
|
155
|
+
{ name: "pages", type: "string", value: "<range>", description: "images/pages covered" },
|
|
156
|
+
{ name: "method", type: "string", value: "<m>", description: SEARCH_METHODS.join(", ") },
|
|
157
|
+
{ name: "result", type: "string", value: "<r>", description: SEARCH_RESULTS.join(", ") },
|
|
158
|
+
{ name: "found", type: "string", multiple: true, value: "<S…>", description: "sources found (repeatable; replaces)" },
|
|
159
|
+
{ name: "task", type: "string", value: "<T…>", description: "task this search served" },
|
|
160
|
+
{ name: "note", type: "string", value: "<text>", description: "add a note" },
|
|
161
|
+
],
|
|
162
|
+
examples: ['strom search edit Q0001 --recordset B0002 --reason "the index belongs to the other volume"', 'strom search edit Q0001 --pages 95-100 --note "images 101-104 unreadable"'],
|
|
163
|
+
run(ctx, { args, opts }) {
|
|
164
|
+
const tree = ctx.tree();
|
|
165
|
+
const id = requireRecord(tree, args[0], "search").id;
|
|
166
|
+
if (opts["no-recordset"] && csvOpt(opts.recordset).length)
|
|
167
|
+
throw new UsageError("--recordset or --no-recordset, not both");
|
|
168
|
+
const years = opts.years === undefined ? undefined : String(opts.years);
|
|
169
|
+
if (years !== undefined && !yearsRange(years))
|
|
170
|
+
throw new UsageError(`invalid --years "${years}"`, { hint: "1903 or 1903-1907" });
|
|
171
|
+
const scope = {
|
|
172
|
+
...(years ? { years } : {}),
|
|
173
|
+
...(csvOpt(opts.surname).length ? { surnames: csvOpt(opts.surname) } : {}),
|
|
174
|
+
...(listOpt(opts.place).length ? { places: listOpt(opts.place) } : {}),
|
|
175
|
+
...(opts.pages ? { pages: String(opts.pages) } : {}),
|
|
176
|
+
};
|
|
177
|
+
const change = {
|
|
178
|
+
...(typeof opts.question === "string" && opts.question.trim() ? { question: opts.question.trim() } : {}),
|
|
179
|
+
...(csvOpt(opts.recordset).length ? { recordsets: csvOpt(opts.recordset).map((b) => requireRecord(tree, b, "recordset").id) } : {}),
|
|
180
|
+
...(opts["no-recordset"] ? { recordsets: [] } : {}),
|
|
181
|
+
...(opts.method !== undefined ? { method: oneOf(opts.method, SEARCH_METHODS, "method") } : {}),
|
|
182
|
+
...(opts.result !== undefined ? { result: oneOf(opts.result, SEARCH_RESULTS, "result") } : {}),
|
|
183
|
+
...(csvOpt(opts.found).length ? { findings: csvOpt(opts.found).map((s) => requireRecord(tree, s, "source").id) } : {}),
|
|
184
|
+
...(opts.task ? { task: requireRecord(tree, String(opts.task), "task").id } : {}),
|
|
185
|
+
};
|
|
186
|
+
const note = typeof opts.note === "string" && opts.note.trim() ? makeNote(tree, opts.note) : undefined;
|
|
187
|
+
const fields = [...Object.keys(change), ...Object.keys(scope), ...(note ? ["note"] : [])];
|
|
188
|
+
if (!fields.length)
|
|
189
|
+
throw new UsageError("nothing to change", { hint: `strom help search edit` });
|
|
190
|
+
const reason = typeof opts.reason === "string" && opts.reason.trim() ? opts.reason.trim() : undefined;
|
|
191
|
+
const s = update(tree, id, "search", (cur) => {
|
|
192
|
+
// What was recorded as searched is evidence: overwriting it needs a reason, filling it in doesn't.
|
|
193
|
+
const was = { recordsets: cur.recordsets, method: cur.method, result: cur.result, findings: cur.findings, ...cur.scope };
|
|
194
|
+
const now = { ...change, ...scope };
|
|
195
|
+
const overwritten = ["recordsets", "method", "result", "findings", "years", "surnames", "places", "pages"].filter((k) => {
|
|
196
|
+
const old = was[k];
|
|
197
|
+
const empty = old === undefined || (Array.isArray(old) && old.length === 0);
|
|
198
|
+
return k in now && !empty && JSON.stringify(old) !== JSON.stringify(now[k]);
|
|
199
|
+
});
|
|
200
|
+
if (overwritten.length && !reason)
|
|
201
|
+
throw new UsageError(`changing ${overwritten.join(", ")} of ${id} needs --reason`, { hint: 'a search is evidence: e.g. --reason "the index belongs to the other volume"' });
|
|
202
|
+
const next = { ...cur, ...change, scope: { ...cur.scope, ...scope }, ...(note ? { notes: [...cur.notes, note] } : {}) };
|
|
203
|
+
if (next.result === "found" && next.findings.length === 0)
|
|
204
|
+
throw new UsageError("result found needs --found <S…>", { hint: "create the source first: strom source add …" });
|
|
205
|
+
return next;
|
|
206
|
+
}, { op: "search.edit", summary: `${id} ${fields.join(", ")}`, reason, targets: [...(change.recordsets ?? []), ...(change.findings ?? [])] });
|
|
207
|
+
return { text: written(tree), data: { search: s } };
|
|
208
|
+
},
|
|
209
|
+
}, {
|
|
210
|
+
path: ["searched"],
|
|
211
|
+
summary: "Has anyone looked there already? (by record set, person, place or surname)",
|
|
212
|
+
group: "analysis",
|
|
213
|
+
tree: true,
|
|
214
|
+
description: "Check this BEFORE searching: repeating a finished search is the most common waste.",
|
|
215
|
+
args: [{ name: "what", description: "B0001, P0001, a place name or a surname", required: true }],
|
|
216
|
+
options: [{ name: "years", type: "string", value: "<from-to>", description: "only searches overlapping these years" }],
|
|
217
|
+
examples: ["strom searched B0001 --years 1903-1907", "strom searched Novák", "strom searched P0001"],
|
|
218
|
+
run(ctx, { args, opts }) {
|
|
219
|
+
const tree = ctx.tree();
|
|
220
|
+
const q = args[0].trim();
|
|
221
|
+
const id = normId(q);
|
|
222
|
+
let match;
|
|
223
|
+
let what = q;
|
|
224
|
+
if (typeOfId(id) === "recordset") {
|
|
225
|
+
what = requireRecord(tree, id, "recordset").id;
|
|
226
|
+
match = (s) => s.recordsets.includes(id);
|
|
227
|
+
}
|
|
228
|
+
else if (typeOfId(id) === "person") {
|
|
229
|
+
const p = resolvePerson(tree, id);
|
|
230
|
+
const sur = foldText(primaryName(p).surname);
|
|
231
|
+
what = `${p.id} ${displayName(p)}`;
|
|
232
|
+
match = (s) => (s.scope.surnames ?? []).some((x) => foldText(x) === sur) || (!!s.task && tree.get(s.task) !== undefined && (tree.get(s.task).subject ?? []).includes(p.id));
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
const key = foldText(q);
|
|
236
|
+
match = (s) => (s.scope.surnames ?? []).some((x) => foldText(x) === key) ||
|
|
237
|
+
(s.scope.places ?? []).some((x) => foldText(x).includes(key)) ||
|
|
238
|
+
foldText(s.question).includes(key) ||
|
|
239
|
+
s.recordsets.some((b) => {
|
|
240
|
+
const r = tree.get(b);
|
|
241
|
+
return !!r && (foldText(r.title).includes(key) || r.places.some((pl) => foldText(pl).includes(key)));
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
const years = opts.years ? String(opts.years) : undefined;
|
|
245
|
+
const hits = tree.list("search").filter((s) => match(s) && overlaps(s.scope.years, years));
|
|
246
|
+
const negative = hits.filter((s) => s.result === "negative").length;
|
|
247
|
+
const text = hits.length
|
|
248
|
+
? lines(`${hits.length} search(es) for ${what}${years ? ` in ${years}` : ""} — ${negative} negative, ${hits.length - negative} with results`, table(hits.map(searchLine)))
|
|
249
|
+
: `nothing searched yet for ${what}${years ? ` in ${years}` : ""}`;
|
|
250
|
+
return { text, data: { searches: hits } };
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
// ── conflicts and hypotheses ───────────────────────────────────────────────
|
|
254
|
+
register({
|
|
255
|
+
path: ["conflict", "add"],
|
|
256
|
+
summary: "Record that sources disagree (never silently pick one)",
|
|
257
|
+
group: "analysis",
|
|
258
|
+
tree: true,
|
|
259
|
+
writes: true,
|
|
260
|
+
args: [{ name: "title", description: 'e.g. "Birth year of Antonín: 1811 or 1813?"', required: true }],
|
|
261
|
+
options: [
|
|
262
|
+
{ name: "about", type: "string", multiple: true, value: "<who>", description: "person/record concerned (repeatable)" },
|
|
263
|
+
{ name: "claim", type: "string", multiple: true, value: "<S…: value>", description: 'a claim, e.g. "S0027: aged 27 at marriage 1839" (repeat for each)' },
|
|
264
|
+
{ name: "note", type: "string", value: "<text>", description: "short note" },
|
|
265
|
+
],
|
|
266
|
+
examples: ['strom conflict add "Rok narození Josefa" --about P0002 --claim "S0001: 27 let při sňatku 1839" --claim "S0002: 70 let při úmrtí 1883"'],
|
|
267
|
+
run(ctx, { args, opts }) {
|
|
268
|
+
const tree = ctx.tree();
|
|
269
|
+
const claims = listOpt(opts.claim).map((c) => {
|
|
270
|
+
const m = /^([Ss]\d+)\s*:\s*(.+)$/.exec(c);
|
|
271
|
+
if (m)
|
|
272
|
+
return { source: requireRecord(tree, m[1], "source").id, value: m[2].trim() };
|
|
273
|
+
return { value: c.trim() };
|
|
274
|
+
});
|
|
275
|
+
if (claims.length < 2)
|
|
276
|
+
throw new UsageError("a conflict needs at least two --claim", { hint: '--claim "S0027: 27 years" --claim "S0031: 70 years"' });
|
|
277
|
+
const subject = anyRefs(tree, listOpt(opts.about));
|
|
278
|
+
if (!subject.length)
|
|
279
|
+
throw new UsageError("--about is required");
|
|
280
|
+
const x = create(tree, "conflict", { title: args[0].trim(), subject, claims, state: "open", note: opts.note }, (id) => `+${id} conflict "${truncate(args[0], 60)}"`, subject);
|
|
281
|
+
return { text: written(tree), data: { conflict: x } };
|
|
282
|
+
},
|
|
283
|
+
}, {
|
|
284
|
+
path: ["conflict", "resolve"],
|
|
285
|
+
summary: "Resolve a conflict with the reasoning",
|
|
286
|
+
group: "analysis",
|
|
287
|
+
tree: true,
|
|
288
|
+
writes: true,
|
|
289
|
+
args: [{ name: "conflict", description: "conflict ID (X0001)", required: true }],
|
|
290
|
+
options: [
|
|
291
|
+
{ name: "resolution", type: "string", value: "<text>", description: "the conclusion" },
|
|
292
|
+
{ name: "reasoning", type: "string", value: "<text>", description: "why — which evidence outweighs which" },
|
|
293
|
+
],
|
|
294
|
+
run(ctx, { args, opts }) {
|
|
295
|
+
const tree = ctx.tree();
|
|
296
|
+
if (!opts.resolution || !opts.reasoning)
|
|
297
|
+
throw new UsageError("--resolution and --reasoning are required");
|
|
298
|
+
const id = normId(args[0], "conflict");
|
|
299
|
+
const x = update(tree, id, "conflict", (c) => ({ ...c, state: "resolved", resolution: String(opts.resolution), reasoning: String(opts.reasoning) }), {
|
|
300
|
+
op: "conflict.resolve",
|
|
301
|
+
summary: `${id} resolved`,
|
|
302
|
+
});
|
|
303
|
+
return { text: lines(written(tree), stillOpen(tree, id)), data: { conflict: x } };
|
|
304
|
+
},
|
|
305
|
+
}, {
|
|
306
|
+
path: ["conflict", "list"],
|
|
307
|
+
summary: "Conflicts between sources",
|
|
308
|
+
group: "analysis",
|
|
309
|
+
tree: true,
|
|
310
|
+
options: [{ name: "all", type: "boolean", description: "include resolved" }],
|
|
311
|
+
run(ctx, { opts }) {
|
|
312
|
+
const all = ctx.tree().list("conflict").filter((c) => opts.all || c.state === "open");
|
|
313
|
+
return { text: all.length ? table(all.map((c) => [c.id, c.state, truncate(c.title, 60), c.subject.join(" ")])) : "no open conflicts", data: { conflicts: all } };
|
|
314
|
+
},
|
|
315
|
+
}, {
|
|
316
|
+
path: ["conflict", "show"],
|
|
317
|
+
summary: "One conflict with its claims",
|
|
318
|
+
group: "analysis",
|
|
319
|
+
tree: true,
|
|
320
|
+
args: [{ name: "conflict", description: "conflict ID", required: true }],
|
|
321
|
+
run(ctx, { args }) {
|
|
322
|
+
const tree = ctx.tree();
|
|
323
|
+
const c = requireRecord(tree, args[0], "conflict");
|
|
324
|
+
return {
|
|
325
|
+
text: lines(`${c.id} ${c.title} [${c.state}]`, `about ${c.subject.join(" ")}`, taskLines(tree, c.id), ...c.claims.map((cl) => `claim ${cl.source ? `${cl.source}: ` : ""}${cl.value}`), c.resolution ? `\nresolution ${c.resolution}\nreasoning ${c.reasoning ?? ""}` : undefined, ...c.notes.map((n) => `note ${n.text}`)),
|
|
326
|
+
data: { conflict: c },
|
|
327
|
+
};
|
|
328
|
+
},
|
|
329
|
+
}, {
|
|
330
|
+
path: ["hypothesis", "add"],
|
|
331
|
+
summary: "Record competing explanations (A/B/C) with support and objections",
|
|
332
|
+
group: "analysis",
|
|
333
|
+
tree: true,
|
|
334
|
+
writes: true,
|
|
335
|
+
args: [{ name: "question", description: 'e.g. "Who was the father of Marie?"', required: true }],
|
|
336
|
+
options: [
|
|
337
|
+
{ name: "about", type: "string", multiple: true, value: "<who>", description: "person/record concerned (repeatable)" },
|
|
338
|
+
{ name: "variant", type: "string", multiple: true, value: "<A: claim>", description: 'a variant, e.g. "A: Josef from No. 12" (repeat)' },
|
|
339
|
+
{ name: "note", type: "string", value: "<text>", description: "short note" },
|
|
340
|
+
],
|
|
341
|
+
run(ctx, { args, opts }) {
|
|
342
|
+
const tree = ctx.tree();
|
|
343
|
+
const variants = listOpt(opts.variant).map((v, i) => {
|
|
344
|
+
const m = /^([\p{L}\p{N}]{1,3})\s*:\s*(.+)$/u.exec(v);
|
|
345
|
+
return { label: m ? m[1] : String.fromCharCode(65 + i), claim: (m ? m[2] : v).trim(), support: [], against: [] };
|
|
346
|
+
});
|
|
347
|
+
if (variants.length < 2)
|
|
348
|
+
throw new UsageError("a hypothesis needs at least two --variant");
|
|
349
|
+
const subject = anyRefs(tree, listOpt(opts.about));
|
|
350
|
+
if (!subject.length)
|
|
351
|
+
throw new UsageError("--about is required");
|
|
352
|
+
const h = create(tree, "hypothesis", { question: args[0].trim(), subject, variants, state: "open", note: opts.note }, (id) => `+${id} hypothesis "${truncate(args[0], 60)}"`, subject);
|
|
353
|
+
return { text: written(tree), data: { hypothesis: h } };
|
|
354
|
+
},
|
|
355
|
+
}, {
|
|
356
|
+
path: ["hypothesis", "argue"],
|
|
357
|
+
summary: "Add support for or an objection to one variant",
|
|
358
|
+
group: "analysis",
|
|
359
|
+
tree: true,
|
|
360
|
+
writes: true,
|
|
361
|
+
args: [
|
|
362
|
+
{ name: "hypothesis", description: "hypothesis ID (H0001)", required: true },
|
|
363
|
+
{ name: "variant", description: "variant label, e.g. A", required: true },
|
|
364
|
+
],
|
|
365
|
+
options: [
|
|
366
|
+
{ name: "for", type: "string", value: "<text>", description: "supporting evidence (cite S… inside)" },
|
|
367
|
+
{ name: "against", type: "string", value: "<text>", description: "objection" },
|
|
368
|
+
],
|
|
369
|
+
run(ctx, { args, opts }) {
|
|
370
|
+
const tree = ctx.tree();
|
|
371
|
+
if (!opts.for && !opts.against)
|
|
372
|
+
throw new UsageError("give --for or --against");
|
|
373
|
+
const id = normId(args[0], "hypothesis");
|
|
374
|
+
const h = update(tree, id, "hypothesis", (h) => {
|
|
375
|
+
const v = h.variants.find((x) => foldText(x.label) === foldText(args[1]));
|
|
376
|
+
if (!v)
|
|
377
|
+
throw new UsageError(`no variant ${args[1]} in ${id}`, { hint: h.variants.map((x) => x.label).join(", ") });
|
|
378
|
+
if (opts.for)
|
|
379
|
+
v.support.push(String(opts.for));
|
|
380
|
+
if (opts.against)
|
|
381
|
+
v.against.push(String(opts.against));
|
|
382
|
+
return h;
|
|
383
|
+
}, { op: "hypothesis.argue", summary: `${id} ${args[1]} ${opts.for ? "+for" : "+against"}` });
|
|
384
|
+
return { text: written(tree), data: { hypothesis: h } };
|
|
385
|
+
},
|
|
386
|
+
}, {
|
|
387
|
+
path: ["hypothesis", "decide"],
|
|
388
|
+
summary: "Decide a hypothesis (or abandon it) — again, with a reason, when a new record overturns the decision",
|
|
389
|
+
group: "analysis",
|
|
390
|
+
tree: true,
|
|
391
|
+
writes: true,
|
|
392
|
+
description: "Deciding again keeps the earlier decision in a note; it needs --reason.",
|
|
393
|
+
args: [{ name: "hypothesis", description: "hypothesis ID", required: true }],
|
|
394
|
+
options: [
|
|
395
|
+
{ name: "decision", type: "string", value: "<text>", description: "which variant and why" },
|
|
396
|
+
{ name: "abandon", type: "boolean", description: "none of the variants can be decided" },
|
|
397
|
+
],
|
|
398
|
+
run(ctx, { args, opts }) {
|
|
399
|
+
const tree = ctx.tree();
|
|
400
|
+
if (!opts.decision)
|
|
401
|
+
throw new UsageError("--decision is required");
|
|
402
|
+
const id = normId(args[0], "hypothesis");
|
|
403
|
+
const reason = typeof opts.reason === "string" && opts.reason.trim() ? opts.reason.trim() : undefined;
|
|
404
|
+
const h = update(tree, id, "hypothesis", (h) => {
|
|
405
|
+
// A decision is overturned in the open: the earlier one stays readable.
|
|
406
|
+
const earlier = h.state !== "open" && h.decision ? h.decision : undefined;
|
|
407
|
+
if (earlier && !reason)
|
|
408
|
+
throw new UsageError(`${id} is already ${h.state}: "${truncate(earlier, 80)}"`, { hint: 'deciding again needs --reason (e.g. "the marriage entry of 1839 overturns it")' });
|
|
409
|
+
const notes = earlier ? [...h.notes, makeNote(tree, truncate(`earlier ${h.state}: ${earlier}`, 480))] : h.notes;
|
|
410
|
+
return { ...h, state: opts.abandon ? "abandoned" : "decided", decision: String(opts.decision), notes };
|
|
411
|
+
}, { op: "hypothesis.decide", summary: `${id} ${opts.abandon ? "abandoned" : "decided"}${reason ? " again" : ""}`, reason });
|
|
412
|
+
return { text: lines(written(tree), stillOpen(tree, id)), data: { hypothesis: h } };
|
|
413
|
+
},
|
|
414
|
+
}, {
|
|
415
|
+
path: ["hypothesis", "list"],
|
|
416
|
+
summary: "Open hypotheses",
|
|
417
|
+
group: "analysis",
|
|
418
|
+
tree: true,
|
|
419
|
+
options: [{ name: "all", type: "boolean", description: "include decided and abandoned" }],
|
|
420
|
+
run(ctx, { opts }) {
|
|
421
|
+
const all = ctx.tree().list("hypothesis").filter((h) => opts.all || h.state === "open");
|
|
422
|
+
return {
|
|
423
|
+
text: all.length ? table(all.map((h) => [h.id, h.state, truncate(h.question, 60), h.variants.map((v) => v.label).join("/")])) : "no open hypotheses",
|
|
424
|
+
data: { hypotheses: all },
|
|
425
|
+
};
|
|
426
|
+
},
|
|
427
|
+
}, {
|
|
428
|
+
path: ["hypothesis", "show"],
|
|
429
|
+
summary: "One hypothesis with all variants and arguments",
|
|
430
|
+
group: "analysis",
|
|
431
|
+
tree: true,
|
|
432
|
+
args: [{ name: "hypothesis", description: "hypothesis ID", required: true }],
|
|
433
|
+
run(ctx, { args }) {
|
|
434
|
+
const tree = ctx.tree();
|
|
435
|
+
const h = requireRecord(tree, args[0], "hypothesis");
|
|
436
|
+
return {
|
|
437
|
+
text: lines(`${h.id} ${h.question} [${h.state}]`, `about ${h.subject.join(" ")}`, taskLines(tree, h.id), ...h.variants.flatMap((v) => [`\n${v.label}: ${v.claim}`, ...v.support.map((s) => ` + ${s}`), ...v.against.map((s) => ` − ${s}`)]), h.decision ? `\ndecision ${h.decision}` : undefined),
|
|
438
|
+
data: { hypothesis: h },
|
|
439
|
+
};
|
|
440
|
+
},
|
|
441
|
+
});
|
|
442
|
+
// ── lessons ────────────────────────────────────────────────────────────────
|
|
443
|
+
register({
|
|
444
|
+
path: ["lesson", "add"],
|
|
445
|
+
summary: "Record something learned — attached to what it is about, one sentence",
|
|
446
|
+
group: "analysis",
|
|
447
|
+
tree: true,
|
|
448
|
+
writes: true,
|
|
449
|
+
description: `A lesson on a record set, archive or place is shown to whoever works with it next.\nRule: one sentence, max ${LESSON_MAX} characters; details in --detail.`,
|
|
450
|
+
args: [{ name: "rule", description: "the lesson as one rule", required: true }],
|
|
451
|
+
options: [
|
|
452
|
+
{ name: "scope", type: "string", value: "<scope>", description: `${LESSON_SCOPES.join(", ")} (default: from --on, else project)` },
|
|
453
|
+
{ name: "on", type: "string", value: "<B…|R…|L…>", description: "what it is about" },
|
|
454
|
+
{ name: "detail", type: "string", value: "<text>", description: "the longer story" },
|
|
455
|
+
],
|
|
456
|
+
examples: ['strom lesson add "Folio = 2 × image + 1, checked at six anchors" --on B0001'],
|
|
457
|
+
run(ctx, { args, opts }) {
|
|
458
|
+
const tree = ctx.tree();
|
|
459
|
+
const rule = args[0].trim();
|
|
460
|
+
if ([...rule].length > LESSON_MAX)
|
|
461
|
+
throw new UsageError(`the rule is ${[...rule].length} characters (max ${LESSON_MAX})`, { hint: "one sentence as the rule, the story in --detail" });
|
|
462
|
+
const target = opts.on ? normId(String(opts.on)) : undefined;
|
|
463
|
+
const type = target ? typeOfId(target) : undefined;
|
|
464
|
+
if (target && (!type || !["repository", "recordset", "place"].includes(type) || !tree.get(target)))
|
|
465
|
+
throw new UsageError(`--on must be an existing record set, repository or place`);
|
|
466
|
+
const scope = opts.scope ? oneOf(opts.scope, LESSON_SCOPES, "scope") : (type ?? "project");
|
|
467
|
+
const l = create(tree, "lesson", { scope, target, rule, detail: opts.detail }, (id) => `+${id} lesson "${truncate(rule, 60)}"`, target ? [target] : []);
|
|
468
|
+
return { text: written(tree), data: { lesson: l } };
|
|
469
|
+
},
|
|
470
|
+
}, {
|
|
471
|
+
path: ["lesson", "list"],
|
|
472
|
+
summary: "Lessons (optionally only those about one record set, archive or place)",
|
|
473
|
+
group: "analysis",
|
|
474
|
+
tree: true,
|
|
475
|
+
options: [{ name: "on", type: "string", value: "<B…|R…|L…>", description: "only lessons about this" }],
|
|
476
|
+
run(ctx, { opts }) {
|
|
477
|
+
const target = opts.on ? normId(String(opts.on)) : undefined;
|
|
478
|
+
const all = ctx.tree().list("lesson").filter((l) => !target || l.target === target);
|
|
479
|
+
return { text: all.length ? table(all.map((l) => [l.id, l.scope, l.target ?? "", l.rule])) : "no lessons", data: { lessons: all } };
|
|
480
|
+
},
|
|
481
|
+
});
|
|
482
|
+
// ── find and gaps ──────────────────────────────────────────────────────────
|
|
483
|
+
/** Searchable text of a record, for `strom find`. */
|
|
484
|
+
function haystack(r) {
|
|
485
|
+
const parts = [];
|
|
486
|
+
const add = (v) => {
|
|
487
|
+
if (typeof v === "string")
|
|
488
|
+
parts.push(v);
|
|
489
|
+
else if (Array.isArray(v))
|
|
490
|
+
v.forEach(add);
|
|
491
|
+
else if (v && typeof v === "object")
|
|
492
|
+
Object.values(v).forEach(add);
|
|
493
|
+
};
|
|
494
|
+
const { id: _i, type: _t, created: _c, updated: _u, ...rest } = r;
|
|
495
|
+
add(rest);
|
|
496
|
+
return parts.join(" \u0001 ");
|
|
497
|
+
}
|
|
498
|
+
function label(tree, r) {
|
|
499
|
+
switch (r.type) {
|
|
500
|
+
case "person":
|
|
501
|
+
return `${displayName(r)}${lifespan(r) ? ` (${lifespan(r)})` : ""}`;
|
|
502
|
+
case "family":
|
|
503
|
+
return r.partners.map((p) => tree.get(p)).filter(Boolean).map((p) => displayName(p)).join(" & ") || "family";
|
|
504
|
+
case "research":
|
|
505
|
+
return r.name;
|
|
506
|
+
case "source":
|
|
507
|
+
case "recordset":
|
|
508
|
+
return r.title;
|
|
509
|
+
case "repository":
|
|
510
|
+
return r.name;
|
|
511
|
+
case "place":
|
|
512
|
+
return r.names[0]?.name ?? r.id;
|
|
513
|
+
case "search":
|
|
514
|
+
return r.question;
|
|
515
|
+
case "task":
|
|
516
|
+
return r.what;
|
|
517
|
+
case "conflict":
|
|
518
|
+
return r.title;
|
|
519
|
+
case "hypothesis":
|
|
520
|
+
return r.question;
|
|
521
|
+
case "lesson":
|
|
522
|
+
return r.rule;
|
|
523
|
+
case "input":
|
|
524
|
+
return r.name;
|
|
525
|
+
case "session":
|
|
526
|
+
return r.summary ?? `session ${r.state}`;
|
|
527
|
+
case "media":
|
|
528
|
+
return `image ${r.image ?? "?"}${r.recordset ? ` of ${r.recordset}` : ""}${r.from ? ` (${r.from})` : ""}`;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
register({
|
|
532
|
+
path: ["find"],
|
|
533
|
+
summary: "Full-text search across everything (people, sources, transcripts, searches, tasks, notes)",
|
|
534
|
+
group: "analysis",
|
|
535
|
+
tree: true,
|
|
536
|
+
description: "Words in one argument must all be there. Several arguments are several searches in one go (e.g. old codes\nof a converted research) — each answered on its own.",
|
|
537
|
+
args: [{ name: "text", description: "words to find (diacritics optional); several arguments = several searches", required: true, variadic: true }],
|
|
538
|
+
options: [{ name: "type", type: "string", value: "<type>", description: `only one record type: ${Object.keys(RECORD_TYPES).join(", ")}` }],
|
|
539
|
+
examples: ['strom find "čp. 12"', "strom find mlynar --type source", "strom find \"č. 12\" \"č. 15\" mlynar"],
|
|
540
|
+
run(ctx, { args, opts }) {
|
|
541
|
+
const tree = ctx.tree();
|
|
542
|
+
const types = (opts.type ? [String(opts.type)] : Object.keys(RECORD_TYPES));
|
|
543
|
+
if (opts.type && !(String(opts.type) in RECORD_TYPES))
|
|
544
|
+
throw new UsageError(`unknown type "${opts.type}"`, { hint: Object.keys(RECORD_TYPES).join(", ") });
|
|
545
|
+
const queries = [...new Set(args.map((a) => a.trim()).filter(Boolean))];
|
|
546
|
+
if (!queries.length)
|
|
547
|
+
throw new UsageError("what to find?", { hint: 'strom find "čp. 12"' });
|
|
548
|
+
const records = types.flatMap((t) => tree.list(t)).map((r) => ({ r, hay: haystack(r) })).map((x) => ({ ...x, folded: foldText(x.hay) }));
|
|
549
|
+
const hits = [];
|
|
550
|
+
const missing = [];
|
|
551
|
+
for (const q of queries) {
|
|
552
|
+
const words = foldText(q).split(" ").filter(Boolean);
|
|
553
|
+
let n = 0;
|
|
554
|
+
for (const { r, hay, folded } of records) {
|
|
555
|
+
if (!words.every((w) => folded.includes(w)))
|
|
556
|
+
continue;
|
|
557
|
+
const at = folded.indexOf(words[0]);
|
|
558
|
+
let start = Math.max(0, at - 30);
|
|
559
|
+
while (start > 0 && start < at && hay[start - 1] !== " ")
|
|
560
|
+
start++; // begin at a word
|
|
561
|
+
const snippet = hay.slice(start, at + 70).replace(/\u0001/g, "·");
|
|
562
|
+
hits.push({ ...(queries.length > 1 ? { query: q } : {}), id: r.id, type: r.type, label: truncate(label(tree, r), 50), snippet: truncate(snippet, 100) });
|
|
563
|
+
n++;
|
|
564
|
+
}
|
|
565
|
+
if (!n)
|
|
566
|
+
missing.push(q);
|
|
567
|
+
}
|
|
568
|
+
const page = paginate(hits, ctx.limit, ctx.page);
|
|
569
|
+
const more = moreLine(page, `strom find ${queries.map((q) => `"${q}"`).join(" ")}`);
|
|
570
|
+
if (queries.length === 1)
|
|
571
|
+
return {
|
|
572
|
+
text: hits.length ? lines(table(page.items.map((h) => [h.id, h.type, h.label, h.snippet])), more) : "nothing found",
|
|
573
|
+
data: { total: hits.length, hits: page.items },
|
|
574
|
+
};
|
|
575
|
+
const shown = queries.filter((q) => page.items.some((h) => h.query === q));
|
|
576
|
+
return {
|
|
577
|
+
text: lines(...shown.map((q) => lines(`${q}`, table(page.items.filter((h) => h.query === q).map((h) => [` ${h.id}`, h.type, h.label, h.snippet])))), missing.length ? `nothing found for: ${missing.join(", ")}` : undefined, more),
|
|
578
|
+
data: { total: hits.length, hits: page.items, missing },
|
|
579
|
+
};
|
|
580
|
+
},
|
|
581
|
+
}, {
|
|
582
|
+
path: ["gaps"],
|
|
583
|
+
summary: "Where the most work is: people with missing birth, death, parents, marriage or evidence",
|
|
584
|
+
group: "analysis",
|
|
585
|
+
tree: true,
|
|
586
|
+
options: [{ name: "research", type: "string", value: "<G…>", description: "only people in this research (with generations)" }],
|
|
587
|
+
run(ctx, { opts }) {
|
|
588
|
+
const tree = ctx.tree();
|
|
589
|
+
let people = tree.list("person").filter((p) => !p.retracted);
|
|
590
|
+
let gens;
|
|
591
|
+
if (opts.research) {
|
|
592
|
+
const r = resolveResearch(tree, String(opts.research));
|
|
593
|
+
gens = ancestorGenerations(tree, r.focus, r.limits?.generations ?? 50);
|
|
594
|
+
people = people.filter((p) => gens.has(p.id));
|
|
595
|
+
}
|
|
596
|
+
const rows = people
|
|
597
|
+
.map((p) => {
|
|
598
|
+
const gaps = [];
|
|
599
|
+
if (!birthEvent(p))
|
|
600
|
+
gaps.push("birth");
|
|
601
|
+
if (!deathEvent(p))
|
|
602
|
+
gaps.push("death");
|
|
603
|
+
if (parentsOf(tree, p.id).length < 2)
|
|
604
|
+
gaps.push("parents");
|
|
605
|
+
for (const f of familiesAsPartner(tree, p.id))
|
|
606
|
+
if (!f.events.some((e) => e.kind === "MARR" && !e.retracted))
|
|
607
|
+
gaps.push(`marriage ${f.id}`);
|
|
608
|
+
if (!p.events.some((e) => e.citations.length > 0) && !p.names.some((n) => n.citations?.length))
|
|
609
|
+
gaps.push("no evidence");
|
|
610
|
+
return { p, gaps };
|
|
611
|
+
})
|
|
612
|
+
.filter((x) => x.gaps.length)
|
|
613
|
+
.sort((a, b) => (gens ? gens.get(a.p.id) - gens.get(b.p.id) : 0) || b.gaps.length - a.gaps.length);
|
|
614
|
+
const page = paginate(rows, ctx.limit, ctx.page);
|
|
615
|
+
return {
|
|
616
|
+
text: rows.length
|
|
617
|
+
? lines(table(page.items.map(({ p, gaps }) => [p.id, displayName(p), lifespan(p), gens ? `G${gens.get(p.id)}` : "", gaps.join(", ")])), moreLine(page, "strom gaps"))
|
|
618
|
+
: "no gaps — every person has birth, death, parents and evidence",
|
|
619
|
+
data: { total: rows.length, gaps: page.items.map(({ p, gaps }) => ({ id: p.id, name: displayName(p), gaps, generation: gens?.get(p.id) })) },
|
|
620
|
+
};
|
|
621
|
+
},
|
|
622
|
+
});
|