strom-research 1.13.0 → 1.13.1-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/lang/cs.json +1 -0
- package/assets/lang/de.json +1 -0
- package/dist/brief/brief.js +2 -1
- package/dist/cli/ui.js +1 -0
- package/dist/core/directions.js +19 -0
- package/dist/core/edge.js +5 -4
- package/dist/core/kin.js +7 -2
- package/dist/core/links.js +56 -31
- package/dist/core/live.js +150 -30
- package/dist/core/review.js +2 -1
- package/dist/core/tree.js +1 -1
- package/dist/gedcom/export.js +9 -2
- package/package.json +4 -1
package/assets/lang/cs.json
CHANGED
|
@@ -256,6 +256,7 @@
|
|
|
256
256
|
"ui.doc.links.ours": "ano",
|
|
257
257
|
"ui.doc.links.other": "vedou do jiného programu (nebo tam, kde strom býval)",
|
|
258
258
|
"ui.doc.links.none": "žádné",
|
|
259
|
+
"ui.doc.links.unknown": "systém neodpověděl (zjistí se příště)",
|
|
259
260
|
"ui.doc.app": "aplikace Strom",
|
|
260
261
|
"ui.doc.tree": "tento rodokmen",
|
|
261
262
|
"ui.doc.innode": "strom {version} v Node {node}",
|
package/assets/lang/de.json
CHANGED
|
@@ -256,6 +256,7 @@
|
|
|
256
256
|
"ui.doc.links.ours": "ja",
|
|
257
257
|
"ui.doc.links.other": "führen zu einem anderen Programm (oder dorthin, wo strom früher war)",
|
|
258
258
|
"ui.doc.links.none": "keine",
|
|
259
|
+
"ui.doc.links.unknown": "das System hat nicht geantwortet (beim nächsten Mal erneut gefragt)",
|
|
259
260
|
"ui.doc.app": "Strom-App",
|
|
260
261
|
"ui.doc.tree": "dieser Stammbaum",
|
|
261
262
|
"ui.doc.innode": "strom {version} in Node {node}",
|
package/dist/brief/brief.js
CHANGED
|
@@ -18,6 +18,7 @@ import { readyConnectors } from "../core/connector.js";
|
|
|
18
18
|
import { runs, shellArg } from "../cli/format.js";
|
|
19
19
|
import { foldText } from "../core/text.js";
|
|
20
20
|
import { subjectPeople } from "../core/records.js";
|
|
21
|
+
import { hypothesisPeople } from "../core/directions.js";
|
|
21
22
|
export { DEFAULT_BUDGET } from "../core/config.js";
|
|
22
23
|
import { DEFAULT_BUDGET } from "../core/config.js";
|
|
23
24
|
/** Rough token estimate: good enough for a soft budget. */
|
|
@@ -254,7 +255,7 @@ export function buildBrief(tree, opts) {
|
|
|
254
255
|
// the task's own conflicts and hypotheses come first, whatever their state
|
|
255
256
|
const own = (id) => subjects.has(id);
|
|
256
257
|
const conflicts = tree.list("conflict").filter((c) => own(c.id) || (c.state === "open" && c.subject.some((s) => ids.has(s))));
|
|
257
|
-
const hyps = tree.list("hypothesis").filter((h) => own(h.id) || (h.state === "open" && h.
|
|
258
|
+
const hyps = tree.list("hypothesis").filter((h) => own(h.id) || (h.state === "open" && hypothesisPeople(tree, h).some((s) => ids.has(s))));
|
|
258
259
|
const first = (list) => [...list.filter((x) => own(x.id)), ...list.filter((x) => !own(x.id))];
|
|
259
260
|
const mark = (id, state) => `${own(id) ? "→ " : " "}${id}${state === "open" ? "" : ` [${state}]`}`;
|
|
260
261
|
if (conflicts.length || hyps.length)
|
package/dist/cli/ui.js
CHANGED
|
@@ -223,6 +223,7 @@ export const UI = {
|
|
|
223
223
|
"ui.doc.links.ours": "yes",
|
|
224
224
|
"ui.doc.links.other": "lead to another program (or where strom was before)",
|
|
225
225
|
"ui.doc.links.none": "none",
|
|
226
|
+
"ui.doc.links.unknown": "the system did not answer (asked again next time)",
|
|
226
227
|
"ui.doc.app": "Strom app",
|
|
227
228
|
"ui.doc.tree": "this family tree",
|
|
228
229
|
"ui.doc.innode": "strom {version} in Node {node}",
|
package/dist/core/directions.js
CHANGED
|
@@ -14,6 +14,25 @@ export function aboutPeople(tree, subject) {
|
|
|
14
14
|
const partners = subject.filter((id) => typeOfId(id) === "family").flatMap((id) => tree.get(id)?.partners ?? []);
|
|
15
15
|
return [...new Set([...subjectPeople(tree, subject), ...partners])];
|
|
16
16
|
}
|
|
17
|
+
/** Whether a text names a record by its ID (any script around it: never \b, which knows only ASCII letters). */
|
|
18
|
+
export function namesId(text, id) {
|
|
19
|
+
return new RegExp(`(?<![\\p{L}\\p{M}\\p{N}])${id}(?![\\p{L}\\p{M}\\p{N}])`, "u").test(text);
|
|
20
|
+
}
|
|
21
|
+
const PERSON_ID = /(?<![\p{L}\p{M}\p{N}])P\d{4,}(?![\p{L}\p{M}\p{N}])/gu;
|
|
22
|
+
/**
|
|
23
|
+
* The people a hypothesis is about: those of its subject, and the people of the tree its question and its variants'
|
|
24
|
+
* claims name by ID ("the son of P0012 and P0013") — a variant that names a family nothing links to the tree would
|
|
25
|
+
* join it, though the subject names only the person of the tree. What each variant cites for or against it is
|
|
26
|
+
* evidence, not who it is about.
|
|
27
|
+
*/
|
|
28
|
+
export function hypothesisPeople(tree, h) {
|
|
29
|
+
const named = [h.question, ...h.variants.map((v) => v.claim)].flatMap((text) => text.match(PERSON_ID) ?? []);
|
|
30
|
+
const people = named.filter((id) => {
|
|
31
|
+
const p = tree.get(id);
|
|
32
|
+
return p && !p.retracted;
|
|
33
|
+
});
|
|
34
|
+
return [...new Set([...aboutPeople(tree, h.subject), ...people])];
|
|
35
|
+
}
|
|
17
36
|
/** The direction a task belongs to: its own, else the narrowest one its people are in; none for a task about nobody. */
|
|
18
37
|
export function directionOf(tree, t, all) {
|
|
19
38
|
if (t.research)
|
package/dist/core/edge.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// tree ends (_STROM_EDGE, _STROM_ISLAND) and what `strom edge` says.
|
|
7
7
|
import { now } from "./tree.js";
|
|
8
8
|
import { Settings } from "./config.js";
|
|
9
|
-
import { aboutPeople, directionOf, scopes } from "./directions.js";
|
|
9
|
+
import { aboutPeople, directionOf, hypothesisPeople, namesId, scopes } from "./directions.js";
|
|
10
10
|
import { birthEstimate, birthRecordProven, birthWindow, frontier, FRONTIER_LEVELS, RECORD_KINDS, recordsetsCovering } from "./frontier.js";
|
|
11
11
|
import { kinship, offTree } from "./kin.js";
|
|
12
12
|
import { familiesAsChild, familiesAsPartner, parentsOf, primaryName } from "./people.js";
|
|
@@ -81,7 +81,7 @@ function islands(tree, kin, tasks, hypotheses) {
|
|
|
81
81
|
const island = {
|
|
82
82
|
people: [...people],
|
|
83
83
|
hypotheses: hypotheses
|
|
84
|
-
.map((h) => ({ h, about:
|
|
84
|
+
.map((h) => ({ h, about: hypothesisPeople(tree, h) }))
|
|
85
85
|
.filter(({ about }) => about.some((x) => people.has(x)) && about.some((x) => kin.has(x)))
|
|
86
86
|
.map(({ h, about }) => ({ id: h.id, joins: about.filter((x) => kin.has(x)) })),
|
|
87
87
|
held: tasks.filter((t) => LIVE.has(t.state) && off(t) && aboutPeople(tree, t.subject).some((x) => people.has(x))).length,
|
|
@@ -113,7 +113,7 @@ export function treeEdges(tree) {
|
|
|
113
113
|
frontiers.set(r.id, new Map(frontier(tree, r).map((i) => [i.person.id, i])));
|
|
114
114
|
return frontiers.get(r.id);
|
|
115
115
|
};
|
|
116
|
-
const hypAbout = hypotheses.map((h) => ({ h, about:
|
|
116
|
+
const hypAbout = hypotheses.map((h) => ({ h, about: hypothesisPeople(tree, h) }));
|
|
117
117
|
const edges = new Map();
|
|
118
118
|
for (const p of tree.list("person")) {
|
|
119
119
|
if (p.retracted)
|
|
@@ -256,7 +256,8 @@ export function treeEdges(tree) {
|
|
|
256
256
|
question: h.question,
|
|
257
257
|
joins,
|
|
258
258
|
...(isle ? { island: { people: isle.people.length, held: isle.held } } : {}),
|
|
259
|
-
|
|
259
|
+
// a task about it, or one whose words name it ("the baptism … (H0007 B)")
|
|
260
|
+
tests: tasks.filter((t) => LIVE.has(t.state) && (t.subject.includes(h.id) || namesId(t.what, h.id))).map((t) => t.id),
|
|
260
261
|
};
|
|
261
262
|
});
|
|
262
263
|
const edgeConflicts = conflicts
|
package/dist/core/kin.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// app opens on them), and how near anyone is to the people the research is
|
|
3
3
|
// for (which entries get their image in the Strom app first).
|
|
4
4
|
import { Settings } from "./config.js";
|
|
5
|
-
import { aboutPeople } from "./directions.js";
|
|
5
|
+
import { aboutPeople, hypothesisPeople } from "./directions.js";
|
|
6
6
|
import { ancestorGenerations, familiesAsChild, familiesAsPartner } from "./people.js";
|
|
7
7
|
function researches(tree) {
|
|
8
8
|
return tree
|
|
@@ -125,5 +125,10 @@ export function joiningHypotheses(tree, people) {
|
|
|
125
125
|
}
|
|
126
126
|
return tree
|
|
127
127
|
.list("hypothesis")
|
|
128
|
-
.filter((h) =>
|
|
128
|
+
.filter((h) => {
|
|
129
|
+
if (h.state !== "open")
|
|
130
|
+
return false;
|
|
131
|
+
const about = hypothesisPeople(tree, h);
|
|
132
|
+
return about.some((s) => family.has(s)) && about.some((s) => kin.has(s));
|
|
133
|
+
});
|
|
129
134
|
}
|
package/dist/core/links.js
CHANGED
|
@@ -329,22 +329,22 @@ const sys = (cmd, args) => {
|
|
|
329
329
|
return { status: r.error ? null : r.status, stdout: r.stdout ?? "" };
|
|
330
330
|
};
|
|
331
331
|
const LSREGISTER = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
|
|
332
|
-
/** The app the system opens a link of this scheme with (macOS),
|
|
332
|
+
/** The app the system opens a link of this scheme with (macOS), "" for none, undefined when the system did not answer. */
|
|
333
333
|
function macHandlerPath(run, scheme) {
|
|
334
334
|
const js = `ObjC.import("AppKit");var u=$.NSWorkspace.sharedWorkspace.URLForApplicationToOpenURL($.NSURL.URLWithString("${scheme}://status"));u.isNil()?"":u.path.js`;
|
|
335
335
|
const r = run("osascript", ["-l", "JavaScript", "-e", js]);
|
|
336
|
-
return r.status === 0 ? r.stdout.trim() :
|
|
336
|
+
return r.status === 0 ? r.stdout.trim() : undefined;
|
|
337
337
|
}
|
|
338
338
|
/** The mark inside strom's applet: what it runs (another strom's applet, or one of an older place, is not ours). */
|
|
339
339
|
function macMark(app) {
|
|
340
340
|
return path.join(app, "Contents", "Resources", "strom-link.json");
|
|
341
341
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
export async function linkHandlerStateLater(env, platform = process.platform) {
|
|
347
|
-
if (env.STROM_NO_INSTALL === "1")
|
|
342
|
+
const sysLater = (cmd, args) => new Promise((resolve) => {
|
|
343
|
+
// a program that failed to start or was stopped for taking too long: no status (no answer)
|
|
344
|
+
execFile(cmd, args, { encoding: "utf8", timeout: 30_000, windowsHide: true }, (err, stdout) => resolve({ status: err ? (typeof err.code === "number" ? err.code : null) : 0, stdout: stdout ?? "" }));
|
|
345
|
+
});
|
|
346
|
+
export async function linkHandlerStateLater(env, platform = process.platform, exec = sysLater) {
|
|
347
|
+
if (env.STROM_NO_INSTALL === "1" && exec === sysLater)
|
|
348
348
|
return "none";
|
|
349
349
|
const calls = [];
|
|
350
350
|
// the commands it would run, found by a dry pass, then each asked in the background
|
|
@@ -353,18 +353,22 @@ export async function linkHandlerStateLater(env, platform = process.platform) {
|
|
|
353
353
|
return { status: null, stdout: "" };
|
|
354
354
|
});
|
|
355
355
|
for (const c of calls)
|
|
356
|
-
c.r = await
|
|
357
|
-
execFile(c.cmd, c.args, { encoding: "utf8", timeout: 30_000, windowsHide: true }, (err, stdout) => resolve({ status: err ? (typeof err.code === "number" ? err.code : null) : 0, stdout: stdout ?? "" }));
|
|
358
|
-
});
|
|
356
|
+
c.r = await exec(c.cmd, c.args);
|
|
359
357
|
return linkHandlerState(env, platform, (cmd, args) => calls.find((c) => c.cmd === cmd && JSON.stringify(c.args) === JSON.stringify(args))?.r ?? { status: null, stdout: "" });
|
|
360
358
|
}
|
|
361
|
-
/**
|
|
359
|
+
/**
|
|
360
|
+
* Does the scheme lead to this strom? No system is asked from a test (STROM_NO_INSTALL). "none" only when the system
|
|
361
|
+
* answered that nothing takes the links; a query that failed or timed out is "unknown" (found: a bridge said no links
|
|
362
|
+
* for a minute while osascript was slow, and the Strom app forgot them).
|
|
363
|
+
*/
|
|
362
364
|
export function linkHandlerState(env, platform = process.platform, run = sys) {
|
|
363
365
|
if (env.STROM_NO_INSTALL === "1" && run === sys)
|
|
364
366
|
return "none";
|
|
365
367
|
const argv = handlerArgv(env, platform);
|
|
366
368
|
if (platform === "darwin") {
|
|
367
369
|
const at = macHandlerPath(run, linkScheme(env));
|
|
370
|
+
if (at === undefined)
|
|
371
|
+
return "unknown";
|
|
368
372
|
if (!at)
|
|
369
373
|
return "none";
|
|
370
374
|
const app = macApp(env);
|
|
@@ -379,12 +383,17 @@ export function linkHandlerState(env, platform = process.platform, run = sys) {
|
|
|
379
383
|
}
|
|
380
384
|
if (platform === "win32") {
|
|
381
385
|
const r = run("reg.exe", ["query", `${winKey(env)}\\shell\\open\\command`, "/ve"]);
|
|
386
|
+
// reg.exe that answered: no such key (1); one that did not run or answer: nothing known
|
|
387
|
+
if (r.status === null)
|
|
388
|
+
return "unknown";
|
|
382
389
|
if (r.status !== 0)
|
|
383
390
|
return "none";
|
|
384
391
|
const value = /REG_(?:EXPAND_)?SZ\s+(.*)$/m.exec(r.stdout)?.[1]?.trim();
|
|
385
392
|
return value === windowsCommand(argv) ? "ours" : value ? "other" : "none";
|
|
386
393
|
}
|
|
387
394
|
const r = run("xdg-mime", ["query", "default", `x-scheme-handler/${linkScheme(env)}`]);
|
|
395
|
+
if (r.status === null)
|
|
396
|
+
return "unknown";
|
|
388
397
|
const entry = r.status === 0 ? r.stdout.trim() : "";
|
|
389
398
|
if (!entry)
|
|
390
399
|
return "none";
|
|
@@ -415,28 +424,42 @@ export function registerLinks(env, platform = process.platform, run = sys) {
|
|
|
415
424
|
if (platform === "darwin") {
|
|
416
425
|
const app = macApp(env);
|
|
417
426
|
fs.mkdirSync(path.dirname(app), { recursive: true });
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
const
|
|
422
|
-
fs.rmSync(
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
run("
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
427
|
+
// The new applet is made beside, whole (its mark, its seal), and only then put in the old one's place: the links
|
|
428
|
+
// lead here at every moment (found on Mac: made again in place, a bridge said no links meanwhile and the Strom app
|
|
429
|
+
// forgot them). An applet that cannot be made leaves the one there as it was.
|
|
430
|
+
const work = path.join(path.dirname(app), `.strom-link-${process.pid}`);
|
|
431
|
+
fs.rmSync(work, { recursive: true, force: true });
|
|
432
|
+
fs.mkdirSync(work);
|
|
433
|
+
try {
|
|
434
|
+
const fresh = path.join(work, path.basename(app));
|
|
435
|
+
const script = path.join(work, "strom-link.applescript");
|
|
436
|
+
fs.writeFileSync(script, macScript(argv));
|
|
437
|
+
if (run("osacompile", ["-o", fresh, script]).status !== 0 || !fs.existsSync(fresh))
|
|
438
|
+
return false;
|
|
439
|
+
const plist = path.join(fresh, "Contents", "Info.plist");
|
|
440
|
+
run("plutil", ["-replace", "CFBundleIdentifier", "-string", `info.stromapp.research.link${suffix ? `.${suffix}` : ""}`, plist]);
|
|
441
|
+
run("plutil", ["-replace", "CFBundleName", "-string", name, plist]);
|
|
442
|
+
run("plutil", ["-replace", "LSUIElement", "-bool", "true", plist]);
|
|
443
|
+
run("plutil", ["-replace", "CFBundleURLTypes", "-json", JSON.stringify([{ CFBundleURLName: name, CFBundleURLSchemes: [scheme] }]), plist]);
|
|
444
|
+
// its icon: Strom Research's own
|
|
445
|
+
if (fs.existsSync(ICON.png))
|
|
446
|
+
run("sips", ["-s", "format", "icns", ICON.png, "--out", path.join(fresh, "Contents", "Resources", "applet.icns")]);
|
|
447
|
+
fs.writeFileSync(macMark(fresh), JSON.stringify({ argv }, null, 2));
|
|
448
|
+
// the applet's seal covers its Info.plist: sealed again, by itself (no one's certificate)
|
|
449
|
+
run("codesign", ["--force", "--sign", "-", fresh]);
|
|
450
|
+
// into its place: a folder is not renamed over another, so the old one aside first — a moment of two renames
|
|
451
|
+
if (fs.existsSync(app))
|
|
452
|
+
fs.renameSync(app, path.join(work, "old.app"));
|
|
453
|
+
fs.renameSync(fresh, app);
|
|
454
|
+
}
|
|
455
|
+
finally {
|
|
456
|
+
fs.rmSync(work, { recursive: true, force: true });
|
|
457
|
+
}
|
|
436
458
|
run(LSREGISTER, ["-f", app]);
|
|
437
459
|
return linkHandlerState(env, platform, run) === "ours";
|
|
438
460
|
}
|
|
439
461
|
if (platform === "win32") {
|
|
462
|
+
// each value written over the one there (reg add /f): the command is never missing meanwhile
|
|
440
463
|
const add = (key, args) => run("reg.exe", ["add", key, ...args, "/f"]).status === 0;
|
|
441
464
|
const key = winKey(env);
|
|
442
465
|
const ok = add(key, ["/ve", "/d", `URL:${name}`]) &&
|
|
@@ -447,7 +470,9 @@ export function registerLinks(env, platform = process.platform, run = sys) {
|
|
|
447
470
|
}
|
|
448
471
|
const file = linuxEntry(env);
|
|
449
472
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
450
|
-
|
|
473
|
+
// written beside, then put in its place: never read half written
|
|
474
|
+
fs.writeFileSync(`${file}.${process.pid}`, linuxDesktopEntry(argv, scheme, name));
|
|
475
|
+
fs.renameSync(`${file}.${process.pid}`, file);
|
|
451
476
|
run("xdg-mime", ["default", linuxEntryName(env), `x-scheme-handler/${scheme}`]);
|
|
452
477
|
run("update-desktop-database", [path.dirname(file)]);
|
|
453
478
|
return linkHandlerState(env, platform, run) === "ours";
|
package/dist/core/live.js
CHANGED
|
@@ -172,22 +172,91 @@ export function liveLogFile(root) {
|
|
|
172
172
|
return path.join(root, ".strom", "live.log");
|
|
173
173
|
}
|
|
174
174
|
const LIVE_LOG_BYTES = 200 * 1024;
|
|
175
|
+
/** The tree's folder as the system names it (its links followed: /tmp is /private/tmp on a Mac) — never normalized. */
|
|
176
|
+
function realRoot(root) {
|
|
177
|
+
try {
|
|
178
|
+
return fs.realpathSync(root);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return path.resolve(root);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/** Whether two paths are one folder: the same name, else the same folder of the same disk; undefined when one is not there. */
|
|
185
|
+
function sameFolder(a, b) {
|
|
186
|
+
if (a === b)
|
|
187
|
+
return true;
|
|
188
|
+
try {
|
|
189
|
+
const x = fs.statSync(a, { bigint: true });
|
|
190
|
+
const y = fs.statSync(b, { bigint: true });
|
|
191
|
+
return x.dev === y.dev && x.ino === y.ino;
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Where a process runs (its working folder): a bridge runs in its tree's folder. Undefined where it cannot be asked
|
|
199
|
+
* (Windows; lsof missing) — then a process is taken for what its note says, as before.
|
|
200
|
+
*/
|
|
201
|
+
function processCwd(pid) {
|
|
202
|
+
if (process.platform === "linux") {
|
|
203
|
+
try {
|
|
204
|
+
return fs.readlinkSync(`/proc/${pid}/cwd`);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (process.platform !== "darwin")
|
|
211
|
+
return undefined;
|
|
212
|
+
const r = spawnSync("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], { encoding: "utf8", timeout: 10_000 });
|
|
213
|
+
if (r.error || r.status !== 0)
|
|
214
|
+
return undefined;
|
|
215
|
+
const line = (r.stdout ?? "").split("\n").find((l) => l.startsWith("n"));
|
|
216
|
+
return line ? line.slice(1) : undefined;
|
|
217
|
+
}
|
|
218
|
+
/** A bridge running in another folder than this tree's — never ended for this tree. Its folder, else undefined. */
|
|
219
|
+
function bridgeElsewhere(pid, root) {
|
|
220
|
+
const cwd = processCwd(pid);
|
|
221
|
+
return cwd && sameFolder(cwd, realRoot(root)) === false ? cwd : undefined;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* The folder a bridge's note (live.json, live-last.json) belongs to when it is not this tree's: a research copied with
|
|
225
|
+
* its .strom (by hand, Finder) carries the original's notes — its bridge never taken for the copy's own, never ended by
|
|
226
|
+
* it, its address never taken over (found on Mac: the copy's strom ended the original's bridge and served the Strom app
|
|
227
|
+
* following the original at its address). A note of an older strom names no folder: its process, while it is a bridge,
|
|
228
|
+
* is asked where it runs. A folder named that is no longer there is not another one: the research was moved.
|
|
229
|
+
*/
|
|
230
|
+
function foreignNote(root, note) {
|
|
231
|
+
if (typeof note.root === "string")
|
|
232
|
+
return sameFolder(note.root, realRoot(root)) === false ? note.root : undefined;
|
|
233
|
+
if (typeof note.pid === "number" && note.pid !== process.pid && isBridge(note.pid))
|
|
234
|
+
return bridgeElsewhere(note.pid, root);
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
175
237
|
function lastFile(root) {
|
|
176
238
|
return path.join(root, ".strom", "live-last.json");
|
|
177
239
|
}
|
|
178
|
-
|
|
240
|
+
/** The last bridge of this tree — and, when the note there is another folder's (a research copied with its .strom), that folder. */
|
|
241
|
+
function readLastOf(root) {
|
|
179
242
|
try {
|
|
180
243
|
const last = JSON.parse(fs.readFileSync(lastFile(root), "utf8"));
|
|
181
|
-
|
|
244
|
+
if (!Number.isInteger(last.port) || !(last.token === undefined || /^[0-9a-f]{32}$/.test(last.token)))
|
|
245
|
+
return {};
|
|
246
|
+
const foreign = foreignNote(root, last);
|
|
247
|
+
return foreign ? { foreign } : { last };
|
|
182
248
|
}
|
|
183
249
|
catch {
|
|
184
|
-
return
|
|
250
|
+
return {};
|
|
185
251
|
}
|
|
186
252
|
}
|
|
253
|
+
function readLast(root) {
|
|
254
|
+
return readLastOf(root).last;
|
|
255
|
+
}
|
|
187
256
|
function writeLast(root, last) {
|
|
188
257
|
try {
|
|
189
258
|
const file = lastFile(root);
|
|
190
|
-
fs.writeFileSync(`${file}.${process.pid}`, JSON.stringify(last, null, 2));
|
|
259
|
+
fs.writeFileSync(`${file}.${process.pid}`, JSON.stringify({ ...last, root: realRoot(root) }, null, 2));
|
|
191
260
|
fs.renameSync(`${file}.${process.pid}`, file);
|
|
192
261
|
}
|
|
193
262
|
catch {
|
|
@@ -227,11 +296,11 @@ function alive(pid) {
|
|
|
227
296
|
return e.code === "EPERM";
|
|
228
297
|
}
|
|
229
298
|
}
|
|
230
|
-
/** The bridge of this tree when it runs. */
|
|
299
|
+
/** The bridge of this tree when it runs (never another folder's whose note came with a copy of the research). */
|
|
231
300
|
export function liveRunning(root) {
|
|
232
301
|
try {
|
|
233
|
-
const info = JSON.parse(fs.readFileSync(liveFile(root), "utf8"));
|
|
234
|
-
if (info.pid && alive(info.pid))
|
|
302
|
+
const { root: of, ...info } = JSON.parse(fs.readFileSync(liveFile(root), "utf8"));
|
|
303
|
+
if (info.pid && alive(info.pid) && !foreignNote(root, { root: of, pid: info.pid }))
|
|
235
304
|
return info;
|
|
236
305
|
}
|
|
237
306
|
catch {
|
|
@@ -351,9 +420,12 @@ function isBridge(pid) {
|
|
|
351
420
|
* End a bridge's process: asked to (SIGTERM — it closes as it should), and when it does not end within a while
|
|
352
421
|
* (a bridge stuck on something) ended for good (SIGKILL). How it ended; "alive" when even that did not end it.
|
|
353
422
|
*/
|
|
354
|
-
function endBridge(pid, waitMs = 3000) {
|
|
423
|
+
function endBridge(pid, root, waitMs = 3000) {
|
|
355
424
|
if (!isBridge(pid))
|
|
356
425
|
return "gone";
|
|
426
|
+
// a bridge of another folder (a note copied with the research) is never ended for this tree
|
|
427
|
+
if (bridgeElsewhere(pid, root))
|
|
428
|
+
return "elsewhere";
|
|
357
429
|
// ended: no such process, or one that only waits for its parent to hear it ended (a zombie)
|
|
358
430
|
const ended = () => {
|
|
359
431
|
if (!alive(pid))
|
|
@@ -396,7 +468,12 @@ export function stopLive(root, why = "strom live stop") {
|
|
|
396
468
|
const last = readLast(root);
|
|
397
469
|
if (last?.pid === info.pid)
|
|
398
470
|
writeLast(root, { ...last, ended: { at: new Date().toISOString(), reason: why } });
|
|
399
|
-
const how = endBridge(info.pid);
|
|
471
|
+
const how = endBridge(info.pid, root);
|
|
472
|
+
if (how === "elsewhere") {
|
|
473
|
+
noteLive(root, `a bridge of another folder (${info.pid}) is named in live.json: not ended, not this tree's`);
|
|
474
|
+
fs.rmSync(liveFile(root), { force: true });
|
|
475
|
+
return "none";
|
|
476
|
+
}
|
|
400
477
|
if (how === "killed")
|
|
401
478
|
noteLive(root, `the bridge ${info.pid} did not end when asked: ended for good (SIGKILL)`);
|
|
402
479
|
if (how === "alive") {
|
|
@@ -450,7 +527,7 @@ function masked(url) {
|
|
|
450
527
|
function writeLive(root, info) {
|
|
451
528
|
const file = liveFile(root);
|
|
452
529
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
453
|
-
fs.writeFileSync(`${file}.${process.pid}`, JSON.stringify(info, null, 2));
|
|
530
|
+
fs.writeFileSync(`${file}.${process.pid}`, JSON.stringify({ ...info, root: realRoot(root) }, null, 2));
|
|
454
531
|
fs.renameSync(`${file}.${process.pid}`, file);
|
|
455
532
|
}
|
|
456
533
|
function head(root) {
|
|
@@ -693,27 +770,59 @@ function directions(tree, all, queued, workingOn) {
|
|
|
693
770
|
}
|
|
694
771
|
/** How long what the system says of the links is taken as true (asking it costs a program started). */
|
|
695
772
|
const LINKS_FRESH_MS = 60_000;
|
|
696
|
-
|
|
697
|
-
|
|
773
|
+
/** No links after links that worked: asked again so soon, and taken only when said again. */
|
|
774
|
+
const LINKS_AGAIN_MS = 5_000;
|
|
698
775
|
/**
|
|
699
|
-
*
|
|
700
|
-
*
|
|
701
|
-
*
|
|
776
|
+
* What the bridge says of the links, asked of the system now and then: the first time at once, then in the background
|
|
777
|
+
* (the bridge never waits for it) — until it answers, what it said last. The system that does not answer (a failed or
|
|
778
|
+
* slow query: unknown) changes nothing. No links after links that worked are taken only when the system says so twice
|
|
779
|
+
* in a row: an applet being made again, a moment of LaunchServices — and the Strom app forgot the links (found on Mac).
|
|
702
780
|
*/
|
|
781
|
+
export function linksWatch(read, opts = {}) {
|
|
782
|
+
const freshMs = opts.freshMs ?? LINKS_FRESH_MS;
|
|
783
|
+
const againMs = opts.againMs ?? LINKS_AGAIN_MS;
|
|
784
|
+
const clock = opts.clock ?? Date.now;
|
|
785
|
+
let seen;
|
|
786
|
+
let asking = false;
|
|
787
|
+
// none said once after links that worked: not taken yet
|
|
788
|
+
let doubt = false;
|
|
789
|
+
// asked again in a few seconds rather than in a minute
|
|
790
|
+
const soon = () => clock() - freshMs + againMs;
|
|
791
|
+
return () => {
|
|
792
|
+
if (!seen) {
|
|
793
|
+
const state = read.now();
|
|
794
|
+
seen = { at: state === "unknown" ? soon() : clock(), actions: linkActions(state) };
|
|
795
|
+
}
|
|
796
|
+
else if (clock() - seen.at > freshMs && !asking) {
|
|
797
|
+
asking = true;
|
|
798
|
+
read.later().then((state) => {
|
|
799
|
+
asking = false;
|
|
800
|
+
const was = seen?.actions ?? [];
|
|
801
|
+
if (state === "unknown") {
|
|
802
|
+
seen = { at: was.length ? clock() : soon(), actions: was };
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
const actions = linkActions(state);
|
|
806
|
+
if (!actions.length && was.length && !doubt) {
|
|
807
|
+
doubt = true;
|
|
808
|
+
seen = { at: soon(), actions: was };
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
doubt = false;
|
|
812
|
+
seen = { at: clock(), actions };
|
|
813
|
+
}, () => {
|
|
814
|
+
asking = false;
|
|
815
|
+
seen = { at: clock(), actions: seen?.actions ?? [] };
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
return seen.actions;
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
let linksOf;
|
|
822
|
+
/** The strom-research:// links this computer takes: said to the app only while the scheme leads to this strom. */
|
|
703
823
|
function links(env) {
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
else if (Date.now() - linksSeen.at > LINKS_FRESH_MS && !linksAsking) {
|
|
707
|
-
linksAsking = true;
|
|
708
|
-
linkHandlerStateLater(env).then((state) => {
|
|
709
|
-
linksSeen = { at: Date.now(), actions: linkActions(state) };
|
|
710
|
-
linksAsking = false;
|
|
711
|
-
}, () => {
|
|
712
|
-
linksSeen = { at: Date.now(), actions: linksSeen?.actions ?? [] };
|
|
713
|
-
linksAsking = false;
|
|
714
|
-
});
|
|
715
|
-
}
|
|
716
|
-
return linksSeen.actions;
|
|
824
|
+
linksOf ??= linksWatch({ now: () => linkHandlerState(env), later: () => linkHandlerStateLater(env) });
|
|
825
|
+
return linksOf();
|
|
717
826
|
}
|
|
718
827
|
/**
|
|
719
828
|
* A second installation's own scheme (strom-research-beta), for the app to build its links with — said whenever it is
|
|
@@ -883,7 +992,11 @@ export function serveLive(root, env) {
|
|
|
883
992
|
}
|
|
884
993
|
// the address of the last bridge, so the app that followed it finds this one (its secret: none once it was ended for
|
|
885
994
|
// good — a new one); replaced while it runs when a page that is no Strom app comes with it (leaked)
|
|
886
|
-
const last =
|
|
995
|
+
const { last, foreign } = readLastOf(root);
|
|
996
|
+
// the notes of another folder's bridge (the research copied with its .strom): a new address of its own — a port the
|
|
997
|
+
// system picks and a new secret, so the app following the original never comes here
|
|
998
|
+
if (foreign)
|
|
999
|
+
noteLive(root, `the bridge's notes came from another folder (${foreign}): a new address`);
|
|
887
1000
|
let token = last?.token ?? crypto.randomBytes(16).toString("hex");
|
|
888
1001
|
// what this bridge says of itself (live.json), once it listens
|
|
889
1002
|
let live;
|
|
@@ -1847,7 +1960,14 @@ export function serveLive(root, env) {
|
|
|
1847
1960
|
// the bridge before this one still holds it (stuck, or ending just now): ended, its port taken again — the
|
|
1848
1961
|
// app following the address goes on; a port another program holds: another one (the app needs ?live= again)
|
|
1849
1962
|
const before = last && last.pid !== process.pid ? last.pid : undefined;
|
|
1850
|
-
|
|
1963
|
+
const ended = before && tries === 0 ? endBridge(before, root) : "gone";
|
|
1964
|
+
if (ended === "elsewhere") {
|
|
1965
|
+
// a bridge of another folder (its note copied with the research): never ended, never its address — a new one
|
|
1966
|
+
noteLive(root, `a bridge of another folder (${before}) holds port ${wanted}: not ended`);
|
|
1967
|
+
token = crypto.randomBytes(16).toString("hex");
|
|
1968
|
+
tries = 20;
|
|
1969
|
+
}
|
|
1970
|
+
else if (ended !== "gone") {
|
|
1851
1971
|
noteLive(root, `the bridge before (${before}) still held port ${wanted}: ended`);
|
|
1852
1972
|
tries = 1;
|
|
1853
1973
|
}
|
package/dist/core/review.js
CHANGED
|
@@ -7,6 +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 { hypothesisPeople } from "./directions.js";
|
|
10
11
|
import { birthEstimate, RECORD_KINDS, recordsetsCovering, researchPeople } from "./frontier.js";
|
|
11
12
|
import { birthEvent, displayName, familiesAsPartner, lifespan } from "./people.js";
|
|
12
13
|
import { phrase } from "./phrases.js";
|
|
@@ -209,7 +210,7 @@ function openConflicts(tree, p) {
|
|
|
209
210
|
const about = (subject) => subject.includes(p.id) || subject.some((s) => families.has(s));
|
|
210
211
|
return [
|
|
211
212
|
...tree.list("conflict").filter((c) => c.state === "open" && about(c.subject)),
|
|
212
|
-
...tree.list("hypothesis").filter((h) => h.state === "open" &&
|
|
213
|
+
...tree.list("hypothesis").filter((h) => h.state === "open" && hypothesisPeople(tree, h).includes(p.id)),
|
|
213
214
|
].map((x) => x.id);
|
|
214
215
|
}
|
|
215
216
|
/** The tasks strom proposes for the people of a person research (strom review). */
|
package/dist/core/tree.js
CHANGED
|
@@ -27,7 +27,7 @@ import { lastBackup } from "./backup.js";
|
|
|
27
27
|
export const TREE_FILE = "strom.json";
|
|
28
28
|
// between releases the candidate being made (a bridge run from the sources says what it is: the Strom app goes by it);
|
|
29
29
|
// at a release the release's own (package.json)
|
|
30
|
-
export const VERSION = "1.13.
|
|
30
|
+
export const VERSION = "1.13.1-beta.2";
|
|
31
31
|
const GITIGNORE = `# Strom Research — local state and large files are not versioned
|
|
32
32
|
.strom/
|
|
33
33
|
/media/
|
package/dist/gedcom/export.js
CHANGED
|
@@ -34,6 +34,7 @@ import { quay } from "../core/evidence.js";
|
|
|
34
34
|
import { VERSION } from "../core/tree.js";
|
|
35
35
|
import { dataUrl, turned } from "../core/excerpt.js";
|
|
36
36
|
import { mainPerson } from "../core/kin.js";
|
|
37
|
+
import { hypothesisPeople } from "../core/directions.js";
|
|
37
38
|
import * as git from "../core/git.js";
|
|
38
39
|
import { treeEdges } from "../core/edge.js";
|
|
39
40
|
import { readersOf } from "../core/review.js";
|
|
@@ -141,7 +142,13 @@ export function exportGedcom(tree, opts = {}) {
|
|
|
141
142
|
// What the research knows beyond the facts (the Strom profile, opts.research): read once.
|
|
142
143
|
const research = {
|
|
143
144
|
conflicts: opts.research ? tree.list("conflict") : [],
|
|
144
|
-
|
|
145
|
+
// with the people each is about: its subject's, and those its variants name (core/directions.ts hypothesisPeople)
|
|
146
|
+
hypotheses: opts.research
|
|
147
|
+
? tree
|
|
148
|
+
.list("hypothesis")
|
|
149
|
+
.filter((h) => h.state === "open")
|
|
150
|
+
.map((h) => ({ h, people: hypothesisPeople(tree, h) }))
|
|
151
|
+
: [],
|
|
145
152
|
// a search is of the people of the task it served
|
|
146
153
|
searched: opts.research
|
|
147
154
|
? tree
|
|
@@ -559,7 +566,7 @@ export function exportGedcom(tree, opts = {}) {
|
|
|
559
566
|
if (c.state === "resolved" && c.resolution)
|
|
560
567
|
w.text(2, "DECI", c.resolution);
|
|
561
568
|
}
|
|
562
|
-
for (const h of research.hypotheses.filter((x) =>
|
|
569
|
+
for (const { h } of research.hypotheses.filter((x) => x.people.includes(p.id))) {
|
|
563
570
|
w.line(1, "_STROM_HYPO", h.id);
|
|
564
571
|
w.text(2, "TITL", h.question);
|
|
565
572
|
w.text(2, "NOTE", h.variants.map((v) => `${v.label}: ${v.claim}`).join("\n"));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "strom-research",
|
|
3
|
-
"version": "1.13.
|
|
3
|
+
"version": "1.13.1-beta.2",
|
|
4
4
|
"description": "Genealogical research toolkit for AI coding agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -45,5 +45,8 @@
|
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@types/node": "^22.20.4",
|
|
47
47
|
"typescript": "^7.0.2"
|
|
48
|
+
},
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"tag": "beta"
|
|
48
51
|
}
|
|
49
52
|
}
|