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.
@@ -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 (starý port byl obsazený): aplikace Strom ji najde, až v ní výzkum znovu otevře – strom app --live",
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á.)",
@@ -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 (der alte Port war belegt): die Strom-App findet sie, wenn die Forschung darin wieder geöffnet wird – strom app --live",
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.)",
@@ -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 (https://stromapp.info) — strom's companion: a free family
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
- https://stromapp.info/run/ — \`strom app install\` opens it there and says
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:
@@ -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
- export const GLOBAL_TEXT = `# strom — family history research
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 (https://stromapp.info) is strom's companion: a free family
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 https://stromapp.info/run/ (\`strom app install\` opens it there; it
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
- return [
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 SKILL = `---
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
- ${GLOBAL_TEXT}`;
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" ? SKILL : GLOBAL_TEXT;
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${GLOBAL_TEXT}${END}\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;
@@ -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
- if (this.env.STROM_NONINTERACTIVE !== "1" && opts.window !== false) {
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 (its old port was taken): the Strom app finds it when the research is opened in it again — strom app --live",
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.)",
@@ -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
- export function guideText(lang) {
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 (https://stromapp.info) is strom's companion: a free family tree app, no account,
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
- https://stromapp.info/run/ the app itself: opened in the browser, installed from there (the
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
@@ -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
  }, {
@@ -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" ? undefined : placeholders(lang, "strom config set strom.app.url <address> · strom config unset strom.app.url"));
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.description, ...(r.invalid ? { invalid: true } : {}) };
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))}`);
@@ -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 it takes the address it had (its port while free), so the app following it goes on by itself;\n" +
322
- "one that ended without a word is started again when a session starts.",
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: "Started again, the bridge takes its last address (its port while free, its secret), so the Strom app that kept it\n" +
355
- "goes on by itself. --forget: the next bridge gets a new secret — the address the app kept no longer works (the\n" +
356
- "app gets the new one when the research is opened in it again: strom app).",
357
- options: [{ name: "forget", type: "boolean", description: "the next bridge gets a new secret address" }],
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
- return { text: lines(said, opts.forget ? ui(lang, "ui.live.forgotten") : undefined), data: { stopped: how !== "none", how, ...(opts.forget ? { forgotten: true } : {}) } };
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
- stopLive(k.root, "strom uninstall");
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
@@ -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
  }
@@ -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 https://stromapp.info/run/ — e.g. its development (http://127.0.0.1:8080/); installed from a browser, that copy opens as its own app" },
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 = updateChannel(env) === "beta" ? `${STROM_APP_BETA_URL} · ${STROM_APP_URL}` : STROM_APP_URL;
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";
@@ -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")
@@ -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
- return { app, unknownIsNoSurname: app && appUnknownIsNoSurname(val(sour, "VERS")) };
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 = val(r, "SEX")?.toUpperCase();
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: sex === "M" || sex === "F" ? sex : "U",
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). Started again, it takes the address it had
42
- // (.strom/live-last.json: its port while free, its token), so the app following
43
- // it goes on by itself; a bridge that ended without a word is started again
44
- // when a session starts (reviveLive).
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
- const token = last?.token ?? crypto.randomBytes(16).toString("hex");
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} ${String(req.url ?? "").replace(token, "…")} failed: ${errorText(e)}`);
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 = String(req.url ?? "").replace(token, "…").replace(/\?.*$/, "");
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
- const info = { port, token, pid: process.pid, url: `http://127.0.0.1:${port}/${token}`, started: new Date().toISOString(), version: VERSION };
1736
- fs.mkdirSync(path.dirname(liveFile(root)), { recursive: true });
1737
- fs.writeFileSync(liveFile(root), JSON.stringify(info, null, 2));
1738
- writeLast(root, { port, token, pid: process.pid, started: info.started });
1739
- noteLive(root, `started: strom ${VERSION}, port ${port}, ${last && last.port === port ? "the address of the last bridge" : last ? "the token of the last bridge, another port" : "a new address"}`);
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
- fs.writeFileSync(liveFile(root), JSON.stringify(info, null, 2));
1812
- writeLast(root, { port, token, pid: process.pid, started: info.started });
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
@@ -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)) {
@@ -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 = val(r, "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 === "M" || sex === "F" ? { 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
- const sexU = val(head, "_STROM_SEX_U")?.trim().toUpperCase() === "Y";
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
- ...(d.research?.sexU === true && p.sexUnknown ? {} : p.gender === "male" ? { sex: "M" } : p.gender === "female" ? { sex: "F" } : {}),
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, ...(r?.sexU === true ? { sexU: true } : {}), problems: [] };
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
- const overGuess = mySex === "U" && guessed !== undefined;
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.4";
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
- const record = composed(input);
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("; ")}`, {
@@ -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 = [];
@@ -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; an unknown sex keeps the order given.
272
- const husb = [a, b].find((p) => p?.sex === "M") ?? (a?.sex !== "F" ? a : undefined);
273
- const wife = [a, b].find((p) => p && p !== husb);
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strom-research",
3
- "version": "1.13.0-beta.4",
3
+ "version": "1.13.0-beta.5",
4
4
  "description": "Genealogical research toolkit for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {