strom-research 1.20.0 → 1.21.0-beta.2
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 +14 -0
- package/CHANGELOG.de.md +17 -0
- package/CHANGELOG.md +16 -0
- package/assets/lang/cs.json +30 -1
- package/assets/lang/de.json +30 -1
- package/dist/cli/menu-waiting.js +25 -8
- package/dist/cli/menu.js +5 -2
- package/dist/cli/ui.js +30 -1
- package/dist/commands/browse.js +7 -3
- package/dist/commands/intake.js +73 -4
- package/dist/core/live.js +8 -0
- package/dist/core/phone-finds.js +9 -4
- package/dist/core/phone-watch.js +12 -1
- package/dist/core/tree.js +1 -1
- package/dist/core/waiting.js +35 -2
- package/dist/core/widget.js +415 -0
- package/package.json +4 -1
package/CHANGELOG.cs.md
CHANGED
|
@@ -5,6 +5,20 @@
|
|
|
5
5
|
Změny každé verze ve zkratce. Úplné poznámky a instalační řádky jsou na
|
|
6
6
|
[GitHub Releases](https://github.com/ACiDekCZ/strom-research/releases) (anglicky).
|
|
7
7
|
|
|
8
|
+
## 1.21.0
|
|
9
|
+
|
|
10
|
+
*Připravuje se: bety do 1.21.0-beta.2. Oddíl bude úplný s vydáním.*
|
|
11
|
+
|
|
12
|
+
- **„Čeká“ říká, co aplikace Strom neukazuje.** Řádek v menu a `strom plan` řekne, kolik z čekajícího je jen tady
|
|
13
|
+
v terminálu a kolik čeká na odpověď; zbytek je číslo, které ukazuje aplikace Strom, takže se obě shodují.
|
|
14
|
+
- **„Co čeká“ dává vlastní věci terminálu na začátek.** Co ukazuje jen terminál (rozpor vlastní úpravy, odeslání
|
|
15
|
+
z aplikace, otázka ke čtení skenů), je pod „Jen tady v terminálu“, zbytek pod „I v aplikaci Strom“.
|
|
16
|
+
- **`strom input assign`, `hide` a `unskip` řeknou, co se změnilo**, v jazyce výzkumu: každý soubor, každý
|
|
17
|
+
uvolněný nebo nový úkol, lidi jménem a jak změnu vrátit. Jejich JSON zůstává stejný.
|
|
18
|
+
- **Přehled výzkumu pro widget na ploše macOS.** Dokud běží most (`strom live`), drží se přehled každého stromu
|
|
19
|
+
v souboru pro widget na ploše Macu: co se právě dělá, nálezy za poslední den a co čeká, každý text i bez jmen
|
|
20
|
+
lidí. Mimo macOS se nic nezapisuje.
|
|
21
|
+
|
|
8
22
|
## 1.20.0
|
|
9
23
|
|
|
10
24
|
*25. 10. 2026 · patří k aplikaci Strom 3.16.0.*
|
package/CHANGELOG.de.md
CHANGED
|
@@ -5,6 +5,23 @@
|
|
|
5
5
|
Die Änderungen jeder Version in Kürze. Die vollständigen Hinweise und die Installationszeilen stehen bei
|
|
6
6
|
[GitHub Releases](https://github.com/ACiDekCZ/strom-research/releases) (auf Englisch).
|
|
7
7
|
|
|
8
|
+
## 1.21.0
|
|
9
|
+
|
|
10
|
+
*In Vorbereitung: die Betas bis 1.21.0-beta.2. Der Abschnitt ist mit dem Release vollständig.*
|
|
11
|
+
|
|
12
|
+
- **„Wartet“ sagt, was die Strom-App nicht zeigt.** Die Zeile im Menü und `strom plan` sagt, wie viel des Wartenden
|
|
13
|
+
nur hier im Terminal ist und wie viel auf Antwort wartet; der Rest ist die Zahl, die die Strom-App zeigt, sodass
|
|
14
|
+
beide übereinstimmen.
|
|
15
|
+
- **„Was wartet“ stellt das Eigene des Terminals voran.** Was nur das Terminal zeigt (ein Widerspruch einer eigenen
|
|
16
|
+
Änderung, eine Sendung der App, eine Frage zum Lesen von Scans), steht unter „Nur hier im Terminal“, der Rest unter
|
|
17
|
+
„Auch in der Strom-App“.
|
|
18
|
+
- **`strom input assign`, `hide` und `unskip` sagen, was sich geändert hat**, in der Sprache der Forschung: jede
|
|
19
|
+
Datei, jede freigegebene oder neue Aufgabe, Personen mit Namen und wie es rückgängig geht. Ihr JSON bleibt gleich.
|
|
20
|
+
- **Eine Übersicht der Forschung für ein Widget auf dem macOS-Schreibtisch.** Solange die Brücke läuft
|
|
21
|
+
(`strom live`), wird die Übersicht jedes Stammbaums in einer Datei für ein Widget auf dem Mac-Schreibtisch
|
|
22
|
+
gehalten: was gerade läuft, die Funde des letzten Tages und was wartet, jeder Text auch ohne die Namen von
|
|
23
|
+
Personen. Außerhalb von macOS wird nichts geschrieben.
|
|
24
|
+
|
|
8
25
|
## 1.20.0
|
|
9
26
|
|
|
10
27
|
*25.10.2026 · gehört zur Strom-App 3.16.0.*
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ English · [Čeština](CHANGELOG.cs.md) · [Deutsch](CHANGELOG.de.md)
|
|
|
5
5
|
The changes of each version in short. The full notes and the install lines are on
|
|
6
6
|
[GitHub Releases](https://github.com/ACiDekCZ/strom-research/releases).
|
|
7
7
|
|
|
8
|
+
## 1.21.0
|
|
9
|
+
|
|
10
|
+
*In preparation: the betas up to 1.21.0-beta.2. The section is complete with the release.*
|
|
11
|
+
|
|
12
|
+
- **"Waiting" says what the Strom app does not show.** The line in the menu and `strom plan` says how many of the
|
|
13
|
+
waiting things are only here in the terminal and how many wait for a reply; the rest is the number the Strom app
|
|
14
|
+
shows, so the two agree.
|
|
15
|
+
- **"What waits" puts the terminal's own first.** What only the terminal shows (a conflict of an own edit, a send of
|
|
16
|
+
the app, a question about the reading of scans) is listed under "Only here in the terminal", the rest under
|
|
17
|
+
"Also in the Strom app".
|
|
18
|
+
- **`strom input assign`, `hide` and `unskip` say what changed** in the research's language: each file, each task
|
|
19
|
+
let go of or made, people by name, and how to take it back. Their JSON stays the same.
|
|
20
|
+
- **An overview of the research for a widget on the macOS desktop.** While the bridge runs (`strom live`), each
|
|
21
|
+
tree's overview is kept in a file for a widget on the Mac's desktop: what is at work now, the finds of the last day
|
|
22
|
+
and what waits, each text also without the names of people. Nothing is written off macOS.
|
|
23
|
+
|
|
8
24
|
## 1.20.0
|
|
9
25
|
|
|
10
26
|
*2026-10-25 · goes with the Strom app 3.16.0.*
|
package/assets/lang/cs.json
CHANGED
|
@@ -90,6 +90,9 @@
|
|
|
90
90
|
"ui.menu.title": "Strom výzkum {version}",
|
|
91
91
|
"ui.menu.tree": "Rodokmen: {name} · osob: {persons}",
|
|
92
92
|
"ui.menu.waiting": "Čeká: {count}",
|
|
93
|
+
"ui.waiting.split.reply": "{n, plural, one {# čeká} few {# čekají} other {# čeká}} na odpověď",
|
|
94
|
+
"ui.waiting.split.here": "{n} jen tady v terminálu",
|
|
95
|
+
"ui.waiting.split": "{n} (z toho {parts})",
|
|
93
96
|
"ui.menu.working": "Agent právě pracuje samostatně.",
|
|
94
97
|
"ui.menu.chat": "Pokračovat ve výzkumu s agentem",
|
|
95
98
|
"ui.menu.start": "Začít výzkum s agentem",
|
|
@@ -448,6 +451,8 @@
|
|
|
448
451
|
"ui.wait.how1": "snímky patří do schránky (inbox), pro každou knihu vlastní složka, každý pojmenovaný číslem snímku, jak ho počítá prohlížeč",
|
|
449
452
|
"ui.wait.how2": "(9.jpg; přiblížená část: 9a.jpg) – další běh je zaregistruje a úkol se sám vrátí do fronty",
|
|
450
453
|
"ui.wait.todo.n": "k vyřízení {n}",
|
|
454
|
+
"ui.wait.app.head": "I v aplikaci Strom · {n}",
|
|
455
|
+
"ui.wait.only.head": "Jen tady v terminálu (v aplikaci Strom nejsou) · {n}",
|
|
451
456
|
"ui.wait.sent.n": "čeká na odpověď {n}",
|
|
452
457
|
"ui.wait.sent.title": "Čeká na odpověď",
|
|
453
458
|
"ui.wait.filter": "filtr: {section}",
|
|
@@ -1368,6 +1373,12 @@
|
|
|
1368
1373
|
"ui.phone.removed": "telefon {name} je odebraný ze spárovaných",
|
|
1369
1374
|
"ui.phone.ended": "server telefonu skončil",
|
|
1370
1375
|
"ui.phone.waiting.item": "Něco k vyřízení na počítači",
|
|
1376
|
+
"ui.widget.person": "osoba",
|
|
1377
|
+
"ui.widget.family": "rodina",
|
|
1378
|
+
"ui.widget.up": "Předci osoby{span}",
|
|
1379
|
+
"ui.widget.down": "Potomci osoby{span}",
|
|
1380
|
+
"ui.widget.one": "Osoba{span}",
|
|
1381
|
+
"ui.widget.question": "Otázka výzkumu",
|
|
1371
1382
|
"ui.phone.qr": "QR kód ke spárování telefonu (jeden telefon, do {time}): fotoaparátem telefonu ve stejné Wi-Fi jako tento počítač.",
|
|
1372
1383
|
"ui.phone.qr.url": "adresa pod kódem: {url}",
|
|
1373
1384
|
"ui.phone.qr.more": "další adresa tohoto počítače: strom phone qr --address {url}",
|
|
@@ -1633,7 +1644,7 @@
|
|
|
1633
1644
|
"ui.plan.images": " (nejdřív potřebuje snímky)",
|
|
1634
1645
|
"ui.plan.more": "… a další úkoly: {n}",
|
|
1635
1646
|
"ui.plan.stories": "Potom vyprávění: {n} ({names})",
|
|
1636
|
-
"ui.plan.waiting": "Čeká: {
|
|
1647
|
+
"ui.plan.waiting": "Čeká: {count} · v menu: Co čeká",
|
|
1637
1648
|
"ui.plan.none": "Fronta je prázdná – nové úkoly agent navrhne po dalším sezení, nebo mu jde v rozhovoru říct, co hledat.",
|
|
1638
1649
|
"ui.plan.steer": "Pořadí se mění v rozhovoru s agentem: stačí mu říct, co má přednost.",
|
|
1639
1650
|
"ui.plan.session": "sezení z {day}",
|
|
@@ -1869,6 +1880,24 @@
|
|
|
1869
1880
|
"ui.intake.scans.archive": "Ve složce {folder} {n, plural, one {je # obrázek. Uložit ho do Archivu Stromu jako rodinný dokument?} few {jsou # obrázky. Uložit je do Archivu Stromu jako rodinné dokumenty?} other {je # obrázků. Uložit je do Archivu Stromu jako rodinné dokumenty?}}",
|
|
1870
1881
|
"ui.intake.book.archive": "{folder}: neuloženo.",
|
|
1871
1882
|
"ui.intake.known": "Tyhle podklady už ve výzkumu jsou – nic nepřibylo.",
|
|
1883
|
+
"ui.input.assign.add": "{id} – nově patří k: {who}",
|
|
1884
|
+
"ui.input.assign.remove": "{id} – už nepatří k: {who}",
|
|
1885
|
+
"ui.input.batch.add": "dávka {name} – nově patří k: {who}",
|
|
1886
|
+
"ui.input.batch.remove": "dávka {name} – už nepatří k: {who}",
|
|
1887
|
+
"ui.input.task.of": "úkol {task} – nově se týká i: {who}",
|
|
1888
|
+
"ui.input.assign.kept": "{id}: {who} zůstává – patří k celé dávce (odebrat z dávky: strom input assign --batch {batch} --remove {person})",
|
|
1889
|
+
"ui.input.assign.none": "Nic ke změně.",
|
|
1890
|
+
"ui.input.hidden": "{id} skryto",
|
|
1891
|
+
"ui.input.why.unrelated": "nic z rodiny",
|
|
1892
|
+
"ui.input.why.duplicate": "totéž jako jiný soubor",
|
|
1893
|
+
"ui.input.task.without": "úkol {task} – bez {files} (skryto)",
|
|
1894
|
+
"ui.input.task.dropped": "úkol {task} zrušen – jeho soubory jsou skryté ({files})",
|
|
1895
|
+
"ui.input.hide.back": "zobrazit znovu: strom input unskip {ids}",
|
|
1896
|
+
"ui.input.hide.none": "Už skryto – nic se nezměnilo.",
|
|
1897
|
+
"ui.input.shown.new": "{id} znovu zobrazeno (čeká na roztřídění)",
|
|
1898
|
+
"ui.input.shown.processed": "{id} znovu zobrazeno (zpracováno)",
|
|
1899
|
+
"ui.input.task.gather": "úkol {task}: {what}",
|
|
1900
|
+
"ui.input.unskip.none": "Nic skrytého – nic se nezměnilo.",
|
|
1872
1901
|
"ui.intake.done": "✓ Přidáno do výzkumu: {n}. Agent to projde při další práci.",
|
|
1873
1902
|
"ui.intake.done.archive": "✓ Uloženo do Archivu Stromu: {n}.",
|
|
1874
1903
|
"ui.intake.done.waiting": "✓ Uloženo do výzkumu: {n}. Agent to projde, až bude na počítači.",
|
package/assets/lang/de.json
CHANGED
|
@@ -90,6 +90,9 @@
|
|
|
90
90
|
"ui.menu.title": "Strom Ahnenforschung {version}",
|
|
91
91
|
"ui.menu.tree": "Stammbaum: {name} · Personen: {persons}",
|
|
92
92
|
"ui.menu.waiting": "Wartet: {count}",
|
|
93
|
+
"ui.waiting.split.reply": "{n, plural, one {# wartet} other {# warten}} auf Antwort",
|
|
94
|
+
"ui.waiting.split.here": "{n} nur hier im Terminal",
|
|
95
|
+
"ui.waiting.split": "{n} (davon {parts})",
|
|
93
96
|
"ui.menu.working": "Der Agent arbeitet gerade selbstständig.",
|
|
94
97
|
"ui.menu.chat": "Die Forschung mit dem Agenten fortsetzen",
|
|
95
98
|
"ui.menu.start": "Die Forschung mit dem Agenten beginnen",
|
|
@@ -448,6 +451,8 @@
|
|
|
448
451
|
"ui.wait.how1": "Scans kommen in den Eingang (inbox), ein Ordner pro Buch, jeder benannt nach der Bildnummer, wie der Viewer sie zählt",
|
|
449
452
|
"ui.wait.how2": "(9.jpg; ein vergrößerter Ausschnitt: 9a.jpg) – der nächste Lauf registriert sie und die Aufgabe kehrt von selbst zurück",
|
|
450
453
|
"ui.wait.todo.n": "zu erledigen {n}",
|
|
454
|
+
"ui.wait.app.head": "Auch in der Strom-App · {n}",
|
|
455
|
+
"ui.wait.only.head": "Nur hier im Terminal (nicht in der Strom-App) · {n}",
|
|
451
456
|
"ui.wait.sent.n": "wartet auf Antwort {n}",
|
|
452
457
|
"ui.wait.sent.title": "Wartet auf Antwort",
|
|
453
458
|
"ui.wait.filter": "Filter: {section}",
|
|
@@ -1368,6 +1373,12 @@
|
|
|
1368
1373
|
"ui.phone.removed": "das Telefon {name} ist von den gekoppelten entfernt",
|
|
1369
1374
|
"ui.phone.ended": "der Server des Telefons ist beendet",
|
|
1370
1375
|
"ui.phone.waiting.item": "Etwas am Computer zu erledigen",
|
|
1376
|
+
"ui.widget.person": "Person",
|
|
1377
|
+
"ui.widget.family": "Familie",
|
|
1378
|
+
"ui.widget.up": "Vorfahren einer Person{span}",
|
|
1379
|
+
"ui.widget.down": "Nachkommen einer Person{span}",
|
|
1380
|
+
"ui.widget.one": "Eine Person{span}",
|
|
1381
|
+
"ui.widget.question": "Eine Frage der Forschung",
|
|
1371
1382
|
"ui.phone.qr": "Ein QR-Code zum Koppeln eines Telefons (ein Telefon, bis {time}): mit der Kamera des Telefons im selben WLAN wie dieser Computer.",
|
|
1372
1383
|
"ui.phone.qr.url": "die Adresse unter dem Code: {url}",
|
|
1373
1384
|
"ui.phone.qr.more": "eine weitere Adresse dieses Computers: strom phone qr --address {url}",
|
|
@@ -1633,7 +1644,7 @@
|
|
|
1633
1644
|
"ui.plan.images": " (braucht zuerst die Scans)",
|
|
1634
1645
|
"ui.plan.more": "… und {n, plural, one {# weitere Aufgabe} other {# weitere Aufgaben}}",
|
|
1635
1646
|
"ui.plan.stories": "Danach die Geschichten: {n} ({names})",
|
|
1636
|
-
"ui.plan.waiting": "Wartet: {
|
|
1647
|
+
"ui.plan.waiting": "Wartet: {count} · im Menü: Was wartet",
|
|
1637
1648
|
"ui.plan.none": "Die Warteschlange ist leer – neue Aufgaben schlägt der Agent nach der nächsten Sitzung vor, oder ihm im Gespräch sagen, was er suchen soll.",
|
|
1638
1649
|
"ui.plan.steer": "Die Reihenfolge ändert sich im Gespräch mit dem Agenten: ihm sagen, was Vorrang hat.",
|
|
1639
1650
|
"ui.plan.session": "Sitzung vom {day}",
|
|
@@ -1869,6 +1880,24 @@
|
|
|
1869
1880
|
"ui.intake.scans.archive": "Im Ordner {folder} {n, plural, one {ist # Bild. Als Familiendokument im Strom-Archiv speichern?} other {sind # Bilder. Als Familiendokumente im Strom-Archiv speichern?}}",
|
|
1870
1881
|
"ui.intake.book.archive": "{folder}: nicht gespeichert.",
|
|
1871
1882
|
"ui.intake.known": "Diese Unterlagen sind schon in der Forschung – nichts hinzugefügt.",
|
|
1883
|
+
"ui.input.assign.add": "{id} – gehört jetzt auch zu: {who}",
|
|
1884
|
+
"ui.input.assign.remove": "{id} – gehört nicht mehr zu: {who}",
|
|
1885
|
+
"ui.input.batch.add": "Stapel {name} – gehört jetzt auch zu: {who}",
|
|
1886
|
+
"ui.input.batch.remove": "Stapel {name} – gehört nicht mehr zu: {who}",
|
|
1887
|
+
"ui.input.task.of": "Aufgabe {task} – betrifft jetzt auch: {who}",
|
|
1888
|
+
"ui.input.assign.kept": "{id}: {who} bleibt – gehört zum ganzen Stapel (vom Stapel entfernen: strom input assign --batch {batch} --remove {person})",
|
|
1889
|
+
"ui.input.assign.none": "Nichts zu ändern.",
|
|
1890
|
+
"ui.input.hidden": "{id} ausgeblendet",
|
|
1891
|
+
"ui.input.why.unrelated": "nichts von der Familie",
|
|
1892
|
+
"ui.input.why.duplicate": "dasselbe wie eine andere Datei",
|
|
1893
|
+
"ui.input.task.without": "Aufgabe {task} – ohne {files} (ausgeblendet)",
|
|
1894
|
+
"ui.input.task.dropped": "Aufgabe {task} aufgegeben – ihre Dateien sind ausgeblendet ({files})",
|
|
1895
|
+
"ui.input.hide.back": "wieder zeigen: strom input unskip {ids}",
|
|
1896
|
+
"ui.input.hide.none": "Bereits ausgeblendet – nichts geändert.",
|
|
1897
|
+
"ui.input.shown.new": "{id} wieder sichtbar (wartet auf Sortierung)",
|
|
1898
|
+
"ui.input.shown.processed": "{id} wieder sichtbar (verarbeitet)",
|
|
1899
|
+
"ui.input.task.gather": "Aufgabe {task}: {what}",
|
|
1900
|
+
"ui.input.unskip.none": "Nichts ausgeblendet – nichts geändert.",
|
|
1872
1901
|
"ui.intake.done": "✓ Zur Forschung hinzugefügt: {n}. Der Agent sieht es bei der nächsten Arbeit durch.",
|
|
1873
1902
|
"ui.intake.done.archive": "✓ Im Strom-Archiv gespeichert: {n}.",
|
|
1874
1903
|
"ui.intake.done.waiting": "✓ In der Forschung aufbewahrt: {n}. Der Agent sieht es durch, sobald er auf diesem Computer ist.",
|
package/dist/cli/menu-waiting.js
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// an office, one message to the family. What is to do apart from what waits for a reply from outside (sent).
|
|
4
4
|
//
|
|
5
5
|
// the overview the places of what is to do, a page of 15 at a time (next, previous, a range, all), filtered by
|
|
6
|
-
// section, "?" for what waits for a reply; a number opens a place — a place of one thing that thing
|
|
6
|
+
// section, "?" for what waits for a reply; a number opens a place — a place of one thing that thing;
|
|
7
|
+
// what the Strom app does not show (core/waiting.ts terminalOnly) first, under a heading of its own,
|
|
8
|
+
// the app's after it under theirs
|
|
7
9
|
// a place its things, then for all of them at once: the request texts joined, sent, a conversation
|
|
8
10
|
// a thing what it is and waits for, then only what makes sense for its kind: the archive or the site, the
|
|
9
11
|
// folder for images saved by hand, the request's text, sent (or back among what is to do), an answer
|
|
@@ -26,7 +28,7 @@ import { claimOf, isEditConflict, sideText } from "../core/conflicts.js";
|
|
|
26
28
|
import { openForUser } from "../core/open.js";
|
|
27
29
|
import { isArchive } from "../core/mode.js";
|
|
28
30
|
import { listConnectors } from "../core/connector.js";
|
|
29
|
-
import { requestFile, requestTexts, waitingFor } from "../core/waiting.js";
|
|
31
|
+
import { inApp, requestFile, requestTexts, waitingFor } from "../core/waiting.js";
|
|
30
32
|
import { SENDABLE } from "../core/waitkind.js";
|
|
31
33
|
/** A person for the person reading: "Jan Novák (*1905) [P0001]". */
|
|
32
34
|
const label = (p) => `${displayName(p)}${lifespan(p) ? ` (${lifespan(p)})` : ""} [${p.id}]`;
|
|
@@ -87,16 +89,25 @@ function short(tree, lang, w) {
|
|
|
87
89
|
}
|
|
88
90
|
const sentAtOf = (w) => String(w.item.sentAt ?? w.item.at);
|
|
89
91
|
/** The places of a list, in its order (the items of one place stand together). */
|
|
90
|
-
function placesOf(items) {
|
|
92
|
+
function placesOf(items, only = false) {
|
|
91
93
|
const by = new Map();
|
|
92
94
|
for (const w of items) {
|
|
93
95
|
let p = by.get(w.item.group.key);
|
|
94
96
|
if (!p)
|
|
95
|
-
by.set(w.item.group.key, (p = { key: w.item.group.key, name: w.item.group.name, items: [] }));
|
|
97
|
+
by.set(w.item.group.key, (p = { key: w.item.group.key, name: w.item.group.name, items: [], only }));
|
|
96
98
|
p.items.push(w);
|
|
97
99
|
}
|
|
98
100
|
return [...by.values()];
|
|
99
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* The places of the overview: when something is only here in the terminal, its places first, apart from those the
|
|
104
|
+
* Strom app shows too (a place with both — an archive's milder question beside its tasks — in each part with its own);
|
|
105
|
+
* else the places as they come.
|
|
106
|
+
*/
|
|
107
|
+
function overviewPlaces(items) {
|
|
108
|
+
const here = items.filter((w) => !inApp(w));
|
|
109
|
+
return here.length ? [...placesOf(here, true), ...placesOf(items.filter(inApp))] : placesOf(items);
|
|
110
|
+
}
|
|
100
111
|
/** A place's line on the overview: the thing itself when it is one, else what is there by kind. */
|
|
101
112
|
function placeDetail(t, lang, tree, p, phase) {
|
|
102
113
|
const one = p.items.length === 1 ? truncate(short(tree, lang, p.items[0]), 60) : undefined;
|
|
@@ -144,7 +155,9 @@ export async function waitingForYou(ctx, run, lang, root) {
|
|
|
144
155
|
counts.set(w.item.section, (counts.get(w.item.section) ?? 0) + 1);
|
|
145
156
|
if (filter && !counts.get(filter))
|
|
146
157
|
filter = undefined;
|
|
147
|
-
const places =
|
|
158
|
+
const places = overviewPlaces(filter ? here.filter((w) => w.item.section === filter) : here);
|
|
159
|
+
// the part only here in the terminal and the part the Strom app shows too, each with its heading
|
|
160
|
+
const split = places.some((p) => p.only);
|
|
148
161
|
const pages = Math.max(1, Math.ceil(places.length / PAGE));
|
|
149
162
|
page = Math.min(page, pages - 1);
|
|
150
163
|
const [from, to] = view === "all" ? [0, places.length] : view ? [view.from, Math.min(view.to, places.length)] : [page * PAGE, Math.min(places.length, (page + 1) * PAGE)];
|
|
@@ -166,6 +179,10 @@ export async function waitingForYou(ctx, run, lang, root) {
|
|
|
166
179
|
const width = Math.max(0, ...names.map((n) => [...n].length));
|
|
167
180
|
for (const [k, p] of shown.entries()) {
|
|
168
181
|
const name = names[k];
|
|
182
|
+
if (split && (k === 0 || shown[k - 1].only !== p.only)) {
|
|
183
|
+
const n = places.filter((x) => x.only === p.only).reduce((a, x) => a + x.items.length, 0);
|
|
184
|
+
screen.push(` ${t(p.only ? "ui.wait.only.head" : "ui.wait.app.head", { n })}`);
|
|
185
|
+
}
|
|
169
186
|
screen.push(`${String(from + k + 1).padStart(2)} ${name}${" ".repeat(width - [...name].length)} ${placeDetail(t, lang, tree, p, phase)}`);
|
|
170
187
|
}
|
|
171
188
|
// what can be typed here, only what does something now
|
|
@@ -247,7 +264,7 @@ export async function waitingForYou(ctx, run, lang, root) {
|
|
|
247
264
|
if (/^\d+$/u.test(a) && Number(a) >= 1 && Number(a) <= places.length) {
|
|
248
265
|
const p = places[Number(a) - 1];
|
|
249
266
|
// a place of one: that thing at once
|
|
250
|
-
await guarded(ctx, lang, async () => (p.items.length === 1 ? thing(ctx, run, lang, root, p.items[0]) : place(ctx, run, lang, root, p.key, phase, filter)));
|
|
267
|
+
await guarded(ctx, lang, async () => (p.items.length === 1 ? thing(ctx, run, lang, root, p.items[0]) : place(ctx, run, lang, root, p.key, phase, filter, split ? p.only : undefined)));
|
|
251
268
|
break;
|
|
252
269
|
}
|
|
253
270
|
out(controlLine);
|
|
@@ -255,14 +272,14 @@ export async function waitingForYou(ctx, run, lang, root) {
|
|
|
255
272
|
}
|
|
256
273
|
}
|
|
257
274
|
/** One place: its things, then for all of them at once — the request texts joined, sent, a conversation. */
|
|
258
|
-
async function place(ctx, run, lang, root, key, phase, filter) {
|
|
275
|
+
async function place(ctx, run, lang, root, key, phase, filter, only) {
|
|
259
276
|
const t = translator(lang);
|
|
260
277
|
const out = (line = "") => ctx.io.stdout(line + "\n");
|
|
261
278
|
for (;;) {
|
|
262
279
|
if (outOfAnswers(ctx))
|
|
263
280
|
return;
|
|
264
281
|
const tree = Tree.open(root, ctx.env);
|
|
265
|
-
const items = waitingHere(ctx, tree).filter((w) => w.item.group.key === key && w.item.phase === phase && (!filter || w.item.section === filter));
|
|
282
|
+
const items = waitingHere(ctx, tree).filter((w) => w.item.group.key === key && w.item.phase === phase && (!filter || w.item.section === filter) && (only === undefined || only === !inApp(w)));
|
|
266
283
|
if (!items.length)
|
|
267
284
|
return;
|
|
268
285
|
const name = items[0].item.group.name;
|
package/dist/cli/menu.js
CHANGED
|
@@ -43,6 +43,7 @@ import { pathOf, startAgain } from "../core/userpath.js";
|
|
|
43
43
|
import { offerAgent } from "./fixes.js";
|
|
44
44
|
import { addToResearch } from "./menu-research.js";
|
|
45
45
|
import { waitingForYou, waitingHere } from "./menu-waiting.js";
|
|
46
|
+
import { waitingLine } from "../core/waiting.js";
|
|
46
47
|
import { permissionWaitingLines } from "../core/archiveasks.js";
|
|
47
48
|
import { settingsMenu } from "./menu-settings.js";
|
|
48
49
|
import { mainPerson } from "../core/kin.js";
|
|
@@ -177,7 +178,8 @@ export async function runMenu(ctx, run) {
|
|
|
177
178
|
}
|
|
178
179
|
// what waits for the person: the same list What waits shows (each thing once — a task that waits for an archive's
|
|
179
180
|
// decision is one of its question's)
|
|
180
|
-
const
|
|
181
|
+
const waits = waitingHere(ctx, tree);
|
|
182
|
+
const waiting = waits.length;
|
|
181
183
|
const started = tree.list("research").length > 0;
|
|
182
184
|
out(t("ui.menu.tree", { name: tree.config.name, persons: tree.countLive("person") }));
|
|
183
185
|
// No agent yet (none installed, or not the one chosen): the research waits for it — kept as an archive meanwhile,
|
|
@@ -201,8 +203,9 @@ export async function runMenu(ctx, run) {
|
|
|
201
203
|
out();
|
|
202
204
|
}
|
|
203
205
|
}
|
|
206
|
+
// what of it the Strom app does not show (only here in the terminal) said, so its number and the app's agree
|
|
204
207
|
if (waiting)
|
|
205
|
-
out(
|
|
208
|
+
out(waitingLine(lang, waits));
|
|
206
209
|
// an archive's images wait for the person's decision: one line, only until it is answered
|
|
207
210
|
// (since when, and the tasks that stand on it, once it waits long or holds up much)
|
|
208
211
|
for (const line of permissionWaitingLines(tree, lang))
|
package/dist/cli/ui.js
CHANGED
|
@@ -63,6 +63,9 @@ export const UI = {
|
|
|
63
63
|
"ui.menu.title": "Strom research {version}",
|
|
64
64
|
"ui.menu.tree": "Family tree: {name} · people: {persons}",
|
|
65
65
|
"ui.menu.waiting": "Waiting: {count}",
|
|
66
|
+
"ui.waiting.split": "{n} (of these {parts})",
|
|
67
|
+
"ui.waiting.split.here": "{n} only here in the terminal",
|
|
68
|
+
"ui.waiting.split.reply": "{n} waiting for a reply",
|
|
66
69
|
"ui.menu.working": "The agent is working on its own right now.",
|
|
67
70
|
"ui.menu.chat": "Continue the research with the agent",
|
|
68
71
|
"ui.menu.start": "Start the research with the agent",
|
|
@@ -425,6 +428,8 @@ export const UI = {
|
|
|
425
428
|
"ui.wait.how1": "scans go into the inbox, a folder per book, each named by its image number as the viewer counts it",
|
|
426
429
|
"ui.wait.how2": "(9.jpg; a zoomed-in part of it: 9a.jpg) — the next run registers them and the task comes back by itself",
|
|
427
430
|
"ui.wait.todo.n": "to do {n}",
|
|
431
|
+
"ui.wait.only.head": "Only here in the terminal (not in the Strom app) · {n}",
|
|
432
|
+
"ui.wait.app.head": "Also in the Strom app · {n}",
|
|
428
433
|
"ui.wait.sent.n": "waiting for a reply {n}",
|
|
429
434
|
"ui.wait.sent.title": "Waiting for a reply",
|
|
430
435
|
"ui.wait.filter": "filter: {section}",
|
|
@@ -1357,6 +1362,12 @@ export const UI = {
|
|
|
1357
1362
|
"ui.phone.removed": "the phone {name} is taken off the paired ones",
|
|
1358
1363
|
"ui.phone.ended": "the phone's server ended",
|
|
1359
1364
|
"ui.phone.waiting.item": "Something to see to on the computer",
|
|
1365
|
+
"ui.widget.person": "person",
|
|
1366
|
+
"ui.widget.family": "family",
|
|
1367
|
+
"ui.widget.up": "Ancestors of a person{span}",
|
|
1368
|
+
"ui.widget.down": "Descendants of a person{span}",
|
|
1369
|
+
"ui.widget.one": "A person{span}",
|
|
1370
|
+
"ui.widget.question": "A question of the research",
|
|
1360
1371
|
"ui.phone.qr": "A QR code to pair a phone (one phone, until {time}): the phone's camera, on the same Wi-Fi as this computer.",
|
|
1361
1372
|
"ui.phone.qr.url": "the address under the code: {url}",
|
|
1362
1373
|
"ui.phone.qr.more": "another address of this computer: strom phone qr --address {url}",
|
|
@@ -1622,7 +1633,7 @@ export const UI = {
|
|
|
1622
1633
|
"ui.plan.images": " (needs the scans first)",
|
|
1623
1634
|
"ui.plan.more": "… and {n, plural, one {# more task} other {# more tasks}}",
|
|
1624
1635
|
"ui.plan.stories": "Then the stories: {n} ({names})",
|
|
1625
|
-
"ui.plan.waiting": "Waiting: {
|
|
1636
|
+
"ui.plan.waiting": "Waiting: {count} · in the menu: What waits",
|
|
1626
1637
|
"ui.plan.none": "Nothing in the queue — the agent proposes new tasks after its next session, or tell it in a conversation what to look for.",
|
|
1627
1638
|
"ui.plan.steer": "To change the order, tell the agent in a conversation what comes first.",
|
|
1628
1639
|
"ui.plan.session": "the session of {day}",
|
|
@@ -1858,6 +1869,24 @@ export const UI = {
|
|
|
1858
1869
|
"ui.intake.scans.archive": "The folder {folder} holds {n, plural, one {# image. Save it to the Strom archive as a family document?} other {# images. Save them to the Strom archive as family documents?}}",
|
|
1859
1870
|
"ui.intake.book.archive": "{folder}: not saved.",
|
|
1860
1871
|
"ui.intake.known": "This material is in the research already — nothing added.",
|
|
1872
|
+
"ui.input.assign.add": "{id} – now also of: {who}",
|
|
1873
|
+
"ui.input.assign.remove": "{id} – no longer of: {who}",
|
|
1874
|
+
"ui.input.batch.add": "batch {name} – now also of: {who}",
|
|
1875
|
+
"ui.input.batch.remove": "batch {name} – no longer of: {who}",
|
|
1876
|
+
"ui.input.task.of": "task {task} – now also about: {who}",
|
|
1877
|
+
"ui.input.assign.kept": "{id}: {who} stays – the whole batch is of them (off the batch: strom input assign --batch {batch} --remove {person})",
|
|
1878
|
+
"ui.input.assign.none": "Nothing to change.",
|
|
1879
|
+
"ui.input.hidden": "{id} hidden",
|
|
1880
|
+
"ui.input.why.unrelated": "nothing of the family",
|
|
1881
|
+
"ui.input.why.duplicate": "the same as another file",
|
|
1882
|
+
"ui.input.task.without": "task {task} – without {files} (hidden)",
|
|
1883
|
+
"ui.input.task.dropped": "task {task} dropped – its files are hidden ({files})",
|
|
1884
|
+
"ui.input.hide.back": "to show again: strom input unskip {ids}",
|
|
1885
|
+
"ui.input.hide.none": "Hidden already – nothing changed.",
|
|
1886
|
+
"ui.input.shown.new": "{id} shown again (waits to be sorted)",
|
|
1887
|
+
"ui.input.shown.processed": "{id} shown again (processed)",
|
|
1888
|
+
"ui.input.task.gather": "task {task}: {what}",
|
|
1889
|
+
"ui.input.unskip.none": "Nothing hidden – nothing changed.",
|
|
1861
1890
|
"ui.intake.done": "✓ Added to the research: {n}. The agent goes through it in its next work.",
|
|
1862
1891
|
"ui.intake.done.archive": "✓ Saved to the Strom archive: {n}.",
|
|
1863
1892
|
"ui.intake.done.waiting": "✓ Kept in the research: {n}. The agent goes through it once it is on this computer.",
|
package/dist/commands/browse.js
CHANGED
|
@@ -17,7 +17,9 @@ import { lacksImages } from "../core/queue.js";
|
|
|
17
17
|
import { forStory } from "../core/stories.js";
|
|
18
18
|
import { isArchive } from "../core/mode.js";
|
|
19
19
|
import { actsAsArchive, stateOf } from "../core/state.js";
|
|
20
|
-
import { rankedQueue
|
|
20
|
+
import { rankedQueue } from "./tasks.js";
|
|
21
|
+
import { waitingHere } from "../cli/menu-waiting.js";
|
|
22
|
+
import { waitingLine } from "../core/waiting.js";
|
|
21
23
|
import { readTuneLog } from "../core/tune.js";
|
|
22
24
|
import { calibrationLabel } from "../core/viewsizes.js";
|
|
23
25
|
/** A change strom made by itself in the reading of scans, as a person reads it in strom recent. */
|
|
@@ -205,7 +207,9 @@ register({
|
|
|
205
207
|
// the stories, with the searches beyond the registers before them
|
|
206
208
|
const work = queue.filter((t) => !forStory(t));
|
|
207
209
|
const stories = queue.filter(forStory);
|
|
208
|
-
|
|
210
|
+
// what waits for the person: the menu's What waits, what of it is only here in the terminal said (core/waiting.ts)
|
|
211
|
+
const waits = waitingHere(ctx, tree);
|
|
212
|
+
const waiting = waits.length;
|
|
209
213
|
const shown = work.slice(0, count);
|
|
210
214
|
let n = 0;
|
|
211
215
|
const rows = shown.map((t) => [
|
|
@@ -225,7 +229,7 @@ register({
|
|
|
225
229
|
out.push(ui(lang, "ui.plan.stories", { n: stories.length, names: storyNames.join(", ") }));
|
|
226
230
|
}
|
|
227
231
|
if (waiting)
|
|
228
|
-
out.push(
|
|
232
|
+
out.push(waitingLine(lang, waits, "ui.plan.waiting"));
|
|
229
233
|
if (queue.length)
|
|
230
234
|
out.push("", ui(lang, "ui.plan.steer"));
|
|
231
235
|
const item = (t) => ({ id: t.id, what: humanTask(tree, t.what, lang), level: t.level, state: t.state, needsImages: lacksImages(tree, t) });
|
package/dist/commands/intake.js
CHANGED
|
@@ -14,7 +14,7 @@ import { collectFiles, fileSha256, inputPath, MAX_IN_TREE, mimeOf, storeShared }
|
|
|
14
14
|
import { create, normId, requireRecord, update } from "../core/records.js";
|
|
15
15
|
import { phrase } from "../core/phrases.js";
|
|
16
16
|
import { addMarriedNames, importedTree, importGedcom, importStromJson, isStromJson, marriedNamesPlan, undoMarriedNames } from "../core/import.js";
|
|
17
|
-
import { gedcomName, likelyDuplicates } from "../core/people.js";
|
|
17
|
+
import { displayName, gedcomName, likelyDuplicates } from "../core/people.js";
|
|
18
18
|
import { decodeText, safeFolderName } from "../core/text.js";
|
|
19
19
|
import { amendInput, checkOriginalMeta } from "../core/originals.js";
|
|
20
20
|
import { batchStatus, closeBatch, listBatches, readBatch } from "../core/batches.js";
|
|
@@ -61,6 +61,69 @@ function idsGiven(ctx, args, opts) {
|
|
|
61
61
|
return ids;
|
|
62
62
|
}
|
|
63
63
|
/** The research the inputs are for: the one named, else the only active one. */
|
|
64
|
+
/** A person for the person reading, as the material names them: "Jan Novák [P0002]" (an ID the research lacks as given). */
|
|
65
|
+
function whoOf(tree, ids) {
|
|
66
|
+
return ids
|
|
67
|
+
.map((id) => {
|
|
68
|
+
const p = tree.get(id);
|
|
69
|
+
return p ? `${displayName(p)} [${p.id}]` : id;
|
|
70
|
+
})
|
|
71
|
+
.join(", ");
|
|
72
|
+
}
|
|
73
|
+
/** The tasks' files before a write: what a task let go of is said by its files (strom input hide). */
|
|
74
|
+
function filesOfTasks(tree) {
|
|
75
|
+
return new Map(tree.list("task").map((t) => [t.id, t.where.filter((w) => /^I\d/u.test(w))]));
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* What strom input assign|hide|unskip changed, in the research language (the person reads it — the menu, the Strom
|
|
79
|
+
* app's bridge, an agent too): each file, each task, each batch, from what this write logged and how the records are
|
|
80
|
+
* now. The history keeps its own words.
|
|
81
|
+
*/
|
|
82
|
+
function materialLines(tree, before, batch) {
|
|
83
|
+
const lang = tree.lang;
|
|
84
|
+
const out = [];
|
|
85
|
+
for (const o of tree.written) {
|
|
86
|
+
const [id, ...rest] = o.targets;
|
|
87
|
+
if (o.op === "input.assign" && id) {
|
|
88
|
+
const i = tree.get(id);
|
|
89
|
+
const now = new Set([...(i?.persons ?? []), ...(i?.sorted?.persons ?? [])]);
|
|
90
|
+
const add = rest.filter((p) => now.has(p));
|
|
91
|
+
const remove = rest.filter((p) => !now.has(p));
|
|
92
|
+
if (add.length)
|
|
93
|
+
out.push(ui(lang, "ui.input.assign.add", { id, who: whoOf(tree, add) }));
|
|
94
|
+
if (remove.length)
|
|
95
|
+
out.push(ui(lang, "ui.input.assign.remove", { id, who: whoOf(tree, remove) }));
|
|
96
|
+
}
|
|
97
|
+
else if (o.op === "batch.assign" && batch) {
|
|
98
|
+
const b = readBatch(tree.root, batch);
|
|
99
|
+
const now = new Set(b?.persons ?? []);
|
|
100
|
+
const name = b?.name ?? batch;
|
|
101
|
+
const add = o.targets.filter((p) => now.has(p));
|
|
102
|
+
const remove = o.targets.filter((p) => !now.has(p));
|
|
103
|
+
if (add.length)
|
|
104
|
+
out.push(ui(lang, "ui.input.batch.add", { name, who: whoOf(tree, add) }));
|
|
105
|
+
if (remove.length)
|
|
106
|
+
out.push(ui(lang, "ui.input.batch.remove", { name, who: whoOf(tree, remove) }));
|
|
107
|
+
}
|
|
108
|
+
else if (o.op === "task.edit" && id && rest.length)
|
|
109
|
+
out.push(ui(lang, "ui.input.task.of", { task: id, who: whoOf(tree, rest) }));
|
|
110
|
+
else if (o.op === "task.edit" && id) {
|
|
111
|
+
const t = tree.get(id);
|
|
112
|
+
const files = (before.get(id) ?? []).filter((f) => !t?.where.includes(f));
|
|
113
|
+
if (files.length)
|
|
114
|
+
out.push(ui(lang, "ui.input.task.without", { task: id, files: files.join(", ") }));
|
|
115
|
+
}
|
|
116
|
+
else if (o.op === "task.drop" && id)
|
|
117
|
+
out.push(ui(lang, "ui.input.task.dropped", { task: id, files: (before.get(id) ?? []).join(", ") }));
|
|
118
|
+
else if (o.op === "input.hide" && id) {
|
|
119
|
+
const why = tree.get(id)?.hidden?.reason;
|
|
120
|
+
out.push(`${ui(lang, "ui.input.hidden", { id })}${why ? ` (${ui(lang, `ui.input.why.${why}`)})` : ""}`);
|
|
121
|
+
}
|
|
122
|
+
else if (o.op === "input.unskip" && id)
|
|
123
|
+
out.push(ui(lang, tree.get(id)?.state === "processed" ? "ui.input.shown.processed" : "ui.input.shown.new", { id }));
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
64
127
|
function researchFor(tree, named) {
|
|
65
128
|
if (typeof named === "string")
|
|
66
129
|
return requireRecord(tree, named, "research").id;
|
|
@@ -340,8 +403,10 @@ register({
|
|
|
340
403
|
run(ctx, { args, opts }) {
|
|
341
404
|
const tree = ctx.tree();
|
|
342
405
|
const ids = idsGiven(ctx, args, opts);
|
|
406
|
+
const before = filesOfTasks(tree);
|
|
343
407
|
const got = tree.withTreeLock(() => unhideMaterial(tree, ids));
|
|
344
|
-
|
|
408
|
+
const tasks = got.tasks.map((id) => ui(tree.lang, "ui.input.task.gather", { task: id, what: tree.get(id)?.what ?? "" }));
|
|
409
|
+
return { text: got.changed.length ? lines(...materialLines(tree, before), ...tasks) : ui(tree.lang, "ui.input.unskip.none"), data: got };
|
|
345
410
|
},
|
|
346
411
|
}, {
|
|
347
412
|
path: ["input", "hide"],
|
|
@@ -364,8 +429,12 @@ register({
|
|
|
364
429
|
if (why !== undefined && !HIDE_REASONS.includes(why))
|
|
365
430
|
throw new UsageError(`--why: ${HIDE_REASONS.join(" or ")}`, { code: "material.body", params: { why: why.slice(0, 40) } });
|
|
366
431
|
const ids = idsGiven(ctx, args, opts);
|
|
432
|
+
const before = filesOfTasks(tree);
|
|
367
433
|
const got = tree.withTreeLock(() => hideMaterial(tree, ids, { reason: why, by: fileBy(tree, ctx.env) }));
|
|
368
|
-
return {
|
|
434
|
+
return {
|
|
435
|
+
text: got.changed.length ? lines(...materialLines(tree, before), ui(tree.lang, "ui.input.hide.back", { ids: got.changed.length > 3 ? "…" : got.changed.join(" ") })) : ui(tree.lang, "ui.input.hide.none"),
|
|
436
|
+
data: got,
|
|
437
|
+
};
|
|
369
438
|
},
|
|
370
439
|
}, {
|
|
371
440
|
path: ["input", "assign"],
|
|
@@ -393,7 +462,7 @@ register({
|
|
|
393
462
|
throw new UsageError("give the files or --batch, not both", { code: "material.body" });
|
|
394
463
|
const got = tree.withTreeLock(() => assignMaterial(tree, { ...(batch ? { batch } : { files: idsGiven(ctx, args, opts) }), add: people(opts.add), remove: people(opts.remove) }));
|
|
395
464
|
return {
|
|
396
|
-
text: lines(got.changed.length ? lines(...tree
|
|
465
|
+
text: lines(got.changed.length ? lines(...materialLines(tree, new Map(), got.batch)) : ui(tree.lang, "ui.input.assign.none"), ...got.kept.map((k) => ui(tree.lang, "ui.input.assign.kept", { id: k.id, who: whoOf(tree, [k.person]), batch: k.batch, person: k.person }))),
|
|
397
466
|
data: got,
|
|
398
467
|
};
|
|
399
468
|
},
|
package/dist/core/live.js
CHANGED
|
@@ -130,6 +130,7 @@ const EXCERPT_MS = 20_000;
|
|
|
130
130
|
/** A source with more excerpts than this is answered without the pictures: the app asks for each (?clip=). */
|
|
131
131
|
const EXCERPTS_AT_ONCE = 12;
|
|
132
132
|
import { anyoneAtWork, liveWorkers } from "./workers.js";
|
|
133
|
+
import { widgetWriter } from "./widget.js";
|
|
133
134
|
import { monthSpend, openSessions } from "./session.js";
|
|
134
135
|
import { rankTasks } from "./queue.js";
|
|
135
136
|
import { humanTask } from "../cli/human.js";
|
|
@@ -3029,6 +3030,11 @@ export function serveLive(root, env) {
|
|
|
3029
3030
|
return atWorkNow;
|
|
3030
3031
|
};
|
|
3031
3032
|
let workers = safe("the start", () => JSON.stringify(atWork(seen))) ?? "";
|
|
3033
|
+
// the research on the desktop of a Mac (the Strom Widget): the tree's overview written while the bridge runs
|
|
3034
|
+
// (core/widget.ts; none off macOS) — the first a moment after the start, never in its way
|
|
3035
|
+
const widget = widgetWriter(root, env);
|
|
3036
|
+
if (widget)
|
|
3037
|
+
setTimeout(() => safe("the widget", () => widget.tick(true)), 1000).unref?.();
|
|
3032
3038
|
const send = (event, data) => {
|
|
3033
3039
|
for (const s of streams)
|
|
3034
3040
|
s.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
@@ -3179,6 +3185,7 @@ export function serveLive(root, env) {
|
|
|
3179
3185
|
// its research gone (taken off the computer, a test's folder): nothing to follow
|
|
3180
3186
|
if (researchGone())
|
|
3181
3187
|
return stop("the research is no longer there", "gone", false);
|
|
3188
|
+
safe("the widget", () => widget?.tick());
|
|
3182
3189
|
if (!streams.size)
|
|
3183
3190
|
return;
|
|
3184
3191
|
safe("a tick", () => {
|
|
@@ -3218,6 +3225,7 @@ export function serveLive(root, env) {
|
|
|
3218
3225
|
clearInterval(timer);
|
|
3219
3226
|
clearInterval(keepAlive);
|
|
3220
3227
|
clearInterval(stopAsked);
|
|
3228
|
+
safe("the widget", () => widget?.end(reason === "gone"));
|
|
3221
3229
|
send("end", { reason, ...(back === undefined ? {} : { back }), at: new Date().toISOString() });
|
|
3222
3230
|
for (const s of streams)
|
|
3223
3231
|
s.end();
|
package/dist/core/phone-finds.js
CHANGED
|
@@ -22,6 +22,7 @@ import { factSaid } from "./changelog.js";
|
|
|
22
22
|
import { conflictTitle, displayName, lifespan } from "./people.js";
|
|
23
23
|
import { ownerName } from "./overview.js";
|
|
24
24
|
import { forPhone } from "./phone-watch.js";
|
|
25
|
+
import { eventName } from "../cli/human.js";
|
|
25
26
|
/** The window when the page asks for none: the last 24 hours. */
|
|
26
27
|
export const FINDS_HOURS = 24;
|
|
27
28
|
/** How far back the page may ask, and how far back a find before the window is looked for. */
|
|
@@ -128,7 +129,7 @@ export function findGroups(tree, ops, lang = tree.lang) {
|
|
|
128
129
|
if (r.type === "person") {
|
|
129
130
|
const span = lifespan(r);
|
|
130
131
|
const title = forPhone(`${displayName(r)}${span ? ` (${span})` : ""}`);
|
|
131
|
-
return title ? { kind: "person", title } : undefined;
|
|
132
|
+
return title ? { kind: "person", title, name: forPhone(displayName(r)), ...(span ? { span } : {}) } : undefined;
|
|
132
133
|
}
|
|
133
134
|
const title = forPhone(ownerName(tree, r.id));
|
|
134
135
|
return title ? { kind: "family", title } : undefined;
|
|
@@ -171,7 +172,7 @@ export function findGroups(tree, ops, lang = tree.lang) {
|
|
|
171
172
|
const span = lifespan(child);
|
|
172
173
|
const text = forPhone(`${displayName(child)}${span ? ` (${span})` : ""}`);
|
|
173
174
|
if (text)
|
|
174
|
-
add(ofRecord(r), { kind: "child", text, at });
|
|
175
|
+
add(ofRecord(r), { kind: "child", text, at, ...(span ? { span } : {}) });
|
|
175
176
|
break;
|
|
176
177
|
}
|
|
177
178
|
case "event.add":
|
|
@@ -182,9 +183,12 @@ export function findGroups(tree, ops, lang = tree.lang) {
|
|
|
182
183
|
const e = r?.events.find((x) => x.id === id);
|
|
183
184
|
if (!r || !e || e.retracted)
|
|
184
185
|
break;
|
|
185
|
-
const
|
|
186
|
+
const raw = factSaid(tree, r.id, e.id, lang) ?? "";
|
|
187
|
+
const said = forPhone(raw);
|
|
186
188
|
if (!said)
|
|
187
189
|
break;
|
|
190
|
+
const name = eventName(e.kind, lang, e.label);
|
|
191
|
+
const fact = { kind: e.kind, name, what: raw.startsWith(`${name}: `) ? forPhone(raw.slice(name.length + 2)) : "" };
|
|
188
192
|
// a line of its own on the phone: its first letter as a sentence's
|
|
189
193
|
const text = said.charAt(0).toLocaleUpperCase(lang) + said.slice(1);
|
|
190
194
|
const kind = o.op === "event.add" ? "fact" : o.op === "event.edit" ? "fixed" : "cited";
|
|
@@ -192,13 +196,14 @@ export function findGroups(tree, ops, lang = tree.lang) {
|
|
|
192
196
|
if (was) {
|
|
193
197
|
// said already: as it is now, at its newest moment
|
|
194
198
|
was.text = text;
|
|
199
|
+
was.fact = fact;
|
|
195
200
|
was.at = at;
|
|
196
201
|
const g = ofRecord(r);
|
|
197
202
|
if (g && at > g.at)
|
|
198
203
|
g.at = at;
|
|
199
204
|
break;
|
|
200
205
|
}
|
|
201
|
-
const line = { kind, text, at };
|
|
206
|
+
const line = { kind, text, at, fact };
|
|
202
207
|
facts.set(`${r.id}:${e.id}`, line);
|
|
203
208
|
add(ofRecord(r), line);
|
|
204
209
|
break;
|
package/dist/core/phone-watch.js
CHANGED
|
@@ -17,6 +17,7 @@ import { history, whoAtWork } from "./live.js";
|
|
|
17
17
|
import { agentsHere } from "./apps.js";
|
|
18
18
|
import { humanTask } from "../cli/human.js";
|
|
19
19
|
import { ui } from "../cli/ui.js";
|
|
20
|
+
import { lifespan } from "./people.js";
|
|
20
21
|
import { ALL_PREFIXES } from "./model.js";
|
|
21
22
|
/** How long the overview of the tree shown is kept (the page asks every 10 s); the marks of the other trees in the switcher. */
|
|
22
23
|
export const OVERVIEW_MS = 15_000;
|
|
@@ -177,7 +178,17 @@ export function treeOverview(root, env) {
|
|
|
177
178
|
const way = r?.direction === "ancestors" ? "up" : r?.direction === "descendants" ? "down" : "other";
|
|
178
179
|
const what = task ? taskText(tree, task) : undefined;
|
|
179
180
|
const name = (r ? forPhone(r.name) : "") || whoAtWork(w.label, tree.lang);
|
|
180
|
-
|
|
181
|
+
const focus = r?.focus ? tree.get(r.focus) : undefined;
|
|
182
|
+
const span = focus?.type === "person" ? lifespan(focus) : "";
|
|
183
|
+
return {
|
|
184
|
+
way,
|
|
185
|
+
name,
|
|
186
|
+
...(what ? { task: what } : {}),
|
|
187
|
+
since: w.since,
|
|
188
|
+
...(w.paused ? { paused: { until: w.paused.until } } : {}),
|
|
189
|
+
...(r ? { direction: r.direction } : {}),
|
|
190
|
+
...(span ? { span } : {}),
|
|
191
|
+
};
|
|
181
192
|
});
|
|
182
193
|
const sessions = tree.list("session");
|
|
183
194
|
const last = sessions.map((s) => s.ended ?? s.started).sort().at(-1);
|
package/dist/core/tree.js
CHANGED
|
@@ -29,7 +29,7 @@ export const TREE_FILE = "strom.json";
|
|
|
29
29
|
export const SEAL_FOREIGN = "seal.foreign";
|
|
30
30
|
// between releases the candidate being made (a bridge run from the sources says what it is: the Strom app goes by it);
|
|
31
31
|
// at a release the release's own (package.json)
|
|
32
|
-
export const VERSION = "1.
|
|
32
|
+
export const VERSION = "1.21.0-beta.2";
|
|
33
33
|
const GITIGNORE = `# Strom Research — local state and large files are not versioned
|
|
34
34
|
.strom/
|
|
35
35
|
/media/
|
package/dist/core/waiting.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// What waits for the person, in one ordered list: the tasks that wait for them, the questions about archives, the new
|
|
2
2
|
// versions of approved stories — and, for the terminal, the trees the Strom app sent and the questions about the reading
|
|
3
3
|
// of scans. The bridge sends it to the Strom app (/status `waiting`, the app's spec ZADANI_VYZKUM_menu-ceka.md); the
|
|
4
|
-
// menu's What waits reads the same
|
|
4
|
+
// menu's What waits reads the same — the terminal's own marked `terminalOnly` (TERMINAL_ONLY), so the menu says what
|
|
5
|
+
// of its count the app does not show (waitingSplit, waitingLine). Computed, nothing written.
|
|
5
6
|
//
|
|
6
7
|
// Each item: its kind (core/waitkind.ts for a task), the section the app shows it in, the place where it is dealt with
|
|
7
8
|
// (its group), todo or sent, its order; for a task its priority, the site, the request's text, whom, the folder for
|
|
@@ -66,6 +67,35 @@ function actionsOf(kind, phase, note) {
|
|
|
66
67
|
: [];
|
|
67
68
|
return all.filter((a) => ACTIONS_OFFERED.includes(a));
|
|
68
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* The options that add what only the terminal shows (the menu's What waits): the bridge never asks for them, so the
|
|
72
|
+
* Strom app never shows them. Each item they add is marked `terminalOnly`; a new kind of the terminal's own goes here.
|
|
73
|
+
*/
|
|
74
|
+
export const TERMINAL_ONLY = ["sends", "tune", "conflicts", "milder"];
|
|
75
|
+
/** Whether the Strom app shows a thing that waits too (its /status `waiting`): everything but the terminal's own. */
|
|
76
|
+
export function inApp(w) {
|
|
77
|
+
return !w.terminalOnly;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* How many things wait, split as the Strom app counts them: all of them (the menu's count), those shown only in the
|
|
81
|
+
* terminal, those in the app that wait for a reply (sent — the app counts only what is to do), and what is to do in
|
|
82
|
+
* the app (its number: all − only here − sent).
|
|
83
|
+
*/
|
|
84
|
+
export function waitingSplit(items) {
|
|
85
|
+
const here = items.filter((w) => !inApp(w)).length;
|
|
86
|
+
const sent = items.filter((w) => inApp(w) && w.item.phase === "sent").length;
|
|
87
|
+
return { all: items.length, here, sent, app: items.length - here - sent };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The line "Waiting: N" for the person: what of it is only here in the terminal and what waits for a reply, each said
|
|
91
|
+
* on its own — the rest is the app's number; as it was only when neither is there (N is the app's number then).
|
|
92
|
+
*/
|
|
93
|
+
export function waitingLine(lang, items, key = "ui.menu.waiting") {
|
|
94
|
+
const s = waitingSplit(items);
|
|
95
|
+
const parts = [...(s.here ? [ui(lang, "ui.waiting.split.here", { n: s.here })] : []), ...(s.sent ? [ui(lang, "ui.waiting.split.reply", { n: s.sent })] : [])];
|
|
96
|
+
const count = parts.length ? ui(lang, "ui.waiting.split", { n: s.all, parts: parts.join(", ") }) : String(s.all);
|
|
97
|
+
return ui(lang, key, { count });
|
|
98
|
+
}
|
|
69
99
|
/** An address a person may open: http(s) only. */
|
|
70
100
|
function webUrl(u) {
|
|
71
101
|
return u && /^https?:\/\//iu.test(u) && URL.canParse(u) ? u : undefined;
|
|
@@ -256,7 +286,7 @@ export function waitingFor(tree, o = {}) {
|
|
|
256
286
|
for (const q of questions.filter((p) => p.kind === "milder" && p.host && p.connector)) {
|
|
257
287
|
const title = q.title ?? q.connector;
|
|
258
288
|
const what = ui(lang, "ui.archive.milder.item", { title, host: q.host, to: levelWords(lang, q.to ?? "allowed"), from: levelWords(lang, q.from ?? "restricted") });
|
|
259
|
-
out.push({ question: q, item: { id: q.key, what, on: "", at: q.at, kind: "milder", section: "decide", group: archiveGroup(tree, q.host, title), phase: "todo", actions: [], host: q.host, connector: q.connector } });
|
|
289
|
+
out.push({ question: q, terminalOnly: true, item: { id: q.key, what, on: "", at: q.at, kind: "milder", section: "decide", group: archiveGroup(tree, q.host, title), phase: "todo", actions: [], host: q.host, connector: q.connector } });
|
|
260
290
|
}
|
|
261
291
|
// a new version of an approved story: whose (a couple's: person and partner), since when
|
|
262
292
|
if (o.stories !== false)
|
|
@@ -291,6 +321,7 @@ export function waitingFor(tree, o = {}) {
|
|
|
291
321
|
for (const r of receivedPending(tree.root))
|
|
292
322
|
out.push({
|
|
293
323
|
send: r,
|
|
324
|
+
terminalOnly: true,
|
|
294
325
|
item: {
|
|
295
326
|
id: r.intake,
|
|
296
327
|
what: ui(lang, "ui.waiting.sent", { day: humanDay(r.at, lang), time: r.at.slice(11, 16), n: r.changes }),
|
|
@@ -308,6 +339,7 @@ export function waitingFor(tree, o = {}) {
|
|
|
308
339
|
for (const c of conflictsToDecide(tree))
|
|
309
340
|
out.push({
|
|
310
341
|
conflict: c,
|
|
342
|
+
terminalOnly: true,
|
|
311
343
|
item: { id: c.id, what: conflictTitle(tree, c), on: "", at: c.created, kind: "conflict", section: "decide", group: { key: "conflicts", name: phrase(lang, "wait.group.conflicts"), kind: "other" }, phase: "todo", actions: [] },
|
|
312
344
|
});
|
|
313
345
|
// the questions about the reading of scans only a person answers
|
|
@@ -315,6 +347,7 @@ export function waitingFor(tree, o = {}) {
|
|
|
315
347
|
for (const q of tuneQuestions(tree, o.tune).due)
|
|
316
348
|
out.push({
|
|
317
349
|
tune: q,
|
|
350
|
+
terminalOnly: true,
|
|
318
351
|
item: { id: q.id, what: q.text, on: q.why, at: q.asked, kind: "tune", section: "decide", group: { key: "tune", name: phrase(lang, "wait.group.tune"), kind: "other" }, phase: "todo", actions: [] },
|
|
319
352
|
});
|
|
320
353
|
// one place, one name: the first said of it (an archive's question names it as the research knows the archive; its
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
// The research on the desktop of a Mac (the Strom Widget, a program of its own, strom-mac): each tree's overview written
|
|
2
|
+
// into a file the widget reads — who works on what now, the finds of the last 24 hours, what waits for the person in
|
|
3
|
+
// the Strom app, the last 24 hours — worked out by the same functions as the phone's page (phone-watch.ts,
|
|
4
|
+
// phone-finds.ts) and cleaned as for it (forPhone): never a path, a record's ID, a spend or a model. Beside each text
|
|
5
|
+
// that names a person, the same without the names (the widget's "hide names").
|
|
6
|
+
//
|
|
7
|
+
// Written by the tree's bridge (strom live) while it runs, on macOS alone: ~/Library/Application Support/Strom/widget/
|
|
8
|
+
// <tree id>.json and index.json (the trees there), each whole (a file beside it renamed over it). Looked at every
|
|
9
|
+
// WIDGET_CHECK_MS, written when what it says changed, else every WIDGET_HEARTBEAT_MS; the bridge ending as it should
|
|
10
|
+
// writes `running: false`, its research gone takes the file away. An archive has none. The widget only reads: nothing
|
|
11
|
+
// of it comes back. An isolated installation (STROM_ISOLATED: a test's, a second strom) writes into its own settings
|
|
12
|
+
// folder — a research of a test never shows in the person's widget.
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { configDir, isolated, userHome } from "./paths.js";
|
|
16
|
+
import { Settings } from "./config.js";
|
|
17
|
+
import { Tree } from "./tree.js";
|
|
18
|
+
import { actsAsArchive } from "./state.js";
|
|
19
|
+
import { foldText } from "./text.js";
|
|
20
|
+
import { writeFileAtomic } from "./json.js";
|
|
21
|
+
import { appUrlSetting } from "./stromapp.js";
|
|
22
|
+
import { forPhone, treeOverview } from "./phone-watch.js";
|
|
23
|
+
import { treeFinds } from "./phone-finds.js";
|
|
24
|
+
import { waitingFor, inApp } from "./waiting.js";
|
|
25
|
+
import { scopes } from "./directions.js";
|
|
26
|
+
import { agentsHere } from "./apps.js";
|
|
27
|
+
import { humanTask } from "../cli/human.js";
|
|
28
|
+
import { ui } from "../cli/ui.js";
|
|
29
|
+
/** The version of the files' shape the widget reads (a newer one: the widget asks for its own update). */
|
|
30
|
+
export const WIDGET_SCHEMA = 1;
|
|
31
|
+
/** How often the bridge looks whether what the widget shows changed; written at least this often all the same. */
|
|
32
|
+
export const WIDGET_CHECK_MS = 15_000;
|
|
33
|
+
export const WIDGET_HEARTBEAT_MS = 60_000;
|
|
34
|
+
/** At most so many finds (the newest), so many of what waits with its text. */
|
|
35
|
+
export const WIDGET_FINDS = 50;
|
|
36
|
+
export const WIDGET_PENDING = 3;
|
|
37
|
+
/** What stands for a name taken out of a text. */
|
|
38
|
+
const HIDDEN = "…";
|
|
39
|
+
/**
|
|
40
|
+
* Where the widget's files are written: none but on macOS; STROM_WIDGET_DIR when set; an isolated installation into its
|
|
41
|
+
* own settings folder; else ~/Library/Application Support/Strom/widget of the HOME strom runs with.
|
|
42
|
+
*/
|
|
43
|
+
export function widgetDir(env, platform = process.platform) {
|
|
44
|
+
if (platform !== "darwin")
|
|
45
|
+
return undefined;
|
|
46
|
+
const said = env.STROM_WIDGET_DIR?.trim();
|
|
47
|
+
if (said)
|
|
48
|
+
return path.resolve(said);
|
|
49
|
+
if (isolated(env))
|
|
50
|
+
return path.join(configDir(env, platform), "widget");
|
|
51
|
+
return path.join(userHome(env), "Library", "Application Support", "Strom", "widget");
|
|
52
|
+
}
|
|
53
|
+
/** A moment as the widget reads it: ISO 8601 to the second, in UTC (Swift's .iso8601 reads no fractions). */
|
|
54
|
+
export function isoSecond(at) {
|
|
55
|
+
const t = typeof at === "number" ? at : Date.parse(at);
|
|
56
|
+
return Number.isFinite(t) ? new Date(t).toISOString().replace(/\.\d{3}Z$/u, "Z") : "";
|
|
57
|
+
}
|
|
58
|
+
// The app's slug of a tree's name (strom-beta src/filenames.ts asciiSlug): ?tree=<slug> opens the tree of that name.
|
|
59
|
+
const STANDALONE = { ß: "ss", æ: "ae", œ: "oe", ø: "o", đ: "d", ð: "d", þ: "th", ł: "l", ħ: "h", ı: "i", ŧ: "t", ŋ: "n", ĸ: "k" };
|
|
60
|
+
export function appSlug(name) {
|
|
61
|
+
return name
|
|
62
|
+
.toLowerCase()
|
|
63
|
+
.replace(/[ßæœøđðþłħıŧŋĸ]/gu, (c) => STANDALONE[c] ?? c)
|
|
64
|
+
.normalize("NFD")
|
|
65
|
+
.replace(/[̀-ͯ]/gu, "")
|
|
66
|
+
.replace(/[^a-z0-9]+/gu, "-")
|
|
67
|
+
.replace(/^-+|-+$/gu, "");
|
|
68
|
+
}
|
|
69
|
+
/** The tree in the Strom app strom uses (the setting strom.app.url, the channel's copy): ?tree=<slug of its name>. */
|
|
70
|
+
function treeUrl(env, name) {
|
|
71
|
+
const url = new URL(appUrlSetting(new Settings(env, {})).url);
|
|
72
|
+
const slug = appSlug(name);
|
|
73
|
+
if (slug)
|
|
74
|
+
url.searchParams.set("tree", slug);
|
|
75
|
+
return url.href;
|
|
76
|
+
}
|
|
77
|
+
// ── names out of a text ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
78
|
+
/** The stems of every name of the tree's people, folded: a word is a name when it is one of them and a few letters more. */
|
|
79
|
+
const stemsKept = new Map();
|
|
80
|
+
const MIN_STEM = 3;
|
|
81
|
+
const MAX_ENDING = 4;
|
|
82
|
+
function stemOf(folded) {
|
|
83
|
+
return folded.length > MIN_STEM ? folded.slice(0, Math.max(MIN_STEM, folded.length - 1)) : folded;
|
|
84
|
+
}
|
|
85
|
+
function nameStems(tree, key) {
|
|
86
|
+
const was = stemsKept.get(tree.root);
|
|
87
|
+
if (was && was.at === key)
|
|
88
|
+
return was.stems;
|
|
89
|
+
const names = [];
|
|
90
|
+
for (const p of tree.list("person"))
|
|
91
|
+
if (!p.retracted)
|
|
92
|
+
for (const n of p.names)
|
|
93
|
+
names.push(n.given, n.surname);
|
|
94
|
+
const stems = stemsOfNames(names);
|
|
95
|
+
stemsKept.set(tree.root, { at: key, stems });
|
|
96
|
+
return stems;
|
|
97
|
+
}
|
|
98
|
+
/** A name that drops its e before the last consonant in the other cases (Karel → Karla, Cvrlek → Cvrlka, Němec → Němcová). */
|
|
99
|
+
const MOVABLE_E = /^(.*[bcdfghjklmnpqrstvwxz])e([kclnr])$/u;
|
|
100
|
+
/** A stem ending in two consonants, as such a name has it without its e (Cvrlková → Cvrlk-, Němcová → Němc-). */
|
|
101
|
+
const WITHOUT_E = /^(.*[bcdfghjklmnpqrstvwxz])([kclnr])$/u;
|
|
102
|
+
/** The stems of names (folded): what a word of a text starts with when it is one of the names in any of its forms. */
|
|
103
|
+
export function stemsOfNames(names) {
|
|
104
|
+
const stems = new Set();
|
|
105
|
+
for (const name of names)
|
|
106
|
+
for (const part of name.split(/[^\p{L}\p{M}]+/u)) {
|
|
107
|
+
const f = foldText(part);
|
|
108
|
+
if ([...f].length < 2)
|
|
109
|
+
continue;
|
|
110
|
+
const e = MOVABLE_E.exec(f);
|
|
111
|
+
if (e && e[1].length >= 2) {
|
|
112
|
+
// the name as it stands (Cvrlek) and without its e (Cvrlka, Cvrlkovi, Cvrlková) — not "Něme…", which "neměl" is too
|
|
113
|
+
stems.add(f);
|
|
114
|
+
stems.add(e[1] + e[2]);
|
|
115
|
+
stems.add(stemOf(`${e[1]}${e[2]}ova`));
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
stems.add(stemOf(f));
|
|
119
|
+
// a woman's surname said of the man or the family ("Lhotáková" → "Lhoták", "Lhotákovi"), and a man's said of her
|
|
120
|
+
const base = /^(.{3,})ova$/u.exec(f)?.[1];
|
|
121
|
+
const cut = base ? WITHOUT_E.exec(base) : null;
|
|
122
|
+
if (cut && base.length >= 4) {
|
|
123
|
+
// "Cvrlková" said of the man: Cvrlk- (Cvrlka) and Cvrlek
|
|
124
|
+
stems.add(base);
|
|
125
|
+
stems.add(`${cut[1]}e${cut[2]}`);
|
|
126
|
+
}
|
|
127
|
+
else if (base)
|
|
128
|
+
stems.add(stemOf(base));
|
|
129
|
+
else if (f.length >= 4)
|
|
130
|
+
stems.add(stemOf(`${f}ova`));
|
|
131
|
+
}
|
|
132
|
+
return stems;
|
|
133
|
+
}
|
|
134
|
+
/** Whether a word is a name of the tree's people: one of their stems and at most MAX_ENDING letters more (Kryštofa, Lhotákovou). */
|
|
135
|
+
function isName(word, stems) {
|
|
136
|
+
const f = foldText(word);
|
|
137
|
+
const chars = [...f];
|
|
138
|
+
for (let k = Math.max(1, chars.length - MAX_ENDING); k <= chars.length; k++)
|
|
139
|
+
if (stems.has(chars.slice(0, k).join("")))
|
|
140
|
+
return true;
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* A text with the names of the tree's people taken out: each run of them (with what joins them: "Kryštofa Lhotáka")
|
|
145
|
+
* one "…". Any script; a word that only looks like a name goes too (better one word too many than a name shown).
|
|
146
|
+
* "" when nothing but names is left.
|
|
147
|
+
*/
|
|
148
|
+
export function withoutNames(text, stems) {
|
|
149
|
+
const parts = text.split(/([\p{L}\p{M}]+)/u);
|
|
150
|
+
const out = [];
|
|
151
|
+
let hidden = false;
|
|
152
|
+
for (let i = 0; i < parts.length; i++) {
|
|
153
|
+
const p = parts[i];
|
|
154
|
+
const word = i % 2 === 1;
|
|
155
|
+
if (word && isName(p, stems)) {
|
|
156
|
+
if (!hidden)
|
|
157
|
+
out.push(HIDDEN);
|
|
158
|
+
hidden = true;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
// what stands between two names of one run (a space, a hyphen) goes with them
|
|
162
|
+
if (!word && hidden && /^[\s-]*$/u.test(p) && i + 1 < parts.length && isName(parts[i + 1], stems))
|
|
163
|
+
continue;
|
|
164
|
+
hidden = false;
|
|
165
|
+
out.push(p);
|
|
166
|
+
}
|
|
167
|
+
const s = out.join("").replace(/\s+/gu, " ").trim();
|
|
168
|
+
return /[\p{L}\p{N}]/u.test(s.replaceAll(HIDDEN, "")) ? s : "";
|
|
169
|
+
}
|
|
170
|
+
// ── the overview ─────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
171
|
+
/** A fact's name as a heading: its first letter capital ("povolání" → "Povolání"), in the research language's way. */
|
|
172
|
+
function heading(name, lang) {
|
|
173
|
+
const [first = "", ...rest] = [...name];
|
|
174
|
+
return first.toLocaleUpperCase(lang) + rest.join("");
|
|
175
|
+
}
|
|
176
|
+
const FACTS = { BIRT: "birth", CHR: "baptism", BAPM: "baptism", MARR: "marriage", DEAT: "death", BURI: "burial" };
|
|
177
|
+
/** The finds of the phone as the widget's lines: newest first, each its text and the same without names. */
|
|
178
|
+
function findItems(tree, groups, stems, openUrl) {
|
|
179
|
+
const lang = tree.lang;
|
|
180
|
+
const person = ui(lang, "ui.widget.person");
|
|
181
|
+
const anon = (span) => (span ? `${person} (${span})` : person);
|
|
182
|
+
const out = [];
|
|
183
|
+
const at = (l) => isoSecond(l.at);
|
|
184
|
+
for (const g of groups)
|
|
185
|
+
for (const l of g.lines) {
|
|
186
|
+
const who = g.kind === "person" ? (g.name ?? g.title ?? "") : (g.title ?? "");
|
|
187
|
+
const whoNoNames = g.kind === "person" ? person : ui(lang, "ui.widget.family");
|
|
188
|
+
const base = { at: at(l), openUrl };
|
|
189
|
+
switch (l.kind) {
|
|
190
|
+
case "person":
|
|
191
|
+
out.push({ kind: "person_new", text: g.title ?? "", textNoNames: anon(g.span), ...base });
|
|
192
|
+
break;
|
|
193
|
+
case "family":
|
|
194
|
+
out.push({ kind: "family_new", text: g.title ?? "", textNoNames: whoNoNames, ...base });
|
|
195
|
+
break;
|
|
196
|
+
case "name":
|
|
197
|
+
out.push({ kind: "name", text: l.text, textNoNames: anon(g.span), ...base });
|
|
198
|
+
break;
|
|
199
|
+
case "child":
|
|
200
|
+
out.push({ kind: "child", text: l.text, textNoNames: anon(l.span), ...base });
|
|
201
|
+
break;
|
|
202
|
+
case "fact":
|
|
203
|
+
case "fixed":
|
|
204
|
+
case "cited": {
|
|
205
|
+
const fact = FACTS[l.fact?.kind ?? ""] ?? "other";
|
|
206
|
+
const what = l.fact?.what ?? "";
|
|
207
|
+
out.push({
|
|
208
|
+
kind: "fact",
|
|
209
|
+
fact,
|
|
210
|
+
label: fact === "other" && l.fact?.name ? heading(l.fact.name, lang) : null,
|
|
211
|
+
text: [who, what].filter(Boolean).join(", "),
|
|
212
|
+
textNoNames: [whoNoNames, withoutNames(what, stems)].filter(Boolean).join(", "),
|
|
213
|
+
...base,
|
|
214
|
+
});
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
case "source": {
|
|
218
|
+
// places, dates and sources stay (the widget's "hide names"); a name in a source's title goes
|
|
219
|
+
const none = withoutNames(l.text, stems);
|
|
220
|
+
out.push({ kind: "source_new", text: l.text, ...(none ? { textNoNames: none } : {}), ...base });
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
case "conflict":
|
|
224
|
+
case "hypothesis": {
|
|
225
|
+
const none = withoutNames(l.text, stems);
|
|
226
|
+
out.push({ kind: "resolved", resolved: l.kind === "conflict" ? "conflict" : "hypothesis_confirmed", text: l.text, ...(none ? { textNoNames: none } : {}), ...base });
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return out.filter((f) => f.text).sort((a, b) => b.at.localeCompare(a.at)).slice(0, WIDGET_FINDS);
|
|
232
|
+
}
|
|
233
|
+
/** What waits for the person as the Strom app counts it: its /status `waiting`, only what is to do (not sent, waiting for a reply). */
|
|
234
|
+
function pendingOf(tree, env) {
|
|
235
|
+
const settings = new Settings(env, {});
|
|
236
|
+
return waitingFor(tree, {
|
|
237
|
+
shared: settings.shared()?.value,
|
|
238
|
+
claude: () => settings.agent(tree.config).value === "claude" && agentsHere(env).some((a) => a.id === "claude" && a.cli),
|
|
239
|
+
stories: true,
|
|
240
|
+
scopes: scopes(tree),
|
|
241
|
+
}).filter((w) => inApp(w) && w.item.phase === "todo");
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* A line of "Now" without names: its direction and the years of whom it is about ("Předci osoby (1784)"); a question,
|
|
245
|
+
* by its words without the names; the agent itself (no research), as it is said (no name in it).
|
|
246
|
+
*/
|
|
247
|
+
function lineNoNames(lang, l, stems) {
|
|
248
|
+
const of = l.span ? ` (${l.span})` : "";
|
|
249
|
+
if (l.direction === "ancestors")
|
|
250
|
+
return ui(lang, "ui.widget.up", { span: of });
|
|
251
|
+
if (l.direction === "descendants")
|
|
252
|
+
return ui(lang, "ui.widget.down", { span: of });
|
|
253
|
+
if (l.direction === "person")
|
|
254
|
+
return ui(lang, "ui.widget.one", { span: of });
|
|
255
|
+
return withoutNames(l.name, stems) || ui(lang, "ui.widget.question");
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* The overview of a tree as the widget reads it (`running`: its bridge runs); undefined for an archive (nobody works
|
|
259
|
+
* on it: nothing to show of an agent) and a research that cannot be read.
|
|
260
|
+
*/
|
|
261
|
+
export function widgetTree(root, env, opts) {
|
|
262
|
+
const now = opts.now ?? Date.now();
|
|
263
|
+
const tree = Tree.open(root, env);
|
|
264
|
+
if (actsAsArchive(tree))
|
|
265
|
+
return undefined;
|
|
266
|
+
const lang = tree.lang;
|
|
267
|
+
const openUrl = treeUrl(env, tree.config.name);
|
|
268
|
+
const o = treeOverview(root, env);
|
|
269
|
+
const stems = nameStems(tree, o.latest ?? "");
|
|
270
|
+
const lines = o.now.map((l) => {
|
|
271
|
+
const task = l.task;
|
|
272
|
+
const taskNoNames = task ? withoutNames(task, stems) : "";
|
|
273
|
+
return {
|
|
274
|
+
dir: l.way,
|
|
275
|
+
title: l.name,
|
|
276
|
+
titleNoNames: lineNoNames(lang, l, stems),
|
|
277
|
+
...(task ? { task } : {}),
|
|
278
|
+
...(taskNoNames ? { taskNoNames } : {}),
|
|
279
|
+
since: isoSecond(l.since),
|
|
280
|
+
pausedUntil: l.paused ? isoSecond(l.paused.until) : null,
|
|
281
|
+
openUrl,
|
|
282
|
+
};
|
|
283
|
+
});
|
|
284
|
+
const windowStart = now - 24 * 3_600_000;
|
|
285
|
+
const finds = treeFinds(root, env, new Date(windowStart).toISOString(), now);
|
|
286
|
+
const waits = pendingOf(tree, env);
|
|
287
|
+
const pendingItems = waits.slice(0, WIDGET_PENDING).map(({ item }) => {
|
|
288
|
+
const text = forPhone(humanTask(tree, item.what, lang)) || ui(lang, "ui.phone.waiting.item");
|
|
289
|
+
const none = withoutNames(text, stems);
|
|
290
|
+
return { text, ...(none ? { textNoNames: none } : {}) };
|
|
291
|
+
});
|
|
292
|
+
const at = isoSecond(now);
|
|
293
|
+
return {
|
|
294
|
+
schemaVersion: WIDGET_SCHEMA,
|
|
295
|
+
treeKey: tree.config.id,
|
|
296
|
+
treeName: tree.config.name,
|
|
297
|
+
lang,
|
|
298
|
+
generatedAt: at,
|
|
299
|
+
running: opts.running,
|
|
300
|
+
lastActiveAt: at,
|
|
301
|
+
openUrl,
|
|
302
|
+
now: { lines, ...(o.lastWorked ? { lastWorkedAt: isoSecond(o.lastWorked) } : {}) },
|
|
303
|
+
findings: { windowStart: isoSecond(finds.since), items: findItems(tree, finds.groups, stems, openUrl) },
|
|
304
|
+
pending: { count: waits.length, items: pendingItems, openUrl },
|
|
305
|
+
...(o.day ? { stats24h: o.day } : {}),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
// ── the files ────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
309
|
+
const treeFile = (dir, key) => path.join(dir, `${key}.json`);
|
|
310
|
+
/** A tree's key as a file's name: the tree's ID (a UUID) — never a path. */
|
|
311
|
+
const KEY = /^[0-9A-Za-z-]{1,64}$/u;
|
|
312
|
+
/** index.json as the files of the trees there say it, written whole when it says something else. */
|
|
313
|
+
export function writeWidgetIndex(dir, now = Date.now()) {
|
|
314
|
+
const trees = [];
|
|
315
|
+
let names = [];
|
|
316
|
+
try {
|
|
317
|
+
names = fs.readdirSync(dir).filter((n) => n.endsWith(".json") && n !== "index.json" && !n.startsWith("."));
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
for (const n of names.sort()) {
|
|
323
|
+
try {
|
|
324
|
+
const t = JSON.parse(fs.readFileSync(path.join(dir, n), "utf8"));
|
|
325
|
+
if (typeof t.treeKey !== "string" || `${t.treeKey}.json` !== n)
|
|
326
|
+
continue;
|
|
327
|
+
trees.push({ treeKey: t.treeKey, treeName: String(t.treeName ?? ""), lang: String(t.lang ?? ""), running: t.running === true, lastActiveAt: String(t.lastActiveAt ?? ""), generatedAt: String(t.generatedAt ?? "") });
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
// being written by another bridge this moment: said the next time
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const file = path.join(dir, "index.json");
|
|
334
|
+
try {
|
|
335
|
+
const was = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
336
|
+
if (JSON.stringify(was.trees) === JSON.stringify(trees) && was.schemaVersion === WIDGET_SCHEMA)
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
// none yet
|
|
341
|
+
}
|
|
342
|
+
writeFileAtomic(file, `${JSON.stringify({ schemaVersion: WIDGET_SCHEMA, generatedAt: isoSecond(now), trees }, null, 2)}\n`);
|
|
343
|
+
}
|
|
344
|
+
/** A tree's file written whole (a file beside it renamed over it), then index.json. */
|
|
345
|
+
export function writeWidgetTree(dir, data) {
|
|
346
|
+
if (!KEY.test(data.treeKey))
|
|
347
|
+
return;
|
|
348
|
+
writeFileAtomic(treeFile(dir, data.treeKey), `${JSON.stringify(data, null, 2)}\n`);
|
|
349
|
+
writeWidgetIndex(dir, Date.parse(data.generatedAt) || Date.now());
|
|
350
|
+
}
|
|
351
|
+
/** A tree's file taken away (its research gone, an archive now), and index.json without it. */
|
|
352
|
+
export function dropWidgetTree(dir, key) {
|
|
353
|
+
if (!KEY.test(key))
|
|
354
|
+
return;
|
|
355
|
+
try {
|
|
356
|
+
fs.rmSync(treeFile(dir, key), { force: true });
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
writeWidgetIndex(dir);
|
|
362
|
+
}
|
|
363
|
+
/** The writer of a tree's bridge; none off macOS. */
|
|
364
|
+
export function widgetWriter(root, env, platform = process.platform, clock = Date.now) {
|
|
365
|
+
const dir = widgetDir(env, platform);
|
|
366
|
+
if (!dir)
|
|
367
|
+
return undefined;
|
|
368
|
+
const checkMs = Number(env.STROM_WIDGET_CHECK_MS ?? WIDGET_CHECK_MS);
|
|
369
|
+
const beatMs = Number(env.STROM_WIDGET_HEARTBEAT_MS ?? WIDGET_HEARTBEAT_MS);
|
|
370
|
+
let checked = -Infinity;
|
|
371
|
+
let written = -Infinity;
|
|
372
|
+
let said = "";
|
|
373
|
+
let last;
|
|
374
|
+
let key;
|
|
375
|
+
// what moves with the clock alone says nothing new
|
|
376
|
+
const content = (t) => JSON.stringify({ ...t, generatedAt: "", lastActiveAt: "", findings: { ...t.findings, windowStart: "" } });
|
|
377
|
+
return {
|
|
378
|
+
tick(force = false) {
|
|
379
|
+
const now = clock();
|
|
380
|
+
if (!force && now - checked < checkMs)
|
|
381
|
+
return;
|
|
382
|
+
checked = now;
|
|
383
|
+
const t = widgetTree(root, env, { running: true, now });
|
|
384
|
+
if (!t) {
|
|
385
|
+
// an archive now: nothing of it on the desktop
|
|
386
|
+
if (key)
|
|
387
|
+
dropWidgetTree(dir, key);
|
|
388
|
+
key = undefined;
|
|
389
|
+
last = undefined;
|
|
390
|
+
said = "";
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
key = t.treeKey;
|
|
394
|
+
last = t;
|
|
395
|
+
const c = content(t);
|
|
396
|
+
if (c === said && now - written < beatMs)
|
|
397
|
+
return;
|
|
398
|
+
writeWidgetTree(dir, t);
|
|
399
|
+
said = c;
|
|
400
|
+
written = now;
|
|
401
|
+
},
|
|
402
|
+
end(gone) {
|
|
403
|
+
if (!key)
|
|
404
|
+
return;
|
|
405
|
+
// what the file says now is no longer what was written: the next look writes it again
|
|
406
|
+
said = "";
|
|
407
|
+
if (gone)
|
|
408
|
+
return dropWidgetTree(dir, key);
|
|
409
|
+
if (!last)
|
|
410
|
+
return;
|
|
411
|
+
const at = isoSecond(clock());
|
|
412
|
+
writeWidgetTree(dir, { ...last, running: false, generatedAt: at, lastActiveAt: at });
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "strom-research",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.21.0-beta.2",
|
|
4
4
|
"description": "Genealogical research toolkit for AI coding agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -57,5 +57,8 @@
|
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@types/node": "^22.20.4",
|
|
59
59
|
"typescript": "^7.0.2"
|
|
60
|
+
},
|
|
61
|
+
"publishConfig": {
|
|
62
|
+
"tag": "beta"
|
|
60
63
|
}
|
|
61
64
|
}
|