strom-research 1.9.0 → 1.11.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.
@@ -11,15 +11,23 @@ import { EXIT, StromError, UsageError } from "../core/errors.js";
11
11
  import { fileSha256, MAX_IN_TREE, mimeOf } from "../core/media.js";
12
12
  import { create, update } from "../core/records.js";
13
13
  import { safeFolderName } from "../core/text.js";
14
+ import { humanAge } from "../core/age.js";
14
15
  import { now } from "../core/tree.js";
15
16
  import { applySync, nothingSince, planSync, readTreeFile, receivedSince, undoSync } from "../core/sync.js";
16
17
  import { startLive } from "../core/live.js";
17
18
  import { appSendsChanges, installedStromApp, sendAppUrl, stromAppUrl } from "../core/stromapp.js";
18
19
  import { chromiumBrowser, openInBrowser, openWebApp } from "../core/chromium.js";
19
- function factText(f, lang) {
20
+ function factText(f, lang, name = (k) => k) {
20
21
  if (!f)
21
22
  return "—";
22
- return [f.value, humanDate(f.date, lang), humanPlace(f.place, undefined, lang)].filter(Boolean).join(", ") || "—";
23
+ return [f.value, humanDate(f.date, lang), humanPlace(f.place, undefined, lang), detailText(f, lang, name)].filter(Boolean).join(", ") || "—";
24
+ }
25
+ /** What a fact says besides: its house, age (a couple's: each partner's) and cause. */
26
+ function detailText(f, lang, name = (k) => k) {
27
+ if (!f)
28
+ return "";
29
+ const ages = Object.entries(f.ages ?? {}).map(([who, a]) => `${name(who)} ${humanAge(a, lang)}`);
30
+ return [f.house && ui(lang, "ui.sync.house", { x: f.house }), f.age && humanAge(f.age, lang), ...ages, f.cause && ui(lang, "ui.sync.cause", { x: f.cause })].filter(Boolean).join(", ");
23
31
  }
24
32
  /** A position on the map as a map shows it: degrees, six decimals at most. */
25
33
  const point = (p) => `${+p.lat.toFixed(6)}, ${+p.lon.toFixed(6)}`;
@@ -34,15 +42,18 @@ function changeLine(tree, c, incoming, lang) {
34
42
  return p ? `${(p.names[0] ? `${p.names[0].given} ${p.names[0].surname}` : key).trim()} [${key}]` : key;
35
43
  };
36
44
  const who = c.partners ? c.partners.map(name).join(" & ") || c.family || "?" : name(c.person);
45
+ // a partner in their age: the given name
46
+ const short = (key) => (key.startsWith("x:") ? name(key) : (tree.get(key)?.names[0]?.given ?? key));
37
47
  const kind = (f) => (f ? eventName(f.kind, lang, f.label) : "");
38
48
  const v = {
39
49
  n: c.n,
40
50
  who,
41
51
  name: name(c.person),
42
52
  kind: kind(c.fact ?? c.was),
43
- fact: `${kind(c.fact ?? c.was)} ${factText(c.fact ?? c.was, lang)}`,
44
- was: factText(c.was, lang),
45
- now: factText(c.fact, lang),
53
+ fact: `${kind(c.fact ?? c.was)} ${factText(c.fact ?? c.was, lang, short)}`,
54
+ was: c.kind === "fact.detail" ? detailText(c.was, lang, short) || "—" : factText(c.was, lang, short),
55
+ now: c.kind === "fact.detail" ? detailText(c.fact, lang, short) : factText(c.fact, lang, short),
56
+ when: [humanDate(c.fact?.date, lang), humanPlace(c.fact?.place, undefined, lang)].filter(Boolean).join(", "),
46
57
  text: truncate(c.text ?? "", 120),
47
58
  child: name(c.child),
48
59
  kids: c.kind === "family.new" && c.text ? ` + ${c.text.split(" ").filter(Boolean).map(name).join(", ")}` : "",
@@ -51,7 +62,8 @@ function changeLine(tree, c, incoming, lang) {
51
62
  from: c.place?.was ? point(c.place.was) : "",
52
63
  };
53
64
  const key = c.kind === "place.coords" && !c.place?.was ? "ui.sync.place.located" : `ui.sync.${c.kind}`;
54
- return `${ui(lang, key, v)} → ${ui(lang, `ui.sync.do.${c.action}`)}`;
65
+ const does = c.kind === "fact.detail" && c.action === "add" ? "ui.sync.do.detail" : `ui.sync.do.${c.action}`;
66
+ return `${ui(lang, key, v)} → ${ui(lang, does)}`;
55
67
  }
56
68
  function planText(tree, plan, incoming, file, lang, edits) {
57
69
  const shown = file.includes(" ") ? `"${file}"` : file;
@@ -22,6 +22,7 @@ import { readJsonLines } from "../core/json.js";
22
22
  import { lacksImages, rankTasks } from "../core/queue.js";
23
23
  import { joiningHypotheses, offTree } from "../core/kin.js";
24
24
  import { aboutPeople } from "../core/directions.js";
25
+ import { storiesToApprove } from "../core/stories.js";
25
26
  import { clipNote, inboxFolderFor, parseImageList, transcriptNote } from "../core/media.js";
26
27
  function written(tree) {
27
28
  return lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined);
@@ -63,8 +64,11 @@ export function waitingForUser(tree) {
63
64
  */
64
65
  export function waitingLines(tree, where = {}, max = 5) {
65
66
  const w = tree.list("task").filter((t) => t.state === "waiting");
67
+ // the new versions of stories the user approved: they decide (the lock)
68
+ const stories = storiesToApprove(tree).map((s) => s.id);
69
+ const storyLine = stories.length ? ` ${ui(tree.lang, "ui.wait.stories", { ids: stories.join(" ") })}` : undefined;
66
70
  if (!w.length)
67
- return undefined;
71
+ return stories.length ? lines(ui(tree.lang, "ui.wait.title", { n: stories.length }), storyLine) : undefined;
68
72
  const show = where.display ?? ((p) => p);
69
73
  const lang = tree.lang;
70
74
  const images = (t) => {
@@ -77,7 +81,7 @@ export function waitingLines(tree, where = {}, max = 5) {
77
81
  ` ${ui(lang, "ui.wait.save", { dir: `${dir}${path.sep}` })}`,
78
82
  ];
79
83
  };
80
- return lines(ui(lang, "ui.wait.title", { n: w.length }), ...w.slice(0, max).flatMap((t) => [` ${t.id} ${truncate(t.waitingOn || t.what, 150)}`, ...images(t)]), w.length > max ? ` … strom task list --state waiting` : undefined, ` ${ui(lang, "ui.wait.how1")}`, ` ${ui(lang, "ui.wait.how2")}`);
84
+ return lines(ui(lang, "ui.wait.title", { n: w.length + stories.length }), ...w.slice(0, max).flatMap((t) => [` ${t.id} ${truncate(t.waitingOn || t.what, 150)}`, ...images(t)]), w.length > max ? ` … strom task list --state waiting` : undefined, storyLine, ` ${ui(lang, "ui.wait.how1")}`, ` ${ui(lang, "ui.wait.how2")}`);
81
85
  }
82
86
  /** The open tasks of the queue in the order to work on them (see core/queue.ts). */
83
87
  export function taskQueue(tree, filter = {}) {
@@ -589,8 +589,10 @@ export function editPerson(tree, id, edit, reason) {
589
589
  */
590
590
  export function editEvent(tree, eventId, edit, reason) {
591
591
  const date = edit.date === undefined ? undefined : parseDate(edit.date);
592
- const age = edit.age === undefined ? undefined : parseAge(edit.age);
593
- const ages = edit.ages ? Object.fromEntries(Object.entries(edit.ages).map(([who, a]) => [who, parseAge(a, who)])) : undefined;
592
+ // an empty age takes it away
593
+ const age = edit.age === undefined ? undefined : edit.age.trim() ? parseAge(edit.age) : "";
594
+ // an empty age of a partner takes it away
595
+ const ages = edit.ages ? Object.fromEntries(Object.entries(edit.ages).map(([who, a]) => [who, a.trim() ? parseAge(a, who) : ""])) : undefined;
594
596
  return replaceEvent(tree, eventId, (e) => {
595
597
  const changed = [];
596
598
  const set = (k, v) => {
@@ -620,7 +622,11 @@ export function editEvent(tree, eventId, edit, reason) {
620
622
  for (const [who, a] of Object.entries(ages))
621
623
  if (e.ages?.[who] !== undefined && e.ages[who] !== a)
622
624
  changed.push(`age of ${who}`);
623
- next.ages = { ...e.ages, ...ages };
625
+ const merged = Object.fromEntries(Object.entries({ ...e.ages, ...ages }).filter(([, a]) => a));
626
+ if (Object.keys(merged).length)
627
+ next.ages = merged;
628
+ else
629
+ delete next.ages;
624
630
  }
625
631
  const key = (p) => `${p.role}|${p.person ?? foldText(p.name ?? "")}`;
626
632
  if (edit.drop?.length) {
@@ -821,7 +827,11 @@ export function mergeFamilies(tree, keepId, otherId, reason) {
821
827
  return { family: merged, repointed };
822
828
  });
823
829
  }
824
- /** Write (or rewrite) the story of a person or a couple. The facts it leans on must exist. */
830
+ /**
831
+ * Write (or rewrite) the story of a person or a couple. The facts it leans on must exist. A story the user approved is
832
+ * locked: written again, the new version waits beside it for their approval (story.draft) — unless they approve it now
833
+ * (final).
834
+ */
825
835
  export function setStory(tree, id, input) {
826
836
  const text = input.text.replace(/\r\n?/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
827
837
  if (!text)
@@ -829,28 +839,55 @@ export function setStory(tree, id, input) {
829
839
  const facts = [...new Set((input.facts ?? []).map((f) => f.trim().toUpperCase()))];
830
840
  for (const f of facts)
831
841
  findEventOwner(tree, f);
842
+ const sources = [...new Set((input.sources ?? []).map((s) => s.trim().toUpperCase()))];
843
+ for (const s of sources)
844
+ if (tree.get(s)?.type !== "source")
845
+ throw new UsageError(`no source ${s}`, { hint: "the background of place and time is recorded first: strom source add … --kind book|newspaper|web" });
832
846
  return tree.withTreeLock(() => {
833
847
  const rec = tree.get(id);
834
848
  if (!rec || (rec.type !== "person" && rec.type !== "family"))
835
849
  throw new UsageError(`no person or family ${id}`);
836
850
  if (rec.mergedInto)
837
851
  throw new UsageError(`${id} was merged into ${rec.mergedInto}`);
838
- const story = {
852
+ const version = {
839
853
  ...(input.title?.trim() ? { title: input.title.trim() } : {}),
840
- status: input.final ? "final" : "draft",
841
854
  text,
842
855
  facts,
856
+ ...(sources.length ? { sources } : {}),
843
857
  ...(input.note?.trim() ? { note: input.note.trim() } : {}),
844
858
  at: now(),
845
859
  by: tree.actor,
846
860
  };
847
- const updated = { ...rec, story, updated: now() };
848
861
  const words = text.split(/\s+/).length;
862
+ if (rec.story?.status === "final" && !input.final) {
863
+ const updated = { ...rec, story: { ...rec.story, draft: version }, updated: now() };
864
+ tree.put(updated, { op: "story.set", targets: [id], summary: `${id} story: a new version (${words} words) waits for the user's approval; the approved one stays` });
865
+ return updated;
866
+ }
867
+ const story = { ...version, status: input.final ? "final" : "draft" };
868
+ const updated = { ...rec, story, updated: now() };
849
869
  tree.put(updated, { op: "story.set", targets: [id], summary: `${id} story${rec.story ? " rewritten" : ""}: ${words} words, ${story.status}` });
850
870
  return updated;
851
871
  });
852
872
  }
853
- /** The user approved the story as it is: no longer a draft (the text stays). */
873
+ /** The new version of an approved story is not wanted: it goes, the approved story stays (the history keeps it). */
874
+ export function discardStoryDraft(tree, id) {
875
+ return tree.withTreeLock(() => {
876
+ const rec = tree.get(id);
877
+ if (!rec || (rec.type !== "person" && rec.type !== "family"))
878
+ throw new UsageError(`no person or family ${id}`);
879
+ if (!rec.story?.draft)
880
+ throw new UsageError(`${id} has no new version of its story waiting`, { hint: `strom story show ${id}` });
881
+ const { draft: _gone, ...story } = rec.story;
882
+ const updated = { ...rec, story, updated: now() };
883
+ tree.put(updated, { op: "story.discard", targets: [id], summary: `${id} story: the new version discarded, the approved one stays` });
884
+ return updated;
885
+ });
886
+ }
887
+ /**
888
+ * The user approved the story as it is: no longer a draft (the text stays), locked — or, when a new version waits
889
+ * beside an approved one, that new version takes its place, approved.
890
+ */
854
891
  export function approveStory(tree, id) {
855
892
  return tree.withTreeLock(() => {
856
893
  const rec = tree.get(id);
@@ -858,6 +895,11 @@ export function approveStory(tree, id) {
858
895
  throw new UsageError(`no person or family ${id}`);
859
896
  if (!rec.story)
860
897
  throw new UsageError(`${id} has no story yet`, { hint: `strom story set ${id} --text @file --fact E…` });
898
+ if (rec.story.draft) {
899
+ const updated = { ...rec, story: { ...rec.story.draft, status: "final" }, updated: now() };
900
+ tree.put(updated, { op: "story.approve", targets: [id], summary: `${id} story: the new version approved, in place of the old one` });
901
+ return updated;
902
+ }
861
903
  if (rec.story.status === "final")
862
904
  return rec;
863
905
  const updated = { ...rec, story: { ...rec.story, status: "final" }, updated: now() };
@@ -48,6 +48,7 @@ const SAID = {
48
48
  "repository.edit": "log.repository.edit",
49
49
  "story.set": "log.story.set",
50
50
  "story.approve": "log.story.approve",
51
+ "story.discard": "log.story.discard",
51
52
  "note.add": "log.note.add",
52
53
  "input.add": "log.input.add",
53
54
  "input.sync": "log.input.sync",
@@ -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: "stories.sources", env: "STROM_STORIES_SOURCES", tree: true, kind: "choice", choices: ["yes", "no"], description: "before a story is written, a search beyond the registers — newspapers, directories, military, land and court records, graves, the history of the place — so the story can tell more, every word still on a record: yes (default, while stories are on), no (the story from what the research found)" },
47
48
  { 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)" },
48
49
  { 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)" },
49
50
  { 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)" },
@@ -362,6 +363,10 @@ export class Settings {
362
363
  const v = this.resolve("stories", tree)?.value;
363
364
  return { on: v !== "no", said: v === "yes" || v === "no" };
364
365
  }
366
+ /** Before a story, the sources beyond the registers (core/stories.ts): on unless the user said no. */
367
+ storySources(tree) {
368
+ return this.resolve("stories.sources", tree)?.value !== "no";
369
+ }
365
370
  /** strom sync: whether the user's edit to a fact a record proves wins, or becomes a conflict. */
366
371
  syncEdits(tree) {
367
372
  return this.resolve("sync.edits", tree)?.value === "user" ? "user" : "conflict";
@@ -40,7 +40,47 @@ export function importDate(raw) {
40
40
  .replace(/^(AFTER|>)\s*/, "AFT ")
41
41
  .replace(/\?$/, "")
42
42
  .trim();
43
- return normalizeDate(cleaned);
43
+ return normalizeDate(cleaned) ?? localDate(raw);
44
+ }
45
+ // Words other programs write in place of GEDCOM's ("Po 1919", "kolem 1850", "zwischen 1811 und 1812",
46
+ // "около 1870"), folded: Czech, Slovak, German, Polish, Russian, Ukrainian, English.
47
+ const DATE_QUALIFIER = {
48
+ ...Object.fromEntries(["after", "po", "nach", "после", "після"].map((w) => [w, "AFT"])),
49
+ ...Object.fromEntries(["before", "pred", "vor", "przed", "do", "bis", "до"].map((w) => [w, "BEF"])),
50
+ ...Object.fromEntries(["about", "circa", "ca", "c", "cca", "kolem", "okolo", "asi", "zhruba", "priblizne", "um", "etwa", "ungefahr", "ok", "около", "близько", "приблизно"].map((w) => [w, "ABT"])),
51
+ };
52
+ const DATE_RANGE = {
53
+ ...Object.fromEntries(["between", "mezi", "medzi", "miedzy", "pomiedzy", "zwischen", "между", "між"].map((w) => [w, "BET"])),
54
+ ...Object.fromEntries(["from", "od", "von", "ab", "с", "з", "від"].map((w) => [w, "FROM"])),
55
+ };
56
+ const DATE_JOIN = new Set(["and", "a", "i", "und", "и", "і", "та", "to", "do", "bis", "по", "до"]);
57
+ /** "po roce 1919", "im Jahr 1850", "1870 г.": words around the year that say nothing. */
58
+ const DATE_FILLER = new Set(["r", "roku", "roce", "rokem", "rok", "im", "jahr", "jahre", "year", "the", "г", "года", "году", "року", "рік"]);
59
+ /** A date written in words of a language ("Po 1919" → "AFT 1919"); undefined when it is not one. */
60
+ function localDate(raw) {
61
+ const words = foldText(raw)
62
+ .split(" ")
63
+ .map((w) => w.replace(/[.,]+$/u, ""))
64
+ .filter((w) => w && !DATE_FILLER.has(w));
65
+ const plain = (part) => {
66
+ const d = part.length ? normalizeDate(part.join(" ")) : undefined;
67
+ return d && !/^(ABT|CAL|EST|BEF|AFT|BET|FROM|TO) /.test(d) ? d : undefined;
68
+ };
69
+ const first = words[0] ?? "";
70
+ const range = DATE_RANGE[first];
71
+ if (range) {
72
+ const j = words.findIndex((w, i) => i > 1 && DATE_JOIN.has(w));
73
+ const a = plain(j > 0 ? words.slice(1, j) : words.slice(1));
74
+ const b = j > 0 ? plain(words.slice(j + 1)) : undefined;
75
+ if (!a)
76
+ return undefined;
77
+ if (range === "BET")
78
+ return b ? `BET ${a} AND ${b}` : undefined;
79
+ return j > 0 ? (b ? `FROM ${a} TO ${b}` : undefined) : `FROM ${a}`;
80
+ }
81
+ const q = DATE_QUALIFIER[first];
82
+ const d = q ? plain(words.slice(1)) : undefined;
83
+ return q && d ? `${q} ${d}` : undefined;
44
84
  }
45
85
  function makeSource(tree, title, input) {
46
86
  const t = now();
@@ -405,6 +445,12 @@ export function isStromJson(data) {
405
445
  export function fromFlexDate(flex) {
406
446
  if (!flex)
407
447
  return undefined;
448
+ // a range ("1785..1804"): between the two
449
+ const range = /^(.+?)\.\.(.+)$/.exec(flex.trim());
450
+ if (range) {
451
+ const [a, b] = [fromFlexDate(range[1]), fromFlexDate(range[2])];
452
+ return a && b && !/^(ABT|BEF|AFT|BET|FROM|TO) /.test(a) && !/^(ABT|BEF|AFT|BET|FROM|TO) /.test(b) ? normalizeDate(`BET ${a} AND ${b}`) : undefined;
453
+ }
408
454
  const m = /^([~<>])?(\d{3,4})(?:-(\d{2}))?(?:-(\d{2}))?$/.exec(flex.trim());
409
455
  if (!m)
410
456
  return importDate(flex);
@@ -496,7 +542,7 @@ export function importStromJson(tree, data, opts) {
496
542
  return result;
497
543
  }
498
544
  /** "čp. 13", "č. p. 13", "Nr. 13", "House No. 13" → "13": the house, without the word for it. */
499
- function houseOf(address) {
545
+ export function houseOf(address) {
500
546
  return address
501
547
  .replace(/\s+/g, " ")
502
548
  .trim()
@@ -33,6 +33,8 @@ export const LINK_SCHEME = "strom-research";
33
33
  * and (the app's 3.5.0) the research followed live in the app's window; a direction of the research paused, ended or
34
34
  * taken up again, a conversation about one direction, a session at work asked to finish.
35
35
  */
36
+ /** A story: approved (or, beside an approved one, its new version approved in its place), or that new version not wanted. */
37
+ export const LINK_STORY_DOS = ["final", "keep"];
36
38
  export const LINK_ACTIONS = ["send", "excerpt", "app", "open", "chat", "task", "review", "research", "new", "update", "sessions", "conflict", "story", "sync-undo", "setup", "live", "direction", "finish"];
37
39
  /** A person's review: the person, with the family, with the ancestors (strom review --scope). */
38
40
  export const LINK_SCOPES = ["person", "family", "line"];
@@ -139,7 +141,9 @@ export function parseLink(text) {
139
141
  return { action, tree: id, person, scope: oneOf(q, "scope", LINK_SCOPES, "person") };
140
142
  if (action === "research")
141
143
  return { action, tree: id, person, direction: oneOf(q, "direction", LINK_DIRECTIONS, "ancestors") };
142
- return { action, tree: id, person, do: oneOf(q, "do", ["final"]) };
144
+ // a couple's story: the app knows no family IDs, so the family of the two partners
145
+ const partner = idOf(q, "partner", PERSON, false);
146
+ return { action, tree: id, person, ...(partner ? { partner } : {}), do: oneOf(q, "do", LINK_STORY_DOS) };
143
147
  }
144
148
  case "conflict":
145
149
  return { action, tree: id, id: idOf(q, "id", CONFLICT, true), do: oneOf(q, "do", LINK_CONFLICT_DOS, "decide") };
package/dist/core/live.js CHANGED
@@ -35,7 +35,7 @@ import { spawn, spawnSync } from "node:child_process";
35
35
  import { Tree, VERSION } from "./tree.js";
36
36
  import { changeLines } from "./changelog.js";
37
37
  import { directionOf, scopes } from "./directions.js";
38
- import { ancestorGenerations } from "./people.js";
38
+ import { ancestorGenerations, displayName } from "./people.js";
39
39
  import { phrase } from "./phrases.js";
40
40
  import { exportGedcom } from "../gedcom/export.js";
41
41
  import { excerptSettings, planExcerpts } from "./excerpt.js";
@@ -49,10 +49,11 @@ import { rankTasks } from "./queue.js";
49
49
  import { humanTask } from "../cli/human.js";
50
50
  import { knownNewerVersion } from "./update.js";
51
51
  import { gitProgram, runGit } from "./git.js";
52
- import { appOpensLinks, appShowsEdges, isStromAppOrigin } from "./stromapp.js";
52
+ import { appOpensLinks, appShowsCoupleEvents, appShowsEdges, appShowsStoryDrafts, isStromAppOrigin } from "./stromapp.js";
53
53
  import { Settings } from "./config.js";
54
54
  import { linkActions, linkHandlerState } from "./links.js";
55
55
  import { stromLauncher } from "./self.js";
56
+ import { storiesToApprove } from "./stories.js";
56
57
  /** Where a running bridge of a tree is noted. */
57
58
  export function liveFile(root) {
58
59
  return path.join(root, ".strom", "live.json");
@@ -300,6 +301,30 @@ function personOf(t) {
300
301
  const people = (t?.subject ?? []).filter((id) => /^P\d{1,7}$/.test(id));
301
302
  return people.length === 1 ? { person: people[0] } : {};
302
303
  }
304
+ /** The new versions of approved stories, as items of `waiting`: whose (a couple's: person and partner), since when. */
305
+ function storiesWaiting(tree) {
306
+ return storiesToApprove(tree).flatMap((s) => {
307
+ const rec = tree.get(s.id);
308
+ if (!rec)
309
+ return [];
310
+ const people = rec.type === "family" ? rec.partners : [rec.id];
311
+ const name = (id) => {
312
+ const p = tree.get(id);
313
+ return p ? displayName(p) : id;
314
+ };
315
+ return [
316
+ {
317
+ kind: "story",
318
+ id: rec.id,
319
+ what: ui(tree.lang, "ui.waiting.story", { who: people.map(name).join(" & ") }),
320
+ on: "",
321
+ at: s.at,
322
+ ...(people[0] ? { person: people[0] } : {}),
323
+ ...(people[1] ? { partner: people[1] } : {}),
324
+ },
325
+ ];
326
+ });
327
+ }
303
328
  /** Who works, as the user reads it: "Claude Code on its own", "Codex conversation" in the research language. */
304
329
  function whoAtWork(label, lang) {
305
330
  const run = /^(.+) on its own$/.exec(label);
@@ -459,7 +484,11 @@ function status(root, env) {
459
484
  working: atWork,
460
485
  open: openSessions(tree).map((s) => ({ id: s.id, task: s.task, started: s.started })),
461
486
  // since when it waits: its last change is the one that made it wait
462
- waiting: waiting.map((t) => ({ id: t.id, what: t.what, on: t.waitingOn ?? "", at: t.updated, ...personOf(t), ...researchOf(tree, t, all) })),
487
+ waiting: [
488
+ ...waiting.map((t) => ({ id: t.id, what: t.what, on: t.waitingOn ?? "", at: t.updated, ...personOf(t), ...researchOf(tree, t, all) })),
489
+ // a new version of an approved story, as the menu counts it — for an app that shows it (the _DRAFT gate)
490
+ ...(appShowsStoryDrafts(settings) ? storiesWaiting(tree) : []),
491
+ ],
463
492
  links: links(env),
464
493
  ...queue(tree, next, all),
465
494
  ...(newer ? { update: { version: newer } } : {}),
@@ -566,11 +595,13 @@ export function serveLive(root, env) {
566
595
  const opens = appOpensLinks(new Settings(env, {}));
567
596
  // …and where the tree ends, for an app that shows it
568
597
  const edges = appShowsEdges(new Settings(env, {}));
598
+ const storyDrafts = appShowsStoryDrafts(new Settings(env, {}));
599
+ const coupleResi = appShowsCoupleEvents(new Settings(env, {}));
569
600
  const offered = opens ? links(env) : [];
570
601
  ged = {
571
602
  head: h,
572
603
  links: offered.join(" "),
573
- text: exportGedcom(tree, { for: "strom", ...(h ? { head: h } : {}), ...(images ? { excerpts: images.of } : {}), ...(opens ? { clips: true, research: true } : {}), ...(edges ? { edges } : {}), ...(offered.length ? { links: offered } : {}) }).text,
604
+ text: exportGedcom(tree, { for: "strom", ...(h ? { head: h } : {}), ...(images ? { excerpts: images.of } : {}), ...(opens ? { clips: true, research: true } : {}), ...(edges ? { edges } : {}), ...(storyDrafts ? { storyDrafts } : {}), ...(coupleResi ? { coupleResi } : {}), ...(offered.length ? { links: offered } : {}) }).text,
574
605
  };
575
606
  }
576
607
  res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", "X-Strom-Head": h }).end(ged.text);
@@ -31,6 +31,10 @@ export const PHRASES = {
31
31
  "story.where": "the facts recorded (strom person show {id})",
32
32
  "story.why": "{count} facts from records tell the life (generation {generation} of {research}); the family reads it in the Strom app's family book",
33
33
  "story.done": "strom story set {id} with every fact it rests on (--fact) — a draft until the user approves it",
34
+ "story.sources.what": "Beyond the registers, for the story of {name}: newspapers, directories, military, land and court records, graves, the history of the place",
35
+ "story.sources.where": "the digitised newspapers, books and archives of the country and time; the web (indexes and trees are leads only)",
36
+ "story.sources.why": "the story is to tell more than the registers do — every word still on a record; then strom proposes the story",
37
+ "story.sources.done": "each find recorded as a source and a fact with its citation (the person matched by more than the name), the history of the place as a source, every search recorded — also in vain",
34
38
  "review.research": "Review of {name}",
35
39
  "review.unproven.research": "People without a record of their own",
36
40
  "review.more": " (and {count} more after these)",
@@ -130,6 +134,7 @@ export const PHRASES = {
130
134
  "log.repository.edit": "Archive changed: {name}",
131
135
  "log.story.set": "Story: {who}",
132
136
  "log.story.approve": "Story approved: {who}",
137
+ "log.story.discard": "The new version of the story not wanted, the approved one stays: {who}",
133
138
  "log.note.add": "Note: {name}",
134
139
  "log.input.add": "Material from the family: {name}",
135
140
  "log.input.sync": "Family tree taken in: {name}",
@@ -16,6 +16,7 @@ import { now } from "./tree.js";
16
16
  import { subjectPeople } from "./records.js";
17
17
  import { directionOf, scopes } from "./directions.js";
18
18
  import { offTree } from "./kin.js";
19
+ import { forStory } from "./stories.js";
19
20
  export const LEVEL_ORDER = { intake: 0, locate: 1, link: 2, verify: 3, enrich: 4, request: 5, narrate: 6 };
20
21
  /** Levels that read records: without images of their record sets they can only ask the user for them. */
21
22
  const NEEDS_IMAGES = new Set(["link", "verify"]);
@@ -92,7 +93,10 @@ export function rankTasks(tree, tasks, strategy = "balanced", opts = {}) {
92
93
  return t ? (place(t)?.line ?? `task:${t.id}`) : undefined;
93
94
  });
94
95
  // A story's turn: STORY_EVERY sessions since the last one on a story (or ever), and one waits.
95
- const sinceStory = ended.findIndex((s) => tasksById.get(s.task)?.level === "narrate");
96
+ const sinceStory = ended.findIndex((s) => {
97
+ const t = tasksById.get(s.task);
98
+ return !!t && forStory(t);
99
+ });
96
100
  const storyDue = !!opts.storyTurn && strategy !== "priority" && (sinceStory === -1 ? ended.length : sinceStory) >= STORY_EVERY;
97
101
  const tries = new Map();
98
102
  for (const s of ended)
@@ -110,7 +114,7 @@ export function rankTasks(tree, tasks, strategy = "balanced", opts = {}) {
110
114
  ? 0
111
115
  : 2 * t.priority + intake - (gen - 1) - 2 * recent - 1.5 * tried;
112
116
  const blocked = lacksImages(tree, t, withImages);
113
- const storyTurn = storyDue && t.level === "narrate";
117
+ const storyTurn = storyDue && forStory(t);
114
118
  const why = [
115
119
  t.state === "doing" ? "in progress" : undefined,
116
120
  storyTurn ? `a story's turn: none in the last ${STORY_EVERY} sessions` : undefined,
@@ -122,7 +126,7 @@ export function rankTasks(tree, tasks, strategy = "balanced", opts = {}) {
122
126
  ]
123
127
  .filter(Boolean)
124
128
  .join(" · ");
125
- return { task: t, why, doing: t.state === "doing" ? 0 : 1, blocked: blocked ? 1 : 0, story: storyTurn ? 0 : t.level === "narrate" ? 2 : 1, score, gen };
129
+ return { task: t, why, doing: t.state === "doing" ? 0 : 1, blocked: blocked ? 1 : 0, story: storyTurn ? 0 : forStory(t) ? 2 : 1, score, gen };
126
130
  });
127
131
  return rows
128
132
  .sort((a, b) => a.doing - b.doing ||
@@ -1,10 +1,21 @@
1
1
  // Stories of the ancestors (the setting `stories`, on by default): once records
2
2
  // tell enough of a person's life, strom proposes the task of writing it for the
3
3
  // family — and of adding to it when records brought more after it was written.
4
- // The story goes into the family book of the Strom app (GEDCOM _STORY).
4
+ // The story goes into the family book of the Strom app (GEDCOM _STORY). Before
5
+ // it (the setting `stories.sources`, on by default), once per person a search
6
+ // beyond the registers — newspapers, directories, military, land and court
7
+ // records, graves, the history of the place — so the story can tell more, every
8
+ // word still on a record; the story is proposed when that search is finished.
5
9
  import { researchPeople } from "./frontier.js";
6
10
  import { displayName, familiesAsPartner, lifespan } from "./people.js";
7
11
  import { phrase } from "./phrases.js";
12
+ import { Settings } from "./config.js";
13
+ /** The origin of the search beyond the registers that comes before a story (an enrich task). */
14
+ export const STORY_SOURCES_ORIGIN = "story:sources";
15
+ /** Work for a story: writing it, or the search beyond the registers before it — the queue keeps them after the research. */
16
+ export function forStory(t) {
17
+ return t.level === "narrate" || t.origin === STORY_SOURCES_ORIGIN;
18
+ }
8
19
  const FROM_RECORDS = new Set(["proven", "probable"]);
9
20
  const BIRTHS = new Set(["BIRT", "CHR", "BAPM"]);
10
21
  const OPEN = new Set(["open", "doing", "parked", "waiting"]);
@@ -22,7 +33,9 @@ export function factsFromRecords(tree, p) {
22
33
  /** The stories strom proposes to write (or add to) for the ancestors of a research. */
23
34
  export function storyProposals(tree, research) {
24
35
  const lang = tree.lang;
25
- const narrate = tree.list("task").filter((t) => t.level === "narrate");
36
+ const tasks = tree.list("task");
37
+ const narrate = tasks.filter((t) => t.level === "narrate");
38
+ const sources = new Settings(tree.env, {}).storySources(tree.config);
26
39
  const out = [];
27
40
  for (const [id, generation] of researchPeople(tree, research)) {
28
41
  const p = tree.get(id);
@@ -34,17 +47,40 @@ export function storyProposals(tree, research) {
34
47
  const mine = narrate.filter((t) => t.subject.includes(id));
35
48
  if (mine.some((t) => OPEN.has(t.state)))
36
49
  continue;
50
+ // The latest version written: a new one waiting beside an approved story, else the story.
51
+ const latest = p.story?.draft ?? p.story;
37
52
  // Dropped since the story was written (or with none): that was the answer — not again until the story changes.
38
- const since = p.story?.at ?? "";
53
+ const since = latest?.at ?? "";
39
54
  if (mine.some((t) => t.state === "dropped" && t.updated >= since))
40
55
  continue;
41
56
  // Done, yet no story: somebody decided so; the user can add the task by hand.
42
57
  if (!p.story && mine.some((t) => FINISHED.has(t.state)))
43
58
  continue;
44
59
  const name = `${displayName(p)}${lifespan(p) ? ` (${lifespan(p)})` : ""}`;
45
- const fresh = p.story ? facts.filter((e) => !p.story.facts.includes(e.id)) : facts;
60
+ const fresh = latest ? facts.filter((e) => !latest.facts.includes(e.id)) : facts;
46
61
  if (p.story && fresh.length < STORY_NEW_FACTS)
47
62
  continue;
63
+ // First, once, the search beyond the registers; the story waits while it is open, and comes when it is finished
64
+ // (done, or dropped: the user's answer about the search, not about the story).
65
+ if (sources) {
66
+ const before = tasks.filter((t) => t.origin === STORY_SOURCES_ORIGIN && t.subject.includes(id));
67
+ if (before.some((t) => OPEN.has(t.state)))
68
+ continue;
69
+ if (!before.length) {
70
+ out.push({
71
+ level: "enrich",
72
+ priority: 1, // with the stories: after the research
73
+ what: phrase(lang, "story.sources.what", { name }),
74
+ where: [phrase(lang, "story.sources.where")],
75
+ why: phrase(lang, "story.sources.why"),
76
+ doneWhen: phrase(lang, "story.sources.done"),
77
+ subject: [id],
78
+ research: research.id,
79
+ origin: STORY_SOURCES_ORIGIN,
80
+ });
81
+ continue;
82
+ }
83
+ }
48
84
  out.push({
49
85
  level: "narrate",
50
86
  priority: 1, // the research first; a story when there is a moment for it
@@ -58,3 +94,10 @@ export function storyProposals(tree, research) {
58
94
  }
59
95
  return out;
60
96
  }
97
+ /** The new versions of approved stories that wait for the user (the lock): whose, and since when. */
98
+ export function storiesToApprove(tree) {
99
+ return [...tree.list("person"), ...tree.list("family")]
100
+ .filter((r) => !r.retracted && r.story?.status === "final" && r.story.draft)
101
+ .map((r) => ({ id: r.id, at: r.story.draft.at }))
102
+ .sort((a, b) => a.at.localeCompare(b.at));
103
+ }
@@ -82,6 +82,48 @@ export function appShowsEdges(settings, version) {
82
82
  return (a[i] ?? 0) > (b[i] ?? 0);
83
83
  return true;
84
84
  }
85
+ /**
86
+ * The Strom app keeps a couple's events as events (the app's spec docs/ZADANI_VYZKUM_udalosti-paru.md, its 3.8.0):
87
+ * their residence goes to it as 1 RESI under FAM (before: an EVEN named so — an older app keeps either in the couple's
88
+ * note). stromapp.info from 3.8.0 (2026-10-02); another copy of the app (its beta, its development) is taken as current.
89
+ * An app of unknown version: today's.
90
+ */
91
+ export const APP_SHOWS_COUPLE_EVENTS = "3.8.0";
92
+ export function appShowsCoupleEvents(settings, version) {
93
+ if (stromAppUrl(settings) !== STROM_APP_URL)
94
+ return true;
95
+ if (!APP_SHOWS_COUPLE_EVENTS)
96
+ return false;
97
+ if (!version)
98
+ return true;
99
+ const n = (v) => v.split(".").map((x) => Number.parseInt(x, 10) || 0);
100
+ const [a, b] = [n(version), n(APP_SHOWS_COUPLE_EVENTS)];
101
+ for (let i = 0; i < 3; i++)
102
+ if ((a[i] ?? 0) !== (b[i] ?? 0))
103
+ return (a[i] ?? 0) > (b[i] ?? 0);
104
+ return true;
105
+ }
106
+ /**
107
+ * The Strom app shows the new version of a story the user approved, waiting beside it (the app's spec
108
+ * docs/ZADANI_VYZKUM_vypraveni-zamek.md): 2 _DRAFT under _STORY, and in the bridge's waiting (kind story). stromapp.info
109
+ * from 3.7.0 (2026-10-02); another copy of the app (its beta, its development: strom.app.url) is taken as current, so it
110
+ * can be built. An app of unknown version: today's.
111
+ */
112
+ export const APP_SHOWS_STORY_DRAFTS = "3.7.0";
113
+ export function appShowsStoryDrafts(settings, version) {
114
+ if (stromAppUrl(settings) !== STROM_APP_URL)
115
+ return true;
116
+ if (!APP_SHOWS_STORY_DRAFTS)
117
+ return false;
118
+ if (!version)
119
+ return true;
120
+ const n = (v) => v.split(".").map((x) => Number.parseInt(x, 10) || 0);
121
+ const [a, b] = [n(version), n(APP_SHOWS_STORY_DRAFTS)];
122
+ for (let i = 0; i < 3; i++)
123
+ if ((a[i] ?? 0) !== (b[i] ?? 0))
124
+ return (a[i] ?? 0) > (b[i] ?? 0);
125
+ return true;
126
+ }
85
127
  /** The address that opens the Strom app to send the user's edited tree to the bridge. */
86
128
  export function sendAppUrl(bridge, settings) {
87
129
  return `${stromAppUrl(settings)}?send=${encodeURIComponent(bridge)}`;