strom-research 1.13.0-beta.4 → 1.13.0-beta.5
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 +3 -1
- package/assets/lang/de.json +3 -1
- package/dist/agents/files.js +5 -2
- package/dist/agents/global.js +13 -8
- package/dist/cli/context.js +3 -2
- package/dist/cli/ui.js +3 -1
- package/dist/commands/guide.js +5 -3
- package/dist/commands/meta.js +1 -1
- package/dist/commands/setup.js +5 -3
- package/dist/commands/start.js +15 -9
- package/dist/core/actions.js +6 -1
- package/dist/core/config.js +14 -3
- package/dist/core/dialog.js +12 -0
- package/dist/core/import.js +18 -4
- package/dist/core/live.js +110 -17
- package/dist/core/people.js +58 -0
- package/dist/core/stromapp.js +32 -1
- package/dist/core/sync.js +43 -13
- package/dist/core/tree.js +6 -5
- package/dist/core/validate.js +14 -0
- package/dist/gedcom/export.js +10 -4
- package/package.json +1 -1
package/assets/lang/cs.json
CHANGED
|
@@ -278,6 +278,7 @@
|
|
|
278
278
|
"ui.doc.appurl": "adresa aplikace Strom",
|
|
279
279
|
"ui.doc.appurl.config": "nastavení strom.app.url „{value}“ není adresa aplikace Strom – strom app nic neotevře, dokud se neopraví nebo neodebere",
|
|
280
280
|
"ui.doc.appurl.env": "proměnná STROM_APP_URL „{value}“ není adresa aplikace Strom – strom app nic neotevře, dokud se neopraví nebo neodebere",
|
|
281
|
+
"ui.doc.appurl.env.fix": "proměnnou STROM_APP_URL nastavit na adresu aplikace Strom ({web}, nebo její kopie na tomto počítači http://127.0.0.1:<port>/), nebo odebrat",
|
|
281
282
|
"ui.doc.tree.ok": "{name}: data v pořádku a zapečetěná",
|
|
282
283
|
"ui.doc.tree.bad": "{name}: problémů {n}",
|
|
283
284
|
"ui.doc.allok": "Všechno potřebné je na místě.",
|
|
@@ -849,11 +850,12 @@
|
|
|
849
850
|
"ui.live.app": " aplikace Strom ho sleduje na adrese: {url}",
|
|
850
851
|
"ui.live.stophow": " zastavit: strom live stop",
|
|
851
852
|
"ui.live.none": "most neběží – strom live start (nebo strom app --live)",
|
|
852
|
-
"ui.live.moved": " má novou adresu
|
|
853
|
+
"ui.live.moved": " má novou adresu: aplikace Strom ji najde, až v ní výzkum znovu otevře – strom app --live",
|
|
853
854
|
"ui.live.stopped": "most se zastavil",
|
|
854
855
|
"ui.live.killed": "most neskončil, když byl požádán: byl ukončen natvrdo",
|
|
855
856
|
"ui.live.noneran": "žádný most neběžel",
|
|
856
857
|
"ui.live.forgotten": "jeho adresa je zapomenutá: další most dostane novou",
|
|
858
|
+
"ui.live.newsecret": " další most dostane novou tajnou adresu: aplikace Strom ji dostane při dalším otevření výzkumu – strom app",
|
|
857
859
|
"ui.live.ended": "most skončil",
|
|
858
860
|
"ui.guide.archive": "Tento rodokmen je archiv: data přicházejí z aplikace Strom. Co v něm je: strom stats · strom person card <kdo> · strom pedigree <kdo> · strom recent. Výzkum zapíná jen uživatel: strom mode research (nebo menu).",
|
|
859
861
|
"ui.setup.links.archive": "Dovolit aplikaci Strom otevírat archiv na tomto počítači? (Jedním klikem pošle úpravy zpět nebo otevře výřez v plné kvalitě – vždy se tu nejdřív zeptá.)",
|
package/assets/lang/de.json
CHANGED
|
@@ -278,6 +278,7 @@
|
|
|
278
278
|
"ui.doc.appurl": "Adresse der Strom-App",
|
|
279
279
|
"ui.doc.appurl.config": "die Einstellung strom.app.url „{value}“ ist keine Adresse der Strom-App – strom app öffnet nichts, bis sie berichtigt oder entfernt ist",
|
|
280
280
|
"ui.doc.appurl.env": "die Variable STROM_APP_URL „{value}“ ist keine Adresse der Strom-App – strom app öffnet nichts, bis sie berichtigt oder entfernt ist",
|
|
281
|
+
"ui.doc.appurl.env.fix": "die Variable STROM_APP_URL auf eine Adresse der Strom-App setzen ({web}, oder eine Kopie auf diesem Computer http://127.0.0.1:<port>/) oder entfernen",
|
|
281
282
|
"ui.doc.tree.ok": "{name}: Daten in Ordnung und versiegelt",
|
|
282
283
|
"ui.doc.tree.bad": "{name}: Probleme: {n}",
|
|
283
284
|
"ui.doc.allok": "Alles Nötige ist vorhanden.",
|
|
@@ -849,11 +850,12 @@
|
|
|
849
850
|
"ui.live.app": " die Strom-App folgt ihr unter: {url}",
|
|
850
851
|
"ui.live.stophow": " anhalten: strom live stop",
|
|
851
852
|
"ui.live.none": "die Brücke läuft nicht – strom live start (oder strom app --live)",
|
|
852
|
-
"ui.live.moved": " sie hat eine neue Adresse
|
|
853
|
+
"ui.live.moved": " sie hat eine neue Adresse: die Strom-App findet sie, wenn die Forschung darin wieder geöffnet wird – strom app --live",
|
|
853
854
|
"ui.live.stopped": "die Brücke ist angehalten",
|
|
854
855
|
"ui.live.killed": "die Brücke endete nicht auf Bitte: sie wurde hart beendet",
|
|
855
856
|
"ui.live.noneran": "keine Brücke lief",
|
|
856
857
|
"ui.live.forgotten": "ihre Adresse ist vergessen: die nächste Brücke bekommt eine neue",
|
|
858
|
+
"ui.live.newsecret": " die nächste Brücke bekommt eine neue geheime Adresse: die Strom-App bekommt sie beim nächsten Öffnen der Forschung – strom app",
|
|
857
859
|
"ui.live.ended": "die Brücke ist beendet",
|
|
858
860
|
"ui.guide.archive": "Dieser Stammbaum ist ein Archiv: die Daten kommen aus der Strom-App. Was er enthält: strom stats · strom person card <wer> · strom pedigree <wer> · strom recent. Die Forschung schaltet nur der Benutzer ein: strom mode research (oder das Menü).",
|
|
859
861
|
"ui.setup.links.archive": "Der Strom-App erlauben, das Archiv auf diesem Computer zu öffnen? (Ein Klick schickt die Änderungen zurück oder öffnet einen Ausschnitt in voller Qualität – jeweils erst hier gefragt.)",
|
package/dist/agents/files.js
CHANGED
|
@@ -12,12 +12,15 @@ import { writeFileAtomic } from "../core/json.js";
|
|
|
12
12
|
import { PROFILES, SELF_READING } from "./profiles.js";
|
|
13
13
|
import { isArchive } from "../core/mode.js";
|
|
14
14
|
import { Settings } from "../core/config.js";
|
|
15
|
+
import { agentAppUrl, appSite } from "../core/stromapp.js";
|
|
15
16
|
import { configDir } from "../core/paths.js";
|
|
16
17
|
import { agentBrowser } from "../core/connector.js";
|
|
17
18
|
import { CHROME_ALLOW, CHROME_DENY, chromeDomain } from "../core/browser.js";
|
|
18
19
|
export const MARKER = "<!-- strom: generated above this line (strom agents sync); your own notes below are kept -->";
|
|
19
20
|
function agentsMd(tree) {
|
|
20
21
|
const lang = langName(tree.config.lang);
|
|
22
|
+
// the Strom app's address as strom app opens it now (the beta its beta, strom.app.url where it says another)
|
|
23
|
+
const app = agentAppUrl(tree.env);
|
|
21
24
|
return `# Family research: ${tree.config.name}
|
|
22
25
|
|
|
23
26
|
This folder is a family tree researched with **strom**, a command-line tool
|
|
@@ -99,12 +102,12 @@ they ask.
|
|
|
99
102
|
- **What waits for them** (\`strom\` shows it): tell them plainly what to do
|
|
100
103
|
and where, one thing at a time.
|
|
101
104
|
- **Results and the Strom app**: \`output/tree-strom.ged\` is the family tree
|
|
102
|
-
for the Strom app (
|
|
105
|
+
for the Strom app (${appSite(app)}) — strom's companion: a free family
|
|
103
106
|
tree app, no account, their data stay on their computer; it shows the tree,
|
|
104
107
|
the sources, a map, a family book. When they want to see the results, or
|
|
105
108
|
when \`strom\` says to offer it (it does once, not in every conversation),
|
|
106
109
|
suggest it gently, in a sentence or two: best installed as an app from the browser, from
|
|
107
|
-
|
|
110
|
+
${app} — \`strom app install\` opens it there and says
|
|
108
111
|
where to click; it works offline then; \`strom app\` opens it, with this
|
|
109
112
|
research when the app can take it — run by you, the app then follows the
|
|
110
113
|
research live, what you record shows there by itself (else in the app:
|
package/dist/agents/global.js
CHANGED
|
@@ -16,9 +16,11 @@ import path from "node:path";
|
|
|
16
16
|
import { isolated, userHome } from "../core/paths.js";
|
|
17
17
|
import { writeFileAtomic } from "../core/json.js";
|
|
18
18
|
import { installation } from "../core/self.js";
|
|
19
|
+
import { agentAppUrl, appSite } from "../core/stromapp.js";
|
|
19
20
|
const BEGIN = "<!-- strom: begin (strom agents install; removed by strom agents uninstall) -->";
|
|
20
21
|
const END = "<!-- strom: end -->";
|
|
21
|
-
|
|
22
|
+
/** What the agents are taught; app: the Strom app's address it names — the one `strom app` opens when it is written (agentAppUrl). */
|
|
23
|
+
export const globalText = (app) => `# strom — family history research
|
|
22
24
|
|
|
23
25
|
strom (the \`strom\` command) is installed on this computer: a tool for
|
|
24
26
|
researching a family's history with you as the researcher — people, parish
|
|
@@ -57,11 +59,11 @@ mentions strom, a family tree or the Strom app.
|
|
|
57
59
|
8. Scans come through strom. An archive the research needs has no
|
|
58
60
|
downloader (connector) yet: build one yourself, unasked
|
|
59
61
|
(\`strom connector new\`), and tell the user in a sentence.
|
|
60
|
-
9. The Strom app (
|
|
62
|
+
9. The Strom app (${appSite(app)}) is strom's companion: a free family
|
|
61
63
|
tree app, no account, the data stay on the user's computer. The research's
|
|
62
64
|
result (\`output/tree-strom.ged\`) opens in it. When the user wants to see
|
|
63
65
|
the tree (or \`strom\` says to offer it — once), suggest it gently — best installed as an app from the browser,
|
|
64
|
-
from
|
|
66
|
+
from ${app} (\`strom app install\` opens it there; it
|
|
65
67
|
then works offline); \`strom app\` opens it — with the research, followed
|
|
66
68
|
live while you work, when the app can take it. Without it, the user can ask
|
|
67
69
|
you about anyone in the tree: answer from \`strom person show\` and the like.
|
|
@@ -149,7 +151,9 @@ export function globalTargets(env) {
|
|
|
149
151
|
if (isolated(env))
|
|
150
152
|
return [];
|
|
151
153
|
const home = userHome(env);
|
|
152
|
-
|
|
154
|
+
// the app's address as strom app opens it now: a run of another channel (its first) writes the texts again
|
|
155
|
+
const app = agentAppUrl(env);
|
|
156
|
+
const targets = [
|
|
153
157
|
{ agent: "claude", file: path.join(env.CLAUDE_CONFIG_DIR ?? path.join(home, ".claude"), "skills", "strom", "SKILL.md"), kind: "own" },
|
|
154
158
|
{ agent: "claude", file: path.join(env.CLAUDE_CONFIG_DIR ?? path.join(home, ".claude"), "settings.json"), kind: "allow" },
|
|
155
159
|
{ agent: "codex", file: path.join(env.CODEX_HOME ?? path.join(home, ".codex"), "AGENTS.md"), kind: "block" },
|
|
@@ -161,13 +165,14 @@ export function globalTargets(env) {
|
|
|
161
165
|
{ agent: "grok", file: path.join(grokDir(env), "skills", "strom", "SKILL.md"), kind: "own" },
|
|
162
166
|
{ agent: "grok", file: path.join(grokDir(env), "config.toml"), kind: "allow" },
|
|
163
167
|
];
|
|
168
|
+
return targets.map((t) => ({ ...t, app }));
|
|
164
169
|
}
|
|
165
|
-
const
|
|
170
|
+
const skill = (app) => `---
|
|
166
171
|
name: strom
|
|
167
172
|
description: Family history and genealogy research with the strom command — ancestors, family trees, parish registers and archives, evidence, GEDCOM and the Strom app. Use when the user wants to research their family or mentions strom or the Strom app.
|
|
168
173
|
---
|
|
169
174
|
|
|
170
|
-
${
|
|
175
|
+
${globalText(app)}`;
|
|
171
176
|
function read(file) {
|
|
172
177
|
try {
|
|
173
178
|
return fs.readFileSync(file, "utf8");
|
|
@@ -282,10 +287,10 @@ export function installGlobal(t) {
|
|
|
282
287
|
next = JSON.stringify(s, null, 2) + "\n";
|
|
283
288
|
}
|
|
284
289
|
else if (t.kind === "own")
|
|
285
|
-
next = t.agent === "claude" || t.agent === "grok" ?
|
|
290
|
+
next = t.agent === "claude" || t.agent === "grok" ? skill(t.app) : globalText(t.app);
|
|
286
291
|
else {
|
|
287
292
|
const rest = cur ? withoutBlock(cur).replace(/\s+$/, "") : "";
|
|
288
|
-
next = `${rest ? `${rest}\n\n` : ""}${BEGIN}\n${
|
|
293
|
+
next = `${rest ? `${rest}\n\n` : ""}${BEGIN}\n${globalText(t.app)}${END}\n`;
|
|
289
294
|
}
|
|
290
295
|
if (cur === next)
|
|
291
296
|
return false;
|
package/dist/cli/context.js
CHANGED
|
@@ -16,7 +16,7 @@ import { readJsonIfExists } from "../core/json.js";
|
|
|
16
16
|
import { foldText } from "../core/text.js";
|
|
17
17
|
import { currentSession } from "../core/session.js";
|
|
18
18
|
import { isAgent } from "../core/which.js";
|
|
19
|
-
import { systemDialog, WAIT_SECONDS } from "../core/dialog.js";
|
|
19
|
+
import { canShowDialog, systemDialog, WAIT_SECONDS } from "../core/dialog.js";
|
|
20
20
|
import { UI, ui } from "./ui.js";
|
|
21
21
|
export class Context {
|
|
22
22
|
env;
|
|
@@ -179,7 +179,8 @@ export class Context {
|
|
|
179
179
|
const agent = isAgent(this.env);
|
|
180
180
|
if (this.interactive && !agent)
|
|
181
181
|
return "terminal";
|
|
182
|
-
|
|
182
|
+
// a window only where one can be shown (the test's own answer stands for one): else nothing said to wait in it
|
|
183
|
+
if (this.env.STROM_NONINTERACTIVE !== "1" && opts.window !== false && (this.io.dialog || canShowDialog(this.env))) {
|
|
183
184
|
const lang = this.uiLang();
|
|
184
185
|
const text = { title: ui(lang, "ui.dialog.title"), question: says ?? question, yes: ui(lang, "ui.dialog.yes"), no: ui(lang, "ui.dialog.no") };
|
|
185
186
|
// said first where it runs: a window can be behind others, and the command waits for it without a word
|
package/dist/cli/ui.js
CHANGED
|
@@ -245,6 +245,7 @@ export const UI = {
|
|
|
245
245
|
"ui.doc.appurl": "address of the Strom app",
|
|
246
246
|
"ui.doc.appurl.config": "the setting strom.app.url \"{value}\" is no address of the Strom app — strom app opens nothing until it is set right or removed",
|
|
247
247
|
"ui.doc.appurl.env": "the variable STROM_APP_URL \"{value}\" is no address of the Strom app — strom app opens nothing until it is set right or removed",
|
|
248
|
+
"ui.doc.appurl.env.fix": "the variable STROM_APP_URL set to an address of the Strom app ({web}, or a copy on this computer http://127.0.0.1:<port>/), or removed",
|
|
248
249
|
"ui.doc.tree.ok": "{name}: data consistent and sealed",
|
|
249
250
|
"ui.doc.tree.bad": "{name}: {n} problem(s)",
|
|
250
251
|
"ui.doc.allok": "Everything needed is in place.",
|
|
@@ -765,11 +766,12 @@ export const UI = {
|
|
|
765
766
|
"ui.live.app": " the Strom app following it: {url}",
|
|
766
767
|
"ui.live.stophow": " stop: strom live stop",
|
|
767
768
|
"ui.live.none": "the bridge does not run — strom live start (or strom app --live)",
|
|
768
|
-
"ui.live.moved": " its address is new
|
|
769
|
+
"ui.live.moved": " its address is new: the Strom app finds it when the research is opened in it again — strom app --live",
|
|
769
770
|
"ui.live.stopped": "the bridge stopped",
|
|
770
771
|
"ui.live.killed": "the bridge did not end when asked: it was ended for good",
|
|
771
772
|
"ui.live.noneran": "no bridge ran",
|
|
772
773
|
"ui.live.forgotten": "its address is forgotten: the next bridge gets a new one",
|
|
774
|
+
"ui.live.newsecret": " the next bridge gets a new secret address: the Strom app gets it when the research is opened in it again — strom app",
|
|
773
775
|
"ui.live.ended": "the bridge ended",
|
|
774
776
|
"ui.guide.archive": "This family tree is an archive: the data come from the Strom app. What it holds: strom stats · strom person card <who> · strom pedigree <who> · strom recent. Research is switched on by the user alone: strom mode research (or the menu).",
|
|
775
777
|
"ui.setup.links.archive": "Let the Strom app open the archive on this computer? (One click sends the edits back or opens an excerpt in full quality — each asked here first.)",
|
package/dist/commands/guide.js
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// sessions and costs tokens every time. Every command in it must exist
|
|
4
4
|
// (test/cli/examples.test.ts checks them against the registry).
|
|
5
5
|
import { langName } from "../core/lang.js";
|
|
6
|
-
|
|
6
|
+
import { appSite } from "../core/stromapp.js";
|
|
7
|
+
/** The guide; appUrl: the Strom app's address it names — the one `strom app` opens (appUrlShown). */
|
|
8
|
+
export function guideText(lang, appUrl) {
|
|
7
9
|
const language = lang
|
|
8
10
|
? `The research language of this tree is ${langName(lang)} (${lang}): talk to the user in ${langName(lang)} and write notes, tasks and stories in ${langName(lang)}. Keep transcripts of records in their original language.`
|
|
9
11
|
: "Talk to the user in their language. Set it as the research language: strom setup --lang <code> (cs, en, de, pl, …).";
|
|
@@ -181,11 +183,11 @@ output/tree-strom.ged (for the Strom app) — both from the same evidence, also
|
|
|
181
183
|
Changes are committed automatically; you never run git yourself.
|
|
182
184
|
|
|
183
185
|
THE STROM APP — where the user sees the result
|
|
184
|
-
The Strom app (
|
|
186
|
+
The Strom app (${appSite(appUrl)}) is strom's companion: a free family tree app, no account,
|
|
185
187
|
the family's data stay on their computer; it shows the tree, the sources, a map, a family book.
|
|
186
188
|
When the user wants to see the results, or when strom says to offer it (once, not in every
|
|
187
189
|
conversation), suggest it gently, in a sentence or two — best installed as an app from the browser, then it works offline:
|
|
188
|
-
|
|
190
|
+
${appUrl} the app itself: opened in the browser, installed from there (the
|
|
189
191
|
install icon at the end of the address bar; Safari: File → Add to Dock)
|
|
190
192
|
strom app install opens it there and says where to click
|
|
191
193
|
strom app opens it — with this research when the app can take it (strom says so), and
|
package/dist/commands/meta.js
CHANGED
|
@@ -209,7 +209,7 @@ register({
|
|
|
209
209
|
const said = ui(ctx.tree().lang, "ui.guide.archive");
|
|
210
210
|
return { text: said, data: { guide: said, lang, archive: true } };
|
|
211
211
|
}
|
|
212
|
-
const text = guideText(lang);
|
|
212
|
+
const text = guideText(lang, appUrlShown(ctx.settings).url);
|
|
213
213
|
return { text, data: { guide: text, lang } };
|
|
214
214
|
},
|
|
215
215
|
}, {
|
package/dist/commands/setup.js
CHANGED
|
@@ -5,7 +5,7 @@ import path from "node:path";
|
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
6
|
import { APP_URL_INVALID_SETTING, register } from "../cli/registry.js";
|
|
7
7
|
import { lines, table } from "../cli/format.js";
|
|
8
|
-
import { checkValue, configFile, DEFAULT_BUDGET, DEFAULT_RUN_MINUTES, OTHER_ENV, SETTINGS, settingDef, writeStored } from "../core/config.js";
|
|
8
|
+
import { appWebPages, checkValue, configFile, DEFAULT_BUDGET, DEFAULT_RUN_MINUTES, OTHER_ENV, SETTINGS, settingDef, settingDescription, writeStored } from "../core/config.js";
|
|
9
9
|
import { syncAgentFiles } from "../agents/files.js";
|
|
10
10
|
import { gitVersion } from "../core/git.js";
|
|
11
11
|
import { linuxGitCommand } from "../core/deps.js";
|
|
@@ -361,7 +361,9 @@ function diagnose(ctx) {
|
|
|
361
361
|
// settings) is a problem said with its way out — doctor never fails on it (strom app refuses to open it)
|
|
362
362
|
const appUrl = appUrlSetting(ctx.settings).invalid;
|
|
363
363
|
if (appUrl)
|
|
364
|
-
add("appurl", "fail", t(appUrl.source === "env" ? "ui.doc.appurl.env" : "ui.doc.appurl.config", { value: appUrl.value }), appUrl.source === "env"
|
|
364
|
+
add("appurl", "fail", t(appUrl.source === "env" ? "ui.doc.appurl.env" : "ui.doc.appurl.config", { value: appUrl.value }), appUrl.source === "env"
|
|
365
|
+
? t("ui.doc.appurl.env.fix", { web: appWebPages(ctx.env) })
|
|
366
|
+
: placeholders(lang, "strom config set strom.app.url <address> · strom config unset strom.app.url"));
|
|
365
367
|
// …and whether it may start the research here (strom-research:// links): only while the app is wanted
|
|
366
368
|
if (app !== "no" && appOpensLinks(ctx.settings)) {
|
|
367
369
|
const links = linkHandlerState(ctx.env);
|
|
@@ -762,7 +764,7 @@ register({
|
|
|
762
764
|
run(ctx) {
|
|
763
765
|
const rows = SETTINGS.map((def) => {
|
|
764
766
|
const r = effective(ctx, def);
|
|
765
|
-
return { key: def.key, value: r.value, source: r.source, env: def.env, tree: def.tree, description: def.
|
|
767
|
+
return { key: def.key, value: r.value, source: r.source, env: def.env, tree: def.tree, description: settingDescription(def, ctx.env), ...(r.invalid ? { invalid: true } : {}) };
|
|
766
768
|
});
|
|
767
769
|
const invalid = rows.filter((r) => r.invalid).map((r) => r.key);
|
|
768
770
|
const text = lines(table(rows.map((r) => [r.key, `${display(ctx, settingDef(r.key), r.value)}${r.invalid ? " (invalid)" : ""}`, r.source, r.env])), "", ...(invalid.length ? [APP_URL_INVALID_SETTING, ""] : []), "order: flag > env > tree (strom.json) > config > default", `change: strom config set <key> <value> [--for-tree] · strom config unset <key> [--for-tree]`, `other env: ${OTHER_ENV.map((e) => e.env).join(" ")} (strom help config where)`, `config file: ${ctx.display(configFile(ctx.env))}`);
|
package/dist/commands/start.js
CHANGED
|
@@ -20,7 +20,7 @@ import { agentBrowser } from "../core/connector.js";
|
|
|
20
20
|
import { browserNote } from "./connectors.js";
|
|
21
21
|
import { offerLinks } from "../cli/wizard.js";
|
|
22
22
|
import { appOpensLinks, appOpensResearch, appUrlSetting, importAppUrl, installedStromApp, liveAppUrl, liveAppUrlShown, noticeStromApp, stromAppUrl } from "../core/stromapp.js";
|
|
23
|
-
import { forgetLive, liveRunning, serveLive, startLive, stopLive } from "../core/live.js";
|
|
23
|
+
import { dropLiveSecret, forgetLive, liveRunning, serveLive, startLive, stopLive } from "../core/live.js";
|
|
24
24
|
import { openForUser } from "../core/open.js";
|
|
25
25
|
import { createShortcut, openInNewTerminal } from "../core/shortcut.js";
|
|
26
26
|
import { browserKind, openFileWith, revealFile } from "../core/chromium.js";
|
|
@@ -318,8 +318,9 @@ register({
|
|
|
318
318
|
"strom app --live, takes the tree from it and hears what changes — who is at work on what, what was recorded, what\n" +
|
|
319
319
|
"waits for the user. It ends by itself when nobody asks it anything for two hours, or with strom live stop.\n" +
|
|
320
320
|
"One error does not end it (a read that fails is tried again); what it did and what went wrong is in .strom/live.log.\n" +
|
|
321
|
-
"Started again
|
|
322
|
-
"one that ended without a word is started again
|
|
321
|
+
"Started again by itself (strom updated, one that ended without a word or when idle) it takes the address it had\n" +
|
|
322
|
+
"(its port while free), so the app following it goes on by itself; one that ended without a word is started again\n" +
|
|
323
|
+
"when a session starts. Stopped with strom live stop, the next one gets a new secret address.",
|
|
323
324
|
examples: ["strom live", "strom live start", "strom live stop"],
|
|
324
325
|
run(ctx) {
|
|
325
326
|
const tree = bridgeTree(ctx);
|
|
@@ -351,20 +352,23 @@ register({
|
|
|
351
352
|
summary: "Stop the live bridge",
|
|
352
353
|
group: "output",
|
|
353
354
|
tree: true,
|
|
354
|
-
description: "
|
|
355
|
-
"
|
|
356
|
-
"
|
|
357
|
-
options: [{ name: "forget", type: "boolean", description: "the next bridge gets a new secret
|
|
355
|
+
description: "Ended for good: the next bridge gets a new secret (its port while free) — the address the Strom app kept no longer\n" +
|
|
356
|
+
"works, and the app gets the new one when the research is opened in it again (strom app). --forget: its port too.\n" +
|
|
357
|
+
"(A bridge started again by itself — strom updated, one that ended when idle — keeps its address.)",
|
|
358
|
+
options: [{ name: "forget", type: "boolean", description: "the next bridge gets a new port too, not only a new secret" }],
|
|
358
359
|
examples: ["strom live stop", "strom live stop --forget"],
|
|
359
360
|
run(ctx, { opts }) {
|
|
360
361
|
const { root, lang } = bridgeTree(ctx);
|
|
361
362
|
const how = stopLive(root);
|
|
362
363
|
if (how === "alive")
|
|
363
364
|
throw new StromError("the bridge could not be ended", { hint: `its process: see .strom/live.json — end it in the system's task manager`, code: "live.alive" });
|
|
365
|
+
// ended for good: its secret dropped (the address that may have got out dies with it)
|
|
366
|
+
const dropped = dropLiveSecret(root, "strom live stop");
|
|
364
367
|
if (opts.forget)
|
|
365
368
|
forgetLive(root);
|
|
366
369
|
const said = ui(lang, how === "none" ? "ui.live.noneran" : how === "killed" ? "ui.live.killed" : "ui.live.stopped");
|
|
367
|
-
|
|
370
|
+
const next = opts.forget ? ui(lang, "ui.live.forgotten") : dropped ? ui(lang, "ui.live.newsecret") : undefined;
|
|
371
|
+
return { text: lines(said, next), data: { stopped: how !== "none", how, ...(dropped ? { newSecret: true } : {}), ...(opts.forget ? { forgotten: true } : {}) } };
|
|
368
372
|
},
|
|
369
373
|
}, {
|
|
370
374
|
path: ["live", "serve"],
|
|
@@ -465,7 +469,9 @@ register({
|
|
|
465
469
|
// the bridges of the research (the Strom app following it) end first: they run this installation's Node
|
|
466
470
|
for (const k of ctx.knownTrees()) {
|
|
467
471
|
try {
|
|
468
|
-
|
|
472
|
+
// ended for good: its secret too (installed again, the app gets the new address through strom app)
|
|
473
|
+
if (stopLive(k.root, "strom uninstall") !== "alive")
|
|
474
|
+
dropLiveSecret(k.root, "strom uninstall");
|
|
469
475
|
}
|
|
470
476
|
catch {
|
|
471
477
|
// its tree unreadable: its bridge, if any, is found below
|
package/dist/core/actions.js
CHANGED
|
@@ -5,7 +5,7 @@ import { normalizeDate } from "./gdate.js";
|
|
|
5
5
|
import { normalizeAge } from "./age.js";
|
|
6
6
|
import { checkStatus, defaultStatus } from "./evidence.js";
|
|
7
7
|
import { eventKind, FAMILY_EVENT_KINDS, NOTE_MAX, STATUSES, PARTICIPANT_ROLES, NAME_KINDS, RECORD_TYPES, } from "./model.js";
|
|
8
|
-
import { cleanTitle, displayName, familiesAsChild, familiesAsPartner, gedcomName, gedcomTitledName, isBirthFamily, notAName, parseName, primaryName, sameName, slashInName } from "./people.js";
|
|
8
|
+
import { cleanTitle, displayName, familiesAsChild, familiesAsPartner, gedcomName, gedcomTitledName, isBirthFamily, keepSides, notAName, parseName, primaryName, sameName, slashInName } from "./people.js";
|
|
9
9
|
import { foldText } from "./text.js";
|
|
10
10
|
import { roleWord } from "./roles.js";
|
|
11
11
|
import { now, typeOfId } from "./tree.js";
|
|
@@ -380,6 +380,7 @@ export function editFamily(tree, familyId, edit, reason) {
|
|
|
380
380
|
if (!was)
|
|
381
381
|
throw new UsageError(`${edit.remove} is not in ${familyId}`);
|
|
382
382
|
next.partners = next.partners.filter((p) => p !== edit.remove);
|
|
383
|
+
delete next.husb;
|
|
383
384
|
next.children = next.children.filter((c) => c.person !== edit.remove);
|
|
384
385
|
for (const c of next.children)
|
|
385
386
|
if (c.relations?.[edit.remove]) {
|
|
@@ -623,7 +624,11 @@ export function editPerson(tree, id, edit, reason) {
|
|
|
623
624
|
edit.prefix !== undefined ? `title before the name ${retitled.prefix ? `"${retitled.prefix}"` : "taken off"}` : "",
|
|
624
625
|
edit.suffix !== undefined ? `title after the name ${retitled.suffix ? `"${retitled.suffix}"` : "taken off"}` : "",
|
|
625
626
|
].filter(Boolean).join(", ");
|
|
627
|
+
// its couples keep the sides their files wrote (U01-e): written before the person, read with the sex as it was
|
|
628
|
+
const sides = sex !== undefined && sex !== p.sex ? keepSides(tree, p, updated) : [];
|
|
626
629
|
tree.put(updated, { op: "person.edit", targets: [id], summary: `${id} ${what}`, ...(reason ? { reason } : {}) });
|
|
630
|
+
for (const { next } of sides)
|
|
631
|
+
tree.put({ ...next, updated: now() }, { op: "family.edit", targets: [next.id], summary: next.husb ? `${next.id} ${next.husb} kept as HUSB (the sex of ${id} changed)` : `${next.id} sides by sex again`, ...(reason ? { reason } : {}) });
|
|
627
632
|
return updated;
|
|
628
633
|
});
|
|
629
634
|
}
|
package/dist/core/config.js
CHANGED
|
@@ -13,7 +13,7 @@ import { DEFAULT_AGENT, PROFILES, TIERS } from "../agents/profiles.js";
|
|
|
13
13
|
import { EXCERPT_QUALITIES, EXCERPT_SCOPES, EXCERPTS_MAX_MB, STRATEGIES } from "./model.js";
|
|
14
14
|
import { downloadsDir } from "./browser.js";
|
|
15
15
|
import { acquireLock } from "./lock.js";
|
|
16
|
-
import { isAppVersion, isStromAppOrigin, STROM_APP_BETA_URL, STROM_APP_URL } from "./stromapp.js";
|
|
16
|
+
import { defaultAppUrl, isAppVersion, isStromAppOrigin, STROM_APP_BETA_URL, STROM_APP_URL } from "./stromapp.js";
|
|
17
17
|
import { updateChannel } from "./update.js";
|
|
18
18
|
export const PERMISSION_LEVELS = ["ask", "auto", "full"];
|
|
19
19
|
/** Earlier names of the levels, still understood. */
|
|
@@ -65,7 +65,7 @@ export const SETTINGS = [
|
|
|
65
65
|
{ key: "updates", env: "STROM_UPDATES", tree: false, kind: "choice", choices: ["check", "off"], description: "look for new versions of strom: check (default — at most once a day, one small file from the project's releases; strom says so, strom update installs it) or off" },
|
|
66
66
|
{ key: "strom.app", env: "", tree: false, kind: "choice", choices: ["yes", "no"], description: "you use the Strom app: yes (strom says which file to import into it), no (strom never mentions it) — unset: strom notices it itself" },
|
|
67
67
|
{ key: "app.browser", env: "STROM_APP_BROWSER", tree: false, kind: "choice", choices: ["chrome", "edge", "brave", "opera", "firefox", "chromium"], description: "the browser strom opens the Strom app in — the one its tree came from (strom keeps it), or yours: chrome, edge, brave, opera, firefox, chromium (another Chromium: Vivaldi, Arc). The Strom app installed from a browser always comes first (from this one when it is installed from several); without one, a tab of this browser — unset: of the default browser when the app reaches strom from it, else of the first such browser here" },
|
|
68
|
-
{ key: "strom.app.url", env: "STROM_APP_URL", tree: false, kind: "url", description: "another copy of the Strom app to open instead of
|
|
68
|
+
{ key: "strom.app.url", env: "STROM_APP_URL", tree: false, kind: "url", description: "another copy of the Strom app to open instead of {appUrl} — e.g. its development (http://127.0.0.1:8080/); installed from a browser, that copy opens as its own app" },
|
|
69
69
|
];
|
|
70
70
|
/** Fields of the stored settings whose key is not the field name. */
|
|
71
71
|
const FIELDS = {
|
|
@@ -97,6 +97,13 @@ export const OTHER_ENV = [
|
|
|
97
97
|
{ env: "STROM_DOCUMENTS", description: "the Documents folder, where strom suggests its home (default: the system's)" },
|
|
98
98
|
{ env: "STROM_APP", description: "set by the Strom app when it starts strom (or an agent for it): strom then knows the app is there" },
|
|
99
99
|
];
|
|
100
|
+
/**
|
|
101
|
+
* A setting's description as it is said here: {appUrl} the copy of the Strom app strom opens when strom.app.url says
|
|
102
|
+
* none — the channel's (defaultAppUrl: the beta its beta, the releases stromapp.info) (B1-g).
|
|
103
|
+
*/
|
|
104
|
+
export function settingDescription(def, env) {
|
|
105
|
+
return def.description.replace(/\{appUrl\}/g, defaultAppUrl(env));
|
|
106
|
+
}
|
|
100
107
|
export function settingDef(key) {
|
|
101
108
|
const def = SETTINGS.find((s) => s.key === key);
|
|
102
109
|
if (!def)
|
|
@@ -124,6 +131,10 @@ function isAppAddress(v) {
|
|
|
124
131
|
return false; // not an address
|
|
125
132
|
}
|
|
126
133
|
}
|
|
134
|
+
/** The Strom app's pages on the web strom names as its addresses: the releases stromapp.info only (production says nothing of a beta), the beta its beta too. */
|
|
135
|
+
export function appWebPages(env) {
|
|
136
|
+
return updateChannel(env) === "beta" ? `${STROM_APP_BETA_URL} · ${STROM_APP_URL}` : STROM_APP_URL;
|
|
137
|
+
}
|
|
127
138
|
/**
|
|
128
139
|
* strom.app.url that says no address of the Strom app — typed (`set`: strom config set), in the variable STROM_APP_URL
|
|
129
140
|
* (`env`), or in the settings however it got there (`config`: written by hand): refused, with what is an address of it
|
|
@@ -132,7 +143,7 @@ function isAppAddress(v) {
|
|
|
132
143
|
* a copy on this computer (its development) both.
|
|
133
144
|
*/
|
|
134
145
|
export function appUrlInvalid(raw, how, env) {
|
|
135
|
-
const web =
|
|
146
|
+
const web = appWebPages(env);
|
|
136
147
|
const allowed = `the Strom app: ${web}, or a copy on this computer http://127.0.0.1:<port>/`;
|
|
137
148
|
const fix = how === "config" ? "\nset it right: strom config set strom.app.url <address> — or remove it: strom config unset strom.app.url" : how === "env" ? "\nthe variable STROM_APP_URL set right, or removed" : "";
|
|
138
149
|
const code = "config.app-url";
|
package/dist/core/dialog.js
CHANGED
|
@@ -28,6 +28,18 @@ function psString(s) {
|
|
|
28
28
|
* Ask in a window: true (yes), false (no, closed or no answer in time), or
|
|
29
29
|
* undefined when this computer cannot show one (no desktop, no dialog tool).
|
|
30
30
|
*/
|
|
31
|
+
/**
|
|
32
|
+
* Can this computer show a window of the system to ask in? Not with dialogs off (STROM_NO_DIALOG: a test run, a user who
|
|
33
|
+
* wants their consents in a terminal only), nor on Linux without a desktop or a dialog tool — then nothing is said to
|
|
34
|
+
* wait in one (found: strom update --json said a question waited in a window with none shown).
|
|
35
|
+
*/
|
|
36
|
+
export function canShowDialog(env, platform = process.platform) {
|
|
37
|
+
if (env.STROM_NO_DIALOG === "1")
|
|
38
|
+
return false;
|
|
39
|
+
if (platform === "darwin" || platform === "win32")
|
|
40
|
+
return true;
|
|
41
|
+
return Boolean((env.DISPLAY || env.WAYLAND_DISPLAY) && (which("zenity", env) || which("kdialog", env)));
|
|
42
|
+
}
|
|
31
43
|
export function systemDialog(text, env, platform = process.platform) {
|
|
32
44
|
// A test run, or a user who wants their consents in a terminal only.
|
|
33
45
|
if (env.STROM_NO_DIALOG === "1")
|
package/dist/core/import.js
CHANGED
|
@@ -12,7 +12,7 @@ import { validateRecord } from "./validate.js";
|
|
|
12
12
|
import { normalizeDate } from "./gdate.js";
|
|
13
13
|
import { appNoSurname, cleanTitle, noName, notAName, parseName, sameName, surnameAfterGiven, withoutTitles } from "./people.js";
|
|
14
14
|
import { now } from "./tree.js";
|
|
15
|
-
import { appUnknownIsNoSurname } from "./stromapp.js";
|
|
15
|
+
import { appUnknownIsNoSurname, appWritesSexUnknown } from "./stromapp.js";
|
|
16
16
|
import { children, parseGedcomText, val } from "../gedcom/parse.js";
|
|
17
17
|
/** Christenings and baptisms are one fact for a comparison. */
|
|
18
18
|
const SAME_KIND = { CHR: "BAPM", BAPM: "BAPM" };
|
|
@@ -120,11 +120,25 @@ function nameKind(type) {
|
|
|
120
120
|
/**
|
|
121
121
|
* A file of the Strom app (its HEAD: 1 SOUR STROM), and whether its "? /Unknown/" is a person of no surname — an app
|
|
122
122
|
* before 3.10.0-beta.7 or one whose version the HEAD's 2 VERS does not say (T08b); from it on the surname Unknown.
|
|
123
|
+
* Its SEX U is a sex left unknown (U01) where the header says so (1 _STROM_SEX_U Y: the app's tree linked to a
|
|
124
|
+
* research) or the app is 3.10.0-beta.11 or newer (its HEAD's 2 VERS; the bridge stamps the version the app said);
|
|
125
|
+
* else the app guessed there (a husband male, anyone else female).
|
|
123
126
|
*/
|
|
124
127
|
export function stromAppFile(head) {
|
|
125
128
|
const sour = head ? children(head, "SOUR")[0] : undefined;
|
|
126
129
|
const app = sour?.value.trim() === "STROM";
|
|
127
|
-
|
|
130
|
+
const marked = val(head, "_STROM_SEX_U")?.trim().toUpperCase() === "Y";
|
|
131
|
+
return { app, unknownIsNoSurname: app && appUnknownIsNoSurname(val(sour, "VERS")), sexUnknown: marked || (app && appWritesSexUnknown(val(sour, "VERS"))) };
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The sex of a person of a file, read the same way by strom intake and strom sync: M and F as it says; SEX U the sex
|
|
135
|
+
* left unknown — "U", a value like the others — where the file means it so (stromAppFile: sexUnknown, U01), else
|
|
136
|
+
* nothing the file says (an app's guess). No SEX line, or another value, says nothing of it in any file (B5-d: the app
|
|
137
|
+
* writes SEX for everyone; found: a file of its 3.10.0-beta.11 without SEX lines read as every sex set unknown).
|
|
138
|
+
*/
|
|
139
|
+
export function readGedSex(r, file) {
|
|
140
|
+
const sex = val(r, "SEX")?.trim().toUpperCase();
|
|
141
|
+
return sex === "M" || sex === "F" ? sex : sex === "U" && file.sexUnknown ? "U" : undefined;
|
|
128
142
|
}
|
|
129
143
|
/**
|
|
130
144
|
* One NAME of a file, read the same way by strom intake and strom sync: the titles (NPFX, NSFX) apart from it — the
|
|
@@ -371,7 +385,7 @@ export function importGedcom(tree, text, opts) {
|
|
|
371
385
|
const names = namesOf(r);
|
|
372
386
|
// The birth name leads: a married name is a variant.
|
|
373
387
|
names.sort((a, b) => (a.kind === "married" || a.kind === "alias" ? 1 : 0) - (b.kind === "married" || b.kind === "alias" ? 1 : 0));
|
|
374
|
-
const sex =
|
|
388
|
+
const sex = readGedSex(r, fromApp) ?? "U";
|
|
375
389
|
const refs = [{ system, id: r.xref }];
|
|
376
390
|
if (refn)
|
|
377
391
|
refs.push({ system: "refn", id: refn });
|
|
@@ -390,7 +404,7 @@ export function importGedcom(tree, text, opts) {
|
|
|
390
404
|
id: xrefToId.get(r.xref),
|
|
391
405
|
type: "person",
|
|
392
406
|
names: names.length ? names : [{ given: "?", surname: "" }],
|
|
393
|
-
sex
|
|
407
|
+
sex,
|
|
394
408
|
events: personEvents,
|
|
395
409
|
notes: personNotes.filter((x) => x.trim()).map((x) => note(tree, x)),
|
|
396
410
|
refs,
|
package/dist/core/live.js
CHANGED
|
@@ -38,10 +38,23 @@
|
|
|
38
38
|
// It never ends because of one error: a read that fails while another strom
|
|
39
39
|
// writes is tried again at the next tick, a request that fails answers 500.
|
|
40
40
|
// What it did and what went wrong is in .strom/live.log (start, end and why,
|
|
41
|
-
// errors with their stack).
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
41
|
+
// errors with their stack).
|
|
42
|
+
//
|
|
43
|
+
// The secret: started again by itself — a newer strom on disk (live start
|
|
44
|
+
// --current), one that ended without a word (reviveLive), the first run of a
|
|
45
|
+
// newer strom bringing its bridges back, a start after it ended when idle — it
|
|
46
|
+
// takes the address it had (.strom/live-last.json: its port while free, its
|
|
47
|
+
// token), so the app following it goes on by itself. Ended for good (strom live
|
|
48
|
+
// stop, strom uninstall) its secret is dropped: the next bridge gets a new one
|
|
49
|
+
// (its port kept), the address the app kept no longer works, and the app gets
|
|
50
|
+
// the new one when the research is opened in it again (strom app, ?live=). A
|
|
51
|
+
// request with the secret from a page that is no Strom app (an Origin the
|
|
52
|
+
// bridge does not let in; never "null", none at all, or a copy of the app on
|
|
53
|
+
// this computer) means the address got out: the secret is replaced at once,
|
|
54
|
+
// written where the next strom app reads it, the app's open event streams
|
|
55
|
+
// ended, and that request answered as one without the secret. What is being
|
|
56
|
+
// written then (a send, an original) is finished; only new requests need the
|
|
57
|
+
// new secret. Nothing is replaced on a timer.
|
|
45
58
|
import { opsLogsOf } from "./opslog.js";
|
|
46
59
|
import fs from "node:fs";
|
|
47
60
|
import http from "node:http";
|
|
@@ -74,7 +87,7 @@ import { rankTasks } from "./queue.js";
|
|
|
74
87
|
import { humanTask } from "../cli/human.js";
|
|
75
88
|
import { knownNewerVersion, updateChannel } from "./update.js";
|
|
76
89
|
import { gitProgram, runGit } from "./git.js";
|
|
77
|
-
import { appKnowsArchive, appKnowsNoCouple, appOpensLinks, appReadsTitles, appShowsCoupleEvents, appShowsEdges, appShowsFactStatus, appShowsSourceReads, appShowsStoryDrafts, appTurnsExcerpts, isAppVersion, isStromAppOrigin } from "./stromapp.js";
|
|
90
|
+
import { appKnowsArchive, appKnowsNoCouple, appOpensLinks, appReadsTitles, appShowsCoupleEvents, appShowsEdges, appShowsFactStatus, appShowsSourceReads, appShowsStoryDrafts, appTurnsExcerpts, appUrlSetting, isAppVersion, isStromAppOrigin } from "./stromapp.js";
|
|
78
91
|
import { Settings } from "./config.js";
|
|
79
92
|
import { LINK_SCHEME, linkActions, linkHandlerState, linkHandlerStateLater, linkScheme } from "./links.js";
|
|
80
93
|
import { autoTidy } from "./tidy.js";
|
|
@@ -133,7 +146,7 @@ function lastFile(root) {
|
|
|
133
146
|
function readLast(root) {
|
|
134
147
|
try {
|
|
135
148
|
const last = JSON.parse(fs.readFileSync(lastFile(root), "utf8"));
|
|
136
|
-
return Number.isInteger(last.port) && /^[0-9a-f]{32}$/.test(last.token) ? last : undefined;
|
|
149
|
+
return Number.isInteger(last.port) && (last.token === undefined || /^[0-9a-f]{32}$/.test(last.token)) ? last : undefined;
|
|
137
150
|
}
|
|
138
151
|
catch {
|
|
139
152
|
return undefined;
|
|
@@ -270,6 +283,20 @@ function spawnBridge(root, env, extra = {}) {
|
|
|
270
283
|
fs.closeSync(log);
|
|
271
284
|
return child;
|
|
272
285
|
}
|
|
286
|
+
/**
|
|
287
|
+
* The bridge ended for good (strom live stop, strom uninstall): its secret dropped, its port kept — the next bridge gets a
|
|
288
|
+
* new secret, the address the app kept is dead (the app gets the new one when the research is opened in it again). Whether
|
|
289
|
+
* there was one to drop.
|
|
290
|
+
*/
|
|
291
|
+
export function dropLiveSecret(root, why) {
|
|
292
|
+
const last = readLast(root);
|
|
293
|
+
if (!last?.token)
|
|
294
|
+
return false;
|
|
295
|
+
const { token: _dropped, ...rest } = last;
|
|
296
|
+
writeLast(root, { ...rest, ended: last.ended ?? { at: new Date().toISOString(), reason: why } });
|
|
297
|
+
noteLive(root, `its secret dropped (${why}): the next bridge gets a new one, at port ${last.port} while free — the Strom app gets it when the research is opened in it again (strom app)`);
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
273
300
|
/** The bridge's last address forgotten: the next one gets a new secret (and port) — the address the app kept is dead. */
|
|
274
301
|
export function forgetLive(root) {
|
|
275
302
|
fs.rmSync(lastFile(root), { force: true });
|
|
@@ -365,6 +392,35 @@ export function reviveLive(root, env) {
|
|
|
365
392
|
function allowedOrigin(origin) {
|
|
366
393
|
return origin && isStromAppOrigin(origin) ? origin : undefined;
|
|
367
394
|
}
|
|
395
|
+
/**
|
|
396
|
+
* A request with the secret from a page that is no Strom app: the address got out. Not one without an Origin (curl, a
|
|
397
|
+
* process of this computer, the app's EventSource), "null" (the app opened as a file), a page the bridge lets in, nor the
|
|
398
|
+
* copy of the app the person set (strom.app.url).
|
|
399
|
+
*/
|
|
400
|
+
function foreignOrigin(origin, env) {
|
|
401
|
+
if (origin === undefined || origin === "null" || allowedOrigin(origin))
|
|
402
|
+
return false;
|
|
403
|
+
try {
|
|
404
|
+
const said = appUrlSetting(new Settings(env, {}));
|
|
405
|
+
if (!said.invalid && new URL(said.url).origin === origin)
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
// no setting to read: the pages the bridge lets in are what there is
|
|
410
|
+
}
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
/** An address as the log says it: never its secret (one replaced meanwhile neither). */
|
|
414
|
+
function masked(url) {
|
|
415
|
+
return String(url ?? "").replace(/\/[0-9a-f]{32}(?=[/?]|$)/, "/…");
|
|
416
|
+
}
|
|
417
|
+
/** live.json written whole: a strom reading it meanwhile never takes a half for no bridge. */
|
|
418
|
+
function writeLive(root, info) {
|
|
419
|
+
const file = liveFile(root);
|
|
420
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
421
|
+
fs.writeFileSync(`${file}.${process.pid}`, JSON.stringify(info, null, 2));
|
|
422
|
+
fs.renameSync(`${file}.${process.pid}`, file);
|
|
423
|
+
}
|
|
368
424
|
function head(root) {
|
|
369
425
|
const r = spawnSync(gitProgram() ?? "git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8", windowsHide: true });
|
|
370
426
|
return r.status === 0 ? r.stdout.trim() : "";
|
|
@@ -793,9 +849,12 @@ export function serveLive(root, env) {
|
|
|
793
849
|
catch (e) {
|
|
794
850
|
noteLive(root, `an archive's tasks not put aside now: ${errorText(e)}`);
|
|
795
851
|
}
|
|
796
|
-
// the address of the last bridge, so the app that followed it finds this one
|
|
852
|
+
// the address of the last bridge, so the app that followed it finds this one (its secret: none once it was ended for
|
|
853
|
+
// good — a new one); replaced while it runs when a page that is no Strom app comes with it (leaked)
|
|
797
854
|
const last = readLast(root);
|
|
798
|
-
|
|
855
|
+
let token = last?.token ?? crypto.randomBytes(16).toString("hex");
|
|
856
|
+
// what this bridge says of itself (live.json), once it listens
|
|
857
|
+
let live;
|
|
799
858
|
const idleMs = Number(env.STROM_LIVE_IDLE_MS ?? 2 * 60 * 60_000);
|
|
800
859
|
const pollMs = Number(env.STROM_LIVE_POLL_MS ?? 2000);
|
|
801
860
|
const streams = new Set();
|
|
@@ -804,7 +863,7 @@ export function serveLive(root, env) {
|
|
|
804
863
|
let log;
|
|
805
864
|
/** A request that failed: said to the app, written into the log — the bridge goes on. */
|
|
806
865
|
const failed = (req, res, e) => {
|
|
807
|
-
noteLive(root, `${req.method} ${
|
|
866
|
+
noteLive(root, `${req.method} ${masked(req.url)} failed: ${errorText(e)}`);
|
|
808
867
|
try {
|
|
809
868
|
// an answer begun (the events) is only ended
|
|
810
869
|
if (res.headersSent)
|
|
@@ -854,7 +913,7 @@ export function serveLive(root, env) {
|
|
|
854
913
|
if (Array.isArray(said.skipped) && said.skipped.length)
|
|
855
914
|
marks.push(`skipped ${said.skipped.length}`);
|
|
856
915
|
const why = res.statusCode >= 400 ? String(said.text ?? said.reason ?? said.error ?? "").replace(/\s+/g, " ").slice(0, 300) : "";
|
|
857
|
-
const route =
|
|
916
|
+
const route = masked(req.url).replace(/\?.*$/, "");
|
|
858
917
|
noteLive(root, `${req.method} ${route} → ${res.statusCode}${marks.length ? ` · ${marks.join(", ")}` : ""}${why ? ` · ${why}` : ""} (${Date.now() - started} ms)`);
|
|
859
918
|
});
|
|
860
919
|
};
|
|
@@ -869,11 +928,44 @@ export function serveLive(root, env) {
|
|
|
869
928
|
// the client's own wait runs out after Node's 5 s, the request comes as the connection closes — "reset")
|
|
870
929
|
server.keepAliveTimeout = 65_000;
|
|
871
930
|
server.headersTimeout = 66_000;
|
|
931
|
+
/**
|
|
932
|
+
* The secret came from a page that is no Strom app: replaced at once — written where strom app reads it (live.json,
|
|
933
|
+
* live-last.json), the event streams on the old one ended (the app notices), said in the log. What is being written
|
|
934
|
+
* meanwhile finishes; every new request needs the new secret.
|
|
935
|
+
*/
|
|
936
|
+
const leaked = (req, from, what) => {
|
|
937
|
+
token = crypto.randomBytes(16).toString("hex");
|
|
938
|
+
for (const s of streams)
|
|
939
|
+
s.end();
|
|
940
|
+
streams.clear();
|
|
941
|
+
if (live) {
|
|
942
|
+
live = { ...live, token, url: `http://127.0.0.1:${live.port}/${token}` };
|
|
943
|
+
try {
|
|
944
|
+
const now = liveRunning(root);
|
|
945
|
+
if (!now || now.pid === process.pid)
|
|
946
|
+
writeLive(root, live);
|
|
947
|
+
const was = readLast(root);
|
|
948
|
+
if (!was || was.pid === process.pid)
|
|
949
|
+
writeLast(root, { port: live.port, token, pid: process.pid, started: live.started });
|
|
950
|
+
}
|
|
951
|
+
catch (e) {
|
|
952
|
+
noteLive(root, `the new secret not written: ${errorText(e)}`);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
const page = from.replace(/[\u0000-\u001f\u007f]/g, "?").slice(0, 200);
|
|
956
|
+
noteLive(root, `${req.method} /…/${String(what ?? "").slice(0, 40)} came with the secret from ${page}, a page that is no Strom app: the address got out — a new secret, the old one no longer works (the Strom app gets the new one when the research is opened in it again: strom app)`);
|
|
957
|
+
};
|
|
872
958
|
const answer = (req, res) => {
|
|
873
959
|
const [, t, what, sub, act] = (req.url ?? "").split("?")[0].split("/");
|
|
874
960
|
// the app asking now and then (?poll=1) keeps no bridge running; anything else does
|
|
875
961
|
if (!(req.method === "GET" && what === "status" && /[?&]poll=1(?:&|$)/.test(req.url ?? "")))
|
|
876
962
|
lastAsked = Date.now();
|
|
963
|
+
// the secret from a page that is no Strom app: replaced, this request answered as one without it (no data, no CORS)
|
|
964
|
+
if (t === token && foreignOrigin(req.headers.origin, env)) {
|
|
965
|
+
leaked(req, String(req.headers.origin), what);
|
|
966
|
+
res.writeHead(404).end();
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
877
969
|
const origin = allowedOrigin(req.headers.origin);
|
|
878
970
|
const version = appVersionOf(req, new Settings(env, {}));
|
|
879
971
|
if (origin) {
|
|
@@ -1732,11 +1824,11 @@ export function serveLive(root, env) {
|
|
|
1732
1824
|
server.once("listening", () => {
|
|
1733
1825
|
up = true;
|
|
1734
1826
|
const port = server.address().port;
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
noteLive(root, `started: strom ${VERSION}, port ${port}, ${
|
|
1827
|
+
live = { port, token, pid: process.pid, url: `http://127.0.0.1:${port}/${token}`, started: new Date().toISOString(), version: VERSION };
|
|
1828
|
+
writeLive(root, live);
|
|
1829
|
+
writeLast(root, { port, token, pid: process.pid, started: live.started });
|
|
1830
|
+
const how = !last ? "a new address" : last.port === port ? (last.token ? "the address of the last bridge" : "the port of the last bridge, a new secret (it was ended for good)") : last.token ? "the token of the last bridge, another port" : "a new address";
|
|
1831
|
+
noteLive(root, `started: strom ${VERSION}, port ${port}, ${how}`);
|
|
1740
1832
|
// A read that fails (another strom writing that moment) is tried again at the next tick; written down once.
|
|
1741
1833
|
let trouble;
|
|
1742
1834
|
const safe = (what, fn) => {
|
|
@@ -1808,8 +1900,9 @@ export function serveLive(root, env) {
|
|
|
1808
1900
|
server.listen(port, "127.0.0.1", () => {
|
|
1809
1901
|
renewing = false;
|
|
1810
1902
|
try {
|
|
1811
|
-
|
|
1812
|
-
|
|
1903
|
+
if (live)
|
|
1904
|
+
writeLive(root, live);
|
|
1905
|
+
writeLast(root, { port, token, pid: process.pid, started: live?.started ?? new Date().toISOString() });
|
|
1813
1906
|
}
|
|
1814
1907
|
catch {
|
|
1815
1908
|
// its research gone meanwhile: the next tick ends it
|
package/dist/core/people.js
CHANGED
|
@@ -263,6 +263,64 @@ export function familiesAsChild(tree, id) {
|
|
|
263
263
|
export function familiesAsPartner(tree, id) {
|
|
264
264
|
return relIndex(tree).asPartner.get(id) ?? [];
|
|
265
265
|
}
|
|
266
|
+
/**
|
|
267
|
+
* The two sides of a couple, HUSB and WIFE, as the Strom app sides them (its coupleSides): a man HUSB, a woman WIFE,
|
|
268
|
+
* one of unknown sex the side the other one leaves free; two of one sex, or two unknown, in the order given. One
|
|
269
|
+
* partner alone: HUSB unless a woman. Never one of them left out.
|
|
270
|
+
*/
|
|
271
|
+
export function coupleSides(a, b) {
|
|
272
|
+
if (!a || !b) {
|
|
273
|
+
const one = a ?? b;
|
|
274
|
+
return one?.sex === "F" ? [undefined, one] : [one, undefined];
|
|
275
|
+
}
|
|
276
|
+
if (a.sex === "M" && b.sex !== "M")
|
|
277
|
+
return [a, b];
|
|
278
|
+
if (b.sex === "M" && a.sex !== "M")
|
|
279
|
+
return [b, a];
|
|
280
|
+
if (b.sex === "F" && a.sex !== "F")
|
|
281
|
+
return [a, b];
|
|
282
|
+
if (a.sex === "F" && b.sex !== "F")
|
|
283
|
+
return [b, a];
|
|
284
|
+
return [a, b];
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* A family's sides as its files write them: the ones kept when a sex of the partners changed (Family.husb, U01-e) —
|
|
288
|
+
* unless that puts a woman HUSB beside a man —, else coupleSides. An app that knows no unknown sex (the Strom app
|
|
289
|
+
* before 3.10.0-beta.11) guesses one by the side: a side kept is no change of the guess.
|
|
290
|
+
*/
|
|
291
|
+
export function familySides(f, a, b) {
|
|
292
|
+
if (a && b && (f.husb === a.id || f.husb === b.id)) {
|
|
293
|
+
const [h, w] = f.husb === a.id ? [a, b] : [b, a];
|
|
294
|
+
if (!(h.sex === "F" && w.sex === "M"))
|
|
295
|
+
return [h, w];
|
|
296
|
+
}
|
|
297
|
+
return coupleSides(a, b);
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* A person's sex about to change (editPerson): each couple of theirs keeps the sides its files wrote — set on the family
|
|
301
|
+
* where coupleSides would now swap them, taken off where it no longer would (U01-e).
|
|
302
|
+
*/
|
|
303
|
+
export function keepSides(tree, before, after) {
|
|
304
|
+
const out = [];
|
|
305
|
+
for (const f of familiesAsPartner(tree, before.id)) {
|
|
306
|
+
if (f.partners.length !== 2)
|
|
307
|
+
continue;
|
|
308
|
+
const of = (p) => f.partners.map((id) => (id === before.id ? p : tree.get(id))).filter((x) => !!x);
|
|
309
|
+
const [a, b] = of(before);
|
|
310
|
+
const [x, y] = of(after);
|
|
311
|
+
if (!a || !b || !x || !y)
|
|
312
|
+
continue;
|
|
313
|
+
const was = familySides(f, a, b)[0]?.id;
|
|
314
|
+
const kept = familySides({ husb: was }, x, y)[0]?.id;
|
|
315
|
+
const plain = coupleSides(x, y)[0]?.id;
|
|
316
|
+
const husb = kept === plain ? undefined : kept;
|
|
317
|
+
if (husb === f.husb)
|
|
318
|
+
continue;
|
|
319
|
+
const { husb: _old, ...rest } = f;
|
|
320
|
+
out.push({ family: f, next: { ...rest, ...(husb ? { husb } : {}) } });
|
|
321
|
+
}
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
266
324
|
export function parentsOf(tree, id) {
|
|
267
325
|
const out = [];
|
|
268
326
|
for (const f of familiesAsChild(tree, id)) {
|
package/dist/core/stromapp.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import fs from "node:fs";
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { desktopDir, userHome } from "./paths.js";
|
|
10
|
-
import { appUrlInvalid, checkValue, settingDef } from "./config.js";
|
|
10
|
+
import { appUrlInvalid, checkValue, settingDef, Settings } from "./config.js";
|
|
11
11
|
import { UsageError } from "./errors.js";
|
|
12
12
|
import { foldText } from "./text.js";
|
|
13
13
|
import { compareVersions, updateChannel } from "./update.js";
|
|
@@ -44,6 +44,20 @@ export function appUnknownIsNoSurname(version) {
|
|
|
44
44
|
const v = version?.trim();
|
|
45
45
|
return !v || !isAppVersion(v) || compareVersions(v, APP_WRITES_NO_SURNAME_EMPTY) < 0;
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* The first Strom app whose sex "unknown" is a sex of its own (its data version 12): it writes SEX U where the sex is
|
|
49
|
+
* unknown in its tree, never a guess — before it, it read SEX U as a husband male and anyone else female and wrote
|
|
50
|
+
* that guess back. A real pre-release, not a gate: its own betas before it guessed. Its file says so also by the
|
|
51
|
+
* header's 1 _STROM_SEX_U Y, but only in a tree linked to a research (U01).
|
|
52
|
+
*/
|
|
53
|
+
export const APP_WRITES_SEX_UNKNOWN = "3.10.0-beta.11";
|
|
54
|
+
/** The data version of the Strom app's JSON from which its gender "unknown" is a sex of its own (its 3.10.0-beta.11, U01). */
|
|
55
|
+
export const APP_DATA_SEX_UNKNOWN = 12;
|
|
56
|
+
/** Is SEX U in a file of the Strom app of this version (its HEAD's 2 VERS) the sex the user left unknown? From 3.10.0-beta.11 on; no version said: no. */
|
|
57
|
+
export function appWritesSexUnknown(version) {
|
|
58
|
+
const v = version?.trim();
|
|
59
|
+
return !!v && isAppVersion(v) && compareVersions(v, APP_WRITES_SEX_UNKNOWN) >= 0;
|
|
60
|
+
}
|
|
47
61
|
/** The pages of the Strom app — on the web, its beta, a copy on this computer (its development): the only ones strom opens with a research, and the only ones its bridge lets in. */
|
|
48
62
|
export function isStromAppOrigin(origin) {
|
|
49
63
|
return origin === "https://stromapp.info" || origin === "https://beta.stromapp.info" || /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin);
|
|
@@ -149,6 +163,23 @@ export function appUrlShown(settings) {
|
|
|
149
163
|
const said = appUrlSetting(settings);
|
|
150
164
|
return said.invalid ? { url: said.url, invalid: true } : { url: said.url };
|
|
151
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* The Strom app's address the texts for an agent name — the guide, a tree's AGENTS.md, what strom agents install
|
|
168
|
+
* teaches the agents: the one `strom app` opens (appUrlShown: strom.app.url, else the channel's default — the beta its
|
|
169
|
+
* beta, the releases stromapp.info), read when the text is written (B1-i).
|
|
170
|
+
*/
|
|
171
|
+
export function agentAppUrl(env) {
|
|
172
|
+
return appUrlShown(new Settings(env, {})).url;
|
|
173
|
+
}
|
|
174
|
+
/** The Strom app's site beside its address in a text for an agent: https://stromapp.info, its beta's, a copy's (its origin). */
|
|
175
|
+
export function appSite(url) {
|
|
176
|
+
try {
|
|
177
|
+
return new URL(url).origin;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return url;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
152
183
|
/** Is the copy of the app another than stromapp.info (its beta, its development)? Never failing on the setting: an invalid one is none. */
|
|
153
184
|
function anotherCopy(settings) {
|
|
154
185
|
return appUrlSetting(settings).url !== STROM_APP_URL;
|
package/dist/core/sync.js
CHANGED
|
@@ -27,7 +27,7 @@ import { phrase } from "./phrases.js";
|
|
|
27
27
|
import { addChild, addEvent, addFamily, addName, addNote, editEvent, editPerson, retractEvent, retractPerson } from "./actions.js";
|
|
28
28
|
import { exportGedcom, REFN_TYPE } from "../gedcom/export.js";
|
|
29
29
|
import { children, parseGedcomText, val } from "../gedcom/parse.js";
|
|
30
|
-
import { fromFlexDate, houseOf, importDate, readGedName, stromAppFile } from "./import.js";
|
|
30
|
+
import { fromFlexDate, houseOf, importDate, readGedName, readGedSex, stromAppFile } from "./import.js";
|
|
31
31
|
import { humanAge, normalizeAge } from "./age.js";
|
|
32
32
|
import { dateYears } from "./gdate.js";
|
|
33
33
|
import { create, update } from "./records.js";
|
|
@@ -40,8 +40,8 @@ import { StromError, UsageError } from "./errors.js";
|
|
|
40
40
|
import * as git from "./git.js";
|
|
41
41
|
import { now, Tree, typeOfId } from "./tree.js";
|
|
42
42
|
import { isArchive } from "./mode.js";
|
|
43
|
-
import { isAppVersion } from "./stromapp.js";
|
|
44
|
-
import { cleanTitle, formatName, foundTitles, gedcomTitledName, primaryName, titledName } from "./people.js";
|
|
43
|
+
import { APP_DATA_SEX_UNKNOWN, isAppVersion } from "./stromapp.js";
|
|
44
|
+
import { cleanTitle, familiesAsPartner, formatName, foundTitles, gedcomTitledName, primaryName, titledName } from "./people.js";
|
|
45
45
|
/** The words of a _STAT (the Strom app's own, also Legacy's and FTM's) as a union, or nothing it names. */
|
|
46
46
|
function unionOfStat(value) {
|
|
47
47
|
const v = (value ?? "").trim().toLowerCase();
|
|
@@ -479,7 +479,7 @@ export function readGedcom(text) {
|
|
|
479
479
|
const ours = refn && OUR_ID.test(refn.value.trim()) && (!type || type === REFN_TYPE) ? refn.value.trim() : undefined;
|
|
480
480
|
const key = ours && !persons.has(ours) ? ours : `x:${r.xref.replace(/@/g, "")}`;
|
|
481
481
|
keys.set(r.xref, key);
|
|
482
|
-
const sex =
|
|
482
|
+
const sex = readGedSex(r, fromApp);
|
|
483
483
|
// each NAME read as strom intake reads it (readGedName): its parts (GIVN, SURN) first, the line's surname after the
|
|
484
484
|
// given name where it has no slashes (N11), the titles apart; a person of no name and no surname the Strom app
|
|
485
485
|
// writes "? //" (its stand-in for an unknown parent: "//", nobody) — and "? /Unknown/" before its 3.10.0-beta.7 (or
|
|
@@ -511,7 +511,7 @@ export function readGedcom(text) {
|
|
|
511
511
|
persons.set(key, {
|
|
512
512
|
key,
|
|
513
513
|
names,
|
|
514
|
-
...(sex
|
|
514
|
+
...(sex ? { sex } : {}),
|
|
515
515
|
facts,
|
|
516
516
|
notes: children(r, "NOTE").map(noteText).map((t) => t.trim()).filter(Boolean),
|
|
517
517
|
said: allNotes(r, noteText),
|
|
@@ -578,7 +578,8 @@ export function readGedcom(text) {
|
|
|
578
578
|
const treeId = val(head, "_STROM_TREE");
|
|
579
579
|
const at = val(head, "_STROM_HEAD");
|
|
580
580
|
const transcripts = val(head, "_STROM_TRANSCRIPTS")?.trim().toLowerCase() === "evidence" ? "evidence" : "lead";
|
|
581
|
-
|
|
581
|
+
// SEX U where the sex is unknown in the app (its header's mark, an app from 3.10.0-beta.11): no guess of it (U01)
|
|
582
|
+
const sexU = fromApp.sexUnknown;
|
|
582
583
|
const appTree = val(head, "_STROM_APP_TREE")?.trim();
|
|
583
584
|
const since = val(head, "_STROM_SINCE")?.trim();
|
|
584
585
|
const again = val(head, "_STROM_AGAIN")?.trim();
|
|
@@ -632,6 +633,9 @@ export function readStromJson(data) {
|
|
|
632
633
|
const d = data;
|
|
633
634
|
if (!d || typeof d !== "object" || !d.persons || typeof d.persons !== "object")
|
|
634
635
|
throw new UsageError("not a family tree of the Strom app: no persons in it", { code: "tree.unreadable" });
|
|
636
|
+
// the sex "unknown" is the user's (U01): a sex of its own from the app's data version 12 (its 3.10.0-beta.11), never
|
|
637
|
+
// a guess of it; before it, only where the research's unknown stood beside the app's stand-in (research.sexU)
|
|
638
|
+
const sexU = (typeof d.version === "number" && d.version >= APP_DATA_SEX_UNKNOWN) || d.research?.sexU === true;
|
|
635
639
|
// the sources: ours by their REFN, the app's own by its ID; a citation takes the entry's page, date and quality
|
|
636
640
|
const sources = new Map();
|
|
637
641
|
const sourceKeys = new Map();
|
|
@@ -736,7 +740,10 @@ export function readStromJson(data) {
|
|
|
736
740
|
key,
|
|
737
741
|
...(titles ? { titles } : {}),
|
|
738
742
|
names: [name, ...(p.nameVariants ?? [])].filter((n) => n.replace(/[/?\s]/g, "")),
|
|
739
|
-
|
|
743
|
+
// the sex left unknown a value like the others (U01): the app's gender unknown from its data version 12, the
|
|
744
|
+
// research's unknown standing beside its stand-in (research.sexU) before it; else male and female only — a gender
|
|
745
|
+
// missing says nothing of it (B5-d)
|
|
746
|
+
...(d.research?.sexU === true && p.sexUnknown ? { sex: "U" } : p.gender === "male" ? { sex: "M" } : p.gender === "female" ? { sex: "F" } : sexU && p.gender === "unknown" ? { sex: "U" } : {}),
|
|
740
747
|
facts: cleanFacts(facts),
|
|
741
748
|
notes: p.notes?.trim() ? [p.notes.trim()] : [],
|
|
742
749
|
said: p.notes ?? "",
|
|
@@ -883,7 +890,7 @@ export function readStromJson(data) {
|
|
|
883
890
|
}
|
|
884
891
|
const r = d.research;
|
|
885
892
|
const transcripts = r?.transcripts === "evidence" ? "evidence" : "lead";
|
|
886
|
-
return { format: "strom-json", ...(r?.id ? { treeId: r.id } : {}), ...(r?.head ? { head: r.head } : {}), persons, families, places, sources, transcripts, ...(
|
|
893
|
+
return { format: "strom-json", ...(r?.id ? { treeId: r.id } : {}), ...(r?.head ? { head: r.head } : {}), persons, families, places, sources, transcripts, ...(sexU ? { sexU: true } : {}), problems: [] };
|
|
887
894
|
}
|
|
888
895
|
/** A file coming back: GEDCOM or the Strom app's JSON; empty or unreadable throws. */
|
|
889
896
|
export function readTreeFile(file) {
|
|
@@ -2059,15 +2066,19 @@ export function planSync(tree, incoming, edits, opts = {}) {
|
|
|
2059
2066
|
// given unknown the app only guessed: nothing to tell from it — but a file that writes U where the unknown stands
|
|
2060
2067
|
// (_STROM_SEX_U, the app's beta.61) gives a sex there only as the user's edit, and an older app's guess is known (a
|
|
2061
2068
|
// husband male, anyone else female: its reading of SEX U), so another sex is the user's edit (found on Mac: Petr U,
|
|
2062
|
-
// guessed male, set female in the app, "changes 0" and the edit lost)
|
|
2069
|
+
// guessed male, set female in the app, "changes 0" and the edit lost); the unknown such a file gives is a sex like
|
|
2070
|
+
// the others (U01): a known sex set unknown there is the user's edit too — a lead's corrected, a record's the
|
|
2071
|
+
// user's to decide (found with the app's 3.10.0-beta.11: M → U dropped in silence)
|
|
2063
2072
|
const [mySex, givenSex] = [o.sex ?? "U", b ? (b.sex ?? "U") : undefined];
|
|
2064
2073
|
const guessed = givenSex === "U" ? (incoming.sexU ? "U" : b.husb ? "M" : "F") : undefined;
|
|
2065
2074
|
if (p.sex && p.sex !== mySex && givenSex !== p.sex && (mySex !== "U" || (givenSex !== undefined && givenSex !== "U") || (guessed !== undefined && p.sex !== guessed))) {
|
|
2066
2075
|
const backed = (tree.get(id)?.events ?? []).some((e) => !e.retracted && recordBacked(tree, e));
|
|
2067
2076
|
const since = givenSex !== undefined && mySex !== givenSex;
|
|
2068
2077
|
// set otherwise than the app guessed for the research's unknown: the user's to decide, record or none (Milan,
|
|
2069
|
-
// 2026-10-04: never lost in silence, a conflict; the app keeps "unknown" only after its 3.9)
|
|
2070
|
-
|
|
2078
|
+
// 2026-10-04: never lost in silence, a conflict; the app keeps "unknown" only after its 3.9) — but a file whose
|
|
2079
|
+
// unknown is exact guessed nothing (U01-d): a sex set there for the research's unknown is an edit like M → U, a
|
|
2080
|
+
// correction where no record gives the person's facts, the user's to decide where one does
|
|
2081
|
+
const overGuess = mySex === "U" && guessed !== undefined && !incoming.sexU;
|
|
2071
2082
|
push({ kind: "sex.changed", action: b ? (edits === "user" ? "user" : backed || since || overGuess ? "conflict" : "correct") : "report", person: id, name, text: p.sex });
|
|
2072
2083
|
}
|
|
2073
2084
|
notesDiff({ person: id, name }, p.notes, [o.said, b?.said ?? "", ...o.names], o.facts);
|
|
@@ -3411,8 +3422,14 @@ export function applySync(tree, plan, incoming, source, only) {
|
|
|
3411
3422
|
]);
|
|
3412
3423
|
break;
|
|
3413
3424
|
}
|
|
3425
|
+
// the sides its couples kept before (Family.husb) — where the edit changes them, put back by an undo after the
|
|
3426
|
+
// sexes, so the files after it are those before the sync (B5-b): recorded once per family, ahead of its sexes
|
|
3427
|
+
const at = applied.length;
|
|
3428
|
+
const sides = familiesAsPartner(tree, owner).map((f) => [f.id, f.husb ?? ""]);
|
|
3414
3429
|
editPerson(tree, owner, { sex: c.text }, `the user's edit: ${reason}`);
|
|
3415
3430
|
applied.push({ do: "sex.edit", id: owner, before: p.sex });
|
|
3431
|
+
const moved = sides.filter(([id, was]) => (tree.get(id)?.husb ?? "") !== was && !applied.some((a) => a.do === "family.sides" && a.id === id));
|
|
3432
|
+
applied.splice(at, 0, ...moved.map(([id, was]) => ({ do: "family.sides", id, before: was })));
|
|
3416
3433
|
break;
|
|
3417
3434
|
}
|
|
3418
3435
|
case "family.new": {
|
|
@@ -3508,7 +3525,7 @@ export function applySync(tree, plan, incoming, source, only) {
|
|
|
3508
3525
|
summary: `${fam.id} partner ${partner} removed: ${phrase(tree.lang, "archive.removed")}`,
|
|
3509
3526
|
reason: `${phrase(tree.lang, "archive.removed")}: ${reason}`,
|
|
3510
3527
|
});
|
|
3511
|
-
applied.push({ do: "partner.remove", id: fam.id, before: JSON.stringify({ partner, at: fam.partners.indexOf(partner) }) });
|
|
3528
|
+
applied.push({ do: "partner.remove", id: fam.id, before: JSON.stringify({ partner, at: fam.partners.indexOf(partner), ...(fam.husb ? { husb: fam.husb } : {}) }) });
|
|
3512
3529
|
break;
|
|
3513
3530
|
}
|
|
3514
3531
|
case "child.parents": {
|
|
@@ -3988,6 +4005,18 @@ export function undoSync(tree, input) {
|
|
|
3988
4005
|
case "sex.edit":
|
|
3989
4006
|
editPerson(tree, a.id, { sex: String(a.before ?? "U") }, reason);
|
|
3990
4007
|
break;
|
|
4008
|
+
case "family.sides": {
|
|
4009
|
+
// the side the couple kept before the sync, after its sexes are back (B5-b)
|
|
4010
|
+
const was = String(a.before ?? "");
|
|
4011
|
+
const f = tree.get(a.id);
|
|
4012
|
+
if (f && (f.husb ?? "") !== was)
|
|
4013
|
+
update(tree, a.id, "family", ({ husb: _h, ...x }) => ({ ...x, ...(was ? { husb: was } : {}) }), {
|
|
4014
|
+
op: "family.edit",
|
|
4015
|
+
summary: was ? `${a.id} ${was} kept as HUSB again: ${reason}` : `${a.id} sides by sex again: ${reason}`,
|
|
4016
|
+
reason,
|
|
4017
|
+
});
|
|
4018
|
+
break;
|
|
4019
|
+
}
|
|
3991
4020
|
case "child.add":
|
|
3992
4021
|
update(tree, a.id, "family", (f) => ({ ...f, children: f.children.filter((c) => c.person !== a.before) }), { op: "family.edit", summary: `${a.id} child ${String(a.before)} removed: ${reason}`, reason });
|
|
3993
4022
|
break;
|
|
@@ -4010,8 +4039,9 @@ export function undoSync(tree, input) {
|
|
|
4010
4039
|
break;
|
|
4011
4040
|
case "partner.remove": {
|
|
4012
4041
|
const b = JSON.parse(String(a.before ?? "{}"));
|
|
4042
|
+
// the partner back, and the side the couple kept with them (B5-b)
|
|
4013
4043
|
if (b.partner)
|
|
4014
|
-
update(tree, a.id, "family", (f) => (f.partners.includes(b.partner) || f.partners.length >= 2 ? f : { ...f, partners: f.partners.toSpliced(b.at ?? f.partners.length, 0, b.partner) }), { op: "family.edit", summary: `${a.id} partner ${b.partner} back: ${reason}`, reason });
|
|
4044
|
+
update(tree, a.id, "family", (f) => (f.partners.includes(b.partner) || f.partners.length >= 2 ? f : { ...f, partners: f.partners.toSpliced(b.at ?? f.partners.length, 0, b.partner), ...(b.husb ? { husb: b.husb } : {}) }), { op: "family.edit", summary: `${a.id} partner ${b.partner} back: ${reason}`, reason });
|
|
4015
4045
|
break;
|
|
4016
4046
|
}
|
|
4017
4047
|
case "family.add":
|
package/dist/core/tree.js
CHANGED
|
@@ -17,7 +17,7 @@ import { acquireLock, withLock } from "./lock.js";
|
|
|
17
17
|
/** How long a writer waits for the tree while another one works (a long intake, a batch). */
|
|
18
18
|
const LOCK_WAIT_MS = 120_000;
|
|
19
19
|
import { RECORD_TYPES, ALL_PREFIXES, SCHEMA_VERSION } from "./model.js";
|
|
20
|
-
import { validateRecord } from "./validate.js";
|
|
20
|
+
import { settledRecord, validateRecord } from "./validate.js";
|
|
21
21
|
import { NeedsConsentError, StromError, UsageError } from "./errors.js";
|
|
22
22
|
import * as git from "./git.js";
|
|
23
23
|
import { opsLogs } from "./opslog.js";
|
|
@@ -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.13.0-beta.
|
|
30
|
+
export const VERSION = "1.13.0-beta.5";
|
|
31
31
|
const GITIGNORE = `# Strom Research — local state and large files are not versioned
|
|
32
32
|
.strom/
|
|
33
33
|
/media/
|
|
@@ -296,7 +296,7 @@ export class Tree {
|
|
|
296
296
|
continue;
|
|
297
297
|
let rec;
|
|
298
298
|
try {
|
|
299
|
-
rec = readJson(path.join(dir, f));
|
|
299
|
+
rec = settledRecord(readJson(path.join(dir, f)));
|
|
300
300
|
}
|
|
301
301
|
catch {
|
|
302
302
|
continue; // unreadable record: reported by `strom check`
|
|
@@ -323,7 +323,7 @@ export class Tree {
|
|
|
323
323
|
let one = this.single.get(id);
|
|
324
324
|
if (one === undefined) {
|
|
325
325
|
try {
|
|
326
|
-
one = readJson(this.recordPath(type, id));
|
|
326
|
+
one = settledRecord(readJson(this.recordPath(type, id)));
|
|
327
327
|
}
|
|
328
328
|
catch {
|
|
329
329
|
one = null;
|
|
@@ -512,7 +512,8 @@ export class Tree {
|
|
|
512
512
|
}
|
|
513
513
|
/** Validate and write a record, then log the sealed operation. Call inside withTreeLock. */
|
|
514
514
|
put(input, op) {
|
|
515
|
-
|
|
515
|
+
// a family's husb that names none of its two partners is dropped, never written (B5-a)
|
|
516
|
+
const record = settledRecord(composed(input));
|
|
516
517
|
const problems = validateRecord(record);
|
|
517
518
|
if (problems.length > 0) {
|
|
518
519
|
throw new UsageError(`invalid ${record.type} ${record.id}: ${problems.map((p) => `${p.path} ${p.message}`).join("; ")}`, {
|
package/dist/core/validate.js
CHANGED
|
@@ -252,6 +252,20 @@ export function validateRecord(value) {
|
|
|
252
252
|
}
|
|
253
253
|
return out;
|
|
254
254
|
}
|
|
255
|
+
/**
|
|
256
|
+
* A record as strom reads, writes and checks it: a family's husb (the side its files keep, U01-e) only while it names
|
|
257
|
+
* one of its two partners — else it is dropped, never an error (B5-a: a partner taken away by a sync, by an archive's
|
|
258
|
+
* send, or by an older strom that keeps the field without knowing it). The same object when nothing is dropped.
|
|
259
|
+
*/
|
|
260
|
+
export function settledRecord(value) {
|
|
261
|
+
if (!isObj(value) || value.type !== "family" || !("husb" in value))
|
|
262
|
+
return value;
|
|
263
|
+
const f = value;
|
|
264
|
+
if (typeof f.husb === "string" && Array.isArray(f.partners) && f.partners.length === 2 && f.partners.includes(f.husb))
|
|
265
|
+
return value;
|
|
266
|
+
const { husb: _stale, ...rest } = f;
|
|
267
|
+
return rest;
|
|
268
|
+
}
|
|
255
269
|
/** Every reference a record makes to another record (for dangling-reference checks). */
|
|
256
270
|
export function recordRefs(value) {
|
|
257
271
|
const refs = [];
|
package/dist/gedcom/export.js
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
// in notes.
|
|
27
27
|
import { GedWriter } from "./lines.js";
|
|
28
28
|
import { labels, RELA } from "./labels.js";
|
|
29
|
-
import { birthEvent, claimText, conflictTitle, displayName, formatName, gedcomTitledName, noName, preferredOrder, primaryName, relationTo } from "../core/people.js";
|
|
29
|
+
import { birthEvent, claimText, conflictTitle, coupleSides, displayName, familySides, formatName, gedcomTitledName, noName, preferredOrder, primaryName, relationTo } from "../core/people.js";
|
|
30
30
|
import { foldText } from "../core/text.js";
|
|
31
31
|
import { dateYears } from "../core/gdate.js";
|
|
32
32
|
import { humanAge, isGedcomAge, normalizeAge } from "../core/age.js";
|
|
@@ -268,9 +268,15 @@ export function exportGedcom(tree, opts = {}) {
|
|
|
268
268
|
stats.families++;
|
|
269
269
|
w.record(x(f.id), "FAM");
|
|
270
270
|
const [a, b] = f.partners.map((id) => persons.find((p) => p.id === id));
|
|
271
|
-
// HUSB/WIFE by sex
|
|
272
|
-
|
|
273
|
-
|
|
271
|
+
// HUSB/WIFE by sex as the Strom app sides a couple (its coupleSides): a man HUSB, a woman WIFE, an unknown sex the
|
|
272
|
+
// side the other leaves free; two of one sex, or two unknown, keep the order given — never a partner left out
|
|
273
|
+
// (found: two women, or a woman and one of unknown sex, written with the WIFE alone). One alone: a woman WIFE.
|
|
274
|
+
// The sides kept when a sex of theirs changed (Family.husb): an app that guesses an unknown sex by the side sees
|
|
275
|
+
// no change (familySides) — only in the Strom file: every version of the app keeps a couple's partners in the order
|
|
276
|
+
// of HUSB and WIFE and counts another order as a changed couple, and draws and exports them by sex itself. The
|
|
277
|
+
// standard file by coupleSides alone (B5-c): another program reads HUSB as the man, WIFE as the woman — never a man
|
|
278
|
+
// WIFE or a woman HUSB beside one of unknown sex there (found: a man of the app written WIFE beside one unknown)
|
|
279
|
+
const [husb, wife] = strict ? coupleSides(a, b) : familySides(f, a, b);
|
|
274
280
|
if (husb)
|
|
275
281
|
w.line(1, "HUSB", x(husb.id));
|
|
276
282
|
if (wife)
|