strom-research 1.9.0 → 1.11.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 +23 -2
- package/assets/lang/de.json +21 -0
- package/assets/method/enrich.md +31 -0
- package/assets/method/narrate.md +11 -3
- package/dist/cli/menu-links.js +21 -2
- package/dist/cli/menu-waiting.js +68 -10
- package/dist/cli/menu.js +4 -2
- package/dist/cli/ui.js +16 -0
- package/dist/commands/browse.js +4 -2
- package/dist/commands/links.js +12 -3
- package/dist/commands/output.js +5 -2
- package/dist/commands/session.js +1 -1
- package/dist/commands/story.js +41 -10
- package/dist/commands/sync.js +18 -6
- package/dist/commands/tasks.js +6 -2
- package/dist/core/actions.js +50 -8
- package/dist/core/changelog.js +1 -0
- package/dist/core/config.js +5 -0
- package/dist/core/import.js +48 -2
- package/dist/core/links.js +5 -1
- package/dist/core/live.js +35 -4
- package/dist/core/phrases.js +5 -0
- package/dist/core/queue.js +7 -3
- package/dist/core/stories.js +47 -4
- package/dist/core/stromapp.js +42 -0
- package/dist/core/sync.js +188 -20
- package/dist/core/tree.js +1 -1
- package/dist/core/validate.js +10 -0
- package/dist/gedcom/export.js +46 -22
- package/dist/gedcom/validate.js +4 -2
- package/package.json +1 -1
package/dist/commands/sync.js
CHANGED
|
@@ -11,15 +11,23 @@ import { EXIT, StromError, UsageError } from "../core/errors.js";
|
|
|
11
11
|
import { fileSha256, MAX_IN_TREE, mimeOf } from "../core/media.js";
|
|
12
12
|
import { create, update } from "../core/records.js";
|
|
13
13
|
import { safeFolderName } from "../core/text.js";
|
|
14
|
+
import { humanAge } from "../core/age.js";
|
|
14
15
|
import { now } from "../core/tree.js";
|
|
15
16
|
import { applySync, nothingSince, planSync, readTreeFile, receivedSince, undoSync } from "../core/sync.js";
|
|
16
17
|
import { startLive } from "../core/live.js";
|
|
17
18
|
import { appSendsChanges, installedStromApp, sendAppUrl, stromAppUrl } from "../core/stromapp.js";
|
|
18
19
|
import { chromiumBrowser, openInBrowser, openWebApp } from "../core/chromium.js";
|
|
19
|
-
function factText(f, lang) {
|
|
20
|
+
function factText(f, lang, name = (k) => k) {
|
|
20
21
|
if (!f)
|
|
21
22
|
return "—";
|
|
22
|
-
return [f.value, humanDate(f.date, lang), humanPlace(f.place, undefined, lang)].filter(Boolean).join(", ") || "—";
|
|
23
|
+
return [f.value, humanDate(f.date, lang), humanPlace(f.place, undefined, lang), detailText(f, lang, name)].filter(Boolean).join(", ") || "—";
|
|
24
|
+
}
|
|
25
|
+
/** What a fact says besides: its house, age (a couple's: each partner's) and cause. */
|
|
26
|
+
function detailText(f, lang, name = (k) => k) {
|
|
27
|
+
if (!f)
|
|
28
|
+
return "";
|
|
29
|
+
const ages = Object.entries(f.ages ?? {}).map(([who, a]) => `${name(who)} ${humanAge(a, lang)}`);
|
|
30
|
+
return [f.house && ui(lang, "ui.sync.house", { x: f.house }), f.age && humanAge(f.age, lang), ...ages, f.cause && ui(lang, "ui.sync.cause", { x: f.cause })].filter(Boolean).join(", ");
|
|
23
31
|
}
|
|
24
32
|
/** A position on the map as a map shows it: degrees, six decimals at most. */
|
|
25
33
|
const point = (p) => `${+p.lat.toFixed(6)}, ${+p.lon.toFixed(6)}`;
|
|
@@ -34,15 +42,18 @@ function changeLine(tree, c, incoming, lang) {
|
|
|
34
42
|
return p ? `${(p.names[0] ? `${p.names[0].given} ${p.names[0].surname}` : key).trim()} [${key}]` : key;
|
|
35
43
|
};
|
|
36
44
|
const who = c.partners ? c.partners.map(name).join(" & ") || c.family || "?" : name(c.person);
|
|
45
|
+
// a partner in their age: the given name
|
|
46
|
+
const short = (key) => (key.startsWith("x:") ? name(key) : (tree.get(key)?.names[0]?.given ?? key));
|
|
37
47
|
const kind = (f) => (f ? eventName(f.kind, lang, f.label) : "");
|
|
38
48
|
const v = {
|
|
39
49
|
n: c.n,
|
|
40
50
|
who,
|
|
41
51
|
name: name(c.person),
|
|
42
52
|
kind: kind(c.fact ?? c.was),
|
|
43
|
-
fact: `${kind(c.fact ?? c.was)} ${factText(c.fact ?? c.was, lang)}`,
|
|
44
|
-
was: factText(c.was, lang),
|
|
45
|
-
now: factText(c.fact, lang),
|
|
53
|
+
fact: `${kind(c.fact ?? c.was)} ${factText(c.fact ?? c.was, lang, short)}`,
|
|
54
|
+
was: c.kind === "fact.detail" ? detailText(c.was, lang, short) || "—" : factText(c.was, lang, short),
|
|
55
|
+
now: c.kind === "fact.detail" ? detailText(c.fact, lang, short) : factText(c.fact, lang, short),
|
|
56
|
+
when: [humanDate(c.fact?.date, lang), humanPlace(c.fact?.place, undefined, lang)].filter(Boolean).join(", "),
|
|
46
57
|
text: truncate(c.text ?? "", 120),
|
|
47
58
|
child: name(c.child),
|
|
48
59
|
kids: c.kind === "family.new" && c.text ? ` + ${c.text.split(" ").filter(Boolean).map(name).join(", ")}` : "",
|
|
@@ -51,7 +62,8 @@ function changeLine(tree, c, incoming, lang) {
|
|
|
51
62
|
from: c.place?.was ? point(c.place.was) : "",
|
|
52
63
|
};
|
|
53
64
|
const key = c.kind === "place.coords" && !c.place?.was ? "ui.sync.place.located" : `ui.sync.${c.kind}`;
|
|
54
|
-
|
|
65
|
+
const does = c.kind === "fact.detail" && c.action === "add" ? "ui.sync.do.detail" : `ui.sync.do.${c.action}`;
|
|
66
|
+
return `${ui(lang, key, v)} → ${ui(lang, does)}`;
|
|
55
67
|
}
|
|
56
68
|
function planText(tree, plan, incoming, file, lang, edits) {
|
|
57
69
|
const shown = file.includes(" ") ? `"${file}"` : file;
|
package/dist/commands/tasks.js
CHANGED
|
@@ -22,6 +22,7 @@ import { readJsonLines } from "../core/json.js";
|
|
|
22
22
|
import { lacksImages, rankTasks } from "../core/queue.js";
|
|
23
23
|
import { joiningHypotheses, offTree } from "../core/kin.js";
|
|
24
24
|
import { aboutPeople } from "../core/directions.js";
|
|
25
|
+
import { storiesToApprove } from "../core/stories.js";
|
|
25
26
|
import { clipNote, inboxFolderFor, parseImageList, transcriptNote } from "../core/media.js";
|
|
26
27
|
function written(tree) {
|
|
27
28
|
return lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined);
|
|
@@ -63,8 +64,11 @@ export function waitingForUser(tree) {
|
|
|
63
64
|
*/
|
|
64
65
|
export function waitingLines(tree, where = {}, max = 5) {
|
|
65
66
|
const w = tree.list("task").filter((t) => t.state === "waiting");
|
|
67
|
+
// the new versions of stories the user approved: they decide (the lock)
|
|
68
|
+
const stories = storiesToApprove(tree).map((s) => s.id);
|
|
69
|
+
const storyLine = stories.length ? ` ${ui(tree.lang, "ui.wait.stories", { ids: stories.join(" ") })}` : undefined;
|
|
66
70
|
if (!w.length)
|
|
67
|
-
return undefined;
|
|
71
|
+
return stories.length ? lines(ui(tree.lang, "ui.wait.title", { n: stories.length }), storyLine) : undefined;
|
|
68
72
|
const show = where.display ?? ((p) => p);
|
|
69
73
|
const lang = tree.lang;
|
|
70
74
|
const images = (t) => {
|
|
@@ -77,7 +81,7 @@ export function waitingLines(tree, where = {}, max = 5) {
|
|
|
77
81
|
` ${ui(lang, "ui.wait.save", { dir: `${dir}${path.sep}` })}`,
|
|
78
82
|
];
|
|
79
83
|
};
|
|
80
|
-
return lines(ui(lang, "ui.wait.title", { n: w.length }), ...w.slice(0, max).flatMap((t) => [` ${t.id} ${truncate(t.waitingOn || t.what, 150)}`, ...images(t)]), w.length > max ? ` … strom task list --state waiting` : undefined, ` ${ui(lang, "ui.wait.how1")}`, ` ${ui(lang, "ui.wait.how2")}`);
|
|
84
|
+
return lines(ui(lang, "ui.wait.title", { n: w.length + stories.length }), ...w.slice(0, max).flatMap((t) => [` ${t.id} ${truncate(t.waitingOn || t.what, 150)}`, ...images(t)]), w.length > max ? ` … strom task list --state waiting` : undefined, storyLine, ` ${ui(lang, "ui.wait.how1")}`, ` ${ui(lang, "ui.wait.how2")}`);
|
|
81
85
|
}
|
|
82
86
|
/** The open tasks of the queue in the order to work on them (see core/queue.ts). */
|
|
83
87
|
export function taskQueue(tree, filter = {}) {
|
package/dist/core/actions.js
CHANGED
|
@@ -589,8 +589,10 @@ export function editPerson(tree, id, edit, reason) {
|
|
|
589
589
|
*/
|
|
590
590
|
export function editEvent(tree, eventId, edit, reason) {
|
|
591
591
|
const date = edit.date === undefined ? undefined : parseDate(edit.date);
|
|
592
|
-
|
|
593
|
-
const
|
|
592
|
+
// an empty age takes it away
|
|
593
|
+
const age = edit.age === undefined ? undefined : edit.age.trim() ? parseAge(edit.age) : "";
|
|
594
|
+
// an empty age of a partner takes it away
|
|
595
|
+
const ages = edit.ages ? Object.fromEntries(Object.entries(edit.ages).map(([who, a]) => [who, a.trim() ? parseAge(a, who) : ""])) : undefined;
|
|
594
596
|
return replaceEvent(tree, eventId, (e) => {
|
|
595
597
|
const changed = [];
|
|
596
598
|
const set = (k, v) => {
|
|
@@ -620,7 +622,11 @@ export function editEvent(tree, eventId, edit, reason) {
|
|
|
620
622
|
for (const [who, a] of Object.entries(ages))
|
|
621
623
|
if (e.ages?.[who] !== undefined && e.ages[who] !== a)
|
|
622
624
|
changed.push(`age of ${who}`);
|
|
623
|
-
|
|
625
|
+
const merged = Object.fromEntries(Object.entries({ ...e.ages, ...ages }).filter(([, a]) => a));
|
|
626
|
+
if (Object.keys(merged).length)
|
|
627
|
+
next.ages = merged;
|
|
628
|
+
else
|
|
629
|
+
delete next.ages;
|
|
624
630
|
}
|
|
625
631
|
const key = (p) => `${p.role}|${p.person ?? foldText(p.name ?? "")}`;
|
|
626
632
|
if (edit.drop?.length) {
|
|
@@ -821,7 +827,11 @@ export function mergeFamilies(tree, keepId, otherId, reason) {
|
|
|
821
827
|
return { family: merged, repointed };
|
|
822
828
|
});
|
|
823
829
|
}
|
|
824
|
-
/**
|
|
830
|
+
/**
|
|
831
|
+
* Write (or rewrite) the story of a person or a couple. The facts it leans on must exist. A story the user approved is
|
|
832
|
+
* locked: written again, the new version waits beside it for their approval (story.draft) — unless they approve it now
|
|
833
|
+
* (final).
|
|
834
|
+
*/
|
|
825
835
|
export function setStory(tree, id, input) {
|
|
826
836
|
const text = input.text.replace(/\r\n?/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
827
837
|
if (!text)
|
|
@@ -829,28 +839,55 @@ export function setStory(tree, id, input) {
|
|
|
829
839
|
const facts = [...new Set((input.facts ?? []).map((f) => f.trim().toUpperCase()))];
|
|
830
840
|
for (const f of facts)
|
|
831
841
|
findEventOwner(tree, f);
|
|
842
|
+
const sources = [...new Set((input.sources ?? []).map((s) => s.trim().toUpperCase()))];
|
|
843
|
+
for (const s of sources)
|
|
844
|
+
if (tree.get(s)?.type !== "source")
|
|
845
|
+
throw new UsageError(`no source ${s}`, { hint: "the background of place and time is recorded first: strom source add … --kind book|newspaper|web" });
|
|
832
846
|
return tree.withTreeLock(() => {
|
|
833
847
|
const rec = tree.get(id);
|
|
834
848
|
if (!rec || (rec.type !== "person" && rec.type !== "family"))
|
|
835
849
|
throw new UsageError(`no person or family ${id}`);
|
|
836
850
|
if (rec.mergedInto)
|
|
837
851
|
throw new UsageError(`${id} was merged into ${rec.mergedInto}`);
|
|
838
|
-
const
|
|
852
|
+
const version = {
|
|
839
853
|
...(input.title?.trim() ? { title: input.title.trim() } : {}),
|
|
840
|
-
status: input.final ? "final" : "draft",
|
|
841
854
|
text,
|
|
842
855
|
facts,
|
|
856
|
+
...(sources.length ? { sources } : {}),
|
|
843
857
|
...(input.note?.trim() ? { note: input.note.trim() } : {}),
|
|
844
858
|
at: now(),
|
|
845
859
|
by: tree.actor,
|
|
846
860
|
};
|
|
847
|
-
const updated = { ...rec, story, updated: now() };
|
|
848
861
|
const words = text.split(/\s+/).length;
|
|
862
|
+
if (rec.story?.status === "final" && !input.final) {
|
|
863
|
+
const updated = { ...rec, story: { ...rec.story, draft: version }, updated: now() };
|
|
864
|
+
tree.put(updated, { op: "story.set", targets: [id], summary: `${id} story: a new version (${words} words) waits for the user's approval; the approved one stays` });
|
|
865
|
+
return updated;
|
|
866
|
+
}
|
|
867
|
+
const story = { ...version, status: input.final ? "final" : "draft" };
|
|
868
|
+
const updated = { ...rec, story, updated: now() };
|
|
849
869
|
tree.put(updated, { op: "story.set", targets: [id], summary: `${id} story${rec.story ? " rewritten" : ""}: ${words} words, ${story.status}` });
|
|
850
870
|
return updated;
|
|
851
871
|
});
|
|
852
872
|
}
|
|
853
|
-
/** The
|
|
873
|
+
/** The new version of an approved story is not wanted: it goes, the approved story stays (the history keeps it). */
|
|
874
|
+
export function discardStoryDraft(tree, id) {
|
|
875
|
+
return tree.withTreeLock(() => {
|
|
876
|
+
const rec = tree.get(id);
|
|
877
|
+
if (!rec || (rec.type !== "person" && rec.type !== "family"))
|
|
878
|
+
throw new UsageError(`no person or family ${id}`);
|
|
879
|
+
if (!rec.story?.draft)
|
|
880
|
+
throw new UsageError(`${id} has no new version of its story waiting`, { hint: `strom story show ${id}` });
|
|
881
|
+
const { draft: _gone, ...story } = rec.story;
|
|
882
|
+
const updated = { ...rec, story, updated: now() };
|
|
883
|
+
tree.put(updated, { op: "story.discard", targets: [id], summary: `${id} story: the new version discarded, the approved one stays` });
|
|
884
|
+
return updated;
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* The user approved the story as it is: no longer a draft (the text stays), locked — or, when a new version waits
|
|
889
|
+
* beside an approved one, that new version takes its place, approved.
|
|
890
|
+
*/
|
|
854
891
|
export function approveStory(tree, id) {
|
|
855
892
|
return tree.withTreeLock(() => {
|
|
856
893
|
const rec = tree.get(id);
|
|
@@ -858,6 +895,11 @@ export function approveStory(tree, id) {
|
|
|
858
895
|
throw new UsageError(`no person or family ${id}`);
|
|
859
896
|
if (!rec.story)
|
|
860
897
|
throw new UsageError(`${id} has no story yet`, { hint: `strom story set ${id} --text @file --fact E…` });
|
|
898
|
+
if (rec.story.draft) {
|
|
899
|
+
const updated = { ...rec, story: { ...rec.story.draft, status: "final" }, updated: now() };
|
|
900
|
+
tree.put(updated, { op: "story.approve", targets: [id], summary: `${id} story: the new version approved, in place of the old one` });
|
|
901
|
+
return updated;
|
|
902
|
+
}
|
|
861
903
|
if (rec.story.status === "final")
|
|
862
904
|
return rec;
|
|
863
905
|
const updated = { ...rec, story: { ...rec.story, status: "final" }, updated: now() };
|
package/dist/core/changelog.js
CHANGED
|
@@ -48,6 +48,7 @@ const SAID = {
|
|
|
48
48
|
"repository.edit": "log.repository.edit",
|
|
49
49
|
"story.set": "log.story.set",
|
|
50
50
|
"story.approve": "log.story.approve",
|
|
51
|
+
"story.discard": "log.story.discard",
|
|
51
52
|
"note.add": "log.note.add",
|
|
52
53
|
"input.add": "log.input.add",
|
|
53
54
|
"input.sync": "log.input.sync",
|
package/dist/core/config.js
CHANGED
|
@@ -44,6 +44,7 @@ export const SETTINGS = [
|
|
|
44
44
|
{ key: "queue.strategy", env: "STROM_QUEUE_STRATEGY", tree: true, kind: "choice", choices: STRATEGIES, description: "order of the task queue: balanced (default — nearest ancestors first, spread over the lines, nothing taken forever), depth (stay on one line), priority (strict priority)" },
|
|
45
45
|
{ key: "gedcom.for", env: "STROM_GEDCOM_FOR", tree: true, kind: "choice", choices: ["both", "standard", "strom"], description: "GEDCOM files written: both (default), standard (any program), strom (the Strom app)" },
|
|
46
46
|
{ key: "stories", env: "STROM_STORIES", tree: true, kind: "choice", choices: ["yes", "no"], description: "stories of the ancestors for the family, written from the facts: yes (default — strom proposes one once a person's life is told by records), no — the user is told when the research starts and may say no" },
|
|
47
|
+
{ key: "stories.sources", env: "STROM_STORIES_SOURCES", tree: true, kind: "choice", choices: ["yes", "no"], description: "before a story is written, a search beyond the registers — newspapers, directories, military, land and court records, graves, the history of the place — so the story can tell more, every word still on a record: yes (default, while stories are on), no (the story from what the research found)" },
|
|
47
48
|
{ key: "sync.edits", env: "STROM_SYNC_EDITS", tree: true, kind: "choice", choices: ["conflict", "user"], description: "your edits in the Strom app (or another program's file) to a fact a record proves, taken by strom sync: conflict (default — the research keeps the record's, a conflict for you to decide) or user (your edit wins, the record's fact is withdrawn with the reason)" },
|
|
48
49
|
{ key: "main.person", env: "STROM_MAIN_PERSON", tree: true, kind: "person", description: "the main person of the tree (P…): first in the GEDCOM files — the Strom app opens on them (default: the nearest person descended from every research's focus, else the first research's focus)" },
|
|
49
50
|
{ key: "excerpts.quality", env: "STROM_EXCERPTS_QUALITY", tree: true, kind: "choice", choices: EXCERPT_QUALITIES, description: "the entries cut out of their scans in the file for the Strom app (output/tree-strom.ged): small (1000 px, grey — half the size), normal (default, 1200 px), sharp (1600 px)" },
|
|
@@ -362,6 +363,10 @@ export class Settings {
|
|
|
362
363
|
const v = this.resolve("stories", tree)?.value;
|
|
363
364
|
return { on: v !== "no", said: v === "yes" || v === "no" };
|
|
364
365
|
}
|
|
366
|
+
/** Before a story, the sources beyond the registers (core/stories.ts): on unless the user said no. */
|
|
367
|
+
storySources(tree) {
|
|
368
|
+
return this.resolve("stories.sources", tree)?.value !== "no";
|
|
369
|
+
}
|
|
365
370
|
/** strom sync: whether the user's edit to a fact a record proves wins, or becomes a conflict. */
|
|
366
371
|
syncEdits(tree) {
|
|
367
372
|
return this.resolve("sync.edits", tree)?.value === "user" ? "user" : "conflict";
|
package/dist/core/import.js
CHANGED
|
@@ -40,7 +40,47 @@ export function importDate(raw) {
|
|
|
40
40
|
.replace(/^(AFTER|>)\s*/, "AFT ")
|
|
41
41
|
.replace(/\?$/, "")
|
|
42
42
|
.trim();
|
|
43
|
-
return normalizeDate(cleaned);
|
|
43
|
+
return normalizeDate(cleaned) ?? localDate(raw);
|
|
44
|
+
}
|
|
45
|
+
// Words other programs write in place of GEDCOM's ("Po 1919", "kolem 1850", "zwischen 1811 und 1812",
|
|
46
|
+
// "около 1870"), folded: Czech, Slovak, German, Polish, Russian, Ukrainian, English.
|
|
47
|
+
const DATE_QUALIFIER = {
|
|
48
|
+
...Object.fromEntries(["after", "po", "nach", "после", "після"].map((w) => [w, "AFT"])),
|
|
49
|
+
...Object.fromEntries(["before", "pred", "vor", "przed", "do", "bis", "до"].map((w) => [w, "BEF"])),
|
|
50
|
+
...Object.fromEntries(["about", "circa", "ca", "c", "cca", "kolem", "okolo", "asi", "zhruba", "priblizne", "um", "etwa", "ungefahr", "ok", "около", "близько", "приблизно"].map((w) => [w, "ABT"])),
|
|
51
|
+
};
|
|
52
|
+
const DATE_RANGE = {
|
|
53
|
+
...Object.fromEntries(["between", "mezi", "medzi", "miedzy", "pomiedzy", "zwischen", "между", "між"].map((w) => [w, "BET"])),
|
|
54
|
+
...Object.fromEntries(["from", "od", "von", "ab", "с", "з", "від"].map((w) => [w, "FROM"])),
|
|
55
|
+
};
|
|
56
|
+
const DATE_JOIN = new Set(["and", "a", "i", "und", "и", "і", "та", "to", "do", "bis", "по", "до"]);
|
|
57
|
+
/** "po roce 1919", "im Jahr 1850", "1870 г.": words around the year that say nothing. */
|
|
58
|
+
const DATE_FILLER = new Set(["r", "roku", "roce", "rokem", "rok", "im", "jahr", "jahre", "year", "the", "г", "года", "году", "року", "рік"]);
|
|
59
|
+
/** A date written in words of a language ("Po 1919" → "AFT 1919"); undefined when it is not one. */
|
|
60
|
+
function localDate(raw) {
|
|
61
|
+
const words = foldText(raw)
|
|
62
|
+
.split(" ")
|
|
63
|
+
.map((w) => w.replace(/[.,]+$/u, ""))
|
|
64
|
+
.filter((w) => w && !DATE_FILLER.has(w));
|
|
65
|
+
const plain = (part) => {
|
|
66
|
+
const d = part.length ? normalizeDate(part.join(" ")) : undefined;
|
|
67
|
+
return d && !/^(ABT|CAL|EST|BEF|AFT|BET|FROM|TO) /.test(d) ? d : undefined;
|
|
68
|
+
};
|
|
69
|
+
const first = words[0] ?? "";
|
|
70
|
+
const range = DATE_RANGE[first];
|
|
71
|
+
if (range) {
|
|
72
|
+
const j = words.findIndex((w, i) => i > 1 && DATE_JOIN.has(w));
|
|
73
|
+
const a = plain(j > 0 ? words.slice(1, j) : words.slice(1));
|
|
74
|
+
const b = j > 0 ? plain(words.slice(j + 1)) : undefined;
|
|
75
|
+
if (!a)
|
|
76
|
+
return undefined;
|
|
77
|
+
if (range === "BET")
|
|
78
|
+
return b ? `BET ${a} AND ${b}` : undefined;
|
|
79
|
+
return j > 0 ? (b ? `FROM ${a} TO ${b}` : undefined) : `FROM ${a}`;
|
|
80
|
+
}
|
|
81
|
+
const q = DATE_QUALIFIER[first];
|
|
82
|
+
const d = q ? plain(words.slice(1)) : undefined;
|
|
83
|
+
return q && d ? `${q} ${d}` : undefined;
|
|
44
84
|
}
|
|
45
85
|
function makeSource(tree, title, input) {
|
|
46
86
|
const t = now();
|
|
@@ -405,6 +445,12 @@ export function isStromJson(data) {
|
|
|
405
445
|
export function fromFlexDate(flex) {
|
|
406
446
|
if (!flex)
|
|
407
447
|
return undefined;
|
|
448
|
+
// a range ("1785..1804"): between the two
|
|
449
|
+
const range = /^(.+?)\.\.(.+)$/.exec(flex.trim());
|
|
450
|
+
if (range) {
|
|
451
|
+
const [a, b] = [fromFlexDate(range[1]), fromFlexDate(range[2])];
|
|
452
|
+
return a && b && !/^(ABT|BEF|AFT|BET|FROM|TO) /.test(a) && !/^(ABT|BEF|AFT|BET|FROM|TO) /.test(b) ? normalizeDate(`BET ${a} AND ${b}`) : undefined;
|
|
453
|
+
}
|
|
408
454
|
const m = /^([~<>])?(\d{3,4})(?:-(\d{2}))?(?:-(\d{2}))?$/.exec(flex.trim());
|
|
409
455
|
if (!m)
|
|
410
456
|
return importDate(flex);
|
|
@@ -496,7 +542,7 @@ export function importStromJson(tree, data, opts) {
|
|
|
496
542
|
return result;
|
|
497
543
|
}
|
|
498
544
|
/** "čp. 13", "č. p. 13", "Nr. 13", "House No. 13" → "13": the house, without the word for it. */
|
|
499
|
-
function houseOf(address) {
|
|
545
|
+
export function houseOf(address) {
|
|
500
546
|
return address
|
|
501
547
|
.replace(/\s+/g, " ")
|
|
502
548
|
.trim()
|
package/dist/core/links.js
CHANGED
|
@@ -33,6 +33,8 @@ export const LINK_SCHEME = "strom-research";
|
|
|
33
33
|
* and (the app's 3.5.0) the research followed live in the app's window; a direction of the research paused, ended or
|
|
34
34
|
* taken up again, a conversation about one direction, a session at work asked to finish.
|
|
35
35
|
*/
|
|
36
|
+
/** A story: approved (or, beside an approved one, its new version approved in its place), or that new version not wanted. */
|
|
37
|
+
export const LINK_STORY_DOS = ["final", "keep"];
|
|
36
38
|
export const LINK_ACTIONS = ["send", "excerpt", "app", "open", "chat", "task", "review", "research", "new", "update", "sessions", "conflict", "story", "sync-undo", "setup", "live", "direction", "finish"];
|
|
37
39
|
/** A person's review: the person, with the family, with the ancestors (strom review --scope). */
|
|
38
40
|
export const LINK_SCOPES = ["person", "family", "line"];
|
|
@@ -139,7 +141,9 @@ export function parseLink(text) {
|
|
|
139
141
|
return { action, tree: id, person, scope: oneOf(q, "scope", LINK_SCOPES, "person") };
|
|
140
142
|
if (action === "research")
|
|
141
143
|
return { action, tree: id, person, direction: oneOf(q, "direction", LINK_DIRECTIONS, "ancestors") };
|
|
142
|
-
|
|
144
|
+
// a couple's story: the app knows no family IDs, so the family of the two partners
|
|
145
|
+
const partner = idOf(q, "partner", PERSON, false);
|
|
146
|
+
return { action, tree: id, person, ...(partner ? { partner } : {}), do: oneOf(q, "do", LINK_STORY_DOS) };
|
|
143
147
|
}
|
|
144
148
|
case "conflict":
|
|
145
149
|
return { action, tree: id, id: idOf(q, "id", CONFLICT, true), do: oneOf(q, "do", LINK_CONFLICT_DOS, "decide") };
|
package/dist/core/live.js
CHANGED
|
@@ -35,7 +35,7 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
35
35
|
import { Tree, VERSION } from "./tree.js";
|
|
36
36
|
import { changeLines } from "./changelog.js";
|
|
37
37
|
import { directionOf, scopes } from "./directions.js";
|
|
38
|
-
import { ancestorGenerations } from "./people.js";
|
|
38
|
+
import { ancestorGenerations, displayName } from "./people.js";
|
|
39
39
|
import { phrase } from "./phrases.js";
|
|
40
40
|
import { exportGedcom } from "../gedcom/export.js";
|
|
41
41
|
import { excerptSettings, planExcerpts } from "./excerpt.js";
|
|
@@ -49,10 +49,11 @@ import { rankTasks } from "./queue.js";
|
|
|
49
49
|
import { humanTask } from "../cli/human.js";
|
|
50
50
|
import { knownNewerVersion } from "./update.js";
|
|
51
51
|
import { gitProgram, runGit } from "./git.js";
|
|
52
|
-
import { appOpensLinks, appShowsEdges, isStromAppOrigin } from "./stromapp.js";
|
|
52
|
+
import { appOpensLinks, appShowsCoupleEvents, appShowsEdges, appShowsStoryDrafts, isStromAppOrigin } from "./stromapp.js";
|
|
53
53
|
import { Settings } from "./config.js";
|
|
54
54
|
import { linkActions, linkHandlerState } from "./links.js";
|
|
55
55
|
import { stromLauncher } from "./self.js";
|
|
56
|
+
import { storiesToApprove } from "./stories.js";
|
|
56
57
|
/** Where a running bridge of a tree is noted. */
|
|
57
58
|
export function liveFile(root) {
|
|
58
59
|
return path.join(root, ".strom", "live.json");
|
|
@@ -300,6 +301,30 @@ function personOf(t) {
|
|
|
300
301
|
const people = (t?.subject ?? []).filter((id) => /^P\d{1,7}$/.test(id));
|
|
301
302
|
return people.length === 1 ? { person: people[0] } : {};
|
|
302
303
|
}
|
|
304
|
+
/** The new versions of approved stories, as items of `waiting`: whose (a couple's: person and partner), since when. */
|
|
305
|
+
function storiesWaiting(tree) {
|
|
306
|
+
return storiesToApprove(tree).flatMap((s) => {
|
|
307
|
+
const rec = tree.get(s.id);
|
|
308
|
+
if (!rec)
|
|
309
|
+
return [];
|
|
310
|
+
const people = rec.type === "family" ? rec.partners : [rec.id];
|
|
311
|
+
const name = (id) => {
|
|
312
|
+
const p = tree.get(id);
|
|
313
|
+
return p ? displayName(p) : id;
|
|
314
|
+
};
|
|
315
|
+
return [
|
|
316
|
+
{
|
|
317
|
+
kind: "story",
|
|
318
|
+
id: rec.id,
|
|
319
|
+
what: ui(tree.lang, "ui.waiting.story", { who: people.map(name).join(" & ") }),
|
|
320
|
+
on: "",
|
|
321
|
+
at: s.at,
|
|
322
|
+
...(people[0] ? { person: people[0] } : {}),
|
|
323
|
+
...(people[1] ? { partner: people[1] } : {}),
|
|
324
|
+
},
|
|
325
|
+
];
|
|
326
|
+
});
|
|
327
|
+
}
|
|
303
328
|
/** Who works, as the user reads it: "Claude Code on its own", "Codex conversation" in the research language. */
|
|
304
329
|
function whoAtWork(label, lang) {
|
|
305
330
|
const run = /^(.+) on its own$/.exec(label);
|
|
@@ -459,7 +484,11 @@ function status(root, env) {
|
|
|
459
484
|
working: atWork,
|
|
460
485
|
open: openSessions(tree).map((s) => ({ id: s.id, task: s.task, started: s.started })),
|
|
461
486
|
// since when it waits: its last change is the one that made it wait
|
|
462
|
-
waiting:
|
|
487
|
+
waiting: [
|
|
488
|
+
...waiting.map((t) => ({ id: t.id, what: t.what, on: t.waitingOn ?? "", at: t.updated, ...personOf(t), ...researchOf(tree, t, all) })),
|
|
489
|
+
// a new version of an approved story, as the menu counts it — for an app that shows it (the _DRAFT gate)
|
|
490
|
+
...(appShowsStoryDrafts(settings) ? storiesWaiting(tree) : []),
|
|
491
|
+
],
|
|
463
492
|
links: links(env),
|
|
464
493
|
...queue(tree, next, all),
|
|
465
494
|
...(newer ? { update: { version: newer } } : {}),
|
|
@@ -566,11 +595,13 @@ export function serveLive(root, env) {
|
|
|
566
595
|
const opens = appOpensLinks(new Settings(env, {}));
|
|
567
596
|
// …and where the tree ends, for an app that shows it
|
|
568
597
|
const edges = appShowsEdges(new Settings(env, {}));
|
|
598
|
+
const storyDrafts = appShowsStoryDrafts(new Settings(env, {}));
|
|
599
|
+
const coupleResi = appShowsCoupleEvents(new Settings(env, {}));
|
|
569
600
|
const offered = opens ? links(env) : [];
|
|
570
601
|
ged = {
|
|
571
602
|
head: h,
|
|
572
603
|
links: offered.join(" "),
|
|
573
|
-
text: exportGedcom(tree, { for: "strom", ...(h ? { head: h } : {}), ...(images ? { excerpts: images.of } : {}), ...(opens ? { clips: true, research: true } : {}), ...(edges ? { edges } : {}), ...(offered.length ? { links: offered } : {}) }).text,
|
|
604
|
+
text: exportGedcom(tree, { for: "strom", ...(h ? { head: h } : {}), ...(images ? { excerpts: images.of } : {}), ...(opens ? { clips: true, research: true } : {}), ...(edges ? { edges } : {}), ...(storyDrafts ? { storyDrafts } : {}), ...(coupleResi ? { coupleResi } : {}), ...(offered.length ? { links: offered } : {}) }).text,
|
|
574
605
|
};
|
|
575
606
|
}
|
|
576
607
|
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", "X-Strom-Head": h }).end(ged.text);
|
package/dist/core/phrases.js
CHANGED
|
@@ -31,6 +31,10 @@ export const PHRASES = {
|
|
|
31
31
|
"story.where": "the facts recorded (strom person show {id})",
|
|
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
|
+
"story.sources.what": "Beyond the registers, for the story of {name}: newspapers, directories, military, land and court records, graves, the history of the place",
|
|
35
|
+
"story.sources.where": "the digitised newspapers, books and archives of the country and time; the web (indexes and trees are leads only)",
|
|
36
|
+
"story.sources.why": "the story is to tell more than the registers do — every word still on a record; then strom proposes the story",
|
|
37
|
+
"story.sources.done": "each find recorded as a source and a fact with its citation (the person matched by more than the name), the history of the place as a source, every search recorded — also in vain",
|
|
34
38
|
"review.research": "Review of {name}",
|
|
35
39
|
"review.unproven.research": "People without a record of their own",
|
|
36
40
|
"review.more": " (and {count} more after these)",
|
|
@@ -130,6 +134,7 @@ export const PHRASES = {
|
|
|
130
134
|
"log.repository.edit": "Archive changed: {name}",
|
|
131
135
|
"log.story.set": "Story: {who}",
|
|
132
136
|
"log.story.approve": "Story approved: {who}",
|
|
137
|
+
"log.story.discard": "The new version of the story not wanted, the approved one stays: {who}",
|
|
133
138
|
"log.note.add": "Note: {name}",
|
|
134
139
|
"log.input.add": "Material from the family: {name}",
|
|
135
140
|
"log.input.sync": "Family tree taken in: {name}",
|
package/dist/core/queue.js
CHANGED
|
@@ -16,6 +16,7 @@ import { now } from "./tree.js";
|
|
|
16
16
|
import { subjectPeople } from "./records.js";
|
|
17
17
|
import { directionOf, scopes } from "./directions.js";
|
|
18
18
|
import { offTree } from "./kin.js";
|
|
19
|
+
import { forStory } from "./stories.js";
|
|
19
20
|
export const LEVEL_ORDER = { intake: 0, locate: 1, link: 2, verify: 3, enrich: 4, request: 5, narrate: 6 };
|
|
20
21
|
/** Levels that read records: without images of their record sets they can only ask the user for them. */
|
|
21
22
|
const NEEDS_IMAGES = new Set(["link", "verify"]);
|
|
@@ -92,7 +93,10 @@ export function rankTasks(tree, tasks, strategy = "balanced", opts = {}) {
|
|
|
92
93
|
return t ? (place(t)?.line ?? `task:${t.id}`) : undefined;
|
|
93
94
|
});
|
|
94
95
|
// A story's turn: STORY_EVERY sessions since the last one on a story (or ever), and one waits.
|
|
95
|
-
const sinceStory = ended.findIndex((s) =>
|
|
96
|
+
const sinceStory = ended.findIndex((s) => {
|
|
97
|
+
const t = tasksById.get(s.task);
|
|
98
|
+
return !!t && forStory(t);
|
|
99
|
+
});
|
|
96
100
|
const storyDue = !!opts.storyTurn && strategy !== "priority" && (sinceStory === -1 ? ended.length : sinceStory) >= STORY_EVERY;
|
|
97
101
|
const tries = new Map();
|
|
98
102
|
for (const s of ended)
|
|
@@ -110,7 +114,7 @@ export function rankTasks(tree, tasks, strategy = "balanced", opts = {}) {
|
|
|
110
114
|
? 0
|
|
111
115
|
: 2 * t.priority + intake - (gen - 1) - 2 * recent - 1.5 * tried;
|
|
112
116
|
const blocked = lacksImages(tree, t, withImages);
|
|
113
|
-
const storyTurn = storyDue && t
|
|
117
|
+
const storyTurn = storyDue && forStory(t);
|
|
114
118
|
const why = [
|
|
115
119
|
t.state === "doing" ? "in progress" : undefined,
|
|
116
120
|
storyTurn ? `a story's turn: none in the last ${STORY_EVERY} sessions` : undefined,
|
|
@@ -122,7 +126,7 @@ export function rankTasks(tree, tasks, strategy = "balanced", opts = {}) {
|
|
|
122
126
|
]
|
|
123
127
|
.filter(Boolean)
|
|
124
128
|
.join(" · ");
|
|
125
|
-
return { task: t, why, doing: t.state === "doing" ? 0 : 1, blocked: blocked ? 1 : 0, story: storyTurn ? 0 : t
|
|
129
|
+
return { task: t, why, doing: t.state === "doing" ? 0 : 1, blocked: blocked ? 1 : 0, story: storyTurn ? 0 : forStory(t) ? 2 : 1, score, gen };
|
|
126
130
|
});
|
|
127
131
|
return rows
|
|
128
132
|
.sort((a, b) => a.doing - b.doing ||
|
package/dist/core/stories.js
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
// Stories of the ancestors (the setting `stories`, on by default): once records
|
|
2
2
|
// tell enough of a person's life, strom proposes the task of writing it for the
|
|
3
3
|
// family — and of adding to it when records brought more after it was written.
|
|
4
|
-
// The story goes into the family book of the Strom app (GEDCOM _STORY).
|
|
4
|
+
// The story goes into the family book of the Strom app (GEDCOM _STORY). Before
|
|
5
|
+
// it (the setting `stories.sources`, on by default), once per person a search
|
|
6
|
+
// beyond the registers — newspapers, directories, military, land and court
|
|
7
|
+
// records, graves, the history of the place — so the story can tell more, every
|
|
8
|
+
// word still on a record; the story is proposed when that search is finished.
|
|
5
9
|
import { researchPeople } from "./frontier.js";
|
|
6
10
|
import { displayName, familiesAsPartner, lifespan } from "./people.js";
|
|
7
11
|
import { phrase } from "./phrases.js";
|
|
12
|
+
import { Settings } from "./config.js";
|
|
13
|
+
/** The origin of the search beyond the registers that comes before a story (an enrich task). */
|
|
14
|
+
export const STORY_SOURCES_ORIGIN = "story:sources";
|
|
15
|
+
/** Work for a story: writing it, or the search beyond the registers before it — the queue keeps them after the research. */
|
|
16
|
+
export function forStory(t) {
|
|
17
|
+
return t.level === "narrate" || t.origin === STORY_SOURCES_ORIGIN;
|
|
18
|
+
}
|
|
8
19
|
const FROM_RECORDS = new Set(["proven", "probable"]);
|
|
9
20
|
const BIRTHS = new Set(["BIRT", "CHR", "BAPM"]);
|
|
10
21
|
const OPEN = new Set(["open", "doing", "parked", "waiting"]);
|
|
@@ -22,7 +33,9 @@ export function factsFromRecords(tree, p) {
|
|
|
22
33
|
/** The stories strom proposes to write (or add to) for the ancestors of a research. */
|
|
23
34
|
export function storyProposals(tree, research) {
|
|
24
35
|
const lang = tree.lang;
|
|
25
|
-
const
|
|
36
|
+
const tasks = tree.list("task");
|
|
37
|
+
const narrate = tasks.filter((t) => t.level === "narrate");
|
|
38
|
+
const sources = new Settings(tree.env, {}).storySources(tree.config);
|
|
26
39
|
const out = [];
|
|
27
40
|
for (const [id, generation] of researchPeople(tree, research)) {
|
|
28
41
|
const p = tree.get(id);
|
|
@@ -34,17 +47,40 @@ export function storyProposals(tree, research) {
|
|
|
34
47
|
const mine = narrate.filter((t) => t.subject.includes(id));
|
|
35
48
|
if (mine.some((t) => OPEN.has(t.state)))
|
|
36
49
|
continue;
|
|
50
|
+
// The latest version written: a new one waiting beside an approved story, else the story.
|
|
51
|
+
const latest = p.story?.draft ?? p.story;
|
|
37
52
|
// Dropped since the story was written (or with none): that was the answer — not again until the story changes.
|
|
38
|
-
const since =
|
|
53
|
+
const since = latest?.at ?? "";
|
|
39
54
|
if (mine.some((t) => t.state === "dropped" && t.updated >= since))
|
|
40
55
|
continue;
|
|
41
56
|
// Done, yet no story: somebody decided so; the user can add the task by hand.
|
|
42
57
|
if (!p.story && mine.some((t) => FINISHED.has(t.state)))
|
|
43
58
|
continue;
|
|
44
59
|
const name = `${displayName(p)}${lifespan(p) ? ` (${lifespan(p)})` : ""}`;
|
|
45
|
-
const fresh =
|
|
60
|
+
const fresh = latest ? facts.filter((e) => !latest.facts.includes(e.id)) : facts;
|
|
46
61
|
if (p.story && fresh.length < STORY_NEW_FACTS)
|
|
47
62
|
continue;
|
|
63
|
+
// First, once, the search beyond the registers; the story waits while it is open, and comes when it is finished
|
|
64
|
+
// (done, or dropped: the user's answer about the search, not about the story).
|
|
65
|
+
if (sources) {
|
|
66
|
+
const before = tasks.filter((t) => t.origin === STORY_SOURCES_ORIGIN && t.subject.includes(id));
|
|
67
|
+
if (before.some((t) => OPEN.has(t.state)))
|
|
68
|
+
continue;
|
|
69
|
+
if (!before.length) {
|
|
70
|
+
out.push({
|
|
71
|
+
level: "enrich",
|
|
72
|
+
priority: 1, // with the stories: after the research
|
|
73
|
+
what: phrase(lang, "story.sources.what", { name }),
|
|
74
|
+
where: [phrase(lang, "story.sources.where")],
|
|
75
|
+
why: phrase(lang, "story.sources.why"),
|
|
76
|
+
doneWhen: phrase(lang, "story.sources.done"),
|
|
77
|
+
subject: [id],
|
|
78
|
+
research: research.id,
|
|
79
|
+
origin: STORY_SOURCES_ORIGIN,
|
|
80
|
+
});
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
48
84
|
out.push({
|
|
49
85
|
level: "narrate",
|
|
50
86
|
priority: 1, // the research first; a story when there is a moment for it
|
|
@@ -58,3 +94,10 @@ export function storyProposals(tree, research) {
|
|
|
58
94
|
}
|
|
59
95
|
return out;
|
|
60
96
|
}
|
|
97
|
+
/** The new versions of approved stories that wait for the user (the lock): whose, and since when. */
|
|
98
|
+
export function storiesToApprove(tree) {
|
|
99
|
+
return [...tree.list("person"), ...tree.list("family")]
|
|
100
|
+
.filter((r) => !r.retracted && r.story?.status === "final" && r.story.draft)
|
|
101
|
+
.map((r) => ({ id: r.id, at: r.story.draft.at }))
|
|
102
|
+
.sort((a, b) => a.at.localeCompare(b.at));
|
|
103
|
+
}
|
package/dist/core/stromapp.js
CHANGED
|
@@ -82,6 +82,48 @@ export function appShowsEdges(settings, version) {
|
|
|
82
82
|
return (a[i] ?? 0) > (b[i] ?? 0);
|
|
83
83
|
return true;
|
|
84
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* The Strom app keeps a couple's events as events (the app's spec docs/ZADANI_VYZKUM_udalosti-paru.md, its 3.8.0):
|
|
87
|
+
* their residence goes to it as 1 RESI under FAM (before: an EVEN named so — an older app keeps either in the couple's
|
|
88
|
+
* note). stromapp.info from 3.8.0 (2026-10-02); another copy of the app (its beta, its development) is taken as current.
|
|
89
|
+
* An app of unknown version: today's.
|
|
90
|
+
*/
|
|
91
|
+
export const APP_SHOWS_COUPLE_EVENTS = "3.8.0";
|
|
92
|
+
export function appShowsCoupleEvents(settings, version) {
|
|
93
|
+
if (stromAppUrl(settings) !== STROM_APP_URL)
|
|
94
|
+
return true;
|
|
95
|
+
if (!APP_SHOWS_COUPLE_EVENTS)
|
|
96
|
+
return false;
|
|
97
|
+
if (!version)
|
|
98
|
+
return true;
|
|
99
|
+
const n = (v) => v.split(".").map((x) => Number.parseInt(x, 10) || 0);
|
|
100
|
+
const [a, b] = [n(version), n(APP_SHOWS_COUPLE_EVENTS)];
|
|
101
|
+
for (let i = 0; i < 3; i++)
|
|
102
|
+
if ((a[i] ?? 0) !== (b[i] ?? 0))
|
|
103
|
+
return (a[i] ?? 0) > (b[i] ?? 0);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The Strom app shows the new version of a story the user approved, waiting beside it (the app's spec
|
|
108
|
+
* docs/ZADANI_VYZKUM_vypraveni-zamek.md): 2 _DRAFT under _STORY, and in the bridge's waiting (kind story). stromapp.info
|
|
109
|
+
* from 3.7.0 (2026-10-02); another copy of the app (its beta, its development: strom.app.url) is taken as current, so it
|
|
110
|
+
* can be built. An app of unknown version: today's.
|
|
111
|
+
*/
|
|
112
|
+
export const APP_SHOWS_STORY_DRAFTS = "3.7.0";
|
|
113
|
+
export function appShowsStoryDrafts(settings, version) {
|
|
114
|
+
if (stromAppUrl(settings) !== STROM_APP_URL)
|
|
115
|
+
return true;
|
|
116
|
+
if (!APP_SHOWS_STORY_DRAFTS)
|
|
117
|
+
return false;
|
|
118
|
+
if (!version)
|
|
119
|
+
return true;
|
|
120
|
+
const n = (v) => v.split(".").map((x) => Number.parseInt(x, 10) || 0);
|
|
121
|
+
const [a, b] = [n(version), n(APP_SHOWS_STORY_DRAFTS)];
|
|
122
|
+
for (let i = 0; i < 3; i++)
|
|
123
|
+
if ((a[i] ?? 0) !== (b[i] ?? 0))
|
|
124
|
+
return (a[i] ?? 0) > (b[i] ?? 0);
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
85
127
|
/** The address that opens the Strom app to send the user's edited tree to the bridge. */
|
|
86
128
|
export function sendAppUrl(bridge, settings) {
|
|
87
129
|
return `${stromAppUrl(settings)}?send=${encodeURIComponent(bridge)}`;
|