feat: add azure and google translator providers, closes #1135 and closes #1215 (#1218)

This commit is contained in:
Huang Xin
2025-05-21 23:25:00 +08:00
committed by GitHub
parent b6b8b17760
commit a4bc88d305
33 changed files with 453 additions and 55 deletions
@@ -319,5 +319,10 @@
"Are you sure to delete {{count}} selected book(s)?_other": "هل أنت متأكد من حذف {{count}} من الكتب المحددة؟",
"Are you sure to delete the selected book?": "هل أنت متأكد من حذف الكتاب المحدد؟",
"Deselect": "إلغاء",
"Select All": "الكل"
"Select All": "الكل",
"No translation available.": "لا توجد ترجمة متاحة.",
"Translated by {{provider}}.": "ترجم بواسطة {{provider}}.",
"DeepL": "ديب إل",
"Google Translate": "ترجمة جوجل",
"Azure Translator": "مترجم أزور"
}
@@ -315,5 +315,10 @@
"Are you sure to delete {{count}} selected book(s)?_other": "Möchtest du die {{count}} ausgewählten Bücher wirklich löschen?",
"Are you sure to delete the selected book?": "Möchtest du das ausgewählte Buch wirklich löschen?",
"Deselect": "Aufheben",
"Select All": "Alle"
"Select All": "Alle",
"No translation available.": "Keine Übersetzung verfügbar.",
"Translated by {{provider}}.": "Übersetzt von {{provider}}.",
"DeepL": "DeepL",
"Google Translate": "Google Übersetzer",
"Azure Translator": "Azure Übersetzer"
}
@@ -315,5 +315,10 @@
"Are you sure to delete {{count}} selected book(s)?_other": "Είστε σίγουροι ότι θέλετε να διαγράψετε τα {{count}} επιλεγμένα βιβλία;",
"Are you sure to delete the selected book?": "Είστε σίγουροι ότι θέλετε να διαγράψετε το επιλεγμένο βιβλίο;",
"Deselect": "Άρση",
"Select All": "Όλα"
"Select All": "Όλα",
"No translation available.": "Δεν είναι διαθέσιμη καμία μετάφραση.",
"Translated by {{provider}}.": "Μεταφρασμένο από {{provider}}.",
"DeepL": "DeepL",
"Google Translate": "Μετάφραση Google",
"Azure Translator": "Μεταφραστής Azure"
}
@@ -316,5 +316,10 @@
"Are you sure to delete {{count}} selected book(s)?_other": "¿Seguro que quieres eliminar los {{count}} libros seleccionados?",
"Are you sure to delete the selected book?": "¿Seguro que quieres eliminar el libro seleccionado?",
"Deselect": "Quitar",
"Select All": "Todo"
"Select All": "Todo",
"No translation available.": "No hay traducción disponible.",
"Translated by {{provider}}.": "Traducido por {{provider}}.",
"DeepL": "DeepL",
"Google Translate": "Google Translate",
"Azure Translator": "Azure Translator"
}
@@ -316,5 +316,10 @@
"Are you sure to delete {{count}} selected book(s)?_other": "Supprimer les {{count}} livres sélectionnés ?",
"Are you sure to delete the selected book?": "Supprimer le livre sélectionné ?",
"Deselect": "Annuler",
"Select All": "Tous"
"Select All": "Tous",
"No translation available.": "Aucune traduction disponible.",
"Translated by {{provider}}.": "Traduit par {{provider}}.",
"DeepL": "DeepL",
"Google Translate": "Google Traduction",
"Azure Translator": "Traducteur Azure"
}
@@ -315,5 +315,10 @@
"Are you sure to delete {{count}} selected book(s)?_other": "क्या आप चयनित {{count}} पुस्तकें हटाना चाहते हैं?",
"Are you sure to delete the selected book?": "क्या आप चयनित पुस्तक हटाना चाहते हैं?",
"Deselect": "रद्द करें",
"Select All": "सब चुनें"
"Select All": "सब चुनें",
"No translation available.": "कोई अनुवाद उपलब्ध नहीं है।",
"Translated by {{provider}}.": "{{provider}} द्वारा अनुवादित।",
"DeepL": "डीपएल",
"Google Translate": "गूगल अनुवाद",
"Azure Translator": "एज़्योर अनुवादक"
}
@@ -314,5 +314,10 @@
"Are you sure to delete {{count}} selected book(s)?_other": "Apakah Anda yakin ingin menghapus {{count}} buku yang dipilih?",
"Are you sure to delete the selected book?": "Apakah Anda yakin ingin menghapus buku yang dipilih?",
"Deselect": "Batal",
"Select All": "Pilih Semua"
"Select All": "Pilih Semua",
"No translation available.": "Tidak ada terjemahan yang tersedia.",
"Translated by {{provider}}.": "Terjemahan oleh {{provider}}.",
"DeepL": "DeepL",
"Google Translate": "Google Translate",
"Azure Translator": "Azure Translator"
}
@@ -316,5 +316,10 @@
"Are you sure to delete {{count}} selected book(s)?_other": "Sei sicuro di voler eliminare {{count}} libri selezionati?",
"Are you sure to delete the selected book?": "Sei sicuro di voler eliminare il libro selezionato?",
"Deselect": "Annulla",
"Select All": "Tutti"
"Select All": "Tutti",
"No translation available.": "Nessuna traduzione disponibile.",
"Translated by {{provider}}.": "Tradotto da {{provider}}.",
"DeepL": "DeepL",
"Google Translate": "Google Translate",
"Azure Translator": "Traduttore Azure"
}
@@ -314,5 +314,10 @@
"Are you sure to delete {{count}} selected book(s)?_other": "選択した{{count}}冊の本を削除してもよろしいですか?",
"Are you sure to delete the selected book?": "選択した本を削除してもよろしいですか?",
"Deselect": "解除",
"Select All": "全選択"
"Select All": "全選択",
"No translation available.": "翻訳はありません。",
"Translated by {{provider}}.": "{{provider}}によって翻訳されました。",
"DeepL": "DeepL",
"Google Translate": "Google翻訳",
"Azure Translator": "Azure翻訳"
}
@@ -314,5 +314,10 @@
"Are you sure to delete {{count}} selected book(s)?_other": "선택한 {{count}}권의 책을 삭제하시겠습니까?",
"Are you sure to delete the selected book?": "선택한 책을 삭제하시겠습니까?",
"Deselect": "해제",
"Select All": "전체선택"
"Select All": "전체선택",
"No translation available.": "번역이 없습니다.",
"Translated by {{provider}}.": "{{provider}}에 의해 번역됨.",
"DeepL": "DeepL",
"Google Translate": "구글 번역",
"Azure Translator": "Azure 번역기"
}
@@ -315,5 +315,10 @@
"Are you sure to delete {{count}} selected book(s)?_other": "Weet je zeker dat je {{count}} geselecteerde boeken wilt verwijderen?",
"Are you sure to delete the selected book?": "Weet je zeker dat je het geselecteerde boek wilt verwijderen?",
"Deselect": "Deselecteer",
"Select All": "Alles"
"Select All": "Alles",
"No translation available.": "Geen vertaling beschikbaar.",
"Translated by {{provider}}.": "Vertaald door {{provider}}.",
"DeepL": "DeepL",
"Google Translate": "Google Vertalen",
"Azure Translator": "Azure Vertaler"
}
@@ -317,5 +317,10 @@
"Are you sure to delete {{count}} selected book(s)?_other": "Czy na pewno chcesz usunąć {{count}} wybranych książek?",
"Are you sure to delete the selected book?": "Czy na pewno chcesz usunąć wybraną książkę?",
"Deselect": "Odznacz",
"Select All": "Zaznacz"
"Select All": "Zaznacz",
"No translation available.": "Brak dostępnego tłumaczenia.",
"Translated by {{provider}}.": "Przetłumaczone przez {{provider}}.",
"DeepL": "DeepL",
"Google Translate": "Tłumacz Google",
"Azure Translator": "Azure Translator"
}
@@ -316,5 +316,10 @@
"Are you sure to delete {{count}} selected book(s)?_other": "Tem certeza que deseja excluir {{count}} livros selecionados?",
"Are you sure to delete the selected book?": "Tem certeza que deseja excluir o livro selecionado?",
"Deselect": "Desmarcar",
"Select All": "Marcar tudo"
"Select All": "Marcar tudo",
"No translation available.": "Sem tradução disponível.",
"Translated by {{provider}}.": "Traduzido por {{provider}}.",
"DeepL": "DeepL",
"Google Translate": "Google Tradutor",
"Azure Translator": "Tradutor Azure"
}
@@ -317,5 +317,10 @@
"Are you sure to delete {{count}} selected book(s)?_other": "Вы уверены, что хотите удалить {{count}} выбранных книг?",
"Are you sure to delete the selected book?": "Вы уверены, что хотите удалить выбранную книгу?",
"Deselect": "Снять",
"Select All": "Выбрать все"
"Select All": "Выбрать все",
"No translation available.": "Нет доступного перевода.",
"Translated by {{provider}}.": "Переведено {{provider}}.",
"DeepL": "DeepL",
"Google Translate": "Google Translate",
"Azure Translator": "Azure Translator"
}
@@ -315,5 +315,10 @@
"Are you sure to delete {{count}} selected book(s)?_other": "{{count}} seçili kitabı silmek istediğinize emin misiniz?",
"Are you sure to delete the selected book?": "Seçili kitabı silmek istediğinize emin misiniz?",
"Deselect": "Kaldır",
"Select All": "Hepsi"
"Select All": "Hepsi",
"No translation available.": "Çeviri mevcut değil.",
"Translated by {{provider}}.": "{{provider}} tarafından çevrildi.",
"DeepL": "DeepL",
"Google Translate": "Google Çeviri",
"Azure Translator": "Azure Çevirmen"
}
@@ -317,5 +317,10 @@
"Are you sure to delete {{count}} selected book(s)?_other": "Ви впевнені, що хочете видалити {{count}} вибраних книг?",
"Are you sure to delete the selected book?": "Ви впевнені, що хочете видалити вибрану книгу?",
"Deselect": "Зняти",
"Select All": "Усі"
"Select All": "Усі",
"No translation available.": "Переклад недоступний.",
"Translated by {{provider}}.": "Перекладено {{provider}}.",
"DeepL": "DeepL",
"Google Translate": "Google Translate",
"Azure Translator": "Azure Translator"
}
@@ -314,5 +314,10 @@
"Are you sure to delete {{count}} selected book(s)?_other": "Bạn có chắc muốn xóa {{count}} cuốn sách đã chọn không?",
"Are you sure to delete the selected book?": "Bạn có chắc muốn xóa cuốn sách đã chọn không?",
"Deselect": "Bỏ chọn",
"Select All": "Chọn tất"
"Select All": "Chọn tất",
"No translation available.": "Không có bản dịch nào.",
"Translated by {{provider}}.": "Được dịch bởi {{provider}}.",
"DeepL": "DeepL",
"Google Translate": "Google Dịch",
"Azure Translator": "Azure Translator"
}
@@ -314,5 +314,10 @@
"Are you sure to delete {{count}} selected book(s)?_other": "您确定要删除 {{count}} 本选中的书籍吗?",
"Are you sure to delete the selected book?": "您确定要删除选中的书籍吗?",
"Deselect": "取消选择",
"Select All": "全选"
"Select All": "全选",
"No translation available.": "暂无可用的翻译",
"Translated by {{provider}}.": "由 {{provider}} 提供翻译",
"DeepL": "DeepL",
"Google Translate": "Google Translate",
"Azure Translator": "Azure Translator"
}
@@ -314,5 +314,10 @@
"Are you sure to delete {{count}} selected book(s)?_other": "確定要刪除已選擇的 {{count}} 本書嗎?",
"Are you sure to delete the selected book?": "確定要刪除已選擇的書籍嗎?",
"Deselect": "取消選取",
"Select All": "全選"
"Select All": "全選",
"No translation available.": "暫無翻譯",
"Translated by {{provider}}.": "由 {{provider}} 提供翻譯",
"DeepL": "DeepL",
"Google Translate": "Google Translate",
"Azure Translator": "Azure Translator"
}
@@ -61,6 +61,15 @@
},
{
"url": "https://*.cloudflarestorage.com"
},
{
"url": "https://edge.microsoft.com"
},
{
"url": "https://*.microsofttranslator.com"
},
{
"url": "https://translate.googleapis.com"
}
]
},
+1 -1
View File
@@ -15,7 +15,7 @@
"security": {
"csp": {
"default-src": "'self' 'unsafe-inline' blob: data: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost",
"connect-src": "'self' blob: data: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com wss://speech.platform.bing.com https://*.cloudflarestorage.com",
"connect-src": "'self' blob: data: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com wss://speech.platform.bing.com https://*.cloudflarestorage.com https://translate.googleapis.com https://*.microsofttranslator.com https://edge.microsoft.com",
"img-src": "'self' blob: data: asset: http://asset.localhost https://*",
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://fontsapi.zeoseven.com",
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://fontsapi.zeoseven.com",
@@ -30,7 +30,7 @@ import { HIGHLIGHT_COLOR_HEX } from '@/services/constants';
import AnnotationPopup from './AnnotationPopup';
import WiktionaryPopup from './WiktionaryPopup';
import WikipediaPopup from './WikipediaPopup';
import DeepLPopup from './DeepLPopup';
import TranslatorPopup from './TranslatorPopup';
const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
@@ -499,7 +499,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
/>
)}
{showDeepLPopup && trianglePosition && translatorPopupPosition && (
<DeepLPopup
<TranslatorPopup
text={selection?.text as string}
position={translatorPopupPosition}
trianglePosition={trianglePosition}
@@ -1,3 +1,4 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import Popup from '@/components/Popup';
import { Position } from '@/utils/sel';
@@ -6,6 +7,7 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useTranslator } from '@/hooks/useTranslator';
import { TRANSLATED_LANGS } from '@/services/constants';
import { TranslatorName } from '@/services/translators';
const notSupportedLangs = ['hi', 'vi'];
@@ -27,7 +29,7 @@ const generateTranslatorLangs = () => {
const TRANSLATOR_LANGS = generateTranslatorLangs();
interface DeepLPopupProps {
interface TranslatorPopupProps {
text: string;
position: Position;
trianglePosition: Position;
@@ -35,7 +37,12 @@ interface DeepLPopupProps {
popupHeight: number;
}
const DeepLPopup: React.FC<DeepLPopupProps> = ({
interface TranslatorType {
name: string;
label: string;
}
const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
text,
position,
trianglePosition,
@@ -45,6 +52,10 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
const _ = useTranslation();
const { token } = useAuth();
const { settings, setSettings } = useSettingsStore();
const [providers, setProviders] = useState<TranslatorType[]>([]);
const [provider, setProvider] = useState<TranslatorName>(
settings.globalReadSettings.translationProvider as TranslatorName,
);
const [sourceLang, setSourceLang] = useState('AUTO');
const [targetLang, setTargetLang] = useState(settings.globalReadSettings.translateTargetLang);
const [translation, setTranslation] = useState<string | null>(null);
@@ -54,7 +65,8 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { translate } = useTranslator({
const { translate, translators } = useTranslator({
provider,
sourceLang,
targetLang,
});
@@ -69,9 +81,25 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
setTargetLang(event.target.value);
};
const handleProviderChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const selectedTranslator = translators.find((t) => t.name === event.target.value);
if (selectedTranslator) {
settings.globalReadSettings.translationProvider = selectedTranslator.name;
setSettings(settings);
setProvider(selectedTranslator.name as TranslatorName);
}
};
useEffect(() => {
const availableProviders = translators.map((t) => {
return { name: t.name, label: t.label };
});
setProviders(availableProviders);
}, [translators]);
useEffect(() => {
setLoading(true);
const fetchTranslation = async () => {
setLoading(true);
setError(null);
setTranslation(null);
@@ -103,7 +131,7 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
fetchTranslation();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [text, token, sourceLang, targetLang]);
}, [text, token, sourceLang, targetLang, provider]);
return (
<div>
@@ -122,7 +150,10 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
<select
value={sourceLang}
onChange={handleSourceLangChange}
className='select h-8 min-h-8 rounded-md border-none bg-gray-600 text-sm text-white/75 focus:outline-none focus:ring-0'
className={clsx(
'select h-8 min-h-8 rounded-md border-none text-end text-sm',
'bg-gray-600 text-white/75 focus:outline-none focus:ring-0',
)}
>
{[
['AUTO', _('Auto Detect')],
@@ -144,13 +175,16 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
<div className='mx-4 flex-shrink-0 border-t border-gray-500/30'></div>
<div className='overflow-y-auto p-4 font-sans'>
<div className='overflow-y-auto px-4 pt-4 font-sans'>
<div className='mb-2 flex items-center justify-between'>
<h2 className='text-sm font-normal'>{_('Translated Text')}</h2>
<select
value={targetLang}
onChange={handleTargetLangChange}
className='select h-8 min-h-8 rounded-md border-none bg-gray-600 text-sm text-white/75 focus:outline-none focus:ring-0'
className={clsx(
'select h-8 min-h-8 rounded-md border-none text-end text-sm',
'bg-gray-600 text-white/75 focus:outline-none focus:ring-0',
)}
>
{Object.entries(TRANSLATOR_LANGS)
.sort((a, b) => a[1].localeCompare(b[1]))
@@ -164,14 +198,40 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
{loading ? (
<p className='text-base italic text-gray-500'>{_('Loading...')}</p>
) : error ? (
<p className='text-base text-red-600'>{error}</p>
) : (
<div>
<p className='text-base text-white/90'>
{translation || 'No translation available.'}
</p>
<div className='pt-4 text-sm opacity-60'>Translated by DeepL.</div>
{error ? (
<p className='text-base text-red-600'>{error}</p>
) : (
<p className='text-base text-white/90'>
{translation || _('No translation available.')}
</p>
)}
<div className='flex h-10 items-center justify-between pt-4'>
{provider && (
<div className='text-xs opacity-60'>
{error
? ''
: _('Translated by {{provider}}.', {
provider: providers.find((p) => p.name === provider)?.label,
})}
</div>
)}
<select
value={provider}
onChange={handleProviderChange}
className={clsx(
'select h-8 min-h-8 rounded-md border-none text-end text-sm',
'bg-gray-600 text-white/75 focus:outline-none focus:ring-0',
)}
>
{providers.map(({ name, label }) => (
<option key={name} value={name}>
{label}
</option>
))}
</select>
</div>
</div>
)}
</div>
@@ -180,4 +240,4 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
);
};
export default DeepLPopup;
export default TranslatorPopup;
@@ -1,14 +1,19 @@
import clsx from 'clsx';
import React, { ReactNode } from 'react';
import ReactDOM from 'react-dom';
interface ModalPortalProps {
children: ReactNode;
showOverlay?: boolean;
}
const ModalPortal: React.FC<ModalPortalProps> = ({ children }) => {
const ModalPortal: React.FC<ModalPortalProps> = ({ children, showOverlay = true }) => {
return ReactDOM.createPortal(
<div
className='fixed inset-0 isolate z-50 flex items-center justify-center bg-black bg-opacity-50'
className={clsx(
'fixed inset-0 isolate z-50 flex items-center justify-center',
showOverlay && 'bg-black bg-opacity-50',
)}
style={{ transform: 'translateZ(0)' }}
>
{children}
+22 -14
View File
@@ -1,23 +1,25 @@
import { useState, useCallback, useEffect } from 'react';
import { useAuth } from '@/context/AuthContext';
import {
deeplProvider,
getFromCache,
storeInCache,
UseTranslatorOptions,
} from '@/services/translators';
import { getTranslator, getTranslators } from '@/services/translators';
import { getFromCache, storeInCache, UseTranslatorOptions } from '@/services/translators';
export function useTranslator({
provider = deeplProvider,
provider = 'deepl',
sourceLang = 'AUTO',
targetLang = 'EN',
}: UseTranslatorOptions = {}) {
const { token } = useAuth();
const [loading, setLoading] = useState(false);
const [translator, setTransltor] = useState(() => getTranslator(provider));
const [translators] = useState(() => getTranslators());
useEffect(() => {
setLoading(false);
}, [provider.name, sourceLang, targetLang]);
}, [provider, sourceLang, targetLang]);
useEffect(() => {
setTransltor(getTranslator(provider));
}, [provider]);
const translate = useCallback(
async (
@@ -44,7 +46,7 @@ export function useTranslator({
text,
sourceLanguage,
targetLanguage,
provider.name,
provider,
);
if (cachedTranslation) return;
@@ -56,7 +58,7 @@ export function useTranslator({
if (textsNeedingTranslation.length === 0) {
const results = await Promise.all(
textsToTranslate.map((text) =>
getFromCache(text, sourceLanguage, targetLanguage, provider.name).then(
getFromCache(text, sourceLanguage, targetLanguage, provider).then(
(cached) => cached || text,
),
),
@@ -68,7 +70,11 @@ export function useTranslator({
setLoading(true);
try {
const translatedTexts = await provider.translate(
const translator = translators.find((t) => t.name === provider);
if (!translator) {
throw new Error(`No translator found for provider: ${provider}`);
}
const translatedTexts = await translator.translate(
textsNeedingTranslation,
sourceLanguage,
targetLanguage,
@@ -83,7 +89,7 @@ export function useTranslator({
translatedTexts[index] || '',
sourceLanguage,
targetLanguage,
provider.name,
provider,
);
}),
);
@@ -103,7 +109,7 @@ export function useTranslator({
originalText,
sourceLanguage,
targetLanguage,
provider.name,
provider,
);
if (cachedTranslation) {
@@ -121,11 +127,13 @@ export function useTranslator({
throw err instanceof Error ? err : new Error(String(err));
}
},
[provider, sourceLang, targetLang, token],
[provider, sourceLang, targetLang, translator, token],
);
return {
translate,
translator,
translators,
loading,
};
}
@@ -56,6 +56,7 @@ export const DEFAULT_READSETTINGS: ReadSettings = {
notebookWidth: '25%',
isNotebookPinned: false,
autohideCursor: true,
translationProvider: 'deepl',
translateTargetLang: 'EN',
customThemes: [],
@@ -0,0 +1,102 @@
import { stubTranslation as _ } from '@/utils/misc';
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
import { isTauriAppPlatform } from '@/services/environment';
import { TranslationProvider } from '../types';
import { langToDefaultLocale } from '@/utils/lang';
interface TokenCache {
token: string;
expiresAt: number;
}
let tokenCache: TokenCache | null = null;
const getAuthToken = async (): Promise<string> => {
const now = Date.now();
if (tokenCache && tokenCache.expiresAt > now) {
return tokenCache.token;
}
try {
const fetch = isTauriAppPlatform() ? tauriFetch : window.fetch;
const tokenResponse = await fetch('https://edge.microsoft.com/translate/auth', {
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0',
},
});
if (!tokenResponse.ok) {
throw new Error(`Failed to get auth token: ${tokenResponse.status}`);
}
const token = await tokenResponse.text();
const expiresAt = now + 8 * 60 * 1000;
tokenCache = {
token,
expiresAt,
};
return token;
} catch (error) {
console.error('Error getting Microsoft translation auth token:', error);
throw error;
}
};
export const azureProvider: TranslationProvider = {
name: 'azure',
label: _('Azure Translator'),
translate: async (text: string[], sourceLang: string, targetLang: string): Promise<string[]> => {
if (!text.length) return [];
const results: string[] = [];
const msSourceLang = sourceLang ? langToDefaultLocale(sourceLang.toLowerCase()) : '';
const msTargetLang = langToDefaultLocale(targetLang.toLowerCase());
const translationPromises = text.map(async (line, index) => {
if (!line?.trim().length) {
results[index] = line;
return;
}
const url = 'https://api-edge.cognitive.microsofttranslator.com/translate';
const params = new URLSearchParams({
to: msTargetLang,
'api-version': '3.0',
});
if (msSourceLang && msSourceLang !== 'auto') {
params.append('from', msSourceLang);
}
const token = await getAuthToken();
const fetch = isTauriAppPlatform() ? tauriFetch : window.fetch;
const response = await fetch(`${url}?${params.toString()}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify([{ Text: line }]),
});
if (!response.ok) {
throw new Error(`Translation failed with status ${response.status}`);
}
const data = await response.json();
if (Array.isArray(data) && data.length > 0 && data[0].translations) {
results[index] = data[0].translations[0].text || line;
} else {
results[index] = line;
}
});
await Promise.all(translationPromises);
return results;
},
};
@@ -1,10 +1,12 @@
import { getAPIBaseUrl } from '@/services/environment';
import { stubTranslation as _ } from '@/utils/misc';
import { TranslationProvider } from '../types';
const DEEPL_API_ENDPOINT = getAPIBaseUrl() + '/deepl/translate';
export const deeplProvider: TranslationProvider = {
name: 'deepl',
label: _('DeepL'),
translate: async (
text: string[],
sourceLang: string,
@@ -0,0 +1,51 @@
import { stubTranslation as _ } from '@/utils/misc';
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
import { isTauriAppPlatform } from '@/services/environment';
import { TranslationProvider } from '../types';
export const googleProvider: TranslationProvider = {
name: 'google',
label: _('Google Translate'),
translate: async (text: string[], sourceLang: string, targetLang: string): Promise<string[]> => {
if (!text.length) return [];
const results: string[] = [];
const translationPromises = text.map(async (line, index) => {
if (!line?.trim().length) {
results[index] = line;
return;
}
const url = new URL('https://translate.googleapis.com/translate_a/single');
url.searchParams.append('client', 'gtx');
url.searchParams.append('dt', 't');
url.searchParams.append('sl', sourceLang.toLowerCase() || 'auto');
url.searchParams.append('tl', targetLang.toLowerCase());
url.searchParams.append('q', line);
const fetch = isTauriAppPlatform() ? tauriFetch : window.fetch;
const response = await fetch(url.toString());
if (!response.ok) {
throw new Error(`Translation failed with status ${response.status}`);
}
const data = await response.json();
if (Array.isArray(data) && Array.isArray(data[0])) {
const translatedText = data[0]
.filter((segment) => Array.isArray(segment) && segment[0])
.map((segment) => segment[0])
.join('');
results[index] = translatedText || line;
} else {
results[index] = line;
}
});
await Promise.all(translationPromises);
return results;
},
};
@@ -1 +1,37 @@
export * from './deepl';
import { TranslationProvider } from '../types';
import { deeplProvider } from './deepl';
import { azureProvider } from './azure';
import { googleProvider } from './google';
function createTranslator<T extends string>(
name: T,
implementation: TranslationProvider,
): TranslationProvider & { name: T } {
if (name !== implementation.name) {
throw Error(
`Translator name "${name}" does not match implementation name "${implementation.name}"`,
);
}
return { ...implementation, name };
}
const deeplTranslator = createTranslator('deepl', deeplProvider);
const azureTranslator = createTranslator('azure', azureProvider);
const googleTranslator = createTranslator('google', googleProvider);
const availableTranslators = [
deeplTranslator,
azureTranslator,
googleTranslator,
// Add more translators here
];
export type TranslatorName = (typeof availableTranslators)[number]['name'];
export const getTranslator = (name: TranslatorName): TranslationProvider | undefined => {
return availableTranslators.find((translator) => translator.name === name);
};
export const getTranslators = (): TranslationProvider[] => {
return availableTranslators;
};
@@ -1,5 +1,8 @@
import { TranslatorName } from './providers';
export interface TranslationProvider {
name: string;
label: string;
translate: (
texts: string[],
sourceLang: string,
@@ -14,7 +17,7 @@ export interface TranslationCache {
}
export interface UseTranslatorOptions {
provider?: TranslationProvider;
provider?: TranslatorName;
sourceLang?: string;
targetLang?: string;
}
+1
View File
@@ -11,6 +11,7 @@ export interface ReadSettings {
notebookWidth: string;
isNotebookPinned: boolean;
autohideCursor: boolean;
translationProvider: string;
translateTargetLang: string;
highlightStyle: HighlightStyle;
+25
View File
@@ -1,3 +1,28 @@
export const isCJKStr = (str: string) => {
return /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(str ?? '');
};
export const langToDefaultLocale = (langCode: string): string => {
const mapping: Record<string, string> = {
en: 'en-US',
fr: 'fr-FR',
de: 'de-DE',
es: 'es-ES',
it: 'it-IT',
ja: 'ja-JP',
ko: 'ko-KR',
pt: 'pt-PT',
ar: 'ar-SA',
nl: 'nl-NL',
pl: 'pl-PL',
tr: 'tr-TR',
id: 'id-ID',
ru: 'ru-RU',
uk: 'uk-UA',
zh: 'zh-Hans',
'zh-hans': 'zh-Hans',
'zh-hant': 'zh-Hant',
};
return mapping[langCode] || langCode;
};