@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.cjs CHANGED
@@ -1,12 +1,12 @@
1
1
  'use strict';
2
2
 
3
- var fs = require('fs');
4
3
  var path = require('path');
4
+ var zod = require('zod');
5
+ var fs = require('fs');
5
6
  var cosmiconfig = require('cosmiconfig');
6
7
  var cosmiconfigTypescriptLoader = require('cosmiconfig-typescript-loader');
7
8
  var crypto = require('crypto');
8
9
  var promises = require('fs/promises');
9
- var zod = require('zod');
10
10
  var Anthropic = require('@anthropic-ai/sdk');
11
11
  var deepl = require('deepl-node');
12
12
  var module$1 = require('module');
@@ -17,8 +17,8 @@ var icuMessageformatParser = require('@formatjs/icu-messageformat-parser');
17
17
  var xmldom = require('@xmldom/xmldom');
18
18
  var yaml = require('yaml');
19
19
  var ExcelJS = require('exceljs');
20
- var stream = require('stream');
21
20
  var JSZip = require('jszip');
21
+ var stream = require('stream');
22
22
  var chokidar = require('chokidar');
23
23
 
24
24
  var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
@@ -49,6 +49,96 @@ var OpenAI__default = /*#__PURE__*/_interopDefault(OpenAI);
49
49
  var ExcelJS__default = /*#__PURE__*/_interopDefault(ExcelJS);
50
50
  var JSZip__default = /*#__PURE__*/_interopDefault(JSZip);
51
51
 
52
+ // src/cache/translation-memory.ts
53
+ var CACHE_FILE_NAME = "verbatra.cache.json";
54
+ var CURRENT_VERSION = 1;
55
+ var EMPTY_MEMORY = { version: CURRENT_VERSION, entries: {} };
56
+ var MAX_CACHE_FILE_BYTES = 64 * 1024 * 1024;
57
+ var translationMemorySchema = zod.z.object({
58
+ version: zod.z.number().int().positive(),
59
+ entries: zod.z.record(zod.z.string(), zod.z.record(zod.z.string(), zod.z.record(zod.z.string(), zod.z.string())))
60
+ });
61
+ function cacheFilePath(cwd) {
62
+ return path.resolve(cwd, CACHE_FILE_NAME);
63
+ }
64
+ async function readTranslationMemory(path, fs) {
65
+ let read;
66
+ try {
67
+ read = await fs.readFileBounded(path, MAX_CACHE_FILE_BYTES);
68
+ } catch {
69
+ return EMPTY_MEMORY;
70
+ }
71
+ if (read.kind !== "ok") {
72
+ return EMPTY_MEMORY;
73
+ }
74
+ let parsed;
75
+ try {
76
+ parsed = JSON.parse(read.content);
77
+ } catch {
78
+ return EMPTY_MEMORY;
79
+ }
80
+ const result = translationMemorySchema.safeParse(parsed);
81
+ if (!result.success || result.data.version !== CURRENT_VERSION) {
82
+ return EMPTY_MEMORY;
83
+ }
84
+ return result.data;
85
+ }
86
+ function lookupMemory(memory, fingerprint, locale, contentHash2) {
87
+ return memory.entries[fingerprint]?.[locale]?.[contentHash2];
88
+ }
89
+ function applyAdditions(base, fingerprint, additionsByLocale) {
90
+ if (additionsByLocale.size === 0) {
91
+ return base;
92
+ }
93
+ const fingerprintEntries = {};
94
+ for (const [locale, hashes] of Object.entries(base.entries[fingerprint] ?? {})) {
95
+ fingerprintEntries[locale] = { ...hashes };
96
+ }
97
+ for (const [locale, hashes] of additionsByLocale) {
98
+ fingerprintEntries[locale] = { ...fingerprintEntries[locale], ...hashes };
99
+ }
100
+ return {
101
+ version: base.version,
102
+ entries: { ...base.entries, [fingerprint]: fingerprintEntries }
103
+ };
104
+ }
105
+ function additionsToRecord(additions) {
106
+ const record = {};
107
+ for (const addition of additions) {
108
+ record[addition.contentHash] = addition.value;
109
+ }
110
+ return record;
111
+ }
112
+ function sortEntries(record) {
113
+ return Object.entries(record).sort(([a], [b]) => a < b ? -1 : 1);
114
+ }
115
+ function serialize(memory) {
116
+ const entries = {};
117
+ for (const [fingerprint, locales] of sortEntries(memory.entries)) {
118
+ const localeMap = {};
119
+ for (const [locale, hashes] of sortEntries(locales)) {
120
+ localeMap[locale] = Object.fromEntries(sortEntries(hashes));
121
+ }
122
+ entries[fingerprint] = localeMap;
123
+ }
124
+ return `${JSON.stringify({ version: memory.version, entries }, null, 2)}
125
+ `;
126
+ }
127
+ async function writeTranslationMemory(path, memory, fs) {
128
+ await fs.writeFile(path, serialize(memory));
129
+ }
130
+ async function feedTranslationMemory(cwd, fs, fingerprint, additionsByLocale) {
131
+ if (additionsByLocale.size === 0) {
132
+ return;
133
+ }
134
+ try {
135
+ const path = cacheFilePath(cwd);
136
+ const memory = await readTranslationMemory(path, fs);
137
+ await writeTranslationMemory(path, applyAdditions(memory, fingerprint, additionsByLocale), fs);
138
+ } catch {
139
+ }
140
+ }
141
+
52
142
  // src/config/define-config.ts
53
143
  function defineConfig(config) {
54
144
  return config;
@@ -242,7 +332,7 @@ async function resolveGlossary(glossary, baseDir, fs) {
242
332
  var FNV_OFFSET_BASIS = 14695981039346656037n;
243
333
  var FNV_PRIME = 1099511628211n;
244
334
  var U64_MASK = (1n << 64n) - 1n;
245
- function fnv1a64(input) {
335
+ function stableStringHash(input) {
246
336
  let hash = FNV_OFFSET_BASIS;
247
337
  for (let index = 0; index < input.length; index += 1) {
248
338
  hash ^= BigInt(input.charCodeAt(index));
@@ -263,7 +353,7 @@ function canonicalize(entry) {
263
353
  ]);
264
354
  }
265
355
  function contentHash(entry) {
266
- return fnv1a64(canonicalize(entry));
356
+ return stableStringHash(canonicalize(entry));
267
357
  }
268
358
  function sorted(keys) {
269
359
  return [...keys].sort();
@@ -308,7 +398,8 @@ var SUPPORTED_FORMATS = [
308
398
  "ngx-translate-json",
309
399
  "xliff",
310
400
  "yaml",
311
- "arb"
401
+ "arb",
402
+ "properties"
312
403
  ];
313
404
  var supportedFormatSchema = zod.z.enum(SUPPORTED_FORMATS);
314
405
  var translationEntrySchema = zod.z.object({
@@ -359,77 +450,76 @@ function checkPlaceholders(source, translated) {
359
450
  reordered
360
451
  };
361
452
  }
362
- var LOCALE_TOKEN = "{locale}";
363
- function localeFilePath(cwd, pattern, locale) {
364
- return path.resolve(cwd, pattern.replaceAll(LOCALE_TOKEN, locale));
365
- }
366
- var RATE_LIMITED_STATUS = 429;
367
- var TIMEOUT_STATUS = 408;
368
- var AUTH_FAILED_STATUSES = /* @__PURE__ */ new Set([401, 403]);
369
- var RATE_LIMITED_CLASS_NAMES = /* @__PURE__ */ new Set([
370
- "RateLimitError",
371
- "TooManyRequestsError"
372
- ]);
373
- var AUTH_FAILED_CLASS_NAMES = /* @__PURE__ */ new Set([
374
- "AuthenticationError",
375
- "PermissionDeniedError",
376
- "AuthorizationError"
377
- ]);
378
- var TIMEOUT_CLASS_NAMES = /* @__PURE__ */ new Set([
379
- "APIConnectionTimeoutError",
380
- "ConnectionError"
381
- ]);
382
- var ABORT_ERROR_CLASS_NAMES = /* @__PURE__ */ new Set(["APIUserAbortError"]);
383
- function getErrorStatus(error) {
384
- if (typeof error !== "object" || error === null || !("status" in error)) {
385
- return void 0;
453
+ var MIN_SOURCE_LENGTH = 8;
454
+ var MAX_LENGTH_MULTIPLE = 12;
455
+ var MAX_REPEAT_UNIT_LENGTH = 16;
456
+ var MIN_CONSECUTIVE_REPEATS = 8;
457
+ var REPEAT_COVERAGE_FRACTION = 0.5;
458
+ var MAX_SCAN_LENGTH = 8192;
459
+ function matchesUnit(text, a, b, unit) {
460
+ for (let i = 0; i < unit; i++) {
461
+ if (text[a + i] !== text[b + i]) {
462
+ return false;
463
+ }
386
464
  }
387
- const status = error.status;
388
- return typeof status === "number" ? status : void 0;
465
+ return true;
389
466
  }
390
- function readClassName(error) {
391
- if (typeof error !== "object" || error === null) {
392
- return void 0;
467
+ function countConsecutiveCopies(text, start, unit, scanLength) {
468
+ let copies = 1;
469
+ let next = start + unit;
470
+ while (next + unit <= scanLength && matchesUnit(text, start, next, unit)) {
471
+ copies++;
472
+ next += unit;
473
+ }
474
+ return copies;
475
+ }
476
+ function hasDominantRun(text, scanLength, unit, coverage) {
477
+ const limit = scanLength - unit;
478
+ let start = 0;
479
+ while (start <= limit) {
480
+ const copies = countConsecutiveCopies(text, start, unit, scanLength);
481
+ if (copies >= MIN_CONSECUTIVE_REPEATS && copies * unit >= coverage) {
482
+ return true;
483
+ }
484
+ start += copies * unit;
393
485
  }
394
- return error.constructor?.name;
486
+ return false;
395
487
  }
396
- function classifyProviderError(error) {
397
- const status = getErrorStatus(error);
398
- if (status === RATE_LIMITED_STATUS) {
399
- return "RATE_LIMITED";
400
- }
401
- if (status !== void 0 && AUTH_FAILED_STATUSES.has(status)) {
402
- return "AUTH_FAILED";
403
- }
404
- if (status === TIMEOUT_STATUS) {
405
- return "TIMEOUT";
406
- }
407
- const className = readClassName(error);
408
- if (className !== void 0) {
409
- if (RATE_LIMITED_CLASS_NAMES.has(className)) {
410
- return "RATE_LIMITED";
411
- }
412
- if (AUTH_FAILED_CLASS_NAMES.has(className)) {
413
- return "AUTH_FAILED";
414
- }
415
- if (TIMEOUT_CLASS_NAMES.has(className)) {
416
- return "TIMEOUT";
488
+ function hasRunawayRepetition(text) {
489
+ const scanLength = Math.min(text.length, MAX_SCAN_LENGTH);
490
+ const coverage = scanLength * REPEAT_COVERAGE_FRACTION;
491
+ const maxUnit = Math.min(
492
+ MAX_REPEAT_UNIT_LENGTH,
493
+ Math.floor(scanLength / MIN_CONSECUTIVE_REPEATS)
494
+ );
495
+ for (let unit = 1; unit <= maxUnit; unit++) {
496
+ if (hasDominantRun(text, scanLength, unit, coverage)) {
497
+ return true;
417
498
  }
418
499
  }
419
- return "PROVIDER_ERROR";
500
+ return false;
420
501
  }
421
- function isAbortShapedError(error) {
422
- if (error instanceof Error && error.name === "AbortError") {
423
- return true;
502
+ function assessValueDegeneracy(sourceValue, candidateValue) {
503
+ const source = sourceValue.trim();
504
+ const candidate = candidateValue.trim();
505
+ if (source.length >= MIN_SOURCE_LENGTH && candidate.length >= source.length * MAX_LENGTH_MULTIPLE) {
506
+ return { degenerate: true };
424
507
  }
425
- const className = readClassName(error);
426
- return className !== void 0 && ABORT_ERROR_CLASS_NAMES.has(className);
508
+ return { degenerate: hasRunawayRepetition(candidate) };
427
509
  }
428
- function isAbortError(error, signal) {
429
- if (signal !== void 0) {
430
- return signal.aborted === true && isAbortShapedError(error);
510
+ var LOCALE_TOKEN = "{locale}";
511
+ function localeFilePath(cwd, pattern, locale) {
512
+ return path.resolve(cwd, pattern.replaceAll(LOCALE_TOKEN, locale));
513
+ }
514
+ function checkBatchIntegrity(inputs, extract, compare) {
515
+ const integrity = /* @__PURE__ */ new Map();
516
+ for (const { key, sourceValue, translatedValue } of inputs) {
517
+ integrity.set(
518
+ key,
519
+ compare !== void 0 ? compare(sourceValue, translatedValue) : checkPlaceholders(extract(sourceValue), extract(translatedValue))
520
+ );
431
521
  }
432
- return isAbortShapedError(error);
522
+ return integrity;
433
523
  }
434
524
  var REDACTED = "[REDACTED]";
435
525
  var KEY_PATTERNS = [
@@ -467,38 +557,6 @@ var ProviderError = class extends Error {
467
557
  this.code = code;
468
558
  }
469
559
  };
470
- var PROVIDER_CALL_FAILED_MESSAGE = "The translation provider request failed.";
471
- var RATE_LIMITED_MESSAGE = "The translation provider rate-limited this request.";
472
- var TIMEOUT_MESSAGE = "The translation provider request timed out.";
473
- var AUTH_FAILED_MESSAGE = "The translation provider rejected the request credentials.";
474
- var MESSAGE_BY_CODE = {
475
- RATE_LIMITED: RATE_LIMITED_MESSAGE,
476
- TIMEOUT: TIMEOUT_MESSAGE,
477
- AUTH_FAILED: AUTH_FAILED_MESSAGE,
478
- PROVIDER_ERROR: PROVIDER_CALL_FAILED_MESSAGE
479
- };
480
- async function guardProviderCall(call, signal) {
481
- signal?.throwIfAborted();
482
- try {
483
- return await call();
484
- } catch (error) {
485
- if (isAbortError(error, signal)) {
486
- throw error;
487
- }
488
- const code = classifyProviderError(error);
489
- throw new ProviderError(code, MESSAGE_BY_CODE[code]);
490
- }
491
- }
492
- function checkBatchIntegrity(inputs, extract, compare) {
493
- const integrity = /* @__PURE__ */ new Map();
494
- for (const { key, sourceValue, translatedValue } of inputs) {
495
- integrity.set(
496
- key,
497
- compare !== void 0 ? compare(sourceValue, translatedValue) : checkPlaceholders(extract(sourceValue), extract(translatedValue))
498
- );
499
- }
500
- return integrity;
501
- }
502
560
  var requestDataSchema = zod.z.object({
503
561
  sourceLocale: zod.z.string().min(1),
504
562
  targetLocale: zod.z.string().min(1),
@@ -741,6 +799,121 @@ function assertNotTruncated(truncated) {
741
799
  throw new ProviderError("OUTPUT_TRUNCATED", OUTPUT_TRUNCATED_MESSAGE);
742
800
  }
743
801
  }
802
+ var RATE_LIMITED_STATUS = 429;
803
+ var TIMEOUT_STATUS = 408;
804
+ var AUTH_FAILED_STATUSES = /* @__PURE__ */ new Set([401, 403]);
805
+ var RATE_LIMITED_CLASS_NAMES = /* @__PURE__ */ new Set([
806
+ "RateLimitError",
807
+ "TooManyRequestsError"
808
+ ]);
809
+ var AUTH_FAILED_CLASS_NAMES = /* @__PURE__ */ new Set([
810
+ "AuthenticationError",
811
+ "PermissionDeniedError",
812
+ "AuthorizationError"
813
+ ]);
814
+ var TIMEOUT_CLASS_NAMES = /* @__PURE__ */ new Set([
815
+ "APIConnectionTimeoutError",
816
+ "ConnectionError"
817
+ ]);
818
+ var ABORT_ERROR_CLASS_NAMES = /* @__PURE__ */ new Set(["APIUserAbortError"]);
819
+ function getErrorStatus(error) {
820
+ if (typeof error !== "object" || error === null || !("status" in error)) {
821
+ return void 0;
822
+ }
823
+ const status = error.status;
824
+ return typeof status === "number" ? status : void 0;
825
+ }
826
+ function readClassName(error) {
827
+ if (typeof error !== "object" || error === null) {
828
+ return void 0;
829
+ }
830
+ return error.constructor?.name;
831
+ }
832
+ function classifyProviderError(error) {
833
+ const status = getErrorStatus(error);
834
+ if (status === RATE_LIMITED_STATUS) {
835
+ return "RATE_LIMITED";
836
+ }
837
+ if (status !== void 0 && AUTH_FAILED_STATUSES.has(status)) {
838
+ return "AUTH_FAILED";
839
+ }
840
+ if (status === TIMEOUT_STATUS) {
841
+ return "TIMEOUT";
842
+ }
843
+ const className = readClassName(error);
844
+ if (className !== void 0) {
845
+ if (RATE_LIMITED_CLASS_NAMES.has(className)) {
846
+ return "RATE_LIMITED";
847
+ }
848
+ if (AUTH_FAILED_CLASS_NAMES.has(className)) {
849
+ return "AUTH_FAILED";
850
+ }
851
+ if (TIMEOUT_CLASS_NAMES.has(className)) {
852
+ return "TIMEOUT";
853
+ }
854
+ }
855
+ return "PROVIDER_ERROR";
856
+ }
857
+ function isAbortShapedError(error) {
858
+ if (error instanceof Error && error.name === "AbortError") {
859
+ return true;
860
+ }
861
+ const className = readClassName(error);
862
+ return className !== void 0 && ABORT_ERROR_CLASS_NAMES.has(className);
863
+ }
864
+ function isAbortError(error, signal) {
865
+ if (signal !== void 0) {
866
+ return signal.aborted === true && isAbortShapedError(error);
867
+ }
868
+ return isAbortShapedError(error);
869
+ }
870
+ var PROVIDER_CALL_FAILED_MESSAGE = "The translation provider request failed.";
871
+ var RATE_LIMITED_MESSAGE = "The translation provider rate-limited this request.";
872
+ var TIMEOUT_MESSAGE = "The translation provider request timed out.";
873
+ var AUTH_FAILED_MESSAGE = "The translation provider rejected the request credentials.";
874
+ var MESSAGE_BY_CODE = {
875
+ RATE_LIMITED: RATE_LIMITED_MESSAGE,
876
+ TIMEOUT: TIMEOUT_MESSAGE,
877
+ AUTH_FAILED: AUTH_FAILED_MESSAGE,
878
+ PROVIDER_ERROR: PROVIDER_CALL_FAILED_MESSAGE
879
+ };
880
+ async function guardProviderCall(call, signal) {
881
+ signal?.throwIfAborted();
882
+ try {
883
+ return await call();
884
+ } catch (error) {
885
+ if (isAbortError(error, signal)) {
886
+ throw error;
887
+ }
888
+ const code = classifyProviderError(error);
889
+ throw new ProviderError(code, MESSAGE_BY_CODE[code]);
890
+ }
891
+ }
892
+ var DEFAULT_REQUEST_TIMEOUT_MS = 12e4;
893
+ function requestTimedOutMessage(timeoutMs) {
894
+ return `The translation provider request exceeded the ${timeoutMs} ms request timeout.`;
895
+ }
896
+ function combineSignals(caller, timeout) {
897
+ return caller === void 0 ? timeout : AbortSignal.any([caller, timeout]);
898
+ }
899
+ async function withRequestTimeout(timeoutMs, callerSignal, call) {
900
+ const timeoutController = new AbortController();
901
+ const signal = combineSignals(callerSignal, timeoutController.signal);
902
+ let timer;
903
+ const timedOut = new Promise((_, reject) => {
904
+ timer = setTimeout(() => {
905
+ timeoutController.abort();
906
+ reject(new ProviderError("TIMEOUT", requestTimedOutMessage(timeoutMs)));
907
+ }, timeoutMs);
908
+ });
909
+ try {
910
+ return await Promise.race([guardProviderCall(() => call(signal), signal), timedOut]);
911
+ } finally {
912
+ if (timer !== void 0) {
913
+ clearTimeout(timer);
914
+ }
915
+ }
916
+ }
744
917
  var PROVIDER_ENV = {
745
918
  anthropic: "ANTHROPIC_API_KEY",
746
919
  openai: "OPENAI_API_KEY",
@@ -795,7 +968,13 @@ function createDefaultClient() {
795
968
  }
796
969
  var anthropicConfigSchema = zod.z.object({
797
970
  model: zod.z.string().min(1),
798
- maxTokens: zod.z.number().int().positive()
971
+ maxTokens: zod.z.number().int().positive(),
972
+ /**
973
+ * Optional verbatra-imposed per-request timeout in milliseconds. A positive integer; when absent,
974
+ * the shared default request timeout applies. Bounds each outbound request so a hung-but-alive
975
+ * server cannot hold a locale's write lock open forever.
976
+ */
977
+ requestTimeoutMs: zod.z.number().int().positive().optional()
799
978
  });
800
979
  var SUBMIT_TOOL_NAME = "submit_translations";
801
980
  var SYSTEM_RULES = [
@@ -855,10 +1034,11 @@ function createAnthropicProvider(config, deps = {}) {
855
1034
  };
856
1035
  }
857
1036
  function createMechanism(client, config) {
1037
+ const timeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
858
1038
  return {
859
1039
  translate: async ({ payloadJson, signal }) => {
860
1040
  const body = buildRequest(config, payloadJson);
861
- const message = await callClient(client, body, signal);
1041
+ const message = await callClient(client, body, timeoutMs, signal);
862
1042
  assertNotTruncated(message.stop_reason === "max_tokens");
863
1043
  const raw = requireToolInput(message.content);
864
1044
  const usage = toUsage(message.usage);
@@ -866,10 +1046,11 @@ function createMechanism(client, config) {
866
1046
  }
867
1047
  };
868
1048
  }
869
- function callClient(client, body, signal) {
870
- return guardProviderCall(
871
- () => client.messages.create(body, signal !== void 0 ? { signal } : void 0),
872
- signal
1049
+ function callClient(client, body, timeoutMs, signal) {
1050
+ return withRequestTimeout(
1051
+ timeoutMs,
1052
+ signal,
1053
+ (requestSignal) => client.messages.create(body, { signal: requestSignal })
873
1054
  );
874
1055
  }
875
1056
  function toUsage(usage) {
@@ -883,7 +1064,14 @@ function toUsage(usage) {
883
1064
  return { inputTokens: input_tokens, outputTokens: output_tokens };
884
1065
  }
885
1066
  var deepLConfigSchema = zod.z.object({
886
- glossaryId: zod.z.string().min(1).optional()
1067
+ glossaryId: zod.z.string().min(1).optional(),
1068
+ /**
1069
+ * Optional verbatra-imposed per-request timeout in milliseconds. A positive integer; when absent,
1070
+ * the shared default request timeout applies. Bounds each outbound request so a hung-but-alive
1071
+ * server cannot hold a locale's write lock open forever. deepl-node cannot cancel an in-flight
1072
+ * request, so on timeout verbatra's await is released but the underlying request is left to settle.
1073
+ */
1074
+ requestTimeoutMs: zod.z.number().int().positive().optional()
887
1075
  });
888
1076
  var DEEPL_LOGGER = "deepl";
889
1077
  function resolveDeeplLoglevel(requireFn = module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)))) {
@@ -1054,6 +1242,7 @@ async function translate(bundle, config, request) {
1054
1242
  options,
1055
1243
  request.extractPlaceholders,
1056
1244
  request.comparePlaceholders,
1245
+ config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
1057
1246
  request.signal
1058
1247
  );
1059
1248
  if (unprotectable.length > 0) {
@@ -1088,7 +1277,7 @@ function buildReviewFlags2(protectable, data, values, integrity, glossary) {
1088
1277
  }
1089
1278
  return reviewFlags;
1090
1279
  }
1091
- async function translateProtectable(client, data, protectable, options, extract, compare, signal) {
1280
+ async function translateProtectable(client, data, protectable, options, extract, compare, timeoutMs, signal) {
1092
1281
  if (protectable.length === 0) {
1093
1282
  return { values: /* @__PURE__ */ new Map(), integrity: /* @__PURE__ */ new Map() };
1094
1283
  }
@@ -1099,29 +1288,45 @@ async function translateProtectable(client, data, protectable, options, extract,
1099
1288
  data.sourceLocale,
1100
1289
  data.targetLocale,
1101
1290
  options,
1291
+ timeoutMs,
1102
1292
  signal
1103
1293
  );
1104
1294
  const { values, integrityInputs } = zipResults(protectable, results);
1105
1295
  const integrity = checkBatchIntegrity(integrityInputs, extract, compare);
1106
1296
  return { values, integrity };
1107
1297
  }
1108
- function callClient2(client, texts, sourceLang, targetLang, options, signal) {
1109
- return guardProviderCall(
1110
- () => client.translateText(texts, sourceLang, targetLang, options),
1111
- signal
1298
+ function callClient2(client, texts, sourceLang, targetLang, options, timeoutMs, signal) {
1299
+ return withRequestTimeout(
1300
+ timeoutMs,
1301
+ signal,
1302
+ () => client.translateText(texts, sourceLang, targetLang, options)
1112
1303
  );
1113
1304
  }
1114
- async function callClientChunked(client, texts, sourceLang, targetLang, options, signal) {
1305
+ async function callClientChunked(client, texts, sourceLang, targetLang, options, timeoutMs, signal) {
1115
1306
  const results = [];
1116
1307
  for (const chunk2 of chunkTextsForDeepL(texts)) {
1117
- const chunkResults = await callClient2(client, chunk2, sourceLang, targetLang, options, signal);
1308
+ const chunkResults = await callClient2(
1309
+ client,
1310
+ chunk2,
1311
+ sourceLang,
1312
+ targetLang,
1313
+ options,
1314
+ timeoutMs,
1315
+ signal
1316
+ );
1118
1317
  results.push(...chunkResults);
1119
1318
  }
1120
1319
  return results;
1121
1320
  }
1122
1321
  var geminiConfigSchema = zod.z.object({
1123
1322
  model: zod.z.string().min(1),
1124
- maxOutputTokens: zod.z.number().int().positive()
1323
+ maxOutputTokens: zod.z.number().int().positive(),
1324
+ /**
1325
+ * Optional verbatra-imposed per-request timeout in milliseconds. A positive integer; when absent,
1326
+ * the shared default request timeout applies. Bounds each outbound request so a hung-but-alive
1327
+ * server cannot hold a locale's write lock open forever.
1328
+ */
1329
+ requestTimeoutMs: zod.z.number().int().positive().optional()
1125
1330
  });
1126
1331
  var DEFAULT_GEMINI_RETRY = { attempts: 3, baseDelayMs: 250 };
1127
1332
  function isRetryableStatus(error) {
@@ -1136,14 +1341,14 @@ function throwAbort(signal) {
1136
1341
  throw new DOMException("This operation was aborted.", "AbortError");
1137
1342
  }
1138
1343
  function delay(ms, signal) {
1139
- return new Promise((resolve9) => {
1344
+ return new Promise((resolve10) => {
1140
1345
  const onAbort = () => {
1141
1346
  clearTimeout(timer);
1142
- resolve9();
1347
+ resolve10();
1143
1348
  };
1144
1349
  const timer = setTimeout(() => {
1145
1350
  signal?.removeEventListener("abort", onAbort);
1146
- resolve9();
1351
+ resolve10();
1147
1352
  }, ms);
1148
1353
  signal?.addEventListener("abort", onAbort, { once: true });
1149
1354
  });
@@ -1311,20 +1516,27 @@ function createGeminiProvider(config, deps = {}) {
1311
1516
  };
1312
1517
  }
1313
1518
  function createMechanism2(client, config) {
1519
+ const timeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
1314
1520
  return {
1315
1521
  translate: async ({ payloadJson, signal }) => {
1316
- const request = buildGeminiRequest(config, payloadJson, signal);
1317
- const response = await callClient3(client, request, signal);
1522
+ const response = await withRequestTimeout(
1523
+ timeoutMs,
1524
+ signal,
1525
+ (requestSignal) => client.models.generateContent(buildGeminiRequest(config, payloadJson, requestSignal))
1526
+ );
1318
1527
  return extractGeminiResult(response);
1319
1528
  }
1320
1529
  };
1321
1530
  }
1322
- function callClient3(client, request, signal) {
1323
- return guardProviderCall(() => client.models.generateContent(request), signal);
1324
- }
1325
1531
  var openAiConfigSchema = zod.z.object({
1326
1532
  model: zod.z.string().min(1),
1327
- maxOutputTokens: zod.z.number().int().positive()
1533
+ maxOutputTokens: zod.z.number().int().positive(),
1534
+ /**
1535
+ * Optional verbatra-imposed per-request timeout in milliseconds. A positive integer; when absent,
1536
+ * the shared default request timeout applies. Bounds each outbound request so a hung-but-alive
1537
+ * server cannot hold a locale's write lock open forever.
1538
+ */
1539
+ requestTimeoutMs: zod.z.number().int().positive().optional()
1328
1540
  });
1329
1541
  function createDefaultClient4() {
1330
1542
  const sdk = new OpenAI__default.default({ apiKey: requireOpenAiKey(), logLevel: "off" });
@@ -1479,18 +1691,20 @@ function createOpenAiProvider(config, deps = {}) {
1479
1691
  };
1480
1692
  }
1481
1693
  function createMechanism3(client, config) {
1694
+ const timeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
1482
1695
  return {
1483
1696
  translate: async ({ payloadJson, signal }) => {
1484
1697
  const body = buildOpenAiRequest(config, payloadJson);
1485
- const completion = await callClient4(client, body, signal);
1698
+ const completion = await callClient3(client, body, timeoutMs, signal);
1486
1699
  return extractOpenAiResult(completion);
1487
1700
  }
1488
1701
  };
1489
1702
  }
1490
- function callClient4(client, body, signal) {
1491
- return guardProviderCall(
1492
- () => client.chat.completions.create(body, signal !== void 0 ? { signal } : void 0),
1493
- signal
1703
+ function callClient3(client, body, timeoutMs, signal) {
1704
+ return withRequestTimeout(
1705
+ timeoutMs,
1706
+ signal,
1707
+ (requestSignal) => client.chat.completions.create(body, { signal: requestSignal })
1494
1708
  );
1495
1709
  }
1496
1710
  var HOSTED_PROVIDER_ENV_VARS = new Set(
@@ -1513,7 +1727,13 @@ var openAiCompatibleConfigSchema = zod.z.object({
1513
1727
  maxOutputTokens: zod.z.number().int().positive(),
1514
1728
  apiKeyEnvVar: zod.z.string().min(1).refine(isNotHostedProviderEnvVar, {
1515
1729
  message: "apiKeyEnvVar must not name a hosted provider's environment variable."
1516
- }).optional()
1730
+ }).optional(),
1731
+ /**
1732
+ * Optional verbatra-imposed per-request timeout in milliseconds. A positive integer; when absent,
1733
+ * the shared default request timeout applies. Bounds each outbound request so a hung-but-alive local
1734
+ * server (the reported stuck-LM-Studio case) cannot hold a locale's write lock open forever.
1735
+ */
1736
+ requestTimeoutMs: zod.z.number().int().positive().optional()
1517
1737
  });
1518
1738
  function createDefaultClient5(config) {
1519
1739
  const sdk = new OpenAI__default.default({
@@ -1545,18 +1765,20 @@ function createOpenAiCompatibleProvider(config, deps = {}) {
1545
1765
  };
1546
1766
  }
1547
1767
  function createMechanism4(client, config) {
1768
+ const timeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
1548
1769
  return {
1549
1770
  translate: async ({ payloadJson, signal }) => {
1550
1771
  const body = buildOpenAiRequest(config, payloadJson, "strict-schema", "max_tokens");
1551
- const completion = await callClient5(client, body, signal);
1772
+ const completion = await callClient4(client, body, timeoutMs, signal);
1552
1773
  return extractOpenAiResult(completion, true);
1553
1774
  }
1554
1775
  };
1555
1776
  }
1556
- function callClient5(client, body, signal) {
1557
- return guardProviderCall(
1558
- () => client.chat.completions.create(body, signal !== void 0 ? { signal } : void 0),
1559
- signal
1777
+ function callClient4(client, body, timeoutMs, signal) {
1778
+ return withRequestTimeout(
1779
+ timeoutMs,
1780
+ signal,
1781
+ (requestSignal) => client.chat.completions.create(body, { signal: requestSignal })
1560
1782
  );
1561
1783
  }
1562
1784
  var SCAFFOLD_MODELS = {
@@ -1766,6 +1988,8 @@ var LOCAL_DIR_NAME = ".verbatra-local";
1766
1988
  var LOCK_FILE_GUARD_STEM = "_lockfile";
1767
1989
  var DEFAULT_POLL_INTERVAL_MS = 100;
1768
1990
  var DEFAULT_ACQUIRE_TIMEOUT_MS = 10 * 6e4;
1991
+ var WAIT_NOTICE_INTERVAL_MS = 1e3;
1992
+ var MAX_LOCK_PAYLOAD_BYTES = 64 * 1024;
1769
1993
  function lockPath(cwd, stem) {
1770
1994
  return path.resolve(cwd, LOCAL_DIR_NAME, "locks", `${stem}.lock`);
1771
1995
  }
@@ -1783,7 +2007,47 @@ function sleep(ms) {
1783
2007
  function lockPayload() {
1784
2008
  return JSON.stringify({ pid: process.pid, acquiredAt: (/* @__PURE__ */ new Date()).toISOString() });
1785
2009
  }
1786
- async function acquireLock(path, fs, pollIntervalMs, deadline) {
2010
+ function parseHolder(read) {
2011
+ if (read.kind !== "ok") {
2012
+ return void 0;
2013
+ }
2014
+ let parsed;
2015
+ try {
2016
+ parsed = JSON.parse(read.content);
2017
+ } catch {
2018
+ return void 0;
2019
+ }
2020
+ if (typeof parsed !== "object" || parsed === null) {
2021
+ return void 0;
2022
+ }
2023
+ const record = parsed;
2024
+ const holder = {};
2025
+ if (typeof record.pid === "number") {
2026
+ holder.pid = record.pid;
2027
+ }
2028
+ if (typeof record.acquiredAt === "string") {
2029
+ holder.acquiredAt = record.acquiredAt;
2030
+ }
2031
+ return holder;
2032
+ }
2033
+ function makeWaitNotifier(path, fs, onWait, start) {
2034
+ let holder;
2035
+ let holderRead = false;
2036
+ let lastEmit;
2037
+ return async () => {
2038
+ if (!holderRead) {
2039
+ holderRead = true;
2040
+ holder = parseHolder(await fs.readFileBounded(path, MAX_LOCK_PAYLOAD_BYTES));
2041
+ }
2042
+ const elapsedMs = Date.now() - start;
2043
+ if (lastEmit !== void 0 && elapsedMs - lastEmit < WAIT_NOTICE_INTERVAL_MS) {
2044
+ return;
2045
+ }
2046
+ lastEmit = elapsedMs;
2047
+ onWait({ lockPath: path, elapsedMs, ...holder !== void 0 ? { holder } : {} });
2048
+ };
2049
+ }
2050
+ async function acquireLock(path, fs, pollIntervalMs, deadline, notify) {
1787
2051
  for (; ; ) {
1788
2052
  if (await fs.createExclusive(path, lockPayload())) {
1789
2053
  return;
@@ -1794,6 +2058,9 @@ async function acquireLock(path, fs, pollIntervalMs, deadline) {
1794
2058
  `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.`
1795
2059
  );
1796
2060
  }
2061
+ if (notify !== void 0) {
2062
+ await notify();
2063
+ }
1797
2064
  const jitter = Math.random() * pollIntervalMs;
1798
2065
  await sleep(pollIntervalMs + jitter);
1799
2066
  }
@@ -1801,7 +2068,9 @@ async function acquireLock(path, fs, pollIntervalMs, deadline) {
1801
2068
  async function withFileLock(path, fs, fn, options) {
1802
2069
  const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
1803
2070
  const acquireTimeoutMs = options.acquireTimeoutMs ?? DEFAULT_ACQUIRE_TIMEOUT_MS;
1804
- await acquireLock(path, fs, pollIntervalMs, Date.now() + acquireTimeoutMs);
2071
+ const start = Date.now();
2072
+ const notify = options.onWait !== void 0 ? makeWaitNotifier(path, fs, options.onWait, start) : void 0;
2073
+ await acquireLock(path, fs, pollIntervalMs, start + acquireTimeoutMs, notify);
1805
2074
  try {
1806
2075
  return await fn();
1807
2076
  } finally {
@@ -1817,8 +2086,8 @@ async function withLockFileGuard(cwd, fs, fn, options = {}) {
1817
2086
 
1818
2087
  // src/lock/lock-file.ts
1819
2088
  var LOCK_FILE_NAME = "verbatra.lock.json";
1820
- var CURRENT_VERSION = 1;
1821
- var EMPTY_LOCK = { version: CURRENT_VERSION, locales: {} };
2089
+ var CURRENT_VERSION2 = 1;
2090
+ var EMPTY_LOCK = { version: CURRENT_VERSION2, locales: {} };
1822
2091
  var MAX_LOCK_FILE_BYTES = 16 * 1024 * 1024;
1823
2092
  var lockFileSchema = zod.z.object({
1824
2093
  version: zod.z.number().int().positive(),
@@ -1847,10 +2116,10 @@ function parseLockFileRead(read, path) {
1847
2116
  if (!result.success) {
1848
2117
  throw new SdkError("LOCK_FILE_INVALID", `The lock-file at ${path} has an unexpected shape.`);
1849
2118
  }
1850
- if (result.data.version !== CURRENT_VERSION) {
2119
+ if (result.data.version !== CURRENT_VERSION2) {
1851
2120
  throw new SdkError(
1852
2121
  "LOCK_FILE_INVALID",
1853
- `The lock-file at ${path} has version ${result.data.version}, but this version of verbatra supports version ${CURRENT_VERSION}.`
2122
+ `The lock-file at ${path} has version ${result.data.version}, but this version of verbatra supports version ${CURRENT_VERSION2}.`
1854
2123
  );
1855
2124
  }
1856
2125
  return result.data;
@@ -2640,7 +2909,7 @@ function createTreeFileAdapter(options) {
2640
2909
  extensions,
2641
2910
  sniff,
2642
2911
  parse: parse3,
2643
- serialize,
2912
+ serialize: serialize2,
2644
2913
  deriveEntry,
2645
2914
  extractPlaceholders,
2646
2915
  computeInvalidIcuKeys,
@@ -2675,7 +2944,7 @@ function createTreeFileAdapter(options) {
2675
2944
  },
2676
2945
  async write(resource, filePath) {
2677
2946
  const tree = buildWriteTree ? await buildWriteTree(resource.entries, filePath) : unflattenEntries(resource.entries);
2678
- await atomicWriteFile(filePath, serialize(tree));
2947
+ await atomicWriteFile(filePath, serialize2(tree));
2679
2948
  }
2680
2949
  };
2681
2950
  }
@@ -2894,42 +3163,429 @@ async function detectStyle(filePath) {
2894
3163
  if (outcome.kind !== "ok") {
2895
3164
  return "nested";
2896
3165
  }
2897
- parsed = JSON.parse(outcome.content);
2898
- } catch {
2899
- return "nested";
3166
+ parsed = JSON.parse(outcome.content);
3167
+ } catch {
3168
+ return "nested";
3169
+ }
3170
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
3171
+ return "nested";
3172
+ }
3173
+ for (const value of Object.values(parsed)) {
3174
+ if (typeof value === "object" && value !== null) {
3175
+ return "nested";
3176
+ }
3177
+ }
3178
+ return "flat";
3179
+ }
3180
+ function buildFlatTree(entries) {
3181
+ const out = /* @__PURE__ */ new Map();
3182
+ for (const [key, entry] of entries) {
3183
+ out.set(key, entry.value);
3184
+ }
3185
+ return out;
3186
+ }
3187
+ async function buildNgxWriteTree(entries, filePath) {
3188
+ const style = await detectStyle(filePath);
3189
+ return style === "flat" ? buildFlatTree(entries) : unflattenEntries(entries);
3190
+ }
3191
+ function createNgxTranslateJsonAdapter() {
3192
+ return createJsonFileAdapter({
3193
+ format: "ngx-translate-json",
3194
+ extractPlaceholders: extractDoubleBracePlaceholders,
3195
+ deriveEntry: (_key, value) => ({
3196
+ placeholders: extractDoubleBracePlaceholders(value),
3197
+ isPlural: false
3198
+ }),
3199
+ validateTree: assertNotMixed,
3200
+ buildWriteTree: buildNgxWriteTree,
3201
+ keyMode: "path-notation"
3202
+ });
3203
+ }
3204
+ function toEntries2(content, namespace, parseEntries) {
3205
+ try {
3206
+ return parseEntries(content, namespace);
3207
+ } catch (error) {
3208
+ rethrowStructured(error, "The file could not be parsed.");
3209
+ }
3210
+ }
3211
+ function createFlatFileAdapter(options) {
3212
+ const {
3213
+ format,
3214
+ extensions,
3215
+ sniff,
3216
+ parseEntries,
3217
+ serializeEntries,
3218
+ extractPlaceholders,
3219
+ validateMessage,
3220
+ computeInvalidIcuKeys
3221
+ } = options;
3222
+ return {
3223
+ format,
3224
+ canHandle: buildCanHandle(extensions, sniff),
3225
+ extractPlaceholders,
3226
+ validateMessage: validateMessage ?? (() => true),
3227
+ async read(filePath, locale) {
3228
+ const content = await readFileContent(filePath);
3229
+ const namespace = namespaceOf(filePath);
3230
+ const entries = toEntries2(content, namespace, parseEntries);
3231
+ const resource = { locale, namespace, format, entries };
3232
+ const invalidIcuKeys = computeIcu(entries, computeInvalidIcuKeys);
3233
+ return { resource, invalidIcuKeys, excludedLeafPaths: [] };
3234
+ },
3235
+ async write(resource, filePath) {
3236
+ const data = await serializeEntries(resource.entries, filePath);
3237
+ await atomicWriteFile(filePath, data);
3238
+ }
3239
+ };
3240
+ }
3241
+ var SUBMESSAGE_TYPES = /* @__PURE__ */ new Set(["plural", "select", "selectordinal", "choice"]);
3242
+ var ARGUMENT_NAME = /^(?:\d+|[A-Za-z_$][\w$-]*)$/;
3243
+ function matchingBraces(value) {
3244
+ const close = new Int32Array(value.length).fill(-1);
3245
+ const open3 = [];
3246
+ for (let i = 0; i < value.length; i += 1) {
3247
+ const char = value[i];
3248
+ if (char === "{") {
3249
+ open3.push(i);
3250
+ } else if (char === "}") {
3251
+ const start = open3.pop();
3252
+ if (start !== void 0) {
3253
+ close[start] = i;
3254
+ }
3255
+ }
3256
+ }
3257
+ return close;
3258
+ }
3259
+ function findNameEnd(value, from, limit) {
3260
+ for (let i = from; i < limit; i += 1) {
3261
+ const char = value[i];
3262
+ if (char === ",") {
3263
+ return { comma: i, hasBrace: false };
3264
+ }
3265
+ if (char === "{" || char === "}") {
3266
+ return { comma: -1, hasBrace: true };
3267
+ }
3268
+ }
3269
+ return { comma: -1, hasBrace: false };
3270
+ }
3271
+ function parseArgumentAt(value, open3, close) {
3272
+ const { comma, hasBrace } = findNameEnd(value, open3 + 1, close);
3273
+ if (hasBrace) {
3274
+ return null;
3275
+ }
3276
+ const nameEnd = comma === -1 ? close : comma;
3277
+ const name = value.slice(open3 + 1, nameEnd).trim();
3278
+ if (!ARGUMENT_NAME.test(name)) {
3279
+ return null;
3280
+ }
3281
+ if (comma === -1) {
3282
+ return { name, type: null, styleStart: -1 };
3283
+ }
3284
+ const secondComma = value.indexOf(",", comma + 1);
3285
+ if (secondComma === -1 || secondComma >= close) {
3286
+ return { name, type: value.slice(comma + 1, close).trim(), styleStart: -1 };
3287
+ }
3288
+ return { name, type: value.slice(comma + 1, secondComma).trim(), styleStart: secondComma + 1 };
3289
+ }
3290
+ function canonicalToken(value, arg, close) {
3291
+ if (arg.type === null) {
3292
+ return `{${arg.name}}`;
3293
+ }
3294
+ if (arg.styleStart === -1) {
3295
+ return `{${arg.name},${arg.type}}`;
3296
+ }
3297
+ return `{${arg.name},${arg.type},${value.slice(arg.styleStart, close).trim()}}`;
3298
+ }
3299
+ function emitArgument(value, close, arg, closeIndex, out) {
3300
+ if (arg.type !== null && SUBMESSAGE_TYPES.has(arg.type)) {
3301
+ out.push(`{${arg.name},${arg.type}}`);
3302
+ if (arg.styleStart !== -1) {
3303
+ scanRange(value, close, arg.styleStart, closeIndex, false, out);
3304
+ }
3305
+ return;
3306
+ }
3307
+ out.push(canonicalToken(value, arg, closeIndex));
3308
+ }
3309
+ function scanRange(value, close, start, end, topLevel, out) {
3310
+ let i = start;
3311
+ while (i < end) {
3312
+ if (value[i] !== "{") {
3313
+ i += 1;
3314
+ continue;
3315
+ }
3316
+ if (topLevel && value[i + 1] === "{") {
3317
+ i += 2;
3318
+ continue;
3319
+ }
3320
+ const closeIndex = close[i] ?? -1;
3321
+ if (closeIndex === -1 || closeIndex >= end) {
3322
+ i += 1;
3323
+ continue;
3324
+ }
3325
+ const arg = parseArgumentAt(value, i, closeIndex);
3326
+ if (arg === null) {
3327
+ i += 1;
3328
+ continue;
3329
+ }
3330
+ emitArgument(value, close, arg, closeIndex, out);
3331
+ i = closeIndex + 1;
3332
+ }
3333
+ }
3334
+ function extractPropertiesPlaceholders(value) {
3335
+ const out = [];
3336
+ scanRange(value, matchingBraces(value), 0, value.length, true, out);
3337
+ return out;
3338
+ }
3339
+ var UNICODE_ESCAPE = /^[0-9a-fA-F]{4}$/;
3340
+ var LEADING_WHITESPACE = /^[ \t\f]+/;
3341
+ var TRAILING_TERMINATOR = /(?:\r\n|\r|\n)$/;
3342
+ function isPropertiesWhitespace(char) {
3343
+ return char === " " || char === " " || char === "\f";
3344
+ }
3345
+ function splitPhysicalLines(content) {
3346
+ if (content === "") {
3347
+ return [];
3348
+ }
3349
+ const lines = content.split(/\r\n|\r|\n/);
3350
+ if (TRAILING_TERMINATOR.test(content)) {
3351
+ lines.pop();
3352
+ }
3353
+ return lines;
3354
+ }
3355
+ function countTrailingBackslashes(line) {
3356
+ let count = 0;
3357
+ for (let i = line.length - 1; i >= 0 && line[i] === "\\"; i -= 1) {
3358
+ count += 1;
3359
+ }
3360
+ return count;
3361
+ }
3362
+ function isContinued(line) {
3363
+ return countTrailingBackslashes(line) % 2 === 1;
3364
+ }
3365
+ function isBlankLine(line) {
3366
+ return line.trim() === "";
3367
+ }
3368
+ function isCommentLine(line) {
3369
+ const trimmed = line.replace(LEADING_WHITESPACE, "");
3370
+ return trimmed.startsWith("#") || trimmed.startsWith("!");
3371
+ }
3372
+ function joinContinuation(lines, start) {
3373
+ const pieces = [];
3374
+ let index = start;
3375
+ let current = lines[start] ?? "";
3376
+ while (isContinued(current) && index + 1 < lines.length) {
3377
+ pieces.push(current.slice(0, -1));
3378
+ index += 1;
3379
+ current = (lines[index] ?? "").replace(LEADING_WHITESPACE, "");
3380
+ }
3381
+ if (isContinued(current)) {
3382
+ current = current.slice(0, -1);
3383
+ }
3384
+ pieces.push(current);
3385
+ return { logical: pieces.join(""), nextIndex: index + 1 };
3386
+ }
3387
+ function decodeSimpleEscape(char) {
3388
+ switch (char) {
3389
+ case "t":
3390
+ return " ";
3391
+ case "n":
3392
+ return "\n";
3393
+ case "r":
3394
+ return "\r";
3395
+ case "f":
3396
+ return "\f";
3397
+ default:
3398
+ return char;
3399
+ }
3400
+ }
3401
+ function decodeUnicodeEscape(raw, at) {
3402
+ const hex = raw.slice(at, at + 4);
3403
+ if (!UNICODE_ESCAPE.test(hex)) {
3404
+ throw new AdapterError("INVALID_STRUCTURE", "The file has a malformed unicode escape.");
3405
+ }
3406
+ return String.fromCharCode(Number.parseInt(hex, 16));
3407
+ }
3408
+ function decodeEscapes(raw) {
3409
+ let out = "";
3410
+ let i = 0;
3411
+ while (i < raw.length) {
3412
+ const char = raw[i];
3413
+ if (char !== "\\") {
3414
+ out += char;
3415
+ i += 1;
3416
+ continue;
3417
+ }
3418
+ const next = raw[i + 1];
3419
+ if (next === void 0) {
3420
+ break;
3421
+ }
3422
+ if (next === "u") {
3423
+ out += decodeUnicodeEscape(raw, i + 2);
3424
+ i += 6;
3425
+ continue;
3426
+ }
3427
+ out += decodeSimpleEscape(next);
3428
+ i += 2;
3429
+ }
3430
+ return out;
3431
+ }
3432
+ function keyEndIndex(logical, from) {
3433
+ let i = from;
3434
+ while (i < logical.length) {
3435
+ const char = logical[i];
3436
+ if (char === "\\") {
3437
+ i += 2;
3438
+ continue;
3439
+ }
3440
+ if (char === void 0 || isPropertiesWhitespace(char) || char === "=" || char === ":") {
3441
+ break;
3442
+ }
3443
+ i += 1;
3444
+ }
3445
+ return i;
3446
+ }
3447
+ function skipWhitespace(logical, from) {
3448
+ let i = from;
3449
+ while (i < logical.length) {
3450
+ const char = logical[i];
3451
+ if (char === void 0 || !isPropertiesWhitespace(char)) {
3452
+ break;
3453
+ }
3454
+ i += 1;
3455
+ }
3456
+ return i;
3457
+ }
3458
+ function parseEntryLine(logical) {
3459
+ const keyStart = skipWhitespace(logical, 0);
3460
+ const keyEnd = keyEndIndex(logical, keyStart);
3461
+ const afterKey = skipWhitespace(logical, keyEnd);
3462
+ const separator = logical[afterKey];
3463
+ const valueStart = separator === "=" || separator === ":" ? skipWhitespace(logical, afterKey + 1) : afterKey;
3464
+ return {
3465
+ key: decodeEscapes(logical.slice(keyStart, keyEnd)),
3466
+ value: decodeEscapes(logical.slice(valueStart))
3467
+ };
3468
+ }
3469
+ function parseItems(content) {
3470
+ const lines = splitPhysicalLines(content);
3471
+ const items = [];
3472
+ let i = 0;
3473
+ while (i < lines.length) {
3474
+ const line = lines[i] ?? "";
3475
+ if (isBlankLine(line) || isCommentLine(line)) {
3476
+ items.push({ kind: "raw", text: line });
3477
+ i += 1;
3478
+ continue;
3479
+ }
3480
+ const { logical, nextIndex } = joinContinuation(lines, i);
3481
+ items.push({ kind: "entry", ...parseEntryLine(logical) });
3482
+ i = nextIndex;
3483
+ }
3484
+ return items;
3485
+ }
3486
+ function parsePropertiesEntries(content, namespace) {
3487
+ const map = /* @__PURE__ */ new Map();
3488
+ for (const item of parseItems(content)) {
3489
+ if (item.kind === "entry") {
3490
+ map.set(item.key, {
3491
+ key: item.key,
3492
+ namespace,
3493
+ value: item.value,
3494
+ placeholders: extractPropertiesPlaceholders(item.value),
3495
+ isPlural: false
3496
+ });
3497
+ }
3498
+ }
3499
+ return map;
3500
+ }
3501
+ function unicodeEscape(code) {
3502
+ return `\\u${code.toString(16).toUpperCase().padStart(4, "0")}`;
3503
+ }
3504
+ function escapeChar(char, isFirst, isKey) {
3505
+ switch (char) {
3506
+ case "\\":
3507
+ return "\\\\";
3508
+ case " ":
3509
+ return "\\t";
3510
+ case "\n":
3511
+ return "\\n";
3512
+ case "\r":
3513
+ return "\\r";
3514
+ case "\f":
3515
+ return "\\f";
3516
+ case " ":
3517
+ return isKey || isFirst ? "\\ " : " ";
3518
+ }
3519
+ if (isKey && (char === "=" || char === ":" || char === "#" || char === "!")) {
3520
+ return `\\${char}`;
3521
+ }
3522
+ const code = char.charCodeAt(0);
3523
+ return code < 32 || code > 126 ? unicodeEscape(code) : char;
3524
+ }
3525
+ function escapeString(input, isKey) {
3526
+ let out = "";
3527
+ for (let i = 0; i < input.length; i += 1) {
3528
+ const char = input[i];
3529
+ if (char !== void 0) {
3530
+ out += escapeChar(char, i === 0, isKey);
3531
+ }
3532
+ }
3533
+ return out;
3534
+ }
3535
+ function formatEntry(key, value) {
3536
+ return `${escapeString(key, true)}=${escapeString(value, false)}`;
3537
+ }
3538
+ function isFileNotFound(error) {
3539
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
3540
+ }
3541
+ async function readStructure(filePath) {
3542
+ let outcome;
3543
+ try {
3544
+ outcome = await readBounded2(filePath);
3545
+ } catch (error) {
3546
+ if (isFileNotFound(error)) {
3547
+ return [];
3548
+ }
3549
+ throw new AdapterError("INVALID_STRUCTURE", "The destination file could not be read.");
2900
3550
  }
2901
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
2902
- return "nested";
3551
+ if (outcome.kind === "not-a-file") {
3552
+ throw new AdapterError("INVALID_STRUCTURE", "The destination path is not a regular file.");
2903
3553
  }
2904
- for (const value of Object.values(parsed)) {
2905
- if (typeof value === "object" && value !== null) {
2906
- return "nested";
2907
- }
3554
+ if (outcome.kind === "too-large") {
3555
+ throw new AdapterError("INPUT_TOO_LARGE", "The file exceeds the maximum allowed size.");
2908
3556
  }
2909
- return "flat";
3557
+ return parseItems(outcome.content);
2910
3558
  }
2911
- function buildFlatTree(entries) {
2912
- const out = /* @__PURE__ */ new Map();
3559
+ async function serializePropertiesEntries(entries, filePath) {
3560
+ const structure = await readStructure(filePath);
3561
+ const emitted = /* @__PURE__ */ new Set();
3562
+ const lines = [];
3563
+ for (const item of structure) {
3564
+ if (item.kind === "raw") {
3565
+ lines.push(item.text);
3566
+ continue;
3567
+ }
3568
+ const entry = entries.get(item.key);
3569
+ if (entry !== void 0 && !emitted.has(item.key)) {
3570
+ lines.push(formatEntry(item.key, entry.value));
3571
+ emitted.add(item.key);
3572
+ }
3573
+ }
2913
3574
  for (const [key, entry] of entries) {
2914
- out.set(key, entry.value);
3575
+ if (!emitted.has(key)) {
3576
+ lines.push(formatEntry(key, entry.value));
3577
+ }
2915
3578
  }
2916
- return out;
2917
- }
2918
- async function buildNgxWriteTree(entries, filePath) {
2919
- const style = await detectStyle(filePath);
2920
- return style === "flat" ? buildFlatTree(entries) : unflattenEntries(entries);
3579
+ return lines.length === 0 ? "" : `${lines.join("\n")}
3580
+ `;
2921
3581
  }
2922
- function createNgxTranslateJsonAdapter() {
2923
- return createJsonFileAdapter({
2924
- format: "ngx-translate-json",
2925
- extractPlaceholders: extractDoubleBracePlaceholders,
2926
- deriveEntry: (_key, value) => ({
2927
- placeholders: extractDoubleBracePlaceholders(value),
2928
- isPlural: false
2929
- }),
2930
- validateTree: assertNotMixed,
2931
- buildWriteTree: buildNgxWriteTree,
2932
- keyMode: "path-notation"
3582
+ function createPropertiesAdapter() {
3583
+ return createFlatFileAdapter({
3584
+ format: "properties",
3585
+ extensions: [".properties"],
3586
+ parseEntries: parsePropertiesEntries,
3587
+ serializeEntries: serializePropertiesEntries,
3588
+ extractPlaceholders: extractPropertiesPlaceholders
2933
3589
  });
2934
3590
  }
2935
3591
  var AdapterRegistry = class {
@@ -3009,43 +3665,6 @@ function createVueI18nJsonAdapter() {
3009
3665
  })
3010
3666
  });
3011
3667
  }
3012
- function toEntries2(content, namespace, parseEntries) {
3013
- try {
3014
- return parseEntries(content, namespace);
3015
- } catch (error) {
3016
- rethrowStructured(error, "The file could not be parsed.");
3017
- }
3018
- }
3019
- function createFlatFileAdapter(options) {
3020
- const {
3021
- format,
3022
- extensions,
3023
- sniff,
3024
- parseEntries,
3025
- serializeEntries,
3026
- extractPlaceholders,
3027
- validateMessage,
3028
- computeInvalidIcuKeys
3029
- } = options;
3030
- return {
3031
- format,
3032
- canHandle: buildCanHandle(extensions, sniff),
3033
- extractPlaceholders,
3034
- validateMessage: validateMessage ?? (() => true),
3035
- async read(filePath, locale) {
3036
- const content = await readFileContent(filePath);
3037
- const namespace = namespaceOf(filePath);
3038
- const entries = toEntries2(content, namespace, parseEntries);
3039
- const resource = { locale, namespace, format, entries };
3040
- const invalidIcuKeys = computeIcu(entries, computeInvalidIcuKeys);
3041
- return { resource, invalidIcuKeys, excludedLeafPaths: [] };
3042
- },
3043
- async write(resource, filePath) {
3044
- const data = await serializeEntries(resource.entries, filePath);
3045
- await atomicWriteFile(filePath, data);
3046
- }
3047
- };
3048
- }
3049
3668
  var XLIFF_PATTERN = /<(?:x|g|bx|ex|ph|it|mrk)\b[^>]*>|\{[^{}]+\}/g;
3050
3669
  function extractXliffPlaceholders(value) {
3051
3670
  const result = [];
@@ -3356,7 +3975,7 @@ function createYamlAdapter() {
3356
3975
  });
3357
3976
  }
3358
3977
  function createDefaultRegistry() {
3359
- return new AdapterRegistry().register(createI18nextJsonAdapter()).register(createVueI18nJsonAdapter()).register(createNextIntlJsonAdapter()).register(createNgxTranslateJsonAdapter()).register(createXliffAdapter()).register(createYamlAdapter()).register(createArbAdapter());
3978
+ return new AdapterRegistry().register(createI18nextJsonAdapter()).register(createVueI18nJsonAdapter()).register(createNextIntlJsonAdapter()).register(createNgxTranslateJsonAdapter()).register(createXliffAdapter()).register(createYamlAdapter()).register(createArbAdapter()).register(createPropertiesAdapter());
3360
3979
  }
3361
3980
 
3362
3981
  // src/selection/select-adapter.ts
@@ -3477,6 +4096,28 @@ async function diff(input, deps = {}) {
3477
4096
  return { hasPendingChanges: locales.some((entry) => entry.hasPendingChanges), locales };
3478
4097
  }
3479
4098
 
4099
+ // src/cache/fingerprint.ts
4100
+ function fingerprintModel(provider) {
4101
+ const options = provider.options;
4102
+ const model = options.model;
4103
+ return typeof model === "string" ? model : null;
4104
+ }
4105
+ function sortGlossary(glossary) {
4106
+ if (glossary === void 0) {
4107
+ return {};
4108
+ }
4109
+ return Object.fromEntries(Object.entries(glossary).sort(([a], [b]) => a < b ? -1 : 1));
4110
+ }
4111
+ function computeFingerprint(config) {
4112
+ const canonical = JSON.stringify({
4113
+ provider: config.provider.id,
4114
+ model: fingerprintModel(config.provider),
4115
+ tone: config.tone ?? null,
4116
+ glossary: sortGlossary(config.glossary)
4117
+ });
4118
+ return stableStringHash(canonical);
4119
+ }
4120
+
3480
4121
  // src/flow/integrity-gate.ts
3481
4122
  function gateCandidateValue(sourceEntry, candidateValue, adapter) {
3482
4123
  const placeholderResult = adapter.comparePlaceholders?.(sourceEntry.value, candidateValue) ?? checkPlaceholders(sourceEntry.placeholders, adapter.extractPlaceholders(candidateValue));
@@ -3486,6 +4127,9 @@ function gateCandidateValue(sourceEntry, candidateValue, adapter) {
3486
4127
  if (!adapter.validateMessage(candidateValue)) {
3487
4128
  return { accepted: false, reason: "icu" };
3488
4129
  }
4130
+ if (assessValueDegeneracy(sourceEntry.value, candidateValue).degenerate) {
4131
+ return { accepted: false, reason: "degenerate" };
4132
+ }
3489
4133
  return { accepted: true };
3490
4134
  }
3491
4135
 
@@ -3524,6 +4168,12 @@ async function editEntry(input, deps = {}) {
3524
4168
  mode: "merge",
3525
4169
  entries: { [input.key]: contentHash(sourceEntry) }
3526
4170
  });
4171
+ await feedTranslationMemory(
4172
+ cwd,
4173
+ fs,
4174
+ computeFingerprint(config),
4175
+ /* @__PURE__ */ new Map([[locale, { [contentHash(sourceEntry)]: input.value }]])
4176
+ );
3527
4177
  return { accepted: true, value: input.value };
3528
4178
  });
3529
4179
  }
@@ -3741,13 +4391,19 @@ async function retranslateEntry(input, deps = {}) {
3741
4391
  mode: "merge",
3742
4392
  entries: { [input.key]: contentHash(sourceEntry) }
3743
4393
  });
4394
+ await feedTranslationMemory(
4395
+ cwd,
4396
+ fs,
4397
+ computeFingerprint(config),
4398
+ /* @__PURE__ */ new Map([[locale, { [contentHash(sourceEntry)]: value }]])
4399
+ );
3744
4400
  const reviewReasons = result.reviewFlags?.get(input.key)?.reasons ?? [];
3745
4401
  return { accepted: true, value, reviewReasons };
3746
4402
  });
3747
4403
  }
3748
4404
  var RUN_STATUS_DIR_NAME = ".verbatra-local";
3749
4405
  var RUN_STATUS_FILE_NAME = "run-status.json";
3750
- var CURRENT_VERSION2 = 1;
4406
+ var CURRENT_VERSION3 = 1;
3751
4407
  var MAX_RUN_STATUS_FILE_BYTES = 16 * 1024 * 1024;
3752
4408
  var reviewReasonCodeSchema = zod.z.enum([
3753
4409
  "LENGTH_RATIO_OUTLIER",
@@ -3773,7 +4429,7 @@ var runBudgetSchema = zod.z.object({
3773
4429
  });
3774
4430
  var runStatusLocaleSchema = zod.z.object({
3775
4431
  locale: zod.z.string(),
3776
- status: zod.z.enum(["succeeded", "failed"]),
4432
+ status: zod.z.enum(["succeeded", "partial", "failed"]),
3777
4433
  needsReview: zod.z.array(needsReviewEntrySchema),
3778
4434
  usage: usageSummarySchema.optional()
3779
4435
  });
@@ -3797,7 +4453,7 @@ function toRunStatusLocale(locale) {
3797
4453
  }
3798
4454
  function buildRunStatusFile(summary, generatedAt = (/* @__PURE__ */ new Date()).toISOString()) {
3799
4455
  return {
3800
- version: CURRENT_VERSION2,
4456
+ version: CURRENT_VERSION3,
3801
4457
  generatedAt,
3802
4458
  ...summary.usage !== void 0 ? { usage: summary.usage } : {},
3803
4459
  ...summary.budget !== void 0 ? { budget: summary.budget } : {},
@@ -3830,7 +4486,7 @@ async function readRunStatusFile(path, fs) {
3830
4486
  return void 0;
3831
4487
  }
3832
4488
  const result = runStatusFileSchema.safeParse(parsed);
3833
- if (!result.success || result.data.version !== CURRENT_VERSION2) {
4489
+ if (!result.success || result.data.version !== CURRENT_VERSION3) {
3834
4490
  return void 0;
3835
4491
  }
3836
4492
  return fromParsed(result.data);
@@ -3909,19 +4565,34 @@ function failureSummary(locale, error) {
3909
4565
  orphaned: [],
3910
4566
  pruned: [],
3911
4567
  invalidIcuSource: [],
4568
+ cacheHits: [],
3912
4569
  integrityMismatches: [],
3913
4570
  providerFailures: [],
3914
4571
  budgetWithheld: [],
3915
4572
  generated: [],
3916
4573
  notices: [],
3917
4574
  needsReview: [],
4575
+ unfilled: [],
4576
+ malformedRows: [],
4577
+ duplicateKeys: [],
3918
4578
  error: describeError(error)
3919
4579
  };
3920
4580
  }
4581
+ function deriveLocaleStatus(parts) {
4582
+ const withheld = parts.integrityMismatches.length > 0 || parts.providerFailures.length > 0 || parts.budgetWithheld.length > 0;
4583
+ if (!withheld) {
4584
+ return "succeeded";
4585
+ }
4586
+ const accepted = parts.translated.length > 0 || parts.cacheHits.length > 0 || parts.generated.length > 0;
4587
+ return accepted ? "partial" : "failed";
4588
+ }
3921
4589
  function partition(locales) {
3922
- const succeeded = locales.filter((s) => s.status === "succeeded").map((s) => s.locale);
3923
- const failed = locales.filter((s) => s.status === "failed").map((s) => s.locale);
3924
- return { succeeded, failed };
4590
+ const namesWith = (status) => locales.filter((s) => s.status === status).map((s) => s.locale);
4591
+ return {
4592
+ succeeded: namesWith("succeeded"),
4593
+ partial: namesWith("partial"),
4594
+ failed: namesWith("failed")
4595
+ };
3925
4596
  }
3926
4597
 
3927
4598
  // src/flow/batching.ts
@@ -4232,6 +4903,97 @@ function foldGenerationItem(item, result, adapter, accepted, withheld, providerF
4232
4903
  }
4233
4904
 
4234
4905
  // src/flow/locale-run.ts
4906
+ function partitionCacheHits(params, toTranslate) {
4907
+ const cache = params.cache;
4908
+ const hits = /* @__PURE__ */ new Map();
4909
+ if (cache === void 0) {
4910
+ return { hits, misses: toTranslate };
4911
+ }
4912
+ const misses = [];
4913
+ for (const key of toTranslate) {
4914
+ const source = params.source.entries.get(key);
4915
+ if (source === void 0) {
4916
+ continue;
4917
+ }
4918
+ const cached = lookupMemory(
4919
+ cache.snapshot,
4920
+ cache.fingerprint,
4921
+ params.targetLocale,
4922
+ contentHash(source)
4923
+ );
4924
+ if (cached !== void 0 && gateCandidateValue(source, cached, params.adapter).accepted) {
4925
+ hits.set(key, { value: cached, source });
4926
+ } else {
4927
+ misses.push(key);
4928
+ }
4929
+ }
4930
+ return { hits, misses };
4931
+ }
4932
+ function collectCacheAdditions(params, accepted, cacheHitKeys) {
4933
+ if (params.cache === void 0) {
4934
+ return [];
4935
+ }
4936
+ const additions = [];
4937
+ for (const [key, entry] of accepted) {
4938
+ if (!cacheHitKeys.has(key)) {
4939
+ additions.push({ contentHash: contentHash(entry.source), value: entry.value });
4940
+ }
4941
+ }
4942
+ return additions;
4943
+ }
4944
+ function groupMissesByContent(params, misses) {
4945
+ const byHash = /* @__PURE__ */ new Map();
4946
+ for (const key of misses) {
4947
+ const source = params.source.entries.get(key);
4948
+ if (source === void 0) {
4949
+ continue;
4950
+ }
4951
+ const existing = byHash.get(contentHash(source));
4952
+ if (existing === void 0) {
4953
+ byHash.set(contentHash(source), { representative: key, duplicates: [] });
4954
+ } else {
4955
+ existing.duplicates.push(key);
4956
+ }
4957
+ }
4958
+ return [...byHash.values()];
4959
+ }
4960
+ function fanOutContentDuplicates(params, groups, outcome) {
4961
+ for (const group of groups) {
4962
+ if (group.duplicates.length > 0) {
4963
+ applyGroupOutcome(params, group, outcome);
4964
+ }
4965
+ }
4966
+ }
4967
+ function applyGroupOutcome(params, group, outcome) {
4968
+ const acceptedRepresentative = outcome.accepted.get(group.representative);
4969
+ if (acceptedRepresentative !== void 0) {
4970
+ fanOutAccepted(params, group, acceptedRepresentative, outcome);
4971
+ return;
4972
+ }
4973
+ withheldBucketFor(group.representative, outcome).push(...group.duplicates);
4974
+ }
4975
+ function fanOutAccepted(params, group, acceptedRepresentative, outcome) {
4976
+ const flag = outcome.reviewFlags.get(group.representative);
4977
+ for (const key of group.duplicates) {
4978
+ const source = params.source.entries.get(key);
4979
+ if (source === void 0) {
4980
+ continue;
4981
+ }
4982
+ outcome.accepted.set(key, { value: acceptedRepresentative.value, source });
4983
+ if (flag !== void 0) {
4984
+ outcome.reviewFlags.set(key, flag);
4985
+ }
4986
+ }
4987
+ }
4988
+ function withheldBucketFor(representative, outcome) {
4989
+ if (outcome.integrityMismatches.includes(representative)) {
4990
+ return outcome.integrityMismatches;
4991
+ }
4992
+ if (outcome.budgetWithheld.includes(representative)) {
4993
+ return outcome.budgetWithheld;
4994
+ }
4995
+ return outcome.providerFailures;
4996
+ }
4235
4997
  function buildRequest3(params, entries) {
4236
4998
  return {
4237
4999
  sourceLocale: params.sourceLocale,
@@ -4274,6 +5036,7 @@ async function runLocale(params) {
4274
5036
  orphaned,
4275
5037
  invalidIcuSource,
4276
5038
  translated: toTranslate,
5039
+ cacheHits: [],
4277
5040
  generated: [],
4278
5041
  integrityMismatches: [],
4279
5042
  providerFailures: [],
@@ -4281,12 +5044,16 @@ async function runLocale(params) {
4281
5044
  pruned,
4282
5045
  notices: sdkNotices
4283
5046
  }),
4284
- lockEntries: {}
5047
+ lockEntries: {},
5048
+ cacheAdditions: []
4285
5049
  };
4286
5050
  }
4287
- const entries = toTranslate.map((key) => params.source.entries.get(key)).filter((entry) => entry !== void 0);
5051
+ const partition2 = partitionCacheHits(params, toTranslate);
5052
+ const cacheHitKeys = new Set(partition2.hits.keys());
5053
+ const missGroups = groupMissesByContent(params, partition2.misses);
5054
+ const entries = missGroups.map((group) => params.source.entries.get(group.representative)).filter((entry) => entry !== void 0);
4288
5055
  const startedStopped = params.budget.stopped;
4289
- const accepted = /* @__PURE__ */ new Map();
5056
+ const accepted = new Map(partition2.hits);
4290
5057
  const integrityMismatches = [];
4291
5058
  const providerFailures = [];
4292
5059
  const budgetWithheld = [];
@@ -4301,6 +5068,13 @@ async function runLocale(params) {
4301
5068
  budgetWithheld,
4302
5069
  reviewFlags
4303
5070
  );
5071
+ fanOutContentDuplicates(params, missGroups, {
5072
+ accepted,
5073
+ integrityMismatches,
5074
+ providerFailures,
5075
+ budgetWithheld,
5076
+ reviewFlags
5077
+ });
4304
5078
  const merged = new Map(target.entries);
4305
5079
  for (const key of pruned) {
4306
5080
  merged.delete(key);
@@ -4347,7 +5121,8 @@ async function runLocale(params) {
4347
5121
  unchanged: diff2.unchanged,
4348
5122
  orphaned,
4349
5123
  invalidIcuSource,
4350
- translated: [...accepted.keys()],
5124
+ translated: [...accepted.keys()].filter((key) => !cacheHitKeys.has(key)),
5125
+ cacheHits: [...cacheHitKeys].sort(),
4351
5126
  generated: generation.accepted.map((form) => form.targetKey).sort(),
4352
5127
  integrityMismatches: [...integrityMismatches, ...generation.withheld].sort(),
4353
5128
  providerFailures: [...providerFailures, ...generation.providerFailures].sort(),
@@ -4357,7 +5132,8 @@ async function runLocale(params) {
4357
5132
  needsReview: needsReviewFor(accepted.keys(), reviewFlags),
4358
5133
  ...localeUsage !== void 0 ? { usage: localeUsage } : {}
4359
5134
  }),
4360
- lockEntries: computeLockEntries(params, merged, withheld, generation.accepted)
5135
+ lockEntries: computeLockEntries(params, merged, withheld, generation.accepted),
5136
+ cacheAdditions: collectCacheAdditions(params, accepted, cacheHitKeys)
4361
5137
  };
4362
5138
  }
4363
5139
  var NO_GENERATION_RESULT = {
@@ -4402,18 +5178,22 @@ function pluralNoticeFor(params, merged) {
4402
5178
  function baseSummary(parts) {
4403
5179
  return {
4404
5180
  locale: parts.locale,
4405
- status: "succeeded",
5181
+ status: deriveLocaleStatus(parts),
4406
5182
  translated: parts.translated,
4407
5183
  unchanged: parts.unchanged,
4408
5184
  orphaned: parts.orphaned,
4409
5185
  pruned: parts.pruned,
4410
5186
  invalidIcuSource: parts.invalidIcuSource,
5187
+ cacheHits: parts.cacheHits,
4411
5188
  integrityMismatches: parts.integrityMismatches,
4412
5189
  providerFailures: parts.providerFailures,
4413
5190
  budgetWithheld: parts.budgetWithheld,
4414
5191
  generated: parts.generated,
4415
5192
  notices: parts.notices,
4416
5193
  needsReview: parts.needsReview ?? [],
5194
+ unfilled: [],
5195
+ malformedRows: [],
5196
+ duplicateKeys: [],
4417
5197
  ...parts.usage !== void 0 ? { usage: parts.usage } : {}
4418
5198
  };
4419
5199
  }
@@ -4431,7 +5211,16 @@ async function translateAndCheck(provider, params, entries, accepted, integrityM
4431
5211
  const notices = [];
4432
5212
  const usage = createUsageAccumulator();
4433
5213
  let tripped = false;
4434
- for (const batch of chunk(entries, params.maxBatchSize)) {
5214
+ const batches = chunk(entries, params.maxBatchSize);
5215
+ let batchIndex = 0;
5216
+ for (const batch of batches) {
5217
+ batchIndex += 1;
5218
+ params.onProgress?.({
5219
+ type: "sub-batch",
5220
+ locale: params.targetLocale,
5221
+ batchIndex,
5222
+ totalBatches: batches.length
5223
+ });
4435
5224
  if (params.budget.stopped) {
4436
5225
  for (const entry of batch) {
4437
5226
  budgetWithheld.push(entry.key);
@@ -4461,10 +5250,16 @@ async function runSubBatch(provider, params, batch, accepted, integrityMismatche
4461
5250
  try {
4462
5251
  result = await provider.translateBatch(buildRequest3(params, batch));
4463
5252
  } catch (error) {
4464
- for (const entry of batch) {
4465
- providerFailures.push(entry.key);
4466
- }
4467
- return { notices: [subBatchFailedNotice(batch.length, error)], usage: void 0 };
5253
+ return handleSubBatchFailure(
5254
+ error,
5255
+ provider,
5256
+ params,
5257
+ batch,
5258
+ accepted,
5259
+ integrityMismatches,
5260
+ providerFailures,
5261
+ reviewFlags
5262
+ );
4468
5263
  }
4469
5264
  for (const entry of batch) {
4470
5265
  foldEntryResult(entry, result, params.adapter, accepted, integrityMismatches, providerFailures);
@@ -4476,6 +5271,44 @@ async function runSubBatch(provider, params, batch, accepted, integrityMismatche
4476
5271
  }
4477
5272
  return { notices: readNotices(result), usage: result.usage };
4478
5273
  }
5274
+ function isOutputTruncated(error) {
5275
+ return error instanceof ProviderError && error.code === "OUTPUT_TRUNCATED";
5276
+ }
5277
+ async function handleSubBatchFailure(error, provider, params, batch, accepted, integrityMismatches, providerFailures, reviewFlags) {
5278
+ if (isOutputTruncated(error) && batch.length > 1) {
5279
+ return retryTruncatedSplit(
5280
+ provider,
5281
+ params,
5282
+ batch,
5283
+ accepted,
5284
+ integrityMismatches,
5285
+ providerFailures,
5286
+ reviewFlags
5287
+ );
5288
+ }
5289
+ for (const entry of batch) {
5290
+ providerFailures.push(entry.key);
5291
+ }
5292
+ return { notices: [subBatchFailedNotice(batch.length, error)], usage: void 0 };
5293
+ }
5294
+ async function retryTruncatedSplit(provider, params, batch, accepted, integrityMismatches, providerFailures, reviewFlags) {
5295
+ const notices = [];
5296
+ let usage;
5297
+ for (const half of chunk(batch, Math.ceil(batch.length / 2))) {
5298
+ const sub = await runSubBatch(
5299
+ provider,
5300
+ params,
5301
+ half,
5302
+ accepted,
5303
+ integrityMismatches,
5304
+ providerFailures,
5305
+ reviewFlags
5306
+ );
5307
+ notices.push(...sub.notices);
5308
+ usage = combineUsage(usage, sub.usage);
5309
+ }
5310
+ return { notices, usage };
5311
+ }
4479
5312
  function foldEntryResult(entry, result, adapter, accepted, integrityMismatches, providerFailures) {
4480
5313
  const value = result.values.get(entry.key);
4481
5314
  if (value === void 0) {
@@ -4533,6 +5366,26 @@ async function recordRunStatus(cwd, dryRun, summary, fs) {
4533
5366
  } catch {
4534
5367
  }
4535
5368
  }
5369
+ async function createRunCacheState(input, config, cwd, dryRun, fs) {
5370
+ if (dryRun || input.cache === false) {
5371
+ return void 0;
5372
+ }
5373
+ return {
5374
+ memory: await readTranslationMemory(cacheFilePath(cwd), fs),
5375
+ fingerprint: computeFingerprint(config),
5376
+ additions: /* @__PURE__ */ new Map()
5377
+ };
5378
+ }
5379
+ async function recordCacheAdditions(cwd, cache, fs) {
5380
+ if (cache === void 0 || cache.additions.size === 0) {
5381
+ return;
5382
+ }
5383
+ try {
5384
+ const merged = applyAdditions(cache.memory, cache.fingerprint, cache.additions);
5385
+ await writeTranslationMemory(cacheFilePath(cwd), merged, fs);
5386
+ } catch {
5387
+ }
5388
+ }
4536
5389
  function buildLocaleRunParams(context, targetLocale, baseline) {
4537
5390
  return {
4538
5391
  source: context.source.resource,
@@ -4551,7 +5404,9 @@ function buildLocaleRunParams(context, targetLocale, baseline) {
4551
5404
  generatePlurals: context.generatePlurals,
4552
5405
  maxBatchSize: context.maxBatchSize,
4553
5406
  fs: context.fs,
4554
- budget: context.budget
5407
+ budget: context.budget,
5408
+ ...context.cache !== void 0 ? { cache: { snapshot: context.cache.memory, fingerprint: context.cache.fingerprint } } : {},
5409
+ ...context.onProgress !== void 0 ? { onProgress: context.onProgress } : {}
4555
5410
  };
4556
5411
  }
4557
5412
  async function runDryLocale(context, targetLocale, lock) {
@@ -4559,16 +5414,29 @@ async function runDryLocale(context, targetLocale, lock) {
4559
5414
  return (await runLocale(params)).summary;
4560
5415
  }
4561
5416
  async function runLiveLocale(context, targetLocale) {
4562
- return withLocaleWriteLock(context.cwd, targetLocale, context.fs, async () => {
4563
- const lock = await readLockFile(lockFilePath(context.cwd), context.fs);
4564
- const params = buildLocaleRunParams(context, targetLocale, baselineFor(lock, targetLocale));
4565
- const result = await runLocale(params);
4566
- await updateLockFileLocale(context.cwd, context.fs, targetLocale, {
4567
- mode: "replace",
4568
- entries: result.lockEntries
4569
- });
4570
- return result.summary;
4571
- });
5417
+ const lockOptions = {
5418
+ ...context.onLockWait !== void 0 ? { onWait: context.onLockWait } : {},
5419
+ ...context.lockAcquireTimeoutMs !== void 0 ? { acquireTimeoutMs: context.lockAcquireTimeoutMs } : {}
5420
+ };
5421
+ return withLocaleWriteLock(
5422
+ context.cwd,
5423
+ targetLocale,
5424
+ context.fs,
5425
+ async () => {
5426
+ const lock = await readLockFile(lockFilePath(context.cwd), context.fs);
5427
+ const params = buildLocaleRunParams(context, targetLocale, baselineFor(lock, targetLocale));
5428
+ const result = await runLocale(params);
5429
+ await updateLockFileLocale(context.cwd, context.fs, targetLocale, {
5430
+ mode: "replace",
5431
+ entries: result.lockEntries
5432
+ });
5433
+ if (context.cache !== void 0 && result.cacheAdditions.length > 0) {
5434
+ context.cache.additions.set(targetLocale, additionsToRecord(result.cacheAdditions));
5435
+ }
5436
+ return result.summary;
5437
+ },
5438
+ lockOptions
5439
+ );
4572
5440
  }
4573
5441
  async function runOneLocale(targetLocale, run) {
4574
5442
  try {
@@ -4580,27 +5448,88 @@ async function runOneLocale(targetLocale, run) {
4580
5448
  return failureSummary(targetLocale, error);
4581
5449
  }
4582
5450
  }
4583
- async function runAllLocalesDry(context, targetLocales) {
5451
+ async function runLocaleAt(context, targetLocales, localeIndex, runOne, results) {
5452
+ const targetLocale = targetLocales[localeIndex];
5453
+ if (targetLocale === void 0) {
5454
+ return;
5455
+ }
5456
+ context.onProgress?.({
5457
+ type: "locale-started",
5458
+ locale: targetLocale,
5459
+ localeIndex,
5460
+ totalLocales: targetLocales.length
5461
+ });
5462
+ const summary = await runOneLocale(targetLocale, () => runOne(targetLocale));
5463
+ results[localeIndex] = summary;
5464
+ context.onProgress?.({
5465
+ type: "locale-finished",
5466
+ locale: targetLocale,
5467
+ translated: summary.translated.length
5468
+ });
5469
+ }
5470
+ async function runLocalesWithProgress(context, targetLocales, runOne, concurrency) {
5471
+ const totalLocales = targetLocales.length;
5472
+ const results = new Array(totalLocales);
5473
+ let nextIndex = 0;
5474
+ async function worker() {
5475
+ while (nextIndex < totalLocales) {
5476
+ const localeIndex = nextIndex;
5477
+ nextIndex += 1;
5478
+ await runLocaleAt(context, targetLocales, localeIndex, runOne, results);
5479
+ }
5480
+ }
5481
+ const workerCount = Math.min(concurrency, totalLocales);
5482
+ const workers = [];
5483
+ for (let index = 0; index < workerCount; index += 1) {
5484
+ workers.push(worker());
5485
+ }
5486
+ await Promise.all(workers);
5487
+ return results.filter((summary) => summary !== void 0);
5488
+ }
5489
+ async function runAllLocalesDry(context, targetLocales, concurrency) {
4584
5490
  const lock = await readLockFile(lockFilePath(context.cwd), context.fs);
4585
- const summaries = [];
4586
- for (const targetLocale of targetLocales) {
4587
- summaries.push(
4588
- await runOneLocale(targetLocale, () => runDryLocale(context, targetLocale, lock))
5491
+ return runLocalesWithProgress(
5492
+ context,
5493
+ targetLocales,
5494
+ (targetLocale) => runDryLocale(context, targetLocale, lock),
5495
+ concurrency
5496
+ );
5497
+ }
5498
+ async function runAllLocalesLive(context, targetLocales, concurrency) {
5499
+ return runLocalesWithProgress(
5500
+ context,
5501
+ targetLocales,
5502
+ (targetLocale) => runLiveLocale(context, targetLocale),
5503
+ concurrency
5504
+ );
5505
+ }
5506
+ function resolveConcurrency(value) {
5507
+ if (value === void 0) {
5508
+ return 1;
5509
+ }
5510
+ if (!Number.isInteger(value) || value < 1) {
5511
+ throw new SdkError(
5512
+ "CONCURRENCY_INVALID",
5513
+ `The concurrency option must be an integer of at least 1, got ${value}.`
4589
5514
  );
4590
5515
  }
4591
- return summaries;
5516
+ return value;
4592
5517
  }
4593
- async function runAllLocalesLive(context, targetLocales) {
4594
- const summaries = [];
4595
- for (const targetLocale of targetLocales) {
4596
- summaries.push(await runOneLocale(targetLocale, () => runLiveLocale(context, targetLocale)));
5518
+ function resolveRunConcurrency(value, dryRun, config) {
5519
+ const concurrency = resolveConcurrency(value);
5520
+ if (!dryRun && concurrency > 1 && config.maxTokens !== void 0) {
5521
+ throw new SdkError(
5522
+ "CONCURRENCY_BUDGET_CONFLICT",
5523
+ "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."
5524
+ );
4597
5525
  }
4598
- return summaries;
5526
+ return concurrency;
4599
5527
  }
4600
5528
  async function translate2(input, deps = {}) {
4601
5529
  const config = input.config;
4602
5530
  const cwd = input.cwd ?? process.cwd();
4603
5531
  const dryRun = input.dryRun ?? false;
5532
+ const concurrency = resolveRunConcurrency(input.concurrency, dryRun, config);
4604
5533
  const prune = input.prune ?? config.prune ?? false;
4605
5534
  const generatePlurals = input.generatePlurals ?? config.generatePlurals ?? false;
4606
5535
  const maxBatchSize = config.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
@@ -4612,6 +5541,7 @@ async function translate2(input, deps = {}) {
4612
5541
  const adapter = selectAdapter(config.format, deps.adapterRegistry);
4613
5542
  const provider = dryRun ? void 0 : selectProvider(config.provider, deps.createProvider);
4614
5543
  const source = await readSource(config, cwd, fs, adapter);
5544
+ const cache = await createRunCacheState(input, config, cwd, dryRun, fs);
4615
5545
  const context = {
4616
5546
  source,
4617
5547
  adapter,
@@ -4622,10 +5552,15 @@ async function translate2(input, deps = {}) {
4622
5552
  generatePlurals,
4623
5553
  maxBatchSize,
4624
5554
  fs,
4625
- budget
5555
+ budget,
5556
+ cache,
5557
+ ...input.onLockWait !== void 0 ? { onLockWait: input.onLockWait } : {},
5558
+ ...input.onProgress !== void 0 ? { onProgress: input.onProgress } : {},
5559
+ ...input.lockAcquireTimeoutMs !== void 0 ? { lockAcquireTimeoutMs: input.lockAcquireTimeoutMs } : {}
4626
5560
  };
4627
- const summaries = dryRun ? await runAllLocalesDry(context, config.targetLocales) : await runAllLocalesLive(context, config.targetLocales);
4628
- const { succeeded, failed } = partition(summaries);
5561
+ const summaries = dryRun ? await runAllLocalesDry(context, config.targetLocales, concurrency) : await runAllLocalesLive(context, config.targetLocales, concurrency);
5562
+ input.onProgress?.({ type: "run-finished", localesCompleted: summaries.length });
5563
+ const { succeeded, partial, failed } = partition(summaries);
4629
5564
  const usage = summaries.reduce(
4630
5565
  (total, summary2) => combineUsage(total, summary2.usage),
4631
5566
  void 0
@@ -4635,10 +5570,12 @@ async function translate2(input, deps = {}) {
4635
5570
  dryRun,
4636
5571
  locales: summaries,
4637
5572
  succeeded,
5573
+ partial,
4638
5574
  failed,
4639
5575
  ...usage !== void 0 ? { usage } : {},
4640
5576
  ...budgetSummary !== void 0 ? { budget: budgetSummary } : {}
4641
5577
  };
5578
+ await recordCacheAdditions(cwd, cache, fs);
4642
5579
  await recordRunStatus(cwd, dryRun, summary, fs);
4643
5580
  return summary;
4644
5581
  }
@@ -4654,8 +5591,12 @@ var INSTRUCTIONS_LINES = [
4654
5591
  "How to use this workbook",
4655
5592
  "",
4656
5593
  "1. There is one sheet per language. Open the sheet named for the language you are translating.",
5594
+ " Do not rename, delete, or reorder the language tabs. verbatra matches each tab to a language by",
5595
+ " its exact name; a renamed or missing tab is reported and its translations are not imported.",
4657
5596
  "2. Fill ONLY the 'Translation' column. Leave every other column unchanged.",
4658
- "3. An empty 'Translation' cell means 'not translated yet'. It is skipped, never written as an empty string.",
5597
+ "3. An empty 'Translation' cell means 'not translated yet'. It is skipped, never written as an empty",
5598
+ " string. A cell that contains only spaces is treated the same as an empty cell.",
5599
+ " To deliberately clear an existing value (set it to empty), type exactly [[CLEAR]] in the cell.",
4659
5600
  "4. Keep placeholders exactly as they appear. A token such as {name} or {count} must stay verbatim,",
4660
5601
  " in your translation, with the same spelling. Do not translate or remove it.",
4661
5602
  "5. For ICU messages (for example {count, plural, one {# item} other {# items}}), keep the ICU",
@@ -4820,6 +5761,26 @@ function buildInstructionsSheet(workbook) {
4820
5761
  }
4821
5762
  sheet.getRow(1).font = { bold: true };
4822
5763
  }
5764
+ var WORKBOOK_PROTECTION_XML = '<workbookProtection lockStructure="1" lockWindows="0"/>';
5765
+ function spliceWorkbookProtection(xml) {
5766
+ const anchor = xml.includes("<bookViews") ? "<bookViews" : "<sheets";
5767
+ return xml.replace(anchor, `${WORKBOOK_PROTECTION_XML}${anchor}`);
5768
+ }
5769
+ async function protectWorkbookStructure(bytes) {
5770
+ let zip;
5771
+ try {
5772
+ zip = await JSZip__default.default.loadAsync(bytes);
5773
+ } catch {
5774
+ throw new ExchangeError("WORKBOOK_INVALID", "The workbook could not be serialized.");
5775
+ }
5776
+ const workbookPart = zip.file("xl/workbook.xml");
5777
+ if (workbookPart === null) {
5778
+ return bytes;
5779
+ }
5780
+ const xml = await workbookPart.async("string");
5781
+ zip.file("xl/workbook.xml", spliceWorkbookProtection(xml));
5782
+ return zip.generateAsync({ type: "uint8array", compression: "DEFLATE" });
5783
+ }
4823
5784
  async function buildWorkbook(model) {
4824
5785
  assertNoWorksheetNameCollisions(model.sheets);
4825
5786
  const workbook = new ExcelJS__default.default.Workbook();
@@ -4827,13 +5788,15 @@ async function buildWorkbook(model) {
4827
5788
  for (const sheet of model.sheets) {
4828
5789
  await buildDataSheet(workbook, sheet);
4829
5790
  }
5791
+ let serialized;
4830
5792
  try {
4831
5793
  const buffer = await workbook.xlsx.writeBuffer();
4832
- const view = buffer;
4833
- return Uint8Array.prototype.slice.call(view);
5794
+ serialized = buffer;
4834
5795
  } catch {
4835
5796
  throw new ExchangeError("WORKBOOK_INVALID", "The workbook could not be serialized.");
4836
5797
  }
5798
+ const protectedBytes = await protectWorkbookStructure(serialized);
5799
+ return Uint8Array.prototype.slice.call(protectedBytes);
4837
5800
  }
4838
5801
  var DEFAULT_WORKBOOK_LIMITS = {
4839
5802
  maxDecompressedBytes: 64 * 1024 * 1024,
@@ -4953,26 +5916,24 @@ function assertHeader(sheet) {
4953
5916
  );
4954
5917
  }
4955
5918
  }
4956
- function parseRow(sheet, row) {
5919
+ var MALFORMED_ROW_COLUMN = "Status";
5920
+ function parseRow(row) {
4957
5921
  const candidate = {
4958
5922
  key: cellString(row.getCell(COLUMN.key)),
4959
5923
  source: cellString(row.getCell(COLUMN.source)),
4960
5924
  currentTarget: cellString(row.getCell(COLUMN.current)),
4961
5925
  status: cellString(row.getCell(COLUMN.status)),
4962
5926
  sourceHash: cellString(row.getCell(COLUMN.sourceHash)),
4963
- translation: cellString(row.getCell(COLUMN.translation)),
5927
+ translation: cellString(row.getCell(COLUMN.translation)).trim(),
4964
5928
  context: cellString(row.getCell(COLUMN.context)),
4965
5929
  reviewStatus: cellString(row.getCell(COLUMN.reviewStatus)),
4966
5930
  reviewReasons: cellString(row.getCell(COLUMN.reviewReasons))
4967
5931
  };
4968
5932
  const result = rowSchema.safeParse(candidate);
4969
5933
  if (!result.success) {
4970
- throw new ExchangeError(
4971
- "WORKBOOK_INVALID",
4972
- `The sheet "${sheet.name}" has a row with a missing key or an unrecognized status.`
4973
- );
5934
+ return { ok: false, column: MALFORMED_ROW_COLUMN };
4974
5935
  }
4975
- return result.data;
5936
+ return { ok: true, row: result.data };
4976
5937
  }
4977
5938
  function readDataSheet(sheet, limits) {
4978
5939
  assertHeader(sheet);
@@ -4983,6 +5944,9 @@ function readDataSheet(sheet, limits) {
4983
5944
  );
4984
5945
  }
4985
5946
  const rows = [];
5947
+ const malformed = [];
5948
+ const duplicates = [];
5949
+ const seenKeys = /* @__PURE__ */ new Set();
4986
5950
  for (let rowNumber = HEADER_ROW + 1; rowNumber <= sheet.rowCount; rowNumber += 1) {
4987
5951
  const row = sheet.getRow(rowNumber);
4988
5952
  if (row.cellCount > limits.maxCellsPerRow) {
@@ -4994,9 +5958,19 @@ function readDataSheet(sheet, limits) {
4994
5958
  if (cellString(row.getCell(COLUMN.key)) === "") {
4995
5959
  continue;
4996
5960
  }
4997
- rows.push(parseRow(sheet, row));
5961
+ const outcome = parseRow(row);
5962
+ if (!outcome.ok) {
5963
+ malformed.push({ locale: sheet.name, row: rowNumber, column: outcome.column });
5964
+ continue;
5965
+ }
5966
+ if (seenKeys.has(outcome.row.key)) {
5967
+ duplicates.push({ locale: sheet.name, key: outcome.row.key, row: rowNumber });
5968
+ continue;
5969
+ }
5970
+ seenKeys.add(outcome.row.key);
5971
+ rows.push(outcome.row);
4998
5972
  }
4999
- return { locale: sheet.name, rows };
5973
+ return { sheet: { locale: sheet.name, rows }, malformed, duplicates };
5000
5974
  }
5001
5975
  async function loadWorkbook(bytes) {
5002
5976
  const workbook = new ExcelJS__default.default.Workbook();
@@ -5019,13 +5993,18 @@ async function readWorkbook(bytes, options = {}) {
5019
5993
  );
5020
5994
  }
5021
5995
  const sheets = [];
5996
+ const malformedRows = [];
5997
+ const duplicateKeys = [];
5022
5998
  for (const sheet of workbook.worksheets) {
5023
5999
  if (sheet.name === INSTRUCTIONS_SHEET_NAME) {
5024
6000
  continue;
5025
6001
  }
5026
- sheets.push(readDataSheet(sheet, limits));
6002
+ const read = readDataSheet(sheet, limits);
6003
+ sheets.push(read.sheet);
6004
+ malformedRows.push(...read.malformed);
6005
+ duplicateKeys.push(...read.duplicates);
5027
6006
  }
5028
- return { sheets };
6007
+ return { sheets, malformedRows, duplicateKeys };
5029
6008
  }
5030
6009
 
5031
6010
  // src/flow/workbook/export-workbook.ts
@@ -5126,6 +6105,7 @@ async function exportWorkbook(input, deps = {}) {
5126
6105
  }
5127
6106
 
5128
6107
  // src/flow/workbook/import-locale.ts
6108
+ var CLEAR_SENTINEL = "[[CLEAR]]";
5129
6109
  var UnknownKeyError = class extends Error {
5130
6110
  key;
5131
6111
  constructor(key) {
@@ -5154,9 +6134,20 @@ function trackBlankDrift(row, params, buckets) {
5154
6134
  buckets.blankDrifted.add(row.key);
5155
6135
  }
5156
6136
  }
6137
+ function classifyClear(row, sourceEntry, buckets) {
6138
+ if (contentHash(sourceEntry) !== row.sourceHash) {
6139
+ buckets.mismatches.push(row.key);
6140
+ buckets.withheld.add(row.key);
6141
+ return;
6142
+ }
6143
+ buckets.accepted.set(row.key, { value: "", source: sourceEntry });
6144
+ }
5157
6145
  function classifyRows(params, buckets) {
5158
6146
  for (const row of params.sheet.rows) {
5159
6147
  if (row.translation === "") {
6148
+ if (row.status === "changed") {
6149
+ buckets.unfilled.push(row.key);
6150
+ }
5160
6151
  trackBlankDrift(row, params, buckets);
5161
6152
  continue;
5162
6153
  }
@@ -5167,6 +6158,10 @@ function classifyRows(params, buckets) {
5167
6158
  if (sourceEntry === void 0) {
5168
6159
  continue;
5169
6160
  }
6161
+ if (row.translation === CLEAR_SENTINEL) {
6162
+ classifyClear(row, sourceEntry, buckets);
6163
+ continue;
6164
+ }
5170
6165
  const reason = judge(row, sourceEntry, params.adapter);
5171
6166
  if (reason === void 0) {
5172
6167
  buckets.accepted.set(row.key, { value: row.translation, source: sourceEntry });
@@ -5188,25 +6183,39 @@ function importLocale(params) {
5188
6183
  accepted: /* @__PURE__ */ new Map(),
5189
6184
  mismatches: [],
5190
6185
  withheld: /* @__PURE__ */ new Set(),
5191
- blankDrifted: /* @__PURE__ */ new Set()
6186
+ blankDrifted: /* @__PURE__ */ new Set(),
6187
+ unfilled: []
5192
6188
  };
5193
6189
  classifyRows(params, buckets);
5194
6190
  const rowKeys = new Set(params.sheet.rows.map((row) => row.key));
5195
6191
  const invalidIcuSource = [...new Set(params.sourceInvalidIcuKeys)].filter((key) => rowKeys.has(key)).sort();
6192
+ const translated = [...buckets.accepted.keys()].sort();
6193
+ const integrityMismatches = [...buckets.mismatches].sort();
5196
6194
  const summary = {
5197
6195
  locale: params.sheet.locale,
5198
- status: "succeeded",
5199
- translated: [...buckets.accepted.keys()].sort(),
6196
+ status: deriveLocaleStatus({
6197
+ translated,
6198
+ cacheHits: [],
6199
+ generated: [],
6200
+ integrityMismatches,
6201
+ providerFailures: [],
6202
+ budgetWithheld: []
6203
+ }),
6204
+ translated,
5200
6205
  unchanged: diff2.unchanged,
5201
6206
  orphaned: diff2.orphaned,
5202
6207
  pruned: [],
5203
6208
  invalidIcuSource,
5204
- integrityMismatches: [...buckets.mismatches].sort(),
6209
+ cacheHits: [],
6210
+ integrityMismatches,
5205
6211
  providerFailures: [],
5206
6212
  budgetWithheld: [],
5207
6213
  generated: [],
5208
6214
  notices: buckets.blankDrifted.size > 0 ? [blankRowBaselineNotice(buckets.blankDrifted.size)] : [],
5209
- needsReview: []
6215
+ needsReview: [],
6216
+ unfilled: [...new Set(buckets.unfilled)].sort(),
6217
+ malformedRows: params.malformedRows,
6218
+ duplicateKeys: params.duplicateKeys
5210
6219
  };
5211
6220
  return { summary, accepted: buckets.accepted, withheld: buckets.withheld };
5212
6221
  }
@@ -5233,6 +6242,19 @@ function mergeAccepted(target, accepted) {
5233
6242
  }
5234
6243
  return merged;
5235
6244
  }
6245
+ function sheetCacheAdditions(accepted) {
6246
+ const record = {};
6247
+ for (const [, { value, source }] of accepted) {
6248
+ record[contentHash(source)] = value;
6249
+ }
6250
+ return record;
6251
+ }
6252
+ function collectSheetAdditions(byLocale, locale, additions) {
6253
+ if (Object.keys(additions).length === 0) {
6254
+ return;
6255
+ }
6256
+ byLocale.set(locale, { ...byLocale.get(locale), ...additions });
6257
+ }
5236
6258
  function computeLockEntries2(source, merged, baseline, accepted) {
5237
6259
  const entries = {};
5238
6260
  for (const key of merged.keys()) {
@@ -5249,11 +6271,20 @@ function computeLockEntries2(source, merged, baseline, accepted) {
5249
6271
  }
5250
6272
  return entries;
5251
6273
  }
6274
+ var MissingSheetError = class extends Error {
6275
+ code = "WORKBOOK_SHEET_MISSING";
6276
+ constructor(locale) {
6277
+ super(
6278
+ `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.`
6279
+ );
6280
+ this.name = "MissingSheetError";
6281
+ }
6282
+ };
5252
6283
  async function runSheet(ctx, sheet, lock) {
5253
6284
  if (!ctx.config.targetLocales.includes(sheet.locale)) {
5254
6285
  throw new SdkError(
5255
6286
  "CONFIG_INVALID",
5256
- `The workbook has a sheet for locale "${sheet.locale}", which is not a configured target locale.`
6287
+ `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.`
5257
6288
  );
5258
6289
  }
5259
6290
  const target = await readTarget(ctx.cwd, ctx.config, ctx.adapter, ctx.fs, sheet.locale);
@@ -5264,10 +6295,12 @@ async function runSheet(ctx, sheet, lock) {
5264
6295
  target,
5265
6296
  baseline,
5266
6297
  adapter: ctx.adapter,
5267
- sourceInvalidIcuKeys: ctx.sourceInvalidIcuKeys
6298
+ sourceInvalidIcuKeys: ctx.sourceInvalidIcuKeys,
6299
+ malformedRows: ctx.malformedRows.filter((problem) => problem.locale === sheet.locale).map((problem) => ({ row: problem.row, column: problem.column })),
6300
+ duplicateKeys: ctx.duplicateKeys.filter((duplicate) => duplicate.locale === sheet.locale).map((duplicate) => ({ key: duplicate.key, row: duplicate.row }))
5268
6301
  });
5269
6302
  if (ctx.dryRun) {
5270
- return { summary, lockEntries: {} };
6303
+ return { summary, lockEntries: {}, cacheAdditions: {} };
5271
6304
  }
5272
6305
  const merged = mergeAccepted(target, accepted);
5273
6306
  if (accepted.size > 0) {
@@ -5282,7 +6315,11 @@ async function runSheet(ctx, sheet, lock) {
5282
6315
  path
5283
6316
  );
5284
6317
  }
5285
- return { summary, lockEntries: computeLockEntries2(ctx.source, merged, baseline, accepted) };
6318
+ return {
6319
+ summary,
6320
+ lockEntries: computeLockEntries2(ctx.source, merged, baseline, accepted),
6321
+ cacheAdditions: sheetCacheAdditions(accepted)
6322
+ };
5286
6323
  }
5287
6324
  async function importWorkbook(input, deps = {}) {
5288
6325
  const config = input.config;
@@ -5307,9 +6344,12 @@ async function importWorkbook(input, deps = {}) {
5307
6344
  fs,
5308
6345
  source: source.resource,
5309
6346
  sourceInvalidIcuKeys: source.invalidIcuKeys,
5310
- dryRun
6347
+ dryRun,
6348
+ malformedRows: data.malformedRows,
6349
+ duplicateKeys: data.duplicateKeys
5311
6350
  };
5312
6351
  const summaries = [];
6352
+ const cacheAdditions = /* @__PURE__ */ new Map();
5313
6353
  for (const sheet of data.sheets) {
5314
6354
  try {
5315
6355
  let summary;
@@ -5322,6 +6362,7 @@ async function importWorkbook(input, deps = {}) {
5322
6362
  mode: "replace",
5323
6363
  entries: result.lockEntries
5324
6364
  });
6365
+ collectSheetAdditions(cacheAdditions, sheet.locale, result.cacheAdditions);
5325
6366
  return result.summary;
5326
6367
  });
5327
6368
  }
@@ -5330,8 +6371,17 @@ async function importWorkbook(input, deps = {}) {
5330
6371
  summaries.push(failureSummary(sheet.locale, error));
5331
6372
  }
5332
6373
  }
5333
- const { succeeded, failed } = partition(summaries);
5334
- return { dryRun, locales: summaries, succeeded, failed };
6374
+ const presentLocales = new Set(data.sheets.map((sheet) => sheet.locale));
6375
+ for (const locale of config.targetLocales) {
6376
+ if (!presentLocales.has(locale)) {
6377
+ summaries.push(failureSummary(locale, new MissingSheetError(locale)));
6378
+ }
6379
+ }
6380
+ if (!dryRun) {
6381
+ await feedTranslationMemory(cwd, fs, computeFingerprint(config), cacheAdditions);
6382
+ }
6383
+ const { succeeded, partial, failed } = partition(summaries);
6384
+ return { dryRun, locales: summaries, succeeded, partial, failed };
5335
6385
  }
5336
6386
 
5337
6387
  // src/lock/load-lock-file.ts
@@ -5389,7 +6439,15 @@ async function watch(input, deps = {}) {
5389
6439
  );
5390
6440
  }
5391
6441
  const runTranslate = deps.runTranslate ?? defaultRunTranslate(deps);
5392
- const runInput = { config: input.config, cwd };
6442
+ const runInput = {
6443
+ config: input.config,
6444
+ cwd,
6445
+ ...input.onLockWait !== void 0 ? { onLockWait: input.onLockWait } : {},
6446
+ ...input.onProgress !== void 0 ? { onProgress: input.onProgress } : {},
6447
+ ...input.lockAcquireTimeoutMs !== void 0 ? { lockAcquireTimeoutMs: input.lockAcquireTimeoutMs } : {},
6448
+ ...input.concurrency !== void 0 ? { concurrency: input.concurrency } : {},
6449
+ ...input.cache !== void 0 ? { cache: input.cache } : {}
6450
+ };
5393
6451
  let state = "idle";
5394
6452
  let pending = false;
5395
6453
  let stopped = false;
@@ -5456,6 +6514,7 @@ async function watch(input, deps = {}) {
5456
6514
  return { stop };
5457
6515
  }
5458
6516
 
6517
+ exports.CACHE_FILE_NAME = CACHE_FILE_NAME;
5459
6518
  exports.DEFAULT_WORKBOOK_PATH = DEFAULT_WORKBOOK_PATH;
5460
6519
  exports.LOCK_FILE_NAME = LOCK_FILE_NAME;
5461
6520
  exports.SdkError = SdkError;