strom-research 1.13.0-beta.6 → 1.13.0-beta.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/lang/cs.json +3 -0
- package/assets/lang/de.json +3 -0
- package/dist/agents/global.js +243 -49
- package/dist/cli/ui.js +2 -0
- package/dist/commands/sync.js +15 -13
- package/dist/core/changelog.js +1 -0
- package/dist/core/phrases.js +1 -0
- package/dist/core/sync.js +109 -7
- package/dist/core/tree.js +1 -1
- package/package.json +1 -1
package/assets/lang/cs.json
CHANGED
|
@@ -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}",
|
package/assets/lang/de.json
CHANGED
|
@@ -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}",
|
package/dist/agents/global.js
CHANGED
|
@@ -93,36 +93,100 @@ 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
|
+
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.
|
|
124
|
+
*/
|
|
96
125
|
function grokWithout(text) {
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
let
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
126
|
+
let lines = text.split("\n");
|
|
127
|
+
const blank = (i) => i >= 0 && i < lines.length && lines[i].trim() === "";
|
|
128
|
+
for (let at = grokBlock(lines); at; at = grokBlock(lines)) {
|
|
129
|
+
const [b, e] = at;
|
|
130
|
+
let from = b;
|
|
131
|
+
let to = e + 1;
|
|
132
|
+
// a blank line before it, and the end of the file or another blank line after it: the one before was strom's
|
|
133
|
+
if (blank(b - 1) && (e + 1 >= lines.length || blank(e + 1)))
|
|
134
|
+
from = b - 1;
|
|
135
|
+
// first in the file, then a blank line and the person's text: that blank line was strom's
|
|
136
|
+
else if (b === 0 && blank(e + 1) && e + 1 < lines.length - 1)
|
|
137
|
+
to = e + 2;
|
|
138
|
+
// taken to the end of a file with no line break at its end: the CR of the break before it went with it
|
|
139
|
+
const tail = to >= lines.length && from > 0 ? [lines[from - 1].replace(/\r$/, "")] : lines.slice(from - 1, from);
|
|
140
|
+
lines = [...lines.slice(0, Math.max(0, from - 1)), ...tail, ...lines.slice(to)];
|
|
107
141
|
}
|
|
108
|
-
return
|
|
142
|
+
return lines.join("\n");
|
|
109
143
|
}
|
|
110
144
|
function grokWith(text) {
|
|
111
|
-
const rest = grokWithout(text);
|
|
112
145
|
const allow = `allow = [${allowRules("grok").map((r) => JSON.stringify(r)).join(", ")}]`;
|
|
113
|
-
const
|
|
114
|
-
const
|
|
146
|
+
const nl = text.includes("\r\n") ? "\r\n" : "\n";
|
|
147
|
+
const cr = nl === "\r\n" ? "\r" : "";
|
|
148
|
+
const lines = text.split("\n");
|
|
149
|
+
const at = grokBlock(lines);
|
|
150
|
+
if (at) {
|
|
151
|
+
// strom's block there already: brought to this version where it is, the person's text around it untouched
|
|
152
|
+
const [b, e] = at;
|
|
153
|
+
const header = lines.findIndex((l, i) => (i < b || i > e) && isPermissionHeader(l));
|
|
154
|
+
let inner;
|
|
155
|
+
if (header < 0)
|
|
156
|
+
inner = ["[permission]", allow];
|
|
157
|
+
else if (header < b && !lines.slice(header + 1, b).some(isTomlTable)) {
|
|
158
|
+
// in the person's own table: up to the next table
|
|
159
|
+
let end = lines.findIndex((l, i) => i > e && isTomlTable(l));
|
|
160
|
+
if (end < 0)
|
|
161
|
+
end = lines.length;
|
|
162
|
+
if (hasOwnAllow([...lines.slice(header + 1, b), ...lines.slice(e + 1, end)]))
|
|
163
|
+
return undefined;
|
|
164
|
+
inner = [allow];
|
|
165
|
+
}
|
|
166
|
+
if (inner) {
|
|
167
|
+
const last = e === lines.length - 1; // the file ends with strom's end line: no line break after it
|
|
168
|
+
const block = [TOML_BEGIN, ...inner, TOML_END].map((l, i, all) => (last && i === all.length - 1 ? l : l + cr));
|
|
169
|
+
return [...lines.slice(0, b), ...block, ...lines.slice(e + 1)].join("\n");
|
|
170
|
+
}
|
|
171
|
+
// a table of strom's beside the person's own [permission] table (two would break the file): into theirs
|
|
172
|
+
}
|
|
173
|
+
const rest = at ? grokWithout(text) : text;
|
|
174
|
+
const rl = rest.split("\n");
|
|
175
|
+
const header = rl.findIndex(isPermissionHeader);
|
|
115
176
|
if (header < 0) {
|
|
116
|
-
|
|
117
|
-
|
|
177
|
+
// appended after a blank line, the person's last line ending as it did (grokWithout takes exactly this away)
|
|
178
|
+
const block = [TOML_BEGIN, "[permission]", allow, TOML_END].join(nl);
|
|
179
|
+
if (!rest)
|
|
180
|
+
return block + nl;
|
|
181
|
+
return rest.endsWith("\n") ? `${rest}${nl}${block}${nl}` : `${rest}${nl}${nl}${block}`;
|
|
118
182
|
}
|
|
119
183
|
// the user's table: up to the next table
|
|
120
|
-
let end =
|
|
184
|
+
let end = rl.findIndex((l, i) => i > header && isTomlTable(l));
|
|
121
185
|
if (end < 0)
|
|
122
|
-
end =
|
|
123
|
-
if (
|
|
186
|
+
end = rl.length;
|
|
187
|
+
if (hasOwnAllow(rl.slice(header + 1, end)))
|
|
124
188
|
return undefined;
|
|
125
|
-
return [...
|
|
189
|
+
return [...rl.slice(0, header + 1), ...[TOML_BEGIN, allow, TOML_END].map((l) => l + cr), ...rl.slice(header + 1)].join("\n");
|
|
126
190
|
}
|
|
127
191
|
/** A rule of strom's in Claude Code's or Antigravity's settings, whichever installation wrote it. */
|
|
128
192
|
function isStromRule(agent, rule) {
|
|
@@ -223,30 +287,54 @@ function hasAllow(t, s) {
|
|
|
223
287
|
const own = path.join(path.dirname(t.file), "strom.md");
|
|
224
288
|
return isObject(bash) && bash[OPENCODE_STROM] === "allow" && Array.isArray(s.instructions) && s.instructions.includes(own);
|
|
225
289
|
}
|
|
290
|
+
/** Strom allowed in the settings; what it made there to do it. */
|
|
226
291
|
function addAllow(t, s) {
|
|
292
|
+
const made = [];
|
|
293
|
+
const plain = [];
|
|
227
294
|
if (t.agent !== "opencode") {
|
|
295
|
+
if (!isObject(s.permissions))
|
|
296
|
+
made.push("permissions");
|
|
297
|
+
const perms = isObject(s.permissions) ? s.permissions : {};
|
|
298
|
+
if (!Array.isArray(perms.allow))
|
|
299
|
+
made.push("permissions.allow");
|
|
228
300
|
const have = allowList(s);
|
|
229
|
-
s.permissions = { ...
|
|
230
|
-
return;
|
|
301
|
+
s.permissions = { ...perms, allow: [...have, ...allowRules(t.agent).filter((r) => !have.includes(r))] };
|
|
302
|
+
return { made };
|
|
231
303
|
}
|
|
304
|
+
if (!isObject(s.permission))
|
|
305
|
+
(typeof s.permission === "string" ? plain : made).push("permission");
|
|
232
306
|
const perm = isObject(s.permission) ? s.permission : typeof s.permission === "string" ? { "*": s.permission } : {};
|
|
233
307
|
// A plain value for bash ("ask") becomes the general rule; strom's comes after it (the last match counts).
|
|
308
|
+
if (!isObject(perm.bash))
|
|
309
|
+
(typeof perm.bash === "string" ? plain : made).push("permission.bash");
|
|
234
310
|
const bash = isObject(perm.bash) ? perm.bash : typeof perm.bash === "string" ? { "*": perm.bash } : {};
|
|
235
311
|
s.permission = { ...perm, bash: { ...bash, [OPENCODE_STROM]: "allow" } };
|
|
236
312
|
const own = path.join(path.dirname(t.file), "strom.md");
|
|
313
|
+
if (!Array.isArray(s.instructions))
|
|
314
|
+
made.push("instructions");
|
|
237
315
|
const list = Array.isArray(s.instructions) ? s.instructions : [];
|
|
238
316
|
if (!list.includes(own))
|
|
239
317
|
s.instructions = [...list, own];
|
|
318
|
+
return { made, plain };
|
|
240
319
|
}
|
|
241
|
-
|
|
320
|
+
/**
|
|
321
|
+
* Strom's rules out of the settings. What strom made there (rec) goes when empty, and nothing else; a value it turned
|
|
322
|
+
* into an object becomes plain again. Nothing recorded (an installation before the record): every container left
|
|
323
|
+
* empty goes, as before.
|
|
324
|
+
*/
|
|
325
|
+
function removeAllow(t, s, rec) {
|
|
326
|
+
const goes = (p) => (rec ? (rec.made ?? []).includes(p) : true);
|
|
327
|
+
const wasPlain = (p, v) => (rec?.plain ?? []).includes(p) && Object.keys(v).length === 1 && "*" in v;
|
|
242
328
|
if (t.agent !== "opencode") {
|
|
329
|
+
if (!isObject(s.permissions))
|
|
330
|
+
return;
|
|
243
331
|
const rest = allowList(s).filter((r) => !isStromRule(t.agent, r));
|
|
244
332
|
const perms = { ...s.permissions };
|
|
245
|
-
if (rest.length)
|
|
333
|
+
if (rest.length || !goes("permissions.allow"))
|
|
246
334
|
perms.allow = rest;
|
|
247
335
|
else
|
|
248
336
|
delete perms.allow;
|
|
249
|
-
if (Object.keys(perms).length)
|
|
337
|
+
if (Object.keys(perms).length || !goes("permissions"))
|
|
250
338
|
s.permissions = perms;
|
|
251
339
|
else
|
|
252
340
|
delete s.permissions;
|
|
@@ -255,7 +343,7 @@ function removeAllow(t, s) {
|
|
|
255
343
|
const own = path.join(path.dirname(t.file), "strom.md");
|
|
256
344
|
if (Array.isArray(s.instructions)) {
|
|
257
345
|
const rest = s.instructions.filter((i) => i !== own);
|
|
258
|
-
if (rest.length)
|
|
346
|
+
if (rest.length || !goes("instructions"))
|
|
259
347
|
s.instructions = rest;
|
|
260
348
|
else
|
|
261
349
|
delete s.instructions;
|
|
@@ -264,16 +352,97 @@ function removeAllow(t, s) {
|
|
|
264
352
|
const bash = { ...s.permission.bash };
|
|
265
353
|
delete bash[OPENCODE_STROM];
|
|
266
354
|
const perm = { ...s.permission };
|
|
267
|
-
if (
|
|
355
|
+
if (wasPlain("permission.bash", bash))
|
|
356
|
+
perm.bash = bash["*"];
|
|
357
|
+
else if (Object.keys(bash).length || !goes("permission.bash"))
|
|
268
358
|
perm.bash = bash;
|
|
269
359
|
else
|
|
270
360
|
delete perm.bash;
|
|
271
|
-
if (
|
|
361
|
+
if (wasPlain("permission", perm))
|
|
362
|
+
s.permission = perm["*"];
|
|
363
|
+
else if (Object.keys(perm).length || !goes("permission"))
|
|
272
364
|
s.permission = perm;
|
|
273
365
|
else
|
|
274
366
|
delete s.permission;
|
|
275
367
|
}
|
|
276
368
|
}
|
|
369
|
+
/** The spans of the JSON value at i of a text JSON.parse read (so nothing here needs to check it). */
|
|
370
|
+
function jsonSpan(text, i) {
|
|
371
|
+
const ws = (k) => {
|
|
372
|
+
while (k < text.length && /\s/.test(text[k]))
|
|
373
|
+
k++;
|
|
374
|
+
return k;
|
|
375
|
+
};
|
|
376
|
+
const strEnd = (k) => {
|
|
377
|
+
for (k++; text[k] !== '"'; k++)
|
|
378
|
+
if (text[k] === "\\")
|
|
379
|
+
k++;
|
|
380
|
+
return k + 1;
|
|
381
|
+
};
|
|
382
|
+
const start = ws(i);
|
|
383
|
+
const c = text[start];
|
|
384
|
+
if (c === '"')
|
|
385
|
+
return { start, end: strEnd(start) };
|
|
386
|
+
if (c === "{" || c === "[") {
|
|
387
|
+
const keys = new Map();
|
|
388
|
+
const items = [];
|
|
389
|
+
let k = ws(start + 1);
|
|
390
|
+
while (text[k] !== (c === "{" ? "}" : "]")) {
|
|
391
|
+
if (c === "{") {
|
|
392
|
+
const keyEnd = strEnd(k);
|
|
393
|
+
const key = JSON.parse(text.slice(k, keyEnd));
|
|
394
|
+
const v = jsonSpan(text, ws(keyEnd) + 1); // after the colon
|
|
395
|
+
keys.set(key, v);
|
|
396
|
+
k = ws(v.end);
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
const v = jsonSpan(text, k);
|
|
400
|
+
items.push(v);
|
|
401
|
+
k = ws(v.end);
|
|
402
|
+
}
|
|
403
|
+
if (text[k] === ",")
|
|
404
|
+
k = ws(k + 1);
|
|
405
|
+
}
|
|
406
|
+
return c === "{" ? { start, end: k + 1, keys } : { start, end: k + 1, items };
|
|
407
|
+
}
|
|
408
|
+
let end = start;
|
|
409
|
+
while (end < text.length && !/[\s,\]}]/.test(text[end]))
|
|
410
|
+
end++;
|
|
411
|
+
return { start, end };
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* The settings written as the person's file is: what did not change exactly as its text has it, what did in its
|
|
415
|
+
* indentation (or compact, as the file is), its line breaks, the whitespace around it. A blank file or none: strom's
|
|
416
|
+
* way, two spaces and a line break at the end.
|
|
417
|
+
*/
|
|
418
|
+
function formatJson(s, text) {
|
|
419
|
+
if (text === undefined || !text.trim())
|
|
420
|
+
return JSON.stringify(s, null, 2) + "\n";
|
|
421
|
+
const body = text.trim();
|
|
422
|
+
const nl = body.includes("\r\n") ? "\r\n" : "\n";
|
|
423
|
+
// the first indented line is one level in; all on one line: compact (an empty {} says nothing: strom's way)
|
|
424
|
+
const indent = /\n([ \t]+)\S/.exec(body)?.[1] ?? (body.includes("\n") || /^\{\s*\}$/.test(body) ? " " : "");
|
|
425
|
+
const same = (v, at) => JSON.stringify(v) === JSON.stringify(JSON.parse(text.slice(at.start, at.end)));
|
|
426
|
+
const write = (v, at, depth) => {
|
|
427
|
+
if (at && same(v, at))
|
|
428
|
+
return text.slice(at.start, at.end);
|
|
429
|
+
if (v === null || typeof v !== "object")
|
|
430
|
+
return JSON.stringify(v) ?? "null";
|
|
431
|
+
const entries = Array.isArray(v)
|
|
432
|
+
? v.map((x, i) => [undefined, x, at?.items?.[i]])
|
|
433
|
+
: Object.entries(v).map(([k, x]) => [k, x, at?.keys?.get(k)]);
|
|
434
|
+
const [open, close] = Array.isArray(v) ? ["[", "]"] : ["{", "}"];
|
|
435
|
+
if (!entries.length)
|
|
436
|
+
return open + close;
|
|
437
|
+
const parts = entries.map(([k, x, sub]) => (k === undefined ? "" : JSON.stringify(k) + (indent ? ": " : ":")) + write(x, sub, depth + 1));
|
|
438
|
+
if (!indent)
|
|
439
|
+
return open + parts.join(",") + close;
|
|
440
|
+
const pad = (d) => indent.repeat(d);
|
|
441
|
+
return open + nl + parts.map((p) => pad(depth + 1) + p).join("," + nl) + nl + pad(depth) + close;
|
|
442
|
+
};
|
|
443
|
+
const lead = text.slice(0, text.length - text.trimStart().length);
|
|
444
|
+
return lead + write(s, jsonSpan(text, 0), 0) + text.slice(text.trimEnd().length);
|
|
445
|
+
}
|
|
277
446
|
function readMade(file) {
|
|
278
447
|
try {
|
|
279
448
|
const v = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
@@ -287,10 +456,11 @@ function writeMade(file, m) {
|
|
|
287
456
|
try {
|
|
288
457
|
const files = m.files && Object.keys(m.files).length ? m.files : undefined;
|
|
289
458
|
const dirs = m.dirs?.length ? [...new Set(m.dirs)].sort() : undefined;
|
|
290
|
-
|
|
459
|
+
const json = m.json && Object.keys(m.json).length ? m.json : undefined;
|
|
460
|
+
if (!files && !dirs && !json)
|
|
291
461
|
fs.rmSync(file, { force: true });
|
|
292
462
|
else
|
|
293
|
-
writeFileAtomic(file, JSON.stringify({ ...(files ? { files } : {}), ...(dirs ? { dirs } : {}) }, null, 2) + "\n");
|
|
463
|
+
writeFileAtomic(file, JSON.stringify({ ...(files ? { files } : {}), ...(dirs ? { dirs } : {}), ...(json ? { json } : {}) }, null, 2) + "\n");
|
|
294
464
|
}
|
|
295
465
|
catch {
|
|
296
466
|
// the user config not writable: the files stay as an uninstall without the record leaves them
|
|
@@ -312,31 +482,46 @@ function hasStrom(t, text) {
|
|
|
312
482
|
const own = path.join(path.dirname(t.file), "strom.md");
|
|
313
483
|
return (Array.isArray(s.instructions) && s.instructions.includes(own)) || (isObject(s.permission) && isObject(s.permission.bash) && OPENCODE_STROM in s.permission.bash);
|
|
314
484
|
}
|
|
315
|
-
/**
|
|
316
|
-
|
|
485
|
+
/**
|
|
486
|
+
* Recorded after a write: the file created now, or the person's (one without strom's part); the folders made for it;
|
|
487
|
+
* in a settings file, what strom made in it (json).
|
|
488
|
+
*/
|
|
489
|
+
function noteWritten(t, before, firstDir, json) {
|
|
317
490
|
const m = readMade(t.made);
|
|
318
491
|
const files = { ...(m.files ?? {}) };
|
|
319
492
|
const dirs = [...(m.dirs ?? [])];
|
|
493
|
+
const jsons = { ...(m.json ?? {}) };
|
|
494
|
+
const fresh = before === undefined || !hasStrom(t, before);
|
|
320
495
|
if (before === undefined)
|
|
321
496
|
files[t.file] = "created";
|
|
322
497
|
// there without strom's part: the person's — also one recorded created, deleted since and made again by the person
|
|
323
|
-
else if (
|
|
498
|
+
else if (fresh)
|
|
324
499
|
files[t.file] = "existed";
|
|
325
500
|
// strom's part already there: what is recorded stands; nothing recorded, an older installation's file — whose, nobody can tell
|
|
501
|
+
const prev = jsons[t.file];
|
|
502
|
+
const both = (a, b) => [...new Set([...(a ?? []), ...(b ?? [])])];
|
|
503
|
+
const kept = (j) => ({ ...(j.made?.length ? { made: j.made } : {}), ...(j.plain?.length ? { plain: j.plain } : {}), ...(j.blank !== undefined ? { blank: j.blank } : {}) });
|
|
504
|
+
if (json && fresh)
|
|
505
|
+
jsons[t.file] = kept(json);
|
|
506
|
+
else if (json && prev)
|
|
507
|
+
jsons[t.file] = kept({ ...prev, made: both(prev.made, json.made), plain: both(prev.plain, json.plain) });
|
|
508
|
+
// strom's part there and nothing recorded of it: an older installation's, taken away as it was before the record
|
|
326
509
|
if (firstDir)
|
|
327
510
|
for (let d = path.dirname(t.file);; d = path.dirname(d)) {
|
|
328
511
|
dirs.push(d);
|
|
329
512
|
if (d === firstDir || path.dirname(d) === d)
|
|
330
513
|
break;
|
|
331
514
|
}
|
|
332
|
-
if (JSON.stringify(files) !== JSON.stringify(m.files ?? {}) || dirs.length !== (m.dirs ?? []).length)
|
|
333
|
-
writeMade(t.made, { files, dirs });
|
|
515
|
+
if (JSON.stringify(files) !== JSON.stringify(m.files ?? {}) || dirs.length !== (m.dirs ?? []).length || JSON.stringify(jsons) !== JSON.stringify(m.json ?? {}))
|
|
516
|
+
writeMade(t.made, { files, dirs, json: jsons });
|
|
334
517
|
}
|
|
335
518
|
/** Its part taken away: the file is the person's again (or gone); the folders strom made for it go when empty. */
|
|
336
519
|
function noteRemoved(t) {
|
|
337
520
|
const m = readMade(t.made);
|
|
338
521
|
const files = { ...(m.files ?? {}) };
|
|
339
522
|
delete files[t.file];
|
|
523
|
+
const json = { ...(m.json ?? {}) };
|
|
524
|
+
delete json[t.file];
|
|
340
525
|
const here = path.dirname(t.file);
|
|
341
526
|
const within = (d) => here === d || here.startsWith(d.endsWith(path.sep) ? d : d + path.sep);
|
|
342
527
|
// deepest first: a folder strom made in another it made
|
|
@@ -353,12 +538,13 @@ function noteRemoved(t) {
|
|
|
353
538
|
}
|
|
354
539
|
return false;
|
|
355
540
|
});
|
|
356
|
-
writeMade(t.made, { files, dirs });
|
|
541
|
+
writeMade(t.made, { files, dirs, json });
|
|
357
542
|
}
|
|
358
543
|
/** Write it for one agent; false when it was already there as it is. */
|
|
359
544
|
export function installGlobal(t) {
|
|
360
545
|
const cur = read(t.file);
|
|
361
546
|
let next;
|
|
547
|
+
let json;
|
|
362
548
|
if (t.kind === "allow" && t.agent === "grok") {
|
|
363
549
|
const with_ = grokWith(cur ?? "");
|
|
364
550
|
if (with_ === undefined)
|
|
@@ -369,8 +555,11 @@ export function installGlobal(t) {
|
|
|
369
555
|
const s = settingsOf(cur);
|
|
370
556
|
if (!s || hasAllow(t, s))
|
|
371
557
|
return false;
|
|
372
|
-
addAllow(t, s);
|
|
373
|
-
|
|
558
|
+
json = addAllow(t, s);
|
|
559
|
+
if (cur !== undefined && !cur.trim())
|
|
560
|
+
json.blank = cur;
|
|
561
|
+
// written as the person's file is: its indentation, its line breaks, a line break at its end or none
|
|
562
|
+
next = formatJson(s, cur);
|
|
374
563
|
}
|
|
375
564
|
else if (t.kind === "own")
|
|
376
565
|
next = t.agent === "claude" || t.agent === "grok" ? skill(t.app) : globalText(t.app);
|
|
@@ -382,12 +571,13 @@ export function installGlobal(t) {
|
|
|
382
571
|
return false;
|
|
383
572
|
const firstDir = fs.mkdirSync(path.dirname(t.file), { recursive: true });
|
|
384
573
|
writeFileAtomic(t.file, next);
|
|
385
|
-
noteWritten(t, cur, firstDir);
|
|
574
|
+
noteWritten(t, cur, firstDir, json);
|
|
386
575
|
return true;
|
|
387
576
|
}
|
|
388
577
|
/** Take it away again; false when there was nothing of strom's. */
|
|
389
578
|
export function uninstallGlobal(t) {
|
|
390
|
-
const
|
|
579
|
+
const m = readMade(t.made);
|
|
580
|
+
const removed = takeAway(t, m.files?.[t.file], m.json?.[t.file]);
|
|
391
581
|
if (removed)
|
|
392
582
|
noteRemoved(t);
|
|
393
583
|
return removed;
|
|
@@ -397,18 +587,17 @@ export function uninstallGlobal(t) {
|
|
|
397
587
|
* an installation that recorded nothing stays as it did before the record — a settings file kept as {}, a text file
|
|
398
588
|
* with nothing but strom's part gone.
|
|
399
589
|
*/
|
|
400
|
-
function takeAway(t, made) {
|
|
590
|
+
function takeAway(t, made, rec) {
|
|
401
591
|
const cur = read(t.file);
|
|
402
592
|
if (cur === undefined)
|
|
403
593
|
return false;
|
|
404
594
|
if (t.kind === "allow" && t.agent === "grok") {
|
|
405
|
-
if (!cur.
|
|
595
|
+
if (!grokBlock(cur.split("\n")))
|
|
406
596
|
return false;
|
|
597
|
+
// the person's text before and after strom's block as it was
|
|
407
598
|
const rest = grokWithout(cur);
|
|
408
|
-
if (rest.trim())
|
|
409
|
-
writeFileAtomic(t.file, rest
|
|
410
|
-
else if (made === "existed")
|
|
411
|
-
writeFileAtomic(t.file, "");
|
|
599
|
+
if (rest.trim() || made === "existed")
|
|
600
|
+
writeFileAtomic(t.file, rest);
|
|
412
601
|
else
|
|
413
602
|
fs.rmSync(t.file);
|
|
414
603
|
return true;
|
|
@@ -419,11 +608,15 @@ function takeAway(t, made) {
|
|
|
419
608
|
const any = t.agent === "opencode" ? s && hasAllow(t, s) : s && allowList(s).some((r) => isStromRule(t.agent, r));
|
|
420
609
|
if (!s || !any)
|
|
421
610
|
return false;
|
|
422
|
-
removeAllow(t, s);
|
|
611
|
+
removeAllow(t, s, rec);
|
|
423
612
|
if (!Object.keys(s).length && made === "created")
|
|
424
613
|
fs.rmSync(t.file);
|
|
614
|
+
// the person's blank file: blank again
|
|
615
|
+
else if (!Object.keys(s).length && rec?.blank !== undefined)
|
|
616
|
+
writeFileAtomic(t.file, rec.blank);
|
|
617
|
+
// written as the person's file is
|
|
425
618
|
else
|
|
426
|
-
writeFileAtomic(t.file,
|
|
619
|
+
writeFileAtomic(t.file, formatJson(s, cur));
|
|
427
620
|
return true;
|
|
428
621
|
}
|
|
429
622
|
if (t.kind === "own") {
|
|
@@ -455,8 +648,9 @@ export function isInstalled(t) {
|
|
|
455
648
|
const cur = read(t.file);
|
|
456
649
|
if (cur === undefined)
|
|
457
650
|
return false;
|
|
651
|
+
// strom's block there, wherever the person's text puts it (an install brings it to this version where it is)
|
|
458
652
|
if (t.kind === "allow" && t.agent === "grok")
|
|
459
|
-
return cur.
|
|
653
|
+
return grokBlock(cur.split("\n")) !== undefined;
|
|
460
654
|
if (t.kind === "allow")
|
|
461
655
|
return hasAllow(t, settingsOf(cur) ?? {});
|
|
462
656
|
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)",
|
package/dist/commands/sync.js
CHANGED
|
@@ -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 === "
|
|
142
|
-
? "ui.sync.do.
|
|
143
|
-
: c.kind === "
|
|
144
|
-
? "ui.sync.do.
|
|
145
|
-
: c.kind === "fact.
|
|
146
|
-
?
|
|
147
|
-
: c.kind
|
|
148
|
-
? `ui.sync.do
|
|
149
|
-
: c.kind === "fact.
|
|
150
|
-
?
|
|
151
|
-
:
|
|
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)}`;
|
package/dist/core/changelog.js
CHANGED
|
@@ -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/phrases.js
CHANGED
|
@@ -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
|
-
/**
|
|
1992
|
-
|
|
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
|
|
1996
|
-
|
|
1997
|
-
|
|
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.
|
|
30
|
+
export const VERSION = "1.13.0-beta.7";
|
|
31
31
|
const GITIGNORE = `# Strom Research — local state and large files are not versioned
|
|
32
32
|
.strom/
|
|
33
33
|
/media/
|