strom-research 1.15.0 → 1.16.0-beta.2

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/CHANGELOG.cs.md CHANGED
@@ -5,6 +5,23 @@
5
5
  Změny každé verze ve zkratce. Úplné poznámky a instalační řádky jsou na
6
6
  [GitHub Releases](https://github.com/ACiDekCZ/strom-research/releases) (anglicky).
7
7
 
8
+ ## 1.16.0
9
+
10
+ - **Terminál pro okna výzkumu (macOS).** Co aplikace Strom otevírá v terminálu a rozhovor předaný do nového okna
11
+ se může otevírat v iTerm2 místo v terminálu systému: první položka nastavení, průvodce, když jsou tu oba, nebo
12
+ `strom config set terminal iterm2`. Když zvolený terminál chybí, otevře se terminál systému a okno to řekne;
13
+ `strom doctor` ukáže, který je zvolený. Na Windows terminál určuje systém.
14
+ - **Každá osoba na kraji stromu s vlastními variantami.** Když se otevřená hypotéza napojuje na strom u více osob,
15
+ každá nese varianty, které ji jmenují, a rodinu mimo strom, ke které patří; aplikace Strom tak může ukázat všechny,
16
+ ne jen první.
17
+ - **Kraj stromu osobu po osobě.** `strom edge --json` vypíše každou osobu, na kterou hypotéza navazuje, s variantami
18
+ a rodinou každé z nich.
19
+ - **Příjmení po sňatku oběma směry s aplikací Strom.** Jméno po sňatku, které aplikace Strom pošle zpět, se zapíše
20
+ jako jméno po sňatku, nikdy jako přejmenování ani jako další tvar rodného jména; stejné příjmení je stejné jméno,
21
+ ať s ním jde jakékoli křestní. Když se v aplikaci změní nebo smaže, staré zmizí tam, kde ho daly jen úpravy
22
+ v aplikaci; jméno doložené záznamem zůstává. `strom intake` čte `_MARNM` jiných programů jako jméno po sňatku.
23
+ - **Linie výzkumu.** České texty všude říkají „linie výzkumu“, nikde už „směry“.
24
+
8
25
  ## 1.15.0
9
26
 
10
27
  *16. 10. 2026 · patří k aplikaci Strom 3.12.0.*
package/CHANGELOG.de.md CHANGED
@@ -5,6 +5,25 @@
5
5
  Die Änderungen jeder Version in Kürze. Die vollständigen Hinweise und die Installationszeilen stehen bei
6
6
  [GitHub Releases](https://github.com/ACiDekCZ/strom-research/releases) (auf Englisch).
7
7
 
8
+ ## 1.16.0
9
+
10
+ - **Das Terminal für die Fenster der Forschung (macOS).** Was die Strom-App in einem Terminal öffnet, und ein an ein
11
+ neues Fenster übergebenes Gespräch, kann sich in iTerm2 statt im Terminal des Systems öffnen: der erste Punkt der
12
+ Einstellungen, die Einrichtung, wenn beide da sind, oder `strom config set terminal iterm2`. Ein nicht installiertes
13
+ Terminal fällt auf das des Systems zurück und sagt es im Fenster; `strom doctor` nennt das gewählte. Unter Windows
14
+ bestimmt es das System.
15
+ - **Jede Person am Rand des Stammbaums mit eigenen Varianten.** Wenn eine offene Hypothese den Stammbaum bei mehreren
16
+ Personen anschließt, trägt jede die Varianten, die sie nennen, und die Familie außerhalb des Stammbaums, zu der sie
17
+ gehört; so kann die Strom-App alle zeigen, nicht nur die erste.
18
+ - **Der Rand des Stammbaums, Person für Person.** `strom edge --json` nennt jede Person, an die eine Hypothese
19
+ anschließt, mit den Varianten und der Familie jeder einzelnen.
20
+ - **Der Familienname nach der Heirat, in beide Richtungen mit der Strom-App.** Ein Name nach der Heirat, den die
21
+ Strom-App zurückschickt, kommt als Name nach der Heirat an, nie als Umbenennung oder als weitere Form des
22
+ Geburtsnamens; derselbe Familienname ist derselbe Name, mit welchem Vornamen auch immer. In der App geändert oder
23
+ entfernt, geht der alte dort, wo ihn nur Änderungen in der App gaben; einer, den ein Eintrag belegt, bleibt.
24
+ `strom intake` liest `_MARNM` anderer Programme als Namen nach der Heirat.
25
+ - **Forschungslinien auf Tschechisch.** Die tschechischen Texte sagen jetzt überall „linie výzkumu“ (Forschungslinien).
26
+
8
27
  ## 1.15.0
9
28
 
10
29
  *16.10.2026 · gehört zur Strom-App 3.12.0.*
package/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ English · [Čeština](CHANGELOG.cs.md) · [Deutsch](CHANGELOG.de.md)
5
5
  The changes of each version in short. The full notes and the install lines are on
6
6
  [GitHub Releases](https://github.com/ACiDekCZ/strom-research/releases).
7
7
 
8
+ ## 1.16.0
9
+
10
+ - **The terminal for the research's windows (macOS).** What the Strom app opens in a terminal, and a conversation
11
+ handed over to a new window, can open in iTerm2 instead of the system's terminal: the settings' first item, the
12
+ setup when both are here, or `strom config set terminal iterm2`. A terminal not installed falls back to the
13
+ system's and says so in the window; `strom doctor` names the one chosen. On Windows the system decides it.
14
+ - **Each person where the tree ends, with its own variants.** When an open hypothesis joins the tree at several
15
+ people, each of them carries the variants that name it and the family off the tree it belongs to, so the Strom app
16
+ can show all of them, not only the first.
17
+ - **Where the tree ends, person by person.** `strom edge --json` lists each person a hypothesis joins, with the
18
+ variants and the family of each one.
19
+ - **The surname after a marriage, both ways with the Strom app.** A name after a marriage the Strom app sends back
20
+ comes in as a name of the married kind, never as a rename or another form of the birth name; the same surname is
21
+ the same name whatever first name goes with it. Changed or taken off in the app, the old one goes where only edits
22
+ in the app gave it; one a record gives stays. `strom intake` reads `_MARNM` of other programs as a married name.
23
+ - **Lines of research in Czech.** The Czech texts say „linie výzkumu“ (lines of research) everywhere now.
24
+
8
25
  ## 1.15.0
9
26
 
10
27
  *2026-10-16 · goes with the Strom app 3.12.0.*
@@ -1123,6 +1123,10 @@
1123
1123
  "ui.sync.cause": "příčina {x}",
1124
1124
  "ui.sync.name.new": "{n}. {who}: další jméno — {text}",
1125
1125
  "ui.sync.name.changed": "{n}. {who}: změněné jméno — {old} → {new}",
1126
+ "ui.sync.name.married": "{n}. {who}: jméno po sňatku — {text}",
1127
+ "ui.sync.name.gone": "{n}. {who}: jméno po sňatku už v něm není — {text}",
1128
+ "ui.sync.do.name.gone": "odebere se (měly ho jen úpravy v aplikaci nebo jiné rodokmeny)",
1129
+ "ui.sync.do.name.kept": "zůstává: dokládá ho záznam",
1126
1130
  "ui.sync.do.name.user": "platí úprava z aplikace: osoba se ukazuje pod novým jménem, jméno ze záznamu zůstane vedle něj",
1127
1131
  "ui.sync.name.title": "{n}. {who}: {title} — {old} → {new}",
1128
1132
  "ui.sync.do.title": "doplní se ke jménu s odkazem na strom z aplikace",
@@ -1581,6 +1585,20 @@
1581
1585
  "ui.settings.archives": "Archivy: přihlášení, souhlas se stahováním",
1582
1586
  "ui.settings.wizard.what": "Co nastavit?",
1583
1587
  "ui.settings.wizard.all": "Všechno, krok za krokem (průvodce nastavením)",
1588
+ "ui.settings.terminal": "Terminál pro okna výzkumu (z aplikace Strom, předaný rozhovor): {state}",
1589
+ "ui.settings.terminal.archive": "Terminál pro okna výzkumu (z aplikace Strom): {state}",
1590
+ "ui.terminal.system": "výchozí systému ({name})",
1591
+ "ui.terminal.system.plain": "výchozí systému",
1592
+ "ui.terminal.name.terminal": "Terminál",
1593
+ "ui.terminal.name.iterm2": "iTerm2",
1594
+ "ui.terminal.notHere": "{name} (tu není nainstalovaný: otevře se výchozí systému)",
1595
+ "ui.terminal.bySystem": "určuje systém",
1596
+ "ui.terminal.windows": "Na Windows terminál určuje systém: Nastavení → Systém → Pro vývojáře → Terminál.",
1597
+ "ui.terminal.pick": "Ve kterém terminálu otevírat okna výzkumu?",
1598
+ "ui.terminal.missing": "{name} tu není nainstalovaný, okno se otevřelo ve výchozím terminálu.",
1599
+ "ui.doc.terminal": "terminál pro okna",
1600
+ "ui.doc.terminal.missing": "zvolený {name} tu není nainstalovaný: okna se otevírají ve výchozím systému",
1601
+ "ui.doc.terminal.unknown": "terminál „{value}“ tu strom nezná: okna se otevírají ve výchozím systému",
1584
1602
  "ui.settings.maintenance": "Údržba: kontrola instalace, místo na disku",
1585
1603
  "ui.settings.maintenance.tidy": "Údržba: kontrola instalace, místo na disku (jde uvolnit {size})",
1586
1604
  "ui.settings.maintenance.update": "Údržba: kontrola instalace, místo na disku, aktualizace na {version}",
@@ -1920,7 +1938,7 @@
1920
1938
  "ui.error.fs.no-permission": "chybí oprávnění{where}",
1921
1939
  "ui.error.fs.no-permission.hint": "vybrat složku, do které lze zapisovat",
1922
1940
  "ui.task.none": "Žádné úkoly.",
1923
- "ui.research.none": "Žádné směry výzkumu.",
1941
+ "ui.research.none": "Žádné linie výzkumu.",
1924
1942
  "ui.person.none": "Žádné osoby.",
1925
1943
  "ui.person.nomatch": "Žádná osoba neodpovídá.",
1926
1944
  "ui.person.leadsonly": "jen vodítka",
@@ -1123,6 +1123,10 @@
1123
1123
  "ui.sync.cause": "Ursache {x}",
1124
1124
  "ui.sync.name.new": "{n}. {who}: ein weiterer Name — {text}",
1125
1125
  "ui.sync.name.changed": "{n}. {who}: Name geändert — {old} → {new}",
1126
+ "ui.sync.name.married": "{n}. {who}: Name nach der Heirat — {text}",
1127
+ "ui.sync.name.gone": "{n}. {who}: Name nach der Heirat nicht mehr darin — {text}",
1128
+ "ui.sync.do.name.gone": "wird entfernt (nur Änderungen in der App oder andere Stammbäume gaben ihn)",
1129
+ "ui.sync.do.name.kept": "bleibt: ein Eintrag belegt ihn",
1126
1130
  "ui.sync.do.name.user": "Die eigene Änderung gilt: die Person erscheint unter dem neuen Namen, der Name aus dem Eintrag bleibt daneben",
1127
1131
  "ui.sync.name.title": "{n}. {who}: {title} — {old} → {new}",
1128
1132
  "ui.sync.do.title": "wird dem Namen hinzugefügt, mit Verweis auf den eigenen Stammbaum",
@@ -1581,6 +1585,20 @@
1581
1585
  "ui.settings.archives": "Archive: Anmeldungen, Zustimmung zum Herunterladen",
1582
1586
  "ui.settings.wizard.what": "Was einstellen?",
1583
1587
  "ui.settings.wizard.all": "Alles, Schritt für Schritt (die Einrichtung)",
1588
+ "ui.settings.terminal": "Terminal für die Fenster der Forschung (aus der Strom-App, ein übergebenes Gespräch): {state}",
1589
+ "ui.settings.terminal.archive": "Terminal für die Fenster der Forschung (aus der Strom-App): {state}",
1590
+ "ui.terminal.system": "Standard des Systems ({name})",
1591
+ "ui.terminal.system.plain": "Standard des Systems",
1592
+ "ui.terminal.name.terminal": "Terminal",
1593
+ "ui.terminal.name.iterm2": "iTerm2",
1594
+ "ui.terminal.notHere": "{name} (hier nicht installiert: der Standard des Systems öffnet sich)",
1595
+ "ui.terminal.bySystem": "bestimmt das System",
1596
+ "ui.terminal.windows": "Unter Windows bestimmt das System das Terminal: Einstellungen → System → Für Entwickler → Terminal.",
1597
+ "ui.terminal.pick": "In welchem Terminal sollen sich die Fenster der Forschung öffnen?",
1598
+ "ui.terminal.missing": "{name} ist hier nicht installiert; das Fenster wurde im Standardterminal geöffnet.",
1599
+ "ui.doc.terminal": "Terminal für die Fenster",
1600
+ "ui.doc.terminal.missing": "{name} gewählt, hier nicht installiert: die Fenster öffnen sich im Standard des Systems",
1601
+ "ui.doc.terminal.unknown": "das Terminal „{value}“ kennt strom hier nicht: die Fenster öffnen sich im Standard des Systems",
1584
1602
  "ui.settings.maintenance": "Wartung: Prüfung der Installation, Speicherplatz",
1585
1603
  "ui.settings.maintenance.tidy": "Wartung: Prüfung der Installation, Speicherplatz ({size} lassen sich freigeben)",
1586
1604
  "ui.settings.maintenance.update": "Wartung: Prüfung der Installation, Speicherplatz, Aktualisierung auf {version}",
@@ -23,6 +23,7 @@ import { agentsHere } from "../core/apps.js";
23
23
  import { mb, tidyPlan, TIDY_SAID } from "../core/tidy.js";
24
24
  import { calibrationOffer, viewSizesFor } from "../core/viewsizes.js";
25
25
  import { archivePermissions, archiveState, browserOnlyHere } from "./menu-archives.js";
26
+ import { chooseTerminal, terminalState } from "./terminals.js";
26
27
  export async function settingsMenu(ctx, run, lang, root, newer) {
27
28
  const t = translator(lang);
28
29
  return subMenu(ctx, lang, () => {
@@ -46,14 +47,22 @@ export async function settingsMenu(ctx, run, lang, root, newer) {
46
47
  key: "1",
47
48
  label: t(archive ? "ui.settings.wizard.archive" : "ui.settings.wizard"),
48
49
  act: async () => {
49
- // the setup's model step alone, or the whole setup (Enter); 0 back with nothing changed
50
+ // the setup's model step alone, the terminal for the research's windows, or the whole setup (Enter); 0 back
51
+ // with nothing changed (the menu has nine items: these are steps of the setup)
52
+ const parts = [];
50
53
  if (modelShown) {
51
54
  const kept = ctx.settings.resolve("model.lead", tree, agent);
52
- const i = await ctx.choose(t("ui.settings.wizard.what"), [{ label: t("ui.settings.wizard.all") }, { label: t("ui.settings.model", { agent: PROFILES[agent].name, model: kept ? String(kept.value) : t("ui.settings.model.own") }) }], 0, { back: t("ui.browse.back") });
55
+ parts.push({ label: t("ui.settings.model", { agent: PROFILES[agent].name, model: kept ? String(kept.value) : t("ui.settings.model.own") }), act: () => modelStep(ctx, run, lang, agent, tree) });
56
+ }
57
+ // macOS: the terminal chosen here; Windows: the system decides it, said where
58
+ if (process.platform === "darwin" || process.platform === "win32")
59
+ parts.push({ label: t(archive ? "ui.settings.terminal.archive" : "ui.settings.terminal", { state: terminalState(ctx, lang) }), act: () => terminalStep(ctx, run, lang) });
60
+ if (parts.length) {
61
+ const i = await ctx.choose(t("ui.settings.wizard.what"), [{ label: t("ui.settings.wizard.all") }, ...parts.map((p) => ({ label: p.label }))], 0, { back: t("ui.browse.back") });
53
62
  if (i === undefined)
54
63
  return;
55
- if (i === 1)
56
- return void (await modelStep(ctx, run, lang, agent, tree));
64
+ if (i > 0)
65
+ return void (await parts[i - 1].act());
57
66
  }
58
67
  // After the wizard the menu starts afresh: the research may be in another folder now.
59
68
  await run(["setup"]);
@@ -130,6 +139,18 @@ export async function settingsMenu(ctx, run, lang, root, newer) {
130
139
  return { title: t("ui.settings.title"), items };
131
140
  }, { fixed: true });
132
141
  }
142
+ /** The terminal for the research's windows: picked among those here (0 back, nothing changed); Windows: where the system decides it. */
143
+ async function terminalStep(ctx, run, lang) {
144
+ if (process.platform === "win32") {
145
+ ctx.io.stdout(translator(lang)("ui.terminal.windows") + "\n");
146
+ return pause(ctx, lang);
147
+ }
148
+ const now = ctx.settings.terminal() ?? "system";
149
+ const picked = await chooseTerminal(ctx, lang, now, { back: translator(lang)("ui.browse.back") });
150
+ if (picked === undefined || picked === now)
151
+ return;
152
+ await run(picked === "system" ? ["config", "unset", "terminal"] : ["config", "set", "terminal", picked], true);
153
+ }
133
154
  /** Maintenance: the check of the installation, what strom keeps beside the research (disk space), the newer strom when one is out. */
134
155
  async function maintenance(ctx, run, lang, root, newer) {
135
156
  const t = translator(lang);
@@ -0,0 +1,50 @@
1
+ // The terminal for the research's windows as a person sees and picks it (the setting terminal, core/terminal.ts): the
2
+ // system's default with its name first, then the terminals found on this computer — macOS; Windows decides it itself.
3
+ import { ui } from "./ui.js";
4
+ import { systemTerminal, terminalHere, terminalOfMark, terminalsHere } from "../core/terminal.js";
5
+ /** A terminal's name, as a person reads it. */
6
+ export function terminalName(lang, choice) {
7
+ return ui(lang, `ui.terminal.name.${choice}`);
8
+ }
9
+ /** The system's default, with its name when the system says. */
10
+ export function systemLabel(env, lang, platform = process.platform) {
11
+ const sys = systemTerminal(env, platform);
12
+ const name = sys?.choice ? terminalName(lang, sys.choice) : sys?.name;
13
+ return name ? ui(lang, "ui.terminal.system", { name }) : ui(lang, "ui.terminal.system.plain");
14
+ }
15
+ /** What holds now, in a few words: the one chosen (said when it is not here), else the system's default. */
16
+ export function terminalState(ctx, lang, platform = process.platform) {
17
+ if (platform === "win32")
18
+ return ui(lang, "ui.terminal.bySystem");
19
+ const chosen = ctx.settings.terminal();
20
+ if (!chosen)
21
+ return systemLabel(ctx.env, lang, platform);
22
+ return terminalHere(chosen, ctx.env, platform) ? terminalName(lang, chosen) : ui(lang, "ui.terminal.notHere", { name: terminalName(lang, chosen) });
23
+ }
24
+ /** The choices offered: the system's default, then the terminals found here (the one chosen too, though it is gone). */
25
+ function terminalOptions(ctx, lang, platform) {
26
+ const found = terminalsHere(ctx.env, platform);
27
+ const chosen = ctx.settings.terminal();
28
+ return [
29
+ { value: "system", label: systemLabel(ctx.env, lang, platform) },
30
+ ...found.map((v) => ({ value: v, label: terminalName(lang, v) })),
31
+ ...(chosen && !found.includes(chosen) ? [{ value: chosen, label: ui(lang, "ui.terminal.notHere", { name: terminalName(lang, chosen) }) }] : []),
32
+ ];
33
+ }
34
+ /**
35
+ * The terminal the setup suggests: the one chosen; none, the one the setup runs in (TERM_PROGRAM) — the system's
36
+ * default when that is the same one.
37
+ */
38
+ export function suggestedTerminal(ctx, platform = process.platform) {
39
+ const chosen = ctx.settings.terminal();
40
+ if (chosen)
41
+ return chosen;
42
+ const here = terminalOfMark(ctx.env.TERM_PROGRAM);
43
+ return !here || systemTerminal(ctx.env, platform)?.choice === here ? "system" : here;
44
+ }
45
+ /** The person picks the terminal (Enter: `suggested`): its value (system: the system's default); undefined: back. */
46
+ export async function chooseTerminal(ctx, lang, suggested, opts = {}, platform = process.platform) {
47
+ const options = terminalOptions(ctx, lang, platform);
48
+ const i = await ctx.choose(ui(lang, "ui.terminal.pick"), options.map((o) => ({ label: o.label })), Math.max(0, options.findIndex((o) => o.value === suggested)), opts);
49
+ return i === undefined ? undefined : options[i].value;
50
+ }
package/dist/cli/ui.js CHANGED
@@ -1027,6 +1027,10 @@ export const UI = {
1027
1027
  "ui.sync.cause": "cause {x}",
1028
1028
  "ui.sync.name.new": "{n}. {who}: another name — {text}",
1029
1029
  "ui.sync.name.changed": "{n}. {who}: name changed — {old} → {new}",
1030
+ "ui.sync.name.married": "{n}. {who}: a name after the marriage — {text}",
1031
+ "ui.sync.name.gone": "{n}. {who}: a name after the marriage no longer in it — {text}",
1032
+ "ui.sync.do.name.gone": "taken off (only edits in the app or other family trees gave it)",
1033
+ "ui.sync.do.name.kept": "kept: a record gives it",
1030
1034
  "ui.sync.do.name.user": "the edit wins: shown by the new name, the record's kept beside it",
1031
1035
  "ui.sync.name.title": "{n}. {who}: {title} — {old} → {new}",
1032
1036
  "ui.sync.do.title": "added to the name, citing the tree sent",
@@ -1494,6 +1498,20 @@ export const UI = {
1494
1498
  "ui.settings.archives": "Archives: logins, downloaders asking first",
1495
1499
  "ui.settings.wizard.what": "What to set up?",
1496
1500
  "ui.settings.wizard.all": "Everything, step by step (the setup)",
1501
+ "ui.settings.terminal": "Terminal for the research's windows (from the Strom app, a conversation handed over): {state}",
1502
+ "ui.settings.terminal.archive": "Terminal for the research's windows (from the Strom app): {state}",
1503
+ "ui.terminal.system": "the system's default ({name})",
1504
+ "ui.terminal.system.plain": "the system's default",
1505
+ "ui.terminal.name.terminal": "Terminal",
1506
+ "ui.terminal.name.iterm2": "iTerm2",
1507
+ "ui.terminal.notHere": "{name} (not installed here: the system's default opens)",
1508
+ "ui.terminal.bySystem": "decided by the system",
1509
+ "ui.terminal.windows": "On Windows the system decides the terminal: Settings → System → For developers → Terminal.",
1510
+ "ui.terminal.pick": "Which terminal should the research's windows open in?",
1511
+ "ui.terminal.missing": "{name} is not installed here; the window opened in the default terminal.",
1512
+ "ui.doc.terminal": "terminal for the windows",
1513
+ "ui.doc.terminal.missing": "{name} chosen, not installed here: the windows open in the system's default",
1514
+ "ui.doc.terminal.unknown": "the terminal \"{value}\" is not one strom knows here: the windows open in the system's default",
1497
1515
  "ui.settings.maintenance": "Maintenance: the check of the installation, disk space",
1498
1516
  "ui.settings.maintenance.tidy": "Maintenance: the check of the installation, disk space ({size} can be freed)",
1499
1517
  "ui.settings.maintenance.update": "Maintenance: the check of the installation, disk space, the update to {version}",
@@ -11,6 +11,8 @@ import { gitVersion } from "../core/git.js";
11
11
  import { fixGit, offerAgent } from "./fixes.js";
12
12
  import { agentsHere, suggestedWay, waysHere, whereToTalk } from "../core/apps.js";
13
13
  import { chooseWay, wayName } from "./ways.js";
14
+ import { chooseTerminal, suggestedTerminal } from "./terminals.js";
15
+ import { terminalsHere } from "../core/terminal.js";
14
16
  import { chooseEffort, chooseModel } from "./model-choice.js";
15
17
  import { ADDON_SWITCHES, PROFILES } from "../agents/profiles.js";
16
18
  import { writeStored, PERMISSION_LEVELS } from "../core/config.js";
@@ -238,6 +240,19 @@ export async function setupWizard(ctx, opts = {}) {
238
240
  await settleFromApp(ctx, lang);
239
241
  else if (stromAppState(s) !== "no" && appOpensLinks(s))
240
242
  await offerLinks(ctx, lang);
243
+ // 10. The terminal for the research's windows (a link from the Strom app, a conversation handed over): asked only on a
244
+ // Mac with more than one — the one the setup runs in suggested (run again: the one chosen); this computer's alone
245
+ if (process.platform === "darwin" && terminalsHere(ctx.env).length > 1) {
246
+ const now = s.terminal() ?? "system";
247
+ const picked = await chooseTerminal(ctx, lang, suggestedTerminal(ctx), keep);
248
+ if (picked !== undefined && picked !== now) {
249
+ if (picked === "system")
250
+ delete cfg.terminal;
251
+ else
252
+ cfg.terminal = picked;
253
+ s.save();
254
+ }
255
+ }
241
256
  out();
242
257
  out(`${ui(lang, "ui.setup.done")} (${langName(lang, lang)} · ${ctx.display(home)})`);
243
258
  return { home, lang, ...(agent ? { agent } : {}), permissions: level };
@@ -47,6 +47,15 @@ function keepAnswer(ctx, answer) {
47
47
  ctx.settings.config.links = answer;
48
48
  ctx.settings.save();
49
49
  }
50
+ /** The window a link opens: in the terminal the person chose (this computer's setting, never the link's), said in their language. */
51
+ function windowOf(ctx) {
52
+ const terminal = ctx.settings.terminal();
53
+ return { ...(terminal ? { terminal } : {}), lang: ctx.uiLang() };
54
+ }
55
+ /** The chosen terminal not here: the window opened in the system's (said in it). */
56
+ function fellBack(opened) {
57
+ return opened === "fallback" ? { terminal: "fallback" } : {};
58
+ }
50
59
  /** macOS: the terminal program this runs in, by its mark (TERM_PROGRAM). */
51
60
  const TERMINALS = { Apple_Terminal: "Terminal", "iTerm.app": "iTerm", WezTerm: "WezTerm", ghostty: "Ghostty" };
52
61
  /**
@@ -404,8 +413,8 @@ register({
404
413
  if (link.action === "menu") {
405
414
  const where = ctx.settings.home()?.value ?? configDir(ctx.env);
406
415
  fs.mkdirSync(where, { recursive: true });
407
- openInNewTerminal([], where, ctx.env);
408
- return { text: "", data: { done: true, action: "menu" } };
416
+ const opened = openInNewTerminal([], where, ctx.env, process.platform, windowOf(ctx));
417
+ return { text: "", data: { done: true, action: "menu", ...fellBack(opened) } };
409
418
  }
410
419
  // a tree of the app becomes a research: always in a terminal (it asks the name, waits for the app)
411
420
  if (link.action === "new") {
@@ -419,9 +428,10 @@ register({
419
428
  }
420
429
  const where = ctx.settings.home()?.value ?? configDir(ctx.env);
421
430
  fs.mkdirSync(where, { recursive: true });
422
- if (openInNewTerminal(["link", "open", linkText(link, linkScheme(ctx.env))], where, ctx.env) === false)
431
+ const opened = openInNewTerminal(["link", "open", linkText(link, linkScheme(ctx.env))], where, ctx.env, process.platform, windowOf(ctx));
432
+ if (opened === false)
423
433
  return say(ctx, "ui.link.noterminal.new");
424
- return { text: "", data: { done: true, action: "new" } };
434
+ return { text: "", data: { done: true, action: "new", ...fellBack(opened) } };
425
435
  }
426
436
  if (link.action === "media") {
427
437
  // an original the app sent: the file itself, in the viewer of the system; nothing written
@@ -452,12 +462,12 @@ register({
452
462
  const quiet = await withoutTerminal(ctx, link, copies[0].root);
453
463
  if (quiet)
454
464
  return quiet;
455
- const opened = openInNewTerminal(["link", "open", linkText(link, linkScheme(ctx.env))], copies[0].root, ctx.env);
465
+ const opened = openInNewTerminal(["link", "open", linkText(link, linkScheme(ctx.env))], copies[0].root, ctx.env, process.platform, windowOf(ctx));
456
466
  if (opened === false)
457
467
  return link.action === "send"
458
468
  ? say(ctx, "ui.link.noterminal", { item: `${ui(ctx.uiLang(), "ui.more.title")} → ${ui(ctx.uiLang(), "ui.more.sync")}` })
459
469
  : say(ctx, "ui.link.noterminal.any", { name: copies[0].name });
460
- return { text: "", data: { done: true, action: link.action, tree: copies[0].root } };
470
+ return { text: "", data: { done: true, action: link.action, tree: copies[0].root, ...fellBack(opened) } };
461
471
  }
462
472
  const got = excerptOf(ctx, link);
463
473
  if ("file" in got) {
@@ -1,4 +1,6 @@
1
1
  // setup · doctor · config — the environment around the trees.
2
+ import { terminalHere } from "../core/terminal.js";
3
+ import { terminalName, terminalState } from "../cli/terminals.js";
2
4
  import { refusedHosts } from "../core/terms.js";
3
5
  import { gitSize, lastCompacted } from "../core/history.js";
4
6
  import fs from "node:fs";
@@ -410,6 +412,21 @@ function diagnose(ctx) {
410
412
  add("appurl", "fail", t(appUrl.source === "env" ? "ui.doc.appurl.env" : "ui.doc.appurl.config", { value: appUrl.value }), appUrl.source === "env"
411
413
  ? t("ui.doc.appurl.env.fix", { web: appWebPages(ctx.env) })
412
414
  : placeholders(lang, "strom config set strom.app.url <address> · strom config unset strom.app.url"));
415
+ // The terminal strom opens its windows in (a link from the Strom app, a conversation handed over): the one chosen
416
+ // (macOS), gone: said with the way back to the system's; Windows: the system decides it. A value this system does not
417
+ // take (by hand, a newer strom's, another system's) counts as the system's: said with the way back too
418
+ const unknownTerm = ctx.settings.terminalUnknown();
419
+ if (unknownTerm !== undefined)
420
+ add("terminal", "warn", t("ui.doc.terminal.unknown", { value: unknownTerm.replace(/\p{Cc}/gu, "").slice(0, 40) }), "strom config unset terminal");
421
+ else if (process.platform === "darwin") {
422
+ const term = ctx.settings.terminal();
423
+ if (term && !terminalHere(term, ctx.env))
424
+ add("terminal", "warn", t("ui.doc.terminal.missing", { name: terminalName(lang, term) }), "strom config unset terminal");
425
+ else
426
+ add("terminal", "ok", terminalState(ctx, lang));
427
+ }
428
+ else if (process.platform === "win32")
429
+ add("terminal", "ok", t("ui.terminal.windows"));
413
430
  // …and whether it may start the research here (strom-research:// links): only while the app is wanted
414
431
  if (app !== "no" && appOpensLinks(ctx.settings)) {
415
432
  const links = linkHandlerState(ctx.env);
@@ -189,10 +189,15 @@ register({
189
189
  // In a terminal: a new window, not a conversation inside this one.
190
190
  if (agentHere && !opts.print) {
191
191
  const say = about ? ["--task", about.id] : said ? ["--say", said] : [];
192
- const opened = openInNewTerminal(["chat", "--agent", agent, "--where", "terminal", ...say], tree.root, ctx.env);
192
+ // in the terminal the person chose (this computer's setting), said in the research's language
193
+ const terminal = ctx.settings.terminal();
194
+ const opened = openInNewTerminal(["chat", "--agent", agent, "--where", "terminal", ...say], tree.root, ctx.env, process.platform, { ...(terminal ? { terminal } : {}), lang });
193
195
  if (opened === "recent")
194
196
  return { text: ui(lang, "ui.chat.handover.recent", { agent: profile.name }), data: { agent, handover: "recent", cwd: tree.root } };
195
- return { text: ui(lang, opened ? "ui.chat.handover" : "ui.chat.handover.failed", { agent: profile.name }), data: { agent, handover: opened ? "window" : "none", cwd: tree.root } };
197
+ return {
198
+ text: ui(lang, opened ? "ui.chat.handover" : "ui.chat.handover.failed", { agent: profile.name }),
199
+ data: { agent, handover: opened ? "window" : "none", cwd: tree.root, ...(opened === "fallback" ? { terminal: "fallback" } : {}) },
200
+ };
196
201
  }
197
202
  const base = {
198
203
  root: tree.root,
@@ -124,7 +124,9 @@ function changeLine(tree, c, incoming, lang) {
124
124
  ? "ui.sync.place.located"
125
125
  : c.kind === "fact.part"
126
126
  ? `ui.sync.fact.part${!c.part ? ".gone" : c.wasPart && c.wasPart.role !== c.part.role ? ".role" : c.wasPart ? ".link" : ""}`
127
- : `ui.sync.${c.kind}`;
127
+ : c.kind === "name.new" && c.married
128
+ ? "ui.sync.name.married"
129
+ : `ui.sync.${c.kind}`;
128
130
  // the app's sources and what a fact takes from them: said what becomes of them (a reading of a record, or a lead)
129
131
  const does = c.kept
130
132
  ? "ui.sync.do.kept"
@@ -136,21 +138,23 @@ function changeLine(tree, c, incoming, lang) {
136
138
  ? "ui.sync.do.parents"
137
139
  : c.kind === "name.changed" && c.action === "user"
138
140
  ? "ui.sync.do.name.user"
139
- : c.kind === "name.title" && (c.action === "add" || c.action === "user")
140
- ? `ui.sync.do.title${c.action === "user" ? ".user" : ""}`
141
- : c.kind === "note.changed" && c.action !== "pick"
142
- ? "ui.sync.do.note.changed"
143
- : c.kind === "family.union" && c.action !== "report"
144
- ? "ui.sync.do.family.union"
145
- : c.kind === "fact.detail" && c.action === "add"
146
- ? "ui.sync.do.detail"
147
- : c.kind === "fact.part" && (c.action === "add" || c.action === "user")
148
- ? `ui.sync.do.part${c.action === "user" ? ".user" : ""}`
149
- : c.kind.startsWith("source.") || c.kind === "fact.cite" || c.kind === "person.cite" || c.kind === "family.cite"
150
- ? `ui.sync.do.${c.kind}${c.reads || (c.kind === "source.new" && newReads(c, incoming)) ? ".read" : ""}`
151
- : c.kind === "fact.new" && c.reads
152
- ? "ui.sync.do.reading"
153
- : `ui.sync.do.${c.action}`;
141
+ : c.kind === "name.gone"
142
+ ? `ui.sync.do.name.${c.action === "remove" ? "gone" : "kept"}`
143
+ : c.kind === "name.title" && (c.action === "add" || c.action === "user")
144
+ ? `ui.sync.do.title${c.action === "user" ? ".user" : ""}`
145
+ : c.kind === "note.changed" && c.action !== "pick"
146
+ ? "ui.sync.do.note.changed"
147
+ : c.kind === "family.union" && c.action !== "report"
148
+ ? "ui.sync.do.family.union"
149
+ : c.kind === "fact.detail" && c.action === "add"
150
+ ? "ui.sync.do.detail"
151
+ : c.kind === "fact.part" && (c.action === "add" || c.action === "user")
152
+ ? `ui.sync.do.part${c.action === "user" ? ".user" : ""}`
153
+ : c.kind.startsWith("source.") || c.kind === "fact.cite" || c.kind === "person.cite" || c.kind === "family.cite"
154
+ ? `ui.sync.do.${c.kind}${c.reads || (c.kind === "source.new" && newReads(c, incoming)) ? ".read" : ""}`
155
+ : c.kind === "fact.new" && c.reads
156
+ ? "ui.sync.do.reading"
157
+ : `ui.sync.do.${c.action}`;
154
158
  // an archive: nobody reads a record there, nor is an agent told — said without it
155
159
  const said = actsAsArchive(tree) && `${does}.archive` in UI ? `${does}.archive` : does;
156
160
  return `${ui(lang, key, v)} → ${ui(lang, said)}`;
@@ -17,6 +17,7 @@ import { acquireLock } from "./lock.js";
17
17
  import { defaultAppUrl, isAppVersion, isStromAppOrigin, STROM_APP_BETA_URL, STROM_APP_URL } from "./stromapp.js";
18
18
  import { updateChannel } from "./update.js";
19
19
  import { WEB_PER_HOST, WEB_SOFT } from "./metrics.js";
20
+ import { TERMINAL_CHOICES, terminalChoices } from "./terminal.js";
20
21
  export const PERMISSION_LEVELS = ["ask", "auto", "full"];
21
22
  /** Earlier names of the levels, still understood. */
22
23
  const PERMISSION_ALIASES = { list: "auto", bypass: "full" };
@@ -88,6 +89,9 @@ export const SETTINGS = [
88
89
  { 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" },
89
90
  { 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" },
90
91
  { 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" },
92
+ // This computer's alone (never a research's strom.json: a research is packed and shared, its folder would choose the
93
+ // program that runs) and never from a link; fixed values only, each a fixed bundle ID (core/terminal.ts).
94
+ { key: "terminal", env: "STROM_TERMINAL", tree: false, kind: "choice", choices: TERMINAL_CHOICES, description: "the terminal strom opens its windows in (a link from the Strom app, a conversation handed over): system (default: what opens .command files), terminal, iterm2 — macOS; one not installed: the system's (said in the window). Windows: the system's default terminal application decides" },
91
95
  // Measured, never typed: strom media calibrate (a person starts it) keeps it per agent and model; unset: the defaults.
92
96
  { key: "views.size", env: "", tree: false, kind: "views", description: "the size of the scan views for the research's agent and model: find (a whole image, to find an entry; default 1400 px) and read (a half, a crop, a reader's view; default what the model takes) — set by strom media calibrate, which a person starts; strom config unset views.size returns to the defaults" },
93
97
  { key: "tune.transcripts", env: "STROM_TUNE_TRANSCRIPTS", tree: false, kind: "choice", choices: ["on", "off"], description: "the transcripts Claude Code keeps on this computer of the sessions strom started, read to tune how scans are read (the context cleared, files opened again, what each reader cost): on (default — only the tokens of each request, the names of the tools and the files read, never the conversation; nothing leaves this computer) or off (nothing read)" },
@@ -211,8 +215,10 @@ export function checkValue(def, raw, resolvePath, env = {}) {
211
215
  return def.key === "model.effort" ? v.toLowerCase() : v;
212
216
  case "choice": {
213
217
  const c = def.key === "agent.permissions" ? (PERMISSION_ALIASES[v.toLowerCase()] ?? v.toLowerCase()) : v.toLowerCase();
214
- if (!def.choices?.includes(c))
215
- throw new UsageError(`invalid ${def.key} "${raw}"`, { hint: def.choices?.join(", ") });
218
+ // the terminal: what this system takes (macOS its terminals, elsewhere the system's alone)
219
+ const choices = def.key === "terminal" ? terminalChoices() : def.choices;
220
+ if (!choices?.includes(c))
221
+ throw new UsageError(`invalid ${def.key} "${raw}"`, { hint: choices?.join(", ") });
216
222
  return c;
217
223
  }
218
224
  case "pace": {
@@ -474,6 +480,19 @@ export class Settings {
474
480
  const r = this.resolve("app.browser");
475
481
  return r ? String(r.value) : undefined;
476
482
  }
483
+ /**
484
+ * The terminal strom opens its windows in, if the person chose one (env, else the config; never a tree's). A value of
485
+ * the config this system does not take (written by hand, by a newer strom, on another system) counts as the system's.
486
+ */
487
+ terminal(platform = process.platform) {
488
+ const r = this.resolve("terminal");
489
+ return r && r.value !== "system" && terminalChoices(platform).includes(String(r.value)) ? String(r.value) : undefined;
490
+ }
491
+ /** The terminal in the config this system does not take (doctor says it, with the way back): none when it takes it. */
492
+ terminalUnknown(platform = process.platform) {
493
+ const v = readStored(this.config, "terminal", "");
494
+ return v !== undefined && !this.env.STROM_TERMINAL && !this.flagOf("terminal") && !terminalChoices(platform).includes(String(v)) ? String(v) : undefined;
495
+ }
477
496
  /** Where the user wants to talk with the agent, if they said (env, else the config); see whereToTalk. */
478
497
  agentWhere() {
479
498
  const r = this.resolve("agent.where");
package/dist/core/edge.js CHANGED
@@ -289,12 +289,18 @@ export function treeEdges(tree, opts = {}) {
289
289
  const isle = joins.length && onTree ? isles.get(joins[0]) : undefined;
290
290
  // the variants that would make the join: those that name the people off the tree
291
291
  const variants = joins.length ? joiningVariants(tree, h, onTree ? joins : (isles.get(p.id)?.people ?? [p.id])) : [];
292
+ const each = joins.map((j) => {
293
+ const named = joiningVariants(tree, h, [j]);
294
+ const own = onTree ? isles.get(j) : undefined;
295
+ return { person: j, ...(named.length ? { variants: named } : {}), ...(own ? { island: { people: own.people.length, held: own.held } } : {}) };
296
+ });
292
297
  return {
293
298
  id: h.id,
294
299
  question: h.question,
295
300
  joins,
296
301
  ...(isle ? { island: { people: isle.people.length, held: isle.held } } : {}),
297
302
  ...(variants.length ? { variants } : {}),
303
+ ...(each.length ? { each } : {}),
298
304
  // a task about it, or one whose words name it ("the baptism … (H0007 B)")
299
305
  tests: tasks.filter((t) => LIVE.has(t.state) && (t.subject.includes(h.id) || namesId(t.what, h.id))).map((t) => t.id),
300
306
  };
@@ -173,6 +173,30 @@ export function readGedName(n, file) {
173
173
  const nameless = file.app && !surn && (file.unknownIsNoSurname ? /^\?\s*\/\s*(?:Unknown\s*)?\/$/u : /^\?\s*\/\s*\/$/u).test(n.value.trim());
174
174
  return { name, nameless };
175
175
  }
176
+ /**
177
+ * The married names a file gives as _MARNM (Legacy, PAF and other programs): under a NAME (2 _MARNM) of that name's
178
+ * given name, under the person (1 _MARNM) of their first name's. A value with slashes is a whole name, else a surname.
179
+ * Read the same way by strom intake and strom sync.
180
+ */
181
+ export function marnmNames(r, givenOf) {
182
+ const out = [];
183
+ const add = (value, given) => {
184
+ const v = value.replace(/\s+/g, " ").trim();
185
+ if (!v)
186
+ return;
187
+ const n = v.includes("/") ? parseName(v) : { given: "", surname: v };
188
+ const name = { given: n.given || given, surname: n.surname, kind: "married" };
189
+ if ((name.given || name.surname) && !out.some((x) => sameName(x, name)))
190
+ out.push(name);
191
+ };
192
+ const names = children(r, "NAME");
193
+ for (const n of names)
194
+ for (const m of children(n, "_MARNM"))
195
+ add(m.value, givenOf(n));
196
+ for (const m of children(r, "_MARNM"))
197
+ add(m.value, names[0] ? givenOf(names[0]) : "");
198
+ return out;
199
+ }
176
200
  export function importGedcom(tree, text, opts) {
177
201
  const { records, problems } = parseGedcomText(text);
178
202
  /** A file of the Strom app (its HEAD: 1 SOUR STROM): its own ways of writing are read as it means them. */
@@ -334,16 +358,21 @@ export function importGedcom(tree, text, opts) {
334
358
  tree.put(rec, op);
335
359
  return true;
336
360
  };
337
- const namesOf = (r) => children(r, "NAME")
338
- .map((n) => {
339
- const found = readGedName(n, fromApp).name;
340
- // a word that describes the person in place of a name (stillborn, son): the name is "?", the word goes to a note
341
- if (notAName(found.given))
342
- found.given = "?";
343
- // a slash inside a name ("/⟨K/Č⟩emenská/") is kept as "|": the name stays one GEDCOM can write
344
- return { ...found, given: found.given.replace(/\//g, "|"), surname: found.surname.replace(/\//g, "|") };
345
- })
346
- .map((n) => (n.given || n.surname ? n : { ...n, given: "?", surname: "" }));
361
+ const namesOf = (r) => {
362
+ const names = children(r, "NAME")
363
+ .map((n) => {
364
+ const found = readGedName(n, fromApp).name;
365
+ // a word that describes the person in place of a name (stillborn, son): the name is "?", the word goes to a note
366
+ if (notAName(found.given))
367
+ found.given = "?";
368
+ // a slash inside a name ("/⟨K/Č⟩emenská/") is kept as "|": the name stays one GEDCOM can write
369
+ return { ...found, given: found.given.replace(/\//g, "|"), surname: found.surname.replace(/\//g, "|") };
370
+ })
371
+ .map((n) => (n.given || n.surname ? n : { ...n, given: "?", surname: "" }));
372
+ // a married name other programs give apart (_MARNM): a name of the married kind, unless the person has it already
373
+ const given = (n) => (notAName(readGedName(n, fromApp).name.given) ? "" : readGedName(n, fromApp).name.given.replace(/\//g, "|"));
374
+ return [...names, ...marnmNames(r, given).filter((m) => !names.some((n) => sameName(n, m)))];
375
+ };
347
376
  /** The Strom app keeps what a birth or death entry adds in the person's note ("Úmrtí: Věk: 61 let"): back to the fact; the other lines are returned. */
348
377
  const factNotes = (r, personEvents) => {
349
378
  const personNotes = [];
package/dist/core/live.js CHANGED
@@ -679,7 +679,7 @@ function idsOf(applied, known) {
679
679
  return Object.keys(ids.persons).length || Object.keys(ids.sources).length ? { ids } : {};
680
680
  }
681
681
  /** What the bridge does that an app may ask about (each added once, never taken away). */
682
- export const BRIDGE_FEATURES = ["sync.again", "sync.undoneSince", "sync.takenBack", "sync.conflictEdit", "sync.since", "sync.ids", "family.noCouple", "family.alone", "adopt.transfer", "adopt.empty", "material.list", "person.titles", "media.codes", "hypothesis.links", "status.recent", "spend.readers", "hypothesis.intree", "archive.permission", "task.check", "waiting.groups", "waiting.sent", "waiting.note", "batch.kept", "live.end", "whats.new"];
682
+ export const BRIDGE_FEATURES = ["sync.again", "sync.undoneSince", "sync.takenBack", "sync.conflictEdit", "sync.since", "sync.ids", "family.noCouple", "family.alone", "adopt.transfer", "adopt.empty", "material.list", "person.titles", "media.codes", "hypothesis.links", "status.recent", "spend.readers", "hypothesis.intree", "archive.permission", "task.check", "waiting.groups", "waiting.sent", "waiting.note", "batch.kept", "live.end", "whats.new", "terminal.choice", "person.marriedSurname"];
683
683
  /** /status recent, when it is ready. */
684
684
  function recentOf(root, env, head) {
685
685
  const recent = head ? recentNow(root, env, head) : undefined;
@@ -9,6 +9,8 @@ import { configDir, desktopDir, userHome } from "./paths.js";
9
9
  import { stromLauncher } from "./self.js";
10
10
  import { isAgentMark } from "./which.js";
11
11
  import { assetPath } from "./assets.js";
12
+ import { macTerminal } from "./terminal.js";
13
+ import { ui } from "../cli/ui.js";
12
14
  /** Strom Research's icon, as each system takes it. */
13
15
  export const ICON = { png: assetPath("icon", "icon-512.png"), ico: assetPath("icon", "icon.ico") };
14
16
  /** Where the Windows shortcut's command file lives (the desktop has the .lnk with the icon). */
@@ -97,7 +99,11 @@ export function createShortcut(name, env, platform = process.platform) {
97
99
  */
98
100
  /** One handover window at a time: another asked within this long is not opened. */
99
101
  export const HANDOVER_GAP_MS = 15_000;
100
- export function openInNewTerminal(argv, cwd, env, platform = process.platform) {
102
+ /**
103
+ * `terminal`: the one the person chose (core/terminal.ts; macOS) — opened by its bundle ID; not installed: the system's,
104
+ * the window saying so first in `lang` ("fallback").
105
+ */
106
+ export function openInNewTerminal(argv, cwd, env, platform = process.platform, opts = {}) {
101
107
  if (env.STROM_NO_OPEN === "1")
102
108
  return false;
103
109
  const { command, args } = stromLauncher();
@@ -129,6 +135,23 @@ export function openInNewTerminal(argv, cwd, env, platform = process.platform) {
129
135
  const body = ["#!/bin/sh", 'rm -f -- "$0"', ...(marks.length ? [`unset ${marks.join(" ")}`] : []), ...own.map(([k, v]) => `export ${k}=${q(v)}`), "export STROM_HANDOVER=1", `cd ${q(cwd)}`, `exec ${[command, ...args, ...argv].map(q).join(" ")}`, ""];
130
136
  fs.writeFileSync(file, body.join("\n"), { mode: 0o755 });
131
137
  fs.chmodSync(file, 0o755);
138
+ // macOS, a terminal chosen: opened by its bundle ID (LaunchServices, as a double-click: no permission asked); not
139
+ // installed (open says so), the system's — the window says it first, then goes on
140
+ const chosen = macTerminal(opts.terminal, platform);
141
+ if (chosen) {
142
+ const r = spawnSync("open", ["-b", chosen.id, file], { stdio: "ignore", timeout: 15_000, env: env });
143
+ if (r.status === 0 || (r.status === null && r.error?.code !== "ENOENT"))
144
+ return true;
145
+ if (r.status === null)
146
+ return false; // no open here
147
+ const lang = opts.lang ?? "en";
148
+ const said = ui(lang, "ui.terminal.missing", { name: ui(lang, `ui.terminal.name.${opts.terminal}`) });
149
+ fs.writeFileSync(file, [body[0], body[1], `printf '%s\\n\\n' ${q(said)}`, ...body.slice(2)].join("\n"), { mode: 0o755 });
150
+ const o = spawn("open", [file], { detached: true, stdio: "ignore", env: env });
151
+ o.on("error", () => undefined);
152
+ o.unref();
153
+ return "fallback";
154
+ }
132
155
  // macOS: a .command file opens in Terminal. Linux: the desktop's terminal, when there is one.
133
156
  const how = platform === "darwin"
134
157
  ? [["open", [file]]]
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, readGedSex, stromAppFile } from "./import.js";
30
+ import { fromFlexDate, houseOf, importDate, marnmNames, 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";
@@ -42,7 +42,7 @@ import { now, Tree, typeOfId } from "./tree.js";
42
42
  import { actsAsArchive } from "./state.js";
43
43
  import { appSides } from "./conflicts.js";
44
44
  import { APP_DATA_SEX_UNKNOWN, isAppVersion } from "./stromapp.js";
45
- import { cleanTitle, familiesAsPartner, formatName, foundTitles, gedcomTitledName, primaryName, titledName } from "./people.js";
45
+ import { cleanTitle, familiesAsPartner, formatName, foundTitles, gedcomTitledName, parseName, primaryName, sameName as sameWords, titledName } from "./people.js";
46
46
  /** The words of a _STAT (the Strom app's own, also Legacy's and FTM's) as a union, or nothing it names. */
47
47
  function unionOfStat(value) {
48
48
  const v = (value ?? "").trim().toLowerCase();
@@ -532,9 +532,17 @@ export function readGedcom(text) {
532
532
  const titles = named[0] ? titlesFrom(named[0].name.prefix, named[0].name.suffix) : undefined;
533
533
  // (the research's own person: no name at all, as the research writes it — never another name, never a rename);
534
534
  // what stands for no name ("N/A /Chrpa/", "N.N.") is the research's "?" (T08)
535
- const names = named
536
- .filter(({ nameless }) => !(ours && nameless))
537
- .map(({ name, nameless }, i) => (nameless ? "? //" : gedcomTitledName(i === 0 && titles ? { given: name.given, surname: name.surname } : name)));
535
+ const kept = named.filter(({ nameless }) => !(ours && nameless));
536
+ const names = kept.map(({ name, nameless }, i) => (nameless ? "? //" : gedcomTitledName(i === 0 && titles ? { given: name.given, surname: name.surname } : name)));
537
+ // the names after a marriage (TYPE married; another program's _MARNM, a name of its own), never the one shown
538
+ const married = names.filter((_, i) => i > 0 && kept[i].name.kind === "married");
539
+ for (const m of names.length ? marnmNames(r, (n) => readGedName(n, fromApp).name.given) : []) {
540
+ const line = gedcomTitledName(m);
541
+ if (names.some((n) => nameKey(n) === nameKey(line)))
542
+ continue;
543
+ names.push(line);
544
+ married.push(line);
545
+ }
538
546
  // a placeholder of the Strom app (an unknown parent drawn in the tree): nobody
539
547
  if (!ours && !names.length)
540
548
  continue;
@@ -557,6 +565,7 @@ export function readGedcom(text) {
557
565
  said: allNotes(r, noteText),
558
566
  ...(cites.length ? { cites } : {}),
559
567
  ...(titles ? { titles } : {}),
568
+ ...(married.length ? { married } : {}),
560
569
  });
561
570
  }
562
571
  // how a child belongs to a family (the child's FAMC + PEDI: adopted, foster, step): none said is birth
@@ -778,10 +787,16 @@ export function readStromJson(data) {
778
787
  }
779
788
  const cites = citesOf(p.sourceIds);
780
789
  const titles = titlesFrom(p.titleBefore, p.titleAfter);
790
+ // the surname after the marriage (spec 010) with the person's first name: a name of the married kind, after the one
791
+ // shown and before the other forms (as the app's GEDCOM gives it)
792
+ const marriedSurname = typeof p.marriedSurname === "string" ? p.marriedSurname.replace(/[/\s]+/g, " ").trim() : "";
793
+ const married = marriedSurname ? `${p.firstName ?? ""} /${marriedSurname}/`.trim() : undefined;
794
+ const names = [name, ...(married ? [married] : []), ...(p.nameVariants ?? [])].filter((n) => n.replace(/[/?\s]/g, ""));
781
795
  persons.set(key, {
782
796
  key,
783
797
  ...(titles ? { titles } : {}),
784
- names: [name, ...(p.nameVariants ?? [])].filter((n) => n.replace(/[/?\s]/g, "")),
798
+ names: names.filter((n, i) => n !== married || names.findIndex((m) => nameKey(m) === nameKey(n)) === i),
799
+ ...(married && names.findIndex((n) => nameKey(n) === nameKey(married)) > 0 ? { married: [married] } : {}),
785
800
  // the sex left unknown a value like the others (U01): the app's gender unknown from its data version 12, the
786
801
  // research's unknown standing beside its stand-in (research.sexU) before it; else male and female only — a gender
787
802
  // missing says nothing of it (B5-d)
@@ -2091,6 +2106,48 @@ export function planSync(tree, incoming, edits, opts = {}) {
2091
2106
  push({ kind: "note.new", action: "add", ...owner, text: out.join("\n") });
2092
2107
  }
2093
2108
  };
2109
+ /**
2110
+ * The names after a marriage (spec 010; the Strom app keeps one, the last, with its own first name): the same by its
2111
+ * surname — "Marie /Nováková/" of the app is the research's "Maria /Nováková/". One the research lacks is added of
2112
+ * the married kind (a name of the research the file now says is one after a marriage: that kind); one the app was
2113
+ * given and no longer has (taken off, or changed: the new one added) goes where only the user's edits or other
2114
+ * family trees gave it, a record's stays (said) — never the name they are shown by.
2115
+ */
2116
+ const marriedDiff = (id, name, p, o, b) => {
2117
+ const surname = (n) => exact(/\/([^/]*)\//.exec(n)?.[1] ?? "");
2118
+ const same = (n, list) => list.some((m) => nameKey(m) === nameKey(n) || (!!surname(n) && surname(m) === surname(n)));
2119
+ const person = tree.get(id);
2120
+ if (!person)
2121
+ return;
2122
+ const ofResearch = (n) => person.names.find((m) => nameKey(gedcomName(m)) === nameKey(n));
2123
+ const shown = o.names[0] ?? "";
2124
+ for (const n of p.married ?? []) {
2125
+ if (same(n, o.married ?? []))
2126
+ continue;
2127
+ const has = ofResearch(n);
2128
+ if (has) {
2129
+ // the research's own words of another kind: the file says they are a name after a marriage
2130
+ if (!has.kind && nameKey(n) !== nameKey(shown))
2131
+ push({ kind: "name.new", action: "add", person: id, name, text: n, married: true });
2132
+ continue;
2133
+ }
2134
+ // the research took it off since the app was given it: that stays
2135
+ if (b && (b.married ?? []).some((m) => nameKey(m) === nameKey(n)))
2136
+ continue;
2137
+ push({ kind: "name.new", action: "add", person: id, name, text: n, married: true });
2138
+ }
2139
+ if (!b || partial)
2140
+ return;
2141
+ for (const g of b.married ?? []) {
2142
+ // still in the file (as a name after a marriage by its surname, or as another form), or no longer the research's
2143
+ if (p.names.some((n) => nameKey(n) === nameKey(g)) || same(g, p.married ?? []))
2144
+ continue;
2145
+ const mine = ofResearch(g);
2146
+ if (!mine || mine.kind !== "married" || nameKey(g) === nameKey(shown))
2147
+ continue;
2148
+ push({ kind: "name.gone", action: nameBacked(tree, mine) ? "report" : "remove", person: id, name, text: gedcomName(mine) });
2149
+ }
2150
+ };
2094
2151
  // people
2095
2152
  for (const [key, p] of incoming.persons) {
2096
2153
  const name = p.names[0] ?? "?";
@@ -2099,7 +2156,7 @@ export function planSync(tree, incoming, edits, opts = {}) {
2099
2156
  factsDiff({ person: key, name }, p.facts, [], undefined, false);
2100
2157
  citesDiff({ person: key, name }, p.cites, undefined, undefined);
2101
2158
  for (const n of p.names.slice(1))
2102
- push({ kind: "name.new", action: "add", person: key, name, text: n });
2159
+ push({ kind: "name.new", action: "add", person: key, name, text: n, ...(p.married?.includes(n) ? { married: true } : {}) });
2103
2160
  for (const t of p.notes)
2104
2161
  push({ kind: "note.new", action: "add", person: key, name, text: t });
2105
2162
  continue;
@@ -2127,9 +2184,11 @@ export function planSync(tree, incoming, edits, opts = {}) {
2127
2184
  const shown = primaryName(tree.get(id));
2128
2185
  push({ kind: "name.changed", action: renamed && !nameBacked(tree, shown) ? "correct" : edits === "user" ? "user" : "conflict", person: id, name, text: now, wasName: mine });
2129
2186
  }
2187
+ const theirMarried = new Set(p.married ?? []);
2130
2188
  for (const n of p.names)
2131
- if (!((renamed || both) && n === now) && !ourNames.has(nameKey(n)) && !baseNames.has(nameKey(n)))
2189
+ if (!theirMarried.has(n) && !((renamed || both) && n === now) && !ourNames.has(nameKey(n)) && !baseNames.has(nameKey(n)))
2132
2190
  push({ kind: "name.new", action: "add", person: id, name, text: n });
2191
+ marriedDiff(id, name, p, o, b);
2133
2192
  // the titles of the name they are shown by (T07), each apart and never as a change of the name itself: the user's
2134
2193
  // edit where the file's differs from what the app was given — one the name lacks added, a lead's corrected, a
2135
2194
  // record's name the user's to decide (or their word: sync.edits user, an archive); the research's changed since
@@ -3446,9 +3505,30 @@ export function applySync(tree, plan, incoming, source, only) {
3446
3505
  case "name.new": {
3447
3506
  if (!owner || !c.text)
3448
3507
  break;
3508
+ // a name of the research the file says is one after a marriage: of that kind now, citing the sync (undo: as it was)
3509
+ const had = c.married ? tree.get(owner)?.names.find((n) => sameWords(n, parseName(c.text))) : undefined;
3449
3510
  // beside the name the person is shown by, always last: what the app shows does not move
3450
- const { name } = addName(tree, owner, { name: c.text, citation: cite("name"), other: true });
3451
- applied.push({ do: "name.add", id: owner, before: exact(`${name.given}|${name.surname}`) });
3511
+ const { name } = addName(tree, owner, { name: c.text, citation: cite("name"), other: true, ...(c.married ? { kind: "married" } : {}) });
3512
+ if (had)
3513
+ applied.push({ do: "name.edit", id: owner, before: JSON.stringify({ name: had, now: exact(`${name.given}|${name.surname}`) }) });
3514
+ else
3515
+ applied.push({ do: "name.add", id: owner, before: exact(`${name.given}|${name.surname}`) });
3516
+ break;
3517
+ }
3518
+ case "name.gone": {
3519
+ // a name after a marriage taken off in the app that only the user's edits or other family trees gave: off,
3520
+ // where it stood kept for an undo (a record's stays: only said)
3521
+ const p = owner && c.action === "remove" && c.text ? tree.get(owner) : undefined;
3522
+ const at = p ? p.names.findIndex((n) => n.kind === "married" && n !== primaryName(p) && nameKey(gedcomName(n)) === nameKey(c.text) && !nameBacked(tree, n)) : -1;
3523
+ if (!p || at < 0)
3524
+ break;
3525
+ const gone = p.names[at];
3526
+ update(tree, p.id, "person", (x) => ({ ...x, names: x.names.filter((_, i) => i !== at) }), {
3527
+ op: "name.remove",
3528
+ summary: `${p.id} name ${gedcomName(gone)} removed: ${phrase(tree.lang, "archive.removed")}`,
3529
+ reason: `${phrase(tree.lang, "archive.removed")}: ${reason}`,
3530
+ });
3531
+ applied.push({ do: "name.drop", id: p.id, before: JSON.stringify({ name: gone, at }) });
3452
3532
  break;
3453
3533
  }
3454
3534
  case "name.changed": {
@@ -0,0 +1,70 @@
1
+ // The terminal strom opens its windows in (a link from the Strom app, a
2
+ // conversation handed over): the system's — what opens a .command file — unless
3
+ // the person chose another (terminal, a setting of this computer alone: never a
4
+ // research's strom.json, never a link). macOS only for now: each choice maps to
5
+ // a fixed bundle ID, opened with `open -b`; one not installed: the system's.
6
+ // Windows decides it itself (its default terminal application), Linux keeps its
7
+ // order (core/shortcut.ts).
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { spawnSync } from "node:child_process";
11
+ import { userHome } from "./paths.js";
12
+ export const MAC_TERMINALS = {
13
+ terminal: { id: "com.apple.Terminal", app: "Terminal.app", name: "Terminal", mark: "Apple_Terminal" },
14
+ iterm2: { id: "com.googlecode.iterm2", app: "iTerm.app", name: "iTerm2", mark: "iTerm.app" },
15
+ };
16
+ /** Every value of the setting, on any system (the help names them). */
17
+ export const TERMINAL_CHOICES = ["system", ...Object.keys(MAC_TERMINALS)];
18
+ /** The values this system takes: macOS its terminals, elsewhere the system's alone. */
19
+ export function terminalChoices(platform = process.platform) {
20
+ return platform === "darwin" ? [...TERMINAL_CHOICES] : ["system"];
21
+ }
22
+ /** The chosen terminal of macOS (none: the system's) — one of its own keys only, never one of an object's prototype. */
23
+ export function macTerminal(choice, platform = process.platform) {
24
+ return platform === "darwin" && choice && Object.hasOwn(MAC_TERMINALS, choice) ? MAC_TERMINALS[choice] : undefined;
25
+ }
26
+ /** The folders apps lie in (STROM_APP_DIRS: folders to look in instead — tests). */
27
+ function appDirs(env) {
28
+ if (env.STROM_APP_DIRS !== undefined)
29
+ return env.STROM_APP_DIRS.split(path.delimiter).filter(Boolean);
30
+ return ["/Applications", "/Applications/Utilities", "/System/Applications/Utilities", path.join(userHome(env), "Applications")];
31
+ }
32
+ /** Ask the system (JXA) — nothing when tests give folders of their own. */
33
+ function jxa(env, js, args = []) {
34
+ if (env.STROM_APP_DIRS !== undefined)
35
+ return undefined;
36
+ const r = spawnSync("osascript", ["-l", "JavaScript", "-e", js, ...args], { encoding: "utf8", timeout: 10_000 });
37
+ return r.status === 0 ? r.stdout.trim() : undefined;
38
+ }
39
+ /** The terminals of macOS on this computer, in strom's order: by their bundle ID (NSWorkspace), else in the app folders. */
40
+ export function terminalsHere(env, platform = process.platform) {
41
+ if (platform !== "darwin")
42
+ return [];
43
+ const keys = Object.keys(MAC_TERMINALS);
44
+ const js = 'function run(a){ObjC.import("AppKit");return a.map(function(id){var u=$.NSWorkspace.sharedWorkspace.URLForApplicationWithBundleIdentifier(id);return u.isNil()?"":"y"}).join(",")}';
45
+ const said = jxa(env, js, keys.map((k) => MAC_TERMINALS[k].id))?.split(",");
46
+ return keys.filter((k, i) => said?.[i] === "y" || appDirs(env).some((d) => fs.existsSync(path.join(d, MAC_TERMINALS[k].app))));
47
+ }
48
+ /** Is the chosen terminal on this computer? (the system's always is) */
49
+ export function terminalHere(choice, env, platform = process.platform) {
50
+ return !macTerminal(choice, platform) || terminalsHere(env, platform).includes(choice);
51
+ }
52
+ /** What opens a .command file here (the system's terminal), when the system says: one of the choices, else its name. */
53
+ export function systemTerminal(env, platform = process.platform) {
54
+ if (platform !== "darwin")
55
+ return undefined;
56
+ const js = 'ObjC.import("CoreServices");ObjC.import("AppKit");var r=$.LSCopyDefaultRoleHandlerForContentType($("com.apple.terminal.shell-script"),$.kLSRolesAll);var id=r?ObjC.castRefToObject(r):$();var s=id.isNil()?"":id.js;var u=s?$.NSWorkspace.sharedWorkspace.URLForApplicationWithBundleIdentifier(s):$();s+"\\n"+(s&&!u.isNil()?$.NSFileManager.defaultManager.displayNameAtPath(u.path).js:"")';
57
+ const said = jxa(env, js);
58
+ if (!said)
59
+ return undefined;
60
+ const [id, shown] = said.split("\n");
61
+ const choice = Object.keys(MAC_TERMINALS).find((k) => MAC_TERMINALS[k].id.toLowerCase() === id?.toLowerCase());
62
+ if (choice)
63
+ return { choice, name: MAC_TERMINALS[choice].name };
64
+ const name = shown?.replace(/\.app$/u, "");
65
+ return name ? { name } : undefined;
66
+ }
67
+ /** The terminal a window runs in, by its mark (TERM_PROGRAM): one of the choices, else none. */
68
+ export function terminalOfMark(mark) {
69
+ return Object.keys(MAC_TERMINALS).find((k) => MAC_TERMINALS[k].mark === mark);
70
+ }
package/dist/core/tree.js CHANGED
@@ -27,7 +27,7 @@ import { lastBackup } from "./backup.js";
27
27
  export const TREE_FILE = "strom.json";
28
28
  // between releases the candidate being made (a bridge run from the sources says what it is: the Strom app goes by it);
29
29
  // at a release the release's own (package.json)
30
- export const VERSION = "1.15.0";
30
+ export const VERSION = "1.16.0-beta.2";
31
31
  const GITIGNORE = `# Strom Research — local state and large files are not versioned
32
32
  .strom/
33
33
  /media/
@@ -725,8 +725,18 @@ export function exportGedcom(tree, opts = {}) {
725
725
  w.line(2, "_TRIED", id);
726
726
  for (const h of e.hypotheses) {
727
727
  w.line(2, "_HYPO", h.id);
728
- for (const j of h.joins)
729
- w.line(3, "_JOIN", j);
728
+ for (const j of h.each ?? []) {
729
+ w.line(3, "_JOIN", j.person);
730
+ // each join on its own: the variants that name the person, the family off the tree it belongs to (an older app
731
+ // passes level 4 by and takes the numbers of the hypothesis, those of the first join)
732
+ if (opts.hypothesisLinks)
733
+ for (const v of j.variants ?? [])
734
+ w.line(4, "_VAR", v);
735
+ if (j.island) {
736
+ w.line(4, "_ISLAND", String(j.island.people));
737
+ w.line(4, "_HELD", String(j.island.held));
738
+ }
739
+ }
730
740
  // which variant would make the join (an app that shows what a hypothesis would connect)
731
741
  if (opts.hypothesisLinks)
732
742
  for (const v of h.variants ?? [])
@@ -29,6 +29,8 @@ const CHILDREN = {
29
29
  "INDI._STROM_SEARCHED": ["TITL", "DATE", "RESN", "_AT"],
30
30
  "INDI._STROM_EDGE": ["_SCOPE", "_RESEARCH", "_GEN", "_END", "_NEXT", "_EST", "DATE", "_RECORDS", "_BOOK", "_COVERED", "_NORECORDS", "_TASK", "_TRIED", "_HYPO", "_CONFLICT", "_SEARCHES", "_SESSIONS", "_LAST"],
31
31
  "INDI._STROM_ISLAND": ["_HYPO", "_HELD"],
32
+ // one join of a hypothesis at the edge: the variants that name the person, the family off the tree it belongs to
33
+ "_STROM_EDGE._JOIN": ["_VAR", "_ISLAND", "_HELD"],
32
34
  // a value of a conflict the app decides by side: its side, a sex as M, F or U
33
35
  _STROM_VALUE: ["SOUR", "_STROM_SIDE", "_STROM_RAW"],
34
36
  "INDI.NAME": ["TYPE", "GIVN", "SURN", "NPFX", "NSFX", "NICK", "SPFX", "SOUR", "NOTE"],
@@ -90,6 +92,8 @@ function contextOf(anc) {
90
92
  return "_LINK";
91
93
  if (parent === "_INTREE" && anc[2] === "_VAR")
92
94
  return "_INTREE";
95
+ if (parent === "_JOIN" && anc[1] === "_STROM_EDGE" && anc[2] === "_HYPO")
96
+ return "_STROM_EDGE._JOIN";
93
97
  if (parent === "ADDR")
94
98
  return "ADDR";
95
99
  if (parent === "DATA" && anc[depth - 2] === "SOUR")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strom-research",
3
- "version": "1.15.0",
3
+ "version": "1.16.0-beta.2",
4
4
  "description": "Genealogical research toolkit for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -48,5 +48,8 @@
48
48
  "devDependencies": {
49
49
  "@types/node": "^22.20.4",
50
50
  "typescript": "^7.0.2"
51
+ },
52
+ "publishConfig": {
53
+ "tag": "beta"
51
54
  }
52
55
  }