dsh-cait 1.1.6 → 1.1.7
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/CHANGELOG.md +43 -0
- package/README.ja.md +20 -10
- package/README.md +91 -72
- package/README.zh-CN.md +20 -10
- package/client/components.js +126 -13
- package/client/locales.js +74 -2
- package/client/styles.js +6 -2
- package/client.js +206 -17
- package/lib/aligner.js +6 -6
- package/lib/api.js +6 -0
- package/lib/docx.js +124 -26
- package/lib/import-export.js +115 -10
- package/lib/localization.js +1098 -0
- package/lib/persistence.js +14 -0
- package/lib/pptx.js +33 -1
- package/lib/qa.js +18 -0
- package/lib/store-utils.js +8 -3
- package/lib/store.js +14 -2
- package/lib/subtitles.js +213 -0
- package/lib/xlsx.js +176 -66
- package/package.json +1 -1
|
@@ -0,0 +1,1098 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key/value localization formats: Apple .strings/.stringsdict, Android
|
|
3
|
+
* strings.xml, .resx, ARB, flat YAML and Java .properties.
|
|
4
|
+
*
|
|
5
|
+
* The parser keeps an AST in formatMeta.localizationTemplate and puts the
|
|
6
|
+
* stable key on every segment. Export walks the AST again so comments, key
|
|
7
|
+
* order and plural quantities survive a round trip. Inline XML markup is
|
|
8
|
+
* tokenised into {n} placeholders and restored from segment.meta.inline.
|
|
9
|
+
*/
|
|
10
|
+
import { CatError } from './errors.js'
|
|
11
|
+
import { decodeXml, escapeXml } from './xml.js'
|
|
12
|
+
|
|
13
|
+
const KINDS = new Set([
|
|
14
|
+
'apple-strings',
|
|
15
|
+
'apple-stringsdict',
|
|
16
|
+
'android',
|
|
17
|
+
'resx',
|
|
18
|
+
'arb',
|
|
19
|
+
'yaml',
|
|
20
|
+
'properties',
|
|
21
|
+
])
|
|
22
|
+
|
|
23
|
+
function formatError(code, message) {
|
|
24
|
+
return new CatError(code, message)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function normalizeKind(kind) {
|
|
28
|
+
const value = String(kind ?? '').trim().toLowerCase()
|
|
29
|
+
if (!KINDS.has(value)) throw formatError('LOCALIZATION_KIND_UNSUPPORTED', `不支持的本地化格式: ${kind}`)
|
|
30
|
+
return value
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function cdata(value) {
|
|
34
|
+
return String(value ?? '').replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const INLINE_TAG = /<\/?(?:b|i|u|em|strong|font|a|br|span|xliff:g)\b[^>]*\/?>/gi
|
|
38
|
+
|
|
39
|
+
function extractInline(raw) {
|
|
40
|
+
const tokens = []
|
|
41
|
+
const text = String(raw ?? '').replace(INLINE_TAG, (xml) => {
|
|
42
|
+
const token = `{${tokens.length + 1}}`
|
|
43
|
+
tokens.push({ token, xml })
|
|
44
|
+
return token
|
|
45
|
+
})
|
|
46
|
+
return { text: decodeXml(cdata(text)).trim(), tokens }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function renderInline(value, tokens) {
|
|
50
|
+
const text = String(value ?? '')
|
|
51
|
+
if (!Array.isArray(tokens) || !tokens.length) return escapeXml(cdata(text))
|
|
52
|
+
const map = new Map(tokens.map((item) => [item.token, item.xml]))
|
|
53
|
+
let output = ''
|
|
54
|
+
let last = 0
|
|
55
|
+
for (const match of text.matchAll(/\{(\d+)\}/g)) {
|
|
56
|
+
output += escapeXml(text.slice(last, match.index))
|
|
57
|
+
output += map.get(match[0]) ?? escapeXml(match[0])
|
|
58
|
+
last = match.index + match[0].length
|
|
59
|
+
}
|
|
60
|
+
output += escapeXml(text.slice(last))
|
|
61
|
+
return output
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function pushSegment(segments, kind, key, value, extra = {}, comment = '') {
|
|
65
|
+
const parsed = extractInline(value)
|
|
66
|
+
if (!parsed.text) return
|
|
67
|
+
const meta = {
|
|
68
|
+
format: kind,
|
|
69
|
+
identifier: key,
|
|
70
|
+
keyPath: extra.keyPath || key,
|
|
71
|
+
...extra,
|
|
72
|
+
}
|
|
73
|
+
if (parsed.tokens.length) meta.inline = parsed.tokens
|
|
74
|
+
if (comment) meta.comments = [String(comment).slice(0, 500)]
|
|
75
|
+
segments.push({
|
|
76
|
+
source: parsed.text,
|
|
77
|
+
target: '',
|
|
78
|
+
paragraph: segments.length,
|
|
79
|
+
meta,
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function decodeEscape(value, kind) {
|
|
84
|
+
if (kind === 'properties') {
|
|
85
|
+
return String(value ?? '').replace(/\\(u[0-9a-fA-F]{4}|.)/g, (all, body) => {
|
|
86
|
+
if (body[0] === 'u') return String.fromCharCode(Number.parseInt(body.slice(1), 16))
|
|
87
|
+
if (body === 'n') return '\n'
|
|
88
|
+
if (body === 't') return '\t'
|
|
89
|
+
if (body === 'r') return '\r'
|
|
90
|
+
return body
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
return String(value ?? '')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function escapeProperties(value, isKey = false) {
|
|
97
|
+
const escaped = String(value ?? '')
|
|
98
|
+
.replace(/\\/g, '\\\\')
|
|
99
|
+
.replace(/\n/g, '\\n')
|
|
100
|
+
.replace(/\t/g, '\\t')
|
|
101
|
+
.replace(/\r/g, '\\r')
|
|
102
|
+
return isKey ? escaped.replace(/([=:])/g, '\\$1') : escaped
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function escapeApple(value) {
|
|
106
|
+
return String(value ?? '')
|
|
107
|
+
.replace(/\\/g, '\\\\')
|
|
108
|
+
.replace(/"/g, '\\"')
|
|
109
|
+
.replace(/\n/g, '\\n')
|
|
110
|
+
.replace(/\t/g, '\\t')
|
|
111
|
+
.replace(/\r/g, '\\r')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function decodeApple(value) {
|
|
115
|
+
return String(value ?? '').replace(/\\(u[0-9a-fA-F]{4}|U[0-9a-fA-F]{4}|.)/g, (all, body) => {
|
|
116
|
+
if (body[0] === 'u' || body[0] === 'U') return String.fromCharCode(Number.parseInt(body.slice(1), 16))
|
|
117
|
+
if (body === 'n') return '\n'
|
|
118
|
+
if (body === 't') return '\t'
|
|
119
|
+
if (body === 'r') return '\r'
|
|
120
|
+
return body
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function parseAppleStrings(text, kind = 'apple-strings') {
|
|
125
|
+
const input = String(text ?? '').replace(/^\uFEFF/, '')
|
|
126
|
+
if (!input.trim()) throw formatError('LOCALIZATION_EMPTY', '文件内容为空。')
|
|
127
|
+
const entries = []
|
|
128
|
+
const segments = []
|
|
129
|
+
let index = 0
|
|
130
|
+
let pendingComment = ''
|
|
131
|
+
|
|
132
|
+
const skipSpace = () => {
|
|
133
|
+
while (index < input.length && /\s/.test(input[index])) index += 1
|
|
134
|
+
}
|
|
135
|
+
const readQuoted = () => {
|
|
136
|
+
const quote = input[index]
|
|
137
|
+
index += 1
|
|
138
|
+
let value = ''
|
|
139
|
+
while (index < input.length) {
|
|
140
|
+
const char = input[index]
|
|
141
|
+
if (char === '\\') {
|
|
142
|
+
value += char
|
|
143
|
+
index += 1
|
|
144
|
+
if (index < input.length) value += input[index]
|
|
145
|
+
index += 1
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
if (char === quote) {
|
|
149
|
+
index += 1
|
|
150
|
+
return value
|
|
151
|
+
}
|
|
152
|
+
value += char
|
|
153
|
+
index += 1
|
|
154
|
+
}
|
|
155
|
+
throw formatError('LOCALIZATION_PARSE', '字符串没有闭合引号。')
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
while (index < input.length) {
|
|
159
|
+
skipSpace()
|
|
160
|
+
if (index >= input.length) break
|
|
161
|
+
if (input.startsWith('/*', index)) {
|
|
162
|
+
const end = input.indexOf('*/', index + 2)
|
|
163
|
+
if (end < 0) break
|
|
164
|
+
pendingComment = input.slice(index + 2, end).trim()
|
|
165
|
+
index = end + 2
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
if (input.startsWith('//', index)) {
|
|
169
|
+
const end = input.indexOf('\n', index + 2)
|
|
170
|
+
pendingComment = input.slice(index + 2, end < 0 ? input.length : end).trim()
|
|
171
|
+
index = end < 0 ? input.length : end + 1
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
let key
|
|
175
|
+
if (input[index] === '"') key = decodeApple(readQuoted())
|
|
176
|
+
else {
|
|
177
|
+
const start = index
|
|
178
|
+
while (index < input.length && input[index] !== '=' && !/\s/.test(input[index])) index += 1
|
|
179
|
+
key = input.slice(start, index).trim()
|
|
180
|
+
}
|
|
181
|
+
skipSpace()
|
|
182
|
+
if (input[index] !== '=') {
|
|
183
|
+
index += 1
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
index += 1
|
|
187
|
+
skipSpace()
|
|
188
|
+
let value = ''
|
|
189
|
+
if (input[index] === '"') value = decodeApple(readQuoted())
|
|
190
|
+
else {
|
|
191
|
+
const start = index
|
|
192
|
+
while (index < input.length && input[index] !== ';') index += 1
|
|
193
|
+
value = input.slice(start, index).trim()
|
|
194
|
+
}
|
|
195
|
+
skipSpace()
|
|
196
|
+
if (input[index] === ';') index += 1
|
|
197
|
+
if (!key) continue
|
|
198
|
+
entries.push({ type: 'string', key, value, comment: pendingComment })
|
|
199
|
+
pushSegment(segments, kind, key, value, {}, pendingComment)
|
|
200
|
+
pendingComment = ''
|
|
201
|
+
}
|
|
202
|
+
if (!segments.length) throw formatError('LOCALIZATION_NO_UNITS', '文件中没有可翻译的字符串。')
|
|
203
|
+
return {
|
|
204
|
+
format: kind,
|
|
205
|
+
segments,
|
|
206
|
+
formatMeta: { localizationTemplate: JSON.stringify({ kind, entries }) },
|
|
207
|
+
warnings: [],
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function readAttr(source, name) {
|
|
212
|
+
const match = String(source ?? '').match(new RegExp(`\\b${name}\\s*=\\s*["']([^"']*)["']`, 'i'))
|
|
213
|
+
return match ? decodeXml(match[1]) : ''
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function parseAndroid(text) {
|
|
217
|
+
const input = String(text ?? '').replace(/^\uFEFF/, '')
|
|
218
|
+
if (!/<resources\b/i.test(input)) throw formatError('LOCALIZATION_PARSE', 'Android 字符串缺少 <resources> 根节点。')
|
|
219
|
+
const entries = []
|
|
220
|
+
const segments = []
|
|
221
|
+
let pendingComment = ''
|
|
222
|
+
const token = /<!--([\s\S]*?)-->|<\s*(string|plurals)\b([^>]*)>([\s\S]*?)<\/\s*\2\s*>/gi
|
|
223
|
+
let match
|
|
224
|
+
while ((match = token.exec(input))) {
|
|
225
|
+
if (match[1] != null) {
|
|
226
|
+
pendingComment = match[1].trim()
|
|
227
|
+
continue
|
|
228
|
+
}
|
|
229
|
+
const type = match[2].toLowerCase()
|
|
230
|
+
const attrs = match[3]
|
|
231
|
+
const body = match[4]
|
|
232
|
+
const key = readAttr(attrs, 'name')
|
|
233
|
+
if (!key) continue
|
|
234
|
+
const translatable = !/\btranslatable\s*=\s*["']false["']/i.test(attrs)
|
|
235
|
+
if (type === 'string') {
|
|
236
|
+
const parsed = extractInline(body)
|
|
237
|
+
entries.push({
|
|
238
|
+
type: 'string',
|
|
239
|
+
key,
|
|
240
|
+
value: body,
|
|
241
|
+
comment: pendingComment,
|
|
242
|
+
translatable,
|
|
243
|
+
})
|
|
244
|
+
if (translatable) pushSegment(segments, 'android', key, body, {}, pendingComment)
|
|
245
|
+
} else {
|
|
246
|
+
const items = []
|
|
247
|
+
const itemPattern = /<\s*item\b([^>]*)>([\s\S]*?)<\/\s*item\s*>/gi
|
|
248
|
+
let item
|
|
249
|
+
while ((item = itemPattern.exec(body))) {
|
|
250
|
+
const quantity = readAttr(item[1], 'quantity') || 'other'
|
|
251
|
+
items.push({ quantity, value: item[2] })
|
|
252
|
+
if (translatable) {
|
|
253
|
+
pushSegment(segments, 'android', key, item[2], {
|
|
254
|
+
keyPath: `${key}#${quantity}`,
|
|
255
|
+
context: quantity,
|
|
256
|
+
plural: true,
|
|
257
|
+
msgidPlural: key,
|
|
258
|
+
icuType: 'quantity',
|
|
259
|
+
}, pendingComment)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
entries.push({ type: 'plural', key, items, comment: pendingComment, translatable })
|
|
263
|
+
}
|
|
264
|
+
pendingComment = ''
|
|
265
|
+
}
|
|
266
|
+
if (!segments.length) throw formatError('LOCALIZATION_NO_UNITS', 'Android 字符串中没有可翻译的文本。')
|
|
267
|
+
return {
|
|
268
|
+
format: 'android',
|
|
269
|
+
segments,
|
|
270
|
+
formatMeta: { localizationTemplate: JSON.stringify({ kind: 'android', entries }) },
|
|
271
|
+
warnings: [],
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function parseResx(text) {
|
|
276
|
+
const input = String(text ?? '').replace(/^\uFEFF/, '')
|
|
277
|
+
const entries = []
|
|
278
|
+
const segments = []
|
|
279
|
+
const pattern = /<!--([\s\S]*?)-->|<data\b([^>]*)>([\s\S]*?)<\/data>/gi
|
|
280
|
+
let pendingComment = ''
|
|
281
|
+
let match
|
|
282
|
+
while ((match = pattern.exec(input))) {
|
|
283
|
+
if (match[1] != null) {
|
|
284
|
+
pendingComment = match[1].trim()
|
|
285
|
+
continue
|
|
286
|
+
}
|
|
287
|
+
const attrs = match[2]
|
|
288
|
+
const body = match[3]
|
|
289
|
+
const key = readAttr(attrs, 'name')
|
|
290
|
+
if (!key) continue
|
|
291
|
+
if (/\btype\s*=/.test(attrs)) continue
|
|
292
|
+
const valueMatch = body.match(/<value>([\s\S]*?)<\/value>/i)
|
|
293
|
+
if (!valueMatch) continue
|
|
294
|
+
const commentMatch = body.match(/<comment>([\s\S]*?)<\/comment>/i)
|
|
295
|
+
const comment = commentMatch ? decodeXml(cdata(commentMatch[1])).trim() : pendingComment
|
|
296
|
+
entries.push({ type: 'string', key, value: valueMatch[1], comment })
|
|
297
|
+
pushSegment(segments, 'resx', key, valueMatch[1], {}, comment)
|
|
298
|
+
pendingComment = ''
|
|
299
|
+
}
|
|
300
|
+
if (!segments.length) throw formatError('LOCALIZATION_NO_UNITS', '.resx 中没有可翻译的字符串。')
|
|
301
|
+
return {
|
|
302
|
+
format: 'resx',
|
|
303
|
+
segments,
|
|
304
|
+
formatMeta: { localizationTemplate: JSON.stringify({ kind: 'resx', entries }) },
|
|
305
|
+
warnings: [],
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function parseArb(text) {
|
|
310
|
+
const input = String(text ?? '').replace(/^\uFEFF/, '')
|
|
311
|
+
let root
|
|
312
|
+
try {
|
|
313
|
+
root = JSON.parse(input)
|
|
314
|
+
} catch (error) {
|
|
315
|
+
throw formatError('LOCALIZATION_PARSE', `ARB 解析失败: ${error.message}`)
|
|
316
|
+
}
|
|
317
|
+
if (!root || typeof root !== 'object' || Array.isArray(root)) {
|
|
318
|
+
throw formatError('LOCALIZATION_PARSE', 'ARB 根节点必须是对象。')
|
|
319
|
+
}
|
|
320
|
+
const segments = []
|
|
321
|
+
const warnings = []
|
|
322
|
+
for (const [key, value] of Object.entries(root)) {
|
|
323
|
+
if (key.startsWith('@')) continue
|
|
324
|
+
if (typeof value !== 'string') {
|
|
325
|
+
warnings.push(`ARB 键 ${key} 不是字符串,已跳过。`)
|
|
326
|
+
continue
|
|
327
|
+
}
|
|
328
|
+
pushSegment(segments, 'arb', key, value)
|
|
329
|
+
}
|
|
330
|
+
if (!segments.length) throw formatError('LOCALIZATION_NO_UNITS', 'ARB 中没有可翻译的字符串。')
|
|
331
|
+
return {
|
|
332
|
+
format: 'arb',
|
|
333
|
+
segments,
|
|
334
|
+
formatMeta: { localizationTemplate: JSON.stringify({ kind: 'arb', data: root }) },
|
|
335
|
+
warnings,
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function stripYamlQuote(value) {
|
|
340
|
+
const text = String(value ?? '').trim()
|
|
341
|
+
if (text.length >= 2 && text[0] === '"' && text[text.length - 1] === '"') {
|
|
342
|
+
try {
|
|
343
|
+
return JSON.parse(text)
|
|
344
|
+
} catch {
|
|
345
|
+
return text.slice(1, -1)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (text.length >= 2 && text[0] === "'" && text[text.length - 1] === "'") {
|
|
349
|
+
return text.slice(1, -1).replace(/''/g, "'")
|
|
350
|
+
}
|
|
351
|
+
return text
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function yamlValue(value) {
|
|
355
|
+
const text = String(value ?? '')
|
|
356
|
+
if (!text || /^[\s:#,[\]{}&*!|>'"%@`]/.test(text) || /:\s|#\s|\n/.test(text)) {
|
|
357
|
+
return JSON.stringify(text)
|
|
358
|
+
}
|
|
359
|
+
return text
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function yamlScalarText(raw) {
|
|
363
|
+
let text = String(raw ?? '').trim()
|
|
364
|
+
if (!text) return ''
|
|
365
|
+
let quote = ''
|
|
366
|
+
let escaped = false
|
|
367
|
+
let cut = -1
|
|
368
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
369
|
+
const char = text[index]
|
|
370
|
+
if (escaped) {
|
|
371
|
+
escaped = false
|
|
372
|
+
continue
|
|
373
|
+
}
|
|
374
|
+
if (char === '\\' && quote === '"') {
|
|
375
|
+
escaped = true
|
|
376
|
+
continue
|
|
377
|
+
}
|
|
378
|
+
if (quote) {
|
|
379
|
+
if (char === quote) quote = ''
|
|
380
|
+
continue
|
|
381
|
+
}
|
|
382
|
+
if (char === '"' || char === "'") {
|
|
383
|
+
quote = char
|
|
384
|
+
continue
|
|
385
|
+
}
|
|
386
|
+
if (char === '#' && (index === 0 || /\s/.test(text[index - 1]))) {
|
|
387
|
+
cut = index
|
|
388
|
+
break
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (cut >= 0) text = text.slice(0, cut).trim()
|
|
392
|
+
if (text.startsWith('&') || text.startsWith('*') || text === '<<') {
|
|
393
|
+
throw formatError('LOCALIZATION_YAML_UNSUPPORTED', '暂不支持 YAML 锚点、别名或 merge key。')
|
|
394
|
+
}
|
|
395
|
+
if (text.startsWith('{') || text.startsWith('[')) {
|
|
396
|
+
throw formatError('LOCALIZATION_YAML_UNSUPPORTED', '暂不支持 YAML flow 集合,请使用缩进块。')
|
|
397
|
+
}
|
|
398
|
+
return stripYamlQuote(text)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function yamlColon(text) {
|
|
402
|
+
let quote = ''
|
|
403
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
404
|
+
const char = text[index]
|
|
405
|
+
if (quote) {
|
|
406
|
+
if (char === quote) quote = ''
|
|
407
|
+
continue
|
|
408
|
+
}
|
|
409
|
+
if (char === '"' || char === "'") {
|
|
410
|
+
quote = char
|
|
411
|
+
continue
|
|
412
|
+
}
|
|
413
|
+
if (char === ':' && (index === text.length - 1 || /\s/.test(text[index + 1]))) {
|
|
414
|
+
return index
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return -1
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function yamlIndent(line) {
|
|
421
|
+
const spaces = String(line).match(/^ */)?.[0]?.length ?? 0
|
|
422
|
+
if (String(line)[spaces] === '\t') {
|
|
423
|
+
throw formatError('LOCALIZATION_YAML_UNSUPPORTED', 'YAML 缩进不能使用 Tab。')
|
|
424
|
+
}
|
|
425
|
+
return spaces
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function parseYamlBlock(text) {
|
|
429
|
+
const input = String(text ?? '').replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n')
|
|
430
|
+
if (!input.trim()) throw formatError('LOCALIZATION_EMPTY', '文件内容为空。')
|
|
431
|
+
const lines = input.split('\n')
|
|
432
|
+
let index = 0
|
|
433
|
+
let pendingComments = []
|
|
434
|
+
|
|
435
|
+
const skip = () => {
|
|
436
|
+
while (index < lines.length) {
|
|
437
|
+
const line = lines[index]
|
|
438
|
+
if (!line.trim()) {
|
|
439
|
+
index += 1
|
|
440
|
+
continue
|
|
441
|
+
}
|
|
442
|
+
if (/^\s*#/.test(line)) {
|
|
443
|
+
pendingComments.push(line.replace(/^\s*#\s?/, '').trim())
|
|
444
|
+
index += 1
|
|
445
|
+
continue
|
|
446
|
+
}
|
|
447
|
+
break
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const takeComment = () => {
|
|
452
|
+
const comment = pendingComments.join('\n').trim()
|
|
453
|
+
pendingComments = []
|
|
454
|
+
return comment
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const parseScalar = (raw, style = 'plain') => ({
|
|
458
|
+
type: 'scalar',
|
|
459
|
+
value: yamlScalarText(raw),
|
|
460
|
+
style,
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
const parseBlockScalar = (parentIndent, header) => {
|
|
464
|
+
const literal = header.startsWith('|')
|
|
465
|
+
const chomp = header.includes('-') ? 'strip' : header.includes('+') ? 'keep' : 'clip'
|
|
466
|
+
const collected = []
|
|
467
|
+
let minIndent = Infinity
|
|
468
|
+
while (index < lines.length) {
|
|
469
|
+
const line = lines[index]
|
|
470
|
+
if (!line.trim()) {
|
|
471
|
+
collected.push('')
|
|
472
|
+
index += 1
|
|
473
|
+
continue
|
|
474
|
+
}
|
|
475
|
+
const indent = yamlIndent(line)
|
|
476
|
+
if (indent <= parentIndent) break
|
|
477
|
+
minIndent = Math.min(minIndent, indent)
|
|
478
|
+
collected.push(line)
|
|
479
|
+
index += 1
|
|
480
|
+
}
|
|
481
|
+
if (!collected.length) return parseScalar('', literal ? 'literal' : 'folded')
|
|
482
|
+
const dedented = collected.map((line) => (line ? line.slice(minIndent) : '')).join('\n')
|
|
483
|
+
let value = literal ? dedented : dedented.replace(/\n(?!\n)/g, ' ')
|
|
484
|
+
if (chomp === 'strip') value = value.replace(/\n+$/, '')
|
|
485
|
+
else if (chomp === 'clip') value = `${value.replace(/\n+$/, '')}\n`
|
|
486
|
+
return { type: 'scalar', value, style: literal ? 'literal' : 'folded' }
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const parseNode = (indent) => {
|
|
490
|
+
skip()
|
|
491
|
+
if (index >= lines.length) return parseScalar('')
|
|
492
|
+
const line = lines[index]
|
|
493
|
+
const actual = yamlIndent(line)
|
|
494
|
+
if (actual < indent) return parseScalar('')
|
|
495
|
+
if (actual > indent) {
|
|
496
|
+
throw formatError('LOCALIZATION_YAML_PARSE', `第 ${index + 1} 行缩进不一致。`)
|
|
497
|
+
}
|
|
498
|
+
const content = line.slice(indent)
|
|
499
|
+
if (/^-(\s|$)/.test(content)) return parseSequence(indent)
|
|
500
|
+
if (yamlColon(content) >= 0) return parseMapping(indent)
|
|
501
|
+
index += 1
|
|
502
|
+
return parseScalar(content)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const parseMapping = (indent) => {
|
|
506
|
+
const entries = []
|
|
507
|
+
while (index < lines.length) {
|
|
508
|
+
skip()
|
|
509
|
+
if (index >= lines.length) break
|
|
510
|
+
const line = lines[index]
|
|
511
|
+
const actual = yamlIndent(line)
|
|
512
|
+
if (actual < indent) break
|
|
513
|
+
if (actual > indent) {
|
|
514
|
+
throw formatError('LOCALIZATION_YAML_PARSE', `第 ${index + 1} 行缩进不一致。`)
|
|
515
|
+
}
|
|
516
|
+
const content = line.slice(indent)
|
|
517
|
+
if (/^-(\s|$)/.test(content)) break
|
|
518
|
+
const colon = yamlColon(content)
|
|
519
|
+
if (colon < 0) {
|
|
520
|
+
throw formatError('LOCALIZATION_YAML_PARSE', `第 ${index + 1} 行缺少键值分隔符。`)
|
|
521
|
+
}
|
|
522
|
+
const key = stripYamlQuote(content.slice(0, colon).trim())
|
|
523
|
+
const rest = content.slice(colon + 1).trim()
|
|
524
|
+
const comment = takeComment()
|
|
525
|
+
index += 1
|
|
526
|
+
let value
|
|
527
|
+
if (!rest) {
|
|
528
|
+
skip()
|
|
529
|
+
value = index < lines.length && yamlIndent(lines[index]) > indent
|
|
530
|
+
? parseNode(yamlIndent(lines[index]))
|
|
531
|
+
: parseScalar('')
|
|
532
|
+
} else if (rest.startsWith('|') || rest.startsWith('>')) {
|
|
533
|
+
value = parseBlockScalar(indent, rest)
|
|
534
|
+
} else {
|
|
535
|
+
value = parseScalar(rest)
|
|
536
|
+
}
|
|
537
|
+
entries.push({ key, value, ...(comment ? { comment } : {}) })
|
|
538
|
+
}
|
|
539
|
+
return { type: 'map', entries }
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const parseSequence = (indent) => {
|
|
543
|
+
const items = []
|
|
544
|
+
while (index < lines.length) {
|
|
545
|
+
skip()
|
|
546
|
+
if (index >= lines.length) break
|
|
547
|
+
const line = lines[index]
|
|
548
|
+
const actual = yamlIndent(line)
|
|
549
|
+
if (actual < indent) break
|
|
550
|
+
if (actual > indent) {
|
|
551
|
+
throw formatError('LOCALIZATION_YAML_PARSE', `第 ${index + 1} 行缩进不一致。`)
|
|
552
|
+
}
|
|
553
|
+
const content = line.slice(indent)
|
|
554
|
+
const match = content.match(/^-(\s|$)([\s\S]*)$/)
|
|
555
|
+
if (!match) break
|
|
556
|
+
const rest = match[2].trim()
|
|
557
|
+
index += 1
|
|
558
|
+
const comment = takeComment()
|
|
559
|
+
let value
|
|
560
|
+
if (!rest) {
|
|
561
|
+
skip()
|
|
562
|
+
value = index < lines.length && yamlIndent(lines[index]) > indent
|
|
563
|
+
? parseNode(yamlIndent(lines[index]))
|
|
564
|
+
: parseScalar('')
|
|
565
|
+
} else if (yamlColon(rest) >= 0) {
|
|
566
|
+
const colon = yamlColon(rest)
|
|
567
|
+
const key = stripYamlQuote(rest.slice(0, colon).trim())
|
|
568
|
+
const tail = rest.slice(colon + 1).trim()
|
|
569
|
+
const map = {
|
|
570
|
+
type: 'map',
|
|
571
|
+
entries: [{ key, value: tail ? parseScalar(tail) : parseScalar('') }],
|
|
572
|
+
}
|
|
573
|
+
skip()
|
|
574
|
+
if (index < lines.length && yamlIndent(lines[index]) > indent) {
|
|
575
|
+
const child = parseNode(yamlIndent(lines[index]))
|
|
576
|
+
if (child.type === 'map') map.entries.push(...child.entries)
|
|
577
|
+
}
|
|
578
|
+
value = map
|
|
579
|
+
} else {
|
|
580
|
+
value = parseScalar(rest)
|
|
581
|
+
}
|
|
582
|
+
items.push({ value, ...(comment ? { comment } : {}) })
|
|
583
|
+
}
|
|
584
|
+
return { type: 'seq', items }
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
skip()
|
|
588
|
+
if (index >= lines.length) throw formatError('LOCALIZATION_NO_UNITS', 'YAML 中没有可翻译的字符串。')
|
|
589
|
+
const root = parseNode(yamlIndent(lines[index]))
|
|
590
|
+
const trailing = takeComment()
|
|
591
|
+
return { root, ...(trailing ? { comment: trailing } : {}) }
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function walkYaml(node, path, segments, comment = '') {
|
|
595
|
+
if (!node || typeof node !== 'object') return
|
|
596
|
+
if (node.type === 'map') {
|
|
597
|
+
for (const entry of node.entries || []) {
|
|
598
|
+
const next = path ? `${path}.${entry.key}` : entry.key
|
|
599
|
+
walkYaml(entry.value, next, segments, entry.comment || comment)
|
|
600
|
+
}
|
|
601
|
+
return
|
|
602
|
+
}
|
|
603
|
+
if (node.type === 'seq') {
|
|
604
|
+
for (const [itemIndex, item] of (node.items || []).entries()) {
|
|
605
|
+
walkYaml(item.value, `${path}[${itemIndex}]`, segments, item.comment || comment)
|
|
606
|
+
}
|
|
607
|
+
return
|
|
608
|
+
}
|
|
609
|
+
if (node.type !== 'scalar') return
|
|
610
|
+
const text = String(node.value ?? '').trim()
|
|
611
|
+
if (!text) return
|
|
612
|
+
if (/^(true|false|null|~)$/i.test(text)) return
|
|
613
|
+
if (/^-?\d+(?:\.\d+)?$/.test(text)) return
|
|
614
|
+
pushSegment(segments, 'yaml', path, node.value, { keyPath: path }, comment)
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function parseYaml(text) {
|
|
618
|
+
const root = parseYamlBlock(text)
|
|
619
|
+
const segments = []
|
|
620
|
+
walkYaml(root.root, '', segments)
|
|
621
|
+
const rootComment = root.comment || ''
|
|
622
|
+
if (!segments.length) throw formatError('LOCALIZATION_NO_UNITS', 'YAML 中没有可翻译的字符串。')
|
|
623
|
+
return {
|
|
624
|
+
format: 'yaml',
|
|
625
|
+
segments,
|
|
626
|
+
formatMeta: {
|
|
627
|
+
localizationTemplate: JSON.stringify({
|
|
628
|
+
kind: 'yaml',
|
|
629
|
+
root: root.root,
|
|
630
|
+
...(rootComment ? { comment: rootComment } : {}),
|
|
631
|
+
}),
|
|
632
|
+
},
|
|
633
|
+
warnings: [],
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function yamlOut(value) {
|
|
638
|
+
return yamlValue(String(value ?? ''))
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function buildYamlNode(node, indent, values, path) {
|
|
642
|
+
const pad = ' '.repeat(indent)
|
|
643
|
+
const lines = []
|
|
644
|
+
if (!node) return lines
|
|
645
|
+
if (node.type === 'scalar') {
|
|
646
|
+
return [`${pad}${yamlOut(values.get(path) ?? node.value)}`]
|
|
647
|
+
}
|
|
648
|
+
if (node.type === 'map') {
|
|
649
|
+
for (const entry of node.entries || []) {
|
|
650
|
+
const next = path ? `${path}.${entry.key}` : entry.key
|
|
651
|
+
if (entry.comment) {
|
|
652
|
+
for (const line of String(entry.comment).split('\n')) lines.push(`${pad}# ${line}`)
|
|
653
|
+
}
|
|
654
|
+
const child = entry.value
|
|
655
|
+
if (child?.type === 'scalar') {
|
|
656
|
+
const value = values.get(next) ?? child.value
|
|
657
|
+
if (child.style === 'literal' || child.style === 'folded') {
|
|
658
|
+
lines.push(`${pad}${entry.key}: ${child.style === 'literal' ? '|' : '>'}`)
|
|
659
|
+
for (const line of String(value).replace(/\n$/, '').split('\n')) {
|
|
660
|
+
lines.push(`${pad} ${line}`)
|
|
661
|
+
}
|
|
662
|
+
} else {
|
|
663
|
+
lines.push(`${pad}${entry.key}: ${yamlOut(value)}`)
|
|
664
|
+
}
|
|
665
|
+
} else {
|
|
666
|
+
lines.push(`${pad}${entry.key}:`)
|
|
667
|
+
lines.push(...buildYamlNode(child, indent + 2, values, next))
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
return lines
|
|
671
|
+
}
|
|
672
|
+
if (node.type === 'seq') {
|
|
673
|
+
for (const [itemIndex, item] of (node.items || []).entries()) {
|
|
674
|
+
const next = `${path}[${itemIndex}]`
|
|
675
|
+
if (item.comment) {
|
|
676
|
+
for (const line of String(item.comment).split('\n')) lines.push(`${pad}# ${line}`)
|
|
677
|
+
}
|
|
678
|
+
if (item.value?.type === 'scalar') {
|
|
679
|
+
lines.push(`${pad}- ${yamlOut(values.get(next) ?? item.value.value)}`)
|
|
680
|
+
} else {
|
|
681
|
+
lines.push(`${pad}-`)
|
|
682
|
+
lines.push(...buildYamlNode(item.value, indent + 2, values, next))
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
return lines
|
|
686
|
+
}
|
|
687
|
+
return lines
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function buildYaml(doc, ast) {
|
|
691
|
+
const values = targetLookup(doc)
|
|
692
|
+
const lines = []
|
|
693
|
+
if (ast.comment) {
|
|
694
|
+
for (const line of String(ast.comment).split('\n')) lines.push(`# ${line}`)
|
|
695
|
+
}
|
|
696
|
+
lines.push(...buildYamlNode(ast.root, 0, values, ''))
|
|
697
|
+
return `${lines.join('\n')}\n`
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function parseKeyValueLines(text, kind) {
|
|
701
|
+
const input = String(text ?? '').replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n')
|
|
702
|
+
const entries = []
|
|
703
|
+
const segments = []
|
|
704
|
+
const warnings = []
|
|
705
|
+
let pendingComment = ''
|
|
706
|
+
const lines = input.split('\n')
|
|
707
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
708
|
+
const line = lines[index]
|
|
709
|
+
if (!line.trim()) continue
|
|
710
|
+
if (/^\s*[#!]/.test(line)) {
|
|
711
|
+
pendingComment = line.replace(/^\s*[#!]\s?/, '').trim()
|
|
712
|
+
continue
|
|
713
|
+
}
|
|
714
|
+
if (/^\s/.test(line) && kind === 'yaml') {
|
|
715
|
+
throw formatError('LOCALIZATION_YAML_NESTED', '当前只支持扁平 YAML(键值不缩进)。')
|
|
716
|
+
}
|
|
717
|
+
let body = line
|
|
718
|
+
if (kind === 'properties') {
|
|
719
|
+
while (body.endsWith('\\') && index + 1 < lines.length) {
|
|
720
|
+
body = `${body.slice(0, -1)}${lines[index + 1]}`
|
|
721
|
+
index += 1
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
let separatorIndex = -1
|
|
725
|
+
let separator = kind === 'yaml' ? ':' : '='
|
|
726
|
+
if (kind === 'yaml') {
|
|
727
|
+
separatorIndex = body.indexOf(':')
|
|
728
|
+
if (separatorIndex < 0) continue
|
|
729
|
+
} else {
|
|
730
|
+
let escaped = false
|
|
731
|
+
for (let cursor = 0; cursor < body.length; cursor += 1) {
|
|
732
|
+
const char = body[cursor]
|
|
733
|
+
if (escaped) {
|
|
734
|
+
escaped = false
|
|
735
|
+
continue
|
|
736
|
+
}
|
|
737
|
+
if (char === '\\') {
|
|
738
|
+
escaped = true
|
|
739
|
+
continue
|
|
740
|
+
}
|
|
741
|
+
if (char === '=' || char === ':') {
|
|
742
|
+
separatorIndex = cursor
|
|
743
|
+
separator = char
|
|
744
|
+
break
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
if (separatorIndex < 0) {
|
|
748
|
+
const match = body.match(/^(\S+)\s+(.*)$/)
|
|
749
|
+
if (!match) continue
|
|
750
|
+
separatorIndex = match[1].length
|
|
751
|
+
separator = ' '
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
const key = body.slice(0, separatorIndex).trim()
|
|
755
|
+
let value = body.slice(separatorIndex + (separator === ' ' ? 0 : 1)).trim()
|
|
756
|
+
if (!key) continue
|
|
757
|
+
if (kind === 'properties') value = decodeEscape(value, 'properties')
|
|
758
|
+
else value = stripYamlQuote(value)
|
|
759
|
+
entries.push({ type: 'string', key, value, comment: pendingComment, separator })
|
|
760
|
+
pushSegment(segments, kind, key, value, {}, pendingComment)
|
|
761
|
+
pendingComment = ''
|
|
762
|
+
}
|
|
763
|
+
if (!segments.length) throw formatError('LOCALIZATION_NO_UNITS', '文件中没有可翻译的键值对。')
|
|
764
|
+
return {
|
|
765
|
+
format: kind,
|
|
766
|
+
segments,
|
|
767
|
+
formatMeta: { localizationTemplate: JSON.stringify({ kind, entries }) },
|
|
768
|
+
warnings,
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function parsePlist(xml) {
|
|
773
|
+
const tokens = String(xml ?? '')
|
|
774
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
775
|
+
.match(/<[^>]+>|[^<]+/g) ?? []
|
|
776
|
+
let index = 0
|
|
777
|
+
|
|
778
|
+
const skipNoise = () => {
|
|
779
|
+
while (index < tokens.length) {
|
|
780
|
+
const token = tokens[index]
|
|
781
|
+
if (/^\s*$/.test(token) || token.startsWith('<?') || token.startsWith('<!')) index += 1
|
|
782
|
+
else break
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
const parseText = (tag) => {
|
|
786
|
+
let output = ''
|
|
787
|
+
while (index < tokens.length && tokens[index] !== `</${tag}>`) {
|
|
788
|
+
output += tokens[index]
|
|
789
|
+
index += 1
|
|
790
|
+
}
|
|
791
|
+
if (tokens[index] === `</${tag}>`) index += 1
|
|
792
|
+
return decodeXml(cdata(output)).trim()
|
|
793
|
+
}
|
|
794
|
+
const parseValue = () => {
|
|
795
|
+
skipNoise()
|
|
796
|
+
const token = tokens[index]
|
|
797
|
+
if (!token) throw formatError('LOCALIZATION_PARSE', 'plist 结构不完整。')
|
|
798
|
+
if (token.startsWith('<plist')) {
|
|
799
|
+
index += 1
|
|
800
|
+
return parseValue()
|
|
801
|
+
}
|
|
802
|
+
if (token === '<dict>') {
|
|
803
|
+
index += 1
|
|
804
|
+
const result = {}
|
|
805
|
+
while (index < tokens.length && tokens[index] !== '</dict>') {
|
|
806
|
+
skipNoise()
|
|
807
|
+
if (tokens[index] !== '<key>') {
|
|
808
|
+
index += 1
|
|
809
|
+
continue
|
|
810
|
+
}
|
|
811
|
+
index += 1
|
|
812
|
+
const key = parseText('key')
|
|
813
|
+
skipNoise()
|
|
814
|
+
result[key] = parseValue()
|
|
815
|
+
}
|
|
816
|
+
index += 1
|
|
817
|
+
return result
|
|
818
|
+
}
|
|
819
|
+
if (token === '<array>') {
|
|
820
|
+
index += 1
|
|
821
|
+
const result = []
|
|
822
|
+
while (index < tokens.length && tokens[index] !== '</array>') {
|
|
823
|
+
result.push(parseValue())
|
|
824
|
+
}
|
|
825
|
+
index += 1
|
|
826
|
+
return result
|
|
827
|
+
}
|
|
828
|
+
if (token.startsWith('<string>')) {
|
|
829
|
+
index += 1
|
|
830
|
+
return parseText('string')
|
|
831
|
+
}
|
|
832
|
+
if (token.startsWith('<integer>') || token.startsWith('<real>')) {
|
|
833
|
+
index += 1
|
|
834
|
+
const value = Number(parseText(token.startsWith('<integer>') ? 'integer' : 'real'))
|
|
835
|
+
return Number.isFinite(value) ? value : 0
|
|
836
|
+
}
|
|
837
|
+
if (token === '<true/>' || token === '<false/>') {
|
|
838
|
+
index += 1
|
|
839
|
+
return token === '<true/>'
|
|
840
|
+
}
|
|
841
|
+
index += 1
|
|
842
|
+
return ''
|
|
843
|
+
}
|
|
844
|
+
const value = parseValue()
|
|
845
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
846
|
+
throw formatError('LOCALIZATION_PARSE', 'stringsdict 根节点必须是字典。')
|
|
847
|
+
}
|
|
848
|
+
return value
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function parseStringsdict(text) {
|
|
852
|
+
const root = parsePlist(text)
|
|
853
|
+
const entries = []
|
|
854
|
+
const segments = []
|
|
855
|
+
for (const [key, config] of Object.entries(root)) {
|
|
856
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) continue
|
|
857
|
+
const formatKey = String(config.NSStringLocalizedFormatKey ?? '')
|
|
858
|
+
const variable = formatKey.match(/%#@([A-Za-z0-9_]+)@/)?.[1] ?? 'count'
|
|
859
|
+
const quantitySource = config[variable] && typeof config[variable] === 'object' && !Array.isArray(config[variable])
|
|
860
|
+
? config[variable]
|
|
861
|
+
: config
|
|
862
|
+
const items = []
|
|
863
|
+
for (const [quantity, value] of Object.entries(quantitySource)) {
|
|
864
|
+
if (quantity.startsWith('NSString') || quantity === variable) continue
|
|
865
|
+
if (typeof value !== 'string') continue
|
|
866
|
+
items.push({ quantity, value })
|
|
867
|
+
pushSegment(segments, 'apple-stringsdict', key, value, {
|
|
868
|
+
keyPath: `${key}#${quantity}`,
|
|
869
|
+
context: quantity,
|
|
870
|
+
plural: true,
|
|
871
|
+
msgidPlural: key,
|
|
872
|
+
icuType: 'quantity',
|
|
873
|
+
})
|
|
874
|
+
}
|
|
875
|
+
entries.push({
|
|
876
|
+
type: 'stringsdict',
|
|
877
|
+
key,
|
|
878
|
+
variable,
|
|
879
|
+
formatKey: formatKey || `%#@${variable}@`,
|
|
880
|
+
valueType: String(quantitySource.NSStringFormatValueTypeKey ?? 'd'),
|
|
881
|
+
items,
|
|
882
|
+
})
|
|
883
|
+
}
|
|
884
|
+
if (!segments.length) throw formatError('LOCALIZATION_NO_UNITS', 'stringsdict 中没有可翻译的复数文本。')
|
|
885
|
+
return {
|
|
886
|
+
format: 'apple-stringsdict',
|
|
887
|
+
segments,
|
|
888
|
+
formatMeta: { localizationTemplate: JSON.stringify({ kind: 'apple-stringsdict', entries }) },
|
|
889
|
+
warnings: [],
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
export function parseLocalization(text, kind) {
|
|
894
|
+
const normalized = normalizeKind(kind)
|
|
895
|
+
if (normalized === 'apple-strings') return parseAppleStrings(text, normalized)
|
|
896
|
+
if (normalized === 'apple-stringsdict') return parseStringsdict(text)
|
|
897
|
+
if (normalized === 'android') return parseAndroid(text)
|
|
898
|
+
if (normalized === 'resx') return parseResx(text)
|
|
899
|
+
if (normalized === 'arb') return parseArb(text)
|
|
900
|
+
if (normalized === 'yaml') return parseYaml(text)
|
|
901
|
+
return parseKeyValueLines(text, normalized)
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function targetLookup(doc) {
|
|
905
|
+
const map = new Map()
|
|
906
|
+
for (const segment of doc.segments) {
|
|
907
|
+
const id = segment.meta?.identifier || segment.meta?.keyPath
|
|
908
|
+
if (!id) continue
|
|
909
|
+
const context = segment.meta?.context || ''
|
|
910
|
+
const value = String(segment.target || segment.source || '')
|
|
911
|
+
map.set(`${id}\u0000${context}`, value)
|
|
912
|
+
if (!context) map.set(id, value)
|
|
913
|
+
}
|
|
914
|
+
return map
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function segmentLookup(doc) {
|
|
918
|
+
const map = new Map()
|
|
919
|
+
for (const segment of doc.segments) {
|
|
920
|
+
const id = segment.meta?.identifier
|
|
921
|
+
if (!id) continue
|
|
922
|
+
const context = segment.meta?.context || ''
|
|
923
|
+
map.set(`${id}\u0000${context}`, segment)
|
|
924
|
+
}
|
|
925
|
+
return map
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function templateOf(doc, kind) {
|
|
929
|
+
const raw = doc.formatMeta?.localizationTemplate
|
|
930
|
+
if (raw) {
|
|
931
|
+
try {
|
|
932
|
+
const parsed = JSON.parse(raw)
|
|
933
|
+
if (parsed && parsed.kind === kind) return parsed
|
|
934
|
+
} catch {
|
|
935
|
+
// fall through to a minimal rebuild
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
return {
|
|
939
|
+
kind,
|
|
940
|
+
entries: doc.segments.map((segment) => ({
|
|
941
|
+
type: 'string',
|
|
942
|
+
key: segment.meta?.identifier || segment.meta?.keyPath || String(segment.paragraph ?? 0),
|
|
943
|
+
value: segment.source,
|
|
944
|
+
})),
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function buildAppleStrings(doc, ast) {
|
|
949
|
+
const values = targetLookup(doc)
|
|
950
|
+
const segments = segmentLookup(doc)
|
|
951
|
+
const lines = []
|
|
952
|
+
for (const entry of ast.entries || []) {
|
|
953
|
+
if (!entry || entry.type !== 'string') continue
|
|
954
|
+
if (entry.comment) lines.push(`/* ${entry.comment} */`)
|
|
955
|
+
const segment = segments.get(`${entry.key}\u0000`)
|
|
956
|
+
const value = values.get(`${entry.key}\u0000`) ?? entry.value
|
|
957
|
+
lines.push(`"${escapeApple(entry.key)}" = "${escapeApple(value)}";`)
|
|
958
|
+
}
|
|
959
|
+
return `${lines.join('\n')}\n`
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function buildAndroid(doc, ast) {
|
|
963
|
+
const values = targetLookup(doc)
|
|
964
|
+
const segments = segmentLookup(doc)
|
|
965
|
+
const lines = ['<?xml version="1.0" encoding="utf-8"?>', '<resources>']
|
|
966
|
+
for (const entry of ast.entries || []) {
|
|
967
|
+
if (!entry || !entry.key) continue
|
|
968
|
+
if (entry.comment) lines.push(` <!-- ${entry.comment} -->`)
|
|
969
|
+
const translatable = entry.translatable === false ? ' translatable="false"' : ''
|
|
970
|
+
if (entry.type === 'string') {
|
|
971
|
+
const segment = segments.get(`${entry.key}\u0000`)
|
|
972
|
+
const value = values.get(`${entry.key}\u0000`) ?? entry.value
|
|
973
|
+
lines.push(` <string name="${escapeXml(entry.key)}"${translatable}>${segment ? renderInline(value, segment.meta?.inline) : escapeXml(cdata(value))}</string>`)
|
|
974
|
+
} else if (entry.type === 'plural') {
|
|
975
|
+
lines.push(` <plurals name="${escapeXml(entry.key)}"${translatable}>`)
|
|
976
|
+
for (const item of entry.items || []) {
|
|
977
|
+
const segment = segments.get(`${entry.key}\u0000${item.quantity}`)
|
|
978
|
+
const value = values.get(`${entry.key}\u0000${item.quantity}`) ?? item.value
|
|
979
|
+
lines.push(` <item quantity="${escapeXml(item.quantity)}">${segment ? renderInline(value, segment.meta?.inline) : escapeXml(cdata(value))}</item>`)
|
|
980
|
+
}
|
|
981
|
+
lines.push(' </plurals>')
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
lines.push('</resources>')
|
|
985
|
+
return `${lines.join('\n')}\n`
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function buildResx(doc, ast) {
|
|
989
|
+
const values = targetLookup(doc)
|
|
990
|
+
const segments = segmentLookup(doc)
|
|
991
|
+
const lines = [
|
|
992
|
+
'<?xml version="1.0" encoding="utf-8"?>',
|
|
993
|
+
'<root>',
|
|
994
|
+
' <resheader name="resmimetype"><value>text/microsoft-resx</value></resheader>',
|
|
995
|
+
' <resheader name="version"><value>2.0</value></resheader>',
|
|
996
|
+
' <resheader name="reader"><value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value></resheader>',
|
|
997
|
+
' <resheader name="writer"><value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value></resheader>',
|
|
998
|
+
]
|
|
999
|
+
for (const entry of ast.entries || []) {
|
|
1000
|
+
if (!entry || entry.type !== 'string' || !entry.key) continue
|
|
1001
|
+
const segment = segments.get(`${entry.key}\u0000`)
|
|
1002
|
+
const value = values.get(`${entry.key}\u0000`) ?? entry.value
|
|
1003
|
+
lines.push(` <data name="${escapeXml(entry.key)}" xml:space="preserve">`)
|
|
1004
|
+
lines.push(` <value>${segment ? renderInline(value, segment.meta?.inline) : escapeXml(cdata(value))}</value>`)
|
|
1005
|
+
if (entry.comment) lines.push(` <comment>${escapeXml(entry.comment)}</comment>`)
|
|
1006
|
+
lines.push(' </data>')
|
|
1007
|
+
}
|
|
1008
|
+
lines.push('</root>')
|
|
1009
|
+
return `${lines.join('\n')}\n`
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function buildArb(doc, ast) {
|
|
1013
|
+
const data = ast.data && typeof ast.data === 'object' && !Array.isArray(ast.data) ? structuredClone(ast.data) : {}
|
|
1014
|
+
for (const segment of doc.segments) {
|
|
1015
|
+
const key = segment.meta?.identifier
|
|
1016
|
+
if (!key || !(key in data)) continue
|
|
1017
|
+
data[key] = String(segment.target || segment.source || '')
|
|
1018
|
+
}
|
|
1019
|
+
return `${JSON.stringify(data, null, 2)}\n`
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function buildKeyValue(doc, ast, kind) {
|
|
1023
|
+
const values = targetLookup(doc)
|
|
1024
|
+
const lines = []
|
|
1025
|
+
for (const entry of ast.entries || []) {
|
|
1026
|
+
if (!entry || entry.type !== 'string' || !entry.key) continue
|
|
1027
|
+
if (entry.comment) lines.push(`${kind === 'yaml' ? '#' : '#'} ${entry.comment}`)
|
|
1028
|
+
const value = values.get(`${entry.key}\u0000`) ?? entry.value
|
|
1029
|
+
if (kind === 'yaml') lines.push(`${entry.key}: ${yamlValue(value)}`)
|
|
1030
|
+
else lines.push(`${escapeProperties(entry.key, true)}=${escapeProperties(value)}`)
|
|
1031
|
+
}
|
|
1032
|
+
return `${lines.join('\n')}\n`
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function buildStringsdict(doc, ast) {
|
|
1036
|
+
const values = targetLookup(doc)
|
|
1037
|
+
const lines = [
|
|
1038
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
1039
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
1040
|
+
'<plist version="1.0">',
|
|
1041
|
+
'<dict>',
|
|
1042
|
+
]
|
|
1043
|
+
for (const entry of ast.entries || []) {
|
|
1044
|
+
if (!entry || entry.type !== 'stringsdict' || !entry.key) continue
|
|
1045
|
+
const variable = entry.variable || 'count'
|
|
1046
|
+
lines.push(` <key>${escapeXml(entry.key)}</key>`)
|
|
1047
|
+
lines.push(' <dict>')
|
|
1048
|
+
lines.push(' <key>NSStringLocalizedFormatKey</key>')
|
|
1049
|
+
lines.push(` <string>${escapeXml(entry.formatKey || `%#@${variable}@`)}</string>`)
|
|
1050
|
+
lines.push(` <key>${escapeXml(variable)}</key>`)
|
|
1051
|
+
lines.push(' <dict>')
|
|
1052
|
+
lines.push(' <key>NSStringFormatSpecTypeKey</key>')
|
|
1053
|
+
lines.push(' <string>NSStringPluralRuleType</string>')
|
|
1054
|
+
lines.push(' <key>NSStringFormatValueTypeKey</key>')
|
|
1055
|
+
lines.push(` <string>${escapeXml(entry.valueType || 'd')}</string>`)
|
|
1056
|
+
for (const item of entry.items || []) {
|
|
1057
|
+
const value = values.get(`${entry.key}\u0000${item.quantity}`) ?? item.value
|
|
1058
|
+
lines.push(` <key>${escapeXml(item.quantity)}</key>`)
|
|
1059
|
+
lines.push(` <string>${escapeXml(value)}</string>`)
|
|
1060
|
+
}
|
|
1061
|
+
lines.push(' </dict>')
|
|
1062
|
+
lines.push(' </dict>')
|
|
1063
|
+
}
|
|
1064
|
+
lines.push('</dict>', '</plist>')
|
|
1065
|
+
return `${lines.join('\n')}\n`
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
export function buildLocalization(doc, kind) {
|
|
1069
|
+
const normalized = normalizeKind(kind || doc.format)
|
|
1070
|
+
const ast = templateOf(doc, normalized)
|
|
1071
|
+
if (normalized === 'apple-strings') return buildAppleStrings(doc, ast)
|
|
1072
|
+
if (normalized === 'apple-stringsdict') return buildStringsdict(doc, ast)
|
|
1073
|
+
if (normalized === 'android') return buildAndroid(doc, ast)
|
|
1074
|
+
if (normalized === 'resx') return buildResx(doc, ast)
|
|
1075
|
+
if (normalized === 'arb') return buildArb(doc, ast)
|
|
1076
|
+
if (normalized === 'yaml' && ast.root) return buildYaml(doc, ast)
|
|
1077
|
+
return buildKeyValue(doc, ast, normalized)
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
export const LOCALIZATION_EXTENSIONS = {
|
|
1081
|
+
strings: 'apple-strings',
|
|
1082
|
+
stringsdict: 'apple-stringsdict',
|
|
1083
|
+
resx: 'resx',
|
|
1084
|
+
arb: 'arb',
|
|
1085
|
+
yaml: 'yaml',
|
|
1086
|
+
yml: 'yaml',
|
|
1087
|
+
properties: 'properties',
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
export const LOCALIZATION_EXPORT = {
|
|
1091
|
+
'apple-strings': { extension: 'strings', mime: 'text/plain;charset=utf-8' },
|
|
1092
|
+
'apple-stringsdict': { extension: 'stringsdict', mime: 'application/xml;charset=utf-8' },
|
|
1093
|
+
android: { extension: 'xml', mime: 'application/xml;charset=utf-8' },
|
|
1094
|
+
resx: { extension: 'resx', mime: 'application/xml;charset=utf-8' },
|
|
1095
|
+
arb: { extension: 'arb', mime: 'application/json;charset=utf-8' },
|
|
1096
|
+
yaml: { extension: 'yaml', mime: 'text/yaml;charset=utf-8' },
|
|
1097
|
+
properties: { extension: 'properties', mime: 'text/plain;charset=utf-8' },
|
|
1098
|
+
}
|