strom-research 1.16.0-beta.2 → 1.16.0-beta.4

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
@@ -20,6 +20,10 @@ Změny každé verze ve zkratce. Úplné poznámky a instalační řádky jsou n
20
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
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
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
+ Výzkum založený dřív z takového rodokmenu (MyHeritage, Legacy, PAF) je doplní ze souboru, který má uložený:
24
+ `strom input names I…` je ukáže, `--apply` přidá jen tato jména s citací importu, `--undo` je vezme zpět.
25
+ - **Jeden archiv, jedna otázka.** Když obrazy jednoho archivu přicházejí z více adres (jeho web, album, server
26
+ obrazů), svolení archivu se žádá jednou a platí pro všechny, ne adresu po adrese.
23
27
  - **Linie výzkumu.** České texty všude říkají „linie výzkumu“, nikde už „směry“.
24
28
 
25
29
  ## 1.15.0
package/CHANGELOG.de.md CHANGED
@@ -21,7 +21,12 @@ Die Änderungen jeder Version in Kürze. Die vollständigen Hinweise und die Ins
21
21
  Strom-App zurückschickt, kommt als Name nach der Heirat an, nie als Umbenennung oder als weitere Form des
22
22
  Geburtsnamens; derselbe Familienname ist derselbe Name, mit welchem Vornamen auch immer. In der App geändert oder
23
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.
24
+ `strom intake` liest `_MARNM` anderer Programme als Namen nach der Heirat. Eine Forschung, die früher aus so einem
25
+ Stammbaum begonnen wurde (MyHeritage, Legacy, PAF), bekommt sie aus der Datei, die sie aufbewahrt:
26
+ `strom input names I…` zeigt sie, `--apply` fügt nur diese Namen hinzu, mit dem Import als Quelle, `--undo` nimmt
27
+ sie zurück.
28
+ - **Ein Archiv, eine Frage.** Kommen die Bilder eines Archivs von mehreren Adressen (seine Seite, ein Album, ein
29
+ Bildserver), wird die Erlaubnis des Archivs einmal erfragt und gilt für alle, nicht Adresse für Adresse.
25
30
  - **Forschungslinien auf Tschechisch.** Die tschechischen Texte sagen jetzt überall „linie výzkumu“ (Forschungslinien).
26
31
 
27
32
  ## 1.15.0
package/CHANGELOG.md CHANGED
@@ -20,6 +20,10 @@ The changes of each version in short. The full notes and the install lines are o
20
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
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
22
  in the app gave it; one a record gives stays. `strom intake` reads `_MARNM` of other programs as a married name.
23
+ A research started earlier from such a family tree (MyHeritage, Legacy, PAF) gets them from the file it keeps:
24
+ `strom input names I…` shows them, `--apply` adds only these names, citing the import, `--undo` takes them back.
25
+ - **One archive, one question.** When one archive's images come from several hosts (its site, an album, an image
26
+ server), the permission it needs is asked once and decided for all of them, not host by host.
23
27
  - **Lines of research in Czech.** The Czech texts say „linie výzkumu“ (lines of research) everywhere now.
24
28
 
25
29
  ## 1.15.0
@@ -11,7 +11,7 @@ import { humanDay } from "./human.js";
11
11
  import { Tree } from "../core/tree.js";
12
12
  import { isArchive } from "../core/mode.js";
13
13
  import { bareHost, listConnectors, routeOf, routesOf } from "../core/connector.js";
14
- import { claimOf, loadArchives, rank } from "../core/terms.js";
14
+ import { claimOf, loadArchives, pendingQuestions, questionHosts, rank } from "../core/terms.js";
15
15
  import { connectorFor, levelWords, tasksStanding, tasksWaitingOn, termsText, waysOffered, wokenLines } from "../core/archiveasks.js";
16
16
  import { wakeForArchive } from "../core/archivewait.js";
17
17
  import { archivesOverview, capWords, levelsOf, paceFor } from "../commands/archives.js";
@@ -53,8 +53,9 @@ export async function decideArchive(ctx, run, lang, root, host, q, all) {
53
53
  const title = a?.title ?? q?.title ?? c.manifest.title ?? host;
54
54
  const tree = root ? Tree.open(root, ctx.env) : undefined;
55
55
  const hosts = all && all.length > 1 ? all : [host];
56
- // the command's target: the downloader for all its hosts at once, else the host
57
- const target = hosts.length > 1 ? c.name : host;
56
+ // the command's target: the downloader for all its hosts at once, else the host — with every host of its question
57
+ // (strom allow archive decides a question of one archive for all its hosts)
58
+ const target = hosts.length > 1 && hosts.some((h) => !q?.archiveHosts?.includes(h)) ? c.name : host;
58
59
  out("");
59
60
  out(` ${t("ui.archive.item", { title, host: hosts.join(", ") })}`);
60
61
  out(` ${termsText(lang, ctx.env, { host, ...(a?.quote ? { quote: a.quote } : q?.quote ? { quote: q.quote } : {}), ...(a?.terms ?? q?.terms ? { terms: (a?.terms ?? q?.terms) } : {}), ...(a?.quoteBy ? { quoteBy: a.quoteBy } : q?.quoteBy ? { quoteBy: q.quoteBy } : {}) })}`);
@@ -222,7 +223,7 @@ function hostStates(ctx, lang, c) {
222
223
  /** Settings → Archives → permissions: each archive of a downloader here, then the same choices as in What waits. */
223
224
  export async function archivePermissions(ctx, run, lang, root) {
224
225
  const t = translator(lang);
225
- const pending = (hosts) => loadArchives(ctx.env).pending.find((p) => p.kind === "permission" && hosts.includes(p.host ?? ""));
226
+ const pending = (hosts) => pendingQuestions(ctx.env).find((p) => p.kind === "permission" && questionHosts(p).some((h) => hosts.includes(h)));
226
227
  await subMenu(ctx, lang, () => {
227
228
  const rows = archivesOverview(ctx);
228
229
  const connectors = connectorsHere(ctx);
@@ -35,6 +35,7 @@ import { foldText, safeFolderName } from "../core/text.js";
35
35
  import { StromError } from "../core/errors.js";
36
36
  import { forgetTree } from "../commands/trees.js";
37
37
  import { archiveQuestions } from "../core/archiveasks.js";
38
+ import { questionHosts } from "../core/terms.js";
38
39
  import { bareHost, listConnectors } from "../core/connector.js";
39
40
  import { decideArchive } from "./menu-archives.js";
40
41
  import { inboxOf, requestFile, sameGroup } from "../core/waiting.js";
@@ -173,11 +174,11 @@ export async function archiveAsked(ctx, run, lang, root, host) {
173
174
  const tree = Tree.open(root, ctx.env);
174
175
  if (isArchive(tree))
175
176
  return stop(ctx, t("ui.link.archive.none"));
176
- const q = archiveQuestions(tree).find((p) => p.kind === "permission" && p.host === host);
177
+ const q = archiveQuestions(tree).find((p) => p.kind === "permission" && questionHosts(p).includes(host));
177
178
  const known = q ? true : listConnectors(ctx.settings.shared()?.value, ctx.env).some((c) => c.manifest.hosts.map(bareHost).includes(host));
178
179
  if (!known)
179
180
  return stop(ctx, t("ui.link.archive.none"));
180
- return decideArchive(ctx, run, lang, root, host, q);
181
+ return decideArchive(ctx, run, lang, root, host, q, q?.archiveHosts);
181
182
  }
182
183
  /** A direction of the research paused, ended (each with the person's reason, if they give one) or taken up again. */
183
184
  export async function directionDo(ctx, run, lang, root, id, what) {
@@ -426,7 +426,7 @@ async function decide(ctx, run, lang, root, w) {
426
426
  return void (await decideStory(ctx, run, lang, root, w.story, storyOf(tree, w.story)));
427
427
  const q = w.question;
428
428
  if (q?.kind === "permission" && q.host)
429
- return void (await decideArchive(ctx, run, lang, root, q.host, q));
429
+ return void (await decideArchive(ctx, run, lang, root, q.host, q, q.archiveHosts));
430
430
  if (q?.kind === "takeover")
431
431
  return decideTakeover(ctx, run, lang, q);
432
432
  if (q?.kind === "milder" && q.connector) {
@@ -19,7 +19,7 @@ import { currentSession } from "../core/session.js";
19
19
  import { noteSessionWait, wakeForArchive } from "../core/archivewait.js";
20
20
  import { connectorFor, levelWords, waysOffered, wokenLines } from "../core/archiveasks.js";
21
21
  import { claudeHere } from "../cli/menu-parts.js";
22
- import { acceptConnectors, allows, keepReadings, askLater, claimOf, connectorLevels, grantWay, hostOf, humanPace, keepNetVerdict, keyHost, latchedPace, LEVELS, loadArchives, pendingQuestions, rank, settlePending, revokeWay, setFloor, strictest, takenOverWaiting, windowShown, WAYS, } from "../core/terms.js";
22
+ import { acceptConnectors, allows, keepReadings, askLater, claimOf, connectorLevels, grantWay, hostOf, humanPace, keepNetVerdict, keyHost, latchedPace, LEVELS, loadArchives, pendingQuestions, questionHosts, rank, settlePending, revokeWay, setFloor, strictest, takenOverWaiting, windowShown, WAYS, } from "../core/terms.js";
23
23
  /** The images (or pages) of an archive wait for the person's decision: nothing was asked of the archive. */
24
24
  export class NeedsPermissionError extends StromError {
25
25
  hosts;
@@ -466,7 +466,10 @@ export function syncTrees(ctx) {
466
466
  }
467
467
  return synced;
468
468
  }
469
- /** The hosts a name stands for: a connector's (its name), else the connector hosts on that host or site — never wider. */
469
+ /**
470
+ * The hosts a name stands for: a connector's (its name), else the connector hosts on that host or site — never wider,
471
+ * but for the other hosts of one archive whose question waits with it (one archive, one decision).
472
+ */
470
473
  function targetHosts(ctx, target) {
471
474
  const all = listConnectors(shared(ctx), ctx.env);
472
475
  const byName = all.find((c) => c.name === target);
@@ -477,6 +480,14 @@ function targetHosts(ctx, target) {
477
480
  const hosts = [...new Set(connectors.flatMap((c) => c.manifest.hosts.map(bareHost).filter((h) => hostAllowed(h, [given]) || hostAllowed(given, [h]))))];
478
481
  if (!hosts.length)
479
482
  throw new UsageError(`no connector reaches ${given}`, { hint: all.length ? `connectors: ${all.map((c) => `${c.name} (${c.manifest.hosts.join(", ")})`).join(", ")}` : "none yet: strom connector list" });
483
+ const reached = new Set(connectors.flatMap((c) => c.manifest.hosts.map(bareHost)));
484
+ for (const q of pendingQuestions(ctx.env)) {
485
+ const its = questionHosts(q);
486
+ if (q.kind === "permission" && its.some((h) => hosts.includes(h)))
487
+ for (const h of its)
488
+ if (reached.has(h) && !hosts.includes(h))
489
+ hosts.push(h);
490
+ }
480
491
  return { hosts, connectors };
481
492
  }
482
493
  const WAY_TEXT = { slow: "images slowly, at a person's pace", browser: "images through the browser on this computer", manual: "images by hand only" };
@@ -9,11 +9,11 @@ import path from "node:path";
9
9
  import { register } from "../cli/registry.js";
10
10
  import { lines, moreLine, paginate, table, truncate } from "../cli/format.js";
11
11
  import { notFound, UsageError } from "../core/errors.js";
12
- import { likelyDuplicates } from "../core/people.js";
13
12
  import { collectFiles, fileSha256, inputPath, MAX_IN_TREE, mimeOf, storeShared } from "../core/media.js";
14
13
  import { create, normId, requireRecord, update } from "../core/records.js";
15
14
  import { phrase } from "../core/phrases.js";
16
- import { importGedcom, importStromJson, isStromJson } from "../core/import.js";
15
+ import { addMarriedNames, importedTree, importGedcom, importStromJson, isStromJson, marriedNamesPlan, undoMarriedNames } from "../core/import.js";
16
+ import { gedcomName, likelyDuplicates } from "../core/people.js";
17
17
  import { decodeText, safeFolderName } from "../core/text.js";
18
18
  import { amendInput, checkOriginalMeta } from "../core/originals.js";
19
19
  import { batchStatus, closeBatch, listBatches, readBatch } from "../core/batches.js";
@@ -153,6 +153,13 @@ register({
153
153
  skipped++;
154
154
  if (forTask)
155
155
  handed.push(known.get(sha));
156
+ // a family tree imported before strom read its married names (_MARNM): said how they come in
157
+ const had = tree.get(known.get(sha));
158
+ if (!quiet && had && !importedTree(had)) {
159
+ const missing = marriedNamesPlan(tree, had, decodeText(fs.readFileSync(file))).add.length;
160
+ if (missing)
161
+ report.push(` ${had.id}: ${missing} married name(s) it gives were left out when it was imported → strom input names ${had.id}`);
162
+ }
156
163
  continue;
157
164
  }
158
165
  const size = fs.statSync(file).size;
@@ -315,6 +322,73 @@ register({
315
322
  const got = tree.withTreeLock(() => amendInput(tree, id, { persons, note: typeof opts.note === "string" ? opts.note : undefined }));
316
323
  return { text: tree.written.length ? lines(...tree.written.map((o) => o.summary)) : `${id}: nothing new`, data: got };
317
324
  },
325
+ }, {
326
+ path: ["input", "names"],
327
+ summary: "The married names (_MARNM) of a family tree imported before strom read them: shown, --apply adds them",
328
+ group: "inputs",
329
+ tree: true,
330
+ writes: true,
331
+ description: "strom before 1.16 left out the married names other programs write as _MARNM (MyHeritage, Legacy, PAF). This reads\n" +
332
+ "the file the research was started from again — the input it keeps — and finds the people imported from it by the\n" +
333
+ "IDs the import kept (nobody else). Only names of the married kind are added, beside the name each person is shown\n" +
334
+ "by, citing the import's source; a person who has the name already stays as they are; people of the file not in the\n" +
335
+ "research are only listed. Without --apply only shown; --apply is one commit; --undo takes back what it added.\n" +
336
+ "Another export of the tree (another file) is not paired: give the input the research has (strom input list).",
337
+ args: [{ name: "input", description: "the imported family tree: its input (I…), or the same file", required: true }],
338
+ options: [
339
+ { name: "apply", type: "boolean", description: "add them (without: only show)" },
340
+ { name: "undo", type: "boolean", description: "take back the married names it added from this input" },
341
+ ],
342
+ examples: ["strom input names I0004", "strom input names ~/Downloads/rodokmen.ged --apply", "strom input names I0004 --undo"],
343
+ run(ctx, { args, opts }) {
344
+ const tree = ctx.tree();
345
+ const arg = args[0];
346
+ const inputs = tree.list("input");
347
+ let input = /^I?\d+$/i.test(arg.trim()) ? inputs.find((i) => i.id === normId(arg, "input")) : undefined;
348
+ if (!input) {
349
+ const p = ctx.resolvePath(arg);
350
+ if (!fs.existsSync(p) || !fs.statSync(p).isFile()) {
351
+ // an input's ID this research does not have (a file of that name neither)
352
+ if (/^I?\d+$/i.test(arg.trim()))
353
+ throw new UsageError(`no input ${normId(arg, "input")} in this research`, { hint: "strom input list", code: "input.names.missing", params: { input: normId(arg, "input") } });
354
+ throw notFound(arg);
355
+ }
356
+ const sha = fileSha256(p);
357
+ input = inputs.find((i) => i.sha === sha);
358
+ if (!input)
359
+ throw new UsageError(`${ctx.display(p)} is not a family tree this research was started from (another file, another export)`, {
360
+ hint: "the research keeps the file it imported: strom input list — then strom input names I…",
361
+ code: "input.names.other-file",
362
+ });
363
+ }
364
+ const why = importedTree(input);
365
+ if (why)
366
+ throw new UsageError(why, { hint: "strom input list — a family tree imported from GEDCOM (kind tree)", code: "input.names.not-imported", params: { input: input.id } });
367
+ const name = (id) => {
368
+ const p = tree.get(id);
369
+ return p?.names[0] ? gedcomName(p.names[0]).replace(/\//g, "").replace(/\s+/g, " ").trim() : id;
370
+ };
371
+ if (opts.undo) {
372
+ const gone = undoMarriedNames(tree, input);
373
+ return {
374
+ text: gone.length ? lines(...gone.map((g) => ` ${g.person} ${name(g.person)}: − ${gedcomName(g.name)}`), `${gone.length} married name(s) from ${input.id} taken back`) : `${input.id}: no married name of it to take back`,
375
+ data: { input: input.id, undone: gone.map((g) => ({ person: g.person, name: gedcomName(g.name) })) },
376
+ };
377
+ }
378
+ const file = inputPath(tree, input);
379
+ if (!file || !fs.existsSync(file))
380
+ throw new UsageError(`the file of ${input.id} is not here (${input.file ?? "none"})`, { hint: `strom input show ${input.id}`, code: "input.names.no-file", params: { input: input.id } });
381
+ const plan = marriedNamesPlan(tree, input, decodeText(fs.readFileSync(file)));
382
+ if (opts.apply && plan.add.length)
383
+ addMarriedNames(tree, plan);
384
+ const done = !!opts.apply && !tree.dryRun;
385
+ const text = lines(`${input.id} ${input.name}: ${plan.add.length} married name(s) ${done ? "added" : "to add"}, ${plan.have.length} already there, ${plan.unpaired.length} person(s) of the file not in the research`, ...plan.add.map((a) => ` ${a.person} ${name(a.person)}: + ${gedcomName(a.name)} (married)`), plan.unpaired.length ? "not in the research (nothing added):" : undefined, ...plan.unpaired.map((u) => ` ${u.xref} ${u.name.replace(/\//g, "").replace(/\s+/g, " ").trim()}: ${u.married.map(gedcomName).join(", ")}`), plan.add.length && !opts.apply ? `\nadd them: strom input names ${input.id} --apply` : undefined, done && plan.add.length ? `take them back: strom input names ${input.id} --undo` : undefined);
386
+ const row = (a) => ({ person: a.person, xref: a.xref, name: gedcomName(a.name) });
387
+ return {
388
+ text,
389
+ data: { input: input.id, source: plan.source, applied: done, add: plan.add.map(row), have: plan.have.map(row), unpaired: plan.unpaired.map((u) => ({ xref: u.xref, name: u.name, married: u.married.map(gedcomName) })) },
390
+ };
391
+ },
318
392
  }, {
319
393
  path: ["input", "batch"],
320
394
  summary: "Batches of material the Strom app sent: how many files, how far they are sorted",
@@ -113,7 +113,7 @@ function orientation(ctx) {
113
113
  // the ways as the menu and the bridge offer them (a site only a browser goes to, a bot check: never slowly)
114
114
  const connectors = listConnectors(ctx.settings.shared()?.value, ctx.env);
115
115
  const claude = claudeHere(ctx, tree.config);
116
- base.permissions = asked.map((q) => ({ host: q.host, title: q.title ?? q.host, ...(q.connector ? { connector: q.connector } : {}), since: q.at, tries: q.tries ?? 1, tasks: tasksStanding(tree, q), command: `strom allow archive ${q.host} --way ${waysOffered(connectorFor(connectors, q.host, q.connector), claude, q.way, !!q.browserOnly).join("|")}` }));
116
+ base.permissions = asked.map((q) => ({ host: q.host, ...(q.archiveHosts ? { hosts: q.archiveHosts } : {}), title: q.title ?? q.host, ...(q.connector ? { connector: q.connector } : {}), since: q.at, tries: q.tries ?? 1, tasks: tasksStanding(tree, q), command: `strom allow archive ${q.host} --way ${waysOffered(connectorFor(connectors, q.host, q.connector), claude, q.way, !!q.browserOnly).join("|")}` }));
117
117
  }
118
118
  const stories = ctx.settings.stories(tree.config);
119
119
  base.stories = !stories.on ? "off" : stories.said || told.stories ? "on" : "tell";
@@ -7,7 +7,7 @@ import { actsAsArchive } from "./state.js";
7
7
  import { waitsOnHost } from "./archivewait.js";
8
8
  import { waitsForDecision } from "./queue.js";
9
9
  export { savedInInbox } from "./archivewait.js";
10
- import { claimOf, pendingQuestions, termsFor } from "./terms.js";
10
+ import { claimOf, pendingQuestions, questionHosts, termsFor } from "./terms.js";
11
11
  import { bareHost, listConnectors, routesOf } from "./connector.js";
12
12
  import { ui } from "../cli/ui.js";
13
13
  import { humanDay } from "../cli/human.js";
@@ -52,7 +52,7 @@ export function tasksWaitingOn(tree, host) {
52
52
  * those the queue holds back for it, and those a fetch needed it for (still to be done) — their IDs.
53
53
  */
54
54
  export function tasksStanding(tree, q) {
55
- const ids = new Set(tasksWaitingOn(tree, q.host).map((t) => t.id));
55
+ const ids = new Set(questionHosts(q).flatMap((h) => tasksWaitingOn(tree, h).map((t) => t.id)));
56
56
  const held = waitsForDecision(tree, [q]);
57
57
  for (const t of tree.list("task"))
58
58
  if (t.state === "open" && held(t))
@@ -169,12 +169,13 @@ export function archiveWaitingItems(tree, shared, claude) {
169
169
  {
170
170
  kind: "permission",
171
171
  id: q.key,
172
- what: ui(lang, "ui.archive.item", { title, host: q.host }),
172
+ what: ui(lang, "ui.archive.item", { title, host: questionHosts(q).join(", ") }),
173
173
  on: termsText(lang, tree.env, q),
174
174
  at: q.at,
175
175
  since: q.at,
176
176
  tries: q.tries ?? 1,
177
177
  host: q.host,
178
+ ...(q.archiveHosts ? { hosts: q.archiveHosts } : {}),
178
179
  title,
179
180
  ...(q.terms ? { terms: q.terms } : {}),
180
181
  ...(q.quote ? { quote: q.quote } : {}),
@@ -11,7 +11,8 @@ import { foldText } from "./text.js";
11
11
  import { roleWord } from "./roles.js";
12
12
  import { validateRecord } from "./validate.js";
13
13
  import { normalizeDate } from "./gdate.js";
14
- import { appNoSurname, cleanTitle, noName, notAName, parseName, sameName, surnameAfterGiven, withoutTitles } from "./people.js";
14
+ import { appNoSurname, cleanTitle, gedcomName, noName, notAName, parseName, sameName, surnameAfterGiven, withoutTitles } from "./people.js";
15
+ import { update } from "./records.js";
15
16
  import { now } from "./tree.js";
16
17
  import { appUnknownIsNoSurname, appWritesSexUnknown } from "./stromapp.js";
17
18
  import { children, parseGedcomText, val } from "../gedcom/parse.js";
@@ -197,6 +198,11 @@ export function marnmNames(r, givenOf) {
197
198
  add(m.value, names[0] ? givenOf(names[0]) : "");
198
199
  return out;
199
200
  }
201
+ /** The married names (_MARNM) of a person of a file as strom intake takes them: with the given name of their NAME (none for what stands in place of a name). */
202
+ function importedMarnm(r, file) {
203
+ const given = (n) => (notAName(readGedName(n, file).name.given) ? "" : readGedName(n, file).name.given.replace(/\//g, "|"));
204
+ return marnmNames(r, given);
205
+ }
200
206
  export function importGedcom(tree, text, opts) {
201
207
  const { records, problems } = parseGedcomText(text);
202
208
  /** A file of the Strom app (its HEAD: 1 SOUR STROM): its own ways of writing are read as it means them. */
@@ -370,8 +376,7 @@ export function importGedcom(tree, text, opts) {
370
376
  })
371
377
  .map((n) => (n.given || n.surname ? n : { ...n, given: "?", surname: "" }));
372
378
  // 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)))];
379
+ return [...names, ...importedMarnm(r, fromApp).filter((m) => !names.some((n) => sameName(n, m)))];
375
380
  };
376
381
  /** 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. */
377
382
  const factNotes = (r, personEvents) => {
@@ -532,6 +537,100 @@ export function importGedcom(tree, text, opts) {
532
537
  * research's tree coming back"); a file that names no research (the standard file, another program) only where most
533
538
  * of the people its IDs name are alike by name — and then each by its name too. Merged since: the one it went into.
534
539
  */
540
+ // ── the married names an earlier import left out ─────────────────────────────
541
+ //
542
+ // strom before 1.16 read no _MARNM (MyHeritage, Legacy, PAF): a research started from such a file lacks the names
543
+ // after a marriage. Taken in again from the very file it was started from (its input), by the IDs the import kept
544
+ // (refs gedcom:<sha> + the file's xref) — nobody else, nothing but these names.
545
+ /** Where a married name taken in later cites the import: the person's xref in the file and the tag. */
546
+ const MARNM_LOCATOR = " _MARNM";
547
+ /** The input a family tree was imported from (a GEDCOM file kept by the research), else why not. */
548
+ export function importedTree(input) {
549
+ if (input.kind !== "tree" || !input.sha || !/\.ged$/i.test(input.name))
550
+ return `${input.id} is not a GEDCOM family tree`;
551
+ if (!input.source || !input.imported)
552
+ return `${input.id} was not imported (strom intake --no-import)`;
553
+ return undefined;
554
+ }
555
+ /** What the input's file gives as married names (_MARNM) that the people imported from it lack. */
556
+ export function marriedNamesPlan(tree, input, text) {
557
+ const { records } = parseGedcomText(text);
558
+ const file = stromAppFile(records.find((r) => r.tag === "HEAD"));
559
+ const system = `gedcom:${input.sha.slice(0, 12)}`;
560
+ // the people the import made of the file (or added to): by the xref it kept; one merged since is the one it went into
561
+ const byXref = new Map();
562
+ for (const p of tree.list("person"))
563
+ for (const r of p.refs ?? [])
564
+ if (r.system === system)
565
+ byXref.set(r.id, p);
566
+ const live = (p) => {
567
+ for (let i = 0; p && i < 20; i++) {
568
+ if (!p.mergedInto)
569
+ return p.retracted ? undefined : p;
570
+ p = tree.get(p.mergedInto);
571
+ }
572
+ return undefined;
573
+ };
574
+ const plan = { input: input.id, source: input.source, add: [], have: [], unpaired: [] };
575
+ for (const r of records.filter((x) => x.tag === "INDI" && x.xref)) {
576
+ // NFC as every record (a file may write a name decomposed)
577
+ const married = importedMarnm(r, file).map((n) => ({ ...n, given: n.given.normalize("NFC"), surname: n.surname.normalize("NFC") }));
578
+ if (!married.length)
579
+ continue;
580
+ const p = live(byXref.get(r.xref));
581
+ if (!p) {
582
+ plan.unpaired.push({ xref: r.xref, name: (children(r, "NAME")[0]?.value ?? "?").replace(/\s+/g, " ").trim(), married });
583
+ continue;
584
+ }
585
+ for (const name of married) {
586
+ const had = p.names.some((n) => sameName(n, name)) || plan.add.some((a) => a.person === p.id && sameName(a.name, name));
587
+ (had ? plan.have : plan.add).push({ person: p.id, xref: r.xref, name });
588
+ }
589
+ }
590
+ return plan;
591
+ }
592
+ /** Add the names of a plan: each of the married kind, last, citing the import (the person's xref and _MARNM). */
593
+ export function addMarriedNames(tree, plan) {
594
+ tree.withTreeLock(() => {
595
+ for (const a of plan.add) {
596
+ const name = { ...a.name, citations: [{ source: plan.source, locator: `${a.xref.replace(/@/g, "")}${MARNM_LOCATOR}` }] };
597
+ update(tree, a.person, "person", (p) => (p.names.some((n) => sameName(n, name)) ? p : { ...p, names: [...p.names, name] }), {
598
+ op: "person.name",
599
+ summary: `${a.person} name ${gedcomName(name)} (married) ← ${plan.source}`,
600
+ reason: `the married name ${plan.input} gives (_MARNM), left out when it was imported`,
601
+ });
602
+ }
603
+ });
604
+ }
605
+ /** Take back what addMarriedNames added from this input: the names citing its import there (another citation kept: only this one goes). */
606
+ export function undoMarriedNames(tree, inputRec) {
607
+ const source = inputRec.source;
608
+ const ours = (c) => c.source === source && !!c.locator?.endsWith(MARNM_LOCATOR);
609
+ const gone = [];
610
+ if (!source)
611
+ return gone;
612
+ tree.withTreeLock(() => {
613
+ for (const p of tree.list("person")) {
614
+ const at = p.names.flatMap((n, i) => (i > 0 && n.kind === "married" && n.citations?.some(ours) ? [i] : []));
615
+ if (!at.length)
616
+ continue;
617
+ const hit = at.map((i) => p.names[i]);
618
+ update(tree, p.id, "person", (x) => ({
619
+ ...x,
620
+ names: x.names.flatMap((n, i) => {
621
+ if (!at.includes(i))
622
+ return [n];
623
+ // cited by something else too: only this citation goes
624
+ const rest = (n.citations ?? []).filter((c) => !ours(c));
625
+ return rest.length ? [{ ...n, citations: rest }] : [];
626
+ }),
627
+ }), { op: "name.remove", summary: `${p.id} name ${hit.map(gedcomName).join(", ")} taken back`, reason: `the married names taken in from ${inputRec.id} taken back` });
628
+ for (const n of hit)
629
+ gone.push({ person: p.id, name: n });
630
+ }
631
+ });
632
+ return gone;
633
+ }
535
634
  function oursByRefn(tree, treeId, people) {
536
635
  if (treeId && treeId !== tree.config.id)
537
636
  return people.map(() => undefined);
@@ -186,12 +186,75 @@ export function updateArchives(env, change) {
186
186
  * know (a list grown since covers more) — none left, no question.
187
187
  */
188
188
  export function pendingQuestions(env) {
189
- return loadArchives(env).pending.flatMap((q) => {
189
+ return oneArchive(loadArchives(env).pending.flatMap((q) => {
190
190
  if (q.kind !== "takeover" || !q.hosts)
191
191
  return [q];
192
192
  const left = (q.connectors ?? []).filter((n) => !q.hosts?.[n] || !coveredByTerms(env, q.hosts[n]));
193
193
  return left.length ? [{ ...q, connectors: left }] : [];
194
- });
194
+ }));
195
+ }
196
+ /** The hosts a question is for: those of its archive, else its own. */
197
+ export function questionHosts(q) {
198
+ return q.archiveHosts?.length ? q.archiveHosts : q.host ? [q.host] : [];
199
+ }
200
+ /**
201
+ * The questions about the images of one archive — its hosts (the site's pages, its image servers) asked one by one,
202
+ * the same connector, site and terms — read as one: since the first time asked, the tasks of them all (each once), the
203
+ * ways every host may go. Each host keeps its own question in the file (an older strom reads them as before).
204
+ */
205
+ function oneArchive(qs) {
206
+ const groups = new Map();
207
+ const out = [];
208
+ for (const q of qs) {
209
+ if (q.kind !== "permission" || !q.host) {
210
+ out.push(q);
211
+ continue;
212
+ }
213
+ const key = JSON.stringify([q.connector ?? "", webDomain(q.host), q.terms ?? ""]);
214
+ const g = groups.get(key);
215
+ if (g)
216
+ g.push(q);
217
+ else {
218
+ const fresh = [q];
219
+ groups.set(key, fresh);
220
+ out.push(fresh);
221
+ }
222
+ }
223
+ return out.map((x) => (Array.isArray(x) ? (x.length === 1 ? x[0] : merged(x)) : x));
224
+ }
225
+ function merged(qs) {
226
+ const all = [...qs].sort((a, b) => a.at.localeCompare(b.at));
227
+ const first = all[0];
228
+ const latest = (f) => all.map(f).filter((x) => !!x).sort().at(-1);
229
+ const tasks = [];
230
+ for (const q of all)
231
+ for (const t of q.tasks ?? (q.tree && q.task ? [{ tree: q.tree, task: q.task }] : []))
232
+ if (!tasks.some((x) => x.tree === t.tree && x.task === t.task))
233
+ tasks.push(t);
234
+ const lastAsked = [...all].sort((a, b) => (a.last ?? a.at).localeCompare(b.last ?? b.at)).at(-1);
235
+ const browserOnly = all.some((q) => q.browserOnly);
236
+ const choices = all.every((q) => q.choices) ? first.choices.filter((w) => all.every((q) => q.choices.includes(w)) && !(browserOnly && w === "slow")) : undefined;
237
+ const title = all.find((q) => q.title)?.title;
238
+ const level = all.some((q) => q.level) ? strictest(...all.map((q) => q.level)) : undefined;
239
+ const way = browserOnly ? "browser" : first.way;
240
+ const window = latest((q) => q.window);
241
+ const last = latest((q) => q.last);
242
+ return {
243
+ ...first,
244
+ archiveHosts: [...new Set(all.map((q) => q.host))],
245
+ ...(title ? { title } : {}),
246
+ ...(level ? { level } : {}),
247
+ ...(way ? { way } : {}),
248
+ ...(choices ? { choices: choices.includes("manual") ? choices : [...choices, "manual"] } : {}),
249
+ ...(browserOnly ? { browserOnly } : {}),
250
+ ...(window ? { window } : {}),
251
+ ...(lastAsked.tree ? { tree: lastAsked.tree } : {}),
252
+ ...(lastAsked.task ? { task: lastAsked.task } : {}),
253
+ ...(tasks.length ? { tasks } : {}),
254
+ // one command asks all the archive's hosts at once: the most any host was needed
255
+ tries: Math.max(...all.map((q) => q.tries ?? 1)),
256
+ ...(last && last > first.at ? { last } : {}),
257
+ };
195
258
  }
196
259
  export function dropPending(s, key) {
197
260
  s.pending = s.pending.filter((p) => p.key !== key);
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.16.0-beta.2";
30
+ export const VERSION = "1.16.0-beta.4";
31
31
  const GITIGNORE = `# Strom Research — local state and large files are not versioned
32
32
  .strom/
33
33
  /media/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strom-research",
3
- "version": "1.16.0-beta.2",
3
+ "version": "1.16.0-beta.4",
4
4
  "description": "Genealogical research toolkit for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {