strom-research 1.13.1 → 1.13.2-beta.2
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/lang/cs.json +7 -0
- package/assets/lang/de.json +7 -0
- package/assets/method/core.md +13 -0
- package/assets/method/recording.md +4 -1
- package/dist/cli/menu-links.js +26 -3
- package/dist/cli/ui.js +3 -0
- package/dist/commands/analysis.js +324 -14
- package/dist/commands/links.js +3 -2
- package/dist/commands/output.js +6 -2
- package/dist/commands/session.js +5 -2
- package/dist/core/changelog.js +2 -0
- package/dist/core/conflicts.js +105 -0
- package/dist/core/directions.js +26 -5
- package/dist/core/edge.js +48 -7
- package/dist/core/links.js +8 -2
- package/dist/core/live.js +168 -5
- package/dist/core/model.js +7 -0
- package/dist/core/phrases.js +4 -0
- package/dist/core/review.js +8 -0
- package/dist/core/schema.js +26 -3
- package/dist/core/stromapp.js +45 -0
- package/dist/core/sync.js +38 -5
- package/dist/core/tree.js +1 -1
- package/dist/core/validate.js +39 -0
- package/dist/gedcom/export.js +77 -5
- package/dist/gedcom/lines.js +1 -1
- package/dist/gedcom/validate.js +14 -4
- package/package.json +4 -1
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 { appKnowsArchive, appKnowsNoCouple, appOpensLinks, appReadsTitles, appShowsCoupleEvents, appShowsEdges, appShowsSourceReads, appShowsFactStatus, appShowsStoryDrafts, appTurnsExcerpts } from "../core/stromapp.js";
|
|
16
|
+
import { appKnowsArchive, appKnowsNoCouple, appShowsHypothesisLinks, appDecidesConflicts, appOpensLinks, appReadsTitles, appShowsCoupleEvents, appShowsEdges, appShowsSourceReads, appShowsFactStatus, appShowsStoryDrafts, appTurnsExcerpts } 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]));
|
|
@@ -85,7 +85,11 @@ export function writeTreeGed(tree, opts) {
|
|
|
85
85
|
const noCouple = opts.for === "strom" && appKnowsNoCouple(new Settings(tree.env, {}), opts.stromVersion);
|
|
86
86
|
const archive = opts.for === "strom" && appKnowsArchive(new Settings(tree.env, {}), opts.stromVersion);
|
|
87
87
|
const titles = opts.for === "strom" && appReadsTitles(new Settings(tree.env, {}), opts.stromVersion);
|
|
88
|
-
|
|
88
|
+
// …and what each variant of a hypothesis would connect, for an app that shows it
|
|
89
|
+
const hypothesisLinks = opts.for === "strom" && appShowsHypothesisLinks(new Settings(tree.env, {}), opts.stromVersion);
|
|
90
|
+
// …and the sides of a conflict of the user's edit, for an app that decides it
|
|
91
|
+
const conflictSides = opts.for === "strom" && appDecidesConflicts(new Settings(tree.env, {}), opts.stromVersion);
|
|
92
|
+
const result = exportGedcom(tree, { for: opts.for, stromVersion: opts.stromVersion, ...(persons ? { persons } : {}), ...(images ? { excerpts: images.of } : {}), ...(clips ? { clips } : {}), ...(research ? { research } : {}), ...(edges ? { edges } : {}), ...(storyDrafts ? { storyDrafts } : {}), ...(coupleResi ? { coupleResi } : {}), ...(sourceReads ? { sourceReads } : {}), ...(factStatus ? { factStatus } : {}), ...(archive ? { archive } : {}), ...(turnsExcerpts ? { turnsExcerpts } : {}), ...(noCouple ? { noCouple } : {}), ...(titles ? { titles } : {}), ...(hypothesisLinks ? { hypothesisLinks } : {}), ...(conflictSides ? { conflictSides } : {}) });
|
|
89
93
|
const findings = validateGedcom(result.text, opts.for === "standard" ? { strict: true } : {});
|
|
90
94
|
const file = opts.out ?? gedFile(tree, opts.for, opts.research);
|
|
91
95
|
if (!hasGedErrors(findings) && !tree.dryRun) {
|
package/dist/commands/session.js
CHANGED
|
@@ -362,6 +362,8 @@ function edgeNext(e) {
|
|
|
362
362
|
const join = hyp ? ` · ${hyp.id} ${hyp.island ? `joins ${hyp.joins.join(" ")} (${hyp.island.people} people off the tree)` : `would join through ${hyp.joins.join(" ")}`}` : "";
|
|
363
363
|
if (t)
|
|
364
364
|
return `${t.id}${t.position ? ` #${t.position}` : ""}${t.held ? ` (${t.held})` : ""}${e.tasks.length > 1 ? ` +${e.tasks.length - 1}` : ""}${join}`;
|
|
365
|
+
if (e.next === "decide" && e.end === "named")
|
|
366
|
+
return `${e.hypotheses.filter((h) => !h.tests.length).map((h) => h.id).join(" ")} names the parents, no task tests it — the user decides${join}`;
|
|
365
367
|
if (e.next === "decide")
|
|
366
368
|
return `tried ${e.tried.join(" ")} — ask the user${join}`;
|
|
367
369
|
return join.replace(/^ · /, "");
|
|
@@ -383,6 +385,7 @@ const SCOPE_WORDS = {
|
|
|
383
385
|
};
|
|
384
386
|
const END_WORDS = {
|
|
385
387
|
unnamed: "the baptism names no father (born out of wedlock)",
|
|
388
|
+
named: "a variant of an open hypothesis names the parents — not linked until it is decided",
|
|
386
389
|
lost: "the records of those years are lost",
|
|
387
390
|
"before-records": "born before the known birth records of the place begin",
|
|
388
391
|
gap: "the known records have a gap in those years",
|
|
@@ -416,10 +419,10 @@ function edgeLines(tree, e, who) {
|
|
|
416
419
|
e.books.length ? ` books: ${e.books.map((b) => `${b.id}${b.years ? ` (${b.years})` : ""}${b.access.startsWith("online") ? "" : ` ${b.access}`}`).join(", ")}` : undefined,
|
|
417
420
|
e.covered.length ? ` searched in vain: ${e.covered.map(y).join(", ")}` : undefined,
|
|
418
421
|
e.noRecords.length ? ` no records known: ${e.noRecords.map(y).join(", ")}` : undefined,
|
|
419
|
-
` next: ${NEXT_WORDS[e.next]}`,
|
|
422
|
+
` next: ${e.next === "decide" && e.end === "named" ? "the hypothesis that names the parents waits for a decision — the user decides (or a task tests it)" : NEXT_WORDS[e.next]}`,
|
|
420
423
|
...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)}`),
|
|
421
424
|
e.tried.length ? ` tried: ${e.tried.join(" ")}` : undefined,
|
|
422
|
-
...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"}`),
|
|
425
|
+
...e.hypotheses.map((h) => ` ${h.id} ${truncate(h.question, 90)}${h.variants?.length ? ` (variant ${h.variants.join(", ")})` : ""}${!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"}`),
|
|
423
426
|
e.conflicts.length ? ` open conflicts: ${e.conflicts.join(" ")}` : undefined,
|
|
424
427
|
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,
|
|
425
428
|
].filter((x) => x !== undefined);
|
package/dist/core/changelog.js
CHANGED
|
@@ -41,6 +41,8 @@ const SAID = {
|
|
|
41
41
|
"lesson.add": "log.lesson.add",
|
|
42
42
|
"hypothesis.add": "log.hypothesis.add",
|
|
43
43
|
"hypothesis.argue": "log.hypothesis.argue",
|
|
44
|
+
"hypothesis.link": "log.hypothesis.link",
|
|
45
|
+
"hypothesis.variant": "log.hypothesis.variant",
|
|
44
46
|
"hypothesis.decide": "log.hypothesis.decide",
|
|
45
47
|
"conflict.add": "log.conflict.add",
|
|
46
48
|
"conflict.resolve": "log.conflict.resolve",
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// A conflict of the user's edit in the Strom app with what the research holds: its two sides, the user's and the
|
|
2
|
+
// research's — what `strom conflict resolve --take user|research` decides by.
|
|
3
|
+
import { UsageError } from "./errors.js";
|
|
4
|
+
import { claimText } from "./people.js";
|
|
5
|
+
/** The note on the user's claim of a conflict of their edit (core/sync.ts conflictOfEdit). */
|
|
6
|
+
export const USER_EDIT = "the user's edit";
|
|
7
|
+
/** A conflict of the user's edit in the Strom app: a fact's, a child's parents, a name's, a title's or a sex's. */
|
|
8
|
+
export function isEditConflict(c) {
|
|
9
|
+
return Boolean(c.edit || c.parents || (["NAME", "SEX", "NPFX", "NSFX"].includes(c.fact ?? "") && c.claims.some((x) => x.note === USER_EDIT)));
|
|
10
|
+
}
|
|
11
|
+
/** Which side a claim of a conflict of the user's edit is: the user's, the research's; none in any other conflict. */
|
|
12
|
+
export function sideOf(c, claim) {
|
|
13
|
+
if (!isEditConflict(c))
|
|
14
|
+
return undefined;
|
|
15
|
+
if (claim.note === USER_EDIT)
|
|
16
|
+
return "user";
|
|
17
|
+
if (claim.note?.startsWith("the research"))
|
|
18
|
+
return "research";
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
/** The claim of a side of a conflict of the user's edit. */
|
|
22
|
+
export function claimOf(c, side) {
|
|
23
|
+
return c.claims.find((x) => sideOf(c, x) === side);
|
|
24
|
+
}
|
|
25
|
+
/** The kinds of fact a conflict of the user's edit is decided by side from the Strom app (phase 1: not a child's parents). */
|
|
26
|
+
const BY_SIDE = new Set(["NAME", "SEX", "NPFX", "NSFX"]);
|
|
27
|
+
/**
|
|
28
|
+
* The two sides of a conflict of the user's edit the Strom app decides by side (conflict.decide): a fact's value (date,
|
|
29
|
+
* place, value — not its details or the people it names, which carry no edit), a name, a title before or after it, a
|
|
30
|
+
* sex. None for a child's parents (FAMC), a conflict between sources, or one without both sides.
|
|
31
|
+
*/
|
|
32
|
+
export function sidesOf(c) {
|
|
33
|
+
if (!isEditConflict(c) || c.parents || c.fact === "FAMC")
|
|
34
|
+
return undefined;
|
|
35
|
+
if (!c.edit && !BY_SIDE.has(c.fact ?? ""))
|
|
36
|
+
return undefined;
|
|
37
|
+
const user = claimOf(c, "user");
|
|
38
|
+
const research = claimOf(c, "research");
|
|
39
|
+
return user && research ? { user, research } : undefined;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* A side's value as the Strom app is given it (the Strom file's VAL, the bridge's sides): in the research's language,
|
|
43
|
+
* an empty value empty (a title the user took off) — never a dash.
|
|
44
|
+
*/
|
|
45
|
+
export function sideText(tree, c, claim) {
|
|
46
|
+
return claim.value.trim() === "" ? "" : claimText(tree, c, claim);
|
|
47
|
+
}
|
|
48
|
+
/** What the app is told of a conflict it decides by side: both sides' values, a sex also as M, F or U. */
|
|
49
|
+
export function appSides(tree, c) {
|
|
50
|
+
const s = c.state === "open" ? sidesOf(c) : undefined;
|
|
51
|
+
if (!s)
|
|
52
|
+
return undefined;
|
|
53
|
+
return {
|
|
54
|
+
take: true,
|
|
55
|
+
sides: { user: sideText(tree, c, s.user), research: sideText(tree, c, s.research) },
|
|
56
|
+
...(c.fact === "SEX" ? { raw: { user: sexRaw(s.user.value), research: sexRaw(s.research.value) } } : {}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** A sex as a program reads it: M, F, else U. */
|
|
60
|
+
export function sexRaw(v) {
|
|
61
|
+
return v === "M" || v === "F" ? v : "U";
|
|
62
|
+
}
|
|
63
|
+
/** The sources a conflict's decision weighed: those of its claims, and those of the fact of the user's edit. */
|
|
64
|
+
export function weighedSources(tree, c) {
|
|
65
|
+
const out = new Set(c.claims.map((x) => x.source).filter((s) => !!s));
|
|
66
|
+
if (c.edit)
|
|
67
|
+
for (const owner of c.subject) {
|
|
68
|
+
const rec = tree.get(owner);
|
|
69
|
+
const e = rec && (rec.type === "person" || rec.type === "family") ? rec.events.find((x) => x.id === c.edit.event) : undefined;
|
|
70
|
+
for (const cite of e?.citations ?? [])
|
|
71
|
+
out.add(cite.source);
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Whether a decided conflict may be decided again (strom conflict resolve), else why not: only with a reason; a side
|
|
77
|
+
* once taken is not taken again (its fact is written: changed with the fact commands); what the user decided, an agent
|
|
78
|
+
* decides again only on a source the decision did not weigh.
|
|
79
|
+
*/
|
|
80
|
+
export function againAllowed(tree, c, how) {
|
|
81
|
+
const was = c.resolution ?? "";
|
|
82
|
+
const params = { id: c.id, resolution: was, ...(c.taken ? { take: c.taken } : {}), ...(c.decidedBy ? { by: c.decidedBy } : {}) };
|
|
83
|
+
if (!how.reason)
|
|
84
|
+
throw new UsageError(`${c.id} is decided already: "${was.slice(0, 120)}"${c.decidedBy === "user" ? " — the user's decision" : ""}`, {
|
|
85
|
+
hint: c.decidedBy === "user" ? `the user's decision stands; deciding it again needs a new source and a reason: strom conflict resolve ${c.id} … --source S… --reason "<what the new record shows>"` : `deciding again needs --reason (e.g. "the marriage entry of 1839 overturns it")`,
|
|
86
|
+
code: "conflict.decided",
|
|
87
|
+
params,
|
|
88
|
+
});
|
|
89
|
+
if (how.side && c.taken)
|
|
90
|
+
throw new UsageError(`${c.id} took the ${c.taken === "user" ? "user's" : "research's"} value already: its fact is written — change the fact with the fact commands (event edit, event retract), then decide it again with --resolution`, {
|
|
91
|
+
hint: `strom conflict show ${c.id}`,
|
|
92
|
+
code: "conflict.taken",
|
|
93
|
+
params,
|
|
94
|
+
});
|
|
95
|
+
if (c.decidedBy === "user" && how.by === "agent") {
|
|
96
|
+
const weighed = weighedSources(tree, c);
|
|
97
|
+
const fresh = how.sources.filter((s) => !weighed.has(s));
|
|
98
|
+
if (!fresh.length)
|
|
99
|
+
throw new UsageError(`${c.id} is the user's decision: an agent decides it again only on a source it did not weigh (${[...weighed].join(", ") || "none"})`, {
|
|
100
|
+
hint: `strom conflict resolve ${c.id} … --source S… --reason "<what the new record shows>" — or ask the user`,
|
|
101
|
+
code: "conflict.user-decided",
|
|
102
|
+
params,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
package/dist/core/directions.js
CHANGED
|
@@ -20,19 +20,40 @@ export function namesId(text, id) {
|
|
|
20
20
|
}
|
|
21
21
|
const PERSON_ID = /(?<![\p{L}\p{M}\p{N}])P\d{4,}(?![\p{L}\p{M}\p{N}])/gu;
|
|
22
22
|
/**
|
|
23
|
-
* The people a hypothesis is about: those of its subject, and the people
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
23
|
+
* The people a hypothesis is about: those of its subject, and the people its variants' links would connect (strom
|
|
24
|
+
* hypothesis link). A hypothesis no variant of which says what it would connect yet: the people of the tree its
|
|
25
|
+
* question and its variants' claims name by ID instead ("the son of P0012 and P0013") — a variant that names a family
|
|
26
|
+
* nothing links to the tree would join it, though the subject names only the person of the tree. Once links say it,
|
|
27
|
+
* an ID in a claim is context (a wife, a witness), not who it is about. What each variant cites for or against it is
|
|
28
|
+
* evidence, never who it is about.
|
|
27
29
|
*/
|
|
28
30
|
export function hypothesisPeople(tree, h) {
|
|
29
|
-
const
|
|
31
|
+
const linked = h.variants.some((v) => v.links?.length);
|
|
32
|
+
const named = linked
|
|
33
|
+
? h.variants.flatMap((v) => (v.links ?? []).flatMap((l) => linkPeople(tree, l)))
|
|
34
|
+
: [h.question, ...h.variants.map((v) => v.claim)].flatMap((text) => text.match(PERSON_ID) ?? []);
|
|
30
35
|
const people = named.filter((id) => {
|
|
31
36
|
const p = tree.get(id);
|
|
32
37
|
return p && !p.retracted;
|
|
33
38
|
});
|
|
34
39
|
return [...new Set([...aboutPeople(tree, h.subject), ...people])];
|
|
35
40
|
}
|
|
41
|
+
/** The people one variant names: those its links would connect and those its claim names by ID. */
|
|
42
|
+
export function variantPeople(tree, v) {
|
|
43
|
+
return [...new Set([...(v.links ?? []).flatMap((l) => linkPeople(tree, l)), ...(v.claim.match(PERSON_ID) ?? [])])];
|
|
44
|
+
}
|
|
45
|
+
/** The variants that would join these people (any of them): by their links, or their claim naming them by ID. */
|
|
46
|
+
export function joiningVariants(tree, h, people) {
|
|
47
|
+
const set = new Set(people);
|
|
48
|
+
return h.variants.filter((v) => variantPeople(tree, v).some((x) => set.has(x))).map((v) => v.label);
|
|
49
|
+
}
|
|
50
|
+
/** The people a variant's link would connect: the child and its parents (the partners of its family), or its persons. */
|
|
51
|
+
export function linkPeople(tree, l) {
|
|
52
|
+
if (l.kind !== "child")
|
|
53
|
+
return l.persons;
|
|
54
|
+
const family = l.family ? tree.get(l.family) : undefined;
|
|
55
|
+
return [l.person, ...(l.parents ?? []), ...(family && !family.retracted ? family.partners : [])];
|
|
56
|
+
}
|
|
36
57
|
/** The direction a task belongs to: its own, else the narrowest one its people are in; none for a task about nobody. */
|
|
37
58
|
export function directionOf(tree, t, all) {
|
|
38
59
|
if (t.research)
|
package/dist/core/edge.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// tree ends (_STROM_EDGE, _STROM_ISLAND) and what `strom edge` says.
|
|
7
7
|
import { now } from "./tree.js";
|
|
8
8
|
import { Settings } from "./config.js";
|
|
9
|
-
import { aboutPeople, directionOf, hypothesisPeople, namesId, scopes } from "./directions.js";
|
|
9
|
+
import { aboutPeople, directionOf, hypothesisPeople, joiningVariants, namesId, scopes } from "./directions.js";
|
|
10
10
|
import { birthEstimate, birthRecordProven, birthWindow, frontier, FRONTIER_LEVELS, RECORD_KINDS, recordsetsCovering } from "./frontier.js";
|
|
11
11
|
import { kinship, offTree } from "./kin.js";
|
|
12
12
|
import { familiesAsChild, familiesAsPartner, parentsOf, primaryName } from "./people.js";
|
|
@@ -23,12 +23,13 @@ export const EDGE_MISSING = ["parents", "father", "mother", "proof"];
|
|
|
23
23
|
*/
|
|
24
24
|
export const EDGE_SCOPES = ["in", "limit", "paused", "done", "living", "outside", "off-tree"];
|
|
25
25
|
/**
|
|
26
|
-
* What the records say so far, from the most final: the baptism names no father (born out of wedlock);
|
|
26
|
+
* What the records say so far, from the most final: the baptism names no father (born out of wedlock); a variant of an
|
|
27
|
+
* open hypothesis names the parents, nobody linked them (strom hypothesis link --child: the user decides); every year of the
|
|
27
28
|
* birth is searched in vain or has no records — lost, before the known records begin, a gap in them; searched in
|
|
28
29
|
* vain everywhere strom knew to look; the books left are only in the archive; searched in part; not searched yet;
|
|
29
30
|
* no book of the birthplace known; the birthplace unknown; nothing to go on (no year, no place).
|
|
30
31
|
*/
|
|
31
|
-
export const EDGE_ENDS = ["unnamed", "lost", "before-records", "gap", "not-found", "offline", "partly", "unsearched", "no-books", "no-place", "no-clue"];
|
|
32
|
+
export const EDGE_ENDS = ["unnamed", "named", "lost", "before-records", "gap", "not-found", "offline", "partly", "unsearched", "no-books", "no-place", "no-clue"];
|
|
32
33
|
/**
|
|
33
34
|
* 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
35
|
* (a direction paused, people off the tree, put aside), strom proposes it with the next session, everything strom
|
|
@@ -83,7 +84,11 @@ function islands(tree, kin, tasks, hypotheses) {
|
|
|
83
84
|
hypotheses: hypotheses
|
|
84
85
|
.map((h) => ({ h, about: hypothesisPeople(tree, h) }))
|
|
85
86
|
.filter(({ about }) => about.some((x) => people.has(x)) && about.some((x) => kin.has(x)))
|
|
86
|
-
.map(({ h, about }) =>
|
|
87
|
+
.map(({ h, about }) => {
|
|
88
|
+
// the variants that would join this family: they name its people
|
|
89
|
+
const variants = joiningVariants(tree, h, people);
|
|
90
|
+
return { id: h.id, joins: about.filter((x) => kin.has(x)), ...(variants.length ? { variants } : {}) };
|
|
91
|
+
}),
|
|
87
92
|
held: tasks.filter((t) => LIVE.has(t.state) && off(t) && aboutPeople(tree, t.subject).some((x) => people.has(x))).length,
|
|
88
93
|
};
|
|
89
94
|
for (const id of people)
|
|
@@ -91,8 +96,34 @@ function islands(tree, kin, tasks, hypotheses) {
|
|
|
91
96
|
}
|
|
92
97
|
return out;
|
|
93
98
|
}
|
|
94
|
-
/**
|
|
95
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Whether a variant of the hypothesis would make the person a child of parents of the tree (strom hypothesis link
|
|
101
|
+
* --child): a family there, or parents there — not already (as the Strom file says it: core/gedcom export).
|
|
102
|
+
*/
|
|
103
|
+
function namesParents(tree, h, person) {
|
|
104
|
+
const there = (id) => {
|
|
105
|
+
const p = tree.get(id);
|
|
106
|
+
return !!p && !p.retracted;
|
|
107
|
+
};
|
|
108
|
+
return h.variants.some((v) => (v.links ?? []).some((l) => {
|
|
109
|
+
if (l.kind !== "child" || l.person !== person)
|
|
110
|
+
return false;
|
|
111
|
+
const parents = [...new Set(l.parents ?? [])];
|
|
112
|
+
// parents of no family together — none since, with the child in it
|
|
113
|
+
if (!l.family)
|
|
114
|
+
return (parents.length > 0 &&
|
|
115
|
+
parents.every(there) &&
|
|
116
|
+
!familiesAsChild(tree, person).some((f) => !f.retracted && f.partners.length === parents.length && parents.every((x) => f.partners.includes(x))));
|
|
117
|
+
const f = tree.get(l.family);
|
|
118
|
+
return !!f && !f.retracted && !f.children.some((c) => c.person === person);
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Every edge of the tree with something the research knows of it, and the families nothing links to the tree.
|
|
123
|
+
* `named`: an edge whose parents a variant names (the end named) — off for a Strom app that does not know it yet.
|
|
124
|
+
*/
|
|
125
|
+
export function treeEdges(tree, opts = {}) {
|
|
126
|
+
const named = opts.named ?? true;
|
|
96
127
|
const lang = tree.lang;
|
|
97
128
|
const today = now().slice(0, 10);
|
|
98
129
|
const kin = kinship(tree);
|
|
@@ -220,10 +251,14 @@ export function treeEdges(tree) {
|
|
|
220
251
|
open.push({ year: y, online: kept.some((b) => b.access === "online-free" || b.access === "online-login") });
|
|
221
252
|
}
|
|
222
253
|
const earliest = Math.min(...placeBooks.map((b) => yearsOf(b.years)?.from ?? Infinity));
|
|
254
|
+
// the parents a variant of an open hypothesis names, not linked: the user decides it (the hypothesis, its tests)
|
|
255
|
+
const naming = named && missing !== "proof" ? hyps.filter(({ h }) => namesParents(tree, h, p.id)).map(({ h }) => h.id) : [];
|
|
223
256
|
let end;
|
|
224
257
|
// the baptism is read and names the mother alone (a child born out of wedlock); a mother missing is one not recorded yet
|
|
225
258
|
if (missing === "father" && proven)
|
|
226
259
|
end = "unnamed";
|
|
260
|
+
else if (naming.length)
|
|
261
|
+
end = "named";
|
|
227
262
|
else if (!est.place)
|
|
228
263
|
end = est.year ? "no-place" : "no-clue";
|
|
229
264
|
else if (!placeBooks.length)
|
|
@@ -251,15 +286,21 @@ export function treeEdges(tree) {
|
|
|
251
286
|
const joining = kin.size > 0 && about.some((x) => kin.has(x)) && about.some((x) => !kin.has(x));
|
|
252
287
|
const joins = joining ? about.filter((x) => kin.has(x) !== onTree) : [];
|
|
253
288
|
const isle = joins.length && onTree ? isles.get(joins[0]) : undefined;
|
|
289
|
+
// the variants that would make the join: those that name the people off the tree
|
|
290
|
+
const variants = joins.length ? joiningVariants(tree, h, onTree ? joins : (isles.get(p.id)?.people ?? [p.id])) : [];
|
|
254
291
|
return {
|
|
255
292
|
id: h.id,
|
|
256
293
|
question: h.question,
|
|
257
294
|
joins,
|
|
258
295
|
...(isle ? { island: { people: isle.people.length, held: isle.held } } : {}),
|
|
296
|
+
...(variants.length ? { variants } : {}),
|
|
259
297
|
// a task about it, or one whose words name it ("the baptism … (H0007 B)")
|
|
260
298
|
tests: tasks.filter((t) => LIVE.has(t.state) && (t.subject.includes(h.id) || namesId(t.what, h.id))).map((t) => t.id),
|
|
261
299
|
};
|
|
262
300
|
});
|
|
301
|
+
// named, and no task tests the hypotheses that name them: what comes next is the user's decision
|
|
302
|
+
const untested = naming.length > 0 && edgeHyps.filter((h) => naming.includes(h.id)).every((h) => !h.tests.length);
|
|
303
|
+
const nextNow = end === "named" && untested && (next === "none" || next === "proposed") ? "decide" : next;
|
|
263
304
|
const edgeConflicts = conflicts
|
|
264
305
|
.filter((c) => subjectPeople(tree, c.subject).includes(p.id) && (!c.fact || EDGE_FACTS.has(c.fact)))
|
|
265
306
|
.map((c) => c.id);
|
|
@@ -276,7 +317,7 @@ export function treeEdges(tree) {
|
|
|
276
317
|
scope,
|
|
277
318
|
...(through ? { research: through.research.id, generation: through.people.get(p.id) } : {}),
|
|
278
319
|
end,
|
|
279
|
-
next,
|
|
320
|
+
next: nextNow,
|
|
280
321
|
...(est.year || est.place ? { estimate: { ...(est.year ? { year: est.year } : {}), ...(est.place ? { place: est.place } : {}), ...(est.basis ? { basis: est.basis } : {}) } } : {}),
|
|
281
322
|
...(window ? { window } : {}),
|
|
282
323
|
...(Number.isFinite(earliest) && (end === "before-records" || noRecords.some((y) => y < earliest)) ? { recordsFrom: earliest } : {}),
|
package/dist/core/links.js
CHANGED
|
@@ -62,6 +62,8 @@ export const LINK_DIRECTION_DOS = ["pause", "done", "resume"];
|
|
|
62
62
|
export const LINK_SYNC_DOS = ["show"];
|
|
63
63
|
/** What a link does with a conflict: the user decides it, or leaves it to the agent. */
|
|
64
64
|
export const LINK_CONFLICT_DOS = ["decide", "agent"];
|
|
65
|
+
/** The side a conflict of the user's edit is decided for, picked in the app: the terminal asks only to confirm and why. */
|
|
66
|
+
export const LINK_CONFLICT_TAKES = ["user", "research"];
|
|
65
67
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
66
68
|
const SOURCE = /^S\d{1,9}$/;
|
|
67
69
|
const PERSON = /^P\d{1,9}$/;
|
|
@@ -227,8 +229,12 @@ export function parseLink(text, scheme = LINK_SCHEME) {
|
|
|
227
229
|
const partner = idOf(q, "partner", PERSON, false);
|
|
228
230
|
return { action, tree: id, person, ...(partner ? { partner } : {}), do: oneOf(q, "do", LINK_STORY_DOS) };
|
|
229
231
|
}
|
|
230
|
-
case "conflict":
|
|
231
|
-
|
|
232
|
+
case "conflict": {
|
|
233
|
+
const how = oneOf(q, "do", LINK_CONFLICT_DOS, "decide");
|
|
234
|
+
// the side, only of a decision, only one of its fixed values
|
|
235
|
+
const take = how === "decide" && q.has("take") ? oneOf(q, "take", LINK_CONFLICT_TAKES) : undefined;
|
|
236
|
+
return { action, tree: id, id: idOf(q, "id", CONFLICT, true), do: how, ...(take ? { take } : {}) };
|
|
237
|
+
}
|
|
232
238
|
case "sync-undo":
|
|
233
239
|
return { action, tree: id, intake: idOf(q, "intake", INPUT, true) };
|
|
234
240
|
case "sync": {
|
package/dist/core/live.js
CHANGED
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
// X-Strom-Batch, -Path (and -Zip: 1, a ZIP unpacked here): one file of a batch
|
|
27
27
|
// POST <token>/batch/<id>/done the batch is whole ({name, files, person, note}): its files become sorting tasks
|
|
28
28
|
// (a batch nobody closes is closed a day after its last file)
|
|
29
|
+
// POST <token>/conflict/<X…> the person decides a conflict of their edit in the app by side ({"do": "decide",
|
|
30
|
+
// "take": "user" | "research", "note"?}): strom conflict resolve --take, written as the user's
|
|
29
31
|
// GET <token>/adopt a new research waits for a tree of the app: its mark, name, until when; transfer: true when
|
|
30
32
|
// the tree comes from a browser the app cannot reach strom from; existing: true when it goes
|
|
31
33
|
// into a research made before, its tree never came (POST /adopt hands it over)
|
|
@@ -62,7 +64,7 @@ import path from "node:path";
|
|
|
62
64
|
import crypto from "node:crypto";
|
|
63
65
|
import { spawn, spawnSync } from "node:child_process";
|
|
64
66
|
import { withoutAgentMarks } from "./which.js";
|
|
65
|
-
import { newerTree, Tree, TREE_FILE, VERSION } from "./tree.js";
|
|
67
|
+
import { newerTree, Tree, TREE_FILE, typeOfId, VERSION } from "./tree.js";
|
|
66
68
|
import { readJsonIfExists } from "./json.js";
|
|
67
69
|
import { changeLines } from "./changelog.js";
|
|
68
70
|
import { directionOf, scopes } from "./directions.js";
|
|
@@ -87,11 +89,13 @@ import { rankTasks } from "./queue.js";
|
|
|
87
89
|
import { humanTask } from "../cli/human.js";
|
|
88
90
|
import { knownNewerVersion, updateChannel } from "./update.js";
|
|
89
91
|
import { gitProgram, runGit } from "./git.js";
|
|
90
|
-
import { appKnowsArchive, appKnowsNoCouple, appOpensLinks, appReadsTitles, appShowsCoupleEvents, appShowsEdges, appShowsFactStatus, appShowsSourceReads, appShowsStoryDrafts, appTurnsExcerpts, appUrlSetting, isAppVersion, isStromAppOrigin } from "./stromapp.js";
|
|
92
|
+
import { appKnowsArchive, appKnowsNoCouple, appShowsHypothesisLinks, appDecidesConflicts, appOpensLinks, appReadsTitles, appShowsCoupleEvents, appShowsEdges, appShowsFactStatus, appShowsSourceReads, appShowsStoryDrafts, appTurnsExcerpts, appUrlSetting, isAppVersion, isStromAppOrigin } from "./stromapp.js";
|
|
91
93
|
import { Settings } from "./config.js";
|
|
92
94
|
import { LINK_SCHEME, linkActions, linkHandlerState, linkHandlerStateLater, linkScheme } from "./links.js";
|
|
93
95
|
import { autoTidy } from "./tidy.js";
|
|
94
96
|
import { diskVersion, stromLauncher } from "./self.js";
|
|
97
|
+
import { sidesOf } from "./conflicts.js";
|
|
98
|
+
import { normId } from "./records.js";
|
|
95
99
|
import { foldText } from "./text.js";
|
|
96
100
|
import { storiesToApprove } from "./stories.js";
|
|
97
101
|
/**
|
|
@@ -145,6 +149,15 @@ function said(lang, key, params = {}) {
|
|
|
145
149
|
* - `batch.closed` 409 {batch} — the batch is closed already: send the rest as a new batch
|
|
146
150
|
* - `batch.full-files` 413 {batch, files}; `batch.full-bytes` 413 {batch, gb} — the most a batch takes
|
|
147
151
|
* - `batch.none` 404 {batch} — POST /batch/<id>/done: no such batch here; `batch.bad-body` 400 — its body no JSON
|
|
152
|
+
*
|
|
153
|
+
* A decision of the person (POST /conflict/<X…>): 200 {decided, take, head, written, person?, family?}, else
|
|
154
|
+
* - `app.only` 403; `decide.bad-body` 400 — no JSON {"do": "decide", …}; `conflict.bad-take` 400 — take not user|research
|
|
155
|
+
* - `conflict.none` 404 {id} — no such conflict; `conflict.no-edit` 422 {id} — not one the app decides by side
|
|
156
|
+
* - `conflict.decided` 409 {id} (and resolution, take, at, by, in) — decided already (a terminal, an agent, the app)
|
|
157
|
+
* - `research.busy` 503 (and Retry-After, `retry`) — a send or an adoption is being written, another decision, another
|
|
158
|
+
* strom holding the research: ask again in a moment
|
|
159
|
+
* - `locked` 423 (and reasonCode, reasonParams) — a newer strom wrote the research: this one never writes it
|
|
160
|
+
* - `decide.failed` 500 — went wrong (the English sentence says why)
|
|
148
161
|
*/
|
|
149
162
|
function refusal(error, code, params = {}) {
|
|
150
163
|
return { error, code, text: error, ...(Object.keys(params).length ? { params } : {}) };
|
|
@@ -593,7 +606,7 @@ function idsOf(applied, known) {
|
|
|
593
606
|
return Object.keys(ids.persons).length || Object.keys(ids.sources).length ? { ids } : {};
|
|
594
607
|
}
|
|
595
608
|
/** What the bridge does that an app may ask about (each added once, never taken away). */
|
|
596
|
-
export const BRIDGE_FEATURES = ["sync.again", "sync.undoneSince", "sync.takenBack", "sync.conflictEdit", "sync.since", "sync.ids", "family.noCouple", "family.alone", "adopt.transfer", "adopt.empty", "material.list", "person.titles", "media.codes"];
|
|
609
|
+
export const BRIDGE_FEATURES = ["sync.again", "sync.undoneSince", "sync.takenBack", "sync.conflictEdit", "sync.since", "sync.ids", "family.noCouple", "family.alone", "adopt.transfer", "adopt.empty", "material.list", "person.titles", "media.codes", "hypothesis.links", "conflict.decide"];
|
|
597
610
|
export function history(root, tree, range = [`-n${LOG_MAX}`]) {
|
|
598
611
|
const r = runGit(root, ["log", ...range, "--format=%x1e%H%x1f%cI%x1f%s%x1f%b%x1f", "--name-only"]);
|
|
599
612
|
if (r.status !== 0)
|
|
@@ -1050,7 +1063,7 @@ export function serveLive(root, env) {
|
|
|
1050
1063
|
catch {
|
|
1051
1064
|
// not JSON: the status alone
|
|
1052
1065
|
}
|
|
1053
|
-
const marks = ["code", "reasonCode", "intake", "input", "changes", "applied", "pending", "keptAs", "known", "batch", "sha"]
|
|
1066
|
+
const marks = ["code", "reasonCode", "decided", "take", "intake", "input", "changes", "applied", "pending", "keptAs", "known", "batch", "sha"]
|
|
1054
1067
|
.filter((k) => said[k] !== undefined && said[k] !== null && typeof said[k] !== "object")
|
|
1055
1068
|
.map((k) => `${k} ${String(said[k]).slice(0, 80)}`);
|
|
1056
1069
|
if (Array.isArray(said.conflicts) && said.conflicts.length)
|
|
@@ -1153,6 +1166,10 @@ export function serveLive(root, env) {
|
|
|
1153
1166
|
batchDone(req, res, origin, decodeURIComponent(sub ?? ""));
|
|
1154
1167
|
return;
|
|
1155
1168
|
}
|
|
1169
|
+
if (req.method === "POST" && t === token && what === "conflict" && sub && !act) {
|
|
1170
|
+
decideFor(req, res, origin, "conflict", decodeURIComponent(sub), conflictDecision);
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1156
1173
|
if (req.method !== "GET" || t !== token) {
|
|
1157
1174
|
res.writeHead(404).end();
|
|
1158
1175
|
return;
|
|
@@ -1251,12 +1268,14 @@ export function serveLive(root, env) {
|
|
|
1251
1268
|
const turnsExcerpts = appTurnsExcerpts(new Settings(env, {}), version);
|
|
1252
1269
|
const noCouple = appKnowsNoCouple(new Settings(env, {}), version);
|
|
1253
1270
|
const titles = appReadsTitles(new Settings(env, {}), version);
|
|
1271
|
+
const hypothesisLinks = appShowsHypothesisLinks(new Settings(env, {}), version);
|
|
1272
|
+
const conflictSides = appDecidesConflicts(new Settings(env, {}), version);
|
|
1254
1273
|
const offered = opens ? links(env) : [];
|
|
1255
1274
|
ged = {
|
|
1256
1275
|
head: h,
|
|
1257
1276
|
links: offered.join(" "),
|
|
1258
1277
|
version: version ?? "",
|
|
1259
|
-
text: exportGedcom(tree, { for: "strom", ...(h ? { head: h } : {}), ...(images ? { excerpts: images.of } : {}), ...(opens ? { clips: true, research: true } : {}), ...(edges ? { edges } : {}), ...(storyDrafts ? { storyDrafts } : {}), ...(coupleResi ? { coupleResi } : {}), ...(sourceReads ? { sourceReads } : {}), ...(factStatus ? { factStatus } : {}), ...(archive ? { archive } : {}), ...(turnsExcerpts ? { turnsExcerpts } : {}), ...(noCouple ? { noCouple } : {}), ...(titles ? { titles } : {}), ...(offered.length ? { links: offered, ...(linkScheme(env) !== LINK_SCHEME ? { linkScheme: linkScheme(env) } : {}) } : {}) }).text,
|
|
1278
|
+
text: exportGedcom(tree, { for: "strom", ...(h ? { head: h } : {}), ...(images ? { excerpts: images.of } : {}), ...(opens ? { clips: true, research: true } : {}), ...(edges ? { edges } : {}), ...(storyDrafts ? { storyDrafts } : {}), ...(coupleResi ? { coupleResi } : {}), ...(sourceReads ? { sourceReads } : {}), ...(factStatus ? { factStatus } : {}), ...(archive ? { archive } : {}), ...(turnsExcerpts ? { turnsExcerpts } : {}), ...(noCouple ? { noCouple } : {}), ...(titles ? { titles } : {}), ...(hypothesisLinks ? { hypothesisLinks } : {}), ...(conflictSides ? { conflictSides } : {}), ...(offered.length ? { links: offered, ...(linkScheme(env) !== LINK_SCHEME ? { linkScheme: linkScheme(env) } : {}) } : {}) }).text,
|
|
1260
1279
|
};
|
|
1261
1280
|
}
|
|
1262
1281
|
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", "X-Strom-Head": h }).end(ged.text);
|
|
@@ -1925,6 +1944,150 @@ export function serveLive(root, env) {
|
|
|
1925
1944
|
closeBatchNow(id, extra, reply);
|
|
1926
1945
|
}));
|
|
1927
1946
|
};
|
|
1947
|
+
/**
|
|
1948
|
+
* A decision of the person in the app (POST /conflict/<X…>; one route of each kind — a hypothesis's later shares
|
|
1949
|
+
* this): checked here, then written by a strom of its own as the user's (forApp: the command the terminal runs, its
|
|
1950
|
+
* lock, its log and commit), one at a time and never while a send or an adoption is written — then busy (503 +
|
|
1951
|
+
* Retry-After, research.busy: what the bridge says of an original or a batch the research cannot take now). Every
|
|
1952
|
+
* kind answers alike: 200 {decided, head, written, …}; its refusals by their codes (`<kind>.none` 404, `<kind>.decided`
|
|
1953
|
+
* 409, …), the research a newer strom wrote 423 `locked`, not from the app's pages 403 `app.only`.
|
|
1954
|
+
*/
|
|
1955
|
+
let deciding;
|
|
1956
|
+
const DECIDE_RETRY_S = 5;
|
|
1957
|
+
const decideFor = (req, res, origin, kind, raw, how) => {
|
|
1958
|
+
const reply = (code, body) => {
|
|
1959
|
+
if (!res.headersSent)
|
|
1960
|
+
res.writeHead(code, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", ...(code === 503 ? { "Retry-After": String(body.retry ?? DECIDE_RETRY_S) } : {}) }).end(JSON.stringify(body));
|
|
1961
|
+
};
|
|
1962
|
+
if (!origin) {
|
|
1963
|
+
req.resume();
|
|
1964
|
+
return reply(403, refusal("only the Strom app may decide here", "app.only"));
|
|
1965
|
+
}
|
|
1966
|
+
let text = "";
|
|
1967
|
+
let over = false;
|
|
1968
|
+
req.on("data", (c) => {
|
|
1969
|
+
if (text.length + c.length > 8192)
|
|
1970
|
+
over = true;
|
|
1971
|
+
else
|
|
1972
|
+
text += c.toString("utf8");
|
|
1973
|
+
});
|
|
1974
|
+
req.on("end", () => safely(req, res, () => {
|
|
1975
|
+
// a research a newer strom wrote: never opened, never written (as /status says it: locked)
|
|
1976
|
+
const newer = newerTree(root, env);
|
|
1977
|
+
if (newer)
|
|
1978
|
+
return reply(423, { ...refusal(newer.message, "locked"), reasonCode: newer.code, ...(newer.params ? { reasonParams: newer.params } : {}) });
|
|
1979
|
+
let body = {};
|
|
1980
|
+
try {
|
|
1981
|
+
const parsed = text.trim() ? JSON.parse(text) : {};
|
|
1982
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
1983
|
+
throw new Error("not an object");
|
|
1984
|
+
body = parsed;
|
|
1985
|
+
}
|
|
1986
|
+
catch {
|
|
1987
|
+
over = true;
|
|
1988
|
+
}
|
|
1989
|
+
if (over || body.do !== "decide")
|
|
1990
|
+
return reply(400, refusal(`the body: JSON {"do": "decide", …}`, "decide.bad-body"));
|
|
1991
|
+
const id = /^[A-Za-z]\d{1,9}$/.test(raw) ? normId(raw) : "";
|
|
1992
|
+
const tree = Tree.open(root, env);
|
|
1993
|
+
const rec = id && typeOfId(id) === kind ? tree.get(id) : undefined;
|
|
1994
|
+
if (!rec || rec.type !== kind)
|
|
1995
|
+
return reply(404, refusal(`no ${kind} ${raw.slice(0, 32)} in this research`, `${kind}.none`, { id: raw.slice(0, 32) }));
|
|
1996
|
+
const asked = how.ask(tree, rec, body);
|
|
1997
|
+
if ("code" in asked)
|
|
1998
|
+
return reply(asked.code, asked.body);
|
|
1999
|
+
// a send or an adoption being written, another decision: asked again in a moment
|
|
2000
|
+
if (writing || adopting || deciding)
|
|
2001
|
+
return reply(503, { ...refusal("the research is busy — ask again in a moment", "research.busy"), retry: DECIDE_RETRY_S });
|
|
2002
|
+
deciding = id;
|
|
2003
|
+
const { command, args } = stromLauncher();
|
|
2004
|
+
const child = spawn(command, [...args, ...asked.args, "--json"], { cwd: root, env: forApp(env, root), stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
2005
|
+
track(child);
|
|
2006
|
+
let out = "";
|
|
2007
|
+
let err = "";
|
|
2008
|
+
child.stdout.on("data", (d) => (out += d.toString("utf8")));
|
|
2009
|
+
child.stderr.on("data", (d) => (err += d.toString("utf8")));
|
|
2010
|
+
child.on("error", (e) => {
|
|
2011
|
+
deciding = undefined;
|
|
2012
|
+
reply(500, refusal(errorText(e), "decide.failed"));
|
|
2013
|
+
});
|
|
2014
|
+
child.on("close", (code) => {
|
|
2015
|
+
deciding = undefined;
|
|
2016
|
+
if (code === null)
|
|
2017
|
+
return;
|
|
2018
|
+
let data = {};
|
|
2019
|
+
try {
|
|
2020
|
+
data = JSON.parse(out);
|
|
2021
|
+
}
|
|
2022
|
+
catch {
|
|
2023
|
+
// said by the exit code
|
|
2024
|
+
}
|
|
2025
|
+
const now = Tree.open(root, env);
|
|
2026
|
+
if (code === 0)
|
|
2027
|
+
return reply(200, how.done(now, id, data, head(root)));
|
|
2028
|
+
const why = String(data.message ?? err.trim().split("\n").find((l) => l.startsWith("error:"))?.slice(6).trim() ?? `exit ${code}`);
|
|
2029
|
+
noteLive(root, `${id} was not decided (exit ${code}): ${why}`);
|
|
2030
|
+
// the research busy (another strom holding it longer than a writer waits)
|
|
2031
|
+
if (code === EXIT.locked)
|
|
2032
|
+
return reply(503, { ...refusal(why, "research.busy"), retry: DECIDE_RETRY_S });
|
|
2033
|
+
const known = typeof data.code === "string" ? how.refused(now, id, data.code, why) : undefined;
|
|
2034
|
+
reply(known?.code ?? 500, known?.body ?? { ...refusal(why, "decide.failed"), ...(typeof data.code === "string" ? { reasonCode: data.code } : {}) });
|
|
2035
|
+
});
|
|
2036
|
+
}));
|
|
2037
|
+
};
|
|
2038
|
+
/** Whose a conflict is, as the app finds it: its person (a couple's: its first partner) and its family. */
|
|
2039
|
+
const conflictOwners = (tree, c) => {
|
|
2040
|
+
const person = c.subject.find((s) => s.startsWith("P"));
|
|
2041
|
+
const family = c.subject.find((s) => s.startsWith("F"));
|
|
2042
|
+
const partner = person ?? (family ? tree.get(family)?.partners[0] : undefined);
|
|
2043
|
+
return { ...(partner ? { person: partner } : {}), ...(family ? { family } : {}) };
|
|
2044
|
+
};
|
|
2045
|
+
/** A conflict decided already: what was decided, which side, when and by whom. */
|
|
2046
|
+
const decidedAnswer = (c) => ({
|
|
2047
|
+
...refusal(`${c.id} is decided already: ${c.resolution ?? ""}`, "conflict.decided", { id: c.id }),
|
|
2048
|
+
resolution: c.resolution ?? "",
|
|
2049
|
+
...(c.taken ? { take: c.taken } : {}),
|
|
2050
|
+
...(c.decidedAt ? { at: c.decidedAt } : {}),
|
|
2051
|
+
...(c.decidedBy ? { by: c.decidedBy } : {}),
|
|
2052
|
+
...(c.decidedIn ? { in: c.decidedIn } : {}),
|
|
2053
|
+
});
|
|
2054
|
+
/** POST /conflict/<X…> {"do": "decide", "take": "user" | "research", "note"?}: strom conflict resolve --take, the user's. */
|
|
2055
|
+
const conflictDecision = {
|
|
2056
|
+
ask(tree, rec, body) {
|
|
2057
|
+
const c = rec;
|
|
2058
|
+
if (c.state !== "open")
|
|
2059
|
+
return { code: 409, body: decidedAnswer(c) };
|
|
2060
|
+
if (!sidesOf(c))
|
|
2061
|
+
return { code: 422, body: refusal(`${c.id} is no conflict of an edit in the Strom app the app decides by side`, "conflict.no-edit", { id: c.id }) };
|
|
2062
|
+
if (body.take !== "user" && body.take !== "research")
|
|
2063
|
+
return { code: 400, body: refusal(`"take" is "user" or "research"`, "conflict.bad-take") };
|
|
2064
|
+
if (body.note !== undefined && typeof body.note !== "string")
|
|
2065
|
+
return { code: 400, body: refusal(`"note" is text`, "decide.bad-body") };
|
|
2066
|
+
// the person's note: one line of text, 200 characters at most (the rest cut)
|
|
2067
|
+
const note = [...String(body.note ?? "").replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim()].slice(0, 200).join("").trim();
|
|
2068
|
+
const decided = phrase(tree.lang, "conflict.app.decided");
|
|
2069
|
+
const reasoning = note ? phrase(tree.lang, "conflict.app.noted", { decided, note }) : decided;
|
|
2070
|
+
return { args: ["conflict", "resolve", c.id, `--take=${body.take}`, `--reasoning=${reasoning}`] };
|
|
2071
|
+
},
|
|
2072
|
+
done(tree, id, data) {
|
|
2073
|
+
const c = tree.get(id);
|
|
2074
|
+
return {
|
|
2075
|
+
decided: id,
|
|
2076
|
+
take: c.taken ?? data.taken,
|
|
2077
|
+
head: head(root),
|
|
2078
|
+
written: typeof data.written === "string" ? data.written : `${id} decided: the research's value kept`,
|
|
2079
|
+
...conflictOwners(tree, c),
|
|
2080
|
+
};
|
|
2081
|
+
},
|
|
2082
|
+
refused(tree, id, code, why) {
|
|
2083
|
+
const c = tree.get(id);
|
|
2084
|
+
if (c && ["conflict.decided", "conflict.taken", "conflict.user-decided"].includes(code))
|
|
2085
|
+
return { code: 409, body: decidedAnswer(c) };
|
|
2086
|
+
if (code === "conflict.no-edit")
|
|
2087
|
+
return { code: 422, body: refusal(why, "conflict.no-edit", { id }) };
|
|
2088
|
+
return undefined;
|
|
2089
|
+
},
|
|
2090
|
+
};
|
|
1928
2091
|
/** The app sends nothing (unchanged, the user said no, no tree of this research): strom sync --app stops waiting. */
|
|
1929
2092
|
const nothing = (req, res, origin) => {
|
|
1930
2093
|
const reply = (code, body) => res.writeHead(code, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }).end(JSON.stringify(body));
|
package/dist/core/model.js
CHANGED
|
@@ -50,6 +50,13 @@ export const SEARCH_RESULTS = ["found", "negative", "partial", "inconclusive"];
|
|
|
50
50
|
export const STRATEGIES = ["balanced", "depth", "priority"];
|
|
51
51
|
export const TASK_LEVELS = ["intake", "locate", "link", "verify", "enrich", "request", "narrate"];
|
|
52
52
|
export const TASK_STATES = ["open", "doing", "done", "parked", "waiting", "dropped"];
|
|
53
|
+
/**
|
|
54
|
+
* What a variant of a hypothesis would connect, were it true (strom hypothesis link): whose child a person is — a
|
|
55
|
+
* family of the tree, or one or two parents who have no family together —, two records of one person, a couple, or
|
|
56
|
+
* siblings whose parents are unknown. Nothing of it is a link of the tree: the variant's claim, said so a program can
|
|
57
|
+
* show it (the Strom app: _VAR / _LINK under _STROM_HYPO).
|
|
58
|
+
*/
|
|
59
|
+
export const VARIANT_LINK_KINDS = ["child", "same", "partners", "siblings"];
|
|
53
60
|
export const LESSON_SCOPES = ["method", "repository", "recordset", "place", "project"];
|
|
54
61
|
export const LESSON_MAX = 200;
|
|
55
62
|
export const INPUT_KINDS = ["document", "photo", "tree", "text", "other"];
|