forked from akai/readest
refactor: use translator hook to support more translator providers (#1186)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user