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