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:
		| @@ -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>; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user