/** * Photo Queue - Image compression and upload queue * * Handles image capture, compression, storage, and upload queueing for offline mode. * Converts HEIC (iOS) to JPEG, compresses images, and stores them for later upload. */ import { db, OperationType } from './db.js'; import { isOfflineModeEnabled, getDeviceId } from './offlineSettings.js'; // Import image compression from CDN const imageCompression = await import('https://cdn.jsdelivr.net/npm/browser-image-compression@2/dist/browser-image-compression.mjs').then(m => m.default); // Compression settings const COMPRESSION_OPTIONS = { maxSizeMB: 1, // Max 1MB after compression maxWidthOrHeight: 1920, // Max dimension useWebWorker: true, fileType: 'image/jpeg', initialQuality: 0.8 }; // Upload status export const UploadStatus = { PENDING: 'pending', COMPRESSING: 'compressing', UPLOADING: 'uploading', COMPLETED: 'completed', FAILED: 'failed' }; const API_BASE = '/MobileApp/Workorder/Workorder'; /** * Generate unique local ID */ function generateLocalId() { if (crypto.randomUUID) { return crypto.randomUUID(); } return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { const r = Math.random() * 16 | 0; const v = c === 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); } /** * Check if file is HEIC format (iOS) */ function isHeicFile(file) { const type = file.type?.toLowerCase() || ''; const name = file.name?.toLowerCase() || ''; return type === 'image/heic' || type === 'image/heif' || name.endsWith('.heic') || name.endsWith('.heif'); } /** * Convert HEIC to JPEG */ async function convertHeicToJpeg(file) { try { // Dynamically import heic2any const heic2any = await import('https://cdn.jsdelivr.net/npm/heic2any@0.0.4/dist/heic2any.min.js').then(m => m.default || window.heic2any); const result = await heic2any({ blob: file, toType: 'image/jpeg', quality: 0.9 }); // heic2any can return array or single blob const jpegBlob = Array.isArray(result) ? result[0] : result; // Create new file with .jpg extension const newName = file.name.replace(/\.(heic|heif)$/i, '.jpg'); return new File([jpegBlob], newName, { type: 'image/jpeg' }); } catch (error) { console.error('[PhotoQueue] HEIC conversion failed:', error); throw new Error('HEIC-Konvertierung fehlgeschlagen'); } } /** * Compress image file */ async function compressImage(file, onProgress) { try { // Handle HEIC files first let processedFile = file; if (isHeicFile(file)) { onProgress?.({ phase: 'converting', message: 'Konvertiere HEIC...' }); processedFile = await convertHeicToJpeg(file); } // Check if compression needed const fileSizeMB = processedFile.size / (1024 * 1024); if (fileSizeMB <= COMPRESSION_OPTIONS.maxSizeMB && processedFile.type === 'image/jpeg') { // Already small enough and correct format return processedFile; } onProgress?.({ phase: 'compressing', message: 'Komprimiere Bild...' }); const compressedFile = await imageCompression(processedFile, { ...COMPRESSION_OPTIONS, onProgress: (progress) => { onProgress?.({ phase: 'compressing', progress }); } }); console.log(`[PhotoQueue] Compressed ${file.name}: ${fileSizeMB.toFixed(2)}MB -> ${(compressedFile.size / (1024 * 1024)).toFixed(2)}MB`); return compressedFile; } catch (error) { console.error('[PhotoQueue] Compression failed:', error); throw new Error('Bildkomprimierung fehlgeschlagen'); } } /** * Queue photo for upload */ export async function queuePhoto(workorderId, file, documentType, description = '') { const localId = generateLocalId(); // Create initial entry const entry = { localId, workorderId, documentType, description, originalFileName: file.name, originalSize: file.size, mimeType: file.type, status: UploadStatus.COMPRESSING, progress: 0, createdAt: Date.now(), deviceId: getDeviceId(), error: null }; // Store entry await db.pendingFiles.add(entry); try { // Compress image const compressedFile = await compressImage(file, (progressInfo) => { // Update status db.pendingFiles.update(localId, { progress: progressInfo.progress || 0 }); }); // Convert to blob and store const blob = new Blob([compressedFile], { type: 'image/jpeg' }); await db.pendingFiles.update(localId, { status: UploadStatus.PENDING, blob, compressedSize: blob.size, fileName: compressedFile.name || `photo_${localId}.jpg`, progress: 100 }); console.log(`[PhotoQueue] Queued photo ${localId} for workorder ${workorderId}`); return { success: true, localId, message: 'Foto wird bei nächster Verbindung hochgeladen' }; } catch (error) { await db.pendingFiles.update(localId, { status: UploadStatus.FAILED, error: error.message }); return { success: false, localId, error: error.message }; } } /** * Get pending photos */ export async function getPendingPhotos() { return db.pendingFiles .where('status') .equals(UploadStatus.PENDING) .toArray(); } /** * Get pending photos for workorder */ export async function getWorkorderPendingPhotos(workorderId) { return db.pendingFiles .where('workorderId') .equals(workorderId) .toArray(); } /** * Get pending photos count */ export async function getPendingCount() { return db.pendingFiles .where('status') .anyOf([UploadStatus.PENDING, UploadStatus.UPLOADING]) .count(); } /** * Upload single photo */ export async function uploadPhoto(localId, onProgress) { const entry = await db.pendingFiles.get(localId); if (!entry || entry.status !== UploadStatus.PENDING) { return { success: false, error: 'Foto nicht gefunden oder bereits verarbeitet' }; } await db.pendingFiles.update(localId, { status: UploadStatus.UPLOADING }); try { const formData = new FormData(); formData.append('workorderId', entry.workorderId); formData.append('documentType', entry.documentType); formData.append('description', entry.description || ''); formData.append('files[]', entry.blob, entry.fileName); const response = await fetch(`${API_BASE}/uploadDocumentation`, { method: 'POST', credentials: 'include', body: formData }); const result = await response.json(); if (result.success) { // Mark as completed but keep for a while (cleanup later) await db.pendingFiles.update(localId, { status: UploadStatus.COMPLETED, completedAt: Date.now(), serverResponse: result }); console.log(`[PhotoQueue] Uploaded photo ${localId}`); return { success: true, localId, serverResponse: result }; } else { await db.pendingFiles.update(localId, { status: UploadStatus.FAILED, error: result.error || 'Upload fehlgeschlagen' }); return { success: false, localId, error: result.error }; } } catch (error) { console.error(`[PhotoQueue] Upload failed for ${localId}:`, error); await db.pendingFiles.update(localId, { status: UploadStatus.PENDING, // Back to pending for retry error: error.message }); return { success: false, localId, error: error.message, retriable: true }; } } /** * Upload all pending photos */ export async function uploadAllPending(onProgress) { const pending = await getPendingPhotos(); const total = pending.length; let uploaded = 0; let failed = 0; for (const photo of pending) { onProgress?.({ current: uploaded + 1, total, fileName: photo.fileName }); const result = await uploadPhoto(photo.localId); if (result.success) { uploaded++; } else { failed++; } } return { uploaded, failed, total }; } /** * Delete pending photo */ export async function deletePendingPhoto(localId) { await db.pendingFiles.delete(localId); console.log(`[PhotoQueue] Deleted pending photo ${localId}`); } /** * Delete all pending photos for workorder */ export async function deleteWorkorderPhotos(workorderId) { const count = await db.pendingFiles .where('workorderId') .equals(workorderId) .delete(); console.log(`[PhotoQueue] Deleted ${count} pending photos for workorder ${workorderId}`); return count; } /** * Retry failed photo */ export async function retryPhoto(localId) { await db.pendingFiles.update(localId, { status: UploadStatus.PENDING, error: null }); } /** * Cleanup completed photos older than specified time */ export async function cleanupCompleted(maxAgeMs = 24 * 60 * 60 * 1000) { const cutoff = Date.now() - maxAgeMs; const count = await db.pendingFiles .where('status') .equals(UploadStatus.COMPLETED) .filter(p => p.completedAt && p.completedAt < cutoff) .delete(); console.log(`[PhotoQueue] Cleaned up ${count} completed photos`); return count; } /** * Get thumbnail blob for pending photo (for preview) */ export async function getPhotoThumbnail(localId) { const entry = await db.pendingFiles.get(localId); if (!entry || !entry.blob) return null; // Create small thumbnail using canvas return new Promise((resolve) => { const img = new Image(); img.onload = () => { const canvas = document.createElement('canvas'); const maxSize = 200; let width = img.width; let height = img.height; if (width > height) { if (width > maxSize) { height = Math.round((height * maxSize) / width); width = maxSize; } } else { if (height > maxSize) { width = Math.round((width * maxSize) / height); height = maxSize; } } canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, width, height); canvas.toBlob(resolve, 'image/jpeg', 0.7); }; img.onerror = () => resolve(null); img.src = URL.createObjectURL(entry.blob); }); } /** * Get photo URL for display (creates object URL) */ export async function getPhotoUrl(localId) { const entry = await db.pendingFiles.get(localId); if (!entry || !entry.blob) return null; return URL.createObjectURL(entry.blob); } /** * Get storage size used by pending photos */ export async function getStorageUsed() { const photos = await db.pendingFiles.toArray(); let totalSize = 0; for (const photo of photos) { if (photo.blob) { totalSize += photo.blob.size; } } return totalSize; } /** * Check if there's enough storage for new photo */ export async function hasStorageSpace(estimatedSize = 2 * 1024 * 1024) { if (navigator.storage && navigator.storage.estimate) { const estimate = await navigator.storage.estimate(); const available = (estimate.quota || 0) - (estimate.usage || 0); const buffer = 10 * 1024 * 1024; // 10MB buffer return available > (estimatedSize + buffer); } return true; // Assume we have space if we can't check } export default { UploadStatus, queue: queuePhoto, getPending: getPendingPhotos, getWorkorderPhotos: getWorkorderPendingPhotos, getPendingCount, upload: uploadPhoto, uploadAll: uploadAllPending, delete: deletePendingPhoto, deleteForWorkorder: deleteWorkorderPhotos, retry: retryPhoto, cleanup: cleanupCompleted, getThumbnail: getPhotoThumbnail, getUrl: getPhotoUrl, getStorageUsed, hasSpace: hasStorageSpace };