strom-research 1.0.0 → 1.1.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 +13 -5
- package/assets/icon/NOTICE.md +26 -0
- package/assets/icon/favicon.svg +1 -0
- package/assets/icon/icon-256.png +0 -0
- package/assets/icon/icon-512.png +0 -0
- package/assets/icon/icon.ico +0 -0
- package/assets/icon/icon.svg +1 -0
- package/assets/lang/cs.json +184 -8
- package/assets/lang/de.json +184 -8
- package/assets/method/reading.md +4 -2
- package/assets/method/recording.md +10 -1
- package/assets/plugins/connectors/DISCOVERY.md +6 -2
- package/assets/plugins/connectors/README.md +27 -10
- package/dist/agents/files.js +27 -18
- package/dist/agents/global.js +15 -4
- package/dist/brief/brief.js +4 -0
- package/dist/cli/context.js +10 -4
- package/dist/cli/human.js +116 -0
- package/dist/cli/main.js +9 -0
- package/dist/cli/menu.js +254 -38
- package/dist/cli/ui.js +156 -7
- package/dist/cli/wizard.js +36 -9
- package/dist/commands/browse.js +243 -0
- package/dist/commands/clips.js +190 -0
- package/dist/commands/connectors.js +93 -28
- package/dist/commands/guide.js +28 -7
- package/dist/commands/index.js +3 -0
- package/dist/commands/media.js +42 -8
- package/dist/commands/meta.js +33 -9
- package/dist/commands/output.js +103 -13
- package/dist/commands/read.js +1 -1
- package/dist/commands/readers.js +62 -0
- package/dist/commands/research.js +62 -0
- package/dist/commands/session.js +76 -47
- package/dist/commands/setup.js +17 -10
- package/dist/commands/sources.js +88 -16
- package/dist/commands/start.js +57 -37
- package/dist/commands/tasks.js +43 -6
- package/dist/commands/transcripts.js +177 -0
- package/dist/core/chromium.js +60 -19
- package/dist/core/clipfinder.js +118 -0
- package/dist/core/config.js +42 -2
- package/dist/core/connector.js +56 -8
- package/dist/core/excerpt.js +219 -0
- package/dist/core/frontier.js +30 -5
- package/dist/core/git.js +1 -1
- package/dist/core/kin.js +90 -0
- package/dist/core/live.js +22 -13
- package/dist/core/media.js +37 -3
- package/dist/core/migrate.js +33 -0
- package/dist/core/model.js +7 -0
- package/dist/core/net.js +93 -19
- package/dist/core/overview.js +259 -0
- package/dist/core/phrases.js +27 -0
- package/dist/core/places.js +43 -0
- package/dist/core/queue.js +16 -3
- package/dist/core/review.js +369 -0
- package/dist/core/schema.js +4 -0
- package/dist/core/session.js +5 -1
- package/dist/core/shortcut.js +71 -11
- package/dist/core/stories.js +3 -4
- package/dist/core/stromapp.js +86 -25
- package/dist/core/transcriber.js +99 -0
- package/dist/core/tree.js +3 -1
- package/dist/core/uninstall.js +4 -2
- package/dist/core/update.js +48 -22
- package/dist/core/validate.js +3 -1
- package/dist/core/workers.js +13 -0
- package/dist/gedcom/export.js +58 -30
- package/dist/gedcom/lines.js +8 -0
- package/dist/gedcom/validate.js +2 -2
- package/dist/runners/claude.js +2 -0
- package/package.json +1 -1
package/dist/commands/setup.js
CHANGED
|
@@ -17,7 +17,7 @@ import { agentsHere, DESKTOP_APPS, inDesktopApp, whereToTalk } from "../core/app
|
|
|
17
17
|
import { setupWizard } from "../cli/wizard.js";
|
|
18
18
|
import { installation, stromLauncher } from "../core/self.js";
|
|
19
19
|
import { VERSION } from "../core/tree.js";
|
|
20
|
-
import { installUpdate, isNewer, knownNewerVersion, latestVersion, newerVersion } from "../core/update.js";
|
|
20
|
+
import { installUpdate, isNewer, knownNewerVersion, latestVersion, newerNode, newerVersion } from "../core/update.js";
|
|
21
21
|
import { desktopDir } from "../core/paths.js";
|
|
22
22
|
import { researchUrl, stromAppUrl, stromAppState } from "../core/stromapp.js";
|
|
23
23
|
import { isInstalled } from "../agents/global.js";
|
|
@@ -189,7 +189,7 @@ function diagnose(ctx) {
|
|
|
189
189
|
}
|
|
190
190
|
// For a person: the shortcut on the desktop, the Strom app.
|
|
191
191
|
const desktop = desktopDir(ctx.env);
|
|
192
|
-
const shortcut = [`${shortcutName(lang)}.command`, `${shortcutName(lang)}.cmd`, "strom-research.desktop"].some((f) => fs.existsSync(path.join(desktop, f)));
|
|
192
|
+
const shortcut = [`${shortcutName(lang)}.command`, `${shortcutName(lang)}.lnk`, `${shortcutName(lang)}.cmd`, "strom-research.desktop"].some((f) => fs.existsSync(path.join(desktop, f)));
|
|
193
193
|
if (shortcut)
|
|
194
194
|
add("shortcut", "ok", t("ui.doc.yes"));
|
|
195
195
|
else
|
|
@@ -229,7 +229,7 @@ async function repair(ctx, checks, out) {
|
|
|
229
229
|
out(ui(lang, "ui.setup.shortcut.done", { file: ctx.display(f) }));
|
|
230
230
|
}
|
|
231
231
|
if (todo.has("app") && person && (await ctx.confirm(ui(lang, "ui.fix.app"), false))) {
|
|
232
|
-
openForUser(stromAppUrl(ctx.
|
|
232
|
+
openForUser(stromAppUrl(ctx.settings), ctx.env);
|
|
233
233
|
out(ui(lang, "ui.app.install"));
|
|
234
234
|
}
|
|
235
235
|
}
|
|
@@ -277,14 +277,18 @@ register({
|
|
|
277
277
|
throw new StromError(t("ui.update.unknown"), { hint: "check the network — or run the installer again" });
|
|
278
278
|
ctx.settings.config.updateCheck = { at: new Date().toISOString(), latest: newest };
|
|
279
279
|
ctx.settings.save();
|
|
280
|
-
|
|
280
|
+
const inst = installation();
|
|
281
|
+
// The installer's Node: the newest release of its line (security fixes) is taken along.
|
|
282
|
+
const node = inst.kind === "installed" ? await newerNode(ctx.env, inst.node).catch(() => undefined) : undefined;
|
|
283
|
+
const newer = isNewer(newest, VERSION);
|
|
284
|
+
if (!newer && !node)
|
|
281
285
|
return { text: t("ui.update.current", { version: VERSION }), data: { current: VERSION, latest: newest, newer: false } };
|
|
286
|
+
const nodeLine = node ? t("ui.update.node.available", { from: inst.node ?? "?", to: node.version }) : undefined;
|
|
282
287
|
if (opts.check)
|
|
283
|
-
return { text: t("ui.update.available", { version: newest, current: VERSION }), data: { current: VERSION, latest: newest, newer:
|
|
284
|
-
const inst = installation();
|
|
288
|
+
return { text: lines(newer ? t("ui.update.available", { version: newest, current: VERSION }) : undefined, nodeLine), data: { current: VERSION, latest: newest, newer, ...(node ? { node: node.version } : {}) } };
|
|
285
289
|
if (inst.kind !== "installed")
|
|
286
290
|
return { text: t(inst.kind === "npm" ? "ui.update.npm" : "ui.update.source", { version: newest }), data: { current: VERSION, latest: newest, newer: true, npm: inst.kind === "npm" } };
|
|
287
|
-
const question = t("ui.update.sure", { version: newest, current: VERSION });
|
|
291
|
+
const question = newer ? t("ui.update.sure", { version: newest, current: VERSION }) : t("ui.update.node.sure", { from: inst.node ?? "?", to: node.version });
|
|
288
292
|
if (isAgent(ctx.env) || !ctx.yes) {
|
|
289
293
|
if (!isAgent(ctx.env) && ctx.interactive) {
|
|
290
294
|
if (!(await ctx.confirm(question, true)))
|
|
@@ -295,9 +299,9 @@ register({
|
|
|
295
299
|
}
|
|
296
300
|
if (ctx.interactive)
|
|
297
301
|
ctx.io.stdout(`${t("ui.update.downloading", { version: newest })}\n`);
|
|
298
|
-
let
|
|
302
|
+
let done;
|
|
299
303
|
try {
|
|
300
|
-
|
|
304
|
+
done = await installUpdate(ctx.env, inst.root);
|
|
301
305
|
}
|
|
302
306
|
catch (e) {
|
|
303
307
|
throw new StromError(t("ui.update.failed", { detail: e.message }), { hint: "strom update — or run the installer again" });
|
|
@@ -305,7 +309,10 @@ register({
|
|
|
305
309
|
// The agents learn what the new version tells them.
|
|
306
310
|
const { command, args } = stromLauncher();
|
|
307
311
|
spawnSync(command, [...args, "agents", "install"], { stdio: "ignore", env: ctx.env, windowsHide: true });
|
|
308
|
-
return {
|
|
312
|
+
return {
|
|
313
|
+
text: lines(newer ? t("ui.update.done", { version: done.version, previous: VERSION }) : undefined, done.node ? t("ui.update.node.done", done.node) : undefined),
|
|
314
|
+
data: { previous: VERSION, version: done.version, updated: true, ...(done.node ? { node: done.node } : {}) },
|
|
315
|
+
};
|
|
309
316
|
},
|
|
310
317
|
});
|
|
311
318
|
function display(ctx, def, value) {
|
package/dist/commands/sources.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// source · cite · repo · recordset · place — where the evidence comes from.
|
|
2
|
+
import { OFF_MAP_HOW, offMapLine, placesOffMap } from "../core/places.js";
|
|
2
3
|
import fs from "node:fs";
|
|
3
4
|
import { commands, register } from "../cli/registry.js";
|
|
4
5
|
import { lines, moreLine, paginate, table, truncate } from "../cli/format.js";
|
|
@@ -8,7 +9,8 @@ import { create, csvOpt, listOpt, normId, requireRecord, textOpt, update } from
|
|
|
8
9
|
import { makeNote, parseDate } from "../core/actions.js";
|
|
9
10
|
import { calibrationLine, fitCalibration } from "../core/calibration.js";
|
|
10
11
|
import { foldText } from "../core/text.js";
|
|
11
|
-
import { imageOfRef } from "../core/media.js";
|
|
12
|
+
import { clipNote, clipText, imageOfRef, MAX_CLIPS, transcriptNote } from "../core/media.js";
|
|
13
|
+
import { partRegion } from "../core/views.js";
|
|
12
14
|
import { formatName } from "../core/people.js";
|
|
13
15
|
function written(tree) {
|
|
14
16
|
return lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined);
|
|
@@ -40,6 +42,26 @@ function mediaRefs(tree, refs) {
|
|
|
40
42
|
return m.id;
|
|
41
43
|
});
|
|
42
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Where the entry is on an image, as its view was cut: "M0012@0.05,0.4,0.45,0.18", "B0001:57@left:0.1,0.4,0.8,0.1"
|
|
47
|
+
* (a half, then a crop within it), pixels of the registered image, or "M0012" alone (the entry is the whole image).
|
|
48
|
+
*/
|
|
49
|
+
function clipRefs(tree, specs) {
|
|
50
|
+
return specs.map((spec) => {
|
|
51
|
+
const m = /^([^@]+?)(?:@(.+))?$/.exec(spec.trim()) ?? [];
|
|
52
|
+
let [ref, where] = [m[1] ?? "", m[2]];
|
|
53
|
+
// "M0012:0.1,0.2,0.3,0.1" — the colon of a record set's image number stays hers
|
|
54
|
+
const colon = !where && /^([Mm]\d+):(.+)$/.exec(ref);
|
|
55
|
+
if (colon)
|
|
56
|
+
[ref, where] = [colon[1], colon[2]];
|
|
57
|
+
const media = mediaRefs(tree, [ref])[0];
|
|
58
|
+
if (!where)
|
|
59
|
+
return { media, region: { x: 0, y: 0, w: 1, h: 1 } };
|
|
60
|
+
const w = /^(?:(left|right|top|bottom)(?::|$))?(.*)$/.exec(where.trim());
|
|
61
|
+
const region = partRegion({ half: w[1], crop: w[2] || undefined }, tree.get(media));
|
|
62
|
+
return { media, region };
|
|
63
|
+
});
|
|
64
|
+
}
|
|
43
65
|
/** "Teinitz an der Elbe@de:1850-1918" → another name of a place. */
|
|
44
66
|
function altName(a) {
|
|
45
67
|
const m = /^(.+?)(?:@([a-z]{2,3}))?(?::(\d{3,4})?-(\d{3,4})?)?$/.exec(a);
|
|
@@ -113,10 +135,17 @@ register({
|
|
|
113
135
|
{ name: "form", type: "string", value: "<form>", description: "original (default), derivative (copy, index — the default for --kind index and in a record set of kind index only), authored (compiled work)" },
|
|
114
136
|
{ name: "url", type: "string", value: "<url>", description: "link to the image or catalog entry" },
|
|
115
137
|
{ name: "media", type: "string", multiple: true, value: "<M…|B…:n>", description: "the registered image(s) the record is on (repeatable)" },
|
|
138
|
+
{
|
|
139
|
+
name: "clip",
|
|
140
|
+
type: "string",
|
|
141
|
+
multiple: true,
|
|
142
|
+
value: "<M…@x,y,w,h>",
|
|
143
|
+
description: "where the entry itself is on its image — the crop you read it in (the view prints it); a second one when it runs over a page break",
|
|
144
|
+
},
|
|
116
145
|
{ name: "note", type: "string", value: "<text>", description: "short note" },
|
|
117
146
|
],
|
|
118
147
|
examples: [
|
|
119
|
-
'strom source add "Křest Jana Nováka 1905" --kind baptism --recordset B0001 --locator "fol. 45, č. 12" --language la --information primary --transcript @zapis.txt',
|
|
148
|
+
'strom source add "Křest Jana Nováka 1905" --kind baptism --recordset B0001 --locator "fol. 45, č. 12" --language la --information primary --transcript @zapis.txt --clip B0001:2@0.05,0.40,0.45,0.18',
|
|
120
149
|
'strom source add "Rodinný strom od tety Marie" --kind family-tree --form authored --input I0001',
|
|
121
150
|
],
|
|
122
151
|
run(ctx, { args, opts }) {
|
|
@@ -126,7 +155,10 @@ register({
|
|
|
126
155
|
requireRecord(tree, opts.repo, "repository");
|
|
127
156
|
if (opts.input)
|
|
128
157
|
requireRecord(tree, opts.input, "input");
|
|
129
|
-
const
|
|
158
|
+
const clips = clipRefs(tree, listOpt(opts.clip));
|
|
159
|
+
if (clips.length > MAX_CLIPS)
|
|
160
|
+
throw new UsageError(`at most ${MAX_CLIPS} clips: an entry, and its continuation over a page break`);
|
|
161
|
+
const media = [...new Set([...mediaRefs(tree, csvOpt(opts.media)), ...clips.map((c) => c.media)])];
|
|
130
162
|
// a line of an index is a copy made by another hand; a book with its own index holds originals too
|
|
131
163
|
const kind = oneOf(opts.kind, SOURCE_KINDS, "kind", "other");
|
|
132
164
|
const form = kind === "index" || (book && book.kinds.length > 0 && book.kinds.every((k) => k === "index")) ? "derivative" : "original";
|
|
@@ -146,9 +178,10 @@ register({
|
|
|
146
178
|
url: str(opts.url),
|
|
147
179
|
accessed: str(opts.url) ? new Date().toISOString().slice(0, 10) : undefined,
|
|
148
180
|
...(media.length ? { media } : {}),
|
|
181
|
+
...(clips.length ? { clips } : {}),
|
|
149
182
|
note: str(opts.note),
|
|
150
183
|
}, (id) => `+${id} source "${truncate(args[0], 60)}"${media.length ? ` on ${media.join(" ")}` : ""}`, media);
|
|
151
|
-
return { text: lines(written(tree), "", `cite it: strom cite <event> ${src.id} --locator "…"`), data: { source: src } };
|
|
184
|
+
return { text: lines(written(tree), clipNote(tree, src), transcriptNote(tree, src), "", `cite it: strom cite <event> ${src.id} --locator "…"`), data: { source: src } };
|
|
152
185
|
},
|
|
153
186
|
}, {
|
|
154
187
|
path: ["source", "list"],
|
|
@@ -207,7 +240,7 @@ register({
|
|
|
207
240
|
if (c.source === s.id)
|
|
208
241
|
citing.push(`${f.id} ${e.id} ${e.kind}${at(c)}`);
|
|
209
242
|
}
|
|
210
|
-
const text = lines(`${s.id} ${s.title}`, `kind ${s.kind} · ${s.form} · information ${s.information}${s.language ? ` · language ${s.language}` : ""}${s.date ? ` · dated ${s.date}` : ""}`, s.recordset || s.repository ? `in ${[s.recordset, s.repository].filter(Boolean).join(" · ")}${s.locator ? ` · ${s.locator}` : ""}` : s.locator ? `at ${s.locator}` : undefined, s.url ? `url ${s.url}` : undefined, s.media?.length ? `images ${s.media.map((id) => { const m = tree.get(id); return m ? `${id}${m.recordset ? ` (${m.recordset}:${m.image ?? "?"})` : ""}` : id; }).join(" · ")} — strom media view <M…>` : undefined, s.transcript ? `\ntranscript\n${s.transcript}` : undefined, s.translation ? `\ntranslation\n${s.translation}` : undefined, citing.length ? `\ncited by\n${citing.map((c) => ` ${c}`).join("\n")}` : "\ncited by nothing yet", ...s.notes.map((n) => `note ${n.text}`));
|
|
243
|
+
const text = lines(`${s.id} ${s.title}`, `kind ${s.kind} · ${s.form} · information ${s.information}${s.language ? ` · language ${s.language}` : ""}${s.date ? ` · dated ${s.date}` : ""}`, s.recordset || s.repository ? `in ${[s.recordset, s.repository].filter(Boolean).join(" · ")}${s.locator ? ` · ${s.locator}` : ""}` : s.locator ? `at ${s.locator}` : undefined, s.url ? `url ${s.url}` : undefined, s.media?.length ? `images ${s.media.map((id) => { const m = tree.get(id); return m ? `${id}${m.recordset ? ` (${m.recordset}:${m.image ?? "?"})` : ""}` : id; }).join(" · ")} — strom media view <M…>` : undefined, s.clips?.length ? `clips ${s.clips.map((c, i) => `${i + 1}. ${clipText(c)}`).join(" · ")} — where the entry is on its image` : undefined, s.transcript ? `\ntranscript\n${s.transcript}` : undefined, s.translation ? `\ntranslation\n${s.translation}` : undefined, citing.length ? `\ncited by\n${citing.map((c) => ` ${c}`).join("\n")}` : "\ncited by nothing yet", ...s.notes.map((n) => `note ${n.text}`));
|
|
211
244
|
return { text, data: { source: s, citedBy: citing } };
|
|
212
245
|
},
|
|
213
246
|
});
|
|
@@ -228,7 +261,7 @@ register({
|
|
|
228
261
|
{ name: "automation", type: "string", value: "<a>", description: "allowed, manual (browser only), forbidden, unknown (default)" },
|
|
229
262
|
{ name: "note", type: "string", value: "<text>", description: "short note" },
|
|
230
263
|
],
|
|
231
|
-
examples: ['strom repo add "
|
|
264
|
+
examples: ['strom repo add "State Archive, online reading room" --country CZ --url https://archive.example.org --automation manual'],
|
|
232
265
|
run(ctx, { args, opts }) {
|
|
233
266
|
const tree = ctx.tree();
|
|
234
267
|
const r = create(tree, "repository", {
|
|
@@ -396,10 +429,11 @@ register({
|
|
|
396
429
|
{ name: "kind", type: "string", value: "<kind>", description: "village, town, parish, estate, district, …" },
|
|
397
430
|
{ name: "lat", type: "string", value: "<deg>", description: "latitude" },
|
|
398
431
|
{ name: "lon", type: "string", value: "<deg>", description: "longitude" },
|
|
432
|
+
{ name: "unlocated", type: "string", value: "<why>", description: "no coordinates yet, and why: not identified which of the places of this name it is (coordinates given later clear it)" },
|
|
399
433
|
{ name: "parent", type: "string", value: "<L…>", description: "place it belongs to" },
|
|
400
434
|
{ name: "note", type: "string", value: "<text>", description: "short note" },
|
|
401
435
|
],
|
|
402
|
-
examples: ['strom place add "Týnec nad Labem" --alt "Teinitz an der Elbe@de" --kind town --lat 50.042 --lon 15.358'],
|
|
436
|
+
examples: ['strom place add "Týnec nad Labem" --alt "Teinitz an der Elbe@de" --kind town --lat 50.042 --lon 15.358', 'strom place add "Lhota" --kind village --unlocated "which Lhota: the record gives no parish"'],
|
|
403
437
|
run(ctx, { args, opts }) {
|
|
404
438
|
const tree = ctx.tree();
|
|
405
439
|
const names = [{ name: args[0].trim() }];
|
|
@@ -416,6 +450,7 @@ register({
|
|
|
416
450
|
names,
|
|
417
451
|
kind: str(opts.kind),
|
|
418
452
|
coords: lat !== undefined && lon !== undefined ? { lat, lon } : undefined,
|
|
453
|
+
unlocated: lat === undefined ? str(opts.unlocated) : undefined,
|
|
419
454
|
parent: opts.parent ? normId(opts.parent) : undefined,
|
|
420
455
|
jurisdictions: [],
|
|
421
456
|
note: str(opts.note),
|
|
@@ -425,12 +460,21 @@ register({
|
|
|
425
460
|
},
|
|
426
461
|
}, {
|
|
427
462
|
path: ["place", "list"],
|
|
428
|
-
summary: "Places known in this tree",
|
|
463
|
+
summary: "Places known in this tree — or, with --off-map, the places of facts not on the Strom app's map yet",
|
|
429
464
|
group: "sources",
|
|
430
465
|
tree: true,
|
|
431
466
|
args: [{ name: "filter", description: "part of a name" }],
|
|
432
|
-
|
|
467
|
+
options: [{ name: "off-map", type: "boolean", description: "places of facts without coordinates (no place record yet, or one without them), the most facts first" }],
|
|
468
|
+
examples: ["strom place list", "strom place list --off-map"],
|
|
469
|
+
run(ctx, { args, opts }) {
|
|
433
470
|
const tree = ctx.tree();
|
|
471
|
+
if (opts["off-map"]) {
|
|
472
|
+
const off = placesOffMap(tree);
|
|
473
|
+
return {
|
|
474
|
+
text: off.length ? lines(...off.map(offMapLine), OFF_MAP_HOW) : "every place of a fact is on the map (or says why not)",
|
|
475
|
+
data: { offMap: off.map((m) => ({ name: m.name, events: m.events, place: m.place?.id })) },
|
|
476
|
+
};
|
|
477
|
+
}
|
|
434
478
|
const all = args[0] ? tree.list("place").filter((p) => p.names.some((n) => matches(n.name, args[0]))) : tree.list("place");
|
|
435
479
|
return {
|
|
436
480
|
text: all.length
|
|
@@ -519,15 +563,24 @@ register({
|
|
|
519
563
|
description: "Only the options given change; --media adds images, --note adds a note. Changing what a record says or\n" +
|
|
520
564
|
"how good it is (kind, form, information, date, locator, transcript, record set) needs --reason; filling in doesn't.",
|
|
521
565
|
args: [{ name: "source", description: "source ID (S0001)", required: true }],
|
|
522
|
-
options:
|
|
523
|
-
|
|
566
|
+
options: [
|
|
567
|
+
...editOptions(addOptions("source add"), [{ name: "title", type: "string", value: "<text>", description: "a better title" }]).filter((o) => o.name !== "input"),
|
|
568
|
+
{ name: "clip-remove", type: "string", value: "<n|all>", description: "take away a clip that is wrong (its number in strom source show), or all of them" },
|
|
569
|
+
],
|
|
570
|
+
examples: [
|
|
571
|
+
'strom source edit S0001 --translation "Jan, son of Josef" --media B0001:2',
|
|
572
|
+
'strom source edit S0001 --information secondary --reason "the entry was written years later"',
|
|
573
|
+
"strom source edit S0001 --clip M0001@0.05,0.42,0.45,0.16",
|
|
574
|
+
],
|
|
524
575
|
run(ctx, { args, opts }) {
|
|
525
576
|
const tree = ctx.tree();
|
|
526
577
|
if (opts.recordset)
|
|
527
578
|
requireRecord(tree, opts.recordset, "recordset");
|
|
528
579
|
if (opts.repo)
|
|
529
580
|
requireRecord(tree, opts.repo, "repository");
|
|
530
|
-
const
|
|
581
|
+
const clips = clipRefs(tree, listOpt(opts.clip));
|
|
582
|
+
const drop = str(opts["clip-remove"]);
|
|
583
|
+
const media = [...new Set([...mediaRefs(tree, csvOpt(opts.media)), ...clips.map((c) => c.media)])];
|
|
531
584
|
const s = editFields(tree, args[0], "source", {
|
|
532
585
|
title: str(opts.title),
|
|
533
586
|
kind: optOneOf(opts.kind, SOURCE_KINDS, "kind"),
|
|
@@ -542,8 +595,25 @@ register({
|
|
|
542
595
|
form: optOneOf(opts.form, ["original", "derivative", "authored"], "form"),
|
|
543
596
|
url: str(opts.url),
|
|
544
597
|
accessed: str(opts.url) ? new Date().toISOString().slice(0, 10) : undefined,
|
|
545
|
-
}, { kind: "other", form: undefined, information: "unknown", date: undefined, locator: undefined, transcript: undefined, recordset: undefined }, opts, (cur) =>
|
|
546
|
-
|
|
598
|
+
}, { kind: "other", form: undefined, information: "unknown", date: undefined, locator: undefined, transcript: undefined, recordset: undefined }, opts, (cur) => {
|
|
599
|
+
let kept = cur.clips ?? [];
|
|
600
|
+
if (drop === "all")
|
|
601
|
+
kept = [];
|
|
602
|
+
else if (drop !== undefined) {
|
|
603
|
+
const n = Number(drop);
|
|
604
|
+
if (!Number.isInteger(n) || n < 1 || n > kept.length)
|
|
605
|
+
throw new UsageError(kept.length ? `--clip-remove takes 1–${kept.length} or all` : `${cur.id} has no clips`, { hint: `strom source show ${cur.id}` });
|
|
606
|
+
kept = kept.filter((_, i) => i !== n - 1);
|
|
607
|
+
}
|
|
608
|
+
const next = [...kept, ...clips.filter((c) => !kept.some((k) => clipText(k) === clipText(c)))];
|
|
609
|
+
if (next.length > MAX_CLIPS)
|
|
610
|
+
throw new UsageError(`at most ${MAX_CLIPS} clips: an entry, and its continuation over a page break`, { hint: `take one away: --clip-remove <n> (strom source show ${cur.id})` });
|
|
611
|
+
return {
|
|
612
|
+
...(media.some((m) => !(cur.media ?? []).includes(m)) ? { media: [...new Set([...(cur.media ?? []), ...media])] } : {}),
|
|
613
|
+
...(drop !== undefined || clips.length ? { clips: next.length ? next : undefined } : {}),
|
|
614
|
+
};
|
|
615
|
+
});
|
|
616
|
+
return { text: lines(written(tree), media.length ? clipNote(tree, s) : undefined, media.length || clips.length ? transcriptNote(tree, s) : undefined), data: { source: s } };
|
|
547
617
|
},
|
|
548
618
|
}, {
|
|
549
619
|
path: ["repo", "edit"],
|
|
@@ -609,7 +679,7 @@ register({
|
|
|
609
679
|
description: "--alt adds names; a place's names are never removed.",
|
|
610
680
|
args: [{ name: "place", description: "place ID (L0001)", required: true }],
|
|
611
681
|
options: editOptions(addOptions("place add")),
|
|
612
|
-
examples: ['strom place edit L0001 --alt "Teinitz an der Elbe@de:1850-1918" --lat 50.042 --lon 15.358'],
|
|
682
|
+
examples: ['strom place edit L0001 --alt "Teinitz an der Elbe@de:1850-1918" --lat 50.042 --lon 15.358', 'strom place edit L0001 --unlocated "two villages of this name in the district; the record does not say which"'],
|
|
613
683
|
run(ctx, { args, opts }) {
|
|
614
684
|
const tree = ctx.tree();
|
|
615
685
|
const lat = opts.lat === undefined ? undefined : Number(opts.lat);
|
|
@@ -624,10 +694,12 @@ register({
|
|
|
624
694
|
const p = editFields(tree, args[0], "place", {
|
|
625
695
|
kind: str(opts.kind),
|
|
626
696
|
coords: lat !== undefined && lon !== undefined ? { lat, lon } : undefined,
|
|
697
|
+
unlocated: lat === undefined ? str(opts.unlocated) : undefined,
|
|
627
698
|
parent: opts.parent ? normId(opts.parent) : undefined,
|
|
628
699
|
}, {}, opts, (cur) => {
|
|
629
700
|
const add = alt.filter((n) => !cur.names.some((x) => foldText(x.name) === foldText(n.name) && x.lang === n.lang));
|
|
630
|
-
|
|
701
|
+
// located at last: no longer "not identified"
|
|
702
|
+
return { ...(add.length ? { names: [...cur.names, ...add] } : {}), ...(lat !== undefined && cur.unlocated ? { unlocated: undefined } : {}) };
|
|
631
703
|
});
|
|
632
704
|
return { text: written(tree), data: { place: p } };
|
|
633
705
|
},
|
package/dist/commands/start.js
CHANGED
|
@@ -16,19 +16,19 @@ import { syncAgentFiles } from "../agents/files.js";
|
|
|
16
16
|
import { AGENTS, findAgent, isAgent } from "../core/which.js";
|
|
17
17
|
import { DESKTOP_APPS, agentsHere, whereToTalk } from "../core/apps.js";
|
|
18
18
|
import { assertIntact } from "../core/integrity.js";
|
|
19
|
-
import {
|
|
19
|
+
import { treeBrowserConnectors } from "../core/connector.js";
|
|
20
20
|
import { appOpensResearch, importAppUrl, installedStromApp, liveAppUrl, noticeStromApp, STROM_APP_URL, stromAppUrl } from "../core/stromapp.js";
|
|
21
21
|
import { liveRunning, serveLive, startLive, stopLive } from "../core/live.js";
|
|
22
22
|
import { openForUser } from "../core/open.js";
|
|
23
23
|
import { createShortcut, openInNewTerminal } from "../core/shortcut.js";
|
|
24
|
-
import { chromiumBrowser, openFileWith, openInBrowser, revealFile } from "../core/chromium.js";
|
|
24
|
+
import { chromiumBrowser, openFileWith, openInBrowser, openWebApp, revealFile } from "../core/chromium.js";
|
|
25
25
|
import { uninstallPlan } from "../core/uninstall.js";
|
|
26
26
|
import { Tree } from "../core/tree.js";
|
|
27
27
|
import { commitNow, shimDir } from "./session.js";
|
|
28
28
|
import { abandonedSessions, closeSession, openSessions } from "../core/session.js";
|
|
29
29
|
import { enterWorker, liveWorkers } from "../core/workers.js";
|
|
30
30
|
import { phrase } from "../core/phrases.js";
|
|
31
|
-
import { writeGedcoms, writeTreeGed } from "./output.js";
|
|
31
|
+
import { imageSettings, writeGedcoms, writeTreeGed } from "./output.js";
|
|
32
32
|
import { shortcutName } from "../cli/wizard.js";
|
|
33
33
|
/** Has the user trusted this folder in Claude Code already? (Its own record, read only.) */
|
|
34
34
|
function claudeTrusts(root, env) {
|
|
@@ -73,7 +73,8 @@ register({
|
|
|
73
73
|
// An agent strom started (a conversation, a run) is where the research happens already. Any other
|
|
74
74
|
// agent — the one that set strom up from the web page, one opened in some folder — hands the
|
|
75
75
|
// research over: its own conversation, opened where it lives (the tree folder, its permissions).
|
|
76
|
-
|
|
76
|
+
// A window strom opened to hand the research over is the person's (STROM_HANDOVER): never handed over again from there.
|
|
77
|
+
const agentHere = isAgent(ctx.env) && !ctx.env.STROM_HANDOVER;
|
|
77
78
|
if (agentHere && (ctx.env.STROM_WORKER || ctx.env.STROM_SESSION) && !opts.print)
|
|
78
79
|
throw new UsageError("strom chat opens a conversation for the user — you are in one already", { hint: "go on with strom here" });
|
|
79
80
|
const tree = ctx.tree();
|
|
@@ -117,6 +118,8 @@ register({
|
|
|
117
118
|
if (agentHere && !opts.print) {
|
|
118
119
|
const say = typeof opts.say === "string" && opts.say.trim() ? ["--say", opts.say.trim()] : [];
|
|
119
120
|
const opened = openInNewTerminal(["chat", "--agent", agent, ...say], tree.root, ctx.env);
|
|
121
|
+
if (opened === "recent")
|
|
122
|
+
return { text: ui(lang, "ui.chat.handover.recent", { agent: profile.name }), data: { agent, handover: "recent", cwd: tree.root } };
|
|
120
123
|
return { text: ui(lang, opened ? "ui.chat.handover" : "ui.chat.handover.failed", { agent: profile.name }), data: { agent, handover: opened ? "window" : "none", cwd: tree.root } };
|
|
121
124
|
}
|
|
122
125
|
const base = {
|
|
@@ -128,7 +131,7 @@ register({
|
|
|
128
131
|
// its research when there is one, the day.
|
|
129
132
|
name: ["Strom", tree.config.name, researches.length === 1 ? researches[0].name : undefined, new Date().toLocaleDateString(lang, { day: "numeric", month: "numeric" })].filter(Boolean).join(" · "),
|
|
130
133
|
shared,
|
|
131
|
-
...(agent === "claude" ? { chrome:
|
|
134
|
+
...(agent === "claude" ? { chrome: treeBrowserConnectors(tree, shared).length > 0 } : {}),
|
|
132
135
|
};
|
|
133
136
|
const args = conversationArgs(agent, base);
|
|
134
137
|
if (opts.print)
|
|
@@ -137,8 +140,9 @@ register({
|
|
|
137
140
|
// works, and has its own session. Sessions of conversations that ended without closing are closed.
|
|
138
141
|
for (const s of abandonedSessions(Tree.open(tree.root, ctx.env))) {
|
|
139
142
|
const t = Tree.open(tree.root, ctx.env);
|
|
140
|
-
|
|
141
|
-
|
|
143
|
+
// a run that is gone (each has a worker of its own), or a conversation
|
|
144
|
+
closeSession(t, s, { summary: phrase(t.lang, s.runner ? "session.run" : "session.chat"), next: "", interrupted: true, endedBy: s.runner ? "run" : "chat" });
|
|
145
|
+
commitNow(t, `${s.id} interrupted: its ${s.runner ? "run had stopped" : "conversation had ended"}`);
|
|
142
146
|
}
|
|
143
147
|
const worker = `${agent}-${process.pid}-${Date.now().toString(36)}`;
|
|
144
148
|
const leave = enterWorker(tree.root, worker, `${profile.name} conversation`);
|
|
@@ -146,7 +150,8 @@ register({
|
|
|
146
150
|
// Claude Code asks once whether the folder is to be trusted — its own safety step, kept; the user knows what to answer.
|
|
147
151
|
if (agent === "claude" && !claudeTrusts(tree.root, ctx.env))
|
|
148
152
|
ctx.io.stdout(ui(lang, "ui.chat.trust") + "\n");
|
|
149
|
-
const env = { ...prependPath(ctx.env, shimDir(tree)), STROM_WORKER: worker };
|
|
153
|
+
const env = { ...prependPath(ctx.env, shimDir(tree)), STROM_WORKER: worker, ...(base.model ? { STROM_MODEL: base.model } : {}) };
|
|
154
|
+
delete env.STROM_HANDOVER;
|
|
150
155
|
let code;
|
|
151
156
|
try {
|
|
152
157
|
const started = Date.now();
|
|
@@ -176,41 +181,56 @@ register({
|
|
|
176
181
|
group: "output",
|
|
177
182
|
description: "In a tree, with an app that can take it (from its version 3.0.0), the app opens this research itself — the first time as\n" +
|
|
178
183
|
"a new tree, after that the same tree updated — and follows it while somebody is at work on it (the live bridge).\n" +
|
|
179
|
-
`Otherwise the results are ready for it in output/tree-strom.ged (in the app: Import).
|
|
180
|
-
|
|
184
|
+
`Otherwise the results are ready for it in output/tree-strom.ged (in the app: Import). Each entry comes with its image,\n` +
|
|
185
|
+
`cut out of its scan (the settings excerpts.*; excerpts.for none: without). Web: ${STROM_APP_URL}`,
|
|
186
|
+
options: [
|
|
187
|
+
{ name: "live", type: "boolean", description: "the app follows this research while it goes on (the live bridge)" },
|
|
188
|
+
{ name: "images", type: "boolean", description: "each entry with its image (the default; kept for older scripts)" },
|
|
189
|
+
],
|
|
181
190
|
examples: ["strom app", "strom app --live", "strom app install"],
|
|
182
191
|
run(ctx, { opts }) {
|
|
183
192
|
const lang = ctx.uiLang();
|
|
184
|
-
const root = appOpensResearch(ctx.
|
|
193
|
+
const root = appOpensResearch(ctx.settings) ? ctx.locateTree() : undefined;
|
|
185
194
|
if (root) {
|
|
186
195
|
const tree = Tree.open(root, ctx.env);
|
|
187
196
|
const name = tree.config.name;
|
|
188
197
|
// Followed while somebody is at work on it: an agent strom started, or the agent asking for it now.
|
|
189
198
|
const follow = Boolean(opts.live) || liveWorkers(root).length > 0 || isAgent(ctx.env);
|
|
190
|
-
// The file as the research is now: the installed app takes it, or the user drags it in.
|
|
191
|
-
const
|
|
192
|
-
|
|
199
|
+
// The file as the research is now, each entry with its image: the installed app takes it, or the user drags it in.
|
|
200
|
+
const ged = writeTreeGed(tree, { for: "strom", stromVersion: ctx.settings.stromVersion(tree.config) });
|
|
201
|
+
const file = ged.file;
|
|
202
|
+
// what strom decided itself (smaller, left out) the user hears, in their language
|
|
203
|
+
const r = ged.images;
|
|
204
|
+
const set = ged.imageSettings;
|
|
205
|
+
const withImages = r && set
|
|
206
|
+
? lines(ui(lang, "ui.app.images", { n: String(r.excerpts), mb: (r.bytes / 1024 / 1024).toFixed(1) }), r.level !== r.asked ? ui(lang, "ui.app.images.smaller", { limit: String(set.mb), level: r.level, asked: r.asked }) : undefined, r.dropped.length ? ui(lang, "ui.app.images.left", { n: String(r.dropped.length), limit: String(set.mb) }) : undefined, r.outOfScope ? ui(lang, "ui.app.images.scope", { n: String(r.outOfScope), for: set.for }) : undefined, r.unclipped.length ? ui(lang, "ui.app.images.unclipped", { n: String(r.unclipped.length) }) : undefined)
|
|
207
|
+
: undefined;
|
|
208
|
+
// B, D: the app reaches strom on this computer — installed from a Chromium browser (its own window: first
|
|
209
|
+
// choice), else in a Chromium browser's tab; never whatever is the default (Safari cannot).
|
|
210
|
+
// The copy of the app strom opens (strom.app.url: its beta, its development) — installed, that one only.
|
|
211
|
+
const installed = installedStromApp(ctx.env, process.platform, stromAppUrl(ctx.settings));
|
|
212
|
+
const webApp = installed?.appId && installed.browser ? { browser: installed.browser, appId: installed.appId, ...(installed.profile ? { profile: installed.profile } : {}) } : undefined;
|
|
193
213
|
const browser = chromiumBrowser(ctx.env);
|
|
194
|
-
if (browser) {
|
|
214
|
+
if (webApp || browser) {
|
|
195
215
|
// The bridge serves the tree's GEDCOM to the app; it ends by itself when nobody asks it anything.
|
|
196
|
-
const info = startLive(root, ctx.env);
|
|
216
|
+
const info = startLive(root, ctx.env, { current: true });
|
|
197
217
|
if (!info)
|
|
198
218
|
throw new StromError("the bridge did not start", { hint: "strom live serve shows why" });
|
|
199
|
-
const url = follow ? liveAppUrl(info.url, ctx.
|
|
200
|
-
const
|
|
201
|
-
const
|
|
202
|
-
|
|
219
|
+
const url = follow ? liveAppUrl(info.url, ctx.settings) : importAppUrl(`${info.url}/tree.ged`, ctx.settings);
|
|
220
|
+
const inApp = webApp ? openWebApp(webApp, url, ctx.env) : false;
|
|
221
|
+
const opened = inApp || (browser ? openInBrowser(browser, url, ctx.env) : false);
|
|
222
|
+
const where = inApp ? ui(lang, "ui.app.where.installed") : (browser?.name ?? "");
|
|
223
|
+
const text = lines(opened ? ui(lang, follow ? "ui.app.following" : "ui.app.research", { name, where }) : ui(lang, "ui.app.url", { url }), withImages);
|
|
224
|
+
return { text, data: { opened, via: inApp ? "app" : "browser", ...(browser ? { browser: browser.name } : {}), url, bridge: info.url, follow } };
|
|
203
225
|
}
|
|
204
|
-
// A: no such browser to hand it over, but the Strom app installed from one takes the file itself (its
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
if (!follow && !ctx.env.STROM_APP_URL && installed && /Chrome|Edge|Brave|Chromium|Vivaldi/.test(installed.kind) && openFileWith(installed.path, file, ctx.env))
|
|
208
|
-
return { text: ui(lang, "ui.app.research", { name, where: installed.kind }), data: { opened: true, via: "file", file, app: installed.path, follow } };
|
|
226
|
+
// A: no such browser to hand it over, but the Strom app installed from one takes the file itself (its file handler).
|
|
227
|
+
if (!follow && installed && /Chrome|Edge|Brave|Chromium|Vivaldi/.test(installed.kind) && openFileWith(installed.path, file, ctx.env))
|
|
228
|
+
return { text: lines(ui(lang, "ui.app.research", { name, where: installed.kind }), withImages), data: { opened: true, via: "file", file, app: installed.path, follow } };
|
|
209
229
|
// C: no such browser — the app opens, the file is shown, the user drags it into the app's window.
|
|
210
|
-
openForUser(installed?.path ?? stromAppUrl(ctx.
|
|
230
|
+
openForUser(installed?.path ?? stromAppUrl(ctx.settings), ctx.env);
|
|
211
231
|
const shown = revealFile(file, ctx.env);
|
|
212
232
|
return {
|
|
213
|
-
text: lines(follow ? ui(lang, "ui.app.nolive") : undefined, ui(lang, "ui.app.drag", { file: ctx.display(file), shown: shown ? ui(lang, "ui.app.drag.shown") : "" })),
|
|
233
|
+
text: lines(follow ? ui(lang, "ui.app.nolive") : undefined, ui(lang, "ui.app.drag", { file: ctx.display(file), shown: shown ? ui(lang, "ui.app.drag.shown") : "" }), withImages),
|
|
214
234
|
data: { opened: shown, via: "drag", file, follow },
|
|
215
235
|
};
|
|
216
236
|
}
|
|
@@ -218,14 +238,14 @@ register({
|
|
|
218
238
|
const info = startLive(ctx.tree().root, ctx.env);
|
|
219
239
|
if (!info)
|
|
220
240
|
throw new StromError("the bridge did not start", { hint: "strom live serve shows why" });
|
|
221
|
-
const url = liveAppUrl(info.url, ctx.
|
|
241
|
+
const url = liveAppUrl(info.url, ctx.settings);
|
|
222
242
|
const opened = openForUser(url, ctx.env);
|
|
223
243
|
return { text: opened ? ui(lang, "ui.app.opened", { where: ui(lang, "ui.app.browser") }) : ui(lang, "ui.app.url", { url }), data: { opened, url, bridge: info.url } };
|
|
224
244
|
}
|
|
225
|
-
const app = noticeStromApp(ctx.settings, ctx.env, { look: true }) ?? installedStromApp(ctx.env);
|
|
226
|
-
const opened = openForUser(app?.path ?? stromAppUrl(ctx.
|
|
227
|
-
const text = opened ? ui(lang, "ui.app.opened", { where: app ? app.kind : ui(lang, "ui.app.browser") }) : ui(lang, "ui.app.url", { url: stromAppUrl(ctx.
|
|
228
|
-
return { text, data: { opened, url: stromAppUrl(ctx.
|
|
245
|
+
const app = noticeStromApp(ctx.settings, ctx.env, { look: true }) ?? installedStromApp(ctx.env, process.platform, stromAppUrl(ctx.settings));
|
|
246
|
+
const opened = openForUser(app?.path ?? stromAppUrl(ctx.settings), ctx.env);
|
|
247
|
+
const text = opened ? ui(lang, "ui.app.opened", { where: app ? app.kind : ui(lang, "ui.app.browser") }) : ui(lang, "ui.app.url", { url: stromAppUrl(ctx.settings) });
|
|
248
|
+
return { text, data: { opened, url: stromAppUrl(ctx.settings), ...(app ? { installed: app } : {}) } };
|
|
229
249
|
},
|
|
230
250
|
}, {
|
|
231
251
|
path: ["live"],
|
|
@@ -240,7 +260,7 @@ register({
|
|
|
240
260
|
const tree = ctx.tree();
|
|
241
261
|
const info = liveRunning(tree.root);
|
|
242
262
|
return info
|
|
243
|
-
? { text: lines(`the bridge runs: ${info.url}`, ` the Strom app following it: ${liveAppUrl(info.url, ctx.
|
|
263
|
+
? { text: lines(`the bridge runs: ${info.url}`, ` the Strom app following it: ${liveAppUrl(info.url, ctx.settings)}`, " stop: strom live stop"), data: { running: true, ...info, app: liveAppUrl(info.url, ctx.settings) } }
|
|
244
264
|
: { text: "the bridge does not run — strom live start (or strom app --live)", data: { running: false } };
|
|
245
265
|
},
|
|
246
266
|
}, {
|
|
@@ -253,7 +273,7 @@ register({
|
|
|
253
273
|
const info = startLive(tree.root, ctx.env);
|
|
254
274
|
if (!info)
|
|
255
275
|
throw new StromError("the bridge did not start", { hint: "strom live serve shows why" });
|
|
256
|
-
return { text: lines(`the bridge runs: ${info.url}`, ` the Strom app following it: ${liveAppUrl(info.url, ctx.
|
|
276
|
+
return { text: lines(`the bridge runs: ${info.url}`, ` the Strom app following it: ${liveAppUrl(info.url, ctx.settings)}`), data: { ...info, app: liveAppUrl(info.url, ctx.settings) } };
|
|
257
277
|
},
|
|
258
278
|
}, {
|
|
259
279
|
path: ["live", "stop"],
|
|
@@ -280,14 +300,14 @@ register({
|
|
|
280
300
|
group: "output",
|
|
281
301
|
run(ctx) {
|
|
282
302
|
const lang = ctx.uiLang();
|
|
283
|
-
const app = installedStromApp(ctx.env);
|
|
303
|
+
const app = installedStromApp(ctx.env, process.platform, stromAppUrl(ctx.settings));
|
|
284
304
|
if (app) {
|
|
285
305
|
noticeStromApp(ctx.settings, ctx.env, { look: true });
|
|
286
306
|
const opened = openForUser(app.path, ctx.env);
|
|
287
307
|
return { text: ui(lang, "ui.app.installed", { kind: app.kind }), data: { installed: app, opened } };
|
|
288
308
|
}
|
|
289
|
-
const opened = openForUser(stromAppUrl(ctx.
|
|
290
|
-
return { text: opened ? ui(lang, "ui.app.install") : ui(lang, "ui.app.url", { url: stromAppUrl(ctx.
|
|
309
|
+
const opened = openForUser(stromAppUrl(ctx.settings), ctx.env);
|
|
310
|
+
return { text: opened ? ui(lang, "ui.app.install") : ui(lang, "ui.app.url", { url: stromAppUrl(ctx.settings) }), data: { installed: false, opened, url: stromAppUrl(ctx.settings) } };
|
|
291
311
|
},
|
|
292
312
|
});
|
|
293
313
|
register({
|
package/dist/commands/tasks.js
CHANGED
|
@@ -12,11 +12,12 @@ import { UsageError } from "../core/errors.js";
|
|
|
12
12
|
import { TASK_LEVELS } from "../core/model.js";
|
|
13
13
|
import { create, csvOpt, listOpt, normId, requireRecord, update } from "../core/records.js";
|
|
14
14
|
import { resolvePerson } from "../core/people.js";
|
|
15
|
+
import { foldText } from "../core/text.js";
|
|
15
16
|
import { now, typeOfId } from "../core/tree.js";
|
|
16
17
|
import { resolveResearch } from "./research.js";
|
|
17
18
|
import { makeNote } from "../core/actions.js";
|
|
18
19
|
import { lacksImages, rankTasks } from "../core/queue.js";
|
|
19
|
-
import { inboxFolderFor, parseImageList } from "../core/media.js";
|
|
20
|
+
import { clipNote, inboxFolderFor, parseImageList, transcriptNote } from "../core/media.js";
|
|
20
21
|
function written(tree) {
|
|
21
22
|
return lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined);
|
|
22
23
|
}
|
|
@@ -76,7 +77,7 @@ export function rankedQueue(tree, filter = {}) {
|
|
|
76
77
|
.list("task")
|
|
77
78
|
.filter((t) => !filter.research || !t.research || t.research === filter.research)
|
|
78
79
|
.filter((t) => !filter.level || t.level === filter.level);
|
|
79
|
-
return rankTasks(tree, tasks, filter.strategy);
|
|
80
|
+
return rankTasks(tree, tasks, filter.strategy, { storyTurn: filter.storyTurn });
|
|
80
81
|
}
|
|
81
82
|
function taskLine(t) {
|
|
82
83
|
return [t.id, `p${t.priority}`, t.level, t.state === "open" ? "" : `[${t.state}]`, truncate(t.what, 70)];
|
|
@@ -107,6 +108,20 @@ function stateChange(tree, ref, state, fields, reason, verb) {
|
|
|
107
108
|
return next;
|
|
108
109
|
}, { op: `task.${verb}`, summary: `${id} ${verb}${fields.result ? `: ${truncate(fields.result, 60)}` : ""}`, reason });
|
|
109
110
|
}
|
|
111
|
+
/** Tasks still to be done — the ones a new task must not repeat. */
|
|
112
|
+
const OPEN_STATES = ["open", "doing", "waiting", "parked"];
|
|
113
|
+
/** How alike two tasks' texts must be to be the same work: anywhere, or in the same books. */
|
|
114
|
+
const SAME_TEXT = 0.75;
|
|
115
|
+
const SAME_TEXT_IN_BOOK = 0.6;
|
|
116
|
+
/** How alike two task texts are (0–1): the share of their words in common, any script, accents and punctuation aside. */
|
|
117
|
+
export function alikeness(a, b) {
|
|
118
|
+
const words = (t) => new Set(foldText(t).split(/[^\p{L}\p{M}\p{N}]+/u).filter(Boolean));
|
|
119
|
+
const [x, y] = [words(a), words(b)];
|
|
120
|
+
if (!x.size || !y.size)
|
|
121
|
+
return 0;
|
|
122
|
+
const common = [...x].filter((w) => y.has(w)).length;
|
|
123
|
+
return common / (x.size + y.size - common);
|
|
124
|
+
}
|
|
110
125
|
register({
|
|
111
126
|
path: ["task", "add"],
|
|
112
127
|
summary: "Add a task: what, where, why, done-when (all required)",
|
|
@@ -126,6 +141,7 @@ register({
|
|
|
126
141
|
{ name: "about", type: "string", multiple: true, value: "<who>", description: "person/record the task is about, also a conflict X… or hypothesis H… it decides (repeatable)" },
|
|
127
142
|
{ name: "research", type: "string", value: "<G…>", description: "research it belongs to (default: the only active one)" },
|
|
128
143
|
{ name: "note", type: "string", value: "<text>", description: "short note" },
|
|
144
|
+
{ name: "anyway", type: "boolean", description: "add it although an open task of the same person and level reads the same (it is really other work)" },
|
|
129
145
|
],
|
|
130
146
|
examples: [
|
|
131
147
|
'strom task add "Křest Jana Nováka (~1905)" --level link --where B0001 --why "potvrdí otce Josefa" --done-when "zápis nalezen, nebo ročníky 1903–1907 prohledány celé" --about P0001 --priority 4',
|
|
@@ -141,6 +157,24 @@ register({
|
|
|
141
157
|
if (!Number.isInteger(priority) || priority < 1 || priority > 5)
|
|
142
158
|
throw new UsageError("--priority must be 1..5");
|
|
143
159
|
const where = listOpt(opts.where).map((w) => (/^[Bb]\d+$/.test(w) ? requireRecord(tree, w, "recordset").id : w));
|
|
160
|
+
const subject = subjects(tree, listOpt(opts.about));
|
|
161
|
+
const research = defaultResearch(tree, opts.research);
|
|
162
|
+
// The same work again: an open task of the same person and level that reads the same (or nearly, in the same
|
|
163
|
+
// books) — not added; what is new goes into that one.
|
|
164
|
+
if (!opts.anyway) {
|
|
165
|
+
const books = new Set(where.filter((w) => /^B\d+$/.test(w)));
|
|
166
|
+
const same = tree
|
|
167
|
+
.list("task")
|
|
168
|
+
.filter((x) => OPEN_STATES.includes(x.state) && x.level === opts.level && x.research === research && x.subject.some((s) => subject.includes(s)))
|
|
169
|
+
.map((x) => ({ x, alike: alikeness(x.what, args[0]), books: x.where.some((w) => books.has(w)) }))
|
|
170
|
+
.filter((m) => m.alike >= SAME_TEXT || (m.books && m.alike >= SAME_TEXT_IN_BOOK))
|
|
171
|
+
.sort((a, b) => b.alike - a.alike)[0];
|
|
172
|
+
if (same)
|
|
173
|
+
throw new UsageError(`a task like this is open already: ${same.x.id} p${same.x.priority} ${same.x.level} "${truncate(same.x.what, 70)}"`, {
|
|
174
|
+
hint: `add to it: strom task edit ${same.x.id} --priority … --where … --note "…" — or, if it is really other work: --anyway`,
|
|
175
|
+
details: { task: same.x.id },
|
|
176
|
+
});
|
|
177
|
+
}
|
|
144
178
|
const t = create(tree, "task", {
|
|
145
179
|
level: opts.level,
|
|
146
180
|
priority,
|
|
@@ -148,8 +182,8 @@ register({
|
|
|
148
182
|
where,
|
|
149
183
|
why: String(opts.why).trim(),
|
|
150
184
|
doneWhen: String(opts["done-when"]).trim(),
|
|
151
|
-
subject
|
|
152
|
-
research
|
|
185
|
+
subject,
|
|
186
|
+
research,
|
|
153
187
|
state: "open",
|
|
154
188
|
origin: tree.actor,
|
|
155
189
|
note: opts.note,
|
|
@@ -158,7 +192,7 @@ register({
|
|
|
158
192
|
const books = new Set(where.filter((w) => /^B\d+$/.test(w)));
|
|
159
193
|
const similar = tree
|
|
160
194
|
.list("task")
|
|
161
|
-
.filter((x) => x.id !== t.id &&
|
|
195
|
+
.filter((x) => x.id !== t.id && OPEN_STATES.includes(x.state) && x.level === t.level)
|
|
162
196
|
.filter((x) => x.subject.some((s) => t.subject.includes(s)) && (books.size === 0 || x.where.some((w) => books.has(w))));
|
|
163
197
|
// A link without images can only wait for the user: say now what to download, while
|
|
164
198
|
// it is known — not in a session spent finding out that there is nothing to read.
|
|
@@ -338,7 +372,10 @@ register({
|
|
|
338
372
|
for (const id of new Set([...t.subject, ...t.where]))
|
|
339
373
|
if (typeOfId(id) === "input" && tree.get(id)?.state === "new")
|
|
340
374
|
update(tree, id, "input", (i) => ({ ...i, state: "processed" }), { op: "input.done", summary: `${id} processed (${t.id} done)` });
|
|
341
|
-
|
|
375
|
+
// The entries it recorded from scans: each with where it is on its image.
|
|
376
|
+
const recorded = (t.produced ?? []).filter((id) => typeOfId(id) === "source").flatMap((id) => tree.get(id) ?? []);
|
|
377
|
+
const unclipped = recorded.flatMap((s) => [clipNote(tree, s), transcriptNote(tree, s)].filter((n) => !!n));
|
|
378
|
+
return { text: lines(written(tree), noSearch, handover, ...unclipped), data: { task: t } };
|
|
342
379
|
},
|
|
343
380
|
}, {
|
|
344
381
|
path: ["task", "park"],
|