strom-research 1.18.0-beta.3 → 1.18.0-beta.4

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/CHANGELOG.cs.md CHANGED
@@ -7,7 +7,7 @@ Změny každé verze ve zkratce. Úplné poznámky a instalační řádky jsou n
7
7
 
8
8
  ## 1.18.0
9
9
 
10
- *Připravuje se: bety do 1.18.0-beta.3. Oddíl bude úplný s vydáním.*
10
+ *Připravuje se: bety do 1.18.0-beta.4. Oddíl bude úplný s vydáním.*
11
11
 
12
12
  - **Fotky z telefonu nastojato.** Fotka otočená podle údaje EXIF se čte tak, jak se má dívat, nastojato; místa na ní
13
13
  vyznačená (výřezy, poloviny dvoustrany, uložená část) jsou místa obrázku, jak se ukazuje. Fotka sama zůstává, jak
@@ -20,7 +20,8 @@ Změny každé verze ve zkratce. Úplné poznámky a instalační řádky jsou n
20
20
  z matriky, dopis, listinu, náhrobek, rub fotky: přepis slovo od slova, překlad, význam starých slov a zkratek
21
21
  a osoby, které jmenuje, i s tím, kdo ze stromu to může být. Nic se nedomýšlí: jméno, které v dokumentu není,
22
22
  vypadne — i příjmení, nikdy se nebere od otce ani z jiné rodiny —, co nejde přečíst, je označené; druhý čtenář
23
- může číst naslepo. Do stromu se z toho nic nezapíše samo.
23
+ může číst naslepo. Do stromu se z toho nic nezapíše samo. Dokument už přečtený to o sobě řekne, takže se omylem
24
+ nečte podruhé.
24
25
  - **Fotky z telefonu.** `strom phone on`, které zapíná jen člověk, pustí k počítači telefon v domácí síti (Wi-Fi
25
26
  nebo kabel — nikdy mobilní síť ani VPN). Telefon se jednou spáruje přes QR kód; jeho stránka pak fotí nebo vybírá
26
27
  z galerie a posílá fotky celé do stromu výzkumu, s poznámkou a osobou, kterou ukazují — stejně jako „Přidat
package/CHANGELOG.de.md CHANGED
@@ -7,7 +7,7 @@ Die Änderungen jeder Version in Kürze. Die vollständigen Hinweise und die Ins
7
7
 
8
8
  ## 1.18.0
9
9
 
10
- *In Vorbereitung: die Betas bis 1.18.0-beta.3. Der Abschnitt ist mit dem Release vollständig.*
10
+ *In Vorbereitung: die Betas bis 1.18.0-beta.4. Der Abschnitt ist mit dem Release vollständig.*
11
11
 
12
12
  - **Handyfotos aufrecht.** Ein Foto, das nach seiner EXIF-Ausrichtung gedreht ist, wird gelesen, wie es gesehen
13
13
  werden soll, aufrecht; die darauf markierten Stellen (Ausschnitte, die Hälften einer Doppelseite, ein gespeicherter
@@ -21,7 +21,8 @@ Die Änderungen jeder Version in Kürze. Die vollständigen Hinweise und die Ins
21
21
  Abschrift Wort für Wort, ihre Übersetzung, was alte Wörter und Abkürzungen bedeuten, und die genannten Personen,
22
22
  samt wem im Stammbaum sie entsprechen können. Nichts wird ergänzt: ein Name, der nicht im Dokument steht, fällt
23
23
  weg — auch ein Nachname, nie vom Vater oder aus einer anderen Familie genommen —, Unleserliches wird markiert; ein
24
- zweiter Leser kann blind lesen. In den Stammbaum gelangt nichts von selbst.
24
+ zweiter Leser kann blind lesen. In den Stammbaum gelangt nichts von selbst. Ein schon gelesenes Dokument sagt es,
25
+ so wird es nicht versehentlich ein zweites Mal gelesen.
25
26
  - **Fotos vom Handy.** `strom phone on`, das nur ein Mensch einschaltet, lässt ein Handy im Heimnetz (WLAN oder
26
27
  Kabel — nie ein Mobilfunknetz oder ein VPN) zu diesem Computer. Das Handy wird einmal per QR-Code gekoppelt; seine
27
28
  Seite fotografiert dann oder wählt aus der Galerie und schickt die Fotos ganz in einen Stammbaum der Forschung, mit
package/CHANGELOG.md CHANGED
@@ -7,7 +7,7 @@ The changes of each version in short. The full notes and the install lines are o
7
7
 
8
8
  ## 1.18.0
9
9
 
10
- *In preparation: the betas up to 1.18.0-beta.3. The section is complete with the release.*
10
+ *In preparation: the betas up to 1.18.0-beta.4. The section is complete with the release.*
11
11
 
12
12
  - **Phone photos upright.** A photo turned by its EXIF orientation is read as it is meant to be seen, upright; the
13
13
  places marked on it (excerpts, the halves of a spread, a cut-out part) are of the picture as it is shown. The
@@ -20,7 +20,8 @@ The changes of each version in short. The full notes and the install lines are o
20
20
  entry, a letter, a deed, a gravestone, the back of a photo: the transcript word for word, its translation, what its
21
21
  old words and abbreviations mean, and the people it names, with who of the tree they may be. Nothing is filled in:
22
22
  a name the document does not have is left out — a surname too, never taken from a father or another family —,
23
- what cannot be read is marked; a second reader can read it blind. Nothing of it enters the tree by itself.
23
+ what cannot be read is marked; a second reader can read it blind. Nothing of it enters the tree by itself. A
24
+ document read already says so, so it is not read a second time by mistake.
24
25
  - **Photos from a phone.** `strom phone on`, turned on by the person alone, lets a phone on the home network (Wi-Fi
25
26
  or a cable — never a mobile network or a VPN) reach this computer. The phone is paired once by its QR code; its
26
27
  page then takes photos or picks them from the gallery and sends them whole into a tree of the research, with a
@@ -674,6 +674,15 @@
674
674
  "sync.reading.note": "Uživatel přečetl tento záznam v aplikaci Strom jinak ({source}); čtení výzkumu platí, dokud se záznam nepřečte znovu:",
675
675
  "sync.reading.page": "strana",
676
676
  "sync.reading.text": "přepis",
677
+ "read.note.source.edited": "Přepis byl upraven v aplikaci Strom; čtení {read} má: {text}",
678
+ "read.note.edited": "Přepis byl upraven v aplikaci Strom: vodítko, dokud se záznam nepřečte ({read})",
679
+ "read.note.one": "Přečetl jeden čtenář ({read}): možné do druhého, slepého čtení a vlastního pohledu výzkumu na záznam",
680
+ "read.note.differ": "Čtenáři {read} přečetli jinak: {list} — vodítko, dokud se záznam nepřečte",
681
+ "read.note.absent": "Čtení {read} to tak neuvádí: vodítko, dokud se záznam nepřečte",
682
+ "read.note.uncertain": "Čtenář {read} si tím nebyl jistý: vodítko, dokud se záznam nepřečte",
683
+ "read.verify.what": "Ověřit čtení dokumentu u osoby {name}: {list}",
684
+ "read.verify.why": "údaje stojí na čtení čtenářů stromu ({read}, potvrzeném v aplikaci Strom, {source}), které ještě není jisté: jeden čtenář, rozdílná čtení, nebo ručně upravený přepis; druhé, slepé čtení a vlastní pohled výzkumu na záznam je doloží, nebo ukáže chybu",
685
+ "read.verify.done": "po jednom čtenáři druhý, slepý (strom document again {read} --readers 2) a zápis prohlédnutý výzkumem samotným (strom source show {source}, jeho snímek): čtení se shodují → pravděpodobné, prokázané až po tomto pohledu a jen u primární informace; liší se → rozpor s oběma čteními",
677
686
  "archive.held": "odloženo: výzkum je Archiv Stromu (data přicházejí z aplikace Strom); do fronty se vrátí, až se zapne práce s agentem",
678
687
  "archive.removed": "odebráno v aplikaci Strom",
679
688
  "archive.wait": "Rozhodnutí o stahování z archivu {title}",
@@ -1172,6 +1181,8 @@
1172
1181
  "ui.sync.do.family.cite.read": "bude ho citovat rodina (její sňatek), na čtení záznamu z aplikace",
1173
1182
  "ui.sync.do.source.new": "pramen výzkumu; přepis z aplikace je vodítko, dokud záznam nepřečte výzkum",
1174
1183
  "ui.sync.do.source.new.read": "pramen výzkumu; přepis z aplikace platí jako čtení záznamu",
1184
+ "ui.sync.do.source.new.reading": "pramen výzkumu z jeho vlastního čtení dokumentu (snímek a výřez zápisu): údaje tak jisté, jak je čtenáři přečetli",
1185
+ "ui.sync.do.source.new.reading.edited": "pramen výzkumu z jeho čtení dokumentu, přepis upravený v aplikaci: údaje jsou vodítko, dokud záznam nepřečte výzkum",
1175
1186
  "ui.sync.do.source.changed": "převezme se: je to čtení z aplikace",
1176
1187
  "ui.sync.do.source.changed.read": "převezme se: je to čtení z aplikace a platí",
1177
1188
  "ui.sync.do.source.reading": "uloží se vedle čtení výzkumu; výzkum záznam přečte znovu",
@@ -1180,6 +1191,7 @@
1180
1191
  "ui.sync.do.fact.cite": "údaj výzkumu ho bude citovat (vodítko, dokud záznam nepřečte výzkum)",
1181
1192
  "ui.sync.do.fact.cite.read": "údaj výzkumu ho bude citovat a stojí na čtení záznamu z aplikace",
1182
1193
  "ui.sync.do.reading": "přidá se na čtení záznamu z aplikace",
1194
+ "ui.sync.do.reading.document": "přidá se tak jistý, jak ho čtenáři dokumentu přečetli (jeden čtenář: možné, dva ve shodě: pravděpodobné, rozdílně: vodítko)",
1183
1195
  "ui.sync.family.new": "{n}. nová rodina: {who}{kids}",
1184
1196
  "ui.sync.child.new": "{n}. {who}: dítě — {child}",
1185
1197
  "ui.sync.child.gone": "{n}. {who}: už to není jejich dítě — {child}",
@@ -674,6 +674,15 @@
674
674
  "sync.reading.note": "Der Benutzer hat diesen Eintrag in der Strom-App anders gelesen ({source}); die Lesung der Forschung gilt, bis er erneut gelesen ist:",
675
675
  "sync.reading.page": "Seite",
676
676
  "sync.reading.text": "Abschrift",
677
+ "read.note.source.edited": "Die Abschrift wurde in der Strom-App geändert; die Lesung {read} hat: {text}",
678
+ "read.note.edited": "Die Abschrift wurde in der Strom-App geändert: ein Hinweis, bis der Eintrag gelesen ist ({read})",
679
+ "read.note.one": "Von einem Leser gelesen ({read}): möglich bis zu einer zweiten, blinden Lesung und dem eigenen Blick der Forschung auf den Eintrag",
680
+ "read.note.differ": "Die Leser von {read} lasen es verschieden: {list} — ein Hinweis, bis der Eintrag gelesen ist",
681
+ "read.note.absent": "Die Lesung {read} sagt es nicht so: ein Hinweis, bis der Eintrag gelesen ist",
682
+ "read.note.uncertain": "Der Leser von {read} war sich dessen nicht sicher: ein Hinweis, bis der Eintrag gelesen ist",
683
+ "read.verify.what": "Die Lesung eines Dokuments für {name} prüfen: {list}",
684
+ "read.verify.why": "die Angaben stehen auf einer Lesung der Leser von strom ({read}, in der Strom-App bestätigt, {source}), die noch nicht sicher ist: ein Leser, verschiedene Lesungen oder die Abschrift von Hand geändert; eine zweite, blinde Lesung und der eigene Blick der Forschung auf den Eintrag belegen sie oder zeigen einen Fehler",
685
+ "read.verify.done": "nach einem Leser ein zweiter, blinder (strom document again {read} --readers 2), und der Eintrag von der Forschung selbst angesehen (strom source show {source}, sein Bild): die Lesungen stimmen überein → wahrscheinlich, belegt erst nach diesem Blick und nur bei primärer Information; sie weichen ab → ein Widerspruch mit beiden Lesungen",
677
686
  "archive.held": "zurückgestellt: die Forschung ist ein Strom-Archiv (die Daten kommen aus der Strom-App); zurück in der Warteschlange, wenn die Arbeit mit einem Agenten eingeschaltet wird",
678
687
  "archive.removed": "in der Strom-App entfernt",
679
688
  "archive.wait": "Entscheidung über das Herunterladen aus dem Archiv {title}",
@@ -1172,6 +1181,8 @@
1172
1181
  "ui.sync.do.family.cite.read": "die Familie (ihre Heirat) zitiert sie, auf die eigene Lesung des Eintrags",
1173
1182
  "ui.sync.do.source.new": "eine Quelle der Forschung; die eigene Abschrift ist ein Hinweis, bis die Forschung den Eintrag liest",
1174
1183
  "ui.sync.do.source.new.read": "eine Quelle der Forschung; die eigene Abschrift gilt als Lesung des Eintrags",
1184
+ "ui.sync.do.source.new.reading": "eine Quelle der Forschung aus ihrer eigenen Lesung des Dokuments (Bild und Ausschnitt des Eintrags): die Angaben so sicher, wie die Leser sie lasen",
1185
+ "ui.sync.do.source.new.reading.edited": "eine Quelle der Forschung aus ihrer Lesung des Dokuments, die Abschrift in der App geändert: die Angaben ein Hinweis, bis die Forschung den Eintrag liest",
1175
1186
  "ui.sync.do.source.changed": "übernommen: es ist die eigene Lesung",
1176
1187
  "ui.sync.do.source.changed.read": "übernommen: es ist die eigene Lesung, und sie gilt",
1177
1188
  "ui.sync.do.source.reading": "neben der Lesung der Forschung festgehalten; die Forschung liest den Eintrag erneut",
@@ -1180,6 +1191,7 @@
1180
1191
  "ui.sync.do.fact.cite": "die Angabe der Forschung zitiert sie (ein Hinweis, bis die Forschung den Eintrag liest)",
1181
1192
  "ui.sync.do.fact.cite.read": "die Angabe der Forschung zitiert sie und steht auf der eigenen Lesung des Eintrags",
1182
1193
  "ui.sync.do.reading": "hinzugefügt auf der eigenen Lesung des Eintrags",
1194
+ "ui.sync.do.reading.document": "wird so sicher hinzugefügt, wie die Leser des Dokuments es lasen (ein Leser: möglich, zwei übereinstimmend: wahrscheinlich, verschieden: ein Hinweis)",
1183
1195
  "ui.sync.family.new": "{n}. eine neue Familie: {who}{kids}",
1184
1196
  "ui.sync.child.new": "{n}. {who}: ein Kind — {child}",
1185
1197
  "ui.sync.child.gone": "{n}. {who}: nicht mehr ihr Kind — {child}",
@@ -22,6 +22,7 @@ import { clock, hostAllowed, hostState, REFUSED_FOREVER } from "../core/net.js";
22
22
  import { archiveLevel } from "../core/terms.js";
23
23
  import { onHost, permissionQuestions, setHosts } from "../core/archivewait.js";
24
24
  import { runs, runsArg, shellArg } from "../cli/format.js";
25
+ import { readingsLine, readingsOf } from "../core/readsync.js";
25
26
  import { commandSheet } from "./sheet.js";
26
27
  import { foldText } from "../core/text.js";
27
28
  import { subjectPeople } from "../core/records.js";
@@ -933,9 +934,13 @@ export function buildBrief(tree, opts) {
933
934
  const file = inputPath(tree, i);
934
935
  // all that strom input show says of it, so it needs no extra command (K6)
935
936
  const small = !i.text && i.kind === "text" && file && (i.size ?? 0) < 20_000 && /\.(txt|md|csv)$/i.test(file) ? readSmall(file) : undefined;
937
+ // read already (strom document read): its result, the file not read again
938
+ const read = readingsLine(tree, i);
939
+ const readDone = readingsOf(tree, i).some((r) => r.state === "done");
936
940
  return [
937
941
  `${i.id} ${i.name} [${i.kind} · ${i.state}]${i.from ? ` from ${i.from}` : ""}${i.mime ? ` · ${i.mime}${i.size !== undefined ? ` ${Math.round(i.size / 1024)} kB` : ""}` : ""}`,
938
- file ? `file: ${file}${i.kind === "document" || i.kind === "photo" ? " — open it and read it yourself" : ""}` : "",
942
+ file ? `file: ${file}${(i.kind === "document" || i.kind === "photo") && !readDone ? " — open it and read it yourself" : ""}` : "",
943
+ read ? `read: ${read}` : "",
939
944
  i.batch ? `batch: ${i.batch}${i.path ? ` · ${i.path}` : ""}` : "",
940
945
  i.persons?.length ? `of: ${i.persons.join(", ")}` : "",
941
946
  i.sorted ? `sorted: ${i.sorted.as}${i.sorted.source ? ` → ${i.sorted.source}` : ""}${i.sorted.reason ? ` (${i.sorted.reason})` : ""}` : "",
package/dist/cli/ui.js CHANGED
@@ -1073,6 +1073,8 @@ export const UI = {
1073
1073
  "ui.sync.do.family.cite.read": "the family (its marriage) cites it, on the file's reading of the record",
1074
1074
  "ui.sync.do.source.new": "a source of the research; the file's transcript is a lead until the research reads the record",
1075
1075
  "ui.sync.do.source.new.read": "a source of the research; the file's transcript counts as a reading of the record",
1076
+ "ui.sync.do.source.new.reading": "a source of the research from its own reading of the document (its picture and the entry's excerpt): the facts as sure as its readers read them",
1077
+ "ui.sync.do.source.new.reading.edited": "a source of the research from its reading of the document, the transcript changed in the app: the facts a lead until the research reads the record",
1076
1078
  "ui.sync.do.source.changed": "taken: it is the file's reading",
1077
1079
  "ui.sync.do.source.changed.read": "taken: it is the file's reading, and it counts",
1078
1080
  "ui.sync.do.source.reading": "kept beside the research's reading; the research reads the record again",
@@ -1081,6 +1083,7 @@ export const UI = {
1081
1083
  "ui.sync.do.fact.cite": "the research's fact cites it (a lead on it until the research reads the record)",
1082
1084
  "ui.sync.do.fact.cite.read": "the research's fact cites it and stands on the file's reading of the record",
1083
1085
  "ui.sync.do.reading": "added on the file's reading of the record",
1086
+ "ui.sync.do.reading.document": "added as sure as the readers of the document read it (one reader: possible; two alike: probable; apart: a lead)",
1084
1087
  "ui.sync.family.new": "{n}. a new family: {who}{kids}",
1085
1088
  "ui.sync.child.new": "{n}. {who}: a child — {child}",
1086
1089
  "ui.sync.child.gone": "{n}. {who}: no longer their child — {child}",
@@ -562,20 +562,21 @@ register({
562
562
  tree: true,
563
563
  args: [{ name: "reading", description: "the reading: D…", required: true }],
564
564
  examples: ["strom document show D20260101120000-ab12 --json"],
565
- run: (ctx, { args }) => {
566
- const tree = ctx.tree();
567
- const id = readId(args[0]);
568
- const s = loadState(tree.root, id);
569
- let result;
570
- if (s)
571
- result = buildResult(tree, settled(tree, s));
572
- else if (fs.existsSync(resultPath(tree.root, id)))
573
- result = JSON.parse(fs.readFileSync(resultPath(tree.root, id), "utf8"));
574
- else
575
- throw new UsageError(`no reading ${id}`, { hint: "strom document list" });
576
- return { text: said(ctx, result), data: result };
577
- },
565
+ run: (ctx, { args }) => showReading(ctx, readId(args[0])),
578
566
  });
567
+ /** A reading of a document as strom document show (and strom readings D…) says it: its result, or what its readers wrote so far. */
568
+ export function showReading(ctx, id) {
569
+ const tree = ctx.tree();
570
+ const s = loadState(tree.root, id);
571
+ let result;
572
+ if (s)
573
+ result = buildResult(tree, settled(tree, s));
574
+ else if (fs.existsSync(resultPath(tree.root, id)))
575
+ result = JSON.parse(fs.readFileSync(resultPath(tree.root, id), "utf8"));
576
+ else
577
+ throw new UsageError(`no reading ${id}`, { hint: "strom document list" });
578
+ return { text: said(ctx, result), data: result };
579
+ }
579
580
  register({
580
581
  path: ["document", "list"],
581
582
  summary: "The readings of documents of this research, newest first",
@@ -8,6 +8,7 @@ import fs from "node:fs";
8
8
  import path from "node:path";
9
9
  import { register } from "../cli/registry.js";
10
10
  import { counted, lines, moreLine, paginate, table, truncate } from "../cli/format.js";
11
+ import { readingsLine, readingsOf } from "../core/readsync.js";
11
12
  import { notFound, UsageError } from "../core/errors.js";
12
13
  import { collectFiles, fileSha256, inputPath, MAX_IN_TREE, mimeOf, storeShared } from "../core/media.js";
13
14
  import { create, normId, requireRecord, update } from "../core/records.js";
@@ -248,8 +249,9 @@ register({
248
249
  const all = ctx.tree().list("input").filter((i) => !opts.state || i.state === opts.state);
249
250
  const page = paginate(all, ctx.limit, ctx.page);
250
251
  return {
251
- text: all.length ? lines(table(page.items.map((i) => [i.id, i.kind, i.state, truncate(i.name, 60)])), moreLine(page, "strom input list")) : 'no inputs → strom intake <files or folder>',
252
- data: { total: all.length, inputs: opts.full ? page.items : page.items.map((i) => ({ id: i.id, kind: i.kind, state: i.state, name: i.name })) },
252
+ // a file read already (strom document read) says so: its reading, not read again
253
+ text: all.length ? lines(table(page.items.map((i) => [i.id, i.kind, i.state, truncate(i.name, 60), i.read?.length ? `read ${i.read[i.read.length - 1]} (strom readings ${i.read[i.read.length - 1]})` : ""])), moreLine(page, "strom input list")) : 'no inputs → strom intake <files or folder>',
254
+ data: { total: all.length, inputs: opts.full ? page.items : page.items.map((i) => ({ id: i.id, kind: i.kind, state: i.state, name: i.name, ...(i.read?.length ? { read: i.read } : {}) })) },
253
255
  };
254
256
  },
255
257
  }, {
@@ -263,10 +265,10 @@ register({
263
265
  const i = requireRecord(tree, args[0], "input");
264
266
  const abs = inputPath(tree, i);
265
267
  const tasks = tree.list("task").filter((t) => t.where.includes(i.id));
266
- const text = lines(`${i.id} ${i.name} [${i.kind} · ${i.state}]`, abs ? `file ${abs}` : undefined, i.from ? `from ${i.from}` : undefined, i.batch ? `batch ${i.batch}${i.path ? ` · ${i.path}` : ""}` : undefined, i.persons?.length ? `of ${i.persons.join(", ")}` : undefined, i.sorted ? `sorted ${i.sorted.as}${i.sorted.source ? ` → ${i.sorted.source}` : ""}${i.sorted.reason ? ` (${i.sorted.reason})` : ""}` : undefined, i.mime ? `type ${i.mime}${i.size !== undefined ? ` · ${Math.round(i.size / 1024)} kB` : ""}` : undefined, i.imported ? `import ${i.imported.persons} persons, ${i.imported.families} families as leads, cited as ${i.source}` : undefined, i.text ? `\n${i.text}` : undefined, !i.text && i.kind === "text" && abs && fs.existsSync(abs) && (i.size ?? 0) < 20_000 && /\.(txt|md|csv)$/i.test(abs) ? `\n${fs.readFileSync(abs, "utf8").trim()}` : undefined, tasks.length ? `\ntasks\n${table(tasks.map((t) => [` ${t.id}`, t.state, truncate(t.what, 60)]))}` : undefined, ...i.notes.map((n) => `note ${n.text}`), i.kind === "document" && abs
268
+ const text = lines(`${i.id} ${i.name} [${i.kind} · ${i.state}]`, abs ? `file ${abs}` : undefined, i.from ? `from ${i.from}` : undefined, i.batch ? `batch ${i.batch}${i.path ? ` · ${i.path}` : ""}` : undefined, i.persons?.length ? `of ${i.persons.join(", ")}` : undefined, i.sorted ? `sorted ${i.sorted.as}${i.sorted.source ? ` → ${i.sorted.source}` : ""}${i.sorted.reason ? ` (${i.sorted.reason})` : ""}` : undefined, i.mime ? `type ${i.mime}${i.size !== undefined ? ` · ${Math.round(i.size / 1024)} kB` : ""}` : undefined, i.imported ? `import ${i.imported.persons} persons, ${i.imported.families} families as leads, cited as ${i.source}` : undefined, i.read?.length ? `read ${readingsLine(tree, i)}` : undefined, i.text ? `\n${i.text}` : undefined, !i.text && i.kind === "text" && abs && fs.existsSync(abs) && (i.size ?? 0) < 20_000 && /\.(txt|md|csv)$/i.test(abs) ? `\n${fs.readFileSync(abs, "utf8").trim()}` : undefined, tasks.length ? `\ntasks\n${table(tasks.map((t) => [` ${t.id}`, t.state, truncate(t.what, 60)]))}` : undefined, ...i.notes.map((n) => `note ${n.text}`), i.kind === "document" && abs && !readingsOf(tree, i).some((r) => r.state === "done")
267
269
  ? `\nread the file (a big image: strom media view ${i.id} --grid, then --crop), then record what it says: strom source add … --input ${i.id}`
268
270
  : undefined);
269
- return { text, data: { input: i, path: abs, tasks: tasks.map((t) => t.id) } };
271
+ return { text, data: { input: i, path: abs, tasks: tasks.map((t) => t.id), ...(i.read?.length ? { readings: readingsOf(tree, i) } : {}) } };
270
272
  },
271
273
  }, {
272
274
  path: ["input", "done"],
@@ -13,7 +13,7 @@ import { UsageError } from "../core/errors.js";
13
13
  import { excerptSettings, planExcerpts } from "../core/excerpt.js";
14
14
  import { runGit, tracked } from "../core/git.js";
15
15
  import { Settings } from "../core/config.js";
16
- import { appKnowsArchive, appKnowsNoCouple, appShowsHypothesisLinks, appDecidesConflicts, appOpensLinks, appReadsTitles, appShowsCoupleEvents, appShowsEdges, appShowsSourceReads, appShowsFactStatus, appShowsStoryDrafts, appTurnsExcerpts } from "../core/stromapp.js";
16
+ import { appKnowsArchive, appKnowsNoCouple, appShowsHypothesisLinks, appShowsReadings, appDecidesConflicts, appOpensLinks, appReadsTitles, appShowsCoupleEvents, appShowsEdges, appShowsSourceReads, appShowsFactStatus, appShowsStoryDrafts, appTurnsExcerpts } from "../core/stromapp.js";
17
17
  import { EXCERPT_QUALITIES, EXCERPT_SCOPES } from "../core/model.js";
18
18
  import { ui } from "../cli/ui.js";
19
19
  import { isAgent } from "../core/which.js";
@@ -91,7 +91,9 @@ export function writeTreeGed(tree, opts) {
91
91
  const hypothesisLinks = opts.for === "strom" && appShowsHypothesisLinks(new Settings(tree.env, {}), opts.stromVersion);
92
92
  // …and the sides of a conflict of the user's edit, for an app that decides it
93
93
  const conflictSides = opts.for === "strom" && appDecidesConflicts(new Settings(tree.env, {}), opts.stromVersion);
94
- const result = exportGedcom(tree, { for: opts.for, stromVersion: opts.stromVersion, ...(persons ? { persons } : {}), ...(images ? { excerpts: images.of } : {}), ...(clips ? { clips } : {}), ...(research ? { research } : {}), ...(edges ? { edges } : {}), ...(storyDrafts ? { storyDrafts } : {}), ...(coupleResi ? { coupleResi } : {}), ...(sourceReads ? { sourceReads } : {}), ...(factStatus ? { factStatus } : {}), ...(archive ? { archive } : {}), ...(turnsExcerpts ? { turnsExcerpts } : {}), ...(noCouple ? { noCouple } : {}), ...(titles ? { titles } : {}), ...(hypothesisLinks ? { hypothesisLinks } : {}), ...(conflictSides ? { conflictSides } : {}) });
94
+ // …and the reading of a document each source is an entry of, for an app that opens it
95
+ const readings = opts.for === "strom" && appShowsReadings(new Settings(tree.env, {}), opts.stromVersion);
96
+ const result = exportGedcom(tree, { for: opts.for, stromVersion: opts.stromVersion, ...(persons ? { persons } : {}), ...(images ? { excerpts: images.of } : {}), ...(clips ? { clips } : {}), ...(research ? { research } : {}), ...(edges ? { edges } : {}), ...(storyDrafts ? { storyDrafts } : {}), ...(coupleResi ? { coupleResi } : {}), ...(sourceReads ? { sourceReads } : {}), ...(factStatus ? { factStatus } : {}), ...(archive ? { archive } : {}), ...(turnsExcerpts ? { turnsExcerpts } : {}), ...(noCouple ? { noCouple } : {}), ...(titles ? { titles } : {}), ...(hypothesisLinks ? { hypothesisLinks } : {}), ...(conflictSides ? { conflictSides } : {}), ...(readings ? { readings } : {}) });
95
97
  const findings = validateGedcom(result.text, opts.for === "standard" ? { strict: true } : {});
96
98
  const file = opts.out ?? gedFile(tree, opts.for, opts.research);
97
99
  if (!hasGedErrors(findings) && !tree.dryRun) {
@@ -9,6 +9,8 @@ import { counted, lines, runs, shellArg, truncate } from "../cli/format.js";
9
9
  import { UsageError } from "../core/errors.js";
10
10
  import { requireRecord } from "../core/records.js";
11
11
  import { imageRange } from "../core/media.js";
12
+ import { listReadings, READ_ID } from "../core/document.js";
13
+ import { showReading } from "./document.js";
12
14
  import { parseReport } from "../core/reader.js";
13
15
  import { foldText } from "../core/text.js";
14
16
  import { REDUCED_BELOW, viewRegion } from "../core/views.js";
@@ -396,20 +398,29 @@ register({
396
398
  "blocks with nothing found only what the reader could not read (a possible match is often there), the gaps in\n" +
397
399
  "the page numbering and the parts of the book they saw. One image's block word for word: --image N.\n" +
398
400
  "Default: the newest reading; with --images, --image or --match all of them. Nothing is written.",
399
- args: [{ name: "which", description: "a record set (B…), or a reading: its report's name or the start of it (a day)" }],
401
+ args: [{ name: "which", description: "a record set (B…), a reading: its report's name or the start of it (a day), or a reading of a document (D…)" }],
400
402
  options: [
401
403
  { name: "images", type: "string", value: "<from-to>", description: "only these image numbers, e.g. 40-69 or 5-113,130-228" },
402
404
  { name: "image", type: "string", value: "<n>", description: "that image's whole block, word for word (a few: 35-38)" },
403
405
  { name: "match", type: "string", multiple: true, value: "<words>", description: "only blocks that mention it — any spelling of accents (repeatable: variants)" },
404
406
  { name: "list", type: "boolean", description: "the readings there are, one line each" },
405
407
  ],
406
- examples: ["strom readings", "strom readings B0001 --images 40-69 --match Novák --match Nowak", "strom readings B0001 --image 57", "strom readings --list"],
408
+ examples: ["strom readings", "strom readings B0001 --images 40-69 --match Novák --match Nowak", "strom readings B0001 --image 57", "strom readings D20260101120000-ab12", "strom readings --list"],
407
409
  run(ctx, { args, opts }) {
408
410
  const tree = ctx.tree();
411
+ const which = args[0]?.trim();
412
+ // a reading of a document (strom document read): its result, as strom document show says it
413
+ if (which && READ_ID.test(which))
414
+ return showReading(ctx, which);
409
415
  let all = loadReadings(tree);
410
- if (all.length === 0)
416
+ // the readings of documents, one line each, after those of the books (--list)
417
+ const documents = () => listReadings(tree.root).map((r) => ({ line: `${r.id} · document · ${r.state} · ${r.files.join(", ")} · ${counted(r.pages, "page")} — strom readings ${r.id}`, data: { id: r.id, state: r.state, files: r.files, pages: r.pages } }));
418
+ if (all.length === 0) {
419
+ const docs = opts.list ? documents() : [];
420
+ if (docs.length)
421
+ return { text: lines(...docs.map((d) => d.line)), data: { readings: [], documents: docs.map((d) => d.data) } };
411
422
  return { text: "no readings yet (strom read writes them into notes/readings)", data: { readings: [] } };
412
- const which = args[0]?.trim();
423
+ }
413
424
  let book;
414
425
  if (which && /^[Bb]\d+$/.test(which))
415
426
  book = requireRecord(tree, which, "recordset").id;
@@ -439,8 +450,9 @@ register({
439
450
  if (shown.length === 0)
440
451
  return { text: `no reader reported ${image !== undefined ? `image ${runs([...image])}` : "these images"}${book ? ` of ${book}` : ""} · strom readings --list`, data: { readings: [] } };
441
452
  if (opts.list) {
442
- const text = lines(...shown.flatMap(({ r, blocks }) => readingHead(r, blocks)[0].concat(r.question ? ` · "${truncate(r.question, 60)}"` : "")));
443
- return { text, data: { readings: shown.map(({ r, blocks }) => ({ stem: r.stem, question: r.question, reports: r.reports, images: blocks.map((b) => b.num) })) } };
453
+ const docs = which || ranges || image !== undefined || match.length ? [] : documents();
454
+ const text = lines(...shown.flatMap(({ r, blocks }) => readingHead(r, blocks)[0].concat(r.question ? ` · "${truncate(r.question, 60)}"` : "")), ...docs.map((d) => d.line));
455
+ return { text, data: { readings: shown.map(({ r, blocks }) => ({ stem: r.stem, question: r.question, reports: r.reports, images: blocks.map((b) => b.num) })), ...(docs.length ? { documents: docs.map((d) => d.data) } : {}) } };
444
456
  }
445
457
  if (image !== undefined) {
446
458
  const text = lines(...shown.flatMap(({ r, blocks }) => blocks.flatMap((b) => [`${b.report}${b.recordset ? ` · ${b.recordset}` : ""}${r.question ? ` · question: ${truncate(r.question, 120)}` : ""}`, `## ${b.text}`, ""])));
@@ -77,7 +77,7 @@ function newReads(c, incoming) {
77
77
  return !!s && s.quay !== 0 && (incoming.transcripts === "evidence" || !!s.verified);
78
78
  }
79
79
  /** One change as the user reads it, with what strom does with it. */
80
- function changeLine(tree, c, incoming, lang) {
80
+ function changeLine(tree, c, incoming, lang, readings) {
81
81
  const name = (key) => {
82
82
  if (!key)
83
83
  return "?";
@@ -150,11 +150,16 @@ function changeLine(tree, c, incoming, lang) {
150
150
  ? "ui.sync.do.detail"
151
151
  : c.kind === "fact.part" && (c.action === "add" || c.action === "user")
152
152
  ? `ui.sync.do.part${c.action === "user" ? ".user" : ""}`
153
- : c.kind.startsWith("source.") || c.kind === "fact.cite" || c.kind === "person.cite" || c.kind === "family.cite"
154
- ? `ui.sync.do.${c.kind}${c.reads || (c.kind === "source.new" && newReads(c, incoming)) ? ".read" : ""}`
155
- : c.kind === "fact.new" && c.reads
156
- ? "ui.sync.do.reading"
157
- : `ui.sync.do.${c.action}`;
153
+ : c.kind === "source.new" && c.reading?.ok
154
+ ? `ui.sync.do.source.new.reading${c.reading.edited ? ".edited" : ""}`
155
+ : c.kind.startsWith("source.") || c.kind === "fact.cite" || c.kind === "person.cite" || c.kind === "family.cite"
156
+ ? `ui.sync.do.${c.kind}${c.reads || (c.kind === "source.new" && newReads(c, incoming)) ? ".read" : ""}`
157
+ : c.kind === "fact.new" && c.reads
158
+ ? "ui.sync.do.reading"
159
+ // a fact on an entry of the research's reading of a document: as sure as its readers read it
160
+ : c.kind === "fact.new" && c.fact?.cites?.some((x) => readings?.get(x.source)?.ok)
161
+ ? "ui.sync.do.reading.document"
162
+ : `ui.sync.do.${c.action}`;
158
163
  // an archive: nobody reads a record there, nor is an agent told — said without it
159
164
  const said = actsAsArchive(tree) && `${does}.archive` in UI ? `${does}.archive` : does;
160
165
  return `${ui(lang, key, v)} → ${ui(lang, said)}`;
@@ -163,7 +168,7 @@ function planText(tree, plan, incoming, file, lang, edits) {
163
168
  const shown = file.includes(" ") ? `"${file}"` : file;
164
169
  if (!plan.changes.length)
165
170
  return ui(lang, "ui.sync.nothing", { file: path.basename(file) });
166
- 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) ?? incoming.since ?? "" }) : 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)}`), "",
171
+ 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) ?? incoming.since ?? "" }) : 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, plan.readings)}`), "",
167
172
  // an archive mirrors the app whatever the setting says (planSync)
168
173
  actsAsArchive(tree) ? ui(lang, "ui.sync.edits.archive") : 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 }));
169
174
  }
package/dist/core/live.js CHANGED
@@ -112,7 +112,7 @@ import { knownNewerVersion, updateChannel } from "./update.js";
112
112
  import { whatsNew } from "./whatsnew.js";
113
113
  import { gitProgram, runGit } from "./git.js";
114
114
  import { RECENT_HOURS, recentChanges, recentNow } from "./recent.js";
115
- import { appKnowsArchive, appKnowsNoCouple, appShowsHypothesisLinks, appDecidesConflicts, appOpensLinks, appReadsTitles, appShowsCoupleEvents, appShowsEdges, appShowsFactStatus, appShowsSourceReads, appShowsStoryDrafts, appTurnsExcerpts, appUrlSetting, isAppVersion, isStromAppOrigin } from "./stromapp.js";
115
+ import { appKnowsArchive, appKnowsNoCouple, appShowsHypothesisLinks, appShowsReadings, appDecidesConflicts, appOpensLinks, appReadsTitles, appShowsCoupleEvents, appShowsEdges, appShowsFactStatus, appShowsSourceReads, appShowsStoryDrafts, appTurnsExcerpts, appUrlSetting, isAppVersion, isStromAppOrigin } from "./stromapp.js";
116
116
  import { Settings } from "./config.js";
117
117
  import { LINK_SCHEME, linkActions, linkHandlerState, linkHandlerStateLater, linkScheme } from "./links.js";
118
118
  import { autoTidy } from "./tidy.js";
@@ -702,7 +702,7 @@ function idsOf(applied, known) {
702
702
  return Object.keys(ids.persons).length || Object.keys(ids.sources).length ? { ids } : {};
703
703
  }
704
704
  /** What the bridge does that an app may ask about (each added once, never taken away). */
705
- export const BRIDGE_FEATURES = ["sync.again", "sync.undoneSince", "sync.takenBack", "sync.conflictEdit", "sync.since", "sync.ids", "family.noCouple", "family.alone", "adopt.transfer", "adopt.empty", "material.list", "person.titles", "media.codes", "hypothesis.links", "status.recent", "spend.readers", "hypothesis.intree", "archive.permission", "task.check", "waiting.groups", "waiting.sent", "waiting.note", "batch.kept", "live.end", "whats.new", "terminal.choice", "person.marriedSurname", "phone", "read.now"];
705
+ export const BRIDGE_FEATURES = ["sync.again", "sync.undoneSince", "sync.takenBack", "sync.conflictEdit", "sync.since", "sync.ids", "family.noCouple", "family.alone", "adopt.transfer", "adopt.empty", "material.list", "person.titles", "media.codes", "hypothesis.links", "status.recent", "spend.readers", "hypothesis.intree", "archive.permission", "task.check", "waiting.groups", "waiting.sent", "waiting.note", "batch.kept", "live.end", "whats.new", "terminal.choice", "person.marriedSurname", "phone", "read.now", "read.sync"];
706
706
  /** /status recent, when it is ready. */
707
707
  function recentOf(root, env, head) {
708
708
  const recent = head ? recentNow(root, env, head) : undefined;
@@ -1364,12 +1364,13 @@ export function serveLive(root, env) {
1364
1364
  const titles = appReadsTitles(new Settings(env, {}), version);
1365
1365
  const hypothesisLinks = appShowsHypothesisLinks(new Settings(env, {}), version);
1366
1366
  const conflictSides = appDecidesConflicts(new Settings(env, {}), version);
1367
+ const readings = appShowsReadings(new Settings(env, {}), version);
1367
1368
  const offered = opens ? links(env) : [];
1368
1369
  ged = {
1369
1370
  head: h,
1370
1371
  links: offered.join(" "),
1371
1372
  version: version ?? "",
1372
- text: exportGedcom(tree, { for: "strom", ...(h ? { head: h } : {}), ...(images ? { excerpts: images.of } : {}), ...(opens ? { clips: true, research: true } : {}), ...(edges ? { edges } : {}), ...(storyDrafts ? { storyDrafts } : {}), ...(coupleResi ? { coupleResi } : {}), ...(sourceReads ? { sourceReads } : {}), ...(factStatus ? { factStatus } : {}), ...(archive ? { archive } : {}), ...(turnsExcerpts ? { turnsExcerpts } : {}), ...(noCouple ? { noCouple } : {}), ...(titles ? { titles } : {}), ...(hypothesisLinks ? { hypothesisLinks } : {}), ...(conflictSides ? { conflictSides } : {}), ...(offered.length ? { links: offered, ...(linkScheme(env) !== LINK_SCHEME ? { linkScheme: linkScheme(env) } : {}) } : {}) }).text,
1373
+ text: exportGedcom(tree, { for: "strom", ...(h ? { head: h } : {}), ...(images ? { excerpts: images.of } : {}), ...(opens ? { clips: true, research: true } : {}), ...(edges ? { edges } : {}), ...(storyDrafts ? { storyDrafts } : {}), ...(coupleResi ? { coupleResi } : {}), ...(sourceReads ? { sourceReads } : {}), ...(factStatus ? { factStatus } : {}), ...(archive ? { archive } : {}), ...(turnsExcerpts ? { turnsExcerpts } : {}), ...(noCouple ? { noCouple } : {}), ...(titles ? { titles } : {}), ...(hypothesisLinks ? { hypothesisLinks } : {}), ...(conflictSides ? { conflictSides } : {}), ...(readings ? { readings } : {}), ...(offered.length ? { links: offered, ...(linkScheme(env) !== LINK_SCHEME ? { linkScheme: linkScheme(env) } : {}) } : {}) }).text,
1373
1374
  };
1374
1375
  }
1375
1376
  res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", "X-Strom-Head": h }).end(ged.text);
@@ -1701,6 +1702,11 @@ export function serveLive(root, env) {
1701
1702
  : {}),
1702
1703
  // the changes the research could not write (the rest is written): what and why
1703
1704
  ...(data.skipped?.length ? { skipped: data.skipped } : {}),
1705
+ // the sources that said they are an entry of a reading of a document (_STROM_READING): taken as the
1706
+ // research's reading, or not (why: a stable code) and so the app's own source as any
1707
+ ...(changes.some((c) => c.reading)
1708
+ ? { readings: changes.filter((c) => c.reading).map((c) => ({ source: c.source.startsWith("x:") ? `@${c.source.slice(2)}@` : c.source, read: c.reading.id, ok: c.reading.ok, ...(c.reading.entry ? { entry: c.reading.entry } : {}), ...(c.reading.code ? { code: c.reading.code } : {}), ...(c.reading.edited ? { edited: true } : {}) })) }
1709
+ : {}),
1704
1710
  head: head(root),
1705
1711
  ...(data.input ? { input: data.input } : {}),
1706
1712
  conflicts,
@@ -60,6 +60,15 @@ export const PHRASES = {
60
60
  "sync.reading.note": "The user read this record otherwise in the Strom app ({source}); the research's reading stays until it is read again:",
61
61
  "sync.reading.page": "page",
62
62
  "sync.reading.text": "transcript",
63
+ "read.note.source.edited": "The transcript was changed in the Strom app; the reading {read} has: {text}",
64
+ "read.note.edited": "The transcript was changed in the Strom app: a lead until the record is read ({read})",
65
+ "read.note.one": "Read by one reader ({read}): possible until a second, blind reading and the research's own look at the record",
66
+ "read.note.differ": "The readers of {read} read it apart: {list} — a lead until the record is read",
67
+ "read.note.absent": "The reading {read} does not say it so: a lead until the record is read",
68
+ "read.note.uncertain": "The reader of {read} was unsure of it: a lead until the record is read",
69
+ "read.verify.what": "Check the reading of a document for {name}: {list}",
70
+ "read.verify.why": "the facts stand on a reading by strom's readers ({read}, confirmed in the Strom app, {source}) not sure yet: one reader, readers apart, or the transcript changed by hand; a second, blind reading and the research's own look at the record prove them or show a mistake",
71
+ "read.verify.done": "after one reader a second, blind one (strom document again {read} --readers 2), and the entry looked at by the research itself (strom source show {source}, its image): the readings agree → probable, proven only after that look and only for primary information; they differ → a conflict with both readings",
63
72
  "archive.held": "kept for later: the research is a Strom archive (the data come from the Strom app); back in the queue when work with an agent is switched on",
64
73
  "archive.removed": "removed in the Strom app",
65
74
  "archive.wait": "Decision on downloading from the archive {title}",
@@ -0,0 +1,269 @@
1
+ // A reading of a document confirmed in the Strom app (spec 013 U4d): the app writes what the person took of a reading
2
+ // into its tree, one source per entry carrying the reading's mark (_STROM_READING D…, the original's _STROM_SHA), and
3
+ // sends it as any edit. Here the mark is checked against the reading the research keeps, the source is made the
4
+ // research's own (its input, images, the entry's excerpt) and each fact citing it gets its status by what the readers
5
+ // read — never by what the app says.
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { buildResult, loadState, READ_ID, resultPath } from "./document.js";
9
+ import { normalizeDate } from "./gdate.js";
10
+ import { Settings } from "./config.js";
11
+ import { exifOrientation, imageSizeOfFile } from "../image/index.js";
12
+ import { shownToFile } from "../image/image.js";
13
+ import { inputPath, mimeOf, storeShared } from "./media.js";
14
+ import { create, update } from "./records.js";
15
+ import { foldText } from "./text.js";
16
+ /** The reading's result as the research keeps it: its finished result (in git), else its state here when done. */
17
+ export function readingResult(tree, id) {
18
+ if (!READ_ID.test(id))
19
+ return undefined;
20
+ try {
21
+ const file = resultPath(tree.root, id);
22
+ if (fs.existsSync(file))
23
+ return JSON.parse(fs.readFileSync(file, "utf8"));
24
+ }
25
+ catch {
26
+ // an unreadable result is none
27
+ }
28
+ const s = loadState(tree.root, id);
29
+ return s?.state === "done" ? buildResult(tree, s) : undefined;
30
+ }
31
+ /**
32
+ * The readings of an input (Input.read) as an agent is told of them: each one's ID and state here (its state on this
33
+ * computer, else its result kept in git) — one read already is not read again, its result is strom readings D….
34
+ */
35
+ export function readingsOf(tree, input) {
36
+ return (input.read ?? []).map((id) => ({ id, state: loadState(tree.root, id)?.state ?? readingResult(tree, id)?.state ?? "gone" }));
37
+ }
38
+ /** One line of an input's readings for an agent: "D… done — strom readings D…, not read again", or nothing. */
39
+ export function readingsLine(tree, input) {
40
+ const all = readingsOf(tree, input);
41
+ if (!all.length)
42
+ return undefined;
43
+ const done = all.filter((r) => r.state === "done");
44
+ return `${all.map((r) => `${r.id} ${r.state}`).join(", ")} — ${done.length ? `read already: strom readings ${done[done.length - 1].id}; not read again` : `strom readings ${all[all.length - 1].id}`}`;
45
+ }
46
+ /** The words of a transcript as they are compared: NFC, one space, the line breaks (and the reading's " | ") as spaces. */
47
+ // the reading writes a line break of the record as " | ", the app may send it as a new line: the same
48
+ const plain = (t) => (t ?? "").normalize("NFC").replace(/\s*\|\s*/gu, " ").replace(/\s+/gu, " ").trim();
49
+ const tokens = (t) => new Set(foldText(t).split(/[^\p{L}\p{M}\p{N}]+/u).filter(Boolean));
50
+ /** How much of two transcripts is the same words (0–1). */
51
+ function likeness(a, b) {
52
+ const x = tokens(a);
53
+ const y = tokens(b);
54
+ if (!x.size || !y.size)
55
+ return 0;
56
+ let both = 0;
57
+ for (const w of x)
58
+ if (y.has(w))
59
+ both++;
60
+ return (2 * both) / (x.size + y.size);
61
+ }
62
+ /**
63
+ * Whether the mark is the research's reading: it exists and is read, one of its pages is the original the source
64
+ * names (by its content), and the source is one entry of it — by the entry the app names, else its words. The
65
+ * person's words changed since are theirs (edited); the source is still that entry.
66
+ */
67
+ export function checkReading(tree, mark, text) {
68
+ const id = mark.id;
69
+ const no = (code) => ({ id, ok: false, code });
70
+ if (!READ_ID.test(id))
71
+ return no("reading.none");
72
+ const result = readingResult(tree, id);
73
+ if (!result)
74
+ return loadState(tree.root, id) ? no("reading.state") : no("reading.none");
75
+ if (result.state !== "done")
76
+ return no("reading.state");
77
+ const shaOf = (p) => p.sha ?? tree.get(p.file)?.sha;
78
+ const pages = result.pages.filter((p) => mark.shas.includes(shaOf(p) ?? ""));
79
+ if (!pages.length)
80
+ return no("reading.sha");
81
+ const on = result.entries.filter((e) => pages.some((p) => p.n === e.page));
82
+ let entry;
83
+ if (mark.entry)
84
+ entry = on.find((e) => e.id === mark.entry);
85
+ else {
86
+ const words = plain(text);
87
+ entry = on.find((e) => plain(e.transcript) === words) ?? (on.length === 1 ? on[0] : undefined);
88
+ if (!entry && words) {
89
+ const best = on.map((e) => ({ e, k: likeness(e.transcript, words) })).sort((a, b) => b.k - a.k)[0];
90
+ if (best && best.k >= 0.6)
91
+ entry = best.e;
92
+ }
93
+ }
94
+ if (!entry)
95
+ return no("reading.entry");
96
+ const page = result.pages.find((p) => p.n === entry.page);
97
+ const edited = !!plain(text) && plain(text) !== plain(entry.transcript);
98
+ return { id, ok: true, entry, page, result, ...(edited ? { edited: true } : {}) };
99
+ }
100
+ /** The kind of record a kind of document is. */
101
+ const KIND_OF_DOC = {
102
+ "register.baptism": "baptism",
103
+ "register.birth": "birth",
104
+ "register.marriage": "marriage",
105
+ "register.death": "death",
106
+ "register.burial": "burial",
107
+ "register.other": "church",
108
+ letter: "letter",
109
+ deed: "notarial",
110
+ certificate: "certificate",
111
+ list: "census",
112
+ "photo.back": "photo",
113
+ };
114
+ /** The source's kind and information by the kind of document the readers said it is. */
115
+ export function readingKind(result) {
116
+ const k = result.doc.kind ?? "";
117
+ const kind = KIND_OF_DOC[k];
118
+ // a register, a certificate, a deed: written at the time by who was there, for most of what it says
119
+ const primary = k.startsWith("register.") || k === "certificate" || k === "deed";
120
+ return { ...(kind ? { kind } : {}), information: primary ? "primary" : "unknown" };
121
+ }
122
+ const kindOf = (k) => (k === "CHR" ? "BAPM" : k.toUpperCase());
123
+ const firstPart = (s) => foldText((s ?? "").split(",")[0].replace(/\[\?\]/gu, ""));
124
+ /** Whether a place of the fact is the place the reader read (as written, or a place of the tree it is). */
125
+ function samePlace(tree, fact, read) {
126
+ if (!read)
127
+ return false;
128
+ const f = firstPart(fact);
129
+ if (!f)
130
+ return false;
131
+ if (firstPart(read.text) === f)
132
+ return true;
133
+ const known = read.known ? tree.get(read.known) : undefined;
134
+ return !!known && known.names.some((n) => firstPart(n.name) === f);
135
+ }
136
+ function sameDate(fact, read) {
137
+ if (!read)
138
+ return false;
139
+ const a = normalizeDate(fact) ?? plain(fact).toUpperCase();
140
+ const b = normalizeDate(read.gedcom ?? read.text) ?? plain(read.gedcom ?? read.text).toUpperCase();
141
+ return a === b;
142
+ }
143
+ /**
144
+ * How sure a fact citing the reading is (the decision of 19 Oct 2026): the person changed the transcript, the
145
+ * reading does not say it, or its reader was unsure — a lead; read by one reader — possible (a check waits); read
146
+ * alike by two readers, blind — probable. Proven only after the research's own look at the record, never here.
147
+ */
148
+ export function readingGrade(tree, check, fact) {
149
+ const e = check.entry;
150
+ if (check.edited)
151
+ return { status: "lead", why: "edited" };
152
+ const kind = kindOf(fact.kind);
153
+ const two = e.agree !== undefined;
154
+ const varied = (fields) => (e.variants ?? []).filter((v) => fields.includes(v.field));
155
+ // what a person of it is: an occupation, a residence
156
+ if (kind === "OCCU" || kind === "RESI") {
157
+ const said = foldText(fact.value ?? fact.place ?? "");
158
+ const field = kind === "OCCU" ? "occupation" : "residence";
159
+ const p = e.persons.find((x) => {
160
+ const v = foldText(x[field] ?? "");
161
+ return !!v && !!said && (v === said || v.includes(said) || said.includes(v));
162
+ });
163
+ if (!p)
164
+ return { status: "lead", why: "absent" };
165
+ if (p.uncertain?.includes(field))
166
+ return { status: "lead", why: "uncertain" };
167
+ if (!two)
168
+ return { status: "possible", why: "one" };
169
+ const words = tokens(p[field] ?? "");
170
+ const other = (e.variants ?? []).filter((v) => v.field === "entry" || (v.field === "transcript" && [...tokens(v.at)].some((w) => words.has(w))));
171
+ return other.length ? { status: "lead", why: "differ", variants: other.map(({ at, other: o }) => ({ at, other: o })) } : { status: "probable", why: "two" };
172
+ }
173
+ const events = e.events.filter((x) => kindOf(x.type) === kind);
174
+ const ev = events.find((x) => (!fact.date || sameDate(fact.date, x.date)) && (!fact.place || samePlace(tree, fact.place, x.place)));
175
+ if (!ev)
176
+ return { status: "lead", why: "absent" };
177
+ const asked = [fact.date ? "date" : "", fact.place ? "place" : ""].filter(Boolean);
178
+ if (asked.some((f) => ev.uncertain?.includes(f)))
179
+ return { status: "lead", why: "uncertain" };
180
+ if (!two)
181
+ return { status: "possible", why: "one" };
182
+ const other = varied(["entry", ev.type, ...asked.map((f) => `${ev.type}.${f}`)]);
183
+ return other.length ? { status: "lead", why: "differ", variants: other.map(({ at, other: o }) => ({ at, other: o })) } : { status: "probable", why: "two" };
184
+ }
185
+ const RANK = { lead: 0, possible: 1, probable: 2 };
186
+ /** The best of the grades of a fact's readings (cited twice: the surer one counts). */
187
+ export function bestGrade(grades) {
188
+ return grades.reduce((a, g) => (!a || RANK[g.status] > RANK[a.status] ? g : a), undefined);
189
+ }
190
+ /** Whether a status is surer than another (proven and the rest above all a reading gives). */
191
+ export function surer(a, than) {
192
+ const r = (s) => (s && s in RANK ? RANK[s] : s === "proven" ? 3 : s === "disproven" ? -1 : 0);
193
+ return r(a) > r(than);
194
+ }
195
+ export function readingSource(check, text) {
196
+ const e = check.entry;
197
+ const r = check.result;
198
+ const { kind, information } = readingKind(r);
199
+ const input = check.page?.file.startsWith("I") ? check.page.file : undefined;
200
+ return {
201
+ ...(kind ? { kind } : {}),
202
+ information,
203
+ form: "original",
204
+ // the person's words where they changed them: theirs stand, the reading's beside them (a note)
205
+ transcript: check.edited && text ? text.trim() : e.transcript,
206
+ ...(e.translation ? { translation: e.translation } : {}),
207
+ ...(r.doc.language?.[0] ? { language: r.doc.language[0] } : {}),
208
+ ...(input ? { input } : {}),
209
+ reading: check.id,
210
+ readingEntry: e.id,
211
+ };
212
+ }
213
+ const CLIPPED = new Set(["image/jpeg", "image/png"]);
214
+ /**
215
+ * The reading's page the entry is on as the source's image (a picture: registered by its content, the one there
216
+ * already when it is), the entry's region of it as the source's excerpt, and the input sorted as the source.
217
+ */
218
+ export function takeReadingImages(tree, source, check, reason) {
219
+ const out = {};
220
+ const page = check.page;
221
+ const entry = check.entry;
222
+ const rec = tree.get(page.file);
223
+ let media = rec?.type === "media" ? rec : undefined;
224
+ const input = rec?.type === "input" ? rec : undefined;
225
+ if (input) {
226
+ const sha = input.sha;
227
+ media = sha ? tree.list("media").find((m) => !m.retracted && m.sha === sha) : undefined;
228
+ const file = inputPath(tree, input);
229
+ const mime = input.mime ?? mimeOf(input.name);
230
+ const shared = new Settings(tree.env, {}).shared()?.value;
231
+ // a picture of one page (a PDF's pages are no image of their own): registered as the record's image
232
+ if (!media && sha && shared && file && fs.existsSync(file) && mime.startsWith("image/") && page.page <= 1) {
233
+ const rel = input.file.startsWith("media:") ? input.file.slice(6) : path.relative(shared, tree.dryRun ? file : storeShared(shared, file, sha)).split(path.sep).join("/");
234
+ const size = imageSizeOfFile(file);
235
+ media = create(tree, "media", {
236
+ sha,
237
+ file: rel,
238
+ mime,
239
+ size: input.size ?? fs.statSync(file).size,
240
+ ...(size ? { width: size.width, height: size.height } : {}),
241
+ from: input.name,
242
+ accessed: new Date().toISOString().slice(0, 10),
243
+ }, (id) => `+${id} image "${input.name.slice(0, 50)}" of ${source.id} (read ${check.id})`, [source.id, input.id]);
244
+ out.added = media.id;
245
+ }
246
+ }
247
+ if (media) {
248
+ out.media = media.id;
249
+ const shared = new Settings(tree.env, {}).shared()?.value;
250
+ const file = shared ? path.join(shared, media.file) : undefined;
251
+ // the entry where it lies in the file: the reading's region is of the page upright
252
+ const r = entry.region;
253
+ const clip = r && CLIPPED.has(media.mime) && !source.clips?.length ? { media: media.id, region: shownToFile({ x: r[0], y: r[1], w: r[2], h: r[3] }, file && fs.existsSync(file) ? exifOrientation(file) : undefined) } : undefined;
254
+ if (clip)
255
+ out.clip = clip;
256
+ update(tree, source.id, "source", (x) => ({ ...x, media: [...new Set([...(x.media ?? []), media.id])], ...(clip ? { clips: [clip] } : {}) }), {
257
+ op: "source.media",
258
+ summary: `${source.id} image ${media.id}${clip ? " with the part the entry is on" : ""} (${reason})`,
259
+ });
260
+ }
261
+ if (input && input.state !== "processed") {
262
+ out.input = { id: input.id, before: { state: input.state, ...(input.sorted ? { sorted: input.sorted } : {}) } };
263
+ update(tree, input.id, "input", (x) => ({ ...x, state: "processed", sorted: { as: "source", source: source.id, reason: `read ${check.id}, confirmed in the Strom app`, at: new Date().toISOString(), by: tree.actor } }), {
264
+ op: "input.sort",
265
+ summary: `${input.id} the source ${source.id} (read ${check.id}, ${reason})`,
266
+ });
267
+ }
268
+ return out;
269
+ }
@@ -13,6 +13,7 @@ import { birthEvent, displayName, familiesAsPartner, lifespan } from "./people.j
13
13
  import { phrase } from "./phrases.js";
14
14
  import { foldText } from "./text.js";
15
15
  import { weighedSources } from "./conflicts.js";
16
+ import { loadState } from "./document.js";
16
17
  import { parseYears } from "./years.js";
17
18
  export const REVIEW_KINDS = ["mentions", "entries", "verify", "reread", "conflicts", "marriage", "death"];
18
19
  /** At most so many records, entries or facts in one task: a session's worth. */
@@ -122,6 +123,15 @@ export function readersOf(tree) {
122
123
  for (const [id, app] of fromApp)
123
124
  if (app.read)
124
125
  out.set(id, new Set(["user"]));
126
+ // an entry of a reading of a document the app sent back: its readers read it (their model), not whoever ran the sync
127
+ const readings = new Set();
128
+ for (const s of tree.list("source")) {
129
+ if (!s.reading)
130
+ continue;
131
+ readings.add(s.id);
132
+ if (!out.has(s.id))
133
+ out.set(s.id, new Set([loadState(tree.root, s.reading)?.model ?? "unknown"]));
134
+ }
125
135
  for (const op of tree.readOps()) {
126
136
  if (op.op !== "source.add" && op.op !== "source.edit")
127
137
  continue;
@@ -129,7 +139,7 @@ export function readersOf(tree) {
129
139
  for (const id of op.targets) {
130
140
  if (!id.startsWith("S"))
131
141
  continue;
132
- if (fromApp.has(id) && (op.op === "source.add" || !model.has(op.by)))
142
+ if ((fromApp.has(id) || readings.has(id)) && (op.op === "source.add" || !model.has(op.by)))
133
143
  continue;
134
144
  (out.get(id) ?? out.set(id, new Set()).get(id)).add(who);
135
145
  }
@@ -373,6 +373,27 @@ export function appShowsFactStatus(settings, version) {
373
373
  return (a[i] ?? 0) > (b[i] ?? 0);
374
374
  return true;
375
375
  }
376
+ /**
377
+ * The first Strom app that opens a reading of a document from its source (spec 013 U6): each source the research made
378
+ * of a reading confirmed in the app carries 1 _STROM_READING D… (2 _STROM_ENTRY e…). An older app lists the tag as one
379
+ * it does not know and loses nothing, so an unknown version gets it once the app is out. Not released yet: set with
380
+ * the app's U6 — never to a beta version; beta/dev copies of the app get it at once.
381
+ */
382
+ export const APP_SHOWS_READINGS = undefined;
383
+ export function appShowsReadings(settings, version) {
384
+ if (anotherCopy(settings))
385
+ return true;
386
+ if (!APP_SHOWS_READINGS)
387
+ return false;
388
+ if (!version)
389
+ return true;
390
+ const n = (v) => v.split(".").map((x) => Number.parseInt(x, 10) || 0);
391
+ const [a, b] = [n(version), n(APP_SHOWS_READINGS)];
392
+ for (let i = 0; i < 3; i++)
393
+ if ((a[i] ?? 0) !== (b[i] ?? 0))
394
+ return (a[i] ?? 0) > (b[i] ?? 0);
395
+ return true;
396
+ }
376
397
  /**
377
398
  * The Strom app shows what each variant of an open hypothesis would connect (the app's spec
378
399
  * docs/ZADANI_VYZKUM_nejista-spojeni.md, phase 1): 2 _VAR under each 1 _STROM_HYPO with its claim (3 TITL), its links
package/dist/core/sync.js CHANGED
@@ -42,6 +42,7 @@ import { now, Tree, typeOfId } from "./tree.js";
42
42
  import { actsAsArchive } from "./state.js";
43
43
  import { appSides } from "./conflicts.js";
44
44
  import { APP_DATA_SEX_UNKNOWN, isAppVersion } from "./stromapp.js";
45
+ import { bestGrade, checkReading, readingGrade, readingResult, readingSource, surer, takeReadingImages } from "./readsync.js";
45
46
  import { cleanTitle, familiesAsPartner, formatName, foundTitles, gedcomTitledName, parseName, primaryName, sameName as sameWords, titledName } from "./people.js";
46
47
  /** The words of a _STAT (the Strom app's own, also Legacy's and FTM's) as a union, or nothing it names. */
47
48
  function unionOfStat(value) {
@@ -454,6 +455,19 @@ export function stampAppVersion(text, version) {
454
455
  lines.splice(sour + 1, 0, `2 VERS ${version}`);
455
456
  return lines.join(eol);
456
457
  }
458
+ /**
459
+ * The reading a source of the app is an entry of: 1 _STROM_READING D… (2 _STROM_ENTRY e…), and the originals its
460
+ * excerpts are cut from (2 _STROM_SHA under its OBJE, or 1 _STROM_SHA of its own).
461
+ */
462
+ function readingMarkOf(r) {
463
+ const n = r.children.find((c) => c.tag === "_STROM_READING");
464
+ const id = n?.value.trim();
465
+ if (!n || !id)
466
+ return {};
467
+ const entry = val(n, "_STROM_ENTRY")?.trim();
468
+ const shas = [...children(r, "_STROM_SHA"), ...children(r, "OBJE").flatMap((o) => children(o, "_STROM_SHA"))].map((x) => x.value.trim().toLowerCase()).filter(Boolean);
469
+ return { reading: { id, ...(entry ? { entry } : {}), shas: [...new Set(shas)] } };
470
+ }
457
471
  /** A GEDCOM file (or one of ours, in memory) as a snapshot; an unreadable one throws. */
458
472
  export function readGedcom(text) {
459
473
  const { records, problems } = parseGedcomText(text);
@@ -485,6 +499,7 @@ export function readGedcom(text) {
485
499
  ...(url ? { url } : {}),
486
500
  ...(note ? { note } : {}),
487
501
  ...(/^y(es)?$/i.test(val(r, "_STROM_VERIFIED")?.trim() ?? "") ? { verified: true } : {}),
502
+ ...readingMarkOf(r),
488
503
  });
489
504
  }
490
505
  const cite = (n) => {
@@ -710,6 +725,15 @@ export function readStromJson(data) {
710
725
  ...(str(a.note) ? { note: str(a.note) } : {}),
711
726
  ...(q !== undefined ? { quay: q } : {}),
712
727
  ...(a.transcriptVerified === true ? { verified: true } : {}),
728
+ ...(str(a.reading)
729
+ ? {
730
+ reading: {
731
+ id: str(a.reading),
732
+ ...(str(a.readingEntry) ? { entry: str(a.readingEntry) } : {}),
733
+ shas: [...new Set([a.readingSha, ...(Array.isArray(a.excerpts) ? a.excerpts.map((x) => x?.originalSha) : [])].map((x) => str(x)?.toLowerCase()).filter((x) => !!x))],
734
+ },
735
+ }
736
+ : {}),
713
737
  });
714
738
  }
715
739
  const citesOf = (ids) => {
@@ -1857,8 +1881,9 @@ export function planSync(tree, incoming, edits, opts = {}) {
1857
1881
  const carried = new Set([...incoming.persons.values()].flatMap((p) => p.facts.map((f) => kindOf(f.kind))));
1858
1882
  const carriedFam = new Set(incoming.families.flatMap((f) => f.facts.map((x) => kindOf(x.kind))));
1859
1883
  // the sources first: what the file's citations are the research has, or what comes with them
1860
- const sources = planSources(tree, incoming, ours, base, push);
1861
- const reads = readsFor(tree, incoming, sources, changes);
1884
+ const readings = new Map();
1885
+ const sources = planSources(tree, incoming, ours, base, push, readings);
1886
+ const reads = readsFor(tree, incoming, sources, changes, readings);
1862
1887
  const idOf = (key) => (key.startsWith("x:") ? key : (resolve(tree, key)?.id ?? key));
1863
1888
  // who the records name at a fact: the same person by their key or a name of theirs; the role "other" is any role
1864
1889
  // (the Strom app has no name for an informant or a midwife)
@@ -2501,7 +2526,7 @@ export function planSync(tree, incoming, edits, opts = {}) {
2501
2526
  if (left.length)
2502
2527
  c.uncited = [...new Set(left)];
2503
2528
  }
2504
- return { changes, sources, base: !!base, ...(incoming.head ? { head: incoming.head } : {}), partial, identity };
2529
+ return { changes, sources, readings, base: !!base, ...(incoming.head ? { head: incoming.head } : {}), partial, identity };
2505
2530
  }
2506
2531
  /**
2507
2532
  * The send a copy of the app says it is a copy of (_STROM_SINCE R…, the app's beta.34): that send as it came, kept, its
@@ -2767,7 +2792,7 @@ export function readByResearch(tree) {
2767
2792
  * before), or new. Of the research's, what the user changed in the app since it was given — the app's own source
2768
2793
  * corrected, a record the research read a second reading beside it — and "transcript verified" ticked.
2769
2794
  */
2770
- function planSources(tree, incoming, ours, base, push) {
2795
+ function planSources(tree, incoming, ours, base, push, readings) {
2771
2796
  const map = new Map();
2772
2797
  let readSet;
2773
2798
  if (!incoming.sources?.size)
@@ -2780,11 +2805,19 @@ function planSources(tree, incoming, ours, base, push) {
2780
2805
  mine = tree.get(mine.mergedInto);
2781
2806
  if (mine && (mine.type !== "source" || mine.retracted))
2782
2807
  mine = undefined;
2783
- // one the research has: the same entry (a send the app has not had back yet)
2808
+ // an entry of a reading of a document the person confirmed in the app: the research's reading, when it is one
2809
+ // (it exists and is read, the original is its page, the words its entry's) — else the app's own source as any
2810
+ const check = !mine && inc.reading ? checkReading(tree, inc.reading, inc.text) : undefined;
2811
+ // one the research has: the source it made of that entry before (a send the app has not had back yet), the same entry
2812
+ if (check?.ok)
2813
+ mine ??= live.find((s) => s.reading === check.id && s.readingEntry === check.entry.id);
2784
2814
  mine ??= live.find((s) => sameEntry(tree, s, pageOf(s), inc));
2785
2815
  if (!mine) {
2816
+ if (check?.ok)
2817
+ readings.set(key, check);
2818
+ const reading = check ? { reading: { id: check.id, ok: check.ok, ...(check.entry ? { entry: check.entry.id } : {}), ...(check.code ? { code: check.code } : {}), ...(check.edited ? { edited: true } : {}) } } : {};
2786
2819
  if (inc.title || inc.text)
2787
- push({ kind: "source.new", action: "add", source: key, text: inc.title || inc.text.slice(0, 60) });
2820
+ push({ kind: "source.new", action: "add", source: key, text: inc.title || inc.text.slice(0, 60), ...reading });
2788
2821
  continue;
2789
2822
  }
2790
2823
  map.set(key, mine.id);
@@ -2813,11 +2846,14 @@ function planSources(tree, incoming, ours, base, push) {
2813
2846
  * not authored) whose transcript counts — the app's own source when the send says evidence, or verified (now or
2814
2847
  * before); the research's own one cited by the user when the send says evidence.
2815
2848
  */
2816
- function readsFor(tree, incoming, sources, changes) {
2849
+ function readsFor(tree, incoming, sources, changes, readings) {
2817
2850
  const evidence = incoming.transcripts === "evidence";
2818
2851
  return (key) => {
2819
2852
  const inc = incoming.sources?.get(key);
2820
2853
  const id = sources.get(key);
2854
+ // a reading of the research: its readers read it, not the user (the facts' status is by what they read)
2855
+ if (!id && readings.has(key))
2856
+ return false;
2821
2857
  if (!id) {
2822
2858
  // new: the app's quality 0 is someone's word, no record
2823
2859
  return !!inc && inc.quay !== 0 && (evidence || !!inc.verified);
@@ -3134,6 +3170,8 @@ export function applySync(tree, plan, incoming, source, only) {
3134
3170
  // the app's sources first: the facts cite them
3135
3171
  const made = new Map(); // a new source's key → its ID
3136
3172
  const srcId = (key) => plan.sources.get(key) ?? made.get(key);
3173
+ // the sources that are an entry of a reading of the research: its ID → the reading as checked
3174
+ const checks = new Map();
3137
3175
  for (const c of plan.changes.filter((x) => x.kind === "source.new" && taken(x))) {
3138
3176
  const inc = incoming.sources?.get(c.source);
3139
3177
  if (!inc)
@@ -3141,6 +3179,33 @@ export function applySync(tree, plan, incoming, source, only) {
3141
3179
  const repository = inc.repo ? repositoryFor(tree, inc.repo, applied) : undefined;
3142
3180
  const [form, information] = QUAY_EVIDENCE[inc.quay ?? -1] ?? ["original", "unknown"];
3143
3181
  const title = inc.title || inc.text.slice(0, 60);
3182
+ const check = plan.readings?.get(c.source);
3183
+ if (check) {
3184
+ // the research's reading of the record: its transcript, translation, language, input and images; the person's
3185
+ // words where they changed them (the reading's kept beside them, theirs not yet a reading that counts)
3186
+ const read = readingSource(check, inc.text);
3187
+ const notes = [inc.note, check.edited ? phrase(tree.lang, "read.note.source.edited", { read: check.id, text: check.entry.transcript }) : undefined].filter(Boolean).join("\n\n");
3188
+ const made1 = create(tree, "source", {
3189
+ ...read,
3190
+ kind: read.kind ?? sourceKind(incoming, c.source),
3191
+ title,
3192
+ ...(repository ? { repository } : {}),
3193
+ ...(inc.page ? { locator: inc.page } : {}),
3194
+ ...(inc.date ? { date: inc.date } : {}),
3195
+ ...(inc.url ? { url: inc.url } : {}),
3196
+ ...(check.edited ? { app: { read: false } } : {}),
3197
+ ...(notes ? { note: notes } : {}),
3198
+ }, (id) => `+${id} source "${truncateText(title, 50)}" (read ${check.id} ${check.entry.id}, confirmed in the Strom app, ${source.id})`, read.input ? [read.input] : []);
3199
+ made.set(c.source, made1.id);
3200
+ applied.push({ do: "source.add", id: made1.id, before: c.source });
3201
+ const took = takeReadingImages(tree, made1, check, `the Strom app, ${source.id}`);
3202
+ if (took.added)
3203
+ applied.push({ do: "media.add", id: took.added });
3204
+ if (took.input)
3205
+ applied.push({ do: "input.sort", id: took.input.id, before: JSON.stringify(took.input.before) });
3206
+ checks.set(made1.id, check);
3207
+ continue;
3208
+ }
3144
3209
  const made1 = create(tree, "source", {
3145
3210
  kind: sourceKind(incoming, c.source),
3146
3211
  title,
@@ -3161,7 +3226,64 @@ export function applySync(tree, plan, incoming, source, only) {
3161
3226
  /** Whether a citation of this source stands on a reading of a record: the user's, when it counts. */
3162
3227
  const reading = (id) => {
3163
3228
  const s = tree.get(id);
3164
- return !!s && !isWeak(s) && (s.app ? s.app.read : evidence);
3229
+ // a reading of the research's readers: its facts by what they read (graded), unless the person's words changed
3230
+ // there count now (verified)
3231
+ return !!s && !isWeak(s) && (s.app ? s.app.read : !s.reading && evidence);
3232
+ };
3233
+ /** A source that is an entry of a reading of the research (made now, or by a sync before): the reading as checked. */
3234
+ const checkOf = (id) => {
3235
+ if (checks.has(id))
3236
+ return checks.get(id);
3237
+ const s = tree.get(id);
3238
+ const result = s?.reading && !s.retracted ? readingResult(tree, s.reading) : undefined;
3239
+ const entry = result?.entries.find((e) => e.id === s.readingEntry);
3240
+ const k = result && entry ? { id: s.reading, ok: true, entry, page: result.pages.find((p) => p.n === entry.page), result, ...(s.app ? { edited: true } : {}) } : undefined;
3241
+ if (k)
3242
+ checks.set(id, k);
3243
+ return k;
3244
+ };
3245
+ /** How sure a fact is by the readings it cites (decision of 19 Oct 2026; none cited: undefined), and which they are. */
3246
+ const graded = (cits, f) => {
3247
+ if (!f)
3248
+ return undefined;
3249
+ const each = cits.flatMap((c) => {
3250
+ const k = checkOf(c.source);
3251
+ return k && !tree.get(c.source)?.app?.read ? [{ g: readingGrade(tree, k, f), read: k.id, source: c.source }] : [];
3252
+ });
3253
+ const grade = bestGrade(each.map((x) => x.g));
3254
+ return grade ? { grade, reads: [...new Set(each.map((x) => x.read))], sources: [...new Set(each.map((x) => x.source))] } : undefined;
3255
+ };
3256
+ /** Why a fact of a reading is not probable, beside it (both readings where they differ). */
3257
+ const gradeNote = (gr) => {
3258
+ const read = gr.reads.join(", ");
3259
+ switch (gr.grade.why) {
3260
+ case "two":
3261
+ return undefined;
3262
+ case "differ":
3263
+ return phrase(tree.lang, "read.note.differ", { read, list: (gr.grade.variants ?? []).map((v) => `"${v.at}" / "${v.other}"`).join("; ") });
3264
+ default:
3265
+ return phrase(tree.lang, `read.note.${gr.grade.why}`, { read });
3266
+ }
3267
+ };
3268
+ // the people whose facts stand on a reading not yet sure (one reader, readers apart, the words changed): one task each
3269
+ const readVerify = new Map();
3270
+ const toCheck = (owner, gr) => {
3271
+ if (!gr || gr.grade.status === "probable")
3272
+ return;
3273
+ const people = owner.startsWith("F") ? (tree.get(owner)?.partners ?? []) : [owner];
3274
+ for (const p of people)
3275
+ for (const s1 of gr.sources)
3276
+ (readVerify.get(p) ?? readVerify.set(p, new Set()).get(p)).add(s1);
3277
+ };
3278
+ /** A fact of the research a reading now makes surer (never less sure): its status raised. */
3279
+ const lift = (owner, e, gr) => {
3280
+ if (!gr || !surer(gr.grade.status, e.status))
3281
+ return;
3282
+ update(tree, owner, owner.startsWith("F") ? "family" : "person", (o) => ({ ...o, events: o.events.map((x) => (x.id === e.id ? { ...x, status: gr.grade.status } : x)) }), {
3283
+ op: "event.status",
3284
+ summary: `${e.id} ${gr.grade.status}: read ${gr.reads.join(", ")} (${reason})`,
3285
+ });
3286
+ applied.push({ do: "event.status", id: e.id, before: e.status });
3165
3287
  };
3166
3288
  /** The citations of a fact from the file: its sources (where in them), else the sync. */
3167
3289
  const citesOf = (f) => {
@@ -3181,7 +3303,7 @@ export function applySync(tree, plan, incoming, source, only) {
3181
3303
  const unread = cits.filter((c) => {
3182
3304
  const s = tree.get(c.source);
3183
3305
  // a reading of the user's that counts is the first reading: read again only by the usual rules
3184
- return !!s?.app && !s.app.read && !isWeak(s) && !researchRead(tree, s, (read ??= readByResearch(tree)));
3306
+ return !!s?.app && !s.app.read && !s.reading && !isWeak(s) && !researchRead(tree, s, (read ??= readByResearch(tree)));
3185
3307
  });
3186
3308
  if (!unread.length)
3187
3309
  return;
@@ -3233,9 +3355,12 @@ export function applySync(tree, plan, incoming, source, only) {
3233
3355
  const theirs = citesOf(c.fact);
3234
3356
  if (c.kind === "fact.new" || !mine || (c.action === "add" && !mine)) {
3235
3357
  const citations = theirs.length ? theirs : [cite(c.fact.kind)];
3236
- const { event } = addEvent(tree, owner, { ...fields(c.fact), status: citations.some((x) => reading(x.source)) ? "probable" : "lead", citations });
3358
+ const gr = graded(citations, c.fact);
3359
+ const note = gr ? gradeNote(gr) : undefined;
3360
+ const { event } = addEvent(tree, owner, { ...fields(c.fact), status: gr ? gr.grade.status : citations.some((x) => reading(x.source)) ? "probable" : "lead", ...(note ? { note } : {}), citations });
3237
3361
  applied.push({ do: "event.add", id: event.id });
3238
3362
  toVerify(owner, citations);
3363
+ toCheck(owner, gr);
3239
3364
  }
3240
3365
  else if (c.action === "refine") {
3241
3366
  // the record's fact stays as it is proven; what the user added to it cites their tree (or the sources they gave)
@@ -3274,6 +3399,9 @@ export function applySync(tree, plan, incoming, source, only) {
3274
3399
  if (stale.length)
3275
3400
  applied.push({ do: "event.uncite", id: mine.id, before: JSON.stringify(stale) });
3276
3401
  toVerify(owner, theirs);
3402
+ const gr = graded(theirs, c.fact);
3403
+ lift(owner, eventById(tree, mine.id) ?? mine, gr);
3404
+ toCheck(owner, gr);
3277
3405
  applied.push({ do: "event.edit", id: mine.id, before: { kind: mine.kind, ...(mine.date ? { date: mine.date } : {}), ...(mine.place ? { place: mine.place } : {}), ...(mine.value ? { value: mine.value } : {}) } });
3278
3406
  }
3279
3407
  else if (c.action === "user") {
@@ -3283,9 +3411,12 @@ export function applySync(tree, plan, incoming, source, only) {
3283
3411
  break;
3284
3412
  // the user's word: no record makes it probable — unless it is their reading of one, which counts
3285
3413
  const citations = theirs.length ? theirs : [cite(c.fact.kind)];
3286
- const { event } = addEvent(tree, owner, { ...fields(c.fact), status: citations.some((x) => reading(x.source)) ? "probable" : "possible", citations });
3414
+ const gr = graded(citations, c.fact);
3415
+ const note = gr ? gradeNote(gr) : undefined;
3416
+ const { event } = addEvent(tree, owner, { ...fields(c.fact), status: gr ? gr.grade.status : citations.some((x) => reading(x.source)) ? "probable" : "possible", ...(note ? { note } : {}), citations });
3287
3417
  applied.push({ do: "event.add", id: event.id });
3288
3418
  toVerify(owner, citations);
3419
+ toCheck(owner, gr);
3289
3420
  }
3290
3421
  else {
3291
3422
  const title = editTitle(c.name ?? owner, eventName(c.fact.kind, tree.lang, c.fact.label), factWords(tree, incoming, mine), factWords(tree, incoming, c.fact));
@@ -3331,12 +3462,16 @@ export function applySync(tree, plan, incoming, source, only) {
3331
3462
  if (!add.length)
3332
3463
  break;
3333
3464
  const up = (mine.status === "lead" || mine.status === "possible") && add.some((x) => reading(x.source));
3334
- update(tree, owner, owner.startsWith("F") ? "family" : "person", (o) => ({ ...o, events: o.events.map((e) => (e.id === mine.id ? { ...e, citations: [...e.citations, ...add], ...(up ? { status: "probable" } : {}) } : e)) }), {
3465
+ // a reading the person gave it: the research's fact as the readers read it (surer only, never less)
3466
+ const gr = graded(add, mine);
3467
+ const to = gr && surer(gr.grade.status, up ? "probable" : mine.status) ? gr.grade.status : up ? "probable" : undefined;
3468
+ update(tree, owner, owner.startsWith("F") ? "family" : "person", (o) => ({ ...o, events: o.events.map((e) => (e.id === mine.id ? { ...e, citations: [...e.citations, ...add], ...(to ? { status: to } : {}) } : e)) }), {
3335
3469
  op: "event.cite",
3336
- summary: `${mine.id} cites ${add.map((x) => x.source).join(", ")}${up ? " — probable on the user's reading" : ""} (${reason})`,
3470
+ summary: `${mine.id} cites ${add.map((x) => x.source).join(", ")}${to && !up ? ` — ${to}: read ${gr.reads.join(", ")}` : up ? " — probable on the user's reading" : ""} (${reason})`,
3337
3471
  });
3338
3472
  applied.push({ do: "event.cite", id: mine.id, before: JSON.stringify({ status: mine.status, sources: add.map((x) => x.source) }) });
3339
3473
  toVerify(owner, add);
3474
+ toCheck(owner, gr);
3340
3475
  break;
3341
3476
  }
3342
3477
  case "fact.part": {
@@ -3801,8 +3936,32 @@ export function applySync(tree, plan, incoming, source, only) {
3801
3936
  }, (id) => `+${id} task "read for itself what the user wrote of ${person}"`);
3802
3937
  applied.push({ do: "task.add", id: t.id });
3803
3938
  }
3939
+ // one task per person to check what one reader read, or readers read apart, or the person changed (the readings
3940
+ // are the research's own: a second, blind reading and its own look prove them or show a mistake)
3941
+ const openRead = tree.list("task").filter((t) => t.origin === READ_VERIFY_ORIGIN && ["open", "doing", "parked", "waiting"].includes(t.state));
3942
+ for (const [person, srcs] of actsAsArchive(tree) ? [] : readVerify) {
3943
+ const p = tree.get(person);
3944
+ if (!p || p.retracted || openRead.some((t) => t.subject.includes(person) && [...srcs].every((x) => t.subject.includes(x))))
3945
+ continue;
3946
+ const reads = [...new Set([...srcs].map((x) => tree.get(x)?.reading).filter((x) => !!x))];
3947
+ const images = [...new Set([...srcs].flatMap((x) => tree.get(x)?.clips?.map((k) => k.media) ?? []))];
3948
+ const t = create(tree, "task", {
3949
+ level: "verify",
3950
+ priority: 3,
3951
+ what: truncateText(phrase(tree.lang, "read.verify.what", { name: person, list: [...srcs].join(", ") }), 200),
3952
+ where: [...srcs, ...images],
3953
+ why: phrase(tree.lang, "read.verify.why", { read: reads.join(", "), source: source.id }),
3954
+ doneWhen: phrase(tree.lang, "read.verify.done", { read: reads[0] ?? "D…", source: [...srcs][0] }),
3955
+ subject: [person, ...srcs],
3956
+ state: "open",
3957
+ origin: READ_VERIFY_ORIGIN,
3958
+ }, (id) => `+${id} task "check the reading ${reads.join(", ")} of ${person}"`);
3959
+ applied.push({ do: "task.add", id: t.id });
3960
+ }
3804
3961
  return { applied, changes: changes - skipped.length, skipped };
3805
3962
  }
3963
+ /** Where a task to check a reading of a document confirmed in the app comes from (one per person and its sources). */
3964
+ export const READ_VERIFY_ORIGIN = "read:verify";
3806
3965
  /** Where a task to read the user's transcripts again comes from (one per person at a time). */
3807
3966
  export const VERIFY_ORIGIN = "sync:verify";
3808
3967
  /** The Strom app's quality of a citation (GEDCOM QUAY) → what kind of record it is and its information. */
@@ -4218,6 +4377,15 @@ export function undoSync(tree, input) {
4218
4377
  case "source.add":
4219
4378
  update(tree, a.id, "source", (x) => ({ ...x, retracted: { at: now(), reason } }), { op: "source.retract", summary: `${a.id} retracted: ${reason}` });
4220
4379
  break;
4380
+ case "media.add":
4381
+ update(tree, a.id, "media", (x) => ({ ...x, retracted: { at: now(), reason } }), { op: "media.retract", summary: `${a.id} retracted: ${reason}`, reason });
4382
+ break;
4383
+ case "input.sort": {
4384
+ // the material as it stood before the reading's source was made of it
4385
+ const b = JSON.parse(String(a.before ?? "{}"));
4386
+ update(tree, a.id, "input", ({ sorted: _s, ...x }) => ({ ...x, state: b.state ?? "new", ...(b.sorted ? { sorted: b.sorted } : {}) }), { op: "input.sort", summary: `${a.id} ${b.state ?? "new"} again: ${reason}`, reason });
4387
+ break;
4388
+ }
4221
4389
  case "repo.add":
4222
4390
  update(tree, a.id, "repository", (x) => ({ ...x, retracted: { at: now(), reason } }), { op: "repo.retract", summary: `${a.id} retracted: ${reason}`, reason });
4223
4391
  break;
package/dist/core/tree.js CHANGED
@@ -27,7 +27,7 @@ import { lastBackup } from "./backup.js";
27
27
  export const TREE_FILE = "strom.json";
28
28
  // between releases the candidate being made (a bridge run from the sources says what it is: the Strom app goes by it);
29
29
  // at a release the release's own (package.json)
30
- export const VERSION = "1.18.0-beta.3";
30
+ export const VERSION = "1.18.0-beta.4";
31
31
  const GITIGNORE = `# Strom Research — local state and large files are not versioned
32
32
  .strom/
33
33
  /media/
@@ -411,6 +411,12 @@ export function exportGedcom(tree, opts = {}) {
411
411
  }
412
412
  if (opts.sourceReads && s.app?.verified)
413
413
  w.line(1, "_STROM_VERIFIED", "Y");
414
+ // the reading of a document the source is an entry of: the app opens its result there (its pages, translation)
415
+ if (opts.readings && s.reading) {
416
+ w.line(1, "_STROM_READING", s.reading);
417
+ if (s.readingEntry)
418
+ w.line(2, "_STROM_ENTRY", s.readingEntry);
419
+ }
414
420
  for (const cut of opts.excerpts?.(s) ?? []) {
415
421
  const e = opts.turnsExcerpts ? cut : turned(cut);
416
422
  w.line(1, "OBJE");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strom-research",
3
- "version": "1.18.0-beta.3",
3
+ "version": "1.18.0-beta.4",
4
4
  "description": "Genealogical research toolkit for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {