@scoutello/i18n-magic 0.65.0 → 0.66.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.
Files changed (54) hide show
  1. package/README.md +13 -7
  2. package/dist/cli.js +20 -15
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/check-missing.d.ts +11 -1
  5. package/dist/commands/check-missing.d.ts.map +1 -1
  6. package/dist/commands/check-missing.js +56 -96
  7. package/dist/commands/check-missing.js.map +1 -1
  8. package/dist/commands/replace.d.ts +6 -0
  9. package/dist/commands/replace.d.ts.map +1 -1
  10. package/dist/commands/replace.js +48 -29
  11. package/dist/commands/replace.js.map +1 -1
  12. package/dist/commands/scan.d.ts.map +1 -1
  13. package/dist/commands/scan.js +114 -80
  14. package/dist/commands/scan.js.map +1 -1
  15. package/dist/commands/sync-locales.d.ts.map +1 -1
  16. package/dist/commands/sync-locales.js +21 -28
  17. package/dist/commands/sync-locales.js.map +1 -1
  18. package/dist/index.d.ts +2 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/lib/locale-storage.d.ts +7 -0
  23. package/dist/lib/locale-storage.d.ts.map +1 -0
  24. package/dist/lib/locale-storage.js +49 -0
  25. package/dist/lib/locale-storage.js.map +1 -0
  26. package/dist/lib/translation-response.d.ts +8 -0
  27. package/dist/lib/translation-response.d.ts.map +1 -0
  28. package/dist/lib/translation-response.js +43 -0
  29. package/dist/lib/translation-response.js.map +1 -0
  30. package/dist/lib/types.d.ts +1 -1
  31. package/dist/lib/types.d.ts.map +1 -1
  32. package/dist/lib/update-translation-key.d.ts +16 -0
  33. package/dist/lib/update-translation-key.d.ts.map +1 -0
  34. package/dist/lib/update-translation-key.js +67 -0
  35. package/dist/lib/update-translation-key.js.map +1 -0
  36. package/dist/lib/utils.d.ts +2 -0
  37. package/dist/lib/utils.d.ts.map +1 -1
  38. package/dist/lib/utils.js +69 -51
  39. package/dist/lib/utils.js.map +1 -1
  40. package/dist/mcp-server.js +28 -79
  41. package/dist/mcp-server.js.map +1 -1
  42. package/package.json +5 -9
  43. package/src/cli.ts +26 -18
  44. package/src/commands/check-missing.ts +83 -137
  45. package/src/commands/replace.ts +81 -43
  46. package/src/commands/scan.ts +130 -108
  47. package/src/commands/sync-locales.ts +41 -63
  48. package/src/index.ts +6 -1
  49. package/src/lib/locale-storage.ts +76 -0
  50. package/src/lib/translation-response.ts +59 -0
  51. package/src/lib/types.ts +1 -1
  52. package/src/lib/update-translation-key.ts +119 -0
  53. package/src/lib/utils.ts +92 -76
  54. package/src/mcp-server.ts +31 -112
@@ -0,0 +1,119 @@
1
+ import type { Configuration } from "./types.js"
2
+ import { resolveConfiguredLocale } from "./locale-storage.js"
3
+ import {
4
+ loadLocalesFile,
5
+ translateKey,
6
+ writeLocalesFile,
7
+ } from "./utils.js"
8
+
9
+ export interface UpdateTranslationKeyResult {
10
+ key: string
11
+ value: string
12
+ sourceLocale: string
13
+ targetNamespaces: string[]
14
+ updatedLocales: string[]
15
+ pendingLocales: string[]
16
+ }
17
+
18
+ export const updateTranslationKeyOperation = async ({
19
+ config,
20
+ key,
21
+ value,
22
+ language,
23
+ }: {
24
+ config: Configuration
25
+ key: string
26
+ value: string
27
+ language?: string
28
+ }): Promise<UpdateTranslationKeyResult> => {
29
+ const sourceLocale = resolveConfiguredLocale(config, language)
30
+ const sourceFiles = await Promise.all(
31
+ config.namespaces.map(async (namespace) => ({
32
+ namespace,
33
+ translations: await loadLocalesFile(
34
+ config.loadPath,
35
+ sourceLocale,
36
+ namespace,
37
+ { silent: true },
38
+ ),
39
+ })),
40
+ )
41
+ const targetNamespaces = sourceFiles
42
+ .filter(({ translations }) => Object.hasOwn(translations, key))
43
+ .map(({ namespace }) => namespace)
44
+
45
+ if (targetNamespaces.length === 0) {
46
+ throw new Error(
47
+ `Key "${key}" does not exist in the ${sourceLocale} translation files. Use add_translation_key to create it.`,
48
+ )
49
+ }
50
+
51
+ const pendingLocales = config.openai
52
+ ? []
53
+ : config.locales.filter((locale) => locale !== sourceLocale)
54
+ const updatedLocales = config.openai ? [...config.locales] : [sourceLocale]
55
+ const translatedValues: Record<string, string> = {
56
+ [sourceLocale]: value,
57
+ }
58
+
59
+ if (config.openai) {
60
+ await Promise.all(
61
+ config.locales
62
+ .filter((locale) => locale !== sourceLocale)
63
+ .map(async (locale) => {
64
+ const translation = await translateKey({
65
+ context: config.context || "",
66
+ inputLanguage: sourceLocale,
67
+ outputLanguage: locale,
68
+ object: { [key]: value },
69
+ openai: config.openai,
70
+ model: config.model,
71
+ })
72
+ translatedValues[locale] = translation[key]
73
+ }),
74
+ )
75
+ }
76
+
77
+ const snapshots = new Map<string, Record<string, string>>()
78
+ await Promise.all(
79
+ updatedLocales.flatMap((locale) =>
80
+ targetNamespaces.map(async (namespace) => {
81
+ const sourceSnapshot =
82
+ locale === sourceLocale
83
+ ? sourceFiles.find((entry) => entry.namespace === namespace)
84
+ ?.translations
85
+ : undefined
86
+ const existingKeys =
87
+ sourceSnapshot ??
88
+ (await loadLocalesFile(config.loadPath, locale, namespace, {
89
+ silent: true,
90
+ }))
91
+ snapshots.set(
92
+ JSON.stringify([locale, namespace]),
93
+ { ...existingKeys, [key]: translatedValues[locale] },
94
+ )
95
+ }),
96
+ ),
97
+ )
98
+
99
+ for (const locale of updatedLocales) {
100
+ for (const namespace of targetNamespaces) {
101
+ const snapshot = snapshots.get(JSON.stringify([locale, namespace]))
102
+ if (!snapshot) {
103
+ throw new Error(
104
+ `Internal error: no staged snapshot for ${locale}/${namespace}.`,
105
+ )
106
+ }
107
+ await writeLocalesFile(config.savePath, locale, namespace, snapshot)
108
+ }
109
+ }
110
+
111
+ return {
112
+ key,
113
+ value,
114
+ sourceLocale,
115
+ targetNamespaces,
116
+ updatedLocales,
117
+ pendingLocales,
118
+ }
119
+ }
package/src/lib/utils.ts CHANGED
@@ -7,6 +7,15 @@ import { minimatch } from "minimatch"
7
7
  import type OpenAI from "openai"
8
8
  import prompts from "prompts"
9
9
  import { languages } from "./languges.js"
10
+ import {
11
+ resolveConfiguredLocale,
12
+ resolveLocaleTemplatePath,
13
+ } from "./locale-storage.js"
14
+ import {
15
+ parseTranslationResponse,
16
+ TranslationProviderUnavailableError,
17
+ TranslationResponseError,
18
+ } from "./translation-response.js"
10
19
  import type { Configuration, GlobPatternConfig } from "./types.js"
11
20
 
12
21
  export const loadConfig = async ({
@@ -17,8 +26,7 @@ export const loadConfig = async ({
17
26
  const filePath = path.join(process.cwd(), configPath)
18
27
 
19
28
  if (!fs.existsSync(filePath)) {
20
- console.error("Config file does not exist:", filePath)
21
- process.exit(1)
29
+ throw new Error(`Config file does not exist: ${filePath}`)
22
30
  }
23
31
 
24
32
  try {
@@ -33,32 +41,10 @@ export const loadConfig = async ({
33
41
  errorMessage.includes("MODULE_NOT_FOUND") ||
34
42
  errorMessage.includes("Cannot find module")
35
43
 
36
- console.error("Error while loading config:", error)
37
-
38
- if (isModuleNotFound) {
39
- console.error("\n💡 Tip: This error usually occurs when:")
40
- console.error(
41
- " 1. Your config file imports dependencies that aren't installed",
42
- )
43
- console.error(
44
- " 2. Dependencies are installed but have missing peer dependencies",
45
- )
46
- console.error(
47
- " 3. You're using a custom storage solution (e.g., AWS S3) without proper dependencies",
48
- )
49
- console.error("\n To fix:")
50
- console.error(
51
- " - Check if your config file imports any external packages",
52
- )
53
- console.error(
54
- " - Ensure all dependencies are properly installed: pnpm install",
55
- )
56
- console.error(
57
- " - If using AWS SDK, ensure all @smithy/* peer dependencies are installed",
58
- )
59
- }
60
-
61
- process.exit(1)
44
+ const hint = isModuleNotFound
45
+ ? " Check the config imports and installed dependencies."
46
+ : ""
47
+ throw new Error(`Error while loading config: ${errorMessage}.${hint}`)
62
48
  }
63
49
  }
64
50
 
@@ -109,6 +95,8 @@ export const translateKey = async ({
109
95
  }) => {
110
96
  // Split object into chunks of 100 keys
111
97
  const entries = Object.entries(object)
98
+ if (entries.length === 0) return {}
99
+ if (!openai) throw new TranslationProviderUnavailableError()
112
100
  const chunks: Array<[string, string][]> = []
113
101
 
114
102
  for (let i = 0; i < entries.length; i += 100) {
@@ -129,34 +117,57 @@ export const translateKey = async ({
129
117
 
130
118
  for (const chunk of chunks) {
131
119
  const chunkObject = Object.fromEntries(chunk)
132
- const completion = await openai.chat.completions.create({
133
- model,
134
- messages: [
135
- {
136
- content: `You are a bot that translates the values of a locales JSON. ${
137
- context
138
- ? `The user provided some additional context or guidelines about what to fill in the blanks: "${context}". `
139
- : ""
140
- }The user provides you a JSON with a field named "inputLanguage", which defines the language the values of the JSON are defined in. It also has a field named "outputLanguage", which defines the language you should translate the values to. The last field is named "data", which includes the object with the values to translate. The keys of the values should never be changed. You output only a JSON, which has the same keys as the input, but with translated values. I give you an example input: {"inputLanguage": "English", outputLanguage: "German", "keys": {"hello": "Hello", "world": "World"}}. The output should be {"hello": "Hallo", "world": "Welt"}.`,
141
- role: "system",
142
- },
143
- {
144
- content: JSON.stringify({
145
- inputLanguage: input,
146
- outputLanguage: output,
147
- data: chunkObject,
148
- }),
149
- role: "user",
120
+ const requestedKeys = chunk.map(([key]) => key)
121
+ let translatedChunk: Record<string, string> | undefined
122
+
123
+ for (let attempt = 0; attempt < 2; attempt++) {
124
+ const correctivePrompt =
125
+ attempt === 1
126
+ ? ` The previous response was invalid. Return exactly these keys with string values and no additional keys: ${JSON.stringify(requestedKeys)}.`
127
+ : ""
128
+ const completion = await openai.chat.completions.create({
129
+ model,
130
+ messages: [
131
+ {
132
+ content: `You are a bot that translates the values of a locales JSON. ${
133
+ context
134
+ ? `The user provided some additional context or guidelines about what to fill in the blanks: "${context}". `
135
+ : ""
136
+ }The user provides you a JSON with a field named "inputLanguage", which defines the language the values of the JSON are defined in. It also has a field named "outputLanguage", which defines the language you should translate the values to. The last field is named "data", which includes the object with the values to translate. The keys of the values should never be changed. You output only a JSON, which has the same keys as the input, but with translated values. I give you an example input: {"inputLanguage": "English", outputLanguage: "German", "keys": {"hello": "Hello", "world": "World"}}. The output should be {"hello": "Hallo", "world": "Welt"}.${correctivePrompt}`,
137
+ role: "system",
138
+ },
139
+ {
140
+ content: JSON.stringify({
141
+ inputLanguage: input,
142
+ outputLanguage: output,
143
+ data: chunkObject,
144
+ }),
145
+ role: "user",
146
+ },
147
+ ],
148
+ response_format: {
149
+ type: "json_object",
150
150
  },
151
- ],
152
- response_format: {
153
- type: "json_object",
154
- },
155
- })
151
+ })
156
152
 
157
- const translatedChunk = JSON.parse(
158
- completion.choices[0].message.content,
159
- ) as Record<string, string>
153
+ try {
154
+ translatedChunk = parseTranslationResponse(
155
+ completion.choices[0]?.message.content ?? null,
156
+ requestedKeys,
157
+ )
158
+ break
159
+ } catch (error) {
160
+ if (!(error instanceof TranslationResponseError) || attempt === 1) {
161
+ throw error
162
+ }
163
+ }
164
+ }
165
+
166
+ if (!translatedChunk) {
167
+ throw new TranslationResponseError(
168
+ "Translation provider did not return a valid response.",
169
+ )
170
+ }
160
171
 
161
172
  // Merge translated chunk with result
162
173
  result = { ...result, ...translatedChunk }
@@ -185,9 +196,7 @@ export const loadLocalesFile = async (
185
196
  const silent = options?.silent ?? false
186
197
 
187
198
  if (typeof loadPath === "string") {
188
- const resolvedPath = loadPath
189
- .replace("{{lng}}", locale)
190
- .replace("{{ns}}", namespace)
199
+ const resolvedPath = resolveLocaleTemplatePath(loadPath, locale, namespace)
191
200
 
192
201
  // Check if file exists, return empty object if it doesn't
193
202
  if (!fs.existsSync(resolvedPath)) {
@@ -227,9 +236,11 @@ export const writeLocalesFile = async (
227
236
  data: Record<string, string>,
228
237
  ) => {
229
238
  if (typeof savePath === "string") {
230
- const resolvedSavePath = savePath
231
- .replace("{{lng}}", locale)
232
- .replace("{{ns}}", namespace)
239
+ const resolvedSavePath = resolveLocaleTemplatePath(
240
+ savePath,
241
+ locale,
242
+ namespace,
243
+ )
233
244
 
234
245
  // Ensure directory exists
235
246
  const dir = path.dirname(resolvedSavePath)
@@ -876,6 +887,12 @@ export const addTranslationKeys = async ({
876
887
  }
877
888
  }
878
889
 
890
+ const validatedKeys = keys.map(({ key, value, language }) => ({
891
+ key,
892
+ value,
893
+ language: resolveConfiguredLocale(config, language),
894
+ }))
895
+
879
896
  const log = console.log
880
897
  const namespaceResolutionDebug =
881
898
  process.env.DEBUG_NAMESPACE_RESOLUTION === "1" ||
@@ -923,7 +940,7 @@ export const addTranslationKeys = async ({
923
940
  }),
924
941
  )
925
942
 
926
- const preparedKeys = keys.map(({ key, value, language = "en" }) => {
943
+ const preparedKeys = validatedKeys.map(({ key, value, language }) => {
927
944
  const splitKey = key.split(":")
928
945
  const hasNamespacedKey = splitKey.length > 1 && namespaces.includes(splitKey[0])
929
946
  const normalizedKey = hasNamespacedKey ? splitKey.slice(1).join(":") : key
@@ -1029,6 +1046,7 @@ export const addTranslationKeys = async ({
1029
1046
  const fileIOStartTime = performance.now()
1030
1047
  const localeFiles = new Map<string, Record<string, string>>()
1031
1048
  const originalKeyCounts = new Map<string, number>()
1049
+ const changedFiles = new Set<string>()
1032
1050
  const loadErrors: Array<{ fileKey: string; error: string }> = []
1033
1051
 
1034
1052
  // Load files for all locales × all affected namespaces in parallel (read-only)
@@ -1076,6 +1094,7 @@ export const addTranslationKeys = async ({
1076
1094
  }
1077
1095
 
1078
1096
  localeFiles.set(fileKey, existingKeys)
1097
+ changedFiles.add(fileKey)
1079
1098
  }
1080
1099
 
1081
1100
  // Step 7: Batch translate if OpenAI is configured
@@ -1119,18 +1138,12 @@ export const addTranslationKeys = async ({
1119
1138
  object: keysToTranslate,
1120
1139
  openai,
1121
1140
  model,
1122
- })
1123
- .then((translated) => {
1124
- translationCache.set(
1125
- `${inputLanguage}:${targetLocale}`,
1126
- translated,
1127
- )
1128
- })
1129
- .catch((error) => {
1130
- log(
1131
- `⚠️ Failed to translate ${keyValues.length} key(s) from ${inputLanguage} to ${targetLocale}: ${error instanceof Error ? error.message : "Unknown error"}`,
1132
- )
1133
- }),
1141
+ }).then((translated) => {
1142
+ translationCache.set(
1143
+ `${inputLanguage}:${targetLocale}`,
1144
+ translated,
1145
+ )
1146
+ }),
1134
1147
  )
1135
1148
  }
1136
1149
  }
@@ -1148,7 +1161,7 @@ export const addTranslationKeys = async ({
1148
1161
  if (!translated) continue
1149
1162
 
1150
1163
  for (const { key, namespaces } of keyValues) {
1151
- if (translated[key]) {
1164
+ if (Object.hasOwn(translated, key)) {
1152
1165
  for (const namespace of namespaces) {
1153
1166
  const fileKey = `${targetLocale}:${namespace}`
1154
1167
  // File MUST already be loaded from step 5 - if not, something went wrong
@@ -1160,6 +1173,7 @@ export const addTranslationKeys = async ({
1160
1173
  }
1161
1174
  // Merge translated key with existing keys (don't overwrite the whole file)
1162
1175
  localeFiles.get(fileKey)![key] = translated[key]
1176
+ changedFiles.add(fileKey)
1163
1177
  }
1164
1178
  }
1165
1179
  }
@@ -1191,6 +1205,7 @@ export const addTranslationKeys = async ({
1191
1205
 
1192
1206
  // All validations passed, now write files sequentially to avoid race conditions
1193
1207
  for (const [fileKey, keys] of localeFiles) {
1208
+ if (!changedFiles.has(fileKey)) continue
1194
1209
  const [locale, namespace] = fileKey.split(":")
1195
1210
  await writeLocalesFile(savePath, locale, namespace, keys)
1196
1211
  }
@@ -1212,7 +1227,7 @@ export const addTranslationKeys = async ({
1212
1227
  config.locales.forEach((locale) => {
1213
1228
  if (locale !== language) {
1214
1229
  const translated = translationCache.get(`${language}:${locale}`)
1215
- if (translated?.[key]) {
1230
+ if (translated && Object.hasOwn(translated, key)) {
1216
1231
  savedLocales.add(locale)
1217
1232
  }
1218
1233
  }
@@ -1222,6 +1237,7 @@ export const addTranslationKeys = async ({
1222
1237
  return {
1223
1238
  key,
1224
1239
  value,
1240
+ providedLanguage: language,
1225
1241
  namespace: resolvedNamespaces.join(", "),
1226
1242
  locale: Array.from(savedLocales).sort().join(", "),
1227
1243
  }
@@ -1241,7 +1257,7 @@ export const addTranslationKeys = async ({
1241
1257
  export const addTranslationKey = async ({
1242
1258
  key,
1243
1259
  value,
1244
- language = "en",
1260
+ language,
1245
1261
  config,
1246
1262
  }: {
1247
1263
  key: string
package/src/mcp-server.ts CHANGED
@@ -10,6 +10,7 @@ import path from "path"
10
10
  import { fileURLToPath } from "url"
11
11
  import { z } from "zod"
12
12
  import type { Configuration } from "./lib/types.js"
13
+ import { updateTranslationKeyOperation } from "./lib/update-translation-key.js"
13
14
  import {
14
15
  addTranslationKey,
15
16
  addTranslationKeys,
@@ -162,7 +163,7 @@ const AddTranslationKeySchema = z.object({
162
163
  .string()
163
164
  .optional()
164
165
  .describe(
165
- 'The language code of the provided value (e.g., "en", "de", "fr"). Defaults to "en" (English) if not specified.',
166
+ "The configured language code of the provided value. Defaults to config.defaultLocale if not specified.",
166
167
  ),
167
168
  })
168
169
 
@@ -181,7 +182,7 @@ const AddTranslationKeysSchema = z.object({
181
182
  .string()
182
183
  .optional()
183
184
  .describe(
184
- 'The language code of the provided value (e.g., "en", "de", "fr"). Defaults to "en" (English) if not specified.',
185
+ "The configured language code of the provided value. Defaults to config.defaultLocale if not specified.",
185
186
  ),
186
187
  }),
187
188
  )
@@ -210,7 +211,7 @@ const UpdateTranslationKeySchema = z.object({
210
211
  .string()
211
212
  .optional()
212
213
  .describe(
213
- 'The language code of the provided value (e.g., "en", "de", "fr"). Defaults to "en" (English) if not specified.',
214
+ "The configured language code of the provided value. Defaults to config.defaultLocale if not specified.",
214
215
  ),
215
216
  })
216
217
 
@@ -299,7 +300,7 @@ class I18nMagicServer {
299
300
  {
300
301
  name: "add_translation_key",
301
302
  description:
302
- "Add a new translation key with a text value. The destination translation file is resolved automatically from project configuration and code usage. You can optionally specify the language of the value you're providing (defaults to English). For adding multiple keys at once, use add_translation_keys instead for better performance. NOTE: This tool can only ADD keys, it will NEVER remove any existing keys.",
303
+ "Add a new translation key with a text value. The destination translation file is resolved automatically from project configuration and code usage. The language defaults to the configured defaultLocale. For adding multiple keys at once, use add_translation_keys instead for better performance. NOTE: This tool can only ADD keys, it will NEVER remove any existing keys.",
303
304
  inputSchema: {
304
305
  type: "object",
305
306
  properties: {
@@ -315,7 +316,7 @@ class I18nMagicServer {
315
316
  language: {
316
317
  type: "string",
317
318
  description:
318
- 'The language code of the provided value (e.g., "en" for English, "de" for German, "fr" for French). Defaults to "en" if not specified.',
319
+ "The configured language code of the provided value. Defaults to config.defaultLocale if not specified.",
319
320
  },
320
321
  },
321
322
  required: ["key", "value"],
@@ -346,7 +347,7 @@ class I18nMagicServer {
346
347
  language: {
347
348
  type: "string",
348
349
  description:
349
- 'The language code of the provided value (e.g., "en" for English, "de" for German, "fr" for French). Defaults to "en" if not specified.',
350
+ "The configured language code of the provided value. Defaults to config.defaultLocale if not specified.",
350
351
  },
351
352
  },
352
353
  required: ["key", "value"],
@@ -385,7 +386,7 @@ class I18nMagicServer {
385
386
  {
386
387
  name: "update_translation_key",
387
388
  description:
388
- "Update an existing translation key with a new text value. You can optionally specify the language of the value you're providing (defaults to English). This will update the key across all locales (translating automatically to other languages) everywhere it exists. Use this when you need to fix typos, improve wording, or change the text of an existing translation. If you're not sure if a key exists, use get_translation_key or search_translations first. NOTE: This tool can only UPDATE existing keys, it will NEVER remove any keys.",
389
+ "Update an existing translation key with a new text value. The language defaults to the configured defaultLocale. With a provider, every locale is translated before writes begin. Without a provider, only the supplied/default locale is updated and other locales are returned as pending. NOTE: This tool can only UPDATE existing keys, it will NEVER remove any keys.",
389
390
  inputSchema: {
390
391
  type: "object",
391
392
  properties: {
@@ -401,7 +402,7 @@ class I18nMagicServer {
401
402
  language: {
402
403
  type: "string",
403
404
  description:
404
- 'The language code of the provided value (e.g., "en" for English, "de" for German, "fr" for French). Defaults to "en" if not specified.',
405
+ "The configured language code of the provided value. Defaults to config.defaultLocale if not specified.",
405
406
  },
406
407
  },
407
408
  required: ["key", "value"],
@@ -448,7 +449,7 @@ class I18nMagicServer {
448
449
  return await addTranslationKey({
449
450
  key: params.key,
450
451
  value: params.value,
451
- language: params.language || "en",
452
+ language: params.language,
452
453
  config,
453
454
  })
454
455
  })
@@ -467,7 +468,7 @@ class I18nMagicServer {
467
468
  message: `Successfully added translation key "${result.key}"`,
468
469
  key: result.key,
469
470
  value: result.value,
470
- providedLanguage: params.language || "en",
471
+ providedLanguage: result.providedLanguage,
471
472
  locales: result.locale,
472
473
  nextStep: result.locale.includes(",")
473
474
  ? "Key was automatically translated to multiple locales"
@@ -564,7 +565,7 @@ class I18nMagicServer {
564
565
  keys: params.keys.map((k) => ({
565
566
  key: k.key,
566
567
  value: k.value,
567
- language: k.language || "en",
568
+ language: k.language,
568
569
  })),
569
570
  config,
570
571
  })
@@ -892,102 +893,14 @@ class I18nMagicServer {
892
893
  console.log = () => {}
893
894
 
894
895
  try {
895
- // Use mutex to prevent concurrent writes
896
- await translationMutex.withLock(async () => {
897
- // Find which namespaces contain this key
898
- const targetNamespaces: string[] = []
899
-
900
- // Find all namespaces where this key exists
901
- for (const namespace of config.namespaces) {
902
- try {
903
- const keys = await loadLocalesFile(
904
- config.loadPath,
905
- "en",
906
- namespace,
907
- )
908
- if (Object.hasOwn(keys, params.key)) {
909
- targetNamespaces.push(namespace)
910
- }
911
- } catch (error) {
912
- // Namespace file doesn't exist, continue
913
- }
914
- }
915
-
916
- if (targetNamespaces.length === 0) {
917
- throw new Error(
918
- `Key "${params.key}" does not exist in translation files. Use add_translation_key to create it.`,
919
- )
920
- }
921
-
922
- // Build translation cache with new value
923
- const inputLanguage = params.language || "en"
924
- const translationCache: Record<string, string> = {
925
- [inputLanguage]: params.value,
926
- }
927
-
928
- // Translate to all other locales
929
- const otherLocales = config.locales.filter(
930
- (l) => l !== inputLanguage,
931
- )
932
- if (otherLocales.length > 0 && config.openai) {
933
- const { translateKey } = await import("./lib/utils.js")
934
-
935
- await Promise.all(
936
- otherLocales.map(async (locale) => {
937
- const translation = await translateKey({
938
- context: config.context || "",
939
- inputLanguage: inputLanguage,
940
- outputLanguage: locale,
941
- object: {
942
- [params.key]: params.value,
943
- },
944
- openai: config.openai!,
945
- model: config.model,
946
- })
947
- translationCache[locale] = translation[params.key]
948
- }),
949
- )
950
- }
951
-
952
- // Update the key in all relevant namespaces and locales
953
- // Process sequentially to avoid race conditions
954
- const { writeLocalesFile } = await import("./lib/utils.js")
955
-
956
- for (const namespace of targetNamespaces) {
957
- for (const locale of config.locales) {
958
- const newValue = translationCache[locale] || params.value
959
-
960
- // Load existing keys
961
- const existingKeys = await loadLocalesFile(
962
- config.loadPath,
963
- locale,
964
- namespace,
965
- )
966
-
967
- const originalKeyCount = Object.keys(existingKeys).length
968
-
969
- // Update the specific key
970
- existingKeys[params.key] = newValue
971
-
972
- const newKeyCount = Object.keys(existingKeys).length
973
-
974
- // Safety check: we should only be updating, not removing
975
- // Key count should stay same (update) or increase by 1 (add)
976
- if (newKeyCount < originalKeyCount) {
977
- throw new Error(
978
- `Safety check failed: Updating locale "${locale}" would reduce keys from ${originalKeyCount} to ${newKeyCount}. Aborting.`,
979
- )
980
- }
981
-
982
- await writeLocalesFile(
983
- config.savePath,
984
- locale,
985
- namespace,
986
- existingKeys,
987
- )
988
- }
989
- }
990
- })
896
+ const result = await translationMutex.withLock(async () =>
897
+ updateTranslationKeyOperation({
898
+ config,
899
+ key: params.key,
900
+ value: params.value,
901
+ language: params.language,
902
+ }),
903
+ )
991
904
 
992
905
  return {
993
906
  content: [
@@ -996,11 +909,17 @@ class I18nMagicServer {
996
909
  text: JSON.stringify(
997
910
  {
998
911
  success: true,
999
- message: `Successfully updated translation key "${params.key}" in ${config.locales.length} locale(s)`,
1000
- key: params.key,
1001
- newValue: params.value,
1002
- providedLanguage: params.language || "en",
1003
- locales: config.locales,
912
+ message: `Successfully updated translation key "${result.key}" in ${result.updatedLocales.length} locale(s)`,
913
+ key: result.key,
914
+ newValue: result.value,
915
+ providedLanguage: result.sourceLocale,
916
+ updatedLocales: result.updatedLocales,
917
+ pendingLocales: result.pendingLocales,
918
+ locales: result.updatedLocales,
919
+ nextStep:
920
+ result.pendingLocales.length > 0
921
+ ? "Run 'i18n-magic sync' with a configured provider to translate pending locales."
922
+ : "All configured locales were updated.",
1004
923
  },
1005
924
  null,
1006
925
  2,