@verbatra/sdk 0.6.0 → 0.6.2

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/index.js CHANGED
@@ -1,10 +1,10 @@
1
- import { existsSync } from 'fs';
2
1
  import { dirname, resolve, join, basename, extname } from 'path';
2
+ import { z } from 'zod';
3
+ import { existsSync } from 'fs';
3
4
  import { cosmiconfig } from 'cosmiconfig';
4
5
  import { TypeScriptLoader } from 'cosmiconfig-typescript-loader';
5
6
  import { randomUUID } from 'crypto';
6
7
  import { rm, access, mkdir, open, writeFile, rename } from 'fs/promises';
7
- import { z } from 'zod';
8
8
  import Anthropic from '@anthropic-ai/sdk';
9
9
  import * as deepl from 'deepl-node';
10
10
  import { createRequire } from 'module';
@@ -15,10 +15,100 @@ import { parse, TYPE } from '@formatjs/icu-messageformat-parser';
15
15
  import { XMLSerializer, DOMParser } from '@xmldom/xmldom';
16
16
  import { stringify, parse as parse$1 } from 'yaml';
17
17
  import ExcelJS from 'exceljs';
18
- import { Readable } from 'stream';
19
18
  import JSZip from 'jszip';
19
+ import { Readable } from 'stream';
20
20
  import { watch as watch$1 } from 'chokidar';
21
21
 
22
+ // src/cache/translation-memory.ts
23
+ var CACHE_FILE_NAME = "verbatra.cache.json";
24
+ var CURRENT_VERSION = 1;
25
+ var EMPTY_MEMORY = { version: CURRENT_VERSION, entries: {} };
26
+ var MAX_CACHE_FILE_BYTES = 64 * 1024 * 1024;
27
+ var translationMemorySchema = z.object({
28
+ version: z.number().int().positive(),
29
+ entries: z.record(z.string(), z.record(z.string(), z.record(z.string(), z.string())))
30
+ });
31
+ function cacheFilePath(cwd) {
32
+ return resolve(cwd, CACHE_FILE_NAME);
33
+ }
34
+ async function readTranslationMemory(path, fs) {
35
+ let read;
36
+ try {
37
+ read = await fs.readFileBounded(path, MAX_CACHE_FILE_BYTES);
38
+ } catch {
39
+ return EMPTY_MEMORY;
40
+ }
41
+ if (read.kind !== "ok") {
42
+ return EMPTY_MEMORY;
43
+ }
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(read.content);
47
+ } catch {
48
+ return EMPTY_MEMORY;
49
+ }
50
+ const result = translationMemorySchema.safeParse(parsed);
51
+ if (!result.success || result.data.version !== CURRENT_VERSION) {
52
+ return EMPTY_MEMORY;
53
+ }
54
+ return result.data;
55
+ }
56
+ function lookupMemory(memory, fingerprint, locale, contentHash2) {
57
+ return memory.entries[fingerprint]?.[locale]?.[contentHash2];
58
+ }
59
+ function applyAdditions(base, fingerprint, additionsByLocale) {
60
+ if (additionsByLocale.size === 0) {
61
+ return base;
62
+ }
63
+ const fingerprintEntries = {};
64
+ for (const [locale, hashes] of Object.entries(base.entries[fingerprint] ?? {})) {
65
+ fingerprintEntries[locale] = { ...hashes };
66
+ }
67
+ for (const [locale, hashes] of additionsByLocale) {
68
+ fingerprintEntries[locale] = { ...fingerprintEntries[locale], ...hashes };
69
+ }
70
+ return {
71
+ version: base.version,
72
+ entries: { ...base.entries, [fingerprint]: fingerprintEntries }
73
+ };
74
+ }
75
+ function additionsToRecord(additions) {
76
+ const record = {};
77
+ for (const addition of additions) {
78
+ record[addition.contentHash] = addition.value;
79
+ }
80
+ return record;
81
+ }
82
+ function sortEntries(record) {
83
+ return Object.entries(record).sort(([a], [b]) => a < b ? -1 : 1);
84
+ }
85
+ function serialize(memory) {
86
+ const entries = {};
87
+ for (const [fingerprint, locales] of sortEntries(memory.entries)) {
88
+ const localeMap = {};
89
+ for (const [locale, hashes] of sortEntries(locales)) {
90
+ localeMap[locale] = Object.fromEntries(sortEntries(hashes));
91
+ }
92
+ entries[fingerprint] = localeMap;
93
+ }
94
+ return `${JSON.stringify({ version: memory.version, entries }, null, 2)}
95
+ `;
96
+ }
97
+ async function writeTranslationMemory(path, memory, fs) {
98
+ await fs.writeFile(path, serialize(memory));
99
+ }
100
+ async function feedTranslationMemory(cwd, fs, fingerprint, additionsByLocale) {
101
+ if (additionsByLocale.size === 0) {
102
+ return;
103
+ }
104
+ try {
105
+ const path = cacheFilePath(cwd);
106
+ const memory = await readTranslationMemory(path, fs);
107
+ await writeTranslationMemory(path, applyAdditions(memory, fingerprint, additionsByLocale), fs);
108
+ } catch {
109
+ }
110
+ }
111
+
22
112
  // src/config/define-config.ts
23
113
  function defineConfig(config) {
24
114
  return config;
@@ -212,7 +302,7 @@ async function resolveGlossary(glossary, baseDir, fs) {
212
302
  var FNV_OFFSET_BASIS = 14695981039346656037n;
213
303
  var FNV_PRIME = 1099511628211n;
214
304
  var U64_MASK = (1n << 64n) - 1n;
215
- function fnv1a64(input) {
305
+ function stableStringHash(input) {
216
306
  let hash = FNV_OFFSET_BASIS;
217
307
  for (let index = 0; index < input.length; index += 1) {
218
308
  hash ^= BigInt(input.charCodeAt(index));
@@ -233,7 +323,7 @@ function canonicalize(entry) {
233
323
  ]);
234
324
  }
235
325
  function contentHash(entry) {
236
- return fnv1a64(canonicalize(entry));
326
+ return stableStringHash(canonicalize(entry));
237
327
  }
238
328
  function sorted(keys) {
239
329
  return [...keys].sort();
@@ -278,7 +368,8 @@ var SUPPORTED_FORMATS = [
278
368
  "ngx-translate-json",
279
369
  "xliff",
280
370
  "yaml",
281
- "arb"
371
+ "arb",
372
+ "properties"
282
373
  ];
283
374
  var supportedFormatSchema = z.enum(SUPPORTED_FORMATS);
284
375
  var translationEntrySchema = z.object({
@@ -329,77 +420,76 @@ function checkPlaceholders(source, translated) {
329
420
  reordered
330
421
  };
331
422
  }
332
- var LOCALE_TOKEN = "{locale}";
333
- function localeFilePath(cwd, pattern, locale) {
334
- return resolve(cwd, pattern.replaceAll(LOCALE_TOKEN, locale));
335
- }
336
- var RATE_LIMITED_STATUS = 429;
337
- var TIMEOUT_STATUS = 408;
338
- var AUTH_FAILED_STATUSES = /* @__PURE__ */ new Set([401, 403]);
339
- var RATE_LIMITED_CLASS_NAMES = /* @__PURE__ */ new Set([
340
- "RateLimitError",
341
- "TooManyRequestsError"
342
- ]);
343
- var AUTH_FAILED_CLASS_NAMES = /* @__PURE__ */ new Set([
344
- "AuthenticationError",
345
- "PermissionDeniedError",
346
- "AuthorizationError"
347
- ]);
348
- var TIMEOUT_CLASS_NAMES = /* @__PURE__ */ new Set([
349
- "APIConnectionTimeoutError",
350
- "ConnectionError"
351
- ]);
352
- var ABORT_ERROR_CLASS_NAMES = /* @__PURE__ */ new Set(["APIUserAbortError"]);
353
- function getErrorStatus(error) {
354
- if (typeof error !== "object" || error === null || !("status" in error)) {
355
- return void 0;
423
+ var MIN_SOURCE_LENGTH = 8;
424
+ var MAX_LENGTH_MULTIPLE = 12;
425
+ var MAX_REPEAT_UNIT_LENGTH = 16;
426
+ var MIN_CONSECUTIVE_REPEATS = 8;
427
+ var REPEAT_COVERAGE_FRACTION = 0.5;
428
+ var MAX_SCAN_LENGTH = 8192;
429
+ function matchesUnit(text, a, b, unit) {
430
+ for (let i = 0; i < unit; i++) {
431
+ if (text[a + i] !== text[b + i]) {
432
+ return false;
433
+ }
356
434
  }
357
- const status = error.status;
358
- return typeof status === "number" ? status : void 0;
435
+ return true;
359
436
  }
360
- function readClassName(error) {
361
- if (typeof error !== "object" || error === null) {
362
- return void 0;
437
+ function countConsecutiveCopies(text, start, unit, scanLength) {
438
+ let copies = 1;
439
+ let next = start + unit;
440
+ while (next + unit <= scanLength && matchesUnit(text, start, next, unit)) {
441
+ copies++;
442
+ next += unit;
443
+ }
444
+ return copies;
445
+ }
446
+ function hasDominantRun(text, scanLength, unit, coverage) {
447
+ const limit = scanLength - unit;
448
+ let start = 0;
449
+ while (start <= limit) {
450
+ const copies = countConsecutiveCopies(text, start, unit, scanLength);
451
+ if (copies >= MIN_CONSECUTIVE_REPEATS && copies * unit >= coverage) {
452
+ return true;
453
+ }
454
+ start += copies * unit;
363
455
  }
364
- return error.constructor?.name;
456
+ return false;
365
457
  }
366
- function classifyProviderError(error) {
367
- const status = getErrorStatus(error);
368
- if (status === RATE_LIMITED_STATUS) {
369
- return "RATE_LIMITED";
370
- }
371
- if (status !== void 0 && AUTH_FAILED_STATUSES.has(status)) {
372
- return "AUTH_FAILED";
373
- }
374
- if (status === TIMEOUT_STATUS) {
375
- return "TIMEOUT";
376
- }
377
- const className = readClassName(error);
378
- if (className !== void 0) {
379
- if (RATE_LIMITED_CLASS_NAMES.has(className)) {
380
- return "RATE_LIMITED";
381
- }
382
- if (AUTH_FAILED_CLASS_NAMES.has(className)) {
383
- return "AUTH_FAILED";
384
- }
385
- if (TIMEOUT_CLASS_NAMES.has(className)) {
386
- return "TIMEOUT";
458
+ function hasRunawayRepetition(text) {
459
+ const scanLength = Math.min(text.length, MAX_SCAN_LENGTH);
460
+ const coverage = scanLength * REPEAT_COVERAGE_FRACTION;
461
+ const maxUnit = Math.min(
462
+ MAX_REPEAT_UNIT_LENGTH,
463
+ Math.floor(scanLength / MIN_CONSECUTIVE_REPEATS)
464
+ );
465
+ for (let unit = 1; unit <= maxUnit; unit++) {
466
+ if (hasDominantRun(text, scanLength, unit, coverage)) {
467
+ return true;
387
468
  }
388
469
  }
389
- return "PROVIDER_ERROR";
470
+ return false;
390
471
  }
391
- function isAbortShapedError(error) {
392
- if (error instanceof Error && error.name === "AbortError") {
393
- return true;
472
+ function assessValueDegeneracy(sourceValue, candidateValue) {
473
+ const source = sourceValue.trim();
474
+ const candidate = candidateValue.trim();
475
+ if (source.length >= MIN_SOURCE_LENGTH && candidate.length >= source.length * MAX_LENGTH_MULTIPLE) {
476
+ return { degenerate: true };
394
477
  }
395
- const className = readClassName(error);
396
- return className !== void 0 && ABORT_ERROR_CLASS_NAMES.has(className);
478
+ return { degenerate: hasRunawayRepetition(candidate) };
397
479
  }
398
- function isAbortError(error, signal) {
399
- if (signal !== void 0) {
400
- return signal.aborted === true && isAbortShapedError(error);
480
+ var LOCALE_TOKEN = "{locale}";
481
+ function localeFilePath(cwd, pattern, locale) {
482
+ return resolve(cwd, pattern.replaceAll(LOCALE_TOKEN, locale));
483
+ }
484
+ function checkBatchIntegrity(inputs, extract, compare) {
485
+ const integrity = /* @__PURE__ */ new Map();
486
+ for (const { key, sourceValue, translatedValue } of inputs) {
487
+ integrity.set(
488
+ key,
489
+ compare !== void 0 ? compare(sourceValue, translatedValue) : checkPlaceholders(extract(sourceValue), extract(translatedValue))
490
+ );
401
491
  }
402
- return isAbortShapedError(error);
492
+ return integrity;
403
493
  }
404
494
  var REDACTED = "[REDACTED]";
405
495
  var KEY_PATTERNS = [
@@ -437,38 +527,6 @@ var ProviderError = class extends Error {
437
527
  this.code = code;
438
528
  }
439
529
  };
440
- var PROVIDER_CALL_FAILED_MESSAGE = "The translation provider request failed.";
441
- var RATE_LIMITED_MESSAGE = "The translation provider rate-limited this request.";
442
- var TIMEOUT_MESSAGE = "The translation provider request timed out.";
443
- var AUTH_FAILED_MESSAGE = "The translation provider rejected the request credentials.";
444
- var MESSAGE_BY_CODE = {
445
- RATE_LIMITED: RATE_LIMITED_MESSAGE,
446
- TIMEOUT: TIMEOUT_MESSAGE,
447
- AUTH_FAILED: AUTH_FAILED_MESSAGE,
448
- PROVIDER_ERROR: PROVIDER_CALL_FAILED_MESSAGE
449
- };
450
- async function guardProviderCall(call, signal) {
451
- signal?.throwIfAborted();
452
- try {
453
- return await call();
454
- } catch (error) {
455
- if (isAbortError(error, signal)) {
456
- throw error;
457
- }
458
- const code = classifyProviderError(error);
459
- throw new ProviderError(code, MESSAGE_BY_CODE[code]);
460
- }
461
- }
462
- function checkBatchIntegrity(inputs, extract, compare) {
463
- const integrity = /* @__PURE__ */ new Map();
464
- for (const { key, sourceValue, translatedValue } of inputs) {
465
- integrity.set(
466
- key,
467
- compare !== void 0 ? compare(sourceValue, translatedValue) : checkPlaceholders(extract(sourceValue), extract(translatedValue))
468
- );
469
- }
470
- return integrity;
471
- }
472
530
  var requestDataSchema = z.object({
473
531
  sourceLocale: z.string().min(1),
474
532
  targetLocale: z.string().min(1),
@@ -711,6 +769,121 @@ function assertNotTruncated(truncated) {
711
769
  throw new ProviderError("OUTPUT_TRUNCATED", OUTPUT_TRUNCATED_MESSAGE);
712
770
  }
713
771
  }
772
+ var RATE_LIMITED_STATUS = 429;
773
+ var TIMEOUT_STATUS = 408;
774
+ var AUTH_FAILED_STATUSES = /* @__PURE__ */ new Set([401, 403]);
775
+ var RATE_LIMITED_CLASS_NAMES = /* @__PURE__ */ new Set([
776
+ "RateLimitError",
777
+ "TooManyRequestsError"
778
+ ]);
779
+ var AUTH_FAILED_CLASS_NAMES = /* @__PURE__ */ new Set([
780
+ "AuthenticationError",
781
+ "PermissionDeniedError",
782
+ "AuthorizationError"
783
+ ]);
784
+ var TIMEOUT_CLASS_NAMES = /* @__PURE__ */ new Set([
785
+ "APIConnectionTimeoutError",
786
+ "ConnectionError"
787
+ ]);
788
+ var ABORT_ERROR_CLASS_NAMES = /* @__PURE__ */ new Set(["APIUserAbortError"]);
789
+ function getErrorStatus(error) {
790
+ if (typeof error !== "object" || error === null || !("status" in error)) {
791
+ return void 0;
792
+ }
793
+ const status = error.status;
794
+ return typeof status === "number" ? status : void 0;
795
+ }
796
+ function readClassName(error) {
797
+ if (typeof error !== "object" || error === null) {
798
+ return void 0;
799
+ }
800
+ return error.constructor?.name;
801
+ }
802
+ function classifyProviderError(error) {
803
+ const status = getErrorStatus(error);
804
+ if (status === RATE_LIMITED_STATUS) {
805
+ return "RATE_LIMITED";
806
+ }
807
+ if (status !== void 0 && AUTH_FAILED_STATUSES.has(status)) {
808
+ return "AUTH_FAILED";
809
+ }
810
+ if (status === TIMEOUT_STATUS) {
811
+ return "TIMEOUT";
812
+ }
813
+ const className = readClassName(error);
814
+ if (className !== void 0) {
815
+ if (RATE_LIMITED_CLASS_NAMES.has(className)) {
816
+ return "RATE_LIMITED";
817
+ }
818
+ if (AUTH_FAILED_CLASS_NAMES.has(className)) {
819
+ return "AUTH_FAILED";
820
+ }
821
+ if (TIMEOUT_CLASS_NAMES.has(className)) {
822
+ return "TIMEOUT";
823
+ }
824
+ }
825
+ return "PROVIDER_ERROR";
826
+ }
827
+ function isAbortShapedError(error) {
828
+ if (error instanceof Error && error.name === "AbortError") {
829
+ return true;
830
+ }
831
+ const className = readClassName(error);
832
+ return className !== void 0 && ABORT_ERROR_CLASS_NAMES.has(className);
833
+ }
834
+ function isAbortError(error, signal) {
835
+ if (signal !== void 0) {
836
+ return signal.aborted === true && isAbortShapedError(error);
837
+ }
838
+ return isAbortShapedError(error);
839
+ }
840
+ var PROVIDER_CALL_FAILED_MESSAGE = "The translation provider request failed.";
841
+ var RATE_LIMITED_MESSAGE = "The translation provider rate-limited this request.";
842
+ var TIMEOUT_MESSAGE = "The translation provider request timed out.";
843
+ var AUTH_FAILED_MESSAGE = "The translation provider rejected the request credentials.";
844
+ var MESSAGE_BY_CODE = {
845
+ RATE_LIMITED: RATE_LIMITED_MESSAGE,
846
+ TIMEOUT: TIMEOUT_MESSAGE,
847
+ AUTH_FAILED: AUTH_FAILED_MESSAGE,
848
+ PROVIDER_ERROR: PROVIDER_CALL_FAILED_MESSAGE
849
+ };
850
+ async function guardProviderCall(call, signal) {
851
+ signal?.throwIfAborted();
852
+ try {
853
+ return await call();
854
+ } catch (error) {
855
+ if (isAbortError(error, signal)) {
856
+ throw error;
857
+ }
858
+ const code = classifyProviderError(error);
859
+ throw new ProviderError(code, MESSAGE_BY_CODE[code]);
860
+ }
861
+ }
862
+ var DEFAULT_REQUEST_TIMEOUT_MS = 12e4;
863
+ function requestTimedOutMessage(timeoutMs) {
864
+ return `The translation provider request exceeded the ${timeoutMs} ms request timeout.`;
865
+ }
866
+ function combineSignals(caller, timeout) {
867
+ return caller === void 0 ? timeout : AbortSignal.any([caller, timeout]);
868
+ }
869
+ async function withRequestTimeout(timeoutMs, callerSignal, call) {
870
+ const timeoutController = new AbortController();
871
+ const signal = combineSignals(callerSignal, timeoutController.signal);
872
+ let timer;
873
+ const timedOut = new Promise((_, reject) => {
874
+ timer = setTimeout(() => {
875
+ timeoutController.abort();
876
+ reject(new ProviderError("TIMEOUT", requestTimedOutMessage(timeoutMs)));
877
+ }, timeoutMs);
878
+ });
879
+ try {
880
+ return await Promise.race([guardProviderCall(() => call(signal), signal), timedOut]);
881
+ } finally {
882
+ if (timer !== void 0) {
883
+ clearTimeout(timer);
884
+ }
885
+ }
886
+ }
714
887
  var PROVIDER_ENV = {
715
888
  anthropic: "ANTHROPIC_API_KEY",
716
889
  openai: "OPENAI_API_KEY",
@@ -765,7 +938,13 @@ function createDefaultClient() {
765
938
  }
766
939
  var anthropicConfigSchema = z.object({
767
940
  model: z.string().min(1),
768
- maxTokens: z.number().int().positive()
941
+ maxTokens: z.number().int().positive(),
942
+ /**
943
+ * Optional verbatra-imposed per-request timeout in milliseconds. A positive integer; when absent,
944
+ * the shared default request timeout applies. Bounds each outbound request so a hung-but-alive
945
+ * server cannot hold a locale's write lock open forever.
946
+ */
947
+ requestTimeoutMs: z.number().int().positive().optional()
769
948
  });
770
949
  var SUBMIT_TOOL_NAME = "submit_translations";
771
950
  var SYSTEM_RULES = [
@@ -825,10 +1004,11 @@ function createAnthropicProvider(config, deps = {}) {
825
1004
  };
826
1005
  }
827
1006
  function createMechanism(client, config) {
1007
+ const timeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
828
1008
  return {
829
1009
  translate: async ({ payloadJson, signal }) => {
830
1010
  const body = buildRequest(config, payloadJson);
831
- const message = await callClient(client, body, signal);
1011
+ const message = await callClient(client, body, timeoutMs, signal);
832
1012
  assertNotTruncated(message.stop_reason === "max_tokens");
833
1013
  const raw = requireToolInput(message.content);
834
1014
  const usage = toUsage(message.usage);
@@ -836,10 +1016,11 @@ function createMechanism(client, config) {
836
1016
  }
837
1017
  };
838
1018
  }
839
- function callClient(client, body, signal) {
840
- return guardProviderCall(
841
- () => client.messages.create(body, signal !== void 0 ? { signal } : void 0),
842
- signal
1019
+ function callClient(client, body, timeoutMs, signal) {
1020
+ return withRequestTimeout(
1021
+ timeoutMs,
1022
+ signal,
1023
+ (requestSignal) => client.messages.create(body, { signal: requestSignal })
843
1024
  );
844
1025
  }
845
1026
  function toUsage(usage) {
@@ -853,7 +1034,14 @@ function toUsage(usage) {
853
1034
  return { inputTokens: input_tokens, outputTokens: output_tokens };
854
1035
  }
855
1036
  var deepLConfigSchema = z.object({
856
- glossaryId: z.string().min(1).optional()
1037
+ glossaryId: z.string().min(1).optional(),
1038
+ /**
1039
+ * Optional verbatra-imposed per-request timeout in milliseconds. A positive integer; when absent,
1040
+ * the shared default request timeout applies. Bounds each outbound request so a hung-but-alive
1041
+ * server cannot hold a locale's write lock open forever. deepl-node cannot cancel an in-flight
1042
+ * request, so on timeout verbatra's await is released but the underlying request is left to settle.
1043
+ */
1044
+ requestTimeoutMs: z.number().int().positive().optional()
857
1045
  });
858
1046
  var DEEPL_LOGGER = "deepl";
859
1047
  function resolveDeeplLoglevel(requireFn = createRequire(import.meta.url)) {
@@ -1024,6 +1212,7 @@ async function translate(bundle, config, request) {
1024
1212
  options,
1025
1213
  request.extractPlaceholders,
1026
1214
  request.comparePlaceholders,
1215
+ config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
1027
1216
  request.signal
1028
1217
  );
1029
1218
  if (unprotectable.length > 0) {
@@ -1058,7 +1247,7 @@ function buildReviewFlags2(protectable, data, values, integrity, glossary) {
1058
1247
  }
1059
1248
  return reviewFlags;
1060
1249
  }
1061
- async function translateProtectable(client, data, protectable, options, extract, compare, signal) {
1250
+ async function translateProtectable(client, data, protectable, options, extract, compare, timeoutMs, signal) {
1062
1251
  if (protectable.length === 0) {
1063
1252
  return { values: /* @__PURE__ */ new Map(), integrity: /* @__PURE__ */ new Map() };
1064
1253
  }
@@ -1069,29 +1258,45 @@ async function translateProtectable(client, data, protectable, options, extract,
1069
1258
  data.sourceLocale,
1070
1259
  data.targetLocale,
1071
1260
  options,
1261
+ timeoutMs,
1072
1262
  signal
1073
1263
  );
1074
1264
  const { values, integrityInputs } = zipResults(protectable, results);
1075
1265
  const integrity = checkBatchIntegrity(integrityInputs, extract, compare);
1076
1266
  return { values, integrity };
1077
1267
  }
1078
- function callClient2(client, texts, sourceLang, targetLang, options, signal) {
1079
- return guardProviderCall(
1080
- () => client.translateText(texts, sourceLang, targetLang, options),
1081
- signal
1268
+ function callClient2(client, texts, sourceLang, targetLang, options, timeoutMs, signal) {
1269
+ return withRequestTimeout(
1270
+ timeoutMs,
1271
+ signal,
1272
+ () => client.translateText(texts, sourceLang, targetLang, options)
1082
1273
  );
1083
1274
  }
1084
- async function callClientChunked(client, texts, sourceLang, targetLang, options, signal) {
1275
+ async function callClientChunked(client, texts, sourceLang, targetLang, options, timeoutMs, signal) {
1085
1276
  const results = [];
1086
1277
  for (const chunk2 of chunkTextsForDeepL(texts)) {
1087
- const chunkResults = await callClient2(client, chunk2, sourceLang, targetLang, options, signal);
1278
+ const chunkResults = await callClient2(
1279
+ client,
1280
+ chunk2,
1281
+ sourceLang,
1282
+ targetLang,
1283
+ options,
1284
+ timeoutMs,
1285
+ signal
1286
+ );
1088
1287
  results.push(...chunkResults);
1089
1288
  }
1090
1289
  return results;
1091
1290
  }
1092
1291
  var geminiConfigSchema = z.object({
1093
1292
  model: z.string().min(1),
1094
- maxOutputTokens: z.number().int().positive()
1293
+ maxOutputTokens: z.number().int().positive(),
1294
+ /**
1295
+ * Optional verbatra-imposed per-request timeout in milliseconds. A positive integer; when absent,
1296
+ * the shared default request timeout applies. Bounds each outbound request so a hung-but-alive
1297
+ * server cannot hold a locale's write lock open forever.
1298
+ */
1299
+ requestTimeoutMs: z.number().int().positive().optional()
1095
1300
  });
1096
1301
  var DEFAULT_GEMINI_RETRY = { attempts: 3, baseDelayMs: 250 };
1097
1302
  function isRetryableStatus(error) {
@@ -1106,14 +1311,14 @@ function throwAbort(signal) {
1106
1311
  throw new DOMException("This operation was aborted.", "AbortError");
1107
1312
  }
1108
1313
  function delay(ms, signal) {
1109
- return new Promise((resolve9) => {
1314
+ return new Promise((resolve10) => {
1110
1315
  const onAbort = () => {
1111
1316
  clearTimeout(timer);
1112
- resolve9();
1317
+ resolve10();
1113
1318
  };
1114
1319
  const timer = setTimeout(() => {
1115
1320
  signal?.removeEventListener("abort", onAbort);
1116
- resolve9();
1321
+ resolve10();
1117
1322
  }, ms);
1118
1323
  signal?.addEventListener("abort", onAbort, { once: true });
1119
1324
  });
@@ -1281,20 +1486,27 @@ function createGeminiProvider(config, deps = {}) {
1281
1486
  };
1282
1487
  }
1283
1488
  function createMechanism2(client, config) {
1489
+ const timeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
1284
1490
  return {
1285
1491
  translate: async ({ payloadJson, signal }) => {
1286
- const request = buildGeminiRequest(config, payloadJson, signal);
1287
- const response = await callClient3(client, request, signal);
1492
+ const response = await withRequestTimeout(
1493
+ timeoutMs,
1494
+ signal,
1495
+ (requestSignal) => client.models.generateContent(buildGeminiRequest(config, payloadJson, requestSignal))
1496
+ );
1288
1497
  return extractGeminiResult(response);
1289
1498
  }
1290
1499
  };
1291
1500
  }
1292
- function callClient3(client, request, signal) {
1293
- return guardProviderCall(() => client.models.generateContent(request), signal);
1294
- }
1295
1501
  var openAiConfigSchema = z.object({
1296
1502
  model: z.string().min(1),
1297
- maxOutputTokens: z.number().int().positive()
1503
+ maxOutputTokens: z.number().int().positive(),
1504
+ /**
1505
+ * Optional verbatra-imposed per-request timeout in milliseconds. A positive integer; when absent,
1506
+ * the shared default request timeout applies. Bounds each outbound request so a hung-but-alive
1507
+ * server cannot hold a locale's write lock open forever.
1508
+ */
1509
+ requestTimeoutMs: z.number().int().positive().optional()
1298
1510
  });
1299
1511
  function createDefaultClient4() {
1300
1512
  const sdk = new OpenAI({ apiKey: requireOpenAiKey(), logLevel: "off" });
@@ -1449,18 +1661,20 @@ function createOpenAiProvider(config, deps = {}) {
1449
1661
  };
1450
1662
  }
1451
1663
  function createMechanism3(client, config) {
1664
+ const timeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
1452
1665
  return {
1453
1666
  translate: async ({ payloadJson, signal }) => {
1454
1667
  const body = buildOpenAiRequest(config, payloadJson);
1455
- const completion = await callClient4(client, body, signal);
1668
+ const completion = await callClient3(client, body, timeoutMs, signal);
1456
1669
  return extractOpenAiResult(completion);
1457
1670
  }
1458
1671
  };
1459
1672
  }
1460
- function callClient4(client, body, signal) {
1461
- return guardProviderCall(
1462
- () => client.chat.completions.create(body, signal !== void 0 ? { signal } : void 0),
1463
- signal
1673
+ function callClient3(client, body, timeoutMs, signal) {
1674
+ return withRequestTimeout(
1675
+ timeoutMs,
1676
+ signal,
1677
+ (requestSignal) => client.chat.completions.create(body, { signal: requestSignal })
1464
1678
  );
1465
1679
  }
1466
1680
  var HOSTED_PROVIDER_ENV_VARS = new Set(
@@ -1483,7 +1697,13 @@ var openAiCompatibleConfigSchema = z.object({
1483
1697
  maxOutputTokens: z.number().int().positive(),
1484
1698
  apiKeyEnvVar: z.string().min(1).refine(isNotHostedProviderEnvVar, {
1485
1699
  message: "apiKeyEnvVar must not name a hosted provider's environment variable."
1486
- }).optional()
1700
+ }).optional(),
1701
+ /**
1702
+ * Optional verbatra-imposed per-request timeout in milliseconds. A positive integer; when absent,
1703
+ * the shared default request timeout applies. Bounds each outbound request so a hung-but-alive local
1704
+ * server (the reported stuck-LM-Studio case) cannot hold a locale's write lock open forever.
1705
+ */
1706
+ requestTimeoutMs: z.number().int().positive().optional()
1487
1707
  });
1488
1708
  function createDefaultClient5(config) {
1489
1709
  const sdk = new OpenAI({
@@ -1515,18 +1735,20 @@ function createOpenAiCompatibleProvider(config, deps = {}) {
1515
1735
  };
1516
1736
  }
1517
1737
  function createMechanism4(client, config) {
1738
+ const timeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
1518
1739
  return {
1519
1740
  translate: async ({ payloadJson, signal }) => {
1520
1741
  const body = buildOpenAiRequest(config, payloadJson, "strict-schema", "max_tokens");
1521
- const completion = await callClient5(client, body, signal);
1742
+ const completion = await callClient4(client, body, timeoutMs, signal);
1522
1743
  return extractOpenAiResult(completion, true);
1523
1744
  }
1524
1745
  };
1525
1746
  }
1526
- function callClient5(client, body, signal) {
1527
- return guardProviderCall(
1528
- () => client.chat.completions.create(body, signal !== void 0 ? { signal } : void 0),
1529
- signal
1747
+ function callClient4(client, body, timeoutMs, signal) {
1748
+ return withRequestTimeout(
1749
+ timeoutMs,
1750
+ signal,
1751
+ (requestSignal) => client.chat.completions.create(body, { signal: requestSignal })
1530
1752
  );
1531
1753
  }
1532
1754
  var SCAFFOLD_MODELS = {
@@ -1736,6 +1958,8 @@ var LOCAL_DIR_NAME = ".verbatra-local";
1736
1958
  var LOCK_FILE_GUARD_STEM = "_lockfile";
1737
1959
  var DEFAULT_POLL_INTERVAL_MS = 100;
1738
1960
  var DEFAULT_ACQUIRE_TIMEOUT_MS = 10 * 6e4;
1961
+ var WAIT_NOTICE_INTERVAL_MS = 1e3;
1962
+ var MAX_LOCK_PAYLOAD_BYTES = 64 * 1024;
1739
1963
  function lockPath(cwd, stem) {
1740
1964
  return resolve(cwd, LOCAL_DIR_NAME, "locks", `${stem}.lock`);
1741
1965
  }
@@ -1753,7 +1977,47 @@ function sleep(ms) {
1753
1977
  function lockPayload() {
1754
1978
  return JSON.stringify({ pid: process.pid, acquiredAt: (/* @__PURE__ */ new Date()).toISOString() });
1755
1979
  }
1756
- async function acquireLock(path, fs, pollIntervalMs, deadline) {
1980
+ function parseHolder(read) {
1981
+ if (read.kind !== "ok") {
1982
+ return void 0;
1983
+ }
1984
+ let parsed;
1985
+ try {
1986
+ parsed = JSON.parse(read.content);
1987
+ } catch {
1988
+ return void 0;
1989
+ }
1990
+ if (typeof parsed !== "object" || parsed === null) {
1991
+ return void 0;
1992
+ }
1993
+ const record = parsed;
1994
+ const holder = {};
1995
+ if (typeof record.pid === "number") {
1996
+ holder.pid = record.pid;
1997
+ }
1998
+ if (typeof record.acquiredAt === "string") {
1999
+ holder.acquiredAt = record.acquiredAt;
2000
+ }
2001
+ return holder;
2002
+ }
2003
+ function makeWaitNotifier(path, fs, onWait, start) {
2004
+ let holder;
2005
+ let holderRead = false;
2006
+ let lastEmit;
2007
+ return async () => {
2008
+ if (!holderRead) {
2009
+ holderRead = true;
2010
+ holder = parseHolder(await fs.readFileBounded(path, MAX_LOCK_PAYLOAD_BYTES));
2011
+ }
2012
+ const elapsedMs = Date.now() - start;
2013
+ if (lastEmit !== void 0 && elapsedMs - lastEmit < WAIT_NOTICE_INTERVAL_MS) {
2014
+ return;
2015
+ }
2016
+ lastEmit = elapsedMs;
2017
+ onWait({ lockPath: path, elapsedMs, ...holder !== void 0 ? { holder } : {} });
2018
+ };
2019
+ }
2020
+ async function acquireLock(path, fs, pollIntervalMs, deadline, notify) {
1757
2021
  for (; ; ) {
1758
2022
  if (await fs.createExclusive(path, lockPayload())) {
1759
2023
  return;
@@ -1764,6 +2028,9 @@ async function acquireLock(path, fs, pollIntervalMs, deadline) {
1764
2028
  `Could not acquire the write lock at ${path}: another process may be holding it. If no verbatra process is currently running, this lock file was likely left behind by one that was killed; delete it and retry.`
1765
2029
  );
1766
2030
  }
2031
+ if (notify !== void 0) {
2032
+ await notify();
2033
+ }
1767
2034
  const jitter = Math.random() * pollIntervalMs;
1768
2035
  await sleep(pollIntervalMs + jitter);
1769
2036
  }
@@ -1771,7 +2038,9 @@ async function acquireLock(path, fs, pollIntervalMs, deadline) {
1771
2038
  async function withFileLock(path, fs, fn, options) {
1772
2039
  const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
1773
2040
  const acquireTimeoutMs = options.acquireTimeoutMs ?? DEFAULT_ACQUIRE_TIMEOUT_MS;
1774
- await acquireLock(path, fs, pollIntervalMs, Date.now() + acquireTimeoutMs);
2041
+ const start = Date.now();
2042
+ const notify = options.onWait !== void 0 ? makeWaitNotifier(path, fs, options.onWait, start) : void 0;
2043
+ await acquireLock(path, fs, pollIntervalMs, start + acquireTimeoutMs, notify);
1775
2044
  try {
1776
2045
  return await fn();
1777
2046
  } finally {
@@ -1787,8 +2056,8 @@ async function withLockFileGuard(cwd, fs, fn, options = {}) {
1787
2056
 
1788
2057
  // src/lock/lock-file.ts
1789
2058
  var LOCK_FILE_NAME = "verbatra.lock.json";
1790
- var CURRENT_VERSION = 1;
1791
- var EMPTY_LOCK = { version: CURRENT_VERSION, locales: {} };
2059
+ var CURRENT_VERSION2 = 1;
2060
+ var EMPTY_LOCK = { version: CURRENT_VERSION2, locales: {} };
1792
2061
  var MAX_LOCK_FILE_BYTES = 16 * 1024 * 1024;
1793
2062
  var lockFileSchema = z.object({
1794
2063
  version: z.number().int().positive(),
@@ -1817,10 +2086,10 @@ function parseLockFileRead(read, path) {
1817
2086
  if (!result.success) {
1818
2087
  throw new SdkError("LOCK_FILE_INVALID", `The lock-file at ${path} has an unexpected shape.`);
1819
2088
  }
1820
- if (result.data.version !== CURRENT_VERSION) {
2089
+ if (result.data.version !== CURRENT_VERSION2) {
1821
2090
  throw new SdkError(
1822
2091
  "LOCK_FILE_INVALID",
1823
- `The lock-file at ${path} has version ${result.data.version}, but this version of verbatra supports version ${CURRENT_VERSION}.`
2092
+ `The lock-file at ${path} has version ${result.data.version}, but this version of verbatra supports version ${CURRENT_VERSION2}.`
1824
2093
  );
1825
2094
  }
1826
2095
  return result.data;
@@ -2610,7 +2879,7 @@ function createTreeFileAdapter(options) {
2610
2879
  extensions,
2611
2880
  sniff,
2612
2881
  parse: parse3,
2613
- serialize,
2882
+ serialize: serialize2,
2614
2883
  deriveEntry,
2615
2884
  extractPlaceholders,
2616
2885
  computeInvalidIcuKeys,
@@ -2645,7 +2914,7 @@ function createTreeFileAdapter(options) {
2645
2914
  },
2646
2915
  async write(resource, filePath) {
2647
2916
  const tree = buildWriteTree ? await buildWriteTree(resource.entries, filePath) : unflattenEntries(resource.entries);
2648
- await atomicWriteFile(filePath, serialize(tree));
2917
+ await atomicWriteFile(filePath, serialize2(tree));
2649
2918
  }
2650
2919
  };
2651
2920
  }
@@ -2864,42 +3133,429 @@ async function detectStyle(filePath) {
2864
3133
  if (outcome.kind !== "ok") {
2865
3134
  return "nested";
2866
3135
  }
2867
- parsed = JSON.parse(outcome.content);
2868
- } catch {
2869
- return "nested";
3136
+ parsed = JSON.parse(outcome.content);
3137
+ } catch {
3138
+ return "nested";
3139
+ }
3140
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
3141
+ return "nested";
3142
+ }
3143
+ for (const value of Object.values(parsed)) {
3144
+ if (typeof value === "object" && value !== null) {
3145
+ return "nested";
3146
+ }
3147
+ }
3148
+ return "flat";
3149
+ }
3150
+ function buildFlatTree(entries) {
3151
+ const out = /* @__PURE__ */ new Map();
3152
+ for (const [key, entry] of entries) {
3153
+ out.set(key, entry.value);
3154
+ }
3155
+ return out;
3156
+ }
3157
+ async function buildNgxWriteTree(entries, filePath) {
3158
+ const style = await detectStyle(filePath);
3159
+ return style === "flat" ? buildFlatTree(entries) : unflattenEntries(entries);
3160
+ }
3161
+ function createNgxTranslateJsonAdapter() {
3162
+ return createJsonFileAdapter({
3163
+ format: "ngx-translate-json",
3164
+ extractPlaceholders: extractDoubleBracePlaceholders,
3165
+ deriveEntry: (_key, value) => ({
3166
+ placeholders: extractDoubleBracePlaceholders(value),
3167
+ isPlural: false
3168
+ }),
3169
+ validateTree: assertNotMixed,
3170
+ buildWriteTree: buildNgxWriteTree,
3171
+ keyMode: "path-notation"
3172
+ });
3173
+ }
3174
+ function toEntries2(content, namespace, parseEntries) {
3175
+ try {
3176
+ return parseEntries(content, namespace);
3177
+ } catch (error) {
3178
+ rethrowStructured(error, "The file could not be parsed.");
3179
+ }
3180
+ }
3181
+ function createFlatFileAdapter(options) {
3182
+ const {
3183
+ format,
3184
+ extensions,
3185
+ sniff,
3186
+ parseEntries,
3187
+ serializeEntries,
3188
+ extractPlaceholders,
3189
+ validateMessage,
3190
+ computeInvalidIcuKeys
3191
+ } = options;
3192
+ return {
3193
+ format,
3194
+ canHandle: buildCanHandle(extensions, sniff),
3195
+ extractPlaceholders,
3196
+ validateMessage: validateMessage ?? (() => true),
3197
+ async read(filePath, locale) {
3198
+ const content = await readFileContent(filePath);
3199
+ const namespace = namespaceOf(filePath);
3200
+ const entries = toEntries2(content, namespace, parseEntries);
3201
+ const resource = { locale, namespace, format, entries };
3202
+ const invalidIcuKeys = computeIcu(entries, computeInvalidIcuKeys);
3203
+ return { resource, invalidIcuKeys, excludedLeafPaths: [] };
3204
+ },
3205
+ async write(resource, filePath) {
3206
+ const data = await serializeEntries(resource.entries, filePath);
3207
+ await atomicWriteFile(filePath, data);
3208
+ }
3209
+ };
3210
+ }
3211
+ var SUBMESSAGE_TYPES = /* @__PURE__ */ new Set(["plural", "select", "selectordinal", "choice"]);
3212
+ var ARGUMENT_NAME = /^(?:\d+|[A-Za-z_$][\w$-]*)$/;
3213
+ function matchingBraces(value) {
3214
+ const close = new Int32Array(value.length).fill(-1);
3215
+ const open3 = [];
3216
+ for (let i = 0; i < value.length; i += 1) {
3217
+ const char = value[i];
3218
+ if (char === "{") {
3219
+ open3.push(i);
3220
+ } else if (char === "}") {
3221
+ const start = open3.pop();
3222
+ if (start !== void 0) {
3223
+ close[start] = i;
3224
+ }
3225
+ }
3226
+ }
3227
+ return close;
3228
+ }
3229
+ function findNameEnd(value, from, limit) {
3230
+ for (let i = from; i < limit; i += 1) {
3231
+ const char = value[i];
3232
+ if (char === ",") {
3233
+ return { comma: i, hasBrace: false };
3234
+ }
3235
+ if (char === "{" || char === "}") {
3236
+ return { comma: -1, hasBrace: true };
3237
+ }
3238
+ }
3239
+ return { comma: -1, hasBrace: false };
3240
+ }
3241
+ function parseArgumentAt(value, open3, close) {
3242
+ const { comma, hasBrace } = findNameEnd(value, open3 + 1, close);
3243
+ if (hasBrace) {
3244
+ return null;
3245
+ }
3246
+ const nameEnd = comma === -1 ? close : comma;
3247
+ const name = value.slice(open3 + 1, nameEnd).trim();
3248
+ if (!ARGUMENT_NAME.test(name)) {
3249
+ return null;
3250
+ }
3251
+ if (comma === -1) {
3252
+ return { name, type: null, styleStart: -1 };
3253
+ }
3254
+ const secondComma = value.indexOf(",", comma + 1);
3255
+ if (secondComma === -1 || secondComma >= close) {
3256
+ return { name, type: value.slice(comma + 1, close).trim(), styleStart: -1 };
3257
+ }
3258
+ return { name, type: value.slice(comma + 1, secondComma).trim(), styleStart: secondComma + 1 };
3259
+ }
3260
+ function canonicalToken(value, arg, close) {
3261
+ if (arg.type === null) {
3262
+ return `{${arg.name}}`;
3263
+ }
3264
+ if (arg.styleStart === -1) {
3265
+ return `{${arg.name},${arg.type}}`;
3266
+ }
3267
+ return `{${arg.name},${arg.type},${value.slice(arg.styleStart, close).trim()}}`;
3268
+ }
3269
+ function emitArgument(value, close, arg, closeIndex, out) {
3270
+ if (arg.type !== null && SUBMESSAGE_TYPES.has(arg.type)) {
3271
+ out.push(`{${arg.name},${arg.type}}`);
3272
+ if (arg.styleStart !== -1) {
3273
+ scanRange(value, close, arg.styleStart, closeIndex, false, out);
3274
+ }
3275
+ return;
3276
+ }
3277
+ out.push(canonicalToken(value, arg, closeIndex));
3278
+ }
3279
+ function scanRange(value, close, start, end, topLevel, out) {
3280
+ let i = start;
3281
+ while (i < end) {
3282
+ if (value[i] !== "{") {
3283
+ i += 1;
3284
+ continue;
3285
+ }
3286
+ if (topLevel && value[i + 1] === "{") {
3287
+ i += 2;
3288
+ continue;
3289
+ }
3290
+ const closeIndex = close[i] ?? -1;
3291
+ if (closeIndex === -1 || closeIndex >= end) {
3292
+ i += 1;
3293
+ continue;
3294
+ }
3295
+ const arg = parseArgumentAt(value, i, closeIndex);
3296
+ if (arg === null) {
3297
+ i += 1;
3298
+ continue;
3299
+ }
3300
+ emitArgument(value, close, arg, closeIndex, out);
3301
+ i = closeIndex + 1;
3302
+ }
3303
+ }
3304
+ function extractPropertiesPlaceholders(value) {
3305
+ const out = [];
3306
+ scanRange(value, matchingBraces(value), 0, value.length, true, out);
3307
+ return out;
3308
+ }
3309
+ var UNICODE_ESCAPE = /^[0-9a-fA-F]{4}$/;
3310
+ var LEADING_WHITESPACE = /^[ \t\f]+/;
3311
+ var TRAILING_TERMINATOR = /(?:\r\n|\r|\n)$/;
3312
+ function isPropertiesWhitespace(char) {
3313
+ return char === " " || char === " " || char === "\f";
3314
+ }
3315
+ function splitPhysicalLines(content) {
3316
+ if (content === "") {
3317
+ return [];
3318
+ }
3319
+ const lines = content.split(/\r\n|\r|\n/);
3320
+ if (TRAILING_TERMINATOR.test(content)) {
3321
+ lines.pop();
3322
+ }
3323
+ return lines;
3324
+ }
3325
+ function countTrailingBackslashes(line) {
3326
+ let count = 0;
3327
+ for (let i = line.length - 1; i >= 0 && line[i] === "\\"; i -= 1) {
3328
+ count += 1;
3329
+ }
3330
+ return count;
3331
+ }
3332
+ function isContinued(line) {
3333
+ return countTrailingBackslashes(line) % 2 === 1;
3334
+ }
3335
+ function isBlankLine(line) {
3336
+ return line.trim() === "";
3337
+ }
3338
+ function isCommentLine(line) {
3339
+ const trimmed = line.replace(LEADING_WHITESPACE, "");
3340
+ return trimmed.startsWith("#") || trimmed.startsWith("!");
3341
+ }
3342
+ function joinContinuation(lines, start) {
3343
+ const pieces = [];
3344
+ let index = start;
3345
+ let current = lines[start] ?? "";
3346
+ while (isContinued(current) && index + 1 < lines.length) {
3347
+ pieces.push(current.slice(0, -1));
3348
+ index += 1;
3349
+ current = (lines[index] ?? "").replace(LEADING_WHITESPACE, "");
3350
+ }
3351
+ if (isContinued(current)) {
3352
+ current = current.slice(0, -1);
3353
+ }
3354
+ pieces.push(current);
3355
+ return { logical: pieces.join(""), nextIndex: index + 1 };
3356
+ }
3357
+ function decodeSimpleEscape(char) {
3358
+ switch (char) {
3359
+ case "t":
3360
+ return " ";
3361
+ case "n":
3362
+ return "\n";
3363
+ case "r":
3364
+ return "\r";
3365
+ case "f":
3366
+ return "\f";
3367
+ default:
3368
+ return char;
3369
+ }
3370
+ }
3371
+ function decodeUnicodeEscape(raw, at) {
3372
+ const hex = raw.slice(at, at + 4);
3373
+ if (!UNICODE_ESCAPE.test(hex)) {
3374
+ throw new AdapterError("INVALID_STRUCTURE", "The file has a malformed unicode escape.");
3375
+ }
3376
+ return String.fromCharCode(Number.parseInt(hex, 16));
3377
+ }
3378
+ function decodeEscapes(raw) {
3379
+ let out = "";
3380
+ let i = 0;
3381
+ while (i < raw.length) {
3382
+ const char = raw[i];
3383
+ if (char !== "\\") {
3384
+ out += char;
3385
+ i += 1;
3386
+ continue;
3387
+ }
3388
+ const next = raw[i + 1];
3389
+ if (next === void 0) {
3390
+ break;
3391
+ }
3392
+ if (next === "u") {
3393
+ out += decodeUnicodeEscape(raw, i + 2);
3394
+ i += 6;
3395
+ continue;
3396
+ }
3397
+ out += decodeSimpleEscape(next);
3398
+ i += 2;
3399
+ }
3400
+ return out;
3401
+ }
3402
+ function keyEndIndex(logical, from) {
3403
+ let i = from;
3404
+ while (i < logical.length) {
3405
+ const char = logical[i];
3406
+ if (char === "\\") {
3407
+ i += 2;
3408
+ continue;
3409
+ }
3410
+ if (char === void 0 || isPropertiesWhitespace(char) || char === "=" || char === ":") {
3411
+ break;
3412
+ }
3413
+ i += 1;
3414
+ }
3415
+ return i;
3416
+ }
3417
+ function skipWhitespace(logical, from) {
3418
+ let i = from;
3419
+ while (i < logical.length) {
3420
+ const char = logical[i];
3421
+ if (char === void 0 || !isPropertiesWhitespace(char)) {
3422
+ break;
3423
+ }
3424
+ i += 1;
3425
+ }
3426
+ return i;
3427
+ }
3428
+ function parseEntryLine(logical) {
3429
+ const keyStart = skipWhitespace(logical, 0);
3430
+ const keyEnd = keyEndIndex(logical, keyStart);
3431
+ const afterKey = skipWhitespace(logical, keyEnd);
3432
+ const separator = logical[afterKey];
3433
+ const valueStart = separator === "=" || separator === ":" ? skipWhitespace(logical, afterKey + 1) : afterKey;
3434
+ return {
3435
+ key: decodeEscapes(logical.slice(keyStart, keyEnd)),
3436
+ value: decodeEscapes(logical.slice(valueStart))
3437
+ };
3438
+ }
3439
+ function parseItems(content) {
3440
+ const lines = splitPhysicalLines(content);
3441
+ const items = [];
3442
+ let i = 0;
3443
+ while (i < lines.length) {
3444
+ const line = lines[i] ?? "";
3445
+ if (isBlankLine(line) || isCommentLine(line)) {
3446
+ items.push({ kind: "raw", text: line });
3447
+ i += 1;
3448
+ continue;
3449
+ }
3450
+ const { logical, nextIndex } = joinContinuation(lines, i);
3451
+ items.push({ kind: "entry", ...parseEntryLine(logical) });
3452
+ i = nextIndex;
3453
+ }
3454
+ return items;
3455
+ }
3456
+ function parsePropertiesEntries(content, namespace) {
3457
+ const map = /* @__PURE__ */ new Map();
3458
+ for (const item of parseItems(content)) {
3459
+ if (item.kind === "entry") {
3460
+ map.set(item.key, {
3461
+ key: item.key,
3462
+ namespace,
3463
+ value: item.value,
3464
+ placeholders: extractPropertiesPlaceholders(item.value),
3465
+ isPlural: false
3466
+ });
3467
+ }
3468
+ }
3469
+ return map;
3470
+ }
3471
+ function unicodeEscape(code) {
3472
+ return `\\u${code.toString(16).toUpperCase().padStart(4, "0")}`;
3473
+ }
3474
+ function escapeChar(char, isFirst, isKey) {
3475
+ switch (char) {
3476
+ case "\\":
3477
+ return "\\\\";
3478
+ case " ":
3479
+ return "\\t";
3480
+ case "\n":
3481
+ return "\\n";
3482
+ case "\r":
3483
+ return "\\r";
3484
+ case "\f":
3485
+ return "\\f";
3486
+ case " ":
3487
+ return isKey || isFirst ? "\\ " : " ";
3488
+ }
3489
+ if (isKey && (char === "=" || char === ":" || char === "#" || char === "!")) {
3490
+ return `\\${char}`;
3491
+ }
3492
+ const code = char.charCodeAt(0);
3493
+ return code < 32 || code > 126 ? unicodeEscape(code) : char;
3494
+ }
3495
+ function escapeString(input, isKey) {
3496
+ let out = "";
3497
+ for (let i = 0; i < input.length; i += 1) {
3498
+ const char = input[i];
3499
+ if (char !== void 0) {
3500
+ out += escapeChar(char, i === 0, isKey);
3501
+ }
3502
+ }
3503
+ return out;
3504
+ }
3505
+ function formatEntry(key, value) {
3506
+ return `${escapeString(key, true)}=${escapeString(value, false)}`;
3507
+ }
3508
+ function isFileNotFound(error) {
3509
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
3510
+ }
3511
+ async function readStructure(filePath) {
3512
+ let outcome;
3513
+ try {
3514
+ outcome = await readBounded2(filePath);
3515
+ } catch (error) {
3516
+ if (isFileNotFound(error)) {
3517
+ return [];
3518
+ }
3519
+ throw new AdapterError("INVALID_STRUCTURE", "The destination file could not be read.");
2870
3520
  }
2871
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
2872
- return "nested";
3521
+ if (outcome.kind === "not-a-file") {
3522
+ throw new AdapterError("INVALID_STRUCTURE", "The destination path is not a regular file.");
2873
3523
  }
2874
- for (const value of Object.values(parsed)) {
2875
- if (typeof value === "object" && value !== null) {
2876
- return "nested";
2877
- }
3524
+ if (outcome.kind === "too-large") {
3525
+ throw new AdapterError("INPUT_TOO_LARGE", "The file exceeds the maximum allowed size.");
2878
3526
  }
2879
- return "flat";
3527
+ return parseItems(outcome.content);
2880
3528
  }
2881
- function buildFlatTree(entries) {
2882
- const out = /* @__PURE__ */ new Map();
3529
+ async function serializePropertiesEntries(entries, filePath) {
3530
+ const structure = await readStructure(filePath);
3531
+ const emitted = /* @__PURE__ */ new Set();
3532
+ const lines = [];
3533
+ for (const item of structure) {
3534
+ if (item.kind === "raw") {
3535
+ lines.push(item.text);
3536
+ continue;
3537
+ }
3538
+ const entry = entries.get(item.key);
3539
+ if (entry !== void 0 && !emitted.has(item.key)) {
3540
+ lines.push(formatEntry(item.key, entry.value));
3541
+ emitted.add(item.key);
3542
+ }
3543
+ }
2883
3544
  for (const [key, entry] of entries) {
2884
- out.set(key, entry.value);
3545
+ if (!emitted.has(key)) {
3546
+ lines.push(formatEntry(key, entry.value));
3547
+ }
2885
3548
  }
2886
- return out;
2887
- }
2888
- async function buildNgxWriteTree(entries, filePath) {
2889
- const style = await detectStyle(filePath);
2890
- return style === "flat" ? buildFlatTree(entries) : unflattenEntries(entries);
3549
+ return lines.length === 0 ? "" : `${lines.join("\n")}
3550
+ `;
2891
3551
  }
2892
- function createNgxTranslateJsonAdapter() {
2893
- return createJsonFileAdapter({
2894
- format: "ngx-translate-json",
2895
- extractPlaceholders: extractDoubleBracePlaceholders,
2896
- deriveEntry: (_key, value) => ({
2897
- placeholders: extractDoubleBracePlaceholders(value),
2898
- isPlural: false
2899
- }),
2900
- validateTree: assertNotMixed,
2901
- buildWriteTree: buildNgxWriteTree,
2902
- keyMode: "path-notation"
3552
+ function createPropertiesAdapter() {
3553
+ return createFlatFileAdapter({
3554
+ format: "properties",
3555
+ extensions: [".properties"],
3556
+ parseEntries: parsePropertiesEntries,
3557
+ serializeEntries: serializePropertiesEntries,
3558
+ extractPlaceholders: extractPropertiesPlaceholders
2903
3559
  });
2904
3560
  }
2905
3561
  var AdapterRegistry = class {
@@ -2979,43 +3635,6 @@ function createVueI18nJsonAdapter() {
2979
3635
  })
2980
3636
  });
2981
3637
  }
2982
- function toEntries2(content, namespace, parseEntries) {
2983
- try {
2984
- return parseEntries(content, namespace);
2985
- } catch (error) {
2986
- rethrowStructured(error, "The file could not be parsed.");
2987
- }
2988
- }
2989
- function createFlatFileAdapter(options) {
2990
- const {
2991
- format,
2992
- extensions,
2993
- sniff,
2994
- parseEntries,
2995
- serializeEntries,
2996
- extractPlaceholders,
2997
- validateMessage,
2998
- computeInvalidIcuKeys
2999
- } = options;
3000
- return {
3001
- format,
3002
- canHandle: buildCanHandle(extensions, sniff),
3003
- extractPlaceholders,
3004
- validateMessage: validateMessage ?? (() => true),
3005
- async read(filePath, locale) {
3006
- const content = await readFileContent(filePath);
3007
- const namespace = namespaceOf(filePath);
3008
- const entries = toEntries2(content, namespace, parseEntries);
3009
- const resource = { locale, namespace, format, entries };
3010
- const invalidIcuKeys = computeIcu(entries, computeInvalidIcuKeys);
3011
- return { resource, invalidIcuKeys, excludedLeafPaths: [] };
3012
- },
3013
- async write(resource, filePath) {
3014
- const data = await serializeEntries(resource.entries, filePath);
3015
- await atomicWriteFile(filePath, data);
3016
- }
3017
- };
3018
- }
3019
3638
  var XLIFF_PATTERN = /<(?:x|g|bx|ex|ph|it|mrk)\b[^>]*>|\{[^{}]+\}/g;
3020
3639
  function extractXliffPlaceholders(value) {
3021
3640
  const result = [];
@@ -3326,7 +3945,7 @@ function createYamlAdapter() {
3326
3945
  });
3327
3946
  }
3328
3947
  function createDefaultRegistry() {
3329
- return new AdapterRegistry().register(createI18nextJsonAdapter()).register(createVueI18nJsonAdapter()).register(createNextIntlJsonAdapter()).register(createNgxTranslateJsonAdapter()).register(createXliffAdapter()).register(createYamlAdapter()).register(createArbAdapter());
3948
+ return new AdapterRegistry().register(createI18nextJsonAdapter()).register(createVueI18nJsonAdapter()).register(createNextIntlJsonAdapter()).register(createNgxTranslateJsonAdapter()).register(createXliffAdapter()).register(createYamlAdapter()).register(createArbAdapter()).register(createPropertiesAdapter());
3330
3949
  }
3331
3950
 
3332
3951
  // src/selection/select-adapter.ts
@@ -3447,6 +4066,28 @@ async function diff(input, deps = {}) {
3447
4066
  return { hasPendingChanges: locales.some((entry) => entry.hasPendingChanges), locales };
3448
4067
  }
3449
4068
 
4069
+ // src/cache/fingerprint.ts
4070
+ function fingerprintModel(provider) {
4071
+ const options = provider.options;
4072
+ const model = options.model;
4073
+ return typeof model === "string" ? model : null;
4074
+ }
4075
+ function sortGlossary(glossary) {
4076
+ if (glossary === void 0) {
4077
+ return {};
4078
+ }
4079
+ return Object.fromEntries(Object.entries(glossary).sort(([a], [b]) => a < b ? -1 : 1));
4080
+ }
4081
+ function computeFingerprint(config) {
4082
+ const canonical = JSON.stringify({
4083
+ provider: config.provider.id,
4084
+ model: fingerprintModel(config.provider),
4085
+ tone: config.tone ?? null,
4086
+ glossary: sortGlossary(config.glossary)
4087
+ });
4088
+ return stableStringHash(canonical);
4089
+ }
4090
+
3450
4091
  // src/flow/integrity-gate.ts
3451
4092
  function gateCandidateValue(sourceEntry, candidateValue, adapter) {
3452
4093
  const placeholderResult = adapter.comparePlaceholders?.(sourceEntry.value, candidateValue) ?? checkPlaceholders(sourceEntry.placeholders, adapter.extractPlaceholders(candidateValue));
@@ -3456,6 +4097,9 @@ function gateCandidateValue(sourceEntry, candidateValue, adapter) {
3456
4097
  if (!adapter.validateMessage(candidateValue)) {
3457
4098
  return { accepted: false, reason: "icu" };
3458
4099
  }
4100
+ if (assessValueDegeneracy(sourceEntry.value, candidateValue).degenerate) {
4101
+ return { accepted: false, reason: "degenerate" };
4102
+ }
3459
4103
  return { accepted: true };
3460
4104
  }
3461
4105
 
@@ -3494,6 +4138,12 @@ async function editEntry(input, deps = {}) {
3494
4138
  mode: "merge",
3495
4139
  entries: { [input.key]: contentHash(sourceEntry) }
3496
4140
  });
4141
+ await feedTranslationMemory(
4142
+ cwd,
4143
+ fs,
4144
+ computeFingerprint(config),
4145
+ /* @__PURE__ */ new Map([[locale, { [contentHash(sourceEntry)]: input.value }]])
4146
+ );
3497
4147
  return { accepted: true, value: input.value };
3498
4148
  });
3499
4149
  }
@@ -3711,13 +4361,19 @@ async function retranslateEntry(input, deps = {}) {
3711
4361
  mode: "merge",
3712
4362
  entries: { [input.key]: contentHash(sourceEntry) }
3713
4363
  });
4364
+ await feedTranslationMemory(
4365
+ cwd,
4366
+ fs,
4367
+ computeFingerprint(config),
4368
+ /* @__PURE__ */ new Map([[locale, { [contentHash(sourceEntry)]: value }]])
4369
+ );
3714
4370
  const reviewReasons = result.reviewFlags?.get(input.key)?.reasons ?? [];
3715
4371
  return { accepted: true, value, reviewReasons };
3716
4372
  });
3717
4373
  }
3718
4374
  var RUN_STATUS_DIR_NAME = ".verbatra-local";
3719
4375
  var RUN_STATUS_FILE_NAME = "run-status.json";
3720
- var CURRENT_VERSION2 = 1;
4376
+ var CURRENT_VERSION3 = 1;
3721
4377
  var MAX_RUN_STATUS_FILE_BYTES = 16 * 1024 * 1024;
3722
4378
  var reviewReasonCodeSchema = z.enum([
3723
4379
  "LENGTH_RATIO_OUTLIER",
@@ -3743,7 +4399,7 @@ var runBudgetSchema = z.object({
3743
4399
  });
3744
4400
  var runStatusLocaleSchema = z.object({
3745
4401
  locale: z.string(),
3746
- status: z.enum(["succeeded", "failed"]),
4402
+ status: z.enum(["succeeded", "partial", "failed"]),
3747
4403
  needsReview: z.array(needsReviewEntrySchema),
3748
4404
  usage: usageSummarySchema.optional()
3749
4405
  });
@@ -3767,7 +4423,7 @@ function toRunStatusLocale(locale) {
3767
4423
  }
3768
4424
  function buildRunStatusFile(summary, generatedAt = (/* @__PURE__ */ new Date()).toISOString()) {
3769
4425
  return {
3770
- version: CURRENT_VERSION2,
4426
+ version: CURRENT_VERSION3,
3771
4427
  generatedAt,
3772
4428
  ...summary.usage !== void 0 ? { usage: summary.usage } : {},
3773
4429
  ...summary.budget !== void 0 ? { budget: summary.budget } : {},
@@ -3800,7 +4456,7 @@ async function readRunStatusFile(path, fs) {
3800
4456
  return void 0;
3801
4457
  }
3802
4458
  const result = runStatusFileSchema.safeParse(parsed);
3803
- if (!result.success || result.data.version !== CURRENT_VERSION2) {
4459
+ if (!result.success || result.data.version !== CURRENT_VERSION3) {
3804
4460
  return void 0;
3805
4461
  }
3806
4462
  return fromParsed(result.data);
@@ -3879,19 +4535,34 @@ function failureSummary(locale, error) {
3879
4535
  orphaned: [],
3880
4536
  pruned: [],
3881
4537
  invalidIcuSource: [],
4538
+ cacheHits: [],
3882
4539
  integrityMismatches: [],
3883
4540
  providerFailures: [],
3884
4541
  budgetWithheld: [],
3885
4542
  generated: [],
3886
4543
  notices: [],
3887
4544
  needsReview: [],
4545
+ unfilled: [],
4546
+ malformedRows: [],
4547
+ duplicateKeys: [],
3888
4548
  error: describeError(error)
3889
4549
  };
3890
4550
  }
4551
+ function deriveLocaleStatus(parts) {
4552
+ const withheld = parts.integrityMismatches.length > 0 || parts.providerFailures.length > 0 || parts.budgetWithheld.length > 0;
4553
+ if (!withheld) {
4554
+ return "succeeded";
4555
+ }
4556
+ const accepted = parts.translated.length > 0 || parts.cacheHits.length > 0 || parts.generated.length > 0;
4557
+ return accepted ? "partial" : "failed";
4558
+ }
3891
4559
  function partition(locales) {
3892
- const succeeded = locales.filter((s) => s.status === "succeeded").map((s) => s.locale);
3893
- const failed = locales.filter((s) => s.status === "failed").map((s) => s.locale);
3894
- return { succeeded, failed };
4560
+ const namesWith = (status) => locales.filter((s) => s.status === status).map((s) => s.locale);
4561
+ return {
4562
+ succeeded: namesWith("succeeded"),
4563
+ partial: namesWith("partial"),
4564
+ failed: namesWith("failed")
4565
+ };
3895
4566
  }
3896
4567
 
3897
4568
  // src/flow/batching.ts
@@ -4202,6 +4873,97 @@ function foldGenerationItem(item, result, adapter, accepted, withheld, providerF
4202
4873
  }
4203
4874
 
4204
4875
  // src/flow/locale-run.ts
4876
+ function partitionCacheHits(params, toTranslate) {
4877
+ const cache = params.cache;
4878
+ const hits = /* @__PURE__ */ new Map();
4879
+ if (cache === void 0) {
4880
+ return { hits, misses: toTranslate };
4881
+ }
4882
+ const misses = [];
4883
+ for (const key of toTranslate) {
4884
+ const source = params.source.entries.get(key);
4885
+ if (source === void 0) {
4886
+ continue;
4887
+ }
4888
+ const cached = lookupMemory(
4889
+ cache.snapshot,
4890
+ cache.fingerprint,
4891
+ params.targetLocale,
4892
+ contentHash(source)
4893
+ );
4894
+ if (cached !== void 0 && gateCandidateValue(source, cached, params.adapter).accepted) {
4895
+ hits.set(key, { value: cached, source });
4896
+ } else {
4897
+ misses.push(key);
4898
+ }
4899
+ }
4900
+ return { hits, misses };
4901
+ }
4902
+ function collectCacheAdditions(params, accepted, cacheHitKeys) {
4903
+ if (params.cache === void 0) {
4904
+ return [];
4905
+ }
4906
+ const additions = [];
4907
+ for (const [key, entry] of accepted) {
4908
+ if (!cacheHitKeys.has(key)) {
4909
+ additions.push({ contentHash: contentHash(entry.source), value: entry.value });
4910
+ }
4911
+ }
4912
+ return additions;
4913
+ }
4914
+ function groupMissesByContent(params, misses) {
4915
+ const byHash = /* @__PURE__ */ new Map();
4916
+ for (const key of misses) {
4917
+ const source = params.source.entries.get(key);
4918
+ if (source === void 0) {
4919
+ continue;
4920
+ }
4921
+ const existing = byHash.get(contentHash(source));
4922
+ if (existing === void 0) {
4923
+ byHash.set(contentHash(source), { representative: key, duplicates: [] });
4924
+ } else {
4925
+ existing.duplicates.push(key);
4926
+ }
4927
+ }
4928
+ return [...byHash.values()];
4929
+ }
4930
+ function fanOutContentDuplicates(params, groups, outcome) {
4931
+ for (const group of groups) {
4932
+ if (group.duplicates.length > 0) {
4933
+ applyGroupOutcome(params, group, outcome);
4934
+ }
4935
+ }
4936
+ }
4937
+ function applyGroupOutcome(params, group, outcome) {
4938
+ const acceptedRepresentative = outcome.accepted.get(group.representative);
4939
+ if (acceptedRepresentative !== void 0) {
4940
+ fanOutAccepted(params, group, acceptedRepresentative, outcome);
4941
+ return;
4942
+ }
4943
+ withheldBucketFor(group.representative, outcome).push(...group.duplicates);
4944
+ }
4945
+ function fanOutAccepted(params, group, acceptedRepresentative, outcome) {
4946
+ const flag = outcome.reviewFlags.get(group.representative);
4947
+ for (const key of group.duplicates) {
4948
+ const source = params.source.entries.get(key);
4949
+ if (source === void 0) {
4950
+ continue;
4951
+ }
4952
+ outcome.accepted.set(key, { value: acceptedRepresentative.value, source });
4953
+ if (flag !== void 0) {
4954
+ outcome.reviewFlags.set(key, flag);
4955
+ }
4956
+ }
4957
+ }
4958
+ function withheldBucketFor(representative, outcome) {
4959
+ if (outcome.integrityMismatches.includes(representative)) {
4960
+ return outcome.integrityMismatches;
4961
+ }
4962
+ if (outcome.budgetWithheld.includes(representative)) {
4963
+ return outcome.budgetWithheld;
4964
+ }
4965
+ return outcome.providerFailures;
4966
+ }
4205
4967
  function buildRequest3(params, entries) {
4206
4968
  return {
4207
4969
  sourceLocale: params.sourceLocale,
@@ -4244,6 +5006,7 @@ async function runLocale(params) {
4244
5006
  orphaned,
4245
5007
  invalidIcuSource,
4246
5008
  translated: toTranslate,
5009
+ cacheHits: [],
4247
5010
  generated: [],
4248
5011
  integrityMismatches: [],
4249
5012
  providerFailures: [],
@@ -4251,12 +5014,16 @@ async function runLocale(params) {
4251
5014
  pruned,
4252
5015
  notices: sdkNotices
4253
5016
  }),
4254
- lockEntries: {}
5017
+ lockEntries: {},
5018
+ cacheAdditions: []
4255
5019
  };
4256
5020
  }
4257
- const entries = toTranslate.map((key) => params.source.entries.get(key)).filter((entry) => entry !== void 0);
5021
+ const partition2 = partitionCacheHits(params, toTranslate);
5022
+ const cacheHitKeys = new Set(partition2.hits.keys());
5023
+ const missGroups = groupMissesByContent(params, partition2.misses);
5024
+ const entries = missGroups.map((group) => params.source.entries.get(group.representative)).filter((entry) => entry !== void 0);
4258
5025
  const startedStopped = params.budget.stopped;
4259
- const accepted = /* @__PURE__ */ new Map();
5026
+ const accepted = new Map(partition2.hits);
4260
5027
  const integrityMismatches = [];
4261
5028
  const providerFailures = [];
4262
5029
  const budgetWithheld = [];
@@ -4271,6 +5038,13 @@ async function runLocale(params) {
4271
5038
  budgetWithheld,
4272
5039
  reviewFlags
4273
5040
  );
5041
+ fanOutContentDuplicates(params, missGroups, {
5042
+ accepted,
5043
+ integrityMismatches,
5044
+ providerFailures,
5045
+ budgetWithheld,
5046
+ reviewFlags
5047
+ });
4274
5048
  const merged = new Map(target.entries);
4275
5049
  for (const key of pruned) {
4276
5050
  merged.delete(key);
@@ -4317,7 +5091,8 @@ async function runLocale(params) {
4317
5091
  unchanged: diff2.unchanged,
4318
5092
  orphaned,
4319
5093
  invalidIcuSource,
4320
- translated: [...accepted.keys()],
5094
+ translated: [...accepted.keys()].filter((key) => !cacheHitKeys.has(key)),
5095
+ cacheHits: [...cacheHitKeys].sort(),
4321
5096
  generated: generation.accepted.map((form) => form.targetKey).sort(),
4322
5097
  integrityMismatches: [...integrityMismatches, ...generation.withheld].sort(),
4323
5098
  providerFailures: [...providerFailures, ...generation.providerFailures].sort(),
@@ -4327,7 +5102,8 @@ async function runLocale(params) {
4327
5102
  needsReview: needsReviewFor(accepted.keys(), reviewFlags),
4328
5103
  ...localeUsage !== void 0 ? { usage: localeUsage } : {}
4329
5104
  }),
4330
- lockEntries: computeLockEntries(params, merged, withheld, generation.accepted)
5105
+ lockEntries: computeLockEntries(params, merged, withheld, generation.accepted),
5106
+ cacheAdditions: collectCacheAdditions(params, accepted, cacheHitKeys)
4331
5107
  };
4332
5108
  }
4333
5109
  var NO_GENERATION_RESULT = {
@@ -4372,18 +5148,22 @@ function pluralNoticeFor(params, merged) {
4372
5148
  function baseSummary(parts) {
4373
5149
  return {
4374
5150
  locale: parts.locale,
4375
- status: "succeeded",
5151
+ status: deriveLocaleStatus(parts),
4376
5152
  translated: parts.translated,
4377
5153
  unchanged: parts.unchanged,
4378
5154
  orphaned: parts.orphaned,
4379
5155
  pruned: parts.pruned,
4380
5156
  invalidIcuSource: parts.invalidIcuSource,
5157
+ cacheHits: parts.cacheHits,
4381
5158
  integrityMismatches: parts.integrityMismatches,
4382
5159
  providerFailures: parts.providerFailures,
4383
5160
  budgetWithheld: parts.budgetWithheld,
4384
5161
  generated: parts.generated,
4385
5162
  notices: parts.notices,
4386
5163
  needsReview: parts.needsReview ?? [],
5164
+ unfilled: [],
5165
+ malformedRows: [],
5166
+ duplicateKeys: [],
4387
5167
  ...parts.usage !== void 0 ? { usage: parts.usage } : {}
4388
5168
  };
4389
5169
  }
@@ -4401,7 +5181,16 @@ async function translateAndCheck(provider, params, entries, accepted, integrityM
4401
5181
  const notices = [];
4402
5182
  const usage = createUsageAccumulator();
4403
5183
  let tripped = false;
4404
- for (const batch of chunk(entries, params.maxBatchSize)) {
5184
+ const batches = chunk(entries, params.maxBatchSize);
5185
+ let batchIndex = 0;
5186
+ for (const batch of batches) {
5187
+ batchIndex += 1;
5188
+ params.onProgress?.({
5189
+ type: "sub-batch",
5190
+ locale: params.targetLocale,
5191
+ batchIndex,
5192
+ totalBatches: batches.length
5193
+ });
4405
5194
  if (params.budget.stopped) {
4406
5195
  for (const entry of batch) {
4407
5196
  budgetWithheld.push(entry.key);
@@ -4431,10 +5220,16 @@ async function runSubBatch(provider, params, batch, accepted, integrityMismatche
4431
5220
  try {
4432
5221
  result = await provider.translateBatch(buildRequest3(params, batch));
4433
5222
  } catch (error) {
4434
- for (const entry of batch) {
4435
- providerFailures.push(entry.key);
4436
- }
4437
- return { notices: [subBatchFailedNotice(batch.length, error)], usage: void 0 };
5223
+ return handleSubBatchFailure(
5224
+ error,
5225
+ provider,
5226
+ params,
5227
+ batch,
5228
+ accepted,
5229
+ integrityMismatches,
5230
+ providerFailures,
5231
+ reviewFlags
5232
+ );
4438
5233
  }
4439
5234
  for (const entry of batch) {
4440
5235
  foldEntryResult(entry, result, params.adapter, accepted, integrityMismatches, providerFailures);
@@ -4446,6 +5241,44 @@ async function runSubBatch(provider, params, batch, accepted, integrityMismatche
4446
5241
  }
4447
5242
  return { notices: readNotices(result), usage: result.usage };
4448
5243
  }
5244
+ function isOutputTruncated(error) {
5245
+ return error instanceof ProviderError && error.code === "OUTPUT_TRUNCATED";
5246
+ }
5247
+ async function handleSubBatchFailure(error, provider, params, batch, accepted, integrityMismatches, providerFailures, reviewFlags) {
5248
+ if (isOutputTruncated(error) && batch.length > 1) {
5249
+ return retryTruncatedSplit(
5250
+ provider,
5251
+ params,
5252
+ batch,
5253
+ accepted,
5254
+ integrityMismatches,
5255
+ providerFailures,
5256
+ reviewFlags
5257
+ );
5258
+ }
5259
+ for (const entry of batch) {
5260
+ providerFailures.push(entry.key);
5261
+ }
5262
+ return { notices: [subBatchFailedNotice(batch.length, error)], usage: void 0 };
5263
+ }
5264
+ async function retryTruncatedSplit(provider, params, batch, accepted, integrityMismatches, providerFailures, reviewFlags) {
5265
+ const notices = [];
5266
+ let usage;
5267
+ for (const half of chunk(batch, Math.ceil(batch.length / 2))) {
5268
+ const sub = await runSubBatch(
5269
+ provider,
5270
+ params,
5271
+ half,
5272
+ accepted,
5273
+ integrityMismatches,
5274
+ providerFailures,
5275
+ reviewFlags
5276
+ );
5277
+ notices.push(...sub.notices);
5278
+ usage = combineUsage(usage, sub.usage);
5279
+ }
5280
+ return { notices, usage };
5281
+ }
4449
5282
  function foldEntryResult(entry, result, adapter, accepted, integrityMismatches, providerFailures) {
4450
5283
  const value = result.values.get(entry.key);
4451
5284
  if (value === void 0) {
@@ -4503,6 +5336,26 @@ async function recordRunStatus(cwd, dryRun, summary, fs) {
4503
5336
  } catch {
4504
5337
  }
4505
5338
  }
5339
+ async function createRunCacheState(input, config, cwd, dryRun, fs) {
5340
+ if (dryRun || input.cache === false) {
5341
+ return void 0;
5342
+ }
5343
+ return {
5344
+ memory: await readTranslationMemory(cacheFilePath(cwd), fs),
5345
+ fingerprint: computeFingerprint(config),
5346
+ additions: /* @__PURE__ */ new Map()
5347
+ };
5348
+ }
5349
+ async function recordCacheAdditions(cwd, cache, fs) {
5350
+ if (cache === void 0 || cache.additions.size === 0) {
5351
+ return;
5352
+ }
5353
+ try {
5354
+ const merged = applyAdditions(cache.memory, cache.fingerprint, cache.additions);
5355
+ await writeTranslationMemory(cacheFilePath(cwd), merged, fs);
5356
+ } catch {
5357
+ }
5358
+ }
4506
5359
  function buildLocaleRunParams(context, targetLocale, baseline) {
4507
5360
  return {
4508
5361
  source: context.source.resource,
@@ -4521,7 +5374,9 @@ function buildLocaleRunParams(context, targetLocale, baseline) {
4521
5374
  generatePlurals: context.generatePlurals,
4522
5375
  maxBatchSize: context.maxBatchSize,
4523
5376
  fs: context.fs,
4524
- budget: context.budget
5377
+ budget: context.budget,
5378
+ ...context.cache !== void 0 ? { cache: { snapshot: context.cache.memory, fingerprint: context.cache.fingerprint } } : {},
5379
+ ...context.onProgress !== void 0 ? { onProgress: context.onProgress } : {}
4525
5380
  };
4526
5381
  }
4527
5382
  async function runDryLocale(context, targetLocale, lock) {
@@ -4529,16 +5384,29 @@ async function runDryLocale(context, targetLocale, lock) {
4529
5384
  return (await runLocale(params)).summary;
4530
5385
  }
4531
5386
  async function runLiveLocale(context, targetLocale) {
4532
- return withLocaleWriteLock(context.cwd, targetLocale, context.fs, async () => {
4533
- const lock = await readLockFile(lockFilePath(context.cwd), context.fs);
4534
- const params = buildLocaleRunParams(context, targetLocale, baselineFor(lock, targetLocale));
4535
- const result = await runLocale(params);
4536
- await updateLockFileLocale(context.cwd, context.fs, targetLocale, {
4537
- mode: "replace",
4538
- entries: result.lockEntries
4539
- });
4540
- return result.summary;
4541
- });
5387
+ const lockOptions = {
5388
+ ...context.onLockWait !== void 0 ? { onWait: context.onLockWait } : {},
5389
+ ...context.lockAcquireTimeoutMs !== void 0 ? { acquireTimeoutMs: context.lockAcquireTimeoutMs } : {}
5390
+ };
5391
+ return withLocaleWriteLock(
5392
+ context.cwd,
5393
+ targetLocale,
5394
+ context.fs,
5395
+ async () => {
5396
+ const lock = await readLockFile(lockFilePath(context.cwd), context.fs);
5397
+ const params = buildLocaleRunParams(context, targetLocale, baselineFor(lock, targetLocale));
5398
+ const result = await runLocale(params);
5399
+ await updateLockFileLocale(context.cwd, context.fs, targetLocale, {
5400
+ mode: "replace",
5401
+ entries: result.lockEntries
5402
+ });
5403
+ if (context.cache !== void 0 && result.cacheAdditions.length > 0) {
5404
+ context.cache.additions.set(targetLocale, additionsToRecord(result.cacheAdditions));
5405
+ }
5406
+ return result.summary;
5407
+ },
5408
+ lockOptions
5409
+ );
4542
5410
  }
4543
5411
  async function runOneLocale(targetLocale, run) {
4544
5412
  try {
@@ -4550,27 +5418,88 @@ async function runOneLocale(targetLocale, run) {
4550
5418
  return failureSummary(targetLocale, error);
4551
5419
  }
4552
5420
  }
4553
- async function runAllLocalesDry(context, targetLocales) {
5421
+ async function runLocaleAt(context, targetLocales, localeIndex, runOne, results) {
5422
+ const targetLocale = targetLocales[localeIndex];
5423
+ if (targetLocale === void 0) {
5424
+ return;
5425
+ }
5426
+ context.onProgress?.({
5427
+ type: "locale-started",
5428
+ locale: targetLocale,
5429
+ localeIndex,
5430
+ totalLocales: targetLocales.length
5431
+ });
5432
+ const summary = await runOneLocale(targetLocale, () => runOne(targetLocale));
5433
+ results[localeIndex] = summary;
5434
+ context.onProgress?.({
5435
+ type: "locale-finished",
5436
+ locale: targetLocale,
5437
+ translated: summary.translated.length
5438
+ });
5439
+ }
5440
+ async function runLocalesWithProgress(context, targetLocales, runOne, concurrency) {
5441
+ const totalLocales = targetLocales.length;
5442
+ const results = new Array(totalLocales);
5443
+ let nextIndex = 0;
5444
+ async function worker() {
5445
+ while (nextIndex < totalLocales) {
5446
+ const localeIndex = nextIndex;
5447
+ nextIndex += 1;
5448
+ await runLocaleAt(context, targetLocales, localeIndex, runOne, results);
5449
+ }
5450
+ }
5451
+ const workerCount = Math.min(concurrency, totalLocales);
5452
+ const workers = [];
5453
+ for (let index = 0; index < workerCount; index += 1) {
5454
+ workers.push(worker());
5455
+ }
5456
+ await Promise.all(workers);
5457
+ return results.filter((summary) => summary !== void 0);
5458
+ }
5459
+ async function runAllLocalesDry(context, targetLocales, concurrency) {
4554
5460
  const lock = await readLockFile(lockFilePath(context.cwd), context.fs);
4555
- const summaries = [];
4556
- for (const targetLocale of targetLocales) {
4557
- summaries.push(
4558
- await runOneLocale(targetLocale, () => runDryLocale(context, targetLocale, lock))
5461
+ return runLocalesWithProgress(
5462
+ context,
5463
+ targetLocales,
5464
+ (targetLocale) => runDryLocale(context, targetLocale, lock),
5465
+ concurrency
5466
+ );
5467
+ }
5468
+ async function runAllLocalesLive(context, targetLocales, concurrency) {
5469
+ return runLocalesWithProgress(
5470
+ context,
5471
+ targetLocales,
5472
+ (targetLocale) => runLiveLocale(context, targetLocale),
5473
+ concurrency
5474
+ );
5475
+ }
5476
+ function resolveConcurrency(value) {
5477
+ if (value === void 0) {
5478
+ return 1;
5479
+ }
5480
+ if (!Number.isInteger(value) || value < 1) {
5481
+ throw new SdkError(
5482
+ "CONCURRENCY_INVALID",
5483
+ `The concurrency option must be an integer of at least 1, got ${value}.`
4559
5484
  );
4560
5485
  }
4561
- return summaries;
5486
+ return value;
4562
5487
  }
4563
- async function runAllLocalesLive(context, targetLocales) {
4564
- const summaries = [];
4565
- for (const targetLocale of targetLocales) {
4566
- summaries.push(await runOneLocale(targetLocale, () => runLiveLocale(context, targetLocale)));
5488
+ function resolveRunConcurrency(value, dryRun, config) {
5489
+ const concurrency = resolveConcurrency(value);
5490
+ if (!dryRun && concurrency > 1 && config.maxTokens !== void 0) {
5491
+ throw new SdkError(
5492
+ "CONCURRENCY_BUDGET_CONFLICT",
5493
+ "A token budget (maxTokens) and concurrency greater than 1 cannot be combined on a live run: concurrent locales would overshoot the budget nondeterministically. Set concurrency to 1, remove maxTokens, or use --dry-run."
5494
+ );
4567
5495
  }
4568
- return summaries;
5496
+ return concurrency;
4569
5497
  }
4570
5498
  async function translate2(input, deps = {}) {
4571
5499
  const config = input.config;
4572
5500
  const cwd = input.cwd ?? process.cwd();
4573
5501
  const dryRun = input.dryRun ?? false;
5502
+ const concurrency = resolveRunConcurrency(input.concurrency, dryRun, config);
4574
5503
  const prune = input.prune ?? config.prune ?? false;
4575
5504
  const generatePlurals = input.generatePlurals ?? config.generatePlurals ?? false;
4576
5505
  const maxBatchSize = config.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
@@ -4582,6 +5511,7 @@ async function translate2(input, deps = {}) {
4582
5511
  const adapter = selectAdapter(config.format, deps.adapterRegistry);
4583
5512
  const provider = dryRun ? void 0 : selectProvider(config.provider, deps.createProvider);
4584
5513
  const source = await readSource(config, cwd, fs, adapter);
5514
+ const cache = await createRunCacheState(input, config, cwd, dryRun, fs);
4585
5515
  const context = {
4586
5516
  source,
4587
5517
  adapter,
@@ -4592,10 +5522,15 @@ async function translate2(input, deps = {}) {
4592
5522
  generatePlurals,
4593
5523
  maxBatchSize,
4594
5524
  fs,
4595
- budget
5525
+ budget,
5526
+ cache,
5527
+ ...input.onLockWait !== void 0 ? { onLockWait: input.onLockWait } : {},
5528
+ ...input.onProgress !== void 0 ? { onProgress: input.onProgress } : {},
5529
+ ...input.lockAcquireTimeoutMs !== void 0 ? { lockAcquireTimeoutMs: input.lockAcquireTimeoutMs } : {}
4596
5530
  };
4597
- const summaries = dryRun ? await runAllLocalesDry(context, config.targetLocales) : await runAllLocalesLive(context, config.targetLocales);
4598
- const { succeeded, failed } = partition(summaries);
5531
+ const summaries = dryRun ? await runAllLocalesDry(context, config.targetLocales, concurrency) : await runAllLocalesLive(context, config.targetLocales, concurrency);
5532
+ input.onProgress?.({ type: "run-finished", localesCompleted: summaries.length });
5533
+ const { succeeded, partial, failed } = partition(summaries);
4599
5534
  const usage = summaries.reduce(
4600
5535
  (total, summary2) => combineUsage(total, summary2.usage),
4601
5536
  void 0
@@ -4605,10 +5540,12 @@ async function translate2(input, deps = {}) {
4605
5540
  dryRun,
4606
5541
  locales: summaries,
4607
5542
  succeeded,
5543
+ partial,
4608
5544
  failed,
4609
5545
  ...usage !== void 0 ? { usage } : {},
4610
5546
  ...budgetSummary !== void 0 ? { budget: budgetSummary } : {}
4611
5547
  };
5548
+ await recordCacheAdditions(cwd, cache, fs);
4612
5549
  await recordRunStatus(cwd, dryRun, summary, fs);
4613
5550
  return summary;
4614
5551
  }
@@ -4624,8 +5561,12 @@ var INSTRUCTIONS_LINES = [
4624
5561
  "How to use this workbook",
4625
5562
  "",
4626
5563
  "1. There is one sheet per language. Open the sheet named for the language you are translating.",
5564
+ " Do not rename, delete, or reorder the language tabs. verbatra matches each tab to a language by",
5565
+ " its exact name; a renamed or missing tab is reported and its translations are not imported.",
4627
5566
  "2. Fill ONLY the 'Translation' column. Leave every other column unchanged.",
4628
- "3. An empty 'Translation' cell means 'not translated yet'. It is skipped, never written as an empty string.",
5567
+ "3. An empty 'Translation' cell means 'not translated yet'. It is skipped, never written as an empty",
5568
+ " string. A cell that contains only spaces is treated the same as an empty cell.",
5569
+ " To deliberately clear an existing value (set it to empty), type exactly [[CLEAR]] in the cell.",
4629
5570
  "4. Keep placeholders exactly as they appear. A token such as {name} or {count} must stay verbatim,",
4630
5571
  " in your translation, with the same spelling. Do not translate or remove it.",
4631
5572
  "5. For ICU messages (for example {count, plural, one {# item} other {# items}}), keep the ICU",
@@ -4790,6 +5731,26 @@ function buildInstructionsSheet(workbook) {
4790
5731
  }
4791
5732
  sheet.getRow(1).font = { bold: true };
4792
5733
  }
5734
+ var WORKBOOK_PROTECTION_XML = '<workbookProtection lockStructure="1" lockWindows="0"/>';
5735
+ function spliceWorkbookProtection(xml) {
5736
+ const anchor = xml.includes("<bookViews") ? "<bookViews" : "<sheets";
5737
+ return xml.replace(anchor, `${WORKBOOK_PROTECTION_XML}${anchor}`);
5738
+ }
5739
+ async function protectWorkbookStructure(bytes) {
5740
+ let zip;
5741
+ try {
5742
+ zip = await JSZip.loadAsync(bytes);
5743
+ } catch {
5744
+ throw new ExchangeError("WORKBOOK_INVALID", "The workbook could not be serialized.");
5745
+ }
5746
+ const workbookPart = zip.file("xl/workbook.xml");
5747
+ if (workbookPart === null) {
5748
+ return bytes;
5749
+ }
5750
+ const xml = await workbookPart.async("string");
5751
+ zip.file("xl/workbook.xml", spliceWorkbookProtection(xml));
5752
+ return zip.generateAsync({ type: "uint8array", compression: "DEFLATE" });
5753
+ }
4793
5754
  async function buildWorkbook(model) {
4794
5755
  assertNoWorksheetNameCollisions(model.sheets);
4795
5756
  const workbook = new ExcelJS.Workbook();
@@ -4797,13 +5758,15 @@ async function buildWorkbook(model) {
4797
5758
  for (const sheet of model.sheets) {
4798
5759
  await buildDataSheet(workbook, sheet);
4799
5760
  }
5761
+ let serialized;
4800
5762
  try {
4801
5763
  const buffer = await workbook.xlsx.writeBuffer();
4802
- const view = buffer;
4803
- return Uint8Array.prototype.slice.call(view);
5764
+ serialized = buffer;
4804
5765
  } catch {
4805
5766
  throw new ExchangeError("WORKBOOK_INVALID", "The workbook could not be serialized.");
4806
5767
  }
5768
+ const protectedBytes = await protectWorkbookStructure(serialized);
5769
+ return Uint8Array.prototype.slice.call(protectedBytes);
4807
5770
  }
4808
5771
  var DEFAULT_WORKBOOK_LIMITS = {
4809
5772
  maxDecompressedBytes: 64 * 1024 * 1024,
@@ -4923,26 +5886,24 @@ function assertHeader(sheet) {
4923
5886
  );
4924
5887
  }
4925
5888
  }
4926
- function parseRow(sheet, row) {
5889
+ var MALFORMED_ROW_COLUMN = "Status";
5890
+ function parseRow(row) {
4927
5891
  const candidate = {
4928
5892
  key: cellString(row.getCell(COLUMN.key)),
4929
5893
  source: cellString(row.getCell(COLUMN.source)),
4930
5894
  currentTarget: cellString(row.getCell(COLUMN.current)),
4931
5895
  status: cellString(row.getCell(COLUMN.status)),
4932
5896
  sourceHash: cellString(row.getCell(COLUMN.sourceHash)),
4933
- translation: cellString(row.getCell(COLUMN.translation)),
5897
+ translation: cellString(row.getCell(COLUMN.translation)).trim(),
4934
5898
  context: cellString(row.getCell(COLUMN.context)),
4935
5899
  reviewStatus: cellString(row.getCell(COLUMN.reviewStatus)),
4936
5900
  reviewReasons: cellString(row.getCell(COLUMN.reviewReasons))
4937
5901
  };
4938
5902
  const result = rowSchema.safeParse(candidate);
4939
5903
  if (!result.success) {
4940
- throw new ExchangeError(
4941
- "WORKBOOK_INVALID",
4942
- `The sheet "${sheet.name}" has a row with a missing key or an unrecognized status.`
4943
- );
5904
+ return { ok: false, column: MALFORMED_ROW_COLUMN };
4944
5905
  }
4945
- return result.data;
5906
+ return { ok: true, row: result.data };
4946
5907
  }
4947
5908
  function readDataSheet(sheet, limits) {
4948
5909
  assertHeader(sheet);
@@ -4953,6 +5914,9 @@ function readDataSheet(sheet, limits) {
4953
5914
  );
4954
5915
  }
4955
5916
  const rows = [];
5917
+ const malformed = [];
5918
+ const duplicates = [];
5919
+ const seenKeys = /* @__PURE__ */ new Set();
4956
5920
  for (let rowNumber = HEADER_ROW + 1; rowNumber <= sheet.rowCount; rowNumber += 1) {
4957
5921
  const row = sheet.getRow(rowNumber);
4958
5922
  if (row.cellCount > limits.maxCellsPerRow) {
@@ -4964,9 +5928,19 @@ function readDataSheet(sheet, limits) {
4964
5928
  if (cellString(row.getCell(COLUMN.key)) === "") {
4965
5929
  continue;
4966
5930
  }
4967
- rows.push(parseRow(sheet, row));
5931
+ const outcome = parseRow(row);
5932
+ if (!outcome.ok) {
5933
+ malformed.push({ locale: sheet.name, row: rowNumber, column: outcome.column });
5934
+ continue;
5935
+ }
5936
+ if (seenKeys.has(outcome.row.key)) {
5937
+ duplicates.push({ locale: sheet.name, key: outcome.row.key, row: rowNumber });
5938
+ continue;
5939
+ }
5940
+ seenKeys.add(outcome.row.key);
5941
+ rows.push(outcome.row);
4968
5942
  }
4969
- return { locale: sheet.name, rows };
5943
+ return { sheet: { locale: sheet.name, rows }, malformed, duplicates };
4970
5944
  }
4971
5945
  async function loadWorkbook(bytes) {
4972
5946
  const workbook = new ExcelJS.Workbook();
@@ -4989,13 +5963,18 @@ async function readWorkbook(bytes, options = {}) {
4989
5963
  );
4990
5964
  }
4991
5965
  const sheets = [];
5966
+ const malformedRows = [];
5967
+ const duplicateKeys = [];
4992
5968
  for (const sheet of workbook.worksheets) {
4993
5969
  if (sheet.name === INSTRUCTIONS_SHEET_NAME) {
4994
5970
  continue;
4995
5971
  }
4996
- sheets.push(readDataSheet(sheet, limits));
5972
+ const read = readDataSheet(sheet, limits);
5973
+ sheets.push(read.sheet);
5974
+ malformedRows.push(...read.malformed);
5975
+ duplicateKeys.push(...read.duplicates);
4997
5976
  }
4998
- return { sheets };
5977
+ return { sheets, malformedRows, duplicateKeys };
4999
5978
  }
5000
5979
 
5001
5980
  // src/flow/workbook/export-workbook.ts
@@ -5096,6 +6075,7 @@ async function exportWorkbook(input, deps = {}) {
5096
6075
  }
5097
6076
 
5098
6077
  // src/flow/workbook/import-locale.ts
6078
+ var CLEAR_SENTINEL = "[[CLEAR]]";
5099
6079
  var UnknownKeyError = class extends Error {
5100
6080
  key;
5101
6081
  constructor(key) {
@@ -5124,9 +6104,20 @@ function trackBlankDrift(row, params, buckets) {
5124
6104
  buckets.blankDrifted.add(row.key);
5125
6105
  }
5126
6106
  }
6107
+ function classifyClear(row, sourceEntry, buckets) {
6108
+ if (contentHash(sourceEntry) !== row.sourceHash) {
6109
+ buckets.mismatches.push(row.key);
6110
+ buckets.withheld.add(row.key);
6111
+ return;
6112
+ }
6113
+ buckets.accepted.set(row.key, { value: "", source: sourceEntry });
6114
+ }
5127
6115
  function classifyRows(params, buckets) {
5128
6116
  for (const row of params.sheet.rows) {
5129
6117
  if (row.translation === "") {
6118
+ if (row.status === "changed") {
6119
+ buckets.unfilled.push(row.key);
6120
+ }
5130
6121
  trackBlankDrift(row, params, buckets);
5131
6122
  continue;
5132
6123
  }
@@ -5137,6 +6128,10 @@ function classifyRows(params, buckets) {
5137
6128
  if (sourceEntry === void 0) {
5138
6129
  continue;
5139
6130
  }
6131
+ if (row.translation === CLEAR_SENTINEL) {
6132
+ classifyClear(row, sourceEntry, buckets);
6133
+ continue;
6134
+ }
5140
6135
  const reason = judge(row, sourceEntry, params.adapter);
5141
6136
  if (reason === void 0) {
5142
6137
  buckets.accepted.set(row.key, { value: row.translation, source: sourceEntry });
@@ -5158,25 +6153,39 @@ function importLocale(params) {
5158
6153
  accepted: /* @__PURE__ */ new Map(),
5159
6154
  mismatches: [],
5160
6155
  withheld: /* @__PURE__ */ new Set(),
5161
- blankDrifted: /* @__PURE__ */ new Set()
6156
+ blankDrifted: /* @__PURE__ */ new Set(),
6157
+ unfilled: []
5162
6158
  };
5163
6159
  classifyRows(params, buckets);
5164
6160
  const rowKeys = new Set(params.sheet.rows.map((row) => row.key));
5165
6161
  const invalidIcuSource = [...new Set(params.sourceInvalidIcuKeys)].filter((key) => rowKeys.has(key)).sort();
6162
+ const translated = [...buckets.accepted.keys()].sort();
6163
+ const integrityMismatches = [...buckets.mismatches].sort();
5166
6164
  const summary = {
5167
6165
  locale: params.sheet.locale,
5168
- status: "succeeded",
5169
- translated: [...buckets.accepted.keys()].sort(),
6166
+ status: deriveLocaleStatus({
6167
+ translated,
6168
+ cacheHits: [],
6169
+ generated: [],
6170
+ integrityMismatches,
6171
+ providerFailures: [],
6172
+ budgetWithheld: []
6173
+ }),
6174
+ translated,
5170
6175
  unchanged: diff2.unchanged,
5171
6176
  orphaned: diff2.orphaned,
5172
6177
  pruned: [],
5173
6178
  invalidIcuSource,
5174
- integrityMismatches: [...buckets.mismatches].sort(),
6179
+ cacheHits: [],
6180
+ integrityMismatches,
5175
6181
  providerFailures: [],
5176
6182
  budgetWithheld: [],
5177
6183
  generated: [],
5178
6184
  notices: buckets.blankDrifted.size > 0 ? [blankRowBaselineNotice(buckets.blankDrifted.size)] : [],
5179
- needsReview: []
6185
+ needsReview: [],
6186
+ unfilled: [...new Set(buckets.unfilled)].sort(),
6187
+ malformedRows: params.malformedRows,
6188
+ duplicateKeys: params.duplicateKeys
5180
6189
  };
5181
6190
  return { summary, accepted: buckets.accepted, withheld: buckets.withheld };
5182
6191
  }
@@ -5203,6 +6212,19 @@ function mergeAccepted(target, accepted) {
5203
6212
  }
5204
6213
  return merged;
5205
6214
  }
6215
+ function sheetCacheAdditions(accepted) {
6216
+ const record = {};
6217
+ for (const [, { value, source }] of accepted) {
6218
+ record[contentHash(source)] = value;
6219
+ }
6220
+ return record;
6221
+ }
6222
+ function collectSheetAdditions(byLocale, locale, additions) {
6223
+ if (Object.keys(additions).length === 0) {
6224
+ return;
6225
+ }
6226
+ byLocale.set(locale, { ...byLocale.get(locale), ...additions });
6227
+ }
5206
6228
  function computeLockEntries2(source, merged, baseline, accepted) {
5207
6229
  const entries = {};
5208
6230
  for (const key of merged.keys()) {
@@ -5219,11 +6241,20 @@ function computeLockEntries2(source, merged, baseline, accepted) {
5219
6241
  }
5220
6242
  return entries;
5221
6243
  }
6244
+ var MissingSheetError = class extends Error {
6245
+ code = "WORKBOOK_SHEET_MISSING";
6246
+ constructor(locale) {
6247
+ super(
6248
+ `The workbook has no sheet (tab) for the configured target locale "${locale}". The tab may have been renamed, deleted, or reordered out of the workbook.`
6249
+ );
6250
+ this.name = "MissingSheetError";
6251
+ }
6252
+ };
5222
6253
  async function runSheet(ctx, sheet, lock) {
5223
6254
  if (!ctx.config.targetLocales.includes(sheet.locale)) {
5224
6255
  throw new SdkError(
5225
6256
  "CONFIG_INVALID",
5226
- `The workbook has a sheet for locale "${sheet.locale}", which is not a configured target locale.`
6257
+ `The workbook has a sheet named "${sheet.locale}", which is not a configured target locale. It may be a renamed, added, or reordered tab; leave every language tab named exactly as exported.`
5227
6258
  );
5228
6259
  }
5229
6260
  const target = await readTarget(ctx.cwd, ctx.config, ctx.adapter, ctx.fs, sheet.locale);
@@ -5234,10 +6265,12 @@ async function runSheet(ctx, sheet, lock) {
5234
6265
  target,
5235
6266
  baseline,
5236
6267
  adapter: ctx.adapter,
5237
- sourceInvalidIcuKeys: ctx.sourceInvalidIcuKeys
6268
+ sourceInvalidIcuKeys: ctx.sourceInvalidIcuKeys,
6269
+ malformedRows: ctx.malformedRows.filter((problem) => problem.locale === sheet.locale).map((problem) => ({ row: problem.row, column: problem.column })),
6270
+ duplicateKeys: ctx.duplicateKeys.filter((duplicate) => duplicate.locale === sheet.locale).map((duplicate) => ({ key: duplicate.key, row: duplicate.row }))
5238
6271
  });
5239
6272
  if (ctx.dryRun) {
5240
- return { summary, lockEntries: {} };
6273
+ return { summary, lockEntries: {}, cacheAdditions: {} };
5241
6274
  }
5242
6275
  const merged = mergeAccepted(target, accepted);
5243
6276
  if (accepted.size > 0) {
@@ -5252,7 +6285,11 @@ async function runSheet(ctx, sheet, lock) {
5252
6285
  path
5253
6286
  );
5254
6287
  }
5255
- return { summary, lockEntries: computeLockEntries2(ctx.source, merged, baseline, accepted) };
6288
+ return {
6289
+ summary,
6290
+ lockEntries: computeLockEntries2(ctx.source, merged, baseline, accepted),
6291
+ cacheAdditions: sheetCacheAdditions(accepted)
6292
+ };
5256
6293
  }
5257
6294
  async function importWorkbook(input, deps = {}) {
5258
6295
  const config = input.config;
@@ -5277,9 +6314,12 @@ async function importWorkbook(input, deps = {}) {
5277
6314
  fs,
5278
6315
  source: source.resource,
5279
6316
  sourceInvalidIcuKeys: source.invalidIcuKeys,
5280
- dryRun
6317
+ dryRun,
6318
+ malformedRows: data.malformedRows,
6319
+ duplicateKeys: data.duplicateKeys
5281
6320
  };
5282
6321
  const summaries = [];
6322
+ const cacheAdditions = /* @__PURE__ */ new Map();
5283
6323
  for (const sheet of data.sheets) {
5284
6324
  try {
5285
6325
  let summary;
@@ -5292,6 +6332,7 @@ async function importWorkbook(input, deps = {}) {
5292
6332
  mode: "replace",
5293
6333
  entries: result.lockEntries
5294
6334
  });
6335
+ collectSheetAdditions(cacheAdditions, sheet.locale, result.cacheAdditions);
5295
6336
  return result.summary;
5296
6337
  });
5297
6338
  }
@@ -5300,8 +6341,17 @@ async function importWorkbook(input, deps = {}) {
5300
6341
  summaries.push(failureSummary(sheet.locale, error));
5301
6342
  }
5302
6343
  }
5303
- const { succeeded, failed } = partition(summaries);
5304
- return { dryRun, locales: summaries, succeeded, failed };
6344
+ const presentLocales = new Set(data.sheets.map((sheet) => sheet.locale));
6345
+ for (const locale of config.targetLocales) {
6346
+ if (!presentLocales.has(locale)) {
6347
+ summaries.push(failureSummary(locale, new MissingSheetError(locale)));
6348
+ }
6349
+ }
6350
+ if (!dryRun) {
6351
+ await feedTranslationMemory(cwd, fs, computeFingerprint(config), cacheAdditions);
6352
+ }
6353
+ const { succeeded, partial, failed } = partition(summaries);
6354
+ return { dryRun, locales: summaries, succeeded, partial, failed };
5305
6355
  }
5306
6356
 
5307
6357
  // src/lock/load-lock-file.ts
@@ -5359,7 +6409,15 @@ async function watch(input, deps = {}) {
5359
6409
  );
5360
6410
  }
5361
6411
  const runTranslate = deps.runTranslate ?? defaultRunTranslate(deps);
5362
- const runInput = { config: input.config, cwd };
6412
+ const runInput = {
6413
+ config: input.config,
6414
+ cwd,
6415
+ ...input.onLockWait !== void 0 ? { onLockWait: input.onLockWait } : {},
6416
+ ...input.onProgress !== void 0 ? { onProgress: input.onProgress } : {},
6417
+ ...input.lockAcquireTimeoutMs !== void 0 ? { lockAcquireTimeoutMs: input.lockAcquireTimeoutMs } : {},
6418
+ ...input.concurrency !== void 0 ? { concurrency: input.concurrency } : {},
6419
+ ...input.cache !== void 0 ? { cache: input.cache } : {}
6420
+ };
5363
6421
  let state = "idle";
5364
6422
  let pending = false;
5365
6423
  let stopped = false;
@@ -5426,6 +6484,6 @@ async function watch(input, deps = {}) {
5426
6484
  return { stop };
5427
6485
  }
5428
6486
 
5429
- export { DEFAULT_WORKBOOK_PATH, LOCK_FILE_NAME, SdkError, check, defineConfig, diff, diffLocaleSnapshots, editEntry, exportWorkbook, importWorkbook, keyIntegrity, keyValue, loadConfig, loadConfigWithMeta, loadLockFile, lockState, readLocaleFileSnapshot, retranslateEntry, runStatus, scaffoldingMetadata, translate2 as translate, verbatraConfigSchema, watch };
6487
+ export { CACHE_FILE_NAME, DEFAULT_WORKBOOK_PATH, LOCK_FILE_NAME, SdkError, check, defineConfig, diff, diffLocaleSnapshots, editEntry, exportWorkbook, importWorkbook, keyIntegrity, keyValue, loadConfig, loadConfigWithMeta, loadLockFile, lockState, readLocaleFileSnapshot, retranslateEntry, runStatus, scaffoldingMetadata, translate2 as translate, verbatraConfigSchema, watch };
5430
6488
  //# sourceMappingURL=index.js.map
5431
6489
  //# sourceMappingURL=index.js.map