// NextSnap - Sync Engine 'use strict'; console.log('[SYNC] Loading sync.js...'); const Sync = { currentUsername: null, isOnline: navigator.onLine, isSyncing: false, MAX_RETRIES: 5, VERIFY_DELAY_MS: 800, init(username) { this.currentUsername = username; this.setupEventListeners(); console.log('[SYNC] Initialized for:', username); if (this.isOnline) { this.triggerSync(); } }, setupEventListeners() { window.addEventListener('online', () => { console.log('[SYNC] Network online'); this.isOnline = true; this.triggerSync(); }); window.addEventListener('offline', () => { console.log('[SYNC] Network offline'); this.isOnline = false; this.isSyncing = false; }); }, // Prevent page navigation during active upload _setUploading(active) { if (active) { this._beforeUnloadHandler = (e) => { e.preventDefault(); e.returnValue = 'Upload in progress - leaving will cancel it.'; return e.returnValue; }; window.addEventListener('beforeunload', this._beforeUnloadHandler); } else { if (this._beforeUnloadHandler) { window.removeEventListener('beforeunload', this._beforeUnloadHandler); this._beforeUnloadHandler = null; } } }, async triggerSync() { if (!this.isOnline || this.isSyncing) { console.log('[SYNC] Skip sync - online:', this.isOnline, 'syncing:', this.isSyncing); return; } console.log('[SYNC] Starting sync...'); this.isSyncing = true; try { await this.processQueue(); } catch (error) { console.error('[SYNC] Error:', error); } finally { this.isSyncing = false; this._setUploading(false); } }, async processQueue() { const pendingPhotos = await Storage.db.photos .where('username').equals(this.currentUsername) .and(photo => photo.status === 'pending' || photo.status === 'uploading') .sortBy('timestamp'); console.log('[SYNC] Found', pendingPhotos.length, 'photos to process'); if (pendingPhotos.length === 0) { return; } for (const photo of pendingPhotos) { if (!this.isOnline) { console.log('[SYNC] Lost connection, stopping'); break; } // Skip photos that have exceeded max retries const retryCount = photo.retryCount || 0; if (retryCount >= this.MAX_RETRIES) { console.warn('[SYNC] Skipping photo (max retries reached):', photo.filename, 'retries:', retryCount); await Storage.updatePhoto(photo.id, { status: 'failed' }); continue; } await this.uploadPhoto(photo); } }, async uploadPhoto(photo) { const retryCount = photo.retryCount || 0; try { console.log('[SYNC] Uploading:', photo.filename, '(attempt', retryCount + 1, 'of', this.MAX_RETRIES + ')'); // Validate blob before attempting upload if (!photo.blob || !(photo.blob instanceof Blob) || photo.blob.size === 0) { throw new Error('Photo data is missing or corrupted - please delete and re-capture'); } await Storage.updatePhoto(photo.id, { status: 'uploading' }); // Prevent page navigation during upload this._setUploading(true); // Only check for duplicates on retries (skip on first attempt to reduce latency) if (retryCount > 0) { const fullPath = photo.targetPath.replace(/\/$/, '') + '/' + photo.filename; const alreadyExists = await this.checkFileExists(fullPath); if (alreadyExists) { console.log('[SYNC] File already exists on server, skipping upload:', fullPath); await Storage.updatePhoto(photo.id, { status: 'verified', blob: null, completedAt: Date.now() }); await this.pruneHistory(); this._setUploading(false); return; } } // Resize if over 10MB let uploadBlob = photo.blob; if (uploadBlob.size > 10 * 1024 * 1024) { console.log('[SYNC] Photo too large (' + (uploadBlob.size / 1024 / 1024).toFixed(1) + 'MB), resizing...'); uploadBlob = await this.resizeImage(uploadBlob, 10 * 1024 * 1024); console.log('[SYNC] Resized to ' + (uploadBlob.size / 1024 / 1024).toFixed(1) + 'MB'); } const formData = new FormData(); formData.append('file', uploadBlob, photo.filename); const uploadUrl = '/api/files/upload?path=' + encodeURIComponent(photo.targetPath); const uploadResponse = await fetch(uploadUrl, { method: 'POST', body: formData }); if (!uploadResponse.ok) { let errorMsg = 'Upload failed'; try { const errData = await uploadResponse.json(); errorMsg = errData.error || errorMsg; } catch (e) { errorMsg = 'Upload failed (HTTP ' + uploadResponse.status + ')'; } throw new Error(errorMsg); } const uploadResult = await uploadResponse.json(); console.log('[SYNC] Upload successful:', uploadResult.path); await Storage.updatePhoto(photo.id, { status: 'uploaded' }); // Wait before verifying to allow server-side processing await this.delay(this.VERIFY_DELAY_MS); const verifyUrl = '/api/files/verify?path=' + encodeURIComponent(uploadResult.path); const verifyResponse = await fetch(verifyUrl); if (!verifyResponse.ok) { throw new Error('Verification failed'); } const verifyResult = await verifyResponse.json(); if (!verifyResult.exists) { throw new Error('File not found on server'); } console.log('[SYNC] Verified:', uploadResult.path); // Keep record but strip blob to save storage await Storage.updatePhoto(photo.id, { status: 'verified', blob: null, completedAt: Date.now() }); console.log('[SYNC] Upload complete:', photo.id); // Prune old completed entries beyond 20 await this.pruneHistory(); // Clear navigation guard after successful upload this._setUploading(false); } catch (error) { this._setUploading(false); console.error('[SYNC] Upload failed:', error, '(attempt', retryCount + 1 + ')'); await Storage.updatePhoto(photo.id, { status: 'pending', retryCount: retryCount + 1, lastError: error.message, error: error.message }); } }, async resizeImage(blob, maxBytes) { return new Promise((resolve, reject) => { const img = new Image(); const url = URL.createObjectURL(blob); img.onload = () => { URL.revokeObjectURL(url); let quality = 0.85; let scale = 1.0; const attempt = () => { const canvas = document.createElement('canvas'); canvas.width = Math.round(img.width * scale); canvas.height = Math.round(img.height * scale); const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); canvas.toBlob((result) => { if (!result) { reject(new Error('Failed to resize image')); return; } if (result.size <= maxBytes || (quality <= 0.4 && scale <= 0.3)) { resolve(result); } else { // Reduce quality first, then scale down if (quality > 0.5) { quality -= 0.1; } else { scale *= 0.8; quality = 0.7; } attempt(); } }, 'image/jpeg', quality); }; attempt(); }; img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Failed to load image for resize')); }; img.src = url; }); }, async pruneHistory() { // Keep only the last 20 completed (verified/failed) entries per user const completed = await Storage.db.photos .where('username').equals(this.currentUsername) .and(p => p.status === 'verified' || p.status === 'failed') .sortBy('timestamp'); if (completed.length > 20) { const toDelete = completed.slice(0, completed.length - 20); for (const photo of toDelete) { await Storage.deletePhoto(photo.id); } console.log('[SYNC] Pruned', toDelete.length, 'old history entries'); } }, async checkFileExists(path) { try { const verifyUrl = '/api/files/verify?path=' + encodeURIComponent(path); const response = await fetch(verifyUrl); if (!response.ok) return false; const result = await response.json(); return result.exists === true; } catch (e) { // If we can't check, assume it doesn't exist and proceed with upload return false; } }, delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } }; window.SyncEngine = Sync; console.log('[SYNC] SyncEngine exported successfully');