#!/usr/bin/env node
/**
* Auto-translate missing translation keys from en-US to all other locales.
* Uses Claude CLI for translation.
*
* Usage:
* node scripts/auto-translate.js # translate all locales
* node scripts/auto-translate.js de-DE # translate only German
*/
const fs = require('fs')
const path = require('path')
const { execSync } = require('child_process')
const TRANSLATION_DIR = path.resolve(__dirname, '../src/translation')
// Language names for Claude context
const LANGUAGE_NAMES = {
'af-ZA': 'Afrikaans',
'ar-SA': 'Arabic',
'bn-BD': 'Bengali',
'ca-ES': 'Catalan',
'cs-CZ': 'Czech',
'da-DK': 'Danish',
'de-DE': 'German',
'el-GR': 'Greek',
'es-ES': 'Spanish',
'fi-FI': 'Finnish',
'fr-FR': 'French',
'he-IL': 'Hebrew',
'hu-HU': 'Hungarian',
'it-IT': 'Italian',
'ja-JP': 'Japanese',
'ko-KR': 'Korean',
'nb-NO': 'Norwegian',
'nl-NL': 'Dutch',
'pl-PL': 'Polish',
'pt-BR': 'Brazilian Portuguese',
'ro-RO': 'Romanian',
'ru-RU': 'Russian',
'sr-RS': 'Serbian',
'sv-SE': 'Swedish',
'tr-TR': 'Turkish',
'uk-UA': 'Ukrainian',
'vi-VN': 'Vietnamese',
'zh-CN': 'Simplified Chinese',
'zh-TW': 'Traditional Chinese',
}
// Recursively get all leaf key paths from an object
// Returns array of { path: 'a.b.c', value: 'string', type: 'string'|'function' }
function getKeyPaths(obj, prefix = '') {
const results = []
for (const [key, value] of Object.entries(obj)) {
const fullPath = prefix ? `${prefix}.${key}` : key
if (value && typeof value === 'object' && !Array.isArray(value)) {
results.push(...getKeyPaths(value, fullPath))
} else if (typeof value === 'string') {
results.push({ path: fullPath, value, type: 'string' })
} else if (typeof value === 'function') {
results.push({ path: fullPath, value: value.toString(), type: 'function' })
}
}
return results
}
// Get a nested value by dot-path
function getByPath(obj, pathStr) {
return pathStr.split('.').reduce((o, k) => (o && o[k] !== undefined) ? o[k] : undefined, obj)
}
// Set a nested value by dot-path, creating intermediate objects
function setByPath(obj, pathStr, value) {
const keys = pathStr.split('.')
let current = obj
for (let i = 0; i < keys.length - 1; i++) {
if (current[keys[i]] === undefined) {
current[keys[i]] = {}
}
current = current[keys[i]]
}
current[keys[keys.length - 1]] = value
}
// Serialize a translation object back to JS source
function serializeTranslation(obj, indent = 0) {
const pad = ' '.repeat(indent)
const padInner = ' '.repeat(indent + 1)
const entries = []
for (const [key, value] of Object.entries(obj)) {
const safeKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : `'${key}'`
if (value && typeof value === 'object' && !Array.isArray(value)) {
entries.push(`${padInner}${safeKey}: ${serializeTranslation(value, indent + 1)}`)
} else if (typeof value === 'function') {
entries.push(`${padInner}${safeKey}: ${value.toString()}`)
} else if (typeof value === 'string') {
// Use single quotes, escape internal single quotes
const escaped = value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
entries.push(`${padInner}${safeKey}: '${escaped}'`)
}
}
return `{\n${entries.join(',\n')},\n${pad}}`
}
// Build the full file content from translation object
function buildFileContent(translationObj) {
const body = serializeTranslation(translationObj, 0)
return `const translation = ${body};\n\nmodule.exports = translation;\n`
}
// Translate missing keys using Claude CLI
function translateWithClaude(missingKeys, targetLang) {
if (missingKeys.length === 0) return {}
const keysDescription = missingKeys.map(k => {
if (k.type === 'function') {
return `- ${k.path} (JavaScript function, translate only the human-readable text inside template literals, keep the function structure and variable interpolation intact): ${k.value}`
}
return `- ${k.path}: ${k.value}`
}).join('\n')
const prompt = `Translate the following P3X OneNote application UI strings from English to ${targetLang}.
IMPORTANT RULES:
- Return ONLY a valid JSON object mapping each key path to its translated value
- For function entries: return the FULL JavaScript arrow function as a string, with only the human-readable text translated. Keep variable names, template literal syntax, and function structure EXACTLY the same.
- Do NOT translate: technical terms (URL, proxy, P3X OneNote, OneNote, ALT), variable names, or brand names
- Keep the same tone (casual/formal) as the original
- Do NOT add any explanation, markdown fencing, or extra text — ONLY the JSON object
Keys to translate:
${keysDescription}
Return format example:
{"tabs.duplicateTab": "Translated text", "label.startOnLogin": "Translated text"}`
const tmpFile = path.resolve(__dirname, '../.translate-prompt.tmp')
fs.writeFileSync(tmpFile, prompt)
try {
const result = execSync(
`cat ${JSON.stringify(tmpFile)} | claude -p - --no-session-persistence`,
{ encoding: 'utf-8', timeout: 120000 }
).trim()
// Extract JSON from response (Claude might wrap it)
const jsonMatch = result.match(/\{[\s\S]*\}/)
if (!jsonMatch) {
console.error(` Failed to parse Claude response for ${targetLang}`)
return {}
}
return JSON.parse(jsonMatch[0])
} catch (e) {
console.error(` Claude translation failed for ${targetLang}:`, e.message)
return {}
} finally {
try { fs.unlinkSync(tmpFile) } catch {}
}
}
// Main
async function main() {
const targetLocale = process.argv[2] // optional: specific locale
// Load English as source of truth
delete require.cache[require.resolve(path.join(TRANSLATION_DIR, 'en-US.js'))]
const enTranslation = require(path.join(TRANSLATION_DIR, 'en-US.js'))
const enKeys = getKeyPaths(enTranslation)
// Skip the language.translations block — it's the same in all locales
const translatableKeys = enKeys.filter(k => !k.path.startsWith('menu.language.translations.'))
const localeFiles = fs.readdirSync(TRANSLATION_DIR)
.filter(f => f.endsWith('.js') && f !== 'en-US.js')
.filter(f => !targetLocale || f === `${targetLocale}.js`)
if (localeFiles.length === 0) {
console.log(targetLocale ? `Locale ${targetLocale} not found.` : 'No locale files found.')
return
}
let totalTranslated = 0
for (const file of localeFiles) {
const locale = file.replace('.js', '')
const langName = LANGUAGE_NAMES[locale] || locale
const filePath = path.join(TRANSLATION_DIR, file)
// Clear require cache and reload
delete require.cache[require.resolve(filePath)]
const localeTranslation = require(filePath)
// Find missing keys
const missingKeys = translatableKeys.filter(k => {
const existing = getByPath(localeTranslation, k.path)
return existing === undefined
})
if (missingKeys.length === 0) {
console.log(`✓ ${locale} (${langName}) — all keys present`)
continue
}
console.log(`→ ${locale} (${langName}) — ${missingKeys.length} missing key(s), translating...`)
const translations = translateWithClaude(missingKeys, langName)
let translated = 0
for (const key of missingKeys) {
const translatedValue = translations[key.path]
if (translatedValue !== undefined) {
if (key.type === 'function') {
// Store function as eval-able string — we'll handle serialization
try {
// Validate it's a valid function
eval(`(${translatedValue})`)
setByPath(localeTranslation, key.path, eval(`(${translatedValue})`))
translated++
} catch {
console.error(` Skipping ${key.path} — invalid function syntax`)
}
} else {
setByPath(localeTranslation, key.path, translatedValue)
translated++
}
} else {
console.error(` Missing translation for ${key.path}`)
}
}
if (translated > 0) {
const content = buildFileContent(localeTranslation)
fs.writeFileSync(filePath, content)
console.log(` ✓ Updated ${translated} key(s)`)
totalTranslated += translated
}
}
console.log(`\nDone. Total translations added: ${totalTranslated}`)
}
main().catch(console.error)