strom-research 1.13.0-beta.6 → 1.13.0-beta.8

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.
@@ -769,6 +769,7 @@
769
769
  "ui.sync.do.title.user": "platí úprava z aplikace (verze výzkumu zůstane v historii)",
770
770
  "ui.sync.sex.changed": "{n}. {who}: pohlaví {text}",
771
771
  "ui.sync.note.new": "{n}. {who}: poznámka — „{text}“",
772
+ "ui.sync.note.changed": "{n}. {who}: poznámka — „{old}“ → „{new}“",
772
773
  "ui.sync.source.new": "{n}. pramen: „{text}“",
773
774
  "ui.sync.source.changed": "{n}. pramen z aplikace „{text}“: opravený přepis nebo stránka",
774
775
  "ui.sync.source.reading": "{n}. pramen výzkumu „{text}“: v aplikaci přečtený jinak",
@@ -809,6 +810,7 @@
809
810
  "ui.sync.place.located": "{n}. {place}: poloha na mapě {at}",
810
811
  "ui.sync.do.add": "přidá se jako vodítko",
811
812
  "ui.sync.do.correct": "opraví vodítko výzkumu",
813
+ "ui.sync.do.note.changed": "ten řádek poznámky výzkumu se opraví",
812
814
  "ui.sync.do.detail": "doplní se k údaji s odkazem na strom z aplikace",
813
815
  "ui.sync.do.refine": "doplní se k údaji s odkazem na strom z aplikace (záznamu nic neodporuje)",
814
816
  "ui.sync.do.conflict": "rozpor k rozhodnutí (výzkum to má ze záznamu)",
@@ -1283,6 +1285,7 @@
1283
1285
  "log.repository.retract": "Odvolaný archiv: {name}",
1284
1286
  "log.name.remove": "Odebrané jméno: {name} – {who}",
1285
1287
  "log.note.remove": "Odebraná poznámka: „{note}“ – {name}",
1288
+ "log.note.edit": "Opravená poznámka: {name}",
1286
1289
  "log.event.status": "Změněná jistota údaje: {who} – {fact}",
1287
1290
  "log.event.restore": "Údaj znovu platí: {who} – {fact}",
1288
1291
  "log.input.sync.undo": "Vrácený rodokmen: {name}",
@@ -769,6 +769,7 @@
769
769
  "ui.sync.do.title.user": "Die eigene Änderung gilt (die der Forschung bleibt in ihrer Geschichte)",
770
770
  "ui.sync.sex.changed": "{n}. {who}: Geschlecht {text}",
771
771
  "ui.sync.note.new": "{n}. {who}: eine Notiz — „{text}“",
772
+ "ui.sync.note.changed": "{n}. {who}: eine Notiz — „{old}“ → „{new}“",
772
773
  "ui.sync.source.new": "{n}. eine Quelle: „{text}“",
773
774
  "ui.sync.source.changed": "{n}. eigene Quelle „{text}“: Abschrift oder Seite korrigiert",
774
775
  "ui.sync.source.reading": "{n}. die Quelle der Forschung „{text}“: anders gelesen",
@@ -809,6 +810,7 @@
809
810
  "ui.sync.place.located": "{n}. {place}: Lage auf der Karte {at}",
810
811
  "ui.sync.do.add": "als Hinweis übernommen",
811
812
  "ui.sync.do.correct": "berichtigt den Hinweis der Forschung",
813
+ "ui.sync.do.note.changed": "diese Zeile der Notiz der Forschung wird berichtigt",
812
814
  "ui.sync.do.detail": "wird der Angabe hinzugefügt, mit Verweis auf den eigenen Stammbaum",
813
815
  "ui.sync.do.refine": "wird der Angabe hinzugefügt, mit Verweis auf den eigenen Stammbaum (nichts widerspricht dem Eintrag)",
814
816
  "ui.sync.do.conflict": "ein Widerspruch zum Entscheiden (die Forschung hat es aus einem Eintrag)",
@@ -1283,6 +1285,7 @@
1283
1285
  "log.repository.retract": "Archiv zurückgezogen: {name}",
1284
1286
  "log.name.remove": "Name entfernt: {name} – {who}",
1285
1287
  "log.note.remove": "Notiz entfernt: „{note}“ – {name}",
1288
+ "log.note.edit": "Notiz berichtigt: {name}",
1286
1289
  "log.event.status": "Sicherheit einer Angabe geändert: {who} – {fact}",
1287
1290
  "log.event.restore": "Angabe wieder gültig: {who} – {fact}",
1288
1291
  "log.input.sync.undo": "Stammbaum zurückgenommen: {name}",
@@ -93,36 +93,107 @@ function allowRules(agent) {
93
93
  */
94
94
  const TOML_BEGIN = "# strom: begin (strom agents install; removed by strom agents uninstall)";
95
95
  const TOML_END = "# strom: end";
96
- function grokWithout(text) {
97
- const lines = text.split("\n");
98
- const out = [];
99
- let inside = false;
100
- for (const l of lines) {
101
- if (l.trim() === TOML_BEGIN)
102
- inside = true;
103
- else if (l.trim() === TOML_END)
104
- inside = false;
105
- else if (!inside)
106
- out.push(l);
96
+ const isTomlTable = (l) => /^\s*\[/.test(l);
97
+ const isPermissionHeader = (l) => /^\s*\[permission\]\s*(#.*)?$/.test(l);
98
+ const hasOwnAllow = (lines) => lines.some((l) => /^\s*allow\s*=/.test(l));
99
+ /** A line of strom's own in config.toml: its [permission] header, or an allow list of nothing but strom's rules. */
100
+ function isGrokStromLine(l) {
101
+ if (isPermissionHeader(l))
102
+ return true;
103
+ const m = /^\s*allow\s*=\s*\[(.*)\]\s*$/.exec(l);
104
+ const rules = m ? [...m[1].matchAll(/"([^"]*)"/g)].map((r) => r[1]) : [];
105
+ return rules.length > 0 && rules.every((r) => isStromRule("grok", r));
106
+ }
107
+ /** Strom's block among the lines of config.toml (split at \n): the lines of its begin and its end, or undefined. */
108
+ function grokBlock(lines) {
109
+ const b = lines.findIndex((l) => l.trim() === TOML_BEGIN);
110
+ if (b < 0)
111
+ return undefined;
112
+ const e = lines.findIndex((l, i) => i > b && l.trim() === TOML_END);
113
+ if (e > b)
114
+ return [b, e];
115
+ // its end line lost: strom's own lines after its begin, never the person's that follow
116
+ let last = b;
117
+ while (last + 1 < lines.length && isGrokStromLine(lines[last + 1]))
118
+ last++;
119
+ return [b, last];
120
+ }
121
+ /**
122
+ * Strom's block taken out, the person's text before and after it kept as it is — only the blank line an install puts
123
+ * between the person's text and the block it appends goes with it. gap: whether the install that wrote the block put
124
+ * one there (recorded in agents-taught.json); false: no blank line around the block is strom's. Unknown (an
125
+ * installation that recorded nothing, a file strom created): the blank line before the block was strom's, or, the block
126
+ * first in the file, the one after it.
127
+ */
128
+ function grokWithout(text, gap) {
129
+ let lines = text.split("\n");
130
+ const blank = (i) => i >= 0 && i < lines.length && lines[i].trim() === "";
131
+ for (let at = grokBlock(lines); at; at = grokBlock(lines)) {
132
+ const [b, e] = at;
133
+ let from = b;
134
+ let to = e + 1;
135
+ if (gap !== false) {
136
+ // a blank line before it, whatever follows (the end of the file, a blank line, the person's text right after its
137
+ // end): that one was strom's
138
+ if (blank(b - 1))
139
+ from = b - 1;
140
+ // first in the file, then a blank line and the person's text: that blank line was strom's
141
+ else if (b === 0 && blank(e + 1) && e + 1 < lines.length - 1)
142
+ to = e + 2;
143
+ }
144
+ // taken to the end of a file with no line break at its end: the CR of the break before it went with it
145
+ const tail = to >= lines.length && from > 0 ? [lines[from - 1].replace(/\r$/, "")] : lines.slice(from - 1, from);
146
+ lines = [...lines.slice(0, Math.max(0, from - 1)), ...tail, ...lines.slice(to)];
107
147
  }
108
- return out.join("\n");
148
+ return lines.join("\n");
109
149
  }
110
- function grokWith(text) {
111
- const rest = grokWithout(text);
150
+ /** config.toml with strom's block; gap: whether a new block was put after a blank line (undefined: none put now). */
151
+ function grokWith(text, rec) {
112
152
  const allow = `allow = [${allowRules("grok").map((r) => JSON.stringify(r)).join(", ")}]`;
113
- const lines = rest.split("\n");
114
- const header = lines.findIndex((l) => /^\s*\[permission\]\s*(#.*)?$/.test(l));
153
+ const nl = text.includes("\r\n") ? "\r\n" : "\n";
154
+ const cr = nl === "\r\n" ? "\r" : "";
155
+ const lines = text.split("\n");
156
+ const at = grokBlock(lines);
157
+ if (at) {
158
+ // strom's block there already: brought to this version where it is, the person's text around it untouched
159
+ const [b, e] = at;
160
+ const header = lines.findIndex((l, i) => (i < b || i > e) && isPermissionHeader(l));
161
+ let inner;
162
+ if (header < 0)
163
+ inner = ["[permission]", allow];
164
+ else if (header < b && !lines.slice(header + 1, b).some(isTomlTable)) {
165
+ // in the person's own table: up to the next table
166
+ let end = lines.findIndex((l, i) => i > e && isTomlTable(l));
167
+ if (end < 0)
168
+ end = lines.length;
169
+ if (hasOwnAllow([...lines.slice(header + 1, b), ...lines.slice(e + 1, end)]))
170
+ return undefined;
171
+ inner = [allow];
172
+ }
173
+ if (inner) {
174
+ const last = e === lines.length - 1; // the file ends with strom's end line: no line break after it
175
+ const block = [TOML_BEGIN, ...inner, TOML_END].map((l, i, all) => (last && i === all.length - 1 ? l : l + cr));
176
+ return { text: [...lines.slice(0, b), ...block, ...lines.slice(e + 1)].join("\n") };
177
+ }
178
+ // a table of strom's beside the person's own [permission] table (two would break the file): into theirs
179
+ }
180
+ const rest = at ? grokWithout(text, rec) : text;
181
+ const rl = rest.split("\n");
182
+ const header = rl.findIndex(isPermissionHeader);
115
183
  if (header < 0) {
116
- const body = rest.replace(/\s+$/, "");
117
- return `${body ? `${body}\n\n` : ""}${TOML_BEGIN}\n[permission]\n${allow}\n${TOML_END}\n`;
184
+ // appended after a blank line, the person's last line ending as it did (grokWithout takes exactly this away)
185
+ const block = [TOML_BEGIN, "[permission]", allow, TOML_END].join(nl);
186
+ if (!rest)
187
+ return { text: block + nl, gap: false };
188
+ return { text: rest.endsWith("\n") ? `${rest}${nl}${block}${nl}` : `${rest}${nl}${nl}${block}`, gap: true };
118
189
  }
119
190
  // the user's table: up to the next table
120
- let end = lines.findIndex((l, i) => i > header && /^\s*\[/.test(l));
191
+ let end = rl.findIndex((l, i) => i > header && isTomlTable(l));
121
192
  if (end < 0)
122
- end = lines.length;
123
- if (lines.slice(header + 1, end).some((l) => /^\s*allow\s*=/.test(l)))
193
+ end = rl.length;
194
+ if (hasOwnAllow(rl.slice(header + 1, end)))
124
195
  return undefined;
125
- return [...lines.slice(0, header + 1), TOML_BEGIN, allow, TOML_END, ...lines.slice(header + 1)].join("\n");
196
+ return { text: [...rl.slice(0, header + 1), ...[TOML_BEGIN, allow, TOML_END].map((l) => l + cr), ...rl.slice(header + 1)].join("\n"), gap: false };
126
197
  }
127
198
  /** A rule of strom's in Claude Code's or Antigravity's settings, whichever installation wrote it. */
128
199
  function isStromRule(agent, rule) {
@@ -223,30 +294,54 @@ function hasAllow(t, s) {
223
294
  const own = path.join(path.dirname(t.file), "strom.md");
224
295
  return isObject(bash) && bash[OPENCODE_STROM] === "allow" && Array.isArray(s.instructions) && s.instructions.includes(own);
225
296
  }
297
+ /** Strom allowed in the settings; what it made there to do it. */
226
298
  function addAllow(t, s) {
299
+ const made = [];
300
+ const plain = [];
227
301
  if (t.agent !== "opencode") {
302
+ if (!isObject(s.permissions))
303
+ made.push("permissions");
304
+ const perms = isObject(s.permissions) ? s.permissions : {};
305
+ if (!Array.isArray(perms.allow))
306
+ made.push("permissions.allow");
228
307
  const have = allowList(s);
229
- s.permissions = { ...(s.permissions ?? {}), allow: [...have, ...allowRules(t.agent).filter((r) => !have.includes(r))] };
230
- return;
308
+ s.permissions = { ...perms, allow: [...have, ...allowRules(t.agent).filter((r) => !have.includes(r))] };
309
+ return { made };
231
310
  }
311
+ if (!isObject(s.permission))
312
+ (typeof s.permission === "string" ? plain : made).push("permission");
232
313
  const perm = isObject(s.permission) ? s.permission : typeof s.permission === "string" ? { "*": s.permission } : {};
233
314
  // A plain value for bash ("ask") becomes the general rule; strom's comes after it (the last match counts).
315
+ if (!isObject(perm.bash))
316
+ (typeof perm.bash === "string" ? plain : made).push("permission.bash");
234
317
  const bash = isObject(perm.bash) ? perm.bash : typeof perm.bash === "string" ? { "*": perm.bash } : {};
235
318
  s.permission = { ...perm, bash: { ...bash, [OPENCODE_STROM]: "allow" } };
236
319
  const own = path.join(path.dirname(t.file), "strom.md");
320
+ if (!Array.isArray(s.instructions))
321
+ made.push("instructions");
237
322
  const list = Array.isArray(s.instructions) ? s.instructions : [];
238
323
  if (!list.includes(own))
239
324
  s.instructions = [...list, own];
325
+ return { made, plain };
240
326
  }
241
- function removeAllow(t, s) {
327
+ /**
328
+ * Strom's rules out of the settings. What strom made there (rec) goes when empty, and nothing else; a value it turned
329
+ * into an object becomes plain again. Nothing recorded (an installation before the record): every container left
330
+ * empty goes, as before.
331
+ */
332
+ function removeAllow(t, s, rec) {
333
+ const goes = (p) => (rec ? (rec.made ?? []).includes(p) : true);
334
+ const wasPlain = (p, v) => (rec?.plain ?? []).includes(p) && Object.keys(v).length === 1 && "*" in v;
242
335
  if (t.agent !== "opencode") {
336
+ if (!isObject(s.permissions))
337
+ return;
243
338
  const rest = allowList(s).filter((r) => !isStromRule(t.agent, r));
244
339
  const perms = { ...s.permissions };
245
- if (rest.length)
340
+ if (rest.length || !goes("permissions.allow"))
246
341
  perms.allow = rest;
247
342
  else
248
343
  delete perms.allow;
249
- if (Object.keys(perms).length)
344
+ if (Object.keys(perms).length || !goes("permissions"))
250
345
  s.permissions = perms;
251
346
  else
252
347
  delete s.permissions;
@@ -255,7 +350,7 @@ function removeAllow(t, s) {
255
350
  const own = path.join(path.dirname(t.file), "strom.md");
256
351
  if (Array.isArray(s.instructions)) {
257
352
  const rest = s.instructions.filter((i) => i !== own);
258
- if (rest.length)
353
+ if (rest.length || !goes("instructions"))
259
354
  s.instructions = rest;
260
355
  else
261
356
  delete s.instructions;
@@ -264,16 +359,137 @@ function removeAllow(t, s) {
264
359
  const bash = { ...s.permission.bash };
265
360
  delete bash[OPENCODE_STROM];
266
361
  const perm = { ...s.permission };
267
- if (Object.keys(bash).length)
362
+ if (wasPlain("permission.bash", bash))
363
+ perm.bash = bash["*"];
364
+ else if (Object.keys(bash).length || !goes("permission.bash"))
268
365
  perm.bash = bash;
269
366
  else
270
367
  delete perm.bash;
271
- if (Object.keys(perm).length)
368
+ if (wasPlain("permission", perm))
369
+ s.permission = perm["*"];
370
+ else if (Object.keys(perm).length || !goes("permission"))
272
371
  s.permission = perm;
273
372
  else
274
373
  delete s.permission;
275
374
  }
276
375
  }
376
+ /** The spans of the JSON value at i of a text JSON.parse read (so nothing here needs to check it). */
377
+ function jsonSpan(text, i) {
378
+ const ws = (k) => {
379
+ while (k < text.length && /\s/.test(text[k]))
380
+ k++;
381
+ return k;
382
+ };
383
+ const strEnd = (k) => {
384
+ for (k++; text[k] !== '"'; k++)
385
+ if (text[k] === "\\")
386
+ k++;
387
+ return k + 1;
388
+ };
389
+ const start = ws(i);
390
+ const c = text[start];
391
+ if (c === '"')
392
+ return { start, end: strEnd(start) };
393
+ if (c === "{" || c === "[") {
394
+ const keys = new Map();
395
+ const items = [];
396
+ let k = ws(start + 1);
397
+ while (text[k] !== (c === "{" ? "}" : "]")) {
398
+ if (c === "{") {
399
+ const keyEnd = strEnd(k);
400
+ const key = JSON.parse(text.slice(k, keyEnd));
401
+ const v = jsonSpan(text, ws(keyEnd) + 1); // after the colon
402
+ keys.set(key, v);
403
+ k = ws(v.end);
404
+ }
405
+ else {
406
+ const v = jsonSpan(text, k);
407
+ items.push(v);
408
+ k = ws(v.end);
409
+ }
410
+ if (text[k] === ",")
411
+ k = ws(k + 1);
412
+ }
413
+ return c === "{" ? { start, end: k + 1, keys } : { start, end: k + 1, items };
414
+ }
415
+ let end = start;
416
+ while (end < text.length && !/[\s,\]}]/.test(text[end]))
417
+ end++;
418
+ return { start, end };
419
+ }
420
+ /** How a JSON text on one line spaces it: what each place holds the first time it comes (outside strings), else nothing. */
421
+ function oneLineSpacing(body) {
422
+ const seen = new Map();
423
+ const note = (k, v) => {
424
+ if (!seen.has(k))
425
+ seen.set(k, v);
426
+ };
427
+ const wsAfter = (i) => /^[ \t]*/.exec(body.slice(i + 1))[0];
428
+ const wsBefore = (i) => /[ \t]*$/.exec(body.slice(0, i))[0];
429
+ for (let i = 0; i < body.length; i++) {
430
+ const c = body[i];
431
+ if (c === '"') {
432
+ for (i++; i < body.length && body[i] !== '"'; i++)
433
+ if (body[i] === "\\")
434
+ i++;
435
+ continue;
436
+ }
437
+ if (c === ":" || c === ",") {
438
+ note(`${c}<`, wsBefore(i));
439
+ note(`${c}>`, wsAfter(i));
440
+ }
441
+ else if (c === "{" || c === "[") {
442
+ const after = wsAfter(i);
443
+ if (body[i + 1 + after.length] !== (c === "{" ? "}" : "]"))
444
+ note(`${c}>`, after);
445
+ }
446
+ else if (c === "}" || c === "]") {
447
+ const before = wsBefore(i);
448
+ if (body[i - 1 - before.length] !== (c === "}" ? "{" : "["))
449
+ note(`${c}<`, before);
450
+ }
451
+ }
452
+ const at = (k, or = "") => seen.get(k) ?? or;
453
+ // no comma yet (one member): spaced after as its colon is
454
+ return { colon: `${at(":<")}:${at(":>")}`, comma: `${at(",<")},${at(",>", at(":>"))}`, obj: [at("{>"), at("}<")], arr: [at("[>"), at("]<")] };
455
+ }
456
+ /**
457
+ * The settings written as the person's file is: what did not change exactly as its text has it, what did in its
458
+ * indentation (or on one line as the file is, with its spaces), its line breaks, the whitespace around it. A blank file
459
+ * or none: strom's way, two spaces and a line break at the end.
460
+ */
461
+ function formatJson(s, text) {
462
+ if (text === undefined || !text.trim())
463
+ return JSON.stringify(s, null, 2) + "\n";
464
+ const body = text.trim();
465
+ const nl = body.includes("\r\n") ? "\r\n" : "\n";
466
+ // the first indented line is one level in; all on one line: none, its own spaces below (an empty {} says nothing: strom's way)
467
+ const indent = /\n([ \t]+)\S/.exec(body)?.[1] ?? (body.includes("\n") || /^\{\s*\}$/.test(body) ? " " : "");
468
+ // on one line: the person's spaces (none: compact)
469
+ const one = indent ? undefined : oneLineSpacing(body);
470
+ const same = (v, at) => JSON.stringify(v) === JSON.stringify(JSON.parse(text.slice(at.start, at.end)));
471
+ const write = (v, at, depth) => {
472
+ if (at && same(v, at))
473
+ return text.slice(at.start, at.end);
474
+ if (v === null || typeof v !== "object")
475
+ return JSON.stringify(v) ?? "null";
476
+ const entries = Array.isArray(v)
477
+ ? v.map((x, i) => [undefined, x, at?.items?.[i]])
478
+ : Object.entries(v).map(([k, x]) => [k, x, at?.keys?.get(k)]);
479
+ const [open, close] = Array.isArray(v) ? ["[", "]"] : ["{", "}"];
480
+ if (!entries.length)
481
+ return open + close;
482
+ const parts = entries.map(([k, x, sub]) => (k === undefined ? "" : JSON.stringify(k) + (one ? one.colon : ": ")) + write(x, sub, depth + 1));
483
+ if (one) {
484
+ const [inOpen, inClose] = Array.isArray(v) ? one.arr : one.obj;
485
+ return open + inOpen + parts.join(one.comma) + inClose + close;
486
+ }
487
+ const pad = (d) => indent.repeat(d);
488
+ return open + nl + parts.map((p) => pad(depth + 1) + p).join("," + nl) + nl + pad(depth) + close;
489
+ };
490
+ const lead = text.slice(0, text.length - text.trimStart().length);
491
+ return lead + write(s, jsonSpan(text, 0), 0) + text.slice(text.trimEnd().length);
492
+ }
277
493
  function readMade(file) {
278
494
  try {
279
495
  const v = JSON.parse(fs.readFileSync(file, "utf8"));
@@ -287,10 +503,12 @@ function writeMade(file, m) {
287
503
  try {
288
504
  const files = m.files && Object.keys(m.files).length ? m.files : undefined;
289
505
  const dirs = m.dirs?.length ? [...new Set(m.dirs)].sort() : undefined;
290
- if (!files && !dirs)
506
+ const json = m.json && Object.keys(m.json).length ? m.json : undefined;
507
+ const toml = m.toml && Object.keys(m.toml).length ? m.toml : undefined;
508
+ if (!files && !dirs && !json && !toml)
291
509
  fs.rmSync(file, { force: true });
292
510
  else
293
- writeFileAtomic(file, JSON.stringify({ ...(files ? { files } : {}), ...(dirs ? { dirs } : {}) }, null, 2) + "\n");
511
+ writeFileAtomic(file, JSON.stringify({ ...(files ? { files } : {}), ...(dirs ? { dirs } : {}), ...(json ? { json } : {}), ...(toml ? { toml } : {}) }, null, 2) + "\n");
294
512
  }
295
513
  catch {
296
514
  // the user config not writable: the files stay as an uninstall without the record leaves them
@@ -312,31 +530,53 @@ function hasStrom(t, text) {
312
530
  const own = path.join(path.dirname(t.file), "strom.md");
313
531
  return (Array.isArray(s.instructions) && s.instructions.includes(own)) || (isObject(s.permission) && isObject(s.permission.bash) && OPENCODE_STROM in s.permission.bash);
314
532
  }
315
- /** Recorded after a write: the file created now, or the person's (one without strom's part); the folders made for it. */
316
- function noteWritten(t, before, firstDir) {
533
+ /**
534
+ * Recorded after a write: the file created now, or the person's (one without strom's part); the folders made for it;
535
+ * in a settings file, what strom made in it (json); in config.toml, whether a new block went after a blank line (gap).
536
+ */
537
+ function noteWritten(t, before, firstDir, json, gap) {
317
538
  const m = readMade(t.made);
318
539
  const files = { ...(m.files ?? {}) };
319
540
  const dirs = [...(m.dirs ?? [])];
541
+ const jsons = { ...(m.json ?? {}) };
542
+ const tomls = { ...(m.toml ?? {}) };
543
+ // a new block put now (one there already, brought to this version where it is, keeps what is recorded)
544
+ if (gap !== undefined)
545
+ tomls[t.file] = { gap };
546
+ const fresh = before === undefined || !hasStrom(t, before);
320
547
  if (before === undefined)
321
548
  files[t.file] = "created";
322
549
  // there without strom's part: the person's — also one recorded created, deleted since and made again by the person
323
- else if (!hasStrom(t, before))
550
+ else if (fresh)
324
551
  files[t.file] = "existed";
325
552
  // strom's part already there: what is recorded stands; nothing recorded, an older installation's file — whose, nobody can tell
553
+ const prev = jsons[t.file];
554
+ const both = (a, b) => [...new Set([...(a ?? []), ...(b ?? [])])];
555
+ const kept = (j) => ({ ...(j.made?.length ? { made: j.made } : {}), ...(j.plain?.length ? { plain: j.plain } : {}), ...(j.blank !== undefined ? { blank: j.blank } : {}) });
556
+ if (json && fresh)
557
+ jsons[t.file] = kept(json);
558
+ else if (json && prev)
559
+ jsons[t.file] = kept({ ...prev, made: both(prev.made, json.made), plain: both(prev.plain, json.plain) });
560
+ // strom's part there and nothing recorded of it: an older installation's, taken away as it was before the record
326
561
  if (firstDir)
327
562
  for (let d = path.dirname(t.file);; d = path.dirname(d)) {
328
563
  dirs.push(d);
329
564
  if (d === firstDir || path.dirname(d) === d)
330
565
  break;
331
566
  }
332
- if (JSON.stringify(files) !== JSON.stringify(m.files ?? {}) || dirs.length !== (m.dirs ?? []).length)
333
- writeMade(t.made, { files, dirs });
567
+ const changed = (a, b) => JSON.stringify(a) !== JSON.stringify(b ?? {});
568
+ if (changed(files, m.files) || dirs.length !== (m.dirs ?? []).length || changed(jsons, m.json) || changed(tomls, m.toml))
569
+ writeMade(t.made, { files, dirs, json: jsons, toml: tomls });
334
570
  }
335
571
  /** Its part taken away: the file is the person's again (or gone); the folders strom made for it go when empty. */
336
572
  function noteRemoved(t) {
337
573
  const m = readMade(t.made);
338
574
  const files = { ...(m.files ?? {}) };
339
575
  delete files[t.file];
576
+ const json = { ...(m.json ?? {}) };
577
+ delete json[t.file];
578
+ const toml = { ...(m.toml ?? {}) };
579
+ delete toml[t.file];
340
580
  const here = path.dirname(t.file);
341
581
  const within = (d) => here === d || here.startsWith(d.endsWith(path.sep) ? d : d + path.sep);
342
582
  // deepest first: a folder strom made in another it made
@@ -353,24 +593,37 @@ function noteRemoved(t) {
353
593
  }
354
594
  return false;
355
595
  });
356
- writeMade(t.made, { files, dirs });
596
+ writeMade(t.made, { files, dirs, json, toml });
597
+ }
598
+ /**
599
+ * Whether strom put a blank line before its block in this config.toml, as recorded; unknown for an installation that
600
+ * recorded nothing, and for a file strom created (what the person adds to it after its block stands alone again).
601
+ */
602
+ function tomlGap(t, m) {
603
+ return m.files?.[t.file] === "created" ? undefined : m.toml?.[t.file]?.gap;
357
604
  }
358
605
  /** Write it for one agent; false when it was already there as it is. */
359
606
  export function installGlobal(t) {
360
607
  const cur = read(t.file);
361
608
  let next;
609
+ let json;
610
+ let gap;
362
611
  if (t.kind === "allow" && t.agent === "grok") {
363
- const with_ = grokWith(cur ?? "");
612
+ const with_ = grokWith(cur ?? "", tomlGap(t, readMade(t.made)));
364
613
  if (with_ === undefined)
365
614
  return false;
366
- next = with_;
615
+ next = with_.text;
616
+ gap = with_.gap;
367
617
  }
368
618
  else if (t.kind === "allow") {
369
619
  const s = settingsOf(cur);
370
620
  if (!s || hasAllow(t, s))
371
621
  return false;
372
- addAllow(t, s);
373
- next = JSON.stringify(s, null, 2) + "\n";
622
+ json = addAllow(t, s);
623
+ if (cur !== undefined && !cur.trim())
624
+ json.blank = cur;
625
+ // written as the person's file is: its indentation, its line breaks, a line break at its end or none
626
+ next = formatJson(s, cur);
374
627
  }
375
628
  else if (t.kind === "own")
376
629
  next = t.agent === "claude" || t.agent === "grok" ? skill(t.app) : globalText(t.app);
@@ -382,12 +635,13 @@ export function installGlobal(t) {
382
635
  return false;
383
636
  const firstDir = fs.mkdirSync(path.dirname(t.file), { recursive: true });
384
637
  writeFileAtomic(t.file, next);
385
- noteWritten(t, cur, firstDir);
638
+ noteWritten(t, cur, firstDir, json, gap);
386
639
  return true;
387
640
  }
388
641
  /** Take it away again; false when there was nothing of strom's. */
389
642
  export function uninstallGlobal(t) {
390
- const removed = takeAway(t, readMade(t.made).files?.[t.file]);
643
+ const m = readMade(t.made);
644
+ const removed = takeAway(t, m.files?.[t.file], m.json?.[t.file], tomlGap(t, m));
391
645
  if (removed)
392
646
  noteRemoved(t);
393
647
  return removed;
@@ -397,18 +651,17 @@ export function uninstallGlobal(t) {
397
651
  * an installation that recorded nothing stays as it did before the record — a settings file kept as {}, a text file
398
652
  * with nothing but strom's part gone.
399
653
  */
400
- function takeAway(t, made) {
654
+ function takeAway(t, made, rec, gap) {
401
655
  const cur = read(t.file);
402
656
  if (cur === undefined)
403
657
  return false;
404
658
  if (t.kind === "allow" && t.agent === "grok") {
405
- if (!cur.includes(TOML_BEGIN))
659
+ if (!grokBlock(cur.split("\n")))
406
660
  return false;
407
- const rest = grokWithout(cur);
408
- if (rest.trim())
409
- writeFileAtomic(t.file, rest.replace(/\n{3,}/g, "\n\n").replace(/\s*$/, "\n"));
410
- else if (made === "existed")
411
- writeFileAtomic(t.file, "");
661
+ // the person's text before and after strom's block as it was
662
+ const rest = grokWithout(cur, gap);
663
+ if (rest.trim() || made === "existed")
664
+ writeFileAtomic(t.file, rest);
412
665
  else
413
666
  fs.rmSync(t.file);
414
667
  return true;
@@ -419,11 +672,15 @@ function takeAway(t, made) {
419
672
  const any = t.agent === "opencode" ? s && hasAllow(t, s) : s && allowList(s).some((r) => isStromRule(t.agent, r));
420
673
  if (!s || !any)
421
674
  return false;
422
- removeAllow(t, s);
675
+ removeAllow(t, s, rec);
423
676
  if (!Object.keys(s).length && made === "created")
424
677
  fs.rmSync(t.file);
678
+ // the person's blank file: blank again
679
+ else if (!Object.keys(s).length && rec?.blank !== undefined)
680
+ writeFileAtomic(t.file, rec.blank);
681
+ // written as the person's file is
425
682
  else
426
- writeFileAtomic(t.file, JSON.stringify(s, null, 2) + "\n");
683
+ writeFileAtomic(t.file, formatJson(s, cur));
427
684
  return true;
428
685
  }
429
686
  if (t.kind === "own") {
@@ -455,8 +712,9 @@ export function isInstalled(t) {
455
712
  const cur = read(t.file);
456
713
  if (cur === undefined)
457
714
  return false;
715
+ // strom's block there, wherever the person's text puts it (an install brings it to this version where it is)
458
716
  if (t.kind === "allow" && t.agent === "grok")
459
- return cur.includes(TOML_BEGIN) && grokWith(cur) === cur;
717
+ return grokBlock(cur.split("\n")) !== undefined;
460
718
  if (t.kind === "allow")
461
719
  return hasAllow(t, settingsOf(cur) ?? {});
462
720
  return t.kind === "own" || cur.includes(BEGIN);
package/dist/cli/ui.js CHANGED
@@ -676,6 +676,7 @@ export const UI = {
676
676
  "ui.sync.do.title.user": "the edit wins (the research's in its history)",
677
677
  "ui.sync.sex.changed": "{n}. {who}: sex {text}",
678
678
  "ui.sync.note.new": "{n}. {who}: a note — “{text}”",
679
+ "ui.sync.note.changed": "{n}. {who}: a note — “{old}” → “{new}”",
679
680
  "ui.sync.source.new": "{n}. a source: “{text}”",
680
681
  "ui.sync.source.changed": "{n}. the file's source “{text}”: its transcript or page corrected",
681
682
  "ui.sync.source.reading": "{n}. the research's source “{text}”: read otherwise in the file",
@@ -725,6 +726,7 @@ export const UI = {
725
726
  "ui.sync.place.located": "{n}. {place}: on the map at {at}",
726
727
  "ui.sync.do.add": "added as a lead",
727
728
  "ui.sync.do.correct": "corrects the research's lead",
729
+ "ui.sync.do.note.changed": "that line of the research's note corrected",
728
730
  "ui.sync.do.detail": "added to the fact, citing the tree sent",
729
731
  "ui.sync.do.refine": "added to the fact, citing the tree sent (nothing in it disagrees with the record)",
730
732
  "ui.sync.do.conflict": "a conflict to decide (the research has it from a record)",
@@ -17,7 +17,7 @@ import { describeView, makeView, partRegion, viewRegion, VIEW_MAX } from "../cor
17
17
  import { listConnectors, missingConsents } from "../core/connector.js";
18
18
  import { now } from "../core/tree.js";
19
19
  import { originalMax, parseRegion, takeOriginal } from "../core/originals.js";
20
- import { batchFull, batchPath, noteBatch, openBatch } from "../core/batches.js";
20
+ import { batchFull, batchPath, batchRoom, noteBatch, openBatch } from "../core/batches.js";
21
21
  import { readEntry, readZip } from "../core/zip.js";
22
22
  const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".tif", ".tiff", ".gif", ".webp", ".heic", ".jp2", ".bmp"]);
23
23
  /**
@@ -510,13 +510,13 @@ register({
510
510
  throw new UsageError(`no such file: ${args[0]}`);
511
511
  const sha = typeof opts.sha === "string" ? opts.sha.toLowerCase() : fileSha256(file);
512
512
  if (!/^[0-9a-f]{64}$/.test(sha) || fileSha256(file) !== sha)
513
- throw new UsageError("the file is not the one named: its SHA-256 differs", { hint: "send it again" });
513
+ throw new UsageError("the file is not the one named: its SHA-256 differs", { hint: "send it again", code: "media.sha-differs", params: { sha: sha.slice(0, 64) } });
514
514
  const persons = (Array.isArray(opts.person) ? opts.person : opts.person ? [opts.person] : []).flatMap((p) => String(p).split(",")).map((p) => p.trim().toUpperCase()).filter(Boolean);
515
515
  const name = typeof opts.name === "string" ? opts.name : path.basename(file);
516
516
  const batch = typeof opts.batch === "string" ? opts.batch : undefined;
517
517
  const where = batchPath(typeof opts.path === "string" ? opts.path : undefined) ?? batchPath(name) ?? name;
518
518
  if (opts.zip && !batch)
519
- throw new UsageError("a ZIP comes in a batch", { hint: "--batch <id>" });
519
+ throw new UsageError("a ZIP comes in a batch", { hint: "--batch <id>", code: "batch.zip-alone" });
520
520
  const got = tree.withTreeLock(() => {
521
521
  if (!batch)
522
522
  return takeOriginal(tree, shared, file, sha, {
@@ -531,9 +531,9 @@ register({
531
531
  b = { ...b, persons };
532
532
  if (!opts.zip) {
533
533
  const size = fs.statSync(file).size;
534
- const full = batchFull(b, size, ctx.env);
534
+ const full = batchRoom(b, size, ctx.env);
535
535
  if (full)
536
- throw new UsageError(full);
536
+ throw new UsageError(full.text, { code: full.code, params: full.params });
537
537
  try {
538
538
  const one = takeOriginal(tree, shared, file, sha, { name: path.basename(where), persons, batch, path: where, note: typeof opts.note === "string" ? opts.note : undefined });
539
539
  noteBatch(tree.root, b, one.known ? { known: one.known } : { input: one.input, bytes: size });
@@ -559,7 +559,7 @@ function takeZip(ctx, tree, shared, file, zipPath, start, persons) {
559
559
  }
560
560
  catch (e) {
561
561
  fs.rmSync(file, { force: true });
562
- throw new UsageError(`${path.basename(zipPath)}: ${e.message}`, { hint: "unpack it in the system and send the folder" });
562
+ throw new UsageError(`${path.basename(zipPath)}: ${e.message}`, { hint: "unpack it in the system and send the folder", code: "batch.zip-unreadable", params: { name: path.basename(zipPath) } });
563
563
  }
564
564
  const max = originalMax(ctx.env);
565
565
  let b = start;
@@ -102,8 +102,8 @@ function changeLine(tree, c, incoming, lang) {
102
102
  now: c.kind === "fact.detail" ? detailText(c.fact, lang, short) : factText(c.fact, lang, short),
103
103
  when: [humanDate(c.fact?.date, lang), humanPlace(c.fact?.place, undefined, lang)].filter(Boolean).join(", "),
104
104
  text: truncate(c.text ?? "", 120),
105
- old: c.title ? truncate(c.title.was, 80) || "—" : shownName(c.wasName),
106
- new: c.title ? truncate(c.text ?? "", 80) || "—" : shownName(c.text),
105
+ old: c.note ? truncate(c.note.was, 80) : c.title ? truncate(c.title.was, 80) || "—" : shownName(c.wasName),
106
+ new: c.note ? truncate(c.text ?? "", 80) : c.title ? truncate(c.text ?? "", 80) || "—" : shownName(c.text),
107
107
  title: c.title ? ui(lang, c.title.part === "before" ? "ui.conflict.titleBefore" : "ui.conflict.titleAfter") : "",
108
108
  child: name(c.child),
109
109
  // a child's tie to each parent: one word for both, else each parent's
@@ -138,17 +138,19 @@ function changeLine(tree, c, incoming, lang) {
138
138
  ? "ui.sync.do.name.user"
139
139
  : c.kind === "name.title" && (c.action === "add" || c.action === "user")
140
140
  ? `ui.sync.do.title${c.action === "user" ? ".user" : ""}`
141
- : c.kind === "family.union" && c.action !== "report"
142
- ? "ui.sync.do.family.union"
143
- : c.kind === "fact.detail" && c.action === "add"
144
- ? "ui.sync.do.detail"
145
- : c.kind === "fact.part" && (c.action === "add" || c.action === "user")
146
- ? `ui.sync.do.part${c.action === "user" ? ".user" : ""}`
147
- : c.kind.startsWith("source.") || c.kind === "fact.cite" || c.kind === "person.cite" || c.kind === "family.cite"
148
- ? `ui.sync.do.${c.kind}${c.reads || (c.kind === "source.new" && newReads(c, incoming)) ? ".read" : ""}`
149
- : c.kind === "fact.new" && c.reads
150
- ? "ui.sync.do.reading"
151
- : `ui.sync.do.${c.action}`;
141
+ : c.kind === "note.changed" && c.action !== "pick"
142
+ ? "ui.sync.do.note.changed"
143
+ : c.kind === "family.union" && c.action !== "report"
144
+ ? "ui.sync.do.family.union"
145
+ : c.kind === "fact.detail" && c.action === "add"
146
+ ? "ui.sync.do.detail"
147
+ : c.kind === "fact.part" && (c.action === "add" || c.action === "user")
148
+ ? `ui.sync.do.part${c.action === "user" ? ".user" : ""}`
149
+ : c.kind.startsWith("source.") || c.kind === "fact.cite" || c.kind === "person.cite" || c.kind === "family.cite"
150
+ ? `ui.sync.do.${c.kind}${c.reads || (c.kind === "source.new" && newReads(c, incoming)) ? ".read" : ""}`
151
+ : c.kind === "fact.new" && c.reads
152
+ ? "ui.sync.do.reading"
153
+ : `ui.sync.do.${c.action}`;
152
154
  // an archive: nobody reads a record there, nor is an agent told — said without it
153
155
  const said = isArchive(tree) && `${does}.archive` in UI ? `${does}.archive` : does;
154
156
  return `${ui(lang, key, v)} → ${ui(lang, said)}`;
@@ -68,10 +68,10 @@ export function batchPath(raw) {
68
68
  /** A batch to add to: made at its first file; one closed takes nothing more. */
69
69
  export function openBatch(root, id) {
70
70
  if (!BATCH_ID.test(id))
71
- throw new UsageError(`not a batch: "${id.slice(0, 64)}"`);
71
+ throw new UsageError(`not a batch: "${id.slice(0, 64)}"`, { code: "batch.bad-id", params: { batch: id.slice(0, 64) } });
72
72
  const b = readBatch(root, id);
73
73
  if (b?.done)
74
- throw new UsageError(`the batch ${id} is closed already`, { hint: "send the rest as a new batch" });
74
+ throw new UsageError(`the batch ${id} is closed already`, { hint: "send the rest as a new batch", code: "batch.closed", params: { batch: id } });
75
75
  const t = new Date().toISOString();
76
76
  return b ?? { id, created: t, updated: t, inputs: [], known: [], bytes: 0, refused: [], nested: [] };
77
77
  }
@@ -91,11 +91,16 @@ export function noteBatch(root, b, add) {
91
91
  }
92
92
  /** Whether a batch has room for one more file of this size; why not, else undefined. */
93
93
  export function batchFull(b, bytes, env) {
94
+ return batchRoom(b, bytes, env)?.text;
95
+ }
96
+ /** Why a batch has no room for one more file of this size — the English words and, for the app, its code — else undefined. */
97
+ export function batchRoom(b, bytes, env) {
94
98
  const lim = batchLimits(env);
95
99
  if (b.inputs.length + b.known.length >= lim.files)
96
- return `the batch has ${lim.files} files already (the most one takes)`;
100
+ return { text: `the batch has ${lim.files} files already (the most one takes)`, code: "batch.full-files", params: { batch: b.id, files: String(lim.files) } };
101
+ const gb = String(Math.round(lim.bytes / 1024 ** 3));
97
102
  if (b.bytes + bytes > lim.bytes)
98
- return `the batch would be larger than ${Math.round(lim.bytes / 1024 ** 3)} GB (the most one takes)`;
103
+ return { text: `the batch would be larger than ${gb} GB (the most one takes)`, code: "batch.full-bytes", params: { batch: b.id, gb } };
99
104
  return undefined;
100
105
  }
101
106
  /** The folder of a file in its batch that it is sorted with: its first one (a ZIP's: the ZIP and its first one), else none. */
@@ -114,7 +119,7 @@ const IMAGE = /^image\//;
114
119
  export function closeBatch(tree, id, opts) {
115
120
  const b = readBatch(tree.root, id);
116
121
  if (!b)
117
- throw new UsageError(`no batch ${id} here`, { hint: "strom input batch" });
122
+ throw new UsageError(`no batch ${id} here`, { hint: "strom input batch", code: "batch.none", params: { batch: id } });
118
123
  if (b.done)
119
124
  return { ...b, tasks: b.tasks ?? [] };
120
125
  const lang = tree.lang;
@@ -54,6 +54,7 @@ const SAID = {
54
54
  "story.approve": "log.story.approve",
55
55
  "story.discard": "log.story.discard",
56
56
  "note.add": "log.note.add",
57
+ "note.edit": "log.note.edit",
57
58
  "input.add": "log.input.add",
58
59
  "input.sync": "log.input.sync",
59
60
  "input.done": "log.input.done",
package/dist/core/live.js CHANGED
@@ -78,7 +78,7 @@ import { EXIT, StromError } from "./errors.js";
78
78
  import { acquireLock } from "./lock.js";
79
79
  import { material } from "./material.js";
80
80
  import { checkOriginalMeta, freeBytes, knownOriginal, materialWaiting, originalMax, ORIGINAL_RESERVE, ORIGINAL_TYPES, parseRegion } from "./originals.js";
81
- import { BATCH_ID, batchEstimate, batchFull, batchLimits, batchPath, batchStatus, idleBatches, noteBatch, openBatch, readBatch } from "./batches.js";
81
+ import { BATCH_ID, batchEstimate, batchLimits, batchRoom, batchPath, batchStatus, idleBatches, noteBatch, openBatch, readBatch } from "./batches.js";
82
82
  /** New images for the app, made for at most so long when the tree changed (the rest the next time). */
83
83
  const LIVE_IMAGES_MS = 20_000;
84
84
  import { liveWorkers } from "./workers.js";
@@ -122,6 +122,38 @@ const REFUSED = {
122
122
  function said(lang, key, params = {}) {
123
123
  return { error: ui(lang, key, params), code: CODES[key], text: ui("en", key, params), ...(Object.keys(params).length ? { params } : {}) };
124
124
  }
125
+ /**
126
+ * An upload of the app refused (PUT /media/<sha256>, a batch's file, POST /batch/<id>/done, GET /media/<sha256>): the
127
+ * English sentence (`error`, `text`) and its stable `code` with `params` for the app to say it in the person's
128
+ * language (feature `media.codes`). Each code, its answer and its params:
129
+ * - `app.only` 403 — not from the Strom app's pages
130
+ * - `media.bad-sha` 400 — the address names no SHA-256
131
+ * - `media.bad-header` 400 {header} — a header that is not what it should be (X-Strom-Person, -Source, -Batch, -Zip,
132
+ * -Path, -Name, -Note)
133
+ * - `media.bad-region` 400 {region} — X-Strom-Region is no part of an image
134
+ * - `media.no-person` 404 {person} — no such person in the research; `media.no-source` 404 {source} — no such source
135
+ * - `media.no-shared` 500 — the research has no shared folder for files (strom setup)
136
+ * - `media.large` 413 {mb} (and `max`) — larger than the research takes; `media.full` 507 (and `free`) — no room
137
+ * (these two: `error` in the research's language, `text` English — said() above)
138
+ * - `media.sha-differs` 409 {sha} — the file is not the one its address names
139
+ * - `media.type` 415 {name} — not a kind of file the research takes
140
+ * - `media.cut-short` 422 {name, kind: JPEG|PNG|PDF}; `media.too-small` 422 {name, bytes} — the file is not whole
141
+ * - `media.refused` 400 — refused for another reason (the English sentence says why); `media.failed` 500 — went wrong
142
+ * - `media.gone` 410 {known} — GET …?file=1: the research knows it, its file is not on this computer
143
+ * - `research.busy` 503 (and Retry-After, `retry`) — another strom holds the research: send it again in a while
144
+ * - `batch.bad-id` 400 {batch}; `batch.zip-alone` 400 — a ZIP outside a batch; `batch.zip-unreadable` 400 {name}
145
+ * - `batch.closed` 409 {batch} — the batch is closed already: send the rest as a new batch
146
+ * - `batch.full-files` 413 {batch, files}; `batch.full-bytes` 413 {batch, gb} — the most a batch takes
147
+ * - `batch.none` 404 {batch} — POST /batch/<id>/done: no such batch here; `batch.bad-body` 400 — its body no JSON
148
+ */
149
+ function refusal(error, code, params = {}) {
150
+ return { error, code, text: error, ...(Object.keys(params).length ? { params } : {}) };
151
+ }
152
+ /** What a strom of its own said as it failed (its --json: message, code, params): the code it gave, else `fallback`. */
153
+ function refusalOf(why, data, fallback) {
154
+ const params = data.params && typeof data.params === "object" ? Object.fromEntries(Object.entries(data.params).map(([k, v]) => [k, String(v)])) : {};
155
+ return refusal(why, typeof data.code === "string" && data.code ? data.code : fallback, params);
156
+ }
125
157
  /**
126
158
  * The environment of a strom the bridge runs for the Strom app (a send written, the app's tree taken in, an original, a
127
159
  * batch closed): what it writes is the person's in the app — never logged as an agent's or a session's, whatever the
@@ -484,7 +516,7 @@ function idsOf(applied, known) {
484
516
  return Object.keys(ids.persons).length || Object.keys(ids.sources).length ? { ids } : {};
485
517
  }
486
518
  /** What the bridge does that an app may ask about (each added once, never taken away). */
487
- export const BRIDGE_FEATURES = ["sync.again", "sync.undoneSince", "sync.takenBack", "sync.conflictEdit", "sync.since", "sync.ids", "family.noCouple", "family.alone", "adopt.transfer", "adopt.empty", "material.list", "person.titles"];
519
+ export const BRIDGE_FEATURES = ["sync.again", "sync.undoneSince", "sync.takenBack", "sync.conflictEdit", "sync.since", "sync.ids", "family.noCouple", "family.alone", "adopt.transfer", "adopt.empty", "material.list", "person.titles", "media.codes"];
488
520
  export function history(root, tree, range = [`-n${LOG_MAX}`]) {
489
521
  const r = runGit(root, ["log", ...range, "--format=%x1e%H%x1f%cI%x1f%s%x1f%b%x1f", "--name-only"]);
490
522
  if (r.status !== 0)
@@ -1057,7 +1089,7 @@ export function serveLive(root, env) {
1057
1089
  else if (!/[?&]file=1(?:&|$)/.test(req.url ?? ""))
1058
1090
  json(200, { known: known.id, kind: known.kind, mime: known.mime, bytes: known.bytes, ...(known.name ? { name: known.name } : {}), here: !!known.file && fs.existsSync(known.file) });
1059
1091
  else if (!known.file || !fs.existsSync(known.file))
1060
- json(410, { known: known.id, error: "its file is not on this computer" });
1092
+ json(410, { known: known.id, ...refusal("its file is not on this computer", "media.gone", { known: known.id }) });
1061
1093
  else {
1062
1094
  const size = fs.statSync(known.file).size;
1063
1095
  res.writeHead(200, { "Content-Type": known.mime, "Content-Length": String(size), "Cache-Control": "no-store", "Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(known.name ?? path.basename(known.file))}` });
@@ -1493,10 +1525,10 @@ export function serveLive(root, env) {
1493
1525
  req.resume();
1494
1526
  };
1495
1527
  if (!origin)
1496
- return refuse(403, { error: "only the Strom app may send a file here" });
1528
+ return refuse(403, refusal("only the Strom app may send a file here", "app.only"));
1497
1529
  const sha = rawSha.toLowerCase();
1498
1530
  if (!/^[0-9a-f]{64}$/.test(sha))
1499
- return refuse(400, { error: "the address names no SHA-256" });
1531
+ return refuse(400, refusal("the address names no SHA-256", "media.bad-sha"));
1500
1532
  const header = (n) => {
1501
1533
  const v = req.headers[n];
1502
1534
  return typeof v === "string" ? v : undefined;
@@ -1509,33 +1541,45 @@ export function serveLive(root, env) {
1509
1541
  let where;
1510
1542
  const zip = header("x-strom-zip") === "1";
1511
1543
  const tree = Tree.open(root, env);
1544
+ // the header read when it failed (its value not URI-encoded too): said by its name
1545
+ let at = "X-Strom-Batch";
1512
1546
  try {
1513
1547
  batch = header("x-strom-batch")?.trim();
1514
1548
  if (batch !== undefined && !BATCH_ID.test(batch))
1515
1549
  throw new Error("X-Strom-Batch: the batch's mark (letters, digits, -)");
1550
+ at = "X-Strom-Zip";
1516
1551
  if (zip && !batch)
1517
1552
  throw new Error("X-Strom-Zip: a ZIP comes in a batch (X-Strom-Batch)");
1553
+ at = "X-Strom-Path";
1518
1554
  where = header("x-strom-path") ? batchPath(decodeURIComponent(header("x-strom-path"))) : undefined;
1555
+ at = "X-Strom-Name";
1519
1556
  name = decodeURIComponent(header("x-strom-name") ?? "") || sha.slice(0, 12);
1557
+ at = "X-Strom-Note";
1520
1558
  note = header("x-strom-note") ? decodeURIComponent(header("x-strom-note")).slice(0, 500) : undefined;
1559
+ at = "X-Strom-Person";
1521
1560
  const persons = (header("x-strom-person") ?? "").split(",").map((p) => p.trim().toUpperCase()).filter(Boolean);
1522
1561
  if (persons.some((p) => !/^P\d{1,9}$/.test(p)))
1523
1562
  throw new Error("X-Strom-Person: IDs of the research's people (P…), with commas");
1563
+ at = "X-Strom-Source";
1524
1564
  const source = header("x-strom-source")?.trim().toUpperCase();
1525
1565
  if (source && !/^S\d{1,9}$/.test(source))
1526
1566
  throw new Error("X-Strom-Source: the ID of a source of the research (S…)");
1567
+ at = "X-Strom-Region";
1527
1568
  region = header("x-strom-region")?.trim();
1528
1569
  parseRegion(region);
1529
1570
  meta = checkOriginalMeta(tree, { persons, source });
1530
1571
  }
1531
1572
  catch (e) {
1532
- return refuse(/^no (person|source)/.test(e.message) ? 404 : 400, { error: e.message });
1573
+ // a person or a source the research has not got: 404, its JSON body telling it from an address that is no bridge's
1574
+ const m = e.message;
1575
+ const coded = e instanceof StromError && e.code ? refusal(m, e.code, e.params ?? {}) : refusal(m, "media.bad-header", { header: at });
1576
+ return refuse(/^no (person|source)/.test(m) ? 404 : 400, coded);
1533
1577
  }
1534
1578
  const settings = new Settings(env, {});
1535
1579
  const shared = settings.shared()?.value;
1536
1580
  // not set up (no shared folder): sending again does not help — /status says accepts.media null
1537
1581
  if (!shared)
1538
- return refuse(500, { error: "the research has no shared folder for files (strom setup)" });
1582
+ return refuse(500, refusal("the research has no shared folder for files (strom setup)", "media.no-shared"));
1539
1583
  // a batch: open, with room for it
1540
1584
  let open;
1541
1585
  const length = Number(header("content-length") ?? NaN);
@@ -1544,11 +1588,11 @@ export function serveLive(root, env) {
1544
1588
  open = openBatch(root, batch);
1545
1589
  }
1546
1590
  catch (e) {
1547
- return refuse(409, { error: e.message, batch });
1591
+ return refuse(409, { ...refusalOf(e.message, e instanceof StromError ? e : {}, "batch.closed"), batch });
1548
1592
  }
1549
- const full = batchFull(open, Number.isFinite(length) ? length : 0, env);
1593
+ const full = batchRoom(open, Number.isFinite(length) ? length : 0, env);
1550
1594
  if (full)
1551
- return refuse(413, { error: full, batch });
1595
+ return refuse(413, { ...refusal(full.text, full.code, full.params), batch });
1552
1596
  }
1553
1597
  // the same content: only said (an image of another source given again is taken as one more of that source)
1554
1598
  const known = zip ? undefined : knownOriginal(tree, shared, sha);
@@ -1586,7 +1630,7 @@ export function serveLive(root, env) {
1586
1630
  if (code !== 0)
1587
1631
  noteLive(root, `what came again for ${known.id} was not added (exit ${code})`);
1588
1632
  if (code === EXIT.locked)
1589
- return reply(503, { error: "the research is busy — send it again in a while", retry: 30 });
1633
+ return reply(503, { ...refusal("the research is busy — send it again in a while", "research.busy"), retry: 30 });
1590
1634
  reply(200, { known: known.id, kind: known.kind, ...(code === 0 && data.added ? { added: data.added } : {}), ...(code === 0 && data.task ? { task: data.task } : {}), ...(code === 0 ? { head: head(root) } : {}) });
1591
1635
  });
1592
1636
  return;
@@ -1647,7 +1691,7 @@ export function serveLive(root, env) {
1647
1691
  out.end(() => safely(req, res, () => {
1648
1692
  if (hash.digest("hex") !== sha) {
1649
1693
  drop();
1650
- reply(409, { error: "the file is not the one its address names (its SHA-256 differs) — send it again" });
1694
+ reply(409, refusal("the file is not the one its address names (its SHA-256 differs) — send it again", "media.sha-differs", { sha }));
1651
1695
  return;
1652
1696
  }
1653
1697
  // taken by a strom of its own: its lock, its log and its commit
@@ -1668,7 +1712,7 @@ export function serveLive(root, env) {
1668
1712
  child.stderr.on("data", (d) => (stderr += d.toString("utf8")));
1669
1713
  child.on("error", (e) => {
1670
1714
  drop();
1671
- reply(500, { error: errorText(e) });
1715
+ reply(500, refusal(errorText(e), "media.failed"));
1672
1716
  });
1673
1717
  child.on("close", (code) => {
1674
1718
  let data = {};
@@ -1687,8 +1731,8 @@ export function serveLive(root, env) {
1687
1731
  noteLive(root, `an original was not taken (exit ${code}): ${why}`);
1688
1732
  // the research busy (another strom holding it longer than a writer waits): the app sends it again later
1689
1733
  if (code === EXIT.locked)
1690
- return reply(503, { error: why, retry: 30 });
1691
- reply(/not a kind of file/.test(why) ? 415 : /cut short|too small/.test(why) ? 422 : /the most one takes/.test(why) ? 413 : /closed already/.test(why) ? 409 : 400, { error: why });
1734
+ return reply(503, { ...refusal(why, "research.busy"), retry: 30 });
1735
+ reply(/not a kind of file/.test(why) ? 415 : /cut short|too small/.test(why) ? 422 : /the most one takes/.test(why) ? 413 : /closed already/.test(why) ? 409 : 400, refusalOf(why, data, "media.refused"));
1692
1736
  });
1693
1737
  }));
1694
1738
  });
@@ -1713,7 +1757,7 @@ export function serveLive(root, env) {
1713
1757
  child.stderr.on("data", (d) => (stderr += d.toString("utf8")));
1714
1758
  child.on("error", (e) => {
1715
1759
  closing.delete(id);
1716
- reply(500, { error: errorText(e) });
1760
+ reply(500, { ...refusal(errorText(e), "media.failed"), batch: id });
1717
1761
  });
1718
1762
  child.on("close", (code) => {
1719
1763
  closing.delete(id);
@@ -1729,8 +1773,8 @@ export function serveLive(root, env) {
1729
1773
  const why = String(data.message ?? stderr.trim().split("\n").find((l) => l.startsWith("error:"))?.slice(6).trim() ?? "the batch was not closed");
1730
1774
  noteLive(root, `the batch ${id} was not closed (exit ${code}): ${why}`);
1731
1775
  if (code === EXIT.locked)
1732
- return reply(503, { error: why, retry: 30, batch: id });
1733
- reply(/no batch/.test(why) ? 404 : 400, { error: why, batch: id });
1776
+ return reply(503, { ...refusal(why, "research.busy"), retry: 30, batch: id });
1777
+ reply(/no batch/.test(why) ? 404 : 400, { ...refusalOf(why, data, /no batch/.test(why) ? "batch.none" : "media.refused"), batch: id });
1734
1778
  });
1735
1779
  };
1736
1780
  /** The app says a batch is whole: {name, files, person, note} — its files become sorting tasks (the note told in them). */
@@ -1740,10 +1784,10 @@ export function serveLive(root, env) {
1740
1784
  res.writeHead(code, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", ...(code === 503 ? { "Retry-After": "30" } : {}) }).end(JSON.stringify(body));
1741
1785
  };
1742
1786
  if (!origin)
1743
- return reply(403, { error: "only the Strom app may close a batch" });
1787
+ return reply(403, refusal("only the Strom app may close a batch", "app.only"));
1744
1788
  if (!BATCH_ID.test(id) || !readBatch(root, id)) {
1745
1789
  req.resume();
1746
- return reply(404, { error: `no batch ${id.slice(0, 64)} here`, batch: id });
1790
+ return reply(404, { ...refusal(`no batch ${id.slice(0, 64)} here`, "batch.none", { batch: id.slice(0, 64) }), batch: id });
1747
1791
  }
1748
1792
  let body = "";
1749
1793
  req.on("data", (c) => {
@@ -1756,7 +1800,7 @@ export function serveLive(root, env) {
1756
1800
  said = body.trim() ? JSON.parse(body) : {};
1757
1801
  }
1758
1802
  catch {
1759
- return reply(400, { error: "the body: JSON {name, files, person}" });
1803
+ return reply(400, { ...refusal("the body: JSON {name, files, person}", "batch.bad-body"), batch: id });
1760
1804
  }
1761
1805
  const persons = (Array.isArray(said.person) ? said.person : said.person ? [said.person] : []).map((p) => String(p).trim().toUpperCase()).filter((p) => /^P\d{1,9}$/.test(p));
1762
1806
  const extra = [
@@ -98,11 +98,14 @@ export function originalType(file, name) {
98
98
  return undefined;
99
99
  return named;
100
100
  }
101
- /** Why a file of that type is not whole (a JPEG or PNG cut short, a PDF without its end), or undefined. */
101
+ /**
102
+ * Why a file of that type is not whole (a JPEG or PNG cut short, a PDF without its end), or undefined — the English
103
+ * words and, for the app to say it in its own language, its code (media.too-small {bytes}, media.cut-short {kind}).
104
+ */
102
105
  export function originalProblem(file, mime) {
103
106
  const size = fs.statSync(file).size;
104
107
  if (size < 16)
105
- return `${size} bytes — too small`;
108
+ return { text: `${size} bytes — too small`, code: "media.too-small", params: { bytes: String(size) } };
106
109
  const tail = Buffer.alloc(Math.min(2048, size));
107
110
  const fd = fs.openSync(file, "r");
108
111
  try {
@@ -111,12 +114,13 @@ export function originalProblem(file, mime) {
111
114
  finally {
112
115
  fs.closeSync(fd);
113
116
  }
117
+ const short = (kind, text) => ({ text, code: "media.cut-short", params: { kind } });
114
118
  if (mime === "image/jpeg" && !tail.includes(Buffer.from([0xff, 0xd9])))
115
- return "a JPEG cut short (no end marker)";
119
+ return short("JPEG", "a JPEG cut short (no end marker)");
116
120
  if (mime === "image/png" && !tail.includes(Buffer.from("IEND")))
117
- return "a PNG cut short (no end)";
121
+ return short("PNG", "a PNG cut short (no end)");
118
122
  if (mime === "application/pdf" && !tail.includes(Buffer.from("%%EOF")))
119
- return "a PDF cut short (no end)";
123
+ return short("PDF", "a PDF cut short (no end)");
120
124
  return undefined;
121
125
  }
122
126
  /** What the research has of this content: an input or an image (by SHA-256), with where its file is. */
@@ -137,7 +141,7 @@ export function parseRegion(text) {
137
141
  const n = text.split(",").map((x) => Number(x.trim()));
138
142
  const [x, y, w, h] = n;
139
143
  if (n.length !== 4 || n.some((v) => !Number.isFinite(v)) || x < 0 || y < 0 || w <= 0 || h <= 0 || x + w > 1.001 || y + h > 1.001)
140
- throw new UsageError(`not a part of an image: "${text.slice(0, 60)}"`, { hint: "x,y,w,h as fractions of the image (0–1) from its top left corner" });
144
+ throw new UsageError(`not a part of an image: "${text.slice(0, 60)}"`, { hint: "x,y,w,h as fractions of the image (0–1) from its top left corner", code: "media.bad-region", params: { region: text.slice(0, 60) } });
141
145
  return { x: x, y: y, w: Math.min(w, 1 - x), h: Math.min(h, 1 - y) };
142
146
  }
143
147
  /** Where an original came from (an input's `from`). */
@@ -154,7 +158,7 @@ function personOf(tree, id) {
154
158
  for (let hops = 0; p?.mergedInto && hops < 10; hops++)
155
159
  p = tree.get(p.mergedInto);
156
160
  if (!p || p.type !== "person" || p.retracted)
157
- throw new UsageError(`no person ${id} in this research`, { hint: "the person's REFN in the Strom app" });
161
+ throw new UsageError(`no person ${id} in this research`, { hint: "the person's REFN in the Strom app", code: "media.no-person", params: { person: id } });
158
162
  return p;
159
163
  }
160
164
  function sourceOf(tree, id) {
@@ -162,7 +166,7 @@ function sourceOf(tree, id) {
162
166
  for (let hops = 0; s?.mergedInto && hops < 10; hops++)
163
167
  s = tree.get(s.mergedInto);
164
168
  if (!s || s.type !== "source" || s.retracted)
165
- throw new UsageError(`no source ${id} in this research`, { hint: "the source's REFN in the Strom app" });
169
+ throw new UsageError(`no source ${id} in this research`, { hint: "the source's REFN in the Strom app", code: "media.no-source", params: { source: id } });
166
170
  return s;
167
171
  }
168
172
  /** Check what an original is for before it comes: the people and the source the research has, the part of the image. */
@@ -182,10 +186,10 @@ export function takeOriginal(tree, shared, file, sha, metaIn) {
182
186
  const name = path.basename(metaIn.name.normalize("NFC").replace(/[\\/]+/g, "/")).replace(/[\u0000-\u001f]/g, "").slice(0, 200) || `${sha.slice(0, 12)}`;
183
187
  const type = originalType(file, name);
184
188
  if (!type)
185
- throw new UsageError(`${name}: not a kind of file the research takes`, { hint: `images (JPEG, PNG, TIFF, HEIC, WebP), PDF, texts and documents, sound — ${ORIGINAL_TYPES.map((t) => t.ext[0]).join(" ")}` });
189
+ throw new UsageError(`${name}: not a kind of file the research takes`, { hint: `images (JPEG, PNG, TIFF, HEIC, WebP), PDF, texts and documents, sound — ${ORIGINAL_TYPES.map((t) => t.ext[0]).join(" ")}`, code: "media.type", params: { name } });
186
190
  const problem = originalProblem(file, type.mime);
187
191
  if (problem)
188
- throw new UsageError(`${name}: ${problem} — not taken; send it again`);
192
+ throw new UsageError(`${name}: ${problem.text} — not taken; send it again`, { code: problem.code, params: { name, ...problem.params } });
189
193
  const lang = tree.lang;
190
194
  const known = knownOriginal(tree, shared, sha);
191
195
  const s = meta.source && !meta.batch ? tree.get(meta.source) : undefined;
@@ -173,6 +173,7 @@ export const PHRASES = {
173
173
  "log.repository.retract": "Archive withdrawn: {name}",
174
174
  "log.name.remove": "Name taken off: {name} – {who}",
175
175
  "log.note.remove": "Note taken off: \"{note}\" – {name}",
176
+ "log.note.edit": "Note corrected: {name}",
176
177
  "log.event.status": "How sure a fact is, changed: {who} – {fact}",
177
178
  "log.event.restore": "A fact back again: {who} – {fact}",
178
179
  "log.input.sync.undo": "Family tree taken back: {name}",
package/dist/core/sync.js CHANGED
@@ -386,6 +386,39 @@ function knownNote(text, said) {
386
386
  // a word or two of the app's own ("Birth:", "Address:") around what the research said — never a short note all new
387
387
  return unknown.length <= w.size * 0.2 || (unknown.length < 3 && unknown.length * 2 <= w.size);
388
388
  }
389
+ /** A line of a note as compared: NFC, its spaces one (the app joins what GEDCOM splits). */
390
+ const noteLine = (t) => t.normalize("NFC").replace(/\s+/g, " ").trim();
391
+ /** The words of a line as the user wrote them (lower case, accents kept, a letter alone too): what an edit changes. */
392
+ const lineWords = (t) => new Set(t.normalize("NFC").toLowerCase().match(/[\p{L}\p{M}\p{N}]+/gu) ?? []);
393
+ /**
394
+ * Of the research's lines, the one this line is an edit of: at least `share` of the words of the longer of the two in
395
+ * both (a year changed, a name corrected, a word added or taken away), the most of them.
396
+ */
397
+ function closest(ours, line, share) {
398
+ const w = lineWords(line);
399
+ let best;
400
+ let most = 0;
401
+ for (const x of ours) {
402
+ const v = lineWords(x.line);
403
+ const both = [...w].filter((y) => v.has(y)).length;
404
+ if (both && both >= share * Math.max(w.size, v.size) && both > most)
405
+ [best, most] = [x, both];
406
+ }
407
+ return best;
408
+ }
409
+ /** A note with one of its lines (as compared) another: the first such line, the others as they are. */
410
+ function swapLine(text, was, now) {
411
+ const lines = text.split("\n");
412
+ const i = lines.findIndex((l) => noteLine(l) === was);
413
+ if (i >= 0)
414
+ lines[i] = now;
415
+ return lines.join("\n");
416
+ }
417
+ /** The line only adds words around the research's ("Address: čp. 35" of "čp. 35"): the app's own shape, no line of its own. */
418
+ function onlyAdds(ours, line) {
419
+ const w = lineWords(line);
420
+ return [...lineWords(ours)].every((y) => w.has(y));
421
+ }
389
422
  /**
390
423
  * A tree the Strom app sent the bridge, with the version the app said with it (X-Strom-App-Version, ?app=) as its HEAD's
391
424
  * 2 VERS under 1 SOUR STROM, where the file names no version of the app (the app writes "1.0" there): the file is read
@@ -1988,13 +2021,52 @@ export function planSync(tree, incoming, edits, opts = {}) {
1988
2021
  if (fresh.length)
1989
2022
  push({ kind: owner.family || owner.partners ? "family.cite" : "person.cite", action: "add", ...owner, cites: fresh, ...(fresh.some((c) => reads(c.source)) ? { reads: true } : {}) });
1990
2023
  };
1991
- /** A note coming back holds the research's too (the app joins them): only its lines the research has not said. */
1992
- const notesDiff = (owner, inc, said, facts) => {
2024
+ /**
2025
+ * A note coming back holds the research's too (the app joins them): only its lines the research has not said. A line
2026
+ * of the research's own note the user edited in the app (a year, a name: hardly a word else — N38, found with the
2027
+ * app's 3.10.0-beta.13: "nothing new taken", the edit lost) is no line the research has said: the user's own note
2028
+ * (typed by the user, or the app's) corrected, a session's kept and the user's line beside it (sync.edits user, an
2029
+ * archive: corrected too); a line like one the note still has, a line of its own. A line as the app was given it
2030
+ * (the base) is no edit: the research's since stays.
2031
+ */
2032
+ const notesDiff = (owner, inc, said, facts, mine) => {
1993
2033
  const words = new Set(wordsOf([...said, ...facts.map((f) => [f.place, f.value, f.label].join(" "))].join("\n")));
2034
+ // what the research says, and what the app was given (the base: a copy older than the research's edit is no edit)
2035
+ const exactly = new Set(said.flatMap((t) => t.split(/\n+/)).map(noteLine).filter(Boolean));
2036
+ const sent = new Set((inc ?? []).flatMap((t) => t.split(/\n+/)).map(noteLine).filter(Boolean));
2037
+ const ours = (mine ?? []).flatMap((n) => n.text.split(/\n+/).map(noteLine).filter(Boolean).map((line) => ({ note: n, line })));
2038
+ const used = new Set();
1994
2039
  for (const t of inc ?? []) {
1995
- const fresh = t.split(/\n+/).filter((line) => line.trim() && !knownNote(line, words));
1996
- if (fresh.length && !knownNote(fresh.join("\n"), words))
1997
- push({ kind: "note.new", action: "add", ...owner, text: fresh.join("\n") });
2040
+ const lines = t.split(/\n+/).map((line) => line.trim()).filter(Boolean);
2041
+ const own = new Set();
2042
+ const fresh = lines.filter((line) => {
2043
+ const l = noteLine(line);
2044
+ if (exactly.has(l))
2045
+ return false;
2046
+ // an edit of a line the note no longer has; a line beside one it still has, like it but for a word or two
2047
+ const edit = closest(ours.filter((x) => !used.has(x) && !sent.has(x.line)), l, 0.5);
2048
+ const like = !edit && closest(ours.filter((x) => sent.has(x.line) && !onlyAdds(x.line, l)), l, 0.6);
2049
+ if (edit) {
2050
+ used.add(edit);
2051
+ // a file that names no commit it stands on (an older export, another program's): which is newer nobody can
2052
+ // tell — only shown, taken with --only (N39: an older copy set the user's correction back)
2053
+ if (!base) {
2054
+ push({ kind: "note.changed", action: "pick", ...owner, text: line.normalize("NFC"), note: { at: edit.note.at, was: edit.line } });
2055
+ return false;
2056
+ }
2057
+ if (edit.note.by === "user" || edits === "user") {
2058
+ push({ kind: "note.changed", action: "correct", ...owner, text: line.normalize("NFC"), note: { at: edit.note.at, was: edit.line } });
2059
+ return false;
2060
+ }
2061
+ }
2062
+ if (edit || like)
2063
+ own.add(line);
2064
+ return !edit && !like && !knownNote(line, words);
2065
+ });
2066
+ const take = fresh.length > 0 && !knownNote(fresh.join("\n"), words);
2067
+ const out = lines.filter((line) => own.has(line) || (take && fresh.includes(line)));
2068
+ if (out.length)
2069
+ push({ kind: "note.new", action: "add", ...owner, text: out.join("\n") });
1998
2070
  }
1999
2071
  };
2000
2072
  // people
@@ -2081,7 +2153,7 @@ export function planSync(tree, incoming, edits, opts = {}) {
2081
2153
  const overGuess = mySex === "U" && guessed !== undefined && !incoming.sexU;
2082
2154
  push({ kind: "sex.changed", action: b ? (edits === "user" ? "user" : backed || since || overGuess ? "conflict" : "correct") : "report", person: id, name, text: p.sex });
2083
2155
  }
2084
- notesDiff({ person: id, name }, p.notes, [o.said, b?.said ?? "", ...o.names], o.facts);
2156
+ notesDiff({ person: id, name }, p.notes, [o.said, b?.said ?? "", ...o.names], o.facts, tree.get(id)?.notes);
2085
2157
  }
2086
2158
  if (base && !partial)
2087
2159
  for (const [key, b] of base.persons)
@@ -2264,7 +2336,7 @@ export function planSync(tree, incoming, edits, opts = {}) {
2264
2336
  const both = [...new Set([...o.partners, ...partners])];
2265
2337
  factsDiff({ family: fam.id, partners: both }, f.facts, o.facts, b?.facts, true);
2266
2338
  citesDiff({ family: fam.id, partners: both }, f.cites, fam, b?.cites);
2267
- notesDiff({ family: fam.id, partners: both }, f.notes, [o.said ?? "", b?.said ?? "", ...both.map((k) => ours.persons.get(k)?.names.join(" ") ?? "")], o.facts);
2339
+ notesDiff({ family: fam.id, partners: both }, f.notes, [o.said ?? "", b?.said ?? "", ...both.map((k) => ours.persons.get(k)?.names.join(" ") ?? "")], o.facts, fam.notes);
2268
2340
  // how the couple is bound (no record proves it): the user's word where it changed since given, or where the research
2269
2341
  // has none; another word with nothing to stand on only said; none in the file takes nothing away (an older app)
2270
2342
  // (the one partner's married or divorced is the couple's facts' to say once the unknown one is named)
@@ -3410,6 +3482,23 @@ export function applySync(tree, plan, incoming, source, only) {
3410
3482
  applied.push({ do: "note.add", id: owner, before: n.at });
3411
3483
  break;
3412
3484
  }
3485
+ case "note.changed": {
3486
+ // the user's edit of a line of the research's note: that line of it corrected, the rest of the note as it was
3487
+ if (!owner || !c.text || !c.note)
3488
+ break;
3489
+ const { at, was } = c.note;
3490
+ const edited = noteLine(c.text);
3491
+ const rec = tree.get(owner);
3492
+ if (!rec?.notes.some((n) => n.at === at && n.text.split("\n").some((l) => noteLine(l) === was)))
3493
+ break;
3494
+ update(tree, owner, typeOfId(owner) === "family" ? "family" : "person", (o) => ({ ...o, notes: o.notes.map((n) => (n.at === at ? { ...n, text: swapLine(n.text, was, edited) } : n)) }), {
3495
+ op: "note.edit",
3496
+ summary: `${owner} note "${truncateText(was, 50)}" → "${truncateText(edited, 50)}": the user's edit (${reason})`,
3497
+ reason,
3498
+ });
3499
+ applied.push({ do: "note.edit", id: owner, before: JSON.stringify({ at, now: edited, was }) });
3500
+ break;
3501
+ }
3413
3502
  case "sex.changed": {
3414
3503
  if (!owner || !c.text)
3415
3504
  break;
@@ -3911,6 +4000,19 @@ export function undoSync(tree, input) {
3911
4000
  : { op: "person.edit", summary: `${a.id} shown by its name before again: ${reason}`, reason });
3912
4001
  break;
3913
4002
  }
4003
+ case "note.edit": {
4004
+ // the line as it was, back in its note (the note as it is now otherwise)
4005
+ const b = JSON.parse(String(a.before ?? "{}"));
4006
+ const rec = tree.get(a.id);
4007
+ if (!b.at || !b.now || !b.was || !rec?.notes.some((n) => n.at === b.at && n.text.split("\n").some((l) => noteLine(l) === b.now)))
4008
+ break;
4009
+ update(tree, a.id, typeOfId(a.id) === "family" ? "family" : "person", (o) => ({ ...o, notes: o.notes.map((n) => (n.at === b.at ? { ...n, text: swapLine(n.text, b.now, b.was) } : n)) }), {
4010
+ op: "note.edit",
4011
+ summary: `${a.id} note "${truncateText(b.now, 50)}" → "${truncateText(b.was, 50)}" again: ${reason}`,
4012
+ reason,
4013
+ });
4014
+ break;
4015
+ }
3914
4016
  case "note.add":
3915
4017
  update(tree, a.id, typeOfId(a.id) ?? "person", (o) => ({ ...o, notes: o.notes.filter((x) => x.at !== a.before) }), { op: "note.remove", summary: `${a.id} note "${noteText(tree, a.id, a.before)}" removed: ${reason}`, reason });
3916
4018
  break;
package/dist/core/tree.js CHANGED
@@ -27,7 +27,7 @@ import { lastBackup } from "./backup.js";
27
27
  export const TREE_FILE = "strom.json";
28
28
  // between releases the candidate being made (a bridge run from the sources says what it is: the Strom app goes by it);
29
29
  // at a release the release's own (package.json)
30
- export const VERSION = "1.13.0-beta.6";
30
+ export const VERSION = "1.13.0-beta.8";
31
31
  const GITIGNORE = `# Strom Research — local state and large files are not versioned
32
32
  .strom/
33
33
  /media/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strom-research",
3
- "version": "1.13.0-beta.6",
3
+ "version": "1.13.0-beta.8",
4
4
  "description": "Genealogical research toolkit for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {