strom-research 1.16.0-beta.3 → 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
@@ -22,6 +22,8 @@ Změny každé verze ve zkratce. Úplné poznámky a instalační řádky jsou n
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
23
  Výzkum založený dřív z takového rodokmenu (MyHeritage, Legacy, PAF) je doplní ze souboru, který má uložený:
24
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.
25
27
  - **Linie výzkumu.** České texty všude říkají „linie výzkumu“, nikde už „směry“.
26
28
 
27
29
  ## 1.15.0
package/CHANGELOG.de.md CHANGED
@@ -25,6 +25,8 @@ Die Änderungen jeder Version in Kürze. Die vollständigen Hinweise und die Ins
25
25
  Stammbaum begonnen wurde (MyHeritage, Legacy, PAF), bekommt sie aus der Datei, die sie aufbewahrt:
26
26
  `strom input names I…` zeigt sie, `--apply` fügt nur diese Namen hinzu, mit dem Import als Quelle, `--undo` nimmt
27
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.
28
30
  - **Forschungslinien auf Tschechisch.** Die tschechischen Texte sagen jetzt überall „linie výzkumu“ (Forschungslinien).
29
31
 
30
32
  ## 1.15.0
package/CHANGELOG.md CHANGED
@@ -22,6 +22,8 @@ The changes of each version in short. The full notes and the install lines are o
22
22
  in the app gave it; one a record gives stays. `strom intake` reads `_MARNM` of other programs as a married name.
23
23
  A research started earlier from such a family tree (MyHeritage, Legacy, PAF) gets them from the file it keeps:
24
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.
25
27
  - **Lines of research in Czech.** The Czech texts say „linie výzkumu“ (lines of research) everywhere now.
26
28
 
27
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" };
@@ -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 } : {}),
@@ -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.3";
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.3",
3
+ "version": "1.16.0-beta.4",
4
4
  "description": "Genealogical research toolkit for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {