diff --git a/static/index.html b/static/index.html index 83b3e51..e236375 100644 --- a/static/index.html +++ b/static/index.html @@ -4,6 +4,14 @@ EXIF Scanner β€” Find GPS Photos + @@ -308,6 +316,9 @@ Tap to Select Photos Pick from gallery β€” multi-select enabled + @@ -743,6 +754,18 @@ async function uploadSelected() { if (selectedIdx < 0 || !allPhotos[selectedIdx]) return; const file = allPhotos[selectedIdx].file; + // Offline: queue for later + if (!navigator.onLine) { + await photoQueue.add(file, file.name || 'photo.jpg'); + updateOfflineBanner(); + const section = document.getElementById('serverSection'); + const div = document.getElementById('serverResults'); + section.style.display = 'block'; + div.innerHTML = '
πŸ“‘ Photo queued β€” will upload when connection returns
'; + section.scrollIntoView({ behavior: 'smooth' }); + return; + } + const section = document.getElementById('serverSection'); const div = document.getElementById('serverResults'); section.style.display = 'block'; @@ -931,6 +954,22 @@ async function startBulkProcess() { if (!gpsPhotos.length) return; const btn = document.getElementById('bulkBtn'); + + // Offline: queue all photos for later + if (!navigator.onLine) { + btn.disabled = true; + btn.innerHTML = ' Queuing ' + gpsPhotos.length + ' photos...'; + for (const p of gpsPhotos) { + await photoQueue.add(p.file, p.file.name || 'photo.jpg'); + } + updateOfflineBanner(); + btn.disabled = false; + btn.textContent = 'πŸ” Bulk Process GPS Photos'; + document.getElementById('bulkResults').innerHTML = + '
πŸ“‘ ' + gpsPhotos.length + ' photos queued β€” will upload when connection returns
'; + return; + } + btn.disabled = true; btn.innerHTML = ' Processing ' + gpsPhotos.length + ' photos...'; @@ -1414,6 +1453,200 @@ function renderBulkResults(summary) { setTimeout(() => { if (bulkMap) bulkMap.invalidateSize(); }, 200); section.scrollIntoView({behavior:'smooth'}); } + +/* ═══════════════════════════════════════════ + Offline Queue β€” IndexedDB Photo Queue Manager + ═══════════════════════════════════════════ */ + +const DB_NAME = 'exif-scanner-queue'; +const DB_VERSION = 1; +const STORE_NAME = 'photos-queue'; + +class PhotoQueue { + constructor() { + this._ready = this._init(); + } + + async _init() { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = (e) => { + const db = e.target.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true }); + } + }; + req.onsuccess = (e) => { this._db = e.target.result; resolve(); }; + req.onerror = (e) => { console.error('PhotoQueue: DB open failed', e); reject(e); }; + }); + } + + async add(file, filename) { + await this._ready; + return new Promise((resolve, reject) => { + const tx = this._db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + store.add({ fileBlob: file, filename: filename, timestamp: Date.now(), retries: 0, status: 'queued' }); + tx.oncomplete = () => { + if ('serviceWorker' in navigator && navigator.serviceWorker.ready) { + navigator.serviceWorker.ready.then(r => { + try { r.sync.register('sync-photos'); } catch(e) { /* periodicSync not available */ } + }); + } + resolve(); + }; + tx.onerror = (e) => reject(e); + }); + } + + async getAll() { + await this._ready; + return new Promise((resolve, reject) => { + const tx = this._db.transaction(STORE_NAME, 'readonly'); + const store = tx.objectStore(STORE_NAME); + const req = store.getAll(); + req.onsuccess = () => resolve(req.result || []); + req.onerror = (e) => reject(e); + }); + } + + async count() { + await this._ready; + return new Promise((resolve, reject) => { + const tx = this._db.transaction(STORE_NAME, 'readonly'); + const store = tx.objectStore(STORE_NAME); + const req = store.count(); + req.onsuccess = () => resolve(req.result); + req.onerror = (e) => reject(e); + }); + } + + async remove(id) { + await this._ready; + return new Promise((resolve, reject) => { + const tx = this._db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + store.delete(id); + tx.oncomplete = () => resolve(); + tx.onerror = (e) => reject(e); + }); + } + + async updateRetry(id, retries) { + await this._ready; + return new Promise((resolve, reject) => { + const tx = this._db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + const getReq = store.get(id); + getReq.onsuccess = () => { + const item = getReq.result; + if (item) { + item.retries = retries; + item.status = retries >= 3 ? 'failed' : 'queued'; + store.put(item); + } + }; + tx.oncomplete = () => resolve(); + tx.onerror = (e) => reject(e); + }); + } + + async drain() { + const items = await this.getAll(); + const pending = items.filter(i => i.status === 'queued' || i.status === 'uploading'); + if (!pending.length) return { uploaded: 0, failed: 0 }; + + let uploaded = 0, failed = 0, attempt = 0; + for (const item of pending) { + attempt = item.retries || 0; + if (attempt >= 3) { await this.remove(item.id); failed++; continue; } + await this._markStatus(item.id, 'uploading'); + try { + const fd = new FormData(); + fd.append('files', item.fileBlob, item.filename || 'photo.jpg'); + const resp = await fetch('/api/bulk-process' + (window.getOcrParams ? getOcrParams() : ''), { method: 'POST', body: fd }); + if (resp.ok) { + await this.remove(item.id); + uploaded++; + } else { + const delay = [2000, 5000, 15000][attempt] || 15000; + await this._bumpRetry(item, delay); + failed++; + } + } catch (e) { + const delay = [2000, 5000, 15000][attempt] || 15000; + await this._bumpRetry(item, delay); + failed++; + } + } + return { uploaded, failed }; + } + + async _markStatus(id, status) { + const tx = this._db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + const req = store.get(id); + req.onsuccess = () => { const i = req.result; if (i) { i.status = status; store.put(i); } }; + return new Promise(r => tx.oncomplete = () => r()); + } + + async _bumpRetry(item, delayMs) { + item.retries = (item.retries || 0) + 1; + item.status = item.retries >= 3 ? 'failed' : 'queued'; + if (item.retries < 3) { + await new Promise(r => setTimeout(r, delayMs)); + } + const tx = this._db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + store.put(item); + return new Promise(r => tx.oncomplete = () => r()); + } +} + +const photoQueue = new PhotoQueue(); + +function updateOfflineBanner() { + const banner = document.getElementById('offlineBanner'); + const countSpan = document.getElementById('offlineCount'); + if (!banner || !countSpan) return; + if (navigator.onLine) { + banner.style.display = 'none'; + } else { + photoQueue.count().then(n => { + countSpan.textContent = n; + banner.style.display = n > 0 ? 'block' : 'none'; + }); + } +} + +// Online/offline listeners +window.addEventListener('online', async () => { + updateOfflineBanner(); + const countSpan = document.getElementById('offlineCount'); + if (countSpan) countSpan.textContent = 'Syncing…'; + const result = await photoQueue.drain(); + if (result.uploaded > 0) { + if (typeof reloadPreviousPhotos === 'function') reloadPreviousPhotos(); + else location.reload(); + } + updateOfflineBanner(); +}); + +window.addEventListener('offline', () => { + updateOfflineBanner(); +}); + +// Listen for SW sync messages +if (navigator.serviceWorker) { + navigator.serviceWorker.addEventListener('message', (event) => { + if (event.data && event.data.type === 'SYNC_TRIGGERED') { + photoQueue.drain().then(() => updateOfflineBanner()); + } + }); +} + +// Initialize banner on load +setTimeout(updateOfflineBanner, 500); diff --git a/static/sw.js b/static/sw.js new file mode 100644 index 0000000..44ece0a --- /dev/null +++ b/static/sw.js @@ -0,0 +1,49 @@ +// EXIF Scanner β€” Service Worker +// Cache-first for static assets, network-first for API. +const CACHE = "exif-cache-v1"; +const STATIC = ["/", "/static/"]; + +self.addEventListener("install", (e) => { + self.skipWaiting(); + e.waitUntil( + caches.open(CACHE).then((c) => c.addAll(["/", "/static/index.html"])) + ); +}); + +self.addEventListener("activate", (e) => { + e.waitUntil(clients.claim()); +}); + +self.addEventListener("fetch", (e) => { + const url = new URL(e.request.url); + // API calls: network-first + if (url.pathname.startsWith("/api/")) { + e.respondWith( + fetch(e.request).catch(() => { + return new Response( + JSON.stringify({ offline: true, error: "No network connection" }), + { status: 503, headers: { "Content-Type": "application/json" } } + ); + }) + ); + return; + } + // Static assets: cache-first + e.respondWith( + caches.match(e.request).then((r) => r || fetch(e.request)) + ); +}); + +// Background Sync +self.addEventListener("sync", (e) => { + if (e.tag === "sync-photos") { + e.waitUntil(syncPhotos()); + } +}); + +async function syncPhotos() { + const clients = await self.clients.matchAll(); + for (const client of clients) { + client.postMessage({ type: "SYNC_TRIGGERED" }); + } +}