137 lines
4.6 KiB
JavaScript
137 lines
4.6 KiB
JavaScript
import { readFile, writeFile } from 'node:fs/promises'
|
|
|
|
const source = JSON.parse(await readFile('src/i18n/locales/zh-CN.json', 'utf8'))
|
|
|
|
const localeConfigs = {
|
|
'zh-TW': { title: 'Spine粒子編輯器-Yeomai' },
|
|
ja: { title: 'Spineパーティクルエディター-Yeomai' },
|
|
ko: { title: 'Spine 파티클 에디터-Yeomai' },
|
|
fr: { title: 'Spine Particle Editor-Yeomai' },
|
|
de: { title: 'Spine Particle Editor-Yeomai' },
|
|
'pt-BR': { title: 'Spine Particle Editor-Yeomai' },
|
|
es: { title: 'Spine Particle Editor-Yeomai' },
|
|
}
|
|
|
|
const nativeLanguageLabels = {
|
|
zhCN: '中文',
|
|
zhTW: '繁體中文',
|
|
en: 'English',
|
|
ja: '日本語',
|
|
ko: '한국어',
|
|
fr: 'Français',
|
|
de: 'Deutsch',
|
|
ptBR: 'Português',
|
|
es: 'Español',
|
|
}
|
|
|
|
const protectedTerms = [
|
|
'SpineParticle', 'Spine', 'Yeomai', 'JSON', 'FPS', 'ZIP', 'PNG', 'JPEG',
|
|
'WebP', 'WebGL', 'Bézier', 'Bezier', 'Mesh', 'Atlas', 'Ctrl', 'macOS',
|
|
'Windows', 'Universal', 'A/B', 'AB',
|
|
]
|
|
|
|
function flatten(value, path = '', result = []) {
|
|
for (const [key, child] of Object.entries(value)) {
|
|
const nextPath = path ? `${path}.${key}` : key
|
|
if (child && typeof child === 'object' && !Array.isArray(child)) flatten(child, nextPath, result)
|
|
else result.push({ path: nextPath, value: child })
|
|
}
|
|
return result
|
|
}
|
|
|
|
function setPath(target, path, value) {
|
|
const parts = path.split('.')
|
|
let current = target
|
|
for (let index = 0; index < parts.length - 1; index++) {
|
|
current = current[parts[index]] ||= {}
|
|
}
|
|
current[parts.at(-1)] = value
|
|
}
|
|
|
|
function protectText(text) {
|
|
const values = []
|
|
const escapedTerms = protectedTerms
|
|
.sort((a, b) => b.length - a.length)
|
|
.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
const pattern = new RegExp(`\\{[a-zA-Z0-9_]+\\}|\\b(?:${escapedTerms.join('|')})\\b`, 'g')
|
|
const protectedText = String(text).replace(pattern, (match) => {
|
|
const token = `ZXQV${values.length}QXZ`
|
|
values.push(match)
|
|
return token
|
|
})
|
|
return { protectedText, values }
|
|
}
|
|
|
|
function restoreText(text, values) {
|
|
return String(text).replace(/ZXQV(\d+)QXZ/g, (_match, index) => values[Number(index)] ?? '')
|
|
}
|
|
|
|
async function translateBatch(texts, target) {
|
|
const params = new URLSearchParams({
|
|
client: 'dict-chrome-ex',
|
|
sl: 'zh-CN',
|
|
tl: target,
|
|
})
|
|
for (const text of texts) params.append('q', text)
|
|
const response = await fetch(`https://clients5.google.com/translate_a/t?${params}`, {
|
|
signal: AbortSignal.timeout(30000),
|
|
})
|
|
if (!response.ok) throw new Error(`Translation HTTP ${response.status}`)
|
|
const body = await response.json()
|
|
if (!Array.isArray(body)) throw new Error('Unexpected translation response')
|
|
if (body.length !== texts.length) {
|
|
throw new Error(`Translation count mismatch: expected ${texts.length}, got ${body.length}`)
|
|
}
|
|
return body.map((value) => typeof value === 'string' ? value : JSON.stringify(value))
|
|
}
|
|
|
|
async function translateLocale(code, config) {
|
|
const entries = flatten(source).filter(({ path }) => !path.startsWith('language.') && path !== 'app.title')
|
|
const translated = {}
|
|
const batches = []
|
|
let batch = []
|
|
let encodedLength = 0
|
|
for (const entry of entries) {
|
|
const protectedEntry = protectText(entry.value)
|
|
const item = { ...entry, ...protectedEntry }
|
|
const itemLength = encodeURIComponent(item.protectedText).length + 3
|
|
if (batch.length && encodedLength + itemLength > 7000) {
|
|
batches.push(batch)
|
|
batch = []
|
|
encodedLength = 0
|
|
}
|
|
batch.push(item)
|
|
encodedLength += itemLength
|
|
}
|
|
if (batch.length) batches.push(batch)
|
|
|
|
for (let index = 0; index < batches.length; index++) {
|
|
const current = batches[index]
|
|
let values
|
|
for (let attempt = 0; attempt < 4; attempt++) {
|
|
try {
|
|
values = await translateBatch(current.map((entry) => entry.protectedText), code)
|
|
break
|
|
} catch (error) {
|
|
if (attempt === 3) throw error
|
|
await new Promise((resolve) => setTimeout(resolve, 1800 * (attempt + 1)))
|
|
}
|
|
}
|
|
current.forEach((entry, itemIndex) => {
|
|
setPath(translated, entry.path, restoreText(values[itemIndex], entry.values))
|
|
})
|
|
process.stdout.write(`${code}: ${index + 1}/${batches.length}\n`)
|
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
}
|
|
|
|
translated.app.title = config.title
|
|
translated.language = nativeLanguageLabels
|
|
await writeFile(`src/i18n/locales/${code}.json`, `${JSON.stringify(translated, null, 2)}\n`)
|
|
}
|
|
|
|
for (const [code, config] of Object.entries(localeConfigs)) {
|
|
await translateLocale(code, config)
|
|
}
|
|
|
|
console.log('Generated locale drafts. Review and override key terminology before release.')
|