UNPKG

tr-file

Version:

A fast command-line tool and TypeScript/JavaScript library for translating JSON files using Google Translate API. Features structure preservation, placeholder protection, batch translation, recursive search, incremental updates, array support, and program

381 lines (326 loc) 12.9 kB
const fs = require('fs').promises; const path = require('path'); const axios = require('axios'); const chalk = require('chalk'); const ora = require('ora'); class TranslateCommand { constructor(options) { this.sourceFile = options.sourceFile; this.targetLanguages = options.targetLanguages; this.sourceLanguage = options.sourceLanguage; // Can be null for auto-detection this.apiKey = options.apiKey || process.env.GOOGLE_TRANSLATE_API_KEY || 'AIzaSyBOti4mM-6x9WDnZIjIeyEU21OpBXqWBgw'; this.delay = options.delay || 50; this.baseUrl = 'https://translation.googleapis.com/language/translate/v2'; this.detectUrl = 'https://translation.googleapis.com/language/translate/v2/detect'; if (!this.apiKey) { throw new Error('Google Translate API key is required. Use -k flag or set GOOGLE_TRANSLATE_API_KEY environment variable.'); } } async execute() { try { console.log(chalk.blue('🌍 Starting translation process...')); // Load source file const sourceData = await this.loadSourceFile(); console.log(chalk.green(`✓ Loaded source file: ${path.basename(this.sourceFile)}`)); // Extract all translatable strings const strings = this.extractStrings(sourceData); const uniqueStrings = [...new Set(strings.map(s => s.text))]; console.log(chalk.green(`✓ Found ${strings.length} strings to translate (${uniqueStrings.length} unique)`)); // Detect or validate source language let detectedSourceLang = this.sourceLanguage; if (!detectedSourceLang) { // Auto-detect source language using first few strings const sampleTexts = uniqueStrings.slice(0, 3); detectedSourceLang = await this.detectLanguage(sampleTexts); console.log(chalk.cyan(`🔍 Auto-detected source language: ${detectedSourceLang.toUpperCase()}`)); } else { console.log(chalk.cyan(`📝 Using specified source language: ${detectedSourceLang.toUpperCase()}`)); } // Filter out target languages that are the same as source const filteredTargetLanguages = this.targetLanguages.filter(lang => lang.toLowerCase() !== detectedSourceLang.toLowerCase() ); if (filteredTargetLanguages.length !== this.targetLanguages.length) { const skipped = this.targetLanguages.length - filteredTargetLanguages.length; console.log(chalk.yellow(`⚠️ Skipped ${skipped} target language(s) that match source language`)); } if (filteredTargetLanguages.length === 0) { console.log(chalk.yellow('⚠️ No target languages to translate to after filtering')); return; } // Translate to each target language for (const language of filteredTargetLanguages) { await this.translateToLanguage(sourceData, strings, language, detectedSourceLang); } console.log(chalk.green.bold('🎉 Translation completed successfully!')); } catch (error) { console.error(chalk.red('❌ Translation failed:'), error.message); throw error; } } async loadSourceFile() { try { const content = await fs.readFile(this.sourceFile, 'utf8'); return JSON.parse(content); } catch (error) { if (error.code === 'ENOENT') { throw new Error(`Source file not found: ${this.sourceFile}`); } if (error instanceof SyntaxError) { throw new Error(`Invalid JSON in source file: ${this.sourceFile}`); } throw error; } } async detectLanguage(texts) { try { const sampleText = texts.slice(0, 3).join(' '); const response = await axios.post( `${this.detectUrl}?key=${this.apiKey}`, { q: sampleText }, { headers: { 'Content-Type': 'application/json' }, timeout: 10000 } ); if (response.data && response.data.data && response.data.data.detections) { const detection = response.data.data.detections[0][0]; return detection.language; } else { throw new Error('Unable to detect language'); } } catch (error) { console.log(chalk.yellow('⚠️ Language detection failed, defaulting to English (en)')); return 'en'; // Default fallback } } extractStrings(obj, prefix = '') { const strings = []; for (const [key, value] of Object.entries(obj)) { const currentPath = prefix ? `${prefix}.${key}` : key; if (typeof value === 'string') { strings.push({ path: currentPath, text: value }); } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) { // Only recurse if it's a true nested object (not a flat key with dots) strings.push(...this.extractStrings(value, currentPath)); } } return strings; } async translateToLanguage(sourceData, strings, targetLanguage, sourceLanguage) { const spinner = ora(`Translating from ${sourceLanguage.toUpperCase()} to ${targetLanguage.toUpperCase()}...`).start(); try { const outputFile = this.getOutputFilePath(targetLanguage); // Load existing translation file if it exists let existingData = {}; try { const existingContent = await fs.readFile(outputFile, 'utf8'); existingData = JSON.parse(existingContent); spinner.text = `Found existing ${targetLanguage.toUpperCase()} file, merging translations...`; } catch (error) { // File doesn't exist or is invalid, start fresh spinner.text = `Creating new ${targetLanguage.toUpperCase()} translation file...`; } // Use the TranslateAPI class for consistent translation behavior const TranslateAPI = require('./translate-api'); const translateAPI = new TranslateAPI({ apiKey: this.apiKey, sourceLanguage: sourceLanguage, delay: this.delay, verbose: true // Enable verbose for API request logging }); // Get the translated data using the API (preserves structure) const translationResult = await translateAPI.translateJSON(sourceData, [targetLanguage]); const translatedData = translationResult[targetLanguage]; // Merge with existing data if any const mergedData = this.deepMerge(existingData, translatedData); // Count new vs existing translations const existingKeys = this.countKeys(existingData); const mergedKeys = this.countKeys(mergedData); const newKeys = mergedKeys - existingKeys; // Only write and report if there are new keys if (newKeys > 0) { await fs.writeFile(outputFile, JSON.stringify(mergedData, null, 2), 'utf8'); spinner.succeed(`${targetLanguage.toUpperCase()} translation completed → ${path.basename(outputFile)} (${newKeys} new, ${existingKeys} existing)`); } else { spinner.succeed(`${targetLanguage.toUpperCase()} translation up to date → ${path.basename(outputFile)} (no new keys)`); } } catch (error) { spinner.fail(`Failed to translate to ${targetLanguage.toUpperCase()}`); throw error; } } async batchTranslateTexts(texts, targetLanguage, sourceLanguage) { const MAX_BATCH_SIZE = 128; // Google Translate API limit const MAX_CHARS_PER_REQUEST = 30000; // Conservative limit to avoid hitting API limits const results = []; let currentBatch = []; let currentBatchSize = 0; for (const text of texts) { const textLength = text.length; // If adding this text would exceed limits, process current batch if (currentBatch.length >= MAX_BATCH_SIZE || currentBatchSize + textLength > MAX_CHARS_PER_REQUEST) { if (currentBatch.length > 0) { const batchResults = await this.translateBatch(currentBatch, targetLanguage, sourceLanguage); results.push(...batchResults); currentBatch = []; currentBatchSize = 0; // Add delay between batches if (currentBatch.length > 0) { await this.sleep(this.delay); } } } currentBatch.push(text); currentBatchSize += textLength; } // Process final batch if (currentBatch.length > 0) { const batchResults = await this.translateBatch(currentBatch, targetLanguage, sourceLanguage); results.push(...batchResults); } return results; } async translateBatch(texts, targetLanguage, sourceLanguage) { try { const requestBody = { q: texts, target: targetLanguage, format: 'text' }; // Add source language if provided if (sourceLanguage) { requestBody.source = sourceLanguage; } const response = await axios.post( `${this.baseUrl}?key=${this.apiKey}`, requestBody, { headers: { 'Content-Type': 'application/json' }, timeout: 30000 // Longer timeout for batch requests } ); if (response.data && response.data.data && response.data.data.translations) { return response.data.data.translations.map(t => t.translatedText); } else { throw new Error('Invalid response from Google Translate API'); } } catch (error) { if (error.response) { throw new Error(`API Error: ${error.response.status} - ${error.response.data?.error?.message || 'Unknown error'}`); } else if (error.request) { throw new Error('Network error: Unable to reach Google Translate API'); } else { throw new Error(`Translation error: ${error.message}`); } } } async translateString(text, targetLanguage) { try { const response = await axios.post( `${this.baseUrl}?key=${this.apiKey}`, { q: text, target: targetLanguage }, { headers: { 'Content-Type': 'application/json' }, timeout: 10000 } ); if (response.data && response.data.data && response.data.data.translations) { return response.data.data.translations[0].translatedText; } else { throw new Error('Invalid response from Google Translate API'); } } catch (error) { if (error.response) { throw new Error(`API Error: ${error.response.status} - ${error.response.data?.error?.message || 'Unknown error'}`); } else if (error.request) { throw new Error('Network error: Unable to reach Google Translate API'); } else { throw new Error(`Translation error: ${error.message}`); } } } setNestedValue(obj, path, value) { // Check if this is a flat key (exists directly in the object) if (obj.hasOwnProperty(path)) { obj[path] = value; return; } // Handle nested object paths const keys = path.split('.'); let current = obj; for (let i = 0; i < keys.length - 1; i++) { if (!(keys[i] in current)) { current[keys[i]] = {}; } current = current[keys[i]]; } current[keys[keys.length - 1]] = value; } getOutputFilePath(language) { const sourceDir = path.dirname(this.sourceFile); const sourceExt = path.extname(this.sourceFile); return path.join(sourceDir, `${language}${sourceExt}`); } deepMerge(existing, newData) { if (!existing || typeof existing !== 'object') { return newData; } if (!newData || typeof newData !== 'object') { return existing; } const result = { ...existing }; for (const [key, value] of Object.entries(newData)) { if (Array.isArray(value)) { // For arrays, replace entirely (preserving array structure) result[key] = value; } else if (value && typeof value === 'object' && !Array.isArray(value)) { // For objects, merge recursively result[key] = this.deepMerge(result[key], value); } else { // For primitives, only add if not already present if (!(key in result)) { result[key] = value; } } } return result; } countKeys(obj, count = 0) { if (!obj || typeof obj !== 'object') { return count; } for (const [key, value] of Object.entries(obj)) { if (typeof value === 'string') { count++; } else if (Array.isArray(value)) { // Count each array element that contains strings for (const item of value) { count += this.countKeys(item); } } else if (typeof value === 'object' && value !== null) { count += this.countKeys(value); } } return count; } sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } } module.exports = TranslateCommand;