@verbaly/studio 0.61.0 → 0.63.0
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/README.md +9 -1
- package/dist/cli.js +254 -21
- package/dist/cli.js.map +1 -1
- package/dist/index.js +254 -21
- package/dist/index.js.map +1 -1
- package/dist/ui/assets/index-C9E7gq0Q.css +1 -0
- package/dist/ui/assets/index-DojQOxSy.js +3 -0
- package/dist/ui/index.html +2 -2
- package/package.json +5 -5
- package/dist/ui/assets/index-BWCfMFep.js +0 -3
- package/dist/ui/assets/index-BYjBNask.css +0 -1
package/README.md
CHANGED
|
@@ -70,6 +70,7 @@ keep in step. Every answer is JSON, including the errors, which are `{ "error":
|
|
|
70
70
|
|---|---|
|
|
71
71
|
| `GET /api/state` | the whole project: config, catalogs, origins, status, check, drafts, triage and problems |
|
|
72
72
|
| `GET /api/health` | what `verbaly doctor` reports |
|
|
73
|
+
| `GET /api/commit/:key` | the commit that last changed that message, or why there is none |
|
|
73
74
|
| `PUT /api/message/:locale/:key` | write a translation **and clear its draft flag**; body `{ "text": "…" }` |
|
|
74
75
|
| `POST /api/approve` | approve drafts; body `{ "locale": "es", "keys": ["…"] }`, and the whole locale when `keys` is omitted |
|
|
75
76
|
| `POST /api/extract` | read your code and add what your catalogs do not have; answers in the request, because it is local and free |
|
|
@@ -108,6 +109,13 @@ catalogs is a race over the same files.
|
|
|
108
109
|
**What a machine writes stays a draft**, here as everywhere else. The panel does not get to change
|
|
109
110
|
that rule, so a finished run leaves you a list to read, not a job marked done.
|
|
110
111
|
|
|
112
|
+
**`GET /api/commit/:key` answers about values, not about lines.** It walks your catalogs' history
|
|
113
|
+
once and remembers, per message, the newest commit where that message's text really differs from its
|
|
114
|
+
parent's. `git blame` is the obvious way to ask and it is wrong here: adding a key rewrites the
|
|
115
|
+
previous line's comma, so blame blames that commit, which on our own site is 26 messages in every
|
|
116
|
+
100. When there is no answer the route says which of the three reasons applies, because "never
|
|
117
|
+
committed", "older than the history I read" and "there is no git here" are three different facts.
|
|
118
|
+
|
|
111
119
|
## 🔎 Triage: which machine translations are worth reading
|
|
112
120
|
|
|
113
121
|
Adding a language writes hundreds of drafts at once, and reading them one by one is a wall. Walls
|
|
@@ -133,7 +141,7 @@ an unfinished translation is not a second opinion.
|
|
|
133
141
|
changed because of what the measurement said.** `digits` was reading the comma after a number as
|
|
134
142
|
part of it, and it was flagging a number the translation added where the source had none, which is
|
|
135
143
|
what "refresh" becoming "F5" looks like. Those were **10 of 10** of its hits, all correct text, so
|
|
136
|
-
both are now excluded. The result is **
|
|
144
|
+
both are now excluded. The result is **21 messages** flagged in Spanish and 20 in Portuguese.
|
|
137
145
|
|
|
138
146
|
## 🧩 Programmatic API
|
|
139
147
|
|
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { parseArgs, promisify } from "node:util";
|
|
3
|
-
import { DRAFTS_FILE, check, clearDrafts, collectOrigins, counted, doctor, effectiveDrafts, extractProject, formatCliError, loadCatalogs, loadConfig, loadDrafts, markDrafts, readCatalog, resolveProvider, saveDrafts, status, syncCatalogs, targetLocales, translateCatalogs, validateMessage, validatePair, writeCatalog, writeDts } from "@verbaly/compiler";
|
|
3
|
+
import { DRAFTS_FILE, check, clearDrafts, collectOrigins, counted, doctor, effectiveDrafts, extractProject, formatCliError, loadCatalogs, loadConfig, loadDrafts, markDrafts, parseCatalog, readCatalog, resolveProvider, saveDrafts, status, syncCatalogs, targetLocales, translateCatalogs, validateMessage, validatePair, writeCatalog, writeDts } from "@verbaly/compiler";
|
|
4
4
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
5
5
|
import { join, normalize, relative, resolve } from "node:path";
|
|
6
6
|
import { createServer } from "node:http";
|
|
7
|
-
import { execFile } from "node:child_process";
|
|
7
|
+
import { execFile, spawn } from "node:child_process";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
10
10
|
//#region src/http.ts
|
|
@@ -37,6 +37,27 @@ const bareDigits = (v) => trimPunct(v).replaceAll(/[.,]/g, ".");
|
|
|
37
37
|
function push(out, key, reason) {
|
|
38
38
|
(out[key] ??= []).push(reason);
|
|
39
39
|
}
|
|
40
|
+
function clip(text, max = 42) {
|
|
41
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
42
|
+
}
|
|
43
|
+
const quote = (text, max) => `"${clip(text, max)}"`;
|
|
44
|
+
function lost(from, to) {
|
|
45
|
+
const rest = [...to];
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const item of from) {
|
|
48
|
+
const at = rest.indexOf(item);
|
|
49
|
+
if (at < 0) out.push(item);
|
|
50
|
+
else rest.splice(at, 1);
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
function movedText(kind, from, to) {
|
|
55
|
+
const gone = lost(from, to);
|
|
56
|
+
const added = lost(to, from);
|
|
57
|
+
if (gone.length && added.length) return `the source ${kind} ${quote(gone[0])}, this one ${kind} ${quote(added[0])}`;
|
|
58
|
+
if (gone.length) return `the source ${kind} ${quote(gone[0])} and this one does not`;
|
|
59
|
+
return `this one ${kind} ${quote(added[0])} and the source does not`;
|
|
60
|
+
}
|
|
40
61
|
function triage({ source, target, reviewed }) {
|
|
41
62
|
const out = Object.create(null);
|
|
42
63
|
const keys = Object.keys(source);
|
|
@@ -47,38 +68,61 @@ function triage({ source, target, reviewed }) {
|
|
|
47
68
|
}
|
|
48
69
|
for (const [text, group] of Object.entries(bySource)) {
|
|
49
70
|
if (group.length < 2) continue;
|
|
50
|
-
|
|
51
|
-
|
|
71
|
+
let split;
|
|
72
|
+
for (const { locale, catalog } of reviewed) {
|
|
73
|
+
const seen = [...new Set(group.map((k) => catalog[k]?.trim() ?? "").filter(Boolean))];
|
|
74
|
+
if (seen.length > 1) {
|
|
75
|
+
split = {
|
|
76
|
+
locale,
|
|
77
|
+
first: seen[0],
|
|
78
|
+
second: seen[1]
|
|
79
|
+
};
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (split) for (const key of group) push(out, key, {
|
|
52
84
|
signal: "divergent",
|
|
53
|
-
text:
|
|
85
|
+
text: `${split.locale} already says this two ways: ${quote(split.first, 32)} and ${quote(split.second, 32)}`
|
|
54
86
|
});
|
|
55
87
|
const targets = new Set(group.map((key) => target[key]?.trim() ?? "").filter(Boolean));
|
|
56
|
-
if (!
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
88
|
+
if (!split && targets.size > 1) for (const key of group) {
|
|
89
|
+
const mine = target[key]?.trim() ?? "";
|
|
90
|
+
const other = group.find((k) => k !== key && (target[k]?.trim() ?? "") !== mine && target[k]?.trim());
|
|
91
|
+
push(out, key, {
|
|
92
|
+
signal: "collision",
|
|
93
|
+
text: other ? `${other} has the same source text and says ${quote(target[other].trim())}` : `${quote(text)} came back as two different translations`
|
|
94
|
+
});
|
|
95
|
+
}
|
|
60
96
|
}
|
|
61
97
|
for (const key of keys) {
|
|
62
98
|
const src = source[key]?.trim() ?? "";
|
|
63
99
|
const trg = target[key]?.trim() ?? "";
|
|
64
100
|
if (!src || !trg) continue;
|
|
65
|
-
const
|
|
66
|
-
|
|
101
|
+
const voters = reviewed.filter(({ catalog }) => {
|
|
102
|
+
const value = catalog[key]?.trim();
|
|
103
|
+
return value !== void 0 && value !== "";
|
|
104
|
+
});
|
|
105
|
+
if (trg === src && voters.length > 0 && voters.every(({ catalog }) => catalog[key]?.trim() !== src)) push(out, key, {
|
|
67
106
|
signal: "echo",
|
|
68
|
-
text:
|
|
107
|
+
text: `still the source text, and ${voters.map(({ locale }) => locale).join(" and ")} did translate it`
|
|
69
108
|
});
|
|
70
109
|
const srcDigits = bag(src, DIGITS, bareDigits);
|
|
71
|
-
|
|
110
|
+
const trgDigits = bag(trg, DIGITS, bareDigits);
|
|
111
|
+
if (srcDigits.length > 0 && !same(srcDigits, trgDigits)) push(out, key, {
|
|
72
112
|
signal: "digits",
|
|
73
|
-
text: "
|
|
113
|
+
text: movedText("says", srcDigits, trgDigits)
|
|
74
114
|
});
|
|
75
|
-
|
|
115
|
+
const srcUrls = bag(src, URLS, trimPunct);
|
|
116
|
+
const trgUrls = bag(trg, URLS, trimPunct);
|
|
117
|
+
if (!same(srcUrls, trgUrls)) push(out, key, {
|
|
76
118
|
signal: "url",
|
|
77
|
-
text: "
|
|
119
|
+
text: movedText("links to", srcUrls, trgUrls)
|
|
78
120
|
});
|
|
79
|
-
|
|
121
|
+
const srcCode = bag(src, CODE);
|
|
122
|
+
const trgCode = bag(trg, CODE);
|
|
123
|
+
if (!same(srcCode, trgCode)) push(out, key, {
|
|
80
124
|
signal: "code",
|
|
81
|
-
text: "the
|
|
125
|
+
text: movedText("shows the code", srcCode, trgCode)
|
|
82
126
|
});
|
|
83
127
|
}
|
|
84
128
|
return out;
|
|
@@ -151,7 +195,10 @@ async function buildState(cfg) {
|
|
|
151
195
|
for (const locale of targets) perLocale[locale] = triage({
|
|
152
196
|
source,
|
|
153
197
|
target: catalogs[locale] ?? {},
|
|
154
|
-
reviewed: targets.filter((other) => other !== locale && !(live[other] ?? []).length).map((other) =>
|
|
198
|
+
reviewed: targets.filter((other) => other !== locale && !(live[other] ?? []).length).map((other) => ({
|
|
199
|
+
locale: other,
|
|
200
|
+
catalog: catalogs[other] ?? {}
|
|
201
|
+
}))
|
|
155
202
|
});
|
|
156
203
|
return {
|
|
157
204
|
root: cfg.root,
|
|
@@ -313,13 +360,13 @@ async function startTranslate(cfg, locales) {
|
|
|
313
360
|
const run = promisify(execFile);
|
|
314
361
|
const FIELD = "";
|
|
315
362
|
const FORMAT = `--format=%H${FIELD}%an${FIELD}%aI${FIELD}%s`;
|
|
316
|
-
function
|
|
363
|
+
function parseFullLog(stdout) {
|
|
317
364
|
const commits = [];
|
|
318
365
|
for (const line of stdout.split("\n")) {
|
|
319
366
|
const [sha, author, date, ...rest] = line.split(FIELD);
|
|
320
367
|
if (!sha || !author || !date || !rest.length) continue;
|
|
321
368
|
commits.push({
|
|
322
|
-
sha
|
|
369
|
+
sha,
|
|
323
370
|
author,
|
|
324
371
|
date,
|
|
325
372
|
subject: rest.join(FIELD)
|
|
@@ -327,6 +374,12 @@ function parseLog(stdout) {
|
|
|
327
374
|
}
|
|
328
375
|
return commits;
|
|
329
376
|
}
|
|
377
|
+
function parseLog(stdout) {
|
|
378
|
+
return parseFullLog(stdout).map((commit) => ({
|
|
379
|
+
...commit,
|
|
380
|
+
sha: commit.sha.slice(0, 7)
|
|
381
|
+
}));
|
|
382
|
+
}
|
|
330
383
|
async function catalogHistory(cfg, limit = 5) {
|
|
331
384
|
const dir = relative(cfg.root, cfg.dir).replaceAll("\\", "/") || ".";
|
|
332
385
|
try {
|
|
@@ -346,6 +399,182 @@ async function catalogHistory(cfg, limit = 5) {
|
|
|
346
399
|
return [];
|
|
347
400
|
}
|
|
348
401
|
}
|
|
402
|
+
function attribute(revisions, owners = /* @__PURE__ */ new Map()) {
|
|
403
|
+
for (const { commit, now, before } of revisions) {
|
|
404
|
+
if (!now || !before) continue;
|
|
405
|
+
for (const key of /* @__PURE__ */ new Set([...Object.keys(now), ...Object.keys(before)])) if (!owners.has(key) && now[key] !== before[key]) owners.set(key, commit);
|
|
406
|
+
}
|
|
407
|
+
return owners;
|
|
408
|
+
}
|
|
409
|
+
function mergeOwners(maps) {
|
|
410
|
+
const merged = /* @__PURE__ */ new Map();
|
|
411
|
+
for (const map of maps) for (const [key, commit] of map) {
|
|
412
|
+
const held = merged.get(key);
|
|
413
|
+
if (!held || Date.parse(commit.date) > Date.parse(held.date)) merged.set(key, commit);
|
|
414
|
+
}
|
|
415
|
+
return merged;
|
|
416
|
+
}
|
|
417
|
+
const BLOB = /^[0-9a-f]{40} \w+ (\d+)$/;
|
|
418
|
+
function readBlobs(root, requests, take) {
|
|
419
|
+
return new Promise((resolve) => {
|
|
420
|
+
const child = spawn("git", ["cat-file", "--batch"], {
|
|
421
|
+
cwd: root,
|
|
422
|
+
windowsHide: true
|
|
423
|
+
});
|
|
424
|
+
const stop = setTimeout(() => {
|
|
425
|
+
ok = false;
|
|
426
|
+
child.kill();
|
|
427
|
+
}, 2e4);
|
|
428
|
+
let pending = Buffer.alloc(0);
|
|
429
|
+
let index = 0;
|
|
430
|
+
let ok = true;
|
|
431
|
+
const fail = () => {
|
|
432
|
+
ok = false;
|
|
433
|
+
};
|
|
434
|
+
child.on("error", fail);
|
|
435
|
+
child.stdin.on("error", fail);
|
|
436
|
+
child.stdout.on("data", (chunk) => {
|
|
437
|
+
pending = pending.length ? Buffer.concat([pending, chunk]) : chunk;
|
|
438
|
+
for (;;) {
|
|
439
|
+
const end = pending.indexOf(10);
|
|
440
|
+
if (end < 0) break;
|
|
441
|
+
const header = pending.toString("utf8", 0, end);
|
|
442
|
+
const blob = BLOB.exec(header);
|
|
443
|
+
if (!blob) {
|
|
444
|
+
take(index++, null, "");
|
|
445
|
+
pending = pending.subarray(end + 1);
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
const size = Number(blob[1]);
|
|
449
|
+
if (pending.length < end + size + 2) break;
|
|
450
|
+
take(index++, pending.toString("utf8", end + 1, end + 1 + size), header.slice(0, 40));
|
|
451
|
+
pending = pending.subarray(end + size + 2);
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
child.on("close", () => {
|
|
455
|
+
clearTimeout(stop);
|
|
456
|
+
resolve(ok);
|
|
457
|
+
});
|
|
458
|
+
child.stdin.end(requests.join("\n") + "\n");
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
const WINDOW = 200;
|
|
462
|
+
async function walkFile(root, file, limit) {
|
|
463
|
+
const { stdout } = await run("git", [
|
|
464
|
+
"log",
|
|
465
|
+
`-n${limit}`,
|
|
466
|
+
FORMAT,
|
|
467
|
+
"--",
|
|
468
|
+
file
|
|
469
|
+
], {
|
|
470
|
+
cwd: root,
|
|
471
|
+
timeout: 5e3,
|
|
472
|
+
windowsHide: true,
|
|
473
|
+
maxBuffer: 8e6
|
|
474
|
+
});
|
|
475
|
+
const commits = parseFullLog(stdout);
|
|
476
|
+
if (!commits.length) return {
|
|
477
|
+
owners: /* @__PURE__ */ new Map(),
|
|
478
|
+
head: []
|
|
479
|
+
};
|
|
480
|
+
const owners = /* @__PURE__ */ new Map();
|
|
481
|
+
let head = [];
|
|
482
|
+
let waiting;
|
|
483
|
+
let last = null;
|
|
484
|
+
const parse = (body, blob) => {
|
|
485
|
+
if (body === null) return {};
|
|
486
|
+
if (last && last.blob === blob) return last.flat;
|
|
487
|
+
const flat = parseCatalog(body);
|
|
488
|
+
last = {
|
|
489
|
+
blob,
|
|
490
|
+
flat
|
|
491
|
+
};
|
|
492
|
+
return flat;
|
|
493
|
+
};
|
|
494
|
+
if (!await readBlobs(root, commits.flatMap((commit) => [`${commit.sha}:${file}`, `${commit.sha}^:${file}`]), (index, body, blob) => {
|
|
495
|
+
const flat = parse(body, blob);
|
|
496
|
+
if (index % 2 === 0) {
|
|
497
|
+
waiting = flat;
|
|
498
|
+
if (index === 0 && flat) head = Object.keys(flat);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const commit = commits[(index - 1) / 2];
|
|
502
|
+
if (commit) attribute([{
|
|
503
|
+
commit,
|
|
504
|
+
now: waiting,
|
|
505
|
+
before: flat
|
|
506
|
+
}], owners);
|
|
507
|
+
waiting = void 0;
|
|
508
|
+
})) throw new Error("[verbaly] git could not read every revision of this catalog");
|
|
509
|
+
return {
|
|
510
|
+
owners,
|
|
511
|
+
head
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
async function catalogKeyHistory(cfg, limit = WINDOW) {
|
|
515
|
+
const maps = [];
|
|
516
|
+
const committed = /* @__PURE__ */ new Set();
|
|
517
|
+
let ok = false;
|
|
518
|
+
for (const locale of cfg.locales) {
|
|
519
|
+
const file = relative(cfg.root, join(cfg.dir, `${locale}.json`)).replaceAll("\\", "/");
|
|
520
|
+
try {
|
|
521
|
+
const walked = await walkFile(cfg.root, file, limit);
|
|
522
|
+
maps.push(walked.owners);
|
|
523
|
+
for (const key of walked.head) committed.add(key);
|
|
524
|
+
ok = true;
|
|
525
|
+
} catch {}
|
|
526
|
+
}
|
|
527
|
+
return {
|
|
528
|
+
owners: mergeOwners(maps),
|
|
529
|
+
committed,
|
|
530
|
+
ok
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
const walked = /* @__PURE__ */ new Map();
|
|
534
|
+
async function headSha(root) {
|
|
535
|
+
try {
|
|
536
|
+
const { stdout } = await run("git", ["rev-parse", "HEAD"], {
|
|
537
|
+
cwd: root,
|
|
538
|
+
timeout: 3e3,
|
|
539
|
+
windowsHide: true
|
|
540
|
+
});
|
|
541
|
+
return stdout.trim();
|
|
542
|
+
} catch {
|
|
543
|
+
return "";
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
async function keyHistory(cfg) {
|
|
547
|
+
const head = await headSha(cfg.root);
|
|
548
|
+
const held = walked.get(cfg.root);
|
|
549
|
+
if (held && held.head === head) return held.history;
|
|
550
|
+
const history = await catalogKeyHistory(cfg);
|
|
551
|
+
walked.set(cfg.root, {
|
|
552
|
+
head,
|
|
553
|
+
history
|
|
554
|
+
});
|
|
555
|
+
return history;
|
|
556
|
+
}
|
|
557
|
+
async function keyCommit(cfg, key) {
|
|
558
|
+
const history = await keyHistory(cfg);
|
|
559
|
+
if (!history.ok) return {
|
|
560
|
+
key,
|
|
561
|
+
commit: null,
|
|
562
|
+
reason: "nogit"
|
|
563
|
+
};
|
|
564
|
+
const commit = history.owners.get(key);
|
|
565
|
+
if (commit) return {
|
|
566
|
+
key,
|
|
567
|
+
commit: {
|
|
568
|
+
...commit,
|
|
569
|
+
sha: commit.sha.slice(0, 7)
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
return {
|
|
573
|
+
key,
|
|
574
|
+
commit: null,
|
|
575
|
+
reason: history.committed.has(key) ? "older" : "uncommitted"
|
|
576
|
+
};
|
|
577
|
+
}
|
|
349
578
|
//#endregion
|
|
350
579
|
//#region src/assets.ts
|
|
351
580
|
const UI = resolve(fileURLToPath(new URL(".", import.meta.url)), "ui");
|
|
@@ -495,6 +724,10 @@ function createStudioApp(cfg, port, token) {
|
|
|
495
724
|
send(res, 200, { commits: await catalogHistory(cfg) });
|
|
496
725
|
return;
|
|
497
726
|
}
|
|
727
|
+
if (req.method === "GET" && url.pathname.startsWith("/api/commit/")) {
|
|
728
|
+
send(res, 200, await keyCommit(cfg, segment(url.pathname.slice(12), "key")));
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
498
731
|
if (req.method === "GET" && url.pathname === "/api/health") {
|
|
499
732
|
send(res, 200, await health(cfg));
|
|
500
733
|
return;
|