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.
- package/assets/lang/cs.json +82 -0
- package/assets/lang/de.json +82 -0
- package/assets/method/narrate.md +2 -1
- package/assets/method/recording.md +3 -1
- package/dist/agents/files.js +1 -1
- package/dist/cli/human.js +18 -0
- package/dist/cli/main.js +9 -0
- package/dist/cli/menu-links.js +249 -0
- package/dist/cli/menu-parts.js +2 -2
- package/dist/cli/menu-research.js +35 -16
- package/dist/cli/menu-waiting.js +61 -47
- package/dist/cli/ui.js +82 -0
- package/dist/cli/wizard.js +34 -1
- package/dist/commands/analysis.js +6 -2
- package/dist/commands/browse.js +2 -17
- package/dist/commands/guide.js +6 -1
- package/dist/commands/index.js +1 -0
- package/dist/commands/links.js +367 -0
- package/dist/commands/output.js +7 -1
- package/dist/commands/setup.js +13 -2
- package/dist/commands/start.js +6 -2
- package/dist/commands/story.js +17 -1
- package/dist/commands/sync.js +20 -6
- package/dist/core/actions.js +15 -0
- package/dist/core/dialog.js +25 -0
- package/dist/core/excerpt.js +53 -5
- package/dist/core/links.js +357 -0
- package/dist/core/live.js +81 -9
- package/dist/core/schema.js +1 -0
- package/dist/core/session.js +6 -0
- package/dist/core/stromapp.js +25 -0
- package/dist/core/sync.js +92 -2
- package/dist/core/tree.js +1 -1
- package/dist/core/uninstall.js +3 -0
- package/dist/gedcom/export.js +54 -0
- package/dist/gedcom/validate.js +13 -4
- package/package.json +1 -1
package/dist/core/excerpt.js
CHANGED
|
@@ -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
|
-
|
|
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 === "
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/dist/core/schema.js
CHANGED
|
@@ -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"] }),
|
package/dist/core/session.js
CHANGED
|
@@ -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
|
+
}
|