strom-research 1.15.0-beta.2 → 1.15.0-beta.3
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 +14 -0
- package/assets/lang/de.json +14 -0
- package/dist/cli/format.js +5 -3
- package/dist/cli/menu-mode.js +26 -12
- package/dist/cli/ui.js +14 -0
- package/dist/commands/intake.js +25 -15
- package/dist/commands/people.js +5 -3
- package/dist/commands/research.js +12 -3
- package/dist/commands/sync.js +2 -2
- package/dist/commands/tasks.js +4 -2
- package/dist/commands/trees.js +31 -3
- package/dist/core/import.js +45 -11
- package/dist/core/live.js +104 -36
- package/dist/core/state.js +5 -3
- package/dist/core/sync.js +2 -2
- package/dist/core/text.js +22 -0
- package/dist/core/tree.js +1 -1
- package/package.json +1 -1
package/assets/lang/cs.json
CHANGED
|
@@ -1537,6 +1537,9 @@
|
|
|
1537
1537
|
"ui.intake.done": "✓ Přidáno do výzkumu: {n}. Agent to projde při další práci.",
|
|
1538
1538
|
"ui.intake.done.archive": "✓ Uloženo do archivu: {n}. Projde se, až se zapne výzkum.",
|
|
1539
1539
|
"ui.intake.done.waiting": "✓ Uloženo do výzkumu: {n}. Agent to projde, až bude na počítači.",
|
|
1540
|
+
"ui.intake.tree": "{id} rodokmen převzat – osoby: {persons} · rodiny: {families} · údaje: {events}, vše jako vodítka (pramen {source})",
|
|
1541
|
+
"ui.intake.tree.matched": " – už ve výzkumu: {n}",
|
|
1542
|
+
"ui.intake.tree.back": "Je to rodokmen tohoto výzkumu, který se vrací: strom sync \"{file}\" ukáže i to, co se v něm změnilo.",
|
|
1540
1543
|
"ui.intake.same": "Už tu bylo dřív: {n}.",
|
|
1541
1544
|
"ui.intake.chat": "Projít to s agentem hned?",
|
|
1542
1545
|
"ui.intake.say": "Do výzkumu jsem přidal(a) nové podklady – pojďme je projít.",
|
|
@@ -1912,6 +1915,17 @@
|
|
|
1912
1915
|
"ui.error.pack.no-manifest.hint": "zadat ZIP soubor, který přišel",
|
|
1913
1916
|
"ui.error.fs.no-permission": "chybí oprávnění{where}",
|
|
1914
1917
|
"ui.error.fs.no-permission.hint": "vybrat složku, do které lze zapisovat",
|
|
1918
|
+
"ui.task.none": "Žádné úkoly.",
|
|
1919
|
+
"ui.research.none": "Žádné směry výzkumu.",
|
|
1920
|
+
"ui.person.none": "Žádné osoby.",
|
|
1921
|
+
"ui.person.nomatch": "Žádná osoba neodpovídá.",
|
|
1922
|
+
"ui.person.leadsonly": "jen vodítka",
|
|
1923
|
+
"ui.person.events": "údaje: {n}",
|
|
1924
|
+
"ui.list.more": "… dalších {n}: {command}",
|
|
1925
|
+
"ui.error.archive": "Tento výzkum je archiv: data přicházejí z aplikace Strom a nic se nezkoumá. Výzkum se zapíná v menu, první položka.",
|
|
1926
|
+
"ui.error.archive.hint": "nebo v terminálu: strom mode research",
|
|
1927
|
+
"ui.error.agent.missing": "Výzkum čeká na AI agenta ({name}), který na tomto počítači není: do té doby drží data z aplikace Strom jako archiv. Agenta nainstalovat nebo vybrat jiného: menu, první položka.",
|
|
1928
|
+
"ui.error.agent.missing.hint": "nebo v terminálu: strom setup",
|
|
1915
1929
|
"ui.error.fs.missing": "nenalezeno: {path}",
|
|
1916
1930
|
"ui.error.fs.missing.hint": "zkontrolovat cestu (relativní cesta začíná v aktuální složce)",
|
|
1917
1931
|
"ui.error.grep.in.missing": "--in {where}: ve výzkumu nenalezeno",
|
package/assets/lang/de.json
CHANGED
|
@@ -1537,6 +1537,9 @@
|
|
|
1537
1537
|
"ui.intake.done": "✓ Zur Forschung hinzugefügt: {n}. Der Agent sieht es bei der nächsten Arbeit durch.",
|
|
1538
1538
|
"ui.intake.done.archive": "✓ Im Archiv aufbewahrt: {n}. Es wird durchgesehen, sobald die Forschung eingeschaltet ist.",
|
|
1539
1539
|
"ui.intake.done.waiting": "✓ In der Forschung aufbewahrt: {n}. Der Agent sieht es durch, sobald er auf diesem Computer ist.",
|
|
1540
|
+
"ui.intake.tree": "{id} Stammbaum übernommen – Personen: {persons} · Familien: {families} · Angaben: {events}, alle als Hinweise (Quelle {source})",
|
|
1541
|
+
"ui.intake.tree.matched": " – schon in der Forschung: {n}",
|
|
1542
|
+
"ui.intake.tree.back": "Es ist der Stammbaum dieser Forschung, der zurückkommt: strom sync \"{file}\" zeigt auch, was darin geändert wurde.",
|
|
1540
1543
|
"ui.intake.same": "Schon vorher hier: {n}.",
|
|
1541
1544
|
"ui.intake.chat": "Jetzt mit dem Agenten durchgehen?",
|
|
1542
1545
|
"ui.intake.say": "Ich habe der Forschung neue Unterlagen hinzugefügt – gehen wir sie durch.",
|
|
@@ -1912,6 +1915,17 @@
|
|
|
1912
1915
|
"ui.error.pack.no-manifest.hint": "die ZIP-Datei angeben, die gekommen ist",
|
|
1913
1916
|
"ui.error.fs.no-permission": "keine Berechtigung{where}",
|
|
1914
1917
|
"ui.error.fs.no-permission.hint": "einen Ordner wählen, in den geschrieben werden darf",
|
|
1918
|
+
"ui.task.none": "Keine Aufgaben.",
|
|
1919
|
+
"ui.research.none": "Keine Forschungsrichtungen.",
|
|
1920
|
+
"ui.person.none": "Keine Personen.",
|
|
1921
|
+
"ui.person.nomatch": "Keine Person passt.",
|
|
1922
|
+
"ui.person.leadsonly": "nur Hinweise",
|
|
1923
|
+
"ui.person.events": "Angaben: {n}",
|
|
1924
|
+
"ui.list.more": "… {n} weitere: {command}",
|
|
1925
|
+
"ui.error.archive": "Diese Forschung ist ein Archiv: die Daten kommen aus der Strom-App, und nichts wird erforscht. Die Forschung wird im Menü eingeschaltet, erster Punkt.",
|
|
1926
|
+
"ui.error.archive.hint": "oder im Terminal: strom mode research",
|
|
1927
|
+
"ui.error.agent.missing": "Die Forschung wartet auf ihren KI-Agenten ({name}), der auf diesem Computer nicht ist: bis dahin bewahrt sie die Daten aus der Strom-App als Archiv. Den Agenten installieren oder einen anderen wählen: das Menü, erster Punkt.",
|
|
1928
|
+
"ui.error.agent.missing.hint": "oder im Terminal: strom setup",
|
|
1915
1929
|
"ui.error.fs.missing": "nicht gefunden: {path}",
|
|
1916
1930
|
"ui.error.fs.missing.hint": "den Pfad prüfen (ein relativer Pfad beginnt im aktuellen Ordner)",
|
|
1917
1931
|
"ui.error.grep.in.missing": "--in {where}: in der Forschung nicht gefunden",
|
package/dist/cli/format.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Compact, token-efficient text output: aligned columns, one row per record,
|
|
2
2
|
// no box drawing, IDs first.
|
|
3
|
+
import { ui } from "./ui.js";
|
|
3
4
|
export function table(rows, opts = {}) {
|
|
4
5
|
const all = opts.header ? [opts.header, ...rows] : rows;
|
|
5
6
|
if (all.length === 0)
|
|
@@ -25,12 +26,13 @@ export function paginate(items, limit, page) {
|
|
|
25
26
|
* — an agent's tool keeps a longer output in a file and shows only its start (N0280, N0281: the filters never seen).
|
|
26
27
|
*/
|
|
27
28
|
export const LIST_PAGE_MAX = 100;
|
|
28
|
-
/** Footer telling the caller how to see more. */
|
|
29
|
-
export function moreLine(page, command) {
|
|
29
|
+
/** Footer telling the caller how to see more — in the research's language where the listing is (lang), else English. */
|
|
30
|
+
export function moreLine(page, command, lang) {
|
|
30
31
|
if (page.page >= page.pages)
|
|
31
32
|
return "";
|
|
32
33
|
const left = page.total - page.page * page.limit;
|
|
33
|
-
|
|
34
|
+
const next = `${command} --page ${page.page + 1}`;
|
|
35
|
+
return lang ? ui(lang, "ui.list.more", { n: String(left), command: next }) : `… ${left} more: ${next}`;
|
|
34
36
|
}
|
|
35
37
|
export function truncate(text, max) {
|
|
36
38
|
const one = text.replace(/\s+/g, " ").trim();
|
package/dist/cli/menu-mode.js
CHANGED
|
@@ -20,18 +20,10 @@ export async function switchTo(ctx, run, lang, root, to) {
|
|
|
20
20
|
return;
|
|
21
21
|
let asked = false;
|
|
22
22
|
if (to === "research" && !agentReady(ctx, tree.config)) {
|
|
23
|
-
|
|
24
|
-
if (
|
|
25
|
-
await run(["setup", "--research"]);
|
|
26
|
-
asked = true;
|
|
27
|
-
}
|
|
28
|
-
else
|
|
29
|
-
await offerAgent(ctx, out);
|
|
30
|
-
ctx.settings.reload();
|
|
31
|
-
if (!agentReady(ctx, Tree.open(root, ctx.env).config)) {
|
|
32
|
-
out(t("ui.mode.later"));
|
|
23
|
+
const got = await agentFirst(ctx, run, lang, root);
|
|
24
|
+
if (!got.ready)
|
|
33
25
|
return pause(ctx, lang);
|
|
34
|
-
|
|
26
|
+
asked = got.asked;
|
|
35
27
|
}
|
|
36
28
|
if (!(await ctx.confirm(t(to === "archive" ? "ui.consent.mode.archive" : "ui.consent.mode.research", { name: tree.config.name }), false)))
|
|
37
29
|
return;
|
|
@@ -48,8 +40,30 @@ export async function switchTo(ctx, run, lang, root, to) {
|
|
|
48
40
|
}
|
|
49
41
|
await pause(ctx, lang);
|
|
50
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Research switched on where the research's agent is not here: the agent first — the wizard when another one is here,
|
|
45
|
+
* else the one offered. None got: a sentence says the archive stays as it was (the caller writes nothing). `asked`: the
|
|
46
|
+
* wizard's agent steps were asked just now.
|
|
47
|
+
*/
|
|
48
|
+
export async function agentFirst(ctx, run, lang, root) {
|
|
49
|
+
const t = translator(lang);
|
|
50
|
+
const out = (line) => ctx.io.stdout(line + "\n");
|
|
51
|
+
out(t("ui.mode.agent.get"));
|
|
52
|
+
let asked = false;
|
|
53
|
+
if (agentsHere(ctx.env).length) {
|
|
54
|
+
await run(["setup", "--research"]);
|
|
55
|
+
asked = true;
|
|
56
|
+
}
|
|
57
|
+
else
|
|
58
|
+
await offerAgent(ctx, out);
|
|
59
|
+
ctx.settings.reload();
|
|
60
|
+
if (agentReady(ctx, Tree.open(root, ctx.env).config))
|
|
61
|
+
return { ready: true, asked };
|
|
62
|
+
out(t("ui.mode.later"));
|
|
63
|
+
return { ready: false, asked };
|
|
64
|
+
}
|
|
51
65
|
/** The setup's steps of an agent never asked on this computer (stories: asked with them by every wizard before these). */
|
|
52
|
-
function agentStepsSkipped(ctx) {
|
|
66
|
+
export function agentStepsSkipped(ctx) {
|
|
53
67
|
ctx.settings.reload();
|
|
54
68
|
const c = ctx.settings.config;
|
|
55
69
|
return !c.agentAsked && c.stories === undefined;
|
package/dist/cli/ui.js
CHANGED
|
@@ -1450,6 +1450,9 @@ export const UI = {
|
|
|
1450
1450
|
"ui.intake.done": "✓ Added to the research: {n}. The agent goes through it in its next work.",
|
|
1451
1451
|
"ui.intake.done.archive": "✓ Kept in the archive: {n}. It is gone through once research is switched on.",
|
|
1452
1452
|
"ui.intake.done.waiting": "✓ Kept in the research: {n}. The agent goes through it once it is on this computer.",
|
|
1453
|
+
"ui.intake.tree": "{id} family tree taken in – people: {persons} · families: {families} · facts: {events}, all as leads (source {source})",
|
|
1454
|
+
"ui.intake.tree.matched": " – already in the research: {n}",
|
|
1455
|
+
"ui.intake.tree.back": "It is this research's own family tree coming back: strom sync \"{file}\" shows what was changed in it too.",
|
|
1453
1456
|
"ui.intake.same": "Already here before: {n}.",
|
|
1454
1457
|
"ui.intake.chat": "Go through it with the agent now?",
|
|
1455
1458
|
"ui.intake.say": "I added new material to the research — let us go through it.",
|
|
@@ -1716,6 +1719,17 @@ export const UI = {
|
|
|
1716
1719
|
"ui.error.pack.no-manifest.hint": "the ZIP file that came",
|
|
1717
1720
|
"ui.error.fs.no-permission": "no permission to use{where}",
|
|
1718
1721
|
"ui.error.fs.no-permission.hint": "a folder with the right to write in it",
|
|
1722
|
+
"ui.task.none": "No tasks.",
|
|
1723
|
+
"ui.research.none": "No research directions.",
|
|
1724
|
+
"ui.person.none": "No people.",
|
|
1725
|
+
"ui.person.nomatch": "No people match.",
|
|
1726
|
+
"ui.person.leadsonly": "leads only",
|
|
1727
|
+
"ui.person.events": "{n} ev",
|
|
1728
|
+
"ui.list.more": "… {n} more: {command}",
|
|
1729
|
+
"ui.error.archive": "This research is an archive: its data come from the Strom app and nothing is researched. Research is switched on in the menu, its first item.",
|
|
1730
|
+
"ui.error.archive.hint": "or in a terminal: strom mode research",
|
|
1731
|
+
"ui.error.agent.missing": "The research waits for its AI agent ({name}), which is not on this computer: until then it keeps the data from the Strom app as an archive. Installing the agent or choosing another: the menu, its first item.",
|
|
1732
|
+
"ui.error.agent.missing.hint": "or in a terminal: strom setup",
|
|
1719
1733
|
"ui.error.fs.missing": "no such file or folder: {path}",
|
|
1720
1734
|
"ui.error.fs.missing.hint": "check the path (relative paths start in the current folder)",
|
|
1721
1735
|
"ui.error.grep.in.missing": "--in {where}: no such file or folder in the research",
|
package/dist/commands/intake.js
CHANGED
|
@@ -14,7 +14,7 @@ import { collectFiles, fileSha256, inputPath, MAX_IN_TREE, mimeOf, storeShared }
|
|
|
14
14
|
import { create, normId, requireRecord, update } from "../core/records.js";
|
|
15
15
|
import { phrase } from "../core/phrases.js";
|
|
16
16
|
import { importGedcom, importStromJson, isStromJson } from "../core/import.js";
|
|
17
|
-
import { safeFolderName } from "../core/text.js";
|
|
17
|
+
import { decodeText, safeFolderName } from "../core/text.js";
|
|
18
18
|
import { amendInput, checkOriginalMeta } from "../core/originals.js";
|
|
19
19
|
import { batchStatus, closeBatch, listBatches, readBatch } from "../core/batches.js";
|
|
20
20
|
import { SORT_KINDS } from "../core/model.js";
|
|
@@ -134,6 +134,9 @@ register({
|
|
|
134
134
|
});
|
|
135
135
|
return { folder: folder ? path.basename(p) : undefined, files };
|
|
136
136
|
});
|
|
137
|
+
// where nobody researches (an archive, a research waiting for its agent): said to the person, in the research's
|
|
138
|
+
// language — kept, no task to take next (an archive's waits put aside until research is switched on)
|
|
139
|
+
const quiet = actsAsArchive(tree);
|
|
137
140
|
const known = new Map(tree.list("input").filter((i) => i.sha).map((i) => [i.sha, i.id]));
|
|
138
141
|
const report = [];
|
|
139
142
|
const added = [];
|
|
@@ -154,7 +157,8 @@ register({
|
|
|
154
157
|
}
|
|
155
158
|
const size = fs.statSync(file).size;
|
|
156
159
|
const isText = size < 50 * 1024 * 1024 && [".ged", ".json", ".txt", ".md", ".csv"].includes(path.extname(file).toLowerCase());
|
|
157
|
-
|
|
160
|
+
// as whatever saved it: a BOM (Notepad, PowerShell 5) or UTF-16 is still the same family tree
|
|
161
|
+
const text = isText ? decodeText(fs.readFileSync(file)) : undefined;
|
|
158
162
|
const kind = kindOf(file, text);
|
|
159
163
|
const id = tree.peekId("I");
|
|
160
164
|
let stored;
|
|
@@ -179,16 +183,25 @@ register({
|
|
|
179
183
|
op: "input.imported",
|
|
180
184
|
summary: `${input.id} imported: ${imported.persons} persons, ${imported.families} families${imported.matched ? `, ${imported.matched} matched` : ""}`,
|
|
181
185
|
});
|
|
182
|
-
|
|
183
|
-
if (
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
186
|
+
const back = imported.matched && imported.matched >= imported.persons;
|
|
187
|
+
if (quiet) {
|
|
188
|
+
// where nobody researches the person reads it, in the research's language: the tree is in it (no review talk)
|
|
189
|
+
report.push(` ${ui(tree.lang, "ui.intake.tree", { id: input.id, persons: imported.persons, families: imported.families, events: imported.events, source: imported.source })}${imported.matched ? ui(tree.lang, "ui.intake.tree.matched", { n: imported.matched }) : ""}`);
|
|
190
|
+
if (back)
|
|
191
|
+
report.push(` ${ui(tree.lang, "ui.intake.tree.back", { file: ctx.display(file) })}`);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
report.push(` ${input.id} tree: ${imported.persons} persons, ${imported.families} families, ${imported.events} facts as leads${imported.matched ? `, ${imported.matched} matched to existing` : ""}${imported.problems.length ? ` (${imported.problems.length} unreadable lines)` : ""}`);
|
|
195
|
+
if (imported.extended.length)
|
|
196
|
+
report.push(` added to what we have (leads — check them): ${imported.extended.slice(0, 6).join(" · ")}${imported.extended.length > 6 ? " …" : ""}`);
|
|
197
|
+
// the research's own tree coming back: sync sees what was changed in it too, not only what was added
|
|
198
|
+
if (back)
|
|
199
|
+
report.push(` it is this research's tree coming back: strom sync "${ctx.display(file)}" shows what was changed in it too`);
|
|
200
|
+
const system = `gedcom:${sha.slice(0, 12)}`;
|
|
201
|
+
const dupes = likelyDuplicates(tree, tree.list("person").filter((p) => p.refs?.some((r) => r.system === system)).map((p) => p.id));
|
|
202
|
+
if (dupes.length)
|
|
203
|
+
report.push(` probably already in the tree: ${dupes.map((d) => `${d.person.id}≈${d.same.id}`).join(", ")} (the review task lists them)`);
|
|
204
|
+
}
|
|
192
205
|
}
|
|
193
206
|
// A family tree gets its own review task; the other files of a folder share one — files for a task: none
|
|
194
207
|
if (forTask)
|
|
@@ -215,9 +228,6 @@ register({
|
|
|
215
228
|
added.push(input);
|
|
216
229
|
}
|
|
217
230
|
});
|
|
218
|
-
// where nobody researches (an archive, a research waiting for its agent): said to the person, in the research's
|
|
219
|
-
// language — kept, no task to take next (an archive's waits put aside until research is switched on)
|
|
220
|
-
const quiet = actsAsArchive(tree);
|
|
221
231
|
const text = lines(quiet
|
|
222
232
|
? added.length
|
|
223
233
|
? `${ui(tree.lang, isArchive(tree) ? "ui.intake.done.archive" : "ui.intake.done.waiting", { n: added.length })}${skipped ? ` ${ui(tree.lang, "ui.intake.same", { n: skipped })}` : ""}`
|
package/dist/commands/people.js
CHANGED
|
@@ -211,10 +211,12 @@ register({
|
|
|
211
211
|
displayName(p),
|
|
212
212
|
lifespan(p),
|
|
213
213
|
gens ? `G${gens.get(p.id)}` : "",
|
|
214
|
-
|
|
215
|
-
p.events.some((e) => e.status === "proven" || e.status === "probable") ? "" :
|
|
214
|
+
ui(tree.lang, "ui.person.events", { n: String(p.events.length) }),
|
|
215
|
+
p.events.some((e) => e.status === "proven" || e.status === "probable") ? "" : ui(tree.lang, "ui.person.leadsonly"),
|
|
216
216
|
]);
|
|
217
|
-
|
|
217
|
+
// in the research's language (the person follows it in the agent's session too)
|
|
218
|
+
const filtered = !!args[0] || !!opts.research;
|
|
219
|
+
const text = people.length === 0 ? ui(tree.lang, filtered ? "ui.person.nomatch" : "ui.person.none") : lines(table(rows), moreLine(page, "strom person list", tree.lang));
|
|
218
220
|
return {
|
|
219
221
|
text,
|
|
220
222
|
data: {
|
|
@@ -6,7 +6,7 @@ import { applyFrontier, researchProposals } from "./session.js";
|
|
|
6
6
|
import { frontier } from "../core/frontier.js";
|
|
7
7
|
import { DEATH_AFTER_YEARS, livingBorn, unprovenPeople } from "../core/review.js";
|
|
8
8
|
import { parkedWhy } from "./tasks.js";
|
|
9
|
-
import { ui } from "../cli/ui.js";
|
|
9
|
+
import { placeholders, ui } from "../cli/ui.js";
|
|
10
10
|
import { phrase } from "../core/phrases.js";
|
|
11
11
|
import { UsageError } from "../core/errors.js";
|
|
12
12
|
import { REVIEW_SCOPES } from "../core/model.js";
|
|
@@ -15,7 +15,8 @@ import { foldText } from "../core/text.js";
|
|
|
15
15
|
import { now } from "../core/tree.js";
|
|
16
16
|
import { directionOf, scopes } from "../core/directions.js";
|
|
17
17
|
import { otherKind, otherKindHint, update } from "../core/records.js";
|
|
18
|
-
import { isAgent } from "../core/which.js";
|
|
18
|
+
import { detectAgent, isAgent } from "../core/which.js";
|
|
19
|
+
import { actsAsArchive, refuseInArchive } from "../core/state.js";
|
|
19
20
|
import { costPartial, sessionCost } from "../core/session.js";
|
|
20
21
|
function int(v, name) {
|
|
21
22
|
if (v === undefined)
|
|
@@ -73,6 +74,8 @@ register({
|
|
|
73
74
|
],
|
|
74
75
|
run(ctx, { args, opts }) {
|
|
75
76
|
const tree = ctx.tree();
|
|
77
|
+
// a direction is the agent's work: refused where nobody researches (an archive, a research waiting for its agent)
|
|
78
|
+
refuseInArchive(tree, "strom research new", detectAgent(ctx.env) ?? ctx.settings.agent(tree.config).value);
|
|
76
79
|
if (!opts.person === !opts["new-person"])
|
|
77
80
|
throw new UsageError("give exactly one of --person <who> or --new-person <name>");
|
|
78
81
|
let focus;
|
|
@@ -110,8 +113,12 @@ register({
|
|
|
110
113
|
run(ctx, { opts }) {
|
|
111
114
|
const tree = ctx.tree();
|
|
112
115
|
const all = tree.list("research").filter((r) => !opts.state || r.state === opts.state);
|
|
116
|
+
// in the research's language; an archive (or one waiting for its agent) is told of no direction it would refuse
|
|
113
117
|
if (all.length === 0)
|
|
114
|
-
return {
|
|
118
|
+
return {
|
|
119
|
+
text: `${ui(tree.lang, "ui.research.none")}${actsAsArchive(tree) ? "" : ` → ${placeholders(tree.lang, 'strom research new "<research name>" --new-person "<Given /Surname/>"')}`}`,
|
|
120
|
+
data: { researches: [] },
|
|
121
|
+
};
|
|
115
122
|
return { text: table(all.map((r) => researchLine(tree, r))), data: { researches: all } };
|
|
116
123
|
},
|
|
117
124
|
}, ...["pause", "resume", "done"].map((act) => ({
|
|
@@ -230,6 +237,8 @@ register({
|
|
|
230
237
|
],
|
|
231
238
|
async run(ctx, { args, opts }) {
|
|
232
239
|
const tree = ctx.tree();
|
|
240
|
+
// its tasks are the agent's work: refused where nobody researches, before anything is asked or written
|
|
241
|
+
refuseInArchive(tree, "strom review", detectAgent(ctx.env) ?? ctx.settings.agent(tree.config).value);
|
|
233
242
|
const unproven = !!opts.unproven;
|
|
234
243
|
if (unproven && args.length)
|
|
235
244
|
throw new UsageError("--unproven reviews the people without a record — no person with it", { hint: "strom review --unproven · or strom review <person>" });
|
package/dist/commands/sync.js
CHANGED
|
@@ -12,7 +12,7 @@ import { eventName, humanDate, humanDay, humanPlace } from "../cli/human.js";
|
|
|
12
12
|
import { EXIT, StromError, UsageError } from "../core/errors.js";
|
|
13
13
|
import { fileSha256, MAX_IN_TREE, mimeOf } from "../core/media.js";
|
|
14
14
|
import { create, update } from "../core/records.js";
|
|
15
|
-
import { safeFolderName } from "../core/text.js";
|
|
15
|
+
import { decodeText, safeFolderName } from "../core/text.js";
|
|
16
16
|
import { humanAge } from "../core/age.js";
|
|
17
17
|
import { now, Tree } from "../core/tree.js";
|
|
18
18
|
import { actsAsArchive } from "../core/state.js";
|
|
@@ -363,7 +363,7 @@ register({
|
|
|
363
363
|
fs.mkdirSync(path.join(tree.root, "inputs"), { recursive: true });
|
|
364
364
|
// kept as the document the source cites — without the images in it (the research's own excerpts, back from
|
|
365
365
|
// the app: megabytes in the history for nothing)
|
|
366
|
-
fs.writeFileSync(path.join(tree.root, stored), withoutImages(fs.readFileSync(file
|
|
366
|
+
fs.writeFileSync(path.join(tree.root, stored), withoutImages(decodeText(fs.readFileSync(file))));
|
|
367
367
|
}
|
|
368
368
|
const made = 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)`);
|
|
369
369
|
const day = humanDay(now(), lang);
|
package/dist/commands/tasks.js
CHANGED
|
@@ -212,7 +212,9 @@ function taskDetail(tree, t) {
|
|
|
212
212
|
});
|
|
213
213
|
const searches = tree.list("search").filter((s) => s.task === t.id || s.recordsets.some((b) => t.where.includes(b)));
|
|
214
214
|
const lessons = tree.list("lesson").filter((l) => !l.retracted && l.target && t.where.includes(l.target));
|
|
215
|
-
return lines(`${t.id} ${t.what}`, `level ${t.level} · priority ${t.priority} · ${t.state}${t.research ? ` · research ${t.research}` : ""}${t.parkedUntil ? ` · parked until ${t.parkedUntil}` : ""}${t.waitingOn ? ` · waiting on ${t.waitingOn}` : ""}`,
|
|
215
|
+
return lines(`${t.id} ${t.what}`, `level ${t.level} · priority ${t.priority} · ${t.state}${t.research ? ` · research ${t.research}` : ""}${t.parkedUntil ? ` · parked until ${t.parkedUntil}` : ""}${t.waitingOn ? ` · waiting on ${t.waitingOn}` : ""}`,
|
|
216
|
+
// put aside by the archive: its own sentence in the research's language (back when research is switched on, no wake)
|
|
217
|
+
t.state === "parked" ? (t.heldBy === "archive" && t.parkedReason ? t.parkedReason : `parked ${parkedWhy(tree, t) ?? "(no reason recorded)"} — strom task wake ${t.id} to go on`) : undefined, `where ${where.join("\n ")}`, `why ${t.why}`, `done ${t.doneWhen}`, t.subject.length ? `about ${t.subject.join(" ")}` : undefined, t.result ? `result ${t.result}${t.produced?.length ? ` → ${t.produced.join(" ")}` : ""}` : undefined, searches.length ? "\nalready searched there\n" + table(searches.map((s) => [` ${s.id}`, s.result, truncate(s.question, 60), s.scope.years ?? ""])) : undefined, lessons.length ? "\nlessons for these record sets\n" + lessons.map((l) => ` ${l.id} ${l.rule}`).join("\n") : undefined, ...t.notes.map((n) => `note ${n.text}`), t.awaitsCheck ? `check the person checks ${t.awaitsCheck.url}: ${t.awaitsCheck.ask}` : undefined, t.state === "waiting" ? `waits ${waitSaid(tree, t)}` : undefined, t.state === "waiting" && t.wait?.to ? `to ${t.wait.to}` : undefined, request ? `text ${request}` : undefined, t.state === "waiting" && t.wait?.url ? `url ${t.wait.url}` : undefined,
|
|
216
218
|
// the person's own answers (only theirs: strom task wake --answer/--check), apart from the notes
|
|
217
219
|
...(t.answers ?? []).map((a) => `person ${a.at.slice(0, 10)} answered${a.check ? ` ${a.check}` : ""}${a.answer ? `: ${a.answer}` : ""} (asked: ${truncate(a.asked, 80)})`),
|
|
218
220
|
// the files that came for what it waited for: only that they came (strom intake --answers, the Strom app)
|
|
@@ -600,7 +602,7 @@ register({
|
|
|
600
602
|
// the queue says what waits out of it for a link to the tree
|
|
601
603
|
const held = !opts.state && !opts["off-tree"] && !opts.about ? tree.list("task").filter((t) => t.state === "open" && off(t)).length : 0;
|
|
602
604
|
return {
|
|
603
|
-
text: lines(all.length ? lines(table(page.items.map(taskLine)), moreLine(page, "strom task list")) :
|
|
605
|
+
text: lines(all.length ? lines(table(page.items.map(taskLine)), moreLine(page, "strom task list")) : ui(tree.lang, "ui.task.none"), held ? `${held} more wait out of the queue: about people nothing links to the tree yet (strom task list --off-tree)` : undefined, !opts.state && !opts["off-tree"] && !opts.about ? decisionLines(tree, (t) => (!opts.level || t.level === opts.level) && (!research || t.research === research)) : undefined),
|
|
604
606
|
data: {
|
|
605
607
|
total: page.total,
|
|
606
608
|
tasks: page.items.map((t) => ({
|
package/dist/commands/trees.js
CHANGED
|
@@ -30,7 +30,9 @@ import { humanWhen } from "../cli/human.js";
|
|
|
30
30
|
import { onlyItsFiles, packPlan } from "../core/pack.js";
|
|
31
31
|
import { defaultPackFile, packTree } from "./pack.js";
|
|
32
32
|
import { archiveWaitingItems } from "../core/archiveasks.js";
|
|
33
|
-
import { claudeHere } from "../cli/menu-parts.js";
|
|
33
|
+
import { agentReady, claudeHere } from "../cli/menu-parts.js";
|
|
34
|
+
import { agentFirst, agentStepsSkipped } from "../cli/menu-mode.js";
|
|
35
|
+
import { isAgent } from "../core/which.js";
|
|
34
36
|
register({
|
|
35
37
|
path: ["init"],
|
|
36
38
|
summary: "Create a family tree (a folder with its own history) to hold researches",
|
|
@@ -80,7 +82,10 @@ register({
|
|
|
80
82
|
ctx.adopt(tree);
|
|
81
83
|
// in the research's language, also when an agent runs it (found on Mac: English to a Czech research)
|
|
82
84
|
const t = (k, v = {}) => ui(lang, k, v);
|
|
83
|
-
const
|
|
85
|
+
const waiting = mode !== "archive" && stateOf(tree) === "waiting";
|
|
86
|
+
const text = lines(t("ui.init.created", { name, path: ctx.display(root) }), t(langR.source === "detected" ? "ui.init.lang.detected" : "ui.init.lang", { lang: langName(lang, lang), code: lang }), mode === "archive" ? t("ui.init.archive") : waiting ? t("ui.why.waitagent.human") : undefined, "",
|
|
87
|
+
// waiting for its agent: no direction to start (it would be refused) — the menu installs the agent
|
|
88
|
+
t("ui.init.next", { command: mode === "archive" ? "strom app" : waiting ? "strom" : placeholders(lang, `strom research new "<research name>" --new-person "<Given /Surname/>" --born "<date>" --born-place "<place>"`) }));
|
|
84
89
|
return { text, data: { name, root, lang, mode } };
|
|
85
90
|
},
|
|
86
91
|
});
|
|
@@ -305,7 +310,7 @@ register({
|
|
|
305
310
|
"put aside (never while somebody works on it); back, they come back to the queue. Nothing is deleted.",
|
|
306
311
|
args: [{ name: "mode", description: "research or archive (none: say which it is)" }],
|
|
307
312
|
examples: ["strom mode", "strom mode research"],
|
|
308
|
-
run(ctx, { args }) {
|
|
313
|
+
async run(ctx, { args }) {
|
|
309
314
|
const tree = ctx.tree();
|
|
310
315
|
const lang = tree.lang;
|
|
311
316
|
const now = modeOf(tree);
|
|
@@ -322,6 +327,20 @@ register({
|
|
|
322
327
|
// nobody's work is cut short: a session at work finishes first
|
|
323
328
|
if (to === "archive" && (liveWorkers(tree.root).length > 0 || runsAtWork(tree.root).length > 0))
|
|
324
329
|
throw new UsageError("somebody is at work on this research: an archive only once they finished", { hint: "strom session finish (the agent writes down what it found and closes), then strom mode archive" });
|
|
330
|
+
// research on with no agent here, as the menu's "switch research on": the agent first — a person at the terminal
|
|
331
|
+
// gets one offered; none got (or nobody here to get one), the archive stays as it was, nothing written, said why
|
|
332
|
+
let asked;
|
|
333
|
+
if (to === "research" && !agentReady(ctx, tree.config)) {
|
|
334
|
+
const later = { text: ui(lang, "ui.mode.later"), data: { mode: now, state, changed: false, agentHere: false } };
|
|
335
|
+
if (!ctx.interactive || isAgent(ctx.env) || tree.dryRun)
|
|
336
|
+
return later;
|
|
337
|
+
const { main } = await import("../cli/main.js");
|
|
338
|
+
const run = (argv, quiet) => main(argv, quiet ? { ...ctx.io, stdout: () => undefined } : ctx.io, ctx.env, tree.root);
|
|
339
|
+
const got = await agentFirst(ctx, run, lang, tree.root);
|
|
340
|
+
if (!got.ready)
|
|
341
|
+
return { ...later, text: "" };
|
|
342
|
+
asked = got.asked;
|
|
343
|
+
}
|
|
325
344
|
ctx.requireHuman(to === "archive" ? "Make this research an archive (its tasks put aside)?" : "Switch research on (the tasks put aside back in the queue)?", `strom mode ${to}`, "mode", ui(lang, to === "archive" ? "ui.consent.mode.archive" : "ui.consent.mode.research", { name: tree.config.name }));
|
|
326
345
|
let moved = [];
|
|
327
346
|
tree.withTreeLock(() => {
|
|
@@ -335,6 +354,15 @@ register({
|
|
|
335
354
|
installGlobal(t);
|
|
336
355
|
if (files.length && !tree.dryRun)
|
|
337
356
|
tree.withTreeLock(() => tree.commit(`Agent instructions: ${files.join(", ")}`, files));
|
|
357
|
+
// the agent got here just now, its steps of the setup never asked: offered, as the menu does
|
|
358
|
+
if (asked === false && agentStepsSkipped(ctx)) {
|
|
359
|
+
ctx.io.stdout(ui(lang, "ui.mode.now.research", { n: moved.length }) + "\n");
|
|
360
|
+
if (await ctx.confirm(ui(lang, "ui.mode.setup"), true)) {
|
|
361
|
+
const { main } = await import("../cli/main.js");
|
|
362
|
+
await main(["setup", "--research"], ctx.io, ctx.env, tree.root);
|
|
363
|
+
}
|
|
364
|
+
return { text: "", data: { mode: to, changed: true, tasks: moved } };
|
|
365
|
+
}
|
|
338
366
|
return {
|
|
339
367
|
// the sentence in the research's language (found on Windows: "T0001 put aside: the research is an archive")
|
|
340
368
|
text: ui(lang, to === "archive" ? "ui.mode.now.archive" : "ui.mode.now.research", { n: moved.length }),
|
package/dist/core/import.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// A family tree is somebody's conclusions, not a record: everything becomes
|
|
4
4
|
// status "lead", citing one source "family tree <file>", and every person
|
|
5
5
|
// keeps a reference to where it came from. Persons whose REFN is one of our
|
|
6
|
-
// IDs
|
|
6
|
+
// IDs in a tree of this research coming back from the Strom app are matched,
|
|
7
|
+
// not duplicated — another research's IDs never (oursByRefn).
|
|
7
8
|
import { eventKind, FAMILY_EVENT_KINDS, NOTE_MAX } from "./model.js";
|
|
8
9
|
import { normalizeAge } from "./age.js";
|
|
9
10
|
import { foldText } from "./text.js";
|
|
@@ -198,13 +199,11 @@ export function importGedcom(tree, text, opts) {
|
|
|
198
199
|
const individuals = records.filter((x) => x.tag === "INDI" && x.xref);
|
|
199
200
|
// IDs first, so that associations (a godparent's ASSO) point at the right person.
|
|
200
201
|
const fresh = new Set();
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
ours = tree.get(ours.mergedInto);
|
|
207
|
-
if (ours && !ours.retracted) {
|
|
202
|
+
const treeId = val(records.find((x) => x.tag === "HEAD"), "_STROM_TREE")?.trim();
|
|
203
|
+
const ourPeople = oursByRefn(tree, treeId, individuals.map((r) => ({ refn: val(r, "REFN"), names: r.children.filter((c) => c.tag === "NAME").map((c) => c.value) })));
|
|
204
|
+
for (const [i, r] of individuals.entries()) {
|
|
205
|
+
const ours = ourPeople[i];
|
|
206
|
+
if (ours) {
|
|
208
207
|
xrefToId.set(r.xref, ours.id);
|
|
209
208
|
result.matched++;
|
|
210
209
|
}
|
|
@@ -497,6 +496,38 @@ export function importGedcom(tree, text, opts) {
|
|
|
497
496
|
});
|
|
498
497
|
return result;
|
|
499
498
|
}
|
|
499
|
+
/**
|
|
500
|
+
* Our people by the REFNs a family tree carries (a tree of this research coming back from the Strom app), in the order
|
|
501
|
+
* of its people. A file of this research (its _STROM_TREE) is ours by the IDs alone, whatever the names; another
|
|
502
|
+
* research's never — its P0001 is somebody else (found: the app's file of another tree matched by its IDs, "this
|
|
503
|
+
* research's tree coming back"); a file that names no research (the standard file, another program) only where most
|
|
504
|
+
* of the people its IDs name are alike by name — and then each by its name too. Merged since: the one it went into.
|
|
505
|
+
*/
|
|
506
|
+
function oursByRefn(tree, treeId, people) {
|
|
507
|
+
if (treeId && treeId !== tree.config.id)
|
|
508
|
+
return people.map(() => undefined);
|
|
509
|
+
const found = people.map(({ refn }) => {
|
|
510
|
+
if (!refn || !/^P\d{4,}$/.test(refn.trim()))
|
|
511
|
+
return undefined;
|
|
512
|
+
let ours = tree.get(refn.trim());
|
|
513
|
+
for (let hops = 0; ours?.mergedInto && hops < 10; hops++)
|
|
514
|
+
ours = tree.get(ours.mergedInto);
|
|
515
|
+
return ours && ours.type === "person" && !ours.retracted ? ours : undefined;
|
|
516
|
+
});
|
|
517
|
+
if (treeId)
|
|
518
|
+
return found;
|
|
519
|
+
// a word of the name in common, or the same name of a single letter or digit ("1", "J.")
|
|
520
|
+
const norm = (s) => foldText(s.replace(/\//g, " "));
|
|
521
|
+
const words = (s) => new Set(norm(s).split(" ").filter((w) => w.length > 1));
|
|
522
|
+
const alike = (ours, names) => ours.names.some((n) => {
|
|
523
|
+
const mine = `${n.given} ${n.surname}`;
|
|
524
|
+
const w = words(mine);
|
|
525
|
+
return names.some((t) => (!!norm(t) && norm(t) === norm(mine)) || [...words(t)].some((x) => w.has(x)));
|
|
526
|
+
});
|
|
527
|
+
const named = found.map((ours, i) => (ours && alike(ours, people[i].names) ? ours : undefined));
|
|
528
|
+
const resolved = found.filter(Boolean).length;
|
|
529
|
+
return named.filter(Boolean).length * 2 > resolved ? named : people.map(() => undefined);
|
|
530
|
+
}
|
|
500
531
|
/** The title of the source an imported tree becomes, in the research language. */
|
|
501
532
|
function importLabel(lang) {
|
|
502
533
|
return { cs: "Rodokmen", de: "Stammbaum", pl: "Drzewo genealogiczne", sk: "Rodokmeň" }[lang] ?? "Family tree";
|
|
@@ -530,9 +561,12 @@ export function importStromJson(tree, data, opts) {
|
|
|
530
561
|
result.source = source.id;
|
|
531
562
|
const idMap = new Map();
|
|
532
563
|
const cite = (sp, what) => [{ source: source.id, locator: `${sp.id} ${what}` }];
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
564
|
+
const all = Object.values(data.persons);
|
|
565
|
+
const ourPeople = oursByRefn(tree, data.research?.id, all.map((sp) => ({ refn: sp.refn, names: [[sp.firstName, sp.lastName].filter(Boolean).join(" "), ...(sp.nameVariants ?? [])] })));
|
|
566
|
+
for (const [i, sp] of all.entries()) {
|
|
567
|
+
const ours = ourPeople[i];
|
|
568
|
+
if (ours) {
|
|
569
|
+
idMap.set(sp.id, ours.id);
|
|
536
570
|
result.matched++;
|
|
537
571
|
continue;
|
|
538
572
|
}
|
package/dist/core/live.js
CHANGED
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
// text: its lines as the user reads them, kinds: what each is about)
|
|
15
15
|
// GET <token>/recent what the research added in the last hours (?hours=1…168, 24 when not given): the people and
|
|
16
16
|
// sources added, the commits — the same as /status recent (core/recent.ts)
|
|
17
|
-
// GET <token>/events server-sent events: hello, change, working
|
|
17
|
+
// GET <token>/events server-sent events: hello, change, working; end before the bridge ends ({reason: stopped |
|
|
18
|
+
// restart | idle | gone | signal, back: another bridge at this address at once — absent: not
|
|
19
|
+
// known, at}): strom live stop asks it through .strom/live-stop.json, the same on every system
|
|
18
20
|
// POST <token>/sync the Strom app sends the user's edited tree back (?send=, or on its own): kept in the
|
|
19
21
|
// inbox to be shown and written on the user's word (strom sync) — nothing of the
|
|
20
22
|
// research changes; /status says what waits there (inbox) and how each send went (sends)
|
|
@@ -376,7 +378,7 @@ export function startLive(root, env, opts = {}) {
|
|
|
376
378
|
}
|
|
377
379
|
function startNow(root, env, running) {
|
|
378
380
|
if (running)
|
|
379
|
-
stopLive(root, `a bridge of strom ${VERSION} is wanted (this one: ${running.version ?? "older"})
|
|
381
|
+
stopLive(root, `a bridge of strom ${VERSION} is wanted (this one: ${running.version ?? "older"})`, { back: true });
|
|
380
382
|
fs.rmSync(liveFile(root), { force: true });
|
|
381
383
|
const before = readLast(root);
|
|
382
384
|
spawnBridge(root, env);
|
|
@@ -434,10 +436,10 @@ export function forgetLive(root) {
|
|
|
434
436
|
noteLive(root, "its address forgotten (strom live stop --forget): the next bridge gets a new one");
|
|
435
437
|
}
|
|
436
438
|
/** Whether a process is a bridge of strom (its command says "live serve"): a number taken by another program is never stopped. */
|
|
437
|
-
function isBridge(pid) {
|
|
439
|
+
function isBridge(pid, platform = process.platform) {
|
|
438
440
|
if (!alive(pid))
|
|
439
441
|
return false;
|
|
440
|
-
if (
|
|
442
|
+
if (platform === "win32")
|
|
441
443
|
return true;
|
|
442
444
|
const r = spawnSync("ps", ["-o", "stat=,command=", "-p", String(pid)], { encoding: "utf8", timeout: 10_000 });
|
|
443
445
|
// ps not to be asked: the bridge's own note is what there is
|
|
@@ -446,12 +448,29 @@ function isBridge(pid) {
|
|
|
446
448
|
const out = (r.stdout ?? "").trim();
|
|
447
449
|
return !out.startsWith("Z") && /\blive\s+serve\b/.test(out);
|
|
448
450
|
}
|
|
451
|
+
/** Where a bridge is asked to end: the same way on every system (a signal on Windows ends a process without a word). */
|
|
452
|
+
export function liveStopFile(root) {
|
|
453
|
+
return path.join(root, ".strom", "live-stop.json");
|
|
454
|
+
}
|
|
455
|
+
function readStopAsk(root) {
|
|
456
|
+
try {
|
|
457
|
+
const ask = JSON.parse(fs.readFileSync(liveStopFile(root), "utf8"));
|
|
458
|
+
return Number.isInteger(ask.pid) && typeof ask.why === "string" ? ask : undefined;
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
return undefined;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
449
464
|
/**
|
|
450
|
-
* End a bridge's process: asked to
|
|
451
|
-
* (a bridge
|
|
465
|
+
* End a bridge's process: asked to through its stop file (it says the end to the app, closes its streams and ends — the
|
|
466
|
+
* same on every system), else (a bridge of an older strom, or one stuck) by a signal: SIGTERM where a process hears it,
|
|
467
|
+
* and when it does not end within a while ended for good (SIGKILL; on Windows a signal is that at once). How it ended:
|
|
468
|
+
* "stopped" as it should, "killed" for good, "alive" when even that did not end it, "gone" when no bridge ran.
|
|
452
469
|
*/
|
|
453
|
-
function endBridge(pid, root,
|
|
454
|
-
|
|
470
|
+
export function endBridge(pid, root, opts = {}) {
|
|
471
|
+
const platform = opts.platform ?? process.platform;
|
|
472
|
+
const waitMs = opts.waitMs ?? 3000;
|
|
473
|
+
if (!isBridge(pid, platform))
|
|
455
474
|
return "gone";
|
|
456
475
|
// a bridge of another folder (a note copied with the research) is never ended for this tree
|
|
457
476
|
if (bridgeElsewhere(pid, root))
|
|
@@ -460,37 +479,70 @@ function endBridge(pid, root, waitMs = 3000) {
|
|
|
460
479
|
const ended = () => {
|
|
461
480
|
if (!alive(pid))
|
|
462
481
|
return true;
|
|
463
|
-
if (
|
|
482
|
+
if (platform === "win32")
|
|
464
483
|
return false;
|
|
465
484
|
const r = spawnSync("ps", ["-o", "stat=", "-p", String(pid)], { encoding: "utf8", timeout: 5000 });
|
|
466
485
|
return !r.error && (!(r.stdout ?? "").trim() || (r.stdout ?? "").trim().startsWith("Z"));
|
|
467
486
|
};
|
|
468
|
-
const gone = (ms) => {
|
|
487
|
+
const gone = (ms, also = () => false) => {
|
|
469
488
|
for (let t = 0; t < ms; t += 100) {
|
|
470
|
-
if (ended())
|
|
489
|
+
if (ended() || also())
|
|
471
490
|
return true;
|
|
472
491
|
sleep(100);
|
|
473
492
|
}
|
|
474
|
-
return ended();
|
|
493
|
+
return ended() || also();
|
|
475
494
|
};
|
|
495
|
+
const file = liveStopFile(root);
|
|
496
|
+
const taken = () => readStopAsk(root)?.pid !== pid;
|
|
476
497
|
try {
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
498
|
+
let asked = false;
|
|
499
|
+
try {
|
|
500
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
501
|
+
fs.writeFileSync(`${file}.${process.pid}`, JSON.stringify({ pid, why: opts.why ?? "asked to end", ...(opts.back === undefined ? {} : { back: opts.back }) }));
|
|
502
|
+
fs.renameSync(`${file}.${process.pid}`, file);
|
|
503
|
+
asked = true;
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
// its folder cannot be written: a signal is what there is
|
|
507
|
+
}
|
|
508
|
+
// heard: it takes the ask within a moment, then ends (its last streams closed)
|
|
509
|
+
if (asked && gone(opts.askMs ?? 1000, taken)) {
|
|
510
|
+
if (gone(waitMs + 1000))
|
|
511
|
+
return "stopped";
|
|
512
|
+
}
|
|
513
|
+
else {
|
|
514
|
+
// not heard (a bridge of an older strom, one stuck): asked by a signal — on Windows that ends it at once, unheard
|
|
515
|
+
const signal = platform === "win32" ? "SIGKILL" : "SIGTERM";
|
|
516
|
+
try {
|
|
517
|
+
process.kill(pid, signal);
|
|
518
|
+
}
|
|
519
|
+
catch (e) {
|
|
520
|
+
return e.code === "ESRCH" ? "stopped" : "alive";
|
|
521
|
+
}
|
|
522
|
+
if (gone(platform === "win32" ? 2000 : waitMs))
|
|
523
|
+
return platform === "win32" ? "killed" : "stopped";
|
|
524
|
+
if (platform === "win32")
|
|
525
|
+
return "alive";
|
|
526
|
+
}
|
|
527
|
+
try {
|
|
528
|
+
process.kill(pid, "SIGKILL");
|
|
529
|
+
}
|
|
530
|
+
catch (e) {
|
|
531
|
+
return e.code === "ESRCH" ? "stopped" : "alive";
|
|
532
|
+
}
|
|
533
|
+
return gone(2000) ? "killed" : "alive";
|
|
486
534
|
}
|
|
487
|
-
|
|
488
|
-
|
|
535
|
+
finally {
|
|
536
|
+
// an ask nobody took is not left for a bridge that comes later with that number
|
|
537
|
+
if (readStopAsk(root)?.pid === pid)
|
|
538
|
+
fs.rmSync(file, { force: true });
|
|
489
539
|
}
|
|
490
|
-
return gone(2000) ? "killed" : "alive";
|
|
491
540
|
}
|
|
492
|
-
/**
|
|
493
|
-
|
|
541
|
+
/**
|
|
542
|
+
* Stop the bridge of a tree (why: into its log; back: another bridge comes at its address at once — the app following it
|
|
543
|
+
* goes on): how it went — "none" when none ran, "alive" when it could not be ended.
|
|
544
|
+
*/
|
|
545
|
+
export function stopLive(root, why = "strom live stop", opts = {}) {
|
|
494
546
|
const info = liveRunning(root);
|
|
495
547
|
if (!info)
|
|
496
548
|
return "none";
|
|
@@ -498,7 +550,7 @@ export function stopLive(root, why = "strom live stop") {
|
|
|
498
550
|
const last = readLast(root);
|
|
499
551
|
if (last?.pid === info.pid)
|
|
500
552
|
writeLast(root, { ...last, ended: { at: new Date().toISOString(), reason: why } });
|
|
501
|
-
const how = endBridge(info.pid, root);
|
|
553
|
+
const how = endBridge(info.pid, root, { why, back: opts.back ?? false });
|
|
502
554
|
if (how === "elsewhere") {
|
|
503
555
|
noteLive(root, `a bridge of another folder (${info.pid}) is named in live.json: not ended, not this tree's`);
|
|
504
556
|
fs.rmSync(liveFile(root), { force: true });
|
|
@@ -511,7 +563,8 @@ export function stopLive(root, why = "strom live stop") {
|
|
|
511
563
|
return "alive";
|
|
512
564
|
}
|
|
513
565
|
fs.rmSync(liveFile(root), { force: true });
|
|
514
|
-
|
|
566
|
+
// no bridge of that number ran (it ended meanwhile, the number another program's): none was stopped
|
|
567
|
+
return how === "gone" ? "none" : how;
|
|
515
568
|
}
|
|
516
569
|
/**
|
|
517
570
|
* The bridge of a tree that ended without a word (a crash, killed) is started again — on its address, so the
|
|
@@ -623,7 +676,7 @@ function idsOf(applied, known) {
|
|
|
623
676
|
return Object.keys(ids.persons).length || Object.keys(ids.sources).length ? { ids } : {};
|
|
624
677
|
}
|
|
625
678
|
/** What the bridge does that an app may ask about (each added once, never taken away). */
|
|
626
|
-
export const BRIDGE_FEATURES = ["sync.again", "sync.undoneSince", "sync.takenBack", "sync.conflictEdit", "sync.since", "sync.ids", "family.noCouple", "family.alone", "adopt.transfer", "adopt.empty", "material.list", "person.titles", "media.codes", "hypothesis.links", "status.recent", "spend.readers", "hypothesis.intree", "archive.permission", "task.check", "waiting.groups", "waiting.sent", "waiting.note", "batch.kept"];
|
|
679
|
+
export const BRIDGE_FEATURES = ["sync.again", "sync.undoneSince", "sync.takenBack", "sync.conflictEdit", "sync.since", "sync.ids", "family.noCouple", "family.alone", "adopt.transfer", "adopt.empty", "material.list", "person.titles", "media.codes", "hypothesis.links", "status.recent", "spend.readers", "hypothesis.intree", "archive.permission", "task.check", "waiting.groups", "waiting.sent", "waiting.note", "batch.kept", "live.end"];
|
|
627
680
|
/** /status recent, when it is ready. */
|
|
628
681
|
function recentOf(root, env, head) {
|
|
629
682
|
const recent = head ? recentNow(root, env, head) : undefined;
|
|
@@ -2330,7 +2383,7 @@ export function serveLive(root, env) {
|
|
|
2330
2383
|
// the bridge before this one still holds it (stuck, or ending just now): ended, its port taken again — the
|
|
2331
2384
|
// app following the address goes on; a port another program holds: another one (the app needs ?live= again)
|
|
2332
2385
|
const before = last && last.pid !== process.pid ? last.pid : undefined;
|
|
2333
|
-
const ended = before && tries === 0 ? endBridge(before, root) : "gone";
|
|
2386
|
+
const ended = before && tries === 0 ? endBridge(before, root, { why: `a new bridge (${process.pid}) takes port ${wanted}`, back: true }) : "gone";
|
|
2334
2387
|
if (ended === "elsewhere") {
|
|
2335
2388
|
// a bridge of another folder (its note copied with the research): never ended, never its address — a new one
|
|
2336
2389
|
noteLive(root, `a bridge of another folder (${before}) holds port ${wanted}: not ended`);
|
|
@@ -2441,6 +2494,7 @@ export function serveLive(root, env) {
|
|
|
2441
2494
|
return noteLive(root, `strom ${disk.version} is on disk but does not start (exit ${probe.status ?? probe.signal ?? "?"}${probe.stderr ? `: ${String(probe.stderr).trim().split("\n").pop()?.slice(0, 200)}` : ""}): this bridge (${runs}) goes on`);
|
|
2442
2495
|
}
|
|
2443
2496
|
noteLive(root, `strom ${disk.version} on disk: the bridge starts again with it (port ${port}, its token kept)`);
|
|
2497
|
+
send("end", { reason: "restart", back: true, at: new Date().toISOString() });
|
|
2444
2498
|
for (const st of streams)
|
|
2445
2499
|
st.end();
|
|
2446
2500
|
streams.clear();
|
|
@@ -2485,6 +2539,7 @@ export function serveLive(root, env) {
|
|
|
2485
2539
|
noteLive(root, `handed over to the bridge of strom ${disk.version} (${child.pid})`);
|
|
2486
2540
|
clearInterval(timer);
|
|
2487
2541
|
clearInterval(keepAlive);
|
|
2542
|
+
clearInterval(stopAsked);
|
|
2488
2543
|
return resolve();
|
|
2489
2544
|
}
|
|
2490
2545
|
if (Date.now() < until && child.exitCode === null)
|
|
@@ -2509,7 +2564,7 @@ export function serveLive(root, env) {
|
|
|
2509
2564
|
// its research gone (taken off the computer, a test's folder): nothing to follow — even while handing over
|
|
2510
2565
|
// (found: bridges of a folder removed in the middle of it left running for hours)
|
|
2511
2566
|
if (!fs.existsSync(path.join(root, "strom.json")))
|
|
2512
|
-
return stop("the research is no longer there");
|
|
2567
|
+
return stop("the research is no longer there", "gone", false);
|
|
2513
2568
|
if (renewing)
|
|
2514
2569
|
return;
|
|
2515
2570
|
if (Date.now() - renewChecked > RENEW_MS) {
|
|
@@ -2539,10 +2594,10 @@ export function serveLive(root, env) {
|
|
|
2539
2594
|
if (streams.size)
|
|
2540
2595
|
lastAsked = Date.now();
|
|
2541
2596
|
if (Date.now() - lastAsked > idleMs)
|
|
2542
|
-
return stop("idle: nobody asked for a while");
|
|
2597
|
+
return stop("idle: nobody asked for a while", "idle", false);
|
|
2543
2598
|
// its research gone (taken off the computer, a test's folder): nothing to follow
|
|
2544
2599
|
if (!fs.existsSync(path.join(root, "strom.json")))
|
|
2545
|
-
return stop("the research is no longer there");
|
|
2600
|
+
return stop("the research is no longer there", "gone", false);
|
|
2546
2601
|
if (!streams.size)
|
|
2547
2602
|
return;
|
|
2548
2603
|
safe("a tick", () => {
|
|
@@ -2565,9 +2620,22 @@ export function serveLive(root, env) {
|
|
|
2565
2620
|
for (const s of streams)
|
|
2566
2621
|
s.write(": still here\n\n");
|
|
2567
2622
|
}, 20_000);
|
|
2568
|
-
|
|
2623
|
+
// asked to end (strom live stop, a newer bridge wanted): the same on every system — a signal on Windows ends the
|
|
2624
|
+
// process unheard, and the app following it would learn the end only when it gives up asking
|
|
2625
|
+
const stopAsked = setInterval(() => {
|
|
2626
|
+
const ask = readStopAsk(root);
|
|
2627
|
+
if (ask?.pid !== process.pid)
|
|
2628
|
+
return;
|
|
2629
|
+
fs.rmSync(liveStopFile(root), { force: true });
|
|
2630
|
+
stop(ask.why, ask.back ? "restart" : "stopped", ask.back ?? false);
|
|
2631
|
+
}, Number(env.STROM_LIVE_STOP_MS ?? 200));
|
|
2632
|
+
// the end said to the app on its streams before they close: whether another bridge comes at this address (it goes
|
|
2633
|
+
// on following) or none (following ends now); a signal from outside: not known (the app asks /status)
|
|
2634
|
+
const stop = (why, reason = "stopped", back) => {
|
|
2569
2635
|
clearInterval(timer);
|
|
2570
2636
|
clearInterval(keepAlive);
|
|
2637
|
+
clearInterval(stopAsked);
|
|
2638
|
+
send("end", { reason, ...(back === undefined ? {} : { back }), at: new Date().toISOString() });
|
|
2571
2639
|
for (const s of streams)
|
|
2572
2640
|
s.end();
|
|
2573
2641
|
server.close();
|
|
@@ -2585,8 +2653,8 @@ export function serveLive(root, env) {
|
|
|
2585
2653
|
noteLive(root, `ended: ${why}`);
|
|
2586
2654
|
resolve();
|
|
2587
2655
|
};
|
|
2588
|
-
process.once("SIGTERM", () => stop("SIGTERM"));
|
|
2589
|
-
process.once("SIGINT", () => stop("SIGINT"));
|
|
2656
|
+
process.once("SIGTERM", () => stop("SIGTERM", "signal"));
|
|
2657
|
+
process.once("SIGINT", () => stop("SIGINT", "signal"));
|
|
2590
2658
|
});
|
|
2591
2659
|
});
|
|
2592
2660
|
}
|
package/dist/core/state.js
CHANGED
|
@@ -41,16 +41,18 @@ export function actsAsArchive(tree) {
|
|
|
41
41
|
/**
|
|
42
42
|
* Work of an agent refused where nobody researches: an archive (nothing of an agent said: where research is switched
|
|
43
43
|
* on), or a research waiting for its agent when the agent asked for is not here either (`strom run --agent` of another
|
|
44
|
-
* one here goes on). One sentence in the research's language,
|
|
44
|
+
* one here goes on). One sentence in the research's language (ui.error.archive, ui.error.agent.missing), the command for a
|
|
45
|
+
* terminal after it; the English and a code for a program and an agent (the Strom app says it in its own).
|
|
45
46
|
*/
|
|
46
47
|
export function refuseInArchive(tree, what, agent) {
|
|
47
48
|
const state = stateOf(tree);
|
|
48
49
|
if (state === "research")
|
|
49
50
|
return;
|
|
50
51
|
if (state === "archive")
|
|
51
|
-
throw new UsageError(ui(
|
|
52
|
+
throw new UsageError(ui("en", "ui.archive.refuse"), { code: "archive", params: { what }, hint: "strom mode research (the person: strom's menu, its first item)" });
|
|
52
53
|
const named = agent ?? new Settings(tree.env, {}).agent(tree.config).value;
|
|
53
54
|
if (agentHere(named, tree.env))
|
|
54
55
|
return;
|
|
55
|
-
|
|
56
|
+
const name = PROFILES[named]?.name ?? named;
|
|
57
|
+
throw new UsageError(ui("en", "ui.waiting.refuse", { agent: name }), { code: "agent.missing", params: { what, agent: named, name }, hint: "strom setup (the person installs or chooses the agent: strom's menu, its first item)" });
|
|
56
58
|
}
|
package/dist/core/sync.js
CHANGED
|
@@ -31,7 +31,7 @@ import { fromFlexDate, houseOf, importDate, readGedName, readGedSex, stromAppFil
|
|
|
31
31
|
import { humanAge, normalizeAge } from "./age.js";
|
|
32
32
|
import { dateYears } from "./gdate.js";
|
|
33
33
|
import { create, update } from "./records.js";
|
|
34
|
-
import { foldText, safeFolderName } from "./text.js";
|
|
34
|
+
import { decodeText, foldText, safeFolderName } from "./text.js";
|
|
35
35
|
import { roleWord } from "./roles.js";
|
|
36
36
|
import { labels } from "../gedcom/labels.js";
|
|
37
37
|
import { eventName, humanDate } from "../cli/human.js";
|
|
@@ -936,7 +936,7 @@ export function readStromJson(data) {
|
|
|
936
936
|
}
|
|
937
937
|
/** A file coming back: GEDCOM or the Strom app's JSON; empty or unreadable throws. */
|
|
938
938
|
export function readTreeFile(file) {
|
|
939
|
-
const text = fs.readFileSync(file
|
|
939
|
+
const text = decodeText(fs.readFileSync(file));
|
|
940
940
|
if (!text.trim())
|
|
941
941
|
throw new UsageError(`${path.basename(file)} is empty`, { code: "tree.empty", params: { file: path.basename(file) } });
|
|
942
942
|
let snap;
|
package/dist/core/text.js
CHANGED
|
@@ -133,3 +133,25 @@ export function safeFolderName(name) {
|
|
|
133
133
|
return `${cleaned || "tree"}-1`;
|
|
134
134
|
return cleaned;
|
|
135
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* A text file's bytes as text, whatever saved it: UTF-8 with or without a BOM (Notepad, PowerShell 5's
|
|
138
|
+
* `-Encoding UTF8`), UTF-16 LE or BE by its BOM (PowerShell's `-Encoding Unicode`), or without one when it opens with
|
|
139
|
+
* two characters of ASCII (a GEDCOM's "0 ", a JSON's "{"). The BOM is never part of the text.
|
|
140
|
+
*/
|
|
141
|
+
export function decodeText(buf) {
|
|
142
|
+
const b = Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
143
|
+
const le = (from) => b.subarray(from, from + ((b.length - from) & ~1)).toString("utf16le");
|
|
144
|
+
const be = (from) => Buffer.from(b.subarray(from, from + ((b.length - from) & ~1))).swap16().toString("utf16le");
|
|
145
|
+
if (b[0] === 0xef && b[1] === 0xbb && b[2] === 0xbf)
|
|
146
|
+
return b.subarray(3).toString("utf8");
|
|
147
|
+
if (b[0] === 0xff && b[1] === 0xfe)
|
|
148
|
+
return le(2);
|
|
149
|
+
if (b[0] === 0xfe && b[1] === 0xff)
|
|
150
|
+
return be(2);
|
|
151
|
+
const ascii = (x) => x !== undefined && x > 0 && x < 0x80;
|
|
152
|
+
if (b.length >= 4 && ascii(b[0]) && b[1] === 0 && ascii(b[2]) && b[3] === 0)
|
|
153
|
+
return le(0);
|
|
154
|
+
if (b.length >= 4 && b[0] === 0 && ascii(b[1]) && b[2] === 0 && ascii(b[3]))
|
|
155
|
+
return be(0);
|
|
156
|
+
return b.toString("utf8").replace(/^/, "");
|
|
157
|
+
}
|
package/dist/core/tree.js
CHANGED
|
@@ -27,7 +27,7 @@ import { lastBackup } from "./backup.js";
|
|
|
27
27
|
export const TREE_FILE = "strom.json";
|
|
28
28
|
// between releases the candidate being made (a bridge run from the sources says what it is: the Strom app goes by it);
|
|
29
29
|
// at a release the release's own (package.json)
|
|
30
|
-
export const VERSION = "1.15.0-beta.
|
|
30
|
+
export const VERSION = "1.15.0-beta.3";
|
|
31
31
|
const GITIGNORE = `# Strom Research — local state and large files are not versioned
|
|
32
32
|
.strom/
|
|
33
33
|
/media/
|