@langapi/mcp-server 1.0.5 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/locale-detection/index.d.ts.map +1 -1
- package/dist/locale-detection/index.js +12 -3
- package/dist/locale-detection/index.js.map +1 -1
- package/dist/locale-detection/patterns.d.ts.map +1 -1
- package/dist/locale-detection/patterns.js +15 -0
- package/dist/locale-detection/patterns.js.map +1 -1
- package/dist/tools/list-local-locales.d.ts +1 -1
- package/dist/tools/list-local-locales.js +2 -2
- package/dist/tools/list-local-locales.js.map +1 -1
- package/dist/tools/sync-translations.d.ts.map +1 -1
- package/dist/tools/sync-translations.js +234 -334
- package/dist/tools/sync-translations.js.map +1 -1
- package/dist/utils/arb-parser.d.ts +77 -0
- package/dist/utils/arb-parser.d.ts.map +1 -0
- package/dist/utils/arb-parser.js +142 -0
- package/dist/utils/arb-parser.js.map +1 -0
- package/dist/utils/arb-parser.test.d.ts +2 -0
- package/dist/utils/arb-parser.test.d.ts.map +1 -0
- package/dist/utils/arb-parser.test.js +243 -0
- package/dist/utils/arb-parser.test.js.map +1 -0
- package/package.json +1 -1
|
@@ -7,6 +7,7 @@ import { readFile, writeFile, mkdir } from "fs/promises";
|
|
|
7
7
|
import { dirname, resolve } from "path";
|
|
8
8
|
import { detectLocales } from "../locale-detection/index.js";
|
|
9
9
|
import { flattenJson, unflattenJson, parseJsonSafe, } from "../utils/json-parser.js";
|
|
10
|
+
import { isArbFile, parseArbContent, mergeArbContent, getLocaleFileExtension, } from "../utils/arb-parser.js";
|
|
10
11
|
import { parseJsonWithFormat, stringifyWithFormat, } from "../utils/format-preserve.js";
|
|
11
12
|
import { LangAPIClient } from "../api/client.js";
|
|
12
13
|
import { isApiKeyConfigured } from "../config/env.js";
|
|
@@ -15,7 +16,7 @@ import { readSyncCache, writeSyncCache, detectLocalDelta, } from "../utils/sync-
|
|
|
15
16
|
// Input schema
|
|
16
17
|
const SyncTranslationsSchema = z.object({
|
|
17
18
|
source_lang: languageCodeSchema.describe("Source language code (e.g., 'en', 'pt-BR')"),
|
|
18
|
-
target_langs: languageCodesArraySchema.describe("Target language codes to
|
|
19
|
+
target_langs: languageCodesArraySchema.describe("Target language codes to translate to. Can include NEW languages not yet in the project (e.g., ['cs', 'de'] to add Czech and German)"),
|
|
19
20
|
dry_run: z
|
|
20
21
|
.boolean()
|
|
21
22
|
.default(true)
|
|
@@ -110,22 +111,29 @@ function removeExtraKeys(targetObj, sourceKeys) {
|
|
|
110
111
|
/**
|
|
111
112
|
* Compute target file path by replacing source language with target language.
|
|
112
113
|
* Handles both directory-based (locales/en/file.json) and flat (locales/en.json) structures.
|
|
114
|
+
* Also supports Flutter ARB files with underscore naming (app_en.arb → app_ko.arb).
|
|
113
115
|
*/
|
|
114
116
|
function computeTargetFilePath(sourcePath, sourceLang, targetLang) {
|
|
117
|
+
const ext = getLocaleFileExtension(sourcePath);
|
|
115
118
|
// Try directory-based replacement first: /en/ → /ko/
|
|
116
119
|
const dirPattern = `/${sourceLang}/`;
|
|
117
120
|
if (sourcePath.includes(dirPattern)) {
|
|
118
121
|
return sourcePath.replace(dirPattern, `/${targetLang}/`);
|
|
119
122
|
}
|
|
120
|
-
// Try flat file replacement: /en.json → /ko.json
|
|
121
|
-
const filePattern = `/${sourceLang}
|
|
123
|
+
// Try flat file replacement: /en.json → /ko.json or /en.arb → /ko.arb
|
|
124
|
+
const filePattern = `/${sourceLang}${ext}`;
|
|
122
125
|
if (sourcePath.endsWith(filePattern)) {
|
|
123
|
-
return sourcePath.slice(0, -filePattern.length) + `/${targetLang}
|
|
126
|
+
return sourcePath.slice(0, -filePattern.length) + `/${targetLang}${ext}`;
|
|
124
127
|
}
|
|
125
128
|
// Try filename with prefix: messages.en.json → messages.ko.json
|
|
126
|
-
const prefixPattern = `.${sourceLang}
|
|
129
|
+
const prefixPattern = `.${sourceLang}${ext}`;
|
|
127
130
|
if (sourcePath.endsWith(prefixPattern)) {
|
|
128
|
-
return sourcePath.slice(0, -prefixPattern.length) + `.${targetLang}
|
|
131
|
+
return sourcePath.slice(0, -prefixPattern.length) + `.${targetLang}${ext}`;
|
|
132
|
+
}
|
|
133
|
+
// Try Flutter-style underscore pattern: app_en.arb → app_ko.arb
|
|
134
|
+
const underscorePattern = `_${sourceLang}${ext}`;
|
|
135
|
+
if (sourcePath.endsWith(underscorePattern)) {
|
|
136
|
+
return sourcePath.slice(0, -underscorePattern.length) + `_${targetLang}${ext}`;
|
|
129
137
|
}
|
|
130
138
|
// Cannot determine target path
|
|
131
139
|
return null;
|
|
@@ -134,7 +142,7 @@ function computeTargetFilePath(sourcePath, sourceLang, targetLang) {
|
|
|
134
142
|
* Register the sync_translations tool with the MCP server
|
|
135
143
|
*/
|
|
136
144
|
export function registerSyncTranslations(server) {
|
|
137
|
-
server.tool("sync_translations", "
|
|
145
|
+
server.tool("sync_translations", "Add new languages or sync existing translations via LangAPI. Use this tool to: (1) ADD translations for new languages like Czech, Spanish, French - creates new locale files automatically, (2) SYNC existing translations when source content changes. Supports any valid language code (e.g., 'cs' for Czech, 'de' for German). Default is dry_run=true for preview.", SyncTranslationsSchema.shape, async (args) => {
|
|
138
146
|
const input = SyncTranslationsSchema.parse(args);
|
|
139
147
|
const projectPath = input.project_path || process.cwd();
|
|
140
148
|
// Check if API key is configured
|
|
@@ -171,12 +179,24 @@ export function registerSyncTranslations(server) {
|
|
|
171
179
|
const content = await readFile(file.path, "utf-8");
|
|
172
180
|
const parsed = parseJsonWithFormat(content);
|
|
173
181
|
if (parsed) {
|
|
174
|
-
|
|
182
|
+
let flatContent;
|
|
183
|
+
let arbMetadata;
|
|
184
|
+
if (isArbFile(file.path)) {
|
|
185
|
+
// ARB file: extract translatable keys only, preserve metadata
|
|
186
|
+
const arbContent = parseArbContent(parsed.data);
|
|
187
|
+
flatContent = arbContent.translatableKeys;
|
|
188
|
+
arbMetadata = arbContent.metadata;
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
// Regular JSON: flatten all keys
|
|
192
|
+
flatContent = flattenJson(parsed.data);
|
|
193
|
+
}
|
|
175
194
|
sourceFilesData.push({
|
|
176
195
|
file,
|
|
177
196
|
content: parsed.data,
|
|
178
197
|
flatContent,
|
|
179
198
|
format: parsed.format,
|
|
199
|
+
arbMetadata,
|
|
180
200
|
});
|
|
181
201
|
}
|
|
182
202
|
}
|
|
@@ -366,122 +386,139 @@ export function registerSyncTranslations(server) {
|
|
|
366
386
|
const client = LangAPIClient.create();
|
|
367
387
|
// Track skipped keys per language for reporting
|
|
368
388
|
const skippedKeysReport = {};
|
|
369
|
-
//
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
389
|
+
// ========== PER-FILE PROCESSING APPROACH ==========
|
|
390
|
+
// Process each source file separately:
|
|
391
|
+
// 1. For each source file, determine which keys need syncing
|
|
392
|
+
// 2. Make one API call per file → all target languages
|
|
393
|
+
// 3. Write to corresponding target files
|
|
394
|
+
// This ensures correct file mapping without namespace matching bugs
|
|
395
|
+
let totalCreditsUsed = 0;
|
|
396
|
+
let totalWordsToTranslate = 0;
|
|
397
|
+
let currentBalance = 0;
|
|
398
|
+
// Track results per language (aggregated across files)
|
|
399
|
+
const langResults = new Map();
|
|
400
|
+
// Initialize results for all target languages
|
|
401
|
+
for (const lang of input.target_langs) {
|
|
402
|
+
langResults.set(lang, { translated_count: 0, files_written: [] });
|
|
403
|
+
}
|
|
404
|
+
// Track completed files for partial error reporting
|
|
405
|
+
const completedFiles = [];
|
|
406
|
+
const allFilesWritten = [];
|
|
407
|
+
// Process each source file
|
|
408
|
+
for (const sourceFileData of sourceFilesData) {
|
|
409
|
+
const sourceFileKeys = new Set(sourceFileData.flatContent.map((item) => item.key));
|
|
410
|
+
// Filter delta content to only keys in this source file
|
|
411
|
+
const fileKeysInDelta = localDelta.contentToSync.filter((item) => sourceFileKeys.has(item.key));
|
|
412
|
+
// Determine which languages need this file's translations
|
|
413
|
+
// and what content to sync per language
|
|
414
|
+
const langContentMap = new Map();
|
|
386
415
|
for (const targetLang of input.target_langs) {
|
|
387
|
-
//
|
|
416
|
+
// Compute target file path
|
|
417
|
+
const targetFilePath = computeTargetFilePath(sourceFileData.file.path, input.source_lang, targetLang);
|
|
418
|
+
if (!targetFilePath || targetFilePath === sourceFileData.file.path) {
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
const resolvedPath = resolve(targetFilePath);
|
|
422
|
+
if (!isPathWithinProject(resolvedPath, projectPath)) {
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
// Determine base content for this file+language combination
|
|
388
426
|
const isMissingLang = missingLanguages.includes(targetLang);
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
let
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
try {
|
|
402
|
-
const targetContent = await readFile(targetFile.path, "utf-8");
|
|
403
|
-
const parsed = parseJsonWithFormat(targetContent);
|
|
404
|
-
if (parsed) {
|
|
405
|
-
const flatTarget = flattenJson(parsed.data);
|
|
406
|
-
for (const item of flatTarget) {
|
|
407
|
-
// Only count as "existing" if it has a non-empty value
|
|
408
|
-
if (item.value && item.value.trim() !== "") {
|
|
409
|
-
existingTargetKeys.add(item.key);
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
catch {
|
|
415
|
-
// File read failed, treat as missing
|
|
427
|
+
// Check if this specific target file exists
|
|
428
|
+
let targetFileExists = false;
|
|
429
|
+
let existingTargetKeys = new Set();
|
|
430
|
+
try {
|
|
431
|
+
const targetContent = await readFile(resolvedPath, "utf-8");
|
|
432
|
+
targetFileExists = true;
|
|
433
|
+
const parsed = parseJsonWithFormat(targetContent);
|
|
434
|
+
if (parsed) {
|
|
435
|
+
const flatTarget = flattenJson(parsed.data);
|
|
436
|
+
for (const item of flatTarget) {
|
|
437
|
+
if (item.value && item.value.trim() !== "") {
|
|
438
|
+
existingTargetKeys.add(item.key);
|
|
416
439
|
}
|
|
417
440
|
}
|
|
418
|
-
// Filter out keys that already exist in target
|
|
419
|
-
baseContent = baseContent.filter((item) => !existingTargetKeys.has(item.key));
|
|
420
441
|
}
|
|
421
442
|
}
|
|
422
|
-
|
|
443
|
+
catch {
|
|
444
|
+
// File doesn't exist
|
|
445
|
+
}
|
|
446
|
+
// Determine what content to sync
|
|
447
|
+
let contentToSync;
|
|
448
|
+
if (isMissingLang || !targetFileExists) {
|
|
449
|
+
// New language or missing file: sync all keys from this source file
|
|
450
|
+
contentToSync = sourceFileData.flatContent;
|
|
451
|
+
}
|
|
452
|
+
else if (!cachedContent) {
|
|
453
|
+
// No cache: sync only keys missing from target
|
|
454
|
+
contentToSync = sourceFileData.flatContent.filter((item) => !existingTargetKeys.has(item.key));
|
|
455
|
+
}
|
|
456
|
+
else {
|
|
457
|
+
// Has cache: use delta, but only keys from this file
|
|
458
|
+
contentToSync = fileKeysInDelta;
|
|
459
|
+
}
|
|
460
|
+
// Apply skip_keys filter
|
|
423
461
|
const skipSet = getSkipKeysForLang(input.skip_keys, targetLang);
|
|
424
|
-
const filteredContent =
|
|
425
|
-
// Track skipped keys
|
|
462
|
+
const filteredContent = contentToSync.filter((item) => !skipSet.has(item.key));
|
|
463
|
+
// Track skipped keys
|
|
426
464
|
if (skipSet.size > 0) {
|
|
427
|
-
const skippedInContent =
|
|
465
|
+
const skippedInContent = contentToSync
|
|
428
466
|
.filter((item) => skipSet.has(item.key))
|
|
429
467
|
.map((item) => item.key);
|
|
430
468
|
if (skippedInContent.length > 0) {
|
|
431
|
-
skippedKeysReport[targetLang]
|
|
469
|
+
const existing = skippedKeysReport[targetLang] || [];
|
|
470
|
+
skippedKeysReport[targetLang] = [...new Set([...existing, ...skippedInContent])];
|
|
432
471
|
}
|
|
433
472
|
}
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
completedResults.push({
|
|
437
|
-
language: targetLang,
|
|
438
|
-
translated_count: 0,
|
|
439
|
-
skipped_keys: skippedKeysReport[targetLang],
|
|
440
|
-
file_written: null,
|
|
441
|
-
});
|
|
442
|
-
completedLanguages.push(targetLang);
|
|
443
|
-
continue;
|
|
473
|
+
if (filteredContent.length > 0) {
|
|
474
|
+
langContentMap.set(targetLang, filteredContent);
|
|
444
475
|
}
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
// No completed languages - return simple error
|
|
476
|
+
}
|
|
477
|
+
// Skip API call if no languages need this file
|
|
478
|
+
if (langContentMap.size === 0) {
|
|
479
|
+
completedFiles.push(sourceFileData.file.path);
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
// Collect all unique keys to sync for this file (union across all languages)
|
|
483
|
+
const allKeysToSync = new Set();
|
|
484
|
+
for (const content of langContentMap.values()) {
|
|
485
|
+
for (const item of content) {
|
|
486
|
+
allKeysToSync.add(item.key);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
// Get source content for these keys
|
|
490
|
+
const contentForApi = sourceFileData.flatContent.filter((item) => allKeysToSync.has(item.key));
|
|
491
|
+
if (contentForApi.length === 0) {
|
|
492
|
+
completedFiles.push(sourceFileData.file.path);
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
// Make API call for this file → all languages that need it
|
|
496
|
+
const langsNeedingSync = Array.from(langContentMap.keys());
|
|
497
|
+
const response = await client.sync({
|
|
498
|
+
source_lang: input.source_lang,
|
|
499
|
+
target_langs: langsNeedingSync,
|
|
500
|
+
content: contentForApi,
|
|
501
|
+
dry_run: input.dry_run,
|
|
502
|
+
});
|
|
503
|
+
// Handle API error
|
|
504
|
+
if (!response.success) {
|
|
505
|
+
// Return partial error with info about completed files
|
|
506
|
+
if (completedFiles.length > 0) {
|
|
507
|
+
const completedLangs = Array.from(langResults.entries())
|
|
508
|
+
.filter(([_, r]) => r.translated_count > 0 || r.files_written.length > 0)
|
|
509
|
+
.map(([lang]) => lang);
|
|
480
510
|
const output = {
|
|
481
511
|
success: false,
|
|
512
|
+
partial_results: {
|
|
513
|
+
languages_completed: completedLangs,
|
|
514
|
+
files_written: allFilesWritten,
|
|
515
|
+
credits_used: totalCreditsUsed,
|
|
516
|
+
},
|
|
482
517
|
error: {
|
|
483
518
|
code: response.error.code,
|
|
484
519
|
message: response.error.message,
|
|
520
|
+
failed_language: langsNeedingSync[0],
|
|
521
|
+
remaining_languages: langsNeedingSync,
|
|
485
522
|
current_balance: response.error.currentBalance,
|
|
486
523
|
required_credits: response.error.requiredCredits,
|
|
487
524
|
top_up_url: response.error.topUpUrl,
|
|
@@ -491,209 +528,46 @@ export function registerSyncTranslations(server) {
|
|
|
491
528
|
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
|
|
492
529
|
};
|
|
493
530
|
}
|
|
494
|
-
// Handle dry run response - just accumulate costs
|
|
495
|
-
if ("delta" in response && response.cost) {
|
|
496
|
-
totalWordsToTranslate += response.cost.wordsToTranslate || 0;
|
|
497
|
-
totalCreditsUsed += response.cost.creditsRequired || 0;
|
|
498
|
-
currentBalance = response.cost.currentBalance || 0;
|
|
499
|
-
completedLanguages.push(targetLang);
|
|
500
|
-
continue;
|
|
501
|
-
}
|
|
502
|
-
// Handle execute response - write files immediately for this language
|
|
503
|
-
if ("results" in response && response.cost) {
|
|
504
|
-
totalCreditsUsed += response.cost.creditsUsed || 0;
|
|
505
|
-
currentBalance = response.cost.balanceAfterSync || 0;
|
|
506
|
-
const result = response.results[0];
|
|
507
|
-
if (!result) {
|
|
508
|
-
continue;
|
|
509
|
-
}
|
|
510
|
-
const filesWrittenForLang = [];
|
|
511
|
-
// Write files for this language if requested
|
|
512
|
-
if (input.write_to_files) {
|
|
513
|
-
// Check if target locale exists (use detected files)
|
|
514
|
-
const targetLocale = detection.locales.find((l) => l.lang === targetLang);
|
|
515
|
-
for (const sourceFileData of sourceFilesData) {
|
|
516
|
-
// Compute target file path
|
|
517
|
-
let targetFilePath = null;
|
|
518
|
-
if (targetLocale && targetLocale.files.length > 0) {
|
|
519
|
-
// Use existing detected target file path
|
|
520
|
-
// Match by namespace or use first file for single-file projects
|
|
521
|
-
const matchingFile = targetLocale.files.find((f) => f.namespace === sourceFileData.file.namespace) || targetLocale.files[0];
|
|
522
|
-
targetFilePath = matchingFile.path;
|
|
523
|
-
}
|
|
524
|
-
else {
|
|
525
|
-
// New language - compute path from source
|
|
526
|
-
targetFilePath = computeTargetFilePath(sourceFileData.file.path, input.source_lang, targetLang);
|
|
527
|
-
}
|
|
528
|
-
// Safety check: prevent overwriting source file
|
|
529
|
-
if (!targetFilePath || targetFilePath === sourceFileData.file.path) {
|
|
530
|
-
continue;
|
|
531
|
-
}
|
|
532
|
-
const resolvedPath = resolve(targetFilePath);
|
|
533
|
-
if (!isPathWithinProject(resolvedPath, projectPath)) {
|
|
534
|
-
continue;
|
|
535
|
-
}
|
|
536
|
-
// Get keys that belong to this source file
|
|
537
|
-
const sourceFileKeys = new Set(sourceFileData.flatContent.map((item) => item.key));
|
|
538
|
-
// Filter translations to only those from this source file
|
|
539
|
-
const fileTranslations = result.translations.filter((t) => sourceFileKeys.has(t.key));
|
|
540
|
-
if (fileTranslations.length === 0) {
|
|
541
|
-
continue;
|
|
542
|
-
}
|
|
543
|
-
// Read existing target file content (if exists)
|
|
544
|
-
let existingContent = {};
|
|
545
|
-
try {
|
|
546
|
-
const existingFileContent = await readFile(resolvedPath, "utf-8");
|
|
547
|
-
const parsed = parseJsonSafe(existingFileContent);
|
|
548
|
-
if (parsed) {
|
|
549
|
-
existingContent = parsed;
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
catch {
|
|
553
|
-
// File doesn't exist yet, start with empty object
|
|
554
|
-
}
|
|
555
|
-
// Unflatten and merge translations
|
|
556
|
-
const newTranslations = unflattenJson(fileTranslations);
|
|
557
|
-
let mergedContent = deepMerge(existingContent, newTranslations);
|
|
558
|
-
mergedContent = removeExtraKeys(mergedContent, sourceFileKeys);
|
|
559
|
-
// Write file
|
|
560
|
-
await mkdir(dirname(resolvedPath), { recursive: true });
|
|
561
|
-
const fileContent = stringifyWithFormat(mergedContent, sourceFileData.format);
|
|
562
|
-
await writeFile(resolvedPath, fileContent, "utf-8");
|
|
563
|
-
filesWrittenForLang.push(resolvedPath);
|
|
564
|
-
allFilesWritten.push(resolvedPath);
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
completedResults.push({
|
|
568
|
-
language: targetLang,
|
|
569
|
-
translated_count: result.translatedCount,
|
|
570
|
-
skipped_keys: skippedKeysReport[targetLang],
|
|
571
|
-
file_written: filesWrittenForLang.length > 0 ? filesWrittenForLang.join(", ") : null,
|
|
572
|
-
});
|
|
573
|
-
completedLanguages.push(targetLang);
|
|
574
|
-
// Update cache after each successful language write
|
|
575
|
-
if (input.write_to_files) {
|
|
576
|
-
await writeSyncCache(projectPath, input.source_lang, flatContent);
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
// All languages processed successfully
|
|
581
|
-
// Handle dry run response
|
|
582
|
-
if (input.dry_run) {
|
|
583
|
-
const skippedMsg = Object.keys(skippedKeysReport).length > 0
|
|
584
|
-
? ` Skipped keys: ${JSON.stringify(skippedKeysReport)}`
|
|
585
|
-
: "";
|
|
586
531
|
const output = {
|
|
587
|
-
success:
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
532
|
+
success: false,
|
|
533
|
+
error: {
|
|
534
|
+
code: response.error.code,
|
|
535
|
+
message: response.error.message,
|
|
536
|
+
current_balance: response.error.currentBalance,
|
|
537
|
+
required_credits: response.error.requiredCredits,
|
|
538
|
+
top_up_url: response.error.topUpUrl,
|
|
593
539
|
},
|
|
594
|
-
cost: {
|
|
595
|
-
words_to_translate: totalWordsToTranslate,
|
|
596
|
-
credits_required: totalCreditsUsed,
|
|
597
|
-
current_balance: currentBalance,
|
|
598
|
-
balance_after_sync: currentBalance - totalCreditsUsed,
|
|
599
|
-
},
|
|
600
|
-
message: `Preview: ${localDelta.contentToSync.length} keys to sync, ${totalCreditsUsed} credits required.${skippedMsg} Run with dry_run=false to execute.`,
|
|
601
540
|
};
|
|
602
541
|
return {
|
|
603
542
|
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
|
|
604
543
|
};
|
|
605
544
|
}
|
|
606
|
-
//
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
message: `Sync complete. ${totalTranslated} keys translated across ${completedLanguages.length} languages.${skippedMsg}`,
|
|
620
|
-
};
|
|
621
|
-
return {
|
|
622
|
-
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
|
|
623
|
-
};
|
|
624
|
-
}
|
|
625
|
-
// No skip_keys - use batch approach
|
|
626
|
-
const response = await client.sync({
|
|
627
|
-
source_lang: input.source_lang,
|
|
628
|
-
target_langs: input.target_langs,
|
|
629
|
-
content: localDelta.contentToSync,
|
|
630
|
-
dry_run: input.dry_run,
|
|
631
|
-
});
|
|
632
|
-
// Handle error response
|
|
633
|
-
if (!response.success) {
|
|
634
|
-
const output = {
|
|
635
|
-
success: false,
|
|
636
|
-
error: {
|
|
637
|
-
code: response.error.code,
|
|
638
|
-
message: response.error.message,
|
|
639
|
-
current_balance: response.error.currentBalance,
|
|
640
|
-
required_credits: response.error.requiredCredits,
|
|
641
|
-
top_up_url: response.error.topUpUrl,
|
|
642
|
-
},
|
|
643
|
-
};
|
|
644
|
-
return {
|
|
645
|
-
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
|
|
646
|
-
};
|
|
647
|
-
}
|
|
648
|
-
// Handle dry run response - use local delta for accurate new/changed detection
|
|
649
|
-
if (input.dry_run && "delta" in response) {
|
|
650
|
-
const output = {
|
|
651
|
-
success: true,
|
|
652
|
-
dry_run: true,
|
|
653
|
-
delta: {
|
|
654
|
-
new_keys: localDelta.newKeys,
|
|
655
|
-
changed_keys: localDelta.changedKeys,
|
|
656
|
-
total_keys_to_sync: localDelta.contentToSync.length,
|
|
657
|
-
},
|
|
658
|
-
cost: {
|
|
659
|
-
words_to_translate: response.cost.wordsToTranslate,
|
|
660
|
-
credits_required: response.cost.creditsRequired,
|
|
661
|
-
current_balance: response.cost.currentBalance,
|
|
662
|
-
balance_after_sync: response.cost.balanceAfterSync,
|
|
663
|
-
},
|
|
664
|
-
message: `Preview: ${localDelta.contentToSync.length} keys to sync (${localDelta.newKeys.length} new, ${localDelta.changedKeys.length} changed), ${response.cost.creditsRequired} credits required. Run with dry_run=false to execute.`,
|
|
665
|
-
};
|
|
666
|
-
return {
|
|
667
|
-
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
|
|
668
|
-
};
|
|
669
|
-
}
|
|
670
|
-
// Handle execute response
|
|
671
|
-
if ("results" in response) {
|
|
672
|
-
const results = [];
|
|
673
|
-
// Write translated content to files if requested
|
|
674
|
-
if (input.write_to_files) {
|
|
545
|
+
// Handle dry run response - accumulate costs
|
|
546
|
+
if ("delta" in response && response.cost) {
|
|
547
|
+
totalWordsToTranslate += response.cost.wordsToTranslate || 0;
|
|
548
|
+
totalCreditsUsed += response.cost.creditsRequired || 0;
|
|
549
|
+
currentBalance = response.cost.currentBalance || 0;
|
|
550
|
+
completedFiles.push(sourceFileData.file.path);
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
// Handle execute response - write files
|
|
554
|
+
if ("results" in response && response.cost) {
|
|
555
|
+
totalCreditsUsed += response.cost.creditsUsed || 0;
|
|
556
|
+
currentBalance = response.cost.balanceAfterSync || 0;
|
|
557
|
+
// Write translations for each language
|
|
675
558
|
for (const result of response.results) {
|
|
676
|
-
const
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
continue;
|
|
689
|
-
}
|
|
690
|
-
// Get the keys that belong to this source file
|
|
691
|
-
const sourceFileKeys = new Set(sourceFileData.flatContent.map((item) => item.key));
|
|
692
|
-
// Filter translations to only those from this source file
|
|
693
|
-
const fileTranslations = result.translations.filter((t) => sourceFileKeys.has(t.key));
|
|
694
|
-
if (fileTranslations.length === 0) {
|
|
695
|
-
continue;
|
|
696
|
-
}
|
|
559
|
+
const targetLang = result.language;
|
|
560
|
+
// Always compute target path from source (no namespace matching!)
|
|
561
|
+
const targetFilePath = computeTargetFilePath(sourceFileData.file.path, input.source_lang, targetLang);
|
|
562
|
+
if (!targetFilePath || targetFilePath === sourceFileData.file.path) {
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
const resolvedPath = resolve(targetFilePath);
|
|
566
|
+
if (!isPathWithinProject(resolvedPath, projectPath)) {
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
if (input.write_to_files) {
|
|
570
|
+
let mergedContent;
|
|
697
571
|
// Read existing target file content (if exists)
|
|
698
572
|
let existingContent = {};
|
|
699
573
|
try {
|
|
@@ -706,58 +580,84 @@ export function registerSyncTranslations(server) {
|
|
|
706
580
|
catch {
|
|
707
581
|
// File doesn't exist yet, start with empty object
|
|
708
582
|
}
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
583
|
+
if (isArbFile(resolvedPath) && sourceFileData.arbMetadata) {
|
|
584
|
+
// ARB file: merge with existing, preserve metadata, update locale
|
|
585
|
+
mergedContent = mergeArbContent(existingContent, result.translations, sourceFileData.arbMetadata, sourceFileKeys, targetLang);
|
|
586
|
+
}
|
|
587
|
+
else {
|
|
588
|
+
// Regular JSON: merge and remove extra keys
|
|
589
|
+
const newTranslations = unflattenJson(result.translations);
|
|
590
|
+
mergedContent = deepMerge(existingContent, newTranslations);
|
|
591
|
+
mergedContent = removeExtraKeys(mergedContent, sourceFileKeys);
|
|
592
|
+
}
|
|
593
|
+
// Write file
|
|
716
594
|
await mkdir(dirname(resolvedPath), { recursive: true });
|
|
717
|
-
// Write file with format preservation
|
|
718
595
|
const fileContent = stringifyWithFormat(mergedContent, sourceFileData.format);
|
|
719
596
|
await writeFile(resolvedPath, fileContent, "utf-8");
|
|
720
|
-
|
|
597
|
+
allFilesWritten.push(resolvedPath);
|
|
598
|
+
// Update per-language results
|
|
599
|
+
const langResult = langResults.get(targetLang);
|
|
600
|
+
langResult.translated_count += result.translatedCount;
|
|
601
|
+
langResult.files_written.push(resolvedPath);
|
|
602
|
+
}
|
|
603
|
+
else {
|
|
604
|
+
// Just track count without writing
|
|
605
|
+
const langResult = langResults.get(targetLang);
|
|
606
|
+
langResult.translated_count += result.translatedCount;
|
|
721
607
|
}
|
|
722
|
-
results.push({
|
|
723
|
-
language: lang,
|
|
724
|
-
translated_count: result.translatedCount,
|
|
725
|
-
file_written: filesWritten.length > 0 ? filesWritten.join(", ") : null,
|
|
726
|
-
});
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
else {
|
|
730
|
-
for (const result of response.results) {
|
|
731
|
-
results.push({
|
|
732
|
-
language: result.language,
|
|
733
|
-
translated_count: result.translatedCount,
|
|
734
|
-
file_written: null,
|
|
735
|
-
});
|
|
736
608
|
}
|
|
609
|
+
completedFiles.push(sourceFileData.file.path);
|
|
737
610
|
}
|
|
738
|
-
|
|
611
|
+
}
|
|
612
|
+
// Update cache after all files processed
|
|
613
|
+
if (input.write_to_files && !input.dry_run) {
|
|
739
614
|
await writeSyncCache(projectPath, input.source_lang, flatContent);
|
|
615
|
+
}
|
|
616
|
+
// Build final response
|
|
617
|
+
if (input.dry_run) {
|
|
740
618
|
const output = {
|
|
741
619
|
success: true,
|
|
742
|
-
dry_run:
|
|
743
|
-
|
|
620
|
+
dry_run: true,
|
|
621
|
+
delta: {
|
|
622
|
+
new_keys: localDelta.newKeys,
|
|
623
|
+
changed_keys: localDelta.changedKeys,
|
|
624
|
+
total_keys_to_sync: localDelta.contentToSync.length,
|
|
625
|
+
},
|
|
744
626
|
cost: {
|
|
745
|
-
|
|
746
|
-
|
|
627
|
+
words_to_translate: totalWordsToTranslate,
|
|
628
|
+
credits_required: totalCreditsUsed,
|
|
629
|
+
current_balance: currentBalance,
|
|
630
|
+
balance_after_sync: currentBalance - totalCreditsUsed,
|
|
747
631
|
},
|
|
748
|
-
message: `
|
|
632
|
+
message: `Preview: ${localDelta.contentToSync.length} keys to sync across ${sourceFilesData.length} file(s), ${totalCreditsUsed} credits required. Run with dry_run=false to execute.`,
|
|
749
633
|
};
|
|
750
634
|
return {
|
|
751
635
|
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
|
|
752
636
|
};
|
|
753
637
|
}
|
|
754
|
-
//
|
|
638
|
+
// Build results array from aggregated per-language data
|
|
639
|
+
const results = [];
|
|
640
|
+
for (const [lang, data] of langResults) {
|
|
641
|
+
results.push({
|
|
642
|
+
language: lang,
|
|
643
|
+
translated_count: data.translated_count,
|
|
644
|
+
skipped_keys: skippedKeysReport[lang],
|
|
645
|
+
file_written: data.files_written.length > 0 ? data.files_written.join(", ") : null,
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
const totalTranslated = results.reduce((sum, r) => sum + r.translated_count, 0);
|
|
649
|
+
const skippedMsg = Object.keys(skippedKeysReport).length > 0
|
|
650
|
+
? ` Skipped: ${JSON.stringify(skippedKeysReport)}`
|
|
651
|
+
: "";
|
|
755
652
|
const output = {
|
|
756
|
-
success:
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
653
|
+
success: true,
|
|
654
|
+
dry_run: false,
|
|
655
|
+
results,
|
|
656
|
+
cost: {
|
|
657
|
+
credits_used: totalCreditsUsed,
|
|
658
|
+
balance_after_sync: currentBalance,
|
|
760
659
|
},
|
|
660
|
+
message: `Sync complete. ${totalTranslated} keys translated across ${input.target_langs.length} languages.${skippedMsg}`,
|
|
761
661
|
};
|
|
762
662
|
return {
|
|
763
663
|
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
|