strom-research 1.19.0-beta.5 → 1.19.0-beta.7

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.19.0
9
9
 
10
- *Připravuje se: bety do 1.19.0-beta.5. Oddíl bude úplný s vydáním.*
10
+ *Připravuje se: bety do 1.19.0-beta.7. Oddíl bude úplný s vydáním.*
11
11
 
12
12
  - **Na archiv, který zmlkl, se čeká, místo opakovaných dotazů.** Když archiv přestane odpovídat, strom ho nechá na
13
13
  pokoji hodinu — a když týž den mlčí dál, dvě a pak čtyři hodiny — a agent se dozví, do kdy: mezitím dělá
@@ -25,20 +25,26 @@ Změny každé verze ve zkratce. Úplné poznámky a instalační řádky jsou n
25
25
  - **Dokument se přečte spolehlivěji.** Čtenář zapisuje čtení do své zprávy průběžně, a čtení dvou čtenářů, kde četl
26
26
  jen jeden, to tak řekne — nikdy, že se shodují.
27
27
  - **Propojení s telefonem čeká na výzkum, neříká se jako chyba.** Než se po spuštění počítače spustí výzkum, `strom
28
- phone` i nastavení řeknou, že propojení je zapnuté a jeho server se znovu spustí se spuštěním výzkumu.
28
+ phone` i nastavení řeknou, že propojení je zapnuté a jeho server se znovu spustí se spuštěním výzkumu. Na iPhonu
29
+ zůstane list fotek při psaní osoby nebo poznámky nad klávesnicí a nabízené osoby na očích; server telefonu se spustí
30
+ jednou, i když ho spouští několik naráz.
29
31
  - **Opravy vlastních chyb stromu k rozhodnutí.** Když strom zjistí, že některá jeho dřívější verze ve výzkumu něco
30
32
  udělala špatně, `strom fixes` a Nastavení → Údržba → Opravy řeknou, o co jde, co se našlo a co by napravení udělalo,
31
33
  i jak je náročné; bez souhlasu se nestane nic a odmítnutí platí. První: slepá čtení ze zmenšeného snímku tam, kde
32
34
  byla ostřejší část — přečtou se znovu vedle starých, nejdřív na několika, nic se nepřepíše, rozdíl se stane úkolem.
33
- Druhá: po změně modelu nebo agenta pro čtení dřívější čtení, která stojí za nové přečtení — s otevřenými místy,
34
- s neshodou čtenářů, s otevřeným rozporem, nepotvrzená u pramene; řečeno jako „četl model X, teď se čte modelem Y“,
35
- nabídnuto jednou na agenta a model, každé se přečte znovu tak, jak se četlo (snímky jednoho čtení spolu, zkouška
36
- i část vždy z celých čtení). Počítá se v čteních s jejich místy a náročnost ve voláních čtenáře; nabízí se po
37
- stupních — jen sporná (rozchod čtenářů, otevřený rozpor, nepotvrzené u pramene), s přímou linií, nebo všechna —
38
- každý s tím, co stojí, nejužší první; data a svědci jen řadí. Napravit může i jiný připravený agent nebo model
39
- (`strom fixes run <id> --agent <a> --model <m>`), s tím, kdo četl dřív; ten, kdo tu číst nemůže, se odmítne hned
40
- i s těmi, kdo mohou. Čas napravení se zaokrouhluje nahoru a po zkoušce se přepočítá podle čtení, jak běžela
41
- souběžně.
35
+ Čtení se porovnají podle přečteného — jmen, dat a čísel přepisu, ne slov komentáře čtenáře (i v němčině, kde má
36
+ velké písmeno každé podstatné jméno) — a úkol jednou větou řekne, který údaj se liší; kde jméno může jen začínat
37
+ větu, čtení se za shodná nevydávají. Když není co číst znovu, `strom fixes run` to řekne. Druhá: po změně modelu nebo agenta pro čtení dřívější čtení, která stojí za nové přečtení
38
+ — s otevřenými místy, s neshodou čtenářů, s otevřeným rozporem, nepotvrzená u pramene; řečeno jako „četl model X,
39
+ teď se čte modelem Y“, nabídnuto jednou na agenta a model, každé se přečte znovu tak, jak se četlo (snímky jednoho
40
+ čtení spolu, zkouška i část vždy z celých čtení). Počítá se v čteních s jejich místy a náročnost ve voláních
41
+ čtenáře; nabízí se po stupních — jen sporná (rozchod čtenářů, otevřený rozpor, nepotvrzené u pramene), s přímou
42
+ linií, nebo všechna — každý s tím, co stojí, nejužší první; data a svědci jen řadí. Napravit může i jiný připravený
43
+ agent nebo model (`strom fixes run <id> --agent <a> --model <m>`), s tím, kdo četl dřív; ten, kdo tu číst nemůže, se
44
+ odmítne hned i s těmi, kdo mohou. Čas napravení se zaokrouhluje nahoru a po zkoušce se přepočítá podle čtení, jak
45
+ běžela souběžně.
46
+ - **Velikosti tak, jak je píše jazyk výzkumu.** `strom pack`, odebrání rodokmenu i menu uvádějí velikost balíku,
47
+ zálohy nebo snímků jako všude jinde (1,5 MB, u malých 24 kB), nikdy „0.0 MB“.
42
48
 
43
49
  ## 1.18.0
44
50
 
@@ -98,7 +104,8 @@ Změny každé verze ve zkratce. Úplné poznámky a instalační řádky jsou n
98
104
  poznámky: výzkum si nechá svůj text. Místo nebo jiný údaj delší než řádek souboru GEDCOM (255 bajtů) už nezastaví
99
105
  jeho zápis a žádné emoji se nerozdělí tam, kde dlouhý řádek pokračuje. S `excerpts.for all` dostane v aplikaci Strom
100
106
  výřez i pramen, který zatím nikdo necituje, pokud se vedle citovaných vejde do limitu – citované se kvůli němu
101
- nezmenší; co se nevešlo, se řekne.
107
+ nezmenší; co se nevešlo, se řekne. Aplikace Strom se dozví, které obrazy pramene výzkum má, ale soubor je vynechal,
108
+ a na vyžádání si je z výzkumu načte — pramen po prameni, nebo všechny najednou i s tím, kolik jich je a jak asi velké.
102
109
 
103
110
  ## 1.17.0
104
111
 
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.19.0
9
9
 
10
- *In Vorbereitung: die Betas bis 1.19.0-beta.5. Der Abschnitt ist mit dem Release vollständig.*
10
+ *In Vorbereitung: die Betas bis 1.19.0-beta.7. Der Abschnitt ist mit dem Release vollständig.*
11
11
 
12
12
  - **Auf ein verstummtes Archiv wird gewartet, statt es immer wieder zu fragen.** Antwortet ein Archiv nicht mehr,
13
13
  lässt strom es eine Stunde in Ruhe — zwei, dann vier, wenn es am selben Tag still bleibt — und der Agent erfährt, bis
@@ -29,22 +29,30 @@ Die Änderungen jeder Version in Kürze. Die vollständigen Hinweise und die Ins
29
29
  Lesung zweier Leser, bei der nur einer las, sagt das so — nie, dass sie übereinstimmen.
30
30
  - **Die Verbindung mit dem Telefon wartet auf die Forschung, nicht als Fehler genannt.** Bevor die Forschung nach dem
31
31
  Neustart des Computers startet, sagen `strom phone` und die Einstellungen, dass die Verbindung an ist und ihr Server
32
- mit der Forschung wieder startet.
32
+ mit der Forschung wieder startet. Auf einem iPhone bleibt das Blatt der Fotos beim Tippen einer Person oder Notiz über
33
+ der Tastatur, die angebotenen Personen im Blick; der Server des Telefons startet einmal, auch wenn ihn mehrere
34
+ zugleich starten.
33
35
  - **Korrekturen eigener Fehler von strom zur Entscheidung.** Findet strom, dass eine frühere Version von ihm in einer
34
- Forschung etwas falsch gemacht hat, sagen `strom fixes` und Einstellungen → Wartung → Korrekturen, worum es geht, was
35
- gefunden wurde und was eine Behebung täte, auch wie aufwendig sie ist; ohne Ja geschieht nichts, ein Nein bleibt.
36
- Die erste: blinde Lesungen aus dem verkleinerten Scan, wo es einen schärferen Teil gab — neben den alten noch einmal
37
- gelesen, zuerst an einigen, nichts überschrieben, ein Unterschied wird zur Aufgabe. Die zweite: nach einem Wechsel
38
- des Lesemodells oder Agenten die früheren Lesungen, die ein neues Lesen lohnen — mit offenen Stellen, uneinigen
39
- Lesern, einem offenen Widerspruch, an der Quelle nicht bestätigt; gesagt als „gelesen von Modell X, jetzt liest
40
- Modell Y“, einmal je Agent und Modell gefragt, jede so neu gelesen, wie sie gelesen wurde (die Bilder einer Lesung
41
- zusammen, ein Versuch oder ein Teil immer aus ganzen Lesungen). Gezählt in Lesungen mit ihren Stellen, der Aufwand
42
- in Leseaufrufen; angeboten nach Stufen — nur die strittigen (uneinige Leser, ein offener Widerspruch, an der Quelle
43
- nicht bestätigt), mit der direkten Linie oder alle —, jede mit dem, was sie kostet, die engste zuerst; Daten und
44
- Zeugen ordnen nur. Beheben kann auch ein anderer bereiter Agent oder ein anderes Modell
45
- (`strom fixes run <id> --agent <a> --model <m>`), mit dem, der vorher las; einer, der hier nicht lesen kann, wird
46
- sofort abgelehnt, mit denen, die es können. Die Zeit einer Behebung wird aufgerundet und nach dem Versuch nach den
47
- nebeneinander gelaufenen Lesungen neu gemessen.
36
+ Forschung etwas falsch gemacht hat, sagen `strom fixes` und Einstellungen → Wartung → Korrekturen, worum es geht,
37
+ was gefunden wurde und was eine Behebung täte, auch wie aufwendig sie ist; ohne Ja geschieht nichts, ein Nein
38
+ bleibt. Die erste: blinde Lesungen aus dem verkleinerten Scan, wo es einen schärferen Teil gab — neben den alten
39
+ noch einmal gelesen, zuerst an einigen, nichts überschrieben, ein Unterschied wird zur Aufgabe. Verglichen wird, was
40
+ gelesen wurde — die Namen, Daten und Zahlen der Abschrift, nicht die Worte des Kommentars (auch auf Deutsch, wo
41
+ jedes Substantiv groß geschrieben wird) —, und die Aufgabe sagt in einem Satz, welche Angabe abweicht; wo ein Name
42
+ nur einen Satz beginnen kann, gelten die Lesungen nicht als gleich. Gibt es nichts neu zu lesen, sagt `strom fixes
43
+ run` das. Die zweite: nach einem Wechsel des Lesemodells oder Agenten die früheren
44
+ Lesungen, die ein neues Lesen lohnen — mit offenen Stellen, uneinigen Lesern, einem offenen Widerspruch, an der
45
+ Quelle nicht bestätigt; gesagt als „gelesen von Modell X, jetzt liest Modell Y“, einmal je Agent und Modell gefragt,
46
+ jede so neu gelesen, wie sie gelesen wurde (die Bilder einer Lesung zusammen, ein Versuch oder ein Teil immer aus
47
+ ganzen Lesungen). Gezählt in Lesungen mit ihren Stellen, der Aufwand in Leseaufrufen; angeboten nach Stufen — nur
48
+ die strittigen (uneinige Leser, ein offener Widerspruch, an der Quelle nicht bestätigt), mit der direkten Linie oder
49
+ alle —, jede mit dem, was sie kostet, die engste zuerst; Daten und Zeugen ordnen nur. Beheben kann auch ein anderer
50
+ bereiter Agent oder ein anderes Modell (`strom fixes run <id> --agent <a> --model <m>`), mit dem, der vorher las;
51
+ einer, der hier nicht lesen kann, wird sofort abgelehnt, mit denen, die es können. Die Zeit einer Behebung wird
52
+ aufgerundet und nach dem Versuch nach den nebeneinander gelaufenen Lesungen neu gemessen.
53
+ - **Größen, wie die Sprache der Forschung sie schreibt.** `strom pack`, das Entfernen eines Stammbaums und das Menü
54
+ nennen die Größe eines Pakets, einer Sicherung oder der Bilder wie überall sonst (1,5 MB, bei kleinen 24 kB), nie
55
+ „0.0 MB“.
48
56
 
49
57
  ## 1.18.0
50
58
 
@@ -116,7 +124,9 @@ Die Änderungen jeder Version in Kürze. Die vollständigen Hinweise und die Ins
116
124
  GEDCOM-Datei (255 Bytes), hält das Schreiben der Datei nicht mehr auf, und kein Emoji wird dort geteilt, wo eine
117
125
  lange Zeile weitergeht. Mit `excerpts.for all` bekommt auch eine Quelle, die noch niemand zitiert, in der Strom-App
118
126
  ihren Ausschnitt, soweit er neben den zitierten unter die Grenze passt – die zitierten werden dafür nie kleiner; was
119
- nicht passte, wird gesagt.
127
+ nicht passte, wird gesagt. Die Strom-App erfährt, welche Bilder einer Quelle die Forschung hat, ihre Datei aber
128
+ wegließ, und lädt sie bei Bedarf aus der Forschung — Quelle für Quelle oder alle auf einmal, mit Anzahl und etwa
129
+ ihrer Größe.
120
130
 
121
131
  ## 1.17.0
122
132
 
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.19.0
9
9
 
10
- *In preparation: the betas up to 1.19.0-beta.5. The section is complete with the release.*
10
+ *In preparation: the betas up to 1.19.0-beta.7. The section is complete with the release.*
11
11
 
12
12
  - **An archive that went silent is waited for, not asked again and again.** When an archive stops answering, strom
13
13
  leaves it alone for an hour — two, then four when it stays silent the same day — and the agent hears until when:
@@ -28,20 +28,26 @@ The changes of each version in short. The full notes and the install lines are o
28
28
  only one read says so, never that they agree.
29
29
  - **The phone connection waits for the research, not said as an error.** Before the research starts after the computer
30
30
  started again, `strom phone` and the settings say the connection is on and its server starts again with the research.
31
+ On an iPhone the sheet of photos stays above the keyboard while a person or a note is typed, the persons offered in
32
+ sight; the phone's server starts once, also when several start it at the same moment.
31
33
  - **Fixes of strom's own mistakes, offered to decide.** When strom finds that an earlier version of its own did
32
34
  something wrong in a research, `strom fixes` and Settings → Maintenance → Fixes say what it is about, what was found
33
35
  and what a remedy would do, with how demanding it is; nothing happens without a yes, and a no is kept. The first:
34
36
  blind readings made from the reduced scan where a sharper part existed — read again beside the old ones, first on a
35
- few, nothing overwritten, a difference becomes a task. The second: after the reading model or agent changed, the
36
- earlier readings worth reading again — with open places, readers who disagreed, an open conflict, a reading not
37
- confirmed at the source; said as "read by model X, now read by model Y", asked once per agent and model, each read
38
- again as it was read (the images of one reading together, a trial or a part always of whole readings). Counted in
39
- readings with their places and what a remedy takes in reader calls; offered by stage — the disputed alone (readers
40
- who differed, an open conflict, not confirmed at the source), with the direct line, or all — each with what it
41
- takes, the narrowest first, dates and witnesses only ordering them. The remedy may be read by another agent or model
42
- ready here (`strom fixes run <id> --agent <a> --model <m>`), with which one read before said; one that cannot read
43
- here is refused at once, with those that can. The time of a remedy is rounded up and, after the trial, measured
44
- again by the readings as they ran side by side.
37
+ few, nothing overwritten, a difference becomes a task. Held against each other by what was read — the names, dates
38
+ and numbers of the transcript, not the words of the reader's comment (in German too, where every noun has a
39
+ capital) — the task says in one sentence which of them differs; where a name may only begin a sentence, the readings
40
+ are not said to agree. With nothing to read again, `strom fixes run` says so. The second: after the reading model or agent changed, the earlier readings worth reading again — with open
41
+ places, readers who disagreed, an open conflict, a reading not confirmed at the source; said as "read by model X,
42
+ now read by model Y", asked once per agent and model, each read again as it was read (the images of one reading
43
+ together, a trial or a part always of whole readings). Counted in readings with their places and what a remedy takes
44
+ in reader calls; offered by stage — the disputed alone (readers who differed, an open conflict, not confirmed at the
45
+ source), with the direct line, or all — each with what it takes, the narrowest first, dates and witnesses only
46
+ ordering them. The remedy may be read by another agent or model ready here (`strom fixes run <id> --agent <a>
47
+ --model <m>`), with which one read before said; one that cannot read here is refused at once, with those that can.
48
+ The time of a remedy is rounded up and, after the trial, measured again by the readings as they ran side by side.
49
+ - **Sizes as the research's language writes them.** `strom pack`, removing a family tree and the menu say how big a
50
+ package, a backup or the images are as everywhere else (1,5 MB in Czech, 24 kB for a small one), never "0.0 MB".
45
51
 
46
52
  ## 1.18.0
47
53
 
@@ -107,7 +113,8 @@ The changes of each version in short. The full notes and the install lines are o
107
113
  value longer than a line of the GEDCOM file (255 bytes) no longer stops the file from being written, and no emoji is
108
114
  split where a long line goes on. With `excerpts.for all` a source nobody cites yet gets its excerpt in the Strom app
109
115
  too, wherever it fits under the limit beside the cited ones, which never get smaller for it; what did not fit is
110
- said.
116
+ said. The Strom app is told which images of a source the research has but its file left out, and loads them from
117
+ the research on demand — a source at a time, or all at once with how many and about how big.
111
118
 
112
119
  ## 1.17.0
113
120
 
@@ -2114,13 +2114,13 @@
2114
2114
  "ui.trees.pack": "zabalit „{name}“ a poslat někomu (soubor ZIP)",
2115
2115
  "ui.trees.unpack": "rodokmen, který někdo poslal (soubor ZIP)",
2116
2116
  "ui.pack.which": "Které snímky přibalit?",
2117
- "ui.pack.opt.records": "jen se zápisy – {mb} MB (prohledané stránky dá archiv znovu)",
2118
- "ui.pack.opt.all": "všechny – {mb} MB",
2119
- "ui.pack.done": "Zabaleno „{name}“: {file} ({mb} MB)",
2117
+ "ui.pack.opt.records": "jen se zápisy – {size} (prohledané stránky dá archiv znovu)",
2118
+ "ui.pack.opt.all": "všechny – {size}",
2119
+ "ui.pack.done": "Zabaleno „{name}“: {file} ({size})",
2120
2120
  "ui.pack.saved": " rodokmen, jak byl naposledy uložený, i s historií",
2121
- "ui.pack.images": " snímky: {n} ({mb} MB) – se zápisy a ostatní, které nikdo znovu nestáhne",
2122
- "ui.pack.images.all": " snímky: všech {n} ({mb} MB)",
2123
- "ui.pack.left": " vynechané snímky: {n} ({mb} MB) – prohledané stránky; až budou potřeba, strom je z archivu stáhne znovu",
2121
+ "ui.pack.images": " snímky: {n} ({size}) – se zápisy a ostatní, které nikdo znovu nestáhne",
2122
+ "ui.pack.images.all": " snímky: všech {n} ({size})",
2123
+ "ui.pack.left": " vynechané: {n, plural, one {# snímek} few {# snímky} other {# snímků}} ({size}) – prohledané stránky; až budou potřeba, strom je z archivu stáhne znovu",
2124
2124
  "ui.pack.connectors": " konektory: {list}",
2125
2125
  "ui.pack.noconnector": "⚠ některé vynechané snímky přišly konektorem, který na tomto počítači není – nikdo by je znovu nestáhl: strom pack --all-images",
2126
2126
  "ui.pack.missing": "⚠ snímky, které rodokmen uvádí, ale na tomto počítači nejsou: {n} – v balíčku chybí",
@@ -2151,14 +2151,14 @@
2151
2151
  "ui.remove.person": "rodokmen z počítače odebírá jen člověk u počítače",
2152
2152
  "ui.remove.busy": "na rodokmenu „{name}“ se právě pracuje – odebrat ho jde, až práce skončí",
2153
2153
  "ui.remove.what": "„{name}“ ({root}) · osoby: {persons}",
2154
- "ui.remove.backup": "Nejdřív ho zabalit jako zálohu, se všemi snímky ({mb} MB)? Kdykoli se vrátí přes strom unpack.",
2154
+ "ui.remove.backup": "Nejdřív ho zabalit jako zálohu, se všemi snímky ({size})? Kdykoli se vrátí přes strom unpack.",
2155
2155
  "ui.remove.backup.done": " záloha: {file}",
2156
- "ui.remove.images": "Poslat do koše i snímky, které žádný jiný rodokmen nepoužívá (snímky: {n}, {mb} MB)?",
2156
+ "ui.remove.images": "Poslat do koše i snímky, které žádný jiný rodokmen nepoužívá (snímky: {n}, {size})?",
2157
2157
  "ui.remove.type": "Pro potvrzení napsat název rodokmenu ({name}) – cokoli jiného nic neodebere:",
2158
2158
  "ui.remove.cancelled": "Nic se neodebralo.",
2159
2159
  "ui.remove.done": "„{name}“ je v koši.",
2160
2160
  "ui.remove.live": " aplikace Strom ho už živě nesleduje",
2161
- "ui.remove.images.done": " jeho snímky v koši: {n} ({mb} MB)",
2161
+ "ui.remove.images.done": " jeho snímky v koši: {n} ({size})",
2162
2162
  "ui.remove.images.folder": "{name} – snímky",
2163
2163
  "ui.remove.backup.back": " vrátí se ze zálohy: strom unpack „{file}“",
2164
2164
  "ui.remove.trash.alone": " vrátí se z koše systému, dokud se koš nevysype",
@@ -2656,6 +2656,8 @@
2656
2656
  "fix.model.why.differ": "Přečteno znovu modelem {now} (dříve: {before}) tam, kde něco zůstalo otevřené, a čtení se liší.",
2657
2657
  "fix.model.why.refined": "Přečteno znovu modelem {now} (dříve: {before}) tam, kde něco zůstalo otevřené, a nové čtení přečetlo víc nebo jistěji.",
2658
2658
  "fix.model.done": "obě čtení porovnaná mezi sebou ({show} {old} · {show} {now}) a s {sources}: co platí, zapsané u pramene, údaje na něm ověřené; nic odebrané bez důvodu",
2659
+ "fix.differs": "Liší se: dříve {before}, nově {now}.",
2660
+ "fix.more": "Nově přečteno navíc: {now}.",
2659
2661
  "fix.model.unknown": "nezaznamenaný model",
2660
2662
  "fix.model.own": "vlastní model"
2661
2663
  }
@@ -2114,13 +2114,13 @@
2114
2114
  "ui.trees.pack": "„{name}“ einpacken und jemandem schicken (eine ZIP-Datei)",
2115
2115
  "ui.trees.unpack": "ein zugeschickter Stammbaum (eine ZIP-Datei)",
2116
2116
  "ui.pack.which": "Welche Bilder kommen mit?",
2117
- "ui.pack.opt.records": "die, auf denen die Einträge stehen – {mb} MB (die durchgesehenen Seiten gibt das Archiv wieder)",
2118
- "ui.pack.opt.all": "alle – {mb} MB",
2119
- "ui.pack.done": "„{name}“ eingepackt: {file} ({mb} MB)",
2117
+ "ui.pack.opt.records": "die, auf denen die Einträge stehen – {size} (die durchgesehenen Seiten gibt das Archiv wieder)",
2118
+ "ui.pack.opt.all": "alle – {size}",
2119
+ "ui.pack.done": "„{name}“ eingepackt: {file} ({size})",
2120
2120
  "ui.pack.saved": " der Stammbaum, wie er zuletzt gespeichert wurde, mit seiner Geschichte",
2121
- "ui.pack.images": " Bilder: {n} ({mb} MB) – die, auf denen die Einträge stehen, und die, die niemand wieder laden kann",
2122
- "ui.pack.images.all": " Bilder: alle {n} ({mb} MB)",
2123
- "ui.pack.left": " weggelassen: {n, plural, one {# Bild} other {# Bilder}} ({mb} MB) – durchgesehene Seiten; strom lädt sie aus dem Archiv wieder, wenn sie gebraucht werden",
2121
+ "ui.pack.images": " Bilder: {n} ({size}) – die, auf denen die Einträge stehen, und die, die niemand wieder laden kann",
2122
+ "ui.pack.images.all": " Bilder: alle {n} ({size})",
2123
+ "ui.pack.left": " weggelassen: {n, plural, one {# Bild} other {# Bilder}} ({size}) – durchgesehene Seiten; strom lädt sie aus dem Archiv wieder, wenn sie gebraucht werden",
2124
2124
  "ui.pack.connectors": " Konnektoren: {list}",
2125
2125
  "ui.pack.noconnector": "⚠ einige weggelassene Bilder kamen über einen Konnektor, der auf diesem Computer fehlt – niemand könnte sie wieder laden: strom pack --all-images",
2126
2126
  "ui.pack.missing": "⚠ {n, plural, one {# Bild, das der Stammbaum nennt, ist} other {# Bilder, die der Stammbaum nennt, sind}} nicht auf diesem Computer – nicht im Paket",
@@ -2151,14 +2151,14 @@
2151
2151
  "ui.remove.person": "einen Stammbaum nimmt nur die Person am Computer herunter",
2152
2152
  "ui.remove.busy": "an „{name}“ wird gerade gearbeitet – er kann weg, wenn die Arbeit vorbei ist",
2153
2153
  "ui.remove.what": "„{name}“ ({root}) · Personen: {persons}",
2154
- "ui.remove.backup": "Zuerst als Sicherung einpacken, mit allen Bildern ({mb} MB)? Er kommt jederzeit mit strom unpack zurück.",
2154
+ "ui.remove.backup": "Zuerst als Sicherung einpacken, mit allen Bildern ({size})? Er kommt jederzeit mit strom unpack zurück.",
2155
2155
  "ui.remove.backup.done": " Sicherung: {file}",
2156
- "ui.remove.images": "Auch die Bilder, die kein anderer Stammbaum nutzt, in den Papierkorb ({n}, {mb} MB)?",
2156
+ "ui.remove.images": "Auch die Bilder, die kein anderer Stammbaum nutzt, in den Papierkorb ({n}, {size})?",
2157
2157
  "ui.remove.type": "Zur Bestätigung den Namen des Stammbaums ({name}) tippen – alles andere nimmt nichts weg:",
2158
2158
  "ui.remove.cancelled": "Nichts wurde weggenommen.",
2159
2159
  "ui.remove.done": "„{name}“ ist im Papierkorb.",
2160
2160
  "ui.remove.live": " die Strom-App verfolgt ihn nicht mehr live",
2161
- "ui.remove.images.done": " seine Bilder im Papierkorb: {n} ({mb} MB)",
2161
+ "ui.remove.images.done": " seine Bilder im Papierkorb: {n} ({size})",
2162
2162
  "ui.remove.images.folder": "{name} – Bilder",
2163
2163
  "ui.remove.backup.back": " er kommt aus der Sicherung zurück: strom unpack „{file}“",
2164
2164
  "ui.remove.trash.alone": " er kommt aus dem Papierkorb des Systems zurück, bis er geleert wird",
@@ -2656,6 +2656,8 @@
2656
2656
  "fix.model.why.differ": "Von {now} erneut gelesen (zuvor: {before}), wo etwas offen blieb, und die Lesungen weichen ab.",
2657
2657
  "fix.model.why.refined": "Von {now} erneut gelesen (zuvor: {before}), wo etwas offen blieb, und die neue Lesung liest mehr oder sicherer.",
2658
2658
  "fix.model.done": "beide Lesungen miteinander verglichen ({show} {old} · {show} {now}) und mit {sources}: was gilt, an der Quelle vermerkt, die Angaben darauf geprüft; nichts ohne Grund entfernt",
2659
+ "fix.differs": "Abweichend: früher {before}, jetzt {now}.",
2660
+ "fix.more": "Jetzt zusätzlich gelesen: {now}.",
2659
2661
  "fix.model.unknown": "ein nicht vermerktes Modell",
2660
2662
  "fix.model.own": "eigenes Modell"
2661
2663
  }
@@ -122,7 +122,7 @@ button{font:inherit;color:inherit}
122
122
  .strip.warn{color:var(--warning-text)}
123
123
 
124
124
  /* the sheet of the photos to send */
125
- .sheet{position:fixed;left:0;right:0;top:0;height:var(--vvh,100%);z-index:10;background:var(--bg);display:flex;flex-direction:column;max-width:560px;margin:0 auto}
125
+ .sheet{position:fixed;left:0;right:0;top:var(--vvt,0px);height:var(--vvh,100%);z-index:10;background:var(--bg);display:flex;flex-direction:column;max-width:560px;margin:0 auto}
126
126
  .sheet-head{display:flex;align-items:flex-start;gap:8px;padding:max(12px,env(safe-area-inset-top)) 8px 10px 16px;background:var(--surface);border-bottom:1px solid var(--divider)}
127
127
  .sheet-head>div{flex:1;min-width:0}
128
128
  .sheet-head h2{margin:2px 0 2px}
@@ -1158,16 +1158,17 @@
1158
1158
  $("doneBox").hidden = !handed;
1159
1159
  if (handed) $("doneText").textContent = t("commit.done", { n: b.done });
1160
1160
  // the foot: the camera's photos only here, handing over
1161
- $("captureNote").hidden = !items.some((i) => i.capture && i.blob);
1161
+ // while typing, only what ends the typing: the rest of the foot would take what the keyboard leaves of the sheet
1162
+ $("captureNote").hidden = !!S.typing || !items.some((i) => i.capture && i.blob);
1162
1163
  $("photoRow").hidden = !!S.typing;
1163
1164
  $("typingRow").hidden = !S.typing;
1164
1165
  $("noPersonBtn").hidden = S.typing !== "person";
1165
1166
  const committing = !!(b && b.commit);
1166
- $("commitBtn").hidden = !items.length;
1167
+ $("commitBtn").hidden = !items.length || !!S.typing;
1167
1168
  $("commitBtn").disabled = committing || !items.some((i) => !FINAL(i));
1168
1169
  $("commitBtn").textContent = committing ? t("commit.wait") : t("commit");
1169
1170
  $("commitCancel").hidden = !committing;
1170
- $("commitLater").hidden = !items.length || committing;
1171
+ $("commitLater").hidden = !items.length || committing || !!S.typing;
1171
1172
  };
1172
1173
 
1173
1174
  const renderUnpaired = () => {
@@ -1275,6 +1276,17 @@
1275
1276
  render();
1276
1277
  };
1277
1278
 
1279
+ // the field typed in at the top of the sheet's body, what it offers under it (the keyboard leaves little of the sheet)
1280
+ const keepTyped = () => {
1281
+ const f = document.activeElement;
1282
+ if (!f || (f.id !== "person" && f.id !== "note") || $("sheet").hidden) return;
1283
+ const body = $("sheet").querySelector(".sheet-body");
1284
+ const field = (f.closest && f.closest(".field")) || f;
1285
+ if (!body) return;
1286
+ const off = field.getBoundingClientRect().top - body.getBoundingClientRect().top - 8;
1287
+ if (Math.abs(off) > 1) body.scrollTop += off;
1288
+ };
1289
+
1278
1290
  let lookFor = 0;
1279
1291
  let asked = "";
1280
1292
  const findPeople = () => {
@@ -1317,6 +1329,7 @@
1317
1329
  ul.append(li);
1318
1330
  }
1319
1331
  ul.hidden = !people.length;
1332
+ if (people.length) keepTyped();
1320
1333
  }, 250);
1321
1334
  };
1322
1335
 
@@ -1469,12 +1482,22 @@
1469
1482
  renderPill();
1470
1483
  };
1471
1484
  window.addEventListener("scroll", () => S.newCount && window.scrollY <= 4 && renderPill(), { passive: true });
1472
- // the keyboard: the sheet as high as what is seen of the page, its foot above the keyboard
1485
+ // the keyboard: the sheet as high as what is seen of the page and where it is seen (iOS Safari keeps the page as tall
1486
+ // and moves what is seen of it up above the keyboard: a sheet fixed at the page's top would end under the field), its
1487
+ // foot above the keyboard, the field typed in kept in sight; without visualViewport the sheet is the whole window
1473
1488
  const vv = window.visualViewport;
1474
1489
  if (vv) {
1475
- const fit = () => document.documentElement.style.setProperty("--vvh", `${vv.height}px`);
1476
- vv.addEventListener("resize", fit);
1477
- fit();
1490
+ const place = () => {
1491
+ const root = document.documentElement.style;
1492
+ root.setProperty("--vvh", `${Math.round(vv.height)}px`);
1493
+ root.setProperty("--vvt", `${Math.max(0, Math.round(vv.offsetTop || 0))}px`);
1494
+ };
1495
+ vv.addEventListener("resize", () => {
1496
+ place();
1497
+ keepTyped();
1498
+ });
1499
+ vv.addEventListener("scroll", place);
1500
+ place();
1478
1501
  }
1479
1502
 
1480
1503
  // looked at again: asked at once; every 10 s while open
package/dist/cli/main.js CHANGED
@@ -372,12 +372,14 @@ export async function main(argv, io, env, cwd) {
372
372
  }
373
373
  }
374
374
  // …and the phone's server, when the connection is on (it starts again with this version by itself; one the
375
- // installer ended to replace the program back)
376
- try {
377
- revivePhone(env);
378
- }
379
- catch {
380
- // its next bridge brings it back
375
+ // installer ended to replace the program back) — not by the server itself, which would start a second one beside it
376
+ if (def.path.join(" ") !== "phone serve") {
377
+ try {
378
+ revivePhone(env);
379
+ }
380
+ catch {
381
+ // its next bridge brings it back
382
+ }
381
383
  }
382
384
  }
383
385
  // the folder of the program that runs with these settings (from a strom that ran here before it was kept, once):
package/dist/cli/menu.js CHANGED
@@ -611,8 +611,8 @@ async function pickTree(ctx, run, lang) {
611
611
  if (i === packAt) {
612
612
  const tree = Tree.open(known[at].root, ctx.env);
613
613
  const shared = ctx.settings.shared().value;
614
- const size = (all) => (packPlan(tree, shared, all).media.reduce((s, m) => s + m.size, 0) / 1e6).toFixed(0);
615
- const which = await ctx.choose(ui(lang, "ui.pack.which"), [{ label: ui(lang, "ui.pack.opt.records", { mb: size(false) }) }, { label: ui(lang, "ui.pack.opt.all", { mb: size(true) }) }], 0, { back: ui(lang, "ui.browse.back") });
614
+ const size = (all) => mb(packPlan(tree, shared, all).media.reduce((s, m) => s + m.size, 0), lang);
615
+ const which = await ctx.choose(ui(lang, "ui.pack.which"), [{ label: ui(lang, "ui.pack.opt.records", { size: size(false) }) }, { label: ui(lang, "ui.pack.opt.all", { size: size(true) }) }], 0, { back: ui(lang, "ui.browse.back") });
616
616
  if (which === undefined)
617
617
  return;
618
618
  await run(["pack", "--tree", tree.root, ...(which === 1 ? ["--all-images"] : [])]);
package/dist/cli/ui.js CHANGED
@@ -1982,13 +1982,13 @@ export const UI = {
1982
1982
  "ui.trees.pack": "pack \"{name}\" to send to someone (a ZIP file)",
1983
1983
  "ui.trees.unpack": "a family tree from someone else (a ZIP file)",
1984
1984
  "ui.pack.which": "Which images go along?",
1985
- "ui.pack.opt.records": "the ones its records stand on — {mb} MB (the pages searched through the archive gives again)",
1986
- "ui.pack.opt.all": "all of them — {mb} MB",
1987
- "ui.pack.done": "Packed \"{name}\": {file} ({mb} MB)",
1985
+ "ui.pack.opt.records": "the ones its records stand on — {size} (the pages searched through the archive gives again)",
1986
+ "ui.pack.opt.all": "all of them — {size}",
1987
+ "ui.pack.done": "Packed \"{name}\": {file} ({size})",
1988
1988
  "ui.pack.saved": " the family tree as last saved, with its history",
1989
- "ui.pack.images": " images: {n} ({mb} MB) — the ones its records stand on and the ones nobody can fetch again",
1990
- "ui.pack.images.all": " images: all {n} ({mb} MB)",
1991
- "ui.pack.left": " left out: {n, plural, one {# image} other {# images}} ({mb} MB) — pages searched through; strom fetches them from the archive again when they are needed",
1989
+ "ui.pack.images": " images: {n} ({size}) — the ones its records stand on and the ones nobody can fetch again",
1990
+ "ui.pack.images.all": " images: all {n} ({size})",
1991
+ "ui.pack.left": " left out: {n, plural, one {# image} other {# images}} ({size}) — pages searched through; strom fetches them from the archive again when they are needed",
1992
1992
  "ui.pack.connectors": " connectors: {list}",
1993
1993
  "ui.pack.noconnector": "⚠ some images left out came through a connector that is not on this computer — nobody could fetch them again: strom pack --all-images",
1994
1994
  "ui.pack.missing": "⚠ {n, plural, one {# image the family tree names is} other {# images the family tree names are}} not on this computer — not in the package",
@@ -2019,14 +2019,14 @@ export const UI = {
2019
2019
  "ui.remove.person": "only the person at the computer takes a family tree off it",
2020
2020
  "ui.remove.busy": "somebody is at work on \"{name}\" right now — it can go once the work is over",
2021
2021
  "ui.remove.what": "\"{name}\" ({root}) · people: {persons}",
2022
- "ui.remove.backup": "Pack it first as a backup, with all its images ({mb} MB)? It comes back any time with strom unpack.",
2022
+ "ui.remove.backup": "Pack it first as a backup, with all its images ({size})? It comes back any time with strom unpack.",
2023
2023
  "ui.remove.backup.done": " backup: {file}",
2024
- "ui.remove.images": "Into the trash too the images no other family tree uses ({n}, {mb} MB)?",
2024
+ "ui.remove.images": "Into the trash too the images no other family tree uses ({n}, {size})?",
2025
2025
  "ui.remove.type": "To confirm, type the name of the family tree ({name}) — anything else takes nothing off:",
2026
2026
  "ui.remove.cancelled": "Nothing was taken off.",
2027
2027
  "ui.remove.done": "\"{name}\" is in the trash.",
2028
2028
  "ui.remove.live": " the Strom app no longer follows it live",
2029
- "ui.remove.images.done": " its images in the trash: {n} ({mb} MB)",
2029
+ "ui.remove.images.done": " its images in the trash: {n} ({size})",
2030
2030
  "ui.remove.images.folder": "{name} – images",
2031
2031
  "ui.remove.backup.back": " it comes back from the backup: strom unpack \"{file}\"",
2032
2032
  "ui.remove.trash.alone": " it comes back from the system's trash, until it is emptied",
@@ -14,7 +14,7 @@ import { mediaOrient } from "../core/views.js";
14
14
  import { median, readJournal } from "../core/readstats.js";
15
15
  import { metricsDir } from "../core/metrics.js";
16
16
  import { runGit } from "../core/git.js";
17
- import { READ_EXPECT_DEFAULT, READ_EXPECT_OTHERS } from "../core/live-read.js";
17
+ import { READER_MS_MAX, readExpectDefault } from "../core/live-read.js";
18
18
  import { foldText } from "../core/text.js";
19
19
  import { loadReadings } from "./readings.js";
20
20
  import { isArchive } from "../core/mode.js";
@@ -39,6 +39,8 @@ export const BLIND_REDUCED_FIXED_AT = "2026-10-21T13:42:49Z";
39
39
  /** A view made for a reader this long before its reader began belongs to its reading. */
40
40
  const VIEW_BEFORE_MS = 30 * 60_000;
41
41
  const VIEW_AFTER_MS = 2 * 60_000;
42
+ /** strom makes a reader's views before it begins it: of a reader whose own start is known, only those (ms rounding). */
43
+ const VIEW_EXACT_MS = 100;
42
44
  /** Views of one reading are made within moments of each other. */
43
45
  const VIEW_CLUSTER_MS = 10_000;
44
46
  /** A later reading from a sharper copy that covers this much of a place has read it again. */
@@ -96,6 +98,9 @@ function blindReducedDetect(tree, env) {
96
98
  // when each reading began (its readers, this computer)
97
99
  const began = new Map();
98
100
  const capOf = new Map();
101
+ // when the reader of each image of a reading began, and every reader's start of an image (any reading)
102
+ const beganImage = new Map();
103
+ const startsOf = new Map();
99
104
  for (const r of readJournal(path.join(metricsDir(tree.root), "readers.jsonl"))) {
100
105
  if (r.kind !== "read" || typeof r.reader !== "string" || typeof r.at !== "string")
101
106
  continue;
@@ -105,6 +110,17 @@ function blindReducedDetect(tree, env) {
105
110
  began.set(stem, t);
106
111
  if (num(r.cap))
107
112
  capOf.set(stem, num(r.cap));
113
+ if (!Number.isFinite(t))
114
+ continue;
115
+ // the images it read (an older strom's record: those its reading is named after)
116
+ for (const id of Array.isArray(r.imageIds) ? r.imageIds : (stem.match(/M\d+/gu) ?? [])) {
117
+ if (typeof id !== "string")
118
+ continue;
119
+ const k = `${stem}|${id}`;
120
+ if (t < (beganImage.get(k) ?? Infinity))
121
+ beganImage.set(k, t);
122
+ startsOf.set(id, [...(startsOf.get(id) ?? []), t]);
123
+ }
108
124
  }
109
125
  // the views made for readers (this computer): a crop, the image it is of, when
110
126
  const views = new Map();
@@ -132,13 +148,23 @@ function blindReducedDetect(tree, env) {
132
148
  const times = r.reports.map((n) => added.get(n)).filter((t) => t !== undefined);
133
149
  return times.length ? Math.min(...times) : undefined;
134
150
  };
135
- /** The views of one reading of an image: those made last before its reader began, within moments of each other. */
136
- const viewsOf = (key, t) => {
137
- const near = (views.get(key) ?? []).filter((v) => v.t >= t - VIEW_BEFORE_MS && v.t <= t + VIEW_AFTER_MS);
151
+ /**
152
+ * The views of one reading of an image: those made last before its reader began, within moments of each other —
153
+ * never those of another reader of the same image (made before it began). Its reader's own start unknown here and
154
+ * views of more than one moment near: which it read is not sure (an estimate).
155
+ */
156
+ const viewsOf = (stem, key, t) => {
157
+ const own = beganImage.get(`${stem}|${key}`);
158
+ const at = own ?? t;
159
+ const others = (startsOf.get(key) ?? []).filter((s) => s !== own);
160
+ const after = Math.min(at + (own !== undefined ? VIEW_EXACT_MS : VIEW_AFTER_MS), ...others.filter((s) => s > at));
161
+ const before = Math.max(at - VIEW_BEFORE_MS, ...others.filter((s) => s < at));
162
+ const near = (views.get(key) ?? []).filter((v) => v.t > before && v.t <= after);
138
163
  if (!near.length)
139
- return [];
164
+ return { views: [], estimated: false };
140
165
  const last = Math.max(...near.map((v) => v.t));
141
- return near.filter((v) => v.t >= last - VIEW_CLUSTER_MS);
166
+ const cluster = near.filter((v) => v.t >= last - VIEW_CLUSTER_MS);
167
+ return { views: cluster, estimated: own === undefined && cluster.length < near.length };
142
168
  };
143
169
  // places read again from a sharper copy since (a later reading): by whole image, fractions as the file lies
144
170
  const readSharper = new Map();
@@ -167,7 +193,7 @@ function blindReducedDetect(tree, env) {
167
193
  const m = byId.get(id);
168
194
  if (!m || m.retracted)
169
195
  continue;
170
- const own = viewsOf(id, t);
196
+ const { views: own, estimated: unsure } = viewsOf(r.stem, id, t);
171
197
  if (m.part) {
172
198
  // a part read: the place in the whole image read sharper (when its views say where)
173
199
  if (m.recordset !== undefined && m.image !== undefined) {
@@ -189,7 +215,7 @@ function blindReducedDetect(tree, env) {
189
215
  const x1 = Math.max(...own.map((v) => v.region.x + v.region.w));
190
216
  const y1 = Math.max(...own.map((v) => v.region.y + v.region.h));
191
217
  const v = own[0];
192
- places = [{ file: shownToFile({ x: x0 / v.W, y: y0 / v.H, w: (x1 - x0) / v.W, h: (y1 - y0) / v.H }, v.orient), estimated: false }];
218
+ places = [{ file: shownToFile({ x: x0 / v.W, y: y0 / v.H, w: (x1 - x0) / v.W, h: (y1 - y0) / v.H }, v.orient), estimated: unsure }];
193
219
  }
194
220
  else if ((views.size === 0 || !views.has(id)) && r.blind && few && t < Date.parse(BLIND_REDUCED_FIXED_AT)) {
195
221
  // no record of its views here (another computer): the places of the sources' entries on the image, an estimate
@@ -504,7 +530,7 @@ function newModelDetect(tree, env) {
504
530
  const unclear = blocks.reduce((n, b) => n + Math.max(unclearOf(b), b.result === "unclear" ? 1 : 0), 0);
505
531
  // a blind reading that reads otherwise than the researcher's reading of the image before it
506
532
  const theirs = r.blind ? list.filter((x) => !x.r.blind && x.t <= t && x.r !== r).sort((a, b) => b.t - a.t)[0] : undefined;
507
- const differ = !!theirs && compareReadings(theirs.blocks, blocks) === "differ";
533
+ const differ = !!theirs && compareReadings(theirs.blocks, blocks, tree.lang).result === "differ";
508
534
  const reg = cropRegion(crop);
509
535
  const src = reg && !m.part ? sourcesOf(sources, media, m, shownToFile(reg, mediaOrient(env.shared, m))) : sources.filter((s) => (s.media ?? []).includes(id) || (s.clips ?? []).some((c) => c.media === id)).map((s) => s.id).sort();
510
536
  const reasons = [...(unclear ? ["unclear"] : []), ...(differ ? ["differ"] : []), ...(src.some((s) => conflicted.has(s)) ? ["conflict"] : [])];
@@ -671,13 +697,16 @@ export function decide(tree, id, decision) {
671
697
  delete c.fixes;
672
698
  }, { op: "fix.decide", summary: decision ? `fix ${id}: ${decision}` : `fix ${id}: asked again` });
673
699
  }
674
- /** Each check with what it found here and the decision about it. */
700
+ /**
701
+ * Each check with what it found here and the decision about it. Set right holds for what was read again: what a check
702
+ * still finds after (never read again — a few first, or a place strom took wrongly then) waits again, it alone.
703
+ */
675
704
  export function fixStates(tree, env) {
676
705
  return FIX_CHECKS.map((check) => {
677
706
  const found = check.detect(tree, env);
678
707
  const key = decisionKeyOf(tree, check, env);
679
708
  const decision = decisionOf(tree.config, key);
680
- const state = decision ? decision.decision : found.items.length ? "pending" : "nothing";
709
+ const state = decision?.decision === "declined" ? "declined" : found.items.length ? "pending" : decision ? "fixed" : "nothing";
681
710
  return { check, key, found, ...(decision ? { decision } : {}), state };
682
711
  });
683
712
  }
@@ -691,7 +720,7 @@ function earlierReaders(root, agent, model) {
691
720
  if (num(r.usd) !== undefined && !r.usdPartial)
692
721
  usd.push(num(r.usd));
693
722
  const ms = num(r.wallMs) ?? num(r.ms);
694
- if (ms)
723
+ if (ms && ms > 0 && ms <= READER_MS_MAX)
695
724
  secs.push(ms / 1000);
696
725
  }
697
726
  return { usd, secs };
@@ -710,7 +739,7 @@ export function estimateFix(root, o) {
710
739
  const e = earlierReaders(root, o.agent, o.model);
711
740
  const n = o.calls;
712
741
  const mUsd = e.usd.length >= EARLIER_MIN ? median(e.usd) : undefined;
713
- const one = (e.secs.length >= EARLIER_MIN ? median(e.secs) : undefined) ?? READ_EXPECT_DEFAULT[o.agent] ?? READ_EXPECT_OTHERS;
742
+ const one = (e.secs.length >= EARLIER_MIN ? median(e.secs) : undefined) ?? readExpectDefault(o.agent);
714
743
  const secs = o.perReading !== undefined ? n * o.perReading : Math.ceil(n / Math.max(1, o.parallel)) * one;
715
744
  const minutes = Math.max(1, Math.ceil(secs / 60));
716
745
  if (mUsd !== undefined) {
@@ -719,39 +748,123 @@ export function estimateFix(root, o) {
719
748
  }
720
749
  return { level: n >= COST_COUNT.expensive ? "expensive" : n >= COST_COUNT.medium ? "medium" : "cheap", calls: n, minutes, basis: "count", earlier: 0 };
721
750
  }
722
- /** The words of a text, folded, without the marks of uncertainty. */
723
- function words(text) {
724
- return new Set(foldText(text.replace(/\[\?\]/gu, " ")).split(/[^\p{L}\p{M}\p{N}]+/u).filter((w) => w.length > 1));
751
+ /**
752
+ * Languages whose prose writes every noun with a capital ("Der Ausschnitt zeigt …"): there a lone capital is no name for
753
+ * sure, and the little words that begin a sentence there (an article, a pronoun) never one.
754
+ */
755
+ const NOUNS_CAPITALIZED = {
756
+ de: new Set("der die das dem den des ein eine einen einem einer eines im am vom zum zur beim ins es er sie wir ich man dies diese dieser dieses hier da dort auch und aber oder nur noch so wie was wo nach unter von mit bei auf in an".split(" ")),
757
+ };
758
+ // a reader's quotation marks: what it quotes of the record is its transcript, the rest its comment
759
+ const QUOTED = /[„“”"«»「」『』]([^„“”"«»「」『』]*)[„“”"«»「」『』]/gu;
760
+ /** A word as compared: folded, the long s and the ezh of old hands as the letters they are. */
761
+ const wordKey = (w) => foldText(w).replace(/ſ/gu, "s").replace(/ʒ/gu, "z");
762
+ /**
763
+ * The facts of a transcript: the names (a word that begins with a capital, or any word of a script without capitals)
764
+ * and the numbers; a word broken over a line ("Wac- / lawa", "In= / mann", "Wac|lawa") whole, the marks of doubt and an editor's
765
+ * notes in brackets left out (a letter supplied in brackets, "Mi[cha]la", kept). Of a reader's own words (prose: nothing
766
+ * quoted), a capital that only begins a sentence ("The crop shows …", "Výřez ukazuje …") is a name only where the same
767
+ * word stands elsewhere as one; in a language that writes its nouns with a capital (`lang`), so is a lone capital — two or
768
+ * more together ("Jan Novak") and a month after a day ("5. Mai") stay names. Such a word is kept as maybe one.
769
+ */
770
+ function readOf(text, unclear, found, prose = false, lang = "") {
771
+ const clean = text
772
+ .replace(/\[\?\]|\(\?\)/gu, "")
773
+ .replace(/\[[^\]]*\s[^\]]*\]/gu, " ")
774
+ .replace(/[[\]]/gu, "")
775
+ .replace(/([\p{L}\p{M}])(?:[-=¬]\s*[/|]?\s*|\|)(?=[\p{L}\p{M}])/gu, "$1");
776
+ const facts = new Map();
777
+ const words = new Set();
778
+ const tokens = [...clean.matchAll(/[\p{L}\p{M}\p{N}]+/gu)];
779
+ const little = prose ? NOUNS_CAPITALIZED[lang.toLowerCase().split(/[-_]/u)[0]] : undefined;
780
+ const capital = (i) => /^\p{Lu}/u.test(tokens[i]?.[0] ?? "") && !/\p{N}/u.test(tokens[i][0]) && !little?.has(wordKey(tokens[i][0]));
781
+ // the word before a token within its sentence (a sentence's end or a line's in between: none)
782
+ const joined = (a, b) => a >= 0 && b < tokens.length && !/[.!?…:;\n]/u.test(clean.slice(tokens[a].index + tokens[a][0].length, tokens[b].index).replace(/^\./u, (d) => (/\p{N}$/u.test(tokens[a][0]) ? "" : d)));
783
+ const maybeAt = [];
784
+ tokens.forEach((m, i) => {
785
+ const w = m[0];
786
+ const k = wordKey(w);
787
+ words.add(k);
788
+ const digits = /\p{N}/u.test(w);
789
+ const cased = /[\p{Lu}\p{Ll}]/u.test(w);
790
+ if (!digits && cased && prose && /^\p{Lu}/u.test(w)) {
791
+ if (little?.has(k))
792
+ return;
793
+ const lone = little ? !(joined(i - 1, i) && capital(i - 1)) && !(joined(i, i + 1) && capital(i + 1)) && !(joined(i - 1, i) && /^\p{N}+$/u.test(tokens[i - 1][0])) : opensSentence(clean, m.index, w.length);
794
+ if (lone) {
795
+ maybeAt.push({ word: w, key: k, at: i });
796
+ return;
797
+ }
798
+ }
799
+ if (digits || (w.length > 1 && (!cased || /^\p{Lu}/u.test(w))))
800
+ if (!facts.has(k))
801
+ facts.set(k, w);
802
+ });
803
+ const near = (i) => (i >= 0 && i < tokens.length ? wordKey(tokens[i][0]) : "");
804
+ const maybe = maybeAt.filter((x) => x.word.length > 1).map((x) => ({ word: x.word, key: x.key, prev: joined(x.at - 1, x.at) ? near(x.at - 1) : "", next: joined(x.at, x.at + 1) ? near(x.at + 1) : "" }));
805
+ return { facts, words, unclear, found, maybe };
806
+ }
807
+ /**
808
+ * A word that begins a sentence of prose: at a line's start or after a sentence's end (never after a number's dot, "5. Mai"),
809
+ * the next word of its sentence in small letters ("Jan Novak", "Jan, son of" stay names).
810
+ */
811
+ function opensSentence(text, at, length) {
812
+ let i = at - 1;
813
+ while (i >= 0 && /\s/u.test(text[i]) && text[i] !== "\n")
814
+ i--;
815
+ const start = i < 0 || text[i] === "\n" || (/[.!?…]/u.test(text[i]) && i > 0 && !/\p{N}/u.test(text[i - 1]));
816
+ return start && /^\s+\p{Ll}/u.test(text.slice(at + length));
817
+ }
818
+ /**
819
+ * What a reader read of its blocks: the parts of its entries it quotes from the record (when it quotes any), else its
820
+ * entries — never a line of possible readings with their chances or of letters with theirs (a "%" in it), nor how it
821
+ * describes the hand or how sure it is (hand, certainty); what it could not read only counts as unclear.
822
+ */
823
+ function readOfBlocks(blocks, lang) {
824
+ const lines = blocks.flatMap((b) => b.entries ?? []).filter((l) => !/%/u.test(l));
825
+ const quoted = lines.flatMap((l) => [...l.matchAll(QUOTED)].map((m) => m[1]));
826
+ const unclear = blocks.reduce((n, x) => n + unclearOf(x), 0);
827
+ const found = blocks.some((b) => b.result === "found");
828
+ return quoted.length ? readOf(quoted.join(" "), unclear, found) : readOf(lines.join("\n"), unclear, found, true, lang);
829
+ }
830
+ /** What a reading of a document read: the people's names and ages, the events' dates and places (the reader's own fields). */
831
+ function readOfDocument(r) {
832
+ const { unclear } = docText(r);
833
+ const parts = r.entries.flatMap((e) => [...e.persons.flatMap((p) => [p.given, p.surname, p.age]), ...e.events.flatMap((v) => [v.date?.gedcom ?? v.date?.text, v.place?.text])]);
834
+ // every word of its fields counts: a document's reader says what each one is
835
+ const read = readOf(parts.filter(Boolean).join(" "), unclear, r.entries.length > 0);
836
+ for (const w of read.words)
837
+ if (!read.facts.has(w))
838
+ read.facts.set(w, w);
839
+ return { ...read, maybe: [] };
725
840
  }
726
- /** A reader's entries as one text, and its uncertain places. */
727
- const readText = (blocks) => ({ text: blocks.map((b) => (b.entries?.length ? b.entries.join(" ") : b.text)).join(" "), unclear: blocks.reduce((n, x) => n + unclearOf(x), 0) });
728
841
  /**
729
- * The new reading of a place against the earlier one: the same words (shoda), the same with fewer places unclear or
730
- * more read (upřesněno), or other words (rozchod) — nothing read: failed.
842
+ * The new reading of a place against the earlier one, by what each read — the names and numbers of its transcript:
843
+ * the same (shoda); all the earlier one read with more beside it, fewer places unclear, or found where it was unclear
844
+ * (upřesněno); something the earlier one read that the new one does not — another name, date, number or place (rozchod).
845
+ * Of a reader's own words, a capital that may only begin a sentence or be a noun in another place of the same sentence
846
+ * ("Marie byla …" against "Anna byla …"): not sure, so refined, never the same. Nothing read: failed. `lang`: the language
847
+ * the readers wrote their own words in (the research language).
731
848
  */
732
- export function compareReadings(before, now) {
733
- return now.length ? compareTexts(readText(before), readText(now)) : "failed";
849
+ export function compareReadings(before, now, lang) {
850
+ return now.length ? compareRead(readOfBlocks(before, lang), readOfBlocks(now, lang)) : { result: "failed", before: [], now: [] };
734
851
  }
735
- /** A reading of a document again against the earlier one, as compareReadings (its entries' transcripts). */
852
+ /** A reading of a document again against the earlier one, as compareReadings (its people and events). */
736
853
  export function compareDocuments(before, now) {
737
854
  if (!now || !now.entries.length)
738
- return "failed";
739
- return compareTexts(docText(before), docText(now));
740
- }
741
- function compareTexts(before, now) {
742
- const a = words(before.text);
743
- const b = words(now.text);
744
- const both = [...a].filter((w) => b.has(w)).length;
745
- const all = new Set([...a, ...b]).size;
746
- const alike = all ? both / all : 1;
747
- const fewer = now.unclear < before.unclear;
748
- // what the earlier reading had, the new one has too, with more beside it: read further
749
- const more = a.size > 0 && both / a.size >= 0.85 && b.size > a.size;
750
- if (alike >= 0.9)
751
- return fewer ? "refined" : "same";
752
- if ((alike >= 0.6 && fewer) || more)
753
- return "refined";
754
- return "differ";
855
+ return { result: "failed", before: [], now: [] };
856
+ return compareRead(readOfDocument(before), readOfDocument(now));
857
+ }
858
+ function compareRead(a, b) {
859
+ const before = [...a.facts].filter(([k]) => !b.words.has(k)).map(([, w]) => w);
860
+ const now = [...b.facts].filter(([k]) => !a.words.has(k)).map(([, w]) => w);
861
+ if (before.length)
862
+ return { result: "differ", before, now };
863
+ // a maybe-name of one where the other has another between the same words
864
+ const swapped = (x, y) => x.maybe.filter((m) => !x.facts.has(m.key) && !y.words.has(m.key) && y.maybe.some((o) => o.key !== m.key && o.prev === m.prev && o.next === m.next)).map((m) => m.word);
865
+ const maybeNow = swapped(b, a);
866
+ const surer = b.unclear < a.unclear || (b.found && !a.found);
867
+ return { result: now.length || maybeNow.length || surer ? "refined" : "same", before, now: [...now, ...maybeNow] };
755
868
  }
756
869
  // ── what waits ──────────────────────────────────────────────────────────────────────────────────────────────────
757
870
  /** The checks that wait for the person's decision here (none in an archive: nobody reads there). */
@@ -202,6 +202,12 @@ function blocksOf(tree, stem, media) {
202
202
  const r = loadReadings(tree).find((x) => x.stem === stem);
203
203
  return (r?.blocks ?? []).filter((b) => b.media === media.id || (b.recordset === media.recordset && b.num !== undefined && b.num === media.image));
204
204
  }
205
+ /** A comparison as a reading again keeps it: its result, and what differs where something does. */
206
+ function compared(c) {
207
+ if (!c)
208
+ return { result: "failed" };
209
+ return { result: c.result, ...(c.before.length || c.now.length ? { differs: { before: c.before, now: c.now } } : {}) };
210
+ }
205
211
  /** Each place read again, blind with the earlier question, from the sharper copy; the readings held against each other. */
206
212
  async function readAgain(ctx, tree, t, items, model) {
207
213
  const { def } = resolveCommand(["read"]);
@@ -231,7 +237,8 @@ async function readAgain(ctx, tree, t, items, model) {
231
237
  }
232
238
  const before = loadDocResult(tree.root, item.reading);
233
239
  const secs = (Date.now() - began) / 1000;
234
- return [{ item, result: before ? compareDocuments(before, now) : "failed", ...(now?.id ? { reading: now.id } : {}), secs, ...(error ? { error } : {}) }];
240
+ const c = before ? compareDocuments(before, now) : undefined;
241
+ return [{ item, ...compared(c), ...(now?.id ? { reading: now.id } : {}), secs, ...(error ? { error } : {}) }];
235
242
  }
236
243
  const opts = { question: item.question, ...(model ? { model } : {}), ...(item.crop ? { crop: item.crop } : {}), parallel: "1", ...(item.blind ? { blind: true } : {}), ...(item.context ? { context: item.context } : {}) };
237
244
  let reading;
@@ -249,7 +256,7 @@ async function readAgain(ctx, tree, t, items, model) {
249
256
  const m = byId.get(i.media);
250
257
  const now = reading && m ? blocksOf(tree, reading, m) : [];
251
258
  const before = m ? blocksOf(tree, i.reading, m) : [];
252
- return { item: i, result: compareReadings(before, now), ...(reading ? { reading } : {}), secs, ...(error ? { error } : {}) };
259
+ return { item: i, ...compared(compareReadings(before, now, tree.lang)), ...(reading ? { reading } : {}), secs, ...(error ? { error } : {}) };
253
260
  });
254
261
  })).flat();
255
262
  }
@@ -267,7 +274,7 @@ function tasksFor(tree, check, done, now) {
267
274
  priority: d.result === "differ" ? 2 : 3,
268
275
  what: phrase(lang, `${p}.what`, { media: i.document ? i.document.files.join(", ") : i.media, old: i.reading, now: d.reading }),
269
276
  where: [...(i.document ? i.document.files : [i.media]), ...i.sources],
270
- why: phrase(lang, `${p}.why.${d.result}`, { since: check.since, from: i.from ?? "", before: i.before ? label(i.before) : "", now: now ? label(now) : "" }),
277
+ why: [phrase(lang, `${p}.why.${d.result}`, { since: check.since, from: i.from ?? "", before: i.before ? label(i.before) : "", now: now ? label(now) : "" }), differsText(lang, d)].filter(Boolean).join(" "),
271
278
  doneWhen: phrase(lang, `${p}.done`, { old: i.reading, now: d.reading, sources: i.sources.join(", ") || i.media, show: i.document ? "strom document show" : "strom readings" }),
272
279
  subject: [...i.sources],
273
280
  state: "open",
@@ -276,6 +283,15 @@ function tasksFor(tree, check, done, now) {
276
283
  d.task = task.id;
277
284
  }
278
285
  }
286
+ /** One sentence of what differs (the facts each read that the other does not), or what the new reading read beside. */
287
+ function differsText(lang, d) {
288
+ const list = (ws) => (ws.length ? ws.slice(0, 6).join(", ") + (ws.length > 6 ? ", …" : "") : "—");
289
+ if (!d.differs)
290
+ return "";
291
+ if (d.result === "differ")
292
+ return phrase(lang, "fix.differs", { before: list(d.differs.before), now: list(d.differs.now) });
293
+ return d.differs.now.length ? phrase(lang, "fix.more", { now: list(d.differs.now) }) : "";
294
+ }
279
295
  /** `secs`: the real time the readings took, readers at once and all. Its places: the same, refined, different. */
280
296
  function summaryText(t, done, secs) {
281
297
  const n = (r) => done.filter((d) => d.result === r).length;
@@ -532,14 +548,15 @@ register({
532
548
  // who reads, before anything is offered: the agent asked, or with nothing asked, any here
533
549
  if (typeof opts.model === "string" && !opts.model.trim())
534
550
  throw new UsageError("--model names a model", { hint: `${set} --model <model>` });
535
- if (ctx.settings.agent(tree.config).source === "flag" || typeof opts.model === "string" || !readyHere(ctx).length)
536
- requireReady(ctx, tree, set);
537
- personOnly(ctx, t, "Read places of this research again (paid readings)?", set);
538
551
  const first = positive(opts.first, "first");
539
552
  const tryN = positive(opts.try, "try", FIX_TRY);
540
553
  const states = args[0] ? [stateOfCheck(ctx, tree, args[0])] : fixStates(tree, fixEnv(ctx, tree)).filter((s) => s.state === "pending");
541
- if (!states.length)
542
- return { text: t("ui.fixes.none"), data: { checks: [] } };
554
+ // nothing to read again: said so, before who reads or whose choice it is
555
+ if (!states.some((s) => s.found.items.length))
556
+ return { text: t("ui.fixes.none"), data: { checks: states.map((s) => ({ id: s.check.id, read: 0, tasks: [] })) } };
557
+ if (ctx.settings.agent(tree.config).source === "flag" || typeof opts.model === "string" || !readyHere(ctx).length)
558
+ requireReady(ctx, tree, set);
559
+ personOnly(ctx, t, "Read places of this research again (paid readings)?", set);
543
560
  const results = [];
544
561
  for (const s of states) {
545
562
  if (!s.found.items.length) {
@@ -8,12 +8,12 @@ import { NeedsConsentError, UsageError } from "../core/errors.js";
8
8
  import { findPack, freeRoot, INSTALL_UNIX, INSTALL_WINDOWS, packPlan, unpack, writePack } from "../core/pack.js";
9
9
  import { desktopDir, userHome } from "../core/paths.js";
10
10
  import { safeFolderName } from "../core/text.js";
11
+ import { mb } from "../core/tidy.js";
11
12
  import { Tree } from "../core/tree.js";
12
13
  import { syncAgentFiles } from "../agents/files.js";
13
14
  import { setupWizard } from "../cli/wizard.js";
14
15
  import { isAgent } from "../core/which.js";
15
16
  import { ensureShared } from "./setup.js";
16
- const mb = (bytes) => (bytes / 1e6 < 10 ? (bytes / 1e6).toFixed(1) : Math.round(bytes / 1e6).toString());
17
17
  /** Where a package goes unless named: the desktop (else the home), the tree's name and the day, a free name. */
18
18
  export function defaultPackFile(ctx, tree) {
19
19
  const base = `${safeFolderName(tree.config.name) || "tree"} ${new Date().toISOString().slice(0, 10)}`;
@@ -60,11 +60,11 @@ register({
60
60
  if (ctx.dryRun) {
61
61
  const plan = packPlan(tree, shared, all);
62
62
  const bytes = plan.media.reduce((s, m) => s + m.size, 0);
63
- return { text: `dry run: would pack "${tree.config.name}" with ${counted(plan.media.length, "image")} (${mb(bytes)} MB) into ${ctx.display(out)} — nothing written`, data: { out, plan } };
63
+ return { text: `dry run: would pack "${tree.config.name}" with ${counted(plan.media.length, "image")} (${mb(bytes)}) into ${ctx.display(out)} — nothing written`, data: { out, plan } };
64
64
  }
65
65
  const r = packTree(tree, shared, out, all);
66
66
  const took = r.plan.media.reduce((s, m) => s + m.size, 0);
67
- const text = lines(ui(lang, "ui.pack.done", { name: tree.config.name, file: ctx.display(r.file), mb: mb(r.bytes) }), ui(lang, "ui.pack.saved"), ui(lang, all ? "ui.pack.images.all" : "ui.pack.images", { n: r.plan.media.length, mb: mb(took) }), r.plan.left.count ? ui(lang, "ui.pack.left", { n: r.plan.left.count, mb: mb(r.plan.left.bytes) }) : undefined, r.plan.connectors.length ? ui(lang, "ui.pack.connectors", { list: r.plan.connectors.join(", ") }) : undefined, r.plan.left.count && r.plan.left.connectors ? ui(lang, "ui.pack.noconnector") : undefined, r.plan.missing.length ? ui(lang, "ui.pack.missing", { n: r.plan.missing.length }) : undefined, "", ui(lang, "ui.pack.send"));
67
+ const text = lines(ui(lang, "ui.pack.done", { name: tree.config.name, file: ctx.display(r.file), size: mb(r.bytes, lang) }), ui(lang, "ui.pack.saved"), ui(lang, all ? "ui.pack.images.all" : "ui.pack.images", { n: r.plan.media.length, size: mb(took, lang) }), r.plan.left.count ? ui(lang, "ui.pack.left", { n: r.plan.left.count, size: mb(r.plan.left.bytes, lang) }) : undefined, r.plan.connectors.length ? ui(lang, "ui.pack.connectors", { list: r.plan.connectors.join(", ") }) : undefined, r.plan.left.count && r.plan.left.connectors ? ui(lang, "ui.pack.noconnector") : undefined, r.plan.missing.length ? ui(lang, "ui.pack.missing", { n: r.plan.missing.length }) : undefined, "", ui(lang, "ui.pack.send"));
68
68
  return { text, data: { file: r.file, bytes: r.bytes, manifest: r.manifest, missing: r.plan.missing } };
69
69
  },
70
70
  });
@@ -152,17 +152,16 @@ register({
152
152
  const others = ctx.knownTrees().map((k) => k.root).filter((r) => path.resolve(r) !== path.resolve(root));
153
153
  const only = onlyItsFiles(root, others, shared);
154
154
  const onlyBytes = only.reduce((s, f) => s + f.size, 0);
155
- const mb = (b) => (b / 1e6 < 10 ? (b / 1e6).toFixed(1) : Math.round(b / 1e6).toString());
156
155
  ctx.io.stdout(ui(lang, "ui.remove.what", { name, root: ctx.display(root), persons: tree.list("person").length }) + "\n");
157
156
  // a way back first
158
157
  let backup;
159
158
  const all = packPlan(tree, shared, true).media.reduce((s, m) => s + m.size, 0);
160
- if (await ctx.confirm(ui(lang, "ui.remove.backup", { mb: mb(all) }), true)) {
159
+ if (await ctx.confirm(ui(lang, "ui.remove.backup", { size: mb(all, lang) }), true)) {
161
160
  const out = defaultPackFile(ctx, tree);
162
161
  backup = packTree(tree, shared, out, true).file;
163
162
  ctx.io.stdout(ui(lang, "ui.remove.backup.done", { file: ctx.display(backup) }) + "\n");
164
163
  }
165
- const images = only.length > 0 && (await ctx.confirm(ui(lang, "ui.remove.images", { n: only.length, mb: mb(onlyBytes) }), backup !== undefined));
164
+ const images = only.length > 0 && (await ctx.confirm(ui(lang, "ui.remove.images", { n: only.length, size: mb(onlyBytes, lang) }), backup !== undefined));
166
165
  const typed = (await ctx.ask(ui(lang, "ui.remove.type", { name }))).trim();
167
166
  if (!typed || foldText(typed) !== foldText(name))
168
167
  return { text: ui(lang, "ui.remove.cancelled"), data: { removed: false, ...(backup ? { backup } : {}) } };
@@ -182,7 +181,7 @@ register({
182
181
  moveToTrash(folder, ctx.env, ui(lang, "ui.remove.images.folder", { name }));
183
182
  }
184
183
  const cfg = ctx.settings.config;
185
- const text = lines(ui(lang, "ui.remove.done", { name }), followed ? ui(lang, "ui.remove.live") : undefined, moved ? ui(lang, "ui.remove.images.done", { n: moved, mb: mb(onlyBytes) }) : undefined, backup ? ui(lang, "ui.remove.backup.back", { file: ctx.display(backup) }) : undefined, ui(lang, backup ? "ui.remove.trash" : "ui.remove.trash.alone"), next ? ui(lang, "ui.remove.next", { name: next }) : undefined);
184
+ const text = lines(ui(lang, "ui.remove.done", { name }), followed ? ui(lang, "ui.remove.live") : undefined, moved ? ui(lang, "ui.remove.images.done", { n: moved, size: mb(onlyBytes, lang) }) : undefined, backup ? ui(lang, "ui.remove.backup.back", { file: ctx.display(backup) }) : undefined, ui(lang, backup ? "ui.remove.trash" : "ui.remove.trash.alone"), next ? ui(lang, "ui.remove.next", { name: next }) : undefined);
186
185
  return { text, data: { removed: true, root, images: moved, ...(backup ? { backup } : {}), ...(next ? { current: cfg.currentTree } : {}) } };
187
186
  },
188
187
  });
@@ -303,6 +303,10 @@ function notReady(tree, settings, env, seen, now) {
303
303
  /** A reading's seconds before any of this agent's readers ended here (measured 2026-10-20 and 21): Grok's take minutes. */
304
304
  export const READ_EXPECT_DEFAULT = { grok: 240 };
305
305
  export const READ_EXPECT_OTHERS = 60;
306
+ /** The seconds a reading is expected to take with this agent before any of its readers ended here (any name: its own key only). */
307
+ export const readExpectDefault = (agent) => (Object.hasOwn(READ_EXPECT_DEFAULT, agent) ? READ_EXPECT_DEFAULT[agent] : READ_EXPECT_OTHERS);
308
+ /** A reader's time past this is no reader's (a journal written by hand, a clock gone wrong): never learned from. */
309
+ export const READER_MS_MAX = DAY_MS;
306
310
  /** The readers of documents a reading's time is learned from: the last so many of the agent that read to an end. */
307
311
  const EXPECT_LAST = 9;
308
312
  const expectSeen = new Map();
@@ -322,7 +326,7 @@ function readerSecs(root, agent) {
322
326
  const secs = new Map();
323
327
  for (const r of readJournal(file)) {
324
328
  const ms = typeof r.wallMs === "number" ? r.wallMs : typeof r.ms === "number" ? r.ms : NaN;
325
- if (r.kind !== "document" || r.outcome !== "ok" || typeof r.agent !== "string" || !(ms >= 0))
329
+ if (r.kind !== "document" || r.outcome !== "ok" || typeof r.agent !== "string" || !(ms >= 0 && ms <= READER_MS_MAX))
326
330
  continue;
327
331
  secs.set(r.agent, [...(secs.get(r.agent) ?? []), ms / 1000].slice(-EXPECT_LAST));
328
332
  }
@@ -339,7 +343,7 @@ function readerSecs(root, agent) {
339
343
  export function expectSecs(root, agent, rounds = 1) {
340
344
  const secs = readerSecs(root, agent).sort((a, b) => a - b);
341
345
  const mid = secs.length >> 1;
342
- const one = secs.length ? (secs.length % 2 ? secs[mid] : (secs[mid - 1] + secs[mid]) / 2) : (READ_EXPECT_DEFAULT[agent] ?? READ_EXPECT_OTHERS);
346
+ const one = secs.length ? (secs.length % 2 ? secs[mid] : (secs[mid - 1] + secs[mid]) / 2) : readExpectDefault(agent);
343
347
  return Math.max(1, Math.round(one * Math.max(1, rounds)));
344
348
  }
345
349
  /** A reading at work or queued: how long it is expected to take from its start (since, when it is reading). */
@@ -248,6 +248,19 @@ export function phoneRunning(env) {
248
248
  const run = phoneRun(env);
249
249
  return run && !run.ended && alive(run.pid) ? run : undefined;
250
250
  }
251
+ /** How long a server just started may take to say itself (phone-run.json) before another may be started. */
252
+ const SPAWN_SAYS_MS = 20_000;
253
+ /**
254
+ * A server started a moment ago that has not said itself yet (Node starting, its addresses bound): its pid while it
255
+ * lives. Several asking at once (a bridge of each research, a strom just updated) start that one, not one each.
256
+ */
257
+ function startedJustNow(env) {
258
+ const s = readSafe(file(env, "phone-spawn.json"));
259
+ if (!s || typeof s.pid !== "number" || typeof s.at !== "string")
260
+ return undefined;
261
+ const age = Date.now() - Date.parse(s.at);
262
+ return age >= 0 && age < SPAWN_SAYS_MS && alive(s.pid) ? s.pid : undefined;
263
+ }
251
264
  function spawnPhone(env, extra = {}) {
252
265
  const { command, args } = stromLauncher();
253
266
  const dir = configDir(env);
@@ -270,6 +283,14 @@ function spawnPhone(env, extra = {}) {
270
283
  child.unref();
271
284
  if (typeof log === "number")
272
285
  fs.closeSync(log);
286
+ if (child.pid) {
287
+ try {
288
+ writeJson(file(env, "phone-spawn.json"), { pid: child.pid, at: new Date().toISOString() });
289
+ }
290
+ catch {
291
+ // another may start one meanwhile: the one that gets the server's lock serves, the other ends at once
292
+ }
293
+ }
273
294
  return child.pid;
274
295
  }
275
296
  /**
@@ -293,9 +314,13 @@ export function startPhone(env, opts = {}) {
293
314
  const now = phoneRunning(env);
294
315
  if (now)
295
316
  return now;
296
- if (opts.why)
297
- notePhone(env, opts.why);
298
- const pid = spawnPhone(env);
317
+ // one started a moment ago and not said yet: waited for, none started beside it
318
+ let pid = startedJustNow(env);
319
+ if (!pid) {
320
+ if (opts.why)
321
+ notePhone(env, opts.why);
322
+ pid = spawnPhone(env);
323
+ }
299
324
  const waitMs = opts.waitMs ?? 8000;
300
325
  for (let waited = 0; pid && waited < waitMs; waited += 50) {
301
326
  const run = phoneRun(env);
@@ -552,19 +577,19 @@ function pairMs(env) {
552
577
  const t = Number(env.STROM_PHONE_PAIR_MS);
553
578
  return phoneTest(env) && Number.isFinite(t) && t > 0 ? t : PAIR_MS;
554
579
  }
555
- function readPair(env) {
580
+ function readPair(env, clock = Date.now) {
556
581
  const p = readSafe(file(env, "phone-pair.json"));
557
- return p && typeof p.code === "string" && /^[A-Za-z0-9_-]{22}$/.test(p.code) && isTime(p.expiresAt) && Date.parse(p.expiresAt) > Date.now() ? p : undefined;
582
+ return p && typeof p.code === "string" && /^[A-Za-z0-9_-]{22}$/.test(p.code) && isTime(p.expiresAt) && Date.parse(p.expiresAt) > clock() ? p : undefined;
558
583
  }
559
584
  /** When the code to pair a phone ends, while there is one (never the code). */
560
- export function pairExpires(env) {
561
- return readPair(env)?.expiresAt;
585
+ export function pairExpires(env, clock = Date.now) {
586
+ return readPair(env, clock)?.expiresAt;
562
587
  }
563
588
  /**
564
589
  * The code to pair a phone: the one that holds, else a new one (128 bits, PAIR_MS long, kept for strom's user alone).
565
590
  * `tree`: the tree whose QR code it is (the last one shown wins: the page opens it once paired).
566
591
  */
567
- export function pairCode(env, opts = {}) {
592
+ export function pairCode(env, opts = {}, clock = Date.now) {
568
593
  return withLock(file(env, "phone-pair.lock"), { owner: "the code to pair a phone" }, () => {
569
594
  const f = file(env, "phone-pair.json");
570
595
  const write = (p) => {
@@ -576,7 +601,7 @@ export function pairCode(env, opts = {}) {
576
601
  // Windows: the user's folder is theirs
577
602
  }
578
603
  };
579
- const now = readPair(env);
604
+ const now = readPair(env, clock);
580
605
  if (now) {
581
606
  // the last shown counts: one shown with no tree (outside a tree's folder) opens the tree the page chose
582
607
  if (now.tree !== opts.tree) {
@@ -586,7 +611,7 @@ export function pairCode(env, opts = {}) {
586
611
  return { code: now.code, expiresAt: now.expiresAt };
587
612
  }
588
613
  const code = crypto.randomBytes(16).toString("base64url");
589
- const at = new Date();
614
+ const at = new Date(clock());
590
615
  const expiresAt = new Date(at.getTime() + pairMs(env)).toISOString();
591
616
  write({ code, at: at.toISOString(), expiresAt, ...(opts.tree ? { tree: opts.tree } : {}) });
592
617
  notePhone(env, `a code to pair a phone made, it holds until ${expiresAt}`);
@@ -623,9 +648,9 @@ export function phoneName(ua) {
623
648
  * The code given back by a phone's page, taken once: right and holding — the phone paired (its key, only now and only
624
649
  * to it); anything else — none, and the code still holds for the phone it was shown to.
625
650
  */
626
- export function pairPhone(env, code, info) {
651
+ export function pairPhone(env, code, info, clock = Date.now) {
627
652
  const taken = withLock(file(env, "phone-pair.lock"), { owner: "the code to pair a phone" }, () => {
628
- const p = readPair(env);
653
+ const p = readPair(env, clock);
629
654
  const a = Buffer.from(p?.code ?? "");
630
655
  const b = Buffer.from(code);
631
656
  if (!p || a.length !== b.length || !crypto.timingSafeEqual(a, b))
@@ -111,6 +111,8 @@ export const PHRASES = {
111
111
  "fix.model.why.differ": "Read again by {now} (earlier: {before}) where something stayed open, and the readings differ.",
112
112
  "fix.model.why.refined": "Read again by {now} (earlier: {before}) where something stayed open, and the new reading reads more or is surer.",
113
113
  "fix.model.done": "both readings held against each other ({show} {old} · {show} {now}) and against {sources}: what holds kept on the source, the facts on it checked; nothing taken away without a reason",
114
+ "fix.differs": "What differs: earlier {before}, now {now}.",
115
+ "fix.more": "Read now beside the earlier: {now}.",
114
116
  "fix.model.unknown": "a model not recorded",
115
117
  "fix.model.own": "its own model",
116
118
  "batch.unnamed": "material from the Strom app",
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.19.0-beta.5";
30
+ export const VERSION = "1.19.0-beta.7";
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.19.0-beta.5",
3
+ "version": "1.19.0-beta.7",
4
4
  "description": "Genealogical research toolkit for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {