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

606 lines (509 loc) 21 kB
const axios = require('axios'); const chalk = require('chalk'); class TranslateAPI { constructor(options = {}) { this.apiKey = options.apiKey || process.env.GOOGLE_TRANSLATE_API_KEY || 'AIzaSyBOti4mM-6x9WDnZIjIeyEU21OpBXqWBgw'; this.sourceLanguage = options.sourceLanguage; this.delay = options.delay || 50; this.verbose = options.verbose !== false; // Default to true 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. Provide it in options.apiKey or set GOOGLE_TRANSLATE_API_KEY environment variable.'); } } /** * Translate a JSON object to multiple target languages * @param {Object} jsonData - The JSON object to translate * @param {string|Array} targetLanguages - Target language(s) (e.g., 'es' or ['es', 'fr', 'de']) * @param {Object} options - Additional options * @returns {Object} Object with translations for each target language */ async translateJSON(jsonData, targetLanguages, options = {}) { try { // Ensure targetLanguages is an array const langs = Array.isArray(targetLanguages) ? targetLanguages : [targetLanguages]; if (this.verbose) { console.log(chalk.blue('🌍 Starting JSON translation...')); } // Validate input if (!jsonData || typeof jsonData !== 'object') { throw new Error('Input must be a valid JSON object'); } if (langs.length === 0) { throw new Error('At least one target language must be specified'); } // Extract all translatable strings const strings = this.extractStrings(jsonData); const uniqueStrings = [...new Set(strings.map(s => s.text))]; if (this.verbose) { console.log(chalk.green(`✓ Found ${strings.length} strings to translate (${uniqueStrings.length} unique)`)); } // Detect or validate source language let detectedSourceLang = this.sourceLanguage; if (!detectedSourceLang && uniqueStrings.length > 0) { const sampleTexts = uniqueStrings.slice(0, 3); detectedSourceLang = await this.detectLanguage(sampleTexts); if (this.verbose) { console.log(chalk.cyan(`🔍 Auto-detected source language: ${detectedSourceLang.toUpperCase()}`)); } } else if (detectedSourceLang && this.verbose) { console.log(chalk.cyan(`📝 Using specified source language: ${detectedSourceLang.toUpperCase()}`)); } // Filter out target languages that are the same as source const filteredTargetLanguages = langs.filter(lang => lang.toLowerCase() !== (detectedSourceLang || 'en').toLowerCase() ); if (filteredTargetLanguages.length !== langs.length && this.verbose) { const skipped = langs.length - filteredTargetLanguages.length; console.log(chalk.yellow(`⚠️ Skipped ${skipped} target language(s) that match source language`)); } if (filteredTargetLanguages.length === 0) { if (this.verbose) { console.log(chalk.yellow('⚠️ No target languages to translate to after filtering')); } return {}; } // Translate to each target language const results = {}; for (const language of filteredTargetLanguages) { results[language] = await this.translateToLanguage(jsonData, strings, language, detectedSourceLang); } if (this.verbose) { console.log(chalk.green.bold('🎉 JSON translation completed successfully!')); } return results; } catch (error) { if (this.verbose) { console.error(chalk.red('❌ Translation failed:'), error.message); } throw error; } } /** * Translate specific strings to a target language * @param {Array} strings - Array of strings to translate * @param {string} targetLanguage - Target language code * @param {string} sourceLanguage - Source language code (optional) * @returns {Array} Array of translated strings */ async translateStrings(strings, targetLanguage, sourceLanguage = null) { if (!Array.isArray(strings) || strings.length === 0) { return []; } const uniqueStrings = [...new Set(strings)]; const translatedTexts = await this.batchTranslateTexts(uniqueStrings, targetLanguage, sourceLanguage); // Create a mapping from original to translated text const translationMap = new Map(); for (let i = 0; i < uniqueStrings.length; i++) { translationMap.set(uniqueStrings[i], translatedTexts[i]); } // Return translations in the same order as input return strings.map(str => translationMap.get(str) || str); } /** * Detect the language of given text(s) * @param {string|Array} texts - Text(s) to analyze * @returns {string} Detected language code */ async detectLanguage(texts) { try { const textArray = Array.isArray(texts) ? texts : [texts]; const sampleText = textArray.slice(0, 3).join(' '); // Log language detection API request if (this.verbose) { console.log(chalk.blue(`📡 Language Detection: ${sampleText.length} chars`)); } const response = await axios.post( `${this.detectUrl}?key=${this.apiKey}`, { q: sampleText }, { headers: { 'Content-Type': 'application/json' }, timeout: 10000 } ); if (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) { if (this.verbose) { console.log(chalk.yellow('⚠️ Language detection failed, defaulting to English (en)')); } return 'en'; // Default fallback } } async translateToLanguage(sourceData, strings, targetLanguage, sourceLanguage) { if (this.verbose) { console.log(chalk.blue(`Translating to ${targetLanguage.toUpperCase()}...`)); } // Extract unique texts that need translation const textsToTranslate = [...new Set(strings.map(s => s.text))]; if (textsToTranslate.length === 0) { return JSON.parse(JSON.stringify(sourceData)); // Deep copy } // Batch translate all texts const translatedTexts = await this.batchTranslateTexts(textsToTranslate, targetLanguage, sourceLanguage); // Create a mapping from original to translated text const translationMap = new Map(); for (let i = 0; i < textsToTranslate.length; i++) { translationMap.set(textsToTranslate[i], translatedTexts[i]); } // Start with a deep copy of the source data to preserve structure const translatedData = JSON.parse(JSON.stringify(sourceData)); // Apply translations only to translatable strings, preserving original structure // Sort so that flat keys are processed last (they take precedence) const sortedStrings = strings.sort((a, b) => { if (a.path === b.path) { return a.isFlatKey ? 1 : -1; // Flat keys come after nested keys } return 0; }); for (const { path: stringPath, text, isFlatKey } of sortedStrings) { const translatedText = translationMap.get(text) || text; // Use the original source data structure to determine how to set values this.setNestedValuePreservingStructure(translatedData, sourceData, stringPath, translatedText, isFlatKey); } if (this.verbose) { console.log(chalk.green(`✓ ${targetLanguage.toUpperCase()} translation completed (${textsToTranslate.length} strings)`)); } return translatedData; } extractStrings(obj, prefix = '', rootObj = null) { const strings = []; // Use rootObj to reference the original object for checking flat keys if (rootObj === null) { rootObj = obj; } if (Array.isArray(obj)) { // Handle arrays obj.forEach((item, index) => { const currentPath = prefix ? `${prefix}[${index}]` : `[${index}]`; if (typeof item === 'string') { strings.push({ path: currentPath, text: item }); } else if (typeof item === 'object' && item !== null) { strings.push(...this.extractStrings(item, currentPath, rootObj)); } }); } else if (typeof obj === 'object' && obj !== null) { // Handle objects for (const [key, value] of Object.entries(obj)) { const currentPath = prefix ? `${prefix}.${key}` : key; if (typeof value === 'string') { // Check if this is a flat key (exists at root level with dots in the key name) const isTopLevelFlatKey = !prefix && rootObj.hasOwnProperty(key); strings.push({ path: currentPath, text: value, isFlatKey: isTopLevelFlatKey }); } else if (Array.isArray(value)) { strings.push(...this.extractStrings(value, currentPath, rootObj)); } else if (typeof value === 'object' && value !== null) { strings.push(...this.extractStrings(value, currentPath, rootObj)); } } } return strings; } async batchTranslateTexts(texts, targetLanguage, sourceLanguage) { const MAX_BATCH_SIZE = 128; const MAX_CHARS_PER_REQUEST = 30000; const results = []; let currentBatch = []; let currentBatchSize = 0; for (const text of texts) { const textLength = text.length; 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; if (this.delay > 0) { await this.sleep(this.delay); } } } currentBatch.push(text); currentBatchSize += textLength; } if (currentBatch.length > 0) { const batchResults = await this.translateBatch(currentBatch, targetLanguage, sourceLanguage); results.push(...batchResults); } return results; } /** * Protect placeholders in curly braces from translation * @param {string} text - Text containing placeholders * @returns {Object} Object with protected text and placeholder map */ protectPlaceholders(text) { const placeholderMap = new Map(); const placeholderRegex = /\{[^}]+\}/g; let protectedText = text; let match; let index = 0; while ((match = placeholderRegex.exec(text)) !== null) { const placeholder = match[0]; const protectionToken = `__PLACEHOLDER_${index}__`; placeholderMap.set(protectionToken, placeholder); protectedText = protectedText.replace(placeholder, protectionToken); index++; } return { protectedText, placeholderMap }; } /** * Restore placeholders in translated text * @param {string} translatedText - Translated text with protection tokens * @param {Map} placeholderMap - Map of protection tokens to original placeholders * @returns {string} Text with restored placeholders */ restorePlaceholders(translatedText, placeholderMap) { let restoredText = translatedText; for (const [token, placeholder] of placeholderMap) { restoredText = restoredText.replace(new RegExp(token, 'g'), placeholder); } return restoredText; } async translateBatch(texts, targetLanguage, sourceLanguage) { try { // Protect placeholders in all texts const protectedTexts = texts.map(text => this.protectPlaceholders(text)); const textsToTranslate = protectedTexts.map(p => p.protectedText); // Calculate total characters being sent const totalChars = textsToTranslate.reduce((sum, text) => sum + text.length, 0); // Log API request details if (this.verbose) { console.log(chalk.blue(`📡 API Request: ${texts.length} strings, ${totalChars} chars → ${targetLanguage.toUpperCase()}`)); } const requestBody = { q: textsToTranslate, target: targetLanguage, format: 'text' }; if (sourceLanguage) { requestBody.source = sourceLanguage; } const response = await axios.post( `${this.baseUrl}?key=${this.apiKey}`, requestBody, { headers: { 'Content-Type': 'application/json' }, timeout: 30000 } ); if (response.data?.data?.translations) { const translatedTexts = response.data.data.translations.map(t => t.translatedText); // Restore placeholders in translated texts return translatedTexts.map((translatedText, index) => { const { placeholderMap } = protectedTexts[index]; return this.restorePlaceholders(translatedText, placeholderMap); }); } 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) { // First, check if this is a flat key that exists directly in the source object // This preserves keys like "test.test" as literal keys rather than nested paths if (typeof obj === 'object' && obj !== null && obj.hasOwnProperty(path)) { obj[path] = value; return; } // Handle pure array index like "[0]", "[1]", etc. if (path.match(/^\[\d+\]$/)) { const index = parseInt(path.slice(1, -1)); while (obj.length <= index) { obj.push(null); } obj[index] = value; return; } // For complex paths, only proceed if the flat key doesn't already exist // This prevents "edge.cases" from being created as nested when it should be flat const pathParts = this.parsePath(path); // If this is a simple key (no arrays or complex nesting), check if it should be flat if (pathParts.length === 1 && pathParts[0].type === 'object') { // Don't create nested structure for simple dotted keys obj[path] = value; return; } // Handle complex paths with arrays and actual nesting let current = obj; for (let i = 0; i < pathParts.length - 1; i++) { const part = pathParts[i]; if (part.type === 'array') { if (part.key && !Array.isArray(current[part.key])) { current[part.key] = []; } const targetArray = part.key ? current[part.key] : current; // Ensure array has enough elements while (targetArray.length <= part.index) { targetArray.push(null); } if (targetArray[part.index] === null || targetArray[part.index] === undefined) { // Determine if next part is array or object const nextPart = pathParts[i + 1]; targetArray[part.index] = nextPart && nextPart.type === 'array' ? [] : {}; } current = targetArray[part.index]; } else { if (!(part.key in current)) { // Determine if next part is array or object const nextPart = pathParts[i + 1]; current[part.key] = nextPart && nextPart.type === 'array' ? [] : {}; } current = current[part.key]; } } // Set the final value const finalPart = pathParts[pathParts.length - 1]; if (finalPart.type === 'array') { const targetArray = finalPart.key ? current[finalPart.key] : current; if (!Array.isArray(targetArray)) { return; // Skip if target is not an array } while (targetArray.length <= finalPart.index) { targetArray.push(null); } targetArray[finalPart.index] = value; } else { current[finalPart.key] = value; } } setNestedValuePreservingStructure(targetObj, sourceObj, path, value, isFlatKey = false) { // If this is marked as a flat key, treat it as such if (isFlatKey) { targetObj[path] = value; return; } // For nested paths, we need to navigate properly even if a flat key exists // This is the key difference - we use a custom navigation method this.setNestedValueForcePath(targetObj, path, value); } setNestedValueForcePath(obj, path, value) { // Handle pure array index like "[0]", "[1]", etc. if (path.match(/^\[\d+\]$/)) { const index = parseInt(path.slice(1, -1)); while (obj.length <= index) { obj.push(null); } obj[index] = value; return; } // Check if any part of the path exists as a flat key const pathParts = path.split('.'); for (let i = 1; i < pathParts.length; i++) { const flatKeyPath = pathParts.slice(0, i).join('.'); if (obj.hasOwnProperty(flatKeyPath)) { // Navigate into the flat key object and set the remaining path const remainingPath = pathParts.slice(i).join('.'); if (obj[flatKeyPath] && typeof obj[flatKeyPath] === 'object') { this.setNestedValue(obj[flatKeyPath], remainingPath, value); } return; } } // Handle complex paths with arrays and actual nesting const parsedParts = this.parsePath(path); let current = obj; for (let i = 0; i < parsedParts.length - 1; i++) { const part = parsedParts[i]; if (part.type === 'array') { if (part.key && !Array.isArray(current[part.key])) { current[part.key] = []; } const targetArray = part.key ? current[part.key] : current; // Ensure array has enough elements while (targetArray.length <= part.index) { targetArray.push(null); } // Navigate to the array element current = targetArray[part.index]; // Create object if it doesn't exist if (current === null || current === undefined) { current = {}; targetArray[part.index] = current; } } else { // Object navigation if (!current[part.key] || typeof current[part.key] !== 'object') { current[part.key] = {}; } current = current[part.key]; } } // Set the final value const finalPart = parsedParts[parsedParts.length - 1]; if (finalPart.type === 'array') { const targetArray = finalPart.key ? current[finalPart.key] : current; if (!Array.isArray(targetArray)) { return; // Skip if target is not an array } while (targetArray.length <= finalPart.index) { targetArray.push(null); } targetArray[finalPart.index] = value; } else { current[finalPart.key] = value; } } pathExistsInSource(obj, path) { // Check if this exact path exists as a direct property if (typeof obj === 'object' && obj !== null && obj.hasOwnProperty(path)) { return true; } // For complex paths, check if any parent level exists as a flat key const pathParts = path.split('.'); for (let i = 1; i < pathParts.length; i++) { const partialPath = pathParts.slice(0, i).join('.'); if (obj.hasOwnProperty(partialPath)) { // This parent exists as a flat key, so we should navigate into it // instead of creating a nested structure return true; } } return false; } parsePath(path) { const parts = []; const segments = path.split('.'); for (const segment of segments) { const arrayMatch = segment.match(/^(.+?)\[(\d+)\]$/); if (arrayMatch) { // Handle array access like "items[0]" parts.push({ type: 'object', key: arrayMatch[1] }); parts.push({ type: 'array', key: '', index: parseInt(arrayMatch[2]) }); } else if (segment.match(/^\[(\d+)\]$/)) { // Handle pure array access like "[0]" const index = parseInt(segment.slice(1, -1)); parts.push({ type: 'array', key: '', index }); } else { // Regular object key parts.push({ type: 'object', key: segment }); } } return parts; } sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } } module.exports = TranslateAPI;