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.
- package/assets/lang/cs.json +3 -0
- package/assets/lang/de.json +3 -0
- package/dist/agents/global.js +312 -54
- package/dist/cli/ui.js +2 -0
- package/dist/commands/media.js +6 -6
- package/dist/commands/sync.js +15 -13
- package/dist/core/batches.js +10 -5
- package/dist/core/changelog.js +1 -0
- package/dist/core/live.js +65 -21
- package/dist/core/originals.js +14 -10
- 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,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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
148
|
+
return lines.join("\n");
|
|
109
149
|
}
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
114
|
-
const
|
|
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
|
-
|
|
117
|
-
|
|
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 =
|
|
191
|
+
let end = rl.findIndex((l, i) => i > header && isTomlTable(l));
|
|
121
192
|
if (end < 0)
|
|
122
|
-
end =
|
|
123
|
-
if (
|
|
193
|
+
end = rl.length;
|
|
194
|
+
if (hasOwnAllow(rl.slice(header + 1, end)))
|
|
124
195
|
return undefined;
|
|
125
|
-
return [...
|
|
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 = { ...
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
/**
|
|
316
|
-
|
|
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 (
|
|
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
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
659
|
+
if (!grokBlock(cur.split("\n")))
|
|
406
660
|
return false;
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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,
|
|
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.
|
|
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)",
|
package/dist/commands/media.js
CHANGED
|
@@ -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 =
|
|
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;
|
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/batches.js
CHANGED
|
@@ -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 ${
|
|
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;
|
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/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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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, {
|
|
1591
|
+
return refuse(409, { ...refusalOf(e.message, e instanceof StromError ? e : {}, "batch.closed"), batch });
|
|
1548
1592
|
}
|
|
1549
|
-
const full =
|
|
1593
|
+
const full = batchRoom(open, Number.isFinite(length) ? length : 0, env);
|
|
1550
1594
|
if (full)
|
|
1551
|
-
return refuse(413, {
|
|
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, {
|
|
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,
|
|
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,
|
|
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, {
|
|
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,
|
|
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, {
|
|
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, {
|
|
1733
|
-
reply(/no batch/.test(why) ? 404 : 400, {
|
|
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,
|
|
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, {
|
|
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, {
|
|
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 = [
|
package/dist/core/originals.js
CHANGED
|
@@ -98,11 +98,14 @@ export function originalType(file, name) {
|
|
|
98
98
|
return undefined;
|
|
99
99
|
return named;
|
|
100
100
|
}
|
|
101
|
-
/**
|
|
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;
|
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.8";
|
|
31
31
|
const GITIGNORE = `# Strom Research — local state and large files are not versioned
|
|
32
32
|
.strom/
|
|
33
33
|
/media/
|