strom-research 1.0.0 → 1.1.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.
Files changed (73) hide show
  1. package/README.md +13 -5
  2. package/assets/icon/NOTICE.md +26 -0
  3. package/assets/icon/favicon.svg +1 -0
  4. package/assets/icon/icon-256.png +0 -0
  5. package/assets/icon/icon-512.png +0 -0
  6. package/assets/icon/icon.ico +0 -0
  7. package/assets/icon/icon.svg +1 -0
  8. package/assets/lang/cs.json +184 -8
  9. package/assets/lang/de.json +184 -8
  10. package/assets/method/reading.md +4 -2
  11. package/assets/method/recording.md +10 -1
  12. package/assets/plugins/connectors/DISCOVERY.md +6 -2
  13. package/assets/plugins/connectors/README.md +27 -10
  14. package/dist/agents/files.js +27 -18
  15. package/dist/agents/global.js +15 -4
  16. package/dist/brief/brief.js +4 -0
  17. package/dist/cli/context.js +10 -4
  18. package/dist/cli/human.js +116 -0
  19. package/dist/cli/main.js +9 -0
  20. package/dist/cli/menu.js +254 -38
  21. package/dist/cli/ui.js +156 -7
  22. package/dist/cli/wizard.js +36 -9
  23. package/dist/commands/browse.js +243 -0
  24. package/dist/commands/clips.js +190 -0
  25. package/dist/commands/connectors.js +93 -28
  26. package/dist/commands/guide.js +28 -7
  27. package/dist/commands/index.js +3 -0
  28. package/dist/commands/media.js +42 -8
  29. package/dist/commands/meta.js +33 -9
  30. package/dist/commands/output.js +103 -13
  31. package/dist/commands/read.js +1 -1
  32. package/dist/commands/readers.js +62 -0
  33. package/dist/commands/research.js +62 -0
  34. package/dist/commands/session.js +76 -47
  35. package/dist/commands/setup.js +17 -10
  36. package/dist/commands/sources.js +88 -16
  37. package/dist/commands/start.js +57 -37
  38. package/dist/commands/tasks.js +43 -6
  39. package/dist/commands/transcripts.js +177 -0
  40. package/dist/core/chromium.js +60 -19
  41. package/dist/core/clipfinder.js +118 -0
  42. package/dist/core/config.js +42 -2
  43. package/dist/core/connector.js +56 -8
  44. package/dist/core/excerpt.js +219 -0
  45. package/dist/core/frontier.js +30 -5
  46. package/dist/core/git.js +1 -1
  47. package/dist/core/kin.js +90 -0
  48. package/dist/core/live.js +22 -13
  49. package/dist/core/media.js +37 -3
  50. package/dist/core/migrate.js +33 -0
  51. package/dist/core/model.js +7 -0
  52. package/dist/core/net.js +93 -19
  53. package/dist/core/overview.js +259 -0
  54. package/dist/core/phrases.js +27 -0
  55. package/dist/core/places.js +43 -0
  56. package/dist/core/queue.js +16 -3
  57. package/dist/core/review.js +369 -0
  58. package/dist/core/schema.js +4 -0
  59. package/dist/core/session.js +5 -1
  60. package/dist/core/shortcut.js +71 -11
  61. package/dist/core/stories.js +3 -4
  62. package/dist/core/stromapp.js +86 -25
  63. package/dist/core/transcriber.js +99 -0
  64. package/dist/core/tree.js +3 -1
  65. package/dist/core/uninstall.js +4 -2
  66. package/dist/core/update.js +48 -22
  67. package/dist/core/validate.js +3 -1
  68. package/dist/core/workers.js +13 -0
  69. package/dist/gedcom/export.js +58 -30
  70. package/dist/gedcom/lines.js +8 -0
  71. package/dist/gedcom/validate.js +2 -2
  72. package/dist/runners/claude.js +2 -0
  73. package/package.json +1 -1
@@ -0,0 +1,177 @@
1
+ // strom transcripts — the words of entries already on their scans, for sources
2
+ // that have a clip but no transcript (read before strom asked for the words, or
3
+ // brought in from an older research): a reader writes them from the entry cut
4
+ // out of its scan, a second reader holds them against the same cut-out; what
5
+ // passes becomes the source's transcript, with a note of how it was made. Run
6
+ // again, it takes up only what is still missing.
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { register } from "../cli/registry.js";
10
+ import { lines, truncate } from "../cli/format.js";
11
+ import { UsageError } from "../core/errors.js";
12
+ import { EXCERPT_SCOPES } from "../core/model.js";
13
+ import { normId, requireRecord, update } from "../core/records.js";
14
+ import { makeView } from "../core/views.js";
15
+ import { inputPath, regionText, sharperPart } from "../core/media.js";
16
+ import { SCOPE_MAX, sourceNearness, withMargin } from "../core/excerpt.js";
17
+ import { batches } from "../core/reader.js";
18
+ import { checkTranscriptPrompt, parseTranscriptChecks, parseTranscribed, transcribePrompt } from "../core/transcriber.js";
19
+ import { makeNote } from "../core/actions.js";
20
+ import { phrase } from "../core/phrases.js";
21
+ import { verifyFast } from "../core/integrity.js";
22
+ import { pool } from "./read.js";
23
+ import { readers } from "./readers.js";
24
+ import { namesIn } from "./clips.js";
25
+ /** Entries per reader. */
26
+ const PER_READER = 8;
27
+ /** A cut-out as the reader sees it: sharp enough to read a hand, small enough to open. */
28
+ const CUT_MAX = 2000;
29
+ register({
30
+ path: ["transcripts"],
31
+ summary: "Write the words of entries already cut out of their scans (sources with a clip, no transcript): readers read them, a second look checks",
32
+ group: "sources",
33
+ tree: true,
34
+ writes: true,
35
+ lock: "sections",
36
+ description: "For sources with a clip (or a document the user gave as a scan) and no transcript — read before strom asked for the\n" +
37
+ "words, or brought in from an older research. A reader writes the entry's words from its cut-out, as they stand (its\n" +
38
+ "own language and spelling, unread letters [?]); a second reader holds them against the same cut-out and corrects\n" +
39
+ "them: only then they become the source's transcript, with a note of how they were made. The Strom app shows them next\n" +
40
+ "to the entry's image. Paid model work, two looks per entry: --dry-run says how many first. Nearest the research first.",
41
+ args: [{ name: "sources", description: "only these sources (S…)", variadic: true }],
42
+ options: [
43
+ { name: "for", type: "string", value: "<whose>", description: "line, family, connected or all (default: all — the nearest to the research first)" },
44
+ { name: "limit", type: "string", value: "<n>", description: "at most this many sources this time" },
45
+ { name: "batch", type: "string", value: "<n>", description: `entries per reader (default ${PER_READER})` },
46
+ { name: "parallel", type: "string", value: "<n>", description: "readers at the same time (default 3)" },
47
+ { name: "model", type: "string", value: "<model>", description: "model of the readers (default: model.vision — it reads the handwriting)" },
48
+ { name: "minutes", type: "string", value: "<n>", description: "time limit of one reader (default 15)" },
49
+ ],
50
+ examples: ["strom transcripts --dry-run", "strom transcripts --for family --limit 20", "strom transcripts S0001 S0002"],
51
+ run: async (ctx, { args, opts }) => {
52
+ const tree = ctx.tree();
53
+ const shared = ctx.settings.shared()?.value;
54
+ if (!shared)
55
+ throw new UsageError("no shared folder with the images is set", { hint: "strom setup" });
56
+ const scope = (typeof opts.for === "string" ? opts.for : "all");
57
+ if (!EXCERPT_SCOPES.includes(scope) || scope === "none")
58
+ throw new UsageError(`invalid --for "${opts.for}"`, { hint: "line, family, connected or all" });
59
+ const all = tree.list("media");
60
+ const byId = new Map(all.map((m) => [m.id, m]));
61
+ const only = new Set(args.map((a) => requireRecord(tree, normId(a), "source").id));
62
+ const near = sourceNearness(tree);
63
+ const rank = (s) => near.get(s.id) ?? 4;
64
+ /** The cut-outs of a source, from the sharpest image of the place there is; none when its image is not here. */
65
+ const cutsOf = (s) => {
66
+ if (s.clips?.length)
67
+ return s.clips.flatMap((c) => {
68
+ const m = byId.get(c.media);
69
+ if (!m)
70
+ return [];
71
+ const wanted = withMargin(c.region);
72
+ const sharper = sharperPart(all, m, wanted);
73
+ const src = sharper?.part ?? m;
74
+ const file = path.join(shared, src.file);
75
+ return /^image\/(jpeg|png)$/.test(src.mime) && fs.existsSync(file) ? [{ media: m.id, file, crop: regionText(sharper?.crop ?? wanted) }] : [];
76
+ });
77
+ const input = s.input ? tree.get(s.input) : undefined;
78
+ const file = input ? inputPath(tree, input) : undefined;
79
+ return input && file && /\.(jpe?g|png)$/i.test(file) && fs.existsSync(file) ? [{ media: input.id, file }] : [];
80
+ };
81
+ let todo = tree
82
+ .list("source")
83
+ .filter((s) => !s.retracted && !s.transcript?.trim() && (s.clips?.length || s.input))
84
+ .filter((s) => (only.size ? only.has(s.id) : rank(s) <= SCOPE_MAX[scope]))
85
+ .sort((a, b) => rank(a) - rank(b) || a.id.localeCompare(b.id));
86
+ const away = todo.filter((s) => !cutsOf(s).length);
87
+ todo = todo.filter((s) => cutsOf(s).length);
88
+ const limit = opts.limit === undefined ? undefined : Number(opts.limit);
89
+ if (limit !== undefined && !(Number.isInteger(limit) && limit > 0))
90
+ throw new UsageError("--limit must be a positive number");
91
+ if (limit)
92
+ todo = todo.slice(0, limit);
93
+ const size = opts.batch === undefined ? PER_READER : Number(opts.batch);
94
+ if (!Number.isInteger(size) || size < 1)
95
+ throw new UsageError("--batch must be a positive number");
96
+ const groups = batches(todo, size);
97
+ if (!todo.length)
98
+ return { text: lines("nothing to do: every entry cut out of its scan has its words", away.length ? `${away.length} source(s) on images not on this computer` : undefined), data: { sources: 0 } };
99
+ if (tree.dryRun)
100
+ return {
101
+ text: lines(`${todo.length} source(s) to transcribe from their cut-outs — ${groups.length} reader(s) to read them, as many to check`, away.length ? `${away.length} more on images not on this computer` : undefined, "(dry run — no reader started)"),
102
+ data: { sources: todo.length, readers: groups.length, away: away.map((s) => s.id) },
103
+ };
104
+ const r0 = readers(ctx, tree, shared, "transcripts", "read", opts);
105
+ const { parallel, reportsDir, failed, progress } = r0;
106
+ const facts = (s) => [
107
+ s.locator || s.date ? `where: ${[s.locator, s.date ? `recorded ${s.date}` : ""].filter(Boolean).join("; ")}` : "",
108
+ namesIn(tree, s.id).length ? `people in it: ${namesIn(tree, s.id).join(", ")}` : "",
109
+ s.language ? `language: ${s.language}` : "",
110
+ ].filter(Boolean);
111
+ const job = (s) => ({
112
+ source: s.id,
113
+ title: s.title,
114
+ facts: facts(s),
115
+ views: cutsOf(s).map((c) => ({ media: c.media, view: makeView(tree, c.file, c.media, { ...(c.crop ? { crop: c.crop } : {}), max: CUT_MAX }).file })),
116
+ });
117
+ // 1. Read: the words from the cut-out.
118
+ const jobs = new Map(todo.map((s) => [s.id, job(s)]));
119
+ const read = (await pool(groups, parallel, async (group, k) => {
120
+ const js = group.map((s) => jobs.get(s.id));
121
+ progress(`▶ reading ${k + 1}/${groups.length}: ${group.map((s) => s.id).join(" ")}`);
122
+ const r = await r0.read(`read-${k + 1}`, js.flatMap((j) => j.views.map((v) => v.view)), `The words of the entries · batch ${k + 1} of ${groups.length}`, (report) => transcribePrompt(js, report));
123
+ const got = parseTranscribed(r.text).filter((x) => jobs.has(x.source));
124
+ progress(`■ reading ${k + 1}: ${r.outcome} · ${got.filter((x) => x.result === "read").length} read`);
125
+ return got;
126
+ })).flat();
127
+ const words = read.filter((x) => x.result === "read");
128
+ // 2. Check: the same cut-out, word by word.
129
+ const checks = words.map((x) => ({ ...jobs.get(x.source), transcript: x.transcript }));
130
+ const checkGroups = batches(checks, size);
131
+ const verdicts = (await pool(checkGroups, parallel, async (group, k) => {
132
+ progress(`▶ checking ${k + 1}/${checkGroups.length}: ${group.length} transcripts`);
133
+ const r = await r0.read(`check-${k + 1}`, group.flatMap((j) => j.views.map((v) => v.view)), `Checking the words · batch ${k + 1} of ${checkGroups.length}`, (report) => checkTranscriptPrompt(group, report));
134
+ const v = parseTranscriptChecks(r.text);
135
+ progress(`■ checking ${k + 1}: ${r.outcome} · ${v.filter((x) => x.verdict !== "wrong").length} passed`);
136
+ return v;
137
+ })).flat();
138
+ // 3. What passed becomes the source's transcript, with how it was made.
139
+ const passed = new Map();
140
+ for (const v of verdicts) {
141
+ const first = words.find((x) => x.source === v.source);
142
+ if (!first || passed.has(v.source))
143
+ continue;
144
+ if (v.verdict === "ok")
145
+ passed.set(v.source, first.transcript);
146
+ else if (v.verdict === "fixed")
147
+ passed.set(v.source, v.transcript);
148
+ }
149
+ const day = new Date().toISOString().slice(0, 10);
150
+ const how = phrase(tree.lang, "transcript.read", { model: r0.model ?? "?", date: day });
151
+ for (const [id, transcript] of passed)
152
+ update(tree, id, "source", (s) => ({ ...s, transcript, notes: [...s.notes, makeNote(tree, how)] }), {
153
+ op: "source.edit",
154
+ summary: `${id} transcript (${transcript.split("\n").length} line(s), read by a reader, checked)`,
155
+ });
156
+ if (verifyFast(tree).findings.every((f) => f.level !== "error"))
157
+ tree.withTreeLock(() => tree.commit(`Transcripts: ${passed.size} of ${todo.length} entries written from their cut-outs`, [tree.relative(reportsDir)]));
158
+ const rejected = verdicts.filter((v) => v.verdict === "wrong");
159
+ const unread = read.filter((x) => x.result !== "read");
160
+ const missed = todo.filter((s) => !passed.has(s.id));
161
+ return {
162
+ text: lines(`${passed.size} of ${todo.length} source(s) got their words${r0.model ? ` · ${r0.model}` : ""}${r0.cost() ? ` · $${r0.cost().toFixed(2)}` : ""}`, verdicts.some((v) => v.verdict === "fixed") ? `the check corrected ${verdicts.filter((v) => v.verdict === "fixed").length} of them` : undefined, rejected.length ? `the check turned down: ${rejected.map((v) => `${v.source}${v.why ? ` (${truncate(v.why, 40)})` : ""}`).join(", ")}` : undefined, unread.length ? `not read (unreadable, or not this entry in the cut-out): ${unread.map((x) => `${x.source} ${x.result}`).slice(0, 20).join(", ")}` : undefined, missed.length ? `without words: ${missed.map((s) => s.id).slice(0, 20).join(", ")}${missed.length > 20 ? ` … (${missed.length})` : ""} — strom transcripts again, or strom source edit S… --transcript @<file>` : undefined, away.length ? `on images not on this computer: ${away.length}` : undefined, failed.length ? `readers that failed: ${failed.join(", ")}` : undefined, "the words go into the Strom app with the entry's image: strom app (output/tree-strom.ged)"),
163
+ data: {
164
+ sources: todo.length,
165
+ transcribed: [...passed.keys()],
166
+ corrected: verdicts.filter((v) => v.verdict === "fixed").map((v) => v.source),
167
+ rejected,
168
+ unread: unread.map((x) => ({ source: x.source, result: x.result })),
169
+ missed: missed.map((s) => s.id),
170
+ away: away.map((s) => s.id),
171
+ failed,
172
+ costUsd: r0.cost() || undefined,
173
+ },
174
+ exitCode: failed.length ? 1 : 0,
175
+ };
176
+ },
177
+ });
@@ -19,31 +19,26 @@ const BROWSERS = [
19
19
  ];
20
20
  /** A Chromium browser on this computer, the most common first. STROM_APP_DIRS: folders to look in instead (tests). */
21
21
  export function chromiumBrowser(env, platform = process.platform) {
22
+ return chromiumBrowsers(env, platform)[0];
23
+ }
24
+ /** Every Chromium browser on this computer, the most common first. */
25
+ export function chromiumBrowsers(env, platform = process.platform) {
26
+ const found = [];
22
27
  const own = env.STROM_APP_DIRS !== undefined ? env.STROM_APP_DIRS.split(path.delimiter).filter(Boolean) : undefined;
23
28
  for (const b of BROWSERS) {
24
- if (platform === "darwin") {
25
- for (const d of own ?? ["/Applications", path.join(userHome(env), "Applications")])
26
- if (fs.existsSync(path.join(d, b.mac)))
27
- return { name: b.name, path: path.join(d, b.mac) };
28
- }
29
+ let hit;
30
+ if (platform === "darwin")
31
+ hit = (own ?? ["/Applications", path.join(userHome(env), "Applications")]).map((d) => path.join(d, b.mac)).find((f) => fs.existsSync(f));
29
32
  else if (platform === "win32") {
30
33
  const roots = own ?? [env.LOCALAPPDATA, env.ProgramFiles, env["ProgramFiles(x86)"]].filter((r) => Boolean(r));
31
- for (const root of roots)
32
- for (const rel of b.win) {
33
- const exe = own ? path.join(root, path.win32.basename(rel)) : path.join(root, rel);
34
- if (fs.existsSync(exe))
35
- return { name: b.name, path: exe };
36
- }
37
- }
38
- else {
39
- for (const cmd of b.linux) {
40
- const hit = own ? own.map((d) => path.join(d, cmd)).find((f) => fs.existsSync(f)) : which(cmd, env, platform);
41
- if (hit)
42
- return { name: b.name, path: hit };
43
- }
34
+ hit = roots.flatMap((root) => b.win.map((rel) => (own ? path.join(root, path.win32.basename(rel)) : path.join(root, rel)))).find((f) => fs.existsSync(f));
44
35
  }
36
+ else
37
+ hit = b.linux.map((cmd) => (own ? own.map((d) => path.join(d, cmd)).find((f) => fs.existsSync(f)) : which(cmd, env, platform))).find(Boolean);
38
+ if (hit)
39
+ found.push({ name: b.name, path: hit });
45
40
  }
46
- return undefined;
41
+ return found;
47
42
  }
48
43
  function launch(cmd, args, env) {
49
44
  try {
@@ -56,6 +51,52 @@ function launch(cmd, args, env) {
56
51
  return false;
57
52
  }
58
53
  }
54
+ /** Where each browser keeps its profiles, under the system's folder for application data. */
55
+ const USER_DATA = {
56
+ "Google Chrome": { mac: "Google/Chrome", win: "Google\\Chrome\\User Data", linux: "google-chrome" },
57
+ "Microsoft Edge": { mac: "Microsoft Edge", win: "Microsoft\\Edge\\User Data", linux: "microsoft-edge" },
58
+ Brave: { mac: "BraveSoftware/Brave-Browser", win: "BraveSoftware\\Brave-Browser\\User Data", linux: "BraveSoftware/Brave-Browser" },
59
+ Chromium: { mac: "Chromium", win: "Chromium\\User Data", linux: "chromium" },
60
+ Vivaldi: { mac: "Vivaldi", win: "Vivaldi\\User Data", linux: "vivaldi" },
61
+ };
62
+ /** The profile of the browser an installed web app belongs to: the one that holds its files (only folder names are looked at). */
63
+ export function webAppProfile(browser, appId, env, platform = process.platform) {
64
+ const d = USER_DATA[browser];
65
+ if (!d)
66
+ return undefined;
67
+ const home = userHome(env);
68
+ const root = platform === "darwin"
69
+ ? path.join(home, "Library", "Application Support", d.mac)
70
+ : platform === "win32"
71
+ ? path.join(env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), d.win)
72
+ : path.join(env.XDG_CONFIG_HOME ?? path.join(home, ".config"), d.linux);
73
+ let dirs = [];
74
+ try {
75
+ dirs = fs.readdirSync(root);
76
+ }
77
+ catch {
78
+ return undefined;
79
+ }
80
+ return dirs.find((p) => fs.existsSync(path.join(root, p, "Web Applications", "Manifest Resources", appId)));
81
+ }
82
+ /**
83
+ * Open an address in a web app installed from a Chromium browser — its own window, not a tab: the browser
84
+ * is asked to launch the app (its id, its profile) at that address, which must lie within the app.
85
+ */
86
+ export function openWebApp(app, url, env, platform = process.platform) {
87
+ if (env.STROM_NO_OPEN === "1")
88
+ return false;
89
+ const b = chromiumBrowsers(env, platform).find((x) => x.name === app.browser);
90
+ if (!b)
91
+ return false;
92
+ const profile = app.profile ?? webAppProfile(app.browser, app.appId, env, platform);
93
+ const args = [...(profile ? [`--profile-directory=${profile}`] : []), `--app-id=${app.appId}`, `--app-launch-url-for-shortcuts-menu-item=${url}`];
94
+ // macOS: the browser's own program — it hands the request to the browser already running.
95
+ const exe = platform === "darwin" ? path.join(b.path, "Contents", "MacOS", path.basename(b.path, ".app")) : b.path;
96
+ if (platform !== "darwin" && platform !== "win32" && !env.DISPLAY && !env.WAYLAND_DISPLAY)
97
+ return false;
98
+ return launch(exe, args, env);
99
+ }
59
100
  /** Open an address in that browser; false when this computer cannot (no desktop) or tests ask not to. */
60
101
  export function openInBrowser(browser, url, env, platform = process.platform) {
61
102
  if (env.STROM_NO_OPEN === "1")
@@ -0,0 +1,118 @@
1
+ // Finding where entries already read are on their scans (the clips of old
2
+ // sources): a reader looks at each image with a grid and says where the entry
3
+ // is; strom cuts that out and a second reader checks the cut-out shows the
4
+ // whole entry, and that entry. Only what passes the check becomes a clip.
5
+ // Readers never write to the research; strom does, from their reports.
6
+ const RULES = `You are a READER for a genealogical research. You are not the researcher: you do not decide
7
+ anything and you do not write anywhere except your report file. Do not run any commands.`;
8
+ export function locatePrompt(jobs, report) {
9
+ return `${RULES}
10
+
11
+ Each entry below was read earlier from the image(s) named with it. Open each image (one at a time,
12
+ in this order), find that entry on it, and say where it is. The images carry a grid of tenths with
13
+ labels (0.1 … 0.9 across and down): read the position off it, to hundredths.
14
+
15
+ THE ENTRIES
16
+ ${jobs
17
+ .map((j) => [
18
+ `### ${j.source} — ${j.title}`,
19
+ ...j.facts,
20
+ j.words ? `words: ${j.words}` : undefined,
21
+ ...j.images.map((i) => `image ${i.media}: ${i.view}`),
22
+ ]
23
+ .filter(Boolean)
24
+ .join("\n"))
25
+ .join("\n\n")}
26
+
27
+ THE REPORT
28
+ Write your report to ${report} AS YOU GO — after every entry, not at the end. For each entry and image:
29
+
30
+ ## <S… id> · <M… id>
31
+ result: found | nothing | unclear | many
32
+ region: x,y,w,h
33
+
34
+ x,y is the top left corner, w,h the width and height — all as fractions of the whole image (0–1).
35
+ The region holds the whole entry: every column of its row or rows, its notes in the margin — rather
36
+ a little generous than cut, and no more of the entries above and below than it has to. An entry
37
+ that runs over to the next page: a block for each image. "nothing" when the entry is not on the
38
+ image, "unclear" when you cannot tell which one it is — never a guess. "many" when it is not one entry
39
+ at all but a search over many entries or pages (a section read through, a whole book, "not found in
40
+ …", a description of the book): no region then — a page picked out of many would show nothing of it.
41
+ The words "result", "found", "nothing", "unclear", "many" and "region" stay in English: strom reads them.
42
+ When all are done, finish with one line: "done: <n> entries".`;
43
+ }
44
+ export function checkPrompt(jobs, report) {
45
+ return `${RULES}
46
+
47
+ Each file below is a cut-out of a scan that should show one entry of a register. Open each (one at
48
+ a time, in this order) and check it against what the entry says.
49
+
50
+ THE CUT-OUTS
51
+ ${jobs.map((j) => [`### ${j.source} · ${j.media} — ${j.title}`, j.words ? `words: ${j.words}` : undefined, `cut-out: ${j.view}`].filter(Boolean).join("\n")).join("\n\n")}
52
+
53
+ THE REPORT
54
+ Write your report to ${report} AS YOU GO. For each cut-out:
55
+
56
+ ## <S… id> · <M… id>
57
+ verdict: ok | cut | wrong
58
+ missing: top, bottom, left, right (only with cut: the sides where a part of the entry is cut off)
59
+ why: a few words
60
+
61
+ ok: the entry is there whole (a little of its neighbours does not matter). cut: it is there, but a
62
+ part of it is missing — strom cuts it out wider on those sides and asks again. wrong: another entry,
63
+ or none. The words "verdict", "ok", "cut", "wrong", "missing", "top", "bottom", "left" and "right"
64
+ stay in English: strom reads them.
65
+ When all are done, finish with one line: "done: <n> cut-outs".`;
66
+ }
67
+ /** The blocks of a report: "## S0012 · M0034" and its lines. */
68
+ function blocks(text) {
69
+ const out = [];
70
+ for (const b of text.split(/^##\s+/m).slice(1)) {
71
+ const head = b.split("\n")[0] ?? "";
72
+ const source = /\b(S\d{4,})\b/.exec(head)?.[1];
73
+ const media = /\b(M\d{4,})\b/.exec(head)?.[1];
74
+ if (source && media)
75
+ out.push({ source, media, body: b });
76
+ }
77
+ return out;
78
+ }
79
+ export function parseLocated(text) {
80
+ return blocks(text).map(({ source, media, body }) => {
81
+ const r = /^result:\s*(\p{L}+)/imu.exec(body)?.[1]?.toLowerCase();
82
+ const result = r === "found" ? "found" : r === "nothing" ? "nothing" : r === "many" ? "many" : "unclear";
83
+ const nums = /^region:\s*([\d.]+)\s*[,;\s]\s*([\d.]+)\s*[,;\s]\s*([\d.]+)\s*[,;\s]\s*([\d.]+)/im.exec(body)?.slice(1).map(Number);
84
+ const ok = nums && nums.every((n) => Number.isFinite(n) && n >= 0 && n <= 1) && nums[2] > 0.005 && nums[3] > 0.003;
85
+ if (result !== "found" || !ok)
86
+ return { source, media, result: result === "found" ? "unclear" : result };
87
+ const [x, y, w, h] = nums;
88
+ return { source, media, result, region: { x: x, y: y, w: Math.min(w, 1 - x), h: Math.min(h, 1 - y) } };
89
+ });
90
+ }
91
+ export function parseChecked(text) {
92
+ return blocks(text).map(({ source, media, body }) => {
93
+ const v = /^verdict:\s*(\p{L}+)/imu.exec(body)?.[1]?.toLowerCase();
94
+ const why = /^why:\s*(.+)$/im.exec(body)?.[1]?.trim();
95
+ const sides = (/^missing:\s*(.+)$/im.exec(body)?.[1] ?? "").toLowerCase().match(/\b(top|bottom|left|right)\b/g);
96
+ const verdict = v === "ok" ? "ok" : v === "cut" ? "cut" : "wrong";
97
+ return { source, media, verdict, ...(verdict === "cut" && sides?.length ? { missing: [...new Set(sides)] } : {}), ...(why ? { why } : {}) };
98
+ });
99
+ }
100
+ /** A region cut out wider where the check found the entry cut off: half as much again on each such side (all round when none was said). */
101
+ export function widen(r, missing) {
102
+ const sides = missing?.length ? missing : ["top", "bottom", "left", "right"];
103
+ const dx = Math.max(0.02, r.w * 0.5);
104
+ const dy = Math.max(0.02, r.h * 0.5);
105
+ let [x0, y0, x1, y1] = [r.x, r.y, r.x + r.w, r.y + r.h];
106
+ if (sides.includes("left"))
107
+ x0 -= dx;
108
+ if (sides.includes("right"))
109
+ x1 += dx;
110
+ if (sides.includes("top"))
111
+ y0 -= dy;
112
+ if (sides.includes("bottom"))
113
+ y1 += dy;
114
+ const f = (n) => Math.min(1, Math.max(0, n));
115
+ const k = (n) => Math.round(n * 1e4) / 1e4;
116
+ [x0, y0, x1, y1] = [f(x0), f(y0), f(x1), f(y1)];
117
+ return { x: k(x0), y: k(y0), w: k(x1 - x0), h: k(y1 - y0) };
118
+ }
@@ -10,9 +10,10 @@ import { readJsonIfExists, writeJson } from "./json.js";
10
10
  import { detectLang, isValidLang } from "./lang.js";
11
11
  import { UsageError } from "./errors.js";
12
12
  import { DEFAULT_AGENT, PROFILES, TIERS } from "../agents/profiles.js";
13
- import { STRATEGIES } from "./model.js";
13
+ import { EXCERPT_QUALITIES, EXCERPT_SCOPES, EXCERPTS_MAX_MB, STRATEGIES } from "./model.js";
14
14
  import { downloadsDir } from "./browser.js";
15
15
  import { acquireLock } from "./lock.js";
16
+ import { isStromAppOrigin } from "./stromapp.js";
16
17
  export const PERMISSION_LEVELS = ["ask", "auto", "full"];
17
18
  /** Earlier names of the levels, still understood. */
18
19
  const PERMISSION_ALIASES = { list: "auto", bypass: "full" };
@@ -41,6 +42,10 @@ export const SETTINGS = [
41
42
  { 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)" },
42
43
  { 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)" },
43
44
  { 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" },
45
+ { 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)" },
46
+ { 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)" },
47
+ { 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" },
48
+ { key: "excerpts.mb", env: "STROM_EXCERPTS_MB", tree: true, kind: "number", description: `limit of all the images in one file for the Strom app, MB (default ${EXCERPTS_MAX_MB}): over it they are made smaller, then the farthest from the research left out — strom says so` },
44
49
  { key: "strom.version", env: "STROM_APP_VERSION", tree: true, kind: "version", description: "version of the Strom app the Strom GEDCOM is for (set by the app)" },
45
50
  { key: "connectors.consent", env: "STROM_CONNECTORS_CONSENT", tree: false, kind: "choice", choices: ["off", "on"], description: "off (default): connectors run without asking — paced by strom, only to their hosts; on: each needs the user's yes, in their terminal, to it and to each archive host" },
46
51
  { key: "browser.downloads", env: "STROM_BROWSER_DOWNLOADS", tree: false, kind: "path", description: "the folder your browser saves downloads into (default: the system's Downloads folder) — strom takes a connector's images over from there" },
@@ -49,6 +54,7 @@ export const SETTINGS = [
49
54
  { key: "agent.where", env: "STROM_AGENT_WHERE", tree: false, kind: "choice", choices: ["app", "terminal"], description: "where you talk with the agent: app (its desktop app — the easiest), terminal (its CLI) — unset: the app when it is installed" },
50
55
  { key: "updates", env: "STROM_UPDATES", tree: false, kind: "choice", choices: ["check", "off"], description: "look for new versions of strom: check (default — at most once a day, one small file from the project's releases; strom says so, strom update installs it) or off" },
51
56
  { key: "strom.app", env: "", tree: false, kind: "choice", choices: ["yes", "no"], description: "you use the Strom app: yes (strom says which file to import into it), no (strom never mentions it) — unset: strom notices it itself" },
57
+ { key: "strom.app.url", env: "STROM_APP_URL", tree: false, kind: "url", description: "another copy of the Strom app to open instead of https://stromapp.info/run/ — its beta (https://beta.stromapp.info/run/), its development (http://127.0.0.1:8080/); installed from a browser, that copy opens as its own app" },
52
58
  ];
53
59
  /** Fields of the stored settings whose key is not the field name. */
54
60
  const FIELDS = {
@@ -62,6 +68,11 @@ const FIELDS = {
62
68
  "agent.permissions": "agentPermissions",
63
69
  "agent.where": "agentWhere",
64
70
  "strom.app": "stromApp",
71
+ "strom.app.url": "stromAppUrl",
72
+ "main.person": "mainPerson",
73
+ "excerpts.quality": "excerptsQuality",
74
+ "excerpts.for": "excerptsFor",
75
+ "excerpts.mb": "excerptsMb",
65
76
  };
66
77
  /** Environment variables that are not settings but steer strom. */
67
78
  export const OTHER_ENV = [
@@ -69,7 +80,6 @@ export const OTHER_ENV = [
69
80
  { env: "STROM_CONFIG_DIR", description: "where the user config and seal keys live" },
70
81
  { env: "STROM_NONINTERACTIVE", description: "1 = never ask, fail with needs-input instead" },
71
82
  { env: "STROM_DOCUMENTS", description: "the Documents folder, where strom suggests its home (default: the system's)" },
72
- { env: "STROM_APP_URL", description: "another copy of the Strom app to open (its development: http://127.0.0.1:8080/)" },
73
83
  { env: "STROM_APP", description: "set by the Strom app when it starts strom (or an agent for it): strom then knows the app is there" },
74
84
  ];
75
85
  export function settingDef(key) {
@@ -118,6 +128,23 @@ export function checkValue(def, raw, resolvePath) {
118
128
  throw new UsageError(`invalid ${def.key} "${raw}"`, { hint: def.choices?.join(", ") });
119
129
  return c;
120
130
  }
131
+ case "person":
132
+ if (!/^[Pp]\d{4,}$/.test(v))
133
+ throw new UsageError(`invalid ${def.key} "${raw}"`, { hint: "a person's ID, e.g. P0009 (strom find <name>)" });
134
+ return v.toUpperCase();
135
+ case "url": {
136
+ // A copy of the Strom app the bridge lets in (core/live.ts) — the bridge's address goes to it.
137
+ let origin = "";
138
+ try {
139
+ origin = new URL(v).origin;
140
+ }
141
+ catch {
142
+ // not an address
143
+ }
144
+ if (!isStromAppOrigin(origin))
145
+ throw new UsageError(`invalid ${def.key} "${raw}"`, { hint: "the Strom app: https://stromapp.info/run/, its beta https://beta.stromapp.info/run/, or a copy on this computer http://127.0.0.1:<port>/" });
146
+ return v;
147
+ }
121
148
  case "version":
122
149
  if (!/^\d+(\.\d+){0,2}$/.test(v))
123
150
  throw new UsageError(`invalid ${def.key} "${raw}"`, { hint: "a version like 1.4.0" });
@@ -314,6 +341,19 @@ export class Settings {
314
341
  const v = this.resolve("stories", tree)?.value;
315
342
  return { on: v !== "no", said: v === "yes" || v === "no" };
316
343
  }
344
+ /** The images for the Strom app: how sharp, whose, and the limit of all together (MB). */
345
+ excerpts(tree) {
346
+ return {
347
+ quality: String(this.resolve("excerpts.quality", tree)?.value ?? "normal"),
348
+ for: String(this.resolve("excerpts.for", tree)?.value ?? "family"),
349
+ mb: this.number("excerpts.mb", tree, EXCERPTS_MAX_MB),
350
+ };
351
+ }
352
+ /** The main person of the tree as set (strom config set main.person P…), if anybody did. */
353
+ mainPerson(tree) {
354
+ const r = this.resolve("main.person", tree);
355
+ return r ? String(r.value) : undefined;
356
+ }
317
357
  stromVersion(tree) {
318
358
  const r = this.resolve("strom.version", tree);
319
359
  return r ? String(r.value) : undefined;
@@ -25,12 +25,19 @@ import { loadUserConfig, Settings } from "./config.js";
25
25
  import { readJsonIfExists, writeJson } from "./json.js";
26
26
  import { readAsset } from "./assets.js";
27
27
  import { foldText } from "./text.js";
28
- import { CookieJar, hostAllowed, NetError, politeRequest } from "./net.js";
28
+ import { CookieJar, hostAllowed, NetError, paceOf, paceText, politeRequest } from "./net.js";
29
29
  import { loginOf, redactor } from "./logins.js";
30
30
  import { decodeImage, encodeImage, imageSize, imageSizeOfFile } from "../image/index.js";
31
31
  import { blank, paste, toRgb } from "../image/image.js";
32
32
  import { botCheck } from "./browser.js";
33
+ /** The newest version of the contract. */
33
34
  export const INTERFACE = 1;
35
+ /**
36
+ * Every version of the contract this strom runs — each one it ever had: a connector someone built stays
37
+ * working. Within a version the contract only grows (optional fields, capabilities, routes); what a newer
38
+ * strom added, an older one leaves out and runs the rest (ConnectorManifest.newer).
39
+ */
40
+ export const INTERFACES = [1];
34
41
  export const MANIFEST = "connector.json";
35
42
  export const CAPABILITIES = ["find", "list", "fetch", "part", "locate"];
36
43
  /** How its images come: through strom's limiter (direct), or through the user's own browser. */
@@ -109,8 +116,24 @@ export function readManifest(dir) {
109
116
  throw new UsageError(`${MANIFEST} is not valid JSON: ${err.message}`);
110
117
  }
111
118
  const problems = [];
112
- if (m.interface !== INTERFACE)
119
+ if (typeof m.interface === "number" && m.interface > Math.max(...INTERFACES))
120
+ throw new UsageError(`${MANIFEST}: written for version ${m.interface} of the contract — this strom runs ${INTERFACES.join(", ")}`, { hint: "a newer strom runs it: strom update" });
121
+ if (!INTERFACES.includes(m.interface))
113
122
  problems.push(`interface: ${INTERFACE} — the version of the contract it is written for`);
123
+ // Capabilities and routes of a newer contract: left out, the rest runs (an older strom, a connector built for a newer one).
124
+ const newer = [];
125
+ const known = (list, all, what) => {
126
+ if (!Array.isArray(list) || list.some((x) => typeof x !== "string"))
127
+ return list;
128
+ const unknown = list.filter((x) => !all.includes(x) && /^[a-z][a-z-]*$/.test(x));
129
+ newer.push(...unknown.map((x) => `${what} ${x}`));
130
+ return list.filter((x) => !unknown.includes(x));
131
+ };
132
+ m.can = known(m.can, CAPABILITIES, "can");
133
+ if (m.routes !== undefined)
134
+ m.routes = known(m.routes, ROUTES, "route");
135
+ if (newer.length)
136
+ m.newer = newer;
114
137
  if (!m.title || typeof m.title !== "string")
115
138
  problems.push("title: the archive or portal");
116
139
  if (!Array.isArray(m.run) || !m.run.length || m.run.some((x) => typeof x !== "string"))
@@ -177,7 +200,7 @@ export function scanConnectors(shared) {
177
200
  connectors.push({ name: e.name, dir, manifest: readManifest(dir) });
178
201
  }
179
202
  catch (err) {
180
- broken.push({ name: e.name, dir, problem: err.message });
203
+ broken.push({ name: e.name, dir, problem: err.message, ...(err instanceof UsageError && err.hint ? { hint: err.hint } : {}) });
181
204
  }
182
205
  }
183
206
  return { connectors, broken };
@@ -195,10 +218,35 @@ export function routeOf(env, c) {
195
218
  const routes = routesOf(c);
196
219
  return chosen && routes.includes(chosen.via) ? { via: chosen.via, chosen } : { via: routes[0] };
197
220
  }
198
- /** Connectors whose images come through the user's own browser on this computer: the agent gets browser tools for their hosts. */
221
+ /** Connectors whose images come through the user's own browser on this computer. */
199
222
  export function browserConnectors(env, shared) {
200
223
  return listConnectors(shared).filter((c) => c.manifest.policy.automation !== "manual" && routeOf(env, c).via === "browser");
201
224
  }
225
+ /** A site as written anywhere: lower case, without "*." or "www.". */
226
+ function siteOf(host) {
227
+ return bareHost(host).replace(/^www\./, "");
228
+ }
229
+ /**
230
+ * The browser connectors a tree works with — the agent gets browser tools for their hosts, and only there: images
231
+ * fetched through one, or an archive, book or record of the tree on its site. The plugins folder is shared by every
232
+ * tree; a tree whose research never goes to that archive gets no browser.
233
+ */
234
+ export function treeBrowserConnectors(tree, shared) {
235
+ const all = browserConnectors(tree.env, shared);
236
+ if (!all.length)
237
+ return [];
238
+ const fetchedBy = new Set(tree.list("media").flatMap((m) => (m.fetched ? [m.fetched.connector] : [])));
239
+ const hosts = new Set();
240
+ for (const r of [...tree.list("repository"), ...tree.list("recordset"), ...tree.list("source")])
241
+ if (r.url && !r.retracted)
242
+ try {
243
+ hosts.add(siteOf(new URL(r.url).hostname));
244
+ }
245
+ catch {
246
+ // not an address
247
+ }
248
+ return all.filter((c) => fetchedBy.has(c.name) || c.manifest.hosts.some((h) => hosts.has(siteOf(h))));
249
+ }
202
250
  export function findConnector(shared, name) {
203
251
  if (!shared)
204
252
  throw new StromError("Strom is not set up yet", { hint: "strom setup" });
@@ -208,7 +256,7 @@ export function findConnector(shared, name) {
208
256
  return hit;
209
257
  const bad = broken.find((b) => b.name === name);
210
258
  if (bad)
211
- throw new UsageError(`connector ${name} cannot run: ${bad.problem}`, { hint: `fix it in ${bad.dir} — the contract is ${path.join(connectorsDir(shared), "README.md")}` });
259
+ throw new UsageError(`connector ${name} cannot run: ${bad.problem}`, { hint: bad.hint ?? `fix it in ${bad.dir} — the contract is ${path.join(connectorsDir(shared), "README.md")}` });
212
260
  const base = connectorsDir(shared);
213
261
  if (/[\\/]/.test(name) || fs.existsSync(path.join(name, MANIFEST)))
214
262
  throw new UsageError(`a connector runs from the plugins folder, by its name — not from ${name}`, { hint: `copy the folder into ${base} (or: strom connector add ${name}), then use its folder's name` });
@@ -345,14 +393,14 @@ export function readyConnectors(env, shared, url) {
345
393
  });
346
394
  }
347
395
  /** The warning the user reads before allowing automated access to an archive. */
348
- export function hostWarning(host, c) {
396
+ export function hostWarning(host, c, pace = paceOf(c?.manifest.policy.pace)) {
349
397
  const p = c?.manifest.policy;
350
398
  return [
351
399
  `Automated access to ${host}${c ? ` (${c.manifest.title}, connector ${c.name})` : ""}`,
352
400
  " · Archives run small servers: going too fast slows the archive for everyone and gets your IP blocked.",
353
401
  ` · Terms of use: ${p?.terms || "not found yet — read them on the portal before you allow this"}${p?.termsSummary ? `\n ${p.termsSummary}` : ""}`,
354
402
  p ? ` · The connector's reading of them: automation ${p.automation}${p.officialExport ? `; official export: ${p.officialExport}` : ""}` : undefined,
355
- " · strom sends one request at a time, at least 2 s apart, at most 400 an hour; it stops at the first",
403
+ ` · strom sends one request at a time, ${paceText(pace)}; it stops at the first`,
356
404
  " refusal (401/403) and leaves the archive alone for a day, and for an hour when it asks twice to",
357
405
  " slow down or stops answering.",
358
406
  " · You are responsible for how you use the archive. Prefer its official export where there is one;",
@@ -582,7 +630,7 @@ export async function runConnector(c, request, opts) {
582
630
  report.stopped ??= why;
583
631
  child.kill();
584
632
  };
585
- send({ interface: INTERFACE, ...request, ...(login ? { login: true } : {}) });
633
+ send({ interface: c.manifest.interface, ...request, ...(login ? { login: true } : {}) });
586
634
  const lines = readline.createInterface({ input: child.stdout });
587
635
  let queue = Promise.resolve();
588
636
  lines.on("line", (line) => {