@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 +21 -0
- package/dist/index.js +104 -5
- package/dist/utils.d.ts +1 -0
- package/dist/utils.js +48 -28
- package/index.js +50 -32
- package/package.json +1 -1
- package/plugins/i18n/.mcp.json +1 -1
- package/plugins/yapi/.mcp.json +1 -1
- package/src/index.ts +114 -5
- package/src/utils.ts +51 -28
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
|
-
//
|
|
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 (
|
|
284
|
-
output += `\n\n⚠️ 以下 ${
|
|
285
|
-
output +=
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
'2': '
|
|
21
|
-
'
|
|
22
|
-
'
|
|
23
|
-
'
|
|
24
|
-
'
|
|
25
|
-
'
|
|
26
|
-
'
|
|
27
|
-
'
|
|
28
|
-
'
|
|
29
|
-
'
|
|
30
|
-
'
|
|
31
|
-
'
|
|
32
|
-
'
|
|
33
|
-
'
|
|
34
|
-
'
|
|
35
|
-
'
|
|
36
|
-
'
|
|
37
|
-
'
|
|
38
|
-
'
|
|
39
|
-
'
|
|
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
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
"
|
|
123
|
-
"
|
|
124
|
-
"
|
|
125
|
-
"
|
|
126
|
-
"
|
|
127
|
-
"
|
|
128
|
-
"
|
|
129
|
-
"
|
|
130
|
-
"
|
|
131
|
-
"
|
|
132
|
-
"
|
|
133
|
-
"
|
|
134
|
-
"
|
|
135
|
-
"
|
|
136
|
-
"
|
|
137
|
-
"
|
|
138
|
-
"
|
|
139
|
-
"
|
|
140
|
-
"
|
|
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 =
|
|
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 =
|
|
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 =
|
|
561
|
+
const localeName = getLocaleName(langId);
|
|
544
562
|
if (!localeName) {
|
|
545
563
|
filesSkipped++;
|
|
546
564
|
continue;
|
package/package.json
CHANGED
package/plugins/i18n/.mcp.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"language": {
|
|
3
3
|
"command": "npx",
|
|
4
|
-
"args": ["-y", "
|
|
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",
|
package/plugins/yapi/.mcp.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"yapi": {
|
|
3
3
|
"command": "npx",
|
|
4
|
-
"args": ["-y", "
|
|
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
|
-
//
|
|
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 (
|
|
357
|
-
output += `\n\n⚠️ 以下 ${
|
|
358
|
-
output +=
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
'2': '
|
|
16
|
-
'
|
|
17
|
-
'
|
|
18
|
-
'
|
|
19
|
-
'
|
|
20
|
-
'
|
|
21
|
-
'
|
|
22
|
-
'
|
|
23
|
-
'
|
|
24
|
-
'
|
|
25
|
-
'
|
|
26
|
-
'
|
|
27
|
-
'
|
|
28
|
-
'
|
|
29
|
-
'
|
|
30
|
-
'
|
|
31
|
-
'
|
|
32
|
-
'
|
|
33
|
-
'
|
|
34
|
-
'
|
|
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 {
|