@langapi/mcp-server 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +551 -0
- package/bin/langapi-mcp.js +11 -0
- package/dist/api/client.d.ts +24 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +145 -0
- package/dist/api/client.js.map +1 -0
- package/dist/api/types.d.ts +56 -0
- package/dist/api/types.d.ts.map +1 -0
- package/dist/api/types.js +6 -0
- package/dist/api/types.js.map +1 -0
- package/dist/config/env.d.ts +18 -0
- package/dist/config/env.d.ts.map +1 -0
- package/dist/config/env.js +29 -0
- package/dist/config/env.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/locale-detection/index.d.ts +41 -0
- package/dist/locale-detection/index.d.ts.map +1 -0
- package/dist/locale-detection/index.js +188 -0
- package/dist/locale-detection/index.js.map +1 -0
- package/dist/locale-detection/patterns.d.ts +21 -0
- package/dist/locale-detection/patterns.d.ts.map +1 -0
- package/dist/locale-detection/patterns.js +135 -0
- package/dist/locale-detection/patterns.js.map +1 -0
- package/dist/server.d.ts +9 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +24 -0
- package/dist/server.js.map +1 -0
- package/dist/tools/get-diff.d.ts +23 -0
- package/dist/tools/get-diff.d.ts.map +1 -0
- package/dist/tools/get-diff.js +88 -0
- package/dist/tools/get-diff.js.map +1 -0
- package/dist/tools/get-translation-status.d.ts +47 -0
- package/dist/tools/get-translation-status.d.ts.map +1 -0
- package/dist/tools/get-translation-status.js +176 -0
- package/dist/tools/get-translation-status.js.map +1 -0
- package/dist/tools/list-local-locales.d.ts +39 -0
- package/dist/tools/list-local-locales.d.ts.map +1 -0
- package/dist/tools/list-local-locales.js +52 -0
- package/dist/tools/list-local-locales.js.map +1 -0
- package/dist/tools/sync-translations.d.ts +35 -0
- package/dist/tools/sync-translations.d.ts.map +1 -0
- package/dist/tools/sync-translations.js +629 -0
- package/dist/tools/sync-translations.js.map +1 -0
- package/dist/utils/format-preserve.d.ts +25 -0
- package/dist/utils/format-preserve.d.ts.map +1 -0
- package/dist/utils/format-preserve.js +56 -0
- package/dist/utils/format-preserve.js.map +1 -0
- package/dist/utils/json-parser.d.ts +33 -0
- package/dist/utils/json-parser.d.ts.map +1 -0
- package/dist/utils/json-parser.js +92 -0
- package/dist/utils/json-parser.js.map +1 -0
- package/dist/utils/sync-cache.d.ts +40 -0
- package/dist/utils/sync-cache.d.ts.map +1 -0
- package/dist/utils/sync-cache.js +153 -0
- package/dist/utils/sync-cache.js.map +1 -0
- package/dist/utils/sync-cache.test.d.ts +2 -0
- package/dist/utils/sync-cache.test.d.ts.map +1 -0
- package/dist/utils/sync-cache.test.js +205 -0
- package/dist/utils/sync-cache.test.js.map +1 -0
- package/dist/utils/validation.d.ts +37 -0
- package/dist/utils/validation.d.ts.map +1 -0
- package/dist/utils/validation.js +67 -0
- package/dist/utils/validation.js.map +1 -0
- package/package.json +56 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sync_translations MCP Tool
|
|
3
|
+
* Sync translations via LangAPI /v1/sync endpoint
|
|
4
|
+
*/
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
+
declare const SyncTranslationsSchema: z.ZodObject<{
|
|
8
|
+
source_lang: z.ZodString;
|
|
9
|
+
target_langs: z.ZodArray<z.ZodString, "many">;
|
|
10
|
+
dry_run: z.ZodDefault<z.ZodBoolean>;
|
|
11
|
+
project_path: z.ZodOptional<z.ZodString>;
|
|
12
|
+
write_to_files: z.ZodDefault<z.ZodBoolean>;
|
|
13
|
+
skip_keys: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString, "many">>>;
|
|
14
|
+
}, "strip", z.ZodTypeAny, {
|
|
15
|
+
source_lang: string;
|
|
16
|
+
target_langs: string[];
|
|
17
|
+
dry_run: boolean;
|
|
18
|
+
write_to_files: boolean;
|
|
19
|
+
project_path?: string | undefined;
|
|
20
|
+
skip_keys?: Record<string, string[]> | undefined;
|
|
21
|
+
}, {
|
|
22
|
+
source_lang: string;
|
|
23
|
+
target_langs: string[];
|
|
24
|
+
project_path?: string | undefined;
|
|
25
|
+
dry_run?: boolean | undefined;
|
|
26
|
+
write_to_files?: boolean | undefined;
|
|
27
|
+
skip_keys?: Record<string, string[]> | undefined;
|
|
28
|
+
}>;
|
|
29
|
+
export type SyncTranslationsInput = z.infer<typeof SyncTranslationsSchema>;
|
|
30
|
+
/**
|
|
31
|
+
* Register the sync_translations tool with the MCP server
|
|
32
|
+
*/
|
|
33
|
+
export declare function registerSyncTranslations(server: McpServer): void;
|
|
34
|
+
export {};
|
|
35
|
+
//# sourceMappingURL=sync-translations.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sync-translations.d.ts","sourceRoot":"","sources":["../../src/tools/sync-translations.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AA0BzE,QAAA,MAAM,sBAAsB;;;;;;;;;;;;;;;;;;;;;EAqB1B,CAAC;AAEH,MAAM,MAAM,qBAAqB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAwJ3E;;GAEG;AACH,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAylBhE"}
|
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sync_translations MCP Tool
|
|
3
|
+
* Sync translations via LangAPI /v1/sync endpoint
|
|
4
|
+
*/
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
7
|
+
import { dirname, resolve } from "path";
|
|
8
|
+
import { detectLocales } from "../locale-detection/index.js";
|
|
9
|
+
import { flattenJson, unflattenJson, parseJsonSafe, } from "../utils/json-parser.js";
|
|
10
|
+
import { parseJsonWithFormat, stringifyWithFormat, } from "../utils/format-preserve.js";
|
|
11
|
+
import { LangAPIClient } from "../api/client.js";
|
|
12
|
+
import { isApiKeyConfigured } from "../config/env.js";
|
|
13
|
+
import { languageCodeSchema, languageCodesArraySchema, isPathWithinProject, } from "../utils/validation.js";
|
|
14
|
+
import { readSyncCache, writeSyncCache, detectLocalDelta, } from "../utils/sync-cache.js";
|
|
15
|
+
// Input schema
|
|
16
|
+
const SyncTranslationsSchema = z.object({
|
|
17
|
+
source_lang: languageCodeSchema.describe("Source language code (e.g., 'en', 'pt-BR')"),
|
|
18
|
+
target_langs: languageCodesArraySchema.describe("Target language codes to sync"),
|
|
19
|
+
dry_run: z
|
|
20
|
+
.boolean()
|
|
21
|
+
.default(true)
|
|
22
|
+
.describe("If true, only preview changes without syncing. Default: true (safe mode)"),
|
|
23
|
+
project_path: z
|
|
24
|
+
.string()
|
|
25
|
+
.optional()
|
|
26
|
+
.describe("Root path of the project. Defaults to current working directory."),
|
|
27
|
+
write_to_files: z
|
|
28
|
+
.boolean()
|
|
29
|
+
.default(true)
|
|
30
|
+
.describe("If true, write translated content back to local files"),
|
|
31
|
+
skip_keys: z
|
|
32
|
+
.record(z.string(), z.array(z.string()))
|
|
33
|
+
.optional()
|
|
34
|
+
.describe("Keys to skip per language, e.g., { 'fr': ['subtitle', 'brand'] }"),
|
|
35
|
+
});
|
|
36
|
+
/**
|
|
37
|
+
* Get keys to skip for a specific language
|
|
38
|
+
*/
|
|
39
|
+
function getSkipKeysForLang(skipKeys, lang) {
|
|
40
|
+
if (!skipKeys)
|
|
41
|
+
return new Set();
|
|
42
|
+
const keys = skipKeys[lang] || [];
|
|
43
|
+
return new Set(keys);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Deep merge two objects, with source values overriding target values
|
|
47
|
+
*/
|
|
48
|
+
function deepMerge(target, source) {
|
|
49
|
+
const result = { ...target };
|
|
50
|
+
for (const key of Object.keys(source)) {
|
|
51
|
+
const sourceValue = source[key];
|
|
52
|
+
const targetValue = result[key];
|
|
53
|
+
if (sourceValue !== null &&
|
|
54
|
+
typeof sourceValue === "object" &&
|
|
55
|
+
!Array.isArray(sourceValue) &&
|
|
56
|
+
targetValue !== null &&
|
|
57
|
+
typeof targetValue === "object" &&
|
|
58
|
+
!Array.isArray(targetValue)) {
|
|
59
|
+
// Both are objects, merge recursively
|
|
60
|
+
result[key] = deepMerge(targetValue, sourceValue);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
// Source value overrides target
|
|
64
|
+
result[key] = sourceValue;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Remove keys from a nested object using flattened key notation (e.g., "greeting.hello")
|
|
71
|
+
*/
|
|
72
|
+
function removeKeysFromObject(obj, keysToRemove) {
|
|
73
|
+
const result = JSON.parse(JSON.stringify(obj)); // Deep clone
|
|
74
|
+
for (const key of keysToRemove) {
|
|
75
|
+
const parts = key.split(".");
|
|
76
|
+
let current = result;
|
|
77
|
+
// Navigate to the parent of the key to remove
|
|
78
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
79
|
+
if (current[parts[i]] && typeof current[parts[i]] === "object") {
|
|
80
|
+
current = current[parts[i]];
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
// Path doesn't exist, nothing to remove
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Delete the final key
|
|
88
|
+
delete current[parts[parts.length - 1]];
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Remove any keys from target that don't exist in source (to keep target in sync with source structure)
|
|
94
|
+
*/
|
|
95
|
+
function removeExtraKeys(targetObj, sourceKeys) {
|
|
96
|
+
// Flatten the target to get all its keys
|
|
97
|
+
const targetFlat = flattenJson(targetObj);
|
|
98
|
+
// Find keys in target that don't exist in source
|
|
99
|
+
const extraKeys = [];
|
|
100
|
+
for (const item of targetFlat) {
|
|
101
|
+
if (!sourceKeys.has(item.key)) {
|
|
102
|
+
extraKeys.push(item.key);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (extraKeys.length === 0) {
|
|
106
|
+
return targetObj;
|
|
107
|
+
}
|
|
108
|
+
console.error(`[SYNC] Removing extra keys not in source: ${extraKeys.join(", ")}`);
|
|
109
|
+
return removeKeysFromObject(targetObj, extraKeys);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Register the sync_translations tool with the MCP server
|
|
113
|
+
*/
|
|
114
|
+
export function registerSyncTranslations(server) {
|
|
115
|
+
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) => {
|
|
116
|
+
const input = SyncTranslationsSchema.parse(args);
|
|
117
|
+
const projectPath = input.project_path || process.cwd();
|
|
118
|
+
// Check if API key is configured
|
|
119
|
+
if (!isApiKeyConfigured()) {
|
|
120
|
+
const output = {
|
|
121
|
+
success: false,
|
|
122
|
+
error: {
|
|
123
|
+
code: "NO_API_KEY",
|
|
124
|
+
message: "No API key configured. Set the LANGAPI_API_KEY environment variable.",
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
return {
|
|
128
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
// Detect locales
|
|
132
|
+
const detection = await detectLocales(projectPath, false);
|
|
133
|
+
// Find source locale
|
|
134
|
+
const sourceLocale = detection.locales.find((l) => l.lang === input.source_lang);
|
|
135
|
+
if (!sourceLocale) {
|
|
136
|
+
const output = {
|
|
137
|
+
success: false,
|
|
138
|
+
error: {
|
|
139
|
+
code: "SOURCE_NOT_FOUND",
|
|
140
|
+
message: `Source language '${input.source_lang}' not found in project`,
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
return {
|
|
144
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
// Read source files and merge content
|
|
148
|
+
const sourceContent = {};
|
|
149
|
+
let sourceFormat = { indent: " ", trailingNewline: true };
|
|
150
|
+
for (const file of sourceLocale.files) {
|
|
151
|
+
const content = await readFile(file.path, "utf-8");
|
|
152
|
+
const parsed = parseJsonWithFormat(content);
|
|
153
|
+
if (parsed) {
|
|
154
|
+
Object.assign(sourceContent, parsed.data);
|
|
155
|
+
sourceFormat = parsed.format; // Use last file's format
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// Flatten source content for API
|
|
159
|
+
const flatContent = flattenJson(sourceContent);
|
|
160
|
+
const sourceKeys = new Set(flatContent.map((item) => item.key));
|
|
161
|
+
console.error(`[SYNC] projectPath: ${projectPath}`);
|
|
162
|
+
console.error(`[SYNC] flatContent has ${flatContent.length} keys`);
|
|
163
|
+
// Read cache and detect local delta
|
|
164
|
+
const cachedContent = await readSyncCache(projectPath, input.source_lang);
|
|
165
|
+
console.error(`[SYNC] cachedContent: ${cachedContent ? Object.keys(cachedContent).length + ' keys' : 'null'}`);
|
|
166
|
+
const localDelta = detectLocalDelta(flatContent, cachedContent);
|
|
167
|
+
console.error(`[SYNC] localDelta: ${localDelta.newKeys.length} new, ${localDelta.changedKeys.length} changed, ${localDelta.unchangedKeys.length} unchanged`);
|
|
168
|
+
// Detect missing target languages (files that don't exist yet)
|
|
169
|
+
const missingLanguages = [];
|
|
170
|
+
const existingLanguages = [];
|
|
171
|
+
for (const targetLang of input.target_langs) {
|
|
172
|
+
const targetLocale = detection.locales.find((l) => l.lang === targetLang);
|
|
173
|
+
if (!targetLocale || targetLocale.files.length === 0) {
|
|
174
|
+
missingLanguages.push(targetLang);
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
existingLanguages.push(targetLang);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
console.error(`[SYNC] Missing languages: ${missingLanguages.join(", ") || "none"}`);
|
|
181
|
+
console.error(`[SYNC] Existing languages: ${existingLanguages.join(", ") || "none"}`);
|
|
182
|
+
// If no content to sync AND no missing languages, return early with accurate delta
|
|
183
|
+
if (localDelta.contentToSync.length === 0 && missingLanguages.length === 0) {
|
|
184
|
+
if (input.dry_run) {
|
|
185
|
+
// Check for extra keys in target files even in dry_run mode
|
|
186
|
+
let totalExtraKeys = 0;
|
|
187
|
+
const extraKeysByLang = {};
|
|
188
|
+
for (const targetLang of input.target_langs) {
|
|
189
|
+
const targetLocale = detection.locales.find((l) => l.lang === targetLang);
|
|
190
|
+
if (!targetLocale || targetLocale.files.length === 0)
|
|
191
|
+
continue;
|
|
192
|
+
try {
|
|
193
|
+
const content = await readFile(targetLocale.files[0].path, "utf-8");
|
|
194
|
+
const parsed = parseJsonSafe(content);
|
|
195
|
+
if (parsed) {
|
|
196
|
+
const targetFlat = flattenJson(parsed);
|
|
197
|
+
const extraKeys = targetFlat.filter((t) => !sourceKeys.has(t.key)).map((t) => t.key);
|
|
198
|
+
if (extraKeys.length > 0) {
|
|
199
|
+
extraKeysByLang[targetLang] = extraKeys;
|
|
200
|
+
totalExtraKeys += extraKeys.length;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// File doesn't exist or can't be read
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const output = {
|
|
209
|
+
success: true,
|
|
210
|
+
dry_run: true,
|
|
211
|
+
delta: {
|
|
212
|
+
new_keys: [],
|
|
213
|
+
changed_keys: [],
|
|
214
|
+
total_keys_to_sync: 0,
|
|
215
|
+
},
|
|
216
|
+
cost: {
|
|
217
|
+
words_to_translate: 0,
|
|
218
|
+
credits_required: 0,
|
|
219
|
+
current_balance: 0,
|
|
220
|
+
balance_after_sync: 0,
|
|
221
|
+
},
|
|
222
|
+
message: totalExtraKeys > 0
|
|
223
|
+
? `No translations needed, but ${totalExtraKeys} extra keys found in target files will be removed. Run with dry_run=false to clean up. Extra keys: ${JSON.stringify(extraKeysByLang)}`
|
|
224
|
+
: "No changes detected. All keys are already synced.",
|
|
225
|
+
};
|
|
226
|
+
return {
|
|
227
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
// For non-dry_run with no source changes, still check for extra keys in target files
|
|
231
|
+
const results = [];
|
|
232
|
+
if (input.write_to_files) {
|
|
233
|
+
for (const targetLang of input.target_langs) {
|
|
234
|
+
const targetLocale = detection.locales.find((l) => l.lang === targetLang);
|
|
235
|
+
if (!targetLocale || targetLocale.files.length === 0) {
|
|
236
|
+
results.push({ language: targetLang, translated_count: 0, file_written: null });
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const filePath = targetLocale.files[0].path;
|
|
240
|
+
const resolvedPath = resolve(filePath);
|
|
241
|
+
try {
|
|
242
|
+
const existingContent = await readFile(resolvedPath, "utf-8");
|
|
243
|
+
const parsed = parseJsonSafe(existingContent);
|
|
244
|
+
if (!parsed) {
|
|
245
|
+
results.push({ language: targetLang, translated_count: 0, file_written: null });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
// Check for and remove extra keys
|
|
249
|
+
const cleaned = removeExtraKeys(parsed, sourceKeys);
|
|
250
|
+
const cleanedStr = stringifyWithFormat(cleaned, sourceFormat);
|
|
251
|
+
const originalStr = stringifyWithFormat(parsed, sourceFormat);
|
|
252
|
+
if (cleanedStr !== originalStr) {
|
|
253
|
+
// Extra keys were removed, write the cleaned file
|
|
254
|
+
await writeFile(resolvedPath, cleanedStr, "utf-8");
|
|
255
|
+
const keysRemoved = flattenJson(parsed).length - flattenJson(cleaned).length;
|
|
256
|
+
console.error(`[SYNC] Removed ${keysRemoved} extra keys from ${targetLang}`);
|
|
257
|
+
results.push({ language: targetLang, translated_count: 0, file_written: resolvedPath, keys_removed: keysRemoved });
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
results.push({ language: targetLang, translated_count: 0, file_written: null });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
results.push({ language: targetLang, translated_count: 0, file_written: null });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
for (const targetLang of input.target_langs) {
|
|
270
|
+
results.push({ language: targetLang, translated_count: 0, file_written: null });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
await writeSyncCache(projectPath, input.source_lang, flatContent);
|
|
274
|
+
const filesCleanedCount = results.filter((r) => r.keys_removed && r.keys_removed > 0).length;
|
|
275
|
+
const totalKeysRemoved = results.reduce((sum, r) => sum + (r.keys_removed || 0), 0);
|
|
276
|
+
const output = {
|
|
277
|
+
success: true,
|
|
278
|
+
dry_run: false,
|
|
279
|
+
results: results.map(({ language, translated_count, file_written }) => ({
|
|
280
|
+
language,
|
|
281
|
+
translated_count,
|
|
282
|
+
file_written,
|
|
283
|
+
})),
|
|
284
|
+
cost: {
|
|
285
|
+
credits_used: 0,
|
|
286
|
+
balance_after_sync: 0,
|
|
287
|
+
},
|
|
288
|
+
message: filesCleanedCount > 0
|
|
289
|
+
? `No translations needed. Removed ${totalKeysRemoved} extra keys from ${filesCleanedCount} file(s).`
|
|
290
|
+
: "No changes detected. All keys are already synced.",
|
|
291
|
+
};
|
|
292
|
+
return {
|
|
293
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
// Create API client
|
|
297
|
+
const client = LangAPIClient.create();
|
|
298
|
+
// Track skipped keys per language for reporting
|
|
299
|
+
const skippedKeysReport = {};
|
|
300
|
+
// Check if we need per-language filtering
|
|
301
|
+
const hasSkipKeys = input.skip_keys && Object.keys(input.skip_keys).length > 0;
|
|
302
|
+
const hasMissingLanguages = missingLanguages.length > 0;
|
|
303
|
+
// If we have missing languages OR skip_keys, process per-language
|
|
304
|
+
if (hasMissingLanguages || hasSkipKeys) {
|
|
305
|
+
// Call API per language with filtered content
|
|
306
|
+
let totalCreditsRequired = 0;
|
|
307
|
+
let totalWordsToTranslate = 0;
|
|
308
|
+
let currentBalance = 0;
|
|
309
|
+
const allResults = [];
|
|
310
|
+
for (const targetLang of input.target_langs) {
|
|
311
|
+
// Determine base content: ALL keys for missing languages, only changes for existing
|
|
312
|
+
const isMissingLang = missingLanguages.includes(targetLang);
|
|
313
|
+
const baseContent = isMissingLang ? flatContent : localDelta.contentToSync;
|
|
314
|
+
// Apply skip_keys filter on top of base content
|
|
315
|
+
const skipSet = getSkipKeysForLang(input.skip_keys, targetLang);
|
|
316
|
+
const filteredContent = baseContent.filter((item) => !skipSet.has(item.key));
|
|
317
|
+
// Track skipped keys for this language
|
|
318
|
+
if (skipSet.size > 0) {
|
|
319
|
+
const skippedInContent = baseContent
|
|
320
|
+
.filter((item) => skipSet.has(item.key))
|
|
321
|
+
.map((item) => item.key);
|
|
322
|
+
if (skippedInContent.length > 0) {
|
|
323
|
+
skippedKeysReport[targetLang] = skippedInContent;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
// Skip API call if no content after filtering
|
|
327
|
+
if (filteredContent.length === 0) {
|
|
328
|
+
allResults.push({ language: targetLang, translatedCount: 0, translations: [] });
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
console.error(`[SYNC] Translating ${filteredContent.length} keys for ${targetLang} (${isMissingLang ? 'new language' : 'existing language'})`);
|
|
332
|
+
const response = await client.sync({
|
|
333
|
+
source_lang: input.source_lang,
|
|
334
|
+
target_langs: [targetLang],
|
|
335
|
+
content: filteredContent,
|
|
336
|
+
dry_run: input.dry_run,
|
|
337
|
+
});
|
|
338
|
+
if (!response.success) {
|
|
339
|
+
const output = {
|
|
340
|
+
success: false,
|
|
341
|
+
error: {
|
|
342
|
+
code: response.error.code,
|
|
343
|
+
message: response.error.message,
|
|
344
|
+
current_balance: response.error.currentBalance,
|
|
345
|
+
required_credits: response.error.requiredCredits,
|
|
346
|
+
top_up_url: response.error.topUpUrl,
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
return {
|
|
350
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
if ("delta" in response && response.cost) {
|
|
354
|
+
// Dry run response
|
|
355
|
+
totalCreditsRequired += response.cost.creditsRequired || 0;
|
|
356
|
+
totalWordsToTranslate += response.cost.wordsToTranslate || 0;
|
|
357
|
+
currentBalance = response.cost.currentBalance || 0;
|
|
358
|
+
}
|
|
359
|
+
else if ("results" in response && response.cost) {
|
|
360
|
+
// Execute response
|
|
361
|
+
totalCreditsRequired += response.cost.creditsUsed || 0;
|
|
362
|
+
currentBalance = response.cost.balanceAfterSync || 0;
|
|
363
|
+
for (const result of response.results) {
|
|
364
|
+
allResults.push(result);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
// Handle dry run response with skip_keys
|
|
369
|
+
if (input.dry_run) {
|
|
370
|
+
const skippedMsg = Object.keys(skippedKeysReport).length > 0
|
|
371
|
+
? ` Skipped keys: ${JSON.stringify(skippedKeysReport)}`
|
|
372
|
+
: "";
|
|
373
|
+
const output = {
|
|
374
|
+
success: true,
|
|
375
|
+
dry_run: true,
|
|
376
|
+
delta: {
|
|
377
|
+
new_keys: localDelta.newKeys,
|
|
378
|
+
changed_keys: localDelta.changedKeys,
|
|
379
|
+
total_keys_to_sync: localDelta.contentToSync.length,
|
|
380
|
+
},
|
|
381
|
+
cost: {
|
|
382
|
+
words_to_translate: totalWordsToTranslate,
|
|
383
|
+
credits_required: totalCreditsRequired,
|
|
384
|
+
current_balance: currentBalance,
|
|
385
|
+
balance_after_sync: currentBalance - totalCreditsRequired,
|
|
386
|
+
},
|
|
387
|
+
message: `Preview: ${localDelta.contentToSync.length} keys to sync, ${totalCreditsRequired} credits required.${skippedMsg} Run with dry_run=false to execute.`,
|
|
388
|
+
};
|
|
389
|
+
return {
|
|
390
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
// For execute mode, continue to file writing with allResults
|
|
394
|
+
// We'll handle this in the results section below by setting response
|
|
395
|
+
const response = {
|
|
396
|
+
success: true,
|
|
397
|
+
results: allResults,
|
|
398
|
+
cost: { creditsUsed: totalCreditsRequired, balanceAfterSync: currentBalance - totalCreditsRequired },
|
|
399
|
+
};
|
|
400
|
+
// Continue to "Handle execute response" section below
|
|
401
|
+
if ("results" in response) {
|
|
402
|
+
const results = [];
|
|
403
|
+
// Write translated content to files if requested
|
|
404
|
+
if (input.write_to_files) {
|
|
405
|
+
for (const result of response.results) {
|
|
406
|
+
const lang = result.language;
|
|
407
|
+
// Find existing target locale to match directory structure
|
|
408
|
+
const targetLocale = detection.locales.find((l) => l.lang === lang);
|
|
409
|
+
let filePath;
|
|
410
|
+
if (targetLocale && targetLocale.files.length > 0) {
|
|
411
|
+
filePath = targetLocale.files[0].path;
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
const sourceFile = sourceLocale.files[0];
|
|
415
|
+
filePath = sourceFile.path.replace(`/${input.source_lang}`, `/${lang}`);
|
|
416
|
+
filePath = filePath.replace(`${input.source_lang}.json`, `${lang}.json`);
|
|
417
|
+
}
|
|
418
|
+
const resolvedPath = resolve(filePath);
|
|
419
|
+
if (!isPathWithinProject(resolvedPath, projectPath)) {
|
|
420
|
+
results.push({
|
|
421
|
+
language: lang,
|
|
422
|
+
translated_count: result.translatedCount,
|
|
423
|
+
skipped_keys: skippedKeysReport[lang],
|
|
424
|
+
file_written: null,
|
|
425
|
+
});
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
// Read existing target file content (if exists)
|
|
429
|
+
let existingContent = {};
|
|
430
|
+
try {
|
|
431
|
+
const existingFileContent = await readFile(resolvedPath, "utf-8");
|
|
432
|
+
const parsed = parseJsonSafe(existingFileContent);
|
|
433
|
+
if (parsed) {
|
|
434
|
+
existingContent = parsed;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
// File doesn't exist yet, start with empty object
|
|
439
|
+
}
|
|
440
|
+
// Unflatten the new translations
|
|
441
|
+
const newTranslations = unflattenJson(result.translations);
|
|
442
|
+
// Deep merge: new translations override existing ones
|
|
443
|
+
let mergedContent = deepMerge(existingContent, newTranslations);
|
|
444
|
+
// Remove any keys in target that don't exist in source
|
|
445
|
+
mergedContent = removeExtraKeys(mergedContent, sourceKeys);
|
|
446
|
+
await mkdir(dirname(resolvedPath), { recursive: true });
|
|
447
|
+
const fileContent = stringifyWithFormat(mergedContent, sourceFormat);
|
|
448
|
+
await writeFile(resolvedPath, fileContent, "utf-8");
|
|
449
|
+
results.push({
|
|
450
|
+
language: lang,
|
|
451
|
+
translated_count: result.translatedCount,
|
|
452
|
+
skipped_keys: skippedKeysReport[lang],
|
|
453
|
+
file_written: resolvedPath,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
for (const result of response.results) {
|
|
459
|
+
results.push({
|
|
460
|
+
language: result.language,
|
|
461
|
+
translated_count: result.translatedCount,
|
|
462
|
+
skipped_keys: skippedKeysReport[result.language],
|
|
463
|
+
file_written: null,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
await writeSyncCache(projectPath, input.source_lang, flatContent);
|
|
468
|
+
const skippedMsg = Object.keys(skippedKeysReport).length > 0
|
|
469
|
+
? ` Skipped: ${JSON.stringify(skippedKeysReport)}`
|
|
470
|
+
: "";
|
|
471
|
+
const output = {
|
|
472
|
+
success: true,
|
|
473
|
+
dry_run: false,
|
|
474
|
+
results,
|
|
475
|
+
cost: {
|
|
476
|
+
credits_used: response.cost.creditsUsed,
|
|
477
|
+
balance_after_sync: response.cost.balanceAfterSync,
|
|
478
|
+
},
|
|
479
|
+
message: `Sync complete. ${response.results.reduce((sum, r) => sum + r.translatedCount, 0)} keys translated.${skippedMsg}`,
|
|
480
|
+
};
|
|
481
|
+
return {
|
|
482
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
// No skip_keys - use batch approach
|
|
487
|
+
const response = await client.sync({
|
|
488
|
+
source_lang: input.source_lang,
|
|
489
|
+
target_langs: input.target_langs,
|
|
490
|
+
content: localDelta.contentToSync,
|
|
491
|
+
dry_run: input.dry_run,
|
|
492
|
+
});
|
|
493
|
+
// Handle error response
|
|
494
|
+
if (!response.success) {
|
|
495
|
+
const output = {
|
|
496
|
+
success: false,
|
|
497
|
+
error: {
|
|
498
|
+
code: response.error.code,
|
|
499
|
+
message: response.error.message,
|
|
500
|
+
current_balance: response.error.currentBalance,
|
|
501
|
+
required_credits: response.error.requiredCredits,
|
|
502
|
+
top_up_url: response.error.topUpUrl,
|
|
503
|
+
},
|
|
504
|
+
};
|
|
505
|
+
return {
|
|
506
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
// Handle dry run response - use local delta for accurate new/changed detection
|
|
510
|
+
if (input.dry_run && "delta" in response) {
|
|
511
|
+
const output = {
|
|
512
|
+
success: true,
|
|
513
|
+
dry_run: true,
|
|
514
|
+
delta: {
|
|
515
|
+
new_keys: localDelta.newKeys,
|
|
516
|
+
changed_keys: localDelta.changedKeys,
|
|
517
|
+
total_keys_to_sync: localDelta.contentToSync.length,
|
|
518
|
+
},
|
|
519
|
+
cost: {
|
|
520
|
+
words_to_translate: response.cost.wordsToTranslate,
|
|
521
|
+
credits_required: response.cost.creditsRequired,
|
|
522
|
+
current_balance: response.cost.currentBalance,
|
|
523
|
+
balance_after_sync: response.cost.balanceAfterSync,
|
|
524
|
+
},
|
|
525
|
+
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.`,
|
|
526
|
+
};
|
|
527
|
+
return {
|
|
528
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
// Handle execute response
|
|
532
|
+
if ("results" in response) {
|
|
533
|
+
const results = [];
|
|
534
|
+
// Write translated content to files if requested
|
|
535
|
+
if (input.write_to_files) {
|
|
536
|
+
for (const result of response.results) {
|
|
537
|
+
const lang = result.language;
|
|
538
|
+
// Find existing target locale to match directory structure
|
|
539
|
+
const targetLocale = detection.locales.find((l) => l.lang === lang);
|
|
540
|
+
let filePath;
|
|
541
|
+
if (targetLocale && targetLocale.files.length > 0) {
|
|
542
|
+
// Use existing file path
|
|
543
|
+
filePath = targetLocale.files[0].path;
|
|
544
|
+
}
|
|
545
|
+
else {
|
|
546
|
+
// Create new file based on source structure
|
|
547
|
+
const sourceFile = sourceLocale.files[0];
|
|
548
|
+
filePath = sourceFile.path.replace(`/${input.source_lang}`, `/${lang}`);
|
|
549
|
+
filePath = filePath.replace(`${input.source_lang}.json`, `${lang}.json`);
|
|
550
|
+
}
|
|
551
|
+
// Validate path is within project directory (prevent path traversal)
|
|
552
|
+
const resolvedPath = resolve(filePath);
|
|
553
|
+
if (!isPathWithinProject(resolvedPath, projectPath)) {
|
|
554
|
+
results.push({
|
|
555
|
+
language: lang,
|
|
556
|
+
translated_count: result.translatedCount,
|
|
557
|
+
file_written: null, // Skipped: path outside project
|
|
558
|
+
});
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
// Read existing target file content (if exists)
|
|
562
|
+
let existingContent = {};
|
|
563
|
+
try {
|
|
564
|
+
const existingFileContent = await readFile(resolvedPath, "utf-8");
|
|
565
|
+
const parsed = parseJsonSafe(existingFileContent);
|
|
566
|
+
if (parsed) {
|
|
567
|
+
existingContent = parsed;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
571
|
+
// File doesn't exist yet, start with empty object
|
|
572
|
+
}
|
|
573
|
+
// Unflatten the new translations
|
|
574
|
+
const newTranslations = unflattenJson(result.translations);
|
|
575
|
+
// Deep merge: new translations override existing ones
|
|
576
|
+
let mergedContent = deepMerge(existingContent, newTranslations);
|
|
577
|
+
// Remove any keys in target that don't exist in source
|
|
578
|
+
mergedContent = removeExtraKeys(mergedContent, sourceKeys);
|
|
579
|
+
// Ensure directory exists
|
|
580
|
+
await mkdir(dirname(resolvedPath), { recursive: true });
|
|
581
|
+
// Write file with format preservation
|
|
582
|
+
const fileContent = stringifyWithFormat(mergedContent, sourceFormat);
|
|
583
|
+
await writeFile(resolvedPath, fileContent, "utf-8");
|
|
584
|
+
results.push({
|
|
585
|
+
language: lang,
|
|
586
|
+
translated_count: result.translatedCount,
|
|
587
|
+
file_written: resolvedPath,
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
for (const result of response.results) {
|
|
593
|
+
results.push({
|
|
594
|
+
language: result.language,
|
|
595
|
+
translated_count: result.translatedCount,
|
|
596
|
+
file_written: null,
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
// Update cache with current source content after successful sync
|
|
601
|
+
await writeSyncCache(projectPath, input.source_lang, flatContent);
|
|
602
|
+
const output = {
|
|
603
|
+
success: true,
|
|
604
|
+
dry_run: false,
|
|
605
|
+
results,
|
|
606
|
+
cost: {
|
|
607
|
+
credits_used: response.cost.creditsUsed,
|
|
608
|
+
balance_after_sync: response.cost.balanceAfterSync,
|
|
609
|
+
},
|
|
610
|
+
message: `Sync complete. ${response.results.reduce((sum, r) => sum + r.translatedCount, 0)} keys translated across ${response.results.length} languages. ${response.cost.creditsUsed} credits used.`,
|
|
611
|
+
};
|
|
612
|
+
return {
|
|
613
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
// Fallback error
|
|
617
|
+
const output = {
|
|
618
|
+
success: false,
|
|
619
|
+
error: {
|
|
620
|
+
code: "UNEXPECTED_RESPONSE",
|
|
621
|
+
message: "Unexpected response from API",
|
|
622
|
+
},
|
|
623
|
+
};
|
|
624
|
+
return {
|
|
625
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
|
|
626
|
+
};
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
//# sourceMappingURL=sync-translations.js.map
|