Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
168 changes: 168 additions & 0 deletions packages/plugin-iobroker/src/translate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import axios from "axios";
import { translateText } from "./translate";

// Mock axios
jest.mock("axios");
const mockedAxios = axios as jest.Mocked<typeof axios>;

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();
});
});
});
112 changes: 108 additions & 4 deletions packages/plugin-iobroker/src/translate.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,103 @@
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<Record<string, string>> {
// DeepL language mappings - from DeepL API codes to ioBroker expected codes
const deeplLanguageMap: Record<string, string> = {
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<Record<string, string>> {
const translations: Record<string, string> = {
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<Record<string, string>> {
const data = qs.stringify({
text: textEN,
together: true,
});

const response = await axios({
method: "post",
url,
url: ioBrokerUrl,
data,
headers: {
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
Expand All @@ -21,3 +106,22 @@ export async function translateText(textEN: string): Promise<Record<string, stri

return response.data;
}

/** Takes an english text and translates it into multiple languages */
export async function translateText(textEN: string): Promise<Record<string, string>> {
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);
}