Skip to content

Commit

Permalink
Use 'deepl-node' and store api keys in a better way (#46)
Browse files Browse the repository at this point in the history
- Use official deepl sdk using npm package 'deepl-node'
- Store api key in secret store instead of the extension settings
  • Loading branch information
soerenuhrbach authored Feb 25, 2024
1 parent a55bd76 commit 57556b1
Show file tree
Hide file tree
Showing 11 changed files with 278 additions and 394 deletions.
13 changes: 4 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,7 @@
"Other"
],
"activationEvents": [
"onStartupFinished",
"onCommand:deepl.translate",
"onCommand:deepl.translateTo",
"onCommand:deepl.translateFromTo",
"onCommand:deepl.translateBelow"
"onStartupFinished"
],
"icon": "resources/[email protected]",
"galleryBanner": {
Expand All @@ -49,6 +45,7 @@
"deepl.apiKey": {
"type": "string",
"default": "",
"deprecationMessage": "For security reasons the api key will no longer be stored in the configuration. Configured api keys will be automatically migrated to the vscode secret store and removed from the config. The config option will be removed in future releases!",
"markdownDescription": "The key is used to authenticate with the DeepL API. [See offical documentation](https://www.deepl.com/docs-api/accessing-the-api/authentication/)"
},
"deepl.formality": {
Expand Down Expand Up @@ -271,6 +268,7 @@
"eslint": "^8.56.0",
"glob": "^10.3.10",
"husky": "^8.0.3",
"lint-staged": "^15.2.0",
"ts-loader": "^9.2.2",
"ts-node": "^10.1.0",
"typescript": "^5.3.3",
Expand All @@ -279,10 +277,7 @@
},
"dependencies": {
"@vue/reactivity": "^3.4.10",
"axios": "^1.6.0",
"deepl-node": "^1.11.0",
"lint-staged": "^15.2.0",
"xstate": "4.23.0"
"deepl-node": "^1.11.0"
},
"lint-staged": {
"*.{ts,tsx}": [
Expand Down
47 changes: 26 additions & 21 deletions src/commands.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import * as deepl from './deepl';
import * as debug from './debug';
import * as deepl from './deepl';
import * as vscode from 'vscode';
import { state } from './state';
import { showMessageWithTimeout } from './vscode';
import { showApiKeyInput, showSourceLanguageInput, showTargetLanguageInput } from "./inputs";
import { TranslateCommandParam, TranslateParam } from './types';
import { getDefaultSourceLanguage, getDefaultTargetLanguage } from './helper';
import { SourceLanguageCode, TargetLanguageCode } from 'deepl-node';

function translateSelections(selections: vscode.Selection[], translateParam: TranslateParam): Thenable<void> {
const { targetLang, sourceLang, below } = translateParam;
function translateSelections(selections: vscode.Selection[], request: { targetLang: TargetLanguageCode, sourceLang: SourceLanguageCode | null, translateBelow: boolean }): Thenable<void> {
const { targetLang, sourceLang, translateBelow } = request;
return vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: 'Translating' }, async (progress) => {
const increment = 100 / 2 / selections.length;

Expand All @@ -25,12 +25,15 @@ function translateSelections(selections: vscode.Selection[], translateParam: Tra
? `Start translating '${text}' to '${targetLang}'`
: `Start translating '${text}' from '${sourceLang}' to '${targetLang}'`
);
const translations = await deepl.translate(text, targetLang, sourceLang).catch(() => []);
const result = translations.length > 0 ? translations[0] : null;
const result = await deepl.translate(
text,
sourceLang ?? null,
targetLang,
);
progress.report({ increment });
debug.write(
result
? `Successfully translated '${text}' to '${result.text}'! (Source: '${result.detected_source_language}', Target: '${targetLang}')`
? `Successfully translated '${text}' to '${result.text}'! (Source: '${result.detectedSourceLang}', Target: '${targetLang}')`
: `'${text}' could be translated to '${targetLang}! (Reason: DeepL-API returned no translation)'`
);
return result;
Expand All @@ -45,7 +48,7 @@ function translateSelections(selections: vscode.Selection[], translateParam: Tra
if (selection && translation) {
let replacement = translation.text;

if (below) {
if (translateBelow) {
const originalText = vscode.window.activeTextEditor?.document.getText(selection);
replacement = `${originalText}\n${translation.text}`;
}
Expand All @@ -61,8 +64,8 @@ function translateSelections(selections: vscode.Selection[], translateParam: Tra
});
}

function createTranslateCommand(param: TranslateCommandParam) {
const { askForTargetLang, askForSourceLang, below } = param;
function createTranslateCommand(request: { askForTargetLang: boolean, askForSourceLang: boolean, translateBelow: boolean }) {
const { askForTargetLang, askForSourceLang, translateBelow } = request;
return async function () {
if (!state.apiKey) {
await configureSettings();
Expand All @@ -89,27 +92,29 @@ function createTranslateCommand(param: TranslateCommandParam) {
}

if (state.targetLanguage === state.sourceLanguage) {
state.sourceLanguage = null;
state.sourceLanguage = undefined;
}

const selections = vscode.window.activeTextEditor?.selections?.filter(selection => !selection.isEmpty);
if (!selections || selections.length === 0) {
return;
}

const translateParam: TranslateParam = {
targetLang: state.targetLanguage,
sourceLang: sourceLang ?? undefined,
below
};
translateSelections(selections, translateParam);
await translateSelections(
selections,
{
targetLang: state.targetLanguage,
sourceLang: sourceLang ?? null,
translateBelow,
}
);
};
}

export const translate = createTranslateCommand({ askForTargetLang: false, askForSourceLang: false, below: false });
export const translateTo = createTranslateCommand({ askForTargetLang: true, askForSourceLang: false, below: false });
export const translateFromTo = createTranslateCommand({ askForTargetLang: true, askForSourceLang: true, below: false });
export const translateBelow = createTranslateCommand({ askForTargetLang: false, askForSourceLang: false, below: true });
export const translate = createTranslateCommand({ askForTargetLang: false, askForSourceLang: false, translateBelow: false });
export const translateTo = createTranslateCommand({ askForTargetLang: true, askForSourceLang: false, translateBelow: false });
export const translateFromTo = createTranslateCommand({ askForTargetLang: true, askForSourceLang: true, translateBelow: false });
export const translateBelow = createTranslateCommand({ askForTargetLang: false, askForSourceLang: false, translateBelow: true });
export const configureSettings = async () => {
state.apiKey = await showApiKeyInput();
if (!state.apiKey) {
Expand Down
23 changes: 23 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export const DEEPL_CONFIGURATION_SECTION = 'deepl';

export const CONFIG_API_KEY = `${DEEPL_CONFIGURATION_SECTION}.apiKey`;
export const CONFIG_FORMALITY = `${DEEPL_CONFIGURATION_SECTION}.formality`;
export const CONFIG_IGNORE_TAGS = `${DEEPL_CONFIGURATION_SECTION}.ignoreTags`;
export const CONFIG_TAG_HANDLING = `${DEEPL_CONFIGURATION_SECTION}.tagHandling`;
export const CONFIG_SPLITTING_TAGS = `${DEEPL_CONFIGURATION_SECTION}.splittingTags`;
export const CONFIG_SPLIT_SENTENCES = `${DEEPL_CONFIGURATION_SECTION}.splitSentences`;
export const CONFIG_NON_SPLITTING_TAGS = `${DEEPL_CONFIGURATION_SECTION}.nonSplittingTags`;
export const CONFIG_PRESERVE_FORMATTING = `${DEEPL_CONFIGURATION_SECTION}.preserveFormatting`;
export const CONFIG_GLOSSARY_ID = `${DEEPL_CONFIGURATION_SECTION}.glossaryId`;
export const CONFIG_DEFAULT_TARGET_LANGUAGE = `${DEEPL_CONFIGURATION_SECTION}.defaultTargetLanguage`;
export const CONFIG_DEFAULT_SOURCE_LANGUAGE = `${DEEPL_CONFIGURATION_SECTION}.defaultSourceLanguage`;
export const WORKSPACE_TARGET_LANGUAGE = `${DEEPL_CONFIGURATION_SECTION}.targetLanguage`;
export const WORKSPACE_SOURCE_LANGUAGE = `${DEEPL_CONFIGURATION_SECTION}.sourceLanguage`;

export const EXTENSION_IDENTIFIER = 'soerenuhrbach.vscode-deepl';

export const COMMAND_CONFIGURE = 'deepl.configure';
export const COMMAND_TRANSLATE = 'deepl.translate';
export const COMMAND_TRANSLATE_TO = 'deepl.translateTo';
export const COMMAND_TRANSLATE_FROM_TO = 'deepl.translateFromTo';
export const COMMAND_TRANSLATE_BELOW = 'deepl.translateBelow';
180 changes: 55 additions & 125 deletions src/deepl.ts
Original file line number Diff line number Diff line change
@@ -1,134 +1,64 @@
import axios from 'axios';
import * as vscode from 'vscode';
import { state } from './state';
import { Language, LanguageType, Translation } from './types';
import { isFreeAccountAuthKey } from 'deepl-node';

/* eslint-disable */
export enum DeepLErrorCodes {
BAD_REQUEST = 400,
AUTHORIZATION_FAILED = 403,
NOT_FOUND = 404,
REQUEST_SIZE_EXCEEDED = 413,
URL_TOO_LONG = 414,
TOO_MANY_REQUEST_4XX = 429,
CHARACTER_LIMIT_REACHED = 456,
INTERNAL_SERVER_ERROR = 500,
RESOURCE_UNAVAILABLE = 503,
TOO_MANY_REQUEST_5XX = 529,
}
/* eslint-enable */

export class DeepLException extends Error {
public readonly code: DeepLErrorCodes;

constructor(code: DeepLErrorCodes) {
super();
this.code = code;
}

public static createFromStatusCodeAndMessage(code: number, message: string) {
const exception = new DeepLException(code);
exception.name = DeepLException.name;
switch (code) {
case DeepLErrorCodes.AUTHORIZATION_FAILED:
exception.message = "Authentication failed. Please check your DeepL API key. You may be using the DeepL Pro API by mistake.";
break;

case DeepLErrorCodes.REQUEST_SIZE_EXCEEDED:
exception.message = "Please try again with a shorter text";
break;

case DeepLErrorCodes.TOO_MANY_REQUEST_4XX:
case DeepLErrorCodes.TOO_MANY_REQUEST_5XX:
exception.message = "You have done too many translations recently. Please try again later.";
break;

case DeepLErrorCodes.CHARACTER_LIMIT_REACHED:
exception.message = "You have reached the maximum character limit. You can see your usage [here](https://www.deepl.com/pro-account/usage)";
break;

default:
exception.message = "Unfortunately, the DeepL API cannot accept any requests at the moment. Please try again later. (" + message + ")";
break;
import { Language, SourceLanguageCode, TargetLanguageCode, TextResult, Translator } from 'deepl-node';
import { EXTENSION_IDENTIFIER } from './constants';

const cache = {
targetLanguages: [] as readonly Language[],
sourceLanguages: [] as readonly Language[],
};

const createTranslator = (apiKey: string) => {
const extension = vscode.extensions.getExtension(EXTENSION_IDENTIFIER)?.packageJSON;
const appName = extension?.name;
const appVersion = extension?.version;

return new Translator(apiKey, {
appInfo: {
appName: appName,
appVersion: appVersion
},
sendPlatformInfo: false
});
};

export function translate<T extends string | string[]>(texts: T, sourceLang: SourceLanguageCode | null, targetLang: TargetLanguageCode): Promise<T extends string ? TextResult : TextResult[]> {
const translator = createTranslator(state.apiKey!);
return translator.translateText(
texts,
sourceLang ?? null,
targetLang,
{
formality: state.formality || undefined,
glossary: state.glossaryId || undefined,
ignoreTags: state.ignoreTags || undefined,
nonSplittingTags: state.nonSplittingTags || undefined,
splittingTags: state.splittingTags || undefined,
preserveFormatting: state.preserveFormatting || undefined,
splitSentences: state.splitSentences || undefined,
tagHandling: state.tagHandling || undefined
}
return exception;
}
);
}

type ErrorHandler = (e: DeepLException) => void;

const http = axios.create();
const errorHandlers: ErrorHandler[] = [];

http.interceptors.request.use((config) => {
config.baseURL = isFreeAccountAuthKey(state.apiKey!)
? 'https://api-free.deepl.com'
: 'https://api.deepl.com';

if (!config.params) {
config.params = {};
}

config.headers.Authorization = `DeepL-Auth-Key ${state.apiKey}`;

if (config.url!.includes('translate')) {
config.params.split_sentences = state.splitSentences;
config.params.preserve_formatting = state.preserveFormatting ? "1" : "0";

if (config.params.source_lang) {
config.params.glossary_id = state.glossaryId;
}

const formalityAllowed: string[] = state.languages.target
.filter(x => x.supports_formality)
.map(x => x.language);
if (config.params.target_lang && formalityAllowed.includes(config.params.target_lang.toUpperCase())) {
config.params.formality = state.formality;
}

if (state.tagHandling !== 'off') {
config.params.tag_handling = state.tagHandling;
config.params.ignore_tags = state.ignoreTags;
config.params.splitting_tags = state.splittingTags;
config.params.non_splitting_tags = state.nonSplittingTags;
}
export async function getTargetLanguages() {
if (cache.targetLanguages.length > 0) {
return cache.targetLanguages;
}

return config;
});

http.interceptors.response.use(
res => res,
e => {
if (!e.response) {
throw e;
}
const translator = createTranslator(state.apiKey!);
const languages = await translator.getTargetLanguages();
cache.targetLanguages = languages;
return languages;
}

const exception = DeepLException.createFromStatusCodeAndMessage(e.response.status, e.response.data.message);
for (const handler of errorHandlers) {
handler(exception);
}
throw exception;
export async function getSourceLanguages() {
if (cache.sourceLanguages.length > 0) {
return cache.sourceLanguages;
}
);

export async function translate(text: string, targetLanguage: string, sourceLanguage?: string): Promise<Translation[]> {
const response = await http.post('/v2/translate', null, {
/* eslint-disable */
params: {
text: text,
target_lang: targetLanguage,
source_lang: sourceLanguage
}
/* eslint-enable */
});

return response.data.translations as Translation[];
}

export async function languages(type: LanguageType = 'source'): Promise<Language[]> {
const response = await http.get('/v2/languages', { params: { type } });
return response.data as Language[];
}

export const addErrorHandler = (handler: ErrorHandler) => errorHandlers.push(handler);
const translator = createTranslator(state.apiKey!);
const languages = await translator.getSourceLanguages();
cache.sourceLanguages = languages;
return languages;
}
14 changes: 6 additions & 8 deletions src/extension.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
import * as deepl from './deepl';
import * as vscode from 'vscode';
import { setup } from './state';
import { createStatusBarItem } from './status-bar';
import { configureSettings, translate, translateFromTo, translateTo, translateBelow } from './commands';
import { COMMAND_CONFIGURE, COMMAND_TRANSLATE, COMMAND_TRANSLATE_BELOW, COMMAND_TRANSLATE_FROM_TO, COMMAND_TRANSLATE_TO } from './constants';

export async function activate(context: vscode.ExtensionContext) {
deepl.addErrorHandler(e => vscode.window.showErrorMessage(e.message));

await setup(context);

context.subscriptions.push(createStatusBarItem());
context.subscriptions.push(vscode.commands.registerCommand('deepl.configure', configureSettings));
context.subscriptions.push(vscode.commands.registerCommand('deepl.translate', translate));
context.subscriptions.push(vscode.commands.registerCommand('deepl.translateTo', translateTo));
context.subscriptions.push(vscode.commands.registerCommand('deepl.translateFromTo', translateFromTo));
context.subscriptions.push(vscode.commands.registerCommand('deepl.translateBelow', translateBelow));
context.subscriptions.push(vscode.commands.registerCommand(COMMAND_CONFIGURE, configureSettings));
context.subscriptions.push(vscode.commands.registerCommand(COMMAND_TRANSLATE, translate));
context.subscriptions.push(vscode.commands.registerCommand(COMMAND_TRANSLATE_TO, translateTo));
context.subscriptions.push(vscode.commands.registerCommand(COMMAND_TRANSLATE_FROM_TO, translateFromTo));
context.subscriptions.push(vscode.commands.registerCommand(COMMAND_TRANSLATE_BELOW, translateBelow));
}

export function deactivate() {}
Loading

0 comments on commit 57556b1

Please sign in to comment.