From b83972e366e374893159835d03d0f5d88a9046b9 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Mon, 19 May 2025 00:35:37 +0800 Subject: [PATCH] refactor: use translator hook to support more translator providers (#1186) --- .../components/annotator/DeepLPopup.tsx | 34 +- .../src/components/UpdaterWindow.tsx | 37 +- apps/readest-app/src/hooks/useTranslator.ts | 131 ++++ .../src/services/translators/cache.ts | 593 ++++++++++++++++++ .../src/services/translators/index.ts | 3 + .../services/translators/providers/deepl.ts | 57 ++ .../services/translators/providers/index.ts | 1 + .../src/services/translators/types.ts | 20 + apps/readest-app/src/utils/misc.ts | 6 +- 9 files changed, 851 insertions(+), 31 deletions(-) create mode 100644 apps/readest-app/src/hooks/useTranslator.ts create mode 100644 apps/readest-app/src/services/translators/cache.ts create mode 100644 apps/readest-app/src/services/translators/index.ts create mode 100644 apps/readest-app/src/services/translators/providers/deepl.ts create mode 100644 apps/readest-app/src/services/translators/providers/index.ts create mode 100644 apps/readest-app/src/services/translators/types.ts diff --git a/apps/readest-app/src/app/reader/components/annotator/DeepLPopup.tsx b/apps/readest-app/src/app/reader/components/annotator/DeepLPopup.tsx index d42415a4..65635bb0 100644 --- a/apps/readest-app/src/app/reader/components/annotator/DeepLPopup.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/DeepLPopup.tsx @@ -4,7 +4,7 @@ import { Position } from '@/utils/sel'; import { useAuth } from '@/context/AuthContext'; import { useSettingsStore } from '@/store/settingsStore'; import { useTranslation } from '@/hooks/useTranslation'; -import { getAPIBaseUrl } from '@/services/environment'; +import { useTranslator } from '@/hooks/useTranslator'; import { TRANSLATED_LANGS } from '@/services/constants'; const notSupportedLangs = ['hi', 'vi']; @@ -27,8 +27,6 @@ const generateTranslatorLangs = () => { const TRANSLATOR_LANGS = generateTranslatorLangs(); -const DEEPL_API_ENDPOINT = getAPIBaseUrl() + '/deepl/translate'; - interface DeepLPopupProps { text: string; position: Position; @@ -53,8 +51,13 @@ const DeepLPopup: React.FC = ({ const [detectedSourceLang, setDetectedSourceLang] = useState< keyof typeof TRANSLATOR_LANGS | null >(null); + const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const { translate } = useTranslator({ + sourceLang, + targetLang, + }); const handleSourceLangChange = (event: React.ChangeEvent) => { setSourceLang(event.target.value); @@ -73,35 +76,18 @@ const DeepLPopup: React.FC = ({ setTranslation(null); try { - const response = await fetch(DEEPL_API_ENDPOINT, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token ?? ''}`, - }, - body: JSON.stringify({ - text: [text], - target_lang: targetLang.toUpperCase(), - source_lang: sourceLang === 'AUTO' ? undefined : sourceLang.toUpperCase(), - }), - }); - - if (!response.ok) { - throw new Error('Failed to fetch translation'); - } - const data = await response.json(); - const translatedText = data.translations[0]?.text; - const detectedSource = data.translations[0]?.detected_source_language; + const result = await translate([text]); + const translatedText = result[0]; + const detectedSource = null; if (!translatedText) { throw new Error('No translation found'); } + setTranslation(translatedText); if (sourceLang === 'AUTO' && detectedSource) { setDetectedSourceLang(detectedSource); } - - setTranslation(translatedText); } catch (err) { console.error(err); if (!token) { diff --git a/apps/readest-app/src/components/UpdaterWindow.tsx b/apps/readest-app/src/components/UpdaterWindow.tsx index 0a9ab2a2..9cf8ee5f 100644 --- a/apps/readest-app/src/components/UpdaterWindow.tsx +++ b/apps/readest-app/src/components/UpdaterWindow.tsx @@ -7,10 +7,12 @@ import { type as osType, arch as osArch } from '@tauri-apps/plugin-os'; import { check, Update } from '@tauri-apps/plugin-updater'; import { relaunch } from '@tauri-apps/plugin-process'; import { fetch } from '@tauri-apps/plugin-http'; +import { useTranslator } from '@/hooks/useTranslator'; import { useTranslation } from '@/hooks/useTranslation'; import { useSearchParams } from 'next/navigation'; import { tauriDownload } from '@/utils/transfer'; import { installPackage } from '@/utils/bridge'; +import { getLocale } from '@/utils/misc'; import { READEST_UPDATER_FILE, READEST_CHANGELOG_FILE } from '@/services/constants'; import packageJson from '../../package.json'; import Dialog from '@/components/Dialog'; @@ -58,6 +60,11 @@ interface GenericUpdate { export const UpdaterContent = ({ version }: { version?: string }) => { const _ = useTranslation(); + const [targetLang, setTargetLang] = useState('EN'); + const { translate } = useTranslator({ + sourceLang: 'AUTO', + targetLang, + }); const { appService } = useEnv(); const searchParams = useSearchParams(); const currentVersion = packageJson.version; @@ -71,6 +78,17 @@ export const UpdaterContent = ({ version }: { version?: string }) => { const [isDownloading, setIsDownloading] = useState(false); const [downloaded, setDownloaded] = useState(null); + useEffect(() => { + const locale = getLocale(); + let userLang = locale.split('-')[0] || 'en'; + if (locale === 'zh-CN') { + userLang = 'zh-Hans'; + } else if (locale.startsWith('zh')) { + userLang = 'zh-Hant'; + } + setTargetLang(userLang.toUpperCase()); + }, []); + useEffect(() => { const checkDesktopUpdate = async () => { const update = await check(); @@ -181,18 +199,25 @@ export const UpdaterContent = ({ version }: { version?: string }) => { }; const updateChangelogs = async (update: GenericUpdate) => { setNewVersion(update.version); - const changelogs = await fetchChangelogs(currentVersion, update.version); - if (changelogs.length > 0) { - setChangelogs(changelogs); - } else { - setChangelogs([ + let changelogs = await fetchChangelogs(currentVersion, update.version); + if (changelogs.length === 0) { + changelogs = [ { version: update.version, date: new Date(update.date!).toDateString(), notes: parseNumberedList(update.body ?? ''), }, - ]); + ]; } + for (const entry of changelogs) { + try { + entry.notes = await translate(entry.notes, { useCache: true }); + } catch (error) { + console.log('Failed to translate changelog:', error); + } + } + + setChangelogs(changelogs); }; if (update) { updateChangelogs(update); diff --git a/apps/readest-app/src/hooks/useTranslator.ts b/apps/readest-app/src/hooks/useTranslator.ts new file mode 100644 index 00000000..278b17be --- /dev/null +++ b/apps/readest-app/src/hooks/useTranslator.ts @@ -0,0 +1,131 @@ +import { useState, useCallback, useEffect } from 'react'; +import { useAuth } from '@/context/AuthContext'; +import { + deeplProvider, + getFromCache, + storeInCache, + UseTranslatorOptions, +} from '@/services/translators'; + +export function useTranslator({ + provider = deeplProvider, + sourceLang = 'AUTO', + targetLang = 'EN', +}: UseTranslatorOptions = {}) { + const { token } = useAuth(); + const [loading, setLoading] = useState(false); + + useEffect(() => { + setLoading(false); + }, [provider.name, sourceLang, targetLang]); + + const translate = useCallback( + async ( + input: string[], + options?: { source?: string; target?: string; useCache?: boolean }, + ): Promise => { + const sourceLanguage = options?.source || sourceLang; + const targetLanguage = options?.target || targetLang; + const useCache = options?.useCache ?? false; + const textsToTranslate = input; + + if (textsToTranslate.length === 0 || textsToTranslate.every((t) => !t?.trim())) { + return textsToTranslate; + } + + const textsNeedingTranslation: string[] = []; + const indicesNeedingTranslation: number[] = []; + + await Promise.all( + textsToTranslate.map(async (text, index) => { + if (!text?.trim()) return; + + const cachedTranslation = await getFromCache( + text, + sourceLanguage, + targetLanguage, + provider.name, + ); + if (cachedTranslation) return; + + textsNeedingTranslation.push(text); + indicesNeedingTranslation.push(index); + }), + ); + + if (textsNeedingTranslation.length === 0) { + const results = await Promise.all( + textsToTranslate.map((text) => + getFromCache(text, sourceLanguage, targetLanguage, provider.name).then( + (cached) => cached || text, + ), + ), + ); + + return results; + } + + setLoading(true); + + try { + const translatedTexts = await provider.translate( + textsNeedingTranslation, + sourceLanguage, + targetLanguage, + token, + useCache, + ); + + await Promise.all( + textsNeedingTranslation.map(async (text, index) => { + return storeInCache( + text, + translatedTexts[index] || '', + sourceLanguage, + targetLanguage, + provider.name, + ); + }), + ); + + const results = [...textsToTranslate]; + indicesNeedingTranslation.forEach((originalIndex, translationIndex) => { + results[originalIndex] = translatedTexts[translationIndex] || ''; + }); + + await Promise.all( + results.map(async (_, index) => { + if (!indicesNeedingTranslation.includes(index)) { + const originalText = textsToTranslate[index]; + if (!originalText?.trim()) return; + + const cachedTranslation = await getFromCache( + originalText, + sourceLanguage, + targetLanguage, + provider.name, + ); + + if (cachedTranslation) { + results[index] = cachedTranslation; + } + } + }), + ); + + setLoading(false); + return results; + } catch (err) { + console.error('Translation error:', err); + setLoading(false); + throw err instanceof Error ? err : new Error(String(err)); + } + }, + [provider, sourceLang, targetLang, token], + ); + + return { + translate, + loading, + }; +} diff --git a/apps/readest-app/src/services/translators/cache.ts b/apps/readest-app/src/services/translators/cache.ts new file mode 100644 index 00000000..76ebab75 --- /dev/null +++ b/apps/readest-app/src/services/translators/cache.ts @@ -0,0 +1,593 @@ +import { TranslationCache } from './types'; + +const DB_NAME = 'TranslationCache'; +const DB_VERSION = 1; +const STORE_NAME = 'translations'; + +interface CacheEntry { + key: string; + translation: string; + timestamp: number; + provider: string; + sourceLang: string; + targetLang: string; + originalText: string; +} + +const memoryCache: TranslationCache = {}; +const memoryTimestamps: Record = {}; + +const openDatabase = (): Promise => { + return new Promise((resolve, reject) => { + if (!window.indexedDB) { + console.warn('IndexedDB not supported. Using in-memory cache only.'); + reject(new Error('IndexedDB not supported')); + return; + } + + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = (event) => { + console.error('IndexedDB error:', event); + reject(new Error('Could not open IndexedDB')); + }; + + request.onsuccess = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + resolve(db); + }; + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + + if (!db.objectStoreNames.contains(STORE_NAME)) { + const store = db.createObjectStore(STORE_NAME, { keyPath: 'key' }); + store.createIndex('provider', 'provider', { unique: false }); + store.createIndex('timestamp', 'timestamp', { unique: false }); + } + }; + }); +}; + +export const loadCacheFromDB = async ( + options: { + maxAge?: number; + maxEntries?: number; + onlyLoadProviders?: string[]; + onlyLoadLanguages?: { source?: string[]; target?: string[] }; + } = {}, +): Promise => { + try { + const db = await openDatabase(); + const transaction = db.transaction(STORE_NAME, 'readonly'); + const store = transaction.objectStore(STORE_NAME); + + let request: IDBRequest; + if (options.onlyLoadProviders && options.onlyLoadProviders.length > 0) { + const providerPromises = options.onlyLoadProviders.map((provider) => { + return new Promise((resolve) => { + const providerIndex = store.index('provider'); + const request = providerIndex.getAll(provider); + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onerror = () => { + resolve([]); + }; + }); + }); + + const allEntries = (await Promise.all(providerPromises)).flat(); + processLoadedEntries(allEntries, options); + } else { + request = store.getAll(); + + request.onsuccess = () => { + const entries = request.result as CacheEntry[]; + processLoadedEntries(entries, options); + }; + + request.onerror = (event) => { + console.error('Error loading cache from IndexedDB:', event); + }; + } + + transaction.oncomplete = () => { + db.close(); + }; + } catch (error) { + console.error('Failed to load cache from IndexedDB:', error); + } +}; + +const processLoadedEntries = ( + entries: CacheEntry[], + options: { + maxAge?: number; + maxEntries?: number; + onlyLoadLanguages?: { source?: string[]; target?: string[] }; + }, +): void => { + let filteredEntries = entries; + + if (options.maxAge) { + const cutoff = Date.now() - options.maxAge; + filteredEntries = filteredEntries.filter((entry) => entry.timestamp >= cutoff); + } + + if (options.onlyLoadLanguages) { + if (options.onlyLoadLanguages.source && options.onlyLoadLanguages.source.length > 0) { + filteredEntries = filteredEntries.filter((entry) => + options.onlyLoadLanguages!.source!.includes(entry.sourceLang), + ); + } + + if (options.onlyLoadLanguages.target && options.onlyLoadLanguages.target.length > 0) { + filteredEntries = filteredEntries.filter((entry) => + options.onlyLoadLanguages!.target!.includes(entry.targetLang), + ); + } + } + + if (options.maxEntries && filteredEntries.length > options.maxEntries) { + filteredEntries.sort((a, b) => b.timestamp - a.timestamp); + filteredEntries = filteredEntries.slice(0, options.maxEntries); + } + + filteredEntries.forEach((entry) => { + memoryCache[entry.key] = entry.translation; + memoryTimestamps[entry.key] = entry.timestamp; + }); + + // console.log(`Loaded ${filteredEntries.length} translations into memory cache`); +}; + +export const getCacheKey = ( + text: string, + sourceLang: string, + targetLang: string, + provider: string, +): string => { + return `${provider}:${sourceLang}:${targetLang}:${text}`; +}; + +export const getFromCache = async ( + text: string, + sourceLang: string, + targetLang: string, + provider: string, +): Promise => { + if (!text?.trim()) return null; + + const key = getCacheKey(text, sourceLang, targetLang, provider); + + if (memoryCache[key]) { + return memoryCache[key]; + } + + try { + const db = await openDatabase(); + const transaction = db.transaction(STORE_NAME, 'readonly'); + const store = transaction.objectStore(STORE_NAME); + const request = store.get(key); + + return new Promise((resolve) => { + request.onsuccess = () => { + const entry = request.result as CacheEntry; + if (entry) { + memoryCache[key] = entry.translation; + memoryTimestamps[key] = entry.timestamp; + resolve(entry.translation); + } else { + resolve(null); + } + }; + + request.onerror = () => { + resolve(null); + }; + + transaction.oncomplete = () => { + db.close(); + }; + }); + } catch (error) { + console.error('Error accessing IndexedDB:', error); + return null; + } +}; + +export const storeInCache = async ( + text: string, + translation: string, + sourceLang: string, + targetLang: string, + provider: string, +): Promise => { + if (!text?.trim() || !translation) return; + + const key = getCacheKey(text, sourceLang, targetLang, provider); + const timestamp = Date.now(); + + memoryCache[key] = translation; + memoryTimestamps[key] = timestamp; + + try { + const db = await openDatabase(); + const transaction = db.transaction(STORE_NAME, 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + + const entry: CacheEntry = { + key, + translation, + timestamp, + provider, + sourceLang, + targetLang, + originalText: text, + }; + + store.put(entry); + + return new Promise((resolve, reject) => { + transaction.oncomplete = () => { + db.close(); + resolve(); + }; + + transaction.onerror = (event) => { + console.error('Error storing in IndexedDB:', event); + reject(new Error('Failed to store in IndexedDB')); + }; + }); + } catch (error) { + console.error('Error accessing IndexedDB:', error); + } +}; + +export interface CacheFilterOptions { + provider?: string; + maxAge?: number; +} + +export const clearCache = async (filter?: CacheFilterOptions): Promise => { + let deletedCount = 0; + + if (!filter) { + const count = Object.keys(memoryCache).length; + Object.keys(memoryCache).forEach((key) => { + delete memoryCache[key]; + delete memoryTimestamps[key]; + }); + deletedCount = count; + } else { + const keysToDelete: string[] = []; + + Object.keys(memoryCache).forEach((key) => { + let shouldDelete = true; + + if (filter.provider) { + const parts = key.split(':'); + const provider = parts[0]; + + if (filter.provider && provider !== filter.provider) { + shouldDelete = false; + } + } + + if (shouldDelete && filter.maxAge && memoryTimestamps[key]) { + const timestamp = memoryTimestamps[key]; + if (Date.now() - timestamp < filter.maxAge) { + shouldDelete = false; + } + } + + if (shouldDelete) { + keysToDelete.push(key); + } + }); + + keysToDelete.forEach((key) => { + delete memoryCache[key]; + delete memoryTimestamps[key]; + }); + + deletedCount = keysToDelete.length; + } + + try { + const db = await openDatabase(); + const transaction = db.transaction(STORE_NAME, 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + + if (!filter) { + store.clear(); + } else { + const request = store.getAll(); + + request.onsuccess = () => { + const entries = request.result as CacheEntry[]; + const filteredEntries = entries.filter((entry) => { + if (filter.provider && entry.provider !== filter.provider) { + return false; + } + + if (filter.maxAge && Date.now() - entry.timestamp >= filter.maxAge) { + return true; + } + + return true; + }); + + filteredEntries.forEach((entry) => { + store.delete(entry.key); + }); + }; + } + + return new Promise((resolve) => { + transaction.oncomplete = () => { + db.close(); + resolve(deletedCount); + }; + + transaction.onerror = () => { + db.close(); + resolve(deletedCount); + }; + }); + } catch (error) { + console.error('Error clearing IndexedDB cache:', error); + return deletedCount; + } +}; + +export const getCacheStats = async ( + includeDB: boolean = false, +): Promise<{ + memoryCacheEntries: number; + memoryCacheSizeInBytes: number; + dbCacheEntries?: number; + dbCacheSizeInBytes?: number; + totalEntries?: number; + totalSizeInBytes?: number; +}> => { + const memoryCacheEntries = Object.keys(memoryCache).length; + + let memoryCacheSizeInBytes = 0; + for (const key in memoryCache) { + memoryCacheSizeInBytes += key.length; + + const value = memoryCache[key] || ''; + memoryCacheSizeInBytes += value.length; + } + + if (!includeDB) { + return { memoryCacheEntries, memoryCacheSizeInBytes }; + } + + try { + const db = await openDatabase(); + const transaction = db.transaction(STORE_NAME, 'readonly'); + const store = transaction.objectStore(STORE_NAME); + const countRequest = store.count(); + + return new Promise((resolve) => { + countRequest.onsuccess = () => { + const dbCacheEntries = countRequest.result; + + const getAllRequest = store.getAll(); + + getAllRequest.onsuccess = () => { + const entries = getAllRequest.result as CacheEntry[]; + + let dbCacheSizeInBytes = 0; + entries.forEach((entry) => { + const entryString = JSON.stringify(entry); + dbCacheSizeInBytes += entryString.length; + }); + + const totalEntries = + memoryCacheEntries + dbCacheEntries - Math.min(memoryCacheEntries, dbCacheEntries); + + const totalSizeInBytes = memoryCacheSizeInBytes + dbCacheSizeInBytes; + + resolve({ + memoryCacheEntries, + memoryCacheSizeInBytes, + dbCacheEntries, + dbCacheSizeInBytes, + totalEntries, + totalSizeInBytes, + }); + }; + }; + + transaction.oncomplete = () => { + db.close(); + }; + + transaction.onerror = () => { + db.close(); + resolve({ + memoryCacheEntries, + memoryCacheSizeInBytes, + }); + }; + }); + } catch (error) { + console.error('Error getting IndexedDB stats:', error); + return { memoryCacheEntries, memoryCacheSizeInBytes }; + } +}; + +export const pruneCache = async ( + options: { + maxAge?: number; + maxEntries?: number; + maxSizeInBytes?: number; + dryRun?: boolean; + } = {}, +): Promise => { + const { maxAge, maxEntries, maxSizeInBytes, dryRun = false } = options; + + if (!maxAge && !maxEntries && !maxSizeInBytes) { + return 0; + } + + try { + const db = await openDatabase(); + const transaction = db.transaction(STORE_NAME, dryRun ? 'readonly' : 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + const getAllRequest = store.getAll(); + + return new Promise((resolve) => { + getAllRequest.onsuccess = () => { + const entries = getAllRequest.result as CacheEntry[]; + const entriesToPrune: CacheEntry[] = []; + + if (maxAge) { + const cutoffTime = Date.now() - maxAge; + const agedEntries = entries.filter((entry) => entry.timestamp < cutoffTime); + entriesToPrune.push(...agedEntries); + } + + if (maxEntries && entries.length > maxEntries) { + const sortedEntries = [...entries].sort((a, b) => a.timestamp - b.timestamp); + const excessEntries = sortedEntries.slice(0, entries.length - maxEntries); + + const prunedKeys = new Set(entriesToPrune.map((e) => e.key)); + excessEntries.forEach((entry) => { + if (!prunedKeys.has(entry.key)) { + entriesToPrune.push(entry); + } + }); + } + + if (maxSizeInBytes) { + let currentSize = 0; + entries.forEach((entry) => { + const entryString = JSON.stringify(entry); + currentSize += entryString.length; + }); + + if (currentSize > maxSizeInBytes) { + const remainingEntries = entries + .filter((entry) => !entriesToPrune.some((e) => e.key === entry.key)) + .sort((a, b) => a.timestamp - b.timestamp); + + let sizeToRemove = currentSize - maxSizeInBytes; + const prunedKeys = new Set(entriesToPrune.map((e) => e.key)); + + for (const entry of remainingEntries) { + if (sizeToRemove <= 0) break; + + if (!prunedKeys.has(entry.key)) { + const entryString = JSON.stringify(entry); + const entrySize = entryString.length * 2; + + entriesToPrune.push(entry); + prunedKeys.add(entry.key); + sizeToRemove -= entrySize; + } + } + } + } + + const pruneCount = entriesToPrune.length; + + if (!dryRun && pruneCount > 0) { + entriesToPrune.forEach((entry) => { + store.delete(entry.key); + + delete memoryCache[entry.key]; + delete memoryTimestamps[entry.key]; + }); + } + + resolve(pruneCount); + }; + + getAllRequest.onerror = () => { + resolve(0); + }; + + transaction.oncomplete = () => { + db.close(); + }; + }); + } catch (error) { + console.error('Error pruning cache:', error); + return 0; + } +}; + +export const initCache = async ( + options: { + preload?: boolean; + preloadOptions?: { + maxAge?: number; + maxEntries?: number; + onlyLoadProviders?: string[]; + onlyLoadLanguages?: { source?: string[]; target?: string[] }; + }; + autoPrune?: boolean; + pruneInterval?: number; + pruneOptions?: { + maxAge?: number; + maxEntries?: number; + maxSizeInBytes?: number; + }; + } = {}, +): Promise<() => void> => { + const { + preload = true, + preloadOptions = { + maxAge: 7 * 24 * 60 * 60 * 1000, + maxEntries: 1000, + }, + autoPrune = true, + pruneInterval = 60 * 60 * 1000, + pruneOptions = { + maxAge: 30 * 24 * 60 * 60 * 1000, + maxEntries: 10000, + maxSizeInBytes: 10 * 1024 * 1024, + }, + } = options; + + if (preload) { + await loadCacheFromDB(preloadOptions); + } + + let intervalId: number | null = null; + + if (autoPrune) { + await pruneCache(pruneOptions); + + intervalId = window.setInterval(async () => { + await pruneCache(pruneOptions); + }, pruneInterval); + } + + return () => { + if (intervalId !== null) { + clearInterval(intervalId); + } + }; +}; + +let cleanupFunction: (() => void) | null = null; + +if (typeof window !== 'undefined') { + initCache().then((cleanup) => { + cleanupFunction = cleanup; + }); + + window.addEventListener('beforeunload', () => { + if (cleanupFunction) { + cleanupFunction(); + } + }); +} diff --git a/apps/readest-app/src/services/translators/index.ts b/apps/readest-app/src/services/translators/index.ts new file mode 100644 index 00000000..7c1b4ea0 --- /dev/null +++ b/apps/readest-app/src/services/translators/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './cache'; +export * from './providers'; diff --git a/apps/readest-app/src/services/translators/providers/deepl.ts b/apps/readest-app/src/services/translators/providers/deepl.ts new file mode 100644 index 00000000..fb0339fd --- /dev/null +++ b/apps/readest-app/src/services/translators/providers/deepl.ts @@ -0,0 +1,57 @@ +import { getAPIBaseUrl } from '@/services/environment'; +import { TranslationProvider } from '../types'; + +const DEEPL_API_ENDPOINT = getAPIBaseUrl() + '/deepl/translate'; + +export const deeplProvider: TranslationProvider = { + name: 'deepl', + translate: async ( + text: string[], + sourceLang: string, + targetLang: string, + token?: string | null, + useCache: boolean = false, + ): Promise => { + try { + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(DEEPL_API_ENDPOINT, { + method: 'POST', + headers, + body: JSON.stringify({ + text: text, + source_lang: sourceLang, + target_lang: targetLang, + use_cache: useCache, + }), + }); + + if (!response.ok) { + throw new Error(`Translation failed with status ${response.status}`); + } + + const data = await response.json(); + + const result = [...text]; + let translationIndex = 0; + + for (let i = 0; i < text.length; i++) { + if (text[i]!.trim().length > 0) { + result[i] = data.translations?.[translationIndex]?.text || text[i]; + translationIndex++; + } + } + + return result; + } catch (error) { + console.error('Translation error:', error); + return text; + } + }, +}; diff --git a/apps/readest-app/src/services/translators/providers/index.ts b/apps/readest-app/src/services/translators/providers/index.ts new file mode 100644 index 00000000..6ce634da --- /dev/null +++ b/apps/readest-app/src/services/translators/providers/index.ts @@ -0,0 +1 @@ +export * from './deepl'; diff --git a/apps/readest-app/src/services/translators/types.ts b/apps/readest-app/src/services/translators/types.ts new file mode 100644 index 00000000..b04b799c --- /dev/null +++ b/apps/readest-app/src/services/translators/types.ts @@ -0,0 +1,20 @@ +export interface TranslationProvider { + name: string; + translate: ( + texts: string[], + sourceLang: string, + targetLang: string, + token?: string | null, + useCache?: boolean, + ) => Promise; +} + +export interface TranslationCache { + [key: string]: string; +} + +export interface UseTranslatorOptions { + provider?: TranslationProvider; + sourceLang?: string; + targetLang?: string; +} diff --git a/apps/readest-app/src/utils/misc.ts b/apps/readest-app/src/utils/misc.ts index 109926be..3e8f5d6c 100644 --- a/apps/readest-app/src/utils/misc.ts +++ b/apps/readest-app/src/utils/misc.ts @@ -31,8 +31,12 @@ export const makeSafeFilename = (filename: string, replacement = '_') => { return safeName.trim(); }; +export const getLocale = () => { + return localStorage?.getItem('i18nextLng') || navigator?.language || ''; +}; + export const getUserLang = () => { - const locale = localStorage?.getItem('i18nextLng') || navigator?.language || ''; + const locale = getLocale(); return locale.split('-')[0] || 'en'; };