strom-research 1.6.1 → 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 +6 -0
- package/assets/lang/cs.json +34 -3
- package/assets/lang/de.json +34 -3
- package/dist/cli/menu.js +28 -1
- package/dist/cli/ui.js +33 -2
- package/dist/commands/connectors.js +12 -8
- package/dist/commands/index.js +1 -0
- package/dist/commands/media.js +11 -3
- package/dist/commands/pack.js +131 -0
- package/dist/core/pack.js +311 -0
- package/dist/core/tree.js +1 -1
- package/dist/core/views.js +10 -2
- package/dist/core/zip.js +226 -0
- package/package.json +1 -1
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
|
package/assets/lang/cs.json
CHANGED
|
@@ -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)",
|
|
@@ -808,5 +808,36 @@
|
|
|
808
808
|
"ui.menu.failed": "Tohle se nepovedlo: {reason}",
|
|
809
809
|
"ui.review.next.human": "Agent se do nich pustí v rozhovoru nebo při samostatné práci.",
|
|
810
810
|
"ui.review.wake.human": " pokud v něm chcete pokračovat, řekněte to agentovi v rozhovoru",
|
|
811
|
-
"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."
|
|
812
843
|
}
|
package/assets/lang/de.json
CHANGED
|
@@ -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)",
|
|
@@ -808,5 +808,36 @@
|
|
|
808
808
|
"ui.menu.failed": "Das ging nicht: {reason}",
|
|
809
809
|
"ui.review.next.human": "Der Agent nimmt sie im Gespräch oder bei der Arbeit allein auf.",
|
|
810
810
|
"ui.review.wake.human": " wenn es weitergehen soll, sagen Sie es dem Agenten im Gespräch",
|
|
811
|
-
"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."
|
|
812
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)",
|
|
@@ -740,6 +740,37 @@ export const UI = {
|
|
|
740
740
|
"ui.review.next.human": "The agent takes them up in a conversation or when it works alone.",
|
|
741
741
|
"ui.review.wake.human": " to go on with it, tell the agent in a conversation",
|
|
742
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.",
|
|
743
774
|
};
|
|
744
775
|
/** A text for the person, in their language. */
|
|
745
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
|
-
|
|
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.
|
|
1162
|
-
?
|
|
1163
|
-
:
|
|
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}
|
|
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)
|
package/dist/commands/index.js
CHANGED
package/dist/commands/media.js
CHANGED
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// A research handed to someone else: one ZIP file with the tree as it was last
|
|
2
|
+
// saved (its history too), the images its records stand on, the images nobody
|
|
3
|
+
// can fetch again (saved by hand, the family's material) and the connectors
|
|
4
|
+
// that fetched the rest. The other images are left out: the archive gives the
|
|
5
|
+
// same scan again when it is needed (strom fetch puts it back, checked).
|
|
6
|
+
// The one who gets it unpacks it with strom: into their trees, the images into
|
|
7
|
+
// their shared folder, the connectors into their plugins, and takes it over.
|
|
8
|
+
import crypto from "node:crypto";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import * as git from "./git.js";
|
|
13
|
+
import { connectorsDir } from "./connector.js";
|
|
14
|
+
import { StromError, UsageError } from "./errors.js";
|
|
15
|
+
import { createKey } from "./seal.js";
|
|
16
|
+
import { safeFolderName } from "./text.js";
|
|
17
|
+
import { Tree, VERSION } from "./tree.js";
|
|
18
|
+
import { verifyFull } from "./integrity.js";
|
|
19
|
+
import { readZip, unzipTo, ZipWriter } from "./zip.js";
|
|
20
|
+
export const PACK_MANIFEST = "strom-pack.json";
|
|
21
|
+
export const INSTALL_WINDOWS = "irm https://raw.githubusercontent.com/ACiDekCZ/strom-research/main/install/install.ps1 | iex";
|
|
22
|
+
export const INSTALL_UNIX = "curl -fsSL https://raw.githubusercontent.com/ACiDekCZ/strom-research/main/install/install.sh | sh";
|
|
23
|
+
/** What goes into a package of this tree: every image (all), or the ones its records stand on and the ones nobody can fetch again. */
|
|
24
|
+
export function packPlan(tree, shared, all) {
|
|
25
|
+
const media = tree.list("media");
|
|
26
|
+
const cited = new Set();
|
|
27
|
+
for (const s of tree.list("source")) {
|
|
28
|
+
if (s.retracted)
|
|
29
|
+
continue;
|
|
30
|
+
for (const id of s.media ?? [])
|
|
31
|
+
cited.add(id);
|
|
32
|
+
for (const c of s.clips ?? [])
|
|
33
|
+
cited.add(c.media);
|
|
34
|
+
}
|
|
35
|
+
// an image's other copies and parts go along: a view uses the sharpest
|
|
36
|
+
const citedImages = new Set(media.filter((m) => cited.has(m.id) && m.recordset && m.image !== undefined).map((m) => `${m.recordset}:${m.image}`));
|
|
37
|
+
const take = new Map();
|
|
38
|
+
const missing = [];
|
|
39
|
+
const add = (file, size) => {
|
|
40
|
+
if (fs.existsSync(path.join(shared, file)))
|
|
41
|
+
take.set(file, size);
|
|
42
|
+
else
|
|
43
|
+
missing.push(file);
|
|
44
|
+
};
|
|
45
|
+
let leftCount = 0;
|
|
46
|
+
let leftBytes = 0;
|
|
47
|
+
const connectors = new Set();
|
|
48
|
+
for (const m of media) {
|
|
49
|
+
if (m.fetched)
|
|
50
|
+
connectors.add(m.fetched.connector);
|
|
51
|
+
const needed = all || cited.has(m.id) || !m.fetched || citedImages.has(`${m.recordset}:${m.image}`);
|
|
52
|
+
if (needed)
|
|
53
|
+
add(m.file, m.size);
|
|
54
|
+
else {
|
|
55
|
+
leftCount++;
|
|
56
|
+
leftBytes += m.size;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// the family's material kept in the shared store
|
|
60
|
+
for (const i of tree.list("input"))
|
|
61
|
+
if (i.file?.startsWith("media:"))
|
|
62
|
+
add(i.file.slice(6), i.size ?? 0);
|
|
63
|
+
const have = [...connectors].filter((c) => fs.existsSync(path.join(connectorsDir(shared), c, "connector.json")));
|
|
64
|
+
return {
|
|
65
|
+
media: [...take].map(([file, size]) => ({ file, size })).sort((a, b) => a.file.localeCompare(b.file)),
|
|
66
|
+
connectors: have.sort(),
|
|
67
|
+
left: { count: leftCount, bytes: leftBytes, connectors: connectors.size - have.length },
|
|
68
|
+
missing,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** The files under a folder, and its empty folders ending in "/" (git needs them). */
|
|
72
|
+
function walk(dir, rel = "") {
|
|
73
|
+
const out = [];
|
|
74
|
+
const entries = fs.readdirSync(path.join(dir, rel), { withFileTypes: true });
|
|
75
|
+
for (const e of entries) {
|
|
76
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
77
|
+
if (e.isDirectory()) {
|
|
78
|
+
if (e.name === "node_modules")
|
|
79
|
+
continue;
|
|
80
|
+
out.push(...walk(dir, r));
|
|
81
|
+
}
|
|
82
|
+
else if (e.isFile() && e.name !== ".DS_Store")
|
|
83
|
+
out.push(r);
|
|
84
|
+
}
|
|
85
|
+
if (rel && !entries.length)
|
|
86
|
+
out.push(`${rel}/`);
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
function addTree(zip, prefix, dir) {
|
|
90
|
+
for (const f of walk(dir)) {
|
|
91
|
+
if (f.endsWith("/"))
|
|
92
|
+
zip.addDir(`${prefix}/${f}`);
|
|
93
|
+
else
|
|
94
|
+
zip.addFile(`${prefix}/${f}`, path.join(dir, ...f.split("/")));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* What the one who gets the package starts with a double click once it is unpacked: strom installed where it is
|
|
99
|
+
* not (the official installer, nothing else), the research put in place from the folder the launcher lies in,
|
|
100
|
+
* then strom. `say`: the two sentences it may need, in the research language.
|
|
101
|
+
*/
|
|
102
|
+
export function launchers(say) {
|
|
103
|
+
const cmd = [
|
|
104
|
+
"@echo off",
|
|
105
|
+
"chcp 65001 >nul",
|
|
106
|
+
'cd /d "%~dp0"',
|
|
107
|
+
'if exist "%~dp0' + PACK_MANIFEST + '" goto found',
|
|
108
|
+
`echo ${say.extract}`,
|
|
109
|
+
"pause",
|
|
110
|
+
"exit /b 1",
|
|
111
|
+
":found",
|
|
112
|
+
'set "STROM="',
|
|
113
|
+
'for /f "delims=" %%i in (\'where strom.cmd 2^>nul\') do if not defined STROM set "STROM=%%i"',
|
|
114
|
+
'if not defined STROM if exist "%LOCALAPPDATA%\\Programs\\Strom\\strom.cmd" set "STROM=%LOCALAPPDATA%\\Programs\\Strom\\strom.cmd"',
|
|
115
|
+
"if defined STROM goto run",
|
|
116
|
+
`powershell -NoProfile -ExecutionPolicy Bypass -Command "$env:STROM_INSTALL_ONLY='1'; ${INSTALL_WINDOWS}"`,
|
|
117
|
+
'if exist "%LOCALAPPDATA%\\Programs\\Strom\\strom.cmd" set "STROM=%LOCALAPPDATA%\\Programs\\Strom\\strom.cmd"',
|
|
118
|
+
"if defined STROM goto run",
|
|
119
|
+
`echo ${say.failed}`,
|
|
120
|
+
"pause",
|
|
121
|
+
"exit /b 1",
|
|
122
|
+
":run",
|
|
123
|
+
'call "%STROM%" unpack "%~dp0."',
|
|
124
|
+
'call "%STROM%"',
|
|
125
|
+
"",
|
|
126
|
+
].join("\r\n");
|
|
127
|
+
const sh = [
|
|
128
|
+
"#!/bin/bash",
|
|
129
|
+
'cd "$(dirname "$0")" || exit 1',
|
|
130
|
+
'if [ ! -f "' + PACK_MANIFEST + '" ]; then echo "' + say.extract.replace(/"/g, "'") + '"; read -r _; exit 1; fi',
|
|
131
|
+
'STROM="$(command -v strom || true)"',
|
|
132
|
+
'[ -z "$STROM" ] && [ -x "$HOME/.local/bin/strom" ] && STROM="$HOME/.local/bin/strom"',
|
|
133
|
+
'if [ -z "$STROM" ]; then',
|
|
134
|
+
` ${INSTALL_UNIX.replace(/\| sh$/, "| STROM_INSTALL_ONLY=1 sh")}`,
|
|
135
|
+
' [ -x "$HOME/.local/bin/strom" ] && STROM="$HOME/.local/bin/strom"',
|
|
136
|
+
"fi",
|
|
137
|
+
'if [ -z "$STROM" ]; then echo "' + say.failed.replace(/"/g, "'") + '"; read -r _; exit 1; fi',
|
|
138
|
+
'"$STROM" unpack "$(pwd)"',
|
|
139
|
+
'"$STROM"',
|
|
140
|
+
"",
|
|
141
|
+
].join("\n");
|
|
142
|
+
return [
|
|
143
|
+
{ name: "Windows.cmd", text: cmd, mode: 0o755 },
|
|
144
|
+
{ name: "macOS.command", text: sh, mode: 0o755 },
|
|
145
|
+
{ name: "Linux.sh", text: sh, mode: 0o755 },
|
|
146
|
+
];
|
|
147
|
+
}
|
|
148
|
+
/** Write the package. `readme`: the file name and text for the person who gets it; `say`: what its launchers may need to say. */
|
|
149
|
+
export function writePack(tree, shared, out, opts) {
|
|
150
|
+
const plan = packPlan(tree, shared, opts.all);
|
|
151
|
+
const folder = safeFolderName(tree.config.name) || "tree";
|
|
152
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "strom-pack-"));
|
|
153
|
+
const zip = new ZipWriter(out);
|
|
154
|
+
try {
|
|
155
|
+
// the tree as it was last saved, its history too — never what is half written
|
|
156
|
+
const clone = path.join(tmp, folder);
|
|
157
|
+
const r = git.runGit(tmp, ["clone", "--quiet", "--no-hardlinks", tree.root, clone]);
|
|
158
|
+
if (r.status !== 0)
|
|
159
|
+
throw new StromError(`could not copy the tree: ${r.stderr.trim()}`);
|
|
160
|
+
git.runGit(clone, ["remote", "remove", "origin"]);
|
|
161
|
+
git.runGit(clone, ["gc", "--quiet", "--prune=now"]);
|
|
162
|
+
const commit = git.head(clone) ?? "";
|
|
163
|
+
const manifest = {
|
|
164
|
+
kind: "strom-research-pack",
|
|
165
|
+
format: 1,
|
|
166
|
+
tree: { id: tree.config.id, name: tree.config.name, lang: tree.config.lang },
|
|
167
|
+
folder,
|
|
168
|
+
created: new Date().toISOString(),
|
|
169
|
+
strom: VERSION,
|
|
170
|
+
commit,
|
|
171
|
+
images: opts.all ? "all" : "records",
|
|
172
|
+
media: plan.media.map((m) => m.file),
|
|
173
|
+
connectors: plan.connectors,
|
|
174
|
+
left: { count: plan.left.count, bytes: plan.left.bytes },
|
|
175
|
+
};
|
|
176
|
+
// CRLF: Notepad of an older Windows shows it right too
|
|
177
|
+
zip.add(opts.readme.name, Buffer.from(opts.readme.text.replace(/\n/g, "\r\n"), "utf8"));
|
|
178
|
+
for (const l of launchers(opts.say))
|
|
179
|
+
zip.add(l.name, Buffer.from(l.text, "utf8"), { mode: l.mode });
|
|
180
|
+
zip.add(PACK_MANIFEST, Buffer.from(JSON.stringify(manifest, null, 2) + "\n", "utf8"));
|
|
181
|
+
addTree(zip, folder, clone);
|
|
182
|
+
for (const m of plan.media)
|
|
183
|
+
zip.addFile(`shared/${m.file}`, path.join(shared, ...m.file.split("/")));
|
|
184
|
+
for (const c of plan.connectors) {
|
|
185
|
+
addTree(zip, `shared/plugins/connectors/${c}`, path.join(connectorsDir(shared), c));
|
|
186
|
+
}
|
|
187
|
+
zip.close();
|
|
188
|
+
return { file: out, bytes: fs.statSync(out).size, manifest, plan };
|
|
189
|
+
}
|
|
190
|
+
catch (e) {
|
|
191
|
+
zip.abort();
|
|
192
|
+
throw e;
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/** A package given as the ZIP file or the folder a system unpacked it into: its manifest and where it lies. */
|
|
199
|
+
export function findPack(src) {
|
|
200
|
+
if (!fs.existsSync(src))
|
|
201
|
+
throw new UsageError(`there is no ${src}`, { hint: "give the ZIP file you were sent (drag it into the terminal)" });
|
|
202
|
+
const read = (text) => {
|
|
203
|
+
const m = JSON.parse(text);
|
|
204
|
+
if (m.kind !== "strom-research-pack" || !m.tree?.id || !m.folder)
|
|
205
|
+
throw new UsageError("this is not a research packed by strom");
|
|
206
|
+
if (m.format > 1)
|
|
207
|
+
throw new UsageError("this package is of a newer strom", { hint: "strom update" });
|
|
208
|
+
return m;
|
|
209
|
+
};
|
|
210
|
+
if (fs.statSync(src).isDirectory()) {
|
|
211
|
+
// the folder itself, or the one folder a system's unpacking put around it
|
|
212
|
+
const candidates = [src, ...fs.readdirSync(src, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => path.join(src, e.name))];
|
|
213
|
+
const dir = candidates.find((d) => fs.existsSync(path.join(d, PACK_MANIFEST)));
|
|
214
|
+
if (!dir)
|
|
215
|
+
throw new UsageError(`no ${PACK_MANIFEST} in ${src}: not a research packed by strom`, { hint: "give the ZIP file you were sent" });
|
|
216
|
+
return { manifest: read(fs.readFileSync(path.join(dir, PACK_MANIFEST), "utf8")), dir };
|
|
217
|
+
}
|
|
218
|
+
const entries = readZip(src);
|
|
219
|
+
const m = entries.find((e) => e.name === PACK_MANIFEST || e.name.endsWith(`/${PACK_MANIFEST}`));
|
|
220
|
+
if (!m)
|
|
221
|
+
throw new UsageError("this ZIP file holds no research packed by strom", { hint: "the sender makes one with strom pack" });
|
|
222
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "strom-unpack-"));
|
|
223
|
+
try {
|
|
224
|
+
unzipTo(src, dir, entries);
|
|
225
|
+
const root = path.join(dir, ...m.name.split("/").slice(0, -1));
|
|
226
|
+
return { manifest: read(fs.readFileSync(path.join(root, PACK_MANIFEST), "utf8")), zip: src, dir: root, tmp: dir };
|
|
227
|
+
}
|
|
228
|
+
catch (e) {
|
|
229
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
230
|
+
throw e;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Put a package in place: the tree into the trees folder (a free name), its images into the shared store (each
|
|
235
|
+
* checked against its name, the SHA-256 of its content), its connectors into the plugins where there is none of
|
|
236
|
+
* that name; then check the tree whole and take it over on this computer. Nothing is left half done.
|
|
237
|
+
*/
|
|
238
|
+
/** The tree's folder among the trees: its name, or with (2), (3)… when that is taken. */
|
|
239
|
+
export function freeRoot(treesDir, name) {
|
|
240
|
+
const base = safeFolderName(name) || "tree";
|
|
241
|
+
let root = path.join(treesDir, base);
|
|
242
|
+
for (let n = 2; fs.existsSync(root); n++)
|
|
243
|
+
root = path.join(treesDir, `${base} (${n})`);
|
|
244
|
+
return root;
|
|
245
|
+
}
|
|
246
|
+
export function unpack(pack, treesDir, shared, env) {
|
|
247
|
+
const src = pack.dir;
|
|
248
|
+
const m = pack.manifest;
|
|
249
|
+
// a system that unpacked the names in another code page: the one folder with a tree in it
|
|
250
|
+
let from = path.join(src, m.folder);
|
|
251
|
+
if (!fs.existsSync(path.join(from, "strom.json"))) {
|
|
252
|
+
const trees = fs.readdirSync(src, { withFileTypes: true }).filter((e) => e.isDirectory() && fs.existsSync(path.join(src, e.name, "strom.json")));
|
|
253
|
+
if (trees.length !== 1)
|
|
254
|
+
throw new UsageError(`the package has no tree ${m.folder}`);
|
|
255
|
+
from = path.join(src, trees[0].name);
|
|
256
|
+
}
|
|
257
|
+
const root = freeRoot(treesDir, m.tree.name);
|
|
258
|
+
fs.mkdirSync(treesDir, { recursive: true });
|
|
259
|
+
const media = [];
|
|
260
|
+
for (const f of m.media) {
|
|
261
|
+
const file = path.join(src, "shared", ...f.split("/"));
|
|
262
|
+
if (!fs.existsSync(file))
|
|
263
|
+
throw new StromError(`the package is missing ${f}`);
|
|
264
|
+
const sha = path.basename(f).replace(/\.[^.]+$/, "");
|
|
265
|
+
if (/^[0-9a-f]{64}$/.test(sha) && crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex") !== sha)
|
|
266
|
+
throw new StromError(`${f} in the package is not the image its name says — damaged on the way?`, { hint: "ask for the package again" });
|
|
267
|
+
media.push({ from: file, to: path.join(shared, ...f.split("/")) });
|
|
268
|
+
}
|
|
269
|
+
fs.cpSync(from, root, { recursive: true, errorOnExist: true });
|
|
270
|
+
try {
|
|
271
|
+
const tree = Tree.open(root, env);
|
|
272
|
+
const bad = verifyFull(tree).findings.filter((f) => f.level === "error");
|
|
273
|
+
if (bad.length)
|
|
274
|
+
throw new StromError(`the tree in the package does not check out: ${bad[0].message}${bad.length > 1 ? ` (+${bad.length - 1})` : ""}`, { hint: "ask for the package again" });
|
|
275
|
+
let adopted = false;
|
|
276
|
+
if (!tree.key) {
|
|
277
|
+
tree.withTreeLock(() => {
|
|
278
|
+
createKey(env, tree.config.id);
|
|
279
|
+
tree.reloadKey();
|
|
280
|
+
tree.appendOp({ op: "seal.adopt", targets: [], files: [], summary: "Tree adopted on this computer (unpacked)" });
|
|
281
|
+
tree.commit("Seal: tree adopted on this computer");
|
|
282
|
+
});
|
|
283
|
+
adopted = true;
|
|
284
|
+
}
|
|
285
|
+
let imagesHad = 0;
|
|
286
|
+
for (const x of media) {
|
|
287
|
+
if (fs.existsSync(x.to)) {
|
|
288
|
+
imagesHad++;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
fs.mkdirSync(path.dirname(x.to), { recursive: true });
|
|
292
|
+
fs.copyFileSync(x.from, x.to);
|
|
293
|
+
}
|
|
294
|
+
const connectors = [];
|
|
295
|
+
const kept = [];
|
|
296
|
+
for (const c of m.connectors) {
|
|
297
|
+
const to = path.join(connectorsDir(shared), c);
|
|
298
|
+
if (fs.existsSync(to)) {
|
|
299
|
+
kept.push(c);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
fs.cpSync(path.join(src, "shared", "plugins", "connectors", c), to, { recursive: true });
|
|
303
|
+
connectors.push(c);
|
|
304
|
+
}
|
|
305
|
+
return { root, name: tree.config.name, images: media.length - imagesHad, imagesHad, connectors, connectorsKept: kept, adopted };
|
|
306
|
+
}
|
|
307
|
+
catch (e) {
|
|
308
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
309
|
+
throw e;
|
|
310
|
+
}
|
|
311
|
+
}
|
package/dist/core/tree.js
CHANGED
|
@@ -23,7 +23,7 @@ import * as git from "./git.js";
|
|
|
23
23
|
import { commitSeal, createKey, fingerprint, loadKey, sha256, signOp } from "./seal.js";
|
|
24
24
|
import { stringifyCanonical } from "./json.js";
|
|
25
25
|
export const TREE_FILE = "strom.json";
|
|
26
|
-
export const VERSION = "1.
|
|
26
|
+
export const VERSION = "1.7.0";
|
|
27
27
|
const GITIGNORE = `# Strom Research — local state and large files are not versioned
|
|
28
28
|
.strom/
|
|
29
29
|
/media/
|
package/dist/core/views.js
CHANGED
|
@@ -69,8 +69,16 @@ export function tiles(region, max = VIEW_MAX, overlap = 0.1) {
|
|
|
69
69
|
}
|
|
70
70
|
/** Make (or reuse) a view of an image file. `key` names it (M0012, I0003). */
|
|
71
71
|
export function makeView(tree, source, key, spec) {
|
|
72
|
-
if (!fs.existsSync(source))
|
|
73
|
-
|
|
72
|
+
if (!fs.existsSync(source)) {
|
|
73
|
+
// a research handed over without its images: the archive gives the same scan again
|
|
74
|
+
const m = /^M\d+$/.test(key) ? tree.get(key) : undefined;
|
|
75
|
+
const again = m?.fetched && m.recordset && m.image !== undefined
|
|
76
|
+
? `strom fetch ${m.fetched.connector} ${m.fetched.book} --recordset ${m.recordset} --images ${m.image}${m.part ? ` --crop ${[m.part.x, m.part.y, m.part.w, m.part.h].join(",")}` : ""}`
|
|
77
|
+
: undefined;
|
|
78
|
+
throw new UsageError(`the image file is missing: ${source}`, {
|
|
79
|
+
hint: again ? `fetch it again from the archive (checked: the same scan): ${again} — or the shared folder moved: strom config where` : "the shared folder may have moved: strom config where",
|
|
80
|
+
});
|
|
81
|
+
}
|
|
74
82
|
const bytes = new Uint8Array(fs.readFileSync(source));
|
|
75
83
|
const size = imageSize(bytes);
|
|
76
84
|
if (!size) {
|
package/dist/core/zip.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// ZIP files without a dependency: written as they go (deflated where it helps,
|
|
2
|
+
// images stored as they are), read by their central directory. No ZIP64: a
|
|
3
|
+
// package over 4 GB or 65 535 files is refused. Names are UTF-8.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import zlib from "node:zlib";
|
|
7
|
+
import { StromError } from "./errors.js";
|
|
8
|
+
const LOCAL = 0x04034b50;
|
|
9
|
+
const CENTRAL = 0x02014b50;
|
|
10
|
+
const END = 0x06054b50;
|
|
11
|
+
const MAX = 0xffffffff;
|
|
12
|
+
/** Already compressed: deflating them again only costs time. */
|
|
13
|
+
const PACKED = /\.(jpe?g|png|gif|webp|tiff?|jp2|zip|gz|pdf|mp4|mov|mp3|heic)$/i;
|
|
14
|
+
export class ZipTooLarge extends StromError {
|
|
15
|
+
}
|
|
16
|
+
function dos(d) {
|
|
17
|
+
const y = Math.max(1980, d.getFullYear());
|
|
18
|
+
return {
|
|
19
|
+
time: (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2),
|
|
20
|
+
date: ((y - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export class ZipWriter {
|
|
24
|
+
fd;
|
|
25
|
+
offset = 0;
|
|
26
|
+
entries = [];
|
|
27
|
+
file;
|
|
28
|
+
constructor(file) {
|
|
29
|
+
this.file = file;
|
|
30
|
+
this.fd = fs.openSync(file, "w");
|
|
31
|
+
}
|
|
32
|
+
get bytes() {
|
|
33
|
+
return this.offset;
|
|
34
|
+
}
|
|
35
|
+
write(b) {
|
|
36
|
+
fs.writeSync(this.fd, b);
|
|
37
|
+
this.offset += b.length;
|
|
38
|
+
}
|
|
39
|
+
/** One file; `name` with forward slashes. */
|
|
40
|
+
add(name, data, opts = {}) {
|
|
41
|
+
let body = data;
|
|
42
|
+
let method = 0;
|
|
43
|
+
if (!PACKED.test(name) && !name.endsWith("/") && data.length > 64) {
|
|
44
|
+
const z = zlib.deflateRawSync(data);
|
|
45
|
+
if (z.length < data.length) {
|
|
46
|
+
body = z;
|
|
47
|
+
method = 8;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const n = Buffer.from(name, "utf8");
|
|
51
|
+
if (this.entries.length >= 0xffff || this.offset + 30 + n.length + body.length + 46 * (this.entries.length + 1) > MAX)
|
|
52
|
+
throw new ZipTooLarge("the package would be over 4 GB or 65 535 files");
|
|
53
|
+
const e = { name: n, crc: zlib.crc32(data), size: data.length, csize: body.length, method, offset: this.offset, ...dos(opts.mtime ?? new Date()), mode: opts.mode ?? (name.endsWith("/") ? 0o755 : 0o644) };
|
|
54
|
+
const h = Buffer.alloc(30);
|
|
55
|
+
h.writeUInt32LE(LOCAL, 0);
|
|
56
|
+
h.writeUInt16LE(20, 4);
|
|
57
|
+
h.writeUInt16LE(0x0800, 6); // names in UTF-8
|
|
58
|
+
h.writeUInt16LE(method, 8);
|
|
59
|
+
h.writeUInt16LE(e.time, 10);
|
|
60
|
+
h.writeUInt16LE(e.date, 12);
|
|
61
|
+
h.writeUInt32LE(e.crc, 14);
|
|
62
|
+
h.writeUInt32LE(e.csize, 18);
|
|
63
|
+
h.writeUInt32LE(e.size, 22);
|
|
64
|
+
h.writeUInt16LE(n.length, 26);
|
|
65
|
+
h.writeUInt16LE(0, 28);
|
|
66
|
+
this.write(h);
|
|
67
|
+
this.write(n);
|
|
68
|
+
this.write(body);
|
|
69
|
+
this.entries.push(e);
|
|
70
|
+
}
|
|
71
|
+
/** An empty folder (git needs its empty ones). */
|
|
72
|
+
addDir(name) {
|
|
73
|
+
this.add(name.endsWith("/") ? name : `${name}/`, Buffer.alloc(0));
|
|
74
|
+
}
|
|
75
|
+
/** A file from disk, its mode and time kept. */
|
|
76
|
+
addFile(name, file) {
|
|
77
|
+
const st = fs.statSync(file);
|
|
78
|
+
this.add(name, fs.readFileSync(file), { mode: st.mode & 0o777, mtime: st.mtime });
|
|
79
|
+
}
|
|
80
|
+
close() {
|
|
81
|
+
const start = this.offset;
|
|
82
|
+
for (const e of this.entries) {
|
|
83
|
+
const c = Buffer.alloc(46);
|
|
84
|
+
c.writeUInt32LE(CENTRAL, 0);
|
|
85
|
+
c.writeUInt16LE((3 << 8) | 20, 4); // made on Unix: the mode is kept
|
|
86
|
+
c.writeUInt16LE(20, 6);
|
|
87
|
+
c.writeUInt16LE(0x0800, 8);
|
|
88
|
+
c.writeUInt16LE(e.method, 10);
|
|
89
|
+
c.writeUInt16LE(e.time, 12);
|
|
90
|
+
c.writeUInt16LE(e.date, 14);
|
|
91
|
+
c.writeUInt32LE(e.crc, 16);
|
|
92
|
+
c.writeUInt32LE(e.csize, 20);
|
|
93
|
+
c.writeUInt32LE(e.size, 24);
|
|
94
|
+
c.writeUInt16LE(e.name.length, 28);
|
|
95
|
+
const dir = e.name[e.name.length - 1] === 0x2f;
|
|
96
|
+
c.writeUInt32LE((((dir ? 0o040000 : 0o100000) | e.mode) << 16 | (dir ? 0x10 : 0)) >>> 0, 38);
|
|
97
|
+
c.writeUInt32LE(e.offset, 42);
|
|
98
|
+
this.write(c);
|
|
99
|
+
this.write(e.name);
|
|
100
|
+
}
|
|
101
|
+
const end = Buffer.alloc(22);
|
|
102
|
+
end.writeUInt32LE(END, 0);
|
|
103
|
+
end.writeUInt16LE(this.entries.length, 8);
|
|
104
|
+
end.writeUInt16LE(this.entries.length, 10);
|
|
105
|
+
end.writeUInt32LE(this.offset - start, 12);
|
|
106
|
+
end.writeUInt32LE(start, 16);
|
|
107
|
+
this.write(end);
|
|
108
|
+
fs.closeSync(this.fd);
|
|
109
|
+
}
|
|
110
|
+
/** Give up: the half-written file goes. */
|
|
111
|
+
abort() {
|
|
112
|
+
try {
|
|
113
|
+
fs.closeSync(this.fd);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// closed already
|
|
117
|
+
}
|
|
118
|
+
fs.rmSync(this.file, { force: true });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
export class BadZip extends StromError {
|
|
122
|
+
}
|
|
123
|
+
/** A name a package may carry: relative, inside it, no drive, no "..". Undefined for anything else. */
|
|
124
|
+
export function safeEntryName(raw) {
|
|
125
|
+
const name = raw.replace(/\\/g, "/");
|
|
126
|
+
if (!name || name.startsWith("/") || /^[A-Za-z]:/.test(name) || name.includes("\0"))
|
|
127
|
+
return undefined;
|
|
128
|
+
const parts = name.split("/").filter((p) => p !== "" && p !== ".");
|
|
129
|
+
if (parts.some((p) => p === ".."))
|
|
130
|
+
return undefined;
|
|
131
|
+
return parts.join("/") + (name.endsWith("/") ? "/" : "");
|
|
132
|
+
}
|
|
133
|
+
/** The entries of a ZIP file (what a system's own "compress" makes too). Unsafe names: BadZip. */
|
|
134
|
+
export function readZip(file) {
|
|
135
|
+
const fd = fs.openSync(file, "r");
|
|
136
|
+
try {
|
|
137
|
+
const size = fs.fstatSync(fd).size;
|
|
138
|
+
const tail = Buffer.alloc(Math.min(size, 22 + 0xffff));
|
|
139
|
+
fs.readSync(fd, tail, 0, tail.length, size - tail.length);
|
|
140
|
+
let at = -1;
|
|
141
|
+
for (let i = tail.length - 22; i >= 0; i--)
|
|
142
|
+
if (tail.readUInt32LE(i) === END) {
|
|
143
|
+
at = i;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
if (at < 0)
|
|
147
|
+
throw new BadZip("not a ZIP file");
|
|
148
|
+
const count = tail.readUInt16LE(at + 10);
|
|
149
|
+
const cdSize = tail.readUInt32LE(at + 12);
|
|
150
|
+
const cdStart = tail.readUInt32LE(at + 16);
|
|
151
|
+
if (cdStart === MAX || count === 0xffff)
|
|
152
|
+
throw new BadZip("a ZIP64 file (over 4 GB) — unpack it with the system and give the folder");
|
|
153
|
+
const cd = Buffer.alloc(cdSize);
|
|
154
|
+
fs.readSync(fd, cd, 0, cdSize, cdStart);
|
|
155
|
+
const out = [];
|
|
156
|
+
let p = 0;
|
|
157
|
+
for (let i = 0; i < count; i++) {
|
|
158
|
+
if (cd.readUInt32LE(p) !== CENTRAL)
|
|
159
|
+
throw new BadZip("a damaged ZIP file");
|
|
160
|
+
const flags = cd.readUInt16LE(p + 8);
|
|
161
|
+
const nameLen = cd.readUInt16LE(p + 28);
|
|
162
|
+
const extraLen = cd.readUInt16LE(p + 30);
|
|
163
|
+
const commentLen = cd.readUInt16LE(p + 32);
|
|
164
|
+
const raw = cd.subarray(p + 46, p + 46 + nameLen);
|
|
165
|
+
const rawName = flags & 0x0800 ? raw.toString("utf8") : raw.toString("latin1");
|
|
166
|
+
const name = safeEntryName(rawName);
|
|
167
|
+
if (name === undefined)
|
|
168
|
+
throw new BadZip(`a file outside the package: ${rawName}`);
|
|
169
|
+
const attr = cd.readUInt32LE(p + 38) >>> 16;
|
|
170
|
+
out.push({
|
|
171
|
+
name,
|
|
172
|
+
method: cd.readUInt16LE(p + 10),
|
|
173
|
+
crc: cd.readUInt32LE(p + 16),
|
|
174
|
+
csize: cd.readUInt32LE(p + 20),
|
|
175
|
+
size: cd.readUInt32LE(p + 24),
|
|
176
|
+
offset: cd.readUInt32LE(p + 42),
|
|
177
|
+
dir: name.endsWith("/"),
|
|
178
|
+
link: (attr & 0o170000) === 0o120000,
|
|
179
|
+
});
|
|
180
|
+
p += 46 + nameLen + extraLen + commentLen;
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
fs.closeSync(fd);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/** The content of one entry, its checksum checked. */
|
|
189
|
+
export function readEntry(file, e) {
|
|
190
|
+
const fd = fs.openSync(file, "r");
|
|
191
|
+
try {
|
|
192
|
+
const h = Buffer.alloc(30);
|
|
193
|
+
fs.readSync(fd, h, 0, 30, e.offset);
|
|
194
|
+
if (h.readUInt32LE(0) !== LOCAL)
|
|
195
|
+
throw new BadZip(`a damaged ZIP file (${e.name})`);
|
|
196
|
+
const start = e.offset + 30 + h.readUInt16LE(26) + h.readUInt16LE(28);
|
|
197
|
+
const body = Buffer.alloc(e.csize);
|
|
198
|
+
fs.readSync(fd, body, 0, e.csize, start);
|
|
199
|
+
const data = e.method === 0 ? body : e.method === 8 ? zlib.inflateRawSync(body) : undefined;
|
|
200
|
+
if (!data)
|
|
201
|
+
throw new BadZip(`${e.name}: a compression strom does not read (${e.method})`);
|
|
202
|
+
if (data.length !== e.size || zlib.crc32(data) !== e.crc)
|
|
203
|
+
throw new BadZip(`${e.name} is damaged in the ZIP file`);
|
|
204
|
+
return data;
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
fs.closeSync(fd);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/** Unpack into a folder (which must not hold the names already). Links and the system's own litter are left out. */
|
|
211
|
+
export function unzipTo(file, dir, entries = readZip(file)) {
|
|
212
|
+
const written = [];
|
|
213
|
+
for (const e of entries) {
|
|
214
|
+
if (e.link || e.name.startsWith("__MACOSX/") || path.posix.basename(e.name) === ".DS_Store")
|
|
215
|
+
continue;
|
|
216
|
+
const to = path.join(dir, ...e.name.split("/").filter(Boolean));
|
|
217
|
+
if (e.dir) {
|
|
218
|
+
fs.mkdirSync(to, { recursive: true });
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
222
|
+
fs.writeFileSync(to, readEntry(file, e));
|
|
223
|
+
written.push(e.name);
|
|
224
|
+
}
|
|
225
|
+
return written;
|
|
226
|
+
}
|