strom-research 1.4.1 → 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.
Files changed (49) hide show
  1. package/assets/lang/cs.json +160 -1
  2. package/assets/lang/de.json +160 -1
  3. package/assets/method/narrate.md +2 -1
  4. package/assets/method/recording.md +3 -1
  5. package/dist/agents/files.js +8 -1
  6. package/dist/cli/human.js +18 -0
  7. package/dist/cli/main.js +9 -0
  8. package/dist/cli/menu-links.js +249 -0
  9. package/dist/cli/menu-parts.js +6 -3
  10. package/dist/cli/menu-research.js +185 -7
  11. package/dist/cli/menu-settings.js +17 -3
  12. package/dist/cli/menu-waiting.js +61 -47
  13. package/dist/cli/menu.js +3 -2
  14. package/dist/cli/ui.js +159 -1
  15. package/dist/cli/wizard.js +34 -1
  16. package/dist/commands/analysis.js +6 -2
  17. package/dist/commands/browse.js +5 -19
  18. package/dist/commands/checks.js +7 -1
  19. package/dist/commands/guide.js +15 -1
  20. package/dist/commands/index.js +2 -0
  21. package/dist/commands/intake.js +3 -0
  22. package/dist/commands/links.js +367 -0
  23. package/dist/commands/output.js +7 -1
  24. package/dist/commands/people.js +58 -1
  25. package/dist/commands/research.js +113 -24
  26. package/dist/commands/setup.js +13 -2
  27. package/dist/commands/start.js +6 -2
  28. package/dist/commands/story.js +17 -1
  29. package/dist/commands/sync.js +243 -0
  30. package/dist/core/actions.js +15 -0
  31. package/dist/core/config.js +5 -0
  32. package/dist/core/dialog.js +25 -0
  33. package/dist/core/excerpt.js +53 -5
  34. package/dist/core/frontier.js +10 -1
  35. package/dist/core/links.js +357 -0
  36. package/dist/core/live.js +156 -7
  37. package/dist/core/phrases.js +1 -0
  38. package/dist/core/review.js +43 -5
  39. package/dist/core/schema.js +1 -0
  40. package/dist/core/self.js +17 -0
  41. package/dist/core/session.js +6 -0
  42. package/dist/core/stromapp.js +38 -0
  43. package/dist/core/sync.js +876 -0
  44. package/dist/core/tree.js +1 -1
  45. package/dist/core/uninstall.js +3 -0
  46. package/dist/core/validate.js +4 -0
  47. package/dist/gedcom/export.js +66 -1
  48. package/dist/gedcom/validate.js +13 -4
  49. package/package.json +1 -1
package/dist/core/live.js CHANGED
@@ -10,6 +10,9 @@
10
10
  // GET <token>/tree.ged the tree for the Strom app, as it is now, each entry with its image (the settings excerpts.*)
11
11
  // GET <token>/images.ged the same (the address an older strom gave)
12
12
  // GET <token>/events server-sent events: hello, change, working
13
+ // POST <token>/sync the Strom app sends the user's edited tree back (?send=): kept to be shown
14
+ // and written on the user's word (strom sync) — nothing of the research changes
15
+ // POST <token>/cancel …or says it sends nothing ({"reason": "unchanged" | "cancelled" | "no-tree"})
13
16
  //
14
17
  // Only pages of the Strom app may read it (CORS: https://stromapp.info, its beta, and a
15
18
  // local copy on localhost for its development), and a browser asks first
@@ -22,12 +25,19 @@ import { spawn, spawnSync } from "node:child_process";
22
25
  import { Tree, VERSION } from "./tree.js";
23
26
  import { exportGedcom } from "../gedcom/export.js";
24
27
  import { excerptSettings, planExcerpts } from "./excerpt.js";
28
+ import { noteAdoptFailed, noteNothingSent, pendingAdoption, receiveAdopted, receiveTree, SYNC_MAX_BYTES } from "./sync.js";
29
+ import { ui } from "../cli/ui.js";
25
30
  /** New images for the app, made for at most so long when the tree changed (the rest the next time). */
26
31
  const LIVE_IMAGES_MS = 20_000;
27
32
  import { liveWorkers } from "./workers.js";
28
- 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";
29
37
  import { gitProgram } from "./git.js";
30
- 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";
31
41
  import { stromLauncher } from "./self.js";
32
42
  /** Where a running bridge of a tree is noted. */
33
43
  export function liveFile(root) {
@@ -120,10 +130,43 @@ function working(root, tree) {
120
130
  return { who: w.label, since: w.since, ...(s ? { session: s.id, ...(s.task ? { task: task ? `${task.id} ${task.what}` : s.task } : {}) } : {}) };
121
131
  });
122
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
+ }
123
163
  /** What the app shows beside the tree. */
124
164
  function status(root, env) {
125
165
  const tree = Tree.open(root, env);
166
+ const settings = new Settings(env, {});
126
167
  const waiting = tree.list("task").filter((t) => t.state === "waiting");
168
+ const newer = knownNewerVersion(settings, env);
169
+ const intake = lastIntake(tree);
127
170
  return {
128
171
  strom: VERSION,
129
172
  tree: { id: tree.config.id, name: tree.config.name, lang: tree.lang },
@@ -133,7 +176,13 @@ function status(root, env) {
133
176
  researches: tree.list("research").map((r) => ({ id: r.id, name: r.name, state: r.state })),
134
177
  working: working(root, tree),
135
178
  open: openSessions(tree).map((s) => ({ id: s.id, task: s.task, started: s.started })),
136
- 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 } : {}),
137
186
  };
138
187
  }
139
188
  /** Run the bridge of the tree here, until idle or stopped. */
@@ -153,7 +202,7 @@ export function serveLive(root, env) {
153
202
  }
154
203
  if (req.method === "OPTIONS") {
155
204
  if (origin) {
156
- res.setHeader("Access-Control-Allow-Methods", "GET");
205
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST");
157
206
  res.setHeader("Access-Control-Allow-Headers", String(req.headers["access-control-request-headers"] ?? ""));
158
207
  if (req.headers["access-control-request-private-network"])
159
208
  res.setHeader("Access-Control-Allow-Private-Network", "true");
@@ -162,22 +211,48 @@ export function serveLive(root, env) {
162
211
  return;
163
212
  }
164
213
  const [, t, what] = (req.url ?? "").split("?")[0].split("/");
214
+ if (req.method === "POST" && t === token && what === "sync") {
215
+ receive(req, res, origin);
216
+ return;
217
+ }
218
+ if (req.method === "POST" && t === token && what === "cancel") {
219
+ nothing(req, res, origin);
220
+ return;
221
+ }
222
+ if (req.method === "POST" && t === token && what === "adopt") {
223
+ receive(req, res, origin, "adopt");
224
+ return;
225
+ }
165
226
  if (req.method !== "GET" || t !== token) {
166
227
  res.writeHead(404).end();
167
228
  return;
168
229
  }
169
230
  try {
170
- 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") {
171
239
  res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }).end(JSON.stringify(status(root, env)));
172
240
  }
173
241
  else if (what === "tree.ged" || what === "images.ged") {
174
242
  const h = head(root);
175
- if (!ged || ged.head !== h) {
243
+ if (!ged || ged.head !== h || ged.links !== (appOpensLinks(new Settings(env, {})) ? links(env).join(" ") : "")) {
176
244
  const tree = Tree.open(root, env);
177
245
  // the images from the cache; new ones made for a while at most (the rest the next time the tree changes)
178
246
  const set = excerptSettings(tree);
179
247
  const images = set ? planExcerpts(tree, set.shared, { quality: set.quality, for: set.for, maxBytes: set.mb * 1024 * 1024, budgetMs: LIVE_IMAGES_MS }) : undefined;
180
- ged = { head: h, text: exportGedcom(tree, { for: "strom", ...(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
+ };
181
256
  }
182
257
  res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", "X-Strom-Head": h }).end(ged.text);
183
258
  }
@@ -194,6 +269,80 @@ export function serveLive(root, env) {
194
269
  res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }).end(e.message);
195
270
  }
196
271
  });
272
+ /** The user's tree from the app: only from its pages, only so large; the answer in the research's language. */
273
+ const receive = (req, res, origin, as = "sync") => {
274
+ const reply = (code, body) => res.writeHead(code, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }).end(JSON.stringify(body));
275
+ const lang = () => {
276
+ try {
277
+ return Tree.open(root, env).lang;
278
+ }
279
+ catch {
280
+ return "en";
281
+ }
282
+ };
283
+ if (!origin) {
284
+ reply(403, { error: "only the Strom app may send a tree here" });
285
+ return;
286
+ }
287
+ const chunks = [];
288
+ let size = 0;
289
+ let over = false;
290
+ req.on("data", (c) => {
291
+ size += c.length;
292
+ if (size > SYNC_MAX_BYTES)
293
+ over = true;
294
+ else
295
+ chunks.push(c);
296
+ });
297
+ req.on("end", () => {
298
+ if (over) {
299
+ reply(413, { error: ui(lang(), "ui.sync.bridge.large") });
300
+ return;
301
+ }
302
+ try {
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);
311
+ reply(200, { ok: true, changes: got.changes, file: path.basename(got.file) });
312
+ }
313
+ catch (e) {
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");
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;
319
+ reply(400, { error: key ? ui(lang(), key) : m });
320
+ }
321
+ });
322
+ };
323
+ /** The app sends nothing (unchanged, the user said no, no tree of this research): strom sync --app stops waiting. */
324
+ const nothing = (req, res, origin) => {
325
+ const reply = (code, body) => res.writeHead(code, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }).end(JSON.stringify(body));
326
+ if (!origin) {
327
+ reply(403, { error: "only the Strom app may say so" });
328
+ return;
329
+ }
330
+ let body = "";
331
+ req.on("data", (c) => {
332
+ if (body.length < 4096)
333
+ body += c.toString("utf8");
334
+ });
335
+ req.on("end", () => {
336
+ let reason = "cancelled";
337
+ try {
338
+ reason = String(JSON.parse(body).reason ?? reason);
339
+ }
340
+ catch {
341
+ reason = body.trim() || reason;
342
+ }
343
+ reply(200, { ok: true, reason: noteNothingSent(root, reason) });
344
+ });
345
+ };
197
346
  return new Promise((resolve) => {
198
347
  server.listen(0, "127.0.0.1", () => {
199
348
  const port = server.address().port;
@@ -32,6 +32,7 @@ export const PHRASES = {
32
32
  "story.why": "{count} facts from records tell the life (generation {generation} of {research}); the family reads it in the Strom app's family book",
33
33
  "story.done": "strom story set {id} with every fact it rests on (--fact) — a draft until the user approves it",
34
34
  "review.research": "Review of {name}",
35
+ "review.unproven.research": "People without a record of their own",
35
36
  "review.more": " (and {count} more after these)",
36
37
  "review.mentions.what": "What the tree already says of {name} outside their data: {list}{more}",
37
38
  "review.mentions.why": "records, notes or the diary name {name}, yet their data do not show it — the cheapest finds of a review ({research})",
@@ -7,7 +7,7 @@
7
7
  // Each kind is at most one task per person at a time, and what a task of that
8
8
  // kind took up is never proposed again.
9
9
  import { dateYears } from "./gdate.js";
10
- import { RECORD_KINDS, recordsetsCovering, researchPeople } from "./frontier.js";
10
+ import { birthEstimate, RECORD_KINDS, recordsetsCovering, researchPeople } from "./frontier.js";
11
11
  import { birthEvent, displayName, familiesAsPartner, lifespan } from "./people.js";
12
12
  import { phrase } from "./phrases.js";
13
13
  import { foldText } from "./text.js";
@@ -55,6 +55,11 @@ function citedFor(tree, p) {
55
55
  for (const n of p.names)
56
56
  for (const c of n.citations ?? [])
57
57
  out.add(c.source);
58
+ // the record naming a family — a child's baptism names its parents — also without a fact of the family
59
+ for (const f of tree.list("family"))
60
+ if (!f.retracted && (f.partners.includes(p.id) || f.children.some((c) => c.person === p.id)))
61
+ for (const c of f.citations ?? [])
62
+ out.add(c.source);
58
63
  for (const other of tree.list("person"))
59
64
  for (const e of other.events)
60
65
  if (e.participants?.some((x) => x.person === p.id))
@@ -67,6 +72,36 @@ function citedFor(tree, p) {
67
72
  out.add(c.source);
68
73
  return out;
69
74
  }
75
+ /**
76
+ * The people no record of their own proves: not one fact of theirs proven or
77
+ * probable — the living among them marked. Each with how they stand — a record names them elsewhere, their
78
+ * facts are only leads, or nothing but a note says where they come from.
79
+ */
80
+ export function unprovenPeople(tree) {
81
+ const out = [];
82
+ for (const p of tree.list("person")) {
83
+ if (p.retracted)
84
+ continue;
85
+ const facts = p.events.filter((e) => !e.retracted && e.status !== "retracted" && e.status !== "disproven");
86
+ if (facts.some((e) => e.status === "proven" || e.status === "probable"))
87
+ continue;
88
+ const sources = [...citedFor(tree, p)];
89
+ const kind = facts.some((e) => e.citations.length) ? "leads" : sources.length ? "named" : "none";
90
+ out.push({ person: p, kind, sources, living: livingBorn(tree, p) !== undefined });
91
+ }
92
+ return out;
93
+ }
94
+ /**
95
+ * Most likely alive: born (or, by their marriage or eldest child, about) less than DEATH_AFTER_YEARS ago, and no
96
+ * death recorded — the year, else undefined. Their records are the family's; the registers of those years are
97
+ * mostly closed.
98
+ */
99
+ export function livingBorn(tree, p) {
100
+ if (p.events.some((e) => !e.retracted && e.status !== "disproven" && DEATHS.has(e.kind)))
101
+ return undefined;
102
+ const born = birthEstimate(tree, p, tree.lang).year;
103
+ return born !== undefined && born > new Date().getFullYear() - DEATH_AFTER_YEARS ? born : undefined;
104
+ }
70
105
  /** The models that read each source: the sessions that recorded or corrected it (the user's hand counts as "user"). */
71
106
  export function readersOf(tree) {
72
107
  const model = new Map(tree.list("session").map((s) => [s.id, s.model ?? "unknown"]));
@@ -207,10 +242,13 @@ export function reviewProposals(tree, research) {
207
242
  };
208
243
  propose("mentions", "enrich", mentionsOf(tree, p, citedFor(tree, p)));
209
244
  propose("entries", "enrich", entriesOf(tree, p));
210
- const single = singleReadings(tree, p);
211
- propose("verify", "verify", single);
212
- if (reread && readers)
213
- propose("reread", "verify", rereads(tree, p, reread, readers).filter((e) => !single.includes(e)), { model: reread });
245
+ // the people without a record want one of their own first: checking what their family's facts rest on can wait
246
+ if (!research.review?.unproven) {
247
+ const single = singleReadings(tree, p);
248
+ propose("verify", "verify", single);
249
+ if (reread && readers)
250
+ propose("reread", "verify", rereads(tree, p, reread, readers).filter((e) => !single.includes(e)), { model: reread });
251
+ }
214
252
  propose("conflicts", "verify", openConflicts(tree, p));
215
253
  out.push(...gaps(tree, research, p, name, tasks));
216
254
  }
@@ -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/self.js CHANGED
@@ -33,6 +33,23 @@ export function installation() {
33
33
  }
34
34
  return (cached = { kind: "npm" });
35
35
  }
36
+ /**
37
+ * Another strom on disk than the one running (strom update replaced its code or its Node): this process goes on
38
+ * with what it loaded, so a person is told to start strom again. `version`: the running one's.
39
+ */
40
+ export function replacedOnDisk(version) {
41
+ const inst = installation();
42
+ if (inst.kind !== "installed" || !inst.root)
43
+ return false;
44
+ try {
45
+ const app = JSON.parse(fs.readFileSync(path.join(inst.root, "app", "package.json"), "utf8"));
46
+ const info = JSON.parse(fs.readFileSync(path.join(inst.root, "install.json"), "utf8"));
47
+ return app.version !== version || info.node !== inst.node;
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
36
53
  /** The program and arguments that run strom: this Node and its entry script (cli.ts from sources, cli.js built). */
37
54
  export function stromLauncher() {
38
55
  const cli = path.resolve(path.dirname(here), "..", here.endsWith(".ts") ? "cli.ts" : "cli.js");
@@ -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
+ }
@@ -30,6 +30,44 @@ export const APP_OPENS_RESEARCH = true;
30
30
  export function appOpensResearch(settings) {
31
31
  return APP_OPENS_RESEARCH || stromAppUrl(settings) !== STROM_APP_URL;
32
32
  }
33
+ /**
34
+ * The Strom app sends the user's edits back (?send=<the bridge>, POST <bridge>/sync — the app's spec
35
+ * docs/ZADANI_SYNC_Z_APLIKACE.md): the first version of stromapp.info that does (3.3.0, 2026-09-27).
36
+ * Another copy of the app (its beta, its development: strom.app.url) is taken as current.
37
+ */
38
+ export const APP_SENDS_CHANGES = "3.3.0";
39
+ export function appSendsChanges(settings) {
40
+ return APP_SENDS_CHANGES !== undefined || stromAppUrl(settings) !== STROM_APP_URL;
41
+ }
42
+ /**
43
+ * The Strom app opens strom-research:// links (the app's spec docs/ZADANI_VYZKUM_ODKAZY_Z_APLIKACE.md): it reads
44
+ * each excerpt's mark (_STROM_CLIP) and the links a research offers (_STROM_LINKS in the GEDCOM the bridge serves).
45
+ * The first version of stromapp.info that does (3.4.0, 2026-09-27); another copy of the app (its beta, its
46
+ * development: strom.app.url) is taken as current. An app of a known version (strom.version) goes by it.
47
+ */
48
+ export const APP_OPENS_LINKS = "3.4.0";
49
+ export function appOpensLinks(settings, version) {
50
+ if (stromAppUrl(settings) !== STROM_APP_URL)
51
+ return true;
52
+ if (!APP_OPENS_LINKS)
53
+ return false;
54
+ if (!version)
55
+ return true;
56
+ const n = (v) => v.split(".").map((x) => Number.parseInt(x, 10) || 0);
57
+ const [a, b] = [n(version), n(APP_OPENS_LINKS)];
58
+ for (let i = 0; i < 3; i++)
59
+ if ((a[i] ?? 0) !== (b[i] ?? 0))
60
+ return (a[i] ?? 0) > (b[i] ?? 0);
61
+ return true;
62
+ }
63
+ /** The address that opens the Strom app to send the user's edited tree to the bridge. */
64
+ export function sendAppUrl(bridge, settings) {
65
+ return `${stromAppUrl(settings)}?send=${encodeURIComponent(bridge)}`;
66
+ }
67
+ /** The address that opens the Strom app to hand one of its trees to a new research (strom-research://new). */
68
+ export function adoptAppUrl(bridge, settings) {
69
+ return `${stromAppUrl(settings)}?adopt=${encodeURIComponent(bridge)}`;
70
+ }
33
71
  /** The address that opens the Strom app following a research through its bridge. */
34
72
  export function liveAppUrl(bridge, settings) {
35
73
  return `${stromAppUrl(settings)}?live=${encodeURIComponent(bridge)}`;