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
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// strom-research:// links from the Strom app (core/links.ts): strom link open
|
|
2
|
+
// is what the system runs for one; strom link on|off|status registers the
|
|
3
|
+
// scheme for this user, takes it off, says where it leads.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { register } from "../cli/registry.js";
|
|
8
|
+
import { lines } from "../cli/format.js";
|
|
9
|
+
import { ui } from "../cli/ui.js";
|
|
10
|
+
import { readJsonIfExists } from "../core/json.js";
|
|
11
|
+
import { Tree } from "../core/tree.js";
|
|
12
|
+
import { LinkError, linkHandlerState, linkText, parseLink, registerLinks, unregisterLinks } from "../core/links.js";
|
|
13
|
+
import { displayName, lifespan } from "../core/people.js";
|
|
14
|
+
import { PROFILES } from "../agents/profiles.js";
|
|
15
|
+
import { whereToTalk } from "../core/apps.js";
|
|
16
|
+
import { pause } from "../cli/menu-parts.js";
|
|
17
|
+
import { fullExcerpt } from "../core/excerpt.js";
|
|
18
|
+
import { openForUser } from "../core/open.js";
|
|
19
|
+
import { openInNewTerminal } from "../core/shortcut.js";
|
|
20
|
+
import { systemNotice } from "../core/dialog.js";
|
|
21
|
+
import { configDir } from "../core/paths.js";
|
|
22
|
+
/** The researches here with this id — a copy of a research folder has its id too; the current one first. */
|
|
23
|
+
function researchesWithId(ctx, id) {
|
|
24
|
+
const current = ctx.settings.config.currentTree;
|
|
25
|
+
return ctx
|
|
26
|
+
.knownTrees()
|
|
27
|
+
.filter((k) => readJsonIfExists(path.join(k.root, "strom.json"))?.id?.toLowerCase() === id)
|
|
28
|
+
.sort((a, b) => Number(b.root === current) - Number(a.root === current));
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* What a link could not do, said where the person is: the terminal, else a window (a link opens strom with none).
|
|
32
|
+
* The result carries it too.
|
|
33
|
+
*/
|
|
34
|
+
async function say(ctx, key, values = {}, data = {}) {
|
|
35
|
+
const lang = ctx.uiLang();
|
|
36
|
+
const text = ui(lang, key, values);
|
|
37
|
+
if (!ctx.io.tty)
|
|
38
|
+
systemNotice(ui(lang, "ui.dialog.title"), text, ui(lang, "ui.link.ok"), ctx.env);
|
|
39
|
+
return { text, data: { done: false, ...data } };
|
|
40
|
+
}
|
|
41
|
+
/** The person's answer about links, kept: the question when the app opens is not asked again. */
|
|
42
|
+
function keepAnswer(ctx, answer) {
|
|
43
|
+
ctx.settings.reload();
|
|
44
|
+
ctx.settings.config.links = answer;
|
|
45
|
+
ctx.settings.save();
|
|
46
|
+
}
|
|
47
|
+
/** macOS: the terminal program this runs in, by its mark (TERM_PROGRAM). */
|
|
48
|
+
const TERMINALS = { Apple_Terminal: "Terminal", "iTerm.app": "iTerm", WezTerm: "WezTerm", ghostty: "Ghostty" };
|
|
49
|
+
/**
|
|
50
|
+
* The terminal a link opened comes in front of the Strom app when the app has answered (the tree came, or
|
|
51
|
+
* nothing did): the person sees what came of it. macOS; elsewhere the window stays where it is.
|
|
52
|
+
*/
|
|
53
|
+
function bringForward(env) {
|
|
54
|
+
const app = TERMINALS[env.TERM_PROGRAM ?? ""];
|
|
55
|
+
if (process.platform !== "darwin" || !app || env.STROM_NO_OPEN === "1")
|
|
56
|
+
return;
|
|
57
|
+
spawnSync("osascript", ["-e", `tell application "${app}" to activate`], { stdio: "ignore", timeout: 5000 });
|
|
58
|
+
}
|
|
59
|
+
/** strom itself in a research's folder (none: where strom is), as the menu runs it — quiet: its output not shown; said: handed over. */
|
|
60
|
+
function runnerOf(ctx) {
|
|
61
|
+
return async (root) => {
|
|
62
|
+
const { main } = await import("../cli/main.js");
|
|
63
|
+
return (argv, quiet, said) => main(argv, said ? { ...ctx.io, stdout: said } : quiet ? { ...ctx.io, stdout: () => undefined } : ctx.io, ctx.env, root ?? ctx.cwd);
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** A person of the research a link names: the one they were merged into; one taken out is none. */
|
|
67
|
+
function personOf(root, ctx, id) {
|
|
68
|
+
const tree = Tree.open(root, ctx.env);
|
|
69
|
+
let p = tree.get(id);
|
|
70
|
+
if (p?.mergedInto)
|
|
71
|
+
p = tree.get(p.mergedInto);
|
|
72
|
+
return p && !p.retracted ? p : undefined;
|
|
73
|
+
}
|
|
74
|
+
/** A person as the user reads them, with the ID to name them by: "Jan Novák (*1905) [P0012]". */
|
|
75
|
+
function who(p) {
|
|
76
|
+
const span = lifespan(p);
|
|
77
|
+
return `${displayName(p)}${span ? ` (${span})` : ""} [${p.id}]`;
|
|
78
|
+
}
|
|
79
|
+
/** The conversation's first message about one person: only who (from the research's own record), what to ask is the person's. */
|
|
80
|
+
function aboutPerson(lang, p) {
|
|
81
|
+
return ui(lang, "ui.link.chat.say", { person: who(p) });
|
|
82
|
+
}
|
|
83
|
+
/** The conversation's first message about a conflict: its ID and title from the research itself. */
|
|
84
|
+
function aboutConflict(root, ctx, id) {
|
|
85
|
+
const tree = Tree.open(root, ctx.env);
|
|
86
|
+
const c = tree.get(id);
|
|
87
|
+
return c && c.type === "conflict" && c.state === "open" ? ui(tree.lang, "ui.link.conflict.say", { id: c.id, title: c.title }) : undefined;
|
|
88
|
+
}
|
|
89
|
+
/** What a link asks, in this terminal: which copy of the research, then as the menu does it. */
|
|
90
|
+
async function inTerminal(ctx, link) {
|
|
91
|
+
const lang = ctx.uiLang();
|
|
92
|
+
const copies = researchesWithId(ctx, link.tree);
|
|
93
|
+
if (!copies.length)
|
|
94
|
+
return say(ctx, "ui.link.notree", {}, { tree: link.tree });
|
|
95
|
+
let root = copies[0].root;
|
|
96
|
+
if (copies.length > 1) {
|
|
97
|
+
const i = await ctx.choose(ui(lang, link.action === "send" ? "ui.link.which" : "ui.link.which.one"), copies.map((c) => ({ label: `${c.name} (${ctx.display(c.root)})` })), 0, { back: ui(lang, "ui.browse.back") });
|
|
98
|
+
if (i === undefined)
|
|
99
|
+
return { text: "", data: { done: false } };
|
|
100
|
+
root = copies[i].root;
|
|
101
|
+
}
|
|
102
|
+
const run = await runnerOf(ctx)(root);
|
|
103
|
+
const tree = Tree.open(root, ctx.env);
|
|
104
|
+
const treeLang = tree.lang;
|
|
105
|
+
const end = async (done) => {
|
|
106
|
+
await pause(ctx, treeLang, "ui.enter.close");
|
|
107
|
+
return { text: "", data: { done, action: link.action, tree: root } };
|
|
108
|
+
};
|
|
109
|
+
const said = async (key, values = {}) => {
|
|
110
|
+
ctx.io.stdout(ui(treeLang, key, values) + "\n");
|
|
111
|
+
return end(false);
|
|
112
|
+
};
|
|
113
|
+
const agent = PROFILES[ctx.settings.agent(tree.config).value]?.name ?? ctx.settings.agent(tree.config).value;
|
|
114
|
+
switch (link.action) {
|
|
115
|
+
case "send": {
|
|
116
|
+
const { syncFromApp } = await import("../cli/menu-research.js");
|
|
117
|
+
await syncFromApp(ctx, run, treeLang, root, "ui.enter.close", () => bringForward(ctx.env));
|
|
118
|
+
return { text: "", data: { done: true, action: link.action, tree: root } };
|
|
119
|
+
}
|
|
120
|
+
// the research's menu, as `strom` in its folder; the research into the app's window, as strom app; strom's own
|
|
121
|
+
// commands, which ask themselves
|
|
122
|
+
case "open":
|
|
123
|
+
case "app":
|
|
124
|
+
return { text: "", data: { done: (await run(link.action === "open" ? [] : ["app"])) === 0, action: link.action, tree: root } };
|
|
125
|
+
case "update":
|
|
126
|
+
case "setup":
|
|
127
|
+
return end((await run([link.action])) === 0);
|
|
128
|
+
case "sessions": {
|
|
129
|
+
const { sessionsView } = await import("../cli/menu-links.js");
|
|
130
|
+
sessionsView(ctx, treeLang, root);
|
|
131
|
+
return end(true);
|
|
132
|
+
}
|
|
133
|
+
case "task": {
|
|
134
|
+
if (link.do) {
|
|
135
|
+
const { taskDo } = await import("../cli/menu-links.js");
|
|
136
|
+
return end(await taskDo(ctx, run, treeLang, root, link.task, link.do));
|
|
137
|
+
}
|
|
138
|
+
const { waitingTask } = await import("../cli/menu-waiting.js");
|
|
139
|
+
if (!(await waitingTask(ctx, run, treeLang, root, link.task)))
|
|
140
|
+
return said("ui.link.notask");
|
|
141
|
+
return end(true);
|
|
142
|
+
}
|
|
143
|
+
case "conflict": {
|
|
144
|
+
if (link.do === "decide") {
|
|
145
|
+
const { decideConflict } = await import("../cli/menu-links.js");
|
|
146
|
+
return end(await decideConflict(ctx, run, treeLang, root, link.id));
|
|
147
|
+
}
|
|
148
|
+
// left to the agent: it costs — said and asked first, then the conversation about it
|
|
149
|
+
const say = aboutConflict(root, ctx, link.id);
|
|
150
|
+
if (!say)
|
|
151
|
+
return said("ui.link.conflict.none", { id: link.id });
|
|
152
|
+
const { asks } = await import("../cli/menu-links.js");
|
|
153
|
+
if (!(await asks(ctx, treeLang, root, ui(treeLang, "ui.link.what.conflict.agent", { agent, title: tree.get(link.id).title }), ui(treeLang, "ui.link.cost"))))
|
|
154
|
+
return end(false);
|
|
155
|
+
return { text: "", data: { done: (await run(["chat", `--say=${say}`])) === 0, action: link.action, tree: root } };
|
|
156
|
+
}
|
|
157
|
+
case "sync-undo": {
|
|
158
|
+
const { undoSending } = await import("../cli/menu-links.js");
|
|
159
|
+
return end(await undoSending(ctx, run, treeLang, root, link.intake));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const named = "person" in link ? link.person : undefined;
|
|
163
|
+
const person = named ? personOf(root, ctx, named) : undefined;
|
|
164
|
+
if (named && !person)
|
|
165
|
+
return said("ui.link.noperson", { person: named });
|
|
166
|
+
const { asks } = await import("../cli/menu-links.js");
|
|
167
|
+
switch (link.action) {
|
|
168
|
+
case "chat": {
|
|
169
|
+
// the agent costs: said and asked first, then the conversation as strom chat starts it
|
|
170
|
+
const what = person ? ui(treeLang, "ui.link.what.chat.person", { agent, person: who(person) }) : ui(treeLang, "ui.link.what.chat", { agent });
|
|
171
|
+
if (!(await asks(ctx, treeLang, root, what, ui(treeLang, "ui.link.cost"))))
|
|
172
|
+
return { text: "", data: { done: false, action: link.action, tree: root } };
|
|
173
|
+
return { text: "", data: { done: (await run(["chat", ...(person ? [`--say=${aboutPerson(treeLang, person)}`] : [])])) === 0, action: link.action, tree: root } };
|
|
174
|
+
}
|
|
175
|
+
case "story": {
|
|
176
|
+
const { approveStory } = await import("../cli/menu-links.js");
|
|
177
|
+
return end(await approveStory(ctx, run, treeLang, root, person, who(person)));
|
|
178
|
+
}
|
|
179
|
+
case "review": {
|
|
180
|
+
const what = ui(treeLang, `ui.link.what.review.${link.scope}`, { person: who(person) });
|
|
181
|
+
if (await asks(ctx, treeLang, root, what, ui(treeLang, "ui.link.tasks"))) {
|
|
182
|
+
const { reviewOne } = await import("../cli/menu-research.js");
|
|
183
|
+
await reviewOne(ctx, run, treeLang, root, person.id, link.scope);
|
|
184
|
+
}
|
|
185
|
+
return end(true);
|
|
186
|
+
}
|
|
187
|
+
case "research": {
|
|
188
|
+
// the same direction still going: only named, nothing asked
|
|
189
|
+
const same = tree.list("research").find((r) => r.focus === person.id && r.direction === link.direction && r.state === "active");
|
|
190
|
+
if (same)
|
|
191
|
+
return said("ui.research.exists", { name: same.name });
|
|
192
|
+
const what = ui(treeLang, `ui.link.what.research.${link.direction}`, { person: who(person) });
|
|
193
|
+
if (await asks(ctx, treeLang, root, what, ui(treeLang, "ui.link.tasks"))) {
|
|
194
|
+
const { startDirection } = await import("../cli/menu-research.js");
|
|
195
|
+
await startDirection(ctx, run, treeLang, root, link.direction, ["--person", person.id], displayName(person));
|
|
196
|
+
}
|
|
197
|
+
return end(true);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* What needs no terminal: the research as it is now into the app's window (strom app), a conversation in the
|
|
203
|
+
* agent's desktop app (its first message waits there for the person to send it). Undefined: a terminal is needed.
|
|
204
|
+
*/
|
|
205
|
+
async function withoutTerminal(ctx, link, root) {
|
|
206
|
+
const run = await runnerOf(ctx)(root);
|
|
207
|
+
if (link.action === "app")
|
|
208
|
+
return { text: "", data: { done: (await run(["app"], true)) === 0, action: "app", tree: root } };
|
|
209
|
+
if (link.action !== "chat" && !(link.action === "conflict" && link.do === "agent"))
|
|
210
|
+
return undefined;
|
|
211
|
+
const tree = Tree.open(root, ctx.env);
|
|
212
|
+
if (whereToTalk(ctx.settings.agent(tree.config).value, ctx.settings.agentWhere(), ctx.env) !== "app")
|
|
213
|
+
return undefined;
|
|
214
|
+
let first;
|
|
215
|
+
if (link.action === "conflict") {
|
|
216
|
+
first = aboutConflict(root, ctx, link.id);
|
|
217
|
+
if (!first)
|
|
218
|
+
return say(ctx, "ui.link.conflict.none", { id: link.id }, { tree: link.tree });
|
|
219
|
+
}
|
|
220
|
+
else if (link.action === "chat" && link.person) {
|
|
221
|
+
const person = personOf(root, ctx, link.person);
|
|
222
|
+
if (!person)
|
|
223
|
+
return say(ctx, "ui.link.noperson", { person: link.person }, { tree: link.tree });
|
|
224
|
+
first = aboutPerson(tree.lang, person);
|
|
225
|
+
}
|
|
226
|
+
return { text: "", data: { done: (await run(["chat", ...(first ? [`--say=${first}`] : [])], true)) === 0, action: link.action, tree: root } };
|
|
227
|
+
}
|
|
228
|
+
/** An excerpt in full: the first research here that has it. */
|
|
229
|
+
function excerptOf(ctx, link) {
|
|
230
|
+
const shared = ctx.settings.shared()?.value;
|
|
231
|
+
const copies = researchesWithId(ctx, link.tree);
|
|
232
|
+
if (!copies.length || !shared)
|
|
233
|
+
return { missing: "tree" };
|
|
234
|
+
let found = { missing: "clip" };
|
|
235
|
+
for (const c of copies) {
|
|
236
|
+
const s = Tree.open(c.root, ctx.env).get(link.source);
|
|
237
|
+
if (!s || s.retracted)
|
|
238
|
+
continue;
|
|
239
|
+
found = fullExcerpt(Tree.open(c.root, ctx.env), shared, s, link.clip);
|
|
240
|
+
if (!("missing" in found))
|
|
241
|
+
return found;
|
|
242
|
+
}
|
|
243
|
+
return found;
|
|
244
|
+
}
|
|
245
|
+
register({
|
|
246
|
+
path: ["link", "open"],
|
|
247
|
+
summary: "Do what a strom-research:// link from the Strom app asks (the system runs it): send the edited tree back, an excerpt in full, …",
|
|
248
|
+
group: "setup",
|
|
249
|
+
description: "strom-research://send?tree=<research id> — the Strom app sends the edited tree to this research (a terminal opens,\n" +
|
|
250
|
+
"what it brings is shown and written on the user's word, as the menu's \"straight from the Strom app\").\n" +
|
|
251
|
+
"strom-research://excerpt?tree=<id>&source=S0042&clip=<its mark> — the excerpt from the scan, not made smaller, in the\n" +
|
|
252
|
+
"viewer of the system (the page in the online archive when the scan is not here). Nothing is written.\n" +
|
|
253
|
+
"strom-research://app?tree=<id> — the research as it is now into the app's open window (strom app).\n" +
|
|
254
|
+
"strom-research://open?tree=<id> — the research's menu in a terminal.\n" +
|
|
255
|
+
"strom-research://chat?tree=<id>[&person=P0012] — a conversation with the agent (strom chat; of one person: its first\n" +
|
|
256
|
+
"message names only who). strom-research://task?tree=<id>&task=T0007 — the task that waits for the user, answered.\n" +
|
|
257
|
+
"strom-research://review?tree=<id>&person=P0012&scope=person|family|line — the person reviewed (strom review).\n" +
|
|
258
|
+
"strom-research://research?tree=<id>&person=P0012&direction=ancestors|descendants — a new direction (research new).\n" +
|
|
259
|
+
"strom-research://task?…&task=T0007&do=park|drop|wake — the task put aside, given up, back in the queue (the reason asked).\n" +
|
|
260
|
+
"strom-research://conflict?tree=<id>&id=X0007&do=decide|agent — decided by the user (which claim, why) or left to the agent.\n" +
|
|
261
|
+
"strom-research://story?tree=<id>&person=P0012&do=final — the story approved (strom story approve).\n" +
|
|
262
|
+
"strom-research://sync-undo?tree=<id>&intake=I0042 — a sending from the app taken back (strom sync undo).\n" +
|
|
263
|
+
"strom-research://update|sessions|setup?tree=<id> — strom update, the agent's sessions and their cost, strom setup.\n" +
|
|
264
|
+
"strom-research://new?app=<the app's mark> — a tree of the app becomes a new research: named, made, the app hands the\n" +
|
|
265
|
+
"tree to its bridge (?adopt=, GET/POST /adopt), taken in as leads, sent back to the app.\n" +
|
|
266
|
+
"What writes tasks or starts the agent is said and asked in the terminal first. Any page can open such a link:\n" +
|
|
267
|
+
"each part is checked (only IDs and fixed values, never text), an unknown research, person, task or excerpt is\n" +
|
|
268
|
+
"only said. No link: the menu.",
|
|
269
|
+
args: [{ name: "link", description: "the strom-research:// link" }],
|
|
270
|
+
examples: ["strom link open 'strom-research://send?tree=0f8c2d4e-1b2a-4c3d-9e8f-7a6b5c4d3e2f'"],
|
|
271
|
+
async run(ctx, { args }) {
|
|
272
|
+
let link;
|
|
273
|
+
try {
|
|
274
|
+
link = parseLink(args[0]);
|
|
275
|
+
}
|
|
276
|
+
catch (e) {
|
|
277
|
+
if (!(e instanceof LinkError))
|
|
278
|
+
throw e;
|
|
279
|
+
return say(ctx, "ui.link.bad", { why: e.message });
|
|
280
|
+
}
|
|
281
|
+
if (link.action === "menu") {
|
|
282
|
+
const where = ctx.settings.home()?.value ?? configDir(ctx.env);
|
|
283
|
+
fs.mkdirSync(where, { recursive: true });
|
|
284
|
+
openInNewTerminal([], where, ctx.env);
|
|
285
|
+
return { text: "", data: { done: true, action: "menu" } };
|
|
286
|
+
}
|
|
287
|
+
// a tree of the app becomes a research: always in a terminal (it asks the name, waits for the app)
|
|
288
|
+
if (link.action === "new") {
|
|
289
|
+
if (ctx.io.tty) {
|
|
290
|
+
const { newFromApp } = await import("../cli/menu-links.js");
|
|
291
|
+
const { openAppAt } = await import("./sync.js");
|
|
292
|
+
const done = await newFromApp(ctx, runnerOf(ctx), link.app, (url) => openAppAt(ctx, url), () => bringForward(ctx.env));
|
|
293
|
+
if (!done)
|
|
294
|
+
await pause(ctx, ctx.uiLang(), "ui.enter.close");
|
|
295
|
+
return { text: "", data: { done, action: "new" } };
|
|
296
|
+
}
|
|
297
|
+
const where = ctx.settings.home()?.value ?? configDir(ctx.env);
|
|
298
|
+
fs.mkdirSync(where, { recursive: true });
|
|
299
|
+
if (openInNewTerminal(["link", "open", linkText(link)], where, ctx.env) === false)
|
|
300
|
+
return say(ctx, "ui.link.noterminal.new");
|
|
301
|
+
return { text: "", data: { done: true, action: "new" } };
|
|
302
|
+
}
|
|
303
|
+
if (link.action !== "excerpt") {
|
|
304
|
+
// a terminal to show it in: this one, or a new window (the system started strom with none)
|
|
305
|
+
if (ctx.io.tty)
|
|
306
|
+
return inTerminal(ctx, link);
|
|
307
|
+
const copies = researchesWithId(ctx, link.tree);
|
|
308
|
+
if (!copies.length)
|
|
309
|
+
return say(ctx, "ui.link.notree", {}, { tree: link.tree });
|
|
310
|
+
const quiet = await withoutTerminal(ctx, link, copies[0].root);
|
|
311
|
+
if (quiet)
|
|
312
|
+
return quiet;
|
|
313
|
+
const opened = openInNewTerminal(["link", "open", linkText(link)], copies[0].root, ctx.env);
|
|
314
|
+
if (opened === false)
|
|
315
|
+
return link.action === "send"
|
|
316
|
+
? say(ctx, "ui.link.noterminal", { item: `${ui(ctx.uiLang(), "ui.more.title")} → ${ui(ctx.uiLang(), "ui.more.sync")}` })
|
|
317
|
+
: say(ctx, "ui.link.noterminal.any", { name: copies[0].name });
|
|
318
|
+
return { text: "", data: { done: true, action: link.action, tree: copies[0].root } };
|
|
319
|
+
}
|
|
320
|
+
const got = excerptOf(ctx, link);
|
|
321
|
+
if ("file" in got) {
|
|
322
|
+
openForUser(got.file, ctx.env);
|
|
323
|
+
return { text: ctx.display(got.file), data: { done: true, action: "excerpt", file: got.file } };
|
|
324
|
+
}
|
|
325
|
+
if ("url" in got) {
|
|
326
|
+
openForUser(got.url, ctx.env);
|
|
327
|
+
return { text: got.url, data: { done: true, action: "excerpt", url: got.url } };
|
|
328
|
+
}
|
|
329
|
+
return say(ctx, got.missing === "tree" ? "ui.link.notree" : got.missing === "scan" ? "ui.link.noscan" : "ui.link.noclip", { source: link.source }, { tree: link.tree, source: link.source, clip: link.clip });
|
|
330
|
+
},
|
|
331
|
+
}, {
|
|
332
|
+
path: ["link", "on"],
|
|
333
|
+
summary: "Let the Strom app start the research on this computer: register strom-research:// links for this user (the user's yes)",
|
|
334
|
+
group: "setup",
|
|
335
|
+
description: "The Strom app on this computer then sends the edited tree back with one click and opens an excerpt in full quality.\n" +
|
|
336
|
+
"For this user alone, no admin rights: macOS an applet in ~/Applications, Windows a key under HKCU, Linux a .desktop\n" +
|
|
337
|
+
"entry. The user's decision: an agent asks them (a window of the system). strom link off takes it away.",
|
|
338
|
+
run(ctx) {
|
|
339
|
+
const lang = ctx.uiLang();
|
|
340
|
+
if (linkHandlerState(ctx.env) === "ours")
|
|
341
|
+
return { text: ui(lang, "ui.link.on.done"), data: { on: true } };
|
|
342
|
+
ctx.requireHuman("Let the Strom app start strom on this computer (strom-research:// links)?", "strom link on", "links", ui(lang, "ui.setup.links"));
|
|
343
|
+
const on = registerLinks(ctx.env);
|
|
344
|
+
if (on)
|
|
345
|
+
keepAnswer(ctx, "yes");
|
|
346
|
+
return { text: ui(lang, on ? "ui.link.on.done" : "ui.link.on.failed"), data: { on }, ...(on ? {} : { exitCode: 1 }) };
|
|
347
|
+
},
|
|
348
|
+
}, {
|
|
349
|
+
path: ["link", "off"],
|
|
350
|
+
summary: "Take the strom-research:// links off this computer: the Strom app no longer starts the research",
|
|
351
|
+
group: "setup",
|
|
352
|
+
run(ctx) {
|
|
353
|
+
const off = unregisterLinks(ctx.env);
|
|
354
|
+
if (off)
|
|
355
|
+
keepAnswer(ctx, "no");
|
|
356
|
+
return { text: ui(ctx.uiLang(), off ? "ui.link.off.done" : "ui.link.off.failed"), data: { on: !off }, ...(off ? {} : { exitCode: 1 }) };
|
|
357
|
+
},
|
|
358
|
+
}, {
|
|
359
|
+
path: ["link", "status"],
|
|
360
|
+
summary: "Where strom-research:// links lead on this computer: to this strom, to another program, nowhere",
|
|
361
|
+
group: "setup",
|
|
362
|
+
run(ctx) {
|
|
363
|
+
const state = linkHandlerState(ctx.env);
|
|
364
|
+
const lang = ctx.uiLang();
|
|
365
|
+
return { text: lines(`${ui(lang, "ui.doc.links")}: ${ui(lang, `ui.doc.links.${state}`)}`, state === "ours" ? undefined : "→ strom link on"), data: { state } };
|
|
366
|
+
},
|
|
367
|
+
});
|
package/dist/commands/output.js
CHANGED
|
@@ -12,6 +12,8 @@ import { resolveResearch } from "./research.js";
|
|
|
12
12
|
import { UsageError } from "../core/errors.js";
|
|
13
13
|
import { excerptSettings, planExcerpts } from "../core/excerpt.js";
|
|
14
14
|
import { runGit, tracked } from "../core/git.js";
|
|
15
|
+
import { Settings } from "../core/config.js";
|
|
16
|
+
import { appOpensLinks } from "../core/stromapp.js";
|
|
15
17
|
import { EXCERPT_QUALITIES, EXCERPT_SCOPES } from "../core/model.js";
|
|
16
18
|
function findingRows(f) {
|
|
17
19
|
return table(f.slice(0, 30).map((x) => [x.level === "error" ? "ERROR" : "warn", `line ${x.line}`, x.message]));
|
|
@@ -67,7 +69,11 @@ export function writeTreeGed(tree, opts) {
|
|
|
67
69
|
const images = set && set.for !== "none"
|
|
68
70
|
? planExcerpts(tree, set.shared, { quality: set.quality, for: set.for, maxBytes: set.mb * 1024 * 1024, persons, budgetMs: opts.budgetMs })
|
|
69
71
|
: undefined;
|
|
70
|
-
|
|
72
|
+
// each excerpt's mark, for an app that asks for it in full (a strom-research:// link)
|
|
73
|
+
// an app that opens links shows what the research knows of a person too (the same version)
|
|
74
|
+
const research = opts.for === "strom" && appOpensLinks(new Settings(tree.env, {}), opts.stromVersion);
|
|
75
|
+
const clips = research && !!images;
|
|
76
|
+
const result = exportGedcom(tree, { for: opts.for, stromVersion: opts.stromVersion, ...(persons ? { persons } : {}), ...(images ? { excerpts: images.of } : {}), ...(clips ? { clips } : {}), ...(research ? { research } : {}) });
|
|
71
77
|
const findings = validateGedcom(result.text, opts.for === "standard" ? { strict: true } : {});
|
|
72
78
|
const file = opts.out ?? gedFile(tree, opts.for, opts.research);
|
|
73
79
|
if (!hasGedErrors(findings) && !tree.dryRun) {
|
package/dist/commands/setup.js
CHANGED
|
@@ -20,9 +20,10 @@ import { VERSION } from "../core/tree.js";
|
|
|
20
20
|
import { installUpdate, isNewer, knownNewerVersion, latestVersion, newerNode, newerVersion } from "../core/update.js";
|
|
21
21
|
import { desktopDir } from "../core/paths.js";
|
|
22
22
|
import { planMove } from "../core/relocate.js";
|
|
23
|
-
import { researchUrl, stromAppUrl, stromAppState } from "../core/stromapp.js";
|
|
23
|
+
import { appOpensLinks, researchUrl, stromAppUrl, stromAppState } from "../core/stromapp.js";
|
|
24
24
|
import { isInstalled } from "../agents/global.js";
|
|
25
|
-
import { shortcutName } from "../cli/wizard.js";
|
|
25
|
+
import { offerLinks, shortcutName } from "../cli/wizard.js";
|
|
26
|
+
import { linkHandlerState } from "../core/links.js";
|
|
26
27
|
import { globalTargets, installGlobal } from "../agents/global.js";
|
|
27
28
|
import { ui } from "../cli/ui.js";
|
|
28
29
|
import { PERMISSION_LEVELS } from "../core/config.js";
|
|
@@ -225,6 +226,14 @@ function diagnose(ctx) {
|
|
|
225
226
|
add("shortcut", "warn", t("ui.doc.none"), FIX, "shortcut");
|
|
226
227
|
const app = stromAppState(ctx.settings);
|
|
227
228
|
add("app", "ok", t(`ui.doc.app.${app}`), app === "unknown" ? FIX : undefined, app === "unknown" ? "app" : undefined);
|
|
229
|
+
// …and whether it may start the research here (strom-research:// links): only while the app is wanted
|
|
230
|
+
if (app !== "no" && appOpensLinks(ctx.settings)) {
|
|
231
|
+
const links = linkHandlerState(ctx.env);
|
|
232
|
+
if (links === "ours")
|
|
233
|
+
add("links", "ok", t("ui.doc.links.ours"));
|
|
234
|
+
else
|
|
235
|
+
add("links", "warn", t(`ui.doc.links.${links}`), FIX, "links");
|
|
236
|
+
}
|
|
228
237
|
if (ctx.hasTree()) {
|
|
229
238
|
const tr = ctx.tree();
|
|
230
239
|
const errs = check(tr).filter((f) => f.level === "error").length + verifyFull(tr).findings.filter((f) => f.level === "error").length;
|
|
@@ -257,6 +266,8 @@ async function repair(ctx, checks, out) {
|
|
|
257
266
|
for (const f of createShortcut(shortcutName(lang), ctx.env))
|
|
258
267
|
out(ui(lang, "ui.setup.shortcut.done", { file: ctx.display(f) }));
|
|
259
268
|
}
|
|
269
|
+
if (todo.has("links") && person)
|
|
270
|
+
await offerLinks(ctx, lang);
|
|
260
271
|
if (todo.has("app") && person && (await ctx.confirm(ui(lang, "ui.fix.app"), false))) {
|
|
261
272
|
openForUser(stromAppUrl(ctx.settings), ctx.env);
|
|
262
273
|
out(ui(lang, "ui.app.install"));
|
package/dist/commands/start.js
CHANGED
|
@@ -18,7 +18,8 @@ import { DESKTOP_APPS, agentsHere, whereToTalk } from "../core/apps.js";
|
|
|
18
18
|
import { assertIntact } from "../core/integrity.js";
|
|
19
19
|
import { treeBrowserConnectors } from "../core/connector.js";
|
|
20
20
|
import { browserNote } from "./connectors.js";
|
|
21
|
-
import {
|
|
21
|
+
import { offerLinks } from "../cli/wizard.js";
|
|
22
|
+
import { appOpensLinks, appOpensResearch, importAppUrl, installedStromApp, liveAppUrl, noticeStromApp, STROM_APP_URL, stromAppUrl } from "../core/stromapp.js";
|
|
22
23
|
import { liveRunning, serveLive, startLive, stopLive } from "../core/live.js";
|
|
23
24
|
import { openForUser } from "../core/open.js";
|
|
24
25
|
import { createShortcut, openInNewTerminal } from "../core/shortcut.js";
|
|
@@ -193,7 +194,7 @@ register({
|
|
|
193
194
|
{ name: "images", type: "boolean", description: "each entry with its image (the default; kept for older scripts)" },
|
|
194
195
|
],
|
|
195
196
|
examples: ["strom app", "strom app --live", "strom app install"],
|
|
196
|
-
run(ctx, { opts }) {
|
|
197
|
+
async run(ctx, { opts }) {
|
|
197
198
|
const lang = ctx.uiLang();
|
|
198
199
|
const root = appOpensResearch(ctx.settings) ? ctx.locateTree() : undefined;
|
|
199
200
|
if (root) {
|
|
@@ -217,6 +218,9 @@ register({
|
|
|
217
218
|
const webApp = installed?.appId && installed.browser ? { browser: installed.browser, appId: installed.appId, ...(installed.profile ? { profile: installed.profile } : {}) } : undefined;
|
|
218
219
|
const browser = chromiumBrowser(ctx.env);
|
|
219
220
|
if (webApp || browser) {
|
|
221
|
+
// The first time a person opens the research in an app that opens links: may it start the research here? (once)
|
|
222
|
+
if (ctx.interactive && !isAgent(ctx.env) && appOpensLinks(ctx.settings, ctx.settings.stromVersion(tree.config)) && ctx.settings.config.links === undefined)
|
|
223
|
+
await offerLinks(ctx, lang, { ask: false });
|
|
220
224
|
// The bridge serves the tree's GEDCOM to the app; it ends by itself when nobody asks it anything.
|
|
221
225
|
const info = startLive(root, ctx.env, { current: true });
|
|
222
226
|
if (!info)
|
package/dist/commands/story.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import fs from "node:fs";
|
|
5
5
|
import { register } from "../cli/registry.js";
|
|
6
6
|
import { lines } from "../cli/format.js";
|
|
7
|
-
import { setStory, findEventOwner } from "../core/actions.js";
|
|
7
|
+
import { approveStory, setStory, findEventOwner } from "../core/actions.js";
|
|
8
8
|
import { UsageError } from "../core/errors.js";
|
|
9
9
|
import { csvOpt, normId, textOpt } from "../core/records.js";
|
|
10
10
|
import { resolvePerson } from "../core/people.js";
|
|
@@ -43,6 +43,22 @@ register({
|
|
|
43
43
|
const missing = rec.story.facts.length === 0 ? "note: no --fact given — say which facts the story rests on" : undefined;
|
|
44
44
|
return { text: lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined, missing), data: { story: rec.story } };
|
|
45
45
|
},
|
|
46
|
+
}, {
|
|
47
|
+
path: ["story", "approve"],
|
|
48
|
+
summary: "The user approved the story as it is: no longer a draft",
|
|
49
|
+
group: "people",
|
|
50
|
+
tree: true,
|
|
51
|
+
writes: true,
|
|
52
|
+
description: "Only on the user's word (they read it and said it is right): the text stays, the draft becomes the story of the family book.",
|
|
53
|
+
args: [{ name: "who", description: "person (ID or name) or family (F…)", required: true }],
|
|
54
|
+
examples: ["strom story approve P0001"],
|
|
55
|
+
run(ctx, { args }) {
|
|
56
|
+
const tree = ctx.tree();
|
|
57
|
+
const id = owner(tree, args[0]);
|
|
58
|
+
const was = tree.get(id)?.story?.status;
|
|
59
|
+
const rec = approveStory(tree, id);
|
|
60
|
+
return { text: was === "final" ? `${id} story was approved already` : lines(...tree.written.map((o) => o.summary)), data: { story: rec.story } };
|
|
61
|
+
},
|
|
46
62
|
}, {
|
|
47
63
|
path: ["story", "show"],
|
|
48
64
|
summary: "The story of a person or a couple, with the facts it rests on",
|
package/dist/commands/sync.js
CHANGED
|
@@ -53,6 +53,13 @@ function planText(tree, plan, incoming, file, lang, edits) {
|
|
|
53
53
|
return ui(lang, "ui.sync.nothing", { file: path.basename(file) });
|
|
54
54
|
return lines(ui(lang, "ui.sync.title", { file: path.basename(file), n: plan.changes.length }), plan.base ? ui(lang, "ui.sync.base", { head: plan.head.slice(0, 8) }) : ui(lang, plan.head ? "ui.sync.headmissing" : "ui.sync.nobase", { head: plan.head?.slice(0, 8) ?? "" }), plan.partial ? ui(lang, "ui.sync.partial") : undefined, plan.identity.strangers.length ? ui(lang, "ui.sync.strangers", { ids: plan.identity.strangers.join(", ") }) : undefined, "", ...plan.changes.map((c) => ` ${changeLine(tree, c, incoming, lang)}`), "", ui(lang, "ui.sync.edits", { mode: ui(lang, edits === "user" ? "ui.sync.mode.user" : "ui.sync.mode.conflict") }), ui(lang, "ui.sync.next", { file: shown }));
|
|
55
55
|
}
|
|
56
|
+
/** The Strom app at this address: installed from a Chromium browser first, else in such a browser's tab (never Safari: it cannot reach the bridge). */
|
|
57
|
+
export function openAppAt(ctx, url) {
|
|
58
|
+
const installed = installedStromApp(ctx.env, process.platform, stromAppUrl(ctx.settings));
|
|
59
|
+
const webApp = installed?.appId && installed.browser ? { browser: installed.browser, appId: installed.appId, ...(installed.profile ? { profile: installed.profile } : {}) } : undefined;
|
|
60
|
+
const browser = chromiumBrowser(ctx.env);
|
|
61
|
+
return Boolean((webApp && openWebApp(webApp, url, ctx.env)) || (browser && openInBrowser(browser, url, ctx.env)));
|
|
62
|
+
}
|
|
56
63
|
/**
|
|
57
64
|
* The Strom app sends the tree itself: the bridge started, the app opened with ?send= (installed from a Chromium
|
|
58
65
|
* browser first, else in such a browser's tab), and the tree waited for. The file it came as, or what to do instead.
|
|
@@ -64,13 +71,10 @@ async function fromApp(ctx, root, lang) {
|
|
|
64
71
|
if (!info)
|
|
65
72
|
throw new StromError("the bridge did not start", { hint: "strom live serve shows why" });
|
|
66
73
|
const url = sendAppUrl(info.url, ctx.settings);
|
|
67
|
-
const installed = installedStromApp(ctx.env, process.platform, stromAppUrl(ctx.settings));
|
|
68
|
-
const webApp = installed?.appId && installed.browser ? { browser: installed.browser, appId: installed.appId, ...(installed.profile ? { profile: installed.profile } : {}) } : undefined;
|
|
69
|
-
const browser = chromiumBrowser(ctx.env);
|
|
70
74
|
const since = Date.now();
|
|
71
|
-
const opened = (
|
|
75
|
+
const opened = openAppAt(ctx, url);
|
|
72
76
|
const minutes = Math.max(1, Math.round(Number(ctx.env.STROM_SYNC_WAIT_MS ?? 10 * 60_000) / 60_000));
|
|
73
|
-
ctx.io.stderr(`${opened ? ui(lang, "ui.sync.wait", { min: minutes }) : ui(lang, "ui.app.url", { url })}\n`);
|
|
77
|
+
ctx.io.stderr(`${opened ? `${ui(lang, "ui.sync.wait", { min: minutes })}\n${ui(lang, "ui.sync.wait.open", { url })}` : ui(lang, "ui.app.url", { url })}\n`);
|
|
74
78
|
const until = since + Number(ctx.env.STROM_SYNC_WAIT_MS ?? 10 * 60_000);
|
|
75
79
|
while (Date.now() < until) {
|
|
76
80
|
const file = receivedSince(root, since);
|
|
@@ -84,6 +88,14 @@ async function fromApp(ctx, root, lang) {
|
|
|
84
88
|
}
|
|
85
89
|
return { text: ui(lang, "ui.sync.waited", { min: minutes }) };
|
|
86
90
|
}
|
|
91
|
+
/** A family tree file without the images written into it (data: URLs): a GEDCOM's FILE with its CONC lines, a JSON's strings. */
|
|
92
|
+
export function withoutImages(text) {
|
|
93
|
+
const left = (bytes) => `[image left out, ${Math.round(bytes / 1024)} kB]`;
|
|
94
|
+
// GEDCOM: n FILE data:… and the n+1 CONC/CONT lines that go on with it
|
|
95
|
+
const ged = text.replace(/^(\d+) FILE data:[^\r\n]*(?:\r?\n(?:\d+) CON[CT] [^\r\n]*)*/gm, (m, level) => `${level} FILE ${left(m.length)}`);
|
|
96
|
+
// JSON: "data:image/…;base64,…"
|
|
97
|
+
return ged.replace(/"data:image\/[^"]*"/g, (m) => `"${left(m.length)}"`);
|
|
98
|
+
}
|
|
87
99
|
function numbers(v, max) {
|
|
88
100
|
if (v === undefined)
|
|
89
101
|
return undefined;
|
|
@@ -180,7 +192,9 @@ register({
|
|
|
180
192
|
if (stored) {
|
|
181
193
|
tree.remember(path.join(tree.root, stored));
|
|
182
194
|
fs.mkdirSync(path.join(tree.root, "inputs"), { recursive: true });
|
|
183
|
-
|
|
195
|
+
// kept as the document the source cites — without the images in it (the research's own excerpts, back from
|
|
196
|
+
// the app: megabytes in the history for nothing)
|
|
197
|
+
fs.writeFileSync(path.join(tree.root, stored), withoutImages(fs.readFileSync(file, "utf8")));
|
|
184
198
|
}
|
|
185
199
|
input = create(tree, "input", { name: path.basename(file), ...(stored ? { file: stored } : {}), sha, size, mime: mimeOf(file), from: ctx.display(file), kind: "tree", state: "processed" }, (iid) => `+${iid} input tree "${truncate(path.basename(file), 50)}" (sync)`);
|
|
186
200
|
const day = humanDay(now(), lang);
|
package/dist/core/actions.js
CHANGED
|
@@ -850,3 +850,18 @@ export function setStory(tree, id, input) {
|
|
|
850
850
|
return updated;
|
|
851
851
|
});
|
|
852
852
|
}
|
|
853
|
+
/** The user approved the story as it is: no longer a draft (the text stays). */
|
|
854
|
+
export function approveStory(tree, id) {
|
|
855
|
+
return tree.withTreeLock(() => {
|
|
856
|
+
const rec = tree.get(id);
|
|
857
|
+
if (!rec || (rec.type !== "person" && rec.type !== "family"))
|
|
858
|
+
throw new UsageError(`no person or family ${id}`);
|
|
859
|
+
if (!rec.story)
|
|
860
|
+
throw new UsageError(`${id} has no story yet`, { hint: `strom story set ${id} --text @file --fact E…` });
|
|
861
|
+
if (rec.story.status === "final")
|
|
862
|
+
return rec;
|
|
863
|
+
const updated = { ...rec, story: { ...rec.story, status: "final" }, updated: now() };
|
|
864
|
+
tree.put(updated, { op: "story.approve", targets: [id], summary: `${id} story approved` });
|
|
865
|
+
return updated;
|
|
866
|
+
});
|
|
867
|
+
}
|
package/dist/core/dialog.js
CHANGED
|
@@ -59,3 +59,28 @@ export function systemDialog(text, env, platform = process.platform) {
|
|
|
59
59
|
}
|
|
60
60
|
return undefined;
|
|
61
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Say something in a window (one button): what a link from the Strom app could not do, with no terminal to say it
|
|
64
|
+
* in. False when this computer cannot show one.
|
|
65
|
+
*/
|
|
66
|
+
export function systemNotice(title, message, ok, env, platform = process.platform) {
|
|
67
|
+
if (env.STROM_NO_DIALOG === "1")
|
|
68
|
+
return false;
|
|
69
|
+
const run = (cmd, args) => spawnSync(cmd, args, { encoding: "utf8", timeout: (WAIT_SECONDS + 10) * 1000, windowsHide: false, env: env });
|
|
70
|
+
if (platform === "darwin") {
|
|
71
|
+
const script = `display dialog ${appleString(message)} with title ${appleString(title)} buttons {${appleString(ok)}} default button ${appleString(ok)} with icon note giving up after ${WAIT_SECONDS}`;
|
|
72
|
+
return !run("osascript", ["-e", script]).error;
|
|
73
|
+
}
|
|
74
|
+
if (platform === "win32") {
|
|
75
|
+
const ps = `Add-Type -AssemblyName PresentationFramework; [void][System.Windows.MessageBox]::Show(${psString(message)}, ${psString(title)}, 'OK', 'Information', 'OK', 'DefaultDesktopOnly')`;
|
|
76
|
+
const r = run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", ps]);
|
|
77
|
+
return !r.error && r.status === 0;
|
|
78
|
+
}
|
|
79
|
+
if (!env.DISPLAY && !env.WAYLAND_DISPLAY)
|
|
80
|
+
return false;
|
|
81
|
+
if (which("zenity", env))
|
|
82
|
+
return !run("zenity", ["--info", "--title", title, "--text", message, "--timeout", String(WAIT_SECONDS)]).error;
|
|
83
|
+
if (which("kdialog", env))
|
|
84
|
+
return !run("kdialog", ["--title", title, "--msgbox", message]).error;
|
|
85
|
+
return false;
|
|
86
|
+
}
|