strom-research 1.25.1 → 1.25.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 +28 -0
- package/CHANGELOG.de.md +37 -0
- package/CHANGELOG.md +30 -0
- package/CHANGELOG.pl.md +32 -0
- package/assets/lang/cs.json +7 -0
- package/assets/lang/de.json +7 -0
- package/assets/lang/pl.json +7 -0
- package/assets/plugins/gates/claude-usage/gate.json +1 -1
- package/assets/plugins/gates/claude-usage/gate.ts +174 -24
- package/assets/terms/streets.json +214 -20
- package/dist/brief/places.js +40 -5
- package/dist/cli/ui.js +1 -0
- package/dist/commands/browse.js +11 -1
- package/dist/commands/history.js +4 -2
- package/dist/commands/placeid.js +100 -23
- package/dist/commands/session.js +10 -2
- package/dist/commands/sources.js +5 -13
- package/dist/commands/start.js +5 -2
- package/dist/commands/sync.js +2 -2
- package/dist/core/claudedir.js +59 -3
- package/dist/core/gate.js +3 -3
- package/dist/core/people.js +1 -1
- package/dist/core/phrases.js +6 -0
- package/dist/core/placeid.js +733 -79
- package/dist/core/placesview.js +1 -0
- package/dist/core/schema.js +3 -0
- package/dist/core/session.js +5 -0
- package/dist/core/streetfix.js +24 -1
- package/dist/core/streets.js +64 -5
- package/dist/core/sync.js +24 -13
- package/dist/core/tree.js +1 -1
- package/dist/runners/runner.js +3 -3
- package/package.json +1 -1
package/dist/core/placeid.js
CHANGED
|
@@ -20,6 +20,8 @@ import { readPlaces } from "./pastevents.js";
|
|
|
20
20
|
import { phrase } from "./phrases.js";
|
|
21
21
|
import { now } from "./tree.js";
|
|
22
22
|
import path from "node:path";
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
import { opsLogsOf } from "./opslog.js";
|
|
23
25
|
export const PLACES_IDENTIFY_ORIGIN = "places:identify";
|
|
24
26
|
export const PLACE_QUEUE_FILE = "quality/places.json";
|
|
25
27
|
/** At most so many places in one session (one area). */
|
|
@@ -39,12 +41,30 @@ export function readPlacesFile(tree) {
|
|
|
39
41
|
for (const [k, v] of Object.entries(f?.queue ?? {}))
|
|
40
42
|
if (v && typeof v.form === "string" && v.form.trim())
|
|
41
43
|
queue[k] = v;
|
|
42
|
-
|
|
44
|
+
const undos = Array.isArray(f?.undos) ? f.undos.filter((u) => u && typeof u.id === "string") : [];
|
|
45
|
+
return { queue, ...(f?.undo ? { undo: f.undo } : {}), ...(undos.length ? { undos } : {}), ...(f?.converted ? { converted: f.converted } : {}), ...(typeof f?.repaired === "number" ? { repaired: f.repaired } : {}) };
|
|
43
46
|
}
|
|
44
47
|
catch {
|
|
45
48
|
return { queue: {} };
|
|
46
49
|
}
|
|
47
50
|
}
|
|
51
|
+
/** How many steps on places can be undone, each by its own ID (the oldest go first). */
|
|
52
|
+
export const UNDO_KEPT = 20;
|
|
53
|
+
/** A step to be undone kept: the newest; the one before it waits among the others, never lost to it. */
|
|
54
|
+
function keepUndo(file, u) {
|
|
55
|
+
if (file.undo)
|
|
56
|
+
file.undos = [...(file.undos ?? []), file.undo].slice(-(UNDO_KEPT - 1));
|
|
57
|
+
file.undo = u;
|
|
58
|
+
return u.id;
|
|
59
|
+
}
|
|
60
|
+
/** A new step's undo ID: never one waiting already (two steps in one millisecond). */
|
|
61
|
+
function undoId(file) {
|
|
62
|
+
const taken = new Set([file.undo?.id, ...(file.undos ?? []).map((u) => u.id)]);
|
|
63
|
+
let t = Date.now();
|
|
64
|
+
while (taken.has(`u${t.toString(36)}`))
|
|
65
|
+
t++;
|
|
66
|
+
return `u${t.toString(36)}`;
|
|
67
|
+
}
|
|
48
68
|
function fileContent(f) {
|
|
49
69
|
return stringifyCanonical(f);
|
|
50
70
|
}
|
|
@@ -94,8 +114,12 @@ export function factForms(tree) {
|
|
|
94
114
|
* okres Horní"); none when two do — a place two records could be belongs to none until its writing is set right.
|
|
95
115
|
*/
|
|
96
116
|
export function recordIndex(tree) {
|
|
117
|
+
return recordIndexOf(tree.list("place"));
|
|
118
|
+
}
|
|
119
|
+
/** recordIndex over the given records (the records as a step leaves them). */
|
|
120
|
+
function recordIndexOf(places) {
|
|
97
121
|
const byName = new Map();
|
|
98
|
-
for (const p of
|
|
122
|
+
for (const p of places) {
|
|
99
123
|
if (p.retracted)
|
|
100
124
|
continue;
|
|
101
125
|
for (const k of new Set(p.names.map((n) => foldText(n.name))))
|
|
@@ -144,7 +168,11 @@ function areaOf(f) {
|
|
|
144
168
|
return "other";
|
|
145
169
|
return [...f.areas.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0][0];
|
|
146
170
|
}
|
|
147
|
-
/**
|
|
171
|
+
/**
|
|
172
|
+
* The queue cut into sessions: the places of one area together, at most BATCH_MAX in each, in the queue's order — the
|
|
173
|
+
* small areas packed into one session as long as it has room (a session's start is the same price for one place as
|
|
174
|
+
* for fifteen; 1.25 ran most sessions on one or two places), an area never split where it fits whole.
|
|
175
|
+
*/
|
|
148
176
|
export function queueBatches(tree, file = readPlacesFile(tree), forms = factForms(tree)) {
|
|
149
177
|
const byArea = new Map();
|
|
150
178
|
for (const row of queueOrder(file)) {
|
|
@@ -152,11 +180,26 @@ export function queueBatches(tree, file = readPlacesFile(tree), forms = factForm
|
|
|
152
180
|
byArea.set(area, [...(byArea.get(area) ?? []), row]);
|
|
153
181
|
}
|
|
154
182
|
const out = [];
|
|
155
|
-
|
|
156
|
-
|
|
183
|
+
let open = [];
|
|
184
|
+
for (const rows of byArea.values()) {
|
|
185
|
+
let i = 0;
|
|
186
|
+
for (; i + BATCH_MAX <= rows.length; i += BATCH_MAX)
|
|
157
187
|
out.push(rows.slice(i, i + BATCH_MAX));
|
|
188
|
+
if (i < rows.length)
|
|
189
|
+
open = pack(out, open, rows.slice(i));
|
|
190
|
+
}
|
|
191
|
+
if (open.length)
|
|
192
|
+
out.push(open);
|
|
158
193
|
return out;
|
|
159
194
|
}
|
|
195
|
+
/** An area's places into the open session when they fit, else the open one closed and a new one begun with them. */
|
|
196
|
+
function pack(out, open, rows) {
|
|
197
|
+
if (open.length + rows.length <= BATCH_MAX)
|
|
198
|
+
return [...open, ...rows];
|
|
199
|
+
if (open.length)
|
|
200
|
+
out.push(open);
|
|
201
|
+
return [...rows];
|
|
202
|
+
}
|
|
160
203
|
/** Places in the queue the person asked for (the app, a link, the terminal): their task comes first (core/queue.ts). */
|
|
161
204
|
export function personQueued(tree) {
|
|
162
205
|
return Object.values(readPlacesFile(tree).queue).some((e) => e.by === "person");
|
|
@@ -276,7 +319,17 @@ export function queuePlaces(tree, req, by = "person") {
|
|
|
276
319
|
skipped.push({ form: w.form, why: "running" });
|
|
277
320
|
continue;
|
|
278
321
|
}
|
|
279
|
-
|
|
322
|
+
const had = [w.key, ...others].find((k) => file.queue[k]);
|
|
323
|
+
if (had) {
|
|
324
|
+
const e = file.queue[had];
|
|
325
|
+
// queued to be looked for, and on the map since: the check asked now takes its place (the tidying would take
|
|
326
|
+
// the old entry out as identified, and the person's check with it)
|
|
327
|
+
if (req.check && w.place?.coords && !e.check) {
|
|
328
|
+
const { back: _b, ...rest } = e;
|
|
329
|
+
file.queue[had] = { ...rest, at, by, check: true };
|
|
330
|
+
queued.push(e.form);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
280
333
|
skipped.push({ form: w.form, why: "queued" });
|
|
281
334
|
continue;
|
|
282
335
|
}
|
|
@@ -361,10 +414,11 @@ function target(tree, ref, forms) {
|
|
|
361
414
|
/**
|
|
362
415
|
* A name added to a place record when it has none like it (any accent folded alike): a form the facts write (K3.2,
|
|
363
416
|
* K4.5) or another name of the place (another language, a historical name). Each name once: a form that is a name of
|
|
364
|
-
* the record already (the same words, or alike with no language) adds nothing; another name
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
417
|
+
* the record already (the same words, or alike with no language) adds nothing; another name of no language the record
|
|
418
|
+
* has already (with a language or without, today's too) adds nothing; another name given to a form of the facts of the
|
|
419
|
+
* same words, or to another name of no language and no years, takes its place (it says more — the language, the years),
|
|
420
|
+
* and the same name of the same language already there (a form, or of no known kind: written before kinds were kept)
|
|
421
|
+
* becomes it, its years filled in where it had none. Today's name (the first) is never changed.
|
|
368
422
|
*/
|
|
369
423
|
export function addPlaceName(p, name, kind, extra = {}) {
|
|
370
424
|
const n = name.normalize("NFC").trim();
|
|
@@ -378,6 +432,9 @@ export function addPlaceName(p, name, kind, extra = {}) {
|
|
|
378
432
|
return;
|
|
379
433
|
}
|
|
380
434
|
const whole = { name: n, ...(extra.lang ? { lang: extra.lang } : {}), ...(extra.from !== undefined ? { from: extra.from } : {}), ...(extra.to !== undefined ? { to: extra.to } : {}), kind };
|
|
435
|
+
// a name of no language the record has already (with a language or without, today's too): it says nothing more
|
|
436
|
+
if (!extra.lang && same.length && !same.some(({ x }) => !x.lang))
|
|
437
|
+
return;
|
|
381
438
|
const like = same.find(({ x }) => (x.lang ?? "") === (extra.lang ?? ""));
|
|
382
439
|
// the same name already: what it is and its years filled in, never changed
|
|
383
440
|
if (like) {
|
|
@@ -385,7 +442,8 @@ export function addPlaceName(p, name, kind, extra = {}) {
|
|
|
385
442
|
p.names[like.i] = { ...like.x, ...(extra.from !== undefined && like.x.from === undefined ? { from: extra.from } : {}), ...(extra.to !== undefined && like.x.to === undefined ? { to: extra.to } : {}), kind };
|
|
386
443
|
return;
|
|
387
444
|
}
|
|
388
|
-
|
|
445
|
+
// a form of the facts of the same words, or another name of no language and no years: the one that says more takes its place
|
|
446
|
+
const form = same.find(({ x, i }) => i > 0 && ((x.kind === "form" && x.name === n) || (x.kind === "alt" && !x.lang && x.from === undefined && x.to === undefined)));
|
|
389
447
|
if (form)
|
|
390
448
|
p.names[form.i] = whole;
|
|
391
449
|
else
|
|
@@ -403,6 +461,157 @@ export function otherNames(p) {
|
|
|
403
461
|
function addName(p, name) {
|
|
404
462
|
addPlaceName(p, name, "form");
|
|
405
463
|
}
|
|
464
|
+
/** "<name>[@<lang>][:<from>-<to>]" — a single year (":1844") is the year the name is known from; undefined: no such syntax. */
|
|
465
|
+
const ALT = /^(.+?)(?:@(\p{L}{2,3}))?(?::(\d{3,4})?(?:(-)(\d{3,4})?)?)?$/u;
|
|
466
|
+
/**
|
|
467
|
+
* Another name of a place as the agent or the person writes it: "Ober Lhota@de:-1945", "Lhotau@de", "Lhotow:1650-1700",
|
|
468
|
+
* "Lhotow@cs:1844" (one year: known so then). A name still carrying the syntax ("…@xx:…" not understood) is refused.
|
|
469
|
+
*/
|
|
470
|
+
export function parseAltName(a) {
|
|
471
|
+
const s = a.normalize("NFC").trim();
|
|
472
|
+
const m = ALT.exec(s);
|
|
473
|
+
const name = m?.[1]?.trim();
|
|
474
|
+
if (!m || !name || /@\p{L}*(?::|$)/u.test(name))
|
|
475
|
+
throw new UsageError(`invalid name "${a}": <name>[@<lang>][:<from>-<to>]`, { code: "place.name", hint: 'e.g. "Ober Lhota@de:1850-1918", "Lhotau@de", "Lhotow@cs:1654"' });
|
|
476
|
+
const n = { name };
|
|
477
|
+
if (m[2])
|
|
478
|
+
n.lang = m[2].toLowerCase();
|
|
479
|
+
const from = m[3] ? Number(m[3]) : undefined;
|
|
480
|
+
const to = m[5] ? Number(m[5]) : undefined;
|
|
481
|
+
if (from !== undefined)
|
|
482
|
+
n.from = from;
|
|
483
|
+
if (to !== undefined)
|
|
484
|
+
n.to = to;
|
|
485
|
+
// one year, no dash: the year the name is known from — from and to alike
|
|
486
|
+
else if (from !== undefined && !m[4])
|
|
487
|
+
n.to = from;
|
|
488
|
+
if (n.from !== undefined && n.to !== undefined && n.from > n.to)
|
|
489
|
+
throw new UsageError(`invalid name "${a}": the years ${n.from}-${n.to} go backwards`, { code: "place.name" });
|
|
490
|
+
return n;
|
|
491
|
+
}
|
|
492
|
+
/** A name written with the syntax of another name as its text ("Lhotow@cs:1844" kept whole by an older strom): parsed, else undefined. */
|
|
493
|
+
export function nameWithSyntax(n) {
|
|
494
|
+
if (!/[@:]/u.test(n.name) || n.lang || n.from !== undefined || n.to !== undefined)
|
|
495
|
+
return undefined;
|
|
496
|
+
try {
|
|
497
|
+
const p = parseAltName(n.name);
|
|
498
|
+
return p.name !== n.name ? { ...p, ...(n.kind ? { kind: n.kind } : {}) } : undefined;
|
|
499
|
+
}
|
|
500
|
+
catch {
|
|
501
|
+
return undefined;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
/** The distance of two points, km. */
|
|
505
|
+
export function placeKm(a, b) {
|
|
506
|
+
const rad = Math.PI / 180;
|
|
507
|
+
const dLat = (b.lat - a.lat) * rad;
|
|
508
|
+
const dLon = (b.lon - a.lon) * rad;
|
|
509
|
+
const h = Math.sin(dLat / 2) ** 2 + Math.cos(a.lat * rad) * Math.cos(b.lat * rad) * Math.sin(dLon / 2) ** 2;
|
|
510
|
+
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(h)));
|
|
511
|
+
}
|
|
512
|
+
/** Two place records nearer than this are one place until the agent says otherwise (--separate). */
|
|
513
|
+
export const NEAR_KM = 1.5;
|
|
514
|
+
/** The years of the facts writing any name of a place record. */
|
|
515
|
+
function yearsOf(p, forms) {
|
|
516
|
+
return [...new Set(p.names.map((n) => foldText(n.name)))].flatMap((k) => forms.get(k)?.years ?? []);
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Today's name of a place, when its first is not it: the one other name in use since a year with no end
|
|
520
|
+
* ("Lhotowo@pl:1945-"), while the first is a name of before — it ended before that year, or the facts write it only
|
|
521
|
+
* before then. Undefined where nothing says so plainly (no such name, or two of them, or the first in use still).
|
|
522
|
+
*/
|
|
523
|
+
export function todayName(p, years) {
|
|
524
|
+
const first = p.names[0];
|
|
525
|
+
if (!first || (first.from !== undefined && first.to === undefined))
|
|
526
|
+
return undefined;
|
|
527
|
+
const open = p.names.slice(1).filter((n) => n.kind === "alt" && n.from !== undefined && n.to === undefined && foldText(n.name) !== foldText(first.name));
|
|
528
|
+
if (open.length !== 1)
|
|
529
|
+
return undefined;
|
|
530
|
+
const a = open[0];
|
|
531
|
+
if (first.to !== undefined)
|
|
532
|
+
return first.to <= a.from ? a : undefined;
|
|
533
|
+
return years.every((y) => y < a.from) ? a : undefined;
|
|
534
|
+
}
|
|
535
|
+
/** A name made the first (today's); the one first before kept as what it is (a form of the facts, or another name of the place). */
|
|
536
|
+
function setFirst(p, today, forms) {
|
|
537
|
+
const was = p.names[0];
|
|
538
|
+
const rest = p.names
|
|
539
|
+
.filter((n) => n !== today)
|
|
540
|
+
.map((n) => (n === was && !n.kind ? { ...n, kind: forms.has(foldText(n.name)) && !n.lang && n.from === undefined && n.to === undefined ? "form" : "alt" } : n))
|
|
541
|
+
.filter((n) => !(foldText(n.name) === foldText(today.name) && !n.lang));
|
|
542
|
+
const { kind: _k, ...first } = today;
|
|
543
|
+
p.names = [first, ...rest];
|
|
544
|
+
}
|
|
545
|
+
/** "<kind>:<name>" of a jurisdiction to take off. */
|
|
546
|
+
export function parseDrop(s) {
|
|
547
|
+
const m = /^([^:]+):(.+?)(?::\d{0,4}-\d{0,4}|:\d{3,4})?$/u.exec(s.normalize("NFC").trim());
|
|
548
|
+
if (!m || !JURISDICTIONS.includes(m[1].trim()))
|
|
549
|
+
throw new UsageError(`--drop-jurisdiction "${s}": "<kind>:<name>", kind one of ${JURISDICTIONS.join(", ")}`, { code: "place.jurisdiction" });
|
|
550
|
+
return { kind: m[1].trim(), name: m[2].trim() };
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* The details written into a copy of a place record: today's name (given, or the one other name in use since a year
|
|
554
|
+
* with no end), the other names, the forms of the facts, kind, parent, jurisdictions added and taken off. The folded
|
|
555
|
+
* forms it took in (out of the queue too), what it took off, and today's name when it was made the first by itself.
|
|
556
|
+
*/
|
|
557
|
+
function applyDetails(tree, p, t, d, forms) {
|
|
558
|
+
if (d.parent) {
|
|
559
|
+
const parent = tree.get(d.parent);
|
|
560
|
+
if (!parent || parent.type !== "place" || parent.id === p.id)
|
|
561
|
+
throw new UsageError(`no place ${d.parent} to be its parent`, { code: "place.unknown" });
|
|
562
|
+
p.parent = parent.id;
|
|
563
|
+
}
|
|
564
|
+
const dropped = [];
|
|
565
|
+
for (const x of d.drop ?? []) {
|
|
566
|
+
const hit = p.jurisdictions.filter((j) => j.kind === x.kind && foldText(j.name) === foldText(x.name));
|
|
567
|
+
if (!hit.length)
|
|
568
|
+
throw new UsageError(`${p.id} has no ${x.kind} "${x.name}" to take off${p.jurisdictions.length ? ` (it has ${p.jurisdictions.map((j) => `${j.kind}:${j.name}`).join(", ")})` : ""}`, { code: "place.jurisdiction" });
|
|
569
|
+
dropped.push(...hit);
|
|
570
|
+
p.jurisdictions = p.jurisdictions.filter((j) => !hit.includes(j));
|
|
571
|
+
}
|
|
572
|
+
if (d.name) {
|
|
573
|
+
const name = text(d.name, 300, "--name");
|
|
574
|
+
setFirst(p, nameWithSyntax({ name }) ?? { name }, forms);
|
|
575
|
+
}
|
|
576
|
+
for (const a of d.alt ?? [])
|
|
577
|
+
addPlaceName(p, a.name, "alt", a);
|
|
578
|
+
for (const k of t.keys)
|
|
579
|
+
addName(p, forms.get(k)?.form ?? k);
|
|
580
|
+
const more = [];
|
|
581
|
+
for (const f of d.forms ?? []) {
|
|
582
|
+
const key = foldText(f.normalize("NFC").trim());
|
|
583
|
+
if (!key)
|
|
584
|
+
continue;
|
|
585
|
+
addName(p, forms.get(key)?.form ?? f.normalize("NFC").trim());
|
|
586
|
+
more.push(key);
|
|
587
|
+
}
|
|
588
|
+
let today;
|
|
589
|
+
if (!d.name) {
|
|
590
|
+
const a = todayName(p, yearsOf(p, forms));
|
|
591
|
+
if (a) {
|
|
592
|
+
setFirst(p, a, forms);
|
|
593
|
+
today = a.name;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
if (d.kind)
|
|
597
|
+
p.kind = d.kind;
|
|
598
|
+
for (const j of d.jurisdictions ?? [])
|
|
599
|
+
if (!p.jurisdictions.some((x) => x.kind === j.kind && foldText(x.name) === foldText(j.name) && x.from === j.from && x.to === j.to))
|
|
600
|
+
p.jurisdictions.push(j);
|
|
601
|
+
return { more, dropped, today };
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* The records nearer than NEAR_KM to coordinates (the record itself, its parent, its parts and those said apart
|
|
605
|
+
* left out): the same place written twice, until the agent says it is another.
|
|
606
|
+
*/
|
|
607
|
+
function nearRecords(tree, p, at, separate) {
|
|
608
|
+
return tree
|
|
609
|
+
.list("place")
|
|
610
|
+
.filter((o) => !o.retracted && o.id !== p.id && !!o.coords && !separate.has(o.id) && o.id !== p.parent && o.parent !== p.id)
|
|
611
|
+
.map((o) => ({ place: o, km: placeKm(o.coords, at) }))
|
|
612
|
+
.filter((x) => x.km <= NEAR_KM)
|
|
613
|
+
.sort((a, b) => a.km - b.km);
|
|
614
|
+
}
|
|
406
615
|
/** Coordinates checked. */
|
|
407
616
|
export function checkCoords(lat, lon) {
|
|
408
617
|
if (!Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 90 || Math.abs(lon) > 180)
|
|
@@ -421,14 +630,79 @@ function text(v, max, what) {
|
|
|
421
630
|
throw new UsageError(`${what} is ${[...t].length} characters — at most ${max}`, { code: "place.text" });
|
|
422
631
|
return t;
|
|
423
632
|
}
|
|
633
|
+
/**
|
|
634
|
+
* Where a place stands when nobody kept who put it there: coordinates are the person's (personsHand) — a question asked
|
|
635
|
+
* of it never makes them the research's; no coordinates, nothing to keep.
|
|
636
|
+
*/
|
|
637
|
+
function originUnknown(p) {
|
|
638
|
+
return p.coords ? { by: "person", at: p.updated } : { by: "research", at: now() };
|
|
639
|
+
}
|
|
424
640
|
/** Coordinates a person gave, or whose origin nobody knows (before identification was kept): the research never changes them. */
|
|
425
641
|
export function personsHand(p) {
|
|
426
642
|
return !!p.coords && (!p.locating || p.locating.by === "person");
|
|
427
643
|
}
|
|
428
|
-
/**
|
|
429
|
-
|
|
644
|
+
/**
|
|
645
|
+
* A step done on a place takes out of the queue every entry that leads to it now — the forms of its facts, and any
|
|
646
|
+
* form the queue keeps whose writing is a name of the record as the step leaves it (the facts written otherwise since,
|
|
647
|
+
* a rewrite, a merge) — and only those. `keys`: forms the step names itself. `only`: which entries go (all by default).
|
|
648
|
+
*/
|
|
649
|
+
function unqueue(tree, file, changed, keys, only = () => true) {
|
|
650
|
+
const ids = new Set(changed.filter((p) => !p.retracted).map((p) => p.id));
|
|
651
|
+
const byId = new Set(changed.map((p) => p.id));
|
|
652
|
+
const recordOf = recordIndexOf([...tree.list("place").filter((p) => !byId.has(p.id)), ...changed]);
|
|
653
|
+
for (const [k, e] of Object.entries(file.queue)) {
|
|
654
|
+
const p = recordOf(e.form);
|
|
655
|
+
if ((keys.includes(k) && only(e, changed[0])) || (p && ids.has(p.id) && only(e, p)))
|
|
656
|
+
delete file.queue[k];
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* A queue entry whose place was dealt with after it was queued (identified, checked, asked, not located) — what an
|
|
661
|
+
* earlier strom left in the queue when the facts' form of it was written otherwise before the step. An entry the
|
|
662
|
+
* person put back (`back`) is never taken out so.
|
|
663
|
+
*/
|
|
664
|
+
export function settledBy(p, e) {
|
|
665
|
+
if (e.back)
|
|
666
|
+
return false;
|
|
667
|
+
const l = p.locating;
|
|
668
|
+
const asked = !!l?.question && l.question.at >= e.at;
|
|
669
|
+
// checked after it was queued: the time of the check (an earlier strom kept only the day of a check found right — a
|
|
670
|
+
// later day; the same day, or a check undecided of no time, is not known to come after)
|
|
671
|
+
if (e.check)
|
|
672
|
+
return !!p.coords && (asked || (l?.checked ? l.checked >= e.at : !!l?.verified && l.verified > e.at.slice(0, 10)));
|
|
673
|
+
// a place on the map is never queued to be looked for (queuePlaces): on the map since, it was identified
|
|
674
|
+
return !!p.coords || asked || (!!p.unlocated && (l?.at ?? "") >= e.at);
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* The entries of the queue whose place was dealt with already (settledBy) taken out — nothing else: an entry of no
|
|
678
|
+
* record, of a record two places bear, or of a place still to be done stays. One operation; while the person has no
|
|
679
|
+
* step to undo, `strom place undo` puts them back (and they stay then). Inside the tree's lock; the forms taken out.
|
|
680
|
+
*/
|
|
681
|
+
export function settleQueue(tree) {
|
|
682
|
+
const file = readPlacesFile(tree);
|
|
683
|
+
if (!Object.keys(file.queue).length)
|
|
684
|
+
return [];
|
|
685
|
+
const recordOf = recordIndex(tree);
|
|
686
|
+
const gone = {};
|
|
687
|
+
for (const [k, e] of Object.entries(file.queue)) {
|
|
688
|
+
const p = recordOf(e.form);
|
|
689
|
+
if (p && settledBy(p, e))
|
|
690
|
+
gone[k] = e;
|
|
691
|
+
}
|
|
692
|
+
const keys = Object.keys(gone);
|
|
693
|
+
if (!keys.length)
|
|
694
|
+
return [];
|
|
430
695
|
for (const k of keys)
|
|
431
696
|
delete file.queue[k];
|
|
697
|
+
const undo = keepUndo(file, { id: undoId(file), at: now(), places: {}, queue: gone });
|
|
698
|
+
const forms = keys.map((k) => gone[k].form);
|
|
699
|
+
write(tree, [], file, {
|
|
700
|
+
op: "place.settle",
|
|
701
|
+
targets: [],
|
|
702
|
+
reason: "queue entries of places dealt with already, left by an earlier version; nothing else taken out",
|
|
703
|
+
summary: `out of the queue, dealt with already: ${forms.length === 1 ? `place "${forms[0]}"` : `${forms.length} places (${forms.join(", ").slice(0, 600)})`} — undo: strom place undo ${undo}`,
|
|
704
|
+
});
|
|
705
|
+
return forms;
|
|
432
706
|
}
|
|
433
707
|
/**
|
|
434
708
|
* A place identified surely (K2.2, K2.4): its coordinates, names, parent and jurisdictions written into its record (made
|
|
@@ -442,43 +716,30 @@ export function identifyPlace(tree, ref, input) {
|
|
|
442
716
|
const p = t.place;
|
|
443
717
|
const by = input.by ?? "research";
|
|
444
718
|
if (by !== "person" && personsHand(p))
|
|
445
|
-
throw new UsageError(`${p.id} ${p.names[0]?.name}: the coordinates were given by the person${p.locating ? "" : " (or before the research kept who)"} — the research does not change them`, { code: "place.manual", params: { place: p.id }, hint: `the records say otherwise: strom place propose ${p.id} --conflict --candidate …; they agree: strom place verify ${p.id} --ok` });
|
|
719
|
+
throw new UsageError(`${p.id} ${p.names[0]?.name}: the coordinates were given by the person${p.locating ? "" : " (or before the research kept who)"} — the research does not change them`, { code: "place.manual", params: { place: p.id }, hint: `the records say otherwise: strom place propose ${p.id} --conflict --candidate …; they agree: strom place verify ${p.id} --ok (with --alt, --form, --jurisdiction of what was found)` });
|
|
446
720
|
const coords = checkCoords(input.lat, input.lon);
|
|
447
721
|
const why = text(input.why, 1000, "--why");
|
|
448
722
|
if (!why)
|
|
449
723
|
throw new UsageError("why it is this place: --why \"…\" (the parish of the records, the district, the years)", { code: "place.why" });
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
const more = [];
|
|
470
|
-
for (const f of input.forms ?? []) {
|
|
471
|
-
const key = foldText(f.normalize("NFC").trim());
|
|
472
|
-
if (!key)
|
|
473
|
-
continue;
|
|
474
|
-
addName(p, forms.get(key)?.form ?? f);
|
|
475
|
-
more.push(key);
|
|
476
|
-
}
|
|
477
|
-
if (input.kind)
|
|
478
|
-
p.kind = input.kind;
|
|
479
|
-
for (const j of input.jurisdictions ?? [])
|
|
480
|
-
if (!p.jurisdictions.some((x) => x.kind === j.kind && foldText(x.name) === foldText(j.name) && x.from === j.from && x.to === j.to))
|
|
481
|
-
p.jurisdictions.push(j);
|
|
724
|
+
// one place, one record: another record this near is the same place until the agent says it is not
|
|
725
|
+
if (by !== "person") {
|
|
726
|
+
const near = nearRecords(tree, p, coords, new Set((input.separate ?? []).map((s) => placeIdOf(s) ?? s)));
|
|
727
|
+
if (near.length) {
|
|
728
|
+
const o = near[0].place;
|
|
729
|
+
const form = t.made ? p.names[0].name : p.id;
|
|
730
|
+
throw new UsageError(`${t.made ? `"${form}"` : `${p.id} ${p.names[0].name}`} would lie ${near[0].km.toFixed(1)} km from ${near.map((x) => `${x.place.id} ${x.place.names[0].name}`).join(", ")} — the same place written twice?`, {
|
|
731
|
+
code: "place.near",
|
|
732
|
+
params: { place: o.id },
|
|
733
|
+
hint: [
|
|
734
|
+
t.made
|
|
735
|
+
? `the same place: its record takes the form — strom place verify ${o.id} --ok --form "${form}" --why "…"`
|
|
736
|
+
: `the same place: strom place merge ${p.id} --into ${o.id} --reason "…"`,
|
|
737
|
+
`another place close by (a part of it, a neighbour): the same command with --separate ${near.map((x) => x.place.id).join(" --separate ")}`,
|
|
738
|
+
].join("\n"),
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
const { more, dropped, today } = applyDetails(tree, p, t, input, forms);
|
|
482
743
|
const kept = p.locating?.kept;
|
|
483
744
|
p.coords = coords;
|
|
484
745
|
delete p.unlocated;
|
|
@@ -492,15 +753,19 @@ export function identifyPlace(tree, ref, input) {
|
|
|
492
753
|
p.locating = locating;
|
|
493
754
|
p.updated = now();
|
|
494
755
|
const file = readPlacesFile(tree);
|
|
495
|
-
unqueue(file, [...t.keys, ...more]);
|
|
756
|
+
unqueue(tree, file, [p], [...t.keys, ...more]);
|
|
496
757
|
write(tree, [p], file, {
|
|
497
758
|
op: "place.identify",
|
|
498
759
|
targets: [p.id],
|
|
499
|
-
summary: `${p.id} ${p.names[0].name} identified (${by}): ${coords.lat}, ${coords.lon} — ${why.slice(0, 80)}`,
|
|
760
|
+
summary: `${p.id} ${p.names[0].name} identified (${by}): ${coords.lat}, ${coords.lon} — ${why.slice(0, 80)}${detailsSaid(today, dropped)}`,
|
|
761
|
+
...(dropped.length ? { reason: why } : {}),
|
|
500
762
|
});
|
|
501
|
-
return p;
|
|
763
|
+
return { place: p, ...(today ? { today } : {}), dropped };
|
|
502
764
|
});
|
|
503
765
|
}
|
|
766
|
+
function detailsSaid(today, dropped) {
|
|
767
|
+
return `${today ? `; today's name ${today} first` : ""}${dropped.length ? `; taken off as wrong: ${dropped.map((j) => `${j.kind} ${j.name}`).join(", ")}` : ""}`;
|
|
768
|
+
}
|
|
504
769
|
/** Not determined (K2.2): the one state "not located", with why and what would decide it; out of the queue. */
|
|
505
770
|
export function unlocatePlace(tree, ref, why, needs, by = "research") {
|
|
506
771
|
return tree.withTreeLock(() => {
|
|
@@ -525,7 +790,7 @@ export function unlocatePlace(tree, ref, why, needs, by = "research") {
|
|
|
525
790
|
delete p.locating.needs;
|
|
526
791
|
p.updated = now();
|
|
527
792
|
const file = readPlacesFile(tree);
|
|
528
|
-
unqueue(file, t.keys);
|
|
793
|
+
unqueue(tree, file, [p], t.keys);
|
|
529
794
|
write(tree, [p], file, { op: "place.unlocated", targets: [p.id], summary: `${p.id} ${p.names[0].name} not located: ${w.slice(0, 80)}` });
|
|
530
795
|
return p;
|
|
531
796
|
});
|
|
@@ -579,38 +844,126 @@ export function proposePlace(tree, ref, q) {
|
|
|
579
844
|
...(q.change ? { change: q.change } : {}),
|
|
580
845
|
...(why ? { why } : {}),
|
|
581
846
|
};
|
|
582
|
-
p.locating = { ...(p.locating ??
|
|
847
|
+
p.locating = { ...(p.locating ?? originUnknown(p)), question };
|
|
583
848
|
p.updated = now();
|
|
584
849
|
const file = readPlacesFile(tree);
|
|
585
|
-
unqueue(file, t.keys);
|
|
850
|
+
unqueue(tree, file, [p], t.keys);
|
|
586
851
|
write(tree, [p], file, { op: "place.propose", targets: [p.id], summary: `${p.id} ${p.names[0].name}: a question for the person (${q.kind}${q.candidates ? `, ${q.candidates.length} candidates` : ""})` });
|
|
587
852
|
return p;
|
|
588
853
|
});
|
|
589
854
|
}
|
|
590
|
-
/**
|
|
591
|
-
|
|
855
|
+
/**
|
|
856
|
+
* The coordinates checked by the research (K6.6): right (the day kept, and on what), or why it cannot be decided; out
|
|
857
|
+
* of the queue. What the check found of the place beside its coordinates is written too — its other names, the forms
|
|
858
|
+
* of the facts that are it, its jurisdictions (a wrong one taken off, said why) — the coordinates never moved.
|
|
859
|
+
*/
|
|
860
|
+
export function verifyPlace(tree, ref, result, details = {}) {
|
|
592
861
|
return tree.withTreeLock(() => {
|
|
593
862
|
const forms = factForms(tree);
|
|
594
863
|
const t = target(tree, ref, forms);
|
|
595
864
|
const p = t.place;
|
|
596
|
-
if (!p.coords)
|
|
597
|
-
throw new UsageError(`${p.id} has no coordinates to check: strom place identify ${p.id} …`, { code: "place.coords" });
|
|
865
|
+
if (t.made || !p.coords)
|
|
866
|
+
throw new UsageError(`${t.made ? `"${p.names[0].name}"` : p.id} has no coordinates to check: strom place identify ${t.made ? `"${p.names[0].name}"` : p.id} …`, { code: "place.coords" });
|
|
598
867
|
const why = text(result.why, 1000, "--why");
|
|
868
|
+
if (details.drop?.length && !why)
|
|
869
|
+
throw new UsageError('why the jurisdiction is wrong: --why "…" (what the records or the reference say)', { code: "place.why" });
|
|
870
|
+
const { more, dropped, today } = applyDetails(tree, p, t, details, forms);
|
|
599
871
|
const base = p.locating ?? { by: "person", at: p.updated };
|
|
872
|
+
const { verifiedSource: _s, ...kept } = base;
|
|
873
|
+
const source = result.source ? { verifiedSource: { title: text(result.source.title, 300, "--source-title"), ...(result.source.url ? { url: result.source.url.trim() } : {}) } } : {};
|
|
600
874
|
if (result.ok) {
|
|
601
|
-
const { checkWhy: _c, ...rest } =
|
|
602
|
-
|
|
875
|
+
const { checkWhy: _c, verifiedWhy: _w, ...rest } = kept;
|
|
876
|
+
const at = now();
|
|
877
|
+
p.locating = { ...rest, verified: at.slice(0, 10), checked: at, ...(why ? { verifiedWhy: why } : {}), ...source };
|
|
603
878
|
}
|
|
604
879
|
else {
|
|
605
880
|
if (!why)
|
|
606
881
|
throw new UsageError('why it cannot be decided: --undecided "…"', { code: "place.why" });
|
|
607
|
-
p.locating = { ...
|
|
882
|
+
p.locating = { ...kept, checkWhy: why, checked: now(), ...source };
|
|
608
883
|
}
|
|
609
884
|
p.updated = now();
|
|
610
885
|
const file = readPlacesFile(tree);
|
|
611
|
-
unqueue(file, t.keys);
|
|
612
|
-
write(tree, [p], file, {
|
|
613
|
-
|
|
886
|
+
unqueue(tree, file, [p], [...t.keys, ...more]);
|
|
887
|
+
write(tree, [p], file, {
|
|
888
|
+
op: "place.verify",
|
|
889
|
+
targets: [p.id],
|
|
890
|
+
summary: `${p.id} ${p.names[0].name}: ${result.ok ? "checked, right" : `checked, cannot be decided — ${why.slice(0, 60)}`}${detailsSaid(today, dropped)}`,
|
|
891
|
+
...(dropped.length ? { reason: why } : {}),
|
|
892
|
+
});
|
|
893
|
+
return { place: p, ...(today ? { today } : {}), dropped };
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Two records of one place made one (the research sure, with the reason): the duplicate's names, forms and
|
|
898
|
+
* jurisdictions taken into the other, its parts moved under it, the duplicate retracted with where it went. The
|
|
899
|
+
* person's coordinates win: the duplicate's go over when the other has none or only the research's. Two places further
|
|
900
|
+
* apart than NEAR_KM are not merged — that is the person's question. Inside the tree's lock.
|
|
901
|
+
*/
|
|
902
|
+
export function mergePlaces(tree, dupRef, intoRef, reason) {
|
|
903
|
+
return tree.withTreeLock(() => {
|
|
904
|
+
const get = (ref) => {
|
|
905
|
+
const id = placeIdOf(ref);
|
|
906
|
+
const p = id ? tree.get(id) : undefined;
|
|
907
|
+
if (!p || p.type !== "place" || p.retracted)
|
|
908
|
+
throw new UsageError(`no place ${ref}: place IDs (L0012)`, { code: "place.unknown", hint: "strom place list" });
|
|
909
|
+
return p;
|
|
910
|
+
};
|
|
911
|
+
const dup = get(dupRef);
|
|
912
|
+
const into = structuredClone(get(intoRef));
|
|
913
|
+
if (dup.id === into.id)
|
|
914
|
+
throw new UsageError(`${dup.id} is one record`, { code: "place.merge" });
|
|
915
|
+
if (into.parent === dup.id || dup.parent === into.id)
|
|
916
|
+
throw new UsageError(`${dup.id} and ${into.id}: one is a part of the other — not one place`, { code: "place.merge" });
|
|
917
|
+
const why = text(reason, 1000, "--reason");
|
|
918
|
+
if (!why)
|
|
919
|
+
throw new UsageError('why it is one place: --reason "…"', { code: "place.why" });
|
|
920
|
+
if (dup.coords && into.coords) {
|
|
921
|
+
const d = placeKm(dup.coords, into.coords);
|
|
922
|
+
if (d > NEAR_KM)
|
|
923
|
+
throw new UsageError(`${dup.id} and ${into.id} lie ${d.toFixed(1)} km apart — not merged by the research`, {
|
|
924
|
+
code: "place.far",
|
|
925
|
+
hint: `the map is wrong on one: strom place propose ${personsHand(into) ? into.id : dup.id} --conflict --candidate …`,
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
const forms = factForms(tree);
|
|
929
|
+
if (dup.coords && (!into.coords || (personsHand(dup) && !personsHand(into)))) {
|
|
930
|
+
into.coords = { ...dup.coords };
|
|
931
|
+
if (dup.locating)
|
|
932
|
+
into.locating = { ...structuredClone(dup.locating) };
|
|
933
|
+
else
|
|
934
|
+
into.locating = { by: "person", at: dup.updated };
|
|
935
|
+
delete into.unlocated;
|
|
936
|
+
}
|
|
937
|
+
for (const n of dup.names) {
|
|
938
|
+
const plain = !n.lang && n.from === undefined && n.to === undefined;
|
|
939
|
+
addPlaceName(into, n.name, n.kind ?? (plain && forms.has(foldText(n.name)) ? "form" : "alt"), n);
|
|
940
|
+
}
|
|
941
|
+
for (const j of dup.jurisdictions)
|
|
942
|
+
if (!into.jurisdictions.some((x) => x.kind === j.kind && foldText(x.name) === foldText(j.name) && x.from === j.from && x.to === j.to))
|
|
943
|
+
into.jurisdictions.push(structuredClone(j));
|
|
944
|
+
if (!into.kind && dup.kind)
|
|
945
|
+
into.kind = dup.kind;
|
|
946
|
+
if (!into.parent && dup.parent && dup.parent !== into.id)
|
|
947
|
+
into.parent = dup.parent;
|
|
948
|
+
into.notes = [...into.notes, ...dup.notes.map((n) => structuredClone(n))];
|
|
949
|
+
const t = now();
|
|
950
|
+
into.updated = t;
|
|
951
|
+
const parts = tree
|
|
952
|
+
.list("place")
|
|
953
|
+
.filter((x) => !x.retracted && x.parent === dup.id && x.id !== into.id)
|
|
954
|
+
.map((x) => ({ ...structuredClone(x), parent: into.id, updated: t }));
|
|
955
|
+
const gone = { ...structuredClone(dup), retracted: { at: t, reason: `the same place as ${into.id}: ${why}` }, mergedInto: into.id, updated: t };
|
|
956
|
+
// the queue's forms of the duplicate lead to the one record now: out where it was dealt with already
|
|
957
|
+
const file = readPlacesFile(tree);
|
|
958
|
+
const was = JSON.stringify(file.queue);
|
|
959
|
+
unqueue(tree, file, [into, gone, ...parts], [], (e, p) => settledBy(p, e));
|
|
960
|
+
write(tree, [into, gone, ...parts], JSON.stringify(file.queue) !== was ? file : undefined, {
|
|
961
|
+
op: "place.merge",
|
|
962
|
+
targets: [into.id, dup.id, ...parts.map((x) => x.id)].sort(),
|
|
963
|
+
summary: `${dup.id} ${dup.names[0].name} merged into ${into.id} ${into.names[0].name}${parts.length ? ` (its parts ${parts.map((x) => x.id).join(", ")} with it)` : ""}`,
|
|
964
|
+
reason: why,
|
|
965
|
+
});
|
|
966
|
+
return into;
|
|
614
967
|
});
|
|
615
968
|
}
|
|
616
969
|
// ── the facts' places set right (K3) ────────────────────────────────────────────────────────────────────────────
|
|
@@ -685,7 +1038,7 @@ export function rewriteForm(tree, form, to, opts) {
|
|
|
685
1038
|
if (opts.propose) {
|
|
686
1039
|
const why = text(opts.why ?? opts.reason, 1000, "--why");
|
|
687
1040
|
place.locating = {
|
|
688
|
-
...(place.locating ??
|
|
1041
|
+
...(place.locating ?? originUnknown(place)),
|
|
689
1042
|
question: { kind: "form", at: now(), change: { from: { place: form.normalize("NFC").trim() }, to: { place: name, ...(to.house ? { house: to.house } : {}) }, facts: changes.length }, ...(why ? { why } : {}) },
|
|
690
1043
|
};
|
|
691
1044
|
place.updated = now();
|
|
@@ -697,7 +1050,32 @@ export function rewriteForm(tree, form, to, opts) {
|
|
|
697
1050
|
throw new UsageError('why the facts write it so: --reason "…"', { code: "place.why" });
|
|
698
1051
|
const { records, done, left: gone } = applyChanges(tree, changes, "before");
|
|
699
1052
|
place.updated = now();
|
|
700
|
-
|
|
1053
|
+
// the form's entry of the queue leads to the record now: out where the place was dealt with already
|
|
1054
|
+
const file = readPlacesFile(tree);
|
|
1055
|
+
const was = JSON.stringify(file.queue);
|
|
1056
|
+
// the form's entry no fact writes now and no one record bears (another place of that name: found on 1.26.0-beta.2)
|
|
1057
|
+
// goes with the facts to their place — the person's check never left of no place, and checked only there
|
|
1058
|
+
const key = foldText(form.normalize("NFC").trim());
|
|
1059
|
+
const e = file.queue[key];
|
|
1060
|
+
const recordOf = recordIndexOf([...tree.list("place").filter((x) => x.id !== place.id), place]);
|
|
1061
|
+
if (e && !left.length && !gone && recordOf(e.form)?.id !== place.id) {
|
|
1062
|
+
const to = foldText(name);
|
|
1063
|
+
const there = file.queue[to];
|
|
1064
|
+
const check = !!place.coords && (!!e.check || !!there?.check);
|
|
1065
|
+
const flag = there?.flag ?? e.flag;
|
|
1066
|
+
const back = !!there?.back || !!e.back;
|
|
1067
|
+
delete file.queue[key];
|
|
1068
|
+
file.queue[to] = {
|
|
1069
|
+
form: there?.form ?? name,
|
|
1070
|
+
at: there && there.at > e.at ? there.at : e.at,
|
|
1071
|
+
by: there?.by === "person" || e.by === "person" ? "person" : "research",
|
|
1072
|
+
...(check ? { check: true } : {}),
|
|
1073
|
+
...(flag ? { flag } : {}),
|
|
1074
|
+
...(back ? { back: true } : {}),
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
unqueue(tree, file, [place], [], (e, x) => settledBy(x, e));
|
|
1078
|
+
write(tree, [place, ...records], JSON.stringify(file.queue) !== was ? file : undefined, {
|
|
701
1079
|
op: "place.rewrite",
|
|
702
1080
|
targets: [place.id, ...records.map((r) => r.id)].sort(),
|
|
703
1081
|
summary: `the facts' place "${form}" → ${name}${to.house ? ` (house ${to.house})` : ""}: ${done.length} facts in ${records.length} records${left.length ? `, ${left.length} left (another house)` : ""}`,
|
|
@@ -801,12 +1179,12 @@ export function answerPlaces(tree, ids, take, opts = {}) {
|
|
|
801
1179
|
const file = readPlacesFile(tree);
|
|
802
1180
|
const before = new Map(places.map((p) => [p.id, tree.get(p.id)]));
|
|
803
1181
|
const undo = {
|
|
804
|
-
id:
|
|
1182
|
+
id: undoId(file),
|
|
805
1183
|
at: now(),
|
|
806
1184
|
places: Object.fromEntries(places.map((p) => [p.id, { before: before.get(p.id), after: p.updated }])),
|
|
807
1185
|
...(done.length ? { facts: done } : {}),
|
|
808
1186
|
};
|
|
809
|
-
file
|
|
1187
|
+
keepUndo(file, undo);
|
|
810
1188
|
write(tree, [...places, ...records], file, {
|
|
811
1189
|
op: "place.answer",
|
|
812
1190
|
targets: [...places.map((p) => p.id), ...records.map((r) => r.id)].sort(),
|
|
@@ -817,15 +1195,16 @@ export function answerPlaces(tree, ids, take, opts = {}) {
|
|
|
817
1195
|
});
|
|
818
1196
|
}
|
|
819
1197
|
/**
|
|
820
|
-
*
|
|
821
|
-
*
|
|
1198
|
+
* A step on places undone by its ID (the person's answers, a whole batch of them; names taken off; the queue tidied —
|
|
1199
|
+
* each of the last UNDO_KEPT once): each place as it was where nothing changed it since, the facts' places back where
|
|
1200
|
+
* still so, the queue's entries back. Inside the tree's lock.
|
|
822
1201
|
*/
|
|
823
1202
|
export function undoPlaces(tree, id) {
|
|
824
1203
|
return tree.withTreeLock(() => {
|
|
825
1204
|
const file = readPlacesFile(tree);
|
|
826
|
-
const u = file.undo;
|
|
827
|
-
if (!u
|
|
828
|
-
throw new UsageError(
|
|
1205
|
+
const u = file.undo?.id === id ? file.undo : file.undos?.find((x) => x.id === id);
|
|
1206
|
+
if (!u)
|
|
1207
|
+
throw new UsageError(`nothing to undo of that step (each step on places once, the last ${UNDO_KEPT})`, { code: "places.undo.later" });
|
|
829
1208
|
const restored = [];
|
|
830
1209
|
const left = [];
|
|
831
1210
|
const records = [];
|
|
@@ -840,13 +1219,42 @@ export function undoPlaces(tree, id) {
|
|
|
840
1219
|
restored.push(pid);
|
|
841
1220
|
}
|
|
842
1221
|
if (left.length)
|
|
843
|
-
throw new UsageError(`changed since: ${left.join(", ")} — the step is not undone
|
|
1222
|
+
throw new UsageError(`changed since: ${left.join(", ")} — the step is not undone (a later step on the same place is undone first)`, {
|
|
1223
|
+
code: "places.undo.later",
|
|
1224
|
+
params: { places: left.join(", ") },
|
|
1225
|
+
});
|
|
1226
|
+
// an earlier step still to be undone that left a place as this undo puts it back: it knows the place by its new time
|
|
1227
|
+
for (const r of records) {
|
|
1228
|
+
const was = u.places[r.id].before.updated;
|
|
1229
|
+
for (const x of [file.undo, ...(file.undos ?? [])])
|
|
1230
|
+
if (x && x !== u && x.places[r.id]?.after === was)
|
|
1231
|
+
x.places[r.id] = { ...x.places[r.id], after: r.updated };
|
|
1232
|
+
}
|
|
844
1233
|
const back = applyChanges(tree, u.facts ?? [], "after");
|
|
845
|
-
|
|
1234
|
+
// the queue's entries the step took out: back, and kept there (never taken out by the tidying again)
|
|
1235
|
+
let queued = 0;
|
|
1236
|
+
for (const [k, e] of Object.entries(u.queue ?? {})) {
|
|
1237
|
+
if (file.queue[k])
|
|
1238
|
+
continue;
|
|
1239
|
+
file.queue[k] = { ...e, back: true };
|
|
1240
|
+
queued++;
|
|
1241
|
+
}
|
|
1242
|
+
// out of the steps to be undone; the one before it the newest again
|
|
1243
|
+
if (file.undo === u) {
|
|
1244
|
+
const before = file.undos?.pop();
|
|
1245
|
+
if (before)
|
|
1246
|
+
file.undo = before;
|
|
1247
|
+
else
|
|
1248
|
+
delete file.undo;
|
|
1249
|
+
}
|
|
1250
|
+
else
|
|
1251
|
+
file.undos = file.undos.filter((x) => x !== u);
|
|
1252
|
+
if (!file.undos?.length)
|
|
1253
|
+
delete file.undos;
|
|
846
1254
|
write(tree, [...records, ...back.records], file, {
|
|
847
1255
|
op: "place.undo",
|
|
848
1256
|
targets: [...restored, ...back.records.map((r) => r.id)].sort(),
|
|
849
|
-
summary: `
|
|
1257
|
+
summary: `a step on places undone (${u.id}): ${restored.length} places${back.done.length ? `, ${back.done.length} facts' places back` : ""}${queued ? `, ${queued} back in the queue` : ""}`,
|
|
850
1258
|
});
|
|
851
1259
|
return { restored, left };
|
|
852
1260
|
});
|
|
@@ -904,6 +1312,8 @@ export function raisePlacesTask(tree, archive) {
|
|
|
904
1312
|
if (tree.dryRun || archive)
|
|
905
1313
|
return undefined;
|
|
906
1314
|
return tree.withTreeLock(() => {
|
|
1315
|
+
// what an earlier version left in the queue of places dealt with already: out first, so the count is right
|
|
1316
|
+
settleQueue(tree);
|
|
907
1317
|
const file = readPlacesFile(tree);
|
|
908
1318
|
const forms = factForms(tree);
|
|
909
1319
|
const recordOf = recordIndex(tree);
|
|
@@ -964,21 +1374,265 @@ export function raisePlacesTask(tree, archive) {
|
|
|
964
1374
|
return task.id;
|
|
965
1375
|
});
|
|
966
1376
|
}
|
|
1377
|
+
/**
|
|
1378
|
+
* Names of a place record found wrong (a slip of a record kept as a form, another place's name) taken off, with the
|
|
1379
|
+
* reason: never today's name (the first), never a name a fact of the tree still writes for this place (set the facts
|
|
1380
|
+
* right first: strom place rewrite). One operation; while the person has no step to undo, `strom place undo` puts them
|
|
1381
|
+
* back. Inside the tree's lock.
|
|
1382
|
+
*/
|
|
1383
|
+
export function dropPlaceNames(tree, ref, names, reason) {
|
|
1384
|
+
return tree.withTreeLock(() => {
|
|
1385
|
+
const id = placeIdOf(ref);
|
|
1386
|
+
const was = id ? tree.get(id) : undefined;
|
|
1387
|
+
if (!was || was.type !== "place" || was.retracted)
|
|
1388
|
+
throw new UsageError(`no place ${ref}: a place ID (L0012)`, { code: "place.unknown", hint: "strom place list" });
|
|
1389
|
+
const why = text(reason, 1000, "--why");
|
|
1390
|
+
if (!why)
|
|
1391
|
+
throw new UsageError('why the name is wrong: --why "…"', { code: "place.why" });
|
|
1392
|
+
if (!names.length)
|
|
1393
|
+
throw new UsageError('which name: --drop "<name>"', { code: "place.name" });
|
|
1394
|
+
const p = structuredClone(was);
|
|
1395
|
+
const forms = factForms(tree);
|
|
1396
|
+
const recordOf = recordIndex(tree);
|
|
1397
|
+
const dropped = [];
|
|
1398
|
+
for (const raw of names) {
|
|
1399
|
+
const n = raw.normalize("NFC").trim();
|
|
1400
|
+
const key = foldText(n);
|
|
1401
|
+
// the very writing first, else any of the same letters (any accent)
|
|
1402
|
+
const exact = p.names.findIndex((x) => x.name === n);
|
|
1403
|
+
const hit = exact >= 0 ? exact : p.names.findIndex((x) => foldText(x.name) === key);
|
|
1404
|
+
if (hit < 0)
|
|
1405
|
+
throw new UsageError(`${p.id} has no name "${n}" (it has ${p.names.map((x) => x.name).join(", ")})`, { code: "place.name" });
|
|
1406
|
+
if (hit === 0)
|
|
1407
|
+
throw new UsageError(`"${n}" is the first name of ${p.id} — today's name is set with --name (strom place verify ${p.id} --ok --name "…")`, { code: "place.name" });
|
|
1408
|
+
const f = forms.get(foldText(p.names[hit].name));
|
|
1409
|
+
if (f && recordOf(f.form)?.id === p.id && !p.names.some((x, i) => i !== hit && foldText(x.name) === foldText(p.names[hit].name)))
|
|
1410
|
+
throw new UsageError(`${f.facts === 1 ? "a fact" : `${f.facts} facts`} of the tree still write "${f.form}" — set them right first: strom place rewrite "${f.form}" --to ${p.id} --reason "…"`, { code: "place.form" });
|
|
1411
|
+
dropped.push(p.names[hit]);
|
|
1412
|
+
p.names.splice(hit, 1);
|
|
1413
|
+
}
|
|
1414
|
+
p.updated = now();
|
|
1415
|
+
const file = readPlacesFile(tree);
|
|
1416
|
+
const undo = keepUndo(file, { id: undoId(file), at: now(), places: { [p.id]: { before: was, after: p.updated } } });
|
|
1417
|
+
write(tree, [p], file, {
|
|
1418
|
+
op: "place.names",
|
|
1419
|
+
targets: [p.id],
|
|
1420
|
+
reason: why,
|
|
1421
|
+
summary: `${p.id} ${p.names[0].name}: name${dropped.length === 1 ? "" : "s"} taken off as wrong: ${dropped.map((x) => `${x.name}${x.lang ? `@${x.lang}` : ""}`).join(", ")} — ${why.slice(0, 80)} — undo: strom place undo ${undo}`,
|
|
1422
|
+
});
|
|
1423
|
+
return { place: p, dropped, undo };
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1426
|
+
/**
|
|
1427
|
+
* The other names history's map is told of a place (strom history map --also) written among its record's names too —
|
|
1428
|
+
* the record is where the app and the identification read them. Nothing where the facts' place has no one record.
|
|
1429
|
+
* Inside the tree's lock.
|
|
1430
|
+
*/
|
|
1431
|
+
export function addOtherNames(tree, form, also) {
|
|
1432
|
+
if (!also.length || tree.dryRun)
|
|
1433
|
+
return undefined;
|
|
1434
|
+
return tree.withTreeLock(() => {
|
|
1435
|
+
const r = recordIndex(tree)(form);
|
|
1436
|
+
if (!r)
|
|
1437
|
+
return undefined;
|
|
1438
|
+
const p = structuredClone(r);
|
|
1439
|
+
for (const a of also) {
|
|
1440
|
+
let n;
|
|
1441
|
+
try {
|
|
1442
|
+
n = parseAltName(a);
|
|
1443
|
+
}
|
|
1444
|
+
catch {
|
|
1445
|
+
continue;
|
|
1446
|
+
}
|
|
1447
|
+
addPlaceName(p, n.name, "alt", n);
|
|
1448
|
+
}
|
|
1449
|
+
if (JSON.stringify(p.names) === JSON.stringify(r.names))
|
|
1450
|
+
return undefined;
|
|
1451
|
+
p.updated = now();
|
|
1452
|
+
write(tree, [p], undefined, { op: "place.names", targets: [p.id], summary: `${p.id} ${p.names[0].name}: other names ${also.join(", ")}` });
|
|
1453
|
+
return p;
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
/** The repairs of the place records done once per tree (repairPlaces): raised when a new one is added. */
|
|
1457
|
+
export const PLACES_REPAIR = 1;
|
|
1458
|
+
/** A question asked of coordinates nobody kept the origin of, written by 1.25 as the research's (its origin the question's moment). */
|
|
1459
|
+
function originByQuestion(p) {
|
|
1460
|
+
const l = p.locating;
|
|
1461
|
+
const q = l?.question;
|
|
1462
|
+
return !!p.coords && !!l && !!q && l.by === "research" && !l.why && !l.source && !l.verified && Math.abs(Date.parse(l.at) - Date.parse(q.at)) < 1000;
|
|
1463
|
+
}
|
|
1464
|
+
/**
|
|
1465
|
+
* Another name of no language a record has twice (addPlaceName of an older strom): the same words are today's name, or
|
|
1466
|
+
* another name with a language whose years are the same or this one has none.
|
|
1467
|
+
*/
|
|
1468
|
+
function twiceWithLanguage(p, n) {
|
|
1469
|
+
if (n.kind !== "alt" || n.lang)
|
|
1470
|
+
return false;
|
|
1471
|
+
const key = foldText(n.name);
|
|
1472
|
+
const noYears = n.from === undefined && n.to === undefined;
|
|
1473
|
+
return p.names.some((m, i) => m !== n && foldText(m.name) === key && (i === 0 ? noYears : !!m.lang && (noYears || (m.from === n.from && m.to === n.to))));
|
|
1474
|
+
}
|
|
1475
|
+
/**
|
|
1476
|
+
* Once per tree (PLACES_REPAIR), what earlier versions wrote wrong into the place records set right — nothing taken
|
|
1477
|
+
* away: a name kept with its language and years as its text ("Lhotow@cs:1844") read as what it says; coordinates a
|
|
1478
|
+
* question made the research's (their origin unknown) the person's again; today's name first on a place the research
|
|
1479
|
+
* identified while the old name was first; the other names history's map keeps of a place (strom history map --also)
|
|
1480
|
+
* written among the record's names too; a name written twice (once with its language, once of none) kept once. One
|
|
1481
|
+
* operation; while the person has no step to undo, `strom place undo` takes it back. Inside the tree's lock.
|
|
1482
|
+
*/
|
|
1483
|
+
export function repairPlaces(tree) {
|
|
1484
|
+
const file = readPlacesFile(tree);
|
|
1485
|
+
if ((file.repaired ?? 0) >= PLACES_REPAIR)
|
|
1486
|
+
return [];
|
|
1487
|
+
const forms = factForms(tree);
|
|
1488
|
+
const recordOf = recordIndex(tree);
|
|
1489
|
+
const changed = new Map();
|
|
1490
|
+
const said = [];
|
|
1491
|
+
const copy = (p) => changed.get(p.id) ?? structuredClone(p);
|
|
1492
|
+
for (const p0 of tree.list("place")) {
|
|
1493
|
+
if (p0.retracted)
|
|
1494
|
+
continue;
|
|
1495
|
+
let p = copy(p0);
|
|
1496
|
+
const notes = [];
|
|
1497
|
+
// a name with its syntax as text
|
|
1498
|
+
p.names = p.names.map((n, i) => {
|
|
1499
|
+
const parsed = nameWithSyntax(n);
|
|
1500
|
+
if (!parsed)
|
|
1501
|
+
return n;
|
|
1502
|
+
notes.push(`"${n.name}" read as ${parsed.name}`);
|
|
1503
|
+
const { kind: _k, ...name } = parsed;
|
|
1504
|
+
return i === 0 ? name : { ...name, kind: n.kind ?? "alt" };
|
|
1505
|
+
});
|
|
1506
|
+
if (originByQuestion(p)) {
|
|
1507
|
+
p.locating = { ...p.locating, by: "person", at: p.updated };
|
|
1508
|
+
notes.push("the coordinates the person's again (a question had made them the research's)");
|
|
1509
|
+
}
|
|
1510
|
+
if (p.locating && p.locating.by !== "person") {
|
|
1511
|
+
const a = todayName(p, yearsOf(p, forms));
|
|
1512
|
+
if (a) {
|
|
1513
|
+
setFirst(p, a, forms);
|
|
1514
|
+
notes.push(`today's name ${a.name} first`);
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
if (notes.length) {
|
|
1518
|
+
p.updated = now();
|
|
1519
|
+
changed.set(p.id, p);
|
|
1520
|
+
said.push(`${p.id} ${notes.join("; ")}`);
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
// the other names history's map keeps of a place: among the record's names too
|
|
1524
|
+
for (const [key, m] of Object.entries(readPlaces(tree))) {
|
|
1525
|
+
if (!m?.also?.length)
|
|
1526
|
+
continue;
|
|
1527
|
+
const r = recordOf(key);
|
|
1528
|
+
if (!r)
|
|
1529
|
+
continue;
|
|
1530
|
+
const p = copy(r);
|
|
1531
|
+
const before = JSON.stringify(p.names);
|
|
1532
|
+
for (const a of m.also) {
|
|
1533
|
+
let n;
|
|
1534
|
+
try {
|
|
1535
|
+
n = parseAltName(a);
|
|
1536
|
+
}
|
|
1537
|
+
catch {
|
|
1538
|
+
continue;
|
|
1539
|
+
}
|
|
1540
|
+
addPlaceName(p, n.name, "alt", n);
|
|
1541
|
+
}
|
|
1542
|
+
if (JSON.stringify(p.names) !== before) {
|
|
1543
|
+
p.updated = now();
|
|
1544
|
+
changed.set(p.id, p);
|
|
1545
|
+
said.push(`${p.id} the names of history's map: ${m.also.join(", ")}`);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
// a name written twice by an older strom: another name of no language (and no years of its own) beside the same words
|
|
1549
|
+
// with a language, or the same as today's — the one of no language goes, nothing it said is lost
|
|
1550
|
+
for (const p0 of tree.list("place")) {
|
|
1551
|
+
if (p0.retracted)
|
|
1552
|
+
continue;
|
|
1553
|
+
const p = copy(p0);
|
|
1554
|
+
const twice = p.names.filter((n, i) => i > 0 && twiceWithLanguage(p, n));
|
|
1555
|
+
if (!twice.length)
|
|
1556
|
+
continue;
|
|
1557
|
+
p.names = p.names.filter((n) => !twice.includes(n));
|
|
1558
|
+
p.updated = now();
|
|
1559
|
+
changed.set(p.id, p);
|
|
1560
|
+
said.push(`${p.id} written twice: ${twice.map((n) => n.name).join(", ")}`);
|
|
1561
|
+
}
|
|
1562
|
+
const records = [...changed.values()];
|
|
1563
|
+
// nothing to set right: nothing written (looked at again next time — a tree that never had places stays without the file)
|
|
1564
|
+
if (!records.length)
|
|
1565
|
+
return [];
|
|
1566
|
+
file.repaired = PLACES_REPAIR;
|
|
1567
|
+
const undo = keepUndo(file, {
|
|
1568
|
+
id: undoId(file),
|
|
1569
|
+
at: now(),
|
|
1570
|
+
places: Object.fromEntries(records.map((p) => [p.id, { before: tree.get(p.id), after: p.updated }])),
|
|
1571
|
+
});
|
|
1572
|
+
write(tree, records, file, {
|
|
1573
|
+
op: "place.repair",
|
|
1574
|
+
targets: records.map((p) => p.id).sort(),
|
|
1575
|
+
// a name read from its syntax, or written twice, is no name lost: the data check takes the step as reasoned
|
|
1576
|
+
reason: "what an earlier version wrote into the place records set right, nothing lost (undo kept)",
|
|
1577
|
+
summary: `place records set right (${records.length}): ${said.join(" · ").slice(0, 1500)} — undo: strom place undo ${undo}`,
|
|
1578
|
+
});
|
|
1579
|
+
return said;
|
|
1580
|
+
}
|
|
1581
|
+
/**
|
|
1582
|
+
* What a session on the places did, counted by strom from its own log and said in the research's language — the
|
|
1583
|
+
* agent's summary follows it. Checked is never identified: a place whose coordinates were right already is "verified".
|
|
1584
|
+
*/
|
|
1585
|
+
export function placesSessionCounts(tree, sessionId) {
|
|
1586
|
+
const n = { identified: 0, verified: 0, undecided: 0, asked: 0, unlocated: 0, merged: 0 };
|
|
1587
|
+
for (const file of opsLogsOf(tree.dataDir, sessionId)) {
|
|
1588
|
+
for (const line of fs.readFileSync(file, "utf8").split("\n")) {
|
|
1589
|
+
if (!line.trim())
|
|
1590
|
+
continue;
|
|
1591
|
+
let op;
|
|
1592
|
+
try {
|
|
1593
|
+
op = JSON.parse(line);
|
|
1594
|
+
}
|
|
1595
|
+
catch {
|
|
1596
|
+
continue;
|
|
1597
|
+
}
|
|
1598
|
+
if (op.op === "place.identify")
|
|
1599
|
+
n.identified++;
|
|
1600
|
+
else if (op.op === "place.verify")
|
|
1601
|
+
n[/: checked, right/.test(op.summary ?? "") ? "verified" : "undecided"]++;
|
|
1602
|
+
else if (op.op === "place.propose")
|
|
1603
|
+
n.asked++;
|
|
1604
|
+
else if (op.op === "place.unlocated")
|
|
1605
|
+
n.unlocated++;
|
|
1606
|
+
else if (op.op === "place.merge")
|
|
1607
|
+
n.merged++;
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
return Object.keys(n)
|
|
1611
|
+
.filter((k) => n[k] > 0)
|
|
1612
|
+
.map((k) => phrase(tree.lang, `places.count.${k}`, { n: n[k] }))
|
|
1613
|
+
.join(", ");
|
|
1614
|
+
}
|
|
967
1615
|
/**
|
|
968
1616
|
* Where tasks are raised (a session's start and end, the frontier applied): history's places not located taken into
|
|
969
|
-
* the records once, then the task of identification kept with the
|
|
1617
|
+
* the records once, what earlier versions wrote wrong set right once, then the task of identification kept with the
|
|
1618
|
+
* queue. Never in an archive or a dry run.
|
|
970
1619
|
*/
|
|
971
1620
|
export function keepPlacesTask(tree) {
|
|
972
1621
|
if (tree.dryRun || actsAsArchive(tree))
|
|
973
1622
|
return undefined;
|
|
974
1623
|
tree.withTreeLock(() => convertHistoryUnlocated(tree, readPlaces(tree)));
|
|
1624
|
+
tree.withTreeLock(() => repairPlaces(tree));
|
|
975
1625
|
return raisePlacesTask(tree, false);
|
|
976
1626
|
}
|
|
977
|
-
/** The jurisdiction of "<kind>:<name>[:from-to]". */
|
|
1627
|
+
/** The jurisdiction of "<kind>:<name>[:from-to]" — one year (":1899"): known so in that year, from and to alike. */
|
|
978
1628
|
export function parseJurisdiction(s) {
|
|
979
|
-
const m = /^([^:]+):([^:]+?)(?::(\d{3,4})?-(\d{3,4})
|
|
1629
|
+
const m = /^([^:]+):([^:]+?)(?::(?:(\d{3,4})?-(\d{3,4})?|(\d{3,4})))?$/u.exec(s.normalize("NFC").trim());
|
|
980
1630
|
const kind = m?.[1]?.trim();
|
|
981
1631
|
if (!m || !kind || !JURISDICTIONS.includes(kind))
|
|
982
|
-
throw new UsageError(`--jurisdiction "${s}": "<kind>:<name>[:from-to]", kind one of ${JURISDICTIONS.join(", ")}`, { code: "place.jurisdiction" });
|
|
983
|
-
|
|
1632
|
+
throw new UsageError(`--jurisdiction "${s}": "<kind>:<name>[:from-to]" or one year "<kind>:<name>:<year>", kind one of ${JURISDICTIONS.join(", ")}`, { code: "place.jurisdiction" });
|
|
1633
|
+
const from = m[3] ?? m[5];
|
|
1634
|
+
const to = m[4] ?? m[5];
|
|
1635
|
+
if (from && to && Number(from) > Number(to))
|
|
1636
|
+
throw new UsageError(`--jurisdiction "${s}": the years ${from}-${to} go backwards`, { code: "place.jurisdiction" });
|
|
1637
|
+
return { kind: kind, name: m[2].trim(), recordsets: [], ...(from ? { from: Number(from) } : {}), ...(to ? { to: Number(to) } : {}) };
|
|
984
1638
|
}
|