refactor: use translator hook to support more translator providers (#1186)

This commit is contained in:
Huang Xin
2025-05-19 00:35:37 +08:00
committed by GitHub
parent 967536aa23
commit b83972e366
9 changed files with 851 additions and 31 deletions
@@ -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<DeepLPopupProps> = ({
const [detectedSourceLang, setDetectedSourceLang] = useState<
keyof typeof TRANSLATOR_LANGS | null
>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { translate } = useTranslator({
sourceLang,
targetLang,
});
const handleSourceLangChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
setSourceLang(event.target.value);
@@ -73,35 +76,18 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
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) {
@@ -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<number | null>(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);
+131
View File
@@ -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<string[]> => {
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,
};
}
@@ -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<string, number> = {};
const openDatabase = (): Promise<IDBDatabase> => {
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<void> => {
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<CacheEntry[]>((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<string | null> => {
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<void> => {
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<number> => {
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<number> => {
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();
}
});
}
@@ -0,0 +1,3 @@
export * from './types';
export * from './cache';
export * from './providers';
@@ -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<string[]> => {
try {
const headers: Record<string, string> = {
'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;
}
},
};
@@ -0,0 +1 @@
export * from './deepl';
@@ -0,0 +1,20 @@
export interface TranslationProvider {
name: string;
translate: (
texts: string[],
sourceLang: string,
targetLang: string,
token?: string | null,
useCache?: boolean,
) => Promise<string[]>;
}
export interface TranslationCache {
[key: string]: string;
}
export interface UseTranslatorOptions {
provider?: TranslationProvider;
sourceLang?: string;
targetLang?: string;
}
+5 -1
View File
@@ -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';
};