strom-research 1.4.1 → 1.6.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/assets/lang/cs.json +160 -1
- package/assets/lang/de.json +160 -1
- package/assets/method/narrate.md +2 -1
- package/assets/method/recording.md +3 -1
- package/dist/agents/files.js +8 -1
- package/dist/cli/human.js +18 -0
- package/dist/cli/main.js +9 -0
- package/dist/cli/menu-links.js +249 -0
- package/dist/cli/menu-parts.js +6 -3
- package/dist/cli/menu-research.js +185 -7
- package/dist/cli/menu-settings.js +17 -3
- package/dist/cli/menu-waiting.js +61 -47
- package/dist/cli/menu.js +3 -2
- package/dist/cli/ui.js +159 -1
- package/dist/cli/wizard.js +34 -1
- package/dist/commands/analysis.js +6 -2
- package/dist/commands/browse.js +5 -19
- package/dist/commands/checks.js +7 -1
- package/dist/commands/guide.js +15 -1
- package/dist/commands/index.js +2 -0
- package/dist/commands/intake.js +3 -0
- package/dist/commands/links.js +367 -0
- package/dist/commands/output.js +7 -1
- package/dist/commands/people.js +58 -1
- package/dist/commands/research.js +113 -24
- package/dist/commands/setup.js +13 -2
- package/dist/commands/start.js +6 -2
- package/dist/commands/story.js +17 -1
- package/dist/commands/sync.js +243 -0
- package/dist/core/actions.js +15 -0
- package/dist/core/config.js +5 -0
- package/dist/core/dialog.js +25 -0
- package/dist/core/excerpt.js +53 -5
- package/dist/core/frontier.js +10 -1
- package/dist/core/links.js +357 -0
- package/dist/core/live.js +156 -7
- package/dist/core/phrases.js +1 -0
- package/dist/core/review.js +43 -5
- package/dist/core/schema.js +1 -0
- package/dist/core/self.js +17 -0
- package/dist/core/session.js +6 -0
- package/dist/core/stromapp.js +38 -0
- package/dist/core/sync.js +876 -0
- package/dist/core/tree.js +1 -1
- package/dist/core/uninstall.js +3 -0
- package/dist/core/validate.js +4 -0
- package/dist/gedcom/export.js +66 -1
- package/dist/gedcom/validate.js +13 -4
- package/package.json +1 -1
|
@@ -4,6 +4,7 @@ import { lines, table } from "../cli/format.js";
|
|
|
4
4
|
import { addPerson, addResearch } from "../core/actions.js";
|
|
5
5
|
import { applyFrontier, researchProposals } from "./session.js";
|
|
6
6
|
import { frontier } from "../core/frontier.js";
|
|
7
|
+
import { DEATH_AFTER_YEARS, livingBorn, unprovenPeople } from "../core/review.js";
|
|
7
8
|
import { parkedWhy } from "./tasks.js";
|
|
8
9
|
import { ui } from "../cli/ui.js";
|
|
9
10
|
import { phrase } from "../core/phrases.js";
|
|
@@ -145,31 +146,57 @@ register({
|
|
|
145
146
|
};
|
|
146
147
|
},
|
|
147
148
|
});
|
|
149
|
+
/** People without a record in one review at a time. */
|
|
150
|
+
export const UNPROVEN_BATCH = 10;
|
|
148
151
|
register({
|
|
149
152
|
path: ["review"],
|
|
150
|
-
summary: "Review
|
|
153
|
+
summary: "Review a person, several, or those no record proves: what the tree says of them elsewhere, entries to read, facts to check — as tasks",
|
|
151
154
|
group: "research",
|
|
152
155
|
tree: true,
|
|
153
156
|
writes: true,
|
|
154
|
-
description: "Sends the research straight to one person
|
|
155
|
-
"
|
|
156
|
-
"
|
|
157
|
-
"
|
|
158
|
-
"
|
|
159
|
-
"
|
|
160
|
-
|
|
157
|
+
description: "Sends the research straight to one person — or to several named at once (one review for them all), or with\n" +
|
|
158
|
+
"--unproven to the people no record of their own proves (strom person list --unproven), a batch at a time: run\n" +
|
|
159
|
+
"it again for the next batch; who is proved leaves the review. strom looks at what is recorded and proposes\n" +
|
|
160
|
+
"the work, at most one task of each kind for a person: what records, notes and the diary say of them outside\n" +
|
|
161
|
+
"their data; entries whose images are here but were not read whole (transcript, godparents, witnesses,\n" +
|
|
162
|
+
"house); facts resting on one reading; conflicts left open; with --reread, a second reading of what only\n" +
|
|
163
|
+
"another model read — after the agent or the model changed. Birth, parents and the story come as in any\n" +
|
|
164
|
+
"research. Run again later: what a task took up, or a task open elsewhere covers, is not proposed again.\n" +
|
|
165
|
+
"Nothing is deleted; corrections go through strom with their reasons.",
|
|
166
|
+
args: [{ name: "person", description: "whom (ID or name); several: one review for them all; none with --unproven", variadic: true }],
|
|
161
167
|
options: [
|
|
162
|
-
{ name: "scope", type: "string", value: "<scope>", description: "person (default), family (with partners and children), line (with the ancestors)" },
|
|
168
|
+
{ name: "scope", type: "string", value: "<scope>", description: "one person: person (default), family (with partners and children), line (with the ancestors)" },
|
|
169
|
+
{ name: "unproven", type: "boolean", description: "the people no record of their own proves (strom person list --unproven), a batch at a time" },
|
|
170
|
+
{ name: "max", type: "string", value: "<n>", description: `with --unproven: at most so many people in the batch (default ${UNPROVEN_BATCH})` },
|
|
171
|
+
{ name: "living", type: "boolean", description: "review someone most likely alive (born less than 100 years ago) all the same — the user asked for it" },
|
|
163
172
|
{ name: "reread", type: "boolean", description: "also a second reading of what only another model read (the model you read with now: model.vision)" },
|
|
164
173
|
{ name: "model", type: "string", value: "<model>", description: "with --reread: this model instead of model.vision" },
|
|
165
174
|
],
|
|
166
|
-
examples: [
|
|
167
|
-
|
|
175
|
+
examples: [
|
|
176
|
+
"strom review P0001",
|
|
177
|
+
'strom review "Josef Novák" --scope family',
|
|
178
|
+
"strom review P0001 P0002",
|
|
179
|
+
"strom review --unproven",
|
|
180
|
+
"strom review P0001 --reread",
|
|
181
|
+
"strom review P0001 --dry-run",
|
|
182
|
+
],
|
|
183
|
+
async run(ctx, { args, opts }) {
|
|
168
184
|
const tree = ctx.tree();
|
|
169
|
-
const
|
|
185
|
+
const unproven = !!opts.unproven;
|
|
186
|
+
if (unproven && args.length)
|
|
187
|
+
throw new UsageError("--unproven reviews the people without a record — no person with it", { hint: "strom review --unproven · or strom review <person>" });
|
|
188
|
+
if (!unproven && !args.length)
|
|
189
|
+
throw new UsageError("whom? a person, several, or --unproven", { hint: "strom review P0001 · strom review P0001 P0002 · strom review --unproven" });
|
|
190
|
+
if (!unproven && opts.max !== undefined)
|
|
191
|
+
throw new UsageError("--max goes with --unproven");
|
|
192
|
+
if ((unproven || args.length > 1) && opts.scope !== undefined)
|
|
193
|
+
throw new UsageError("--scope is for one person's review");
|
|
170
194
|
const scope = opts.scope ?? "person";
|
|
171
195
|
if (!REVIEW_SCOPES.includes(scope))
|
|
172
196
|
throw new UsageError(`--scope must be one of ${REVIEW_SCOPES.join(", ")}`);
|
|
197
|
+
const max = int(opts.max, "max") ?? UNPROVEN_BATCH;
|
|
198
|
+
if (max < 1)
|
|
199
|
+
throw new UsageError("--max must be 1 or more");
|
|
173
200
|
let reread;
|
|
174
201
|
if (opts.reread) {
|
|
175
202
|
reread = opts.model ?? ctx.settings.models(ctx.settings.agent(tree.config).value, tree.config).vision;
|
|
@@ -178,26 +205,61 @@ register({
|
|
|
178
205
|
}
|
|
179
206
|
else if (opts.model)
|
|
180
207
|
throw new UsageError("--model goes with --reread");
|
|
181
|
-
const review = { scope: scope, ...(reread ? { reread } : {}) };
|
|
182
208
|
const lang = tree.lang;
|
|
183
|
-
|
|
184
|
-
|
|
209
|
+
const chosen = [...new Map(args.map((a) => resolvePerson(tree, a)).map((x) => [x.id, x])).values()];
|
|
210
|
+
// the living are the family's to tell: reviewed only when the person says so
|
|
211
|
+
const living = chosen.map((x) => ({ x, born: livingBorn(tree, x) })).filter((l) => l.born !== undefined);
|
|
212
|
+
if (living.length && !opts.living && !ctx.yes && !tree.dryRun) {
|
|
213
|
+
const who = living.map((l) => `${label(l.x)}`).join(", ");
|
|
214
|
+
if (ctx.interactive && !isAgent(ctx.env)) {
|
|
215
|
+
if (!(await ctx.confirm(ui(lang, "ui.review.living", { who, years: DEATH_AFTER_YEARS }), false)))
|
|
216
|
+
return { text: ui(lang, "ui.review.living.no"), data: { research: null, created: [], planned: [], open: [], living: living.map((l) => l.x.id) } };
|
|
217
|
+
}
|
|
218
|
+
else
|
|
219
|
+
throw new UsageError(`most likely alive (born less than ${DEATH_AFTER_YEARS} years ago, no death recorded): ${who}`, {
|
|
220
|
+
hint: `ask the user whether to review them all the same; if yes: strom review ${args.join(" ")} --living`,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
const p = chosen.length === 1 ? chosen[0] : undefined;
|
|
224
|
+
const several = chosen.length > 1;
|
|
225
|
+
const sameSet = (ids) => !!ids && ids.length === chosen.length && chosen.every((x) => ids.includes(x.id));
|
|
226
|
+
// one review research per person, per set of people, one of the people without a record: again later, it goes on where it was
|
|
227
|
+
const existing = tree
|
|
228
|
+
.list("research")
|
|
229
|
+
.find((r) => r.direction === "person" &&
|
|
230
|
+
r.review &&
|
|
231
|
+
(unproven ? !!r.review.unproven : several ? !r.review.unproven && sameSet(r.review.people) : !r.review.people && r.focus === p.id));
|
|
232
|
+
let review = { scope: scope, ...(reread ? { reread } : {}) };
|
|
233
|
+
let left = 0;
|
|
234
|
+
if (unproven) {
|
|
235
|
+
const batch = unprovenBatch(tree, existing, max, review);
|
|
236
|
+
if (!batch.people.length)
|
|
237
|
+
return { text: ui(lang, "ui.review.unproven.none"), data: { research: existing ?? null, created: [], planned: [], open: [] } };
|
|
238
|
+
review = { ...review, people: batch.people, unproven: true };
|
|
239
|
+
left = batch.left;
|
|
240
|
+
}
|
|
241
|
+
else if (several)
|
|
242
|
+
review = { ...review, people: chosen.map((x) => x.id) };
|
|
243
|
+
const names = chosen.map((x) => displayName(x));
|
|
244
|
+
const name = unproven ? phrase(lang, "review.unproven.research") : names.length > 3 ? `${names.slice(0, 3).join(", ")} +${names.length - 3}` : names.join(", ");
|
|
245
|
+
const focus = p?.id ?? review.people[0];
|
|
185
246
|
// What it would propose, asked before anything is written: with nothing to do, no research is made for it.
|
|
186
247
|
const draft = existing
|
|
187
|
-
? { ...existing, state: "active", review }
|
|
188
|
-
: { id: "G?", type: "research", name: phrase(lang, "review.research", { name
|
|
248
|
+
? { ...existing, focus, state: "active", review }
|
|
249
|
+
: { id: "G?", type: "research", name: unproven ? name : phrase(lang, "review.research", { name }), focus, direction: "person", state: "active", review, notes: [] };
|
|
189
250
|
const planned = researchProposals(tree, draft).map((x) => x.proposal);
|
|
190
251
|
let research = draft;
|
|
191
252
|
let tasks = [];
|
|
253
|
+
const what = unproven ? `${review.people.length} without a record of their own` : several ? review.people.join(" ") : scope;
|
|
192
254
|
if (planned.length && !tree.dryRun) {
|
|
193
255
|
if (existing)
|
|
194
|
-
research = update(tree, existing.id, "research", (r) => ({ ...r, state: "active", review }), {
|
|
256
|
+
research = update(tree, existing.id, "research", (r) => ({ ...r, focus, state: "active", review }), {
|
|
195
257
|
op: "research.edit",
|
|
196
|
-
summary: `${existing.id} review again: ${
|
|
258
|
+
summary: `${existing.id} review again: ${what}${reread ? `, second reading with ${reread}` : ""}`,
|
|
197
259
|
});
|
|
198
260
|
else {
|
|
199
|
-
const r = addResearch(tree, { name: draft.name, focus
|
|
200
|
-
research = update(tree, r.id, "research", (x) => ({ ...x, review }), { op: "research.edit", summary: `${r.id} review: ${
|
|
261
|
+
const r = addResearch(tree, { name: draft.name, focus, direction: "person" });
|
|
262
|
+
research = update(tree, r.id, "research", (x) => ({ ...x, review }), { op: "research.edit", summary: `${r.id} review: ${what}${reread ? `, second reading with ${reread}` : ""}` });
|
|
201
263
|
}
|
|
202
264
|
tasks = applyFrontier(tree, research)
|
|
203
265
|
.map((id) => tree.get(id))
|
|
@@ -219,12 +281,39 @@ register({
|
|
|
219
281
|
// Nothing new: why — the work that covers them already (a parked task says how to go on), a search done in vain.
|
|
220
282
|
const why = list.length ? [] : coveredLines(tree, draft, lang, human);
|
|
221
283
|
const inReview = existing ?? (planned.length && !tree.dryRun ? research : undefined);
|
|
222
|
-
const text = lines(...(human ? [] : tree.written.map((o) => o.summary)), tree.dryRun ? ui(lang, "ui.review.dry") : undefined, (tree.written.length && !human) || tree.dryRun ? "" : undefined, list.length ? ui(lang, "ui.review.new", { name: label(p), n: list.length }) : ui(lang, "ui.review.nothing", { name: label(p) }), ...list, ...why, open.length || tasks.length
|
|
284
|
+
const text = lines(...(human ? [] : tree.written.map((o) => o.summary)), tree.dryRun ? ui(lang, "ui.review.dry") : undefined, (tree.written.length && !human) || tree.dryRun ? "" : undefined, list.length ? ui(lang, "ui.review.new", { name: p ? label(p) : name, n: list.length }) : ui(lang, "ui.review.nothing", { name: p ? label(p) : name }), ...list, ...why, open.length || tasks.length
|
|
223
285
|
? ui(lang, "ui.review.open", { n: open.length || tasks.length, research: inReview.id }) + (avg !== undefined ? ui(lang, "ui.review.cost", { cost: avg.toFixed(2) }) : "")
|
|
224
|
-
: undefined, (open.length || tasks.length) && inReview ? ui(lang, human ? "ui.review.next.human" : "ui.review.next", { research: inReview.id }) : undefined);
|
|
225
|
-
return {
|
|
286
|
+
: undefined, (open.length || tasks.length) && inReview ? ui(lang, human ? "ui.review.next.human" : "ui.review.next", { research: inReview.id }) : undefined, left ? ui(lang, human ? "ui.review.unproven.left.human" : "ui.review.unproven.left", { n: left }) : undefined);
|
|
287
|
+
return {
|
|
288
|
+
text,
|
|
289
|
+
data: { research: inReview ?? null, created: tasks, planned, open: open.map((t) => t.id), ...(unproven ? { people: review.people, left } : {}), ...(why.length ? { why } : {}), ...(avg !== undefined ? { avgCostUsd: avg } : {}) },
|
|
290
|
+
};
|
|
226
291
|
},
|
|
227
292
|
});
|
|
293
|
+
const OPEN_TASK = new Set(["open", "doing", "parked", "waiting"]);
|
|
294
|
+
/**
|
|
295
|
+
* The next batch of people without a record of their own: those whose tasks of the review are still open stay;
|
|
296
|
+
* then the ones strom has work for — a record naming them first (the quickest to prove), then leads, then
|
|
297
|
+
* nothing — those never in a batch before those tried already. How many more wait besides.
|
|
298
|
+
*/
|
|
299
|
+
function unprovenBatch(tree, existing, max, review) {
|
|
300
|
+
// the living are the family's to tell: no research of the registers for them
|
|
301
|
+
const all = unprovenPeople(tree).filter((u) => !u.living);
|
|
302
|
+
const tasks = existing ? tree.list("task").filter((t) => t.research === existing.id) : [];
|
|
303
|
+
const busy = (id) => tasks.some((t) => t.subject.includes(id) && OPEN_TASK.has(t.state));
|
|
304
|
+
const keep = all.filter((u) => busy(u.person.id)).map((u) => u.person.id);
|
|
305
|
+
const before = new Set(existing?.review?.people ?? []);
|
|
306
|
+
const order = { named: 0, leads: 1, none: 2 };
|
|
307
|
+
const rest = all
|
|
308
|
+
.filter((u) => !keep.includes(u.person.id))
|
|
309
|
+
.sort((a, b) => Number(before.has(a.person.id)) - Number(before.has(b.person.id)) || order[a.kind] - order[b.kind] || a.person.id.localeCompare(b.person.id));
|
|
310
|
+
// only those strom has work for: what it would propose for all of them, asked once
|
|
311
|
+
const draft = { ...(existing ?? { id: "G?", type: "research", name: "", direction: "person", state: "active", notes: [] }), review: { ...review, people: rest.map((u) => u.person.id), unproven: true } };
|
|
312
|
+
const planned = researchProposals(tree, { ...draft, focus: rest[0]?.person.id ?? "" }).map((x) => x.proposal);
|
|
313
|
+
const workable = rest.filter((u) => planned.some((x) => x.subject.includes(u.person.id))).map((u) => u.person.id);
|
|
314
|
+
const people = [...keep, ...workable].slice(0, Math.max(max, keep.length));
|
|
315
|
+
return { people, left: all.length - people.length };
|
|
316
|
+
}
|
|
228
317
|
/** Why a review found nothing new, person by person: the work that covers them already, or a search done in vain. */
|
|
229
318
|
function coveredLines(tree, research, lang, human = false) {
|
|
230
319
|
const out = [];
|
package/dist/commands/setup.js
CHANGED
|
@@ -20,9 +20,10 @@ import { VERSION } from "../core/tree.js";
|
|
|
20
20
|
import { installUpdate, isNewer, knownNewerVersion, latestVersion, newerNode, newerVersion } from "../core/update.js";
|
|
21
21
|
import { desktopDir } from "../core/paths.js";
|
|
22
22
|
import { planMove } from "../core/relocate.js";
|
|
23
|
-
import { researchUrl, stromAppUrl, stromAppState } from "../core/stromapp.js";
|
|
23
|
+
import { appOpensLinks, researchUrl, stromAppUrl, stromAppState } from "../core/stromapp.js";
|
|
24
24
|
import { isInstalled } from "../agents/global.js";
|
|
25
|
-
import { shortcutName } from "../cli/wizard.js";
|
|
25
|
+
import { offerLinks, shortcutName } from "../cli/wizard.js";
|
|
26
|
+
import { linkHandlerState } from "../core/links.js";
|
|
26
27
|
import { globalTargets, installGlobal } from "../agents/global.js";
|
|
27
28
|
import { ui } from "../cli/ui.js";
|
|
28
29
|
import { PERMISSION_LEVELS } from "../core/config.js";
|
|
@@ -225,6 +226,14 @@ function diagnose(ctx) {
|
|
|
225
226
|
add("shortcut", "warn", t("ui.doc.none"), FIX, "shortcut");
|
|
226
227
|
const app = stromAppState(ctx.settings);
|
|
227
228
|
add("app", "ok", t(`ui.doc.app.${app}`), app === "unknown" ? FIX : undefined, app === "unknown" ? "app" : undefined);
|
|
229
|
+
// …and whether it may start the research here (strom-research:// links): only while the app is wanted
|
|
230
|
+
if (app !== "no" && appOpensLinks(ctx.settings)) {
|
|
231
|
+
const links = linkHandlerState(ctx.env);
|
|
232
|
+
if (links === "ours")
|
|
233
|
+
add("links", "ok", t("ui.doc.links.ours"));
|
|
234
|
+
else
|
|
235
|
+
add("links", "warn", t(`ui.doc.links.${links}`), FIX, "links");
|
|
236
|
+
}
|
|
228
237
|
if (ctx.hasTree()) {
|
|
229
238
|
const tr = ctx.tree();
|
|
230
239
|
const errs = check(tr).filter((f) => f.level === "error").length + verifyFull(tr).findings.filter((f) => f.level === "error").length;
|
|
@@ -257,6 +266,8 @@ async function repair(ctx, checks, out) {
|
|
|
257
266
|
for (const f of createShortcut(shortcutName(lang), ctx.env))
|
|
258
267
|
out(ui(lang, "ui.setup.shortcut.done", { file: ctx.display(f) }));
|
|
259
268
|
}
|
|
269
|
+
if (todo.has("links") && person)
|
|
270
|
+
await offerLinks(ctx, lang);
|
|
260
271
|
if (todo.has("app") && person && (await ctx.confirm(ui(lang, "ui.fix.app"), false))) {
|
|
261
272
|
openForUser(stromAppUrl(ctx.settings), ctx.env);
|
|
262
273
|
out(ui(lang, "ui.app.install"));
|
package/dist/commands/start.js
CHANGED
|
@@ -18,7 +18,8 @@ import { DESKTOP_APPS, agentsHere, whereToTalk } from "../core/apps.js";
|
|
|
18
18
|
import { assertIntact } from "../core/integrity.js";
|
|
19
19
|
import { treeBrowserConnectors } from "../core/connector.js";
|
|
20
20
|
import { browserNote } from "./connectors.js";
|
|
21
|
-
import {
|
|
21
|
+
import { offerLinks } from "../cli/wizard.js";
|
|
22
|
+
import { appOpensLinks, appOpensResearch, importAppUrl, installedStromApp, liveAppUrl, noticeStromApp, STROM_APP_URL, stromAppUrl } from "../core/stromapp.js";
|
|
22
23
|
import { liveRunning, serveLive, startLive, stopLive } from "../core/live.js";
|
|
23
24
|
import { openForUser } from "../core/open.js";
|
|
24
25
|
import { createShortcut, openInNewTerminal } from "../core/shortcut.js";
|
|
@@ -193,7 +194,7 @@ register({
|
|
|
193
194
|
{ name: "images", type: "boolean", description: "each entry with its image (the default; kept for older scripts)" },
|
|
194
195
|
],
|
|
195
196
|
examples: ["strom app", "strom app --live", "strom app install"],
|
|
196
|
-
run(ctx, { opts }) {
|
|
197
|
+
async run(ctx, { opts }) {
|
|
197
198
|
const lang = ctx.uiLang();
|
|
198
199
|
const root = appOpensResearch(ctx.settings) ? ctx.locateTree() : undefined;
|
|
199
200
|
if (root) {
|
|
@@ -217,6 +218,9 @@ register({
|
|
|
217
218
|
const webApp = installed?.appId && installed.browser ? { browser: installed.browser, appId: installed.appId, ...(installed.profile ? { profile: installed.profile } : {}) } : undefined;
|
|
218
219
|
const browser = chromiumBrowser(ctx.env);
|
|
219
220
|
if (webApp || browser) {
|
|
221
|
+
// The first time a person opens the research in an app that opens links: may it start the research here? (once)
|
|
222
|
+
if (ctx.interactive && !isAgent(ctx.env) && appOpensLinks(ctx.settings, ctx.settings.stromVersion(tree.config)) && ctx.settings.config.links === undefined)
|
|
223
|
+
await offerLinks(ctx, lang, { ask: false });
|
|
220
224
|
// The bridge serves the tree's GEDCOM to the app; it ends by itself when nobody asks it anything.
|
|
221
225
|
const info = startLive(root, ctx.env, { current: true });
|
|
222
226
|
if (!info)
|
package/dist/commands/story.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import fs from "node:fs";
|
|
5
5
|
import { register } from "../cli/registry.js";
|
|
6
6
|
import { lines } from "../cli/format.js";
|
|
7
|
-
import { setStory, findEventOwner } from "../core/actions.js";
|
|
7
|
+
import { approveStory, setStory, findEventOwner } from "../core/actions.js";
|
|
8
8
|
import { UsageError } from "../core/errors.js";
|
|
9
9
|
import { csvOpt, normId, textOpt } from "../core/records.js";
|
|
10
10
|
import { resolvePerson } from "../core/people.js";
|
|
@@ -43,6 +43,22 @@ register({
|
|
|
43
43
|
const missing = rec.story.facts.length === 0 ? "note: no --fact given — say which facts the story rests on" : undefined;
|
|
44
44
|
return { text: lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined, missing), data: { story: rec.story } };
|
|
45
45
|
},
|
|
46
|
+
}, {
|
|
47
|
+
path: ["story", "approve"],
|
|
48
|
+
summary: "The user approved the story as it is: no longer a draft",
|
|
49
|
+
group: "people",
|
|
50
|
+
tree: true,
|
|
51
|
+
writes: true,
|
|
52
|
+
description: "Only on the user's word (they read it and said it is right): the text stays, the draft becomes the story of the family book.",
|
|
53
|
+
args: [{ name: "who", description: "person (ID or name) or family (F…)", required: true }],
|
|
54
|
+
examples: ["strom story approve P0001"],
|
|
55
|
+
run(ctx, { args }) {
|
|
56
|
+
const tree = ctx.tree();
|
|
57
|
+
const id = owner(tree, args[0]);
|
|
58
|
+
const was = tree.get(id)?.story?.status;
|
|
59
|
+
const rec = approveStory(tree, id);
|
|
60
|
+
return { text: was === "final" ? `${id} story was approved already` : lines(...tree.written.map((o) => o.summary)), data: { story: rec.story } };
|
|
61
|
+
},
|
|
46
62
|
}, {
|
|
47
63
|
path: ["story", "show"],
|
|
48
64
|
summary: "The story of a person or a couple, with the facts it rests on",
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// sync · sync undo — a family tree coming back from the Strom app (or another
|
|
2
|
+
// program), compared with the research: what the user changed, taken in on
|
|
3
|
+
// their yes (core/sync.ts).
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { register } from "../cli/registry.js";
|
|
7
|
+
import { lines, truncate } from "../cli/format.js";
|
|
8
|
+
import { ui } from "../cli/ui.js";
|
|
9
|
+
import { eventName, humanDate, humanDay, humanPlace } from "../cli/human.js";
|
|
10
|
+
import { EXIT, StromError, UsageError } from "../core/errors.js";
|
|
11
|
+
import { fileSha256, MAX_IN_TREE, mimeOf } from "../core/media.js";
|
|
12
|
+
import { create, update } from "../core/records.js";
|
|
13
|
+
import { safeFolderName } from "../core/text.js";
|
|
14
|
+
import { now } from "../core/tree.js";
|
|
15
|
+
import { applySync, nothingSince, planSync, readTreeFile, receivedSince, undoSync } from "../core/sync.js";
|
|
16
|
+
import { startLive } from "../core/live.js";
|
|
17
|
+
import { appSendsChanges, installedStromApp, sendAppUrl, stromAppUrl } from "../core/stromapp.js";
|
|
18
|
+
import { chromiumBrowser, openInBrowser, openWebApp } from "../core/chromium.js";
|
|
19
|
+
function factText(f, lang) {
|
|
20
|
+
if (!f)
|
|
21
|
+
return "—";
|
|
22
|
+
return [f.value, humanDate(f.date, lang), humanPlace(f.place, undefined, lang)].filter(Boolean).join(", ") || "—";
|
|
23
|
+
}
|
|
24
|
+
/** One change as the user reads it, with what strom does with it. */
|
|
25
|
+
function changeLine(tree, c, incoming, lang) {
|
|
26
|
+
const name = (key) => {
|
|
27
|
+
if (!key)
|
|
28
|
+
return "?";
|
|
29
|
+
if (key.startsWith("x:"))
|
|
30
|
+
return (incoming.persons.get(key)?.names[0] ?? key).replace(/\//g, "").replace(/\s+/g, " ").trim();
|
|
31
|
+
const p = tree.get(key);
|
|
32
|
+
return p ? `${(p.names[0] ? `${p.names[0].given} ${p.names[0].surname}` : key).trim()} [${key}]` : key;
|
|
33
|
+
};
|
|
34
|
+
const who = c.partners ? c.partners.map(name).join(" & ") || c.family || "?" : name(c.person);
|
|
35
|
+
const kind = (f) => (f ? eventName(f.kind, lang, f.label) : "");
|
|
36
|
+
const v = {
|
|
37
|
+
n: c.n,
|
|
38
|
+
who,
|
|
39
|
+
name: name(c.person),
|
|
40
|
+
kind: kind(c.fact ?? c.was),
|
|
41
|
+
fact: `${kind(c.fact ?? c.was)} ${factText(c.fact ?? c.was, lang)}`,
|
|
42
|
+
was: factText(c.was, lang),
|
|
43
|
+
now: factText(c.fact, lang),
|
|
44
|
+
text: truncate(c.text ?? "", 120),
|
|
45
|
+
child: name(c.child),
|
|
46
|
+
kids: c.kind === "family.new" && c.text ? ` + ${c.text.split(" ").filter(Boolean).map(name).join(", ")}` : "",
|
|
47
|
+
};
|
|
48
|
+
return `${ui(lang, `ui.sync.${c.kind}`, v)} → ${ui(lang, `ui.sync.do.${c.action}`)}`;
|
|
49
|
+
}
|
|
50
|
+
function planText(tree, plan, incoming, file, lang, edits) {
|
|
51
|
+
const shown = file.includes(" ") ? `"${file}"` : file;
|
|
52
|
+
if (!plan.changes.length)
|
|
53
|
+
return ui(lang, "ui.sync.nothing", { file: path.basename(file) });
|
|
54
|
+
return lines(ui(lang, "ui.sync.title", { file: path.basename(file), n: plan.changes.length }), plan.base ? ui(lang, "ui.sync.base", { head: plan.head.slice(0, 8) }) : ui(lang, plan.head ? "ui.sync.headmissing" : "ui.sync.nobase", { head: plan.head?.slice(0, 8) ?? "" }), plan.partial ? ui(lang, "ui.sync.partial") : undefined, plan.identity.strangers.length ? ui(lang, "ui.sync.strangers", { ids: plan.identity.strangers.join(", ") }) : undefined, "", ...plan.changes.map((c) => ` ${changeLine(tree, c, incoming, lang)}`), "", ui(lang, "ui.sync.edits", { mode: ui(lang, edits === "user" ? "ui.sync.mode.user" : "ui.sync.mode.conflict") }), ui(lang, "ui.sync.next", { file: shown }));
|
|
55
|
+
}
|
|
56
|
+
/** The Strom app at this address: installed from a Chromium browser first, else in such a browser's tab (never Safari: it cannot reach the bridge). */
|
|
57
|
+
export function openAppAt(ctx, url) {
|
|
58
|
+
const installed = installedStromApp(ctx.env, process.platform, stromAppUrl(ctx.settings));
|
|
59
|
+
const webApp = installed?.appId && installed.browser ? { browser: installed.browser, appId: installed.appId, ...(installed.profile ? { profile: installed.profile } : {}) } : undefined;
|
|
60
|
+
const browser = chromiumBrowser(ctx.env);
|
|
61
|
+
return Boolean((webApp && openWebApp(webApp, url, ctx.env)) || (browser && openInBrowser(browser, url, ctx.env)));
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The Strom app sends the tree itself: the bridge started, the app opened with ?send= (installed from a Chromium
|
|
65
|
+
* browser first, else in such a browser's tab), and the tree waited for. The file it came as, or what to do instead.
|
|
66
|
+
*/
|
|
67
|
+
async function fromApp(ctx, root, lang) {
|
|
68
|
+
if (!appSendsChanges(ctx.settings))
|
|
69
|
+
return { text: ui(lang, "ui.sync.nosend") };
|
|
70
|
+
const info = startLive(root, ctx.env, { current: true });
|
|
71
|
+
if (!info)
|
|
72
|
+
throw new StromError("the bridge did not start", { hint: "strom live serve shows why" });
|
|
73
|
+
const url = sendAppUrl(info.url, ctx.settings);
|
|
74
|
+
const since = Date.now();
|
|
75
|
+
const opened = openAppAt(ctx, url);
|
|
76
|
+
const minutes = Math.max(1, Math.round(Number(ctx.env.STROM_SYNC_WAIT_MS ?? 10 * 60_000) / 60_000));
|
|
77
|
+
ctx.io.stderr(`${opened ? `${ui(lang, "ui.sync.wait", { min: minutes })}\n${ui(lang, "ui.sync.wait.open", { url })}` : ui(lang, "ui.app.url", { url })}\n`);
|
|
78
|
+
const until = since + Number(ctx.env.STROM_SYNC_WAIT_MS ?? 10 * 60_000);
|
|
79
|
+
while (Date.now() < until) {
|
|
80
|
+
const file = receivedSince(root, since);
|
|
81
|
+
if (file)
|
|
82
|
+
return { file, text: "" };
|
|
83
|
+
// the app sends nothing: said at once, not after the whole wait
|
|
84
|
+
const why = nothingSince(root, since);
|
|
85
|
+
if (why)
|
|
86
|
+
return { text: ui(lang, `ui.sync.nothing.${why}`), settled: why !== "no-tree" };
|
|
87
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
88
|
+
}
|
|
89
|
+
return { text: ui(lang, "ui.sync.waited", { min: minutes }) };
|
|
90
|
+
}
|
|
91
|
+
/** A family tree file without the images written into it (data: URLs): a GEDCOM's FILE with its CONC lines, a JSON's strings. */
|
|
92
|
+
export function withoutImages(text) {
|
|
93
|
+
const left = (bytes) => `[image left out, ${Math.round(bytes / 1024)} kB]`;
|
|
94
|
+
// GEDCOM: n FILE data:… and the n+1 CONC/CONT lines that go on with it
|
|
95
|
+
const ged = text.replace(/^(\d+) FILE data:[^\r\n]*(?:\r?\n(?:\d+) CON[CT] [^\r\n]*)*/gm, (m, level) => `${level} FILE ${left(m.length)}`);
|
|
96
|
+
// JSON: "data:image/…;base64,…"
|
|
97
|
+
return ged.replace(/"data:image\/[^"]*"/g, (m) => `"${left(m.length)}"`);
|
|
98
|
+
}
|
|
99
|
+
function numbers(v, max) {
|
|
100
|
+
if (v === undefined)
|
|
101
|
+
return undefined;
|
|
102
|
+
const out = new Set();
|
|
103
|
+
for (const part of String(v).split(/[\s,;]+/).filter(Boolean)) {
|
|
104
|
+
const m = /^(\d+)(?:-(\d+))?$/.exec(part);
|
|
105
|
+
if (!m)
|
|
106
|
+
throw new UsageError(`--only takes numbers of the list, e.g. 1,3,5-7 — not "${part}"`);
|
|
107
|
+
for (let i = Number(m[1]); i <= Number(m[2] ?? m[1]); i++) {
|
|
108
|
+
if (i < 1 || i > max)
|
|
109
|
+
throw new UsageError(`--only: there is no change ${i} (1–${max})`);
|
|
110
|
+
out.add(i);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
register({
|
|
116
|
+
path: ["sync"],
|
|
117
|
+
summary: "A family tree coming back (the Strom app, another program): what you changed, into the research — shown first",
|
|
118
|
+
group: "research",
|
|
119
|
+
tree: true,
|
|
120
|
+
writes: true,
|
|
121
|
+
// the Strom app may take minutes to send the tree: the tree is locked only while a sync is written
|
|
122
|
+
lock: "sections",
|
|
123
|
+
description: "Compares the file with the research and shows what it would take: people, facts, names, notes, families and\n" +
|
|
124
|
+
"children the file has and the research not; facts changed in it; what is no longer in it. A file of the Strom app\n" +
|
|
125
|
+
"names the state of the research it was given (_STROM_HEAD): only what was changed since then counts as your edit;\n" +
|
|
126
|
+
"without it (another program, an older export) only additions are taken, differences wait to be picked (--only).\n" +
|
|
127
|
+
"Nothing is deleted or overwritten: additions are leads citing one source (this sync), a lead is corrected with the\n" +
|
|
128
|
+
"reason, a change to a fact a record proves is a conflict for you to decide — or, with sync.edits user, your edit\n" +
|
|
129
|
+
"wins and the record's fact is withdrawn with the reason. A file of another research, or one where few people are\n" +
|
|
130
|
+
"the research's, is refused. Writes only with --apply; one sync is one commit: strom sync undo I… takes it back.\n" +
|
|
131
|
+
"--app: the Strom app sends the tree itself — strom opens it (the bridge), the user picks the tree and confirms\n" +
|
|
132
|
+
"there, strom waits for it and shows what it brings; then strom sync <the file it names> --apply.",
|
|
133
|
+
args: [{ name: "file", description: "the tree: GEDCOM (.ged) or the Strom app's JSON (none with --app)" }],
|
|
134
|
+
options: [
|
|
135
|
+
{ name: "app", type: "boolean", description: "straight from the Strom app: it opens, the user sends the tree from it, strom waits for it" },
|
|
136
|
+
{ name: "apply", type: "boolean", description: "write it (without: only show)" },
|
|
137
|
+
{ name: "only", type: "string", value: "<numbers>", description: "with --apply: only these changes of the list (1,3,5-7) — a difference is taken only this way" },
|
|
138
|
+
{ name: "edits", type: "string", value: "<conflict|user>", description: "this once: a change to a fact a record proves is a conflict (default) or your edit wins (setting sync.edits)" },
|
|
139
|
+
{ name: "force", type: "boolean", description: "the file is the research's though few of its people match" },
|
|
140
|
+
{ name: "again", type: "boolean", description: "a file taken in before, once more" },
|
|
141
|
+
],
|
|
142
|
+
examples: ["strom sync ~/Downloads/rodina.ged", "strom sync rodina.ged --apply", "strom sync rodina.json --apply --only 1,4"],
|
|
143
|
+
async run(ctx, { args, opts }) {
|
|
144
|
+
const tree = ctx.tree();
|
|
145
|
+
const lang = tree.lang;
|
|
146
|
+
if (opts.app && args[0])
|
|
147
|
+
throw new UsageError("--app takes the tree from the Strom app — no file with it");
|
|
148
|
+
if (!opts.app && !args[0])
|
|
149
|
+
throw new UsageError("which tree? a file, or --app (straight from the Strom app)", { hint: "strom sync ~/Downloads/rodina.ged · strom sync --app" });
|
|
150
|
+
if (opts.app && opts.apply)
|
|
151
|
+
throw new UsageError("--app shows what the tree brings; write it then with the file it names", { hint: "strom sync --app, then strom sync <file> --apply" });
|
|
152
|
+
let got;
|
|
153
|
+
if (opts.app) {
|
|
154
|
+
const r = await fromApp(ctx, tree.root, lang);
|
|
155
|
+
// nothing to show: the app said so (nothing changed, cancelled) — or never answered, or has no tree of this research
|
|
156
|
+
if (!r.file)
|
|
157
|
+
return { text: r.text, data: { received: null }, exitCode: r.settled ? EXIT.ok : EXIT.needsInput };
|
|
158
|
+
got = r.file;
|
|
159
|
+
}
|
|
160
|
+
const shownAs = got ? ctx.display(got) : args[0];
|
|
161
|
+
const file = got ?? ctx.resolvePath(args[0]);
|
|
162
|
+
if (!fs.existsSync(file) || !fs.statSync(file).isFile())
|
|
163
|
+
throw new UsageError(`no such file: ${args[0]}`);
|
|
164
|
+
const edits = opts.edits === undefined ? ctx.settings.syncEdits(tree.config) : String(opts.edits);
|
|
165
|
+
if (edits !== "conflict" && edits !== "user")
|
|
166
|
+
throw new UsageError("--edits is conflict or user");
|
|
167
|
+
const sha = fileSha256(file);
|
|
168
|
+
const before = tree.list("input").find((i) => i.sha === sha && i.sync && !i.sync.undone);
|
|
169
|
+
if (before && !opts.again)
|
|
170
|
+
return { text: ui(lang, "ui.sync.again", { input: before.id }), data: { input: before.id, changes: [] } };
|
|
171
|
+
const incoming = readTreeFile(file);
|
|
172
|
+
const plan = planSync(tree, incoming, edits, { force: !!opts.force });
|
|
173
|
+
if (!opts.apply || !plan.changes.length || tree.dryRun) {
|
|
174
|
+
if (opts.only !== undefined && !opts.apply)
|
|
175
|
+
throw new UsageError("--only goes with --apply");
|
|
176
|
+
return {
|
|
177
|
+
text: lines(got ? ui(lang, "ui.sync.got") : undefined, planText(tree, plan, incoming, shownAs, lang, edits)),
|
|
178
|
+
data: { ...(got ? { received: got } : {}), base: plan.base, head: plan.head ?? null, partial: plan.partial, changes: plan.changes, strangers: plan.identity.strangers },
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const only = numbers(opts.only, plan.changes.length);
|
|
182
|
+
let input;
|
|
183
|
+
let applied = 0;
|
|
184
|
+
tree.withTreeLock(() => {
|
|
185
|
+
// what another process wrote meanwhile counts: the changes again, now that the tree is ours
|
|
186
|
+
const fresh = planSync(tree, readTreeFile(file), edits, { force: !!opts.force });
|
|
187
|
+
if (fresh.changes.length !== plan.changes.length || fresh.changes.some((c, i) => c.kind !== plan.changes[i].kind || c.person !== plan.changes[i].person))
|
|
188
|
+
throw new UsageError("the research changed while this was shown — look again", { hint: `strom sync ${args[0] ?? file}` });
|
|
189
|
+
const id = tree.peekId("I");
|
|
190
|
+
const size = fs.statSync(file).size;
|
|
191
|
+
const stored = size <= MAX_IN_TREE ? `inputs/${id}-${safeFolderName(path.basename(file))}` : undefined;
|
|
192
|
+
if (stored) {
|
|
193
|
+
tree.remember(path.join(tree.root, stored));
|
|
194
|
+
fs.mkdirSync(path.join(tree.root, "inputs"), { recursive: true });
|
|
195
|
+
// kept as the document the source cites — without the images in it (the research's own excerpts, back from
|
|
196
|
+
// the app: megabytes in the history for nothing)
|
|
197
|
+
fs.writeFileSync(path.join(tree.root, stored), withoutImages(fs.readFileSync(file, "utf8")));
|
|
198
|
+
}
|
|
199
|
+
input = create(tree, "input", { name: path.basename(file), ...(stored ? { file: stored } : {}), sha, size, mime: mimeOf(file), from: ctx.display(file), kind: "tree", state: "processed" }, (iid) => `+${iid} input tree "${truncate(path.basename(file), 50)}" (sync)`);
|
|
200
|
+
const day = humanDay(now(), lang);
|
|
201
|
+
const source = create(tree, "source", {
|
|
202
|
+
kind: "family-tree",
|
|
203
|
+
title: ui(lang, incoming.treeId || plan.identity.matched ? "ui.sync.source.app" : "ui.sync.source.file", { file: path.basename(file), day }),
|
|
204
|
+
input: input.id,
|
|
205
|
+
information: "secondary",
|
|
206
|
+
form: "authored",
|
|
207
|
+
}, (sid) => `+${sid} source "${path.basename(file)}" (sync)`);
|
|
208
|
+
const { applied: done, changes } = applySync(tree, plan, incoming, source, only);
|
|
209
|
+
applied = changes;
|
|
210
|
+
input = update(tree, input.id, "input", (i) => ({ ...i, source: source.id, sync: { ...(plan.head ? { head: plan.head } : {}), edits: edits, applied: done } }), {
|
|
211
|
+
op: "input.sync",
|
|
212
|
+
summary: `${input.id} synced: ${changes} change(s)${plan.head ? ` against ${plan.head.slice(0, 8)}` : ""}`,
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
return {
|
|
216
|
+
text: lines(...tree.written.map((o) => o.summary), "", ui(lang, "ui.sync.done", { n: applied, file: path.basename(file), input: input.id })),
|
|
217
|
+
data: { input: input.id, applied: input.sync?.applied ?? [], changes: plan.changes },
|
|
218
|
+
};
|
|
219
|
+
},
|
|
220
|
+
}, {
|
|
221
|
+
path: ["sync", "undo"],
|
|
222
|
+
summary: "Take a sync back: what it added withdrawn, what it corrected put back, its conflicts closed",
|
|
223
|
+
group: "research",
|
|
224
|
+
tree: true,
|
|
225
|
+
writes: true,
|
|
226
|
+
args: [{ name: "input", description: "the sync's input (I…), as strom sync said", required: true }],
|
|
227
|
+
examples: ["strom sync undo I0002"],
|
|
228
|
+
run(ctx, { args }) {
|
|
229
|
+
const tree = ctx.tree();
|
|
230
|
+
const id = args[0].toUpperCase().replace(/^I?(\d+)$/, (_, n) => `I${n.padStart(4, "0")}`);
|
|
231
|
+
const input = tree.get(id);
|
|
232
|
+
if (!input || input.type !== "input" || !input.sync)
|
|
233
|
+
throw new UsageError(`${args[0]} is not a sync`, { hint: "strom input list — a sync's input says so" });
|
|
234
|
+
if (input.sync.undone)
|
|
235
|
+
return { text: ui(tree.lang, "ui.sync.wasundone", { input: id }), data: { input: id, undone: 0 } };
|
|
236
|
+
let n = 0;
|
|
237
|
+
tree.withTreeLock(() => {
|
|
238
|
+
n = undoSync(tree, input);
|
|
239
|
+
update(tree, id, "input", (i) => ({ ...i, sync: { ...i.sync, undone: now() } }), { op: "input.sync.undo", summary: `${id} sync undone: ${n} step(s)` });
|
|
240
|
+
});
|
|
241
|
+
return { text: lines(...tree.written.map((o) => o.summary), "", ui(tree.lang, "ui.sync.undone", { input: id, n })), data: { input: id, undone: n } };
|
|
242
|
+
},
|
|
243
|
+
});
|
package/dist/core/actions.js
CHANGED
|
@@ -850,3 +850,18 @@ export function setStory(tree, id, input) {
|
|
|
850
850
|
return updated;
|
|
851
851
|
});
|
|
852
852
|
}
|
|
853
|
+
/** The user approved the story as it is: no longer a draft (the text stays). */
|
|
854
|
+
export function approveStory(tree, id) {
|
|
855
|
+
return tree.withTreeLock(() => {
|
|
856
|
+
const rec = tree.get(id);
|
|
857
|
+
if (!rec || (rec.type !== "person" && rec.type !== "family"))
|
|
858
|
+
throw new UsageError(`no person or family ${id}`);
|
|
859
|
+
if (!rec.story)
|
|
860
|
+
throw new UsageError(`${id} has no story yet`, { hint: `strom story set ${id} --text @file --fact E…` });
|
|
861
|
+
if (rec.story.status === "final")
|
|
862
|
+
return rec;
|
|
863
|
+
const updated = { ...rec, story: { ...rec.story, status: "final" }, updated: now() };
|
|
864
|
+
tree.put(updated, { op: "story.approve", targets: [id], summary: `${id} story approved` });
|
|
865
|
+
return updated;
|
|
866
|
+
});
|
|
867
|
+
}
|
package/dist/core/config.js
CHANGED
|
@@ -44,6 +44,7 @@ export const SETTINGS = [
|
|
|
44
44
|
{ key: "queue.strategy", env: "STROM_QUEUE_STRATEGY", tree: true, kind: "choice", choices: STRATEGIES, description: "order of the task queue: balanced (default — nearest ancestors first, spread over the lines, nothing taken forever), depth (stay on one line), priority (strict priority)" },
|
|
45
45
|
{ key: "gedcom.for", env: "STROM_GEDCOM_FOR", tree: true, kind: "choice", choices: ["both", "standard", "strom"], description: "GEDCOM files written: both (default), standard (any program), strom (the Strom app)" },
|
|
46
46
|
{ key: "stories", env: "STROM_STORIES", tree: true, kind: "choice", choices: ["yes", "no"], description: "stories of the ancestors for the family, written from the facts: yes (default — strom proposes one once a person's life is told by records), no — the user is told when the research starts and may say no" },
|
|
47
|
+
{ key: "sync.edits", env: "STROM_SYNC_EDITS", tree: true, kind: "choice", choices: ["conflict", "user"], description: "your edits in the Strom app (or another program's file) to a fact a record proves, taken by strom sync: conflict (default — the research keeps the record's, a conflict for you to decide) or user (your edit wins, the record's fact is withdrawn with the reason)" },
|
|
47
48
|
{ key: "main.person", env: "STROM_MAIN_PERSON", tree: true, kind: "person", description: "the main person of the tree (P…): first in the GEDCOM files — the Strom app opens on them (default: the nearest person descended from every research's focus, else the first research's focus)" },
|
|
48
49
|
{ key: "excerpts.quality", env: "STROM_EXCERPTS_QUALITY", tree: true, kind: "choice", choices: EXCERPT_QUALITIES, description: "the entries cut out of their scans in the file for the Strom app (output/tree-strom.ged): small (1000 px, grey — half the size), normal (default, 1200 px), sharp (1600 px)" },
|
|
49
50
|
{ key: "excerpts.for", env: "STROM_EXCERPTS_FOR", tree: true, kind: "choice", choices: EXCERPT_SCOPES, description: "whose entries get their image in the Strom app: none (the file without images), line (the ancestors), family (default: the ancestors and their families), connected (anyone linked to them), all" },
|
|
@@ -359,6 +360,10 @@ export class Settings {
|
|
|
359
360
|
const v = this.resolve("stories", tree)?.value;
|
|
360
361
|
return { on: v !== "no", said: v === "yes" || v === "no" };
|
|
361
362
|
}
|
|
363
|
+
/** strom sync: whether the user's edit to a fact a record proves wins, or becomes a conflict. */
|
|
364
|
+
syncEdits(tree) {
|
|
365
|
+
return this.resolve("sync.edits", tree)?.value === "user" ? "user" : "conflict";
|
|
366
|
+
}
|
|
362
367
|
/** The images for the Strom app: how sharp, whose, and the limit of all together (MB). */
|
|
363
368
|
excerpts(tree) {
|
|
364
369
|
return {
|
package/dist/core/dialog.js
CHANGED
|
@@ -59,3 +59,28 @@ export function systemDialog(text, env, platform = process.platform) {
|
|
|
59
59
|
}
|
|
60
60
|
return undefined;
|
|
61
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Say something in a window (one button): what a link from the Strom app could not do, with no terminal to say it
|
|
64
|
+
* in. False when this computer cannot show one.
|
|
65
|
+
*/
|
|
66
|
+
export function systemNotice(title, message, ok, env, platform = process.platform) {
|
|
67
|
+
if (env.STROM_NO_DIALOG === "1")
|
|
68
|
+
return false;
|
|
69
|
+
const run = (cmd, args) => spawnSync(cmd, args, { encoding: "utf8", timeout: (WAIT_SECONDS + 10) * 1000, windowsHide: false, env: env });
|
|
70
|
+
if (platform === "darwin") {
|
|
71
|
+
const script = `display dialog ${appleString(message)} with title ${appleString(title)} buttons {${appleString(ok)}} default button ${appleString(ok)} with icon note giving up after ${WAIT_SECONDS}`;
|
|
72
|
+
return !run("osascript", ["-e", script]).error;
|
|
73
|
+
}
|
|
74
|
+
if (platform === "win32") {
|
|
75
|
+
const ps = `Add-Type -AssemblyName PresentationFramework; [void][System.Windows.MessageBox]::Show(${psString(message)}, ${psString(title)}, 'OK', 'Information', 'OK', 'DefaultDesktopOnly')`;
|
|
76
|
+
const r = run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", ps]);
|
|
77
|
+
return !r.error && r.status === 0;
|
|
78
|
+
}
|
|
79
|
+
if (!env.DISPLAY && !env.WAYLAND_DISPLAY)
|
|
80
|
+
return false;
|
|
81
|
+
if (which("zenity", env))
|
|
82
|
+
return !run("zenity", ["--info", "--title", title, "--text", message, "--timeout", String(WAIT_SECONDS)]).error;
|
|
83
|
+
if (which("kdialog", env))
|
|
84
|
+
return !run("kdialog", ["--title", title, "--msgbox", message]).error;
|
|
85
|
+
return false;
|
|
86
|
+
}
|