strom-research 1.13.0-beta.7 → 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/dist/agents/global.js +97 -33
- package/dist/commands/media.js +6 -6
- package/dist/core/batches.js +10 -5
- package/dist/core/live.js +65 -21
- package/dist/core/originals.js +14 -10
- package/dist/core/tree.js +1 -1
- package/package.json +1 -1
package/dist/agents/global.js
CHANGED
|
@@ -120,28 +120,35 @@ function grokBlock(lines) {
|
|
|
120
120
|
}
|
|
121
121
|
/**
|
|
122
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.
|
|
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.
|
|
124
127
|
*/
|
|
125
|
-
function grokWithout(text) {
|
|
128
|
+
function grokWithout(text, gap) {
|
|
126
129
|
let lines = text.split("\n");
|
|
127
130
|
const blank = (i) => i >= 0 && i < lines.length && lines[i].trim() === "";
|
|
128
131
|
for (let at = grokBlock(lines); at; at = grokBlock(lines)) {
|
|
129
132
|
const [b, e] = at;
|
|
130
133
|
let from = b;
|
|
131
134
|
let to = e + 1;
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
+
}
|
|
138
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
|
|
139
145
|
const tail = to >= lines.length && from > 0 ? [lines[from - 1].replace(/\r$/, "")] : lines.slice(from - 1, from);
|
|
140
146
|
lines = [...lines.slice(0, Math.max(0, from - 1)), ...tail, ...lines.slice(to)];
|
|
141
147
|
}
|
|
142
148
|
return lines.join("\n");
|
|
143
149
|
}
|
|
144
|
-
|
|
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) {
|
|
145
152
|
const allow = `allow = [${allowRules("grok").map((r) => JSON.stringify(r)).join(", ")}]`;
|
|
146
153
|
const nl = text.includes("\r\n") ? "\r\n" : "\n";
|
|
147
154
|
const cr = nl === "\r\n" ? "\r" : "";
|
|
@@ -166,19 +173,19 @@ function grokWith(text) {
|
|
|
166
173
|
if (inner) {
|
|
167
174
|
const last = e === lines.length - 1; // the file ends with strom's end line: no line break after it
|
|
168
175
|
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");
|
|
176
|
+
return { text: [...lines.slice(0, b), ...block, ...lines.slice(e + 1)].join("\n") };
|
|
170
177
|
}
|
|
171
178
|
// a table of strom's beside the person's own [permission] table (two would break the file): into theirs
|
|
172
179
|
}
|
|
173
|
-
const rest = at ? grokWithout(text) : text;
|
|
180
|
+
const rest = at ? grokWithout(text, rec) : text;
|
|
174
181
|
const rl = rest.split("\n");
|
|
175
182
|
const header = rl.findIndex(isPermissionHeader);
|
|
176
183
|
if (header < 0) {
|
|
177
184
|
// appended after a blank line, the person's last line ending as it did (grokWithout takes exactly this away)
|
|
178
185
|
const block = [TOML_BEGIN, "[permission]", allow, TOML_END].join(nl);
|
|
179
186
|
if (!rest)
|
|
180
|
-
return block + nl;
|
|
181
|
-
return rest.endsWith("\n") ? `${rest}${nl}${block}${nl}` : `${rest}${nl}${nl}${block}
|
|
187
|
+
return { text: block + nl, gap: false };
|
|
188
|
+
return { text: rest.endsWith("\n") ? `${rest}${nl}${block}${nl}` : `${rest}${nl}${nl}${block}`, gap: true };
|
|
182
189
|
}
|
|
183
190
|
// the user's table: up to the next table
|
|
184
191
|
let end = rl.findIndex((l, i) => i > header && isTomlTable(l));
|
|
@@ -186,7 +193,7 @@ function grokWith(text) {
|
|
|
186
193
|
end = rl.length;
|
|
187
194
|
if (hasOwnAllow(rl.slice(header + 1, end)))
|
|
188
195
|
return undefined;
|
|
189
|
-
return [...rl.slice(0, header + 1), ...[TOML_BEGIN, allow, TOML_END].map((l) => l + cr), ...rl.slice(header + 1)].join("\n");
|
|
196
|
+
return { text: [...rl.slice(0, header + 1), ...[TOML_BEGIN, allow, TOML_END].map((l) => l + cr), ...rl.slice(header + 1)].join("\n"), gap: false };
|
|
190
197
|
}
|
|
191
198
|
/** A rule of strom's in Claude Code's or Antigravity's settings, whichever installation wrote it. */
|
|
192
199
|
function isStromRule(agent, rule) {
|
|
@@ -410,18 +417,56 @@ function jsonSpan(text, i) {
|
|
|
410
417
|
end++;
|
|
411
418
|
return { start, end };
|
|
412
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
|
+
}
|
|
413
456
|
/**
|
|
414
457
|
* 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
|
|
416
|
-
* way, two spaces and a line break at the end.
|
|
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.
|
|
417
460
|
*/
|
|
418
461
|
function formatJson(s, text) {
|
|
419
462
|
if (text === undefined || !text.trim())
|
|
420
463
|
return JSON.stringify(s, null, 2) + "\n";
|
|
421
464
|
const body = text.trim();
|
|
422
465
|
const nl = body.includes("\r\n") ? "\r\n" : "\n";
|
|
423
|
-
// the first indented line is one level in; all on one line:
|
|
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)
|
|
424
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);
|
|
425
470
|
const same = (v, at) => JSON.stringify(v) === JSON.stringify(JSON.parse(text.slice(at.start, at.end)));
|
|
426
471
|
const write = (v, at, depth) => {
|
|
427
472
|
if (at && same(v, at))
|
|
@@ -434,9 +479,11 @@ function formatJson(s, text) {
|
|
|
434
479
|
const [open, close] = Array.isArray(v) ? ["[", "]"] : ["{", "}"];
|
|
435
480
|
if (!entries.length)
|
|
436
481
|
return open + close;
|
|
437
|
-
const parts = entries.map(([k, x, sub]) => (k === undefined ? "" : JSON.stringify(k) + (
|
|
438
|
-
if (
|
|
439
|
-
|
|
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
|
+
}
|
|
440
487
|
const pad = (d) => indent.repeat(d);
|
|
441
488
|
return open + nl + parts.map((p) => pad(depth + 1) + p).join("," + nl) + nl + pad(depth) + close;
|
|
442
489
|
};
|
|
@@ -457,10 +504,11 @@ function writeMade(file, m) {
|
|
|
457
504
|
const files = m.files && Object.keys(m.files).length ? m.files : undefined;
|
|
458
505
|
const dirs = m.dirs?.length ? [...new Set(m.dirs)].sort() : undefined;
|
|
459
506
|
const json = m.json && Object.keys(m.json).length ? m.json : undefined;
|
|
460
|
-
|
|
507
|
+
const toml = m.toml && Object.keys(m.toml).length ? m.toml : undefined;
|
|
508
|
+
if (!files && !dirs && !json && !toml)
|
|
461
509
|
fs.rmSync(file, { force: true });
|
|
462
510
|
else
|
|
463
|
-
writeFileAtomic(file, JSON.stringify({ ...(files ? { files } : {}), ...(dirs ? { dirs } : {}), ...(json ? { json } : {}) }, null, 2) + "\n");
|
|
511
|
+
writeFileAtomic(file, JSON.stringify({ ...(files ? { files } : {}), ...(dirs ? { dirs } : {}), ...(json ? { json } : {}), ...(toml ? { toml } : {}) }, null, 2) + "\n");
|
|
464
512
|
}
|
|
465
513
|
catch {
|
|
466
514
|
// the user config not writable: the files stay as an uninstall without the record leaves them
|
|
@@ -484,13 +532,17 @@ function hasStrom(t, text) {
|
|
|
484
532
|
}
|
|
485
533
|
/**
|
|
486
534
|
* 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).
|
|
535
|
+
* in a settings file, what strom made in it (json); in config.toml, whether a new block went after a blank line (gap).
|
|
488
536
|
*/
|
|
489
|
-
function noteWritten(t, before, firstDir, json) {
|
|
537
|
+
function noteWritten(t, before, firstDir, json, gap) {
|
|
490
538
|
const m = readMade(t.made);
|
|
491
539
|
const files = { ...(m.files ?? {}) };
|
|
492
540
|
const dirs = [...(m.dirs ?? [])];
|
|
493
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 };
|
|
494
546
|
const fresh = before === undefined || !hasStrom(t, before);
|
|
495
547
|
if (before === undefined)
|
|
496
548
|
files[t.file] = "created";
|
|
@@ -512,8 +564,9 @@ function noteWritten(t, before, firstDir, json) {
|
|
|
512
564
|
if (d === firstDir || path.dirname(d) === d)
|
|
513
565
|
break;
|
|
514
566
|
}
|
|
515
|
-
|
|
516
|
-
|
|
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 });
|
|
517
570
|
}
|
|
518
571
|
/** Its part taken away: the file is the person's again (or gone); the folders strom made for it go when empty. */
|
|
519
572
|
function noteRemoved(t) {
|
|
@@ -522,6 +575,8 @@ function noteRemoved(t) {
|
|
|
522
575
|
delete files[t.file];
|
|
523
576
|
const json = { ...(m.json ?? {}) };
|
|
524
577
|
delete json[t.file];
|
|
578
|
+
const toml = { ...(m.toml ?? {}) };
|
|
579
|
+
delete toml[t.file];
|
|
525
580
|
const here = path.dirname(t.file);
|
|
526
581
|
const within = (d) => here === d || here.startsWith(d.endsWith(path.sep) ? d : d + path.sep);
|
|
527
582
|
// deepest first: a folder strom made in another it made
|
|
@@ -538,18 +593,27 @@ function noteRemoved(t) {
|
|
|
538
593
|
}
|
|
539
594
|
return false;
|
|
540
595
|
});
|
|
541
|
-
writeMade(t.made, { files, dirs, json });
|
|
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;
|
|
542
604
|
}
|
|
543
605
|
/** Write it for one agent; false when it was already there as it is. */
|
|
544
606
|
export function installGlobal(t) {
|
|
545
607
|
const cur = read(t.file);
|
|
546
608
|
let next;
|
|
547
609
|
let json;
|
|
610
|
+
let gap;
|
|
548
611
|
if (t.kind === "allow" && t.agent === "grok") {
|
|
549
|
-
const with_ = grokWith(cur ?? "");
|
|
612
|
+
const with_ = grokWith(cur ?? "", tomlGap(t, readMade(t.made)));
|
|
550
613
|
if (with_ === undefined)
|
|
551
614
|
return false;
|
|
552
|
-
next = with_;
|
|
615
|
+
next = with_.text;
|
|
616
|
+
gap = with_.gap;
|
|
553
617
|
}
|
|
554
618
|
else if (t.kind === "allow") {
|
|
555
619
|
const s = settingsOf(cur);
|
|
@@ -571,13 +635,13 @@ export function installGlobal(t) {
|
|
|
571
635
|
return false;
|
|
572
636
|
const firstDir = fs.mkdirSync(path.dirname(t.file), { recursive: true });
|
|
573
637
|
writeFileAtomic(t.file, next);
|
|
574
|
-
noteWritten(t, cur, firstDir, json);
|
|
638
|
+
noteWritten(t, cur, firstDir, json, gap);
|
|
575
639
|
return true;
|
|
576
640
|
}
|
|
577
641
|
/** Take it away again; false when there was nothing of strom's. */
|
|
578
642
|
export function uninstallGlobal(t) {
|
|
579
643
|
const m = readMade(t.made);
|
|
580
|
-
const removed = takeAway(t, m.files?.[t.file], m.json?.[t.file]);
|
|
644
|
+
const removed = takeAway(t, m.files?.[t.file], m.json?.[t.file], tomlGap(t, m));
|
|
581
645
|
if (removed)
|
|
582
646
|
noteRemoved(t);
|
|
583
647
|
return removed;
|
|
@@ -587,7 +651,7 @@ export function uninstallGlobal(t) {
|
|
|
587
651
|
* an installation that recorded nothing stays as it did before the record — a settings file kept as {}, a text file
|
|
588
652
|
* with nothing but strom's part gone.
|
|
589
653
|
*/
|
|
590
|
-
function takeAway(t, made, rec) {
|
|
654
|
+
function takeAway(t, made, rec, gap) {
|
|
591
655
|
const cur = read(t.file);
|
|
592
656
|
if (cur === undefined)
|
|
593
657
|
return false;
|
|
@@ -595,7 +659,7 @@ function takeAway(t, made, rec) {
|
|
|
595
659
|
if (!grokBlock(cur.split("\n")))
|
|
596
660
|
return false;
|
|
597
661
|
// the person's text before and after strom's block as it was
|
|
598
|
-
const rest = grokWithout(cur);
|
|
662
|
+
const rest = grokWithout(cur, gap);
|
|
599
663
|
if (rest.trim() || made === "existed")
|
|
600
664
|
writeFileAtomic(t.file, rest);
|
|
601
665
|
else
|
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/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/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/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/
|