/** * BreakPilot PWA Service Worker * * Funktionen: * 1. Cacht statische Assets fuer Offline-Nutzung * 2. Cacht LLM-Modell fuer lokale Header-Extraktion * 3. Background Sync fuer Korrektur-Uploads */ const APP_CACHE = 'breakpilot-app-v1'; const LLM_CACHE = 'breakpilot-llm-v1'; const DATA_CACHE = 'breakpilot-data-v1'; // Statische Assets zum Cachen beim Install const STATIC_ASSETS = [ '/', '/app', '/static/css/main.css', '/static/js/main.js' ]; // LLM/OCR Assets (groessere Dateien, separater Cache) const LLM_ASSETS = [ // Tesseract.js (OCR) 'https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js', 'https://cdn.jsdelivr.net/npm/tesseract.js-core@5.0.0/tesseract-core.wasm.js', // Tesseract Deutsch Sprachpaket 'https://tessdata.projectnaptha.com/4.0.0/deu.traineddata.gz', // Transformers.js (fuer zukuenftige Vision-Modelle) 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.1/dist/transformers.min.js' ]; // ============================================================ // INSTALL EVENT // ============================================================ self.addEventListener('install', (event) => { console.log('[ServiceWorker] Installing...'); event.waitUntil( Promise.all([ // App Cache caches.open(APP_CACHE).then((cache) => { console.log('[ServiceWorker] Caching app shell'); return cache.addAll(STATIC_ASSETS); }), // LLM Cache (optional, bei Fehler ignorieren) caches.open(LLM_CACHE).then((cache) => { console.log('[ServiceWorker] Pre-caching LLM assets'); return Promise.allSettled( LLM_ASSETS.map(url => cache.add(url).catch(err => { console.warn('[ServiceWorker] Failed to cache:', url, err); }) ) ); }) ]).then(() => { console.log('[ServiceWorker] Install complete'); return self.skipWaiting(); }) ); }); // ============================================================ // ACTIVATE EVENT // ============================================================ self.addEventListener('activate', (event) => { console.log('[ServiceWorker] Activating...'); event.waitUntil( // Alte Caches aufräumen caches.keys().then((cacheNames) => { return Promise.all( cacheNames .filter(name => { // Nur alte Versionen loeschen return name.startsWith('breakpilot-') && ![APP_CACHE, LLM_CACHE, DATA_CACHE].includes(name); }) .map(name => { console.log('[ServiceWorker] Deleting old cache:', name); return caches.delete(name); }) ); }).then(() => { console.log('[ServiceWorker] Claiming clients'); return self.clients.claim(); }) ); }); // ============================================================ // FETCH EVENT // ============================================================ self.addEventListener('fetch', (event) => { const url = new URL(event.request.url); // API-Requests: Network-First (immer frische Daten) if (url.pathname.startsWith('/api/')) { event.respondWith(networkFirstStrategy(event.request)); return; } // LLM/OCR Assets: Cache-First (grosse Dateien) if (isLLMAsset(url)) { event.respondWith(cacheFirstStrategy(event.request, LLM_CACHE)); return; } // Statische Assets: Cache-First mit Network-Fallback if (isStaticAsset(url)) { event.respondWith(cacheFirstStrategy(event.request, APP_CACHE)); return; } // Alles andere: Network-First event.respondWith(networkFirstStrategy(event.request)); }); // ============================================================ // CACHING STRATEGIES // ============================================================ /** * Cache-First Strategy: Versucht Cache, dann Network. * Gut fuer statische Assets und LLM-Modelle. */ async function cacheFirstStrategy(request, cacheName) { const cache = await caches.open(cacheName); const cachedResponse = await cache.match(request); if (cachedResponse) { // Im Hintergrund aktualisieren (Stale-While-Revalidate) fetchAndCache(request, cache); return cachedResponse; } // Nicht im Cache: Von Network holen und cachen try { const networkResponse = await fetch(request); if (networkResponse.ok) { cache.put(request, networkResponse.clone()); } return networkResponse; } catch (error) { console.error('[ServiceWorker] Fetch failed:', error); return new Response('Offline - Bitte Internetverbindung pruefen', { status: 503, statusText: 'Service Unavailable' }); } } /** * Network-First Strategy: Versucht Network, dann Cache. * Gut fuer API-Calls und dynamische Inhalte. */ async function networkFirstStrategy(request) { const cache = await caches.open(DATA_CACHE); try { const networkResponse = await fetch(request); // Erfolgreiche GET-Requests cachen if (networkResponse.ok && request.method === 'GET') { cache.put(request, networkResponse.clone()); } return networkResponse; } catch (error) { // Network failed: Versuche Cache const cachedResponse = await cache.match(request); if (cachedResponse) { return cachedResponse; } // Kein Cache: Offline-Fehler return new Response(JSON.stringify({ error: 'offline', message: 'Keine Internetverbindung' }), { status: 503, headers: { 'Content-Type': 'application/json' } }); } } /** * Holt Resource von Network und cached sie (im Hintergrund). */ async function fetchAndCache(request, cache) { try { const response = await fetch(request); if (response.ok) { cache.put(request, response.clone()); } } catch (error) { // Ignorieren - Cache ist noch gueltig } } // ============================================================ // HELPERS // ============================================================ function isStaticAsset(url) { return url.pathname.startsWith('/static/') || url.pathname.endsWith('.css') || url.pathname.endsWith('.js') || url.pathname.endsWith('.png') || url.pathname.endsWith('.jpg') || url.pathname.endsWith('.svg') || url.pathname.endsWith('.woff2'); } function isLLMAsset(url) { const llmDomains = [ 'cdn.jsdelivr.net', 'huggingface.co', 'tessdata.projectnaptha.com' ]; const llmExtensions = [ '.onnx', '.wasm', '.traineddata', '.traineddata.gz', '.bin' ]; return llmDomains.some(d => url.hostname.includes(d)) || llmExtensions.some(e => url.pathname.endsWith(e)); } // ============================================================ // BACKGROUND SYNC (fuer Offline-Uploads) // ============================================================ self.addEventListener('sync', (event) => { console.log('[ServiceWorker] Sync event:', event.tag); if (event.tag === 'upload-exams') { event.waitUntil(uploadPendingExams()); } }); async function uploadPendingExams() { // Hole ausstehende Uploads aus IndexedDB const db = await openDB('breakpilot-pending', 1); const pendingUploads = await db.getAll('uploads'); for (const upload of pendingUploads) { try { const response = await fetch(upload.url, { method: 'POST', body: upload.formData }); if (response.ok) { await db.delete('uploads', upload.id); // Benachrichtigung an Client self.clients.matchAll().then(clients => { clients.forEach(client => { client.postMessage({ type: 'upload-complete', id: upload.id }); }); }); } } catch (error) { console.error('[ServiceWorker] Upload failed:', error); } } } // Simple IndexedDB wrapper function openDB(name, version) { return new Promise((resolve, reject) => { const request = indexedDB.open(name, version); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result); request.onupgradeneeded = (event) => { const db = event.target.result; if (!db.objectStoreNames.contains('uploads')) { db.createObjectStore('uploads', { keyPath: 'id', autoIncrement: true }); } }; }); } // ============================================================ // PUSH NOTIFICATIONS (fuer Korrektur-Fortschritt) // ============================================================ self.addEventListener('push', (event) => { const data = event.data?.json() || {}; const options = { body: data.body || 'Neue Benachrichtigung', icon: '/static/icons/icon-192.png', badge: '/static/icons/badge-72.png', tag: data.tag || 'default', data: data.url || '/', actions: data.actions || [] }; event.waitUntil( self.registration.showNotification( data.title || 'BreakPilot', options ) ); }); self.addEventListener('notificationclick', (event) => { event.notification.close(); event.waitUntil( clients.matchAll({ type: 'window' }).then(clientList => { // Existierendes Fenster fokussieren for (const client of clientList) { if (client.url.includes('/app') && 'focus' in client) { return client.focus(); } } // Neues Fenster oeffnen if (clients.openWindow) { return clients.openWindow(event.notification.data || '/app'); } }) ); }); console.log('[ServiceWorker] Loaded');