diff --git a/README.md b/README.md index b5f8773..20976b3 100644 --- a/README.md +++ b/README.md @@ -469,6 +469,35 @@ npm run release patch -- --no-workflow-check By default the most recent 7 news entries are kept in `io-package.json`. Using this option, you can change the limit. +#### Use DeepL API for translation (`DEEPL_API_KEY` environment variable) + +By default, the ioBroker plugin uses the ioBroker translator service to translate changelog entries into multiple languages. You can configure it to use DeepL API instead by setting the `DEEPL_API_KEY` environment variable. + +**Setting up DeepL translation:** + +1. Get a DeepL API key from [DeepL API](https://www.deepl.com/api) + - Free tier: Keys end with `:fx` and have usage limits + - Pro tier: Keys don't have the `:fx` suffix + +2. Set the environment variable: + ```bash + export DEEPL_API_KEY="your-api-key:fx" # For free tier + export DEEPL_API_KEY="your-api-key" # For pro tier + ``` + +3. Run the release script normally: + ```bash + npm run release patch + ``` + +**Supported languages:** +- German (de), Spanish (es), French (fr), Italian (it) +- Dutch (nl), Polish (pl), Portuguese (pt), Russian (ru) +- Chinese Simplified (zh-cn) + +**Fallback behavior:** +If DeepL translation fails (invalid key, network issues, etc.), the plugin automatically falls back to the ioBroker translator service to ensure releases continue working. + ### `license` plugin options #### Change where to look for license files to check (`--license`) diff --git a/packages/plugin-iobroker/src/translate.test.ts b/packages/plugin-iobroker/src/translate.test.ts new file mode 100644 index 0000000..3180d20 --- /dev/null +++ b/packages/plugin-iobroker/src/translate.test.ts @@ -0,0 +1,168 @@ +import axios from "axios"; +import { translateText } from "./translate"; + +// Mock axios +jest.mock("axios"); +const mockedAxios = axios as jest.Mocked; + +describe("translateText", () => { + beforeEach(() => { + jest.clearAllMocks(); + delete process.env.DEEPL_API_KEY; + }); + + describe("with ioBroker translator (default behavior)", () => { + it("should use ioBroker translator when no DeepL API key is provided", async () => { + const mockResponse = { + data: { + en: "Test message", + de: "Testnachricht", + fr: "Message de test", + }, + }; + mockedAxios.mockResolvedValueOnce(mockResponse); + + const result = await translateText("Test message"); + + expect(mockedAxios).toHaveBeenCalledTimes(1); + expect(mockedAxios).toHaveBeenCalledWith({ + method: "post", + url: "https://translator.iobroker.in/translator", + data: "text=Test%20message&together=true", + headers: { + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + }, + }); + expect(result).toEqual(mockResponse.data); + }); + }); + + describe("with DeepL API", () => { + it("should use DeepL API when DEEPL_API_KEY is provided", async () => { + process.env.DEEPL_API_KEY = "test-key:fx"; + + // Mock DeepL responses for different languages + const mockResponses = [ + { data: { translations: [{ text: "Testnachricht" }] } }, // German + { data: { translations: [{ text: "Mensaje de prueba" }] } }, // Spanish + { data: { translations: [{ text: "Message de test" }] } }, // French + { data: { translations: [{ text: "Messaggio di prova" }] } }, // Italian + { data: { translations: [{ text: "Testbericht" }] } }, // Dutch + { data: { translations: [{ text: "Wiadomość testowa" }] } }, // Polish + { data: { translations: [{ text: "Mensagem de teste" }] } }, // Portuguese + { data: { translations: [{ text: "Тестовое сообщение" }] } }, // Russian + { data: { translations: [{ text: "测试消息" }] } }, // Chinese + ]; + + mockedAxios.mockImplementation(() => Promise.resolve(mockResponses.shift()!)); + + const result = await translateText("Test message"); + + expect(mockedAxios).toHaveBeenCalledTimes(9); // 9 target languages + expect(mockedAxios).toHaveBeenCalledWith({ + method: "post", + url: "https://api-free.deepl.com/v2/translate", + data: "text=Test%20message&source_lang=EN&target_lang=DE&auth_key=test-key%3Afx", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + + expect(result).toEqual({ + en: "Test message", + de: "Testnachricht", + es: "Mensaje de prueba", + fr: "Message de test", + it: "Messaggio di prova", + nl: "Testbericht", + pl: "Wiadomość testowa", + pt: "Mensagem de teste", + ru: "Тестовое сообщение", + "zh-cn": "测试消息", + }); + }); + + it("should use pro API URL for non-free API keys", async () => { + process.env.DEEPL_API_KEY = "test-key-pro"; + + const mockResponse = { data: { translations: [{ text: "Testnachricht" }] } }; + mockedAxios.mockResolvedValue(mockResponse); + + await translateText("Test message"); + + expect(mockedAxios).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://api.deepl.com/v2/translate", + }), + ); + }); + + it("should continue with other languages if one translation fails", async () => { + process.env.DEEPL_API_KEY = "test-key:fx"; + + // Mock some successful and some failed responses + let callCount = 0; + mockedAxios.mockImplementation(() => { + callCount++; + if (callCount === 2) { + // Fail Spanish translation + return Promise.reject(new Error("API error")); + } + return Promise.resolve({ + data: { translations: [{ text: `Translation ${callCount}` }] }, + }); + }); + + const consoleSpy = jest.spyOn(console, "warn").mockImplementation(() => { + // No-op + }); + + const result = await translateText("Test message"); + + expect(result).toHaveProperty("en", "Test message"); + expect(result).toHaveProperty("de", "Translation 1"); + expect(result).not.toHaveProperty("es"); // Should be missing due to failure + expect(result).toHaveProperty("fr", "Translation 3"); + + expect(consoleSpy).toHaveBeenCalledWith("Failed to translate to es:", "API error"); + + consoleSpy.mockRestore(); + }); + + it("should fall back to ioBroker translator if DeepL completely fails", async () => { + process.env.DEEPL_API_KEY = "invalid-key"; + + // Mock the first DeepL call to fail (which will trigger fallback), then ioBroker to succeed + mockedAxios.mockRejectedValueOnce(new Error("DeepL API error")).mockResolvedValueOnce({ + data: { + en: "Test message", + de: "Testnachricht (ioBroker)", + }, + }); + + const consoleSpy = jest.spyOn(console, "warn").mockImplementation(() => { + // No-op + }); + const consoleLogSpy = jest.spyOn(console, "log").mockImplementation(() => { + // No-op + }); + + const result = await translateText("Test message"); + + // Should have attempted DeepL first (1 call that failed), then called ioBroker (1 call) + expect(mockedAxios).toHaveBeenCalledTimes(2); + expect(result).toEqual({ + en: "Test message", + de: "Testnachricht (ioBroker)", + }); + + expect(consoleSpy).toHaveBeenCalledWith( + "DeepL translation failed, falling back to ioBroker translator:", + "DeepL API error", + ); + + consoleSpy.mockRestore(); + consoleLogSpy.mockRestore(); + }); + }); +}); diff --git a/packages/plugin-iobroker/src/translate.ts b/packages/plugin-iobroker/src/translate.ts index 25c9e66..217ff4d 100644 --- a/packages/plugin-iobroker/src/translate.ts +++ b/packages/plugin-iobroker/src/translate.ts @@ -1,10 +1,95 @@ import axios from "axios"; import * as qs from "querystring"; -const url = "https://translator.iobroker.in/translator"; +const ioBrokerUrl = "https://translator.iobroker.in/translator"; -/** Takes an english text and translates it into multiple languages */ -export async function translateText(textEN: string): Promise> { +// DeepL language mappings - from DeepL API codes to ioBroker expected codes +const deeplLanguageMap: Record = { + de: "de", + es: "es", + fr: "fr", + it: "it", + nl: "nl", + pl: "pl", + pt: "pt", + ru: "ru", + zh: "zh-cn", +}; + +/** Uses DeepL API to translate text into multiple languages */ +async function translateWithDeepL(textEN: string, apiKey: string): Promise> { + const translations: Record = { + en: textEN, // Always include the original English text + }; + + // Get base URL based on API key type (free vs pro) + const baseUrl = apiKey.endsWith(":fx") + ? "https://api-free.deepl.com/v2/translate" + : "https://api.deepl.com/v2/translate"; + + // Test the API key with the first language first to fail fast if key is invalid + const firstLanguage = Object.entries(deeplLanguageMap)[0]; + if (!firstLanguage) { + throw new Error("No target languages configured for DeepL"); + } + + const [firstDeeplLang, firstIoBrokerLang] = firstLanguage; + + // First, test with one language to validate API key + const response = await axios({ + method: "post", + url: baseUrl, + data: qs.stringify({ + text: textEN, + source_lang: "EN", + target_lang: firstDeeplLang.toUpperCase(), + auth_key: apiKey, + }), + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + + if (response.data.translations && response.data.translations.length > 0) { + translations[firstIoBrokerLang] = response.data.translations[0].text; + } + + // Now translate to remaining languages in parallel + const remainingLanguages = Object.entries(deeplLanguageMap).slice(1); + const translatePromises = remainingLanguages.map(async ([deeplLang, ioBrokerLang]) => { + try { + const resp = await axios({ + method: "post", + url: baseUrl, + data: qs.stringify({ + text: textEN, + source_lang: "EN", + target_lang: deeplLang.toUpperCase(), + auth_key: apiKey, + }), + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + + if (resp.data.translations && resp.data.translations.length > 0) { + translations[ioBrokerLang] = resp.data.translations[0].text; + } + } catch (error) { + // If translation fails for one language, continue with others + const message = error instanceof Error ? error.message : String(error); + console.warn(`Failed to translate to ${deeplLang}:`, message); + } + }); + + // Wait for all remaining translations to complete + await Promise.all(translatePromises); + + return translations; +} + +/** Uses ioBroker translator service to translate text into multiple languages */ +async function translateWithIoBroker(textEN: string): Promise> { const data = qs.stringify({ text: textEN, together: true, @@ -12,7 +97,7 @@ export async function translateText(textEN: string): Promise> { + const deeplApiKey = process.env.DEEPL_API_KEY; + + if (deeplApiKey) { + try { + console.log("Using DeepL API for translation"); + return await translateWithDeepL(textEN, deeplApiKey); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn("DeepL translation failed, falling back to ioBroker translator:", message); + // Fall back to ioBroker translator if DeepL fails + } + } + + // Use ioBroker translator as default or fallback + return await translateWithIoBroker(textEN); +}