A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
344 lines
10 KiB
JavaScript
344 lines
10 KiB
JavaScript
/**
|
|
* 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');
|