diff --git a/app/static/js/storage.js b/app/static/js/storage.js index 596a876..0fbf03c 100644 --- a/app/static/js/storage.js +++ b/app/static/js/storage.js @@ -7,43 +7,56 @@ const Storage = { init() { // Initialize Dexie database this.db = new Dexie('nextsnap'); - + // Define schema with compound indexes this.db.version(1).stores({ photos: '++id, username, timestamp, filename, targetPath, status, [username+status], [username+timestamp]', settings: '++id, username, [username+key]' }); - + + // Request persistent storage so the browser won't evict our data + this.requestPersistence(); + return this.db.open(); }, + + async requestPersistence() { + if (navigator.storage && navigator.storage.persist) { + const granted = await navigator.storage.persist(); + console.log('[STORAGE] Persistence:', granted ? 'granted' : 'denied'); + } + }, async savePhoto(photoData) { - /** - * Save a photo to IndexedDB - * photoData: { - * username: string, - * timestamp: number, - * filename: string, - * targetPath: string, - * blob: Blob, - * status: 'pending' | 'uploading' | 'uploaded' | 'verified' - * retryCount: number, - * lastError: string - * } - */ + // Convert Blob to ArrayBuffer for storage resilience. + // iOS Safari can evict Blob file-backed data from IndexedDB, + // but ArrayBuffer is stored inline and won't be evicted. + let imageData = photoData.blob; + if (imageData instanceof Blob) { + imageData = await imageData.arrayBuffer(); + } + const id = await this.db.photos.add({ username: photoData.username, timestamp: photoData.timestamp, filename: photoData.filename, targetPath: photoData.targetPath, - blob: photoData.blob, + blob: imageData, status: photoData.status || 'pending', retryCount: photoData.retryCount || 0, lastError: photoData.lastError || null }); - + return id; }, + + // Convert stored photo data back to a Blob for upload or display. + // Handles both legacy Blob storage and new ArrayBuffer storage. + getBlob(photo) { + if (!photo || !photo.blob) return null; + if (photo.blob instanceof Blob) return photo.blob; + return new Blob([photo.blob], { type: 'image/jpeg' }); + }, async getPhoto(id) { return await this.db.photos.get(id); diff --git a/app/static/js/sync.js b/app/static/js/sync.js index 6d5b197..1e8374b 100644 --- a/app/static/js/sync.js +++ b/app/static/js/sync.js @@ -137,31 +137,18 @@ const Sync = { return true; } - const blob = freshPhoto.blob; + // 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 reference exists - if (!blob || !(blob instanceof Blob) || blob.size === 0) { + // 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'); } - // 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 @@ -294,19 +281,6 @@ const Sync = { } }, - 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); diff --git a/app/static/sw.js b/app/static/sw.js index f234262..968ae06 100644 --- a/app/static/sw.js +++ b/app/static/sw.js @@ -1,9 +1,9 @@ // NextSnap Service Worker // Provides offline-first caching for the app shell -const CACHE_VERSION = 'nextsnap-v14'; -const APP_SHELL_CACHE = 'nextsnap-shell-v10'; -const RUNTIME_CACHE = 'nextsnap-runtime-v10'; +const CACHE_VERSION = 'nextsnap-v15'; +const APP_SHELL_CACHE = 'nextsnap-shell-v11'; +const RUNTIME_CACHE = 'nextsnap-runtime-v11'; // Assets to cache on install const APP_SHELL_ASSETS = [ diff --git a/app/templates/capture.html b/app/templates/capture.html index 4b31417..8efdbcd 100644 --- a/app/templates/capture.html +++ b/app/templates/capture.html @@ -631,7 +631,9 @@ async function loadRecentPhotos() { container.innerHTML = ''; for (const photo of photos) { - const url = URL.createObjectURL(photo.blob); + const blob = Storage.getBlob(photo); + if (!blob) continue; + const url = URL.createObjectURL(blob); const statusClass = photo.status === 'verified' ? 'verified' : photo.status === 'uploading' ? 'uploading' : 'pending'; diff --git a/app/templates/queue.html b/app/templates/queue.html index 7d17bbf..fb5d37e 100644 --- a/app/templates/queue.html +++ b/app/templates/queue.html @@ -452,9 +452,10 @@ function createQueueItem(photo, isCompleted) { // Create thumbnail (use placeholder if blob was stripped) const thumbnail = document.createElement('div'); thumbnail.className = 'queue-item-thumbnail'; - if (photo.blob && photo.blob.size > 0) { + const photoBlob = Storage.getBlob(photo); + if (photoBlob && photoBlob.size > 0) { const img = document.createElement('img'); - img.src = URL.createObjectURL(photo.blob); + img.src = URL.createObjectURL(photoBlob); thumbnail.appendChild(img); } else { thumbnail.innerHTML = '' + @@ -472,8 +473,8 @@ function createQueueItem(photo, isCompleted) { const meta = document.createElement('div'); meta.className = 'queue-item-meta'; const date = new Date(photo.completedAt || photo.timestamp).toLocaleString(); - if (photo.blob && photo.blob.size > 0) { - const size = (photo.blob.size / 1024 / 1024).toFixed(2); + if (photoBlob && photoBlob.size > 0) { + const size = (photoBlob.size / 1024 / 1024).toFixed(2); meta.textContent = size + ' MB \u2022 ' + date; } else { meta.textContent = date; @@ -552,8 +553,9 @@ async function updateStats() { const pendingCount = photos.filter(p => p.status === 'pending').length; const uploadingCount = photos.filter(p => p.status === 'uploading').length; const totalSize = photos - .filter(p => p.blob && p.blob.size) - .reduce((sum, p) => sum + p.blob.size, 0) / 1024 / 1024; + .map(p => Storage.getBlob(p)) + .filter(b => b && b.size) + .reduce((sum, b) => sum + b.size, 0) / 1024 / 1024; document.getElementById('pending-stat').textContent = pendingCount; document.getElementById('uploading-stat').textContent = uploadingCount;