@babelize/engine 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/dist/BabelizeEngine.d.ts +12 -0
- package/dist/BabelizeEngine.js +47 -0
- package/dist/analysis/analyze.d.ts +106 -0
- package/dist/analysis/analyze.js +323 -0
- package/dist/config/env.d.ts +9 -0
- package/dist/config/env.js +16 -0
- package/dist/config.d.ts +74 -0
- package/dist/config.js +47 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +126 -0
- package/dist/injection/InjectionStrategy.d.ts +62 -0
- package/dist/injection/InjectionStrategy.js +2 -0
- package/dist/injection/StrategyFactory.d.ts +4 -0
- package/dist/injection/StrategyFactory.js +29 -0
- package/dist/injection/django/django-template-injector.d.ts +29 -0
- package/dist/injection/django/django-template-injector.js +216 -0
- package/dist/injection/html/html-injector.d.ts +11 -0
- package/dist/injection/html/html-injector.js +173 -0
- package/dist/injection/strategies/NextIntlStrategy.d.ts +25 -0
- package/dist/injection/strategies/NextIntlStrategy.js +141 -0
- package/dist/injection/strategies/ReactI18nextStrategy.d.ts +19 -0
- package/dist/injection/strategies/ReactI18nextStrategy.js +116 -0
- package/dist/injection/strategies/VanillaStrategy.d.ts +11 -0
- package/dist/injection/strategies/VanillaStrategy.js +73 -0
- package/dist/injection/strategies/VueI18nStrategy.d.ts +11 -0
- package/dist/injection/strategies/VueI18nStrategy.js +84 -0
- package/dist/local/formatter.d.ts +26 -0
- package/dist/local/formatter.js +105 -0
- package/dist/local/git.d.ts +23 -0
- package/dist/local/git.js +136 -0
- package/dist/local/hosted-provider.d.ts +68 -0
- package/dist/local/hosted-provider.js +215 -0
- package/dist/local/index.d.ts +23 -0
- package/dist/local/index.js +75 -0
- package/dist/local/provider.d.ts +56 -0
- package/dist/local/provider.js +119 -0
- package/dist/local/pseudo-provider.d.ts +18 -0
- package/dist/local/pseudo-provider.js +207 -0
- package/dist/local/report.d.ts +177 -0
- package/dist/local/report.js +186 -0
- package/dist/local/translate-core.d.ts +215 -0
- package/dist/local/translate-core.js +461 -0
- package/dist/local/translate-messages.d.ts +49 -0
- package/dist/local/translate-messages.js +91 -0
- package/dist/local/workspace-copy.d.ts +91 -0
- package/dist/local/workspace-copy.js +355 -0
- package/dist/modules/extractor/extract.d.ts +20 -0
- package/dist/modules/extractor/extract.js +701 -0
- package/dist/modules/extractor/index.d.ts +2 -0
- package/dist/modules/extractor/index.js +63 -0
- package/dist/modules/finalization/django-finalize.d.ts +13 -0
- package/dist/modules/finalization/django-finalize.js +275 -0
- package/dist/modules/finalization/finalize.d.ts +36 -0
- package/dist/modules/finalization/finalize.js +717 -0
- package/dist/modules/finalization/index.d.ts +1 -0
- package/dist/modules/finalization/index.js +5 -0
- package/dist/modules/finalization/nextjs-finalize.d.ts +37 -0
- package/dist/modules/finalization/nextjs-finalize.js +329 -0
- package/dist/modules/finalization/static-runtime.d.ts +9 -0
- package/dist/modules/finalization/static-runtime.js +261 -0
- package/dist/modules/format-injection/index.d.ts +19 -0
- package/dist/modules/format-injection/index.js +272 -0
- package/dist/modules/format-injection/inject-formats.d.ts +31 -0
- package/dist/modules/format-injection/inject-formats.js +208 -0
- package/dist/modules/glossary/glossary.d.ts +9 -0
- package/dist/modules/glossary/glossary.js +27 -0
- package/dist/modules/glossary/index.d.ts +3 -0
- package/dist/modules/glossary/index.js +8 -0
- package/dist/modules/glossary/masker.d.ts +23 -0
- package/dist/modules/glossary/masker.js +56 -0
- package/dist/modules/glossary/types.d.ts +22 -0
- package/dist/modules/glossary/types.js +2 -0
- package/dist/modules/injection/index.d.ts +1 -0
- package/dist/modules/injection/index.js +6 -0
- package/dist/modules/injection/inject.d.ts +29 -0
- package/dist/modules/injection/inject.js +1066 -0
- package/dist/modules/keygen/index.d.ts +1 -0
- package/dist/modules/keygen/index.js +11 -0
- package/dist/modules/keygen/keygen.d.ts +4 -0
- package/dist/modules/keygen/keygen.js +116 -0
- package/dist/modules/keygen/locale-data.d.ts +7 -0
- package/dist/modules/keygen/locale-data.js +73 -0
- package/dist/modules/keygen/public.d.ts +2 -0
- package/dist/modules/keygen/public.js +12 -0
- package/dist/modules/locale-builder/index.d.ts +1 -0
- package/dist/modules/locale-builder/index.js +7 -0
- package/dist/modules/locale-builder/locales.d.ts +13 -0
- package/dist/modules/locale-builder/locales.js +107 -0
- package/dist/modules/locale-builder/po.d.ts +33 -0
- package/dist/modules/locale-builder/po.js +131 -0
- package/dist/modules/lockfile/hasher.d.ts +7 -0
- package/dist/modules/lockfile/hasher.js +13 -0
- package/dist/modules/lockfile/index.d.ts +3 -0
- package/dist/modules/lockfile/index.js +21 -0
- package/dist/modules/lockfile/lockfile-manager.d.ts +90 -0
- package/dist/modules/lockfile/lockfile-manager.js +482 -0
- package/dist/modules/lockfile/schema.d.ts +154 -0
- package/dist/modules/lockfile/schema.js +45 -0
- package/dist/modules/parser/index.d.ts +2 -0
- package/dist/modules/parser/index.js +9 -0
- package/dist/modules/summary/index.d.ts +1 -0
- package/dist/modules/summary/index.js +7 -0
- package/dist/modules/summary/summary.d.ts +9 -0
- package/dist/modules/summary/summary.js +195 -0
- package/dist/modules/sync-detector/index.d.ts +2 -0
- package/dist/modules/sync-detector/index.js +7 -0
- package/dist/modules/sync-detector/reporter.d.ts +3 -0
- package/dist/modules/sync-detector/reporter.js +16 -0
- package/dist/modules/sync-detector/sync-detector.d.ts +39 -0
- package/dist/modules/sync-detector/sync-detector.js +374 -0
- package/dist/modules/translator/batch-optimizer.d.ts +12 -0
- package/dist/modules/translator/batch-optimizer.js +30 -0
- package/dist/modules/translator/client/byok.provider.d.ts +64 -0
- package/dist/modules/translator/client/byok.provider.js +319 -0
- package/dist/modules/translator/client/direct-translator.client.d.ts +12 -0
- package/dist/modules/translator/client/direct-translator.client.js +71 -0
- package/dist/modules/translator/client/gemini.provider.d.ts +1 -0
- package/dist/modules/translator/client/gemini.provider.js +110 -0
- package/dist/modules/translator/client/groq.provider.d.ts +1 -0
- package/dist/modules/translator/client/groq.provider.js +111 -0
- package/dist/modules/translator/client/request.types.d.ts +1 -0
- package/dist/modules/translator/client/request.types.js +2 -0
- package/dist/modules/translator/client/retry.d.ts +29 -0
- package/dist/modules/translator/client/retry.js +94 -0
- package/dist/modules/translator/client/test-utils/mock.client.d.ts +31 -0
- package/dist/modules/translator/client/test-utils/mock.client.js +59 -0
- package/dist/modules/translator/client/translator.interface.d.ts +1 -0
- package/dist/modules/translator/client/translator.interface.js +2 -0
- package/dist/modules/translator/language-codes/errors.d.ts +10 -0
- package/dist/modules/translator/language-codes/errors.js +18 -0
- package/dist/modules/translator/language-codes/flores-map.d.ts +1 -0
- package/dist/modules/translator/language-codes/flores-map.js +107 -0
- package/dist/modules/translator/language-codes/index.d.ts +11 -0
- package/dist/modules/translator/language-codes/index.js +24 -0
- package/dist/modules/translator/language-codes/language-mapper.d.ts +25 -0
- package/dist/modules/translator/language-codes/language-mapper.js +132 -0
- package/dist/modules/translator/language-codes/rtl-languages.d.ts +2 -0
- package/dist/modules/translator/language-codes/rtl-languages.js +9 -0
- package/dist/modules/translator/memory/memory-matcher.d.ts +8 -0
- package/dist/modules/translator/memory/memory-matcher.js +218 -0
- package/dist/modules/translator/memory/types.d.ts +22 -0
- package/dist/modules/translator/memory/types.js +2 -0
- package/dist/modules/translator/translate.d.ts +38 -0
- package/dist/modules/translator/translate.js +932 -0
- package/dist/modules/translator/translator.d.ts +5 -0
- package/dist/modules/translator/translator.js +46 -0
- package/dist/modules/vue/migrate.d.ts +13 -0
- package/dist/modules/vue/migrate.js +40 -0
- package/dist/modules/vue/script-inject.d.ts +16 -0
- package/dist/modules/vue/script-inject.js +115 -0
- package/dist/modules/vue/sfc.d.ts +11 -0
- package/dist/modules/vue/sfc.js +51 -0
- package/dist/modules/vue/template-rewrite.d.ts +25 -0
- package/dist/modules/vue/template-rewrite.js +76 -0
- package/dist/modules/vue/template-scan.d.ts +29 -0
- package/dist/modules/vue/template-scan.js +264 -0
- package/dist/modules/workspace/index.d.ts +1 -0
- package/dist/modules/workspace/index.js +5 -0
- package/dist/modules/workspace/validate.d.ts +13 -0
- package/dist/modules/workspace/validate.js +298 -0
- package/dist/orchestrator.d.ts +8 -0
- package/dist/orchestrator.js +616 -0
- package/dist/providers/default-translation-provider.d.ts +20 -0
- package/dist/providers/default-translation-provider.js +30 -0
- package/dist/providers/index.d.ts +2 -0
- package/dist/providers/index.js +21 -0
- package/dist/providers/types.d.ts +145 -0
- package/dist/providers/types.js +2 -0
- package/dist/qa/check-project.d.ts +49 -0
- package/dist/qa/check-project.js +150 -0
- package/dist/qa/locale-files.d.ts +44 -0
- package/dist/qa/locale-files.js +159 -0
- package/dist/qa/locale-qa.d.ts +61 -0
- package/dist/qa/locale-qa.js +325 -0
- package/dist/qa/review.d.ts +43 -0
- package/dist/qa/review.js +175 -0
- package/dist/runtime/config.d.ts +8 -0
- package/dist/runtime/config.js +36 -0
- package/dist/runtime/index.d.ts +3 -0
- package/dist/runtime/index.js +8 -0
- package/dist/runtime/types.d.ts +363 -0
- package/dist/runtime/types.js +38 -0
- package/dist/types.d.ts +134 -0
- package/dist/types.js +12 -0
- package/dist/utils/ast-helpers.d.ts +57 -0
- package/dist/utils/ast-helpers.js +608 -0
- package/dist/utils/cache-keys.d.ts +8 -0
- package/dist/utils/cache-keys.js +18 -0
- package/dist/utils/circuit-breaker.d.ts +43 -0
- package/dist/utils/circuit-breaker.js +119 -0
- package/dist/utils/concurrent-map.d.ts +4 -0
- package/dist/utils/concurrent-map.js +21 -0
- package/dist/utils/detect-format.d.ts +5 -0
- package/dist/utils/detect-format.js +125 -0
- package/dist/utils/dtl-mask.d.ts +25 -0
- package/dist/utils/dtl-mask.js +58 -0
- package/dist/utils/extraction-options.d.ts +23 -0
- package/dist/utils/extraction-options.js +23 -0
- package/dist/utils/file-utils.d.ts +33 -0
- package/dist/utils/file-utils.js +262 -0
- package/dist/utils/glossary-match.d.ts +9 -0
- package/dist/utils/glossary-match.js +25 -0
- package/dist/utils/html-extractor.d.ts +8 -0
- package/dist/utils/html-extractor.js +317 -0
- package/dist/utils/i18next-icu.d.ts +38 -0
- package/dist/utils/i18next-icu.js +159 -0
- package/dist/utils/icu-message.d.ts +65 -0
- package/dist/utils/icu-message.js +346 -0
- package/dist/utils/icu-spans.d.ts +10 -0
- package/dist/utils/icu-spans.js +126 -0
- package/dist/utils/jsx-message.d.ts +93 -0
- package/dist/utils/jsx-message.js +621 -0
- package/dist/utils/key-usage.d.ts +35 -0
- package/dist/utils/key-usage.js +247 -0
- package/dist/utils/logger.d.ts +24 -0
- package/dist/utils/logger.js +33 -0
- package/dist/utils/metadata.d.ts +80 -0
- package/dist/utils/metadata.js +255 -0
- package/dist/utils/module-constants.d.ts +30 -0
- package/dist/utils/module-constants.js +265 -0
- package/dist/utils/not-injected.d.ts +6 -0
- package/dist/utils/not-injected.js +36 -0
- package/dist/utils/placeholder-protection.d.ts +57 -0
- package/dist/utils/placeholder-protection.js +136 -0
- package/dist/utils/pseudo-markers.d.ts +9 -0
- package/dist/utils/pseudo-markers.js +16 -0
- package/dist/utils/py-extractor.d.ts +14 -0
- package/dist/utils/py-extractor.js +285 -0
- package/dist/utils/string-utils.d.ts +52 -0
- package/dist/utils/string-utils.js +270 -0
- package/dist/utils/translation-quality.d.ts +34 -0
- package/dist/utils/translation-quality.js +156 -0
- package/dist/utils/translation-sanitizer.d.ts +10 -0
- package/dist/utils/translation-sanitizer.js +57 -0
- package/dist/utils/translator-caller.d.ts +32 -0
- package/dist/utils/translator-caller.js +217 -0
- package/dist/utils/translator-load-balancer.d.ts +73 -0
- package/dist/utils/translator-load-balancer.js +293 -0
- package/dist/utils/ui-copy.d.ts +32 -0
- package/dist/utils/ui-copy.js +126 -0
- package/dist/utils/workspace.d.ts +14 -0
- package/dist/utils/workspace.js +89 -0
- package/package.json +64 -4
- package/schemas/translate-report.schema.json +297 -0
- package/README.md +0 -3
|
@@ -0,0 +1,932 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.FALLBACK_QA_WARNING = void 0;
|
|
37
|
+
exports.translateLocales = translateLocales;
|
|
38
|
+
exports.backfillTranslationMemory = backfillTranslationMemory;
|
|
39
|
+
const path = __importStar(require("path"));
|
|
40
|
+
const cache_keys_1 = require("../../utils/cache-keys");
|
|
41
|
+
const logger_1 = require("../../utils/logger");
|
|
42
|
+
const translation_quality_1 = require("../../utils/translation-quality");
|
|
43
|
+
const file_utils_1 = require("../../utils/file-utils");
|
|
44
|
+
const placeholder_protection_1 = require("../../utils/placeholder-protection");
|
|
45
|
+
const icu_message_1 = require("../../utils/icu-message");
|
|
46
|
+
const keygen_1 = require("../keygen");
|
|
47
|
+
const concurrent_map_1 = require("../../utils/concurrent-map");
|
|
48
|
+
const language_codes_1 = require("./language-codes");
|
|
49
|
+
const glossary_1 = require("../glossary");
|
|
50
|
+
const gemini_provider_1 = require("./client/gemini.provider");
|
|
51
|
+
const memory_matcher_1 = require("./memory/memory-matcher");
|
|
52
|
+
const default_translation_provider_1 = require("../../providers/default-translation-provider");
|
|
53
|
+
const translation_sanitizer_1 = require("../../utils/translation-sanitizer");
|
|
54
|
+
const glossary_match_1 = require("../../utils/glossary-match");
|
|
55
|
+
const icu_spans_1 = require("../../utils/icu-spans");
|
|
56
|
+
const BATCH_SIZE = 15;
|
|
57
|
+
/** QA note on keys the backup (NLLB) translator produced. */
|
|
58
|
+
exports.FALLBACK_QA_WARNING = 'translated with the backup translator; will be retried with the AI translator next run';
|
|
59
|
+
/** Keys re-sent after failing validation go in small batches with per-key notes. */
|
|
60
|
+
const REPAIR_BATCH_SIZE = 5;
|
|
61
|
+
const TRANSLATE_CONCURRENCY = 3; // Max concurrent locales/batches
|
|
62
|
+
/** A plain source whose stored translation is ICU only fits a project whose runtime formats ICU. */
|
|
63
|
+
function reusableTranslation(source, translated, targetPlurals) {
|
|
64
|
+
return targetPlurals || (0, icu_message_1.isIcuMessage)(source) || !(0, icu_message_1.isIcuMessage)(translated);
|
|
65
|
+
}
|
|
66
|
+
/** Per-entry instruction: pluralize a count argument if the target language needs it. */
|
|
67
|
+
function pluralHint(args, targetLocale) {
|
|
68
|
+
const list = args.map((a) => `{${a}}`).join(', ');
|
|
69
|
+
return `${list} ${args.length === 1 ? 'is a number' : 'are numbers'}. If ${targetLocale} changes the words around it by number, return an ICU plural over it, e.g. {${args[0]}, plural, one {…} other {…}} with every ${targetLocale} plural category and "#" in each branch; otherwise keep ${list} as plain text. Keep every {argument} name and tag exactly.`;
|
|
70
|
+
}
|
|
71
|
+
function chunk(items, size) {
|
|
72
|
+
const out = [];
|
|
73
|
+
for (let i = 0; i < items.length; i += size)
|
|
74
|
+
out.push(items.slice(i, i + size));
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* A full sentence (3+ real words) that came back identical to the source was
|
|
79
|
+
* not translated. Short strings ("OK", "Email", a product name) legitimately
|
|
80
|
+
* stay the same in many languages. Both arguments are the tokenized text, so
|
|
81
|
+
* placeholders and terms neither count as words nor hide a difference.
|
|
82
|
+
*/
|
|
83
|
+
function isUnchangedSentence(sentText, returnedText) {
|
|
84
|
+
const norm = (t) => t.replace(/\[\s*#\s*VAR_\d+\s*#\s*\]/gi, ' ').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
85
|
+
const words = norm(sentText).split(' ').filter((w) => /\p{L}{2,}/u.test(w));
|
|
86
|
+
return words.length >= 3 && norm(sentText) === norm(returnedText);
|
|
87
|
+
}
|
|
88
|
+
function escapeTerm(term) {
|
|
89
|
+
return term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
90
|
+
}
|
|
91
|
+
function containsTerm(text, term) {
|
|
92
|
+
return new RegExp(`(?<![\\w])${escapeTerm(term)}(?![\\w])`, 'i').test(text);
|
|
93
|
+
}
|
|
94
|
+
/** Tags, URLs, code and never-translate terms present in the source but absent from the output. */
|
|
95
|
+
function missingSpans(source, translated, neverTranslateTerms) {
|
|
96
|
+
const have = new Set((0, placeholder_protection_1.protectText)(translated, neverTranslateTerms, { icu: true }).tokens.map((t) => t.toLowerCase()));
|
|
97
|
+
const want = new Set((0, placeholder_protection_1.protectText)(source, neverTranslateTerms, { icu: true }).tokens);
|
|
98
|
+
return [...want].filter((t) => !have.has(t.toLowerCase()));
|
|
99
|
+
}
|
|
100
|
+
/** Glossary terms in the source whose required translation is missing from the output. */
|
|
101
|
+
function glossaryWarnings(source, translated, glossary) {
|
|
102
|
+
return glossary
|
|
103
|
+
.filter((g) => containsTerm(source, g.term) && !(0, glossary_match_1.containsGlossaryTranslation)(translated, g.translation))
|
|
104
|
+
.map((g) => `Glossary: "${g.term}" should be translated as "${g.translation}"`);
|
|
105
|
+
}
|
|
106
|
+
const SCRIPT_RANGES = [
|
|
107
|
+
[0x0000, 0x024F], // Latin (Basic, Latin-1, Extended-A/B) — one script
|
|
108
|
+
[0x0370, 0x03FF], // Greek
|
|
109
|
+
[0x0400, 0x04FF], // Cyrillic
|
|
110
|
+
[0x0590, 0x05FF], // Hebrew
|
|
111
|
+
[0x0600, 0x06FF], // Arabic
|
|
112
|
+
[0x0900, 0x097F], // Devanagari
|
|
113
|
+
[0x0980, 0x09FF], // Bengali
|
|
114
|
+
[0x0E00, 0x0E7F], // Thai
|
|
115
|
+
[0x1100, 0x11FF], // Hangul Jamo
|
|
116
|
+
[0x3040, 0x309F], // Hiragana
|
|
117
|
+
[0x30A0, 0x30FF], // Katakana
|
|
118
|
+
[0x4E00, 0x9FFF], // CJK Unified
|
|
119
|
+
[0xAC00, 0xD7AF], // Hangul Syllables
|
|
120
|
+
];
|
|
121
|
+
function detectScript(ch) {
|
|
122
|
+
const code = ch.codePointAt(0);
|
|
123
|
+
for (let i = 0; i < SCRIPT_RANGES.length; i++) {
|
|
124
|
+
if (code >= SCRIPT_RANGES[i][0] && code <= SCRIPT_RANGES[i][1])
|
|
125
|
+
return i;
|
|
126
|
+
}
|
|
127
|
+
return -1;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* True when the text's letters come from more than one writing system
|
|
131
|
+
* (e.g. "Hello 世界"). Only letters count: "Café", "© 2024", "10 °C" and
|
|
132
|
+
* non-breaking spaces are plain Latin text.
|
|
133
|
+
*/
|
|
134
|
+
function hasMixedScripts(text) {
|
|
135
|
+
let seen = -1;
|
|
136
|
+
for (const ch of text) {
|
|
137
|
+
if (!/\p{L}/u.test(ch))
|
|
138
|
+
continue;
|
|
139
|
+
const script = detectScript(ch);
|
|
140
|
+
if (script === -1)
|
|
141
|
+
continue;
|
|
142
|
+
if (seen === -1) {
|
|
143
|
+
seen = script;
|
|
144
|
+
}
|
|
145
|
+
else if (script !== seen) {
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Load a project's glossary and brand voice. Never throws: a failure means
|
|
153
|
+
* translating without that rule, as before. The two lookups are independent,
|
|
154
|
+
* so a glossary failure doesn't also drop the brand voice. `complete` is false
|
|
155
|
+
* if either failed — callers that copy text into shared TM must then assume
|
|
156
|
+
* project-specific wording may be baked in.
|
|
157
|
+
*/
|
|
158
|
+
async function loadProjectTranslationRules(projectId, rules) {
|
|
159
|
+
let neverTranslateTerms = [];
|
|
160
|
+
let alwaysTranslateAsEntries = [];
|
|
161
|
+
let brandVoice = null;
|
|
162
|
+
let complete = true;
|
|
163
|
+
if (!rules)
|
|
164
|
+
return { neverTranslateTerms, alwaysTranslateAsEntries, brandVoice, complete };
|
|
165
|
+
try {
|
|
166
|
+
const entries = await rules.getGlossary(projectId);
|
|
167
|
+
if (entries?.length) {
|
|
168
|
+
neverTranslateTerms = entries
|
|
169
|
+
.filter((e) => e.type === 'never_translate')
|
|
170
|
+
.map((e) => e.term);
|
|
171
|
+
alwaysTranslateAsEntries = entries
|
|
172
|
+
.filter((e) => e.type === 'always_translate_as')
|
|
173
|
+
.map((e) => ({
|
|
174
|
+
term: e.term,
|
|
175
|
+
type: 'always_translate_as',
|
|
176
|
+
translations: e.translations || {},
|
|
177
|
+
}));
|
|
178
|
+
logger_1.engineLogger.info('Loaded project glossary', {
|
|
179
|
+
neverTranslate: neverTranslateTerms.length,
|
|
180
|
+
alwaysTranslateAs: alwaysTranslateAsEntries.length,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
complete = false;
|
|
186
|
+
logger_1.engineLogger.warn('Failed to load glossary entries', { error: err });
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
brandVoice = await rules.getBrandVoice(projectId);
|
|
190
|
+
if (brandVoice) {
|
|
191
|
+
logger_1.engineLogger.info('Brand voice configured', {
|
|
192
|
+
length: brandVoice.length,
|
|
193
|
+
hash: (0, cache_keys_1.hashText)(brandVoice),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
complete = false;
|
|
199
|
+
logger_1.engineLogger.warn('Failed to load brand voice', { error: err });
|
|
200
|
+
}
|
|
201
|
+
return { neverTranslateTerms, alwaysTranslateAsEntries, brandVoice, complete };
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Whether a source string is subject to one of the project's "always
|
|
205
|
+
* translate as" rules for this locale, i.e. its stored translation has
|
|
206
|
+
* project-specific wording baked in.
|
|
207
|
+
*/
|
|
208
|
+
function hasAlwaysTranslateAsTerm(sourceText, entries, targetLocale) {
|
|
209
|
+
return entries.some((e) => !!e.translations[targetLocale] &&
|
|
210
|
+
new RegExp(`\\b${e.term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i').test(sourceText));
|
|
211
|
+
}
|
|
212
|
+
async function translateLocales(workspace, sourceLocale, targetLocales, forceTranslation = false, dryRun = false, projectId, onProgress, keysToTranslateByLocale, cacheProvider, byokConfig, contextMap, extractionContextMap, providers = {}, options = {}) {
|
|
213
|
+
const locales = [];
|
|
214
|
+
const errors = [];
|
|
215
|
+
const failures = [];
|
|
216
|
+
let tmHitsTotal = 0;
|
|
217
|
+
const perLocaleMetadata = {};
|
|
218
|
+
// Ensure locale output directories exist for all target locales
|
|
219
|
+
if (!dryRun) {
|
|
220
|
+
for (const locale of targetLocales) {
|
|
221
|
+
const { dir } = (0, file_utils_1.localeFilePath)(workspace.path, workspace.publicDir, workspace.localeFormat, locale);
|
|
222
|
+
await (0, file_utils_1.ensureDir)(dir);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const flatSource = (0, keygen_1.flattenLocaleData)(sourceLocale.data);
|
|
226
|
+
// Strip _meta.* entries from flatSource so meta fields are never sent to the
|
|
227
|
+
// translation engine, never stored in the lockfile, and never overwrite the
|
|
228
|
+
// target locale's own computed _meta values.
|
|
229
|
+
for (const key of Object.keys(flatSource)) {
|
|
230
|
+
if (key === '_meta' || key.startsWith('_meta.')) {
|
|
231
|
+
delete flatSource[key];
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// Fetch glossary entries for this project
|
|
235
|
+
let neverTranslateTerms = [];
|
|
236
|
+
let alwaysTranslateAsEntries = [];
|
|
237
|
+
let brandVoice = null;
|
|
238
|
+
let rulesComplete = true;
|
|
239
|
+
if (projectId) {
|
|
240
|
+
const rules = await loadProjectTranslationRules(projectId, providers.rules);
|
|
241
|
+
neverTranslateTerms = rules.neverTranslateTerms;
|
|
242
|
+
alwaysTranslateAsEntries = rules.alwaysTranslateAsEntries;
|
|
243
|
+
brandVoice = rules.brandVoice;
|
|
244
|
+
rulesComplete = rules.complete;
|
|
245
|
+
}
|
|
246
|
+
// Fall back to workspace config for CLI/MCP runs (no projectId)
|
|
247
|
+
if (!projectId) {
|
|
248
|
+
try {
|
|
249
|
+
const babelizeConfigPath = path.join(workspace.path, '.babelize', 'config.json');
|
|
250
|
+
const fileUtils = await Promise.resolve().then(() => __importStar(require('../../utils/file-utils')));
|
|
251
|
+
const { readJsonFile } = fileUtils;
|
|
252
|
+
if (await fileUtils.pathExists(babelizeConfigPath)) {
|
|
253
|
+
const config = await readJsonFile(babelizeConfigPath);
|
|
254
|
+
if (config?.customTranslations) {
|
|
255
|
+
for (const [term, entry] of Object.entries(config.customTranslations)) {
|
|
256
|
+
if (entry.neverTranslate) {
|
|
257
|
+
neverTranslateTerms.push(term);
|
|
258
|
+
}
|
|
259
|
+
if (entry.translations) {
|
|
260
|
+
alwaysTranslateAsEntries.push({
|
|
261
|
+
term,
|
|
262
|
+
type: 'always_translate_as',
|
|
263
|
+
translations: entry.translations,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
logger_1.engineLogger.info('Loaded glossary from workspace config', {
|
|
268
|
+
neverTranslate: neverTranslateTerms.length,
|
|
269
|
+
alwaysTranslateAs: alwaysTranslateAsEntries.length,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
logger_1.engineLogger.warn('Failed to load glossary from workspace config', { error: err });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// Check if translator is configured before processing locales
|
|
279
|
+
if (!byokConfig && !providers.translator && !process.env.TRANSLATOR_URL && targetLocales.some(l => l !== sourceLocale.locale)) {
|
|
280
|
+
logger_1.engineLogger.error('TRANSLATOR_URL environment variable is not set and no BYOK config provided. Translations will fall back to source text.');
|
|
281
|
+
}
|
|
282
|
+
const localeTasks = targetLocales
|
|
283
|
+
.filter((l) => l !== sourceLocale.locale && (0, language_codes_1.isSupported)(l));
|
|
284
|
+
const { results: localeResults, errors: localeErrors } = await (0, concurrent_map_1.concurrentMap)(localeTasks, async (targetLocale) => {
|
|
285
|
+
const targetLangCode = (0, language_codes_1.toFlores)(targetLocale);
|
|
286
|
+
const localePaths = (0, file_utils_1.localeFilePath)(workspace.path, workspace.publicDir, workspace.localeFormat, targetLocale);
|
|
287
|
+
const targetPath = localePaths.file;
|
|
288
|
+
const relativePath = localePaths.relativeFile;
|
|
289
|
+
let entriesToTranslate = flatSource;
|
|
290
|
+
if (keysToTranslateByLocale && keysToTranslateByLocale[targetLocale]) {
|
|
291
|
+
entriesToTranslate = {};
|
|
292
|
+
for (const key of keysToTranslateByLocale[targetLocale]) {
|
|
293
|
+
if (flatSource[key]) {
|
|
294
|
+
entriesToTranslate[key] = flatSource[key];
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (Object.keys(entriesToTranslate).length === 0) {
|
|
299
|
+
return {
|
|
300
|
+
locale: targetLocale,
|
|
301
|
+
file: { locale: targetLocale, path: relativePath, data: {}, isNew: true },
|
|
302
|
+
tmHits: 0,
|
|
303
|
+
metadata: {},
|
|
304
|
+
failures: {},
|
|
305
|
+
error: null,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
const sourceLangCode = (0, language_codes_1.toFlores)(sourceLocale.locale);
|
|
309
|
+
// LLM translators (BYOK, or an injected one such as Babelize-hosted)
|
|
310
|
+
// take the brand voice in the prompt; NLLB output is polished after.
|
|
311
|
+
const hasBrandVoiceInPrompt = !!((byokConfig || providers.translator) && brandVoice);
|
|
312
|
+
const translationResult = await translateEntries(entriesToTranslate, sourceLangCode, targetLangCode, projectId, onProgress, neverTranslateTerms, alwaysTranslateAsEntries, targetLocale, cacheProvider, byokConfig, contextMap, extractionContextMap, hasBrandVoiceInPrompt ? (brandVoice || undefined) : undefined, dryRun, forceTranslation, providers, rulesComplete, options);
|
|
313
|
+
const entriesResult = translationResult.result;
|
|
314
|
+
const batchStats = translationResult.stats;
|
|
315
|
+
const batchMetadata = translationResult.metadata;
|
|
316
|
+
const keyFailures = translationResult.failures;
|
|
317
|
+
if (Object.keys(entriesResult).length === 0 && Object.keys(entriesToTranslate).length > 0) {
|
|
318
|
+
// Nothing translated: write no file. Every key is in keyFailures with
|
|
319
|
+
// its reason, so the caller can report exactly what went wrong.
|
|
320
|
+
logger_1.engineLogger.error(`No strings could be translated for locale "${targetLocale}"`, {
|
|
321
|
+
locale: targetLocale, sourceKeys: Object.keys(entriesToTranslate).length,
|
|
322
|
+
});
|
|
323
|
+
return { locale: targetLocale, file: null, tmHits: batchStats.tmHits, metadata: batchMetadata, failures: keyFailures, error: null };
|
|
324
|
+
}
|
|
325
|
+
let translatedEntries = entriesResult;
|
|
326
|
+
// Post-hoc brand voice polish only for the NLLB service; LLM
|
|
327
|
+
// translators already had the brand voice in the prompt.
|
|
328
|
+
if (brandVoice && !hasBrandVoiceInPrompt) {
|
|
329
|
+
// TM hits are approved/previously-accepted text: polishing them again
|
|
330
|
+
// would rewrite reviewed translations nondeterministically every run.
|
|
331
|
+
const toPolish = {};
|
|
332
|
+
for (const [k, v] of Object.entries(translatedEntries)) {
|
|
333
|
+
if (!batchMetadata[k]?.fromMemory)
|
|
334
|
+
toPolish[k] = v;
|
|
335
|
+
}
|
|
336
|
+
if (Object.keys(toPolish).length > 0) {
|
|
337
|
+
const polished = await (0, gemini_provider_1.polishWithBrandVoice)(toPolish, brandVoice, targetLocale);
|
|
338
|
+
if (polished !== toPolish) {
|
|
339
|
+
// The polish is a free rewrite of already-validated text: keep a
|
|
340
|
+
// rewrite only if it still has exactly the same placeholders, tags
|
|
341
|
+
// and terms (and, for ICU, is still a valid equivalent message).
|
|
342
|
+
const accepted = {};
|
|
343
|
+
let rejected = 0;
|
|
344
|
+
for (const [k, v] of Object.entries(polished)) {
|
|
345
|
+
const before = toPolish[k];
|
|
346
|
+
if (before === undefined || typeof v !== 'string' || !v.trim())
|
|
347
|
+
continue;
|
|
348
|
+
// ICU (in the source, or a plural the translation added) must
|
|
349
|
+
// stay an equivalent message of the validated translation.
|
|
350
|
+
const icu = (0, icu_message_1.isIcuMessage)(entriesToTranslate[k] ?? '') || (0, icu_message_1.isIcuMessage)(before) || (0, icu_message_1.isIcuMessage)(v);
|
|
351
|
+
const safe = (0, placeholder_protection_1.sameProtectedSpans)(before, v, neverTranslateTerms) &&
|
|
352
|
+
(!icu || !(0, icu_message_1.compareIcuMessages)(before, v, targetLocale).some((i) => i.severity === 'error'));
|
|
353
|
+
if (safe)
|
|
354
|
+
accepted[k] = v;
|
|
355
|
+
else
|
|
356
|
+
rejected++;
|
|
357
|
+
}
|
|
358
|
+
logger_1.engineLogger.info('Brand voice polish applied', { locale: targetLocale, accepted: Object.keys(accepted).length, rejected });
|
|
359
|
+
translatedEntries = { ...translatedEntries, ...accepted };
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
let translatedData = (0, keygen_1.unflattenLocaleData)(translatedEntries);
|
|
364
|
+
const existingTranslatedMeta = typeof translatedData._meta === 'object' && translatedData._meta !== null
|
|
365
|
+
? translatedData._meta
|
|
366
|
+
: {};
|
|
367
|
+
translatedData = {
|
|
368
|
+
...translatedData,
|
|
369
|
+
_meta: { ...existingTranslatedMeta, dir: (0, language_codes_1.isRTL)(targetLocale) ? 'rtl' : 'ltr', language: targetLocale },
|
|
370
|
+
};
|
|
371
|
+
if (!dryRun)
|
|
372
|
+
await (0, file_utils_1.writeJsonFile)(targetPath, translatedData, true);
|
|
373
|
+
return {
|
|
374
|
+
locale: targetLocale,
|
|
375
|
+
file: {
|
|
376
|
+
locale: targetLocale,
|
|
377
|
+
path: relativePath,
|
|
378
|
+
data: translatedData,
|
|
379
|
+
isNew: true,
|
|
380
|
+
isRTL: (0, language_codes_1.isRTL)(targetLocale),
|
|
381
|
+
},
|
|
382
|
+
tmHits: batchStats.tmHits,
|
|
383
|
+
metadata: batchMetadata,
|
|
384
|
+
failures: keyFailures,
|
|
385
|
+
error: null,
|
|
386
|
+
};
|
|
387
|
+
}, TRANSLATE_CONCURRENCY);
|
|
388
|
+
for (const r of localeResults) {
|
|
389
|
+
if (!r)
|
|
390
|
+
continue;
|
|
391
|
+
for (const [key, f] of Object.entries(r.failures ?? {})) {
|
|
392
|
+
failures.push({ locale: r.locale, key, source: flatSource[key] ?? '', ...f });
|
|
393
|
+
}
|
|
394
|
+
if (r.error) {
|
|
395
|
+
errors.push({ locale: r.locale, error: r.error });
|
|
396
|
+
}
|
|
397
|
+
else if (r.file) {
|
|
398
|
+
locales.push(r.file);
|
|
399
|
+
tmHitsTotal += r.tmHits;
|
|
400
|
+
perLocaleMetadata[r.locale] = r.metadata;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
// Collect errors from unsupported locales
|
|
404
|
+
for (const targetLocale of targetLocales) {
|
|
405
|
+
if (targetLocale === sourceLocale.locale)
|
|
406
|
+
continue;
|
|
407
|
+
if (!(0, language_codes_1.isSupported)(targetLocale)) {
|
|
408
|
+
errors.push({ locale: targetLocale, error: `Unsupported language code: ${targetLocale}` });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
// Fail immediately when BYOK translation errors occurred
|
|
412
|
+
if (byokConfig && (errors.length > 0 || localeErrors.length > 0)) {
|
|
413
|
+
throw localeErrors.length > 0 ? localeErrors[0] : new Error(errors[0].error);
|
|
414
|
+
}
|
|
415
|
+
return {
|
|
416
|
+
translation: { locales, errors, failures },
|
|
417
|
+
stats: { tmHits: tmHitsTotal },
|
|
418
|
+
metadata: perLocaleMetadata
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
async function translateEntries(entries, sourceLang, targetLang, projectId, onProgress, neverTranslateTerms = [], alwaysTranslateAsEntries = [], targetLocale = '', cacheProvider, byokConfig, contextMap, extractionContextMap, brandVoice, dryRun = false, forceTranslation = false, providers = {}, rulesComplete = true, options = {}) {
|
|
422
|
+
const result = {};
|
|
423
|
+
const metadata = {};
|
|
424
|
+
const keys = Object.keys(entries);
|
|
425
|
+
// Cache and TM key: the FLORES code, plus the region for regional variants
|
|
426
|
+
// (pt-BR vs pt-PT share por_Latn but must not share translations).
|
|
427
|
+
const storeLang = targetLocale && (0, language_codes_1.isSupported)(targetLocale) ? (0, language_codes_1.storageLangCode)(targetLocale) : targetLang;
|
|
428
|
+
let uncachedEntries = [];
|
|
429
|
+
let cacheHits = 0;
|
|
430
|
+
let tmHits = 0;
|
|
431
|
+
const cacheBackfill = [];
|
|
432
|
+
const markInTm = (keys) => {
|
|
433
|
+
for (const k of keys)
|
|
434
|
+
metadata[k] = { ...(metadata[k] ?? {}), inTm: true };
|
|
435
|
+
};
|
|
436
|
+
for (const key of keys) {
|
|
437
|
+
const text = entries[key];
|
|
438
|
+
const entryContext = contextMap?.[key];
|
|
439
|
+
const textForHash = entryContext ? `${text}:::${entryContext}` : text;
|
|
440
|
+
const textHash = (0, cache_keys_1.hashText)(textForHash);
|
|
441
|
+
const cacheKey = (0, cache_keys_1.translationCacheKey)(sourceLang, storeLang, textHash);
|
|
442
|
+
const cached = cacheProvider && !forceTranslation ? await cacheProvider.get(cacheKey) : { hit: false, data: null };
|
|
443
|
+
if (cached.hit && cached.data !== null && reusableTranslation(text, cached.data, !!options.targetPlurals)) {
|
|
444
|
+
result[key] = applyGlossaryPostProcessing(text, cached.data, neverTranslateTerms, alwaysTranslateAsEntries, targetLocale);
|
|
445
|
+
cacheHits++;
|
|
446
|
+
// Reused, not translated this run (billing counts only fresh output).
|
|
447
|
+
metadata[key] = { ...(metadata[key] ?? {}), fromCache: true };
|
|
448
|
+
// Keep out of shared TM: sentence fragments (context-dependent), and —
|
|
449
|
+
// since cache entries written before TM stored pre-glossary text may
|
|
450
|
+
// have them baked in — strings with project glossary rules or an
|
|
451
|
+
// in-prompt brand voice, which must not leak into other projects.
|
|
452
|
+
if (rulesComplete && !entryContext && !brandVoice && !hasAlwaysTranslateAsTerm(text, alwaysTranslateAsEntries, targetLocale)) {
|
|
453
|
+
cacheBackfill.push({ key, sourceText: text, translatedText: restoreNeverTranslate(text, cached.data, neverTranslateTerms) });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
else {
|
|
457
|
+
uncachedEntries.push({ key, value: text, hash: textHash, context: entryContext });
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
const memory = providers.memory;
|
|
461
|
+
const translator = providers.translator ?? (0, default_translation_provider_1.createDefaultTranslationProvider)(byokConfig);
|
|
462
|
+
let tmContext = null;
|
|
463
|
+
if (projectId && memory) {
|
|
464
|
+
try {
|
|
465
|
+
tmContext = await memory.getProjectContext(projectId);
|
|
466
|
+
}
|
|
467
|
+
catch (err) {
|
|
468
|
+
logger_1.engineLogger.error('Failed to resolve TM owner; TM disabled for this locale', { projectId, error: err });
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
const ownerId = memory ? tmContext?.ownerId ?? null : null;
|
|
472
|
+
if (cacheBackfill.length > 0 && projectId && tmContext && !dryRun) {
|
|
473
|
+
// Redis hits never reach the translator, so without this a re-run would
|
|
474
|
+
// never populate TM for strings translated before TM writes worked.
|
|
475
|
+
if (await persistToTM(memory, cacheBackfill, projectId, tmContext, sourceLang, storeLang, { overwrite: false })) {
|
|
476
|
+
markInTm(cacheBackfill.map((i) => i.key));
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
// When the TM lookup fails we can't tell which strings already have a
|
|
480
|
+
// (possibly human-edited) TM entry, so later writes must not overwrite.
|
|
481
|
+
let tmLookupFailed = false;
|
|
482
|
+
// Source texts whose TM entry a human approved: a forced re-translate still
|
|
483
|
+
// uses fresh output for this run but must not overwrite those entries.
|
|
484
|
+
const approvedSourceTexts = new Set();
|
|
485
|
+
if (forceTranslation && ownerId && projectId && !dryRun && uncachedEntries.length > 0) {
|
|
486
|
+
try {
|
|
487
|
+
const existing = await memory.getAllForOwner(ownerId, sourceLang, storeLang);
|
|
488
|
+
for (const m of existing)
|
|
489
|
+
if (m.approvedAt)
|
|
490
|
+
approvedSourceTexts.add(m.sourceText);
|
|
491
|
+
}
|
|
492
|
+
catch (err) {
|
|
493
|
+
tmLookupFailed = true;
|
|
494
|
+
logger_1.engineLogger.error('Failed to read Translation Memory before forced re-translate; TM writes will not overwrite', { error: err });
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
if (uncachedEntries.length > 0 && ownerId && !forceTranslation) {
|
|
498
|
+
try {
|
|
499
|
+
logger_1.engineLogger.info('Checking Memory for entries', {
|
|
500
|
+
count: uncachedEntries.length,
|
|
501
|
+
ownerId,
|
|
502
|
+
targetLocale,
|
|
503
|
+
});
|
|
504
|
+
// 1. Fetch all TM for owner (for fuzzy matching)
|
|
505
|
+
// TM rows store FLORES codes (eng_Latn → fra_Latn), so look up by the
|
|
506
|
+
// FLORES codes, not the ISO locale.
|
|
507
|
+
const allMemory = await memory.getAllForOwner(ownerId, sourceLang, storeLang);
|
|
508
|
+
const stillUncached = [];
|
|
509
|
+
for (const entry of uncachedEntries) {
|
|
510
|
+
// TM is context-free; a sentence fragment's translation depends on
|
|
511
|
+
// the sentence around it, so never auto-apply TM to one.
|
|
512
|
+
if (entry.context) {
|
|
513
|
+
stillUncached.push(entry);
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
const lookup = (0, memory_matcher_1.matchFromMemory)(allMemory, entry.value, storeLang);
|
|
517
|
+
if (lookup.hit && lookup.translatedText && reusableTranslation(entry.value, lookup.translatedText, !!options.targetPlurals)) {
|
|
518
|
+
result[entry.key] = applyGlossaryPostProcessing(entry.value, lookup.translatedText, neverTranslateTerms, alwaysTranslateAsEntries, targetLocale);
|
|
519
|
+
tmHits++;
|
|
520
|
+
metadata[entry.key] = { fromMemory: true, inTm: true };
|
|
521
|
+
// Warm Redis cache (stored pre-glossary, like every other cache write)
|
|
522
|
+
const cacheKey = (0, cache_keys_1.translationCacheKey)(sourceLang, storeLang, entry.hash);
|
|
523
|
+
if (cacheProvider) {
|
|
524
|
+
cacheProvider.set(cacheKey, lookup.translatedText, cache_keys_1.TRANSLATION_CACHE_TTL_SECONDS).catch((err) => {
|
|
525
|
+
logger_1.engineLogger.error('Failed to warm Redis cache from TM', { cacheKey, error: err });
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
else if (lookup.fuzzyMatches && lookup.fuzzyMatches.length > 0) {
|
|
530
|
+
// Store fuzzy matches as metadata so they can be surfaced as suggestions in dashboard
|
|
531
|
+
metadata[entry.key] = { fuzzyMatches: lookup.fuzzyMatches };
|
|
532
|
+
stillUncached.push(entry);
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
535
|
+
stillUncached.push(entry);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
uncachedEntries = stillUncached;
|
|
539
|
+
}
|
|
540
|
+
catch (err) {
|
|
541
|
+
tmLookupFailed = true;
|
|
542
|
+
logger_1.engineLogger.error('Failed to query Translation Memory; new TM writes will not overwrite existing entries', { error: err });
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (keys.length > 0) {
|
|
546
|
+
const hitRate = Math.round(((cacheHits + tmHits) / keys.length) * 100);
|
|
547
|
+
logger_1.engineLogger.info('Translation cache stats', {
|
|
548
|
+
cacheHits,
|
|
549
|
+
tmHits,
|
|
550
|
+
total: keys.length,
|
|
551
|
+
hitRate,
|
|
552
|
+
toTranslate: uncachedEntries.length,
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
const failures = {};
|
|
556
|
+
// LLM providers get structure-aware prompts and repair attempts; the NLLB
|
|
557
|
+
// service can't handle ICU or mixed-script text, so those fail up front.
|
|
558
|
+
const isLlm = !!byokConfig || !!providers.translator;
|
|
559
|
+
// Scripts FLORES-200 lacks (sr-Latn, uz-Cyrl) only work with an LLM.
|
|
560
|
+
const nllbTarget = !targetLocale || !(0, language_codes_1.isSupported)(targetLocale) || (0, language_codes_1.isNllbSupported)(targetLocale);
|
|
561
|
+
if (uncachedEntries.length > 0) {
|
|
562
|
+
const glossaryForLocale = alwaysTranslateAsEntries
|
|
563
|
+
.filter((e) => e.translations[targetLocale])
|
|
564
|
+
.map((e) => ({ term: e.term, translation: e.translations[targetLocale] }));
|
|
565
|
+
// 1. Protect placeholders, tags and never-translate terms. Strings with
|
|
566
|
+
// nothing translatable left (e.g. "{count}", a brand name) are their own
|
|
567
|
+
// translation and never reach the translator.
|
|
568
|
+
const prepared = [];
|
|
569
|
+
for (const entry of uncachedEntries) {
|
|
570
|
+
const icu = (0, icu_message_1.isIcuMessage)(entry.value);
|
|
571
|
+
if (!isLlm && (icu || hasMixedScripts(entry.value) || !nllbTarget)) {
|
|
572
|
+
failures[entry.key] = {
|
|
573
|
+
reason: 'unsupported_by_provider',
|
|
574
|
+
retryable: false,
|
|
575
|
+
message: !nllbTarget
|
|
576
|
+
? `${targetLocale} needs an LLM translation provider`
|
|
577
|
+
: icu
|
|
578
|
+
? 'ICU plural/select messages need an LLM translation provider'
|
|
579
|
+
: 'Text mixing writing systems needs an LLM translation provider',
|
|
580
|
+
};
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
// ICU branches legitimately repeat or drop spans (Polish adds branches,
|
|
584
|
+
// Japanese keeps only "other"), so ICU text is sent untokenized and its
|
|
585
|
+
// tags/URLs/terms are checked as a set after translation instead. The
|
|
586
|
+
// same goes for a plain message with a count ("{files} files were
|
|
587
|
+
// uploaded"): an LLM may return a plural for a language that needs one.
|
|
588
|
+
const countArgs = isLlm && options.targetPlurals && !icu ? (0, icu_message_1.countArguments)(entry.value) : [];
|
|
589
|
+
const tokenized = (0, placeholder_protection_1.protectText)(entry.value, neverTranslateTerms);
|
|
590
|
+
const sendRaw = icu || countArgs.length > 0;
|
|
591
|
+
const prot = sendRaw ? { text: entry.value, tokens: [] } : tokenized;
|
|
592
|
+
if ((0, placeholder_protection_1.hasNothingToTranslate)(tokenized.text)) {
|
|
593
|
+
result[entry.key] = entry.value;
|
|
594
|
+
// Copied through (placeholders/codes only); the translator never saw it.
|
|
595
|
+
metadata[entry.key] = { ...(metadata[entry.key] ?? {}), passthrough: true };
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
prepared.push({
|
|
599
|
+
...entry,
|
|
600
|
+
sendText: prot.text,
|
|
601
|
+
tokens: prot.tokens,
|
|
602
|
+
icu: sendRaw,
|
|
603
|
+
countArgs: countArgs.length ? countArgs : undefined,
|
|
604
|
+
plain: countArgs.length ? { sendText: tokenized.text, tokens: tokenized.tokens } : undefined,
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
// 2. Translate in batches; judge every key independently.
|
|
608
|
+
const outcomes = new Map();
|
|
609
|
+
const judge = (entry, keyResult, nllb = !isLlm) => {
|
|
610
|
+
const fail = (reason, retryable, message) => ({ ok: false, reason, retryable, message });
|
|
611
|
+
if (!keyResult)
|
|
612
|
+
return fail('missing_from_response', true, 'The translator returned no translation for this string');
|
|
613
|
+
if (keyResult.status === 'failed') {
|
|
614
|
+
return fail(keyResult.reason, keyResult.retryable, keyResult.message ?? `Translator reported ${keyResult.reason}`);
|
|
615
|
+
}
|
|
616
|
+
if (!keyResult.value.trim())
|
|
617
|
+
return fail('empty_value', true, 'The translator returned an empty string');
|
|
618
|
+
// A plain source must stay plain unless a plural was allowed for it.
|
|
619
|
+
if (!(0, icu_message_1.isIcuMessage)(entry.value) && !entry.countArgs && (0, icu_message_1.isIcuMessage)(keyResult.value)) {
|
|
620
|
+
const args = (0, icu_message_1.extractSimpleArguments)(entry.value);
|
|
621
|
+
return fail('icu_invalid', true, `The source is plain text: return plain text, not ICU plural/select syntax${args.length ? `, and keep ${args.map((a) => `{${a}}`).join(', ')} as is` : ''}`);
|
|
622
|
+
}
|
|
623
|
+
const tokenCheck = (0, placeholder_protection_1.checkTokens)(keyResult.value, entry.tokens.length);
|
|
624
|
+
if (!tokenCheck.ok)
|
|
625
|
+
return fail('placeholder_lost', true, `Placeholder problem: ${(0, placeholder_protection_1.describeTokenProblem)(tokenCheck, entry.tokens)}`);
|
|
626
|
+
const value = (0, placeholder_protection_1.restoreText)((0, translation_sanitizer_1.sanitizeTranslation)(keyResult.value, { nllb }), entry.tokens);
|
|
627
|
+
const warnings = [];
|
|
628
|
+
// A plural added for a count placeholder is validated as ICU too.
|
|
629
|
+
if (entry.icu || (0, icu_message_1.isIcuMessage)(value)) {
|
|
630
|
+
const issues = (0, icu_message_1.compareIcuMessages)(entry.value, value, targetLocale, { pluralizable: entry.countArgs ?? [] });
|
|
631
|
+
const error = issues.find((i) => i.severity === 'error');
|
|
632
|
+
if (error)
|
|
633
|
+
return fail('icu_invalid', true, error.message);
|
|
634
|
+
const lost = missingSpans(entry.value, value, neverTranslateTerms);
|
|
635
|
+
if (lost.length > 0)
|
|
636
|
+
return fail('placeholder_lost', true, `Placeholder problem: dropped ${lost.join(', ')}`);
|
|
637
|
+
// Every branch must keep its own tags/URLs/terms, not just one of them.
|
|
638
|
+
const branch = (0, icu_spans_1.branchSpanProblems)(entry.value, value, neverTranslateTerms);
|
|
639
|
+
if (branch.length > 0) {
|
|
640
|
+
const reason = branch.some((p) => p.lost.length) ? 'placeholder_lost' : 'markup_mismatch';
|
|
641
|
+
return fail(reason, true, `Placeholder problem in ${branch.map(icu_spans_1.describeBranchSpanProblem).join('; ')} (each branch needs the source's tags and links)`);
|
|
642
|
+
}
|
|
643
|
+
warnings.push(...issues.map((i) => i.message));
|
|
644
|
+
}
|
|
645
|
+
if (isUnchangedSentence(entry.sendText, keyResult.value)) {
|
|
646
|
+
return fail('untranslated', true, `Returned unchanged instead of translated into ${targetLocale}`);
|
|
647
|
+
}
|
|
648
|
+
const quality = (0, translation_quality_1.validateTranslationQuality)(entry.value, value);
|
|
649
|
+
// Tag counts legitimately change with an ICU message's branch count;
|
|
650
|
+
// its tags were already checked as a set above.
|
|
651
|
+
if (!entry.icu && !(0, icu_message_1.isIcuMessage)(value) && quality.issues?.some((i) => i.type === 'html_tag_mismatch')) {
|
|
652
|
+
return fail('markup_mismatch', true, 'HTML/JSX tags differ from the source');
|
|
653
|
+
}
|
|
654
|
+
if (quality.issues?.some((i) => i.type === 'suspicious_length')) {
|
|
655
|
+
warnings.push('Length differs a lot from the source; check it fits the UI');
|
|
656
|
+
}
|
|
657
|
+
return { ok: true, value, warnings };
|
|
658
|
+
};
|
|
659
|
+
const translateBatch = async (batch, hints) => {
|
|
660
|
+
const batchGlossary = [
|
|
661
|
+
...glossaryForLocale.filter((g) => batch.some((b) => containsTerm(b.value, g.term))),
|
|
662
|
+
// Untokenized ICU text: keep never-translate terms as-is via the glossary.
|
|
663
|
+
...neverTranslateTerms
|
|
664
|
+
.filter((t) => batch.some((b) => b.icu && containsTerm(b.value, t)))
|
|
665
|
+
.map((t) => ({ term: t, translation: t })),
|
|
666
|
+
];
|
|
667
|
+
const keyResults = await translator.translateBatch({
|
|
668
|
+
entries: batch.map((b) => ({
|
|
669
|
+
key: b.key,
|
|
670
|
+
value: b.sendText,
|
|
671
|
+
parentContext: contextMap?.[b.key],
|
|
672
|
+
extractionContext: extractionContextMap?.[b.key],
|
|
673
|
+
})),
|
|
674
|
+
sourceLang,
|
|
675
|
+
targetLang,
|
|
676
|
+
brandVoice,
|
|
677
|
+
targetLocale,
|
|
678
|
+
glossary: batchGlossary.length > 0 ? batchGlossary : undefined,
|
|
679
|
+
hints,
|
|
680
|
+
});
|
|
681
|
+
const byKey = new Map(keyResults.map((r) => [r.key, r]));
|
|
682
|
+
for (const b of batch)
|
|
683
|
+
outcomes.set(b.key, judge(b, byKey.get(b.key)));
|
|
684
|
+
};
|
|
685
|
+
// A failed request fails only its own keys — except BYOK, where it means
|
|
686
|
+
// the customer's key/quota is the problem and the whole run should stop
|
|
687
|
+
// with that error rather than produce a partial result.
|
|
688
|
+
const runBatches = async (batches, hintsFor) => {
|
|
689
|
+
let byokError = null;
|
|
690
|
+
await (0, concurrent_map_1.concurrentMap)(batches, async (batch, i) => {
|
|
691
|
+
onProgress?.(`Translating batch ${i + 1}/${batches.length} for ${targetLang}...`);
|
|
692
|
+
const hints = hintsFor
|
|
693
|
+
? Object.fromEntries(batch.map((b) => [b.key, hintsFor(b)]).filter(([, h]) => !!h))
|
|
694
|
+
: undefined;
|
|
695
|
+
try {
|
|
696
|
+
await translateBatch(batch, hints);
|
|
697
|
+
}
|
|
698
|
+
catch (error) {
|
|
699
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
700
|
+
logger_1.engineLogger.error('Translation batch failed', { error: message, sourceLang, targetLang });
|
|
701
|
+
if (byokConfig)
|
|
702
|
+
byokError ?? (byokError = error instanceof Error ? error : new Error(message));
|
|
703
|
+
for (const b of batch) {
|
|
704
|
+
outcomes.set(b.key, { ok: false, reason: 'provider_error', retryable: true, message });
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}, TRANSLATE_CONCURRENCY);
|
|
708
|
+
if (byokError)
|
|
709
|
+
throw byokError;
|
|
710
|
+
};
|
|
711
|
+
const firstPassHint = (b) => (b.countArgs ? pluralHint(b.countArgs, targetLocale) : undefined);
|
|
712
|
+
await runBatches(chunk(prepared, BATCH_SIZE), isLlm ? firstPassHint : undefined);
|
|
713
|
+
// 3. One targeted repair attempt for keys that failed validation, telling
|
|
714
|
+
// the model exactly what was wrong. NLLB is deterministic, so for it
|
|
715
|
+
// only keys it skipped are worth asking for again.
|
|
716
|
+
const toRepair = prepared.filter((b) => {
|
|
717
|
+
const o = outcomes.get(b.key);
|
|
718
|
+
return o && !o.ok && o.retryable && o.reason !== 'provider_error' && (isLlm || o.reason === 'missing_from_response');
|
|
719
|
+
});
|
|
720
|
+
if (toRepair.length > 0) {
|
|
721
|
+
logger_1.engineLogger.info('Retrying keys that failed validation', { count: toRepair.length, targetLang });
|
|
722
|
+
onProgress?.(`Retrying ${toRepair.length} string(s) that failed checks for ${targetLang}...`);
|
|
723
|
+
const firstAttempt = new Map(toRepair.map((b) => [b.key, outcomes.get(b.key)]));
|
|
724
|
+
await runBatches(chunk(toRepair, REPAIR_BATCH_SIZE), isLlm
|
|
725
|
+
? (b) => {
|
|
726
|
+
const o = firstAttempt.get(b.key);
|
|
727
|
+
if (!o || o.ok)
|
|
728
|
+
return undefined;
|
|
729
|
+
const fix = `A previous translation was rejected — ${o.message}. Fix that, and copy every [#VAR_n#] token exactly once.`;
|
|
730
|
+
return b.countArgs ? `${fix} ${pluralHint(b.countArgs, targetLocale)}` : fix;
|
|
731
|
+
}
|
|
732
|
+
: undefined);
|
|
733
|
+
}
|
|
734
|
+
// 3b. Keys the LLM couldn't be reached for (rate limit, outage) get one
|
|
735
|
+
// attempt on the fallback translator, if there is one. It's NLLB-class,
|
|
736
|
+
// so ICU and mixed-script strings stay failed.
|
|
737
|
+
const fellBack = new Set();
|
|
738
|
+
const fallback = providers.fallbackTranslator;
|
|
739
|
+
if (fallback && isLlm && nllbTarget) {
|
|
740
|
+
const toFallback = prepared.filter((b) => {
|
|
741
|
+
const o = outcomes.get(b.key);
|
|
742
|
+
// A count entry is a plain message: the fallback translates its tokenized form (no plural).
|
|
743
|
+
return o && !o.ok && o.reason === 'provider_error' && (!b.icu || !!b.plain) && !hasMixedScripts(b.value);
|
|
744
|
+
}).map((b) => (b.plain ? { ...b, ...b.plain, icu: false, countArgs: undefined, plain: undefined } : b));
|
|
745
|
+
if (toFallback.length > 0) {
|
|
746
|
+
logger_1.engineLogger.warn('LLM translator unavailable; trying the fallback translator', { count: toFallback.length, targetLang });
|
|
747
|
+
for (const batch of chunk(toFallback, BATCH_SIZE)) {
|
|
748
|
+
try {
|
|
749
|
+
const keyResults = await fallback.translateBatch({
|
|
750
|
+
entries: batch.map((b) => ({ key: b.key, value: b.sendText, parentContext: contextMap?.[b.key] })),
|
|
751
|
+
sourceLang,
|
|
752
|
+
targetLang,
|
|
753
|
+
targetLocale,
|
|
754
|
+
});
|
|
755
|
+
const byKey = new Map(keyResults.map((r) => [r.key, r]));
|
|
756
|
+
for (const b of batch) {
|
|
757
|
+
const o = judge(b, byKey.get(b.key), true);
|
|
758
|
+
if (o.ok) {
|
|
759
|
+
outcomes.set(b.key, o);
|
|
760
|
+
fellBack.add(b.key);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
catch (error) {
|
|
765
|
+
logger_1.engineLogger.error('Fallback translation batch failed', { error: error instanceof Error ? error.message : String(error), targetLang });
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
// 4. Record results. Failed keys get no value at all: the locale file
|
|
771
|
+
// omits them (the app shows the source string, honestly), the lockfile
|
|
772
|
+
// doesn't mark them done, and the next run retries them.
|
|
773
|
+
const tmInserts = [];
|
|
774
|
+
for (const entry of prepared) {
|
|
775
|
+
const outcome = outcomes.get(entry.key);
|
|
776
|
+
if (!outcome || !outcome.ok) {
|
|
777
|
+
failures[entry.key] = outcome && !outcome.ok
|
|
778
|
+
? { reason: outcome.reason, retryable: outcome.retryable, message: outcome.message }
|
|
779
|
+
: { reason: 'missing_from_response', retryable: true };
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
const finalValue = applyGlossaryPostProcessing(entry.value, outcome.value, [], alwaysTranslateAsEntries, targetLocale);
|
|
783
|
+
result[entry.key] = finalValue;
|
|
784
|
+
const qa = [...outcome.warnings, ...glossaryWarnings(entry.value, finalValue, glossaryForLocale)];
|
|
785
|
+
if (qa.length > 0)
|
|
786
|
+
metadata[entry.key] = { ...(metadata[entry.key] ?? {}), qa };
|
|
787
|
+
// Fallback output is a stopgap: keep it out of the shared cache and TM
|
|
788
|
+
// so they only ever hold the primary translator's work.
|
|
789
|
+
if (fellBack.has(entry.key)) {
|
|
790
|
+
const prev = metadata[entry.key] ?? {};
|
|
791
|
+
metadata[entry.key] = {
|
|
792
|
+
...prev,
|
|
793
|
+
fallback: true,
|
|
794
|
+
qa: [...(prev.qa ?? []), exports.FALLBACK_QA_WARNING],
|
|
795
|
+
};
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
// Cache and TM store the translation *before* project glossary rules
|
|
799
|
+
// ("always translate as") are applied; those rules are re-applied on
|
|
800
|
+
// every hit, so glossary edits reach cached strings and one project's
|
|
801
|
+
// glossary never leaks into the owner's other projects through TM.
|
|
802
|
+
const cacheKey = (0, cache_keys_1.translationCacheKey)(sourceLang, storeLang, entry.hash);
|
|
803
|
+
if (cacheProvider) {
|
|
804
|
+
cacheProvider.set(cacheKey, outcome.value, cache_keys_1.TRANSLATION_CACHE_TTL_SECONDS).catch((err) => {
|
|
805
|
+
logger_1.engineLogger.error('Failed to cache translation', { cacheKey, error: err });
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
if (projectId && !contextMap?.[entry.key] && !approvedSourceTexts.has(entry.value)) {
|
|
809
|
+
tmInserts.push({ key: entry.key, sourceText: entry.value, translatedText: outcome.value });
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
if (tmInserts.length > 0 && projectId && tmContext && !dryRun) {
|
|
813
|
+
// Normally the translator only sees strings TM had no entry for, so
|
|
814
|
+
// overwriting is safe; a forced re-translate overwrites on purpose.
|
|
815
|
+
// If the lookup failed, existing entries may be human edits — keep them.
|
|
816
|
+
const saved = await persistToTM(memory, tmInserts, projectId, tmContext, sourceLang, storeLang, { overwrite: !tmLookupFailed });
|
|
817
|
+
if (saved)
|
|
818
|
+
markInTm(tmInserts.map((i) => i.key));
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
const failureCount = Object.keys(failures).length;
|
|
822
|
+
if (failureCount > 0) {
|
|
823
|
+
logger_1.engineLogger.warn('Some strings could not be translated', { targetLang, failed: failureCount, of: keys.length });
|
|
824
|
+
}
|
|
825
|
+
// Every key this run produced gets metadata (possibly empty) so the lockfile
|
|
826
|
+
// replaces the previous run's — cache hits, fallbacks and mixed-script keys
|
|
827
|
+
// included — instead of keeping a stale fromMemory/fuzzyMatches.
|
|
828
|
+
for (const key of Object.keys(result)) {
|
|
829
|
+
if (!metadata[key])
|
|
830
|
+
metadata[key] = {};
|
|
831
|
+
}
|
|
832
|
+
return { result, stats: { tmHits }, metadata, failures };
|
|
833
|
+
}
|
|
834
|
+
/** Put back never-translate terms that were masked before translation. */
|
|
835
|
+
function restoreNeverTranslate(originalText, translatedText, neverTranslateTerms) {
|
|
836
|
+
if (neverTranslateTerms.length === 0)
|
|
837
|
+
return translatedText;
|
|
838
|
+
const { terms: usedTerms } = (0, glossary_1.applyGlossaryMask)(originalText, neverTranslateTerms);
|
|
839
|
+
return (0, glossary_1.restoreGlossaryMask)(translatedText, usedTerms);
|
|
840
|
+
}
|
|
841
|
+
function applyGlossaryPostProcessing(originalText, translatedText, neverTranslateTerms, alwaysTranslateAsEntries, targetLocale) {
|
|
842
|
+
let processed = restoreNeverTranslate(originalText, translatedText, neverTranslateTerms);
|
|
843
|
+
if (alwaysTranslateAsEntries.length > 0 && targetLocale) {
|
|
844
|
+
processed = (0, glossary_1.applyAlwaysTranslateAs)(processed, alwaysTranslateAsEntries, targetLocale);
|
|
845
|
+
}
|
|
846
|
+
return processed;
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* Persist translations to the project owner's TM. Never throws: a TM failure
|
|
850
|
+
* must not fail the run, but it is logged at error level rather than skipped.
|
|
851
|
+
* `sourceLang`/`targetLang` are FLORES codes, matching what TM lookups use.
|
|
852
|
+
*/
|
|
853
|
+
async function persistToTM(memory, inserts, projectId, tmContext, sourceLang, targetLang, options = {}) {
|
|
854
|
+
if (!tmContext.ownerId) {
|
|
855
|
+
logger_1.engineLogger.error('TM persistence skipped: project has no resolvable owner', {
|
|
856
|
+
projectId,
|
|
857
|
+
count: inserts.length,
|
|
858
|
+
});
|
|
859
|
+
return false;
|
|
860
|
+
}
|
|
861
|
+
try {
|
|
862
|
+
await memory.saveBatch(tmContext.ownerId, projectId, inserts.map((insert) => ({
|
|
863
|
+
sourceText: insert.sourceText,
|
|
864
|
+
sourceLocale: sourceLang,
|
|
865
|
+
targetLocale: targetLang,
|
|
866
|
+
translatedText: insert.translatedText,
|
|
867
|
+
isoKey: insert.key,
|
|
868
|
+
projectName: tmContext.projectName ?? undefined,
|
|
869
|
+
})), options);
|
|
870
|
+
logger_1.engineLogger.info('TM persistence complete', { count: inserts.length, projectId, overwrite: options.overwrite ?? true });
|
|
871
|
+
return true;
|
|
872
|
+
}
|
|
873
|
+
catch (err) {
|
|
874
|
+
logger_1.engineLogger.error('TM persistence failed', {
|
|
875
|
+
count: inserts.length,
|
|
876
|
+
projectId,
|
|
877
|
+
error: err instanceof Error ? err.message : String(err),
|
|
878
|
+
});
|
|
879
|
+
return false;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Backfill TM from translations that were reused without calling the
|
|
884
|
+
* translator (lockfile hits). Existing TM rows are left untouched.
|
|
885
|
+
* Locales are ISO codes here; identity translations (source === target) are
|
|
886
|
+
* skipped since they mean a previous run had no working translator.
|
|
887
|
+
*/
|
|
888
|
+
async function backfillTranslationMemory(projectId, sourceLocale, flatSource, translationsByLocale, excludeKeys = new Set(), providers = {}) {
|
|
889
|
+
const memory = providers.memory;
|
|
890
|
+
if (!memory)
|
|
891
|
+
return {};
|
|
892
|
+
const tmContext = await memory.getProjectContext(projectId);
|
|
893
|
+
const sourceLang = (0, language_codes_1.toFlores)(sourceLocale);
|
|
894
|
+
// Lockfile values are final output: project glossary substitutions and
|
|
895
|
+
// brand-voice rewording are baked in and can't be separated out. Shared TM
|
|
896
|
+
// must not carry them into the owner's other projects, so skip those.
|
|
897
|
+
const rules = await loadProjectTranslationRules(projectId, providers.rules);
|
|
898
|
+
if (rules.brandVoice) {
|
|
899
|
+
logger_1.engineLogger.info('Skipping lockfile TM backfill: project has a brand voice', { projectId });
|
|
900
|
+
return {};
|
|
901
|
+
}
|
|
902
|
+
if (!rules.complete) {
|
|
903
|
+
// Can't tell whether glossary/brand-voice wording is baked into these
|
|
904
|
+
// values; retry on a later run rather than risk leaking it into shared TM.
|
|
905
|
+
logger_1.engineLogger.warn('Skipping lockfile TM backfill: project rules failed to load', { projectId });
|
|
906
|
+
return {};
|
|
907
|
+
}
|
|
908
|
+
// Keys whose TM state is settled for a locale: written, or deliberately
|
|
909
|
+
// skipped. The caller records these so later runs don't redo them.
|
|
910
|
+
const settled = {};
|
|
911
|
+
for (const [locale, translations] of Object.entries(translationsByLocale)) {
|
|
912
|
+
if (locale === sourceLocale || !(0, language_codes_1.isSupported)(locale))
|
|
913
|
+
continue;
|
|
914
|
+
const inserts = [];
|
|
915
|
+
const skipped = [];
|
|
916
|
+
for (const [key, translatedText] of Object.entries(translations)) {
|
|
917
|
+
const sourceText = flatSource[key];
|
|
918
|
+
if (excludeKeys.has(key) ||
|
|
919
|
+
hasAlwaysTranslateAsTerm(sourceText ?? '', rules.alwaysTranslateAsEntries, locale) ||
|
|
920
|
+
!sourceText || !translatedText || sourceText === translatedText) {
|
|
921
|
+
skipped.push(key);
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
inserts.push({ key, sourceText, translatedText });
|
|
925
|
+
}
|
|
926
|
+
const saved = inserts.length === 0 ||
|
|
927
|
+
await persistToTM(memory, inserts, projectId, tmContext, sourceLang, (0, language_codes_1.storageLangCode)(locale), { overwrite: false });
|
|
928
|
+
if (saved)
|
|
929
|
+
settled[locale] = [...skipped, ...inserts.map((i) => i.key)];
|
|
930
|
+
}
|
|
931
|
+
return settled;
|
|
932
|
+
}
|