strom-research 1.4.0 → 1.5.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 +78 -1
- package/assets/lang/de.json +78 -1
- package/assets/method/reading.md +8 -1
- package/dist/agents/files.js +12 -2
- package/dist/agents/global.js +9 -4
- package/dist/agents/profiles.js +8 -4
- package/dist/cli/menu-parts.js +4 -1
- package/dist/cli/menu-research.js +163 -4
- package/dist/cli/menu-settings.js +17 -3
- package/dist/cli/menu.js +3 -2
- package/dist/cli/ui.js +77 -1
- package/dist/commands/browse.js +4 -3
- package/dist/commands/checks.js +7 -1
- package/dist/commands/guide.js +28 -7
- package/dist/commands/index.js +1 -0
- package/dist/commands/intake.js +3 -0
- package/dist/commands/people.js +58 -1
- package/dist/commands/research.js +113 -24
- package/dist/commands/sync.js +229 -0
- package/dist/core/config.js +5 -0
- package/dist/core/frontier.js +10 -1
- package/dist/core/live.js +79 -2
- package/dist/core/phrases.js +1 -0
- package/dist/core/review.js +43 -5
- package/dist/core/self.js +17 -0
- package/dist/core/stromapp.js +13 -0
- package/dist/core/sync.js +786 -0
- package/dist/core/tree.js +1 -1
- package/dist/core/validate.js +4 -0
- package/dist/gedcom/export.js +12 -1
- package/dist/gedcom/validate.js +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// sync · sync undo — a family tree coming back from the Strom app (or another
|
|
2
|
+
// program), compared with the research: what the user changed, taken in on
|
|
3
|
+
// their yes (core/sync.ts).
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { register } from "../cli/registry.js";
|
|
7
|
+
import { lines, truncate } from "../cli/format.js";
|
|
8
|
+
import { ui } from "../cli/ui.js";
|
|
9
|
+
import { eventName, humanDate, humanDay, humanPlace } from "../cli/human.js";
|
|
10
|
+
import { EXIT, StromError, UsageError } from "../core/errors.js";
|
|
11
|
+
import { fileSha256, MAX_IN_TREE, mimeOf } from "../core/media.js";
|
|
12
|
+
import { create, update } from "../core/records.js";
|
|
13
|
+
import { safeFolderName } from "../core/text.js";
|
|
14
|
+
import { now } from "../core/tree.js";
|
|
15
|
+
import { applySync, nothingSince, planSync, readTreeFile, receivedSince, undoSync } from "../core/sync.js";
|
|
16
|
+
import { startLive } from "../core/live.js";
|
|
17
|
+
import { appSendsChanges, installedStromApp, sendAppUrl, stromAppUrl } from "../core/stromapp.js";
|
|
18
|
+
import { chromiumBrowser, openInBrowser, openWebApp } from "../core/chromium.js";
|
|
19
|
+
function factText(f, lang) {
|
|
20
|
+
if (!f)
|
|
21
|
+
return "—";
|
|
22
|
+
return [f.value, humanDate(f.date, lang), humanPlace(f.place, undefined, lang)].filter(Boolean).join(", ") || "—";
|
|
23
|
+
}
|
|
24
|
+
/** One change as the user reads it, with what strom does with it. */
|
|
25
|
+
function changeLine(tree, c, incoming, lang) {
|
|
26
|
+
const name = (key) => {
|
|
27
|
+
if (!key)
|
|
28
|
+
return "?";
|
|
29
|
+
if (key.startsWith("x:"))
|
|
30
|
+
return (incoming.persons.get(key)?.names[0] ?? key).replace(/\//g, "").replace(/\s+/g, " ").trim();
|
|
31
|
+
const p = tree.get(key);
|
|
32
|
+
return p ? `${(p.names[0] ? `${p.names[0].given} ${p.names[0].surname}` : key).trim()} [${key}]` : key;
|
|
33
|
+
};
|
|
34
|
+
const who = c.partners ? c.partners.map(name).join(" & ") || c.family || "?" : name(c.person);
|
|
35
|
+
const kind = (f) => (f ? eventName(f.kind, lang, f.label) : "");
|
|
36
|
+
const v = {
|
|
37
|
+
n: c.n,
|
|
38
|
+
who,
|
|
39
|
+
name: name(c.person),
|
|
40
|
+
kind: kind(c.fact ?? c.was),
|
|
41
|
+
fact: `${kind(c.fact ?? c.was)} ${factText(c.fact ?? c.was, lang)}`,
|
|
42
|
+
was: factText(c.was, lang),
|
|
43
|
+
now: factText(c.fact, lang),
|
|
44
|
+
text: truncate(c.text ?? "", 120),
|
|
45
|
+
child: name(c.child),
|
|
46
|
+
kids: c.kind === "family.new" && c.text ? ` + ${c.text.split(" ").filter(Boolean).map(name).join(", ")}` : "",
|
|
47
|
+
};
|
|
48
|
+
return `${ui(lang, `ui.sync.${c.kind}`, v)} → ${ui(lang, `ui.sync.do.${c.action}`)}`;
|
|
49
|
+
}
|
|
50
|
+
function planText(tree, plan, incoming, file, lang, edits) {
|
|
51
|
+
const shown = file.includes(" ") ? `"${file}"` : file;
|
|
52
|
+
if (!plan.changes.length)
|
|
53
|
+
return ui(lang, "ui.sync.nothing", { file: path.basename(file) });
|
|
54
|
+
return lines(ui(lang, "ui.sync.title", { file: path.basename(file), n: plan.changes.length }), plan.base ? ui(lang, "ui.sync.base", { head: plan.head.slice(0, 8) }) : ui(lang, plan.head ? "ui.sync.headmissing" : "ui.sync.nobase", { head: plan.head?.slice(0, 8) ?? "" }), plan.partial ? ui(lang, "ui.sync.partial") : undefined, plan.identity.strangers.length ? ui(lang, "ui.sync.strangers", { ids: plan.identity.strangers.join(", ") }) : undefined, "", ...plan.changes.map((c) => ` ${changeLine(tree, c, incoming, lang)}`), "", ui(lang, "ui.sync.edits", { mode: ui(lang, edits === "user" ? "ui.sync.mode.user" : "ui.sync.mode.conflict") }), ui(lang, "ui.sync.next", { file: shown }));
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The Strom app sends the tree itself: the bridge started, the app opened with ?send= (installed from a Chromium
|
|
58
|
+
* browser first, else in such a browser's tab), and the tree waited for. The file it came as, or what to do instead.
|
|
59
|
+
*/
|
|
60
|
+
async function fromApp(ctx, root, lang) {
|
|
61
|
+
if (!appSendsChanges(ctx.settings))
|
|
62
|
+
return { text: ui(lang, "ui.sync.nosend") };
|
|
63
|
+
const info = startLive(root, ctx.env, { current: true });
|
|
64
|
+
if (!info)
|
|
65
|
+
throw new StromError("the bridge did not start", { hint: "strom live serve shows why" });
|
|
66
|
+
const url = sendAppUrl(info.url, ctx.settings);
|
|
67
|
+
const installed = installedStromApp(ctx.env, process.platform, stromAppUrl(ctx.settings));
|
|
68
|
+
const webApp = installed?.appId && installed.browser ? { browser: installed.browser, appId: installed.appId, ...(installed.profile ? { profile: installed.profile } : {}) } : undefined;
|
|
69
|
+
const browser = chromiumBrowser(ctx.env);
|
|
70
|
+
const since = Date.now();
|
|
71
|
+
const opened = (webApp && openWebApp(webApp, url, ctx.env)) || (browser && openInBrowser(browser, url, ctx.env));
|
|
72
|
+
const minutes = Math.max(1, Math.round(Number(ctx.env.STROM_SYNC_WAIT_MS ?? 10 * 60_000) / 60_000));
|
|
73
|
+
ctx.io.stderr(`${opened ? ui(lang, "ui.sync.wait", { min: minutes }) : ui(lang, "ui.app.url", { url })}\n`);
|
|
74
|
+
const until = since + Number(ctx.env.STROM_SYNC_WAIT_MS ?? 10 * 60_000);
|
|
75
|
+
while (Date.now() < until) {
|
|
76
|
+
const file = receivedSince(root, since);
|
|
77
|
+
if (file)
|
|
78
|
+
return { file, text: "" };
|
|
79
|
+
// the app sends nothing: said at once, not after the whole wait
|
|
80
|
+
const why = nothingSince(root, since);
|
|
81
|
+
if (why)
|
|
82
|
+
return { text: ui(lang, `ui.sync.nothing.${why}`), settled: why !== "no-tree" };
|
|
83
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
84
|
+
}
|
|
85
|
+
return { text: ui(lang, "ui.sync.waited", { min: minutes }) };
|
|
86
|
+
}
|
|
87
|
+
function numbers(v, max) {
|
|
88
|
+
if (v === undefined)
|
|
89
|
+
return undefined;
|
|
90
|
+
const out = new Set();
|
|
91
|
+
for (const part of String(v).split(/[\s,;]+/).filter(Boolean)) {
|
|
92
|
+
const m = /^(\d+)(?:-(\d+))?$/.exec(part);
|
|
93
|
+
if (!m)
|
|
94
|
+
throw new UsageError(`--only takes numbers of the list, e.g. 1,3,5-7 — not "${part}"`);
|
|
95
|
+
for (let i = Number(m[1]); i <= Number(m[2] ?? m[1]); i++) {
|
|
96
|
+
if (i < 1 || i > max)
|
|
97
|
+
throw new UsageError(`--only: there is no change ${i} (1–${max})`);
|
|
98
|
+
out.add(i);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
register({
|
|
104
|
+
path: ["sync"],
|
|
105
|
+
summary: "A family tree coming back (the Strom app, another program): what you changed, into the research — shown first",
|
|
106
|
+
group: "research",
|
|
107
|
+
tree: true,
|
|
108
|
+
writes: true,
|
|
109
|
+
// the Strom app may take minutes to send the tree: the tree is locked only while a sync is written
|
|
110
|
+
lock: "sections",
|
|
111
|
+
description: "Compares the file with the research and shows what it would take: people, facts, names, notes, families and\n" +
|
|
112
|
+
"children the file has and the research not; facts changed in it; what is no longer in it. A file of the Strom app\n" +
|
|
113
|
+
"names the state of the research it was given (_STROM_HEAD): only what was changed since then counts as your edit;\n" +
|
|
114
|
+
"without it (another program, an older export) only additions are taken, differences wait to be picked (--only).\n" +
|
|
115
|
+
"Nothing is deleted or overwritten: additions are leads citing one source (this sync), a lead is corrected with the\n" +
|
|
116
|
+
"reason, a change to a fact a record proves is a conflict for you to decide — or, with sync.edits user, your edit\n" +
|
|
117
|
+
"wins and the record's fact is withdrawn with the reason. A file of another research, or one where few people are\n" +
|
|
118
|
+
"the research's, is refused. Writes only with --apply; one sync is one commit: strom sync undo I… takes it back.\n" +
|
|
119
|
+
"--app: the Strom app sends the tree itself — strom opens it (the bridge), the user picks the tree and confirms\n" +
|
|
120
|
+
"there, strom waits for it and shows what it brings; then strom sync <the file it names> --apply.",
|
|
121
|
+
args: [{ name: "file", description: "the tree: GEDCOM (.ged) or the Strom app's JSON (none with --app)" }],
|
|
122
|
+
options: [
|
|
123
|
+
{ name: "app", type: "boolean", description: "straight from the Strom app: it opens, the user sends the tree from it, strom waits for it" },
|
|
124
|
+
{ name: "apply", type: "boolean", description: "write it (without: only show)" },
|
|
125
|
+
{ name: "only", type: "string", value: "<numbers>", description: "with --apply: only these changes of the list (1,3,5-7) — a difference is taken only this way" },
|
|
126
|
+
{ name: "edits", type: "string", value: "<conflict|user>", description: "this once: a change to a fact a record proves is a conflict (default) or your edit wins (setting sync.edits)" },
|
|
127
|
+
{ name: "force", type: "boolean", description: "the file is the research's though few of its people match" },
|
|
128
|
+
{ name: "again", type: "boolean", description: "a file taken in before, once more" },
|
|
129
|
+
],
|
|
130
|
+
examples: ["strom sync ~/Downloads/rodina.ged", "strom sync rodina.ged --apply", "strom sync rodina.json --apply --only 1,4"],
|
|
131
|
+
async run(ctx, { args, opts }) {
|
|
132
|
+
const tree = ctx.tree();
|
|
133
|
+
const lang = tree.lang;
|
|
134
|
+
if (opts.app && args[0])
|
|
135
|
+
throw new UsageError("--app takes the tree from the Strom app — no file with it");
|
|
136
|
+
if (!opts.app && !args[0])
|
|
137
|
+
throw new UsageError("which tree? a file, or --app (straight from the Strom app)", { hint: "strom sync ~/Downloads/rodina.ged · strom sync --app" });
|
|
138
|
+
if (opts.app && opts.apply)
|
|
139
|
+
throw new UsageError("--app shows what the tree brings; write it then with the file it names", { hint: "strom sync --app, then strom sync <file> --apply" });
|
|
140
|
+
let got;
|
|
141
|
+
if (opts.app) {
|
|
142
|
+
const r = await fromApp(ctx, tree.root, lang);
|
|
143
|
+
// nothing to show: the app said so (nothing changed, cancelled) — or never answered, or has no tree of this research
|
|
144
|
+
if (!r.file)
|
|
145
|
+
return { text: r.text, data: { received: null }, exitCode: r.settled ? EXIT.ok : EXIT.needsInput };
|
|
146
|
+
got = r.file;
|
|
147
|
+
}
|
|
148
|
+
const shownAs = got ? ctx.display(got) : args[0];
|
|
149
|
+
const file = got ?? ctx.resolvePath(args[0]);
|
|
150
|
+
if (!fs.existsSync(file) || !fs.statSync(file).isFile())
|
|
151
|
+
throw new UsageError(`no such file: ${args[0]}`);
|
|
152
|
+
const edits = opts.edits === undefined ? ctx.settings.syncEdits(tree.config) : String(opts.edits);
|
|
153
|
+
if (edits !== "conflict" && edits !== "user")
|
|
154
|
+
throw new UsageError("--edits is conflict or user");
|
|
155
|
+
const sha = fileSha256(file);
|
|
156
|
+
const before = tree.list("input").find((i) => i.sha === sha && i.sync && !i.sync.undone);
|
|
157
|
+
if (before && !opts.again)
|
|
158
|
+
return { text: ui(lang, "ui.sync.again", { input: before.id }), data: { input: before.id, changes: [] } };
|
|
159
|
+
const incoming = readTreeFile(file);
|
|
160
|
+
const plan = planSync(tree, incoming, edits, { force: !!opts.force });
|
|
161
|
+
if (!opts.apply || !plan.changes.length || tree.dryRun) {
|
|
162
|
+
if (opts.only !== undefined && !opts.apply)
|
|
163
|
+
throw new UsageError("--only goes with --apply");
|
|
164
|
+
return {
|
|
165
|
+
text: lines(got ? ui(lang, "ui.sync.got") : undefined, planText(tree, plan, incoming, shownAs, lang, edits)),
|
|
166
|
+
data: { ...(got ? { received: got } : {}), base: plan.base, head: plan.head ?? null, partial: plan.partial, changes: plan.changes, strangers: plan.identity.strangers },
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
const only = numbers(opts.only, plan.changes.length);
|
|
170
|
+
let input;
|
|
171
|
+
let applied = 0;
|
|
172
|
+
tree.withTreeLock(() => {
|
|
173
|
+
// what another process wrote meanwhile counts: the changes again, now that the tree is ours
|
|
174
|
+
const fresh = planSync(tree, readTreeFile(file), edits, { force: !!opts.force });
|
|
175
|
+
if (fresh.changes.length !== plan.changes.length || fresh.changes.some((c, i) => c.kind !== plan.changes[i].kind || c.person !== plan.changes[i].person))
|
|
176
|
+
throw new UsageError("the research changed while this was shown — look again", { hint: `strom sync ${args[0] ?? file}` });
|
|
177
|
+
const id = tree.peekId("I");
|
|
178
|
+
const size = fs.statSync(file).size;
|
|
179
|
+
const stored = size <= MAX_IN_TREE ? `inputs/${id}-${safeFolderName(path.basename(file))}` : undefined;
|
|
180
|
+
if (stored) {
|
|
181
|
+
tree.remember(path.join(tree.root, stored));
|
|
182
|
+
fs.mkdirSync(path.join(tree.root, "inputs"), { recursive: true });
|
|
183
|
+
fs.copyFileSync(file, path.join(tree.root, stored));
|
|
184
|
+
}
|
|
185
|
+
input = create(tree, "input", { name: path.basename(file), ...(stored ? { file: stored } : {}), sha, size, mime: mimeOf(file), from: ctx.display(file), kind: "tree", state: "processed" }, (iid) => `+${iid} input tree "${truncate(path.basename(file), 50)}" (sync)`);
|
|
186
|
+
const day = humanDay(now(), lang);
|
|
187
|
+
const source = create(tree, "source", {
|
|
188
|
+
kind: "family-tree",
|
|
189
|
+
title: ui(lang, incoming.treeId || plan.identity.matched ? "ui.sync.source.app" : "ui.sync.source.file", { file: path.basename(file), day }),
|
|
190
|
+
input: input.id,
|
|
191
|
+
information: "secondary",
|
|
192
|
+
form: "authored",
|
|
193
|
+
}, (sid) => `+${sid} source "${path.basename(file)}" (sync)`);
|
|
194
|
+
const { applied: done, changes } = applySync(tree, plan, incoming, source, only);
|
|
195
|
+
applied = changes;
|
|
196
|
+
input = update(tree, input.id, "input", (i) => ({ ...i, source: source.id, sync: { ...(plan.head ? { head: plan.head } : {}), edits: edits, applied: done } }), {
|
|
197
|
+
op: "input.sync",
|
|
198
|
+
summary: `${input.id} synced: ${changes} change(s)${plan.head ? ` against ${plan.head.slice(0, 8)}` : ""}`,
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
return {
|
|
202
|
+
text: lines(...tree.written.map((o) => o.summary), "", ui(lang, "ui.sync.done", { n: applied, file: path.basename(file), input: input.id })),
|
|
203
|
+
data: { input: input.id, applied: input.sync?.applied ?? [], changes: plan.changes },
|
|
204
|
+
};
|
|
205
|
+
},
|
|
206
|
+
}, {
|
|
207
|
+
path: ["sync", "undo"],
|
|
208
|
+
summary: "Take a sync back: what it added withdrawn, what it corrected put back, its conflicts closed",
|
|
209
|
+
group: "research",
|
|
210
|
+
tree: true,
|
|
211
|
+
writes: true,
|
|
212
|
+
args: [{ name: "input", description: "the sync's input (I…), as strom sync said", required: true }],
|
|
213
|
+
examples: ["strom sync undo I0002"],
|
|
214
|
+
run(ctx, { args }) {
|
|
215
|
+
const tree = ctx.tree();
|
|
216
|
+
const id = args[0].toUpperCase().replace(/^I?(\d+)$/, (_, n) => `I${n.padStart(4, "0")}`);
|
|
217
|
+
const input = tree.get(id);
|
|
218
|
+
if (!input || input.type !== "input" || !input.sync)
|
|
219
|
+
throw new UsageError(`${args[0]} is not a sync`, { hint: "strom input list — a sync's input says so" });
|
|
220
|
+
if (input.sync.undone)
|
|
221
|
+
return { text: ui(tree.lang, "ui.sync.wasundone", { input: id }), data: { input: id, undone: 0 } };
|
|
222
|
+
let n = 0;
|
|
223
|
+
tree.withTreeLock(() => {
|
|
224
|
+
n = undoSync(tree, input);
|
|
225
|
+
update(tree, id, "input", (i) => ({ ...i, sync: { ...i.sync, undone: now() } }), { op: "input.sync.undo", summary: `${id} sync undone: ${n} step(s)` });
|
|
226
|
+
});
|
|
227
|
+
return { text: lines(...tree.written.map((o) => o.summary), "", ui(tree.lang, "ui.sync.undone", { input: id, n })), data: { input: id, undone: n } };
|
|
228
|
+
},
|
|
229
|
+
});
|
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: "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)" },
|
|
47
48
|
{ 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)" },
|
|
48
49
|
{ 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)" },
|
|
49
50
|
{ key: "excerpts.for", env: "STROM_EXCERPTS_FOR", tree: true, kind: "choice", choices: EXCERPT_SCOPES, description: "whose entries get their image in the Strom app: none (the file without images), line (the ancestors), family (default: the ancestors and their families), connected (anyone linked to them), all" },
|
|
@@ -359,6 +360,10 @@ export class Settings {
|
|
|
359
360
|
const v = this.resolve("stories", tree)?.value;
|
|
360
361
|
return { on: v !== "no", said: v === "yes" || v === "no" };
|
|
361
362
|
}
|
|
363
|
+
/** strom sync: whether the user's edit to a fact a record proves wins, or becomes a conflict. */
|
|
364
|
+
syncEdits(tree) {
|
|
365
|
+
return this.resolve("sync.edits", tree)?.value === "user" ? "user" : "conflict";
|
|
366
|
+
}
|
|
362
367
|
/** The images for the Strom app: how sharp, whose, and the limit of all together (MB). */
|
|
363
368
|
excerpts(tree) {
|
|
364
369
|
return {
|
package/dist/core/frontier.js
CHANGED
|
@@ -102,7 +102,7 @@ export function taskRecordsets(tree, task) {
|
|
|
102
102
|
* 25 years before their marriage, else about 28 before their eldest known child —
|
|
103
103
|
* where that happened. An estimate widens the years a search has to cover.
|
|
104
104
|
*/
|
|
105
|
-
function birthEstimate(tree, p, lang) {
|
|
105
|
+
export function birthEstimate(tree, p, lang) {
|
|
106
106
|
const own = birthEvent(p);
|
|
107
107
|
const ownYear = own?.date ? dateYears(own.date)[0] : undefined;
|
|
108
108
|
if (ownYear && own?.place)
|
|
@@ -132,6 +132,15 @@ export function researchPeople(tree, research) {
|
|
|
132
132
|
return ancestorGenerations(tree, research.focus, research.limits?.generations ?? 50);
|
|
133
133
|
if (research.direction !== "person")
|
|
134
134
|
return new Map();
|
|
135
|
+
// a review of several people; of the people without a record of their own: each until one proves them
|
|
136
|
+
if (research.review?.people) {
|
|
137
|
+
const unproven = !!research.review.unproven;
|
|
138
|
+
const proven = (p) => p.events.some((e) => !e.retracted && (e.status === "proven" || e.status === "probable"));
|
|
139
|
+
return new Map(research.review.people
|
|
140
|
+
.map((id) => tree.get(id))
|
|
141
|
+
.filter((p) => p?.type === "person" && !p.retracted && !(unproven && proven(p)))
|
|
142
|
+
.map((p) => [p.id, 1]));
|
|
143
|
+
}
|
|
135
144
|
const scope = research.review?.scope ?? "person";
|
|
136
145
|
if (scope === "line")
|
|
137
146
|
return ancestorGenerations(tree, research.focus, research.limits?.generations ?? 50);
|
package/dist/core/live.js
CHANGED
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
// GET <token>/tree.ged the tree for the Strom app, as it is now, each entry with its image (the settings excerpts.*)
|
|
11
11
|
// GET <token>/images.ged the same (the address an older strom gave)
|
|
12
12
|
// GET <token>/events server-sent events: hello, change, working
|
|
13
|
+
// POST <token>/sync the Strom app sends the user's edited tree back (?send=): kept to be shown
|
|
14
|
+
// and written on the user's word (strom sync) — nothing of the research changes
|
|
15
|
+
// POST <token>/cancel …or says it sends nothing ({"reason": "unchanged" | "cancelled" | "no-tree"})
|
|
13
16
|
//
|
|
14
17
|
// Only pages of the Strom app may read it (CORS: https://stromapp.info, its beta, and a
|
|
15
18
|
// local copy on localhost for its development), and a browser asks first
|
|
@@ -22,6 +25,8 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
22
25
|
import { Tree, VERSION } from "./tree.js";
|
|
23
26
|
import { exportGedcom } from "../gedcom/export.js";
|
|
24
27
|
import { excerptSettings, planExcerpts } from "./excerpt.js";
|
|
28
|
+
import { noteNothingSent, receiveTree, SYNC_MAX_BYTES } from "./sync.js";
|
|
29
|
+
import { ui } from "../cli/ui.js";
|
|
25
30
|
/** New images for the app, made for at most so long when the tree changed (the rest the next time). */
|
|
26
31
|
const LIVE_IMAGES_MS = 20_000;
|
|
27
32
|
import { liveWorkers } from "./workers.js";
|
|
@@ -153,7 +158,7 @@ export function serveLive(root, env) {
|
|
|
153
158
|
}
|
|
154
159
|
if (req.method === "OPTIONS") {
|
|
155
160
|
if (origin) {
|
|
156
|
-
res.setHeader("Access-Control-Allow-Methods", "GET");
|
|
161
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST");
|
|
157
162
|
res.setHeader("Access-Control-Allow-Headers", String(req.headers["access-control-request-headers"] ?? ""));
|
|
158
163
|
if (req.headers["access-control-request-private-network"])
|
|
159
164
|
res.setHeader("Access-Control-Allow-Private-Network", "true");
|
|
@@ -162,6 +167,14 @@ export function serveLive(root, env) {
|
|
|
162
167
|
return;
|
|
163
168
|
}
|
|
164
169
|
const [, t, what] = (req.url ?? "").split("?")[0].split("/");
|
|
170
|
+
if (req.method === "POST" && t === token && what === "sync") {
|
|
171
|
+
receive(req, res, origin);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (req.method === "POST" && t === token && what === "cancel") {
|
|
175
|
+
nothing(req, res, origin);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
165
178
|
if (req.method !== "GET" || t !== token) {
|
|
166
179
|
res.writeHead(404).end();
|
|
167
180
|
return;
|
|
@@ -177,7 +190,7 @@ export function serveLive(root, env) {
|
|
|
177
190
|
// the images from the cache; new ones made for a while at most (the rest the next time the tree changes)
|
|
178
191
|
const set = excerptSettings(tree);
|
|
179
192
|
const images = set ? planExcerpts(tree, set.shared, { quality: set.quality, for: set.for, maxBytes: set.mb * 1024 * 1024, budgetMs: LIVE_IMAGES_MS }) : undefined;
|
|
180
|
-
ged = { head: h, text: exportGedcom(tree, { for: "strom", ...(images ? { excerpts: images.of } : {}) }).text };
|
|
193
|
+
ged = { head: h, text: exportGedcom(tree, { for: "strom", ...(h ? { head: h } : {}), ...(images ? { excerpts: images.of } : {}) }).text };
|
|
181
194
|
}
|
|
182
195
|
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", "X-Strom-Head": h }).end(ged.text);
|
|
183
196
|
}
|
|
@@ -194,6 +207,70 @@ export function serveLive(root, env) {
|
|
|
194
207
|
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }).end(e.message);
|
|
195
208
|
}
|
|
196
209
|
});
|
|
210
|
+
/** The user's tree from the app: only from its pages, only so large; the answer in the research's language. */
|
|
211
|
+
const receive = (req, res, origin) => {
|
|
212
|
+
const reply = (code, body) => res.writeHead(code, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }).end(JSON.stringify(body));
|
|
213
|
+
const lang = () => {
|
|
214
|
+
try {
|
|
215
|
+
return Tree.open(root, env).lang;
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return "en";
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
if (!origin) {
|
|
222
|
+
reply(403, { error: "only the Strom app may send a tree here" });
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const chunks = [];
|
|
226
|
+
let size = 0;
|
|
227
|
+
let over = false;
|
|
228
|
+
req.on("data", (c) => {
|
|
229
|
+
size += c.length;
|
|
230
|
+
if (size > SYNC_MAX_BYTES)
|
|
231
|
+
over = true;
|
|
232
|
+
else
|
|
233
|
+
chunks.push(c);
|
|
234
|
+
});
|
|
235
|
+
req.on("end", () => {
|
|
236
|
+
if (over) {
|
|
237
|
+
reply(413, { error: ui(lang(), "ui.sync.bridge.large") });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
const got = receiveTree(root, env, Buffer.concat(chunks).toString("utf8"));
|
|
242
|
+
reply(200, { ok: true, changes: got.changes, file: path.basename(got.file) });
|
|
243
|
+
}
|
|
244
|
+
catch (e) {
|
|
245
|
+
const m = e.message;
|
|
246
|
+
const key = /another research/.test(m) ? "ui.sync.bridge.other" : /does not look like/.test(m) ? "ui.sync.bridge.foreign" : /empty|no people|not a GEDCOM/.test(m) ? "ui.sync.bridge.empty" : undefined;
|
|
247
|
+
reply(400, { error: key ? ui(lang(), key) : m });
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
};
|
|
251
|
+
/** The app sends nothing (unchanged, the user said no, no tree of this research): strom sync --app stops waiting. */
|
|
252
|
+
const nothing = (req, res, origin) => {
|
|
253
|
+
const reply = (code, body) => res.writeHead(code, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }).end(JSON.stringify(body));
|
|
254
|
+
if (!origin) {
|
|
255
|
+
reply(403, { error: "only the Strom app may say so" });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
let body = "";
|
|
259
|
+
req.on("data", (c) => {
|
|
260
|
+
if (body.length < 4096)
|
|
261
|
+
body += c.toString("utf8");
|
|
262
|
+
});
|
|
263
|
+
req.on("end", () => {
|
|
264
|
+
let reason = "cancelled";
|
|
265
|
+
try {
|
|
266
|
+
reason = String(JSON.parse(body).reason ?? reason);
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
reason = body.trim() || reason;
|
|
270
|
+
}
|
|
271
|
+
reply(200, { ok: true, reason: noteNothingSent(root, reason) });
|
|
272
|
+
});
|
|
273
|
+
};
|
|
197
274
|
return new Promise((resolve) => {
|
|
198
275
|
server.listen(0, "127.0.0.1", () => {
|
|
199
276
|
const port = server.address().port;
|
package/dist/core/phrases.js
CHANGED
|
@@ -32,6 +32,7 @@ export const PHRASES = {
|
|
|
32
32
|
"story.why": "{count} facts from records tell the life (generation {generation} of {research}); the family reads it in the Strom app's family book",
|
|
33
33
|
"story.done": "strom story set {id} with every fact it rests on (--fact) — a draft until the user approves it",
|
|
34
34
|
"review.research": "Review of {name}",
|
|
35
|
+
"review.unproven.research": "People without a record of their own",
|
|
35
36
|
"review.more": " (and {count} more after these)",
|
|
36
37
|
"review.mentions.what": "What the tree already says of {name} outside their data: {list}{more}",
|
|
37
38
|
"review.mentions.why": "records, notes or the diary name {name}, yet their data do not show it — the cheapest finds of a review ({research})",
|
package/dist/core/review.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// Each kind is at most one task per person at a time, and what a task of that
|
|
8
8
|
// kind took up is never proposed again.
|
|
9
9
|
import { dateYears } from "./gdate.js";
|
|
10
|
-
import { RECORD_KINDS, recordsetsCovering, researchPeople } from "./frontier.js";
|
|
10
|
+
import { birthEstimate, RECORD_KINDS, recordsetsCovering, researchPeople } from "./frontier.js";
|
|
11
11
|
import { birthEvent, displayName, familiesAsPartner, lifespan } from "./people.js";
|
|
12
12
|
import { phrase } from "./phrases.js";
|
|
13
13
|
import { foldText } from "./text.js";
|
|
@@ -55,6 +55,11 @@ function citedFor(tree, p) {
|
|
|
55
55
|
for (const n of p.names)
|
|
56
56
|
for (const c of n.citations ?? [])
|
|
57
57
|
out.add(c.source);
|
|
58
|
+
// the record naming a family — a child's baptism names its parents — also without a fact of the family
|
|
59
|
+
for (const f of tree.list("family"))
|
|
60
|
+
if (!f.retracted && (f.partners.includes(p.id) || f.children.some((c) => c.person === p.id)))
|
|
61
|
+
for (const c of f.citations ?? [])
|
|
62
|
+
out.add(c.source);
|
|
58
63
|
for (const other of tree.list("person"))
|
|
59
64
|
for (const e of other.events)
|
|
60
65
|
if (e.participants?.some((x) => x.person === p.id))
|
|
@@ -67,6 +72,36 @@ function citedFor(tree, p) {
|
|
|
67
72
|
out.add(c.source);
|
|
68
73
|
return out;
|
|
69
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* The people no record of their own proves: not one fact of theirs proven or
|
|
77
|
+
* probable — the living among them marked. Each with how they stand — a record names them elsewhere, their
|
|
78
|
+
* facts are only leads, or nothing but a note says where they come from.
|
|
79
|
+
*/
|
|
80
|
+
export function unprovenPeople(tree) {
|
|
81
|
+
const out = [];
|
|
82
|
+
for (const p of tree.list("person")) {
|
|
83
|
+
if (p.retracted)
|
|
84
|
+
continue;
|
|
85
|
+
const facts = p.events.filter((e) => !e.retracted && e.status !== "retracted" && e.status !== "disproven");
|
|
86
|
+
if (facts.some((e) => e.status === "proven" || e.status === "probable"))
|
|
87
|
+
continue;
|
|
88
|
+
const sources = [...citedFor(tree, p)];
|
|
89
|
+
const kind = facts.some((e) => e.citations.length) ? "leads" : sources.length ? "named" : "none";
|
|
90
|
+
out.push({ person: p, kind, sources, living: livingBorn(tree, p) !== undefined });
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Most likely alive: born (or, by their marriage or eldest child, about) less than DEATH_AFTER_YEARS ago, and no
|
|
96
|
+
* death recorded — the year, else undefined. Their records are the family's; the registers of those years are
|
|
97
|
+
* mostly closed.
|
|
98
|
+
*/
|
|
99
|
+
export function livingBorn(tree, p) {
|
|
100
|
+
if (p.events.some((e) => !e.retracted && e.status !== "disproven" && DEATHS.has(e.kind)))
|
|
101
|
+
return undefined;
|
|
102
|
+
const born = birthEstimate(tree, p, tree.lang).year;
|
|
103
|
+
return born !== undefined && born > new Date().getFullYear() - DEATH_AFTER_YEARS ? born : undefined;
|
|
104
|
+
}
|
|
70
105
|
/** The models that read each source: the sessions that recorded or corrected it (the user's hand counts as "user"). */
|
|
71
106
|
export function readersOf(tree) {
|
|
72
107
|
const model = new Map(tree.list("session").map((s) => [s.id, s.model ?? "unknown"]));
|
|
@@ -207,10 +242,13 @@ export function reviewProposals(tree, research) {
|
|
|
207
242
|
};
|
|
208
243
|
propose("mentions", "enrich", mentionsOf(tree, p, citedFor(tree, p)));
|
|
209
244
|
propose("entries", "enrich", entriesOf(tree, p));
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
propose("
|
|
245
|
+
// the people without a record want one of their own first: checking what their family's facts rest on can wait
|
|
246
|
+
if (!research.review?.unproven) {
|
|
247
|
+
const single = singleReadings(tree, p);
|
|
248
|
+
propose("verify", "verify", single);
|
|
249
|
+
if (reread && readers)
|
|
250
|
+
propose("reread", "verify", rereads(tree, p, reread, readers).filter((e) => !single.includes(e)), { model: reread });
|
|
251
|
+
}
|
|
214
252
|
propose("conflicts", "verify", openConflicts(tree, p));
|
|
215
253
|
out.push(...gaps(tree, research, p, name, tasks));
|
|
216
254
|
}
|
package/dist/core/self.js
CHANGED
|
@@ -33,6 +33,23 @@ export function installation() {
|
|
|
33
33
|
}
|
|
34
34
|
return (cached = { kind: "npm" });
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Another strom on disk than the one running (strom update replaced its code or its Node): this process goes on
|
|
38
|
+
* with what it loaded, so a person is told to start strom again. `version`: the running one's.
|
|
39
|
+
*/
|
|
40
|
+
export function replacedOnDisk(version) {
|
|
41
|
+
const inst = installation();
|
|
42
|
+
if (inst.kind !== "installed" || !inst.root)
|
|
43
|
+
return false;
|
|
44
|
+
try {
|
|
45
|
+
const app = JSON.parse(fs.readFileSync(path.join(inst.root, "app", "package.json"), "utf8"));
|
|
46
|
+
const info = JSON.parse(fs.readFileSync(path.join(inst.root, "install.json"), "utf8"));
|
|
47
|
+
return app.version !== version || info.node !== inst.node;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
36
53
|
/** The program and arguments that run strom: this Node and its entry script (cli.ts from sources, cli.js built). */
|
|
37
54
|
export function stromLauncher() {
|
|
38
55
|
const cli = path.resolve(path.dirname(here), "..", here.endsWith(".ts") ? "cli.ts" : "cli.js");
|
package/dist/core/stromapp.js
CHANGED
|
@@ -30,6 +30,19 @@ export const APP_OPENS_RESEARCH = true;
|
|
|
30
30
|
export function appOpensResearch(settings) {
|
|
31
31
|
return APP_OPENS_RESEARCH || stromAppUrl(settings) !== STROM_APP_URL;
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* The Strom app sends the user's edits back (?send=<the bridge>, POST <bridge>/sync — the app's spec
|
|
35
|
+
* docs/ZADANI_SYNC_Z_APLIKACE.md): the first version of stromapp.info that does (3.3.0, 2026-09-27).
|
|
36
|
+
* Another copy of the app (its beta, its development: strom.app.url) is taken as current.
|
|
37
|
+
*/
|
|
38
|
+
export const APP_SENDS_CHANGES = "3.3.0";
|
|
39
|
+
export function appSendsChanges(settings) {
|
|
40
|
+
return APP_SENDS_CHANGES !== undefined || stromAppUrl(settings) !== STROM_APP_URL;
|
|
41
|
+
}
|
|
42
|
+
/** The address that opens the Strom app to send the user's edited tree to the bridge. */
|
|
43
|
+
export function sendAppUrl(bridge, settings) {
|
|
44
|
+
return `${stromAppUrl(settings)}?send=${encodeURIComponent(bridge)}`;
|
|
45
|
+
}
|
|
33
46
|
/** The address that opens the Strom app following a research through its bridge. */
|
|
34
47
|
export function liveAppUrl(bridge, settings) {
|
|
35
48
|
return `${stromAppUrl(settings)}?live=${encodeURIComponent(bridge)}`;
|