strom-research 1.8.0 → 1.9.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/assets/method/core.md +8 -0
- package/dist/commands/output.js +4 -2
- package/dist/commands/session.js +131 -2
- package/dist/commands/tasks.js +35 -6
- package/dist/core/edge.js +296 -0
- package/dist/core/frontier.js +12 -4
- package/dist/core/kin.js +40 -1
- package/dist/core/live.js +36 -3
- package/dist/core/queue.js +10 -4
- package/dist/core/stromapp.js +22 -0
- package/dist/core/tree.js +1 -1
- package/dist/gedcom/export.js +105 -1
- package/dist/gedcom/validate.js +7 -1
- package/package.json +1 -1
package/assets/method/core.md
CHANGED
|
@@ -37,6 +37,14 @@ conclusion so the next researcher can follow it.
|
|
|
37
37
|
- **Lessons belong where they apply.** A quirk of a register (its calibration,
|
|
38
38
|
two years per page, a hand that writes 7 like 1) goes to `strom lesson add
|
|
39
39
|
--on B…`, so whoever opens that book next sees it.
|
|
40
|
+
- **The tree first, then above it.** A family found in the records that no
|
|
41
|
+
family link joins to the tree is not researched further — not its parents,
|
|
42
|
+
not its children — until the link is proven, or the user asks for it. First
|
|
43
|
+
the hypothesis that would join it (`strom hypothesis add … --about` a person
|
|
44
|
+
of the tree), then a task that tests it and names it (`--about H…`). The
|
|
45
|
+
queue holds back every other task about people off the tree
|
|
46
|
+
(`strom task list --off-tree`); they come back by themselves once the link
|
|
47
|
+
is recorded.
|
|
40
48
|
- **Stay within the task.** New questions become new tasks (`strom task add`,
|
|
41
49
|
with where and done-when), not detours. A new task whose images are not here
|
|
42
50
|
gets them now: through the archive's connector (built first when it has
|
package/dist/commands/output.js
CHANGED
|
@@ -13,7 +13,7 @@ import { UsageError } from "../core/errors.js";
|
|
|
13
13
|
import { excerptSettings, planExcerpts } from "../core/excerpt.js";
|
|
14
14
|
import { runGit, tracked } from "../core/git.js";
|
|
15
15
|
import { Settings } from "../core/config.js";
|
|
16
|
-
import { appOpensLinks } from "../core/stromapp.js";
|
|
16
|
+
import { appOpensLinks, appShowsEdges } from "../core/stromapp.js";
|
|
17
17
|
import { EXCERPT_QUALITIES, EXCERPT_SCOPES } from "../core/model.js";
|
|
18
18
|
function findingRows(f) {
|
|
19
19
|
return table(f.slice(0, 30).map((x) => [x.level === "error" ? "ERROR" : "warn", `line ${x.line}`, x.message]));
|
|
@@ -73,7 +73,9 @@ export function writeTreeGed(tree, opts) {
|
|
|
73
73
|
// an app that opens links shows what the research knows of a person too (the same version)
|
|
74
74
|
const research = opts.for === "strom" && appOpensLinks(new Settings(tree.env, {}), opts.stromVersion);
|
|
75
75
|
const clips = research && !!images;
|
|
76
|
-
|
|
76
|
+
// …and where the tree ends, for an app that shows it
|
|
77
|
+
const edges = opts.for === "strom" && appShowsEdges(new Settings(tree.env, {}), opts.stromVersion);
|
|
78
|
+
const result = exportGedcom(tree, { for: opts.for, stromVersion: opts.stromVersion, ...(persons ? { persons } : {}), ...(images ? { excerpts: images.of } : {}), ...(clips ? { clips } : {}), ...(research ? { research } : {}), ...(edges ? { edges } : {}) });
|
|
77
79
|
const findings = validateGedcom(result.text, opts.for === "standard" ? { strict: true } : {});
|
|
78
80
|
const file = opts.out ?? gedFile(tree, opts.for, opts.research);
|
|
79
81
|
if (!hasGedErrors(findings) && !tree.dryRun) {
|
package/dist/commands/session.js
CHANGED
|
@@ -17,10 +17,12 @@ import { buildBrief } from "../brief/brief.js";
|
|
|
17
17
|
import { DEFAULT_BUDGET, DEFAULT_RUN_MINUTES, Settings } from "../core/config.js";
|
|
18
18
|
import { prependPath } from "../runners/runner.js";
|
|
19
19
|
import { frontier } from "../core/frontier.js";
|
|
20
|
+
import { treeEdges } from "../core/edge.js";
|
|
21
|
+
import { label, parentsOf, resolvePerson } from "../core/people.js";
|
|
20
22
|
import { storyProposals } from "../core/stories.js";
|
|
21
23
|
import { OFF_MAP_HOW, offMapLine, placesOffMap } from "../core/places.js";
|
|
22
24
|
import { create, csvOpt, requireRecord, update } from "../core/records.js";
|
|
23
|
-
import { taskQueue, waitingLines } from "./tasks.js";
|
|
25
|
+
import { offTreeLine, taskQueue, waitingLines } from "./tasks.js";
|
|
24
26
|
import { resolveResearch } from "./research.js";
|
|
25
27
|
import { writeGedcoms } from "./output.js";
|
|
26
28
|
import { syncAgentFiles } from "../agents/files.js";
|
|
@@ -110,7 +112,9 @@ register({
|
|
|
110
112
|
const s = startSession(tree, { ...(task ? { task } : {}), ...(research ? { research: research.id } : {}) });
|
|
111
113
|
reviveLive(tree.root, ctx.env);
|
|
112
114
|
const brief = buildBrief(tree, { ...(task ? { task } : {}), session: s, budget: ctx.settings.number("brief.budget", tree.config, DEFAULT_BUDGET), shared: ctx.settings.shared()?.value });
|
|
113
|
-
|
|
115
|
+
// a task the queue holds back, started by its ID: the agent hears why before it begins
|
|
116
|
+
const off = task && args[0] ? offTreeLine(tree, task, true) : undefined;
|
|
117
|
+
return { text: lines(written(tree), off, "", brief.text), data: { session: s, brief: brief.text, sections: brief.sections, ...(off ? { offTree: true } : {}) } };
|
|
114
118
|
},
|
|
115
119
|
}, {
|
|
116
120
|
path: ["session", "note"],
|
|
@@ -292,7 +296,132 @@ register({
|
|
|
292
296
|
data: { frontier: items.map((i) => ({ person: i.person.id, generation: i.generation, reason: i.reason, coveredBy: i.coveredBy, proposal: i.proposal, exhausted: i.exhausted })), stories },
|
|
293
297
|
};
|
|
294
298
|
},
|
|
299
|
+
}, {
|
|
300
|
+
path: ["edge"],
|
|
301
|
+
summary: "Where the tree ends: above whom it does not go on, why (what the records say so far) and what comes next — what the Strom app shows on those cards",
|
|
302
|
+
group: "tasks",
|
|
303
|
+
tree: true,
|
|
304
|
+
args: [{ name: "person", description: "whose edge in full: ID (P0001) or name (default: every edge the research reaches)", variadic: true }],
|
|
305
|
+
options: [{ name: "all", type: "boolean", description: "also the edges no direction reaches: family outside the directions, people off the tree" }],
|
|
306
|
+
examples: ["strom edge", "strom edge P0001", "strom edge --all --json"],
|
|
307
|
+
run(ctx, { args, opts }) {
|
|
308
|
+
const tree = ctx.tree();
|
|
309
|
+
const { edges, islands } = treeEdges(tree);
|
|
310
|
+
const who = (id) => {
|
|
311
|
+
const p = tree.get(id);
|
|
312
|
+
return p ? label(p) : id;
|
|
313
|
+
};
|
|
314
|
+
if (args.length) {
|
|
315
|
+
const people = args.map((a) => resolvePerson(tree, a));
|
|
316
|
+
const out = [];
|
|
317
|
+
for (const p of people) {
|
|
318
|
+
const e = edges.get(p.id);
|
|
319
|
+
const isle = islands.get(p.id);
|
|
320
|
+
if (out.length)
|
|
321
|
+
out.push("");
|
|
322
|
+
if (!e)
|
|
323
|
+
out.push(`${label(p)} — ${parentsOf(tree, p.id).length === 2 ? "the tree goes on above: a record proves the parents" : "nothing the research knows of the edge above yet (no direction reaches them, no work on it)"}`);
|
|
324
|
+
else
|
|
325
|
+
out.push(...edgeLines(tree, e, who));
|
|
326
|
+
if (isle)
|
|
327
|
+
out.push(islandLine(isle, who));
|
|
328
|
+
}
|
|
329
|
+
const data = people.map((p) => ({ person: p.id, edge: edges.get(p.id) ?? null, island: islands.get(p.id) ? { ...islands.get(p.id), people: islands.get(p.id).people.length } : null }));
|
|
330
|
+
return { text: lines(...out), data: { edges: data } };
|
|
331
|
+
}
|
|
332
|
+
const reached = new Set(["in", "limit", "paused", "done"]);
|
|
333
|
+
const shown = [...edges.values()].filter((e) => opts.all || reached.has(e.scope)).sort((a, b) => (a.generation ?? 99) - (b.generation ?? 99) || a.person.localeCompare(b.person));
|
|
334
|
+
const count = (k) => [...shown.reduce((m, e) => m.set(e[k], (m.get(e[k]) ?? 0) + 1), new Map())].map(([v, n]) => `${v} ${n}`).join(", ");
|
|
335
|
+
const hidden = edges.size - shown.length;
|
|
336
|
+
return {
|
|
337
|
+
text: shown.length
|
|
338
|
+
? lines(table(shown.map((e) => [
|
|
339
|
+
e.person,
|
|
340
|
+
e.generation !== undefined ? `G${e.generation}` : "",
|
|
341
|
+
e.missing,
|
|
342
|
+
e.scope === "in" ? "" : e.scope,
|
|
343
|
+
e.end,
|
|
344
|
+
e.next,
|
|
345
|
+
truncate(edgeNext(e), 70),
|
|
346
|
+
])), "", `ends: ${count("end")}`, `next: ${count("next")}`, hidden ? `${hidden} more no direction reaches (strom edge --all)` : undefined, "one in full: strom edge P…")
|
|
347
|
+
: "no edge the research reaches — every person in its directions has parents a record proves",
|
|
348
|
+
data: { edges: shown, islands: [...new Set(islands.values())].map((i) => ({ ...i, people: i.people.length, first: i.people[0] })) },
|
|
349
|
+
};
|
|
350
|
+
},
|
|
295
351
|
});
|
|
352
|
+
/** What comes next at an edge, in a few words: the first task and where it stands, else what the user decides. */
|
|
353
|
+
function edgeNext(e) {
|
|
354
|
+
const t = e.tasks[0];
|
|
355
|
+
const hyp = e.hypotheses.find((h) => h.joins.length);
|
|
356
|
+
const join = hyp ? ` · ${hyp.id} ${hyp.island ? `joins ${hyp.joins.join(" ")} (${hyp.island.people} people off the tree)` : `would join through ${hyp.joins.join(" ")}`}` : "";
|
|
357
|
+
if (t)
|
|
358
|
+
return `${t.id}${t.position ? ` #${t.position}` : ""}${t.held ? ` (${t.held})` : ""}${e.tasks.length > 1 ? ` +${e.tasks.length - 1}` : ""}${join}`;
|
|
359
|
+
if (e.next === "decide")
|
|
360
|
+
return `tried ${e.tried.join(" ")} — ask the user${join}`;
|
|
361
|
+
return join.replace(/^ · /, "");
|
|
362
|
+
}
|
|
363
|
+
const MISSING_WORDS = {
|
|
364
|
+
parents: "both parents unknown",
|
|
365
|
+
father: "the father unknown",
|
|
366
|
+
mother: "the mother unknown",
|
|
367
|
+
proof: "no record proves the parents recorded",
|
|
368
|
+
};
|
|
369
|
+
const SCOPE_WORDS = {
|
|
370
|
+
in: "reached by",
|
|
371
|
+
limit: "at the limit of",
|
|
372
|
+
paused: "in a paused direction,",
|
|
373
|
+
done: "in an ended direction,",
|
|
374
|
+
living: "most likely alive — not researched",
|
|
375
|
+
outside: "family of the tree no direction goes through",
|
|
376
|
+
"off-tree": "of a family nothing links to the tree yet",
|
|
377
|
+
};
|
|
378
|
+
const END_WORDS = {
|
|
379
|
+
unnamed: "the baptism names no father (born out of wedlock)",
|
|
380
|
+
lost: "the records of those years are lost",
|
|
381
|
+
"before-records": "born before the known birth records of the place begin",
|
|
382
|
+
gap: "the known records have a gap in those years",
|
|
383
|
+
"not-found": "searched in vain everywhere strom knew to look",
|
|
384
|
+
offline: "the books left are only in the archive",
|
|
385
|
+
partly: "searched in part",
|
|
386
|
+
unsearched: "not searched yet",
|
|
387
|
+
"no-books": "no book of the birthplace known",
|
|
388
|
+
"no-place": "the birthplace unknown",
|
|
389
|
+
"no-clue": "nothing to go on: no year, no place of birth",
|
|
390
|
+
};
|
|
391
|
+
const NEXT_WORDS = {
|
|
392
|
+
working: "an agent works on it now",
|
|
393
|
+
waiting: "waits for the user",
|
|
394
|
+
queued: "in the queue",
|
|
395
|
+
held: "waits out of the queue",
|
|
396
|
+
proposed: "strom proposes it with the next session",
|
|
397
|
+
decide: "everything strom could propose was tried — the user decides",
|
|
398
|
+
none: "nothing planned",
|
|
399
|
+
};
|
|
400
|
+
/** One edge in full, for the agent. */
|
|
401
|
+
function edgeLines(tree, e, who) {
|
|
402
|
+
const y = (r) => (r.from === r.to ? `${r.from}` : `${r.from}–${r.to}`);
|
|
403
|
+
const est = e.estimate;
|
|
404
|
+
const basis = est?.basis ? ` (estimated from ${est.basis.kind === "MARR" ? "the marriage" : "the eldest child"} ${est.basis.year})` : "";
|
|
405
|
+
return [
|
|
406
|
+
`${who(e.person)} — the tree ends above: ${MISSING_WORDS[e.missing]}`,
|
|
407
|
+
` ${SCOPE_WORDS[e.scope]}${e.research ? ` ${e.research}, generation ${e.generation}` : ""}`,
|
|
408
|
+
` the records: ${END_WORDS[e.end]}${e.recordsFrom ? ` (from ${e.recordsFrom})` : ""}`,
|
|
409
|
+
e.window ? ` baptism sought ${y(e.window)}${est?.place ? ` in ${est.place}` : ""}${basis}` : est?.place ? ` born in ${est.place}, year unknown` : undefined,
|
|
410
|
+
e.books.length ? ` books: ${e.books.map((b) => `${b.id}${b.years ? ` (${b.years})` : ""}${b.access.startsWith("online") ? "" : ` ${b.access}`}`).join(", ")}` : undefined,
|
|
411
|
+
e.covered.length ? ` searched in vain: ${e.covered.map(y).join(", ")}` : undefined,
|
|
412
|
+
e.noRecords.length ? ` no records known: ${e.noRecords.map(y).join(", ")}` : undefined,
|
|
413
|
+
` next: ${NEXT_WORDS[e.next]}`,
|
|
414
|
+
...e.tasks.map((t) => ` ${t.id} ${t.level} ${t.state}${t.position ? ` #${t.position}` : ""}${t.held ? ` (waits: ${t.held}${t.until ? ` until ${t.until}` : ""})` : ""}${t.on ? ` — ${truncate(t.on, 60)}` : ""}: ${truncate(t.what, 90)}`),
|
|
415
|
+
e.tried.length ? ` tried: ${e.tried.join(" ")}` : undefined,
|
|
416
|
+
...e.hypotheses.map((h) => ` ${h.id} ${truncate(h.question, 90)}${!h.joins.length ? "" : h.island ? ` — would join ${h.joins.map(who).join(", ")} of a family off the tree (${h.island.people} people, ${h.island.held} tasks wait for it)` : ` — would join them to the tree through ${h.joins.map(who).join(", ")}`}${h.tests.length ? `; tested by ${h.tests.join(" ")}` : "; no task tests it"}`),
|
|
417
|
+
e.conflicts.length ? ` open conflicts: ${e.conflicts.join(" ")}` : undefined,
|
|
418
|
+
e.searches || e.sessions ? ` work so far: ${e.searches} search${e.searches === 1 ? "" : "es"}, ${e.sessions} session${e.sessions === 1 ? "" : "s"}${e.cost !== undefined ? `, $${e.cost.toFixed(2)}${e.costPartial ? "+" : ""}` : ""}${e.last ? `; last ${e.last}` : ""}` : undefined,
|
|
419
|
+
].filter((x) => x !== undefined);
|
|
420
|
+
}
|
|
421
|
+
function islandLine(i, who) {
|
|
422
|
+
const joins = i.hypotheses.map((h) => `${h.id} (to ${h.joins.map(who).join(", ")})`);
|
|
423
|
+
return ` of a family nothing links to the tree: ${i.people.length} people, ${i.held} tasks wait for it${joins.length ? `; would join it: ${joins.join(", ")}` : "; no hypothesis would join it yet"}`;
|
|
424
|
+
}
|
|
296
425
|
// ── agents ─────────────────────────────────────────────────────────────────
|
|
297
426
|
register({
|
|
298
427
|
path: ["agents", "sync"],
|
package/dist/commands/tasks.js
CHANGED
|
@@ -20,6 +20,8 @@ import { makeNote } from "../core/actions.js";
|
|
|
20
20
|
import { phrase } from "../core/phrases.js";
|
|
21
21
|
import { readJsonLines } from "../core/json.js";
|
|
22
22
|
import { lacksImages, rankTasks } from "../core/queue.js";
|
|
23
|
+
import { joiningHypotheses, offTree } from "../core/kin.js";
|
|
24
|
+
import { aboutPeople } from "../core/directions.js";
|
|
23
25
|
import { clipNote, inboxFolderFor, parseImageList, transcriptNote } from "../core/media.js";
|
|
24
26
|
function written(tree) {
|
|
25
27
|
return lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined);
|
|
@@ -89,6 +91,22 @@ export function rankedQueue(tree, filter = {}) {
|
|
|
89
91
|
.filter((t) => !filter.level || t.level === filter.level);
|
|
90
92
|
return rankTasks(tree, tasks, filter.strategy, { storyTurn: filter.storyTurn });
|
|
91
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* What an agent is told of a task about people nothing links to the tree (core/kin.ts offTree): it waits out of the
|
|
96
|
+
* queue until the user asks for it or the hypothesis that would join them is decided; the task that tests that
|
|
97
|
+
* hypothesis names it. Undefined for a task of the tree.
|
|
98
|
+
*/
|
|
99
|
+
export function offTreeLine(tree, t, starting = false) {
|
|
100
|
+
if (!offTree(tree)(t))
|
|
101
|
+
return undefined;
|
|
102
|
+
const joins = joiningHypotheses(tree, aboutPeople(tree, t.subject));
|
|
103
|
+
const first = joins.length
|
|
104
|
+
? `test first what would join them: ${joins.map((h) => `${h.id} "${truncate(h.question, 70)}"`).join("; ")} — a task that tests it names it (--about ${joins[0].id})`
|
|
105
|
+
: "first a link to a person of the tree: a hypothesis (strom hypothesis add … --about P… of the tree) and a task that tests it, naming it (--about H…)";
|
|
106
|
+
return starting
|
|
107
|
+
? `⚠ ${t.id} is about people nothing links to the tree yet: go on only if the user asked for this work. Else close the session (strom session close --continue …) and ${first}`
|
|
108
|
+
: `· ${t.id} is about people nothing links to the tree yet: it waits out of the queue until the user asks for it or a hypothesis joins them — ${first}`;
|
|
109
|
+
}
|
|
92
110
|
function taskLine(t) {
|
|
93
111
|
return [t.id, `p${t.priority}`, t.level, t.state === "open" ? "" : `[${t.state}]`, truncate(t.what, 70)];
|
|
94
112
|
}
|
|
@@ -231,7 +249,7 @@ register({
|
|
|
231
249
|
// it is known — not in a session spent finding out that there is nothing to read.
|
|
232
250
|
const noImages = lacksImages(tree, t);
|
|
233
251
|
return {
|
|
234
|
-
text: lines(written(tree), ...similar.map((x) => `⚠ similar ${x.state} task ${x.id} "${truncate(x.what, 60)}" — if it is the same, keep one: strom task drop ${t.id} --reason "duplicate of ${x.id}" and strom task edit ${x.id} --where … --note "…"`), noImages ? `· no images for it here yet — if the user has to download them, say which now: strom task wait ${t.id} --images B…:<numbers> --on "<the book, its link, which images>"` : undefined),
|
|
252
|
+
text: lines(written(tree), ...similar.map((x) => `⚠ similar ${x.state} task ${x.id} "${truncate(x.what, 60)}" — if it is the same, keep one: strom task drop ${t.id} --reason "duplicate of ${x.id}" and strom task edit ${x.id} --where … --note "…"`), offTreeLine(tree, t), noImages ? `· no images for it here yet — if the user has to download them, say which now: strom task wait ${t.id} --images B…:<numbers> --on "<the book, its link, which images>"` : undefined),
|
|
235
253
|
data: { task: t, similar: similar.map((x) => x.id), needsImages: noImages },
|
|
236
254
|
};
|
|
237
255
|
},
|
|
@@ -250,8 +268,13 @@ register({
|
|
|
250
268
|
const strategy = ctx.settings.strategy(tree.config);
|
|
251
269
|
const queue = rankedQueue(tree, { ...(research ? { research } : {}), ...(opts.level ? { level: String(opts.level) } : {}), strategy });
|
|
252
270
|
const first = queue[0];
|
|
253
|
-
if (!first)
|
|
254
|
-
|
|
271
|
+
if (!first) {
|
|
272
|
+
const held = tree.list("task").filter(offTree(tree)).filter((t) => t.state === "open").length;
|
|
273
|
+
const text = held
|
|
274
|
+
? `no open task of the tree — ${held} wait about people nothing links to it yet (strom task list --off-tree): test the hypothesis that would join them, or ask the user`
|
|
275
|
+
: "no open tasks → strom research show <G…> to see what is missing, then strom task add";
|
|
276
|
+
return { text, data: { task: null, offTree: held } };
|
|
277
|
+
}
|
|
255
278
|
const t = first.task;
|
|
256
279
|
return {
|
|
257
280
|
text: lines(taskDetail(tree, t), "", `first because: ${first.why} (queue: ${strategy})`, `start it: strom task start ${t.id}`),
|
|
@@ -271,6 +294,7 @@ register({
|
|
|
271
294
|
{ name: "research", type: "string", value: "<G…>", description: "only this research" },
|
|
272
295
|
{ name: "level", type: "string", value: "<level>", description: "only this level" },
|
|
273
296
|
{ name: "about", type: "string", value: "<who>", description: "only tasks about this person/record (also X…, H…)" },
|
|
297
|
+
{ name: "off-tree", type: "boolean", description: "the open tasks that wait out of the queue: about people nothing links to the tree yet" },
|
|
274
298
|
{ name: "full", type: "boolean", description: "--json: whole records instead of one row each" },
|
|
275
299
|
],
|
|
276
300
|
run(ctx, { opts }) {
|
|
@@ -278,7 +302,10 @@ register({
|
|
|
278
302
|
const research = typeof opts.research === "string" ? resolveResearch(tree, opts.research).id : undefined;
|
|
279
303
|
let all;
|
|
280
304
|
const why = new Map();
|
|
281
|
-
|
|
305
|
+
const off = offTree(tree);
|
|
306
|
+
if (opts["off-tree"])
|
|
307
|
+
all = tree.list("task").filter((t) => t.state === "open" && off(t) && (!opts.level || t.level === opts.level));
|
|
308
|
+
else if (!opts.state) {
|
|
282
309
|
const ranked = rankedQueue(tree, { ...(research ? { research } : {}), ...(opts.level ? { level: String(opts.level) } : {}), strategy: ctx.settings.strategy(tree.config) });
|
|
283
310
|
for (const r of ranked)
|
|
284
311
|
why.set(r.task.id, r.why);
|
|
@@ -297,8 +324,10 @@ register({
|
|
|
297
324
|
all = all.filter((t) => t.subject.includes(id));
|
|
298
325
|
}
|
|
299
326
|
const page = paginate(all, ctx.limit, ctx.page);
|
|
327
|
+
// the queue says what waits out of it for a link to the tree
|
|
328
|
+
const held = !opts.state && !opts["off-tree"] && !opts.about ? tree.list("task").filter((t) => t.state === "open" && off(t)).length : 0;
|
|
300
329
|
return {
|
|
301
|
-
text: all.length ? lines(table(page.items.map(taskLine)), moreLine(page, "strom task list")) : "no tasks",
|
|
330
|
+
text: lines(all.length ? lines(table(page.items.map(taskLine)), moreLine(page, "strom task list")) : "no tasks", held ? `${held} more wait out of the queue: about people nothing links to the tree yet (strom task list --off-tree)` : undefined),
|
|
302
331
|
data: { total: page.total, tasks: opts.full ? page.items : page.items.map((t) => ({ id: t.id, priority: t.priority, level: t.level, state: t.state, what: t.what, subject: t.subject, ...(why.has(t.id) ? { why: why.get(t.id) } : {}) })) },
|
|
303
332
|
};
|
|
304
333
|
},
|
|
@@ -360,7 +389,7 @@ register({
|
|
|
360
389
|
summary: `${id} ${fields.map((f) => (f === "doneWhen" ? "done-when" : f === "subject" ? "about" : f)).join(", ")}${change.where ? ` → ${truncate(change.where.join("; "), 50)}` : ""}`,
|
|
361
390
|
reason: opts.reason,
|
|
362
391
|
});
|
|
363
|
-
return { text: written(tree), data: { task: t } };
|
|
392
|
+
return { text: lines(written(tree), change.subject ? offTreeLine(tree, t) : undefined), data: { task: t } };
|
|
364
393
|
},
|
|
365
394
|
}, {
|
|
366
395
|
path: ["task", "start"],
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// The edge of the tree: the people above whom it does not go on — no parents, one of them, or parents no record
|
|
2
|
+
// proves — and what the research knows there. Why it ends (what the records say so far: the known books of the
|
|
3
|
+
// birth, the years searched in vain, the records that begin later or are lost), what comes next (the work on it and
|
|
4
|
+
// where it stands in the queue, or that the user decides), and the hypotheses that would join a family nothing
|
|
5
|
+
// links to the tree yet. Read only, computed from the research: what the Strom app shows on the card where the
|
|
6
|
+
// tree ends (_STROM_EDGE, _STROM_ISLAND) and what `strom edge` says.
|
|
7
|
+
import { now } from "./tree.js";
|
|
8
|
+
import { Settings } from "./config.js";
|
|
9
|
+
import { aboutPeople, directionOf, scopes } from "./directions.js";
|
|
10
|
+
import { birthEstimate, birthRecordProven, birthWindow, frontier, FRONTIER_LEVELS, RECORD_KINDS, recordsetsCovering } from "./frontier.js";
|
|
11
|
+
import { kinship, offTree } from "./kin.js";
|
|
12
|
+
import { familiesAsChild, familiesAsPartner, parentsOf, primaryName } from "./people.js";
|
|
13
|
+
import { livingBorn } from "./review.js";
|
|
14
|
+
import { effectiveState, rankTasks } from "./queue.js";
|
|
15
|
+
import { subjectPeople } from "./records.js";
|
|
16
|
+
import { foldText } from "./text.js";
|
|
17
|
+
/** What is missing above the person: both parents, one of them, or a record that proves the parents recorded. */
|
|
18
|
+
export const EDGE_MISSING = ["parents", "father", "mother", "proof"];
|
|
19
|
+
/**
|
|
20
|
+
* Whether the research reaches them: a direction of it does (in), would but stops at its limit (limit), is paused
|
|
21
|
+
* or ended (paused, done); none does — they are most likely alive (living), family of the tree no direction goes
|
|
22
|
+
* through (outside), or a family nothing links to the tree yet (off-tree).
|
|
23
|
+
*/
|
|
24
|
+
export const EDGE_SCOPES = ["in", "limit", "paused", "done", "living", "outside", "off-tree"];
|
|
25
|
+
/**
|
|
26
|
+
* What the records say so far, from the most final: the baptism names no father (born out of wedlock); every year of the
|
|
27
|
+
* birth is searched in vain or has no records — lost, before the known records begin, a gap in them; searched in
|
|
28
|
+
* vain everywhere strom knew to look; the books left are only in the archive; searched in part; not searched yet;
|
|
29
|
+
* no book of the birthplace known; the birthplace unknown; nothing to go on (no year, no place).
|
|
30
|
+
*/
|
|
31
|
+
export const EDGE_ENDS = ["unnamed", "lost", "before-records", "gap", "not-found", "offline", "partly", "unsearched", "no-books", "no-place", "no-clue"];
|
|
32
|
+
/**
|
|
33
|
+
* What comes next: an agent works on it now, it waits for the user, it is in the queue, it waits out of the queue
|
|
34
|
+
* (a direction paused, people off the tree, put aside), strom proposes it with the next session, everything strom
|
|
35
|
+
* could propose was tried (the user decides), nothing.
|
|
36
|
+
*/
|
|
37
|
+
export const EDGE_NEXT = ["working", "waiting", "queued", "held", "proposed", "decide", "none"];
|
|
38
|
+
const BIRTH_KINDS = RECORD_KINDS.BIRT;
|
|
39
|
+
const LIVE = new Set(["open", "doing", "waiting", "parked"]);
|
|
40
|
+
const FINISHED = new Set(["done", "dropped"]);
|
|
41
|
+
const EDGE_FACTS = new Set(["BIRT", "CHR", "BAPM", "FAMC"]);
|
|
42
|
+
function yearsOf(text) {
|
|
43
|
+
const m = /^\s*(\d{3,4})(?:\s*[-–]\s*(\d{3,4}))?\s*$/.exec(text ?? "");
|
|
44
|
+
if (!m)
|
|
45
|
+
return undefined;
|
|
46
|
+
const a = Number(m[1]);
|
|
47
|
+
const b = m[2] ? Number(m[2]) : a;
|
|
48
|
+
return { from: Math.min(a, b), to: Math.max(a, b) };
|
|
49
|
+
}
|
|
50
|
+
/** Single years as ranges ([1780, 1781, 1782, 1790] → 1780–1782, 1790). */
|
|
51
|
+
function ranges(years) {
|
|
52
|
+
const out = [];
|
|
53
|
+
for (const y of [...years].sort((a, b) => a - b)) {
|
|
54
|
+
const last = out[out.length - 1];
|
|
55
|
+
if (last && y === last.to + 1)
|
|
56
|
+
last.to = y;
|
|
57
|
+
else
|
|
58
|
+
out.push({ from: y, to: y });
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
const within = (y, r) => !r || (y >= r.from && y <= r.to);
|
|
63
|
+
/** The families nothing links to the tree, person by person (none when the tree has nobody it is for). */
|
|
64
|
+
function islands(tree, kin, tasks, hypotheses) {
|
|
65
|
+
const out = new Map();
|
|
66
|
+
if (!kin.size)
|
|
67
|
+
return out;
|
|
68
|
+
const off = offTree(tree);
|
|
69
|
+
for (const p of tree.list("person")) {
|
|
70
|
+
if (p.retracted || kin.has(p.id) || out.has(p.id))
|
|
71
|
+
continue;
|
|
72
|
+
const people = new Set();
|
|
73
|
+
const queue = [p.id];
|
|
74
|
+
while (queue.length) {
|
|
75
|
+
const id = queue.shift();
|
|
76
|
+
if (people.has(id) || kin.has(id))
|
|
77
|
+
continue;
|
|
78
|
+
people.add(id);
|
|
79
|
+
queue.push(...familiesAsPartner(tree, id).flatMap((f) => [...f.partners, ...f.children.map((c) => c.person)]), ...familiesAsChild(tree, id).flatMap((f) => f.partners));
|
|
80
|
+
}
|
|
81
|
+
const island = {
|
|
82
|
+
people: [...people],
|
|
83
|
+
hypotheses: hypotheses
|
|
84
|
+
.map((h) => ({ h, about: aboutPeople(tree, h.subject) }))
|
|
85
|
+
.filter(({ about }) => about.some((x) => people.has(x)) && about.some((x) => kin.has(x)))
|
|
86
|
+
.map(({ h, about }) => ({ id: h.id, joins: about.filter((x) => kin.has(x)) })),
|
|
87
|
+
held: tasks.filter((t) => LIVE.has(t.state) && off(t) && aboutPeople(tree, t.subject).some((x) => people.has(x))).length,
|
|
88
|
+
};
|
|
89
|
+
for (const id of people)
|
|
90
|
+
out.set(id, island);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
/** Every edge of the tree with something the research knows of it, and the families nothing links to the tree. */
|
|
95
|
+
export function treeEdges(tree) {
|
|
96
|
+
const lang = tree.lang;
|
|
97
|
+
const today = now().slice(0, 10);
|
|
98
|
+
const kin = kinship(tree);
|
|
99
|
+
const all = scopes(tree).filter((s) => !s.research.retracted);
|
|
100
|
+
const tasks = tree.list("task").filter((t) => !t.retracted);
|
|
101
|
+
const hypotheses = tree.list("hypothesis").filter((h) => !h.retracted && h.state === "open");
|
|
102
|
+
const conflicts = tree.list("conflict").filter((c) => !c.retracted && c.state === "open");
|
|
103
|
+
const searches = tree.list("search").filter((q) => !q.retracted);
|
|
104
|
+
const sessions = tree.list("session");
|
|
105
|
+
const researches = new Map(tree.list("research").map((r) => [r.id, r]));
|
|
106
|
+
const ranked = rankTasks(tree, tasks, new Settings(tree.env, {}).strategy(tree.config)).map((r) => r.task.id);
|
|
107
|
+
const position = new Map(ranked.map((id, i) => [id, i + 1]));
|
|
108
|
+
const off = offTree(tree);
|
|
109
|
+
const isles = islands(tree, kin, tasks, hypotheses);
|
|
110
|
+
const frontiers = new Map();
|
|
111
|
+
const frontierOf = (r) => {
|
|
112
|
+
if (!frontiers.has(r.id))
|
|
113
|
+
frontiers.set(r.id, new Map(frontier(tree, r).map((i) => [i.person.id, i])));
|
|
114
|
+
return frontiers.get(r.id);
|
|
115
|
+
};
|
|
116
|
+
const hypAbout = hypotheses.map((h) => ({ h, about: aboutPeople(tree, h.subject) }));
|
|
117
|
+
const edges = new Map();
|
|
118
|
+
for (const p of tree.list("person")) {
|
|
119
|
+
if (p.retracted)
|
|
120
|
+
continue;
|
|
121
|
+
const parents = parentsOf(tree, p.id);
|
|
122
|
+
const proven = birthRecordProven(p);
|
|
123
|
+
let missing;
|
|
124
|
+
if (parents.length === 0)
|
|
125
|
+
missing = "parents";
|
|
126
|
+
else if (parents.length === 1)
|
|
127
|
+
missing = parents[0].sex === "F" ? "father" : parents[0].sex === "M" ? "mother" : "parents";
|
|
128
|
+
else if (!proven)
|
|
129
|
+
missing = "proof";
|
|
130
|
+
else
|
|
131
|
+
continue;
|
|
132
|
+
// ── whether the research reaches them ──
|
|
133
|
+
const est = birthEstimate(tree, p, lang);
|
|
134
|
+
const window = birthWindow(est);
|
|
135
|
+
const reach = all.filter((s) => (s.people.get(p.id) ?? 0) >= 1).sort((a, b) => a.people.size - b.people.size || a.research.id.localeCompare(b.research.id));
|
|
136
|
+
const limited = (s) => {
|
|
137
|
+
const l = s.research.limits;
|
|
138
|
+
return !!((l?.generations && s.people.get(p.id) >= l.generations) || (l?.before && est.year && est.year < l.before));
|
|
139
|
+
};
|
|
140
|
+
const active = reach.filter((s) => s.research.state === "active");
|
|
141
|
+
const through = active.find((s) => !limited(s)) ?? active[0] ?? reach.find((s) => s.research.state === "paused") ?? reach[0];
|
|
142
|
+
const scope = active.some((s) => !limited(s))
|
|
143
|
+
? "in"
|
|
144
|
+
: active.length
|
|
145
|
+
? "limit"
|
|
146
|
+
: through
|
|
147
|
+
? through.research.state
|
|
148
|
+
: livingBorn(tree, p) !== undefined
|
|
149
|
+
? "living"
|
|
150
|
+
: kin.size && !kin.has(p.id)
|
|
151
|
+
? "off-tree"
|
|
152
|
+
: "outside";
|
|
153
|
+
// ── the books of the birth, and the work on the parents ──
|
|
154
|
+
const placeBooks = est.place ? recordsetsCovering(tree, est.place, undefined, BIRTH_KINDS).filter((b) => !b.retracted) : [];
|
|
155
|
+
const books = placeBooks.filter((b) => !window || !b.years || (() => {
|
|
156
|
+
const y = yearsOf(b.years);
|
|
157
|
+
return !y || (y.from <= window.to && y.to >= window.from);
|
|
158
|
+
})());
|
|
159
|
+
const bookIds = new Set(books.map((b) => b.id));
|
|
160
|
+
const hyps = hypAbout.filter(({ about }) => about.includes(p.id)).map(({ h, about }) => ({ h, about }));
|
|
161
|
+
const hypIds = new Set(hyps.map(({ h }) => h.id));
|
|
162
|
+
const onParents = (t) => (t.subject.includes(p.id) && (FRONTIER_LEVELS.has(t.level) || ((t.level === "verify" || t.level === "enrich") && t.where.some((w) => bookIds.has(w))))) ||
|
|
163
|
+
t.subject.some((s) => hypIds.has(s));
|
|
164
|
+
const mine = tasks.filter(onParents);
|
|
165
|
+
const mineIds = new Set(mine.map((t) => t.id));
|
|
166
|
+
const surname = foldText(primaryName(p).surname);
|
|
167
|
+
const sought = searches.filter((q) => (q.task ? mineIds.has(q.task) : !!surname && (q.scope.surnames ?? []).some((s) => foldText(s) === surname) && q.recordsets.some((b) => bookIds.has(b))));
|
|
168
|
+
const edgeTasks = mine
|
|
169
|
+
.filter((t) => LIVE.has(effectiveState(t, today)))
|
|
170
|
+
.map((t) => {
|
|
171
|
+
const state = effectiveState(t, today);
|
|
172
|
+
const at = position.get(t.id);
|
|
173
|
+
const dir = directionOf(tree, t, all);
|
|
174
|
+
const dirState = dir ? researches.get(dir)?.state : undefined;
|
|
175
|
+
const held = state === "parked" ? "parked" : state !== "open" || at ? undefined : dirState === "paused" || dirState === "done" ? dirState : off(t) ? "off-tree" : undefined;
|
|
176
|
+
const on = state === "waiting" ? t.waitingOn : state === "parked" ? t.parkedReason : undefined;
|
|
177
|
+
return {
|
|
178
|
+
id: t.id,
|
|
179
|
+
level: t.level,
|
|
180
|
+
state,
|
|
181
|
+
what: t.what,
|
|
182
|
+
...(at && state === "open" ? { position: at } : {}),
|
|
183
|
+
...(held ? { held } : {}),
|
|
184
|
+
...(state === "parked" && t.parkedUntil ? { until: t.parkedUntil } : {}),
|
|
185
|
+
...(on ? { on } : {}),
|
|
186
|
+
};
|
|
187
|
+
})
|
|
188
|
+
.sort((a, b) => (a.position ?? 1e9) - (b.position ?? 1e9) || a.id.localeCompare(b.id));
|
|
189
|
+
const tried = mine.filter((t) => FINISHED.has(t.state)).map((t) => t.id);
|
|
190
|
+
const item = through && through.research.state === "active" ? frontierOf(through.research).get(p.id) : undefined;
|
|
191
|
+
const next = edgeTasks.some((t) => t.state === "doing")
|
|
192
|
+
? "working"
|
|
193
|
+
: edgeTasks.some((t) => t.state === "waiting")
|
|
194
|
+
? "waiting"
|
|
195
|
+
: edgeTasks.some((t) => t.position)
|
|
196
|
+
? "queued"
|
|
197
|
+
: edgeTasks.length
|
|
198
|
+
? "held"
|
|
199
|
+
: scope === "in" && item?.proposal
|
|
200
|
+
? "proposed"
|
|
201
|
+
: tried.length || item?.exhausted
|
|
202
|
+
? "decide"
|
|
203
|
+
: "none";
|
|
204
|
+
// ── what the records say so far: each year of the window searched in vain, without records, or open ──
|
|
205
|
+
const covered = [];
|
|
206
|
+
const noRecords = [];
|
|
207
|
+
const lostYears = [];
|
|
208
|
+
const open = [];
|
|
209
|
+
if (window && placeBooks.length)
|
|
210
|
+
for (let y = window.from; y <= window.to; y++) {
|
|
211
|
+
const of = books.filter((b) => within(y, yearsOf(b.years)));
|
|
212
|
+
const kept = of.filter((b) => b.access !== "lost");
|
|
213
|
+
if (!of.length)
|
|
214
|
+
noRecords.push(y);
|
|
215
|
+
else if (!kept.length)
|
|
216
|
+
lostYears.push(y);
|
|
217
|
+
else if (kept.every((b) => sought.some((q) => q.result === "negative" && q.recordsets.includes(b.id) && within(y, yearsOf(q.scope.years) ?? (q.scope.pages ? { from: 0, to: -1 } : yearsOf(b.years))))))
|
|
218
|
+
covered.push(y);
|
|
219
|
+
else
|
|
220
|
+
open.push({ year: y, online: kept.some((b) => b.access === "online-free" || b.access === "online-login") });
|
|
221
|
+
}
|
|
222
|
+
const earliest = Math.min(...placeBooks.map((b) => yearsOf(b.years)?.from ?? Infinity));
|
|
223
|
+
let end;
|
|
224
|
+
// the baptism is read and names the mother alone (a child born out of wedlock); a mother missing is one not recorded yet
|
|
225
|
+
if (missing === "father" && proven)
|
|
226
|
+
end = "unnamed";
|
|
227
|
+
else if (!est.place)
|
|
228
|
+
end = est.year ? "no-place" : "no-clue";
|
|
229
|
+
else if (!placeBooks.length)
|
|
230
|
+
end = "no-books";
|
|
231
|
+
else if (window && !open.length)
|
|
232
|
+
end = lostYears.length
|
|
233
|
+
? "lost"
|
|
234
|
+
: noRecords.length === window.to - window.from + 1 && earliest > window.to
|
|
235
|
+
? "before-records"
|
|
236
|
+
: noRecords.length
|
|
237
|
+
? noRecords.every((y) => y < earliest)
|
|
238
|
+
? "before-records"
|
|
239
|
+
: "gap"
|
|
240
|
+
: "not-found";
|
|
241
|
+
else if (item?.exhausted)
|
|
242
|
+
end = "not-found";
|
|
243
|
+
else if (window && open.every((o) => !o.online))
|
|
244
|
+
end = "offline";
|
|
245
|
+
else
|
|
246
|
+
end = sought.length || tried.length ? "partly" : "unsearched";
|
|
247
|
+
// ── the hypotheses, conflicts, and what the work cost ──
|
|
248
|
+
// a hypothesis that names people on and off the tree would join them: the people on the other side of it
|
|
249
|
+
const onTree = kin.has(p.id);
|
|
250
|
+
const edgeHyps = hyps.map(({ h, about }) => {
|
|
251
|
+
const joining = kin.size > 0 && about.some((x) => kin.has(x)) && about.some((x) => !kin.has(x));
|
|
252
|
+
const joins = joining ? about.filter((x) => kin.has(x) !== onTree) : [];
|
|
253
|
+
const isle = joins.length && onTree ? isles.get(joins[0]) : undefined;
|
|
254
|
+
return {
|
|
255
|
+
id: h.id,
|
|
256
|
+
question: h.question,
|
|
257
|
+
joins,
|
|
258
|
+
...(isle ? { island: { people: isle.people.length, held: isle.held } } : {}),
|
|
259
|
+
tests: tasks.filter((t) => LIVE.has(t.state) && t.subject.includes(h.id)).map((t) => t.id),
|
|
260
|
+
};
|
|
261
|
+
});
|
|
262
|
+
const edgeConflicts = conflicts
|
|
263
|
+
.filter((c) => subjectPeople(tree, c.subject).includes(p.id) && (!c.fact || EDGE_FACTS.has(c.fact)))
|
|
264
|
+
.map((c) => c.id);
|
|
265
|
+
const spent = sessions.filter((s) => s.task && mineIds.has(s.task));
|
|
266
|
+
const cost = spent.reduce((n, s) => n + (s.metrics?.costUsd ?? 0), 0);
|
|
267
|
+
const partial = spent.some((s) => s.state !== "open" && (s.metrics?.costPartial || s.metrics?.costUsd === undefined));
|
|
268
|
+
const last = [...spent.map((s) => s.ended ?? s.started), ...sought.map((q) => q.created)].sort().pop()?.slice(0, 10);
|
|
269
|
+
const known = scope === "in" || scope === "limit" || scope === "paused" || scope === "done" || edgeTasks.length || tried.length || edgeHyps.length || sought.length || edgeConflicts.length;
|
|
270
|
+
if (!known)
|
|
271
|
+
continue;
|
|
272
|
+
edges.set(p.id, {
|
|
273
|
+
person: p.id,
|
|
274
|
+
missing,
|
|
275
|
+
scope,
|
|
276
|
+
...(through ? { research: through.research.id, generation: through.people.get(p.id) } : {}),
|
|
277
|
+
end,
|
|
278
|
+
next,
|
|
279
|
+
...(est.year || est.place ? { estimate: { ...(est.year ? { year: est.year } : {}), ...(est.place ? { place: est.place } : {}), ...(est.basis ? { basis: est.basis } : {}) } } : {}),
|
|
280
|
+
...(window ? { window } : {}),
|
|
281
|
+
...(Number.isFinite(earliest) && (end === "before-records" || noRecords.some((y) => y < earliest)) ? { recordsFrom: earliest } : {}),
|
|
282
|
+
books: books.map((b) => ({ id: b.id, title: b.title, ...(b.years ? { years: b.years } : {}), access: b.access })),
|
|
283
|
+
covered: ranges(covered),
|
|
284
|
+
noRecords: ranges(noRecords),
|
|
285
|
+
tasks: edgeTasks,
|
|
286
|
+
tried,
|
|
287
|
+
hypotheses: edgeHyps,
|
|
288
|
+
conflicts: edgeConflicts,
|
|
289
|
+
searches: sought.length,
|
|
290
|
+
sessions: spent.length,
|
|
291
|
+
...(spent.length ? { cost: Math.round(cost * 100) / 100, ...(partial ? { costPartial: true } : {}) } : {}),
|
|
292
|
+
...(last ? { last } : {}),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
return { edges, islands: isles };
|
|
296
|
+
}
|
package/dist/core/frontier.js
CHANGED
|
@@ -7,8 +7,8 @@ import { foldText } from "./text.js";
|
|
|
7
7
|
import { phrase } from "./phrases.js";
|
|
8
8
|
const OPEN_STATES = new Set(["open", "doing", "parked", "waiting"]);
|
|
9
9
|
const FINISHED_STATES = new Set(["done", "dropped"]);
|
|
10
|
-
const FRONTIER_LEVELS = new Set(["locate", "link", "request"]);
|
|
11
|
-
function birthRecordProven(p) {
|
|
10
|
+
export const FRONTIER_LEVELS = new Set(["locate", "link", "request"]);
|
|
11
|
+
export function birthRecordProven(p) {
|
|
12
12
|
return p.events.some((e) => ["BIRT", "CHR", "BAPM"].includes(e.kind) && (e.status === "proven" || e.status === "probable") && !e.retracted);
|
|
13
13
|
}
|
|
14
14
|
/** The kinds of record set in which an event of this kind is written down. */
|
|
@@ -120,7 +120,15 @@ export function birthEstimate(tree, p, lang) {
|
|
|
120
120
|
const year = ownYear ?? (marrYear ? marrYear - 25 : eldest ? eldest.year - 28 : undefined);
|
|
121
121
|
const place = own?.place ?? marr?.place ?? eldest?.b?.place;
|
|
122
122
|
const from = ownYear ? undefined : marrYear ? phrase(lang, "estimate.marriage", { year: marrYear }) : eldest ? phrase(lang, "estimate.child", { year: eldest.year }) : undefined;
|
|
123
|
-
|
|
123
|
+
const basis = ownYear || !year ? undefined : marrYear ? { kind: "MARR", year: marrYear } : { kind: "child", year: eldest.year };
|
|
124
|
+
return { ...(year ? { year } : {}), ...(place ? { place } : {}), ...(from ? { from } : {}), ...(basis ? { basis } : {}) };
|
|
125
|
+
}
|
|
126
|
+
/** The years a search for a birth has to cover: a few around a known year, wider around an estimate. */
|
|
127
|
+
export function birthWindow(est) {
|
|
128
|
+
if (!est.year)
|
|
129
|
+
return undefined;
|
|
130
|
+
const span = est.from ? 10 : 3;
|
|
131
|
+
return { from: est.year - span, to: est.year + span };
|
|
124
132
|
}
|
|
125
133
|
/**
|
|
126
134
|
* The people a research is about, each with their generation from its focus:
|
|
@@ -202,7 +210,7 @@ export function frontier(tree, research) {
|
|
|
202
210
|
const tried = new Set(finished.filter((t) => t.level === "link").flatMap((t) => t.where));
|
|
203
211
|
const place = est.place;
|
|
204
212
|
// an estimate needs a wider search than a known year
|
|
205
|
-
const span = est.from ? 10 : 3;
|
|
213
|
+
const span = est.from ? 10 : 3; // as birthWindow
|
|
206
214
|
const about = est.from && year ? phrase(lang, "born.about", { year, from: est.from }) : "";
|
|
207
215
|
const reason = phrase(lang, hasBirthFamily ? "reason.unproven" : "reason.unknown");
|
|
208
216
|
const sets = candidateRecordsets(tree, place, year).filter((b) => !tried.has(b.id));
|
package/dist/core/kin.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// app opens on them), and how near anyone is to the people the research is
|
|
3
3
|
// for (which entries get their image in the Strom app first).
|
|
4
4
|
import { Settings } from "./config.js";
|
|
5
|
+
import { aboutPeople } from "./directions.js";
|
|
5
6
|
import { ancestorGenerations, familiesAsChild, familiesAsPartner } from "./people.js";
|
|
6
7
|
function researches(tree) {
|
|
7
8
|
return tree
|
|
@@ -57,7 +58,7 @@ export function mainPerson(tree) {
|
|
|
57
58
|
* their ancestors 0, the partners, children and children's partners of those 1, anyone linked by family further 2.
|
|
58
59
|
*/
|
|
59
60
|
export function kinship(tree) {
|
|
60
|
-
const roots = [...new Set([...researches(tree).
|
|
61
|
+
const roots = [...new Set([...researches(tree).flatMap((r) => [r.focus, ...(r.review?.people ?? [])]), mainPerson(tree)].filter((x) => !!x))];
|
|
61
62
|
const out = new Map();
|
|
62
63
|
for (const r of roots)
|
|
63
64
|
for (const id of ancestorGenerations(tree, r).keys())
|
|
@@ -88,3 +89,41 @@ export function kinship(tree) {
|
|
|
88
89
|
}
|
|
89
90
|
return out;
|
|
90
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Work about people nothing links to the tree yet: none of the people a task is about (with those of the conflicts
|
|
94
|
+
* and hypotheses it names) is kin of the people the research is for. It waits out of the queue until the user asks
|
|
95
|
+
* for it (a direction or a review of one of them makes them the research's) or a hypothesis that joins them to the
|
|
96
|
+
* tree is decided and recorded. A task that tests that hypothesis names it (--about H…) or the person of the tree
|
|
97
|
+
* it would join, and so is in the queue. The user's material (intake) and work at hand never wait; a tree with
|
|
98
|
+
* nobody to be for holds nothing.
|
|
99
|
+
*/
|
|
100
|
+
export function offTree(tree) {
|
|
101
|
+
const kin = kinship(tree);
|
|
102
|
+
if (!kin.size)
|
|
103
|
+
return () => false;
|
|
104
|
+
return (t) => {
|
|
105
|
+
if (t.level === "intake" || t.state === "doing")
|
|
106
|
+
return false;
|
|
107
|
+
const about = aboutPeople(tree, t.subject);
|
|
108
|
+
return about.length > 0 && !about.some((p) => kin.has(p));
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The open hypotheses that would join people off the tree to it: each names someone of the tree and someone of
|
|
113
|
+
* their family (the people family links join to them) — the work to do before any work on them.
|
|
114
|
+
*/
|
|
115
|
+
export function joiningHypotheses(tree, people) {
|
|
116
|
+
const kin = kinship(tree);
|
|
117
|
+
const family = new Set();
|
|
118
|
+
const queue = people.filter((p) => !kin.has(p));
|
|
119
|
+
while (queue.length) {
|
|
120
|
+
const p = queue.shift();
|
|
121
|
+
if (family.has(p))
|
|
122
|
+
continue;
|
|
123
|
+
family.add(p);
|
|
124
|
+
queue.push(...familiesAsPartner(tree, p).flatMap((f) => [...f.partners, ...f.children.map((c) => c.person)]), ...familiesAsChild(tree, p).flatMap((f) => f.partners));
|
|
125
|
+
}
|
|
126
|
+
return tree
|
|
127
|
+
.list("hypothesis")
|
|
128
|
+
.filter((h) => h.state === "open" && h.subject.some((s) => family.has(s)) && h.subject.some((s) => kin.has(s)));
|
|
129
|
+
}
|
package/dist/core/live.js
CHANGED
|
@@ -49,7 +49,7 @@ import { rankTasks } from "./queue.js";
|
|
|
49
49
|
import { humanTask } from "../cli/human.js";
|
|
50
50
|
import { knownNewerVersion } from "./update.js";
|
|
51
51
|
import { gitProgram, runGit } from "./git.js";
|
|
52
|
-
import { appOpensLinks, isStromAppOrigin } from "./stromapp.js";
|
|
52
|
+
import { appOpensLinks, appShowsEdges, isStromAppOrigin } from "./stromapp.js";
|
|
53
53
|
import { Settings } from "./config.js";
|
|
54
54
|
import { linkActions, linkHandlerState } from "./links.js";
|
|
55
55
|
import { stromLauncher } from "./self.js";
|
|
@@ -308,6 +308,37 @@ function whoAtWork(label, lang) {
|
|
|
308
308
|
const chat = /^(.+) conversation$/.exec(label);
|
|
309
309
|
return chat ? phrase(lang, "who.chat", { agent: chat[1] }) : label;
|
|
310
310
|
}
|
|
311
|
+
/**
|
|
312
|
+
* The person an agent works on, for the app to follow it in the tree: the first person its task is about (a marriage
|
|
313
|
+
* names both, the one it asks about first), else the last one its session wrote about.
|
|
314
|
+
*/
|
|
315
|
+
function workedOn(root, tree, s, task) {
|
|
316
|
+
const known = (id) => /^P\d{1,7}$/.test(id) && tree.get(id) !== undefined;
|
|
317
|
+
const first = (task?.subject ?? []).find(known);
|
|
318
|
+
if (first)
|
|
319
|
+
return { person: first };
|
|
320
|
+
const file = path.join(root, "data", "ops", `${s.id}.jsonl`);
|
|
321
|
+
let lines = [];
|
|
322
|
+
try {
|
|
323
|
+
lines = fs.readFileSync(file, "utf8").split("\n");
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
return {};
|
|
327
|
+
}
|
|
328
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
329
|
+
let op;
|
|
330
|
+
try {
|
|
331
|
+
op = JSON.parse(lines[i]);
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
continue; // empty, or a line being written
|
|
335
|
+
}
|
|
336
|
+
const person = (op.targets ?? []).findLast(known);
|
|
337
|
+
if (person)
|
|
338
|
+
return { person };
|
|
339
|
+
}
|
|
340
|
+
return {};
|
|
341
|
+
}
|
|
311
342
|
/** Who is at work now, on which task (an agent strom started, and its open session). */
|
|
312
343
|
function working(root, tree, all) {
|
|
313
344
|
const open = openSessions(tree);
|
|
@@ -319,7 +350,7 @@ function working(root, tree, all) {
|
|
|
319
350
|
who: whoAtWork(w.label, tree.lang),
|
|
320
351
|
since: w.since,
|
|
321
352
|
...(w.paused ? { paused: w.paused } : {}),
|
|
322
|
-
...(s ? { session: s.id, ...(s.task ? { task: task ? `${task.id} ${task.what}` : s.task
|
|
353
|
+
...(s ? { session: s.id, ...(s.task ? { task: task ? `${task.id} ${task.what}` : s.task } : {}), ...workedOn(root, tree, s, task) } : {}),
|
|
323
354
|
...(research ? { research } : {}),
|
|
324
355
|
};
|
|
325
356
|
});
|
|
@@ -533,11 +564,13 @@ export function serveLive(root, env) {
|
|
|
533
564
|
const images = set ? planExcerpts(tree, set.shared, { quality: set.quality, for: set.for, maxBytes: set.mb * 1024 * 1024, budgetMs: LIVE_IMAGES_MS }) : undefined;
|
|
534
565
|
// an app that opens strom-research:// links: each excerpt's mark, and the links this computer takes
|
|
535
566
|
const opens = appOpensLinks(new Settings(env, {}));
|
|
567
|
+
// …and where the tree ends, for an app that shows it
|
|
568
|
+
const edges = appShowsEdges(new Settings(env, {}));
|
|
536
569
|
const offered = opens ? links(env) : [];
|
|
537
570
|
ged = {
|
|
538
571
|
head: h,
|
|
539
572
|
links: offered.join(" "),
|
|
540
|
-
text: exportGedcom(tree, { for: "strom", ...(h ? { head: h } : {}), ...(images ? { excerpts: images.of } : {}), ...(opens ? { clips: true, research: true } : {}), ...(offered.length ? { links: offered } : {}) }).text,
|
|
573
|
+
text: exportGedcom(tree, { for: "strom", ...(h ? { head: h } : {}), ...(images ? { excerpts: images.of } : {}), ...(opens ? { clips: true, research: true } : {}), ...(edges ? { edges } : {}), ...(offered.length ? { links: offered } : {}) }).text,
|
|
541
574
|
};
|
|
542
575
|
}
|
|
543
576
|
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", "X-Strom-Head": h }).end(ged.text);
|
package/dist/core/queue.js
CHANGED
|
@@ -6,13 +6,16 @@
|
|
|
6
6
|
// after work that waits for a download: they are never what a session is
|
|
7
7
|
// spent on instead of the research. Working alone (strom run) they still get
|
|
8
8
|
// their turn: after STORY_EVERY sessions without one, a story first. In a
|
|
9
|
-
// conversation the user leads, and a story is written when they want it.
|
|
9
|
+
// conversation the user leads, and a story is written when they want it. Work
|
|
10
|
+
// about people nothing links to the tree waits until the user asks for it or a
|
|
11
|
+
// hypothesis joins them (core/kin.ts offTree). Strategies (setting queue.strategy): balanced (the default), depth
|
|
10
12
|
// (stay on the line of the last sessions), priority (strict priority).
|
|
11
13
|
import { parentsOf } from "./people.js";
|
|
12
14
|
import { taskRecordsets } from "./frontier.js";
|
|
13
15
|
import { now } from "./tree.js";
|
|
14
16
|
import { subjectPeople } from "./records.js";
|
|
15
17
|
import { directionOf, scopes } from "./directions.js";
|
|
18
|
+
import { offTree } from "./kin.js";
|
|
16
19
|
export const LEVEL_ORDER = { intake: 0, locate: 1, link: 2, verify: 3, enrich: 4, request: 5, narrate: 6 };
|
|
17
20
|
/** Levels that read records: without images of their record sets they can only ask the user for them. */
|
|
18
21
|
const NEEDS_IMAGES = new Set(["link", "verify"]);
|
|
@@ -57,8 +60,10 @@ export function rankTasks(tree, tasks, strategy = "balanced", opts = {}) {
|
|
|
57
60
|
const researches = tree.list("research");
|
|
58
61
|
// a direction the user paused or ended: its tasks wait (one at work goes on)
|
|
59
62
|
const stopped = new Set(researches.filter((r) => r.state !== "active").map((r) => r.id));
|
|
60
|
-
const all =
|
|
61
|
-
|
|
63
|
+
const all = scopes(tree);
|
|
64
|
+
// work about people nothing links to the tree waits for the user or for the hypothesis that joins them (core/kin.ts)
|
|
65
|
+
const off = offTree(tree);
|
|
66
|
+
const waits = (t) => t.state !== "doing" && ((stopped.size > 0 && stopped.has(directionOf(tree, t, all) ?? "")) || off(t));
|
|
62
67
|
const open = tasks.filter((t) => ["open", "doing"].includes(effectiveState(t, today)) && !waits(t));
|
|
63
68
|
const withImages = new Set(tree.list("media").map((m) => m.recordset));
|
|
64
69
|
const lines = new Map();
|
|
@@ -72,7 +77,8 @@ export function rankTasks(tree, tasks, strategy = "balanced", opts = {}) {
|
|
|
72
77
|
};
|
|
73
78
|
/** The nearest ancestor a task is about: its generation and line. */
|
|
74
79
|
const place = (t) => {
|
|
75
|
-
|
|
80
|
+
// a task of no research of its own: the direction its people are in (directions.ts), as the menu and the app say
|
|
81
|
+
const map = linesOf(t.research ?? directionOf(tree, t, all));
|
|
76
82
|
const found = [...t.subject, ...subjectPeople(tree, t.subject)].map((s) => map?.get(s)).filter((x) => !!x);
|
|
77
83
|
return found.sort((a, b) => a.gen - b.gen)[0];
|
|
78
84
|
};
|
package/dist/core/stromapp.js
CHANGED
|
@@ -60,6 +60,28 @@ export function appOpensLinks(settings, version) {
|
|
|
60
60
|
return (a[i] ?? 0) > (b[i] ?? 0);
|
|
61
61
|
return true;
|
|
62
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* The Strom app shows where the tree ends (the app's spec docs/ZADANI_VYZKUM_kraj-stromu.md): per person above whom
|
|
65
|
+
* the tree does not go on, what the research knows there (_STROM_EDGE), and the families nothing links to the tree
|
|
66
|
+
* (_STROM_ISLAND). The first version of stromapp.info that does (3.6.0, 2026-09-30); another copy of the app (its
|
|
67
|
+
* beta, its development: strom.app.url) is taken as current. An app of a known version (strom.version) goes by it;
|
|
68
|
+
* an unknown one is taken as today's (an older app only skips the tags).
|
|
69
|
+
*/
|
|
70
|
+
export const APP_SHOWS_EDGES = "3.6.0";
|
|
71
|
+
export function appShowsEdges(settings, version) {
|
|
72
|
+
if (stromAppUrl(settings) !== STROM_APP_URL)
|
|
73
|
+
return true;
|
|
74
|
+
if (!APP_SHOWS_EDGES)
|
|
75
|
+
return false;
|
|
76
|
+
if (!version)
|
|
77
|
+
return true;
|
|
78
|
+
const n = (v) => v.split(".").map((x) => Number.parseInt(x, 10) || 0);
|
|
79
|
+
const [a, b] = [n(version), n(APP_SHOWS_EDGES)];
|
|
80
|
+
for (let i = 0; i < 3; i++)
|
|
81
|
+
if ((a[i] ?? 0) !== (b[i] ?? 0))
|
|
82
|
+
return (a[i] ?? 0) > (b[i] ?? 0);
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
63
85
|
/** The address that opens the Strom app to send the user's edited tree to the bridge. */
|
|
64
86
|
export function sendAppUrl(bridge, settings) {
|
|
65
87
|
return `${stromAppUrl(settings)}?send=${encodeURIComponent(bridge)}`;
|
package/dist/core/tree.js
CHANGED
|
@@ -23,7 +23,7 @@ import * as git from "./git.js";
|
|
|
23
23
|
import { commitSeal, createKey, fingerprint, loadKey, sha256, signOp } from "./seal.js";
|
|
24
24
|
import { stringifyCanonical } from "./json.js";
|
|
25
25
|
export const TREE_FILE = "strom.json";
|
|
26
|
-
export const VERSION = "1.
|
|
26
|
+
export const VERSION = "1.9.0";
|
|
27
27
|
const GITIGNORE = `# Strom Research — local state and large files are not versioned
|
|
28
28
|
.strom/
|
|
29
29
|
/media/
|
package/dist/gedcom/export.js
CHANGED
|
@@ -35,6 +35,8 @@ import { VERSION } from "../core/tree.js";
|
|
|
35
35
|
import { dataUrl } from "../core/excerpt.js";
|
|
36
36
|
import { mainPerson } from "../core/kin.js";
|
|
37
37
|
import * as git from "../core/git.js";
|
|
38
|
+
import { treeEdges } from "../core/edge.js";
|
|
39
|
+
import { humanTask } from "../cli/human.js";
|
|
38
40
|
export const GED_PROFILES = ["standard", "strom"];
|
|
39
41
|
/** The first Strom version that reads AGE, CAUS, ADDR, _FREL/_MREL and more source notes itself (not released yet). */
|
|
40
42
|
export const STROM_READS_TAGS = undefined;
|
|
@@ -135,6 +137,8 @@ export function exportGedcom(tree, opts = {}) {
|
|
|
135
137
|
.filter((x) => x.people.length)
|
|
136
138
|
: [],
|
|
137
139
|
};
|
|
140
|
+
// Where the tree ends, and the families nothing links to it (the Strom profile, opts.edges).
|
|
141
|
+
const ends = opts.edges && opts.for === "strom" ? treeEdges(tree) : undefined;
|
|
138
142
|
// ── header ──
|
|
139
143
|
w.line(0, "HEAD");
|
|
140
144
|
w.line(1, "SOUR", "STROM_RESEARCH");
|
|
@@ -203,6 +207,14 @@ export function exportGedcom(tree, opts = {}) {
|
|
|
203
207
|
story(p.story);
|
|
204
208
|
if (opts.research && opts.for === "strom")
|
|
205
209
|
known(p);
|
|
210
|
+
if (ends) {
|
|
211
|
+
const e = ends.edges.get(p.id);
|
|
212
|
+
if (e)
|
|
213
|
+
edge(e);
|
|
214
|
+
const isle = ends.islands.get(p.id);
|
|
215
|
+
if (isle)
|
|
216
|
+
island(isle);
|
|
217
|
+
}
|
|
206
218
|
for (const link of famc.get(p.id) ?? []) {
|
|
207
219
|
w.line(1, "FAMC", x(link.fam));
|
|
208
220
|
if (link.relation === "adopted" || link.relation === "foster")
|
|
@@ -457,7 +469,7 @@ export function exportGedcom(tree, opts = {}) {
|
|
|
457
469
|
w.text(2, "DECI", c.resolution);
|
|
458
470
|
}
|
|
459
471
|
for (const h of research.hypotheses.filter((x) => about(x.subject))) {
|
|
460
|
-
w.line(1, "_STROM_HYPO");
|
|
472
|
+
w.line(1, "_STROM_HYPO", h.id);
|
|
461
473
|
w.text(2, "TITL", h.question);
|
|
462
474
|
w.text(2, "NOTE", h.variants.map((v) => `${v.label}: ${v.claim}`).join("\n"));
|
|
463
475
|
}
|
|
@@ -471,6 +483,98 @@ export function exportGedcom(tree, opts = {}) {
|
|
|
471
483
|
w.line(2, "_AT", q.created.slice(0, 10));
|
|
472
484
|
}
|
|
473
485
|
}
|
|
486
|
+
/** Years as a GEDCOM date: one year, or FROM … TO …. */
|
|
487
|
+
function years(r) {
|
|
488
|
+
return r.from === r.to ? String(r.from) : `FROM ${r.from} TO ${r.to}`;
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Where the tree ends above a person: what is missing, whether the research reaches them, what the records say
|
|
492
|
+
* so far, what comes next — and what that rests on (core/edge.ts; the app's ZADANI_VYZKUM_kraj-stromu.md).
|
|
493
|
+
*/
|
|
494
|
+
function edge(e) {
|
|
495
|
+
w.line(1, "_STROM_EDGE", e.missing);
|
|
496
|
+
w.line(2, "_SCOPE", e.scope);
|
|
497
|
+
if (e.research)
|
|
498
|
+
w.line(2, "_RESEARCH", e.research);
|
|
499
|
+
if (e.generation !== undefined)
|
|
500
|
+
w.line(2, "_GEN", String(e.generation));
|
|
501
|
+
w.line(2, "_END", e.end);
|
|
502
|
+
w.line(2, "_NEXT", e.next);
|
|
503
|
+
if (e.estimate) {
|
|
504
|
+
w.line(2, "_EST", e.estimate.year ? String(e.estimate.year) : "");
|
|
505
|
+
if (e.estimate.place)
|
|
506
|
+
w.text(3, "PLAC", e.estimate.place);
|
|
507
|
+
if (e.estimate.basis)
|
|
508
|
+
w.line(3, "_BASIS", `${e.estimate.basis.kind} ${e.estimate.basis.year}`);
|
|
509
|
+
}
|
|
510
|
+
if (e.window)
|
|
511
|
+
w.line(2, "DATE", years(e.window));
|
|
512
|
+
if (e.recordsFrom)
|
|
513
|
+
w.line(2, "_RECORDS", String(e.recordsFrom));
|
|
514
|
+
for (const b of e.books) {
|
|
515
|
+
w.line(2, "_BOOK", b.id);
|
|
516
|
+
w.text(3, "TITL", b.title);
|
|
517
|
+
const y = /^(\d{3,4})(?:\s*[-–]\s*(\d{3,4}))?$/.exec(b.years ?? "");
|
|
518
|
+
if (y)
|
|
519
|
+
w.line(3, "DATE", years({ from: Number(y[1]), to: Number(y[2] ?? y[1]) }));
|
|
520
|
+
w.line(3, "_ACCESS", b.access);
|
|
521
|
+
}
|
|
522
|
+
for (const r of e.covered)
|
|
523
|
+
w.line(2, "_COVERED", years(r));
|
|
524
|
+
for (const r of e.noRecords)
|
|
525
|
+
w.line(2, "_NORECORDS", years(r));
|
|
526
|
+
for (const t of e.tasks) {
|
|
527
|
+
w.line(2, "_TASK", t.id);
|
|
528
|
+
w.line(3, "_LEVEL", t.level);
|
|
529
|
+
w.line(3, "STAT", t.state);
|
|
530
|
+
w.text(3, "TITL", humanTask(tree, t.what, lang));
|
|
531
|
+
if (t.position)
|
|
532
|
+
w.line(3, "_POS", String(t.position));
|
|
533
|
+
if (t.held)
|
|
534
|
+
w.line(3, "_HELD", t.held);
|
|
535
|
+
if (t.until)
|
|
536
|
+
w.line(3, "_UNTIL", t.until);
|
|
537
|
+
if (t.on)
|
|
538
|
+
w.text(3, "NOTE", t.on);
|
|
539
|
+
}
|
|
540
|
+
for (const id of e.tried)
|
|
541
|
+
w.line(2, "_TRIED", id);
|
|
542
|
+
for (const h of e.hypotheses) {
|
|
543
|
+
w.line(2, "_HYPO", h.id);
|
|
544
|
+
for (const j of h.joins)
|
|
545
|
+
w.line(3, "_JOIN", j);
|
|
546
|
+
if (h.island) {
|
|
547
|
+
w.line(3, "_ISLAND", String(h.island.people));
|
|
548
|
+
w.line(3, "_HELD", String(h.island.held));
|
|
549
|
+
}
|
|
550
|
+
for (const t of h.tests)
|
|
551
|
+
w.line(3, "_TEST", t);
|
|
552
|
+
}
|
|
553
|
+
for (const c of e.conflicts)
|
|
554
|
+
w.line(2, "_CONFLICT", c);
|
|
555
|
+
if (e.searches)
|
|
556
|
+
w.line(2, "_SEARCHES", String(e.searches));
|
|
557
|
+
if (e.sessions) {
|
|
558
|
+
w.line(2, "_SESSIONS", String(e.sessions));
|
|
559
|
+
if (e.cost !== undefined)
|
|
560
|
+
w.line(3, "_COST", e.cost.toFixed(2));
|
|
561
|
+
if (e.costPartial)
|
|
562
|
+
w.line(3, "_PARTIAL", "Y");
|
|
563
|
+
}
|
|
564
|
+
if (e.last)
|
|
565
|
+
w.line(2, "_LAST", e.last);
|
|
566
|
+
}
|
|
567
|
+
/** A person of a family nothing links to the tree: how many people it has, the hypotheses that would join it, the tasks waiting for it. */
|
|
568
|
+
function island(i) {
|
|
569
|
+
w.line(1, "_STROM_ISLAND", String(i.people.length));
|
|
570
|
+
for (const h of i.hypotheses) {
|
|
571
|
+
w.line(2, "_HYPO", h.id);
|
|
572
|
+
for (const j of h.joins)
|
|
573
|
+
w.line(3, "_JOIN", j);
|
|
574
|
+
}
|
|
575
|
+
if (i.held)
|
|
576
|
+
w.line(2, "_HELD", String(i.held));
|
|
577
|
+
}
|
|
474
578
|
function story(st) {
|
|
475
579
|
if (!st)
|
|
476
580
|
return;
|
package/dist/gedcom/validate.js
CHANGED
|
@@ -18,10 +18,12 @@ const CHILDREN = {
|
|
|
18
18
|
"HEAD.SOUR": ["VERS", "NAME", "CORP", "DATA"],
|
|
19
19
|
"HEAD.GEDC": ["VERS", "FORM"],
|
|
20
20
|
"HEAD.DATE": ["TIME"],
|
|
21
|
-
INDI: ["NAME", "SEX", "FAMC", "FAMS", "NOTE", "SOUR", "REFN", "ASSO", "ALIA", "OBJE", "RESN", "RIN", "CHAN", "_STORY", "_STROM_CONFLICT", "_STROM_HYPO", "_STROM_SEARCHED", ...INDI_EVENTS],
|
|
21
|
+
INDI: ["NAME", "SEX", "FAMC", "FAMS", "NOTE", "SOUR", "REFN", "ASSO", "ALIA", "OBJE", "RESN", "RIN", "CHAN", "_STORY", "_STROM_CONFLICT", "_STROM_HYPO", "_STROM_SEARCHED", "_STROM_EDGE", "_STROM_ISLAND", ...INDI_EVENTS],
|
|
22
22
|
"INDI._STROM_CONFLICT": ["TYPE", "TITL", "STAT", "VAL", "DECI"],
|
|
23
23
|
"INDI._STROM_HYPO": ["TITL", "NOTE"],
|
|
24
24
|
"INDI._STROM_SEARCHED": ["TITL", "DATE", "RESN", "_AT"],
|
|
25
|
+
"INDI._STROM_EDGE": ["_SCOPE", "_RESEARCH", "_GEN", "_END", "_NEXT", "_EST", "DATE", "_RECORDS", "_BOOK", "_COVERED", "_NORECORDS", "_TASK", "_TRIED", "_HYPO", "_CONFLICT", "_SEARCHES", "_SESSIONS", "_LAST"],
|
|
26
|
+
"INDI._STROM_ISLAND": ["_HYPO", "_HELD"],
|
|
25
27
|
_STROM_VALUE: ["SOUR"],
|
|
26
28
|
"INDI.NAME": ["TYPE", "GIVN", "SURN", "NPFX", "NSFX", "NICK", "SPFX", "SOUR", "NOTE"],
|
|
27
29
|
"INDI.FAMC": ["PEDI", "NOTE"],
|
|
@@ -50,6 +52,10 @@ const TEXT_TAGS = new Set(["NOTE", "TEXT", "TITL", "PAGE", "AUTH", "PUBL", "_STO
|
|
|
50
52
|
const EXTENSIONS = new Set([
|
|
51
53
|
"_STORY", "_WITN", "_FREL", "_MREL", "_STROM_KIND", "_URL", "_STROM_TREE", "_STROM_HEAD", "_STROM_LINKS", "_STROM_CLIP",
|
|
52
54
|
"_STROM_ASOF", "_STROM_CONFLICT", "_STROM_HYPO", "_STROM_SEARCHED", "_AT",
|
|
55
|
+
// where the tree ends (_STROM_EDGE) and the families nothing links to it (_STROM_ISLAND), with their parts
|
|
56
|
+
"_STROM_EDGE", "_STROM_ISLAND", "_SCOPE", "_RESEARCH", "_GEN", "_END", "_NEXT", "_EST", "_BASIS", "_RECORDS", "_BOOK", "_ACCESS",
|
|
57
|
+
"_COVERED", "_NORECORDS", "_TASK", "_LEVEL", "_POS", "_HELD", "_UNTIL", "_TRIED", "_HYPO", "_JOIN", "_ISLAND", "_TEST", "_CONFLICT",
|
|
58
|
+
"_SEARCHES", "_SESSIONS", "_COST", "_PARTIAL", "_LAST",
|
|
53
59
|
]);
|
|
54
60
|
/** Context of the children of the last tag in `anc` (the ancestors of a line). */
|
|
55
61
|
function contextOf(anc) {
|