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>
301 lines
10 KiB
JavaScript
301 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: 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');
|