strom-research 1.6.0 → 1.7.0

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/README.md CHANGED
@@ -99,6 +99,12 @@ npm: `npm install -g strom-research@latest`.
99
99
  (`strom stats`), what is new (`strom recent`), what the agent does next
100
100
  (`strom plan`), one person's life with its
101
101
  records (`strom person card`), the ancestors as a tree (`strom pedigree`)
102
+ - Hand the research to a relative in one small ZIP file (`strom pack`): the
103
+ family tree with its history, the images its records stand on and the
104
+ connectors — the pages only searched through the archive gives again. They
105
+ double-click the launcher inside (Windows, macOS, Linux): it installs strom
106
+ where it is missing, puts the research in place and takes it over
107
+ (`strom unpack`)
102
108
 
103
109
  **Safe by design**
104
110
  - What the agent may do alone is your choice: ask, auto (default) or full
@@ -62,8 +62,8 @@
62
62
  "ui.menu.waitlist": "Co čeká na vás",
63
63
  "ui.menu.settings": "Nastavení: agent, samostatná práce, zprávy, archivy, kontrola",
64
64
  "ui.menu.doctor": "Kontrola instalace",
65
- "ui.menu.trees": "Jiný rodokmen",
66
- "ui.menu.newtree": "Založit rodokmen",
65
+ "ui.menu.trees": "Jiný rodokmen · poslat nebo přijmout",
66
+ "ui.menu.newtree": "Založit rodokmen · poslat nebo přijmout",
67
67
  "ui.menu.quit": "Konec",
68
68
  "ui.menu.bye": "Na shledanou. Příště stačí spustit: strom",
69
69
  "ui.run.how": "Kolik úkolů? (skončí dřív, když už nebude co dělat; 0 vrátí zpět)",
@@ -488,10 +488,13 @@
488
488
  "ui.sync.child.new": "{n}. {who}: dítě — {child}",
489
489
  "ui.sync.child.gone": "{n}. {who}: už to není jejich dítě — {child}",
490
490
  "ui.sync.person.gone": "{n}. už v něm není: {name}",
491
+ "ui.sync.place.coords": "{n}. {place}: poloha na mapě {from} → {at}",
492
+ "ui.sync.place.located": "{n}. {place}: poloha na mapě {at}",
491
493
  "ui.sync.do.add": "přidá se jako vodítko",
492
494
  "ui.sync.do.correct": "opraví vodítko výzkumu",
493
495
  "ui.sync.do.conflict": "rozpor k rozhodnutí (výzkum to má ze záznamu)",
494
496
  "ui.sync.do.user": "platí vaše úprava (údaj ze záznamu se stáhne s důvodem)",
497
+ "ui.sync.do.set": "výzkum převezme vaši polohu (žádný zápis ji nedokládá)",
495
498
  "ui.sync.do.pick": "jen když ho vyberete (--only)",
496
499
  "ui.sync.do.report": "nic se nemění — řekněte agentovi, co jste tím mysleli",
497
500
  "ui.sync.edits": "Vaše úpravy údajů doložených záznamem: {mode} (strom config set sync.edits conflict|user)",
@@ -805,5 +808,36 @@
805
808
  "ui.menu.failed": "Tohle se nepovedlo: {reason}",
806
809
  "ui.review.next.human": "Agent se do nich pustí v rozhovoru nebo při samostatné práci.",
807
810
  "ui.review.wake.human": " pokud v něm chcete pokračovat, řekněte to agentovi v rozhovoru",
808
- "ui.waiting.gone": "Tohle už na vás nečeká – agent se toho mezitím ujal."
811
+ "ui.waiting.gone": "Tohle už na vás nečeká – agent se toho mezitím ujal.",
812
+ "ui.trees.pack": "zabalit „{name}“ a poslat někomu (soubor ZIP)",
813
+ "ui.trees.unpack": "rodokmen, který vám někdo poslal (soubor ZIP)",
814
+ "ui.pack.which": "Které snímky přibalit?",
815
+ "ui.pack.opt.records": "ty, na kterých jsou zápisy – {mb} MB (prohledané stránky dá archiv znovu)",
816
+ "ui.pack.opt.all": "všechny – {mb} MB",
817
+ "ui.pack.done": "Zabaleno „{name}“: {file} ({mb} MB)",
818
+ "ui.pack.saved": " rodokmen, jak byl naposledy uložený, i s historií",
819
+ "ui.pack.images": " snímky: {n} ({mb} MB) – ty, na kterých jsou zápisy, a ty, které nikdo znovu nestáhne",
820
+ "ui.pack.images.all": " snímky: všech {n} ({mb} MB)",
821
+ "ui.pack.left": " vynechané snímky: {n} ({mb} MB) – prohledané stránky; až budou potřeba, strom je z archivu stáhne znovu",
822
+ "ui.pack.connectors": " konektory: {list}",
823
+ "ui.pack.noconnector": "⚠ některé vynechané snímky přišly konektorem, který na tomto počítači není – nikdo by je znovu nestáhl: strom pack --all-images",
824
+ "ui.pack.missing": "⚠ snímky, které rodokmen uvádí, ale na tomto počítači nejsou: {n} – v balíčku chybí",
825
+ "ui.pack.send": "Soubor pošlete. Příjemce ho rozbalí a dvojklikem spustí Windows.cmd nebo macOS.command uvnitř: strom se nainstaluje, pokud chybí, a výzkum se otevře (kdo už strom má: strom unpack „<soubor>“).",
826
+ "ui.pack.readme.file": "JAK-NAVAZAT.txt",
827
+ "ui.pack.readme": "Výzkum „{name}“ – jak na něj navázat\n====================================\n\n1. Rozbalte celý soubor ZIP: ve Windows pravým tlačítkem – Extrahovat vše; na Macu dvojklikem.\n\n2. V rozbalené složce spusťte dvojklikem:\n Windows: Windows.cmd\n macOS: macOS.command (poprvé pravým tlačítkem – Otevřít; když to macOS nedovolí:\n Nastavení systému – Soukromí a zabezpečení – Přesto otevřít)\n Linux: v terminálu: bash Linux.sh\n Nainstaluje Strom Research, pokud ještě není (oficiálním instalátorem), dá výzkum\n na místo – rodokmen mezi vaše, snímky do vaší sdílené složky, všechno zkontroluje –,\n převezme ho na váš počítač a otevře strom.\n\n3. Pokračujte: v nabídce stromu rozhovor s agentem.\n\nBez spouštěčů: nainstalujte strom sami\n Windows (PowerShell): {win}\n macOS, Linux (Terminál): {unix}\npak: strom unpack \"<tento soubor ZIP>\" (napište strom unpack a mezeru, přetáhněte soubor\ndo terminálu, Enter) – nebo v nabídce stromu: jiný rodokmen – rodokmen, který vám někdo poslal.\n\nSnímky, na kterých jsou zápisy, v balíčku jsou. Ostatní stránky knih (prohledané,\nnic se na nich nenašlo) kvůli velikosti ne – až budou potřeba, strom je stáhne\nz archivu znovu.\n",
828
+ "ui.unpack.file": "Přetáhněte sem soubor ZIP (nebo napište, kde je; 0 vrátí zpět):",
829
+ "ui.unpack.what": "„{name}“ (ze stromu {strom}, uloženo {date}) · snímky: {images}{connectors} → {root}",
830
+ "ui.unpack.ask": "Dát ho sem a převzít na tento počítač?",
831
+ "ui.unpack.done": "„{name}“ je tady: {root}",
832
+ "ui.unpack.images": " snímky do sdílené složky: {n}{had}",
833
+ "ui.unpack.had": " (už tu byly: {n})",
834
+ "ui.unpack.connectors": " konektory: {list}",
835
+ "ui.unpack.kept": " konektor {name} tu už byl – zůstává váš",
836
+ "ui.unpack.left": " prohledané stránky, které v něm nejsou: {n} – až budou potřeba, strom je stáhne z archivu",
837
+ "ui.unpack.adopted": " převzato na tento počítač",
838
+ "ui.unpack.next": "Pokračujte: strom (nabídka) nebo strom chat.",
839
+ "ui.unpack.same": "tento výzkum už tu je: {root}",
840
+ "ui.unpack.person": "výzkum rozbaluje jen člověk u počítače (přidává konektory)",
841
+ "ui.pack.launch.extract": "Nejdřív rozbalte celý soubor ZIP – ve Windows pravým tlačítkem: Extrahovat vše – a spusťte tohle znovu z rozbalené složky.",
842
+ "ui.pack.launch.failed": "Strom Research se nepodařilo nainstalovat. Nainstalujte ho řádkem z JAK-NAVAZAT.txt a spusťte tohle znovu."
809
843
  }
@@ -62,8 +62,8 @@
62
62
  "ui.menu.waitlist": "Was auf Sie wartet",
63
63
  "ui.menu.settings": "Einstellungen: Agent, allein arbeiten, Nachrichten, Archive, Prüfung",
64
64
  "ui.menu.doctor": "Installation prüfen",
65
- "ui.menu.trees": "Anderer Stammbaum",
66
- "ui.menu.newtree": "Stammbaum anlegen",
65
+ "ui.menu.trees": "Anderer Stammbaum · einen schicken oder empfangen",
66
+ "ui.menu.newtree": "Stammbaum anlegen · einen schicken oder empfangen",
67
67
  "ui.menu.quit": "Beenden",
68
68
  "ui.menu.bye": "Auf Wiedersehen. Nächstes Mal genügt: strom",
69
69
  "ui.run.how": "Wie viele Aufgaben? (er hört früher auf, wenn es nichts mehr zu tun gibt; 0 geht zurück)",
@@ -488,10 +488,13 @@
488
488
  "ui.sync.child.new": "{n}. {who}: ein Kind — {child}",
489
489
  "ui.sync.child.gone": "{n}. {who}: nicht mehr ihr Kind — {child}",
490
490
  "ui.sync.person.gone": "{n}. nicht mehr darin: {name}",
491
+ "ui.sync.place.coords": "{n}. {place}: Lage auf der Karte {from} → {at}",
492
+ "ui.sync.place.located": "{n}. {place}: Lage auf der Karte {at}",
491
493
  "ui.sync.do.add": "als Hinweis übernommen",
492
494
  "ui.sync.do.correct": "berichtigt den Hinweis der Forschung",
493
495
  "ui.sync.do.conflict": "ein Widerspruch zum Entscheiden (die Forschung hat es aus einem Eintrag)",
494
496
  "ui.sync.do.user": "Ihre Änderung gilt (die Angabe aus dem Eintrag wird mit Grund zurückgezogen)",
497
+ "ui.sync.do.set": "die Forschung übernimmt Ihre Lage (kein Eintrag belegt sie)",
495
498
  "ui.sync.do.pick": "nur wenn Sie sie auswählen (--only)",
496
499
  "ui.sync.do.report": "nichts ändert sich — sagen Sie dem Agenten, was Sie meinten",
497
500
  "ui.sync.edits": "Ihre Änderungen an Angaben, die ein Eintrag belegt: {mode} (strom config set sync.edits conflict|user)",
@@ -805,5 +808,36 @@
805
808
  "ui.menu.failed": "Das ging nicht: {reason}",
806
809
  "ui.review.next.human": "Der Agent nimmt sie im Gespräch oder bei der Arbeit allein auf.",
807
810
  "ui.review.wake.human": " wenn es weitergehen soll, sagen Sie es dem Agenten im Gespräch",
808
- "ui.waiting.gone": "Das wartet nicht mehr auf Sie – der Agent hat es inzwischen übernommen."
811
+ "ui.waiting.gone": "Das wartet nicht mehr auf Sie – der Agent hat es inzwischen übernommen.",
812
+ "ui.trees.pack": "„{name}“ einpacken und jemandem schicken (eine ZIP-Datei)",
813
+ "ui.trees.unpack": "ein Stammbaum, den Ihnen jemand geschickt hat (eine ZIP-Datei)",
814
+ "ui.pack.which": "Welche Bilder kommen mit?",
815
+ "ui.pack.opt.records": "die, auf denen die Einträge stehen – {mb} MB (die durchgesehenen Seiten gibt das Archiv wieder)",
816
+ "ui.pack.opt.all": "alle – {mb} MB",
817
+ "ui.pack.done": "„{name}“ eingepackt: {file} ({mb} MB)",
818
+ "ui.pack.saved": " der Stammbaum, wie er zuletzt gespeichert wurde, mit seiner Geschichte",
819
+ "ui.pack.images": " Bilder: {n} ({mb} MB) – die, auf denen die Einträge stehen, und die, die niemand wieder laden kann",
820
+ "ui.pack.images.all": " Bilder: alle {n} ({mb} MB)",
821
+ "ui.pack.left": " weggelassen: {n} Bilder ({mb} MB) – durchgesehene Seiten; strom lädt sie aus dem Archiv wieder, wenn sie gebraucht werden",
822
+ "ui.pack.connectors": " Konnektoren: {list}",
823
+ "ui.pack.noconnector": "⚠ einige weggelassene Bilder kamen über einen Konnektor, der auf diesem Computer fehlt – niemand könnte sie wieder laden: strom pack --all-images",
824
+ "ui.pack.missing": "⚠ {n} Bild(er), die der Stammbaum nennt, sind nicht auf diesem Computer – nicht im Paket",
825
+ "ui.pack.send": "Schicken Sie die Datei. Wer sie bekommt, packt sie aus und startet darin Windows.cmd oder macOS.command per Doppelklick: strom wird installiert, wo es fehlt, und die Forschung öffnet sich (mit strom schon: strom unpack „<die Datei>“).",
826
+ "ui.pack.readme.file": "SO-GEHT-ES-WEITER.txt",
827
+ "ui.pack.readme": "Die Forschung „{name}“ – so geht es weiter\n=========================================\n\n1. Packen Sie die ganze ZIP-Datei aus: unter Windows Rechtsklick – Alle extrahieren; auf dem Mac Doppelklick.\n\n2. Starten Sie im ausgepackten Ordner mit Doppelklick:\n Windows: Windows.cmd\n macOS: macOS.command (beim ersten Mal Rechtsklick – Öffnen; wenn macOS es ablehnt:\n Systemeinstellungen – Datenschutz & Sicherheit – Dennoch öffnen)\n Linux: im Terminal: bash Linux.sh\n Es installiert Strom Research, wo es noch fehlt (der offizielle Installer), bringt die\n Forschung an ihren Platz – den Stammbaum zu Ihren, die Bilder in Ihren gemeinsamen\n Ordner, alles geprüft –, übernimmt sie auf Ihren Computer und öffnet strom.\n\n3. Weiter: im Menü von strom das Gespräch mit dem Agenten.\n\nOhne die Starter: installieren Sie strom selbst\n Windows (PowerShell): {win}\n macOS, Linux (Terminal): {unix}\ndann: strom unpack \"<diese ZIP-Datei>\" (strom unpack und ein Leerzeichen tippen, die Datei\nins Terminal ziehen, Enter) – oder im Menü von strom: ein anderer Stammbaum – ein Stammbaum, den Ihnen jemand geschickt hat.\n\nDie Bilder, auf denen die Einträge stehen, sind im Paket. Die anderen Seiten der Bücher\n(durchgesehen, nichts gefunden) wegen der Größe nicht – strom lädt sie aus dem Archiv\nwieder, wenn sie gebraucht werden.\n",
828
+ "ui.unpack.file": "Ziehen Sie die ZIP-Datei hierher (oder tippen Sie, wo sie liegt; 0 geht zurück):",
829
+ "ui.unpack.what": "„{name}“ (aus strom {strom}, gespeichert {date}): {images} Bild(er){connectors} → {root}",
830
+ "ui.unpack.ask": "Hierher legen und auf diesem Computer übernehmen?",
831
+ "ui.unpack.done": "„{name}“ ist da: {root}",
832
+ "ui.unpack.images": " {n} Bild(er) in den gemeinsamen Ordner{had}",
833
+ "ui.unpack.had": " ({n} waren schon da)",
834
+ "ui.unpack.connectors": " Konnektoren: {list}",
835
+ "ui.unpack.kept": " Konnektor {name} war schon da – Ihrer bleibt",
836
+ "ui.unpack.left": " {n} durchgesehene Seiten sind nicht darin: strom lädt sie aus dem Archiv, wenn sie gebraucht werden",
837
+ "ui.unpack.adopted": " auf diesem Computer übernommen",
838
+ "ui.unpack.next": "Weiter: strom (das Menü) oder strom chat.",
839
+ "ui.unpack.same": "diese Forschung ist schon da: {root}",
840
+ "ui.unpack.person": "eine Forschung packt nur die Person am Computer aus (sie fügt Konnektoren hinzu)",
841
+ "ui.pack.launch.extract": "Packen Sie zuerst die ganze ZIP-Datei aus – unter Windows mit Rechtsklick: Alle extrahieren – und starten Sie dies dann im ausgepackten Ordner neu.",
842
+ "ui.pack.launch.failed": "Strom Research ließ sich nicht installieren. Installieren Sie es mit der Zeile aus SO-GEHT-ES-WEITER.txt und starten Sie dies neu."
809
843
  }
package/dist/cli/menu.js CHANGED
@@ -25,7 +25,9 @@ import { liveWorkers, runsAtWork } from "../core/workers.js";
25
25
  import { askStromApp } from "./wizard.js";
26
26
  import { openForUser } from "../core/open.js";
27
27
  import { displayName } from "../core/people.js";
28
- import { agentReady, guarded, pause as partsPause, pickPerson, subMenu } from "./menu-parts.js";
28
+ import { agentReady, droppedPaths, guarded, pause as partsPause, pickPerson, subMenu } from "./menu-parts.js";
29
+ import { packPlan } from "../core/pack.js";
30
+ import { expandHome } from "../core/paths.js";
29
31
  import { offerAgent } from "./fixes.js";
30
32
  import { addToResearch } from "./menu-research.js";
31
33
  import { waitingForYou } from "./menu-waiting.js";
@@ -423,6 +425,9 @@ async function pickTree(ctx, run, lang) {
423
425
  }
424
426
  const at = known.findIndex((k) => k.root === here);
425
427
  const options = [...known.map((k) => ({ label: `${k.name} (${ctx.display(k.root)})` })), { label: ui(lang, "ui.trees.new") }];
428
+ // handing a research over: this one packed, one someone sent unpacked
429
+ const packAt = at >= 0 ? options.push({ label: ui(lang, "ui.trees.pack", { name: known[at].name }) }) - 1 : -1;
430
+ const unpackAt = options.push({ label: ui(lang, "ui.trees.unpack") }) - 1;
426
431
  const back = at >= 0 ? { back: ui(lang, "ui.back.stay", { name: known[at].name }) } : {};
427
432
  const i = await ctx.choose(ui(lang, "ui.trees.pick"), options, Math.max(0, at), back);
428
433
  if (i === undefined || i === at)
@@ -431,6 +436,28 @@ async function pickTree(ctx, run, lang) {
431
436
  await run(["trees", "use", known[i].root], true);
432
437
  return;
433
438
  }
439
+ if (i === packAt) {
440
+ const tree = Tree.open(known[at].root, ctx.env);
441
+ const shared = ctx.settings.shared().value;
442
+ const size = (all) => (packPlan(tree, shared, all).media.reduce((s, m) => s + m.size, 0) / 1e6).toFixed(0);
443
+ const which = await ctx.choose(ui(lang, "ui.pack.which"), [{ label: ui(lang, "ui.pack.opt.records", { mb: size(false) }) }, { label: ui(lang, "ui.pack.opt.all", { mb: size(true) }) }], 0, { back: ui(lang, "ui.browse.back") });
444
+ if (which === undefined)
445
+ return;
446
+ await run(["pack", "--tree", tree.root, ...(which === 1 ? ["--all-images"] : [])]);
447
+ await partsPause(ctx, lang);
448
+ return;
449
+ }
450
+ if (i === unpackAt) {
451
+ const answer = (await ctx.ask(ui(lang, "ui.unpack.file"))).trim();
452
+ if (!answer || answer === "0")
453
+ return;
454
+ const resolve = (p) => path.resolve(ctx.cwd, expandHome(p, ctx.env));
455
+ const dropped = droppedPaths(answer).map(resolve);
456
+ const file = dropped.length === 1 && fs.existsSync(dropped[0]) ? dropped[0] : resolve(answer);
457
+ await run(["unpack", file]);
458
+ await partsPause(ctx, lang);
459
+ return;
460
+ }
434
461
  const name = (await ctx.ask(ui(lang, "ui.tree.name.new"), ui(lang, "ui.tree.default"))).trim();
435
462
  if (name === "0")
436
463
  return;
package/dist/cli/ui.js CHANGED
@@ -41,8 +41,8 @@ export const UI = {
41
41
  "ui.menu.waitlist": "What waits for you",
42
42
  "ui.menu.settings": "Settings: the agent, working alone, messages, archives, the check",
43
43
  "ui.menu.doctor": "Check the installation",
44
- "ui.menu.trees": "Another family tree",
45
- "ui.menu.newtree": "Create a family tree",
44
+ "ui.menu.trees": "Another family tree · send or receive one",
45
+ "ui.menu.newtree": "Create a family tree · send or receive one",
46
46
  "ui.menu.quit": "Quit",
47
47
  "ui.menu.bye": "See you next time. Start again with: strom",
48
48
  "ui.run.how": "How many tasks? (it stops earlier when there is nothing to do; 0 goes back)",
@@ -420,10 +420,13 @@ export const UI = {
420
420
  "ui.sync.child.new": "{n}. {who}: a child — {child}",
421
421
  "ui.sync.child.gone": "{n}. {who}: no longer their child — {child}",
422
422
  "ui.sync.person.gone": "{n}. no longer in it: {name}",
423
+ "ui.sync.place.coords": "{n}. {place}: on the map {from} → {at}",
424
+ "ui.sync.place.located": "{n}. {place}: on the map at {at}",
423
425
  "ui.sync.do.add": "added as a lead",
424
426
  "ui.sync.do.correct": "corrects the research's lead",
425
427
  "ui.sync.do.conflict": "a conflict for you to decide (the research has it from a record)",
426
428
  "ui.sync.do.user": "your edit wins (the record's fact withdrawn with the reason)",
429
+ "ui.sync.do.set": "the research takes your position (no record proves one)",
427
430
  "ui.sync.do.pick": "only when you pick it (--only)",
428
431
  "ui.sync.do.report": "nothing changes — tell the agent what you meant",
429
432
  "ui.sync.edits": "Your edits to facts a record proves: {mode} (strom config set sync.edits conflict|user)",
@@ -737,6 +740,37 @@ export const UI = {
737
740
  "ui.review.next.human": "The agent takes them up in a conversation or when it works alone.",
738
741
  "ui.review.wake.human": " to go on with it, tell the agent in a conversation",
739
742
  "ui.waiting.gone": "This one no longer waits for you — the agent has taken it up meanwhile.",
743
+ "ui.trees.pack": "pack \"{name}\" to send to someone (a ZIP file)",
744
+ "ui.trees.unpack": "a family tree someone sent you (a ZIP file)",
745
+ "ui.pack.which": "Which images go along?",
746
+ "ui.pack.opt.records": "the ones its records stand on — {mb} MB (the pages searched through the archive gives again)",
747
+ "ui.pack.opt.all": "all of them — {mb} MB",
748
+ "ui.pack.done": "Packed \"{name}\": {file} ({mb} MB)",
749
+ "ui.pack.saved": " the family tree as last saved, with its history",
750
+ "ui.pack.images": " images: {n} ({mb} MB) — the ones its records stand on and the ones nobody can fetch again",
751
+ "ui.pack.images.all": " images: all {n} ({mb} MB)",
752
+ "ui.pack.left": " left out: {n} images ({mb} MB) — pages searched through; strom fetches them from the archive again when they are needed",
753
+ "ui.pack.connectors": " connectors: {list}",
754
+ "ui.pack.noconnector": "⚠ some images left out came through a connector that is not on this computer — nobody could fetch them again: strom pack --all-images",
755
+ "ui.pack.missing": "⚠ {n} image(s) the family tree names are not on this computer — not in the package",
756
+ "ui.pack.send": "Send the file. The one who gets it unpacks it and double-clicks Windows.cmd or macOS.command inside: strom is installed where it is missing and the research opens (with strom already: strom unpack \"<the file>\").",
757
+ "ui.pack.readme.file": "HOW-TO-CONTINUE.txt",
758
+ "ui.pack.readme": "The research \"{name}\" - how to go on with it\n=============================================\n\n1. Unpack the whole ZIP file: on Windows right click it - Extract All; on a Mac double click it.\n\n2. In the unpacked folder start, by a double click:\n Windows: Windows.cmd\n macOS: macOS.command (the first time: right click - Open; when macOS refuses:\n System Settings - Privacy & Security - Open Anyway)\n Linux: in a terminal: bash Linux.sh\n It installs Strom Research where it is not yet (the official installer), puts the\n research in place - the family tree among yours, the images into your shared folder,\n all of it checked - takes it over on your computer and opens strom.\n\n3. Go on: in strom's menu, the conversation with the agent.\n\nWithout the launchers: install strom yourself\n Windows (PowerShell): {win}\n macOS, Linux (Terminal): {unix}\nthen: strom unpack \"<this ZIP file>\" (type strom unpack and a space, drag the file into\nthe terminal, press Enter) - or in strom's menu: another family tree - a family tree someone sent you.\n\nThe images the records stand on are in the package. The other pages of the books\n(searched through, nothing found on them) are not, for size - strom fetches them from\nthe archive again when they are needed.\n",
759
+ "ui.unpack.file": "Drag the ZIP file here (or type where it is; 0 goes back):",
760
+ "ui.unpack.what": "\"{name}\" (sent from strom {strom}, saved {date}): {images} image(s){connectors} → {root}",
761
+ "ui.unpack.ask": "Put it here and take it over on this computer?",
762
+ "ui.unpack.done": "\"{name}\" is here: {root}",
763
+ "ui.unpack.images": " {n} image(s) into the shared folder{had}",
764
+ "ui.unpack.had": " ({n} were here already)",
765
+ "ui.unpack.connectors": " connectors: {list}",
766
+ "ui.unpack.kept": " connector {name} was here already — yours stays",
767
+ "ui.unpack.left": " {n} pages searched through are not in it: strom fetches them from the archive when they are needed",
768
+ "ui.unpack.adopted": " taken over on this computer",
769
+ "ui.unpack.next": "Go on: strom (the menu) or strom chat.",
770
+ "ui.unpack.same": "this research is here already: {root}",
771
+ "ui.unpack.person": "only the person at the computer unpacks a research (it adds connectors)",
772
+ "ui.pack.launch.extract": "First unpack the whole ZIP file - on Windows: right click, Extract All - then start this again from the unpacked folder.",
773
+ "ui.pack.launch.failed": "Strom Research could not be installed. Install it by the line in HOW-TO-CONTINUE.txt and run this again.",
740
774
  };
741
775
  /** A text for the person, in their language. */
742
776
  export function ui(lang, key, values = {}) {
@@ -1094,14 +1094,16 @@ async function fetchWith(ctx, c, request, recordset) {
1094
1094
  });
1095
1095
  if (recordset && request.cmd === "fetch") {
1096
1096
  // what is registered already is not asked for again (a part of an image is not the image)
1097
- const have = new Set(tree.list("media").filter((m) => m.recordset === recordset && !m.part && m.image !== undefined).map((m) => m.image));
1097
+ // …unless its file is not here (a research handed over without its images): fetched again, put back
1098
+ const here = (m) => fs.existsSync(path.join(shared(ctx), m.file));
1099
+ const have = new Set(tree.list("media").filter((m) => m.recordset === recordset && !m.part && m.image !== undefined && here(m)).map((m) => m.image));
1098
1100
  const asked = request.images;
1099
1101
  request.images = asked.filter((n) => !have.has(n));
1100
1102
  if (!request.images.length)
1101
1103
  return { text: `images ${runs(asked)} of ${recordset} are registered already — nothing fetched`, data: { added: [], again: asked } };
1102
1104
  }
1103
1105
  if (recordset && request.cmd === "part") {
1104
- const had = tree.list("media").find((m) => m.recordset === recordset && m.image === request.image && m.part && sameRegion(m.part, request.region));
1106
+ const had = tree.list("media").find((m) => m.recordset === recordset && m.image === request.image && m.part && sameRegion(m.part, request.region) && fs.existsSync(path.join(shared(ctx), m.file)));
1105
1107
  if (had)
1106
1108
  return { text: `part ${regionText(request.region)} of image ${request.image} of ${recordset} is registered already: ${had.id} — nothing fetched`, data: { added: [], again: [had.id] } };
1107
1109
  }
@@ -1158,16 +1160,18 @@ async function fetchWith(ctx, c, request, recordset) {
1158
1160
  ? lines(`part ${regionText(m.part)} of image ${request.image} of ${recordset} fetched and registered: ${m.id}${m.width ? ` · ${m.width}×${m.height} px` : ""}${gain ? ` · ${gain.toFixed(1)}× the detail of the whole image` : ""} · ${took}`, gain !== undefined && gain < 1.2 ? " no sharper than the whole image: the portal gives no more detail than that" : undefined, `look at it: strom media view ${recordset}:${request.image} --crop ${regionText(m.part)} (a view of the image uses the part by itself)`)
1159
1161
  : res.again.length && whole && res.again.includes(whole.id) && r.images.every((i) => isWhole(i.region))
1160
1162
  ? lines(`the portal's sharpest of image ${request.image} is the whole scan, registered already: ${whole.id}${whole.width ? ` · ${whole.width}×${whole.height} px` : ""} — no part needed`, `look at it: strom media view ${recordset}:${request.image} --crop ${regionText(request.region)}`)
1161
- : res.again.length
1162
- ? lines(`that part is registered already: ${res.again.join(" ")}`, ...res.clashes.map((x) => `⚠ ${x}`))
1163
- : "no part fetched";
1163
+ : res.restored.length
1164
+ ? `part ${regionText(request.region)} of image ${request.image} of ${recordset} fetched again and put back (its file was not here; the same scan): ${res.restored.join(" ")} · ${took}`
1165
+ : res.again.length
1166
+ ? lines(`that part is registered already: ${res.again.join(" ")}`, ...res.clashes.map((x) => `⚠ ${x}`))
1167
+ : "no part fetched";
1164
1168
  data = { ...data, added: res.added.map((x) => ({ id: x.id, image: x.image, part: x.part })), again: res.again };
1165
1169
  }
1166
1170
  else if (recordset && r.images.length) {
1167
1171
  const res = registerImages(tree, shared(ctx), r.images.map((i) => ({ file: i.file, image: i.n, url: i.url, from: from(i.file), fetched })), recordset);
1168
1172
  const nums = res.added.map((m) => m.image).filter((n) => n !== undefined);
1169
- text = lines(`${res.added.length} image(s) of ${recordset}${nums.length ? ` (images ${Math.min(...nums)}–${Math.max(...nums)})` : ""} fetched and registered · ${took}`, res.again.length ? `${res.again.length} already registered` : undefined, ...res.clashes.slice(0, 10).map((x) => `⚠ ${x}`), res.woken.length ? `back in the queue (they waited for these images): ${res.woken.join(" ")}` : undefined);
1170
- data = { ...data, added: res.added.map((m) => ({ id: m.id, image: m.image })), again: res.again, woken: res.woken, clashes: res.clashes };
1173
+ text = lines(res.added.length || !res.restored.length ? `${res.added.length} image(s) of ${recordset}${nums.length ? ` (images ${Math.min(...nums)}–${Math.max(...nums)})` : ""} fetched and registered · ${took}` : undefined, res.restored.length ? `${res.restored.length} image(s) of ${recordset} fetched again and put back (their file was not here; the same scan): ${res.restored.slice(0, 5).join(" ")}${res.restored.length > 5 ? " …" : ""} · ${took}` : undefined, res.again.length ? `${res.again.length} already registered` : undefined, ...res.clashes.slice(0, 10).map((x) => `⚠ ${x}`), res.woken.length ? `back in the queue (they waited for these images): ${res.woken.join(" ")}` : undefined);
1174
+ data = { ...data, added: res.added.map((m) => ({ id: m.id, image: m.image })), again: res.again, restored: res.restored, woken: res.woken, clashes: res.clashes };
1171
1175
  fs.rmSync(workDir, { recursive: true, force: true });
1172
1176
  }
1173
1177
  else if (r.images.length) {
@@ -1307,7 +1311,7 @@ async function takeOver(ctx, c, result) {
1307
1311
  if (plan.recordset) {
1308
1312
  const res = registerImages(tree, shared(ctx), mine.map((t) => ({ file: t.file, image: t.item.n, url: t.item.url, from: `connector ${c.name} · your browser · ${path.basename(t.file)}`, part: t.item.region, fetched })), plan.recordset);
1309
1313
  added.push(...res.added.map((m) => ({ id: m.id, ...(m.image !== undefined ? { image: m.image } : {}) })));
1310
- again.push(...res.again);
1314
+ again.push(...res.again, ...res.restored);
1311
1315
  notes.push(...res.clashes);
1312
1316
  woken.push(...res.woken);
1313
1317
  for (const t of mine)
@@ -5,6 +5,7 @@ import "./trees.js";
5
5
  import "./research.js";
6
6
  import "./intake.js";
7
7
  import "./sync.js";
8
+ import "./pack.js";
8
9
  import "./tasks.js";
9
10
  import "./people.js";
10
11
  import "./batch.js";
@@ -26,12 +26,20 @@ export function registerImages(tree, shared, items, recordset) {
26
26
  const known = new Map(tree.list("media").filter((m) => !m.retracted).map((m) => [m.sha, m]));
27
27
  const added = [];
28
28
  const again = [];
29
+ const restored = [];
29
30
  const clashes = [];
30
31
  const copies = [];
31
32
  tree.withTreeLock(() => {
32
33
  for (const it of items) {
33
34
  const sha = fileSha256(it.file);
34
35
  const k = known.get(sha);
36
+ if (k && !tree.dryRun && !fs.existsSync(path.join(shared, k.file))) {
37
+ // its file is not here (a research handed over without its images): the same scan, put back where it was
38
+ fs.mkdirSync(path.dirname(path.join(shared, k.file)), { recursive: true });
39
+ fs.copyFileSync(it.file, path.join(shared, k.file));
40
+ restored.push(k.id);
41
+ continue;
42
+ }
35
43
  if (k) {
36
44
  again.push(k.id);
37
45
  // the same scan as another image: a portal serving another book's images, or a file given the wrong number
@@ -85,7 +93,7 @@ export function registerImages(tree, shared, items, recordset) {
85
93
  });
86
94
  woken.push(t.id);
87
95
  }
88
- return { added, again, woken, clashes, copies };
96
+ return { added, again, restored, woken, clashes, copies };
89
97
  }
90
98
  function sharedDir(ctx) {
91
99
  return ctx.settings.shared().value;
@@ -264,7 +272,7 @@ register({
264
272
  hint: "rename the files by the image number the viewer shows (9.jpg), or --image <n> for one file",
265
273
  });
266
274
  }
267
- const { added, again, woken, clashes, copies } = registerImages(tree, shared, items, recordset);
275
+ const { added, again, restored, woken, clashes, copies } = registerImages(tree, shared, items, recordset);
268
276
  if (opts.inbox && !tree.dryRun) {
269
277
  for (const f of files)
270
278
  fs.rmSync(f, { force: true }); // now in the store
@@ -275,7 +283,7 @@ register({
275
283
  const nums = added.map((m) => m.image).filter((n) => n !== undefined);
276
284
  const text = lines(added.length
277
285
  ? `${added.length} image(s) registered: ${added[0].id}${added.length > 1 ? `–${added.at(-1).id}` : ""}${recordset ? ` of ${recordset}` : ""}${nums.length ? ` (images ${runs(nums)})` : ""}`
278
- : "no new images", again.length ? `${again.length} already registered (same content): ${again.slice(0, 5).join(" ")}${again.length > 5 ? " …" : ""}` : undefined, ...clashes.slice(0, 10).map((c) => `⚠ ${c}`), ...copies.slice(0, 10), copies.length > 10 ? `… and ${copies.length - 10} more images with a sharper copy` : undefined, woken.length ? `back in the queue (they waited for these images): ${woken.join(" ")}` : undefined, tree.dryRun ? "(dry run — nothing written)" : undefined, added[0] ? `\nlook at one: strom media view ${recordset && added[0].image !== undefined ? `${recordset}:${added[0].image}` : added[0].id} --grid` : undefined);
286
+ : restored.length ? undefined : "no new images", restored.length ? `${restored.length} image(s) put back (their file was not here; the same scan): ${restored.slice(0, 5).join(" ")}${restored.length > 5 ? " …" : ""}` : undefined, again.length ? `${again.length} already registered (same content): ${again.slice(0, 5).join(" ")}${again.length > 5 ? " …" : ""}` : undefined, ...clashes.slice(0, 10).map((c) => `⚠ ${c}`), ...copies.slice(0, 10), copies.length > 10 ? `… and ${copies.length - 10} more images with a sharper copy` : undefined, woken.length ? `back in the queue (they waited for these images): ${woken.join(" ")}` : undefined, tree.dryRun ? "(dry run — nothing written)" : undefined, added[0] ? `\nlook at one: strom media view ${recordset && added[0].image !== undefined ? `${recordset}:${added[0].image}` : added[0].id} --grid` : undefined);
279
287
  return { text, data: { added: added.map((m) => ({ id: m.id, image: m.image })), again, woken, clashes, copies } };
280
288
  },
281
289
  }, {
@@ -0,0 +1,131 @@
1
+ // pack · unpack — a research handed to someone else as one ZIP file.
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { register } from "../cli/registry.js";
5
+ import { lines } from "../cli/format.js";
6
+ import { ui } from "../cli/ui.js";
7
+ import { NeedsConsentError, UsageError } from "../core/errors.js";
8
+ import { findPack, freeRoot, INSTALL_UNIX, INSTALL_WINDOWS, packPlan, unpack, writePack } from "../core/pack.js";
9
+ import { desktopDir, userHome } from "../core/paths.js";
10
+ import { safeFolderName } from "../core/text.js";
11
+ import { Tree } from "../core/tree.js";
12
+ import { syncAgentFiles } from "../agents/files.js";
13
+ import { setupWizard } from "../cli/wizard.js";
14
+ import { isAgent } from "../core/which.js";
15
+ import { ensureShared } from "./setup.js";
16
+ const mb = (bytes) => (bytes / 1e6 < 10 ? (bytes / 1e6).toFixed(1) : Math.round(bytes / 1e6).toString());
17
+ register({
18
+ path: ["pack"],
19
+ summary: "Pack the research into one ZIP file for someone to go on with it (the tree, the images its records stand on, the connectors)",
20
+ group: "research",
21
+ tree: true,
22
+ description: "The family tree as it was last saved, with its history; the images its records stand on, the images nobody can\n" +
23
+ "fetch again (saved by hand, the family's material) and the connectors that fetched the others. The pages only\n" +
24
+ "searched through are left out — the archive gives the same scan again (strom fetch puts it back, checked);\n" +
25
+ "--all-images takes them too. Logins, settings and seal keys never go along. The one who gets it: strom unpack.",
26
+ options: [
27
+ { name: "out", type: "string", value: "<file.zip>", description: "where to write it (default: the desktop, named after the tree and the day)" },
28
+ { name: "all-images", type: "boolean", description: "every image, the pages searched through too" },
29
+ ],
30
+ examples: ["strom pack", "strom pack --all-images", 'strom pack --out "~/Novákovi.zip"'],
31
+ run(ctx, { opts }) {
32
+ const tree = ctx.tree();
33
+ const shared = ctx.settings.shared().value;
34
+ const lang = ctx.uiLang();
35
+ const all = Boolean(opts["all-images"]);
36
+ const day = new Date().toISOString().slice(0, 10);
37
+ const base = `${safeFolderName(tree.config.name) || "tree"} ${day}`;
38
+ let out;
39
+ if (opts.out)
40
+ out = ctx.resolvePath(String(opts.out));
41
+ else {
42
+ const desk = desktopDir(ctx.env);
43
+ const dir = fs.existsSync(desk) ? desk : userHome(ctx.env);
44
+ out = path.join(dir, `${base}.zip`);
45
+ for (let n = 2; fs.existsSync(out); n++)
46
+ out = path.join(dir, `${base} (${n}).zip`);
47
+ }
48
+ if (!out.toLowerCase().endsWith(".zip"))
49
+ out += ".zip";
50
+ if (fs.existsSync(out))
51
+ throw new UsageError(`${ctx.display(out)} is there already`, { hint: "another --out, or without it" });
52
+ if (ctx.dryRun) {
53
+ const plan = packPlan(tree, shared, all);
54
+ const bytes = plan.media.reduce((s, m) => s + m.size, 0);
55
+ return { text: `dry run: would pack "${tree.config.name}" with ${plan.media.length} image(s) (${mb(bytes)} MB) into ${ctx.display(out)} — nothing written`, data: { out, plan } };
56
+ }
57
+ const readme = {
58
+ name: ui(tree.config.lang, "ui.pack.readme.file"),
59
+ text: ui(tree.config.lang, "ui.pack.readme", { name: tree.config.name, win: INSTALL_WINDOWS, unix: INSTALL_UNIX }),
60
+ };
61
+ const say = { extract: ui(tree.config.lang, "ui.pack.launch.extract"), failed: ui(tree.config.lang, "ui.pack.launch.failed") };
62
+ const r = writePack(tree, shared, out, { all, readme, say });
63
+ const took = r.plan.media.reduce((s, m) => s + m.size, 0);
64
+ const text = lines(ui(lang, "ui.pack.done", { name: tree.config.name, file: ctx.display(r.file), mb: mb(r.bytes) }), ui(lang, "ui.pack.saved"), ui(lang, all ? "ui.pack.images.all" : "ui.pack.images", { n: r.plan.media.length, mb: mb(took) }), r.plan.left.count ? ui(lang, "ui.pack.left", { n: r.plan.left.count, mb: mb(r.plan.left.bytes) }) : undefined, r.plan.connectors.length ? ui(lang, "ui.pack.connectors", { list: r.plan.connectors.join(", ") }) : undefined, r.plan.left.count && r.plan.left.connectors ? ui(lang, "ui.pack.noconnector") : undefined, r.plan.missing.length ? ui(lang, "ui.pack.missing", { n: r.plan.missing.length }) : undefined, "", ui(lang, "ui.pack.send"));
65
+ return { text, data: { file: r.file, bytes: r.bytes, manifest: r.manifest, missing: r.plan.missing } };
66
+ },
67
+ });
68
+ register({
69
+ path: ["unpack"],
70
+ summary: "Put a research someone packed for you in place and take it over on this computer (the person runs this)",
71
+ group: "research",
72
+ description: "The ZIP file of strom pack, or the folder it was unpacked into. The tree goes among the trees (a free name), its\n" +
73
+ "images into the shared folder (each checked against its SHA-256), its connectors into the plugins where there is\n" +
74
+ "none of that name (yours stays); the tree is checked whole and taken over on this computer. It adds connectors:\n" +
75
+ "only the person at the computer runs it — never an agent.",
76
+ args: [{ name: "file", description: "the ZIP file (or the folder it was unpacked into)", required: true }],
77
+ examples: ['strom unpack "~/Downloads/Novákovi 2026-09-29.zip"'],
78
+ async run(ctx, { args }) {
79
+ const lang = ctx.uiLang();
80
+ const src = ctx.resolvePath(args[0]);
81
+ if (!ctx.interactive)
82
+ throw new NeedsConsentError([{ key: "unpack", kind: "consent", question: ui(lang, "ui.unpack.person"), set: `strom unpack "${src}"` }]);
83
+ // someone new to strom (a launcher of the package installed it just now): the setup wizard first
84
+ if (!ctx.settings.home() && !isAgent(ctx.env)) {
85
+ await setupWizard(ctx);
86
+ ctx.settings.reload();
87
+ }
88
+ await ctx.requireHome();
89
+ const treesDir = ctx.settings.trees().value;
90
+ const shared = ctx.settings.shared().value;
91
+ const pack = findPack(src);
92
+ try {
93
+ const m = pack.manifest;
94
+ const same = ctx.knownTrees().find((t) => {
95
+ try {
96
+ return JSON.parse(fs.readFileSync(path.join(t.root, "strom.json"), "utf8")).id === m.tree.id;
97
+ }
98
+ catch {
99
+ return false;
100
+ }
101
+ });
102
+ if (same)
103
+ throw new UsageError(ui(lang, "ui.unpack.same", { root: ctx.display(same.root) }), { hint: `strom trees use "${same.root}"` });
104
+ const target = freeRoot(treesDir, m.tree.name);
105
+ ctx.io.stdout(ui(lang, "ui.unpack.what", {
106
+ name: m.tree.name,
107
+ strom: m.strom,
108
+ date: m.created.slice(0, 10),
109
+ images: m.media.length,
110
+ connectors: m.connectors.length ? ` · ${m.connectors.join(", ")}` : "",
111
+ root: ctx.display(target),
112
+ }) + "\n");
113
+ if (!(await ctx.confirm(ui(lang, "ui.unpack.ask"), true)))
114
+ return { text: "", data: { unpacked: false } };
115
+ ensureShared(shared);
116
+ const r = unpack(pack, treesDir, shared, ctx.env);
117
+ // the tree's own instructions and permissions, for this computer's folders
118
+ const tree = Tree.open(r.root, ctx.env);
119
+ const files = syncAgentFiles(tree);
120
+ tree.withTreeLock(() => tree.commit(`Agent instructions: ${files.join(", ")}`, files));
121
+ ctx.settings.config.currentTree = r.root;
122
+ ctx.settings.save();
123
+ const text = lines(ui(lang, "ui.unpack.done", { name: r.name, root: ctx.display(r.root) }), r.images || r.imagesHad ? ui(lang, "ui.unpack.images", { n: r.images, had: r.imagesHad ? ui(lang, "ui.unpack.had", { n: r.imagesHad }) : "" }) : undefined, r.connectors.length ? ui(lang, "ui.unpack.connectors", { list: r.connectors.join(", ") }) : undefined, ...r.connectorsKept.map((c) => ui(lang, "ui.unpack.kept", { name: c })), m.left.count ? ui(lang, "ui.unpack.left", { n: m.left.count }) : undefined, r.adopted ? ui(lang, "ui.unpack.adopted") : undefined, "", ui(lang, "ui.unpack.next"));
124
+ return { text, data: { unpacked: true, ...r } };
125
+ }
126
+ finally {
127
+ if (pack.tmp)
128
+ fs.rmSync(pack.tmp, { recursive: true, force: true });
129
+ }
130
+ },
131
+ });
@@ -613,6 +613,8 @@ register({
613
613
  // Close what the agent left open, record the metrics, export, commit.
614
614
  const after = Tree.open(root, runEnv);
615
615
  let s = after.get(session.id);
616
+ // strom worked for the agent when it closed the session itself or recorded something
617
+ const ranStrom = s.state !== "open" || sessionWrites(after, s.id) > 0;
616
618
  if (s.state === "open")
617
619
  s = closeSession(after, s, {
618
620
  summary: result.outcome === "stopped" ? phrase(after.lang, "session.user") : phrase(after.lang, "session.agent", { outcome: result.outcome }),
@@ -656,7 +658,9 @@ register({
656
658
  break;
657
659
  }
658
660
  // An agent that may not run strom can do nothing: stop, do not burn session after session.
659
- if (result.denied?.some((d) => /^Bash: (\S*\/)?strom\b/.test(d))) {
661
+ // A refused command that only starts with strom (strom … && sed …, a loop) is no such thing
662
+ // when strom worked for it otherwise.
663
+ if (!ranStrom && result.denied?.some((d) => /^Bash: (\S*\/)?strom\b/.test(d))) {
660
664
  stopCode = "denied";
661
665
  break;
662
666
  }
@@ -21,6 +21,8 @@ function factText(f, lang) {
21
21
  return "—";
22
22
  return [f.value, humanDate(f.date, lang), humanPlace(f.place, undefined, lang)].filter(Boolean).join(", ") || "—";
23
23
  }
24
+ /** A position on the map as a map shows it: degrees, six decimals at most. */
25
+ const point = (p) => `${+p.lat.toFixed(6)}, ${+p.lon.toFixed(6)}`;
24
26
  /** One change as the user reads it, with what strom does with it. */
25
27
  function changeLine(tree, c, incoming, lang) {
26
28
  const name = (key) => {
@@ -44,8 +46,12 @@ function changeLine(tree, c, incoming, lang) {
44
46
  text: truncate(c.text ?? "", 120),
45
47
  child: name(c.child),
46
48
  kids: c.kind === "family.new" && c.text ? ` + ${c.text.split(" ").filter(Boolean).map(name).join(", ")}` : "",
49
+ place: c.place?.name ?? "",
50
+ at: c.place ? point(c.place) : "",
51
+ from: c.place?.was ? point(c.place.was) : "",
47
52
  };
48
- return `${ui(lang, `ui.sync.${c.kind}`, v)} → ${ui(lang, `ui.sync.do.${c.action}`)}`;
53
+ const key = c.kind === "place.coords" && !c.place?.was ? "ui.sync.place.located" : `ui.sync.${c.kind}`;
54
+ return `${ui(lang, key, v)} → ${ui(lang, `ui.sync.do.${c.action}`)}`;
49
55
  }
50
56
  function planText(tree, plan, incoming, file, lang, edits) {
51
57
  const shown = file.includes(" ") ? `"${file}"` : file;