strom-research 1.18.0-beta.13 → 1.18.0-beta.14

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.13. Oddíl bude úplný s vydáním.*
10
+ *Připravuje se: bety do 1.18.0-beta.14. 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,9 +20,9 @@ Změny každé verze ve zkratce. Úplné poznámky a instalační řádky jsou n
20
20
  matriky, dopis, listinu, náhrobek, rub fotky: přepis slovo od slova, překlad, význam starých slov a zkratek a osoby,
21
21
  které jmenuje, i s tím, kdo ze stromu to může být. Nic se nedomýšlí: jméno, které v dokumentu není, vypadne — i
22
22
  příjmení, nikdy se nebere od otce ani z jiné rodiny —, co nejde přečíst, je označené; druhý čtenář může číst naslepo.
23
- Dokument na úředním formuláři, jako rodný list, si své osoby ponechá — dítě i oba rodiče s jejich podílem —, a dokument
24
- přečtený dřív je po aktualizaci ukáže, aniž by se četl znovu. U každé osoby a události je i místo na stránce, kde
25
- stojí, takže aplikace Strom ukáže výřez každé z nich.
23
+ Dokument na úředním formuláři, jako rodný list, si své osoby ponechá — dítě i oba rodiče s jejich podílem, i jméno a
24
+ příjmení psané proloženě a rodné příjmení po „roz.“ —, a dokument přečtený dřív je po aktualizaci ukáže, aniž by se
25
+ četl znovu. U každé osoby a události je i místo na stránce, kde stojí, takže aplikace Strom ukáže výřez každé z nich.
26
26
  Do stromu se z toho nic nezapíše samo. Dokument už přečtený to o sobě řekne, takže se omylem nečte podruhé. Dokument
27
27
  čtený znovu se ukazuje pod názvem svého souboru, nikdy pod kódem. Dokument poslaný z aplikace Strom najde agenta
28
28
  stejně jako terminál — i nainstalovaného do vlastní složky, přes npm nebo Homebrew — místo hlášení, že agent není
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.13. Der Abschnitt ist mit dem Release vollständig.*
10
+ *In Vorbereitung: die Betas bis 1.18.0-beta.14. 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,9 +21,10 @@ Die Änderungen jeder Version in Kürze. Die vollständigen Hinweise und die Ins
21
21
  Wort, ihre Übersetzung, was alte Wörter und Abkürzungen bedeuten, und die genannten Personen, samt wem im Stammbaum
22
22
  sie entsprechen können. Nichts wird ergänzt: ein Name, der nicht im Dokument steht, fällt weg — auch ein Nachname, nie
23
23
  vom Vater oder aus einer anderen Familie genommen —, Unleserliches wird markiert; ein zweiter Leser kann blind lesen.
24
- Ein Dokument auf einem amtlichen Formular, etwa eine Geburtsurkunde, behält seine Personen — das Kind und beide
25
- Eltern mit ihrem Anteil —, und ein früher gelesenes Dokument zeigt sie nach dem Update, ohne erneut gelesen zu werden.
26
- Jede Person und jedes Ereignis kommt mit der Stelle auf der Seite, an der es steht, so zeigt die Strom-App von jedem
24
+ Ein Dokument auf einem amtlichen Formular, etwa eine Geburtsurkunde, behält seine Personen — das Kind und beide Eltern
25
+ mit ihrem Anteil, auch ein gesperrt getippter Name und ein Geburtsname nach „geb.“ —, und ein früher gelesenes
26
+ Dokument zeigt sie nach dem Update, ohne erneut gelesen zu werden. Jede Person und jedes Ereignis kommt mit der Stelle
27
+ auf der Seite, an der es steht, so zeigt die Strom-App von jedem
27
28
  einen eigenen Ausschnitt.
28
29
  In den Stammbaum gelangt nichts von selbst. Ein schon gelesenes Dokument sagt es, so wird es nicht versehentlich ein
29
30
  zweites Mal gelesen. Ein erneut gelesenes Dokument wird unter dem Namen seiner Datei gezeigt, nie unter einem Code.
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.13. The section is complete with the release.*
10
+ *In preparation: the betas up to 1.18.0-beta.14. 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
@@ -22,8 +22,9 @@ The changes of each version in short. The full notes and the install lines are o
22
22
  name the document does not have is left out — a surname too, never taken from a father or another family —, what
23
23
  cannot be read is marked; a second reader can read it blind. A
24
24
  document on a printed form, as a birth certificate, keeps its people — the child and both parents with their part in
25
- it —, and a document read before shows them after the update with nothing read again. Each person and event it names
26
- comes with the place on the page where it stands, so the Strom app shows each its own excerpt. Nothing of it enters the tree by itself. A document read
25
+ it, their names typed letter-spaced and a mother's birth name after "née" or "roz." too —, and a document read before
26
+ shows them after the update with nothing read again. Each person and event it names comes with the place on the page
27
+ where it stands, so the Strom app shows each its own excerpt. Nothing of it enters the tree by itself. A document read
27
28
  already says so, so it is not read a second time by mistake. A document read again is shown under the name of its
28
29
  file, never a code. A document sent from the Strom app finds the agent as the terminal does — one installed in a
29
30
  folder of its own, by npm or Homebrew too — instead of saying no agent is set up. Every agent the research works with
@@ -114,6 +114,8 @@ term: <a word or abbreviation as written> = <what it means, in ${into}> (term |
114
114
  given and surname only turn the written name into today's form ("Joannes" → "Jan"): never a name the transcript
115
115
  does not have — no surname the entry does not write by the person (a child's is not taken from its father's);
116
116
  on a form (a certificate) whose fields hold a name apart, written= the words of its fields as written there;
117
+ a name in letter-spaced type ("N o v á k") is one word: surname=Novák; a woman written with her birth name
118
+ ("Marie Nováková roz. Dvořáková", geb., née, z domu): surname= the birth name;
117
119
  a name you could not read stays out; leave out what the entry does not say; region: where the person's name
118
120
  stands — its line, or its field on a form — as the entry's region, of the page's whole view (tight, not the entry)
119
121
  event: <BIRT | BAPM | MARR | DEAT | BURI | RESI | EVEN> | date=<as on the record, in numbers: 3.4.1846, or a year> |
@@ -312,20 +314,113 @@ function kindOf(said) {
312
314
  return "photo.back";
313
315
  return "other";
314
316
  }
315
- export function words(s) {
317
+ /** One letter alone, as a typewriter spaces out a name (V í š e k); the last of them may have a stop or a "[?]". */
318
+ const SPACED = /^\p{L}\p{M}*$/u;
319
+ const SPACED_LAST = /^\p{L}\p{M}*(?:\[\?\]|[^\p{L}\p{M}\p{N}\[\]])*$/u;
320
+ const keyOf = (t) => foldText(t.replace(/\[\?\]/gu, "")).replace(/[^\p{L}\p{M}\p{N}]+/gu, "");
321
+ /** The keys of the words of names (a reader's given name and surname, the other reader's text) that join spaced letters. */
322
+ export function nameKeys(...names) {
323
+ return names.flatMap((n) => (n ? words(n).map((w) => w.key) : [])).filter((k) => k.length >= 2 && k !== "[...]");
324
+ }
325
+ /**
326
+ * The words of a text as they are compared: a word after a stop with no space ("roz.Nováková", "n.Labem") a word of
327
+ * its own, glued to the one before; letters standing alone (a name in letter-spaced type, "N o v á k") one word, as
328
+ * written — a run of them that spells one of `names` (the reader's given name or surname), the rest of a run that has
329
+ * one, and else three or more beginning with a capital; a gap of two spaces or more (a typewriter's between the words of
330
+ * a spaced name) or a line ends a run. Letters of running text ("šel a v k domu") stay words of their own.
331
+ */
332
+ export function words(s, names = []) {
316
333
  const src = s
317
334
  .replace(/\s*\[\?\]/gu, "[?]")
318
335
  .replace(/\[\s*(?:\.{2,}|…)\s*\]/gu, " [...] ")
319
- .replace(/\|/gu, " ");
320
- return src
321
- .split(/\s+/u)
322
- .filter(Boolean)
323
- .flatMap((t) => {
324
- if (t === "[...]")
325
- return [{ text: t, key: "[...]", unsure: false, illegible: true }];
326
- const key = foldText(t.replace(/\[\?\]/gu, "")).replace(/[^\p{L}\p{M}\p{N}]+/gu, "");
327
- return key ? [{ text: t, key, unsure: t.includes("[?]"), illegible: false }] : [];
328
- });
336
+ .replace(/\|/gu, " ");
337
+ const parts = [];
338
+ for (const m of src.matchAll(/(\s*)(\S+)/gu)) {
339
+ const wide = m[1].length > 1 || /[\n\r]/u.test(m[1]);
340
+ if (m[2] === "[...]")
341
+ parts.push({ t: m[2], glued: false, wide });
342
+ else
343
+ m[2].split(/(?<=\p{L}\p{M}*\.)(?=\p{L})/u).forEach((t, i) => parts.push({ t, glued: i > 0, wide: i === 0 && wide }));
344
+ }
345
+ const known = new Set(names);
346
+ const out = [];
347
+ const letters = (a, b) => {
348
+ const text = parts.slice(a, b).map((x) => x.t).join(" ");
349
+ out.push({ text, key: keyOf(text), unsure: text.includes("[?]"), illegible: false });
350
+ };
351
+ for (let i = 0; i < parts.length; i++) {
352
+ // a run of letters alone: no gap of two spaces within, the last may have a stop, the word after not glued to it
353
+ let j = i;
354
+ while (j < parts.length && !parts[j].glued && (j === i || !parts[j].wide) && SPACED.test(parts[j].t))
355
+ j++;
356
+ if (j < parts.length && j > i && !parts[j].glued && !parts[j].wide && SPACED_LAST.test(parts[j].t) && !SPACED.test(parts[j].t))
357
+ j++;
358
+ if (j - i >= 2 && (j === parts.length || !parts[j].glued)) {
359
+ // the names it spells, the longest first, and what stands between them
360
+ const named = [];
361
+ for (let a = i; a < j - 1;) {
362
+ let b = j;
363
+ while (b - a >= 2 && !known.has(keyOf(parts.slice(a, b).map((x) => x.t).join(""))))
364
+ b--;
365
+ if (b - a >= 2) {
366
+ named.push([a, b]);
367
+ a = b;
368
+ }
369
+ else
370
+ a++;
371
+ }
372
+ if (named.length) {
373
+ let a = i;
374
+ for (const [x, y] of [...named, [j, j]]) {
375
+ if (x - a >= 2)
376
+ letters(a, x);
377
+ else
378
+ for (let k = a; k < x; k++)
379
+ letters(k, k + 1);
380
+ if (y > x)
381
+ letters(x, y);
382
+ a = y;
383
+ }
384
+ i = j - 1;
385
+ continue;
386
+ }
387
+ if (j - i >= 3 && /^\p{Lu}/u.test(parts[i].t)) {
388
+ letters(i, j);
389
+ i = j - 1;
390
+ continue;
391
+ }
392
+ }
393
+ const { t, glued } = parts[i];
394
+ if (t === "[...]") {
395
+ out.push({ text: t, key: "[...]", unsure: false, illegible: true });
396
+ continue;
397
+ }
398
+ const key = keyOf(t);
399
+ if (key)
400
+ out.push({ text: t, key, unsure: t.includes("[?]"), illegible: false, ...(glued ? { glued } : {}) });
401
+ }
402
+ return out;
403
+ }
404
+ /** Words that stand in a row as they were written: one space between, none before a word glued to the one before. */
405
+ function asWritten(span) {
406
+ return span.map((x, i) => (i && !x.glued ? ` ${x.text}` : x.text)).join("");
407
+ }
408
+ /**
409
+ * The words of a birth name's mark, folded: in full — rozená, geborene, née, nata, urodzona, урождённая, z domu — or
410
+ * shortened with its stop — roz., geb., urodz., ur., урожд. —: the name after one is the person's birth surname.
411
+ */
412
+ const BIRTH_WORDS = new Set(["rozena", "rozeny", "geborene", "geborener", "nee", "nata", "urodzona", "урожденная", "урожденный", "szuletett"]);
413
+ const BIRTH_SHORT = new Set(["roz", "geb", "urodz", "ur", "урожд", "szul"]);
414
+ /** A birth name's mark at `i`; a short one only with its stop, "ur." only before the reader's surname (Ur is a name too). */
415
+ function birthMarkAt(span, i, surnameAt) {
416
+ const x = span[i];
417
+ if (x.key === "domu")
418
+ return span[i - 1]?.key === "z";
419
+ if (BIRTH_WORDS.has(x.key))
420
+ return true;
421
+ if (!BIRTH_SHORT.has(x.key) || !/\.$/u.test(x.text.replace(/\[\?\]/gu, "")))
422
+ return false;
423
+ return x.key !== "ur" || i + 1 === surnameAt;
329
424
  }
330
425
  /** Where the words of `part` stand in `whole`, in a row (its first place), else -1. */
331
426
  function find(whole, part) {
@@ -512,17 +607,20 @@ export function checkEntry(raw, id, blind = false, form = false) {
512
607
  checks.push({ entry: id, ref: p.ref, what: [p.fields.given, p.fields.surname].filter(Boolean).join(" ") || p.role, why: "no-name" });
513
608
  continue;
514
609
  }
515
- const w = words(written);
516
- const at = find(text, w);
610
+ // letters spaced out (a name typed letter-spaced) joined as the reader's given name and surname spell them
611
+ const names = nameKeys(p.fields.given, p.fields.surname);
612
+ const own = words(raw.transcript, names);
613
+ const w = words(written, names);
614
+ const at = find(own, w);
517
615
  // a form (a certificate) holds a name in its fields apart — the surname in one, the given names in another —:
518
616
  // its words each written there are the name; elsewhere a name stands in a row as written
519
- const span = at >= 0 ? text.slice(at, at + w.length) : form ? findApart(text, w) : undefined;
617
+ const span = at >= 0 ? own.slice(at, at + w.length) : form ? findApart(own, w) : undefined;
520
618
  if (!span) {
521
619
  checks.push({ entry: id, ref: p.ref, what: written, why: "not-in-transcript" });
522
620
  continue;
523
621
  }
524
622
  const role = (ROLES.includes(p.role) ? p.role : "other");
525
- const person = { ref: p.ref, role, written: span.map((x) => x.text).join(" ") };
623
+ const person = { ref: p.ref, role, written: at >= 0 ? asWritten(span) : span.map((x) => x.text).join(" ") };
526
624
  if (p.roleText && !blind)
527
625
  person.roleText = p.roleText;
528
626
  const uncertain = new Set();
@@ -548,7 +646,13 @@ export function checkEntry(raw, id, blind = false, form = false) {
548
646
  }
549
647
  // the given name: the other words; a word of today's form must be one of them in its letters (Josephi → Josef,
550
648
  // Joannes → Jan), else the name is as written and the reader's form only a lead (today)
551
- const givenWords = span.filter((_, i) => i !== surnameAt);
649
+ // (not a birth name's mark, roz. or geb., nor the birth name after it)
650
+ const marked = new Set();
651
+ span.forEach((x, i) => {
652
+ if (birthMarkAt(span, i, surnameAt))
653
+ [i, i + 1, ...(x.key === "domu" ? [i - 1] : [])].forEach((k) => marked.add(k));
654
+ });
655
+ const givenWords = span.filter((_, i) => i !== surnameAt && !marked.has(i));
552
656
  const given = p.fields.given?.replace(/\[\?\]/gu, "").trim();
553
657
  if (given) {
554
658
  const legible = givenWords.filter((x) => !x.illegible);
@@ -559,7 +663,7 @@ export function checkEntry(raw, id, blind = false, form = false) {
559
663
  if (fits)
560
664
  person.given = given;
561
665
  else {
562
- person.given = legible.map((x) => x.text.replace(/\[\?\]/gu, "").replace(/^[^\p{L}\p{M}\p{N}]+|[^\p{L}\p{M}\p{N}]+$/gu, "")).join(" ");
666
+ person.given = legible.map((x) => x.text.replace(/\[\?\]|\s+/gu, "").replace(/^[^\p{L}\p{M}\p{N}]+|[^\p{L}\p{M}\p{N}]+$/gu, "")).join(" ");
563
667
  person.today = given;
564
668
  uncertain.add("given");
565
669
  checks.push({ entry: id, ref: p.ref, what: `given "${given}" for "${person.given}"`, why: "not-of-its-letters" });
@@ -658,8 +762,9 @@ function partOf(r, entry) {
658
762
  // ── two readers ───────────────────────────────────────────────────────────────────────────────────────────────────
659
763
  /** The hunks where two transcripts differ, word by word (their keys; the marks [?] are no difference). */
660
764
  export function wordDiff(a, b) {
661
- const x = words(a);
662
- const y = words(b);
765
+ // letters one reader spaced out joined as the other wrote the word
766
+ const x = words(a, nameKeys(b));
767
+ const y = words(b, nameKeys(a));
663
768
  const n = x.length;
664
769
  const m = y.length;
665
770
  const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
@@ -699,7 +804,7 @@ const same = (a, b) => foldText(a ?? "") === foldText(b ?? "");
699
804
  * one's. Index in `mine` → index in `theirs`.
700
805
  */
701
806
  function pairByName(mine, theirs) {
702
- const keys = (p) => words(p.written).filter((x) => !x.illegible).map((x) => x.key);
807
+ const keys = (p) => words(p.written, nameKeys(p.given, p.surname)).filter((x) => !x.illegible).map((x) => x.key);
703
808
  const surname = (p) => words(p.surname ?? "").map((x) => x.key);
704
809
  const WHOLE = 1000;
705
810
  const score = (p, q) => {
@@ -859,7 +964,7 @@ function bornRange(role, year, age) {
859
964
  * and a surname of the same family (Novák/Nováková) are needed; the same sex, a birth year that fits the role and the
860
965
  * record's year, and a place of theirs add to it. A suggestion, never a decision: the person chooses.
861
966
  */
862
- export function matchPerson(tree, p, year, place) {
967
+ export function matchPerson(tree, p, year, place, own, child) {
863
968
  if (!p.given || !p.surname)
864
969
  return [];
865
970
  const firstOf = (s) => foldText(s).split(" ")[0];
@@ -888,8 +993,16 @@ export function matchPerson(tree, p, year, place) {
888
993
  score += 0.1;
889
994
  }
890
995
  const born = dateYears(birthEvent(o)?.date ?? "")[0];
891
- const range = year !== undefined ? bornRange(p.role, year, p.age) : undefined;
892
- if (range && born !== undefined) {
996
+ // a parent of one the document says was born then (a certificate's grandparents) as a parent of that year
997
+ const range = child !== undefined ? bornRange("father", child) : year !== undefined ? bornRange(p.role, year, p.age) : undefined;
998
+ // the year the document says the person was born (a certificate's parents): the tree's, a year apart at most
999
+ if (own !== undefined && born !== undefined) {
1000
+ if (Math.abs(born - own) > 1)
1001
+ continue;
1002
+ why.push("years");
1003
+ score += 0.15;
1004
+ }
1005
+ else if (range && born !== undefined) {
893
1006
  if (born < range[0] - 2 || born > range[1] + 2)
894
1007
  continue;
895
1008
  why.push("years");
@@ -923,8 +1036,14 @@ export function withTree(tree, entries) {
923
1036
  const known = knownPlace(tree, x.place.text);
924
1037
  return known ? { ...x, place: { ...x.place, known } } : x;
925
1038
  });
1039
+ // the year each person was born, where the document says so
1040
+ const bornOf = (ref) => {
1041
+ const birth = e.events.find((x) => ["BIRT", "BAPM", "CHR"].includes(x.type) && x.date?.gedcom && x.persons.some((r) => r.ref === ref && r.role === "principal"));
1042
+ return birth?.date?.gedcom ? dateYears(birth.date.gedcom)[0] : undefined;
1043
+ };
926
1044
  const persons = e.persons.map((p) => {
927
- const match = matchPerson(tree, p, year, place);
1045
+ const child = e.relations.flatMap((r) => (r.type === "parent" && r.parent === p.ref ? [bornOf(r.child)] : [])).find((y) => y !== undefined);
1046
+ const match = matchPerson(tree, p, year, place, bornOf(p.ref), child);
928
1047
  return match.length ? { ...p, match } : p;
929
1048
  });
930
1049
  return { ...e, persons, events };
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.13";
30
+ export const VERSION = "1.18.0-beta.14";
31
31
  const GITIGNORE = `# Strom Research — local state and large files are not versioned
32
32
  .strom/
33
33
  /media/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strom-research",
3
- "version": "1.18.0-beta.13",
3
+ "version": "1.18.0-beta.14",
4
4
  "description": "Genealogical research toolkit for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {