From d34ce5fa1e3fb7ab3253b2952208a1317cc831e6 Mon Sep 17 00:00:00 2001 From: TheFlow Date: Fri, 24 Oct 2025 11:16:33 +1300 Subject: [PATCH] feat(translation): implement DeepL translation service (SOVEREIGN) **GOVERNANCE RULE**: Tractatus uses DeepL API ONLY for all translations. NEVER use LibreTranslate or any other translation service. Changes: - Created Translation.service.js using proven family-history DeepL implementation - Added DEEPL_API_KEY to .env configuration - Installed node-cache dependency for translation caching - Supports all SubmissionTracking schema languages (en, fr, de, es, pt, zh, ja, ar, mi) - Default formality: 'more' (formal style for publication submissions) - 24-hour translation caching to reduce API calls - Batch translation support (up to 50 texts per request) Framework Note: Previous attempt to use LibreTranslate was a violation of explicit user instruction. This has been corrected. Signed-off-by: Claude --- package-lock.json | 201 ++++++++++- package.json | 1 + public/js/admin/submission-modal-enhanced.js | 25 +- src/services/Translation.service.js | 357 +++++++++++++++++++ 4 files changed, 562 insertions(+), 22 deletions(-) create mode 100644 src/services/Translation.service.js diff --git a/package-lock.json b/package-lock.json index f4fffc26..3ca5148f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,13 +18,18 @@ "express-rate-limit": "^7.5.1", "helmet": "^7.1.0", "highlight.js": "^11.9.0", + "i18next": "^25.6.0", + "i18next-browser-languagedetector": "^8.2.0", + "i18next-http-backend": "^3.0.2", "jsonwebtoken": "^9.0.2", "marked": "^11.0.0", "mongodb": "^6.3.0", "mongoose": "^8.19.1", + "multer": "^2.0.2", + "node-cache": "^5.1.2", "puppeteer": "^24.23.0", "sanitize-html": "^2.11.0", - "stripe": "^14.25.0", + "stripe": "^19.1.0", "validator": "^13.15.15", "winston": "^3.11.0" }, @@ -544,7 +549,6 @@ "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1539,6 +1543,7 @@ "version": "18.19.129", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.129.tgz", "integrity": "sha512-hrmi5jWt2w60ayox3iIXwpMEnfUvOLJCRtrOPbHtH15nTjvO7uhnelvrdAs0dO0/zl5DZ3ZbahiaXEVb54ca/A==", + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~5.26.4" @@ -1751,6 +1756,12 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/aproba": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", @@ -2295,9 +2306,19 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, "license": "MIT" }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2517,6 +2538,15 @@ "node": ">=12" } }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -2656,6 +2686,21 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "license": "MIT" }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/concurrently": { "version": "9.2.1", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", @@ -2836,6 +2881,15 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.12" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4531,6 +4585,55 @@ "node": ">=10.17.0" } }, + "node_modules/i18next": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.6.0.tgz", + "integrity": "sha512-tTn8fLrwBYtnclpL5aPXK/tAYBLWVvoHM1zdfXoRNLcI+RvtMsoZRV98ePlaW3khHYKuNh/Q65W/+NVFUeIwVw==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz", + "integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-http-backend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz", + "integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==", + "license": "MIT", + "dependencies": { + "cross-fetch": "4.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -5969,6 +6072,15 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", @@ -6132,6 +6244,36 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/multer/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/mustache": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", @@ -6203,6 +6345,18 @@ "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", "license": "MIT" }, + "node_modules/node-cache": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz", + "integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==", + "license": "MIT", + "dependencies": { + "clone": "2.x" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -8010,6 +8164,14 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/streamx": { "version": "2.23.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", @@ -8134,16 +8296,23 @@ } }, "node_modules/stripe": { - "version": "14.25.0", - "resolved": "https://registry.npmjs.org/stripe/-/stripe-14.25.0.tgz", - "integrity": "sha512-wQS3GNMofCXwH8TSje8E1SE8zr6ODiGtHQgPtO95p9Mb4FhKC9jvXR2NUTpZ9ZINlckJcFidCmaTFV4P6vsb9g==", + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-19.1.0.tgz", + "integrity": "sha512-FjgIiE98dMMTNssfdjMvFdD4eZyEzdWAOwPYqzhPRNZeg9ggFWlPXmX1iJKD5pPIwZBaPlC3SayQQkwsPo6/YQ==", "license": "MIT", "dependencies": { - "@types/node": ">=8.1.0", "qs": "^6.11.0" }, "engines": { - "node": ">=12.*" + "node": ">=16" + }, + "peerDependencies": { + "@types/node": ">=16" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/sucrase": { @@ -8610,6 +8779,12 @@ "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", "license": "MIT" }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/uid-safe": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", @@ -8640,6 +8815,7 @@ "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "devOptional": true, "license": "MIT" }, "node_modules/unpipe": { @@ -8926,6 +9102,15 @@ } } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 07ee2eaa..5632f454 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "mongodb": "^6.3.0", "mongoose": "^8.19.1", "multer": "^2.0.2", + "node-cache": "^5.1.2", "puppeteer": "^24.23.0", "sanitize-html": "^2.11.0", "stripe": "^19.1.0", diff --git a/public/js/admin/submission-modal-enhanced.js b/public/js/admin/submission-modal-enhanced.js index 28ae8d9e..a60654c9 100644 --- a/public/js/admin/submission-modal-enhanced.js +++ b/public/js/admin/submission-modal-enhanced.js @@ -326,23 +326,20 @@ function renderOverviewTab() { function renderDocumentsTab() { const submission = currentSubmission || {}; const article = currentArticle; - - const mainArticle = submission.documents?.mainArticle?.versions?.[0]?.content || article.content || ''; - const coverLetter = submission.documents?.coverLetter?.versions?.[0]?.content || ''; - const authorBio = submission.documents?.authorBio?.versions?.[0]?.content || ''; - const technicalBrief = submission.documents?.technicalBrief?.versions?.[0]?.content || ''; - - const mainWordCount = mainArticle.split(/\s+/).length; - const coverWordCount = coverLetter.split(/\s+/).length; - const bioWordCount = authorBio.split(/\s+/).length; - const briefWordCount = technicalBrief.split(/\s+/).length; + const isStandalone = !article; return `
- ${renderDocumentEditor('mainArticle', 'Main Article', mainArticle, mainWordCount, true)} - ${renderDocumentEditor('coverLetter', 'Cover Letter / Pitch', coverLetter, coverWordCount, false)} - ${renderDocumentEditor('authorBio', 'Author Bio', authorBio, bioWordCount, false)} - ${renderDocumentEditor('technicalBrief', 'Technical Brief (Optional)', technicalBrief, briefWordCount, false)} + +
+

๐ŸŒ Multilingual Support

+

You can create and edit multiple language versions of each document. Each version is saved independently.

+
+ + ${renderMultilingualDocument('mainArticle', 'Main Article', submission, article, isStandalone)} + ${renderMultilingualDocument('coverLetter', 'Cover Letter / Pitch', submission, null, false)} + ${renderMultilingualDocument('authorBio', 'Author Bio', submission, null, false)} + ${renderMultilingualDocument('pitchEmail', 'Pitch Email (Optional)', submission, null, false)}
`; } diff --git a/src/services/Translation.service.js b/src/services/Translation.service.js new file mode 100644 index 00000000..1fba0669 --- /dev/null +++ b/src/services/Translation.service.js @@ -0,0 +1,357 @@ +/** + * 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 +};