strom-research 1.8.0 → 1.10.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/lang/cs.json +23 -2
- package/assets/lang/de.json +21 -0
- package/assets/method/core.md +8 -0
- package/assets/method/enrich.md +31 -0
- package/assets/method/narrate.md +11 -3
- package/dist/cli/menu-links.js +21 -2
- package/dist/cli/menu-waiting.js +68 -10
- package/dist/cli/menu.js +4 -2
- package/dist/cli/ui.js +16 -0
- package/dist/commands/browse.js +4 -2
- package/dist/commands/links.js +12 -3
- package/dist/commands/output.js +6 -2
- package/dist/commands/session.js +132 -3
- package/dist/commands/story.js +41 -10
- package/dist/commands/sync.js +13 -4
- package/dist/commands/tasks.js +41 -8
- package/dist/core/actions.js +43 -6
- package/dist/core/changelog.js +1 -0
- package/dist/core/config.js +5 -0
- package/dist/core/edge.js +296 -0
- package/dist/core/frontier.js +12 -4
- package/dist/core/import.js +48 -2
- package/dist/core/kin.js +40 -1
- package/dist/core/links.js +5 -1
- package/dist/core/live.js +68 -5
- package/dist/core/phrases.js +5 -0
- package/dist/core/queue.js +17 -7
- package/dist/core/stories.js +47 -4
- package/dist/core/stromapp.js +43 -0
- package/dist/core/sync.js +97 -16
- package/dist/core/tree.js +1 -1
- package/dist/core/validate.js +10 -0
- package/dist/gedcom/export.js +149 -21
- package/dist/gedcom/validate.js +10 -2
- package/package.json +1 -1
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";
|
|
@@ -68,7 +70,7 @@ export function researchProposals(tree, research) {
|
|
|
68
70
|
out.push({ proposal, kind: "review" });
|
|
69
71
|
if (new Settings(tree.env, {}).stories(tree.config).on)
|
|
70
72
|
for (const proposal of storyProposals(tree, research))
|
|
71
|
-
out.push({ proposal: { ...proposal, origin: "frontier" }, kind: "story" });
|
|
73
|
+
out.push({ proposal: { ...proposal, origin: proposal.origin ?? "frontier" }, kind: "story" });
|
|
72
74
|
return out;
|
|
73
75
|
}
|
|
74
76
|
/** Create the tasks strom proposes for a research; returns their IDs. */
|
|
@@ -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/story.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import fs from "node:fs";
|
|
5
5
|
import { register } from "../cli/registry.js";
|
|
6
6
|
import { lines } from "../cli/format.js";
|
|
7
|
-
import { approveStory, setStory, findEventOwner } from "../core/actions.js";
|
|
7
|
+
import { approveStory, discardStoryDraft, setStory, findEventOwner } from "../core/actions.js";
|
|
8
8
|
import { UsageError } from "../core/errors.js";
|
|
9
9
|
import { csvOpt, normId, textOpt } from "../core/records.js";
|
|
10
10
|
import { resolvePerson } from "../core/people.js";
|
|
@@ -56,12 +56,14 @@ register({
|
|
|
56
56
|
description: "Every statement rests on a recorded fact: list them with --fact. Paragraphs are separated by a blank line.\n" +
|
|
57
57
|
"Kept: \"## subheading\" lines, \"- \" bullet lines, **bold**, *italic*; anything else shows as plain text in the\n" +
|
|
58
58
|
"Strom app. The title goes in --title (a first line \"# …\" is taken as it). Writing it again replaces it (the\n" +
|
|
59
|
-
"history keeps the old one)."
|
|
59
|
+
"history keeps the old one). A story the user approved is locked: written again, the new version waits beside it\n" +
|
|
60
|
+
"for their approval (strom story approve), or goes (strom story discard) — the approved one stays until then.",
|
|
60
61
|
args: [{ name: "who", description: "person (ID or name) or family (F…)", required: true }],
|
|
61
62
|
options: [
|
|
62
63
|
{ name: "text", type: "string", value: "<text|@file>", description: "the story, in the research language" },
|
|
63
64
|
{ name: "title", type: "string", value: "<text>", description: "a heading" },
|
|
64
65
|
{ name: "fact", type: "string", multiple: true, value: "<E…>", description: "a fact it leans on (repeatable)" },
|
|
66
|
+
{ name: "source", type: "string", multiple: true, value: "<S…>", description: "a source of the background of place and time it tells — not a fact of the person (repeatable)" },
|
|
65
67
|
{ name: "note", type: "string", value: "<text>", description: "your caveat: what is inferred, what is unknown" },
|
|
66
68
|
{ name: "final", type: "boolean", description: "the user approved it" },
|
|
67
69
|
],
|
|
@@ -76,29 +78,49 @@ register({
|
|
|
76
78
|
text,
|
|
77
79
|
title,
|
|
78
80
|
facts: csvOpt(opts.fact).map((f) => normId(f)),
|
|
81
|
+
sources: csvOpt(opts.source).map((s) => normId(s)),
|
|
79
82
|
note: opts.note,
|
|
80
83
|
final: Boolean(opts.final),
|
|
81
84
|
});
|
|
82
|
-
const
|
|
83
|
-
const
|
|
85
|
+
const written = rec.story.draft ?? rec.story;
|
|
86
|
+
const missing = written.facts.length === 0 ? "note: no --fact given — say which facts the story rests on" : undefined;
|
|
87
|
+
const outside = outsideSet(written.text);
|
|
88
|
+
const locked = rec.story.draft
|
|
89
|
+
? `the user approved the story before: it stays as it is, the new version waits for them — tell them, they approve it (strom story approve ${rec.id}) or keep the old one (strom story discard ${rec.id})`
|
|
90
|
+
: undefined;
|
|
84
91
|
const plain = outside.length ? `note: the Strom app shows ${outside.join(", ")} as plain text — keep to paragraphs, "## " subheadings, "- " bullets, **bold**, *italic*` : undefined;
|
|
85
|
-
return { text: lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined, missing, plain), data: { story: rec.story } };
|
|
92
|
+
return { text: lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined, locked, missing, plain), data: { story: rec.story } };
|
|
86
93
|
},
|
|
87
94
|
}, {
|
|
88
95
|
path: ["story", "approve"],
|
|
89
|
-
summary: "The user approved the story as it is: no longer a draft",
|
|
96
|
+
summary: "The user approved the story as it is: no longer a draft, locked — or the new version waiting beside it takes its place",
|
|
90
97
|
group: "people",
|
|
91
98
|
tree: true,
|
|
92
99
|
writes: true,
|
|
93
|
-
description: "Only on the user's word (they read it and said it is right): the text stays, the draft becomes the story of the family book
|
|
100
|
+
description: "Only on the user's word (they read it and said it is right): the text stays, the draft becomes the story of the family book —\n" +
|
|
101
|
+
"locked: written again, the new version waits beside it for the user. With a new version waiting, that one is approved in its place.",
|
|
94
102
|
args: [{ name: "who", description: "person (ID or name) or family (F…)", required: true }],
|
|
95
103
|
examples: ["strom story approve P0001"],
|
|
96
104
|
run(ctx, { args }) {
|
|
97
105
|
const tree = ctx.tree();
|
|
98
106
|
const id = owner(tree, args[0]);
|
|
99
|
-
const was = tree.get(id)?.story
|
|
107
|
+
const was = tree.get(id)?.story;
|
|
100
108
|
const rec = approveStory(tree, id);
|
|
101
|
-
return { text: was === "final" ? `${id} story was approved already` : lines(...tree.written.map((o) => o.summary)), data: { story: rec.story } };
|
|
109
|
+
return { text: was?.status === "final" && !was.draft ? `${id} story was approved already` : lines(...tree.written.map((o) => o.summary)), data: { story: rec.story } };
|
|
110
|
+
},
|
|
111
|
+
}, {
|
|
112
|
+
path: ["story", "discard"],
|
|
113
|
+
summary: "The new version of an approved story is not wanted: the approved one stays",
|
|
114
|
+
group: "people",
|
|
115
|
+
tree: true,
|
|
116
|
+
writes: true,
|
|
117
|
+
description: "Only on the user's word: the new version written beside the story they approved goes (the history keeps it).",
|
|
118
|
+
args: [{ name: "who", description: "person (ID or name) or family (F…)", required: true }],
|
|
119
|
+
examples: ["strom story discard P0001"],
|
|
120
|
+
run(ctx, { args }) {
|
|
121
|
+
const tree = ctx.tree();
|
|
122
|
+
const rec = discardStoryDraft(tree, owner(tree, args[0]));
|
|
123
|
+
return { text: lines(...tree.written.map((o) => o.summary)), data: { story: rec.story } };
|
|
102
124
|
},
|
|
103
125
|
}, {
|
|
104
126
|
path: ["story", "show"],
|
|
@@ -125,7 +147,16 @@ register({
|
|
|
125
147
|
}
|
|
126
148
|
});
|
|
127
149
|
return {
|
|
128
|
-
text: lines(`${id} story · ${st.status} · ${st.at.slice(0, 10)}${st.title ? ` · ${st.title}` : ""}`, "", st.text, "", st.facts.length ? "rests on" : "rests on no recorded fact", ...facts, st.note ? `\nnote: ${st.note}` : undefined
|
|
150
|
+
text: lines(`${id} story · ${st.status} · ${st.at.slice(0, 10)}${st.title ? ` · ${st.title}` : ""}`, "", st.text, "", st.facts.length ? "rests on" : "rests on no recorded fact", ...facts, ...(st.sources?.length ? ["the background of place and time from", ...st.sources.map((s) => ` ${s} ${tree.get(s)?.title ?? "(no longer exists)"}`)] : []), st.note ? `\nnote: ${st.note}` : undefined, ...(st.draft
|
|
151
|
+
? [
|
|
152
|
+
"",
|
|
153
|
+
`── a new version (${st.draft.at.slice(0, 10)}) waits for the user: strom story approve ${id} (it takes the place of the one above) · strom story discard ${id} (the one above stays)`,
|
|
154
|
+
st.draft.title ? `“${st.draft.title}”` : undefined,
|
|
155
|
+
"",
|
|
156
|
+
st.draft.text,
|
|
157
|
+
st.draft.note ? `\nnote: ${st.draft.note}` : undefined,
|
|
158
|
+
]
|
|
159
|
+
: [])),
|
|
129
160
|
data: { story: st },
|
|
130
161
|
};
|
|
131
162
|
},
|
package/dist/commands/sync.js
CHANGED
|
@@ -11,6 +11,7 @@ import { EXIT, StromError, UsageError } from "../core/errors.js";
|
|
|
11
11
|
import { fileSha256, MAX_IN_TREE, mimeOf } from "../core/media.js";
|
|
12
12
|
import { create, update } from "../core/records.js";
|
|
13
13
|
import { safeFolderName } from "../core/text.js";
|
|
14
|
+
import { humanAge } from "../core/age.js";
|
|
14
15
|
import { now } from "../core/tree.js";
|
|
15
16
|
import { applySync, nothingSince, planSync, readTreeFile, receivedSince, undoSync } from "../core/sync.js";
|
|
16
17
|
import { startLive } from "../core/live.js";
|
|
@@ -19,7 +20,13 @@ import { chromiumBrowser, openInBrowser, openWebApp } from "../core/chromium.js"
|
|
|
19
20
|
function factText(f, lang) {
|
|
20
21
|
if (!f)
|
|
21
22
|
return "—";
|
|
22
|
-
return [f.value, humanDate(f.date, lang), humanPlace(f.place, undefined, lang)].filter(Boolean).join(", ") || "—";
|
|
23
|
+
return [f.value, humanDate(f.date, lang), humanPlace(f.place, undefined, lang), detailText(f, lang)].filter(Boolean).join(", ") || "—";
|
|
24
|
+
}
|
|
25
|
+
/** What a fact says besides: its house, age and cause. */
|
|
26
|
+
function detailText(f, lang) {
|
|
27
|
+
if (!f)
|
|
28
|
+
return "";
|
|
29
|
+
return [f.house && ui(lang, "ui.sync.house", { x: f.house }), f.age && humanAge(f.age, lang), f.cause && ui(lang, "ui.sync.cause", { x: f.cause })].filter(Boolean).join(", ");
|
|
23
30
|
}
|
|
24
31
|
/** A position on the map as a map shows it: degrees, six decimals at most. */
|
|
25
32
|
const point = (p) => `${+p.lat.toFixed(6)}, ${+p.lon.toFixed(6)}`;
|
|
@@ -41,8 +48,9 @@ function changeLine(tree, c, incoming, lang) {
|
|
|
41
48
|
name: name(c.person),
|
|
42
49
|
kind: kind(c.fact ?? c.was),
|
|
43
50
|
fact: `${kind(c.fact ?? c.was)} ${factText(c.fact ?? c.was, lang)}`,
|
|
44
|
-
was: factText(c.was, lang),
|
|
45
|
-
now: factText(c.fact, lang),
|
|
51
|
+
was: c.kind === "fact.detail" ? detailText(c.was, lang) || "—" : factText(c.was, lang),
|
|
52
|
+
now: c.kind === "fact.detail" ? detailText(c.fact, lang) : factText(c.fact, lang),
|
|
53
|
+
when: [humanDate(c.fact?.date, lang), humanPlace(c.fact?.place, undefined, lang)].filter(Boolean).join(", "),
|
|
46
54
|
text: truncate(c.text ?? "", 120),
|
|
47
55
|
child: name(c.child),
|
|
48
56
|
kids: c.kind === "family.new" && c.text ? ` + ${c.text.split(" ").filter(Boolean).map(name).join(", ")}` : "",
|
|
@@ -51,7 +59,8 @@ function changeLine(tree, c, incoming, lang) {
|
|
|
51
59
|
from: c.place?.was ? point(c.place.was) : "",
|
|
52
60
|
};
|
|
53
61
|
const key = c.kind === "place.coords" && !c.place?.was ? "ui.sync.place.located" : `ui.sync.${c.kind}`;
|
|
54
|
-
|
|
62
|
+
const does = c.kind === "fact.detail" && c.action === "add" ? "ui.sync.do.detail" : `ui.sync.do.${c.action}`;
|
|
63
|
+
return `${ui(lang, key, v)} → ${ui(lang, does)}`;
|
|
55
64
|
}
|
|
56
65
|
function planText(tree, plan, incoming, file, lang, edits) {
|
|
57
66
|
const shown = file.includes(" ") ? `"${file}"` : file;
|
package/dist/commands/tasks.js
CHANGED
|
@@ -20,6 +20,9 @@ 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";
|
|
25
|
+
import { storiesToApprove } from "../core/stories.js";
|
|
23
26
|
import { clipNote, inboxFolderFor, parseImageList, transcriptNote } from "../core/media.js";
|
|
24
27
|
function written(tree) {
|
|
25
28
|
return lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined);
|
|
@@ -61,8 +64,11 @@ export function waitingForUser(tree) {
|
|
|
61
64
|
*/
|
|
62
65
|
export function waitingLines(tree, where = {}, max = 5) {
|
|
63
66
|
const w = tree.list("task").filter((t) => t.state === "waiting");
|
|
67
|
+
// the new versions of stories the user approved: they decide (the lock)
|
|
68
|
+
const stories = storiesToApprove(tree).map((s) => s.id);
|
|
69
|
+
const storyLine = stories.length ? ` ${ui(tree.lang, "ui.wait.stories", { ids: stories.join(" ") })}` : undefined;
|
|
64
70
|
if (!w.length)
|
|
65
|
-
return undefined;
|
|
71
|
+
return stories.length ? lines(ui(tree.lang, "ui.wait.title", { n: stories.length }), storyLine) : undefined;
|
|
66
72
|
const show = where.display ?? ((p) => p);
|
|
67
73
|
const lang = tree.lang;
|
|
68
74
|
const images = (t) => {
|
|
@@ -75,7 +81,7 @@ export function waitingLines(tree, where = {}, max = 5) {
|
|
|
75
81
|
` ${ui(lang, "ui.wait.save", { dir: `${dir}${path.sep}` })}`,
|
|
76
82
|
];
|
|
77
83
|
};
|
|
78
|
-
return lines(ui(lang, "ui.wait.title", { n: w.length }), ...w.slice(0, max).flatMap((t) => [` ${t.id} ${truncate(t.waitingOn || t.what, 150)}`, ...images(t)]), w.length > max ? ` … strom task list --state waiting` : undefined, ` ${ui(lang, "ui.wait.how1")}`, ` ${ui(lang, "ui.wait.how2")}`);
|
|
84
|
+
return lines(ui(lang, "ui.wait.title", { n: w.length + stories.length }), ...w.slice(0, max).flatMap((t) => [` ${t.id} ${truncate(t.waitingOn || t.what, 150)}`, ...images(t)]), w.length > max ? ` … strom task list --state waiting` : undefined, storyLine, ` ${ui(lang, "ui.wait.how1")}`, ` ${ui(lang, "ui.wait.how2")}`);
|
|
79
85
|
}
|
|
80
86
|
/** The open tasks of the queue in the order to work on them (see core/queue.ts). */
|
|
81
87
|
export function taskQueue(tree, filter = {}) {
|
|
@@ -89,6 +95,22 @@ export function rankedQueue(tree, filter = {}) {
|
|
|
89
95
|
.filter((t) => !filter.level || t.level === filter.level);
|
|
90
96
|
return rankTasks(tree, tasks, filter.strategy, { storyTurn: filter.storyTurn });
|
|
91
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* What an agent is told of a task about people nothing links to the tree (core/kin.ts offTree): it waits out of the
|
|
100
|
+
* queue until the user asks for it or the hypothesis that would join them is decided; the task that tests that
|
|
101
|
+
* hypothesis names it. Undefined for a task of the tree.
|
|
102
|
+
*/
|
|
103
|
+
export function offTreeLine(tree, t, starting = false) {
|
|
104
|
+
if (!offTree(tree)(t))
|
|
105
|
+
return undefined;
|
|
106
|
+
const joins = joiningHypotheses(tree, aboutPeople(tree, t.subject));
|
|
107
|
+
const first = joins.length
|
|
108
|
+
? `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})`
|
|
109
|
+
: "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…)";
|
|
110
|
+
return starting
|
|
111
|
+
? `⚠ ${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}`
|
|
112
|
+
: `· ${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}`;
|
|
113
|
+
}
|
|
92
114
|
function taskLine(t) {
|
|
93
115
|
return [t.id, `p${t.priority}`, t.level, t.state === "open" ? "" : `[${t.state}]`, truncate(t.what, 70)];
|
|
94
116
|
}
|
|
@@ -231,7 +253,7 @@ register({
|
|
|
231
253
|
// it is known — not in a session spent finding out that there is nothing to read.
|
|
232
254
|
const noImages = lacksImages(tree, t);
|
|
233
255
|
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),
|
|
256
|
+
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
257
|
data: { task: t, similar: similar.map((x) => x.id), needsImages: noImages },
|
|
236
258
|
};
|
|
237
259
|
},
|
|
@@ -250,8 +272,13 @@ register({
|
|
|
250
272
|
const strategy = ctx.settings.strategy(tree.config);
|
|
251
273
|
const queue = rankedQueue(tree, { ...(research ? { research } : {}), ...(opts.level ? { level: String(opts.level) } : {}), strategy });
|
|
252
274
|
const first = queue[0];
|
|
253
|
-
if (!first)
|
|
254
|
-
|
|
275
|
+
if (!first) {
|
|
276
|
+
const held = tree.list("task").filter(offTree(tree)).filter((t) => t.state === "open").length;
|
|
277
|
+
const text = held
|
|
278
|
+
? `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`
|
|
279
|
+
: "no open tasks → strom research show <G…> to see what is missing, then strom task add";
|
|
280
|
+
return { text, data: { task: null, offTree: held } };
|
|
281
|
+
}
|
|
255
282
|
const t = first.task;
|
|
256
283
|
return {
|
|
257
284
|
text: lines(taskDetail(tree, t), "", `first because: ${first.why} (queue: ${strategy})`, `start it: strom task start ${t.id}`),
|
|
@@ -271,6 +298,7 @@ register({
|
|
|
271
298
|
{ name: "research", type: "string", value: "<G…>", description: "only this research" },
|
|
272
299
|
{ name: "level", type: "string", value: "<level>", description: "only this level" },
|
|
273
300
|
{ name: "about", type: "string", value: "<who>", description: "only tasks about this person/record (also X…, H…)" },
|
|
301
|
+
{ name: "off-tree", type: "boolean", description: "the open tasks that wait out of the queue: about people nothing links to the tree yet" },
|
|
274
302
|
{ name: "full", type: "boolean", description: "--json: whole records instead of one row each" },
|
|
275
303
|
],
|
|
276
304
|
run(ctx, { opts }) {
|
|
@@ -278,7 +306,10 @@ register({
|
|
|
278
306
|
const research = typeof opts.research === "string" ? resolveResearch(tree, opts.research).id : undefined;
|
|
279
307
|
let all;
|
|
280
308
|
const why = new Map();
|
|
281
|
-
|
|
309
|
+
const off = offTree(tree);
|
|
310
|
+
if (opts["off-tree"])
|
|
311
|
+
all = tree.list("task").filter((t) => t.state === "open" && off(t) && (!opts.level || t.level === opts.level));
|
|
312
|
+
else if (!opts.state) {
|
|
282
313
|
const ranked = rankedQueue(tree, { ...(research ? { research } : {}), ...(opts.level ? { level: String(opts.level) } : {}), strategy: ctx.settings.strategy(tree.config) });
|
|
283
314
|
for (const r of ranked)
|
|
284
315
|
why.set(r.task.id, r.why);
|
|
@@ -297,8 +328,10 @@ register({
|
|
|
297
328
|
all = all.filter((t) => t.subject.includes(id));
|
|
298
329
|
}
|
|
299
330
|
const page = paginate(all, ctx.limit, ctx.page);
|
|
331
|
+
// the queue says what waits out of it for a link to the tree
|
|
332
|
+
const held = !opts.state && !opts["off-tree"] && !opts.about ? tree.list("task").filter((t) => t.state === "open" && off(t)).length : 0;
|
|
300
333
|
return {
|
|
301
|
-
text: all.length ? lines(table(page.items.map(taskLine)), moreLine(page, "strom task list")) : "no tasks",
|
|
334
|
+
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
335
|
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
336
|
};
|
|
304
337
|
},
|
|
@@ -360,7 +393,7 @@ register({
|
|
|
360
393
|
summary: `${id} ${fields.map((f) => (f === "doneWhen" ? "done-when" : f === "subject" ? "about" : f)).join(", ")}${change.where ? ` → ${truncate(change.where.join("; "), 50)}` : ""}`,
|
|
361
394
|
reason: opts.reason,
|
|
362
395
|
});
|
|
363
|
-
return { text: written(tree), data: { task: t } };
|
|
396
|
+
return { text: lines(written(tree), change.subject ? offTreeLine(tree, t) : undefined), data: { task: t } };
|
|
364
397
|
},
|
|
365
398
|
}, {
|
|
366
399
|
path: ["task", "start"],
|
package/dist/core/actions.js
CHANGED
|
@@ -589,7 +589,8 @@ export function editPerson(tree, id, edit, reason) {
|
|
|
589
589
|
*/
|
|
590
590
|
export function editEvent(tree, eventId, edit, reason) {
|
|
591
591
|
const date = edit.date === undefined ? undefined : parseDate(edit.date);
|
|
592
|
-
|
|
592
|
+
// an empty age takes it away
|
|
593
|
+
const age = edit.age === undefined ? undefined : edit.age.trim() ? parseAge(edit.age) : "";
|
|
593
594
|
const ages = edit.ages ? Object.fromEntries(Object.entries(edit.ages).map(([who, a]) => [who, parseAge(a, who)])) : undefined;
|
|
594
595
|
return replaceEvent(tree, eventId, (e) => {
|
|
595
596
|
const changed = [];
|
|
@@ -821,7 +822,11 @@ export function mergeFamilies(tree, keepId, otherId, reason) {
|
|
|
821
822
|
return { family: merged, repointed };
|
|
822
823
|
});
|
|
823
824
|
}
|
|
824
|
-
/**
|
|
825
|
+
/**
|
|
826
|
+
* Write (or rewrite) the story of a person or a couple. The facts it leans on must exist. A story the user approved is
|
|
827
|
+
* locked: written again, the new version waits beside it for their approval (story.draft) — unless they approve it now
|
|
828
|
+
* (final).
|
|
829
|
+
*/
|
|
825
830
|
export function setStory(tree, id, input) {
|
|
826
831
|
const text = input.text.replace(/\r\n?/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
827
832
|
if (!text)
|
|
@@ -829,28 +834,55 @@ export function setStory(tree, id, input) {
|
|
|
829
834
|
const facts = [...new Set((input.facts ?? []).map((f) => f.trim().toUpperCase()))];
|
|
830
835
|
for (const f of facts)
|
|
831
836
|
findEventOwner(tree, f);
|
|
837
|
+
const sources = [...new Set((input.sources ?? []).map((s) => s.trim().toUpperCase()))];
|
|
838
|
+
for (const s of sources)
|
|
839
|
+
if (tree.get(s)?.type !== "source")
|
|
840
|
+
throw new UsageError(`no source ${s}`, { hint: "the background of place and time is recorded first: strom source add … --kind book|newspaper|web" });
|
|
832
841
|
return tree.withTreeLock(() => {
|
|
833
842
|
const rec = tree.get(id);
|
|
834
843
|
if (!rec || (rec.type !== "person" && rec.type !== "family"))
|
|
835
844
|
throw new UsageError(`no person or family ${id}`);
|
|
836
845
|
if (rec.mergedInto)
|
|
837
846
|
throw new UsageError(`${id} was merged into ${rec.mergedInto}`);
|
|
838
|
-
const
|
|
847
|
+
const version = {
|
|
839
848
|
...(input.title?.trim() ? { title: input.title.trim() } : {}),
|
|
840
|
-
status: input.final ? "final" : "draft",
|
|
841
849
|
text,
|
|
842
850
|
facts,
|
|
851
|
+
...(sources.length ? { sources } : {}),
|
|
843
852
|
...(input.note?.trim() ? { note: input.note.trim() } : {}),
|
|
844
853
|
at: now(),
|
|
845
854
|
by: tree.actor,
|
|
846
855
|
};
|
|
847
|
-
const updated = { ...rec, story, updated: now() };
|
|
848
856
|
const words = text.split(/\s+/).length;
|
|
857
|
+
if (rec.story?.status === "final" && !input.final) {
|
|
858
|
+
const updated = { ...rec, story: { ...rec.story, draft: version }, updated: now() };
|
|
859
|
+
tree.put(updated, { op: "story.set", targets: [id], summary: `${id} story: a new version (${words} words) waits for the user's approval; the approved one stays` });
|
|
860
|
+
return updated;
|
|
861
|
+
}
|
|
862
|
+
const story = { ...version, status: input.final ? "final" : "draft" };
|
|
863
|
+
const updated = { ...rec, story, updated: now() };
|
|
849
864
|
tree.put(updated, { op: "story.set", targets: [id], summary: `${id} story${rec.story ? " rewritten" : ""}: ${words} words, ${story.status}` });
|
|
850
865
|
return updated;
|
|
851
866
|
});
|
|
852
867
|
}
|
|
853
|
-
/** The
|
|
868
|
+
/** The new version of an approved story is not wanted: it goes, the approved story stays (the history keeps it). */
|
|
869
|
+
export function discardStoryDraft(tree, id) {
|
|
870
|
+
return tree.withTreeLock(() => {
|
|
871
|
+
const rec = tree.get(id);
|
|
872
|
+
if (!rec || (rec.type !== "person" && rec.type !== "family"))
|
|
873
|
+
throw new UsageError(`no person or family ${id}`);
|
|
874
|
+
if (!rec.story?.draft)
|
|
875
|
+
throw new UsageError(`${id} has no new version of its story waiting`, { hint: `strom story show ${id}` });
|
|
876
|
+
const { draft: _gone, ...story } = rec.story;
|
|
877
|
+
const updated = { ...rec, story, updated: now() };
|
|
878
|
+
tree.put(updated, { op: "story.discard", targets: [id], summary: `${id} story: the new version discarded, the approved one stays` });
|
|
879
|
+
return updated;
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* The user approved the story as it is: no longer a draft (the text stays), locked — or, when a new version waits
|
|
884
|
+
* beside an approved one, that new version takes its place, approved.
|
|
885
|
+
*/
|
|
854
886
|
export function approveStory(tree, id) {
|
|
855
887
|
return tree.withTreeLock(() => {
|
|
856
888
|
const rec = tree.get(id);
|
|
@@ -858,6 +890,11 @@ export function approveStory(tree, id) {
|
|
|
858
890
|
throw new UsageError(`no person or family ${id}`);
|
|
859
891
|
if (!rec.story)
|
|
860
892
|
throw new UsageError(`${id} has no story yet`, { hint: `strom story set ${id} --text @file --fact E…` });
|
|
893
|
+
if (rec.story.draft) {
|
|
894
|
+
const updated = { ...rec, story: { ...rec.story.draft, status: "final" }, updated: now() };
|
|
895
|
+
tree.put(updated, { op: "story.approve", targets: [id], summary: `${id} story: the new version approved, in place of the old one` });
|
|
896
|
+
return updated;
|
|
897
|
+
}
|
|
861
898
|
if (rec.story.status === "final")
|
|
862
899
|
return rec;
|
|
863
900
|
const updated = { ...rec, story: { ...rec.story, status: "final" }, updated: now() };
|
package/dist/core/changelog.js
CHANGED
|
@@ -48,6 +48,7 @@ const SAID = {
|
|
|
48
48
|
"repository.edit": "log.repository.edit",
|
|
49
49
|
"story.set": "log.story.set",
|
|
50
50
|
"story.approve": "log.story.approve",
|
|
51
|
+
"story.discard": "log.story.discard",
|
|
51
52
|
"note.add": "log.note.add",
|
|
52
53
|
"input.add": "log.input.add",
|
|
53
54
|
"input.sync": "log.input.sync",
|
package/dist/core/config.js
CHANGED
|
@@ -44,6 +44,7 @@ export const SETTINGS = [
|
|
|
44
44
|
{ key: "queue.strategy", env: "STROM_QUEUE_STRATEGY", tree: true, kind: "choice", choices: STRATEGIES, description: "order of the task queue: balanced (default — nearest ancestors first, spread over the lines, nothing taken forever), depth (stay on one line), priority (strict priority)" },
|
|
45
45
|
{ key: "gedcom.for", env: "STROM_GEDCOM_FOR", tree: true, kind: "choice", choices: ["both", "standard", "strom"], description: "GEDCOM files written: both (default), standard (any program), strom (the Strom app)" },
|
|
46
46
|
{ key: "stories", env: "STROM_STORIES", tree: true, kind: "choice", choices: ["yes", "no"], description: "stories of the ancestors for the family, written from the facts: yes (default — strom proposes one once a person's life is told by records), no — the user is told when the research starts and may say no" },
|
|
47
|
+
{ key: "stories.sources", env: "STROM_STORIES_SOURCES", tree: true, kind: "choice", choices: ["yes", "no"], description: "before a story is written, a search beyond the registers — newspapers, directories, military, land and court records, graves, the history of the place — so the story can tell more, every word still on a record: yes (default, while stories are on), no (the story from what the research found)" },
|
|
47
48
|
{ key: "sync.edits", env: "STROM_SYNC_EDITS", tree: true, kind: "choice", choices: ["conflict", "user"], description: "your edits in the Strom app (or another program's file) to a fact a record proves, taken by strom sync: conflict (default — the research keeps the record's, a conflict for you to decide) or user (your edit wins, the record's fact is withdrawn with the reason)" },
|
|
48
49
|
{ key: "main.person", env: "STROM_MAIN_PERSON", tree: true, kind: "person", description: "the main person of the tree (P…): first in the GEDCOM files — the Strom app opens on them (default: the nearest person descended from every research's focus, else the first research's focus)" },
|
|
49
50
|
{ key: "excerpts.quality", env: "STROM_EXCERPTS_QUALITY", tree: true, kind: "choice", choices: EXCERPT_QUALITIES, description: "the entries cut out of their scans in the file for the Strom app (output/tree-strom.ged): small (1000 px, grey — half the size), normal (default, 1200 px), sharp (1600 px)" },
|
|
@@ -362,6 +363,10 @@ export class Settings {
|
|
|
362
363
|
const v = this.resolve("stories", tree)?.value;
|
|
363
364
|
return { on: v !== "no", said: v === "yes" || v === "no" };
|
|
364
365
|
}
|
|
366
|
+
/** Before a story, the sources beyond the registers (core/stories.ts): on unless the user said no. */
|
|
367
|
+
storySources(tree) {
|
|
368
|
+
return this.resolve("stories.sources", tree)?.value !== "no";
|
|
369
|
+
}
|
|
365
370
|
/** strom sync: whether the user's edit to a fact a record proves wins, or becomes a conflict. */
|
|
366
371
|
syncEdits(tree) {
|
|
367
372
|
return this.resolve("sync.edits", tree)?.value === "user" ? "user" : "conflict";
|