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
+
+
+
+
+ Enable offline caching
+
+
+
+ Clear Cache
+
+