@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.
@@ -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 sync"),
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}.json`;
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}.json`;
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}.json`;
129
+ const prefixPattern = `.${sourceLang}${ext}`;
127
130
  if (sourcePath.endsWith(prefixPattern)) {
128
- return sourcePath.slice(0, -prefixPattern.length) + `.${targetLang}.json`;
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", "Sync translations by calling the LangAPI /v1/sync endpoint. Default is dry_run=true (preview mode) for safety. Set dry_run=false to actually perform the sync.", SyncTranslationsSchema.shape, async (args) => {
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
- const flatContent = flattenJson(parsed.data);
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
- // Check if we need per-language filtering
370
- const hasSkipKeys = input.skip_keys && Object.keys(input.skip_keys).length > 0;
371
- const hasMissingLanguages = missingLanguages.length > 0;
372
- // If we have missing languages, missing files, skip_keys, OR no cache, process per-language
373
- // When cache is null, we need per-language processing to filter against existing target translations
374
- const hasLanguagesWithMissingFiles = languagesWithMissingFiles.length > 0;
375
- const needsTargetFiltering = !cachedContent && existingLanguages.length > 0;
376
- if (hasMissingLanguages || hasSkipKeys || hasLanguagesWithMissingFiles || needsTargetFiltering) {
377
- // Process each language one at a time: API call → write files → next language
378
- // This ensures partial results are saved immediately and not lost on error
379
- let totalCreditsUsed = 0;
380
- let totalWordsToTranslate = 0;
381
- let currentBalance = 0;
382
- // Track completed languages and their results
383
- const completedResults = [];
384
- const completedLanguages = [];
385
- const allFilesWritten = [];
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
- // Determine base content: ALL keys for missing languages or languages with missing files
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
- const hasMissingFiles = languagesWithMissingFiles.includes(targetLang);
390
- const needsFullSync = isMissingLang || hasMissingFiles;
391
- let baseContent = needsFullSync ? flatContent : localDelta.contentToSync;
392
- // Filter against existing target translations (not just cache)
393
- // This ensures we only sync keys that are actually missing from target files
394
- // Only apply when cache is null - otherwise cache delta already handles new/changed correctly
395
- if (!isMissingLang && !cachedContent) {
396
- const targetLocale = detection.locales.find((l) => l.lang === targetLang);
397
- if (targetLocale && targetLocale.files.length > 0) {
398
- // Read all target files and collect existing keys
399
- const existingTargetKeys = new Set();
400
- for (const targetFile of targetLocale.files) {
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
- // Apply skip_keys filter on top of base content
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 = baseContent.filter((item) => !skipSet.has(item.key));
425
- // Track skipped keys for this language
462
+ const filteredContent = contentToSync.filter((item) => !skipSet.has(item.key));
463
+ // Track skipped keys
426
464
  if (skipSet.size > 0) {
427
- const skippedInContent = baseContent
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] = skippedInContent;
469
+ const existing = skippedKeysReport[targetLang] || [];
470
+ skippedKeysReport[targetLang] = [...new Set([...existing, ...skippedInContent])];
432
471
  }
433
472
  }
434
- // Skip API call if no content after filtering
435
- if (filteredContent.length === 0) {
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
- // Make API call for this language
446
- const response = await client.sync({
447
- source_lang: input.source_lang,
448
- target_langs: [targetLang],
449
- content: filteredContent,
450
- dry_run: input.dry_run,
451
- });
452
- // Handle API error - return immediately (previous languages already saved)
453
- if (!response.success) {
454
- const currentIndex = input.target_langs.indexOf(targetLang);
455
- const remainingLangs = input.target_langs.slice(currentIndex + 1);
456
- // Return partial error with info about completed languages
457
- if (completedLanguages.length > 0) {
458
- const output = {
459
- success: false,
460
- partial_results: {
461
- languages_completed: completedLanguages,
462
- files_written: allFilesWritten,
463
- credits_used: totalCreditsUsed,
464
- },
465
- error: {
466
- code: response.error.code,
467
- message: response.error.message,
468
- failed_language: targetLang,
469
- remaining_languages: remainingLangs,
470
- current_balance: response.error.currentBalance,
471
- required_credits: response.error.requiredCredits,
472
- top_up_url: response.error.topUpUrl,
473
- },
474
- };
475
- return {
476
- content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
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: true,
588
- dry_run: true,
589
- delta: {
590
- new_keys: localDelta.newKeys,
591
- changed_keys: localDelta.changedKeys,
592
- total_keys_to_sync: localDelta.contentToSync.length,
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
- // Return success with all completed results
607
- const skippedMsg = Object.keys(skippedKeysReport).length > 0
608
- ? ` Skipped: ${JSON.stringify(skippedKeysReport)}`
609
- : "";
610
- const totalTranslated = completedResults.reduce((sum, r) => sum + r.translated_count, 0);
611
- const output = {
612
- success: true,
613
- dry_run: false,
614
- results: completedResults,
615
- cost: {
616
- credits_used: totalCreditsUsed,
617
- balance_after_sync: currentBalance,
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 lang = result.language;
677
- const filesWritten = [];
678
- // Write to each source file's corresponding target file
679
- for (const sourceFileData of sourceFilesData) {
680
- // Compute target file path
681
- const targetFilePath = computeTargetFilePath(sourceFileData.file.path, input.source_lang, lang);
682
- // Skip if path computation failed or would overwrite source
683
- if (!targetFilePath || targetFilePath === sourceFileData.file.path) {
684
- continue;
685
- }
686
- const resolvedPath = resolve(targetFilePath);
687
- if (!isPathWithinProject(resolvedPath, projectPath)) {
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
- // Unflatten the new translations
710
- const newTranslations = unflattenJson(fileTranslations);
711
- // Deep merge: new translations override existing ones
712
- let mergedContent = deepMerge(existingContent, newTranslations);
713
- // Remove any keys in target that don't exist in this source file
714
- mergedContent = removeExtraKeys(mergedContent, sourceFileKeys);
715
- // Ensure directory exists
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
- filesWritten.push(resolvedPath);
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
- // Update cache with current source content after successful sync
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: false,
743
- results,
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
- credits_used: response.cost.creditsUsed,
746
- balance_after_sync: response.cost.balanceAfterSync,
627
+ words_to_translate: totalWordsToTranslate,
628
+ credits_required: totalCreditsUsed,
629
+ current_balance: currentBalance,
630
+ balance_after_sync: currentBalance - totalCreditsUsed,
747
631
  },
748
- message: `Sync complete. ${response.results.reduce((sum, r) => sum + r.translatedCount, 0)} keys translated across ${response.results.length} languages. ${response.cost.creditsUsed} credits used.`,
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
- // Fallback error
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: false,
757
- error: {
758
- code: "UNEXPECTED_RESPONSE",
759
- message: "Unexpected response from API",
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) }],