Files
nextsnap/app/static/js/sync.js
kamaji 06e90bbe4e Hide transient upload errors from queue UI
Transient network errors on first upload attempt showed ugly
'Network error: TypeError: Load failed' messages in the queue.
These are normal on iOS Safari and auto-resolve on retry.

- Clean up sync error messages (friendly text, no raw error types)
- Only show error messages in queue after 3+ retries or failed status
- Only show retry counter after 3+ retries
- First couple of retries are silent - just shows Pending status

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 21:11:21 -06:00

300 lines
10 KiB
JavaScript

// 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;
}
// Convert stored data (ArrayBuffer or legacy Blob) to a Blob for upload
const blob = Storage.getBlob(freshPhoto);
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 exists and has content
if (!blob || 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);
// 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');
}
console.error('[SYNC] Fetch threw:', fetchErr.name, fetchErr.message);
throw new Error('Connection lost - will retry');
}
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);
console.error('[SYNC] Upload failed:', error.message, '(attempt', retryCount + 1 + ')');
await Storage.updatePhoto(photo.id, {
status: 'pending',
retryCount: retryCount + 1,
lastError: error.message,
error: error.message
});
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 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');