- Fixed unused function parameters by prefixing with underscore - Removed unused imports and variables - Applied eslint --fix for automatic style fixes - Property shorthand - String template literals - Prefer const over let where appropriate - Spacing and formatting Reduces lint errors from 108+ to 78 (61 unused vars, 17 other issues) Related to CI lint failures in previous commit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
357 lines
10 KiB
JavaScript
357 lines
10 KiB
JavaScript
/**
|
|
* Translation Service using DeepL API
|
|
* Sovereign translation service for Tractatus multilingual submissions
|
|
* Based on proven family-history implementation
|
|
*/
|
|
|
|
const axios = require('axios');
|
|
const crypto = require('crypto');
|
|
const NodeCache = require('node-cache');
|
|
const logger = require('../utils/logger.util');
|
|
|
|
class TranslationService {
|
|
constructor() {
|
|
// DeepL API configuration
|
|
this.apiUrl = process.env.DEEPL_API_URL || 'https://api-free.deepl.com/v2';
|
|
this.apiKey = process.env.DEEPL_API_KEY;
|
|
|
|
// Cache translations for 24 hours to reduce API calls
|
|
this.cache = new NodeCache({ stdTTL: 86400 });
|
|
|
|
// Supported language pairs (as per Tractatus SubmissionTracking schema)
|
|
this.supportedLanguages = {
|
|
'en': 'English',
|
|
'fr': 'French',
|
|
'de': 'German',
|
|
'es': 'Spanish',
|
|
'pt': 'Portuguese',
|
|
'zh': 'Chinese',
|
|
'ja': 'Japanese',
|
|
'ar': 'Arabic',
|
|
'mi': 'Te Reo Māori (not supported by DeepL - fallback to EN)',
|
|
'it': 'Italian',
|
|
'nl': 'Dutch',
|
|
'pl': 'Polish',
|
|
'ru': 'Russian'
|
|
};
|
|
|
|
// Track service availability
|
|
this.isAvailable = false;
|
|
this.lastHealthCheck = null;
|
|
|
|
// Check service availability on startup
|
|
this.checkAvailability();
|
|
}
|
|
|
|
/**
|
|
* Check if DeepL service is available
|
|
*/
|
|
async checkAvailability() {
|
|
try {
|
|
if (!this.apiKey || this.apiKey === 'your-deepl-api-key-here') {
|
|
logger.warn('[Translation] DeepL API key not configured');
|
|
logger.warn('[Translation] Set DEEPL_API_KEY in .env file');
|
|
this.isAvailable = false;
|
|
return false;
|
|
}
|
|
|
|
// Test with a simple translation
|
|
const response = await axios.post(`${this.apiUrl}/translate`,
|
|
`auth_key=${encodeURIComponent(this.apiKey)}&text=${encodeURIComponent('test')}&target_lang=FR`,
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded'
|
|
},
|
|
timeout: 5000
|
|
}
|
|
);
|
|
|
|
this.isAvailable = true;
|
|
this.lastHealthCheck = new Date();
|
|
|
|
logger.info('✅ DeepL translation service available');
|
|
return true;
|
|
} catch (error) {
|
|
this.isAvailable = false;
|
|
logger.warn(`[Translation] DeepL service not available: ${error.message}`);
|
|
if (error.response?.status === 403) {
|
|
logger.error('[Translation] Invalid DeepL API key');
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate cache key for translation
|
|
*/
|
|
getCacheKey(text, sourceLang, targetLang) {
|
|
const hash = crypto.createHash('md5')
|
|
.update(`${sourceLang}:${targetLang}:${text}`)
|
|
.digest('hex');
|
|
return `trans_${hash}`;
|
|
}
|
|
|
|
/**
|
|
* Translate text between languages
|
|
* @param {string} text - Text to translate
|
|
* @param {string} sourceLang - Source language code (e.g., 'en')
|
|
* @param {string} targetLang - Target language code (e.g., 'fr')
|
|
* @param {Object} options - Additional options
|
|
* @returns {Object} Translation result
|
|
*/
|
|
async translate(text, sourceLang, targetLang, options = {}) {
|
|
// Quick validation
|
|
if (!text || sourceLang === targetLang) {
|
|
return {
|
|
translatedText: text,
|
|
cached: false,
|
|
sourceLang,
|
|
targetLang
|
|
};
|
|
}
|
|
|
|
// Check service availability (with periodic recheck)
|
|
if (!this.isAvailable ||
|
|
!this.lastHealthCheck ||
|
|
Date.now() - this.lastHealthCheck > 300000) { // 5 minutes
|
|
await this.checkAvailability();
|
|
}
|
|
|
|
if (!this.isAvailable) {
|
|
logger.error('[Translation] DeepL service not available');
|
|
return {
|
|
translatedText: text,
|
|
cached: false,
|
|
sourceLang,
|
|
targetLang,
|
|
error: true,
|
|
errorMessage: 'DeepL translation service not available. Please check DEEPL_API_KEY.'
|
|
};
|
|
}
|
|
|
|
// Check cache first
|
|
const cacheKey = this.getCacheKey(text, sourceLang, targetLang);
|
|
const cached = this.cache.get(cacheKey);
|
|
if (cached && !options.skipCache) {
|
|
return {
|
|
translatedText: cached,
|
|
cached: true,
|
|
sourceLang,
|
|
targetLang
|
|
};
|
|
}
|
|
|
|
try {
|
|
// Prepare parameters for DeepL API
|
|
const params = new URLSearchParams();
|
|
params.append('auth_key', this.apiKey);
|
|
params.append('text', text);
|
|
params.append('target_lang', targetLang.toUpperCase());
|
|
|
|
// Only add source_lang if not auto-detecting
|
|
if (sourceLang && sourceLang !== 'auto') {
|
|
params.append('source_lang', sourceLang.toUpperCase());
|
|
}
|
|
|
|
// Add HTML handling if needed
|
|
if (options.format === 'html') {
|
|
params.append('tag_handling', 'html');
|
|
params.append('preserve_formatting', '1');
|
|
}
|
|
|
|
// Add formality setting (more formal for publications)
|
|
if (options.formality) {
|
|
params.append('formality', options.formality); // 'default', 'more', or 'less'
|
|
} else {
|
|
params.append('formality', 'more'); // Default to formal for submissions
|
|
}
|
|
|
|
// Make translation request
|
|
const response = await axios.post(`${this.apiUrl}/translate`, params.toString(), {
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded'
|
|
},
|
|
timeout: 30000 // 30 second timeout
|
|
});
|
|
|
|
const translation = response.data.translations[0];
|
|
const translatedText = translation.text;
|
|
const detectedLang = translation.detected_source_language;
|
|
|
|
// Cache the result
|
|
if (translatedText && !options.skipCache) {
|
|
this.cache.set(cacheKey, translatedText);
|
|
}
|
|
|
|
logger.info(`[Translation] ${sourceLang} → ${targetLang}: ${text.substring(0, 50)}... (${translatedText.length} chars)`);
|
|
|
|
return {
|
|
translatedText,
|
|
cached: false,
|
|
sourceLang: detectedLang ? detectedLang.toLowerCase() : sourceLang,
|
|
targetLang,
|
|
detectedLang: detectedLang ? detectedLang.toLowerCase() : null
|
|
};
|
|
|
|
} catch (error) {
|
|
logger.error('[Translation] DeepL API error:', error.message);
|
|
if (error.response) {
|
|
logger.error('[Translation] Response status:', error.response.status);
|
|
logger.error('[Translation] Response data:', error.response.data);
|
|
}
|
|
|
|
// Return original text with error flag
|
|
return {
|
|
translatedText: text,
|
|
error: true,
|
|
errorMessage: `Translation failed: ${error.message}`,
|
|
cached: false,
|
|
sourceLang,
|
|
targetLang
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Translate multiple texts in batch
|
|
* @param {Array} texts - Array of texts to translate
|
|
* @param {string} sourceLang - Source language
|
|
* @param {string} targetLang - Target language
|
|
* @returns {Array} Array of translation results
|
|
*/
|
|
async translateBatch(texts, sourceLang, targetLang) {
|
|
if (!Array.isArray(texts) || texts.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
// DeepL supports up to 50 texts per request
|
|
const batchSize = 50;
|
|
const results = [];
|
|
|
|
for (let i = 0; i < texts.length; i += batchSize) {
|
|
const batch = texts.slice(i, i + batchSize);
|
|
|
|
try {
|
|
// Prepare parameters
|
|
const params = new URLSearchParams();
|
|
params.append('auth_key', this.apiKey);
|
|
|
|
// Add each text as a separate parameter
|
|
batch.forEach(text => params.append('text', text));
|
|
|
|
params.append('target_lang', targetLang.toUpperCase());
|
|
if (sourceLang && sourceLang !== 'auto') {
|
|
params.append('source_lang', sourceLang.toUpperCase());
|
|
}
|
|
params.append('formality', 'more'); // Formal for submissions
|
|
|
|
const response = await axios.post(`${this.apiUrl}/translate`, params.toString(), {
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded'
|
|
},
|
|
timeout: 60000 // 60 seconds for batch
|
|
});
|
|
|
|
const translations = response.data.translations.map(t => ({
|
|
translatedText: t.text,
|
|
detectedLang: t.detected_source_language ? t.detected_source_language.toLowerCase() : null,
|
|
cached: false,
|
|
sourceLang: t.detected_source_language ? t.detected_source_language.toLowerCase() : sourceLang,
|
|
targetLang
|
|
}));
|
|
|
|
results.push(...translations);
|
|
} catch (error) {
|
|
logger.error('[Translation] Batch translation error:', error.message);
|
|
// Return original texts for failed batch
|
|
results.push(...batch.map(text => ({
|
|
translatedText: text,
|
|
error: true,
|
|
errorMessage: error.message,
|
|
cached: false,
|
|
sourceLang,
|
|
targetLang
|
|
})));
|
|
}
|
|
|
|
// Small delay between batches to respect rate limits
|
|
if (i + batchSize < texts.length) {
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Get DeepL API usage statistics
|
|
*/
|
|
async getUsageStats() {
|
|
if (!this.isAvailable) {
|
|
return { error: 'Service not available' };
|
|
}
|
|
|
|
try {
|
|
const response = await axios.get(`${this.apiUrl}/usage`, {
|
|
params: { auth_key: this.apiKey },
|
|
timeout: 5000
|
|
});
|
|
|
|
return {
|
|
characterCount: response.data.character_count,
|
|
characterLimit: response.data.character_limit,
|
|
percentageUsed: ((response.data.character_count / response.data.character_limit) * 100).toFixed(2)
|
|
};
|
|
} catch (error) {
|
|
logger.error('[Translation] Usage check error:', error.message);
|
|
return { error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get cache statistics
|
|
*/
|
|
getCacheStats() {
|
|
return {
|
|
keys: this.cache.keys().length,
|
|
hits: this.cache.getStats().hits,
|
|
misses: this.cache.getStats().misses
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Clear translation cache
|
|
*/
|
|
clearCache() {
|
|
this.cache.flushAll();
|
|
logger.info('[Translation] Cache cleared');
|
|
}
|
|
|
|
/**
|
|
* Get service status
|
|
*/
|
|
getStatus() {
|
|
return {
|
|
available: this.isAvailable,
|
|
lastHealthCheck: this.lastHealthCheck,
|
|
apiUrl: this.apiUrl,
|
|
cacheStats: this.getCacheStats(),
|
|
supportedLanguages: this.supportedLanguages,
|
|
service: 'DeepL',
|
|
apiKeyConfigured: !!(this.apiKey && this.apiKey !== 'your-deepl-api-key-here')
|
|
};
|
|
}
|
|
}
|
|
|
|
// Create singleton instance
|
|
let instance = null;
|
|
|
|
module.exports = {
|
|
getInstance: () => {
|
|
if (!instance) {
|
|
instance = new TranslationService();
|
|
}
|
|
return instance;
|
|
},
|
|
TranslationService
|
|
};
|