Offline-first photo capture app for Nextcloud with: - Camera capture with continuous mode (auto-reopens after each photo) - File browser with fullscreen image gallery, swipe navigation, and rename - Upload queue with background sync engine - Admin panel for Nextcloud user management - Service worker for offline-first caching (v13) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
243 lines
7.9 KiB
JavaScript
243 lines
7.9 KiB
JavaScript
// SYNC.JS VERSION 8 - LOADING
|
|
console.log("[SYNC] Loading sync.js v8...");
|
|
// NextSnap - Sync Engine with Upload Queue and Retry Logic
|
|
'use strict';
|
|
|
|
const Sync = {
|
|
currentUsername: null,
|
|
isOnline: navigator.onLine,
|
|
isSyncing: false,
|
|
currentUpload: null,
|
|
retryTimeouts: {},
|
|
|
|
// Exponential backoff delays (in milliseconds)
|
|
retryDelays: [5000, 15000, 45000, 120000, 300000], // 5s, 15s, 45s, 2m, 5m
|
|
maxRetryDelay: 300000, // Cap at 5 minutes
|
|
|
|
init(username) {
|
|
this.currentUsername = username;
|
|
this.setupEventListeners();
|
|
|
|
// Check for pending uploads on init
|
|
if (this.isOnline) {
|
|
this.triggerSync();
|
|
}
|
|
},
|
|
|
|
setupEventListeners() {
|
|
// Listen for online/offline events
|
|
window.addEventListener('online', () => {
|
|
console.log('Network online - triggering sync');
|
|
this.isOnline = true;
|
|
this.updateConnectivityUI();
|
|
this.triggerSync();
|
|
});
|
|
|
|
window.addEventListener('offline', () => {
|
|
console.log('Network offline');
|
|
this.isOnline = false;
|
|
this.isSyncing = false;
|
|
this.updateConnectivityUI();
|
|
});
|
|
},
|
|
|
|
async triggerSync() {
|
|
if (!this.isOnline || this.isSyncing) {
|
|
return;
|
|
}
|
|
|
|
console.log('Starting sync...');
|
|
this.isSyncing = true;
|
|
this.updateConnectivityUI();
|
|
|
|
try {
|
|
await this.processQueue();
|
|
} catch (error) {
|
|
console.error('Sync error:', error);
|
|
} finally {
|
|
this.isSyncing = false;
|
|
this.updateConnectivityUI();
|
|
}
|
|
},
|
|
|
|
async processQueue() {
|
|
// Get pending and uploading photos (retry stalled uploads)
|
|
const pendingPhotos = await Storage.db.photos
|
|
.where('username').equals(this.currentUsername)
|
|
.and(photo => photo.status === 'pending' || photo.status === 'uploading')
|
|
.sortBy('timestamp');
|
|
|
|
if (pendingPhotos.length === 0) {
|
|
console.log('No pending photos to upload');
|
|
return;
|
|
}
|
|
|
|
console.log(`Found ${pendingPhotos.length} photos to upload`);
|
|
|
|
// Process uploads sequentially (one at a time)
|
|
for (const photo of pendingPhotos) {
|
|
if (!this.isOnline) {
|
|
console.log('Lost connection - stopping sync');
|
|
break;
|
|
}
|
|
|
|
await this.uploadPhoto(photo);
|
|
}
|
|
|
|
// Update UI
|
|
this.updatePendingCount();
|
|
this.updateRecentPhotos();
|
|
},
|
|
|
|
async uploadPhoto(photo) {
|
|
this.currentUpload = photo;
|
|
|
|
try {
|
|
console.log(`Uploading ${photo.filename}...`);
|
|
|
|
// Update status to uploading
|
|
await Storage.updatePhoto(photo.id, { status: 'uploading' });
|
|
this.updatePendingCount();
|
|
|
|
// Upload file
|
|
const formData = new FormData();
|
|
formData.append('file', photo.blob, photo.filename);
|
|
|
|
const uploadUrl = `/api/files/upload?path=${encodeURIComponent(photo.targetPath)}`;
|
|
|
|
const uploadResponse = await fetch(uploadUrl, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
if (!uploadResponse.ok) {
|
|
const error = await uploadResponse.json();
|
|
throw new Error(error.error || 'Upload failed');
|
|
}
|
|
|
|
const uploadResult = await uploadResponse.json();
|
|
console.log(`Upload successful: ${uploadResult.path}`);
|
|
|
|
// Update status to uploaded
|
|
await Storage.updatePhoto(photo.id, { status: 'uploaded' });
|
|
|
|
// Verify file exists on server
|
|
const verifyUrl = `/api/files/verify?path=${encodeURIComponent(uploadResult.path)}`;
|
|
|
|
const verifyResponse = await fetch(verifyUrl);
|
|
|
|
if (!verifyResponse.ok) {
|
|
throw new Error('Verification failed - file not found on server');
|
|
}
|
|
|
|
const verifyResult = await verifyResponse.json();
|
|
|
|
if (!verifyResult.exists) {
|
|
throw new Error('Verification failed - file does not exist');
|
|
}
|
|
|
|
console.log(`Verification successful: ${uploadResult.path}`);
|
|
|
|
// Update status to verified
|
|
await Storage.updatePhoto(photo.id, { status: 'verified' });
|
|
|
|
// Delete from IndexedDB (only after verification!)
|
|
await Storage.deletePhoto(photo.id);
|
|
console.log(`Deleted photo ${photo.id} from IndexedDB`);
|
|
|
|
// Clear any pending retry
|
|
if (this.retryTimeouts[photo.id]) {
|
|
clearTimeout(this.retryTimeouts[photo.id]);
|
|
delete this.retryTimeouts[photo.id];
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error(`Error uploading ${photo.filename}:`, error);
|
|
|
|
// Handle upload failure
|
|
await this.handleUploadFailure(photo, error.message);
|
|
} finally {
|
|
this.currentUpload = null;
|
|
}
|
|
},
|
|
|
|
async handleUploadFailure(photo, errorMessage) {
|
|
const retryCount = (photo.retryCount || 0) + 1;
|
|
|
|
// Update photo with error info
|
|
await Storage.updatePhoto(photo.id, {
|
|
status: 'pending',
|
|
retryCount: retryCount,
|
|
lastError: errorMessage
|
|
});
|
|
|
|
// Calculate retry delay using exponential backoff
|
|
const delayIndex = Math.min(retryCount - 1, this.retryDelays.length - 1);
|
|
const delay = this.retryDelays[delayIndex];
|
|
|
|
console.log(`Scheduling retry #${retryCount} in ${delay / 1000}s for ${photo.filename}`);
|
|
|
|
// Schedule retry
|
|
if (this.retryTimeouts[photo.id]) {
|
|
clearTimeout(this.retryTimeouts[photo.id]);
|
|
}
|
|
|
|
this.retryTimeouts[photo.id] = setTimeout(() => {
|
|
delete this.retryTimeouts[photo.id];
|
|
|
|
if (this.isOnline) {
|
|
console.log(`Retrying upload for ${photo.filename}`);
|
|
this.uploadPhoto(photo);
|
|
}
|
|
}, delay);
|
|
},
|
|
|
|
|
|
|
|
async updateRecentPhotos() {
|
|
if (typeof Camera !== 'undefined' && Camera.updateRecentPhotos) {
|
|
await Camera.updateRecentPhotos();
|
|
}
|
|
},
|
|
updateConnectivityUI() {
|
|
const indicator = document.querySelector(".connectivity-indicator");
|
|
if (!indicator) return;
|
|
indicator.classList.remove("online", "offline", "syncing");
|
|
if (!this.isOnline) {
|
|
indicator.classList.add("offline");
|
|
indicator.title = "Offline";
|
|
} else if (this.isSyncing) {
|
|
indicator.classList.add("syncing");
|
|
indicator.title = "Syncing...";
|
|
} else {
|
|
indicator.classList.add("online");
|
|
indicator.title = "Online";
|
|
}
|
|
},
|
|
|
|
async updatePendingCount() {
|
|
if (!this.currentUsername) return;
|
|
const countElement = document.getElementById("pendingCount");
|
|
const countValueElement = document.getElementById("pendingCountValue");
|
|
if (!countElement || !countValueElement) return;
|
|
const count = await Storage.getPhotoCount(this.currentUsername, "pending");
|
|
if (count > 0) {
|
|
countValueElement.textContent = count;
|
|
countElement.style.display = "block";
|
|
} else {
|
|
countElement.style.display = "none";
|
|
}
|
|
},
|
|
|
|
},
|
|
getState() {
|
|
return {
|
|
isOnline: this.isOnline,
|
|
isSyncing: this.isSyncing,
|
|
};
|
|
}
|
|
|
|
// Make Sync globally available as SyncEngine
|
|
window.SyncEngine = Sync;
|
|
console.log("[SYNC] SyncEngine exported:", typeof window.SyncEngine);
|