Add offline translation caching for seamless offline experience
- Implemented TranslationCache class with IndexedDB storage - Cache translations automatically with 30-day expiration - Added cache management UI in settings modal - Shows cache count and size - Toggle to enable/disable caching - Clear cache button - Check cache first before API calls (when enabled) - Automatic cleanup when reaching 1000 entries limit - Show "(cached)" indicator for cached translations - Works completely offline after translations are cached 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
05ad940079
commit
08791d2fed
@ -14,6 +14,7 @@ import {
|
||||
ServiceWorkerRegistrationExtended,
|
||||
BeforeInstallPromptEvent
|
||||
} from './types';
|
||||
import { TranslationCache } from './translationCache';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Register service worker
|
||||
@ -395,13 +396,41 @@ function initApp(): void {
|
||||
}
|
||||
|
||||
// Translate button click event
|
||||
translateBtn.addEventListener('click', function() {
|
||||
translateBtn.addEventListener('click', async function() {
|
||||
if (!currentSourceText) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if offline mode is enabled
|
||||
const offlineModeEnabled = localStorage.getItem('offlineMode') !== 'false';
|
||||
|
||||
if (offlineModeEnabled) {
|
||||
statusIndicator.textContent = 'Checking cache...';
|
||||
statusIndicator.classList.add('processing');
|
||||
|
||||
// Check cache first
|
||||
const cachedTranslation = await TranslationCache.getCachedTranslation(
|
||||
currentSourceText,
|
||||
sourceLanguage.value,
|
||||
targetLanguage.value
|
||||
);
|
||||
|
||||
if (cachedTranslation) {
|
||||
// Use cached translation
|
||||
console.log('Using cached translation');
|
||||
currentTranslationText = cachedTranslation;
|
||||
translatedText.innerHTML = `<p class="fade-in">${cachedTranslation} <small class="text-muted">(cached)</small></p>`;
|
||||
playTranslation.disabled = false;
|
||||
statusIndicator.textContent = 'Translation complete (from cache)';
|
||||
statusIndicator.classList.remove('processing');
|
||||
statusIndicator.classList.add('success');
|
||||
setTimeout(() => statusIndicator.classList.remove('success'), 2000);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// No cache hit, proceed with API call
|
||||
statusIndicator.textContent = 'Translating...';
|
||||
statusIndicator.classList.add('processing');
|
||||
showProgress();
|
||||
showLoadingOverlay('Translating to ' + targetLanguage.value + '...');
|
||||
|
||||
@ -419,7 +448,7 @@ function initApp(): void {
|
||||
body: JSON.stringify(requestBody)
|
||||
})
|
||||
.then(response => response.json() as Promise<TranslationResponse>)
|
||||
.then(data => {
|
||||
.then(async data => {
|
||||
hideProgress();
|
||||
|
||||
if (data.success && data.translation) {
|
||||
@ -431,7 +460,17 @@ function initApp(): void {
|
||||
statusIndicator.classList.add('success');
|
||||
setTimeout(() => statusIndicator.classList.remove('success'), 2000);
|
||||
|
||||
// Cache the translation in IndexedDB
|
||||
// Cache the translation for offline use if enabled
|
||||
if (offlineModeEnabled) {
|
||||
await TranslationCache.cacheTranslation(
|
||||
currentSourceText,
|
||||
sourceLanguage.value,
|
||||
data.translation,
|
||||
targetLanguage.value
|
||||
);
|
||||
}
|
||||
|
||||
// Also save to regular history
|
||||
saveToIndexedDB('translations', {
|
||||
sourceText: currentSourceText,
|
||||
sourceLanguage: sourceLanguage.value,
|
||||
@ -444,10 +483,18 @@ function initApp(): void {
|
||||
statusIndicator.textContent = 'Translation failed';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
.catch(async error => {
|
||||
hideProgress();
|
||||
console.error('Translation error:', error);
|
||||
translatedText.innerHTML = `<p class="text-danger">Failed to translate. Please try again.</p>`;
|
||||
|
||||
// Check if we're offline and try to find a similar cached translation
|
||||
if (!navigator.onLine) {
|
||||
statusIndicator.textContent = 'Offline - checking cache...';
|
||||
// Could implement fuzzy matching here for similar translations
|
||||
translatedText.innerHTML = `<p class="text-warning">You're offline. Only cached translations are available.</p>`;
|
||||
} else {
|
||||
translatedText.innerHTML = `<p class="text-danger">Failed to translate. Please try again.</p>`;
|
||||
}
|
||||
statusIndicator.textContent = 'Translation failed';
|
||||
});
|
||||
});
|
||||
@ -818,6 +865,47 @@ function initNotificationUI(swRegistration: ServiceWorkerRegistration): void {
|
||||
notifyTranscription.checked = localStorage.getItem('notifyTranscription') !== 'false';
|
||||
notifyTranslation.checked = localStorage.getItem('notifyTranslation') !== 'false';
|
||||
notifyErrors.checked = localStorage.getItem('notifyErrors') === 'true';
|
||||
|
||||
// Initialize cache management UI
|
||||
initCacheManagement();
|
||||
}
|
||||
|
||||
async function initCacheManagement(): Promise<void> {
|
||||
const cacheCount = document.getElementById('cacheCount') as HTMLSpanElement;
|
||||
const cacheSize = document.getElementById('cacheSize') as HTMLSpanElement;
|
||||
const offlineMode = document.getElementById('offlineMode') as HTMLInputElement;
|
||||
const clearCacheBtn = document.getElementById('clearCache') as HTMLButtonElement;
|
||||
|
||||
// Load cache stats
|
||||
async function updateCacheStats() {
|
||||
const stats = await TranslationCache.getCacheStats();
|
||||
cacheCount.textContent = stats.totalEntries.toString();
|
||||
cacheSize.textContent = `${(stats.totalSize / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
// Initial load
|
||||
updateCacheStats();
|
||||
|
||||
// Load offline mode preference
|
||||
offlineMode.checked = localStorage.getItem('offlineMode') !== 'false';
|
||||
|
||||
// Toggle offline mode
|
||||
offlineMode?.addEventListener('change', () => {
|
||||
localStorage.setItem('offlineMode', offlineMode.checked.toString());
|
||||
});
|
||||
|
||||
// Clear cache button
|
||||
clearCacheBtn?.addEventListener('click', async () => {
|
||||
if (confirm('Are you sure you want to clear all cached translations?')) {
|
||||
await TranslationCache.clearCache();
|
||||
await updateCacheStats();
|
||||
alert('Translation cache cleared successfully!');
|
||||
}
|
||||
});
|
||||
|
||||
// Update stats when modal is shown
|
||||
const settingsModal = document.getElementById('settingsModal');
|
||||
settingsModal?.addEventListener('show.bs.modal', updateCacheStats);
|
||||
}
|
||||
|
||||
async function subscribeToPushManager(swRegistration: ServiceWorkerRegistration): Promise<void> {
|
||||
|
241
static/js/src/translationCache.ts
Normal file
241
static/js/src/translationCache.ts
Normal file
@ -0,0 +1,241 @@
|
||||
// Translation cache management for offline support
|
||||
import { TranslationCacheEntry, CacheStats } from './types';
|
||||
|
||||
export class TranslationCache {
|
||||
private static DB_NAME = 'VoiceTranslatorDB';
|
||||
private static DB_VERSION = 2; // Increment version for cache store
|
||||
private static CACHE_STORE = 'translationCache';
|
||||
// private static MAX_CACHE_SIZE = 50 * 1024 * 1024; // 50MB limit - Reserved for future use
|
||||
private static MAX_ENTRIES = 1000; // Maximum number of cached translations
|
||||
private static CACHE_EXPIRY_DAYS = 30; // Expire entries after 30 days
|
||||
|
||||
// Generate cache key from input parameters
|
||||
static generateCacheKey(text: string, sourceLang: string, targetLang: string): string {
|
||||
// Normalize text and create a consistent key
|
||||
const normalizedText = text.trim().toLowerCase();
|
||||
return `${sourceLang}:${targetLang}:${normalizedText}`;
|
||||
}
|
||||
|
||||
// Open or create the cache database
|
||||
static async openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(this.DB_NAME, this.DB_VERSION);
|
||||
|
||||
request.onupgradeneeded = (event: IDBVersionChangeEvent) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
|
||||
// Create cache store if it doesn't exist
|
||||
if (!db.objectStoreNames.contains(this.CACHE_STORE)) {
|
||||
const store = db.createObjectStore(this.CACHE_STORE, { keyPath: 'key' });
|
||||
store.createIndex('timestamp', 'timestamp', { unique: false });
|
||||
store.createIndex('lastAccessed', 'lastAccessed', { unique: false });
|
||||
store.createIndex('sourceLanguage', 'sourceLanguage', { unique: false });
|
||||
store.createIndex('targetLanguage', 'targetLanguage', { unique: false });
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = (event: Event) => {
|
||||
resolve((event.target as IDBOpenDBRequest).result);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject('Failed to open translation cache database');
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Get cached translation
|
||||
static async getCachedTranslation(
|
||||
text: string,
|
||||
sourceLang: string,
|
||||
targetLang: string
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const db = await this.openDB();
|
||||
const transaction = db.transaction([this.CACHE_STORE], 'readwrite');
|
||||
const store = transaction.objectStore(this.CACHE_STORE);
|
||||
|
||||
const key = this.generateCacheKey(text, sourceLang, targetLang);
|
||||
const request = store.get(key);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
request.onsuccess = (event: Event) => {
|
||||
const entry = (event.target as IDBRequest).result as TranslationCacheEntry;
|
||||
|
||||
if (entry) {
|
||||
// Check if entry is not expired
|
||||
const expiryTime = entry.timestamp + (this.CACHE_EXPIRY_DAYS * 24 * 60 * 60 * 1000);
|
||||
if (Date.now() < expiryTime) {
|
||||
// Update access count and last accessed time
|
||||
entry.accessCount++;
|
||||
entry.lastAccessed = Date.now();
|
||||
store.put(entry);
|
||||
|
||||
console.log(`Cache hit for translation: ${sourceLang} -> ${targetLang}`);
|
||||
resolve(entry.targetText);
|
||||
} else {
|
||||
// Entry expired, delete it
|
||||
store.delete(key);
|
||||
resolve(null);
|
||||
}
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
console.error('Failed to get cached translation');
|
||||
resolve(null);
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Cache lookup error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Save translation to cache
|
||||
static async cacheTranslation(
|
||||
sourceText: string,
|
||||
sourceLang: string,
|
||||
targetText: string,
|
||||
targetLang: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const db = await this.openDB();
|
||||
const transaction = db.transaction([this.CACHE_STORE], 'readwrite');
|
||||
const store = transaction.objectStore(this.CACHE_STORE);
|
||||
|
||||
const key = this.generateCacheKey(sourceText, sourceLang, targetLang);
|
||||
const entry: TranslationCacheEntry = {
|
||||
key,
|
||||
sourceText,
|
||||
sourceLanguage: sourceLang,
|
||||
targetText,
|
||||
targetLanguage: targetLang,
|
||||
timestamp: Date.now(),
|
||||
accessCount: 1,
|
||||
lastAccessed: Date.now()
|
||||
};
|
||||
|
||||
// Check cache size before adding
|
||||
await this.ensureCacheSize(db);
|
||||
|
||||
store.put(entry);
|
||||
console.log(`Cached translation: ${sourceLang} -> ${targetLang}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to cache translation:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure cache doesn't exceed size limits
|
||||
static async ensureCacheSize(db: IDBDatabase): Promise<void> {
|
||||
const transaction = db.transaction([this.CACHE_STORE], 'readwrite');
|
||||
const store = transaction.objectStore(this.CACHE_STORE);
|
||||
|
||||
// Count entries
|
||||
const countRequest = store.count();
|
||||
|
||||
countRequest.onsuccess = async () => {
|
||||
const count = countRequest.result;
|
||||
|
||||
if (count >= this.MAX_ENTRIES) {
|
||||
// Delete least recently accessed entries
|
||||
const index = store.index('lastAccessed');
|
||||
const cursor = index.openCursor();
|
||||
let deleted = 0;
|
||||
const toDelete = Math.floor(count * 0.2); // Delete 20% of entries
|
||||
|
||||
cursor.onsuccess = (event: Event) => {
|
||||
const cursor = (event.target as IDBRequest).result;
|
||||
if (cursor && deleted < toDelete) {
|
||||
cursor.delete();
|
||||
deleted++;
|
||||
cursor.continue();
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Get cache statistics
|
||||
static async getCacheStats(): Promise<CacheStats> {
|
||||
try {
|
||||
const db = await this.openDB();
|
||||
const transaction = db.transaction([this.CACHE_STORE], 'readonly');
|
||||
const store = transaction.objectStore(this.CACHE_STORE);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const stats: CacheStats = {
|
||||
totalEntries: 0,
|
||||
totalSize: 0,
|
||||
oldestEntry: Date.now(),
|
||||
newestEntry: 0
|
||||
};
|
||||
|
||||
const countRequest = store.count();
|
||||
countRequest.onsuccess = () => {
|
||||
stats.totalEntries = countRequest.result;
|
||||
};
|
||||
|
||||
const cursor = store.openCursor();
|
||||
cursor.onsuccess = (event: Event) => {
|
||||
const cursor = (event.target as IDBRequest).result;
|
||||
if (cursor) {
|
||||
const entry = cursor.value as TranslationCacheEntry;
|
||||
// Estimate size (rough calculation)
|
||||
stats.totalSize += (entry.sourceText.length + entry.targetText.length) * 2;
|
||||
stats.oldestEntry = Math.min(stats.oldestEntry, entry.timestamp);
|
||||
stats.newestEntry = Math.max(stats.newestEntry, entry.timestamp);
|
||||
cursor.continue();
|
||||
} else {
|
||||
resolve(stats);
|
||||
}
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get cache stats:', error);
|
||||
return {
|
||||
totalEntries: 0,
|
||||
totalSize: 0,
|
||||
oldestEntry: 0,
|
||||
newestEntry: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all cache
|
||||
static async clearCache(): Promise<void> {
|
||||
try {
|
||||
const db = await this.openDB();
|
||||
const transaction = db.transaction([this.CACHE_STORE], 'readwrite');
|
||||
const store = transaction.objectStore(this.CACHE_STORE);
|
||||
store.clear();
|
||||
console.log('Translation cache cleared');
|
||||
} catch (error) {
|
||||
console.error('Failed to clear cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Export cache for backup
|
||||
static async exportCache(): Promise<TranslationCacheEntry[]> {
|
||||
try {
|
||||
const db = await this.openDB();
|
||||
const transaction = db.transaction([this.CACHE_STORE], 'readonly');
|
||||
const store = transaction.objectStore(this.CACHE_STORE);
|
||||
const request = store.getAll();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result);
|
||||
};
|
||||
request.onerror = () => {
|
||||
resolve([]);
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to export cache:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
@ -68,6 +68,24 @@ export interface TranslationRecord extends IndexedDBRecord {
|
||||
targetLanguage: string;
|
||||
}
|
||||
|
||||
export interface TranslationCacheEntry {
|
||||
key: string;
|
||||
sourceText: string;
|
||||
sourceLanguage: string;
|
||||
targetText: string;
|
||||
targetLanguage: string;
|
||||
timestamp: number;
|
||||
accessCount: number;
|
||||
lastAccessed: number;
|
||||
}
|
||||
|
||||
export interface CacheStats {
|
||||
totalEntries: number;
|
||||
totalSize: number;
|
||||
oldestEntry: number;
|
||||
newestEntry: number;
|
||||
}
|
||||
|
||||
// Service Worker types
|
||||
export interface PeriodicSyncManager {
|
||||
register(tag: string, options?: { minInterval: number }): Promise<void>;
|
||||
|
@ -286,6 +286,29 @@
|
||||
Error notifications
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h6>Offline Cache</h6>
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span>Cached translations:</span>
|
||||
<span id="cacheCount" class="badge bg-primary">0</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span>Cache size:</span>
|
||||
<span id="cacheSize" class="badge bg-secondary">0 KB</span>
|
||||
</div>
|
||||
<div class="form-check form-switch mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="offlineMode" checked>
|
||||
<label class="form-check-label" for="offlineMode">
|
||||
Enable offline caching
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" id="clearCache">
|
||||
<i class="fas fa-trash"></i> Clear Cache
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div id="settingsSaveStatus" class="text-success me-auto" style="display: none;">
|
||||
|
Loading…
Reference in New Issue
Block a user