@camscanner/mcp-language-server 1.0.0 → 1.2.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 CHANGED
@@ -37,6 +37,27 @@ claude plugin install i18n@camscanner-plugins
37
37
  - 浏览器数据持久化在 `~/.language-mcp/browser-data/`,保存的密码下次自动填充
38
38
  - 认证信息保存在 `~/.language-mcp/credentials.json`,有效期 24 小时
39
39
 
40
+ ## 开发者指南
41
+
42
+ ### 发布新版本
43
+
44
+ ```bash
45
+ # 1. 修改代码并构建
46
+ npm run build
47
+
48
+ # 2. 更新版本号并发布到 npm
49
+ npm version patch # bug fix: 1.0.0 → 1.0.1
50
+ npm version minor # 新功能: 1.0.0 → 1.1.0
51
+ npm version major # 破坏性变更: 1.0.0 → 2.0.0
52
+
53
+ npm publish --registry https://registry.npmjs.org/ --access public
54
+
55
+ # 3. 推送 tag 到远端
56
+ git push && git push --tags
57
+ ```
58
+
59
+ 用户下次启动 Claude Code 时,`npx -y @camscanner/mcp-language-server@latest` 会自动拉取新版本。
60
+
40
61
  ## 常用产品 ID
41
62
 
42
63
  | ID | 产品 |
package/dist/index.js CHANGED
@@ -163,6 +163,71 @@ server.tool('search-string', '按关键词搜索多语言字符串。可指定
163
163
  }
164
164
  return { content: [{ type: 'text', text: output }] };
165
165
  });
166
+ // Tool: batch-search-string
167
+ server.tool('batch-search-string', '批量搜索多语言字符串。支持同时搜索多个关键词,每个关键词独立返回结果。适合一次性查询大量字符串是否已在平台录入。', {
168
+ product_id: v3_1.z.string().describe('产品ID,先调用 list-products 查询'),
169
+ words: v3_1.z.array(v3_1.z.string()).describe('搜索关键词数组(中文、英文或 key 名)'),
170
+ version_id: v3_1.z.string().optional().describe('版本ID,不传则搜索所有版本'),
171
+ fuzzy: v3_1.z.string().optional().default('0').describe('1=模糊匹配,0=精确匹配(默认)'),
172
+ page_size: v3_1.z.string().optional().default('20').describe('每个关键词的每页条数,最大100'),
173
+ }, async ({ product_id, words, version_id, fuzzy, page_size }) => {
174
+ const authErr = await requireAuth();
175
+ if (authErr)
176
+ return { content: [{ type: 'text', text: authErr }] };
177
+ const results = [];
178
+ let foundCount = 0;
179
+ let notFoundCount = 0;
180
+ // 并发搜索所有关键词
181
+ const tasks = words.map(async (word) => {
182
+ const params = {
183
+ product_id, word, fuzzy: fuzzy || '0', page: '1', page_size: page_size || '20',
184
+ };
185
+ if (version_id)
186
+ params.version_id = version_id;
187
+ try {
188
+ const data = await client.post('/language/mcp-language/get-string-search', params);
189
+ if (data.errno !== 0) {
190
+ return { word, found: false, output: `❌ "${word}" — 查询失败: ${data.message || '未知错误'}` };
191
+ }
192
+ const versions = Array.isArray(data.data) ? data.data : (data.data?.list || []);
193
+ if (versions.length === 0) {
194
+ return { word, found: false, output: `❌ "${word}" — 未找到` };
195
+ }
196
+ let output = `✅ "${word}" — ${versions.length} 个版本匹配:\n`;
197
+ for (const version of versions) {
198
+ const strings = version.ar_string || version.strings || [];
199
+ for (const str of strings) {
200
+ const keys = str.keys
201
+ ? Object.entries(str.keys).map(([p, k]) => `platform_${p}: ${k}`).join(', ')
202
+ : '无';
203
+ const zhCN = str.values?.['1'] || str.values?.['0'] || '';
204
+ const enUS = str.values?.['2'] || str.values?.['0'] || '';
205
+ const zhTW = str.values?.['7'] || '';
206
+ output += ` - key: ${keys} | 中: ${zhCN} | 英: ${enUS}`;
207
+ if (zhTW)
208
+ output += ` | 繁: ${zhTW}`;
209
+ output += ` (v${version.version_number})\n`;
210
+ }
211
+ }
212
+ return { word, found: true, output };
213
+ }
214
+ catch (err) {
215
+ return { word, found: false, output: `❌ "${word}" — 请求异常: ${err.message}` };
216
+ }
217
+ });
218
+ const settled = await Promise.all(tasks);
219
+ for (const r of settled) {
220
+ results.push(r.output);
221
+ if (r.found)
222
+ foundCount++;
223
+ else
224
+ notFoundCount++;
225
+ }
226
+ let output = `## 批量搜索结果\n\n`;
227
+ output += `共搜索 ${words.length} 个关键词: ✅ ${foundCount} 个找到, ❌ ${notFoundCount} 个未找到\n\n`;
228
+ output += results.join('\n');
229
+ return { content: [{ type: 'text', text: output }] };
230
+ });
166
231
  // Tool: export-string
167
232
  server.tool('export-string', '搜索多语言字符串并导出为兼容 cs-i18n 的 locale JSON 格式。支持单语言或全部语言导出,自动将 %s 替换为 {0}/{1}/{2}。', {
168
233
  product_id: v3_1.z.string().describe('产品ID。常用: 1=CamCard, 2=CamScanner, 44=CS Lite, 47=CS PDF, 53=CS Harmony'),
@@ -270,20 +335,54 @@ server.tool('write-locales', '搜索多语言字符串并直接写入项目的 l
270
335
  results.push(`${localeName}.json: 无变化 (${Object.keys(newEntries).length} 条已存在)`);
271
336
  }
272
337
  }
273
- // Detect local locale files that got no remote data
338
+ // Fallback: local locale files with no remote translation use English (langId=2)
339
+ const englishEntries = allStrings['2'] || {};
274
340
  const localLocaleNames = fs_1.default.readdirSync(locales_path)
275
341
  .filter(f => f.endsWith('.json'))
276
342
  .map(f => f.replace('.json', ''));
277
343
  const missingLocales = (0, utils_js_1.findMissingLocales)(localLocaleNames, Object.keys(allStrings));
344
+ let fallbackCount = 0;
345
+ if (Object.keys(englishEntries).length > 0) {
346
+ for (const localeName of missingLocales) {
347
+ const filePath = path_1.default.join(locales_path, `${localeName}.json`);
348
+ if (!fs_1.default.existsSync(filePath))
349
+ continue;
350
+ let existingObj = {};
351
+ try {
352
+ const content = fs_1.default.readFileSync(filePath, 'utf-8');
353
+ existingObj = JSON.parse(content);
354
+ }
355
+ catch {
356
+ // empty or invalid file
357
+ }
358
+ const { merged, keysAdded, keysUpdated } = (0, utils_js_1.mergeLocaleEntries)(existingObj, englishEntries);
359
+ fs_1.default.writeFileSync(filePath, JSON.stringify(merged, null, 2) + '\n');
360
+ filesWritten++;
361
+ fallbackCount++;
362
+ totalKeys += Object.keys(englishEntries).length;
363
+ if (keysAdded.length > 0 || keysUpdated.length > 0) {
364
+ results.push(`${localeName}.json: +${keysAdded.length} 新增, ~${keysUpdated.length} 更新 (英语兜底)`);
365
+ }
366
+ else {
367
+ results.push(`${localeName}.json: 无变化 (${Object.keys(englishEntries).length} 条已存在)`);
368
+ }
369
+ }
370
+ }
371
+ // Remaining missing locales (no English fallback available)
372
+ const stillMissing = Object.keys(englishEntries).length > 0
373
+ ? [] // all missing locales got English fallback
374
+ : missingLocales;
278
375
  let output = `写入完成!\n`;
279
376
  output += `- 目录: ${locales_path}\n`;
280
377
  output += `- 写入 ${filesWritten} 个文件, 跳过 ${filesSkipped} 个 (项目中不存在)\n`;
378
+ if (fallbackCount > 0) {
379
+ output += `- 其中 ${fallbackCount} 个文件使用英语兜底\n`;
380
+ }
281
381
  output += `- 共 ${totalKeys} 条字符串\n\n`;
282
382
  output += results.map(r => ` ${r}`).join('\n');
283
- if (missingLocales.length > 0) {
284
- output += `\n\n⚠️ 以下 ${missingLocales.length} 个本地语言文件未获得远程翻译,未被更新:\n`;
285
- output += missingLocales.map(name => ` - ${name}.json`).join('\n');
286
- output += `\n请确认远程平台是否已为这些语言提供翻译。`;
383
+ if (stillMissing.length > 0) {
384
+ output += `\n\n⚠️ 以下 ${stillMissing.length} 个本地语言文件未获得远程翻译,且无英语兜底:\n`;
385
+ output += stillMissing.map(name => ` - ${name}.json`).join('\n');
287
386
  }
288
387
  return { content: [{ type: 'text', text: output }] };
289
388
  });
package/dist/utils.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export declare const PLATFORM_MAP: Record<number, string>;
2
+ export declare function getLocaleName(langId: string | number): string | null;
2
3
  export declare const LANGUAGE_LOCALE_MAP: Record<string, string>;
3
4
  export declare function fixPlaceholders(value: string): string;
4
5
  export declare function extractStrings(versions: any[], platformId: string): Record<string, Record<string, string>>;
package/dist/utils.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // --- Constants ---
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.LANGUAGE_LOCALE_MAP = exports.PLATFORM_MAP = void 0;
5
+ exports.getLocaleName = getLocaleName;
5
6
  exports.fixPlaceholders = fixPlaceholders;
6
7
  exports.extractStrings = extractStrings;
7
8
  exports.mergeLocaleEntries = mergeLocaleEntries;
@@ -15,35 +16,54 @@ exports.PLATFORM_MAP = {
15
16
  8: 'Harmony',
16
17
  9: 'PC',
17
18
  };
18
- exports.LANGUAGE_LOCALE_MAP = {
19
- '1': 'ZhCn',
20
- '2': 'EnUs',
21
- '3': 'JaJp',
22
- '4': 'KoKr',
23
- '5': 'FrFr',
24
- '6': 'DeDe',
25
- '7': 'ZhTw',
26
- '8': 'PtBr',
27
- '9': 'EsEs',
28
- '10': 'ItIt',
29
- '11': 'RuRu',
30
- '12': 'TrTr',
31
- '13': 'ArSa',
32
- '14': 'ThTh',
33
- '15': 'PlPl',
34
- '16': 'ViVn',
35
- '17': 'InId',
36
- '19': 'MsMy',
37
- '20': 'NlNl',
38
- '22': 'HiDi',
39
- '23': 'BnBd',
40
- '24': 'CsCs',
41
- '25': 'SkSk',
42
- '26': 'FilPh',
43
- '27': 'ElEl',
44
- '28': 'PtPt',
45
- '29': 'RoRo',
19
+ // 语言ID → locale 字符串映射(从 operate-main AppConfMacro::$ar_language_map 复制)
20
+ const LANGUAGE_STRING_MAP = {
21
+ 1: 'zh-cn', 2: 'en-us', 3: 'de-de', 4: 'fr-fr',
22
+ 5: 'ja-jp', 6: 'ko-kr', 7: 'zh-tw', 8: 'es-es',
23
+ 9: 'ru-ru', 10: 'sk-sk', 11: 'cs-cs', 12: 'pt-pt',
24
+ 13: 'pl-pl', 14: 'it-it', 15: 'tr-tr', 16: 'ar-ar',
25
+ 17: 'pt-br', 19: 'ag-ag', 20: 'sm-sm', 22: 'id-id',
26
+ 23: 'th-th', 24: 'fil-ph', 25: 'ms-my', 26: 'vi-vn',
27
+ 27: 'bn-bd', 28: 'fa-ir', 29: 'hi-in', 30: 'nl-nl',
28
+ 31: 'el-gr', 32: 'hu-hu', 33: 'uk-ua', 34: 'no-no',
29
+ 35: 'da-dk', 36: 'ur-pk', 37: 'hr-hr', 38: 'hy-am',
30
+ 39: 'bg-bg', 40: 'si-lk', 41: 'is-is', 42: 'kk-kz',
31
+ 43: 'sr-rs', 44: 'ne-np', 45: 'lv-lv', 46: 'sl-si',
32
+ 47: 'sw-ke', 48: 'ka-ge', 49: 'et-ee', 50: 'sv-se',
33
+ 51: 'be-by', 52: 'zu-za', 53: 'lt-lt', 54: 'my-mm',
34
+ 55: 'ro-ro', 56: 'lo-la', 57: 'mn-mn', 58: 'az-az',
35
+ 59: 'am-et', 60: 'sq-al', 61: 'mk-mk', 62: 'gl-es',
36
+ 63: 'ca-es', 64: 'af-za', 65: 'kn-in', 66: 'gu-in',
37
+ 67: 'eu-es', 68: 'iw-il', 69: 'pa-in', 70: 'ky-kg',
38
+ 71: 'te-in', 72: 'ta-in', 73: 'rm-ch', 74: 'mr-in',
39
+ 75: 'ml-in', 76: 'km-kh', 77: 'bs-ba', 78: 'lb-lu',
40
+ 79: 'rw-rw', 80: 'mt-mt', 81: 'uz-uz', 82: 'ga-ie',
46
41
  };
42
+ // locale 字符串 → PascalCase 文件名(从 operate-main MacroExport::filter2 复制)
43
+ function toLocaleName(localeStr) {
44
+ return localeStr.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('');
45
+ }
46
+ // 特殊映射:项目 locale 文件名与 filter2 输出不一致的语言
47
+ const LOCALE_NAME_OVERRIDE = {
48
+ 16: 'ArSa', // filter2: ArAr → 实际: ArSa
49
+ 20: 'FiFi', // filter2: SmSm → 实际: FiFi (芬兰语)
50
+ 22: 'InId', // filter2: IdId → 实际: InId (印尼语)
51
+ 23: 'Th', // filter2: ThTh → 实际: Th (泰语)
52
+ 29: 'HiDi', // filter2: HiIn → 实际: HiDi (印地语)
53
+ 31: 'ElEl', // filter2: ElGr → 实际: ElEl (希腊语)
54
+ };
55
+ // 获取 locale 文件名(优先用 override,否则 filter2 动态生成)
56
+ function getLocaleName(langId) {
57
+ const id = Number(langId);
58
+ if (LOCALE_NAME_OVERRIDE[id])
59
+ return LOCALE_NAME_OVERRIDE[id];
60
+ const localeStr = LANGUAGE_STRING_MAP[id];
61
+ if (!localeStr)
62
+ return null;
63
+ return toLocaleName(localeStr);
64
+ }
65
+ // 兼容旧接口:动态生成完整映射表
66
+ exports.LANGUAGE_LOCALE_MAP = Object.fromEntries(Object.keys(LANGUAGE_STRING_MAP).map(id => [String(id), getLocaleName(id)]).filter(([, v]) => v));
47
67
  // --- Helpers ---
48
68
  function fixPlaceholders(value) {
49
69
  let cnt = 0;
package/index.js CHANGED
@@ -116,37 +116,55 @@ const PRODUCT_MAP = {
116
116
  53: "CS Harmony",
117
117
  };
118
118
 
119
- // 语言ID → locale 文件名映射(兼容 cs-i18n 工具)
120
- const LANGUAGE_LOCALE_MAP = {
121
- "1": "ZhCn", // 简体中文
122
- "2": "EnUs", // 英语
123
- "3": "JaJp", // 日语
124
- "4": "KoKr", // 韩语
125
- "5": "FrFr", // 法语
126
- "6": "DeDe", // 德语
127
- "7": "ZhTw", // 繁体中文
128
- "8": "PtBr", // 巴西葡萄牙语
129
- "9": "EsEs", // 西班牙语
130
- "10": "ItIt", // 意大利语
131
- "11": "RuRu", // 俄语
132
- "12": "TrTr", // 土耳其语
133
- "13": "ArSa", // 阿拉伯语
134
- "14": "ThTh", // 泰语(Th)
135
- "15": "PlPl", // 波兰语
136
- "16": "ViVn", // 越南语
137
- "17": "InId", // 印度尼西亚语
138
- "19": "MsMy", // 马来语
139
- "20": "NlNl", // 荷兰语
140
- "22": "HiDi", // 印地语
141
- "23": "BnBd", // 孟加拉语
142
- "24": "CsCs", // 捷克语
143
- "25": "SkSk", // 斯洛伐克语
144
- "26": "FilPh", // 菲律宾语
145
- "27": "ElEl", // 希腊语
146
- "28": "PtPt", // 葡萄牙语
147
- "29": "RoRo", // 罗马尼亚语
119
+ // 语言ID → locale 字符串映射(从 operate-main AppConfMacro::$ar_language_map 复制)
120
+ const LANGUAGE_STRING_MAP = {
121
+ 1: "zh-cn", 2: "en-us", 3: "de-de", 4: "fr-fr",
122
+ 5: "ja-jp", 6: "ko-kr", 7: "zh-tw", 8: "es-es",
123
+ 9: "ru-ru", 10: "sk-sk", 11: "cs-cs", 12: "pt-pt",
124
+ 13: "pl-pl", 14: "it-it", 15: "tr-tr", 16: "ar-ar",
125
+ 17: "pt-br", 19: "ag-ag", 20: "sm-sm", 22: "id-id",
126
+ 23: "th-th", 24: "fil-ph", 25: "ms-my", 26: "vi-vn",
127
+ 27: "bn-bd", 28: "fa-ir", 29: "hi-in", 30: "nl-nl",
128
+ 31: "el-gr", 32: "hu-hu", 33: "uk-ua", 34: "no-no",
129
+ 35: "da-dk", 36: "ur-pk", 37: "hr-hr", 38: "hy-am",
130
+ 39: "bg-bg", 40: "si-lk", 41: "is-is", 42: "kk-kz",
131
+ 43: "sr-rs", 44: "ne-np", 45: "lv-lv", 46: "sl-si",
132
+ 47: "sw-ke", 48: "ka-ge", 49: "et-ee", 50: "sv-se",
133
+ 51: "be-by", 52: "zu-za", 53: "lt-lt", 54: "my-mm",
134
+ 55: "ro-ro", 56: "lo-la", 57: "mn-mn", 58: "az-az",
135
+ 59: "am-et", 60: "sq-al", 61: "mk-mk", 62: "gl-es",
136
+ 63: "ca-es", 64: "af-za", 65: "kn-in", 66: "gu-in",
137
+ 67: "eu-es", 68: "iw-il", 69: "pa-in", 70: "ky-kg",
138
+ 71: "te-in", 72: "ta-in", 73: "rm-ch", 74: "mr-in",
139
+ 75: "ml-in", 76: "km-kh", 77: "bs-ba", 78: "lb-lu",
140
+ 79: "rw-rw", 80: "mt-mt", 81: "uz-uz", 82: "ga-ie",
148
141
  };
149
142
 
143
+ // locale 字符串 → PascalCase 文件名(从 operate-main MacroExport::filter2 复制)
144
+ // 'zh-cn' → 'ZhCn', 'fil-ph' → 'FilPh'
145
+ function toLocaleName(localeStr) {
146
+ return localeStr.split("-").map(s => s.charAt(0).toUpperCase() + s.slice(1)).join("");
147
+ }
148
+
149
+ // 特殊映射:项目 locale 文件名与 filter2 输出不一致的语言
150
+ const LOCALE_NAME_OVERRIDE = {
151
+ 16: "ArSa", // filter2: ArAr → 实际: ArSa
152
+ 20: "FiFi", // filter2: SmSm → 实际: FiFi (芬兰语)
153
+ 22: "InId", // filter2: IdId → 实际: InId (印尼语)
154
+ 23: "Th", // filter2: ThTh → 实际: Th (泰语)
155
+ 29: "HiDi", // filter2: HiIn → 实际: HiDi (印地语)
156
+ 31: "ElEl", // filter2: ElGr → 实际: ElEl (希腊语)
157
+ };
158
+
159
+ // 获取 locale 文件名(优先用 override,否则 filter2 动态生成)
160
+ function getLocaleName(langId) {
161
+ const id = Number(langId);
162
+ if (LOCALE_NAME_OVERRIDE[id]) return LOCALE_NAME_OVERRIDE[id];
163
+ const localeStr = LANGUAGE_STRING_MAP[id];
164
+ if (!localeStr) return null;
165
+ return toLocaleName(localeStr);
166
+ }
167
+
150
168
  // %s → {0}, {1}, {2}... 替换
151
169
  function fixPlaceholders(value) {
152
170
  let cnt = 0;
@@ -460,7 +478,7 @@ function registerTools(server) {
460
478
  content: [{ type: "text", text: `找到字符串但语言ID=${language_id} 无翻译内容` }],
461
479
  };
462
480
  }
463
- const localeName = LANGUAGE_LOCALE_MAP[language_id] || `lang_${language_id}`;
481
+ const localeName = getLocaleName(language_id) || `lang_${language_id}`;
464
482
  const json = JSON.stringify(localeObj, null, 2);
465
483
  return {
466
484
  content: [{
@@ -473,7 +491,7 @@ function registerTools(server) {
473
491
  // 全部语言导出
474
492
  let output = `共匹配 ${Object.keys(allStrings).length} 种语言:\n\n`;
475
493
  for (const [langId, localeObj] of Object.entries(allStrings)) {
476
- const localeName = LANGUAGE_LOCALE_MAP[langId] || `lang_${langId}`;
494
+ const localeName = getLocaleName(langId) || `lang_${langId}`;
477
495
  const json = JSON.stringify(localeObj, null, 2);
478
496
  output += `### ${localeName}.json (语言ID=${langId}, ${Object.keys(localeObj).length} 条)\n\`\`\`json\n${json}\n\`\`\`\n\n`;
479
497
  }
@@ -540,7 +558,7 @@ function registerTools(server) {
540
558
  let filesSkipped = 0;
541
559
 
542
560
  for (const [langId, newEntries] of Object.entries(allStrings)) {
543
- const localeName = LANGUAGE_LOCALE_MAP[langId];
561
+ const localeName = getLocaleName(langId);
544
562
  if (!localeName) {
545
563
  filesSkipped++;
546
564
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camscanner/mcp-language-server",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "MCP Server for multi-language string management",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "language": {
3
3
  "command": "npx",
4
- "args": ["-y", "git+https://github.com/tianmuji/mcp-language-server.git"],
4
+ "args": ["-y", "@camscanner/mcp-language-server@latest"],
5
5
  "env": {
6
6
  "OPERATE_BASE_URL": "https://operate.intsig.net",
7
7
  "SSO_LOGIN_URL": "https://web-sso.intsig.net/login",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "yapi": {
3
3
  "command": "npx",
4
- "args": ["-y", "git+https://gitlab.intsig.net/cs-templates/skills/yapi-mcp-server.git"],
4
+ "args": ["-y", "@camscanner/yapi-mcp-server@latest"],
5
5
  "env": {
6
6
  "YAPI_BASE_URL": "https://web-api.intsig.net",
7
7
  "SSO_LOGIN_URL": "https://web-sso.intsig.net/login",
package/src/index.ts CHANGED
@@ -210,6 +210,79 @@ server.tool(
210
210
  }
211
211
  )
212
212
 
213
+ // Tool: batch-search-string
214
+ server.tool(
215
+ 'batch-search-string',
216
+ '批量搜索多语言字符串。支持同时搜索多个关键词,每个关键词独立返回结果。适合一次性查询大量字符串是否已在平台录入。',
217
+ {
218
+ product_id: z.string().describe('产品ID,先调用 list-products 查询'),
219
+ words: z.array(z.string()).describe('搜索关键词数组(中文、英文或 key 名)'),
220
+ version_id: z.string().optional().describe('版本ID,不传则搜索所有版本'),
221
+ fuzzy: z.string().optional().default('0').describe('1=模糊匹配,0=精确匹配(默认)'),
222
+ page_size: z.string().optional().default('20').describe('每个关键词的每页条数,最大100'),
223
+ },
224
+ async ({ product_id, words, version_id, fuzzy, page_size }) => {
225
+ const authErr = await requireAuth()
226
+ if (authErr) return { content: [{ type: 'text', text: authErr }] }
227
+
228
+ const results: string[] = []
229
+ let foundCount = 0
230
+ let notFoundCount = 0
231
+
232
+ // 并发搜索所有关键词
233
+ const tasks = words.map(async (word) => {
234
+ const params: Record<string, string> = {
235
+ product_id, word, fuzzy: fuzzy || '0', page: '1', page_size: page_size || '20',
236
+ }
237
+ if (version_id) params.version_id = version_id
238
+
239
+ try {
240
+ const data = await client.post('/language/mcp-language/get-string-search', params)
241
+ if (data.errno !== 0) {
242
+ return { word, found: false, output: `❌ "${word}" — 查询失败: ${data.message || '未知错误'}` }
243
+ }
244
+
245
+ const versions = Array.isArray(data.data) ? data.data : (data.data?.list || [])
246
+ if (versions.length === 0) {
247
+ return { word, found: false, output: `❌ "${word}" — 未找到` }
248
+ }
249
+
250
+ let output = `✅ "${word}" — ${versions.length} 个版本匹配:\n`
251
+ for (const version of versions) {
252
+ const strings = version.ar_string || version.strings || []
253
+ for (const str of strings) {
254
+ const keys = str.keys
255
+ ? Object.entries(str.keys).map(([p, k]) => `platform_${p}: ${k}`).join(', ')
256
+ : '无'
257
+ const zhCN = str.values?.['1'] || str.values?.['0'] || ''
258
+ const enUS = str.values?.['2'] || str.values?.['0'] || ''
259
+ const zhTW = str.values?.['7'] || ''
260
+ output += ` - key: ${keys} | 中: ${zhCN} | 英: ${enUS}`
261
+ if (zhTW) output += ` | 繁: ${zhTW}`
262
+ output += ` (v${version.version_number})\n`
263
+ }
264
+ }
265
+ return { word, found: true, output }
266
+ } catch (err: any) {
267
+ return { word, found: false, output: `❌ "${word}" — 请求异常: ${err.message}` }
268
+ }
269
+ })
270
+
271
+ const settled = await Promise.all(tasks)
272
+ for (const r of settled) {
273
+ results.push(r.output)
274
+ if (r.found) foundCount++
275
+ else notFoundCount++
276
+ }
277
+
278
+ let output = `## 批量搜索结果\n\n`
279
+ output += `共搜索 ${words.length} 个关键词: ✅ ${foundCount} 个找到, ❌ ${notFoundCount} 个未找到\n\n`
280
+ output += results.join('\n')
281
+
282
+ return { content: [{ type: 'text', text: output }] }
283
+ }
284
+ )
285
+
213
286
  // Tool: export-string
214
287
  server.tool(
215
288
  'export-string',
@@ -341,22 +414,58 @@ server.tool(
341
414
  }
342
415
  }
343
416
 
344
- // Detect local locale files that got no remote data
417
+ // Fallback: local locale files with no remote translation use English (langId=2)
418
+ const englishEntries = allStrings['2'] || {}
345
419
  const localLocaleNames = fs.readdirSync(locales_path)
346
420
  .filter(f => f.endsWith('.json'))
347
421
  .map(f => f.replace('.json', ''))
348
422
  const missingLocales = findMissingLocales(localLocaleNames, Object.keys(allStrings))
423
+ let fallbackCount = 0
424
+
425
+ if (Object.keys(englishEntries).length > 0) {
426
+ for (const localeName of missingLocales) {
427
+ const filePath = path.join(locales_path, `${localeName}.json`)
428
+ if (!fs.existsSync(filePath)) continue
429
+
430
+ let existingObj: Record<string, string> = {}
431
+ try {
432
+ const content = fs.readFileSync(filePath, 'utf-8')
433
+ existingObj = JSON.parse(content)
434
+ } catch {
435
+ // empty or invalid file
436
+ }
437
+
438
+ const { merged, keysAdded, keysUpdated } = mergeLocaleEntries(existingObj, englishEntries)
439
+
440
+ fs.writeFileSync(filePath, JSON.stringify(merged, null, 2) + '\n')
441
+ filesWritten++
442
+ fallbackCount++
443
+ totalKeys += Object.keys(englishEntries).length
444
+ if (keysAdded.length > 0 || keysUpdated.length > 0) {
445
+ results.push(`${localeName}.json: +${keysAdded.length} 新增, ~${keysUpdated.length} 更新 (英语兜底)`)
446
+ } else {
447
+ results.push(`${localeName}.json: 无变化 (${Object.keys(englishEntries).length} 条已存在)`)
448
+ }
449
+ }
450
+ }
451
+
452
+ // Remaining missing locales (no English fallback available)
453
+ const stillMissing = Object.keys(englishEntries).length > 0
454
+ ? [] // all missing locales got English fallback
455
+ : missingLocales
349
456
 
350
457
  let output = `写入完成!\n`
351
458
  output += `- 目录: ${locales_path}\n`
352
459
  output += `- 写入 ${filesWritten} 个文件, 跳过 ${filesSkipped} 个 (项目中不存在)\n`
460
+ if (fallbackCount > 0) {
461
+ output += `- 其中 ${fallbackCount} 个文件使用英语兜底\n`
462
+ }
353
463
  output += `- 共 ${totalKeys} 条字符串\n\n`
354
464
  output += results.map(r => ` ${r}`).join('\n')
355
465
 
356
- if (missingLocales.length > 0) {
357
- output += `\n\n⚠️ 以下 ${missingLocales.length} 个本地语言文件未获得远程翻译,未被更新:\n`
358
- output += missingLocales.map(name => ` - ${name}.json`).join('\n')
359
- output += `\n请确认远程平台是否已为这些语言提供翻译。`
466
+ if (stillMissing.length > 0) {
467
+ output += `\n\n⚠️ 以下 ${stillMissing.length} 个本地语言文件未获得远程翻译,且无英语兜底:\n`
468
+ output += stillMissing.map(name => ` - ${name}.json`).join('\n')
360
469
  }
361
470
 
362
471
  return { content: [{ type: 'text', text: output }] }
package/src/utils.ts CHANGED
@@ -10,36 +10,59 @@ export const PLATFORM_MAP: Record<number, string> = {
10
10
  9: 'PC',
11
11
  }
12
12
 
13
- export const LANGUAGE_LOCALE_MAP: Record<string, string> = {
14
- '1': 'ZhCn',
15
- '2': 'EnUs',
16
- '3': 'JaJp',
17
- '4': 'KoKr',
18
- '5': 'FrFr',
19
- '6': 'DeDe',
20
- '7': 'ZhTw',
21
- '8': 'PtBr',
22
- '9': 'EsEs',
23
- '10': 'ItIt',
24
- '11': 'RuRu',
25
- '12': 'TrTr',
26
- '13': 'ArSa',
27
- '14': 'ThTh',
28
- '15': 'PlPl',
29
- '16': 'ViVn',
30
- '17': 'InId',
31
- '19': 'MsMy',
32
- '20': 'NlNl',
33
- '22': 'HiDi',
34
- '23': 'BnBd',
35
- '24': 'CsCs',
36
- '25': 'SkSk',
37
- '26': 'FilPh',
38
- '27': 'ElEl',
39
- '28': 'PtPt',
40
- '29': 'RoRo',
13
+ // 语言ID locale 字符串映射(从 operate-main AppConfMacro::$ar_language_map 复制)
14
+ const LANGUAGE_STRING_MAP: Record<number, string> = {
15
+ 1: 'zh-cn', 2: 'en-us', 3: 'de-de', 4: 'fr-fr',
16
+ 5: 'ja-jp', 6: 'ko-kr', 7: 'zh-tw', 8: 'es-es',
17
+ 9: 'ru-ru', 10: 'sk-sk', 11: 'cs-cs', 12: 'pt-pt',
18
+ 13: 'pl-pl', 14: 'it-it', 15: 'tr-tr', 16: 'ar-ar',
19
+ 17: 'pt-br', 19: 'ag-ag', 20: 'sm-sm', 22: 'id-id',
20
+ 23: 'th-th', 24: 'fil-ph', 25: 'ms-my', 26: 'vi-vn',
21
+ 27: 'bn-bd', 28: 'fa-ir', 29: 'hi-in', 30: 'nl-nl',
22
+ 31: 'el-gr', 32: 'hu-hu', 33: 'uk-ua', 34: 'no-no',
23
+ 35: 'da-dk', 36: 'ur-pk', 37: 'hr-hr', 38: 'hy-am',
24
+ 39: 'bg-bg', 40: 'si-lk', 41: 'is-is', 42: 'kk-kz',
25
+ 43: 'sr-rs', 44: 'ne-np', 45: 'lv-lv', 46: 'sl-si',
26
+ 47: 'sw-ke', 48: 'ka-ge', 49: 'et-ee', 50: 'sv-se',
27
+ 51: 'be-by', 52: 'zu-za', 53: 'lt-lt', 54: 'my-mm',
28
+ 55: 'ro-ro', 56: 'lo-la', 57: 'mn-mn', 58: 'az-az',
29
+ 59: 'am-et', 60: 'sq-al', 61: 'mk-mk', 62: 'gl-es',
30
+ 63: 'ca-es', 64: 'af-za', 65: 'kn-in', 66: 'gu-in',
31
+ 67: 'eu-es', 68: 'iw-il', 69: 'pa-in', 70: 'ky-kg',
32
+ 71: 'te-in', 72: 'ta-in', 73: 'rm-ch', 74: 'mr-in',
33
+ 75: 'ml-in', 76: 'km-kh', 77: 'bs-ba', 78: 'lb-lu',
34
+ 79: 'rw-rw', 80: 'mt-mt', 81: 'uz-uz', 82: 'ga-ie',
41
35
  }
42
36
 
37
+ // locale 字符串 → PascalCase 文件名(从 operate-main MacroExport::filter2 复制)
38
+ function toLocaleName(localeStr: string): string {
39
+ return localeStr.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('')
40
+ }
41
+
42
+ // 特殊映射:项目 locale 文件名与 filter2 输出不一致的语言
43
+ const LOCALE_NAME_OVERRIDE: Record<number, string> = {
44
+ 16: 'ArSa', // filter2: ArAr → 实际: ArSa
45
+ 20: 'FiFi', // filter2: SmSm → 实际: FiFi (芬兰语)
46
+ 22: 'InId', // filter2: IdId → 实际: InId (印尼语)
47
+ 23: 'Th', // filter2: ThTh → 实际: Th (泰语)
48
+ 29: 'HiDi', // filter2: HiIn → 实际: HiDi (印地语)
49
+ 31: 'ElEl', // filter2: ElGr → 实际: ElEl (希腊语)
50
+ }
51
+
52
+ // 获取 locale 文件名(优先用 override,否则 filter2 动态生成)
53
+ export function getLocaleName(langId: string | number): string | null {
54
+ const id = Number(langId)
55
+ if (LOCALE_NAME_OVERRIDE[id]) return LOCALE_NAME_OVERRIDE[id]
56
+ const localeStr = LANGUAGE_STRING_MAP[id]
57
+ if (!localeStr) return null
58
+ return toLocaleName(localeStr)
59
+ }
60
+
61
+ // 兼容旧接口:动态生成完整映射表
62
+ export const LANGUAGE_LOCALE_MAP: Record<string, string> = Object.fromEntries(
63
+ Object.keys(LANGUAGE_STRING_MAP).map(id => [String(id), getLocaleName(id)!]).filter(([, v]) => v)
64
+ )
65
+
43
66
  // --- Helpers ---
44
67
 
45
68
  export function fixPlaceholders(value: string): string {