diff --git a/static/js/src/app.ts b/static/js/src/app.ts index bf6195b..5d29b63 100644 --- a/static/js/src/app.ts +++ b/static/js/src/app.ts @@ -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 = `

${cachedTranslation} (cached)

`; + 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) - .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 = `

Failed to translate. Please try again.

`; + + // 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 = `

You're offline. Only cached translations are available.

`; + } else { + translatedText.innerHTML = `

Failed to translate. Please try again.

`; + } 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 { + 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 { diff --git a/static/js/src/translationCache.ts b/static/js/src/translationCache.ts new file mode 100644 index 0000000..693a09c --- /dev/null +++ b/static/js/src/translationCache.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 []; + } + } +} \ No newline at end of file diff --git a/static/js/src/types.ts b/static/js/src/types.ts index 209cb2d..096565d 100644 --- a/static/js/src/types.ts +++ b/static/js/src/types.ts @@ -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; diff --git a/templates/index.html b/templates/index.html index dc8160b..182d3cd 100644 --- a/templates/index.html +++ b/templates/index.html @@ -286,6 +286,29 @@ Error notifications + +
+ +
Offline Cache
+
+
+ Cached translations: + 0 +
+
+ Cache size: + 0 KB +
+
+ + +
+ +