@scoutello/i18n-magic
Version:
Intelligent CLI toolkit that automates internationalization workflows for JavaScript/TypeScript projects and auto-translates new string keys to other languages
457 lines (405 loc) ⢠14.1 kB
text/typescript
import fs from "node:fs"
import chalk from "chalk"
import cliProgress from "cli-progress"
import { languages } from "../lib/languges.js"
import type { Configuration } from "../lib/types.js"
import {
findExistingTranslations,
loadLocalesFile,
TranslationError,
translateKey,
writeLocalesFile,
} from "../lib/utils.js"
const getLanguageLabel = (locale: string): string => {
const lang = languages.find((l) => l.value === locale)
return lang?.label || locale
}
export const syncLocales = async (config: Configuration) => {
const {
loadPath,
savePath,
defaultLocale,
namespaces,
locales,
context,
openai,
} = config
const localesToProcess = locales.filter((l) => l !== defaultLocale)
const BATCH_SIZE = 5
console.log(chalk.cyan("\nš Syncing translations\n"))
console.log(
chalk.dim(
` Default locale: ${chalk.white(getLanguageLabel(defaultLocale))}`,
),
)
console.log(chalk.dim(` Namespaces: ${chalk.white(namespaces.join(", "))}`))
console.log(
chalk.dim(` Target languages: ${chalk.white(localesToProcess.length)}`),
)
console.log(
chalk.dim(
` Concurrency: ${chalk.white(`${BATCH_SIZE} languages at a time`)}\n`,
),
)
// Track results for each locale
const localeResults: Record<
string,
{
status: "pending" | "processing" | "translating" | "done" | "error"
translated: number
translationCompleted: number
translationTotal: number
reused: number
error?: string
}
> = {}
for (const locale of localesToProcess) {
localeResults[locale] = {
status: "pending",
translated: 0,
translationCompleted: 0,
translationTotal: 0,
reused: 0,
}
}
const progressBars = new cliProgress.MultiBar(
{
format: ` {label} {bar} {percentage}% | {value}/{total} | {duration_formatted} | {status}`,
barCompleteChar: chalk.green("ā"),
barIncompleteChar: chalk.gray("ā"),
hideCursor: true,
clearOnComplete: false,
emptyOnZero: true,
etaAsynchronousUpdate: true,
barsize: 30,
},
cliProgress.Presets.shades_classic,
)
const localeProgressBar = progressBars.create(localesToProcess.length, 0, {
label: "Languages ",
status: "",
})
const translationProgressBar = progressBars.create(0, 0, {
label: "Translations",
status: "finding keys to translate...",
})
// Suppress console.log during progress bar display
const originalConsoleLog = console.log
const suppressedLog = () => {}
try {
// Helper to ensure a locale/namespace file exists before we add keys
const ensureLocaleNamespaceFile = async (
locale: string,
namespace: string,
) => {
if (typeof savePath === "string") {
const filePath = savePath
.replace("{{lng}}", locale)
.replace("{{ns}}", namespace)
if (!fs.existsSync(filePath)) {
await writeLocalesFile(savePath, locale, namespace, {})
}
}
}
// Ensure all default-locale namespace files exist first
await Promise.all(
namespaces.map((namespace) =>
ensureLocaleNamespaceFile(defaultLocale, namespace),
),
)
// Process locales in parallel batches
let completedCount = 0
const currentlyProcessing: Set<string> = new Set()
// Helper to update progress bars with currently processing languages
const updateProgressDisplay = () => {
const processingArray = Array.from(currentlyProcessing)
const localeStatus =
processingArray.length > 0
? `processing ${processingArray.join(", ")}`
: ""
localeProgressBar.update(completedCount, {
label: "Languages ",
status: localeStatus,
})
const translationTotals = localesToProcess.reduce(
(totals, locale) => {
const result = localeResults[locale]
totals.completed += result.translationCompleted
totals.total += result.translationTotal
return totals
},
{ completed: 0, total: 0 },
)
const translationTotal = translationTotals.total
const translationCurrent =
translationTotal > 0
? Math.min(translationTotals.completed, translationTotal)
: 0
const activeTranslations = localesToProcess.filter((locale) => {
const result = localeResults[locale]
return (
currentlyProcessing.has(locale) &&
result.translationTotal > 0 &&
result.translationCompleted < result.translationTotal
)
})
const translationStatus =
activeTranslations.length > 0
? `translating ${activeTranslations
.map((locale) => {
const result = localeResults[locale]
return `${locale} ${result.translationCompleted}/${result.translationTotal}`
})
.join(", ")}`
: processingArray.length > 0
? "waiting for translation work..."
: translationTotal > 0
? "translations complete"
: "finding keys to translate..."
translationProgressBar.setTotal(translationTotal)
translationProgressBar.update(translationCurrent, {
label: "Translations",
status: translationStatus,
})
}
// Process a single locale
const processLocale = async (locale: string) => {
localeResults[locale].status = "processing"
currentlyProcessing.add(locale)
updateProgressDisplay()
try {
// Ensure all namespace files for this locale exist
await Promise.all(
namespaces.map((namespace) =>
ensureLocaleNamespaceFile(locale, namespace),
),
)
// Collect all missing keys for this locale across all namespaces
const allMissingKeys: Record<
string,
{ value: string; namespaces: string[] }
> = {}
const namespaceKeys: Record<string, Record<string, string>> = {}
// Load existing keys for all namespaces in parallel
const namespaceResults = await Promise.all(
namespaces.map(async (namespace) => {
const defaultLocaleKeys = await loadLocalesFile(
loadPath,
defaultLocale,
namespace,
{ silent: true },
)
const localeKeys = await loadLocalesFile(
loadPath,
locale,
namespace,
{ silent: true },
)
return {
namespace,
defaultLocaleKeys,
localeKeys,
}
}),
)
// Process results and collect missing keys
for (const result of namespaceResults) {
const { namespace, defaultLocaleKeys, localeKeys } = result
namespaceKeys[namespace] = localeKeys
for (const [key, value] of Object.entries(defaultLocaleKeys)) {
if (!localeKeys[key]) {
if (allMissingKeys[key]) {
allMissingKeys[key].namespaces.push(namespace)
} else {
allMissingKeys[key] = {
value,
namespaces: [namespace],
}
}
}
}
}
const missingKeysList = Object.keys(allMissingKeys)
if (missingKeysList.length === 0) {
localeResults[locale].status = "done"
currentlyProcessing.delete(locale)
completedCount++
updateProgressDisplay()
return
}
// Check for existing translations
const keysToTranslate: Record<string, string> = {}
const existingTranslations: Record<string, string> = {}
const existingTranslationResults = await findExistingTranslations(
missingKeysList,
namespaces,
locale,
loadPath,
{ silent: true },
)
for (const key of missingKeysList) {
const existingValue = existingTranslationResults[key]
if (existingValue !== null) {
existingTranslations[key] = existingValue
localeResults[locale].reused++
} else {
keysToTranslate[key] = allMissingKeys[key].value
}
}
let translatedValues: Record<string, string> = {}
// Translate if needed
if (Object.keys(keysToTranslate).length > 0) {
try {
localeResults[locale].status = "translating"
localeResults[locale].translationCompleted = 0
localeResults[locale].translationTotal =
Object.keys(keysToTranslate).length
updateProgressDisplay()
translatedValues = await translateKey({
inputLanguage: defaultLocale,
outputLanguage: locale,
context,
object: keysToTranslate,
openai,
model: config.model,
onProgress: (completed, total) => {
localeResults[locale].translationCompleted = completed
localeResults[locale].translationTotal = total
updateProgressDisplay()
},
})
localeResults[locale].translationCompleted =
Object.keys(keysToTranslate).length
localeResults[locale].translated =
Object.keys(translatedValues).length
} catch (error) {
localeResults[locale].status = "error"
localeResults[locale].error =
error instanceof Error ? error.message : "Translation failed"
currentlyProcessing.delete(locale)
completedCount++
updateProgressDisplay()
return
}
}
// Combine and save
const allTranslations = { ...existingTranslations, ...translatedValues }
await Promise.all(
namespaces.map(async (namespace) => {
let hasChanges = false
const updatedKeys = { ...namespaceKeys[namespace] }
for (const key of missingKeysList) {
if (allMissingKeys[key].namespaces.includes(namespace)) {
const translation = allTranslations[key] || ""
updatedKeys[key] = translation
hasChanges = true
}
}
if (hasChanges) {
await writeLocalesFile(savePath, locale, namespace, updatedKeys)
}
}),
)
localeResults[locale].status = "done"
currentlyProcessing.delete(locale)
completedCount++
updateProgressDisplay()
} catch (error) {
localeResults[locale].status = "error"
localeResults[locale].error =
error instanceof Error ? error.message : "Unknown error"
currentlyProcessing.delete(locale)
completedCount++
updateProgressDisplay()
}
}
// Suppress logs during processing
console.log = suppressedLog
// Process locales in batches
for (let i = 0; i < localesToProcess.length; i += BATCH_SIZE) {
const batch = localesToProcess.slice(i, i + BATCH_SIZE)
await Promise.all(batch.map((locale) => processLocale(locale)))
}
// Restore console.log
console.log = originalConsoleLog
progressBars.stop()
// Print summary
console.log("")
console.log(chalk.green("⨠Sync complete!\n"))
// Count totals
let totalTranslated = 0
let totalReused = 0
let totalErrors = 0
for (const locale of localesToProcess) {
const result = localeResults[locale]
totalTranslated += result.translated
totalReused += result.reused
if (result.status === "error") totalErrors++
}
// Show summary table
console.log(chalk.dim(" Results by language:\n"))
// Group results into columns for compact display
const doneLocales = localesToProcess.filter(
(l) => localeResults[l].status === "done",
)
const errorLocales = localesToProcess.filter(
(l) => localeResults[l].status === "error",
)
// Show successful languages in a compact grid
if (doneLocales.length > 0) {
const columns = 3
const rows = Math.ceil(doneLocales.length / columns)
for (let row = 0; row < rows; row++) {
let line = " "
for (let col = 0; col < columns; col++) {
const idx = row + col * rows
if (idx < doneLocales.length) {
const locale = doneLocales[idx]
const result = localeResults[locale]
const label = getLanguageLabel(locale).substring(0, 14).padEnd(14)
const count =
result.translated > 0
? chalk.cyan(`+${result.translated}`.padStart(5))
: chalk.dim(`${result.reused}`.padStart(5))
line += `${chalk.green("ā")} ${label}${count} `
}
}
console.log(line)
}
}
// Show errors if any
if (errorLocales.length > 0) {
console.log("")
console.log(chalk.red(" Errors:"))
for (const locale of errorLocales) {
const result = localeResults[locale]
console.log(
chalk.red(` ā ${getLanguageLabel(locale)}: ${result.error}`),
)
}
}
console.log("")
console.log(chalk.dim(" Summary:"))
console.log(
chalk.dim(` ⢠${chalk.white(totalTranslated)} keys translated`),
)
console.log(chalk.dim(` ⢠${chalk.white(totalReused)} keys reused`))
if (totalErrors > 0) {
console.log(chalk.dim(` ⢠${chalk.red(totalErrors)} languages failed`))
}
console.log("")
} catch (error) {
// Restore console.log
console.log = originalConsoleLog
progressBars.stop()
if (error instanceof TranslationError) {
throw error
}
throw new TranslationError(
"An unexpected error occurred during translation",
undefined,
undefined,
error instanceof Error ? error : undefined,
)
}
}