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.
@@ -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
- return { queue, ...(f?.undo ? { undo: f.undo } : {}), ...(f?.converted ? { converted: f.converted } : {}) };
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 tree.list("place")) {
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
- /** The queue cut into sessions: the places of one area together, at most BATCH_MAX in each, in the queue's order. */
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
- for (const rows of byArea.values())
156
- for (let i = 0; i < rows.length; i += BATCH_MAX)
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
- if ([w.key, ...others].some((k) => file.queue[k])) {
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 given to a form of the
365
- * facts of the same words takes its place (it says more — the language, the years), and the same name of the same
366
- * language already there (a form, or of no known kind: written before kinds were kept) becomes it, its years filled in
367
- * where it had none. Today's name (the first) is never changed.
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
- const form = same.find(({ x, i }) => i > 0 && x.kind === "form" && x.name === n);
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
- /** The forms queued for a place, and the place's forms, out of the queue. */
429
- function unqueue(file, keys) {
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
- if (input.parent) {
451
- const parent = tree.get(input.parent);
452
- if (!parent || parent.type !== "place" || parent.id === p.id)
453
- throw new UsageError(`no place ${input.parent} to be its parent`, { code: "place.unknown" });
454
- p.parent = parent.id;
455
- }
456
- if (input.name) {
457
- const name = text(input.name, 300, "--name");
458
- const was = p.names[0];
459
- // the name that was first stays as what it is: a form of the facts, or another name of the place
460
- const rest = p.names
461
- .map((n) => (n === was && !n.kind ? { ...n, kind: forms.has(foldText(n.name)) ? "form" : "alt" } : n))
462
- .filter((n) => !(foldText(n.name) === foldText(name) && !n.lang));
463
- p.names = [{ name }, ...rest];
464
- }
465
- for (const a of input.alt ?? [])
466
- addPlaceName(p, a.name, "alt", a);
467
- for (const k of t.keys)
468
- addName(p, forms.get(k)?.form ?? k);
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 ?? { by: "research", at: now() }), question };
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
- /** The coordinates checked by the research (K6.6): right (the day kept), or why it cannot be decided; out of the queue. */
591
- export function verifyPlace(tree, ref, result) {
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 } = base;
602
- p.locating = { ...rest, verified: now().slice(0, 10) };
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 = { ...base, checkWhy: why };
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, { op: "place.verify", targets: [p.id], summary: `${p.id} ${p.names[0].name}: ${result.ok ? "checked, right" : `checked, cannot be decided — ${why.slice(0, 60)}`}` });
613
- return p;
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 ?? { by: "research", at: now() }),
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
- write(tree, [place, ...records], undefined, {
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: `u${Date.now().toString(36)}`,
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.undo = undo;
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
- * The person's last step on places undone (answers, a whole batch of them): each place as it was where nothing changed
821
- * it since, the facts' places back where still so. Inside the tree's lock.
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 || u.id !== id)
828
- throw new UsageError("nothing to undo of that step (only the last step on places, once)", { code: "places.undo.later" });
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`, { code: "places.undo.later", params: { places: left.join(", ") } });
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
- delete file.undo;
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: `the last step on places undone: ${restored.length} places${back.done.length ? `, ${back.done.length} facts' places back` : ""}`,
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 queue. Never in an archive or a dry run.
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})?)?$/u.exec(s.normalize("NFC").trim());
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
- return { kind: kind, name: m[2].trim(), recordsets: [], ...(m[3] ? { from: Number(m[3]) } : {}), ...(m[4] ? { to: Number(m[4]) } : {}) };
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
  }