const CACHE_NAME = 'DictIA-cache-v4'; const ASSETS_TO_CACHE = [ '/', '/static/offline.html', '/static/manifest.json', '/static/css/styles.css', '/static/js/app.modular.js', '/static/js/i18n.js', '/static/js/csrf-refresh.js', '/static/img/icon-192x192.png', '/static/img/icon-512x512.png', '/static/img/favicon.ico', // Local vendor assets (no external CDN dependencies) '/static/vendor/js/tailwind.min.js', '/static/vendor/js/vue.global.js', '/static/vendor/js/marked.min.js', '/static/vendor/js/easymde.min.js', '/static/vendor/css/fontawesome.min.css', '/static/vendor/css/easymde.min.css' ]; // Function to update shortcuts (structure from your example) // The actual `lists` data would need to be sent from your client-side app.js const updateShortcuts = async (lists) => { if (!self.registration || !('shortcuts' in self.registration)) { console.log('Shortcuts API not supported or registration not available.'); return; } try { let shortcuts = [ { name: "New Recording", short_name: "New", description: "Upload or record new audio", url: "/#upload", // Or your direct upload page route icons: [{ src: "/static/img/icon-192x192.png", sizes: "192x192" }] }, { name: "View Gallery", short_name: "Gallery", description: "Access your recordings gallery", url: "/#gallery", // Or your direct gallery page route icons: [{ src: "/static/img/icon-192x192.png", sizes: "192x192" }] } ]; // Example: If you had dynamic lists to add as shortcuts if (Array.isArray(lists) && lists.length > 0) { const dynamicShortcuts = lists.slice(0, 2).map(list => { // Max 2 dynamic, total 4 if (list && list.id && list.title) { return { name: list.title, short_name: list.title.length > 10 ? list.title.substring(0, 9) + '…' : list.title, description: `View ${list.title}`, url: `/list/${list.id}`, // Example dynamic URL icons: [{ src: "/static/img/icon-192x192.png", sizes: "192x192" }] }; } return null; }).filter(Boolean); shortcuts = [...shortcuts, ...dynamicShortcuts]; } await self.registration.shortcuts.set(shortcuts); console.log('PWA shortcuts updated successfully:', shortcuts); } catch (error) { console.error('Error updating PWA shortcuts:', error); } }; // Cache first strategy: Respond from cache if available, otherwise fetch from network and cache. const cacheFirst = async (request) => { const responseFromCache = await caches.match(request); if (responseFromCache) { return responseFromCache; } try { const responseFromNetwork = await fetch(request); // Check if the response is valid before caching if (responseFromNetwork && responseFromNetwork.ok) { const cache = await caches.open(CACHE_NAME); cache.put(request, responseFromNetwork.clone()); } return responseFromNetwork; } catch (error) { console.error('CacheFirst: Network request failed for:', request.url, error); // For assets, returning a generic error or specific offline asset might be better than network error. // However, if it's a critical asset not found, this indicates an issue. return new Response('Network error trying to fetch asset.', { status: 408, headers: { 'Content-Type': 'text/plain' }, }); } }; // Stale-while-revalidate strategy: Respond from cache immediately if available, // then update the cache with a fresh response from the network. const staleWhileRevalidate = async (request) => { const cache = await caches.open(CACHE_NAME); const cachedResponsePromise = cache.match(request); const networkResponsePromise = fetch(request).then(networkResponse => { if (networkResponse && networkResponse.ok) { cache.put(request, networkResponse.clone()); } return networkResponse; }).catch(error => { console.error('StaleWhileRevalidate: Network request failed for:', request.url, error); // If network fails, we still might have a cached response. // If not, this error will propagate. return new Response('API request failed and no cache available.', { status: 503, // Service Unavailable headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ error: 'Service temporarily unavailable. Please try again later.' }) }); }); return (await cachedResponsePromise) || networkResponsePromise; }; // Network first strategy: Try to fetch from network first. // If network fails, fall back to cache. If cache also fails, serve offline page for navigation. const networkFirst = async (request) => { try { const networkResponse = await fetch(request); if (networkResponse && networkResponse.ok) { const cache = await caches.open(CACHE_NAME); cache.put(request, networkResponse.clone()); } return networkResponse; } catch (error) { console.warn('NetworkFirst: Network request failed for:', request.url, error); const cachedResponse = await caches.match(request); if (cachedResponse) { return cachedResponse; } // For navigation requests, fall back to the offline page if both network and cache fail. if (request.mode === 'navigate') { const offlinePage = await caches.match('/static/offline.html'); if (offlinePage) return offlinePage; } // For other types of requests, or if offline page isn't cached, re-throw or return error. return new Response('Network error and no cache available.', { status: 408, headers: { 'Content-Type': 'text/plain' }, }); } }; self.addEventListener('install', (event) => { self.skipWaiting(); // Activate new service worker immediately event.waitUntil( caches.open(CACHE_NAME).then((cache) => { console.log('Service Worker: Caching app shell'); return cache.addAll(ASSETS_TO_CACHE.map(url => new Request(url, { cache: 'reload' }))) // Force reload from network for app shell .catch(error => { console.error('Failed to cache app shell during install:', error); // You might want to log which specific asset failed ASSETS_TO_CACHE.forEach(url => { cache.add(new Request(url, { cache: 'reload' })).catch(err => console.warn(`Failed to cache: ${url}`, err)); }); }); }) ); }); self.addEventListener('activate', (event) => { event.waitUntil( caches.keys().then((cacheNames) => { return Promise.all( cacheNames .filter((name) => name !== CACHE_NAME) .map((name) => { console.log('Service Worker: Deleting old cache', name); return caches.delete(name); }) ); }).then(() => { console.log('Service Worker: Activated and old caches cleared.'); return self.clients.claim(); // Take control of all open clients }) ); }); self.addEventListener('fetch', (event) => { const request = event.request; const url = new URL(request.url); // Skip non-GET requests from caching strategies (they should pass through) if (request.method !== 'GET') { // event.respondWith(fetch(request)); // Let non-GET requests pass through to the network return; // Or simply return to let the browser handle it } // Serve API calls from /api/ with stale-while-revalidate // (excluding auth-related endpoints) if (url.pathname.startsWith('/api/')) { if (url.pathname.includes('/login') || url.pathname.includes('/logout') || url.pathname.includes('/auth')) { // For auth, always go to network, don't cache event.respondWith(fetch(request)); return; } event.respondWith(staleWhileRevalidate(request)); return; } // Serve /audio/ requests with cache-first, then network. // These are media files and can be large, so cache-first is good. if (url.pathname.startsWith('/audio/')) { event.respondWith(cacheFirst(request)); return; } // Handle navigation requests (HTML pages) with network-first, then cache, then offline page. if (request.mode === 'navigate') { event.respondWith(networkFirst(request)); return; } // For static assets listed in ASSETS_TO_CACHE, use cache-first. // This ensures that if an asset path is directly requested, it's served from cache if possible. // We need to match against the origin + pathname for ASSETS_TO_CACHE. const requestPath = url.origin === self.origin ? url.pathname : request.url; if (ASSETS_TO_CACHE.includes(requestPath)) { event.respondWith(cacheFirst(request)); return; } // Default strategy for other GET requests: try cache, then network. // This is a good general fallback for other static assets not explicitly listed // or for assets from other origins if not handled by ASSETS_TO_CACHE. event.respondWith( caches.match(request).then((cachedResponse) => { if (cachedResponse) { return cachedResponse; } return fetch(request).then(networkResponse => { // Optionally cache other successful GET responses here if desired // if (networkResponse && networkResponse.ok) { // const cache = await caches.open(CACHE_NAME); // cache.put(request, networkResponse.clone()); // } return networkResponse; }).catch(() => { // If network fails for a non-navigation, non-API, non-explicitly-cached asset // there isn't much we can do other than return an error or nothing. // For simplicity, let the browser handle the error. }); }) ); }); // Listen for messages from the client (e.g., to update shortcuts) self.addEventListener('message', (event) => { if (event.data && event.data.type === 'UPDATE_SHORTCUTS') { console.log('Service Worker: Received UPDATE_SHORTCUTS message:', event.data.lists); // updateShortcuts(event.data.lists); // Call if you implement dynamic shortcuts based on client data } if (event.data && event.data.type === 'SKIP_WAITING') { self.skipWaiting(); } }); // Background sync for failed uploads self.addEventListener('sync', (event) => { console.log('[Service Worker] Background sync triggered:', event.tag); if (event.tag === 'sync-uploads') { event.waitUntil(syncFailedUploads()); } }); // IndexedDB helper for failed uploads async function openFailedUploadsDB() { return new Promise((resolve, reject) => { const request = indexedDB.open('SpeakrFailedUploads', 1); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result); request.onupgradeneeded = (event) => { const db = event.target.result; if (!db.objectStoreNames.contains('failedUploads')) { const objectStore = db.createObjectStore('failedUploads', { keyPath: 'id', autoIncrement: true }); objectStore.createIndex('timestamp', 'timestamp', { unique: false }); objectStore.createIndex('clientId', 'clientId', { unique: false }); } }; }); } // Get all failed uploads from IndexedDB async function getFailedUploads(db) { return new Promise((resolve, reject) => { const transaction = db.transaction(['failedUploads'], 'readonly'); const objectStore = transaction.objectStore('failedUploads'); const request = objectStore.getAll(); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } // Delete a failed upload after successful retry async function deleteFailedUpload(db, id) { return new Promise((resolve, reject) => { const transaction = db.transaction(['failedUploads'], 'readwrite'); const objectStore = transaction.objectStore('failedUploads'); const request = objectStore.delete(id); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); } // Update retry count for a failed upload async function updateRetryCount(db, id, retryCount, error) { return new Promise(async (resolve, reject) => { try { const transaction = db.transaction(['failedUploads'], 'readwrite'); const objectStore = transaction.objectStore('failedUploads'); const getRequest = objectStore.get(id); getRequest.onsuccess = () => { const upload = getRequest.result; if (!upload) { reject(new Error('Upload not found')); return; } upload.retryCount = retryCount; upload.lastRetry = Date.now(); if (error) { upload.lastError = error; } const putRequest = objectStore.put(upload); putRequest.onsuccess = () => resolve(); putRequest.onerror = () => reject(putRequest.error); }; getRequest.onerror = () => reject(getRequest.error); } catch (error) { reject(error); } }); } // Retry uploading a failed upload async function retryUpload(upload) { const formData = new FormData(); // Reconstruct File from ArrayBuffer const file = new File([upload.fileData], upload.fileName, { type: upload.mimeType }); formData.append('file', file); if (upload.notes) { formData.append('notes', upload.notes); } if (upload.tags && upload.tags.length > 0) { upload.tags.forEach(tag => { formData.append('tags[]', JSON.stringify(tag)); }); } if (upload.asrOptions) { if (upload.asrOptions.language) { formData.append('asr_language', upload.asrOptions.language); } if (upload.asrOptions.min_speakers) { formData.append('asr_min_speakers', upload.asrOptions.min_speakers); } if (upload.asrOptions.max_speakers) { formData.append('asr_max_speakers', upload.asrOptions.max_speakers); } } // Get CSRF token from cookies const csrfToken = getCookie('csrf_access_token'); const headers = csrfToken ? { 'X-CSRF-TOKEN': csrfToken } : {}; const response = await fetch('/upload', { method: 'POST', headers: headers, body: formData, credentials: 'same-origin' }); if (!response.ok) { throw new Error(`Upload failed: ${response.status} ${response.statusText}`); } return response.json(); } // Get cookie value function getCookie(name) { const value = `; ${self.cookies || ''}`; const parts = value.split(`; ${name}=`); if (parts.length === 2) return parts.pop().split(';').shift(); return null; } // Sync failed uploads from IndexedDB async function syncFailedUploads() { console.log('[Service Worker] Syncing failed uploads'); try { const db = await openFailedUploadsDB(); const failedUploads = await getFailedUploads(db); if (failedUploads.length === 0) { console.log('[Service Worker] No failed uploads to retry'); return Promise.resolve(); } console.log(`[Service Worker] Found ${failedUploads.length} failed uploads to retry`); // Notify that sync started await self.registration.showNotification('DictIA Upload Sync', { body: `Retrying ${failedUploads.length} failed upload(s)...`, icon: '/static/img/icon-192x192.png', badge: '/static/img/icon-192x192.png', tag: 'upload-sync', requireInteraction: false }); let successCount = 0; let failCount = 0; for (const upload of failedUploads) { try { // Limit retries to 3 attempts if (upload.retryCount >= 3) { console.log(`[Service Worker] Upload ${upload.id} exceeded retry limit (${upload.retryCount})`); failCount++; continue; } console.log(`[Service Worker] Retrying upload ${upload.id} (attempt ${upload.retryCount + 1})`); await retryUpload(upload); // Success - delete from IndexedDB await deleteFailedUpload(db, upload.id); successCount++; console.log(`[Service Worker] Successfully retried upload ${upload.id}`); } catch (error) { // Update retry count await updateRetryCount(db, upload.id, upload.retryCount + 1, error.message); failCount++; console.error(`[Service Worker] Failed to retry upload ${upload.id}:`, error); } } // Show final notification await self.registration.showNotification('DictIA Upload Sync Complete', { body: `${successCount} succeeded, ${failCount} failed`, icon: '/static/img/icon-192x192.png', badge: '/static/img/icon-192x192.png', tag: 'upload-sync-complete', requireInteraction: false }); return Promise.resolve(); } catch (error) { console.error('[Service Worker] Failed to sync uploads:', error); await self.registration.showNotification('DictIA Upload Sync Failed', { body: 'Could not sync failed uploads. Will retry later.', icon: '/static/img/icon-192x192.png', badge: '/static/img/icon-192x192.png', tag: 'upload-sync-error', requireInteraction: false }); return Promise.reject(error); } } // Push notification handler self.addEventListener('push', (event) => { console.log('[Service Worker] Push notification received'); const options = { icon: '/static/img/icon-192x192.png', badge: '/static/img/icon-192x192.png', vibrate: [200, 100, 200], data: { dateOfArrival: Date.now(), primaryKey: 1 } }; if (event.data) { const data = event.data.json(); event.waitUntil( self.registration.showNotification(data.title || 'DictIA Notification', { body: data.body || 'You have a new notification', ...options, data: data }) ); } else { event.waitUntil( self.registration.showNotification('DictIA Notification', { body: 'You have a new notification', ...options }) ); } }); // Notification click handler self.addEventListener('notificationclick', (event) => { console.log('[Service Worker] Notification clicked:', event.notification.tag); event.notification.close(); // Handle different notification types const urlToOpen = event.notification.data?.url || '/'; event.waitUntil( clients.matchAll({ type: 'window', includeUncontrolled: true }) .then((clientList) => { // Check if there's already a window open for (const client of clientList) { if (client.url === urlToOpen && 'focus' in client) { return client.focus(); } } // If no window is open, open a new one if (clients.openWindow) { return clients.openWindow(urlToOpen); } }) ); });