// NextSnap - Sync Engine 'use strict'; console.log('[SYNC] Loading sync.js...'); const Sync = { currentUsername: null, isOnline: navigator.onLine, isSyncing: false, MAX_RETRIES: 15, VERIFY_DELAY_MS: 800, UPLOAD_TIMEOUT_MS: 120000, _retryTimerId: null, 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; if (this._retryTimerId) { clearTimeout(this._retryTimerId); this._retryTimerId = null; } }); }, // 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; let hadFailures = false; try { hadFailures = await this.processQueue(); } catch (error) { console.error('[SYNC] Error:', error); hadFailures = true; } finally { this.isSyncing = false; this._setUploading(false); } // If there were failures, schedule a retry cycle after a delay // so it doesn't block the current page load if (hadFailures && this.isOnline) { console.log('[SYNC] Scheduling retry in 15s...'); this._retryTimerId = setTimeout(() => { this._retryTimerId = null; this.triggerSync(); }, 15000); } }, 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 false; } let hadFailures = false; 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; } // No delay — upload immediately. Retries are handled by // scheduling a new triggerSync() after the full queue pass. const success = await this.uploadPhoto(photo); if (!success) hadFailures = true; } return hadFailures; }, async uploadPhoto(photo) { const retryCount = photo.retryCount || 0; try { // Re-read photo from IndexedDB to get a fresh blob reference // (iOS Safari can invalidate blob references between reads) const freshPhoto = await Storage.db.photos.get(photo.id); if (!freshPhoto || (freshPhoto.status !== 'pending' && freshPhoto.status !== 'uploading')) { console.log('[SYNC] Photo no longer pending, skipping:', photo.id); return true; } const blob = freshPhoto.blob; console.log('[SYNC] Uploading:', photo.filename, '(' + (blob ? (blob.size / 1024 / 1024).toFixed(1) + 'MB' : 'no blob') + ')', 'attempt', retryCount + 1, 'of', this.MAX_RETRIES); // Validate blob reference exists if (!blob || !(blob instanceof Blob) || blob.size === 0) { throw new Error('Photo data is missing or corrupted - please delete and re-capture'); } // Actually try to read the blob to verify data is accessible // (iOS Safari can evict blob data from IndexedDB while keeping the reference) const readable = await this.validateBlobReadable(blob); if (!readable) { // Blob data is gone — no amount of retrying will fix this console.error('[SYNC] Blob data is no longer readable:', photo.filename); await Storage.updatePhoto(photo.id, { status: 'failed', lastError: 'Photo data was lost by the browser - please re-capture', error: 'Photo data was lost by the browser - please re-capture' }); return false; } await Storage.updatePhoto(photo.id, { status: 'uploading' }); // Prevent page navigation during upload this._setUploading(true); // On retries, first check if a previous attempt actually succeeded // (the upload may have completed but the verify/status update was interrupted) 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 true; } } const formData = new FormData(); formData.append('file', blob, photo.filename); const uploadUrl = '/api/files/upload?path=' + encodeURIComponent(photo.targetPath); // Upload with timeout via AbortController const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.UPLOAD_TIMEOUT_MS); let uploadResponse; try { uploadResponse = await fetch(uploadUrl, { method: 'POST', body: formData, signal: controller.signal }); } catch (fetchErr) { clearTimeout(timeoutId); if (fetchErr.name === 'AbortError') { throw new Error('Upload timed out (' + (this.UPLOAD_TIMEOUT_MS / 1000) + 's)'); } // Capture detailed error info for debugging const errDetail = fetchErr.name + ': ' + fetchErr.message; console.error('[SYNC] Fetch threw:', errDetail); throw new Error('Network error: ' + errDetail); } clearTimeout(timeoutId); 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); return true; } catch (error) { this._setUploading(false); const errMsg = (error.name || 'Error') + ': ' + error.message; console.error('[SYNC] Upload failed:', errMsg, '(attempt', retryCount + 1 + ')'); await Storage.updatePhoto(photo.id, { status: 'pending', retryCount: retryCount + 1, lastError: errMsg, error: errMsg }); return false; } }, 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 validateBlobReadable(blob) { try { // Try to read the first byte — if iOS has evicted the data, // this will throw even though the Blob reference looks valid const slice = blob.slice(0, 1); const buf = await slice.arrayBuffer(); return buf.byteLength > 0; } catch (e) { console.error('[SYNC] Blob readability check failed:', e.message); return false; } }, 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) { return false; } }, delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } }; window.SyncEngine = Sync; console.log('[SYNC] SyncEngine exported successfully');