strom-research 1.0.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/LICENSE +373 -0
- package/README.md +142 -0
- package/assets/lang/cs.json +302 -0
- package/assets/lang/de.json +302 -0
- package/assets/method/core.md +43 -0
- package/assets/method/enrich.md +11 -0
- package/assets/method/intake.md +30 -0
- package/assets/method/link.md +28 -0
- package/assets/method/locate.md +28 -0
- package/assets/method/narrate.md +13 -0
- package/assets/method/reading.md +62 -0
- package/assets/method/recording.md +59 -0
- package/assets/method/request.md +10 -0
- package/assets/method/verify.md +17 -0
- package/assets/plugins/README.md +23 -0
- package/assets/plugins/connectors/DISCOVERY.md +159 -0
- package/assets/plugins/connectors/README.md +376 -0
- package/assets/plugins/connectors/sdk.ts +168 -0
- package/assets/plugins/connectors/template.ts +38 -0
- package/assets/plugins/gitignore +4 -0
- package/dist/agents/files.js +313 -0
- package/dist/agents/global.js +257 -0
- package/dist/agents/launch.js +36 -0
- package/dist/agents/profiles.js +95 -0
- package/dist/brief/brief.js +345 -0
- package/dist/cli/commit.js +44 -0
- package/dist/cli/context.js +311 -0
- package/dist/cli/execute.js +154 -0
- package/dist/cli/fixes.js +78 -0
- package/dist/cli/format.js +53 -0
- package/dist/cli/help.js +59 -0
- package/dist/cli/main.js +152 -0
- package/dist/cli/menu.js +212 -0
- package/dist/cli/registry.js +96 -0
- package/dist/cli/ui.js +266 -0
- package/dist/cli/wizard.js +142 -0
- package/dist/cli.js +14 -0
- package/dist/commands/analysis.js +622 -0
- package/dist/commands/batch.js +181 -0
- package/dist/commands/checks.js +153 -0
- package/dist/commands/connectors.js +1377 -0
- package/dist/commands/guide.js +160 -0
- package/dist/commands/index.js +19 -0
- package/dist/commands/intake.js +234 -0
- package/dist/commands/media.js +406 -0
- package/dist/commands/meta.js +195 -0
- package/dist/commands/output.js +117 -0
- package/dist/commands/people.js +664 -0
- package/dist/commands/read.js +199 -0
- package/dist/commands/research.js +139 -0
- package/dist/commands/session.js +605 -0
- package/dist/commands/setup.js +465 -0
- package/dist/commands/sources.js +634 -0
- package/dist/commands/start.js +383 -0
- package/dist/commands/story.js +75 -0
- package/dist/commands/tasks.js +436 -0
- package/dist/commands/trees.js +128 -0
- package/dist/core/actions.js +852 -0
- package/dist/core/age.js +95 -0
- package/dist/core/apps.js +74 -0
- package/dist/core/assets.js +34 -0
- package/dist/core/awake.js +33 -0
- package/dist/core/browser.js +281 -0
- package/dist/core/calibration.js +48 -0
- package/dist/core/check.js +112 -0
- package/dist/core/chromium.js +88 -0
- package/dist/core/config.js +348 -0
- package/dist/core/connector.js +811 -0
- package/dist/core/deps.js +73 -0
- package/dist/core/dialog.js +61 -0
- package/dist/core/errors.js +89 -0
- package/dist/core/evidence.js +58 -0
- package/dist/core/frontier.js +219 -0
- package/dist/core/gdate.js +77 -0
- package/dist/core/git.js +300 -0
- package/dist/core/guard.js +124 -0
- package/dist/core/http2.js +76 -0
- package/dist/core/import.js +541 -0
- package/dist/core/install.js +28 -0
- package/dist/core/integrity.js +219 -0
- package/dist/core/json.js +87 -0
- package/dist/core/lang.js +70 -0
- package/dist/core/live.js +244 -0
- package/dist/core/lock.js +112 -0
- package/dist/core/logins.js +67 -0
- package/dist/core/media.js +223 -0
- package/dist/core/model.js +101 -0
- package/dist/core/net.js +366 -0
- package/dist/core/open.js +29 -0
- package/dist/core/paths.js +84 -0
- package/dist/core/people.js +283 -0
- package/dist/core/phrases.js +85 -0
- package/dist/core/queue.js +113 -0
- package/dist/core/reader.js +76 -0
- package/dist/core/records.js +105 -0
- package/dist/core/roles.js +30 -0
- package/dist/core/schema.js +261 -0
- package/dist/core/seal.js +77 -0
- package/dist/core/self.js +40 -0
- package/dist/core/session.js +155 -0
- package/dist/core/shortcut.js +90 -0
- package/dist/core/stories.js +61 -0
- package/dist/core/stromapp.js +138 -0
- package/dist/core/text.js +104 -0
- package/dist/core/tree.js +507 -0
- package/dist/core/uninstall.js +128 -0
- package/dist/core/update.js +193 -0
- package/dist/core/validate.js +260 -0
- package/dist/core/views.js +164 -0
- package/dist/core/which.js +51 -0
- package/dist/core/workers.js +42 -0
- package/dist/gedcom/export.js +454 -0
- package/dist/gedcom/labels.js +103 -0
- package/dist/gedcom/lines.js +91 -0
- package/dist/gedcom/parse.js +53 -0
- package/dist/gedcom/validate.js +183 -0
- package/dist/image/image.js +223 -0
- package/dist/image/index.js +114 -0
- package/dist/image/jpeg-decode.js +552 -0
- package/dist/image/jpeg-encode.js +254 -0
- package/dist/image/png.js +241 -0
- package/dist/runners/antigravity.js +70 -0
- package/dist/runners/claude.js +179 -0
- package/dist/runners/codex.js +45 -0
- package/dist/runners/index.js +13 -0
- package/dist/runners/jsonl.js +86 -0
- package/dist/runners/opencode.js +50 -0
- package/dist/runners/runner.js +63 -0
- package/dist/runners/script.js +58 -0
- package/package.json +44 -0
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
// media add · list · show · view — scans and photos of records.
|
|
2
|
+
//
|
|
3
|
+
// An image is registered once (its content hash is its identity), stored in
|
|
4
|
+
// the shared media store, and tied to its record set and image number. It is
|
|
5
|
+
// looked at only through views; only a registered image can be cited.
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { register } from "../cli/registry.js";
|
|
9
|
+
import { lines, moreLine, paginate, runs, shellArg, table } from "../cli/format.js";
|
|
10
|
+
import { UsageError } from "../core/errors.js";
|
|
11
|
+
import { makeNote } from "../core/actions.js";
|
|
12
|
+
import { collectFiles, fileSha256, findImage, imageNumbers, imageOfRef, inboxFolders, inputPath, mimeOf, otherCopies, parseImageList, regionText, sharperPart, storeShared } from "../core/media.js";
|
|
13
|
+
import { create, normId, requireRecord, update } from "../core/records.js";
|
|
14
|
+
import { imageSizeOfFile } from "../image/index.js";
|
|
15
|
+
import { imageOf, pageOf } from "../core/calibration.js";
|
|
16
|
+
import { describeView, makeView, partRegion, viewRegion, VIEW_MAX } from "../core/views.js";
|
|
17
|
+
import { listConnectors, missingConsents } from "../core/connector.js";
|
|
18
|
+
const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".tif", ".tiff", ".gif", ".webp", ".heic", ".jp2", ".bmp"]);
|
|
19
|
+
/**
|
|
20
|
+
* Register images of a record set: stored once by content, each with its number
|
|
21
|
+
* and provenance. Tasks that waited for images of the book go back into the queue.
|
|
22
|
+
*/
|
|
23
|
+
export function registerImages(tree, shared, items, recordset) {
|
|
24
|
+
const known = new Map(tree.list("media").map((m) => [m.sha, m]));
|
|
25
|
+
const added = [];
|
|
26
|
+
const again = [];
|
|
27
|
+
const clashes = [];
|
|
28
|
+
const copies = [];
|
|
29
|
+
tree.withTreeLock(() => {
|
|
30
|
+
for (const it of items) {
|
|
31
|
+
const sha = fileSha256(it.file);
|
|
32
|
+
const k = known.get(sha);
|
|
33
|
+
if (k) {
|
|
34
|
+
again.push(k.id);
|
|
35
|
+
// the same scan as another image: a portal serving another book's images, or a file given the wrong number
|
|
36
|
+
const as = (m) => `${m.recordset ?? "no record set"}${m.image !== undefined ? `:${m.image}` : ""}${m.part ? ` part ${regionText(m.part)}` : ""}`;
|
|
37
|
+
if (as(k) !== as({ recordset, image: it.image, part: it.part }))
|
|
38
|
+
clashes.push(`${as({ recordset, image: it.image, part: it.part })} is the same file as ${k.id} (${as(k)}) — not registered again: the portal may have given another book's image, or a number is wrong; check before citing either`);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const stored = tree.dryRun ? it.file : storeShared(shared, it.file, sha);
|
|
42
|
+
const size = imageSizeOfFile(it.file);
|
|
43
|
+
const m = create(tree, "media", {
|
|
44
|
+
sha,
|
|
45
|
+
file: path.relative(shared, stored).split(path.sep).join("/"),
|
|
46
|
+
mime: mimeOf(it.file),
|
|
47
|
+
size: fs.statSync(it.file).size,
|
|
48
|
+
...(size ? { width: size.width, height: size.height } : {}),
|
|
49
|
+
recordset,
|
|
50
|
+
image: it.image,
|
|
51
|
+
part: it.part,
|
|
52
|
+
url: it.url,
|
|
53
|
+
from: it.from,
|
|
54
|
+
fetched: it.fetched,
|
|
55
|
+
accessed: new Date().toISOString().slice(0, 10),
|
|
56
|
+
}, (id) => `+${id} ${it.part ? `part ${regionText(it.part)} of image` : "image"}${it.image !== undefined ? ` ${it.image}` : ""}${recordset ? ` of ${recordset}` : ""}`, recordset ? [recordset] : []);
|
|
57
|
+
known.set(sha, m);
|
|
58
|
+
added.push(m);
|
|
59
|
+
// another copy of an image already there (a reduced download, then the full one): the sharpest is the image
|
|
60
|
+
const others = otherCopies([...known.values()], m);
|
|
61
|
+
if (others.length && recordset && it.image !== undefined) {
|
|
62
|
+
const best = findImage([...known.values()], recordset, it.image);
|
|
63
|
+
const size = (x) => (x.width ? ` ${x.width}×${x.height}` : "");
|
|
64
|
+
copies.push(`${recordset}:${it.image} has ${others.length + 1} copies — ${best.id === m.id ? `${m.id}${size(m)} is the sharpest: it is the image now` : `${best.id}${size(best)} is sharper and stays the image`}; views of the others use it (${others.map((x) => `${x.id}${size(x)}`).join(", ")})`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
const woken = [];
|
|
69
|
+
if (recordset && added.length)
|
|
70
|
+
for (const t of tree.list("task").filter((t) => t.state === "waiting" && (t.where.includes(recordset) || (t.waitingOn ?? "").includes(recordset)))) {
|
|
71
|
+
// what it asked for and what is still missing, for the session that takes it up
|
|
72
|
+
const have = new Set(tree.list("media").filter((m) => m.recordset === recordset).map((m) => m.image)); // a part counts: it was asked for too
|
|
73
|
+
const missing = t.awaits?.recordset === recordset ? (parseImageList(t.awaits.images) ?? []).filter((n) => !have.has(n)) : [];
|
|
74
|
+
const note = `${added.length} image(s) of ${recordset} registered${t.awaits ? ` (it waited for images ${t.awaits.images}${missing.length ? `; still missing: ${runs(missing)}` : ""})` : ""}`;
|
|
75
|
+
update(tree, t.id, "task", (x) => {
|
|
76
|
+
const { awaits: _, ...rest } = x;
|
|
77
|
+
return { ...rest, state: "open", notes: [...x.notes, makeNote(tree, note)] };
|
|
78
|
+
}, {
|
|
79
|
+
op: "task.wake",
|
|
80
|
+
summary: `${t.id} wake: images of ${recordset} arrived`,
|
|
81
|
+
});
|
|
82
|
+
woken.push(t.id);
|
|
83
|
+
}
|
|
84
|
+
return { added, again, woken, clashes, copies };
|
|
85
|
+
}
|
|
86
|
+
function sharedDir(ctx) {
|
|
87
|
+
return ctx.settings.shared().value;
|
|
88
|
+
}
|
|
89
|
+
function pageLabel(tree, m) {
|
|
90
|
+
if (m.page)
|
|
91
|
+
return m.page;
|
|
92
|
+
const b = m.recordset && m.image !== undefined ? tree.get(m.recordset) : undefined;
|
|
93
|
+
return (b && pageOf(b, m.image)) ?? "";
|
|
94
|
+
}
|
|
95
|
+
/** "M0012", "B0001:57", "I0002" — or a record set with --image / --page. */
|
|
96
|
+
function resolveTarget(tree, shared, ref, opts) {
|
|
97
|
+
const at = /^([Bb]\d+):(\d+)$/.exec(ref.trim());
|
|
98
|
+
if (at || /^[Bb]\d+$/.test(ref.trim())) {
|
|
99
|
+
const b = requireRecord(tree, at ? at[1] : ref, "recordset");
|
|
100
|
+
let image = at ? Number(at[2]) : opts.image !== undefined ? Number(opts.image) : undefined;
|
|
101
|
+
if (image === undefined && opts.page !== undefined) {
|
|
102
|
+
image = imageOf(b, Number.parseInt(String(opts.page), 10));
|
|
103
|
+
if (image === undefined)
|
|
104
|
+
throw new UsageError(`${b.id} has no consistent calibration to find page ${opts.page}`, { hint: `measure it: strom recordset calibrate ${b.id} --point <image>=<page>` });
|
|
105
|
+
}
|
|
106
|
+
if (image === undefined)
|
|
107
|
+
throw new UsageError("which image?", { hint: `${b.id}:57, or --image 57, or --page 112` });
|
|
108
|
+
const m = imageOfRef(tree.list("media"), b.id, image);
|
|
109
|
+
if (!m)
|
|
110
|
+
throw new UsageError(`image ${image} of ${b.id} is not registered`, { hint: `strom media list --recordset ${b.id} · strom media add <files> --recordset ${b.id}` });
|
|
111
|
+
return { key: m.id, file: path.join(shared, m.file), media: m };
|
|
112
|
+
}
|
|
113
|
+
const id = normId(ref);
|
|
114
|
+
if (id.startsWith("I")) {
|
|
115
|
+
const i = requireRecord(tree, id, "input");
|
|
116
|
+
const file = inputPath(tree, i);
|
|
117
|
+
if (!file)
|
|
118
|
+
throw new UsageError(`${i.id} has no file`);
|
|
119
|
+
return { key: i.id, file };
|
|
120
|
+
}
|
|
121
|
+
const m = requireRecord(tree, id, "media");
|
|
122
|
+
return { key: m.id, file: path.join(shared, m.file), media: m };
|
|
123
|
+
}
|
|
124
|
+
function viewSpec(opts) {
|
|
125
|
+
const num = (k) => {
|
|
126
|
+
if (opts[k] === undefined)
|
|
127
|
+
return undefined;
|
|
128
|
+
const n = Number(opts[k]);
|
|
129
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
130
|
+
throw new UsageError(`--${k} must be a positive number`);
|
|
131
|
+
return n;
|
|
132
|
+
};
|
|
133
|
+
const half = opts.half;
|
|
134
|
+
if (half && !["left", "right", "top", "bottom"].includes(half))
|
|
135
|
+
throw new UsageError(`invalid --half "${half}"`, { hint: "left, right, top or bottom" });
|
|
136
|
+
const rot = opts.rotate === undefined ? undefined : Number(opts.rotate);
|
|
137
|
+
if (rot !== undefined && ![90, 180, 270].includes(rot))
|
|
138
|
+
throw new UsageError("--rotate must be 90, 180 or 270");
|
|
139
|
+
return {
|
|
140
|
+
crop: opts.crop,
|
|
141
|
+
half: half,
|
|
142
|
+
scale: num("scale"),
|
|
143
|
+
max: num("max"),
|
|
144
|
+
contrast: Boolean(opts.contrast),
|
|
145
|
+
grey: Boolean(opts.grey),
|
|
146
|
+
grid: Boolean(opts.grid),
|
|
147
|
+
rotate: rot,
|
|
148
|
+
png: Boolean(opts.png),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
register({
|
|
152
|
+
path: ["media", "add"],
|
|
153
|
+
summary: "Register scans or photos of records (a folder, files, or the shared inbox) — tied to a record set",
|
|
154
|
+
group: "sources",
|
|
155
|
+
tree: true,
|
|
156
|
+
writes: true,
|
|
157
|
+
lock: "sections",
|
|
158
|
+
description: "Each image is stored once in the shared media store (by its content) and gets an ID (M…). The image number\n" +
|
|
159
|
+
"is the last number in the file name (s0057.jpg → 57), or counted from --first. Look at them with strom media view.",
|
|
160
|
+
args: [{ name: "paths", description: "image files or folders", variadic: true }],
|
|
161
|
+
options: [
|
|
162
|
+
{ name: "recordset", type: "string", value: "<B…>", description: "the book or collection the images are of" },
|
|
163
|
+
{ name: "image", type: "string", value: "<n>", description: "image number (one file)" },
|
|
164
|
+
{ name: "first", type: "string", value: "<n>", description: "number the images in file order from n (instead of from their names)" },
|
|
165
|
+
{ name: "url", type: "string", value: "<url>", description: "where the images come from (portal, catalog entry)" },
|
|
166
|
+
{ name: "from", type: "string", value: "<text>", description: "who provided them or how they were obtained" },
|
|
167
|
+
{ name: "inbox", type: "boolean", description: "take the files the user put in the shared inbox (they are moved into the store); a folder there is named as the argument — one folder, one record set" },
|
|
168
|
+
{ name: "half", type: "string", value: "<side>", description: "the file is a half of image n: left, right, top or bottom (a half page saved on its own)" },
|
|
169
|
+
{ name: "crop", type: "string", value: "<x,y,w,h>", description: "the file is this part of image n: fractions of it, or pixels of the registered image (a detail saved from the viewer)" },
|
|
170
|
+
],
|
|
171
|
+
examples: [
|
|
172
|
+
"strom media add ~/Downloads/tynec17 --recordset B0001 --url https://archive.example.org/register/17",
|
|
173
|
+
"strom media add --inbox register-17 --recordset B0001",
|
|
174
|
+
"strom media add --inbox --recordset B0001",
|
|
175
|
+
"strom media add ~/Downloads/40-left.jpg --recordset B0001 --image 40 --half left",
|
|
176
|
+
],
|
|
177
|
+
run(ctx, { args, opts }) {
|
|
178
|
+
const tree = ctx.tree();
|
|
179
|
+
const shared = sharedDir(ctx);
|
|
180
|
+
const inbox = path.join(shared, "inbox");
|
|
181
|
+
let paths;
|
|
182
|
+
if (opts.inbox) {
|
|
183
|
+
// Each folder in the inbox is one download: never two books into one record set.
|
|
184
|
+
const folders = inboxFolders(inbox);
|
|
185
|
+
if (args.length === 0 && folders.length > 1)
|
|
186
|
+
throw new UsageError(`the inbox holds ${folders.length} downloads — one record set each`, {
|
|
187
|
+
hint: folders.map((f) => `strom media add --inbox ${f.folder ? shellArg(f.folder) : "."} --recordset B… (${f.files.length} file${f.files.length > 1 ? "s" : ""})`).join("\n"),
|
|
188
|
+
});
|
|
189
|
+
const only = folders.length === 1 && folders[0].folder ? path.join(inbox, folders[0].folder) : inbox;
|
|
190
|
+
// a folder named in any Unicode form (NFD from a macOS shell, NFC on Linux)
|
|
191
|
+
const inInbox = (a) => {
|
|
192
|
+
const hit = folders.find((f) => f.folder && f.folder.normalize("NFC") === a.normalize("NFC").replace(/[\\/]+$/, ""));
|
|
193
|
+
return hit ? path.join(inbox, hit.folder) : path.resolve(inbox, a);
|
|
194
|
+
};
|
|
195
|
+
paths = args.length ? args.map(inInbox) : [only];
|
|
196
|
+
for (const p of paths)
|
|
197
|
+
if (path.relative(inbox, p).startsWith(".."))
|
|
198
|
+
throw new UsageError(`${ctx.display(p)} is not in the inbox`, { hint: "strom media add --inbox <folder in the inbox> --recordset B…" });
|
|
199
|
+
// "." = the files lying in the inbox itself, not its folders
|
|
200
|
+
paths = paths.flatMap((p) => (p === inbox ? (folders.find((f) => f.folder === "")?.files ?? []) : [p]));
|
|
201
|
+
}
|
|
202
|
+
else
|
|
203
|
+
paths = args.map((a) => ctx.resolvePath(a));
|
|
204
|
+
if (paths.length === 0)
|
|
205
|
+
throw new UsageError("give files or folders, or --inbox");
|
|
206
|
+
for (const p of paths)
|
|
207
|
+
if (!fs.existsSync(p))
|
|
208
|
+
throw new UsageError(`no such file or folder: ${ctx.display(p)}`);
|
|
209
|
+
const files = collectFiles(paths).filter((f) => IMAGE_EXT.has(path.extname(f).toLowerCase()));
|
|
210
|
+
// numbers are read per folder: one download names its files one way
|
|
211
|
+
const numbers = new Map();
|
|
212
|
+
for (const dir of new Set(files.map((f) => path.dirname(f)))) {
|
|
213
|
+
const group = files.filter((f) => path.dirname(f) === dir);
|
|
214
|
+
imageNumbers(group).forEach((n, i) => numbers.set(group[i], n));
|
|
215
|
+
}
|
|
216
|
+
if (files.length === 0)
|
|
217
|
+
throw new UsageError("no images there", { hint: "JPEG, PNG, TIFF … files; documents go in with strom intake" });
|
|
218
|
+
// a folder the user filled for a waiting task (strom task wait --images) says which book and which images
|
|
219
|
+
const folderOf = (p) => (opts.inbox && !path.relative(inbox, p).startsWith("..") ? path.relative(inbox, p).split(path.sep)[0]?.normalize("NFC") : undefined);
|
|
220
|
+
const folder = paths.length === 1 ? folderOf(paths[0]) : undefined;
|
|
221
|
+
const awaited = folder
|
|
222
|
+
? tree.list("task").find((t) => t.state === "waiting" && t.awaits && t.awaits.folder.normalize("NFC") === folder && (!opts.recordset || normId(String(opts.recordset), "recordset") === t.awaits.recordset))?.awaits
|
|
223
|
+
: undefined;
|
|
224
|
+
const named = folder ? /^([Bb]\d+)(?=\s|$)/.exec(folder)?.[1] : undefined;
|
|
225
|
+
const recordset = opts.recordset
|
|
226
|
+
? requireRecord(tree, String(opts.recordset), "recordset").id
|
|
227
|
+
: (awaited?.recordset ?? (named && tree.get(normId(named, "recordset")) ? normId(named, "recordset") : undefined));
|
|
228
|
+
if (opts.image !== undefined && files.length > 1)
|
|
229
|
+
throw new UsageError("--image is for one file; for many use --first or numbers in the file names");
|
|
230
|
+
// a part of an image saved on its own: registered with its image, as a part fetched sharper is
|
|
231
|
+
const wantsPart = opts.half !== undefined || opts.crop !== undefined;
|
|
232
|
+
if (wantsPart && (files.length > 1 || !opts.recordset))
|
|
233
|
+
throw new UsageError("a part is one file of one image of a record set", { hint: "strom media add <file> --recordset B… --image <n> --half left" });
|
|
234
|
+
const first = opts.first === undefined ? undefined : Number(opts.first);
|
|
235
|
+
const items = files.map((file, i) => ({
|
|
236
|
+
file,
|
|
237
|
+
image: opts.image !== undefined ? Number(opts.image) : first !== undefined ? first + i : numbers.get(file),
|
|
238
|
+
url: opts.url,
|
|
239
|
+
// the original file name always stays: it is how the archive numbered the image
|
|
240
|
+
from: opts.from ? `${opts.from} · ${path.basename(file)}` : path.basename(file),
|
|
241
|
+
}));
|
|
242
|
+
if (wantsPart && recordset) {
|
|
243
|
+
const it = items[0];
|
|
244
|
+
if (it.image === undefined)
|
|
245
|
+
throw new UsageError("which image is it a part of? --image <n>", { hint: "strom media add <file> --recordset B… --image <n> --half left" });
|
|
246
|
+
const whole = findImage(tree.list("media").filter((m) => !m.part), recordset, it.image);
|
|
247
|
+
it.part = partRegion(opts, whole);
|
|
248
|
+
}
|
|
249
|
+
if (recordset) {
|
|
250
|
+
// an image of a book without its number cannot be cited — nor found again
|
|
251
|
+
const bare = items.filter((it) => it.image === undefined);
|
|
252
|
+
if (bare.length)
|
|
253
|
+
throw new UsageError(`${bare.length} file(s) without an image number in the name: ${bare.slice(0, 5).map((it) => path.basename(it.file)).join(", ")}${bare.length > 5 ? " …" : ""}`, {
|
|
254
|
+
hint: "name each by its number as the portal's viewer counts it (9.jpg), or --image <n> for one file, --first <n> for files in order",
|
|
255
|
+
});
|
|
256
|
+
// saved under the portal's own file name, a number can mean something else (×10, a call number)
|
|
257
|
+
const want = awaited ? parseImageList(awaited.images) : undefined;
|
|
258
|
+
if (want && !opts.first && opts.image === undefined && !items.some((it) => want.includes(it.image)))
|
|
259
|
+
throw new UsageError(`images ${awaited.images} of ${recordset} were asked for, but the file names give ${runs(items.map((it) => it.image))}`, {
|
|
260
|
+
hint: "rename the files by the image number the viewer shows (9.jpg), or --image <n> for one file",
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
const { added, again, woken, clashes, copies } = registerImages(tree, shared, items, recordset);
|
|
264
|
+
if (opts.inbox && !tree.dryRun) {
|
|
265
|
+
for (const f of files)
|
|
266
|
+
fs.rmSync(f, { force: true }); // now in the store
|
|
267
|
+
for (const p of paths)
|
|
268
|
+
if (p !== inbox && fs.existsSync(p) && fs.statSync(p).isDirectory() && collectFiles([p]).length === 0)
|
|
269
|
+
fs.rmSync(p, { recursive: true, force: true });
|
|
270
|
+
}
|
|
271
|
+
const nums = added.map((m) => m.image).filter((n) => n !== undefined);
|
|
272
|
+
const text = lines(added.length
|
|
273
|
+
? `${added.length} image(s) registered: ${added[0].id}${added.length > 1 ? `–${added.at(-1).id}` : ""}${recordset ? ` of ${recordset}` : ""}${nums.length ? ` (images ${runs(nums)})` : ""}`
|
|
274
|
+
: "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);
|
|
275
|
+
return { text, data: { added: added.map((m) => ({ id: m.id, image: m.image })), again, woken, clashes, copies } };
|
|
276
|
+
},
|
|
277
|
+
}, {
|
|
278
|
+
path: ["media", "list"],
|
|
279
|
+
summary: "Registered images (of one record set, a range of images)",
|
|
280
|
+
group: "sources",
|
|
281
|
+
tree: true,
|
|
282
|
+
options: [
|
|
283
|
+
{ name: "recordset", type: "string", value: "<B…>", description: "only this record set" },
|
|
284
|
+
{ name: "images", type: "string", value: "<from-to>", description: "only these image numbers, e.g. 90-120" },
|
|
285
|
+
],
|
|
286
|
+
run(ctx, { opts }) {
|
|
287
|
+
const tree = ctx.tree();
|
|
288
|
+
let all = tree.list("media");
|
|
289
|
+
if (opts.recordset) {
|
|
290
|
+
const b = requireRecord(tree, String(opts.recordset), "recordset").id;
|
|
291
|
+
all = all.filter((m) => m.recordset === b);
|
|
292
|
+
}
|
|
293
|
+
if (opts.images) {
|
|
294
|
+
const m = /^(\d+)(?:-(\d+))?$/.exec(String(opts.images));
|
|
295
|
+
if (!m)
|
|
296
|
+
throw new UsageError("--images must be like 90-120");
|
|
297
|
+
const lo = Number(m[1]);
|
|
298
|
+
const hi = Number(m[2] ?? m[1]);
|
|
299
|
+
all = all.filter((x) => x.image !== undefined && x.image >= lo && x.image <= hi);
|
|
300
|
+
}
|
|
301
|
+
all.sort((a, b) => (a.recordset ?? "").localeCompare(b.recordset ?? "") || (a.image ?? 0) - (b.image ?? 0) || Number(!!a.part) - Number(!!b.part));
|
|
302
|
+
const page = paginate(all, ctx.limit, ctx.page);
|
|
303
|
+
return {
|
|
304
|
+
text: all.length
|
|
305
|
+
? lines(table(page.items.map((m) => [m.id, m.recordset ? `${m.recordset}:${m.image ?? "?"}` : "", m.part ? `part ${regionText(m.part)}` : "", pageLabel(tree, m), m.width ? `${m.width}×${m.height}` : "", `${Math.round(m.size / 1024)} kB`])), moreLine(page, "strom media list"))
|
|
306
|
+
: "no images registered → strom media add <folder> --recordset B…",
|
|
307
|
+
data: { total: all.length, media: page.items.map((m) => ({ id: m.id, recordset: m.recordset, image: m.image, part: m.part, page: pageLabel(tree, m) || undefined, width: m.width, height: m.height })) },
|
|
308
|
+
};
|
|
309
|
+
},
|
|
310
|
+
}, {
|
|
311
|
+
path: ["media", "show"],
|
|
312
|
+
summary: "One image: where it is from, its page, the sources on it",
|
|
313
|
+
group: "sources",
|
|
314
|
+
tree: true,
|
|
315
|
+
args: [{ name: "image", description: "M0012 or B0001:57", required: true }],
|
|
316
|
+
run(ctx, { args, opts }) {
|
|
317
|
+
const tree = ctx.tree();
|
|
318
|
+
const t = resolveTarget(tree, sharedDir(ctx), args[0], opts);
|
|
319
|
+
const m = t.media;
|
|
320
|
+
if (!m)
|
|
321
|
+
throw new UsageError("not a registered image", { hint: "strom media list" });
|
|
322
|
+
const sources = tree.list("source").filter((s) => s.media?.includes(m.id));
|
|
323
|
+
const b = m.recordset ? tree.get(m.recordset) : undefined;
|
|
324
|
+
const all = tree.list("media");
|
|
325
|
+
const parts = !m.part && m.recordset && m.image !== undefined ? all.filter((x) => x.part && x.recordset === m.recordset && x.image === m.image) : [];
|
|
326
|
+
const copies = otherCopies(all, m);
|
|
327
|
+
const image = copies.length && m.recordset && m.image !== undefined ? findImage(all, m.recordset, m.image) : undefined;
|
|
328
|
+
return {
|
|
329
|
+
text: lines(`${m.id} ${m.part ? `part ${regionText(m.part)} of ` : ""}${b ? `image ${m.image ?? "?"} of ${b.id} ${b.title}` : "image"}${pageLabel(tree, m) ? ` · page ${pageLabel(tree, m)}` : ""}`, `${m.width ? `${m.width}×${m.height} px · ` : ""}${Math.round(m.size / 1024)} kB · ${m.mime}${m.from ? ` · from ${m.from}` : ""}${m.url ? ` · ${m.url}` : ""}`, parts.length ? `parts of it, sharper (a view of the image uses them by itself): ${parts.map((p) => `${p.id} ${regionText(p.part)}${p.width ? ` ${p.width}×${p.height}` : ""}`).join(" · ")}` : undefined, copies.length
|
|
330
|
+
? `other copies of the image: ${copies.map((c) => `${c.id}${c.width ? ` ${c.width}×${c.height}` : ""}`).join(" · ")} — the image is the sharpest (${image.id}); a view of any copy uses it`
|
|
331
|
+
: undefined, sources.length ? `sources on it: ${sources.map((s) => `${s.id} ${s.title}`).join(" · ")}` : "no source cites it yet", `view: strom media view ${m.id} [--half left|right] [--crop x,y,w,h] [--grid]`),
|
|
332
|
+
data: { media: m, parts: parts.map((p) => p.id), copies: copies.map((c) => c.id), sources: sources.map((s) => s.id), page: pageLabel(tree, m) || undefined },
|
|
333
|
+
};
|
|
334
|
+
},
|
|
335
|
+
}, {
|
|
336
|
+
path: ["media", "view"],
|
|
337
|
+
summary: "Make a view of an image to look at: a crop, a half page, enlarged, more contrast, a grid",
|
|
338
|
+
group: "sources",
|
|
339
|
+
tree: true,
|
|
340
|
+
description: `Writes the view to .strom/views/ and prints its path: open that file with your image reader. Views are at most\n` +
|
|
341
|
+
`${VIEW_MAX} px on the long side unless --scale says otherwise. Browse whole images reduced; read an entry by\n` +
|
|
342
|
+
"cropping it — use --grid first to see where it is (the labels are tenths of the image).",
|
|
343
|
+
args: [{ name: "image", description: "M0012, B0001:57 (record set:image), B0001 with --page, or an input I0002", required: true }],
|
|
344
|
+
options: [
|
|
345
|
+
{ name: "crop", type: "string", value: "<x,y,w,h>", description: "part of the image: fractions (0.1,0.35,0.4,0.2) or pixels" },
|
|
346
|
+
{ name: "half", type: "string", value: "<side>", description: "left or right page of a double page (top, bottom)" },
|
|
347
|
+
{ name: "scale", type: "string", value: "<f>", description: "size of the result: 2 = twice the original pixels" },
|
|
348
|
+
{ name: "max", type: "string", value: "<px>", description: `longest side when not scaled (default ${VIEW_MAX})` },
|
|
349
|
+
{ name: "contrast", type: "boolean", description: "stretch faded ink (on the part shown)" },
|
|
350
|
+
{ name: "grey", type: "boolean", description: "greyscale" },
|
|
351
|
+
{ name: "grid", type: "boolean", description: "overlay a grid of tenths with labels, to point at a place" },
|
|
352
|
+
{ name: "rotate", type: "string", value: "<deg>", description: "90, 180 or 270" },
|
|
353
|
+
{ name: "image", type: "string", value: "<n>", description: "image number, with a record set ID" },
|
|
354
|
+
{ name: "page", type: "string", value: "<n>", description: "page or folio, with a calibrated record set ID" },
|
|
355
|
+
{ name: "png", type: "boolean", description: "lossless PNG instead of JPEG" },
|
|
356
|
+
],
|
|
357
|
+
examples: [
|
|
358
|
+
"strom media view B0001:2 --grid",
|
|
359
|
+
"strom media view B0001:2 --half left --contrast",
|
|
360
|
+
"strom media view M0001 --crop 0.05,0.40,0.45,0.18",
|
|
361
|
+
"strom media view B0001 --page 112",
|
|
362
|
+
],
|
|
363
|
+
run(ctx, { args, opts }) {
|
|
364
|
+
const tree = ctx.tree();
|
|
365
|
+
const shared = sharedDir(ctx);
|
|
366
|
+
const t = resolveTarget(tree, shared, args[0], opts);
|
|
367
|
+
const spec = viewSpec(opts);
|
|
368
|
+
const all = tree.list("media");
|
|
369
|
+
// a part of the image fetched sharper (or a sharper copy of all of it) shows the same place with more detail: it is used by itself
|
|
370
|
+
let shown = t.media;
|
|
371
|
+
let v;
|
|
372
|
+
let sharper;
|
|
373
|
+
if (t.media?.width && t.media.height && !t.media.part) {
|
|
374
|
+
const px = viewRegion(spec, t.media.width, t.media.height);
|
|
375
|
+
const s = sharperPart(all, t.media, { x: px.x / t.media.width, y: px.y / t.media.height, w: px.w / t.media.width, h: px.h / t.media.height });
|
|
376
|
+
if (s) {
|
|
377
|
+
sharper = s;
|
|
378
|
+
shown = s.part;
|
|
379
|
+
const whole = !s.part.part && !spec.crop && !spec.half;
|
|
380
|
+
v = makeView(tree, path.join(shared, s.part.file), s.part.id, { ...spec, half: undefined, crop: whole ? undefined : regionText(s.crop) });
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
v ??= makeView(tree, t.file, t.key, spec);
|
|
384
|
+
const page = t.media ? pageLabel(tree, t.media) : "";
|
|
385
|
+
// still too little detail: a connector that fetched the image may fetch this part of it sharper
|
|
386
|
+
const base = shown?.part ?? { x: 0, y: 0, w: 1, h: 1 };
|
|
387
|
+
const inWhole = {
|
|
388
|
+
x: base.x + (v.region.x / v.original.width) * base.w,
|
|
389
|
+
y: base.y + (v.region.y / v.original.height) * base.h,
|
|
390
|
+
w: (v.region.w / v.original.width) * base.w,
|
|
391
|
+
h: (v.region.h / v.original.height) * base.h,
|
|
392
|
+
};
|
|
393
|
+
const whole = t.media?.recordset !== undefined && t.media.image !== undefined ? findImage(all, t.media.recordset, t.media.image) : undefined;
|
|
394
|
+
const from = whole?.fetched ? listConnectors(shared).find((c) => c.name === whole.fetched.connector) : undefined;
|
|
395
|
+
const canPart = from && from.manifest.can.includes("part") && from.manifest.policy.automation !== "manual" && !missingConsents(ctx.env, from).code;
|
|
396
|
+
const fetchPart = canPart && v.scale > 1.25 && inWhole.w * inWhole.h < 0.9
|
|
397
|
+
? `strom fetch ${from.name} --recordset ${whole.recordset} --images ${whole.image} --crop ${regionText(inWhole)}`
|
|
398
|
+
: undefined;
|
|
399
|
+
return {
|
|
400
|
+
text: lines(describeView(v, (p) => ctx.display(p), fetchPart), sharper
|
|
401
|
+
? `from ${sharper.part.id}, ${sharper.part.part ? "a part of the image fetched sharper" : "a sharper copy of the image"} (${sharper.gain.toFixed(1)}× the detail of ${sharper.part.part ? "the whole image" : t.media.id})`
|
|
402
|
+
: undefined, page ? `page ${page}` : undefined),
|
|
403
|
+
data: { view: v.file, width: v.width, height: v.height, scale: v.scale, region: v.region, original: v.original, image: t.key, ...(sharper ? { from: sharper.part.id } : {}), page: page || undefined },
|
|
404
|
+
};
|
|
405
|
+
},
|
|
406
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Orientation and self-description: `strom` (no arguments), `strom guide`,
|
|
2
|
+
// `strom commands`, `strom help`. An agent with no prior knowledge learns
|
|
3
|
+
// from these what strom is and what to do next.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { newerVersion } from "../core/update.js";
|
|
7
|
+
import { appOpensResearch, installedStromApp, stromAppUrl } from "../core/stromapp.js";
|
|
8
|
+
import { register, commands, describe, GROUPS } from "../cli/registry.js";
|
|
9
|
+
import { UsageError } from "../core/errors.js";
|
|
10
|
+
import { helpFor } from "../cli/help.js";
|
|
11
|
+
import { lines, table } from "../cli/format.js";
|
|
12
|
+
import { guideText } from "./guide.js";
|
|
13
|
+
import { langName } from "../core/lang.js";
|
|
14
|
+
import { ui } from "../cli/ui.js";
|
|
15
|
+
import { VERSION } from "../core/tree.js";
|
|
16
|
+
import { gitVersion } from "../core/git.js";
|
|
17
|
+
import { linuxGitCommand } from "../core/deps.js";
|
|
18
|
+
import { verifyFast } from "../core/integrity.js";
|
|
19
|
+
import { currentSession, openSessions } from "../core/session.js";
|
|
20
|
+
import { liveHolder } from "../core/lock.js";
|
|
21
|
+
import { isAgent } from "../core/which.js";
|
|
22
|
+
import { taskQueue, waitingForUser, waitingLines } from "./tasks.js";
|
|
23
|
+
import { liveWorkers } from "../core/workers.js";
|
|
24
|
+
import { label } from "../core/people.js";
|
|
25
|
+
function orientation(ctx) {
|
|
26
|
+
const lang = ctx.uiLang();
|
|
27
|
+
const t = (key, values = {}) => ui(lang, key, values);
|
|
28
|
+
const home = ctx.settings.home()?.value;
|
|
29
|
+
const known = home ? ctx.knownTrees() : [];
|
|
30
|
+
const base = { version: VERSION, trees: known, next: { why: "", command: "" } };
|
|
31
|
+
// A person at a terminal lets an agent do the work; an agent does it itself.
|
|
32
|
+
const human = ctx.io.tty && !isAgent(ctx.env);
|
|
33
|
+
if (home)
|
|
34
|
+
base.home = home;
|
|
35
|
+
if (!home) {
|
|
36
|
+
base.next = human
|
|
37
|
+
? { why: t("ui.why.setup.human"), command: "strom setup" }
|
|
38
|
+
: { why: t("ui.why.setup.agent", { home: ctx.display(ctx.settings.suggestedHome()) }), command: "strom setup --yes --lang <code of the language the user speaks with you>" };
|
|
39
|
+
return base;
|
|
40
|
+
}
|
|
41
|
+
// Without git no family tree can be kept: that first (the user says yes to installing it).
|
|
42
|
+
if (!gitVersion(ctx.env)) {
|
|
43
|
+
base.next = { why: t(human ? "ui.why.git.human" : "ui.why.git.agent"), command: process.platform === "linux" ? linuxGitCommand() : "strom doctor --fix" };
|
|
44
|
+
return base;
|
|
45
|
+
}
|
|
46
|
+
if (!ctx.hasTree()) {
|
|
47
|
+
base.next =
|
|
48
|
+
known.length === 0
|
|
49
|
+
? { why: t("ui.why.init"), command: 'strom init "<tree name, e.g. the family surname>"' }
|
|
50
|
+
: { why: t("ui.why.pick"), command: `strom trees use "${known[0].name}"` };
|
|
51
|
+
return base;
|
|
52
|
+
}
|
|
53
|
+
const tree = ctx.tree();
|
|
54
|
+
const findings = verifyFast(tree).findings;
|
|
55
|
+
base.tree = {
|
|
56
|
+
name: tree.config.name,
|
|
57
|
+
root: tree.root,
|
|
58
|
+
lang: tree.lang,
|
|
59
|
+
persons: tree.count("person"),
|
|
60
|
+
families: tree.count("family"),
|
|
61
|
+
errors: findings.filter((f) => f.level === "error").length,
|
|
62
|
+
warnings: findings.filter((f) => f.level === "warn").length,
|
|
63
|
+
};
|
|
64
|
+
const researches = tree.list("research");
|
|
65
|
+
base.researches = researches.map((r) => ({
|
|
66
|
+
id: r.id,
|
|
67
|
+
name: r.name,
|
|
68
|
+
state: r.state,
|
|
69
|
+
focus: tree.get(r.focus) ? label(tree.get(r.focus)) : r.focus,
|
|
70
|
+
}));
|
|
71
|
+
// Other agents at work here now (conversations, a run): shown, and their sessions are theirs.
|
|
72
|
+
const workers = liveWorkers(tree.root).filter((w) => w.id !== ctx.env.STROM_WORKER);
|
|
73
|
+
const sessions = openSessions(tree);
|
|
74
|
+
base.working = workers.map((w) => {
|
|
75
|
+
const s = sessions.find((x) => x.worker === w.id || (w.id === "run" && x.runner));
|
|
76
|
+
return { who: w.label, since: w.since, ...(s ? { session: s.id } : {}), ...(s?.task ? { task: s.task } : {}) };
|
|
77
|
+
});
|
|
78
|
+
if (!base.working.length)
|
|
79
|
+
delete base.working;
|
|
80
|
+
// The results, and where the user sees them: the Strom app — installed here, or to be offered.
|
|
81
|
+
const out = path.join(tree.root, "output");
|
|
82
|
+
const wanted = ctx.settings.config.stromApp !== "no";
|
|
83
|
+
const file = path.join(out, wanted ? "tree-strom.ged" : "tree.ged");
|
|
84
|
+
if (fs.existsSync(file))
|
|
85
|
+
base.results = { file, app: !wanted ? "not-wanted" : installedStromApp(ctx.env) ? "installed" : "offer" };
|
|
86
|
+
const stories = ctx.settings.stories(tree.config);
|
|
87
|
+
base.stories = !stories.on ? "off" : stories.said ? "on" : "tell";
|
|
88
|
+
// The session this agent works in; else one nobody is working in any more.
|
|
89
|
+
const busy = (s) => (s.worker && workers.some((w) => w.id === s.worker)) || (s.runner && workers.some((w) => w.id === "run"));
|
|
90
|
+
const open = currentSession(tree, ctx.env) ?? sessions.find((s) => !busy(s) && !(s.worker && s.worker !== ctx.env.STROM_WORKER && !human));
|
|
91
|
+
const queued = taskQueue(tree).length;
|
|
92
|
+
// An agent outside the tree that strom did not start (it set strom up from the web page, or was
|
|
93
|
+
// opened in some folder): the research goes on in its own conversation, where the tree lives.
|
|
94
|
+
const rel = path.relative(tree.root, ctx.cwd);
|
|
95
|
+
const inTree = rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
96
|
+
const elsewhere = isAgent(ctx.env) && !ctx.env.STROM_WORKER && !ctx.env.STROM_SESSION && !inTree;
|
|
97
|
+
if (base.tree.errors > 0)
|
|
98
|
+
base.next = { why: t("ui.why.check"), command: "strom check" };
|
|
99
|
+
else if (elsewhere)
|
|
100
|
+
base.next = { why: t("ui.why.handover"), command: "strom chat" };
|
|
101
|
+
else if (open && human && open.runner && !liveHolder(path.join(tree.root, ".strom", "run.lock")))
|
|
102
|
+
base.next = { why: t("ui.why.runleft", { id: open.id }), command: "strom run" };
|
|
103
|
+
else if (open && human && open.runner)
|
|
104
|
+
base.next = { why: t("ui.why.running", { id: open.id }), command: "strom status" };
|
|
105
|
+
else if (open)
|
|
106
|
+
base.next = human
|
|
107
|
+
? { why: t("ui.why.open.human", { id: open.id }), command: `strom session close ${open.id} --interrupted` }
|
|
108
|
+
: { why: t("ui.why.open.agent", { id: open.id, on: open.task ? ` ${t("ui.o.on", { task: open.task })}` : "" }), command: "strom brief" };
|
|
109
|
+
else if (researches.length === 0 && queued === 0)
|
|
110
|
+
base.next = { why: t("ui.why.research"), command: 'strom research new "<name>" --new-person "<Given /Surname/>" --born "<date>" --born-place "<place>"' };
|
|
111
|
+
else if (queued === 0 && waitingForUser(tree).length)
|
|
112
|
+
base.next = { why: t("ui.why.waiting"), command: "strom task list --state waiting" };
|
|
113
|
+
else if (queued === 0 && tree.count("input") === 0)
|
|
114
|
+
base.next = { why: t("ui.why.intake"), command: 'strom intake --text "<what the user told you>"' };
|
|
115
|
+
else if (queued > 0)
|
|
116
|
+
base.next = human ? { why: t("ui.why.run", { n: queued }), command: "strom run" } : { why: t("ui.why.session", { n: queued }), command: "strom session start" };
|
|
117
|
+
else {
|
|
118
|
+
const active = researches.find((r) => r.state === "active") ?? researches[0];
|
|
119
|
+
base.next = { why: t("ui.why.frontier"), command: `strom frontier --research ${active.id}` };
|
|
120
|
+
}
|
|
121
|
+
return base;
|
|
122
|
+
}
|
|
123
|
+
register({
|
|
124
|
+
path: [],
|
|
125
|
+
summary: "Orientation: what strom is, where you are, what to do next",
|
|
126
|
+
group: "start",
|
|
127
|
+
async run(ctx) {
|
|
128
|
+
const o = orientation(ctx);
|
|
129
|
+
const newer = await newerVersion(ctx.settings, ctx.env);
|
|
130
|
+
if (newer)
|
|
131
|
+
o.update = newer;
|
|
132
|
+
const lang = ctx.uiLang();
|
|
133
|
+
const t = (key, values = {}) => ui(lang, key, values);
|
|
134
|
+
// Labels in one column, whatever their length in the user's language.
|
|
135
|
+
const labels = [t("ui.o.home"), t("ui.o.tree"), t("ui.o.lang"), t("ui.o.data"), t("ui.o.results"), t("ui.o.stories"), t("ui.o.update"), t("ui.o.next")];
|
|
136
|
+
const width = Math.max(...labels.map((l) => l.length)) + 2;
|
|
137
|
+
const row = (label, value) => `${label.padEnd(width)}${value}`;
|
|
138
|
+
const check = o.tree ? `${o.tree.errors ? t("ui.o.errors", { n: o.tree.errors }) : t("ui.o.ok")}${o.tree.warnings ? `, ${t("ui.o.warnings", { n: o.tree.warnings })}` : ""}` : "";
|
|
139
|
+
const text = lines(t("ui.o.title", { version: o.version }), t("ui.o.rule"), "", row(t("ui.o.home"), o.home ? ctx.display(o.home) : t("ui.o.notsetup")), o.update ? row(t("ui.o.update"), t("ui.o.update.new", { version: o.update, current: o.version })) : undefined, o.tree
|
|
140
|
+
? lines(row(t("ui.o.tree"), `${o.tree.name} (${ctx.display(o.tree.root)})`), row(t("ui.o.lang"), `${langName(o.tree.lang, lang)} (${o.tree.lang}) — ${t("ui.o.langnote")}`), row(t("ui.o.data"), t("ui.o.counts", { persons: o.tree.persons, families: o.tree.families, check })), o.results
|
|
141
|
+
? row(t("ui.o.results"), t(o.results.app === "installed"
|
|
142
|
+
? appOpensResearch(ctx.env) ? "ui.o.results.installed.live" : "ui.o.results.installed"
|
|
143
|
+
: o.results.app === "offer"
|
|
144
|
+
? appOpensResearch(ctx.env) ? "ui.o.results.offer.live" : "ui.o.results.offer"
|
|
145
|
+
: "ui.o.results.plain", { file: ctx.display(o.results.file), url: stromAppUrl(ctx.env) }))
|
|
146
|
+
: undefined, o.stories ? row(t("ui.o.stories"), t(`ui.o.stories.${o.stories}`)) : undefined, o.researches?.length
|
|
147
|
+
? t("ui.o.research") + "\n" + table(o.researches.map((r) => [` ${r.id}`, r.name, `[${r.state}]`, r.focus]))
|
|
148
|
+
: t("ui.o.research.none"), o.working?.length
|
|
149
|
+
? t("ui.o.working") + "\n" + table(o.working.map((w) => [` ${w.who}`, t("ui.o.since", { time: w.since.slice(11, 16) }), w.task ? t("ui.o.on", { task: w.task }) : ""]))
|
|
150
|
+
: undefined, ctx.hasTree() ? waitingLines(ctx.tree(), { shared: ctx.settings.shared()?.value, display: (p) => ctx.display(p) }) : undefined)
|
|
151
|
+
: o.trees.length > 1
|
|
152
|
+
? t("ui.o.trees") + "\n" + table(o.trees.map((x) => [` ${x.name}`, ctx.display(x.root)]))
|
|
153
|
+
: undefined, "", row(t("ui.o.next"), o.next.command), `${" ".repeat(width)}(${o.next.why})`);
|
|
154
|
+
return { text, data: o };
|
|
155
|
+
},
|
|
156
|
+
}, {
|
|
157
|
+
path: ["guide"],
|
|
158
|
+
summary: "How to do research with strom — read this first (for agents)",
|
|
159
|
+
group: "start",
|
|
160
|
+
run(ctx) {
|
|
161
|
+
const lang = ctx.hasTree() ? ctx.tree().lang : ctx.settings.home() ? ctx.settings.lang().value : undefined;
|
|
162
|
+
const text = guideText(lang);
|
|
163
|
+
return { text, data: { guide: text, lang } };
|
|
164
|
+
},
|
|
165
|
+
}, {
|
|
166
|
+
path: ["commands"],
|
|
167
|
+
summary: "Catalog of commands by group; --json gives options and examples (filter: strom commands person --json)",
|
|
168
|
+
group: "start",
|
|
169
|
+
args: [{ name: "filter", description: "only commands starting with these words, or of this group", variadic: true }],
|
|
170
|
+
examples: ["strom commands", "strom commands source --json", "strom commands analysis"],
|
|
171
|
+
run(_ctx, { args }) {
|
|
172
|
+
const words = args.flatMap((a) => a.split(/\s+/)).filter(Boolean);
|
|
173
|
+
const prefix = words.join(" ");
|
|
174
|
+
const defs = commands().filter((c) => c.path.length > 0 && (!prefix || c.group === prefix || c.path.join(" ") === prefix || c.path.join(" ").startsWith(`${prefix} `)));
|
|
175
|
+
if (defs.length === 0)
|
|
176
|
+
throw new UsageError(`no commands match "${prefix}"`, { hint: `groups: ${Object.keys(GROUPS).join(", ")}` });
|
|
177
|
+
const text = Object.entries(GROUPS)
|
|
178
|
+
.map(([g, title]) => {
|
|
179
|
+
const inGroup = defs.filter((c) => c.group === g);
|
|
180
|
+
return inGroup.length ? `${title}:\n${table(inGroup.map((c) => [` ${c.path.join(" ")}`, c.summary]))}` : "";
|
|
181
|
+
})
|
|
182
|
+
.filter(Boolean)
|
|
183
|
+
.join("\n\n");
|
|
184
|
+
return { text, data: { version: VERSION, commands: defs.map(describe) } };
|
|
185
|
+
},
|
|
186
|
+
}, {
|
|
187
|
+
path: ["help"],
|
|
188
|
+
summary: "Help for a command: strom help <command>",
|
|
189
|
+
group: "start",
|
|
190
|
+
args: [{ name: "command", description: "command words, e.g. person add", variadic: true }],
|
|
191
|
+
run(_ctx, input) {
|
|
192
|
+
const text = helpFor(input.args);
|
|
193
|
+
return { text, data: { help: text } };
|
|
194
|
+
},
|
|
195
|
+
});
|