Translate: Add DeepL support (#2721)

Co-authored-by: v <vendicated@riseup.net>
This commit is contained in:
Ashton 2024-08-01 07:10:27 -05:00 committed by GitHub
parent 2382294e8b
commit f8b01c1a31
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 277 additions and 37 deletions

View file

@ -17,10 +17,9 @@
*/ */
import { ChatBarButton } from "@api/ChatButtons"; import { ChatBarButton } from "@api/ChatButtons";
import { Margins } from "@utils/margins";
import { classes } from "@utils/misc"; import { classes } from "@utils/misc";
import { openModal } from "@utils/modal"; import { openModal } from "@utils/modal";
import { Alerts, Forms } from "@webpack/common"; import { Alerts, Forms, Tooltip, useEffect, useState } from "@webpack/common";
import { settings } from "./settings"; import { settings } from "./settings";
import { TranslateModal } from "./TranslateModal"; import { TranslateModal } from "./TranslateModal";
@ -39,9 +38,17 @@ export function TranslateIcon({ height = 24, width = 24, className }: { height?:
); );
} }
export let setShouldShowTranslateEnabledTooltip: undefined | ((show: boolean) => void);
export const TranslateChatBarIcon: ChatBarButton = ({ isMainChat }) => { export const TranslateChatBarIcon: ChatBarButton = ({ isMainChat }) => {
const { autoTranslate, showChatBarButton } = settings.use(["autoTranslate", "showChatBarButton"]); const { autoTranslate, showChatBarButton } = settings.use(["autoTranslate", "showChatBarButton"]);
const [shouldShowTranslateEnabledTooltip, setter] = useState(false);
useEffect(() => {
setShouldShowTranslateEnabledTooltip = setter;
return () => setShouldShowTranslateEnabledTooltip = undefined;
}, []);
if (!isMainChat || !showChatBarButton) return null; if (!isMainChat || !showChatBarButton) return null;
const toggle = () => { const toggle = () => {
@ -52,21 +59,20 @@ export const TranslateChatBarIcon: ChatBarButton = ({ isMainChat }) => {
title: "Vencord Auto-Translate Enabled", title: "Vencord Auto-Translate Enabled",
body: <> body: <>
<Forms.FormText> <Forms.FormText>
You just enabled auto translate (by right clicking the Translate icon). Any message you send will automatically be translated before being sent. You just enabled Auto Translate! Any message <b>will automatically be translated</b> before being sent.
</Forms.FormText>
<Forms.FormText className={Margins.top16}>
If this was an accident, disable it again, or it will change your message content before sending.
</Forms.FormText> </Forms.FormText>
</>, </>,
cancelText: "Disable Auto-Translate", confirmText: "Disable Auto-Translate",
confirmText: "Got it", cancelText: "Got it",
secondaryConfirmText: "Don't show again", secondaryConfirmText: "Don't show again",
onConfirmSecondary: () => settings.store.showAutoTranslateAlert = false, onConfirmSecondary: () => settings.store.showAutoTranslateAlert = false,
onCancel: () => settings.store.autoTranslate = false onConfirm: () => settings.store.autoTranslate = false,
// troll
confirmColor: "vc-notification-log-danger-btn",
}); });
}; };
return ( const button = (
<ChatBarButton <ChatBarButton
tooltip="Open Translate Modal" tooltip="Open Translate Modal"
onClick={e => { onClick={e => {
@ -76,7 +82,7 @@ export const TranslateChatBarIcon: ChatBarButton = ({ isMainChat }) => {
<TranslateModal rootProps={props} /> <TranslateModal rootProps={props} />
)); ));
}} }}
onContextMenu={() => toggle()} onContextMenu={toggle}
buttonProps={{ buttonProps={{
"aria-haspopup": "dialog" "aria-haspopup": "dialog"
}} }}
@ -84,4 +90,13 @@ export const TranslateChatBarIcon: ChatBarButton = ({ isMainChat }) => {
<TranslateIcon className={cl({ "auto-translate": autoTranslate, "chat-button": true })} /> <TranslateIcon className={cl({ "auto-translate": autoTranslate, "chat-button": true })} />
</ChatBarButton> </ChatBarButton>
); );
if (shouldShowTranslateEnabledTooltip && settings.store.showAutoTranslateTooltip)
return (
<Tooltip text="Auto Translate Enabled" forceOpen>
{() => button}
</Tooltip>
);
return button;
}; };

View file

@ -20,9 +20,8 @@ import { Margins } from "@utils/margins";
import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot } from "@utils/modal"; import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot } from "@utils/modal";
import { Forms, SearchableSelect, Switch, useMemo } from "@webpack/common"; import { Forms, SearchableSelect, Switch, useMemo } from "@webpack/common";
import { Languages } from "./languages";
import { settings } from "./settings"; import { settings } from "./settings";
import { cl } from "./utils"; import { cl, getLanguages } from "./utils";
const LanguageSettingKeys = ["receivedInput", "receivedOutput", "sentInput", "sentOutput"] as const; const LanguageSettingKeys = ["receivedInput", "receivedOutput", "sentInput", "sentOutput"] as const;
@ -31,7 +30,7 @@ function LanguageSelect({ settingsKey, includeAuto }: { settingsKey: typeof Lang
const options = useMemo( const options = useMemo(
() => { () => {
const options = Object.entries(Languages).map(([value, label]) => ({ value, label })); const options = Object.entries(getLanguages()).map(([value, label]) => ({ value, label }));
if (!includeAuto) if (!includeAuto)
options.shift(); options.shift();

View file

@ -19,7 +19,6 @@
import { Parser, useEffect, useState } from "@webpack/common"; import { Parser, useEffect, useState } from "@webpack/common";
import { Message } from "discord-types/general"; import { Message } from "discord-types/general";
import { Languages } from "./languages";
import { TranslateIcon } from "./TranslateIcon"; import { TranslateIcon } from "./TranslateIcon";
import { cl, TranslationValue } from "./utils"; import { cl, TranslationValue } from "./utils";
@ -59,7 +58,7 @@ export function TranslationAccessory({ message }: { message: Message; }) {
<TranslateIcon width={16} height={16} /> <TranslateIcon width={16} height={16} />
{Parser.parse(translation.text)} {Parser.parse(translation.text)}
{" "} {" "}
(translated from {Languages[translation.src] ?? translation.src} - <Dismiss onDismiss={() => setTranslation(undefined)} />) (translated from {translation.sourceLanguage} - <Dismiss onDismiss={() => setTranslation(undefined)} />)
</span> </span>
); );
} }

View file

@ -28,7 +28,7 @@ import definePlugin from "@utils/types";
import { ChannelStore, Menu } from "@webpack/common"; import { ChannelStore, Menu } from "@webpack/common";
import { settings } from "./settings"; import { settings } from "./settings";
import { TranslateChatBarIcon, TranslateIcon } from "./TranslateIcon"; import { setShouldShowTranslateEnabledTooltip, TranslateChatBarIcon, TranslateIcon } from "./TranslateIcon";
import { handleTranslate, TranslationAccessory } from "./TranslationAccessory"; import { handleTranslate, TranslationAccessory } from "./TranslationAccessory";
import { translate } from "./utils"; import { translate } from "./utils";
@ -53,8 +53,8 @@ const messageCtxPatch: NavContextMenuPatchCallback = (children, { message }) =>
export default definePlugin({ export default definePlugin({
name: "Translate", name: "Translate",
description: "Translate messages with Google Translate", description: "Translate messages with Google Translate or DeepL",
authors: [Devs.Ven], authors: [Devs.Ven, Devs.AshtonMemer],
dependencies: ["MessageAccessoriesAPI", "MessagePopoverAPI", "MessageEventsAPI", "ChatInputButtonAPI"], dependencies: ["MessageAccessoriesAPI", "MessagePopoverAPI", "MessageEventsAPI", "ChatInputButtonAPI"],
settings, settings,
contextMenus: { contextMenus: {
@ -83,11 +83,18 @@ export default definePlugin({
}; };
}); });
let tooltipTimeout: any;
this.preSend = addPreSendListener(async (_, message) => { this.preSend = addPreSendListener(async (_, message) => {
if (!settings.store.autoTranslate) return; if (!settings.store.autoTranslate) return;
if (!message.content) return; if (!message.content) return;
message.content = (await translate("sent", message.content)).text; setShouldShowTranslateEnabledTooltip?.(true);
clearTimeout(tooltipTimeout);
tooltipTimeout = setTimeout(() => setShouldShowTranslateEnabledTooltip?.(false), 2000);
const trans = await translate("sent", message.content);
message.content = trans.text;
}); });
}, },

View file

@ -31,9 +31,10 @@ copy(Object.fromEntries(
)) ))
*/ */
export type Language = keyof typeof Languages; export type GoogleLanguage = keyof typeof GoogleLanguages;
export type DeeplLanguage = keyof typeof DeeplLanguages;
export const Languages = { export const GoogleLanguages = {
"auto": "Detect language", "auto": "Detect language",
"af": "Afrikaans", "af": "Afrikaans",
"sq": "Albanian", "sq": "Albanian",
@ -169,3 +170,57 @@ export const Languages = {
"yo": "Yoruba", "yo": "Yoruba",
"zu": "Zulu" "zu": "Zulu"
} as const; } as const;
export const DeeplLanguages = {
"": "Detect language",
"ar": "Arabic",
"bg": "Bulgarian",
"zh-hans": "Chinese (Simplified)",
"zh-hant": "Chinese (Traditional)",
"cs": "Czech",
"da": "Danish",
"nl": "Dutch",
"en-us": "English (American)",
"en-gb": "English (British)",
"et": "Estonian",
"fi": "Finnish",
"fr": "French",
"de": "German",
"el": "Greek",
"hu": "Hungarian",
"id": "Indonesian",
"it": "Italian",
"ja": "Japanese",
"ko": "Korean",
"lv": "Latvian",
"lt": "Lithuanian",
"nb": "Norwegian",
"pl": "Polish",
"pt-br": "Portuguese (Brazilian)",
"pt-pt": "Portuguese (European)",
"ro": "Romanian",
"ru": "Russian",
"sk": "Slovak",
"sl": "Slovenian",
"es": "Spanish",
"sv": "Swedish",
"tr": "Turkish",
"uk": "Ukrainian"
} as const;
export function deeplLanguageToGoogleLanguage(language: string) {
switch (language) {
case "": return "auto";
case "nb": return "no";
case "zh-hans": return "zh-CN";
case "zh-hant": return "zh-TW";
case "en-us":
case "en-gb":
return "en";
case "pt-br":
case "pt-pt":
return "pt";
default:
return language;
}
}

View file

@ -0,0 +1,29 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { IpcMainInvokeEvent } from "electron";
export async function makeDeeplTranslateRequest(_: IpcMainInvokeEvent, pro: boolean, apiKey: string, payload: string) {
const url = pro
? "https://api.deepl.com/v2/translate"
: "https://api-free.deepl.com/v2/translate";
try {
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `DeepL-Auth-Key ${apiKey}`
},
body: payload
});
const data = await res.text();
return { status: res.status, data };
} catch (e) {
return { status: -1, data: String(e) };
}
}

View file

@ -22,38 +22,76 @@ import { OptionType } from "@utils/types";
export const settings = definePluginSettings({ export const settings = definePluginSettings({
receivedInput: { receivedInput: {
type: OptionType.STRING, type: OptionType.STRING,
description: "Input language for received messages", description: "Language that received messages should be translated from",
default: "auto", default: "auto",
hidden: true hidden: true
}, },
receivedOutput: { receivedOutput: {
type: OptionType.STRING, type: OptionType.STRING,
description: "Output language for received messages", description: "Language that received messages should be translated to",
default: "en", default: "en",
hidden: true hidden: true
}, },
sentInput: { sentInput: {
type: OptionType.STRING, type: OptionType.STRING,
description: "Input language for sent messages", description: "Language that your own messages should be translated from",
default: "auto", default: "auto",
hidden: true hidden: true
}, },
sentOutput: { sentOutput: {
type: OptionType.STRING, type: OptionType.STRING,
description: "Output language for sent messages", description: "Language that your own messages should be translated to",
default: "en", default: "en",
hidden: true hidden: true
}, },
showChatBarButton: {
type: OptionType.BOOLEAN,
description: "Show translate button in chat bar",
default: true
},
service: {
type: OptionType.SELECT,
description: IS_WEB ? "Translation service (Not supported on Web!)" : "Translation service",
disabled: () => IS_WEB,
options: [
{ label: "Google Translate", value: "google", default: true },
{ label: "DeepL Free", value: "deepl" },
{ label: "DeepL Pro", value: "deepl-pro" }
] as const,
onChange: resetLanguageDefaults
},
deeplApiKey: {
type: OptionType.STRING,
description: "DeepL API key",
default: "",
placeholder: "Get your API key from https://deepl.com/your-account",
disabled: () => IS_WEB
},
autoTranslate: { autoTranslate: {
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
description: "Automatically translate your messages before sending. You can also shift/right click the translate button to toggle this", description: "Automatically translate your messages before sending. You can also shift/right click the translate button to toggle this",
default: false default: false
}, },
showChatBarButton: { showAutoTranslateTooltip: {
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
description: "Show translate button in chat bar", description: "Show a tooltip on the ChatBar button whenever a message is automatically translated",
default: true default: true
} },
}).withPrivateSettings<{ }).withPrivateSettings<{
showAutoTranslateAlert: boolean; showAutoTranslateAlert: boolean;
}>(); }>();
export function resetLanguageDefaults() {
if (IS_WEB || settings.store.service === "google") {
settings.store.receivedInput = "auto";
settings.store.receivedOutput = "en";
settings.store.sentInput = "auto";
settings.store.sentOutput = "en";
} else {
settings.store.receivedInput = "";
settings.store.receivedOutput = "en-us";
settings.store.sentInput = "";
settings.store.sentOutput = "en-us";
}
}

View file

@ -17,12 +17,18 @@
*/ */
import { classNameFactory } from "@api/Styles"; import { classNameFactory } from "@api/Styles";
import { onlyOnce } from "@utils/onlyOnce";
import { PluginNative } from "@utils/types";
import { showToast, Toasts } from "@webpack/common";
import { settings } from "./settings"; import { DeeplLanguages, deeplLanguageToGoogleLanguage, GoogleLanguages } from "./languages";
import { resetLanguageDefaults, settings } from "./settings";
export const cl = classNameFactory("vc-trans-"); export const cl = classNameFactory("vc-trans-");
interface TranslationData { const Native = VencordNative.pluginHelpers.Translate as PluginNative<typeof import("./native")>;
interface GoogleData {
src: string; src: string;
sentences: { sentences: {
// 🏳️‍⚧️ // 🏳️‍⚧️
@ -30,15 +36,47 @@ interface TranslationData {
}[]; }[];
} }
interface DeeplData {
translations: {
detected_source_language: string;
text: string;
}[];
}
export interface TranslationValue { export interface TranslationValue {
src: string; sourceLanguage: string;
text: string; text: string;
} }
export async function translate(kind: "received" | "sent", text: string): Promise<TranslationValue> { export const getLanguages = () => IS_WEB || settings.store.service === "google"
const sourceLang = settings.store[kind + "Input"]; ? GoogleLanguages
const targetLang = settings.store[kind + "Output"]; : DeeplLanguages;
export async function translate(kind: "received" | "sent", text: string): Promise<TranslationValue> {
const translate = IS_WEB || settings.store.service === "google"
? googleTranslate
: deeplTranslate;
try {
return await translate(
text,
settings.store[`${kind}Input`],
settings.store[`${kind}Output`]
);
} catch (e) {
const userMessage = typeof e === "string"
? e
: "Something went wrong. If this issue persists, please check the console or ask for help in the support server.";
showToast(userMessage, Toasts.Type.FAILURE);
throw e instanceof Error
? e
: new Error(userMessage);
}
}
async function googleTranslate(text: string, sourceLang: string, targetLang: string): Promise<TranslationValue> {
const url = "https://translate.googleapis.com/translate_a/single?" + new URLSearchParams({ const url = "https://translate.googleapis.com/translate_a/single?" + new URLSearchParams({
// see https://stackoverflow.com/a/29537590 for more params // see https://stackoverflow.com/a/29537590 for more params
// holy shidd nvidia // holy shidd nvidia
@ -63,13 +101,69 @@ export async function translate(kind: "received" | "sent", text: string): Promis
+ `\n${res.status} ${res.statusText}` + `\n${res.status} ${res.statusText}`
); );
const { src, sentences }: TranslationData = await res.json(); const { src, sentences }: GoogleData = await res.json();
return { return {
src, sourceLanguage: GoogleLanguages[src] ?? src,
text: sentences. text: sentences.
map(s => s?.trans). map(s => s?.trans).
filter(Boolean). filter(Boolean).
join("") join("")
}; };
} }
function fallbackToGoogle(text: string, sourceLang: string, targetLang: string): Promise<TranslationValue> {
return googleTranslate(
text,
deeplLanguageToGoogleLanguage(sourceLang),
deeplLanguageToGoogleLanguage(targetLang)
);
}
const showDeeplApiQuotaToast = onlyOnce(
() => showToast("Deepl API quota exceeded. Falling back to Google Translate", Toasts.Type.FAILURE)
);
async function deeplTranslate(text: string, sourceLang: string, targetLang: string): Promise<TranslationValue> {
if (!settings.store.deeplApiKey) {
showToast("DeepL API key is not set. Resetting to Google", Toasts.Type.FAILURE);
settings.store.service = "google";
resetLanguageDefaults();
return fallbackToGoogle(text, sourceLang, targetLang);
}
// CORS jumpscare
const { status, data } = await Native.makeDeeplTranslateRequest(
settings.store.service === "deepl-pro",
settings.store.deeplApiKey,
JSON.stringify({
text: [text],
target_lang: targetLang,
source_lang: sourceLang.split("-")[0]
})
);
switch (status) {
case 200:
break;
case -1:
throw "Failed to connect to DeepL API: " + data;
case 403:
throw "Invalid DeepL API key or version";
case 456:
showDeeplApiQuotaToast();
return fallbackToGoogle(text, sourceLang, targetLang);
default:
throw new Error(`Failed to translate "${text}" (${sourceLang} -> ${targetLang})\n${status} ${data}`);
}
const { translations }: DeeplData = JSON.parse(data);
const src = translations[0].detected_source_language;
return {
sourceLanguage: DeeplLanguages[src] ?? src,
text: translations[0].text
};
}

View file

@ -538,6 +538,10 @@ export const Devs = /* #__PURE__*/ Object.freeze({
name: "Joona", name: "Joona",
id: 297410829589020673n id: 297410829589020673n
}, },
AshtonMemer: {
name: "AshtonMemer",
id: 373657230530052099n
},
surgedevs: { surgedevs: {
name: "Chloe", name: "Chloe",
id: 1084592643784331324n id: 1084592643784331324n