From a4bc88d3054a9401bfe19b58ff38178366735a55 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Wed, 21 May 2025 23:25:00 +0800 Subject: [PATCH] feat: add azure and google translator providers, closes #1135 and closes #1215 (#1218) --- .../public/locales/ar/translation.json | 7 +- .../public/locales/de/translation.json | 7 +- .../public/locales/el/translation.json | 7 +- .../public/locales/es/translation.json | 7 +- .../public/locales/fr/translation.json | 7 +- .../public/locales/hi/translation.json | 7 +- .../public/locales/id/translation.json | 7 +- .../public/locales/it/translation.json | 7 +- .../public/locales/ja/translation.json | 7 +- .../public/locales/ko/translation.json | 7 +- .../public/locales/nl/translation.json | 7 +- .../public/locales/pl/translation.json | 7 +- .../public/locales/pt/translation.json | 7 +- .../public/locales/ru/translation.json | 7 +- .../public/locales/tr/translation.json | 7 +- .../public/locales/uk/translation.json | 7 +- .../public/locales/vi/translation.json | 7 +- .../public/locales/zh-CN/translation.json | 7 +- .../public/locales/zh-TW/translation.json | 7 +- .../src-tauri/capabilities/default.json | 9 ++ apps/readest-app/src-tauri/tauri.conf.json | 2 +- .../reader/components/annotator/Annotator.tsx | 4 +- .../{DeepLPopup.tsx => TranslatorPopup.tsx} | 90 +++++++++++++--- .../src/components/ModalPortal.tsx | 9 +- apps/readest-app/src/hooks/useTranslator.ts | 36 ++++--- apps/readest-app/src/services/constants.ts | 1 + .../services/translators/providers/azure.ts | 102 ++++++++++++++++++ .../services/translators/providers/deepl.ts | 2 + .../services/translators/providers/google.ts | 51 +++++++++ .../services/translators/providers/index.ts | 38 ++++++- .../src/services/translators/types.ts | 5 +- apps/readest-app/src/types/settings.ts | 1 + apps/readest-app/src/utils/lang.ts | 25 +++++ 33 files changed, 453 insertions(+), 55 deletions(-) rename apps/readest-app/src/app/reader/components/annotator/{DeepLPopup.tsx => TranslatorPopup.tsx} (64%) create mode 100644 apps/readest-app/src/services/translators/providers/azure.ts create mode 100644 apps/readest-app/src/services/translators/providers/google.ts diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index 44d92451..2b48717b 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -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": "مترجم أزور" } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index 97d9dbae..d6698c09 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index 1f80ab23..20e07f21 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index 3d595bfa..335a2015 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index 5beab8e0..35000f07 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index 132b3c9f..1a59fc57 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -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": "एज़्योर अनुवादक" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index 7d0c4451..45456f23 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index ca562f29..53f7187f 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index 8788fadf..50713a7a 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -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翻訳" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index 85aaa817..3345b50b 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -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 번역기" } diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index b12211e5..efc46248 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index 1d7707b5..540f6caa 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index 85a381cb..d480d3e5 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index 28dcc475..920770fb 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index 97843624..b1d5ce72 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index 0815b021..254731c3 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index 8b8f8aee..bed6c175 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index d53aa0fc..5c06020a 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index a37ead1a..7ecee567 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -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" } diff --git a/apps/readest-app/src-tauri/capabilities/default.json b/apps/readest-app/src-tauri/capabilities/default.json index 2bd4cbf3..a6d7b248 100644 --- a/apps/readest-app/src-tauri/capabilities/default.json +++ b/apps/readest-app/src-tauri/capabilities/default.json @@ -61,6 +61,15 @@ }, { "url": "https://*.cloudflarestorage.com" + }, + { + "url": "https://edge.microsoft.com" + }, + { + "url": "https://*.microsofttranslator.com" + }, + { + "url": "https://translate.googleapis.com" } ] }, diff --git a/apps/readest-app/src-tauri/tauri.conf.json b/apps/readest-app/src-tauri/tauri.conf.json index 6ef3da9f..3f29a732 100644 --- a/apps/readest-app/src-tauri/tauri.conf.json +++ b/apps/readest-app/src-tauri/tauri.conf.json @@ -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", diff --git a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx index 0e0a4636..6ef5d45d 100644 --- a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx @@ -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 && ( - { 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 = ({ +interface TranslatorType { + name: string; + label: string; +} + +const TranslatorPopup: React.FC = ({ text, position, trianglePosition, @@ -45,6 +52,10 @@ const DeepLPopup: React.FC = ({ const _ = useTranslation(); const { token } = useAuth(); const { settings, setSettings } = useSettingsStore(); + const [providers, setProviders] = useState([]); + const [provider, setProvider] = useState( + settings.globalReadSettings.translationProvider as TranslatorName, + ); const [sourceLang, setSourceLang] = useState('AUTO'); const [targetLang, setTargetLang] = useState(settings.globalReadSettings.translateTargetLang); const [translation, setTranslation] = useState(null); @@ -54,7 +65,8 @@ const DeepLPopup: React.FC = ({ const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const { translate } = useTranslator({ + const { translate, translators } = useTranslator({ + provider, sourceLang, targetLang, }); @@ -69,9 +81,25 @@ const DeepLPopup: React.FC = ({ setTargetLang(event.target.value); }; + const handleProviderChange = (event: React.ChangeEvent) => { + 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 = ({ fetchTranslation(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [text, token, sourceLang, targetLang]); + }, [text, token, sourceLang, targetLang, provider]); return (
@@ -122,7 +150,10 @@ const DeepLPopup: React.FC = ({ {Object.entries(TRANSLATOR_LANGS) .sort((a, b) => a[1].localeCompare(b[1])) @@ -164,14 +198,40 @@ const DeepLPopup: React.FC = ({ {loading ? (

{_('Loading...')}

- ) : error ? ( -

{error}

) : (
-

- {translation || 'No translation available.'} -

-
Translated by DeepL.
+ {error ? ( +

{error}

+ ) : ( +

+ {translation || _('No translation available.')} +

+ )} +
+ {provider && ( +
+ {error + ? '' + : _('Translated by {{provider}}.', { + provider: providers.find((p) => p.name === provider)?.label, + })} +
+ )} + +
)}
@@ -180,4 +240,4 @@ const DeepLPopup: React.FC = ({ ); }; -export default DeepLPopup; +export default TranslatorPopup; diff --git a/apps/readest-app/src/components/ModalPortal.tsx b/apps/readest-app/src/components/ModalPortal.tsx index 467a08b5..8e62c130 100644 --- a/apps/readest-app/src/components/ModalPortal.tsx +++ b/apps/readest-app/src/components/ModalPortal.tsx @@ -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 = ({ children }) => { +const ModalPortal: React.FC = ({ children, showOverlay = true }) => { return ReactDOM.createPortal(
{children} diff --git a/apps/readest-app/src/hooks/useTranslator.ts b/apps/readest-app/src/hooks/useTranslator.ts index 278b17be..b51d151d 100644 --- a/apps/readest-app/src/hooks/useTranslator.ts +++ b/apps/readest-app/src/hooks/useTranslator.ts @@ -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, }; } diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts index 4b8bf11c..b77ee40a 100644 --- a/apps/readest-app/src/services/constants.ts +++ b/apps/readest-app/src/services/constants.ts @@ -56,6 +56,7 @@ export const DEFAULT_READSETTINGS: ReadSettings = { notebookWidth: '25%', isNotebookPinned: false, autohideCursor: true, + translationProvider: 'deepl', translateTargetLang: 'EN', customThemes: [], diff --git a/apps/readest-app/src/services/translators/providers/azure.ts b/apps/readest-app/src/services/translators/providers/azure.ts new file mode 100644 index 00000000..79cf2b45 --- /dev/null +++ b/apps/readest-app/src/services/translators/providers/azure.ts @@ -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 => { + 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 => { + 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; + }, +}; diff --git a/apps/readest-app/src/services/translators/providers/deepl.ts b/apps/readest-app/src/services/translators/providers/deepl.ts index 593e0daf..c2a72e1d 100644 --- a/apps/readest-app/src/services/translators/providers/deepl.ts +++ b/apps/readest-app/src/services/translators/providers/deepl.ts @@ -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, diff --git a/apps/readest-app/src/services/translators/providers/google.ts b/apps/readest-app/src/services/translators/providers/google.ts new file mode 100644 index 00000000..9ba532ce --- /dev/null +++ b/apps/readest-app/src/services/translators/providers/google.ts @@ -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 => { + 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; + }, +}; diff --git a/apps/readest-app/src/services/translators/providers/index.ts b/apps/readest-app/src/services/translators/providers/index.ts index 6ce634da..e7eb1b43 100644 --- a/apps/readest-app/src/services/translators/providers/index.ts +++ b/apps/readest-app/src/services/translators/providers/index.ts @@ -1 +1,37 @@ -export * from './deepl'; +import { TranslationProvider } from '../types'; +import { deeplProvider } from './deepl'; +import { azureProvider } from './azure'; +import { googleProvider } from './google'; + +function createTranslator( + 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; +}; diff --git a/apps/readest-app/src/services/translators/types.ts b/apps/readest-app/src/services/translators/types.ts index b04b799c..fad26e6b 100644 --- a/apps/readest-app/src/services/translators/types.ts +++ b/apps/readest-app/src/services/translators/types.ts @@ -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; } diff --git a/apps/readest-app/src/types/settings.ts b/apps/readest-app/src/types/settings.ts index a5888a23..537fcf68 100644 --- a/apps/readest-app/src/types/settings.ts +++ b/apps/readest-app/src/types/settings.ts @@ -11,6 +11,7 @@ export interface ReadSettings { notebookWidth: string; isNotebookPinned: boolean; autohideCursor: boolean; + translationProvider: string; translateTargetLang: string; highlightStyle: HighlightStyle; diff --git a/apps/readest-app/src/utils/lang.ts b/apps/readest-app/src/utils/lang.ts index ea05970e..d06a9a72 100644 --- a/apps/readest-app/src/utils/lang.ts +++ b/apps/readest-app/src/utils/lang.ts @@ -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 = { + 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; +};