strom-research 1.19.0-beta.6 → 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.6. 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,13 +25,16 @@ 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
- Čtení se porovnají podle přečteného — jmen, dat a čísel přepisu, ne slov komentáře čtenáře — a úkol jednou větou
34
- řekne, který údaj se liší. Druhá: po změně modelu nebo agenta pro čtení dřívější čtení, která stojí za nové přečtení
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í
35
38
  — s otevřenými místy, s neshodou čtenářů, s otevřeným rozporem, nepotvrzená u pramene; řečeno jako „četl model X,
36
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
37
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
@@ -40,6 +43,8 @@ Změny každé verze ve zkratce. Úplné poznámky a instalační řádky jsou n
40
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
41
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
42
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“.
43
48
 
44
49
  ## 1.18.0
45
50
 
@@ -99,7 +104,8 @@ Změny každé verze ve zkratce. Úplné poznámky a instalační řádky jsou n
99
104
  poznámky: výzkum si nechá svůj text. Místo nebo jiný údaj delší než řádek souboru GEDCOM (255 bajtů) už nezastaví
100
105
  jeho zápis a žádné emoji se nerozdělí tam, kde dlouhý řádek pokračuje. S `excerpts.for all` dostane v aplikaci Strom
101
106
  výřez i pramen, který zatím nikdo necituje, pokud se vedle citovaných vejde do limitu – citované se kvůli němu
102
- 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é.
103
109
 
104
110
  ## 1.17.0
105
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.6. 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,14 +29,18 @@ 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
36
  Forschung etwas falsch gemacht hat, sagen `strom fixes` und Einstellungen → Wartung → Korrekturen, worum es geht,
35
37
  was gefunden wurde und was eine Behebung täte, auch wie aufwendig sie ist; ohne Ja geschieht nichts, ein Nein
36
38
  bleibt. Die erste: blinde Lesungen aus dem verkleinerten Scan, wo es einen schärferen Teil gab — neben den alten
37
39
  noch einmal gelesen, zuerst an einigen, nichts überschrieben, ein Unterschied wird zur Aufgabe. Verglichen wird, was
38
- gelesen wurde — die Namen, Daten und Zahlen der Abschrift, nicht die Worte des Kommentars —, und die Aufgabe sagt in
39
- einem Satz, welche Angabe abweicht. Die zweite: nach einem Wechsel des Lesemodells oder Agenten die früheren
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
40
44
  Lesungen, die ein neues Lesen lohnen — mit offenen Stellen, uneinigen Lesern, einem offenen Widerspruch, an der
41
45
  Quelle nicht bestätigt; gesagt als „gelesen von Modell X, jetzt liest Modell Y“, einmal je Agent und Modell gefragt,
42
46
  jede so neu gelesen, wie sie gelesen wurde (die Bilder einer Lesung zusammen, ein Versuch oder ein Teil immer aus
@@ -46,6 +50,9 @@ Die Änderungen jeder Version in Kürze. Die vollständigen Hinweise und die Ins
46
50
  bereiter Agent oder ein anderes Modell (`strom fixes run <id> --agent <a> --model <m>`), mit dem, der vorher las;
47
51
  einer, der hier nicht lesen kann, wird sofort abgelehnt, mit denen, die es können. Die Zeit einer Behebung wird
48
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“.
49
56
 
50
57
  ## 1.18.0
51
58
 
@@ -117,7 +124,9 @@ Die Änderungen jeder Version in Kürze. Die vollständigen Hinweise und die Ins
117
124
  GEDCOM-Datei (255 Bytes), hält das Schreiben der Datei nicht mehr auf, und kein Emoji wird dort geteilt, wo eine
118
125
  lange Zeile weitergeht. Mit `excerpts.for all` bekommt auch eine Quelle, die noch niemand zitiert, in der Strom-App
119
126
  ihren Ausschnitt, soweit er neben den zitierten unter die Grenze passt – die zitierten werden dafür nie kleiner; was
120
- 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.
121
130
 
122
131
  ## 1.17.0
123
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.6. 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,13 +28,16 @@ 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
37
  few, nothing overwritten, a difference becomes a task. Held against each other by what was read — the names, dates
36
- and numbers of the transcript, not the words of the reader's comment — the task says in one sentence which of them
37
- differs. The second: after the reading model or agent changed, the earlier readings worth reading again — with open
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
38
41
  places, readers who disagreed, an open conflict, a reading not confirmed at the source; said as "read by model X,
39
42
  now read by model Y", asked once per agent and model, each read again as it was read (the images of one reading
40
43
  together, a trial or a part always of whole readings). Counted in readings with their places and what a remedy takes
@@ -43,6 +46,8 @@ The changes of each version in short. The full notes and the install lines are o
43
46
  ordering them. The remedy may be read by another agent or model ready here (`strom fixes run <id> --agent <a>
44
47
  --model <m>`), with which one read before said; one that cannot read here is refused at once, with those that can.
45
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".
46
51
 
47
52
  ## 1.18.0
48
53
 
@@ -108,7 +113,8 @@ The changes of each version in short. The full notes and the install lines are o
108
113
  value longer than a line of the GEDCOM file (255 bytes) no longer stops the file from being written, and no emoji is
109
114
  split where a long line goes on. With `excerpts.for all` a source nobody cites yet gets its excerpt in the Strom app
110
115
  too, wherever it fits under the limit beside the cited ones, which never get smaller for it; what did not fit is
111
- 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.
112
118
 
113
119
  ## 1.17.0
114
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",
@@ -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",
@@ -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";
@@ -530,7 +530,7 @@ function newModelDetect(tree, env) {
530
530
  const unclear = blocks.reduce((n, b) => n + Math.max(unclearOf(b), b.result === "unclear" ? 1 : 0), 0);
531
531
  // a blind reading that reads otherwise than the researcher's reading of the image before it
532
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;
533
- const differ = !!theirs && compareReadings(theirs.blocks, blocks).result === "differ";
533
+ const differ = !!theirs && compareReadings(theirs.blocks, blocks, tree.lang).result === "differ";
534
534
  const reg = cropRegion(crop);
535
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();
536
536
  const reasons = [...(unclear ? ["unclear"] : []), ...(differ ? ["differ"] : []), ...(src.some((s) => conflicted.has(s)) ? ["conflict"] : [])];
@@ -720,7 +720,7 @@ function earlierReaders(root, agent, model) {
720
720
  if (num(r.usd) !== undefined && !r.usdPartial)
721
721
  usd.push(num(r.usd));
722
722
  const ms = num(r.wallMs) ?? num(r.ms);
723
- if (ms)
723
+ if (ms && ms > 0 && ms <= READER_MS_MAX)
724
724
  secs.push(ms / 1000);
725
725
  }
726
726
  return { usd, secs };
@@ -739,7 +739,7 @@ export function estimateFix(root, o) {
739
739
  const e = earlierReaders(root, o.agent, o.model);
740
740
  const n = o.calls;
741
741
  const mUsd = e.usd.length >= EARLIER_MIN ? median(e.usd) : undefined;
742
- 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);
743
743
  const secs = o.perReading !== undefined ? n * o.perReading : Math.ceil(n / Math.max(1, o.parallel)) * one;
744
744
  const minutes = Math.max(1, Math.ceil(secs / 60));
745
745
  if (mUsd !== undefined) {
@@ -748,6 +748,13 @@ export function estimateFix(root, o) {
748
748
  }
749
749
  return { level: n >= COST_COUNT.expensive ? "expensive" : n >= COST_COUNT.medium ? "medium" : "cheap", calls: n, minutes, basis: "count", earlier: 0 };
750
750
  }
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
+ };
751
758
  // a reader's quotation marks: what it quotes of the record is its transcript, the rest its comment
752
759
  const QUOTED = /[„“”"«»「」『』]([^„“”"«»「」『』]*)[„“”"«»「」『』]/gu;
753
760
  /** A word as compared: folded, the long s and the ezh of old hands as the letters they are. */
@@ -756,9 +763,11 @@ const wordKey = (w) => foldText(w).replace(/ſ/gu, "s").replace(/ʒ/gu, "z");
756
763
  * The facts of a transcript: the names (a word that begins with a capital, or any word of a script without capitals)
757
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
758
765
  * notes in brackets left out (a letter supplied in brackets, "Mi[cha]la", kept). Of a reader's own words (prose: nothing
759
- * quoted), a capital that only begins a sentence ("The crop shows …", "Výřez ukazuje …") is no name.
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.
760
769
  */
761
- function readOf(text, unclear, found, prose = false) {
770
+ function readOf(text, unclear, found, prose = false, lang = "") {
762
771
  const clean = text
763
772
  .replace(/\[\?\]|\(\?\)/gu, "")
764
773
  .replace(/\[[^\]]*\s[^\]]*\]/gu, " ")
@@ -766,19 +775,34 @@ function readOf(text, unclear, found, prose = false) {
766
775
  .replace(/([\p{L}\p{M}])(?:[-=¬]\s*[/|]?\s*|\|)(?=[\p{L}\p{M}])/gu, "$1");
767
776
  const facts = new Map();
768
777
  const words = new Set();
769
- for (const m of clean.matchAll(/[\p{L}\p{M}\p{N}]+/gu)) {
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) => {
770
785
  const w = m[0];
771
786
  const k = wordKey(w);
772
787
  words.add(k);
773
788
  const digits = /\p{N}/u.test(w);
774
789
  const cased = /[\p{Lu}\p{Ll}]/u.test(w);
775
- if (!digits && cased && prose && /^\p{Lu}/u.test(w) && opensSentence(clean, m.index, w.length))
776
- continue;
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
+ }
777
799
  if (digits || (w.length > 1 && (!cased || /^\p{Lu}/u.test(w))))
778
800
  if (!facts.has(k))
779
801
  facts.set(k, w);
780
- }
781
- return { facts, words, unclear, found };
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 };
782
806
  }
783
807
  /**
784
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"),
@@ -796,12 +820,12 @@ function opensSentence(text, at, length) {
796
820
  * entries — never a line of possible readings with their chances or of letters with theirs (a "%" in it), nor how it
797
821
  * describes the hand or how sure it is (hand, certainty); what it could not read only counts as unclear.
798
822
  */
799
- function readOfBlocks(blocks) {
823
+ function readOfBlocks(blocks, lang) {
800
824
  const lines = blocks.flatMap((b) => b.entries ?? []).filter((l) => !/%/u.test(l));
801
825
  const quoted = lines.flatMap((l) => [...l.matchAll(QUOTED)].map((m) => m[1]));
802
826
  const unclear = blocks.reduce((n, x) => n + unclearOf(x), 0);
803
827
  const found = blocks.some((b) => b.result === "found");
804
- return quoted.length ? readOf(quoted.join(" "), unclear, found) : readOf(lines.join("\n"), unclear, found, true);
828
+ return quoted.length ? readOf(quoted.join(" "), unclear, found) : readOf(lines.join("\n"), unclear, found, true, lang);
805
829
  }
806
830
  /** What a reading of a document read: the people's names and ages, the events' dates and places (the reader's own fields). */
807
831
  function readOfDocument(r) {
@@ -812,16 +836,18 @@ function readOfDocument(r) {
812
836
  for (const w of read.words)
813
837
  if (!read.facts.has(w))
814
838
  read.facts.set(w, w);
815
- return read;
839
+ return { ...read, maybe: [] };
816
840
  }
817
841
  /**
818
842
  * The new reading of a place against the earlier one, by what each read — the names and numbers of its transcript:
819
843
  * the same (shoda); all the earlier one read with more beside it, fewer places unclear, or found where it was unclear
820
844
  * (upřesněno); something the earlier one read that the new one does not — another name, date, number or place (rozchod).
821
- * Nothing read: failed.
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).
822
848
  */
823
- export function compareReadings(before, now) {
824
- return now.length ? compareRead(readOfBlocks(before), readOfBlocks(now)) : { result: "failed", before: [], now: [] };
849
+ export function compareReadings(before, now, lang) {
850
+ return now.length ? compareRead(readOfBlocks(before, lang), readOfBlocks(now, lang)) : { result: "failed", before: [], now: [] };
825
851
  }
826
852
  /** A reading of a document again against the earlier one, as compareReadings (its people and events). */
827
853
  export function compareDocuments(before, now) {
@@ -834,8 +860,11 @@ function compareRead(a, b) {
834
860
  const now = [...b.facts].filter(([k]) => !a.words.has(k)).map(([, w]) => w);
835
861
  if (before.length)
836
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);
837
866
  const surer = b.unclear < a.unclear || (b.found && !a.found);
838
- return { result: now.length || surer ? "refined" : "same", before, now };
867
+ return { result: now.length || maybeNow.length || surer ? "refined" : "same", before, now: [...now, ...maybeNow] };
839
868
  }
840
869
  // ── what waits ──────────────────────────────────────────────────────────────────────────────────────────────────
841
870
  /** The checks that wait for the person's decision here (none in an archive: nobody reads there). */
@@ -256,7 +256,7 @@ async function readAgain(ctx, tree, t, items, model) {
256
256
  const m = byId.get(i.media);
257
257
  const now = reading && m ? blocksOf(tree, reading, m) : [];
258
258
  const before = m ? blocksOf(tree, i.reading, m) : [];
259
- return { item: i, ...compared(compareReadings(before, now)), ...(reading ? { reading } : {}), secs, ...(error ? { error } : {}) };
259
+ return { item: i, ...compared(compareReadings(before, now, tree.lang)), ...(reading ? { reading } : {}), secs, ...(error ? { error } : {}) };
260
260
  });
261
261
  })).flat();
262
262
  }
@@ -548,14 +548,15 @@ register({
548
548
  // who reads, before anything is offered: the agent asked, or with nothing asked, any here
549
549
  if (typeof opts.model === "string" && !opts.model.trim())
550
550
  throw new UsageError("--model names a model", { hint: `${set} --model <model>` });
551
- if (ctx.settings.agent(tree.config).source === "flag" || typeof opts.model === "string" || !readyHere(ctx).length)
552
- requireReady(ctx, tree, set);
553
- personOnly(ctx, t, "Read places of this research again (paid readings)?", set);
554
551
  const first = positive(opts.first, "first");
555
552
  const tryN = positive(opts.try, "try", FIX_TRY);
556
553
  const states = args[0] ? [stateOfCheck(ctx, tree, args[0])] : fixStates(tree, fixEnv(ctx, tree)).filter((s) => s.state === "pending");
557
- if (!states.length)
558
- 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);
559
560
  const results = [];
560
561
  for (const s of states) {
561
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))
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.6";
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.6",
3
+ "version": "1.19.0-beta.7",
4
4
  "description": "Genealogical research toolkit for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {