strom-research 1.5.0 → 1.6.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.
@@ -7,7 +7,7 @@
7
7
  import crypto from "node:crypto";
8
8
  import fs from "node:fs";
9
9
  import path from "node:path";
10
- import { inputPath, sharperPart } from "./media.js";
10
+ import { clipText, inputPath, sharperPart } from "./media.js";
11
11
  import { kinship } from "./kin.js";
12
12
  import { Settings } from "./config.js";
13
13
  import { crop, resize, toGrey } from "../image/image.js";
@@ -21,6 +21,11 @@ export const LEVELS = [
21
21
  ];
22
22
  /** A margin round the clip, of its longer side: the edge of the entry stays in. */
23
23
  const MARGIN = 0.03;
24
+ /** A clip's mark: from where it is (its image and region) — a clip made again is another excerpt; "input": a document the user gave. */
25
+ export function clipMark(c) {
26
+ return `c${crypto.createHash("sha256").update(clipText(c)).digest("hex").slice(0, 10)}`;
27
+ }
28
+ export const INPUT_MARK = "input";
24
29
  /** A clip with the margin an excerpt keeps round it. */
25
30
  export function withMargin(r) {
26
31
  const pad = MARGIN * Math.max(r.w, r.h);
@@ -60,7 +65,7 @@ export function renderClip(tree, shared, clip, all = tree.list("media"), level =
60
65
  const jpeg = fs.readFileSync(cache);
61
66
  const size = imageSize(jpeg);
62
67
  if (size)
63
- return { media: m.id, from: src.id, jpeg, ...size, url };
68
+ return { media: m.id, from: src.id, jpeg, ...size, url, clip: clipMark(clip) };
64
69
  }
65
70
  if (cachedOnly)
66
71
  return LATER;
@@ -69,7 +74,7 @@ export function renderClip(tree, shared, clip, all = tree.list("media"), level =
69
74
  const out = finish(crop(img, px.x, px.y, Math.max(1, Math.min(px.w, img.width - px.x)), Math.max(1, Math.min(px.h, img.height - px.y))), level);
70
75
  fs.mkdirSync(path.dirname(cache), { recursive: true });
71
76
  fs.writeFileSync(cache, out.jpeg);
72
- return { media: m.id, from: src.id, ...out, url };
77
+ return { media: m.id, from: src.id, ...out, url, clip: clipMark(clip) };
73
78
  }
74
79
  /** The image of a document the user gave (an input), when it is one: JPEG or PNG on this computer. */
75
80
  function inputImage(tree, id) {
@@ -90,14 +95,14 @@ export function renderInput(tree, id, level = LEVELS[1], cachedOnly = false) {
90
95
  const jpeg = fs.readFileSync(cache);
91
96
  const size = imageSize(jpeg);
92
97
  if (size)
93
- return { media: found.input.id, from: found.input.id, jpeg, ...size };
98
+ return { media: found.input.id, from: found.input.id, jpeg, ...size, clip: INPUT_MARK };
94
99
  }
95
100
  if (cachedOnly)
96
101
  return LATER;
97
102
  const out = finish(decodeImage(fs.readFileSync(found.file)), level);
98
103
  fs.mkdirSync(path.dirname(cache), { recursive: true });
99
104
  fs.writeFileSync(cache, out.jpeg);
100
- return { media: found.input.id, from: found.input.id, ...out };
105
+ return { media: found.input.id, from: found.input.id, ...out, clip: INPUT_MARK };
101
106
  }
102
107
  /** The same excerpt at a lower level, made from the excerpt itself (quick: no scan is opened again). */
103
108
  function lower(e, level) {
@@ -217,3 +222,46 @@ export function planExcerpts(tree, shared, opts) {
217
222
  export function dataUrl(e) {
218
223
  return `data:image/jpeg;base64,${Buffer.from(e.jpeg).toString("base64")}`;
219
224
  }
225
+ /** The margin round an excerpt opened in full: the lines round the entry stay in view. */
226
+ const FULL_MARGIN = 0.1;
227
+ /**
228
+ * One excerpt of a source (by its mark) as sharp as the research has it — the region with more round it, from the
229
+ * sharpest image of that place, not made smaller (a strom-research://excerpt link from the Strom app, which keeps
230
+ * them small). A document the user gave: the file itself. Made once, in .strom/excerpts.
231
+ */
232
+ export function fullExcerpt(tree, shared, s, mark) {
233
+ if (mark === INPUT_MARK) {
234
+ const found = !s.clips?.length ? inputImage(tree, s.input) : undefined;
235
+ return found ? { file: found.file } : { missing: "clip" };
236
+ }
237
+ const clip = s.clips?.find((c) => clipMark(c) === mark);
238
+ if (!clip)
239
+ return { missing: "clip" };
240
+ const all = tree.list("media");
241
+ const m = all.find((x) => x.id === clip.media);
242
+ if (!m)
243
+ return { missing: "clip" };
244
+ const pad = FULL_MARGIN * Math.max(clip.region.w, clip.region.h);
245
+ const f = (n) => Math.min(1, Math.max(0, n));
246
+ const x = f(clip.region.x - pad);
247
+ const y = f(clip.region.y - pad);
248
+ const wanted = { x, y, w: f(clip.region.x + clip.region.w + pad) - x, h: f(clip.region.y + clip.region.h + pad) - y };
249
+ const sharper = sharperPart(all, m, wanted);
250
+ const src = sharper?.part ?? m;
251
+ const region = sharper?.crop ?? wanted;
252
+ const file = path.join(shared, src.file);
253
+ if (!fs.existsSync(file)) {
254
+ const url = m.url ?? src.url;
255
+ return url ? { url } : { missing: "scan" };
256
+ }
257
+ const key = crypto.createHash("sha256").update(JSON.stringify({ sha: src.sha, region, full: 1 })).digest("hex").slice(0, 16);
258
+ const cache = path.join(tree.root, ".strom", "excerpts", `full-${s.id}-${src.id}-${key}.jpg`);
259
+ if (fs.existsSync(cache))
260
+ return { file: cache };
261
+ const img = decodeImage(fs.readFileSync(file));
262
+ const px = { x: Math.round(region.x * img.width), y: Math.round(region.y * img.height), w: Math.round(region.w * img.width), h: Math.round(region.h * img.height) };
263
+ const cut = crop(img, px.x, px.y, Math.max(1, Math.min(px.w, img.width - px.x)), Math.max(1, Math.min(px.h, img.height - px.y)));
264
+ fs.mkdirSync(path.dirname(cache), { recursive: true });
265
+ fs.writeFileSync(cache, encodeImage(cut, "jpeg", 92));
266
+ return { file: cache };
267
+ }
@@ -0,0 +1,357 @@
1
+ // Links from the Strom app: strom-research://send?tree=… and
2
+ // strom-research://excerpt?tree=…&source=…&clip=… (the app's spec
3
+ // docs/ZADANI_VYZKUM_ODKAZY_Z_APLIKACE.md in the Strom repository). A web page
4
+ // may not start a program on the computer — only through a scheme of links the
5
+ // program registered with the system, and the browser asks first. strom
6
+ // registers it for this user alone, no admin rights, on the user's yes (the
7
+ // wizard, strom doctor --fix, strom link on):
8
+ //
9
+ // macOS a small AppleScript applet ~/Applications/Strom Research.app —
10
+ // the system hands a link over as an Apple Event, never as an argument
11
+ // Windows HKCU\Software\Classes\strom-research, straight to strom's Node
12
+ // (never through cmd, which would read the link again: %, &, ")
13
+ // Linux a .desktop entry, the default for x-scheme-handler/strom-research
14
+ //
15
+ // Any page can open such a link, so what it carries is never trusted: strom
16
+ // checks each part's shape, writes nothing without the user's word in the
17
+ // terminal, and an unknown action, research or excerpt is only said.
18
+ // The app learns that links work from strom itself (the bridge's /status,
19
+ // the GEDCOM it serves): only while the scheme here leads to this strom.
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import { spawnSync } from "node:child_process";
23
+ import { userHome } from "./paths.js";
24
+ import { stromLauncher } from "./self.js";
25
+ import { ICON } from "./shortcut.js";
26
+ export const LINK_SCHEME = "strom-research";
27
+ /**
28
+ * What the links strom understands do: send the edited tree back, open an excerpt in full, send the research
29
+ * as it is now into the app's window, open the research's menu, a conversation with the agent (of one person),
30
+ * a task (answered, put aside, given up, back in the queue), a person's review, a new direction from a person —
31
+ * and (the app's second wave) a new research with a tree of the app, strom's update, the agent's sessions and
32
+ * what they cost, a conflict decided (by the user or the agent), a story approved, a sync taken back, the setup.
33
+ */
34
+ export const LINK_ACTIONS = ["send", "excerpt", "app", "open", "chat", "task", "review", "research", "new", "update", "sessions", "conflict", "story", "sync-undo", "setup"];
35
+ /** A person's review: the person, with the family, with the ancestors (strom review --scope). */
36
+ export const LINK_SCOPES = ["person", "family", "line"];
37
+ /** A new direction from a person (strom research new --direction). */
38
+ export const LINK_DIRECTIONS = ["ancestors", "descendants"];
39
+ /** What a link does with a task: put it aside, give it up, back into the queue (none: answer it). */
40
+ export const LINK_TASK_DOS = ["park", "drop", "wake"];
41
+ /** What a link does with a conflict: the user decides it, or leaves it to the agent. */
42
+ export const LINK_CONFLICT_DOS = ["decide", "agent"];
43
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
44
+ const SOURCE = /^S\d{1,9}$/;
45
+ const PERSON = /^P\d{1,9}$/;
46
+ const TASK = /^T\d{1,9}$/;
47
+ const CONFLICT = /^X\d{1,9}$/;
48
+ const INPUT = /^I\d{1,9}$/;
49
+ /** The app's own mark of a tree it hands over (32 random bytes, base64url). */
50
+ export const APP_TOKEN = /^[A-Za-z0-9_-]{22,43}$/;
51
+ /** An excerpt's mark (_STROM_CLIP): letters, digits and "-", at most 32. */
52
+ export const CLIP_MARK = /^[A-Za-z0-9-]{1,32}$/;
53
+ /** Why a link was not taken: said to the user, never acted on. */
54
+ export class LinkError extends Error {
55
+ }
56
+ /** One of fixed values, the default when none is given; anything else is a LinkError. */
57
+ function oneOf(q, name, values, fallback) {
58
+ const v = q.get(name) ?? fallback;
59
+ if (v === undefined || !values.includes(v))
60
+ throw new LinkError(`unknown ${name} "${(v ?? "").slice(0, 32)}"`);
61
+ return v;
62
+ }
63
+ /** An ID of a kind (P…, T…): upper-cased, checked; none when not given and not needed. */
64
+ function idOf(q, name, shape, needed) {
65
+ if (!q.has(name) && !needed)
66
+ return undefined;
67
+ const v = (q.get(name) ?? "").toUpperCase();
68
+ if (!shape.test(v))
69
+ throw new LinkError(`no ${name} named`);
70
+ return v;
71
+ }
72
+ /** A link, checked part by part; anything else is a LinkError. No link at all (the applet opened by itself): the menu. */
73
+ export function parseLink(text) {
74
+ if (!text?.trim())
75
+ return { action: "menu" };
76
+ const raw = text.trim();
77
+ if (raw.length > 2048)
78
+ throw new LinkError("too long");
79
+ let url;
80
+ try {
81
+ url = new URL(raw);
82
+ }
83
+ catch {
84
+ throw new LinkError("not a link");
85
+ }
86
+ if (url.protocol !== `${LINK_SCHEME}:`)
87
+ throw new LinkError(`not a ${LINK_SCHEME} link`);
88
+ // strom-research://send?… — and the forms systems make of it: …//send/?…, strom-research:send?…
89
+ const action = (url.host || url.pathname).replace(/^\/+|\/+$/g, "").toLowerCase();
90
+ const q = url.searchParams;
91
+ // the one link without a research: a tree of the app becomes one
92
+ if (action === "new") {
93
+ const app = q.get("app") ?? "";
94
+ if (!APP_TOKEN.test(app))
95
+ throw new LinkError("no tree of the app named");
96
+ return { action, app };
97
+ }
98
+ const tree = q.get("tree") ?? "";
99
+ if (!UUID.test(tree))
100
+ throw new LinkError("no research named");
101
+ const id = tree.toLowerCase();
102
+ const person = idOf(q, "person", PERSON, false);
103
+ switch (action) {
104
+ case "send":
105
+ case "app":
106
+ case "open":
107
+ case "update":
108
+ case "sessions":
109
+ case "setup":
110
+ return { action, tree: id };
111
+ case "excerpt": {
112
+ const source = (q.get("source") ?? "").toUpperCase();
113
+ const clip = q.get("clip") ?? "";
114
+ if (!SOURCE.test(source) || !CLIP_MARK.test(clip))
115
+ throw new LinkError("no excerpt named");
116
+ return { action, tree: id, source, clip };
117
+ }
118
+ case "chat":
119
+ return { action, tree: id, ...(person ? { person } : {}) };
120
+ case "task": {
121
+ const task = idOf(q, "task", TASK, true);
122
+ return { action, tree: id, task, ...(q.has("do") ? { do: oneOf(q, "do", LINK_TASK_DOS) } : {}) };
123
+ }
124
+ case "review":
125
+ case "research":
126
+ case "story": {
127
+ if (!person)
128
+ throw new LinkError("no person named");
129
+ if (action === "review")
130
+ return { action, tree: id, person, scope: oneOf(q, "scope", LINK_SCOPES, "person") };
131
+ if (action === "research")
132
+ return { action, tree: id, person, direction: oneOf(q, "direction", LINK_DIRECTIONS, "ancestors") };
133
+ return { action, tree: id, person, do: oneOf(q, "do", ["final"]) };
134
+ }
135
+ case "conflict":
136
+ return { action, tree: id, id: idOf(q, "id", CONFLICT, true), do: oneOf(q, "do", LINK_CONFLICT_DOS, "decide") };
137
+ case "sync-undo":
138
+ return { action, tree: id, intake: idOf(q, "intake", INPUT, true) };
139
+ }
140
+ throw new LinkError(`unknown action "${action.slice(0, 32)}"`);
141
+ }
142
+ /** A link written again from what was checked in it: only its known parts, in one form (handed to a new terminal). */
143
+ export function linkText(link) {
144
+ const { action, ...parts } = link;
145
+ const q = new URLSearchParams(Object.entries(parts).filter((e) => typeof e[1] === "string"));
146
+ return `${LINK_SCHEME}://${action}?${q}`;
147
+ }
148
+ /**
149
+ * What the handler runs: this strom's Node and script, "link open", the link. A strom of its own settings folder
150
+ * (STROM_CONFIG_DIR) gives its links that folder too (macOS, Linux: through env) — the same researches answer them.
151
+ */
152
+ function handlerArgv(env = {}, platform = process.platform) {
153
+ const { command, args } = stromLauncher();
154
+ const own = env.STROM_CONFIG_DIR && platform !== "win32" ? ["/usr/bin/env", `STROM_CONFIG_DIR=${env.STROM_CONFIG_DIR}`] : [];
155
+ return [...own, command, ...args, "link", "open"];
156
+ }
157
+ /** macOS: the applet strom makes. */
158
+ export function macApp(env) {
159
+ return path.join(userHome(env), "Applications", "Strom Research.app");
160
+ }
161
+ /** Linux: the entry that takes the links (the shortcut's own is strom-research.desktop). */
162
+ export const LINUX_ENTRY = "strom-research-link.desktop";
163
+ function linuxEntry(env) {
164
+ return path.join(env.XDG_DATA_HOME ?? path.join(userHome(env), ".local", "share"), "applications", LINUX_ENTRY);
165
+ }
166
+ const WIN_KEY = `HKCU\\Software\\Classes\\${LINK_SCHEME}`;
167
+ /** AppleScript string literal. */
168
+ function appleString(s) {
169
+ return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
170
+ }
171
+ /** A POSIX shell word. */
172
+ function shellWord(s) {
173
+ return `'${s.replace(/'/g, `'\\''`)}'`;
174
+ }
175
+ /**
176
+ * The applet's script: a link comes as an Apple Event (open location); opened by itself, the menu. strom runs in
177
+ * the background, so the applet ends at once; what strom has to say it says in a window or a terminal of its own.
178
+ */
179
+ export function macScript(argv) {
180
+ const run = argv.map(shellWord).join(" ");
181
+ return [
182
+ "on open location theURL",
183
+ `\tdo shell script ${appleString(`${run} `)} & quoted form of theURL & ${appleString(" >/dev/null 2>&1 &")}`,
184
+ "end open location",
185
+ "on run",
186
+ `\tdo shell script ${appleString(`${run} >/dev/null 2>&1 &`)}`,
187
+ "end run",
188
+ "",
189
+ ].join("\n");
190
+ }
191
+ /** Windows: the command in the registry — strom's Node itself, the link one argument. */
192
+ export function windowsCommand(argv = handlerArgv({}, "win32")) {
193
+ return [...argv, "%1"].map((a) => `"${a}"`).join(" ");
194
+ }
195
+ /** A word of a .desktop Exec line, quoted as the spec says; "%" doubled. */
196
+ function desktopWord(s) {
197
+ const quoted = /[\s"'\\$`<>~|&;*?#()]/.test(s) ? `"${s.replace(/([\\"`$])/g, "\\$1")}"` : s;
198
+ // …and a .desktop value escapes its backslashes once more
199
+ return quoted.replace(/\\/g, "\\\\").replace(/%/g, "%%");
200
+ }
201
+ export function linuxDesktopEntry(argv) {
202
+ return [
203
+ "[Desktop Entry]",
204
+ "Type=Application",
205
+ "Name=Strom Research",
206
+ "NoDisplay=true",
207
+ `Exec=${argv.map(desktopWord).join(" ")} %u`,
208
+ `Icon=${ICON.png}`,
209
+ "Terminal=false",
210
+ `MimeType=x-scheme-handler/${LINK_SCHEME};`,
211
+ "",
212
+ ].join("\n");
213
+ }
214
+ const sys = (cmd, args) => {
215
+ const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 30_000, windowsHide: true });
216
+ return { status: r.error ? null : r.status, stdout: r.stdout ?? "" };
217
+ };
218
+ const LSREGISTER = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
219
+ /** The app the system opens a strom-research link with (macOS), or "". */
220
+ function macHandlerPath(run) {
221
+ const js = `ObjC.import("AppKit");var u=$.NSWorkspace.sharedWorkspace.URLForApplicationToOpenURL($.NSURL.URLWithString("${LINK_SCHEME}://status"));u.isNil()?"":u.path.js`;
222
+ const r = run("osascript", ["-l", "JavaScript", "-e", js]);
223
+ return r.status === 0 ? r.stdout.trim() : "";
224
+ }
225
+ /** The mark inside strom's applet: what it runs (another strom's applet, or one of an older place, is not ours). */
226
+ function macMark(app) {
227
+ return path.join(app, "Contents", "Resources", "strom-link.json");
228
+ }
229
+ /** Does the scheme lead to this strom? No system is asked from a test (STROM_NO_INSTALL). */
230
+ export function linkHandlerState(env, platform = process.platform, run = sys) {
231
+ if (env.STROM_NO_INSTALL === "1" && run === sys)
232
+ return "none";
233
+ const argv = handlerArgv(env, platform);
234
+ if (platform === "darwin") {
235
+ const at = macHandlerPath(run);
236
+ if (!at)
237
+ return "none";
238
+ const app = macApp(env);
239
+ if (path.resolve(at) !== path.resolve(app))
240
+ return "other";
241
+ try {
242
+ return JSON.stringify(JSON.parse(fs.readFileSync(macMark(app), "utf8")).argv) === JSON.stringify(argv) ? "ours" : "other";
243
+ }
244
+ catch {
245
+ return "other";
246
+ }
247
+ }
248
+ if (platform === "win32") {
249
+ const r = run("reg.exe", ["query", `${WIN_KEY}\\shell\\open\\command`, "/ve"]);
250
+ if (r.status !== 0)
251
+ return "none";
252
+ const value = /REG_(?:EXPAND_)?SZ\s+(.*)$/m.exec(r.stdout)?.[1]?.trim();
253
+ return value === windowsCommand(argv) ? "ours" : value ? "other" : "none";
254
+ }
255
+ const r = run("xdg-mime", ["query", "default", `x-scheme-handler/${LINK_SCHEME}`]);
256
+ const entry = r.status === 0 ? r.stdout.trim() : "";
257
+ if (!entry)
258
+ return "none";
259
+ if (entry !== LINUX_ENTRY)
260
+ return "other";
261
+ try {
262
+ return fs.readFileSync(linuxEntry(env), "utf8") === linuxDesktopEntry(argv) ? "ours" : "other";
263
+ }
264
+ catch {
265
+ return "other";
266
+ }
267
+ }
268
+ /** The actions a link can do here: all of them while the scheme leads to this strom, else none. */
269
+ export function linkActions(state) {
270
+ return state === "ours" ? [...LINK_ACTIONS] : [];
271
+ }
272
+ /** Register the scheme for this user (or register it again: this strom moved, was updated); true when it leads to this strom now. */
273
+ export function registerLinks(env, platform = process.platform, run = sys) {
274
+ if (env.STROM_NO_INSTALL === "1" && run === sys)
275
+ return false;
276
+ const argv = handlerArgv(env, platform);
277
+ if (platform === "darwin") {
278
+ const app = macApp(env);
279
+ fs.mkdirSync(path.dirname(app), { recursive: true });
280
+ fs.rmSync(app, { recursive: true, force: true });
281
+ const script = path.join(path.dirname(app), ".strom-link.applescript");
282
+ fs.writeFileSync(script, macScript(argv));
283
+ const made = run("osacompile", ["-o", app, script]);
284
+ fs.rmSync(script, { force: true });
285
+ if (made.status !== 0)
286
+ return false;
287
+ const plist = path.join(app, "Contents", "Info.plist");
288
+ run("plutil", ["-replace", "CFBundleIdentifier", "-string", "info.stromapp.research.link", plist]);
289
+ run("plutil", ["-replace", "CFBundleName", "-string", "Strom Research", plist]);
290
+ run("plutil", ["-replace", "LSUIElement", "-bool", "true", plist]);
291
+ run("plutil", ["-replace", "CFBundleURLTypes", "-json", JSON.stringify([{ CFBundleURLName: "Strom Research", CFBundleURLSchemes: [LINK_SCHEME] }]), plist]);
292
+ // its icon: Strom Research's own
293
+ if (fs.existsSync(ICON.png))
294
+ run("sips", ["-s", "format", "icns", ICON.png, "--out", path.join(app, "Contents", "Resources", "applet.icns")]);
295
+ fs.writeFileSync(macMark(app), JSON.stringify({ argv }, null, 2));
296
+ // the applet's seal covers its Info.plist: sealed again, by itself (no one's certificate)
297
+ run("codesign", ["--force", "--sign", "-", app]);
298
+ run(LSREGISTER, ["-f", app]);
299
+ return linkHandlerState(env, platform, run) === "ours";
300
+ }
301
+ if (platform === "win32") {
302
+ const add = (key, args) => run("reg.exe", ["add", key, ...args, "/f"]).status === 0;
303
+ const ok = add(WIN_KEY, ["/ve", "/d", "URL:Strom Research"]) &&
304
+ add(WIN_KEY, ["/v", "URL Protocol", "/d", ""]) &&
305
+ add(`${WIN_KEY}\\DefaultIcon`, ["/ve", "/d", ICON.ico]) &&
306
+ add(`${WIN_KEY}\\shell\\open\\command`, ["/ve", "/d", windowsCommand(argv)]);
307
+ return ok && linkHandlerState(env, platform, run) === "ours";
308
+ }
309
+ const file = linuxEntry(env);
310
+ fs.mkdirSync(path.dirname(file), { recursive: true });
311
+ fs.writeFileSync(file, linuxDesktopEntry(argv));
312
+ run("xdg-mime", ["default", LINUX_ENTRY, `x-scheme-handler/${LINK_SCHEME}`]);
313
+ run("update-desktop-database", [path.dirname(file)]);
314
+ return linkHandlerState(env, platform, run) === "ours";
315
+ }
316
+ /** What strom put there for the links (for strom uninstall): the applet, the registry key or the entry. */
317
+ export function linkFiles(env, platform = process.platform, run = sys) {
318
+ if (platform === "darwin")
319
+ return fs.existsSync(macApp(env)) && fs.existsSync(macMark(macApp(env))) ? [macApp(env)] : [];
320
+ if (platform === "win32") {
321
+ if (env.STROM_NO_INSTALL === "1" && run === sys)
322
+ return [];
323
+ const r = run("reg.exe", ["query", `${WIN_KEY}\\shell\\open\\command`, "/ve"]);
324
+ return r.status === 0 && /link"? "?open/.test(r.stdout) ? [WIN_KEY] : [];
325
+ }
326
+ return fs.existsSync(linuxEntry(env)) ? [linuxEntry(env)] : [];
327
+ }
328
+ /** Take the scheme off again (strom link off, strom uninstall); true when nothing of strom's is left for it. */
329
+ export function unregisterLinks(env, platform = process.platform, run = sys) {
330
+ if (platform === "darwin") {
331
+ const app = macApp(env);
332
+ if (fs.existsSync(macMark(app))) {
333
+ run(LSREGISTER, ["-u", app]);
334
+ fs.rmSync(app, { recursive: true, force: true });
335
+ }
336
+ return !fs.existsSync(app) || !fs.existsSync(macMark(app));
337
+ }
338
+ if (platform === "win32") {
339
+ if (!linkFiles(env, platform, run).length)
340
+ return true;
341
+ return run("reg.exe", ["delete", WIN_KEY, "/f"]).status === 0;
342
+ }
343
+ const file = linuxEntry(env);
344
+ fs.rmSync(file, { force: true });
345
+ // the default the entry was given goes with it
346
+ const list = path.join(env.XDG_CONFIG_HOME ?? path.join(userHome(env), ".config"), "mimeapps.list");
347
+ try {
348
+ const text = fs.readFileSync(list, "utf8");
349
+ const kept = text.split("\n").filter((l) => !(l.startsWith(`x-scheme-handler/${LINK_SCHEME}=`) && l.includes(LINUX_ENTRY)));
350
+ if (kept.length !== text.split("\n").length)
351
+ fs.writeFileSync(list, kept.join("\n"));
352
+ }
353
+ catch {
354
+ // no list
355
+ }
356
+ return true;
357
+ }
package/dist/core/live.js CHANGED
@@ -25,14 +25,19 @@ import { spawn, spawnSync } from "node:child_process";
25
25
  import { Tree, VERSION } from "./tree.js";
26
26
  import { exportGedcom } from "../gedcom/export.js";
27
27
  import { excerptSettings, planExcerpts } from "./excerpt.js";
28
- import { noteNothingSent, receiveTree, SYNC_MAX_BYTES } from "./sync.js";
28
+ import { noteAdoptFailed, noteNothingSent, pendingAdoption, receiveAdopted, receiveTree, SYNC_MAX_BYTES } from "./sync.js";
29
29
  import { ui } from "../cli/ui.js";
30
30
  /** New images for the app, made for at most so long when the tree changed (the rest the next time). */
31
31
  const LIVE_IMAGES_MS = 20_000;
32
32
  import { liveWorkers } from "./workers.js";
33
- import { openSessions } from "./session.js";
33
+ import { monthSpend, openSessions } from "./session.js";
34
+ import { rankTasks } from "./queue.js";
35
+ import { humanTask } from "../cli/human.js";
36
+ import { knownNewerVersion } from "./update.js";
34
37
  import { gitProgram } from "./git.js";
35
- import { isStromAppOrigin } from "./stromapp.js";
38
+ import { appOpensLinks, isStromAppOrigin } from "./stromapp.js";
39
+ import { Settings } from "./config.js";
40
+ import { linkActions, linkHandlerState } from "./links.js";
36
41
  import { stromLauncher } from "./self.js";
37
42
  /** Where a running bridge of a tree is noted. */
38
43
  export function liveFile(root) {
@@ -125,10 +130,43 @@ function working(root, tree) {
125
130
  return { who: w.label, since: w.since, ...(s ? { session: s.id, ...(s.task ? { task: task ? `${task.id} ${task.what}` : s.task } : {}) } : {}) };
126
131
  });
127
132
  }
133
+ /** How long what the system says of the links is taken as true (asking it costs a program started). */
134
+ const LINKS_FRESH_MS = 60_000;
135
+ let linksSeen;
136
+ /** The strom-research:// links this computer takes: said to the app only while the scheme leads to this strom. */
137
+ function links(env) {
138
+ if (!linksSeen || Date.now() - linksSeen.at > LINKS_FRESH_MS)
139
+ linksSeen = { at: Date.now(), actions: linkActions(linkHandlerState(env)) };
140
+ return linksSeen.actions;
141
+ }
142
+ /** At most so many tasks of the queue the app is told (the rest counted): what comes next, then what was put aside. */
143
+ const QUEUE_NEXT = 14;
144
+ const QUEUE_PARKED = 6;
145
+ /** The agent's queue in its order, for the user (records by their names), and how many more there are. */
146
+ function queue(tree, settings) {
147
+ const lang = tree.lang;
148
+ const next = rankTasks(tree, tree.list("task"), settings.strategy(tree.config)).map((r) => r.task);
149
+ const parked = tree.list("task").filter((t) => t.state === "parked");
150
+ const item = (state) => (t) => ({ id: t.id, text: humanTask(tree, t.what, lang), state });
151
+ const shown = [...next.slice(0, QUEUE_NEXT).map(item("next")), ...parked.slice(0, QUEUE_PARKED).map(item("parked"))];
152
+ return { queue: shown, queueMore: next.length + parked.length - shown.length };
153
+ }
154
+ /** The last tree the Strom app sent (through the bridge) that the research took and has not taken back. */
155
+ function lastIntake(tree) {
156
+ const last = tree
157
+ .list("input")
158
+ .filter((i) => i.sync && !i.sync.undone && i.sync.applied.length && i.name.startsWith("strom-app-"))
159
+ .sort((a, b) => a.created.localeCompare(b.created))
160
+ .pop();
161
+ return last ? { id: last.id, at: last.created } : undefined;
162
+ }
128
163
  /** What the app shows beside the tree. */
129
164
  function status(root, env) {
130
165
  const tree = Tree.open(root, env);
166
+ const settings = new Settings(env, {});
131
167
  const waiting = tree.list("task").filter((t) => t.state === "waiting");
168
+ const newer = knownNewerVersion(settings, env);
169
+ const intake = lastIntake(tree);
132
170
  return {
133
171
  strom: VERSION,
134
172
  tree: { id: tree.config.id, name: tree.config.name, lang: tree.lang },
@@ -138,7 +176,13 @@ function status(root, env) {
138
176
  researches: tree.list("research").map((r) => ({ id: r.id, name: r.name, state: r.state })),
139
177
  working: working(root, tree),
140
178
  open: openSessions(tree).map((s) => ({ id: s.id, task: s.task, started: s.started })),
141
- waiting: waiting.map((t) => ({ id: t.id, what: t.what, on: t.waitingOn ?? "" })),
179
+ // since when it waits: its last change is the one that made it wait
180
+ waiting: waiting.map((t) => ({ id: t.id, what: t.what, on: t.waitingOn ?? "", at: t.updated })),
181
+ links: links(env),
182
+ ...queue(tree, settings),
183
+ ...(newer ? { update: { version: newer } } : {}),
184
+ spend: { ...monthSpend(tree, new Date().toISOString().slice(0, 7)), currency: "USD" },
185
+ ...(intake ? { lastIntake: intake } : {}),
142
186
  };
143
187
  }
144
188
  /** Run the bridge of the tree here, until idle or stopped. */
@@ -175,22 +219,40 @@ export function serveLive(root, env) {
175
219
  nothing(req, res, origin);
176
220
  return;
177
221
  }
222
+ if (req.method === "POST" && t === token && what === "adopt") {
223
+ receive(req, res, origin, "adopt");
224
+ return;
225
+ }
178
226
  if (req.method !== "GET" || t !== token) {
179
227
  res.writeHead(404).end();
180
228
  return;
181
229
  }
182
230
  try {
183
- if (what === "status") {
231
+ if (what === "adopt") {
232
+ // the new research started from the app's link: which tree of the app it waits for (its mark), its name
233
+ const mark = pendingAdoption(root);
234
+ const tree = Tree.open(root, env);
235
+ const body = mark ? { token: mark, name: tree.config.name, tree: tree.config.id } : { error: "this research waits for no tree" };
236
+ res.writeHead(mark ? 200 : 404, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }).end(JSON.stringify(body));
237
+ }
238
+ else if (what === "status") {
184
239
  res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }).end(JSON.stringify(status(root, env)));
185
240
  }
186
241
  else if (what === "tree.ged" || what === "images.ged") {
187
242
  const h = head(root);
188
- if (!ged || ged.head !== h) {
243
+ if (!ged || ged.head !== h || ged.links !== (appOpensLinks(new Settings(env, {})) ? links(env).join(" ") : "")) {
189
244
  const tree = Tree.open(root, env);
190
245
  // the images from the cache; new ones made for a while at most (the rest the next time the tree changes)
191
246
  const set = excerptSettings(tree);
192
247
  const images = set ? planExcerpts(tree, set.shared, { quality: set.quality, for: set.for, maxBytes: set.mb * 1024 * 1024, budgetMs: LIVE_IMAGES_MS }) : undefined;
193
- ged = { head: h, text: exportGedcom(tree, { for: "strom", ...(h ? { head: h } : {}), ...(images ? { excerpts: images.of } : {}) }).text };
248
+ // an app that opens strom-research:// links: each excerpt's mark, and the links this computer takes
249
+ const opens = appOpensLinks(new Settings(env, {}));
250
+ const offered = opens ? links(env) : [];
251
+ ged = {
252
+ head: h,
253
+ links: offered.join(" "),
254
+ text: exportGedcom(tree, { for: "strom", ...(h ? { head: h } : {}), ...(images ? { excerpts: images.of } : {}), ...(opens ? { clips: true, research: true } : {}), ...(offered.length ? { links: offered } : {}) }).text,
255
+ };
194
256
  }
195
257
  res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", "X-Strom-Head": h }).end(ged.text);
196
258
  }
@@ -208,7 +270,7 @@ export function serveLive(root, env) {
208
270
  }
209
271
  });
210
272
  /** The user's tree from the app: only from its pages, only so large; the answer in the research's language. */
211
- const receive = (req, res, origin) => {
273
+ const receive = (req, res, origin, as = "sync") => {
212
274
  const reply = (code, body) => res.writeHead(code, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }).end(JSON.stringify(body));
213
275
  const lang = () => {
214
276
  try {
@@ -238,11 +300,21 @@ export function serveLive(root, env) {
238
300
  return;
239
301
  }
240
302
  try {
241
- const got = receiveTree(root, env, Buffer.concat(chunks).toString("utf8"));
303
+ const text = Buffer.concat(chunks).toString("utf8");
304
+ if (as === "adopt") {
305
+ // kept for the research in the terminal to take in; the app links its tree to this research
306
+ receiveAdopted(root, text);
307
+ reply(200, { tree: Tree.open(root, env).config.id, head: head(root) ?? "" });
308
+ return;
309
+ }
310
+ const got = receiveTree(root, env, text);
242
311
  reply(200, { ok: true, changes: got.changes, file: path.basename(got.file) });
243
312
  }
244
313
  catch (e) {
245
314
  const m = e.message;
315
+ // the new research in the terminal waits for it: told, it stops waiting
316
+ if (as === "adopt" && pendingAdoption(root))
317
+ noteAdoptFailed(root, /empty|no people/.test(m) ? "empty" : "other");
246
318
  const key = /another research/.test(m) ? "ui.sync.bridge.other" : /does not look like/.test(m) ? "ui.sync.bridge.foreign" : /empty|no people|not a GEDCOM/.test(m) ? "ui.sync.bridge.empty" : undefined;
247
319
  reply(400, { error: key ? ui(lang(), key) : m });
248
320
  }
@@ -98,6 +98,7 @@ export const SCHEMAS = {
98
98
  },
99
99
  conflict: {
100
100
  title: S({ req: true, max: 200 }),
101
+ fact: S({ max: 8 }),
101
102
  subject: RS(ANY_SUBJECT, 1),
102
103
  claims: { t: "array", req: true, min: 2, of: { source: R("source"), value: S({ req: true, max: 500 }), note: S({ max: 500 }) } },
103
104
  state: S({ req: true, enum: ["open", "resolved"] }),
@@ -157,3 +157,9 @@ export function recentSessions(tree, research, n = 2) {
157
157
  .sort((a, b) => (b.ended ?? b.updated).localeCompare(a.ended ?? a.updated))
158
158
  .slice(0, n);
159
159
  }
160
+ /** What the agent's sessions of a month ("2026-09", by when they started) cost: how many, the dollars known (a session stopped before it said: its known part). */
161
+ export function monthSpend(tree, month) {
162
+ const of = tree.list("session").filter((s) => s.started.slice(0, 7) === month);
163
+ const amount = of.reduce((sum, s) => sum + (s.metrics?.costUsd ?? 0), 0);
164
+ return { month, sessions: of.length, amount: Math.round(amount * 100) / 100 };
165
+ }