438 lines
12 KiB
JavaScript
438 lines
12 KiB
JavaScript
/**
|
|
* 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
|
|
};
|