@camscanner/mcp-language-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.
@@ -0,0 +1,337 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ fixPlaceholders,
4
+ extractStrings,
5
+ mergeLocaleEntries,
6
+ findMissingLocales,
7
+ PLATFORM_MAP,
8
+ LANGUAGE_LOCALE_MAP,
9
+ } from './utils'
10
+
11
+ // --- fixPlaceholders ---
12
+
13
+ describe('fixPlaceholders', () => {
14
+ it('converts single %s to {0}', () => {
15
+ expect(fixPlaceholders('已选择 %s 项')).toBe('已选择 {0} 项')
16
+ })
17
+
18
+ it('converts multiple %s with incrementing index', () => {
19
+ expect(fixPlaceholders('从 %s 到 %s')).toBe('从 {0} 到 {1}')
20
+ })
21
+
22
+ it('converts three %s', () => {
23
+ expect(fixPlaceholders('%s of %s (%s)')).toBe('{0} of {1} ({2})')
24
+ })
25
+
26
+ it('unescapes \\"', () => {
27
+ expect(fixPlaceholders('say \\"hello\\"')).toBe('say "hello"')
28
+ })
29
+
30
+ it('unescapes \\n', () => {
31
+ expect(fixPlaceholders('line1\\nline2')).toBe('line1\nline2')
32
+ })
33
+
34
+ it('handles combined %s and escape sequences', () => {
35
+ expect(fixPlaceholders('%s said \\"hello\\n%s\\"'))
36
+ .toBe('{0} said "hello\n{1}"')
37
+ })
38
+
39
+ it('returns string unchanged when no placeholders or escapes', () => {
40
+ expect(fixPlaceholders('hello world')).toBe('hello world')
41
+ })
42
+
43
+ it('handles empty string', () => {
44
+ expect(fixPlaceholders('')).toBe('')
45
+ })
46
+ })
47
+
48
+ // --- extractStrings ---
49
+
50
+ describe('extractStrings', () => {
51
+ const mockVersions = [
52
+ {
53
+ version_id: 100,
54
+ version_number: '1.0.0',
55
+ ar_language: ['1', '2'],
56
+ ar_string: [
57
+ {
58
+ id: 'str1',
59
+ keys: { '1': 'android_key', '4': 'web_key' },
60
+ values: { '1': '确认', '2': 'Confirm' },
61
+ },
62
+ {
63
+ id: 'str2',
64
+ keys: { '4': 'cs_cancel' },
65
+ values: { '1': '取消', '2': 'Cancel' },
66
+ },
67
+ ],
68
+ },
69
+ ]
70
+
71
+ it('extracts strings by platform_id', () => {
72
+ const result = extractStrings(mockVersions, '4')
73
+ expect(result['1']).toEqual({ web_key: '确认', cs_cancel: '取消' })
74
+ expect(result['2']).toEqual({ web_key: 'Confirm', cs_cancel: 'Cancel' })
75
+ })
76
+
77
+ it('uses different key for different platform', () => {
78
+ const result = extractStrings(mockVersions, '1')
79
+ expect(result['1']['android_key']).toBe('确认')
80
+ })
81
+
82
+ it('falls back to platform 0 key', () => {
83
+ const versions = [{
84
+ ar_language: ['1'],
85
+ ar_string: [{
86
+ id: 'x',
87
+ keys: { '0': 'fallback_key' },
88
+ values: { '1': '测试' },
89
+ }],
90
+ }]
91
+ const result = extractStrings(versions, '4')
92
+ expect(result['1']['fallback_key']).toBe('测试')
93
+ })
94
+
95
+ it('falls back to first available key', () => {
96
+ const versions = [{
97
+ ar_language: ['1'],
98
+ ar_string: [{
99
+ id: 'x',
100
+ keys: { '3': 'ios_only_key' },
101
+ values: { '1': '测试' },
102
+ }],
103
+ }]
104
+ const result = extractStrings(versions, '4')
105
+ expect(result['1']['ios_only_key']).toBe('测试')
106
+ })
107
+
108
+ it('skips strings with no keys', () => {
109
+ const versions = [{
110
+ ar_language: ['1'],
111
+ ar_string: [{ id: 'x', keys: {}, values: { '1': '测试' } }],
112
+ }]
113
+ const result = extractStrings(versions, '4')
114
+ expect(result).toEqual({})
115
+ })
116
+
117
+ it('skips languages with no value', () => {
118
+ const versions = [{
119
+ ar_language: ['1', '2'],
120
+ ar_string: [{
121
+ id: 'x',
122
+ keys: { '4': 'k' },
123
+ values: { '1': '中文' }, // no value for lang 2
124
+ }],
125
+ }]
126
+ const result = extractStrings(versions, '4')
127
+ expect(result['1']['k']).toBe('中文')
128
+ expect(result['2']).toBeUndefined()
129
+ })
130
+
131
+ it('applies fixPlaceholders to values', () => {
132
+ const versions = [{
133
+ ar_language: ['1'],
134
+ ar_string: [{
135
+ id: 'x',
136
+ keys: { '4': 'k' },
137
+ values: { '1': '共 %s 个文件,已完成 %s' },
138
+ }],
139
+ }]
140
+ const result = extractStrings(versions, '4')
141
+ expect(result['1']['k']).toBe('共 {0} 个文件,已完成 {1}')
142
+ })
143
+
144
+ it('merges strings from multiple versions', () => {
145
+ const versions = [
146
+ {
147
+ ar_language: ['1'],
148
+ ar_string: [{ id: 'a', keys: { '4': 'key_a' }, values: { '1': 'A' } }],
149
+ },
150
+ {
151
+ ar_language: ['1'],
152
+ ar_string: [{ id: 'b', keys: { '4': 'key_b' }, values: { '1': 'B' } }],
153
+ },
154
+ ]
155
+ const result = extractStrings(versions, '4')
156
+ expect(result['1']).toEqual({ key_a: 'A', key_b: 'B' })
157
+ })
158
+
159
+ it('handles version with "strings" field instead of "ar_string"', () => {
160
+ const versions = [{
161
+ ar_language: ['1'],
162
+ strings: [{ id: 'x', keys: { '4': 'k' }, values: { '1': '值' } }],
163
+ }]
164
+ const result = extractStrings(versions, '4')
165
+ expect(result['1']['k']).toBe('值')
166
+ })
167
+
168
+ it('returns empty object for empty versions', () => {
169
+ expect(extractStrings([], '4')).toEqual({})
170
+ })
171
+ })
172
+
173
+ // --- mergeLocaleEntries ---
174
+
175
+ describe('mergeLocaleEntries', () => {
176
+ it('adds new keys', () => {
177
+ const existing = { a: '1' }
178
+ const newEntries = { b: '2' }
179
+ const { merged, keysAdded, keysUpdated } = mergeLocaleEntries(existing, newEntries)
180
+
181
+ expect(merged).toEqual({ a: '1', b: '2' })
182
+ expect(keysAdded).toEqual(['b'])
183
+ expect(keysUpdated).toEqual([])
184
+ })
185
+
186
+ it('updates changed keys', () => {
187
+ const existing = { a: 'old' }
188
+ const newEntries = { a: 'new' }
189
+ const { merged, keysAdded, keysUpdated } = mergeLocaleEntries(existing, newEntries)
190
+
191
+ expect(merged.a).toBe('new')
192
+ expect(keysAdded).toEqual([])
193
+ expect(keysUpdated).toEqual(['a'])
194
+ })
195
+
196
+ it('reports no change for identical keys', () => {
197
+ const existing = { a: 'same' }
198
+ const newEntries = { a: 'same' }
199
+ const { keysAdded, keysUpdated } = mergeLocaleEntries(existing, newEntries)
200
+
201
+ expect(keysAdded).toEqual([])
202
+ expect(keysUpdated).toEqual([])
203
+ })
204
+
205
+ it('fixes existing %s placeholders', () => {
206
+ const existing = { old_key: '共 %s 项' }
207
+ const newEntries = {}
208
+ const { merged } = mergeLocaleEntries(existing, newEntries)
209
+
210
+ expect(merged.old_key).toBe('共 {0} 项')
211
+ })
212
+
213
+ it('preserves insert_before_this_line marker at end', () => {
214
+ const existing = {
215
+ a: '1',
216
+ insert_before_this_line: '---',
217
+ b: '2',
218
+ }
219
+ const newEntries = { c: '3' }
220
+ const { merged } = mergeLocaleEntries(existing, newEntries)
221
+
222
+ const keys = Object.keys(merged)
223
+ expect(keys[keys.length - 1]).toBe('insert_before_this_line')
224
+ expect(merged.c).toBe('3')
225
+ })
226
+
227
+ it('works with empty existing object', () => {
228
+ const { merged, keysAdded } = mergeLocaleEntries({}, { a: '1', b: '2' })
229
+
230
+ expect(merged).toEqual({ a: '1', b: '2' })
231
+ expect(keysAdded).toEqual(['a', 'b'])
232
+ })
233
+
234
+ it('works with empty new entries', () => {
235
+ const { merged, keysAdded, keysUpdated } = mergeLocaleEntries({ a: '1' }, {})
236
+
237
+ expect(merged).toEqual({ a: '1' })
238
+ expect(keysAdded).toEqual([])
239
+ expect(keysUpdated).toEqual([])
240
+ })
241
+
242
+ it('does not mutate original existing object', () => {
243
+ const existing = { a: '1' }
244
+ mergeLocaleEntries(existing, { a: '2', b: '3' })
245
+
246
+ expect(existing).toEqual({ a: '1' })
247
+ })
248
+ })
249
+
250
+ // --- findMissingLocales ---
251
+
252
+ describe('findMissingLocales', () => {
253
+ it('returns locales that exist locally but have no remote data', () => {
254
+ const local = ['ZhCn', 'EnUs', 'JaJp', 'KoKr']
255
+ const remoteIds = ['1', '2'] // ZhCn, EnUs
256
+ const missing = findMissingLocales(local, remoteIds)
257
+
258
+ expect(missing).toEqual(['JaJp', 'KoKr'])
259
+ })
260
+
261
+ it('returns empty when all local files have remote data', () => {
262
+ const local = ['ZhCn', 'EnUs']
263
+ const remoteIds = ['1', '2']
264
+
265
+ expect(findMissingLocales(local, remoteIds)).toEqual([])
266
+ })
267
+
268
+ it('returns all local files when remote has no data', () => {
269
+ const local = ['ZhCn', 'EnUs']
270
+ const remoteIds: string[] = []
271
+
272
+ expect(findMissingLocales(local, remoteIds)).toEqual(['ZhCn', 'EnUs'])
273
+ })
274
+
275
+ it('ignores local files not in LANGUAGE_LOCALE_MAP (e.g. custom files)', () => {
276
+ const local = ['ZhCn', 'EnUs', 'CustomLang']
277
+ const remoteIds = ['1', '2']
278
+ const missing = findMissingLocales(local, remoteIds)
279
+
280
+ // CustomLang is not in LANGUAGE_LOCALE_MAP, so it's not a "missing translation"
281
+ // it should still appear because it's local but not matched
282
+ expect(missing).toEqual(['CustomLang'])
283
+ })
284
+
285
+ it('handles remote language IDs not in LANGUAGE_LOCALE_MAP', () => {
286
+ const local = ['ZhCn']
287
+ const remoteIds = ['1', '999'] // 999 is unknown
288
+ const missing = findMissingLocales(local, remoteIds)
289
+
290
+ expect(missing).toEqual([])
291
+ })
292
+
293
+ it('works with large set of locales', () => {
294
+ // Simulate a real project with 26 locale files but remote only returns 3 languages
295
+ const local = [
296
+ 'ZhCn', 'EnUs', 'JaJp', 'KoKr', 'FrFr', 'DeDe', 'ZhTw',
297
+ 'PtBr', 'EsEs', 'ItIt', 'RuRu', 'TrTr', 'ArSa',
298
+ ]
299
+ const remoteIds = ['1', '2', '7'] // ZhCn, EnUs, ZhTw
300
+ const missing = findMissingLocales(local, remoteIds)
301
+
302
+ expect(missing).toHaveLength(10)
303
+ expect(missing).toContain('JaJp')
304
+ expect(missing).toContain('KoKr')
305
+ expect(missing).not.toContain('ZhCn')
306
+ expect(missing).not.toContain('EnUs')
307
+ expect(missing).not.toContain('ZhTw')
308
+ })
309
+ })
310
+
311
+ // --- Constants ---
312
+
313
+ describe('PLATFORM_MAP', () => {
314
+ it('contains core platforms', () => {
315
+ expect(PLATFORM_MAP[1]).toBe('Android')
316
+ expect(PLATFORM_MAP[3]).toBe('iOS')
317
+ expect(PLATFORM_MAP[4]).toBe('Web')
318
+ expect(PLATFORM_MAP[8]).toBe('Harmony')
319
+ })
320
+
321
+ it('does not contain deprecated platforms', () => {
322
+ expect(PLATFORM_MAP[2]).toBeUndefined() // BlackBerry
323
+ expect(PLATFORM_MAP[6]).toBeUndefined() // WinPhone
324
+ })
325
+ })
326
+
327
+ describe('LANGUAGE_LOCALE_MAP', () => {
328
+ it('maps common language IDs to locale names', () => {
329
+ expect(LANGUAGE_LOCALE_MAP['1']).toBe('ZhCn')
330
+ expect(LANGUAGE_LOCALE_MAP['2']).toBe('EnUs')
331
+ expect(LANGUAGE_LOCALE_MAP['7']).toBe('ZhTw')
332
+ })
333
+
334
+ it('has no mapping for undefined language ID 18', () => {
335
+ expect(LANGUAGE_LOCALE_MAP['18']).toBeUndefined()
336
+ })
337
+ })
package/src/utils.ts ADDED
@@ -0,0 +1,127 @@
1
+ // --- Constants ---
2
+
3
+ export const PLATFORM_MAP: Record<number, string> = {
4
+ 1: 'Android',
5
+ 3: 'iOS',
6
+ 4: 'Web',
7
+ 5: 'Windows',
8
+ 7: 'Market',
9
+ 8: 'Harmony',
10
+ 9: 'PC',
11
+ }
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',
41
+ }
42
+
43
+ // --- Helpers ---
44
+
45
+ export function fixPlaceholders(value: string): string {
46
+ let cnt = 0
47
+ return value
48
+ .replace(/%s/g, () => `{${cnt++}}`)
49
+ .replace(/\\"/g, '"')
50
+ .replace(/\\n/g, '\n')
51
+ }
52
+
53
+ export function extractStrings(versions: any[], platformId: string): Record<string, Record<string, string>> {
54
+ const result: Record<string, Record<string, string>> = {}
55
+ for (const version of versions) {
56
+ const strings = version.ar_string || version.strings || []
57
+ const languages: string[] = version.ar_language || []
58
+ for (const str of strings) {
59
+ const key = str.keys?.[platformId] || str.keys?.['0'] || Object.values(str.keys || {})[0] as string
60
+ if (!key) continue
61
+ for (const langId of languages) {
62
+ const value = str.values?.[langId]
63
+ if (!value) continue
64
+ if (!result[langId]) result[langId] = {}
65
+ result[langId][key] = fixPlaceholders(value)
66
+ }
67
+ }
68
+ }
69
+ return result
70
+ }
71
+
72
+ export interface MergeResult {
73
+ merged: Record<string, string>
74
+ keysAdded: string[]
75
+ keysUpdated: string[]
76
+ }
77
+
78
+ export function mergeLocaleEntries(
79
+ existingObj: Record<string, string>,
80
+ newEntries: Record<string, string>,
81
+ ): MergeResult {
82
+ const merged = { ...existingObj }
83
+
84
+ // Fix existing %s placeholders
85
+ for (const key of Object.keys(merged)) {
86
+ if (typeof merged[key] === 'string' && merged[key].includes('%s')) {
87
+ merged[key] = fixPlaceholders(merged[key])
88
+ }
89
+ }
90
+
91
+ // Preserve insert_before_this_line marker
92
+ const insertMarker = merged['insert_before_this_line']
93
+ delete merged['insert_before_this_line']
94
+
95
+ const keysAdded: string[] = []
96
+ const keysUpdated: string[] = []
97
+ for (const [key, value] of Object.entries(newEntries)) {
98
+ if (merged[key] === undefined) {
99
+ keysAdded.push(key)
100
+ } else if (merged[key] !== value) {
101
+ keysUpdated.push(key)
102
+ }
103
+ merged[key] = value
104
+ }
105
+
106
+ if (insertMarker) {
107
+ merged['insert_before_this_line'] = insertMarker
108
+ }
109
+
110
+ return { merged, keysAdded, keysUpdated }
111
+ }
112
+
113
+ /**
114
+ * 找出本地存在但远程未返回翻译的 locale 文件名。
115
+ * @param localLocaleNames 本地存在的 locale 名称列表(不含 .json 后缀),如 ['ZhCn', 'EnUs', 'JaJp']
116
+ * @param remoteLanguageIds 远程返回的语言 ID 列表,如 ['1', '2']
117
+ * @returns 本地存在但远程无数据的 locale 名称列表
118
+ */
119
+ export function findMissingLocales(
120
+ localLocaleNames: string[],
121
+ remoteLanguageIds: string[],
122
+ ): string[] {
123
+ const remoteLocaleNames = new Set(
124
+ remoteLanguageIds.map(id => LANGUAGE_LOCALE_MAP[id]).filter(Boolean)
125
+ )
126
+ return localLocaleNames.filter(name => !remoteLocaleNames.has(name))
127
+ }