@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 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 **23 messages** flagged in Spanish and 22 in Portuguese.
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
- const divergent = reviewed.some((catalog) => new Set(group.map((k) => catalog[k]?.trim() ?? "").filter(Boolean)).size > 1);
51
- if (divergent) for (const key of group) push(out, key, {
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: `"${text}" was already translated two ways`
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 (!divergent && targets.size > 1) for (const key of group) push(out, key, {
57
- signal: "collision",
58
- text: `"${text}" came back as two different translations`
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 opinions = reviewed.map((c) => c[key]?.trim()).filter((v) => v !== void 0 && v !== "");
66
- if (trg === src && opinions.length > 0 && opinions.every((v) => v !== src)) push(out, key, {
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: "still identical to the source"
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
- if (srcDigits.length > 0 && !same(srcDigits, bag(trg, DIGITS, bareDigits))) push(out, key, {
110
+ const trgDigits = bag(trg, DIGITS, bareDigits);
111
+ if (srcDigits.length > 0 && !same(srcDigits, trgDigits)) push(out, key, {
72
112
  signal: "digits",
73
- text: "the numbers do not match the source"
113
+ text: movedText("says", srcDigits, trgDigits)
74
114
  });
75
- if (!same(bag(src, URLS, trimPunct), bag(trg, URLS, trimPunct))) push(out, key, {
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: "a URL changed"
119
+ text: movedText("links to", srcUrls, trgUrls)
78
120
  });
79
- if (!same(bag(src, CODE), bag(trg, CODE))) push(out, key, {
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 contents of a <code> span changed"
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) => catalogs[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 parseLog(stdout) {
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: sha.slice(0, 7),
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;