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.
@@ -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
+ });
@@ -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 {
@@ -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;
@@ -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})",
@@ -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
- const single = singleReadings(tree, p);
211
- propose("verify", "verify", single);
212
- if (reread && readers)
213
- propose("reread", "verify", rereads(tree, p, reread, readers).filter((e) => !single.includes(e)), { model: reread });
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");
@@ -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)}`;