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:
Adolfo Delorenzo 2025-06-02 21:56:31 -06:00
parent 05ad940079
commit 08791d2fed
4 changed files with 376 additions and 6 deletions

View File

@ -14,6 +14,7 @@ import {
ServiceWorkerRegistrationExtended, ServiceWorkerRegistrationExtended,
BeforeInstallPromptEvent BeforeInstallPromptEvent
} from './types'; } from './types';
import { TranslationCache } from './translationCache';
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Register service worker // Register service worker
@ -395,13 +396,41 @@ function initApp(): void {
} }
// Translate button click event // Translate button click event
translateBtn.addEventListener('click', function() { translateBtn.addEventListener('click', async function() {
if (!currentSourceText) { if (!currentSourceText) {
return; 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.textContent = 'Translating...';
statusIndicator.classList.add('processing');
showProgress(); showProgress();
showLoadingOverlay('Translating to ' + targetLanguage.value + '...'); showLoadingOverlay('Translating to ' + targetLanguage.value + '...');
@ -419,7 +448,7 @@ function initApp(): void {
body: JSON.stringify(requestBody) body: JSON.stringify(requestBody)
}) })
.then(response => response.json() as Promise<TranslationResponse>) .then(response => response.json() as Promise<TranslationResponse>)
.then(data => { .then(async data => {
hideProgress(); hideProgress();
if (data.success && data.translation) { if (data.success && data.translation) {
@ -431,7 +460,17 @@ function initApp(): void {
statusIndicator.classList.add('success'); statusIndicator.classList.add('success');
setTimeout(() => statusIndicator.classList.remove('success'), 2000); 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', { saveToIndexedDB('translations', {
sourceText: currentSourceText, sourceText: currentSourceText,
sourceLanguage: sourceLanguage.value, sourceLanguage: sourceLanguage.value,
@ -444,10 +483,18 @@ function initApp(): void {
statusIndicator.textContent = 'Translation failed'; statusIndicator.textContent = 'Translation failed';
} }
}) })
.catch(error => { .catch(async error => {
hideProgress(); hideProgress();
console.error('Translation error:', error); 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'; statusIndicator.textContent = 'Translation failed';
}); });
}); });
@ -818,6 +865,47 @@ function initNotificationUI(swRegistration: ServiceWorkerRegistration): void {
notifyTranscription.checked = localStorage.getItem('notifyTranscription') !== 'false'; notifyTranscription.checked = localStorage.getItem('notifyTranscription') !== 'false';
notifyTranslation.checked = localStorage.getItem('notifyTranslation') !== 'false'; notifyTranslation.checked = localStorage.getItem('notifyTranslation') !== 'false';
notifyErrors.checked = localStorage.getItem('notifyErrors') === 'true'; 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> { async function subscribeToPushManager(swRegistration: ServiceWorkerRegistration): Promise<void> {

View 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 [];
}
}
}

View File

@ -68,6 +68,24 @@ export interface TranslationRecord extends IndexedDBRecord {
targetLanguage: string; 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 // Service Worker types
export interface PeriodicSyncManager { export interface PeriodicSyncManager {
register(tag: string, options?: { minInterval: number }): Promise<void>; register(tag: string, options?: { minInterval: number }): Promise<void>;

View File

@ -286,6 +286,29 @@
Error notifications Error notifications
</label> </label>
</div> </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>
<div class="modal-footer"> <div class="modal-footer">
<div id="settingsSaveStatus" class="text-success me-auto" style="display: none;"> <div id="settingsSaveStatus" class="text-success me-auto" style="display: none;">