strom-research 1.0.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/LICENSE +373 -0
- package/README.md +142 -0
- package/assets/lang/cs.json +302 -0
- package/assets/lang/de.json +302 -0
- package/assets/method/core.md +43 -0
- package/assets/method/enrich.md +11 -0
- package/assets/method/intake.md +30 -0
- package/assets/method/link.md +28 -0
- package/assets/method/locate.md +28 -0
- package/assets/method/narrate.md +13 -0
- package/assets/method/reading.md +62 -0
- package/assets/method/recording.md +59 -0
- package/assets/method/request.md +10 -0
- package/assets/method/verify.md +17 -0
- package/assets/plugins/README.md +23 -0
- package/assets/plugins/connectors/DISCOVERY.md +159 -0
- package/assets/plugins/connectors/README.md +376 -0
- package/assets/plugins/connectors/sdk.ts +168 -0
- package/assets/plugins/connectors/template.ts +38 -0
- package/assets/plugins/gitignore +4 -0
- package/dist/agents/files.js +313 -0
- package/dist/agents/global.js +257 -0
- package/dist/agents/launch.js +36 -0
- package/dist/agents/profiles.js +95 -0
- package/dist/brief/brief.js +345 -0
- package/dist/cli/commit.js +44 -0
- package/dist/cli/context.js +311 -0
- package/dist/cli/execute.js +154 -0
- package/dist/cli/fixes.js +78 -0
- package/dist/cli/format.js +53 -0
- package/dist/cli/help.js +59 -0
- package/dist/cli/main.js +152 -0
- package/dist/cli/menu.js +212 -0
- package/dist/cli/registry.js +96 -0
- package/dist/cli/ui.js +266 -0
- package/dist/cli/wizard.js +142 -0
- package/dist/cli.js +14 -0
- package/dist/commands/analysis.js +622 -0
- package/dist/commands/batch.js +181 -0
- package/dist/commands/checks.js +153 -0
- package/dist/commands/connectors.js +1377 -0
- package/dist/commands/guide.js +160 -0
- package/dist/commands/index.js +19 -0
- package/dist/commands/intake.js +234 -0
- package/dist/commands/media.js +406 -0
- package/dist/commands/meta.js +195 -0
- package/dist/commands/output.js +117 -0
- package/dist/commands/people.js +664 -0
- package/dist/commands/read.js +199 -0
- package/dist/commands/research.js +139 -0
- package/dist/commands/session.js +605 -0
- package/dist/commands/setup.js +465 -0
- package/dist/commands/sources.js +634 -0
- package/dist/commands/start.js +383 -0
- package/dist/commands/story.js +75 -0
- package/dist/commands/tasks.js +436 -0
- package/dist/commands/trees.js +128 -0
- package/dist/core/actions.js +852 -0
- package/dist/core/age.js +95 -0
- package/dist/core/apps.js +74 -0
- package/dist/core/assets.js +34 -0
- package/dist/core/awake.js +33 -0
- package/dist/core/browser.js +281 -0
- package/dist/core/calibration.js +48 -0
- package/dist/core/check.js +112 -0
- package/dist/core/chromium.js +88 -0
- package/dist/core/config.js +348 -0
- package/dist/core/connector.js +811 -0
- package/dist/core/deps.js +73 -0
- package/dist/core/dialog.js +61 -0
- package/dist/core/errors.js +89 -0
- package/dist/core/evidence.js +58 -0
- package/dist/core/frontier.js +219 -0
- package/dist/core/gdate.js +77 -0
- package/dist/core/git.js +300 -0
- package/dist/core/guard.js +124 -0
- package/dist/core/http2.js +76 -0
- package/dist/core/import.js +541 -0
- package/dist/core/install.js +28 -0
- package/dist/core/integrity.js +219 -0
- package/dist/core/json.js +87 -0
- package/dist/core/lang.js +70 -0
- package/dist/core/live.js +244 -0
- package/dist/core/lock.js +112 -0
- package/dist/core/logins.js +67 -0
- package/dist/core/media.js +223 -0
- package/dist/core/model.js +101 -0
- package/dist/core/net.js +366 -0
- package/dist/core/open.js +29 -0
- package/dist/core/paths.js +84 -0
- package/dist/core/people.js +283 -0
- package/dist/core/phrases.js +85 -0
- package/dist/core/queue.js +113 -0
- package/dist/core/reader.js +76 -0
- package/dist/core/records.js +105 -0
- package/dist/core/roles.js +30 -0
- package/dist/core/schema.js +261 -0
- package/dist/core/seal.js +77 -0
- package/dist/core/self.js +40 -0
- package/dist/core/session.js +155 -0
- package/dist/core/shortcut.js +90 -0
- package/dist/core/stories.js +61 -0
- package/dist/core/stromapp.js +138 -0
- package/dist/core/text.js +104 -0
- package/dist/core/tree.js +507 -0
- package/dist/core/uninstall.js +128 -0
- package/dist/core/update.js +193 -0
- package/dist/core/validate.js +260 -0
- package/dist/core/views.js +164 -0
- package/dist/core/which.js +51 -0
- package/dist/core/workers.js +42 -0
- package/dist/gedcom/export.js +454 -0
- package/dist/gedcom/labels.js +103 -0
- package/dist/gedcom/lines.js +91 -0
- package/dist/gedcom/parse.js +53 -0
- package/dist/gedcom/validate.js +183 -0
- package/dist/image/image.js +223 -0
- package/dist/image/index.js +114 -0
- package/dist/image/jpeg-decode.js +552 -0
- package/dist/image/jpeg-encode.js +254 -0
- package/dist/image/png.js +241 -0
- package/dist/runners/antigravity.js +70 -0
- package/dist/runners/claude.js +179 -0
- package/dist/runners/codex.js +45 -0
- package/dist/runners/index.js +13 -0
- package/dist/runners/jsonl.js +86 -0
- package/dist/runners/opencode.js +50 -0
- package/dist/runners/runner.js +63 -0
- package/dist/runners/script.js +58 -0
- package/package.json +44 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// What strom needs on the computer besides itself, and how it gets it with the
|
|
2
|
+
// user's yes (strom doctor --fix, the setup wizard): git above all.
|
|
3
|
+
//
|
|
4
|
+
// Windows strom's own git — MinGit, the official portable Git for Windows,
|
|
5
|
+
// downloaded, checked against its SHA-256 and unpacked into strom's
|
|
6
|
+
// folder; for strom only: no admin, no installer, nothing on PATH
|
|
7
|
+
// macOS Apple's command line tools (git comes with them): a window of the
|
|
8
|
+
// system installs them
|
|
9
|
+
// Linux the package manager — sudo is the user's; strom says the command
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import crypto from "node:crypto";
|
|
13
|
+
import { spawnSync } from "node:child_process";
|
|
14
|
+
import { gitProgram, ownGitDir, resetCache } from "./git.js";
|
|
15
|
+
/** The MinGit strom installs (from git-for-windows' releases on GitHub). */
|
|
16
|
+
export const MINGIT = {
|
|
17
|
+
version: "2.55.0.5",
|
|
18
|
+
url: "https://github.com/git-for-windows/git/releases/download/v2.55.0.windows.5/MinGit-2.55.0.5-64-bit.zip",
|
|
19
|
+
sha256: "56d7b226b7693196cfc71fef26568f536c4a021ab6c37ff2db4287bed908e96e",
|
|
20
|
+
mb: 39,
|
|
21
|
+
};
|
|
22
|
+
export function gitWay(platform = process.platform) {
|
|
23
|
+
return platform === "win32" ? "download" : platform === "darwin" ? "window" : "command";
|
|
24
|
+
}
|
|
25
|
+
/** The command a Linux user runs (their package manager). */
|
|
26
|
+
export function linuxGitCommand() {
|
|
27
|
+
for (const [pm, cmd] of [
|
|
28
|
+
["apt-get", "sudo apt install git"],
|
|
29
|
+
["dnf", "sudo dnf install git"],
|
|
30
|
+
["pacman", "sudo pacman -S git"],
|
|
31
|
+
["zypper", "sudo zypper install git"],
|
|
32
|
+
])
|
|
33
|
+
if (spawnSync("sh", ["-c", `command -v ${pm}`], { stdio: "ignore" }).status === 0)
|
|
34
|
+
return cmd;
|
|
35
|
+
return "sudo apt install git";
|
|
36
|
+
}
|
|
37
|
+
/** Windows: download MinGit into strom's folder. True when git works after it. */
|
|
38
|
+
export async function installOwnGit(env, say) {
|
|
39
|
+
if (env.STROM_NO_INSTALL === "1")
|
|
40
|
+
return false;
|
|
41
|
+
const dir = ownGitDir(env);
|
|
42
|
+
const part = `${dir}.part`;
|
|
43
|
+
const zip = `${dir}.zip`;
|
|
44
|
+
try {
|
|
45
|
+
const res = await fetch(MINGIT.url);
|
|
46
|
+
if (!res.ok)
|
|
47
|
+
throw new Error(`HTTP ${res.status}`);
|
|
48
|
+
const data = Buffer.from(await res.arrayBuffer());
|
|
49
|
+
const got = crypto.createHash("sha256").update(data).digest("hex");
|
|
50
|
+
if (got !== MINGIT.sha256)
|
|
51
|
+
throw new Error("the download is damaged (checksum mismatch)");
|
|
52
|
+
fs.mkdirSync(path.dirname(dir), { recursive: true });
|
|
53
|
+
fs.writeFileSync(zip, data);
|
|
54
|
+
fs.rmSync(part, { recursive: true, force: true });
|
|
55
|
+
fs.mkdirSync(part, { recursive: true });
|
|
56
|
+
// Windows 10 and later have tar, which unpacks a zip.
|
|
57
|
+
const r = spawnSync("tar", ["-xf", zip, "-C", part], { stdio: "ignore", windowsHide: true });
|
|
58
|
+
if (r.status !== 0)
|
|
59
|
+
throw new Error("could not unpack it");
|
|
60
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
61
|
+
fs.renameSync(part, dir);
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
say(e.message);
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
fs.rmSync(zip, { force: true });
|
|
69
|
+
fs.rmSync(part, { recursive: true, force: true });
|
|
70
|
+
}
|
|
71
|
+
resetCache();
|
|
72
|
+
return Boolean(gitProgram(env));
|
|
73
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// A yes/no question in a window of the operating system. Consents are the
|
|
2
|
+
// user's: from inside an agent's session strom cannot tell whether the user or
|
|
3
|
+
// the agent typed a command, but a window on the screen is answered by the
|
|
4
|
+
// person in front of it — the agent neither sees it nor clicks it. So the user
|
|
5
|
+
// never has to leave the conversation for a terminal of their own.
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { which } from "./which.js";
|
|
8
|
+
/** How long the window waits for an answer before it counts as a no. */
|
|
9
|
+
const WAIT_SECONDS = 300;
|
|
10
|
+
/** AppleScript string literal. */
|
|
11
|
+
function appleString(s) {
|
|
12
|
+
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
13
|
+
}
|
|
14
|
+
/** PowerShell single-quoted string literal. */
|
|
15
|
+
function psString(s) {
|
|
16
|
+
return `'${s.replace(/'/g, "''")}'`;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Ask in a window: true (yes), false (no, closed or no answer in time), or
|
|
20
|
+
* undefined when this computer cannot show one (no desktop, no dialog tool).
|
|
21
|
+
*/
|
|
22
|
+
export function systemDialog(text, env, platform = process.platform) {
|
|
23
|
+
// A test run, or a user who wants their consents in a terminal only.
|
|
24
|
+
if (env.STROM_NO_DIALOG === "1")
|
|
25
|
+
return undefined;
|
|
26
|
+
const run = (cmd, args) => spawnSync(cmd, args, { encoding: "utf8", timeout: (WAIT_SECONDS + 10) * 1000, windowsHide: false, env: env });
|
|
27
|
+
if (platform === "darwin") {
|
|
28
|
+
const script = `display dialog ${appleString(text.question)} with title ${appleString(text.title)} ` +
|
|
29
|
+
`buttons {${appleString(text.no)}, ${appleString(text.yes)}} default button ${appleString(text.no)} ` +
|
|
30
|
+
`cancel button ${appleString(text.no)} with icon caution giving up after ${WAIT_SECONDS}`;
|
|
31
|
+
const r = run("osascript", ["-e", script]);
|
|
32
|
+
if (r.error)
|
|
33
|
+
return undefined;
|
|
34
|
+
return r.status === 0 && r.stdout.includes(`button returned:${text.yes}`) && !r.stdout.includes("gave up:true");
|
|
35
|
+
}
|
|
36
|
+
if (platform === "win32") {
|
|
37
|
+
const ps = "Add-Type -AssemblyName PresentationFramework; " +
|
|
38
|
+
`$r = [System.Windows.MessageBox]::Show(${psString(text.question)}, ${psString(text.title)}, 'YesNo', 'Warning', 'No', 'DefaultDesktopOnly'); ` +
|
|
39
|
+
"Write-Output $r";
|
|
40
|
+
const r = run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", ps]);
|
|
41
|
+
if (r.error || r.status !== 0)
|
|
42
|
+
return undefined;
|
|
43
|
+
return r.stdout.trim() === "Yes";
|
|
44
|
+
}
|
|
45
|
+
// Linux and others: only with a desktop and a dialog tool.
|
|
46
|
+
if (!env.DISPLAY && !env.WAYLAND_DISPLAY)
|
|
47
|
+
return undefined;
|
|
48
|
+
if (which("zenity", env)) {
|
|
49
|
+
const r = run("zenity", ["--question", "--title", text.title, "--text", text.question, "--ok-label", text.yes, "--cancel-label", text.no, "--timeout", String(WAIT_SECONDS)]);
|
|
50
|
+
if (r.error)
|
|
51
|
+
return undefined;
|
|
52
|
+
return r.status === 0;
|
|
53
|
+
}
|
|
54
|
+
if (which("kdialog", env)) {
|
|
55
|
+
const r = run("kdialog", ["--title", text.title, "--yesno", text.question, "--yes-label", text.yes, "--no-label", text.no]);
|
|
56
|
+
if (r.error)
|
|
57
|
+
return undefined;
|
|
58
|
+
return r.status === 0;
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Error types with stable exit codes. Every error carries an optional hint:
|
|
2
|
+
// the exact command the caller (usually an agent) should run next.
|
|
3
|
+
export const EXIT = {
|
|
4
|
+
ok: 0,
|
|
5
|
+
error: 1,
|
|
6
|
+
usage: 2,
|
|
7
|
+
needsInput: 3,
|
|
8
|
+
needsConsent: 4,
|
|
9
|
+
locked: 5,
|
|
10
|
+
};
|
|
11
|
+
export class StromError extends Error {
|
|
12
|
+
exitCode;
|
|
13
|
+
hint;
|
|
14
|
+
details;
|
|
15
|
+
constructor(message, opts = {}) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "StromError";
|
|
18
|
+
this.exitCode = opts.exitCode ?? EXIT.error;
|
|
19
|
+
this.hint = opts.hint;
|
|
20
|
+
this.details = opts.details;
|
|
21
|
+
}
|
|
22
|
+
toJSON() {
|
|
23
|
+
const out = { status: "error", message: this.message };
|
|
24
|
+
if (this.hint)
|
|
25
|
+
out.hint = this.hint;
|
|
26
|
+
if (this.details !== undefined)
|
|
27
|
+
out.details = this.details;
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Invalid arguments or an ambiguous reference. */
|
|
32
|
+
export class UsageError extends StromError {
|
|
33
|
+
constructor(message, opts = {}) {
|
|
34
|
+
super(message, { ...opts, exitCode: EXIT.usage });
|
|
35
|
+
this.name = "UsageError";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** A name matched more than one record. Never pick one silently. */
|
|
39
|
+
export class AmbiguousError extends StromError {
|
|
40
|
+
candidates;
|
|
41
|
+
constructor(query, candidates) {
|
|
42
|
+
super(`"${query}" matches ${candidates.length} records`, {
|
|
43
|
+
exitCode: EXIT.usage,
|
|
44
|
+
hint: "use the ID instead of the name",
|
|
45
|
+
details: { candidates },
|
|
46
|
+
});
|
|
47
|
+
this.name = "AmbiguousError";
|
|
48
|
+
this.candidates = candidates;
|
|
49
|
+
}
|
|
50
|
+
toJSON() {
|
|
51
|
+
return { status: "ambiguous", message: this.message, hint: this.hint, candidates: this.candidates };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** A setting is missing and there is no TTY to ask. */
|
|
55
|
+
export class NeedsInputError extends StromError {
|
|
56
|
+
needs;
|
|
57
|
+
constructor(needs) {
|
|
58
|
+
super(`missing setting: ${needs.map((n) => n.key).join(", ")}`, {
|
|
59
|
+
exitCode: EXIT.needsInput,
|
|
60
|
+
hint: needs.map((n) => n.set).join("\n"),
|
|
61
|
+
});
|
|
62
|
+
this.name = "NeedsInputError";
|
|
63
|
+
this.needs = needs;
|
|
64
|
+
}
|
|
65
|
+
toJSON() {
|
|
66
|
+
return { status: "needs-input", needs: this.needs };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** A human must grant consent on a terminal. Agents must never grant it. */
|
|
70
|
+
export class NeedsConsentError extends StromError {
|
|
71
|
+
needs;
|
|
72
|
+
constructor(needs) {
|
|
73
|
+
super(`consent required — ${needs.map((n) => n.question).join(" · ")}`, {
|
|
74
|
+
exitCode: EXIT.needsConsent,
|
|
75
|
+
hint: `ask the user to run in their own terminal: ${needs.map((n) => n.set).join(" ; ")}`,
|
|
76
|
+
});
|
|
77
|
+
this.name = "NeedsConsentError";
|
|
78
|
+
this.needs = needs;
|
|
79
|
+
}
|
|
80
|
+
toJSON() {
|
|
81
|
+
return { status: "needs-consent", needs: this.needs, hint: this.hint };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export class LockedError extends StromError {
|
|
85
|
+
constructor(message, hint) {
|
|
86
|
+
super(message, { exitCode: EXIT.locked, ...(hint ? { hint } : {}) });
|
|
87
|
+
this.name = "LockedError";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Two different questions, kept apart:
|
|
2
|
+
//
|
|
3
|
+
// How good is this evidence? — a property of one citation: the source
|
|
4
|
+
// (original / derivative / authored) and its information for this fact
|
|
5
|
+
// (primary: written at the time by someone who knew; secondary; unknown).
|
|
6
|
+
// GEDCOM carries it as QUAY on the citation.
|
|
7
|
+
// How sure is the conclusion? — the status of the fact (proven … lead),
|
|
8
|
+
// drawn from all of its evidence.
|
|
9
|
+
//
|
|
10
|
+
// The status may not claim more than the evidence gives: a family tree or a
|
|
11
|
+
// memory never proves anything, and "proven" needs a record with primary
|
|
12
|
+
// information for the fact.
|
|
13
|
+
import { UsageError } from "./errors.js";
|
|
14
|
+
/** Sources that are leads by nature: compiled by someone, not a record of the event. */
|
|
15
|
+
export function isWeak(src) {
|
|
16
|
+
return !src || src.kind === "family-tree" || src.kind === "family-memory" || src.form === "authored";
|
|
17
|
+
}
|
|
18
|
+
/** The information of a source for one fact: the citation's own assessment wins. */
|
|
19
|
+
export function informationOf(src, c) {
|
|
20
|
+
return c.information ?? src?.information ?? "unknown";
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* GEDCOM 5.5.1 QUAY of one citation: 3 direct and primary, 2 secondary
|
|
24
|
+
* (recorded later), 1 questionable reliability, 0 unreliable or estimated.
|
|
25
|
+
*/
|
|
26
|
+
export function quay(src, c) {
|
|
27
|
+
if (!src || src.kind === "family-tree" || src.kind === "family-memory")
|
|
28
|
+
return 0;
|
|
29
|
+
if (src.form === "authored")
|
|
30
|
+
return 1;
|
|
31
|
+
const info = informationOf(src, c);
|
|
32
|
+
if (src.form === "original")
|
|
33
|
+
return info === "primary" ? 3 : 2;
|
|
34
|
+
return info === "primary" ? 2 : 1; // derivative: a copy, an index, a transcript
|
|
35
|
+
}
|
|
36
|
+
/** The status a fact gets when none is given. */
|
|
37
|
+
export function defaultStatus(tree, citations) {
|
|
38
|
+
if (citations.length === 0)
|
|
39
|
+
return "lead";
|
|
40
|
+
return citations.some((c) => !isWeak(tree.get(c.source))) ? "probable" : "lead";
|
|
41
|
+
}
|
|
42
|
+
/** Refuse a status the evidence cannot carry, with what to do instead. */
|
|
43
|
+
export function checkStatus(tree, status, citations) {
|
|
44
|
+
if (status !== "proven" && status !== "probable")
|
|
45
|
+
return;
|
|
46
|
+
if (citations.length === 0)
|
|
47
|
+
throw new UsageError(`status ${status} needs a citation`, { hint: 'cite the record: --cite S0001 --locator "fol. 12" (or keep it a lead: --status lead)' });
|
|
48
|
+
const strong = citations.filter((c) => !isWeak(tree.get(c.source)));
|
|
49
|
+
if (strong.length === 0)
|
|
50
|
+
throw new UsageError(`a family tree, a memory or a compiled work cannot make a fact ${status}`, {
|
|
51
|
+
hint: "keep it a lead (or --status possible) until a record says it: cite that record",
|
|
52
|
+
});
|
|
53
|
+
if (status === "proven" && !strong.some((c) => informationOf(tree.get(c.source), c) === "primary"))
|
|
54
|
+
throw new UsageError("proven needs a record written at the time by someone who knew (primary information)", {
|
|
55
|
+
hint: "if the record is like that: --information primary on the citation (or the source); otherwise --status probable",
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
export const INFORMATION = ["primary", "secondary", "unknown"];
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// The frontier of a research: the people in scope whose parents are not yet
|
|
2
|
+
// proven. For searching ancestors the next step is derivable, so strom
|
|
3
|
+
// proposes the tasks itself instead of hoping an agent remembers to.
|
|
4
|
+
import { dateYears } from "./gdate.js";
|
|
5
|
+
import { ancestorGenerations, birthEvent, displayName, familiesAsChild, familiesAsPartner, isBirthFamily, lifespan } from "./people.js";
|
|
6
|
+
import { foldText } from "./text.js";
|
|
7
|
+
import { phrase } from "./phrases.js";
|
|
8
|
+
const OPEN_STATES = new Set(["open", "doing", "parked", "waiting"]);
|
|
9
|
+
const FINISHED_STATES = new Set(["done", "dropped"]);
|
|
10
|
+
const FRONTIER_LEVELS = new Set(["locate", "link", "request"]);
|
|
11
|
+
function birthRecordProven(p) {
|
|
12
|
+
return p.events.some((e) => ["BIRT", "CHR", "BAPM"].includes(e.kind) && (e.status === "proven" || e.status === "probable") && !e.retracted);
|
|
13
|
+
}
|
|
14
|
+
/** The kinds of record set in which an event of this kind is written down. */
|
|
15
|
+
const RECORD_KINDS = {
|
|
16
|
+
BIRT: ["baptism", "birth"],
|
|
17
|
+
CHR: ["baptism", "birth"],
|
|
18
|
+
BAPM: ["baptism", "birth"],
|
|
19
|
+
MARR: ["marriage"],
|
|
20
|
+
MARB: ["marriage"],
|
|
21
|
+
ENGA: ["marriage"],
|
|
22
|
+
DEAT: ["burial", "death"],
|
|
23
|
+
BURI: ["burial", "death"],
|
|
24
|
+
};
|
|
25
|
+
const GENERAL_KINDS = ["church", "civil"];
|
|
26
|
+
/** Is `name` the place `place` names? "Vavřinec čp. 13" and "Vavřinec, Blansko" are in Vavřinec. */
|
|
27
|
+
function inPlace(place, name) {
|
|
28
|
+
const n = foldText(name);
|
|
29
|
+
return !!n && (place === n || place.startsWith(n + " ") || place.startsWith(n + ","));
|
|
30
|
+
}
|
|
31
|
+
/** Record sets that could hold an event of these kinds at `place` around `year`. */
|
|
32
|
+
function recordsetsCovering(tree, place, year, kinds) {
|
|
33
|
+
if (!place)
|
|
34
|
+
return [];
|
|
35
|
+
const key = foldText(place);
|
|
36
|
+
// Names of the place and of the parishes it belonged to.
|
|
37
|
+
const names = [key];
|
|
38
|
+
for (const pl of tree.list("place"))
|
|
39
|
+
if (pl.names.some((n) => inPlace(key, n.name)))
|
|
40
|
+
for (const j of pl.jurisdictions)
|
|
41
|
+
if (!year || ((!j.from || j.from <= year) && (!j.to || j.to >= year)))
|
|
42
|
+
names.push(foldText(j.name));
|
|
43
|
+
return tree.list("recordset").filter((b) => {
|
|
44
|
+
if (b.kinds.length && !b.kinds.some((k) => kinds.includes(k) || GENERAL_KINDS.includes(k)))
|
|
45
|
+
return false;
|
|
46
|
+
if (!b.places.some((pl) => names.some((n) => inPlace(n, pl))))
|
|
47
|
+
return false;
|
|
48
|
+
if (!year || !b.years)
|
|
49
|
+
return true;
|
|
50
|
+
const [from, to] = b.years.split("-").map(Number);
|
|
51
|
+
return year >= from - 2 && year <= (to ?? from) + 2;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/** Record sets that could hold the birth/baptism of someone born at `place` around `year`. */
|
|
55
|
+
function candidateRecordsets(tree, place, year) {
|
|
56
|
+
return recordsetsCovering(tree, place, year, RECORD_KINDS.BIRT);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The record sets a task works in: those in its `where`. A task written before
|
|
60
|
+
* the books were known names none; then the ones its text mentions (B…), else
|
|
61
|
+
* the ones covering an event of its people in a year the task mentions
|
|
62
|
+
* ("Baptism of František 18 Oct 1862" → the baptisms of his birthplace in 1862).
|
|
63
|
+
*/
|
|
64
|
+
export function taskRecordsets(tree, task) {
|
|
65
|
+
const get = (id) => {
|
|
66
|
+
const b = tree.get(id);
|
|
67
|
+
return b?.type === "recordset" ? b : undefined;
|
|
68
|
+
};
|
|
69
|
+
const named = task.where.map(get).filter((b) => !!b);
|
|
70
|
+
if (named.length)
|
|
71
|
+
return { sets: named, guessed: false };
|
|
72
|
+
const text = [task.what, ...task.where, task.why, task.doneWhen, ...task.notes.map((n) => n.text)].join(" ");
|
|
73
|
+
const mentioned = [...new Set(text.match(/\bB\d{4,}\b/g) ?? [])].map(get).filter((b) => !!b);
|
|
74
|
+
if (mentioned.length)
|
|
75
|
+
return { sets: mentioned, guessed: true };
|
|
76
|
+
const years = dateYears(text);
|
|
77
|
+
const found = new Map();
|
|
78
|
+
for (const id of task.subject) {
|
|
79
|
+
const p = tree.get(id);
|
|
80
|
+
if (p?.type !== "person")
|
|
81
|
+
continue;
|
|
82
|
+
for (const e of [...p.events, ...familiesAsPartner(tree, id).flatMap((f) => f.events)]) {
|
|
83
|
+
const kinds = RECORD_KINDS[e.kind];
|
|
84
|
+
if (!kinds || e.retracted || !e.date)
|
|
85
|
+
continue;
|
|
86
|
+
const span = dateYears(e.date);
|
|
87
|
+
if (!span.length)
|
|
88
|
+
continue;
|
|
89
|
+
const lo = Math.min(...span);
|
|
90
|
+
const hi = Math.max(...span);
|
|
91
|
+
const year = years.find((y) => y >= lo && y <= hi);
|
|
92
|
+
if (year === undefined)
|
|
93
|
+
continue;
|
|
94
|
+
for (const b of recordsetsCovering(tree, e.place, year, kinds))
|
|
95
|
+
found.set(b.id, b);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { sets: [...found.values()], guessed: true };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* When and where a person was born, if only roughly: their own birth, else about
|
|
102
|
+
* 25 years before their marriage, else about 28 before their eldest known child —
|
|
103
|
+
* where that happened. An estimate widens the years a search has to cover.
|
|
104
|
+
*/
|
|
105
|
+
function birthEstimate(tree, p, lang) {
|
|
106
|
+
const own = birthEvent(p);
|
|
107
|
+
const ownYear = own?.date ? dateYears(own.date)[0] : undefined;
|
|
108
|
+
if (ownYear && own?.place)
|
|
109
|
+
return { year: ownYear, place: own.place };
|
|
110
|
+
const fams = familiesAsPartner(tree, p.id);
|
|
111
|
+
const marr = fams.flatMap((f) => f.events).find((e) => e.kind === "MARR" && !e.retracted && e.date);
|
|
112
|
+
const marrYear = marr?.date ? dateYears(marr.date)[0] : undefined;
|
|
113
|
+
const kids = fams
|
|
114
|
+
.flatMap((f) => f.children.map((c) => tree.get(c.person)))
|
|
115
|
+
.map((k) => ({ k, b: k ? birthEvent(k) : undefined }))
|
|
116
|
+
.map(({ k, b }) => ({ k, b, year: b?.date ? dateYears(b.date)[0] : undefined }))
|
|
117
|
+
.filter((x) => x.year)
|
|
118
|
+
.sort((a, z) => a.year - z.year);
|
|
119
|
+
const eldest = kids[0];
|
|
120
|
+
const year = ownYear ?? (marrYear ? marrYear - 25 : eldest ? eldest.year - 28 : undefined);
|
|
121
|
+
const place = own?.place ?? marr?.place ?? eldest?.b?.place;
|
|
122
|
+
const from = ownYear ? undefined : marrYear ? phrase(lang, "estimate.marriage", { year: marrYear }) : eldest ? phrase(lang, "estimate.child", { year: eldest.year }) : undefined;
|
|
123
|
+
return { ...(year ? { year } : {}), ...(place ? { place } : {}), ...(from ? { from } : {}) };
|
|
124
|
+
}
|
|
125
|
+
export function frontier(tree, research) {
|
|
126
|
+
if (research.direction !== "ancestors")
|
|
127
|
+
return [];
|
|
128
|
+
const gens = ancestorGenerations(tree, research.focus, research.limits?.generations ?? 50);
|
|
129
|
+
const all = tree.list("task");
|
|
130
|
+
const tasks = all.filter((t) => FRONTIER_LEVELS.has(t.level));
|
|
131
|
+
const lang = tree.lang;
|
|
132
|
+
const out = [];
|
|
133
|
+
for (const [id, generation] of gens) {
|
|
134
|
+
const p = tree.get(id);
|
|
135
|
+
if (!p || p.retracted)
|
|
136
|
+
continue;
|
|
137
|
+
const est = birthEstimate(tree, p, lang);
|
|
138
|
+
const year = est.year;
|
|
139
|
+
if (research.limits?.generations && generation >= research.limits.generations)
|
|
140
|
+
continue;
|
|
141
|
+
if (research.limits?.before && year && year < research.limits.before)
|
|
142
|
+
continue;
|
|
143
|
+
const birthFamilies = familiesAsChild(tree, id).filter((f) => isBirthFamily(f, id));
|
|
144
|
+
const hasBirthFamily = birthFamilies.some((f) => f.partners.length === 2);
|
|
145
|
+
const proven = birthRecordProven(p);
|
|
146
|
+
if (hasBirthFamily && proven)
|
|
147
|
+
continue;
|
|
148
|
+
// The birth record is read and names one parent (a child born out of wedlock):
|
|
149
|
+
// another search for the same baptism will not name the other.
|
|
150
|
+
if (proven && birthFamilies.some((f) => f.partners.length === 1))
|
|
151
|
+
continue;
|
|
152
|
+
const mine = tasks.filter((t) => t.subject.includes(id));
|
|
153
|
+
// Covered also by other work in the books of the baptism: a task that records or checks it there.
|
|
154
|
+
const births = candidateRecordsets(tree, est.place, year).map((b) => b.id);
|
|
155
|
+
const covered = mine.find((t) => OPEN_STATES.has(t.state)) ??
|
|
156
|
+
all.find((t) => (t.level === "enrich" || t.level === "verify") && OPEN_STATES.has(t.state) && t.subject.includes(id) && t.where.some((w) => births.includes(w)));
|
|
157
|
+
const finished = mine.filter((t) => FINISHED_STATES.has(t.state));
|
|
158
|
+
const name = `${displayName(p)}${lifespan(p) ? ` (${lifespan(p)})` : ""}`;
|
|
159
|
+
const item = {
|
|
160
|
+
person: p,
|
|
161
|
+
generation,
|
|
162
|
+
reason: !hasBirthFamily ? "parents unknown" : "parents not proven by a record",
|
|
163
|
+
...(covered ? { coveredBy: covered.id } : {}),
|
|
164
|
+
};
|
|
165
|
+
if (!covered) {
|
|
166
|
+
// What was already tried is never proposed again: a finished link task
|
|
167
|
+
// used up its record sets, a finished locate task its search for them.
|
|
168
|
+
const tried = new Set(finished.filter((t) => t.level === "link").flatMap((t) => t.where));
|
|
169
|
+
const place = est.place;
|
|
170
|
+
// an estimate needs a wider search than a known year
|
|
171
|
+
const span = est.from ? 10 : 3;
|
|
172
|
+
const about = est.from && year ? phrase(lang, "born.about", { year, from: est.from }) : "";
|
|
173
|
+
const reason = phrase(lang, hasBirthFamily ? "reason.unproven" : "reason.unknown");
|
|
174
|
+
const sets = candidateRecordsets(tree, place, year).filter((b) => !tried.has(b.id));
|
|
175
|
+
const located = finished.some((t) => t.level === "locate");
|
|
176
|
+
const requested = finished.some((t) => t.level === "request");
|
|
177
|
+
if (sets.length)
|
|
178
|
+
item.proposal = {
|
|
179
|
+
level: "link",
|
|
180
|
+
priority: 3, // the queue puts nearer generations first
|
|
181
|
+
what: phrase(lang, "link.what", { name, place: place ? phrase(lang, "link.place", { place }) : "", about }),
|
|
182
|
+
where: sets.map((b) => b.id),
|
|
183
|
+
why: phrase(lang, "link.why", { reason, generation, research: research.name }),
|
|
184
|
+
doneWhen: phrase(lang, "link.done", { years: year ? `${year - span}–${year + span}` : phrase(lang, "whole.book") }),
|
|
185
|
+
subject: [id],
|
|
186
|
+
research: research.id,
|
|
187
|
+
};
|
|
188
|
+
else if ((place || year) && !located)
|
|
189
|
+
item.proposal = {
|
|
190
|
+
level: "locate",
|
|
191
|
+
priority: 3, // the queue puts nearer generations first
|
|
192
|
+
what: phrase(lang, "locate.what", { place: place ?? phrase(lang, "the.birthplace"), around: year ? phrase(lang, "locate.around", { year }) : "", name, about }),
|
|
193
|
+
where: [
|
|
194
|
+
(place ? phrase(lang, "locate.where", { place }) : phrase(lang, "locate.where.unknown")) +
|
|
195
|
+
(year ? phrase(lang, "locate.years", { from: year - span - 2, to: year + span + 2 }) : ""),
|
|
196
|
+
],
|
|
197
|
+
why: phrase(lang, "locate.why", { reason }),
|
|
198
|
+
doneWhen: phrase(lang, "locate.done"),
|
|
199
|
+
subject: [id],
|
|
200
|
+
research: research.id,
|
|
201
|
+
};
|
|
202
|
+
else if (finished.length && !requested)
|
|
203
|
+
item.proposal = {
|
|
204
|
+
level: "request",
|
|
205
|
+
priority: 2,
|
|
206
|
+
what: phrase(lang, "request.what", { name, tasks: finished.map((t) => t.id).join(", ") }),
|
|
207
|
+
where: [phrase(lang, "request.where", { place: place ?? phrase(lang, "the.birthplace") })],
|
|
208
|
+
why: phrase(lang, "request.why", { reason }),
|
|
209
|
+
doneWhen: phrase(lang, "request.done"),
|
|
210
|
+
subject: [id],
|
|
211
|
+
research: research.id,
|
|
212
|
+
};
|
|
213
|
+
else if (finished.length)
|
|
214
|
+
item.exhausted = finished.map((t) => t.id);
|
|
215
|
+
}
|
|
216
|
+
out.push(item);
|
|
217
|
+
}
|
|
218
|
+
return out.sort((a, b) => a.generation - b.generation);
|
|
219
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Genealogical dates in GEDCOM 5.5.1 form: "24 JUN 1783", "ABT 1783",
|
|
2
|
+
// "BET 1811 AND 1812", "BEF MAR 1850", "FROM 1839 TO 1845".
|
|
3
|
+
// ISO input ("1783-06-24", "1783-06") is accepted and converted.
|
|
4
|
+
export const MONTHS = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
|
|
5
|
+
const SIMPLE = /^(?:(\d{1,2}) )?(?:(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC) )?(\d{3,4})$/;
|
|
6
|
+
const QUALIFIERS = ["ABT", "CAL", "EST", "BEF", "AFT"];
|
|
7
|
+
function daysInMonth(month, year) {
|
|
8
|
+
return new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
9
|
+
}
|
|
10
|
+
function validSimple(s) {
|
|
11
|
+
const m = SIMPLE.exec(s);
|
|
12
|
+
if (!m)
|
|
13
|
+
return false;
|
|
14
|
+
const [, day, mon, year] = m;
|
|
15
|
+
if (day && !mon)
|
|
16
|
+
return false;
|
|
17
|
+
if (day && mon) {
|
|
18
|
+
const monthIndex = MONTHS.indexOf(mon) + 1;
|
|
19
|
+
const d = Number(day);
|
|
20
|
+
if (d < 1 || d > daysInMonth(monthIndex, Number(year)))
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
function fromIso(s) {
|
|
26
|
+
const m = /^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$/.exec(s);
|
|
27
|
+
if (!m)
|
|
28
|
+
return undefined;
|
|
29
|
+
const [, y, mo, d] = m;
|
|
30
|
+
if (!mo)
|
|
31
|
+
return y;
|
|
32
|
+
const mon = MONTHS[Number(mo) - 1];
|
|
33
|
+
if (!mon)
|
|
34
|
+
return undefined;
|
|
35
|
+
return d ? `${Number(d)} ${mon} ${y}` : `${mon} ${y}`;
|
|
36
|
+
}
|
|
37
|
+
/** Normalize user input to GEDCOM form, or return undefined if invalid. */
|
|
38
|
+
export function normalizeDate(input) {
|
|
39
|
+
let s = input.trim().toUpperCase().replace(/\s+/g, " ");
|
|
40
|
+
if (s === "")
|
|
41
|
+
return undefined;
|
|
42
|
+
s = s.replace(/\b(\d{4}(?:-\d{2}){0,2})\b/g, (iso) => fromIso(iso) ?? iso);
|
|
43
|
+
const bet = /^BET (.+) AND (.+)$/.exec(s);
|
|
44
|
+
if (bet?.[1] && bet[2])
|
|
45
|
+
return validSimple(bet[1]) && validSimple(bet[2]) ? s : undefined;
|
|
46
|
+
const fromTo = /^FROM (.+?)(?: TO (.+))?$/.exec(s);
|
|
47
|
+
if (fromTo?.[1])
|
|
48
|
+
return validSimple(fromTo[1]) && (!fromTo[2] || validSimple(fromTo[2])) ? s : undefined;
|
|
49
|
+
const to = /^TO (.+)$/.exec(s);
|
|
50
|
+
if (to?.[1])
|
|
51
|
+
return validSimple(to[1]) ? s : undefined;
|
|
52
|
+
const q = /^([A-Z]{3}) (.+)$/.exec(s);
|
|
53
|
+
if (q?.[1] && q[2] && QUALIFIERS.includes(q[1]))
|
|
54
|
+
return validSimple(q[2]) ? s : undefined;
|
|
55
|
+
return validSimple(s) ? s : undefined;
|
|
56
|
+
}
|
|
57
|
+
/** All years mentioned in a GEDCOM date. */
|
|
58
|
+
export function dateYears(date) {
|
|
59
|
+
return [...date.matchAll(/\b(\d{3,4})\b/g)].map((m) => Number(m[1])).filter((y) => y >= 100);
|
|
60
|
+
}
|
|
61
|
+
/** Short year label for listings: "1783", "~1783", "<1850", "1811/1812". */
|
|
62
|
+
export function yearLabel(date) {
|
|
63
|
+
if (!date)
|
|
64
|
+
return "?";
|
|
65
|
+
const years = dateYears(date);
|
|
66
|
+
if (years.length === 0)
|
|
67
|
+
return "?";
|
|
68
|
+
if (date.startsWith("BET") || date.startsWith("FROM"))
|
|
69
|
+
return years.join("/");
|
|
70
|
+
if (/^(ABT|CAL|EST) /.test(date))
|
|
71
|
+
return `~${years[0]}`;
|
|
72
|
+
if (date.startsWith("BEF"))
|
|
73
|
+
return `<${years[0]}`;
|
|
74
|
+
if (date.startsWith("AFT"))
|
|
75
|
+
return `>${years[0]}`;
|
|
76
|
+
return String(years[0]);
|
|
77
|
+
}
|