diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 7ca46bcd..2075fef4 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -67,8 +67,14 @@ "settings.commonSettings.startCommand": "SSG/Framework start command", "settings.integrationsView.deepl.title": "DeepL", - "settings.integrationsView.deepl.intput.label": "Authentication key", - "settings.integrationsView.deepl.intput.placeholder": "Enter your DeepL authentication key", + "settings.integrationsView.deepl.intput.label": "API key", + "settings.integrationsView.deepl.intput.placeholder": "Enter your Azure Translator API key", + + "settings.integrationsView.azure.title": "Azure AI Translator Service", + "settings.integrationsView.azure.intput.label": "Subscription key", + "settings.integrationsView.azure.intput.placeholder": "Enter your Azure AI Translator - Subscription key", + "settings.integrationsView.azure.region.label": "Region", + "settings.integrationsView.azure.region.placeholder": "Enter your Azure AI Translator - Region. Example: westeurope", "developer.title": "Developer mode", "developer.reload.title": "Reload the dashboard", diff --git a/src/commands/i18n.ts b/src/commands/i18n.ts index 760be23d..bbe12970 100644 --- a/src/commands/i18n.ts +++ b/src/commands/i18n.ts @@ -4,13 +4,12 @@ import { ContentType, Extension, FrameworkDetector, - Logger, Notifications, Settings, openFileInEditor, parseWinPath } from '../helpers'; -import { COMMAND_NAME, ExtensionState, SETTING_CONTENT_I18N } from '../constants'; +import { COMMAND_NAME, SETTING_CONTENT_I18N } from '../constants'; import { ContentFolder, Field, I18nConfig, ContentType as IContentType } from '../models'; import { join, parse } from 'path'; import { existsAsync } from '../utils'; @@ -19,6 +18,7 @@ import { ParsedFrontMatter } from '../parsers'; import { PagesListener } from '../listeners/dashboard'; import * as l10n from '@vscode/l10n'; import { LocalizationKey } from '../localization'; +import { Translations } from '../services/Translations'; export class i18n { private static processedFiles: { @@ -380,12 +380,6 @@ export class i18n { targetLocale: I18nConfig ) { return new Promise(async (resolve) => { - const authKey = await Extension.getInstance().getSecret(ExtensionState.Secrets.DeeplApiKey); - if (!authKey) { - resolve(article); - return; - } - await window.withProgress( { location: ProgressLocation.Notification, @@ -393,42 +387,25 @@ export class i18n { cancellable: false }, async () => { - const title = article.data.title || ''; - const description = article.data.description || ''; - const content = article.content || ''; - try { - const body = JSON.stringify({ - text: [title, description, content], - source_lang: sourceLocale.locale, - target_lang: targetLocale.locale - }); + const title = article.data.title || ''; + const description = article.data.description || ''; + const content = article.content || ''; - let host = authKey.endsWith(':fx') ? 'api-free.deepl.com' : 'api.deepl.com'; + const text = [title, description, content]; + const translations = await Translations.translate( + text, + sourceLocale.locale, + targetLocale.locale + ); - const response = await fetch(`https://${host}/v2/translate`, { - method: 'POST', - headers: { - Authorization: `DeepL-Auth-Key ${authKey}`, - 'User-Agent': `FrontMatterCMS/${Extension.getInstance().version}`, - 'Content-Type': 'application/json', - Accept: 'application/json' - }, - body - }); - - if (!response.ok) { - throw new Error(`DeepL: ${response.statusText}`); + if (!translations || translations.length < 3) { + throw new Error('Invalid response'); } - const data = await response.json(); - if (!data.translations || data.translations.length < 3) { - throw new Error('DeepL: Invalid response'); - } - - article.data.title = article.data.title ? data.translations[0].text : ''; - article.data.description = article.data.description ? data.translations[1].text : ''; - article.content = article.content ? data.translations[2].text : ''; + article.data.title = article.data.title ? translations[0] : ''; + article.data.description = article.data.description ? translations[1] : ''; + article.content = article.content ? translations[2] : ''; } catch (error) { Notifications.error(`${(error as Error).message}`); } diff --git a/src/constants/ExtensionState.ts b/src/constants/ExtensionState.ts index e8fe9722..ae9c40a8 100644 --- a/src/constants/ExtensionState.ts +++ b/src/constants/ExtensionState.ts @@ -33,6 +33,12 @@ export const ExtensionState = { }, Secrets: { - DeeplApiKey: `frontMatter:Secrets:DeeplApiKey` + Deepl: { + ApiKey: `frontMatter:Secrets:DeeplApiKey` + }, + Azure: { + TranslatorKey: `frontMatter:Secrets:AzureTranslatorKey`, + TranslatorRegion: `frontMatter:Secrets:AzureTranslatorRegion` + } } }; diff --git a/src/dashboardWebView/components/SettingsView/IntegrationsView.tsx b/src/dashboardWebView/components/SettingsView/IntegrationsView.tsx index bc155dfe..c5ba04dc 100644 --- a/src/dashboardWebView/components/SettingsView/IntegrationsView.tsx +++ b/src/dashboardWebView/components/SettingsView/IntegrationsView.tsx @@ -10,26 +10,64 @@ export interface IIntegrationsViewProps { } export const IntegrationsView: React.FunctionComponent = ({ }: React.PropsWithChildren) => { const [deeplApiKey, setDeeplApiKey] = React.useState(''); + const [azureApiKey, setAzureApiKey] = React.useState(''); + const [azureRegion, setAzureRegion] = React.useState(''); const [crntDeeplApiKey, setCrntDeeplApiKey] = React.useState(''); + const [crntAzureApiKey, setCrntAzureApiKey] = React.useState(''); + const [crntAzureRegion, setCrntAzureRegion] = React.useState(''); const onSave = React.useCallback(() => { - messageHandler.request(GeneralCommands.toVSCode.secrets.set, { - key: ExtensionState.Secrets.DeeplApiKey, - value: crntDeeplApiKey - }).then((apiKey: string) => { - setDeeplApiKey(apiKey); - }); - }, [crntDeeplApiKey]); + if (crntDeeplApiKey !== deeplApiKey) { + messageHandler.request(GeneralCommands.toVSCode.secrets.set, { + key: ExtensionState.Secrets.Deepl.ApiKey, + value: crntDeeplApiKey + }).then((apiKey: string) => { + setDeeplApiKey(apiKey); + }); + } - const onChange = (_: string, value: string) => { - setCrntDeeplApiKey(value); + if (crntAzureApiKey !== azureApiKey) { + messageHandler.request(GeneralCommands.toVSCode.secrets.set, { + key: ExtensionState.Secrets.Azure.TranslatorKey, + value: crntAzureApiKey + }).then((apiKey: string) => { + setAzureApiKey(apiKey); + }); + } + + if (crntAzureRegion !== azureRegion) { + messageHandler.request(GeneralCommands.toVSCode.secrets.set, { + key: ExtensionState.Secrets.Azure.TranslatorRegion, + value: crntAzureRegion + }).then((apiKey: string) => { + setAzureRegion(apiKey); + }); + } + }, [crntDeeplApiKey, deeplApiKey, crntAzureApiKey, azureApiKey, crntAzureRegion, azureRegion]); + + const onChange = (key: string, value: string) => { + if (key === ExtensionState.Secrets.Deepl.ApiKey) { + setCrntDeeplApiKey(value); + } else if (key === ExtensionState.Secrets.Azure.TranslatorKey) { + setCrntAzureApiKey(value); + } else if (key === ExtensionState.Secrets.Azure.TranslatorRegion) { + setCrntAzureRegion(value); + } }; React.useEffect(() => { - messageHandler.request(GeneralCommands.toVSCode.secrets.get, ExtensionState.Secrets.DeeplApiKey).then((apiKey: string) => { + messageHandler.request(GeneralCommands.toVSCode.secrets.get, ExtensionState.Secrets.Deepl.ApiKey).then((apiKey: string) => { setDeeplApiKey(apiKey); setCrntDeeplApiKey(apiKey); }); + messageHandler.request(GeneralCommands.toVSCode.secrets.get, ExtensionState.Secrets.Azure.TranslatorKey).then((apiKey: string) => { + setAzureApiKey(apiKey); + setCrntAzureApiKey(apiKey); + }); + messageHandler.request(GeneralCommands.toVSCode.secrets.get, ExtensionState.Secrets.Azure.TranslatorRegion).then((apiKey: string) => { + setAzureRegion(apiKey); + setCrntAzureRegion(apiKey); + }); }, []); return ( @@ -39,16 +77,37 @@ export const IntegrationsView: React.FunctionComponent = +

{l10n.t(LocalizationKey.settingsIntegrationsViewAzureTitle)}

+ + + +
+ disabled={ + deeplApiKey === crntDeeplApiKey && + azureApiKey === crntAzureApiKey && + azureRegion === crntAzureRegion + }> {l10n.t(LocalizationKey.commonSave)}
diff --git a/src/localization/localization.enum.ts b/src/localization/localization.enum.ts index dcd9c9da..d3ab017f 100644 --- a/src/localization/localization.enum.ts +++ b/src/localization/localization.enum.ts @@ -248,13 +248,33 @@ export enum LocalizationKey { */ settingsIntegrationsViewDeeplTitle = 'settings.integrationsView.deepl.title', /** - * Authentication key + * API key */ settingsIntegrationsViewDeeplIntputLabel = 'settings.integrationsView.deepl.intput.label', /** - * Enter your DeepL authentication key + * Enter your Azure Translator API key */ settingsIntegrationsViewDeeplIntputPlaceholder = 'settings.integrationsView.deepl.intput.placeholder', + /** + * Azure AI Translator Service + */ + settingsIntegrationsViewAzureTitle = 'settings.integrationsView.azure.title', + /** + * Subscription key + */ + settingsIntegrationsViewAzureIntputLabel = 'settings.integrationsView.azure.intput.label', + /** + * Enter your Azure AI Translator - Subscription key + */ + settingsIntegrationsViewAzureIntputPlaceholder = 'settings.integrationsView.azure.intput.placeholder', + /** + * Region + */ + settingsIntegrationsViewAzureRegionLabel = 'settings.integrationsView.azure.region.label', + /** + * Enter your Azure AI Translator - Region. Example: westeurope + */ + settingsIntegrationsViewAzureRegionPlaceholder = 'settings.integrationsView.azure.region.placeholder', /** * Developer mode */ @@ -2156,18 +2176,6 @@ export enum LocalizationKey { * {0} has been updated to v{1} — check out what's new! */ helpersExtensionGetVersionUpdateNotification = 'helpers.extension.getVersion.update.notification', - /** - * The "{0}" and "{1}" settings have been deprecated. Please use the "isPublishDate" and "isModifiedDate" datetime field properties instead. - */ - helpersExtensionMigrateSettingsDeprecatedWarning = 'helpers.extension.migrateSettings.deprecated.warning', - /** - * Hide - */ - helpersExtensionMigrateSettingsDeprecatedWarningHide = 'helpers.extension.migrateSettings.deprecated.warning.hide', - /** - * See migration guide - */ - helpersExtensionMigrateSettingsDeprecatedWarningSeeGuide = 'helpers.extension.migrateSettings.deprecated.warning.seeGuide', /** * {0} - Templates */ diff --git a/src/services/Translations.ts b/src/services/Translations.ts new file mode 100644 index 00000000..56a4aa1b --- /dev/null +++ b/src/services/Translations.ts @@ -0,0 +1,132 @@ +import { ExtensionState } from '../constants'; +import { Extension } from '../helpers'; + +export class Translations { + /** + * Translates an array of text from a source language to a target language. + * @param text - The array of text to be translated. + * @param source - The source language code. + * @param target - The target language code. + * @returns A Promise that resolves to an array of translated text, or undefined if translation is not possible. + */ + public static async translate( + text: string[], + source: string, + target: string + ): Promise { + const deeplAuthKey = await Extension.getInstance().getSecret( + ExtensionState.Secrets.Deepl.ApiKey + ); + const azureAuthKey = await Extension.getInstance().getSecret( + ExtensionState.Secrets.Azure.TranslatorKey + ); + const azureRegion = await Extension.getInstance().getSecret( + ExtensionState.Secrets.Azure.TranslatorRegion + ); + + if (azureAuthKey && azureRegion) { + return this.translateAzure(text, source, target, azureAuthKey, azureRegion); + } + + if (deeplAuthKey) { + return this.translateDeepL(text, source, target, deeplAuthKey); + } + + return; + } + + /** + * Translates an array of text using Azure Cognitive Services Translator API. + * @param text - The array of text to be translated. + * @param source - The source language code. + * @param target - The target language code. + * @param azureAuthKey - The Azure authentication key. + * @param azureRegion - The Azure region for the translation service. + * @returns A promise that resolves to an array of translated text. + * @throws An error if the translation fails. + */ + private static async translateAzure( + text: string[], + source: string, + target: string, + azureAuthKey: string, + azureRegion: string + ): Promise { + try { + const body = JSON.stringify(text.map((t) => ({ Text: t }))); + + const response = await fetch( + `https://api.cognitive.microsofttranslator.com/translate?api-version=3.0&to=${target}&from=${source}&textType=html`, + { + method: 'POST', + headers: { + 'Ocp-Apim-Subscription-Key': azureAuthKey, + 'Ocp-Apim-Subscription-Region': azureRegion, + 'Content-Type': 'application/json; charset=UTF-8' + }, + body + } + ); + + if (!response.ok) { + throw new Error(`${response.statusText}`); + } + + const data = await response.json(); + + return data.map((t: { translations: { text: string }[] }) => t.translations[0].text); + } catch (error) { + throw new Error(`Azure: ${(error as Error).message}`); + } + } + + /** + * Translates an array of text using the DeepL translation service. + * @param text - The text to be translated. + * @param source - The source language of the text. + * @param target - The target language for the translation. + * @param deeplAuthKey - The authentication key for accessing the DeepL API. + * @returns A Promise that resolves to an array of translated text. + * @throws If there is an error during the translation process. + */ + private static async translateDeepL( + text: string[], + source: string, + target: string, + deeplAuthKey: string + ): Promise { + try { + const body = JSON.stringify({ + text, + source_lang: source, + target_lang: target + }); + + let host = deeplAuthKey.endsWith(':fx') ? 'api-free.deepl.com' : 'api.deepl.com'; + + const response = await fetch(`https://${host}/v2/translate`, { + method: 'POST', + headers: { + Authorization: `DeepL-Auth-Key ${deeplAuthKey}`, + 'User-Agent': `FrontMatterCMS/${Extension.getInstance().version}`, + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + body + }); + + if (!response.ok) { + throw new Error(`${response.statusText}`); + } + + const data = await response.json(); + if (!data.translations || data.translations.length < 3) { + throw new Error('Invalid response'); + } + + return data.translations.map((t: { text: string }) => t.text); + } catch (error) { + throw new Error(`DeepL: ${(error as Error).message}`); + } + } +}