Files
thetool/public/mobile/shared/photoQueue.js
2026-01-27 10:35:15 +01:00

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
};