Offline Queue: IndexedDB PhotoQueue, offline routing, SW caching + sync

- PhotoQueue class with IndexedDB store (queued/uploading/done/failed)
- uploadSelected() and startBulkProcess() check navigator.onLine and queue offline
- Online event auto-drains queue via /api/bulk-process with exponential backoff
- Offline banner shows queued count, hides when empty
- Service Worker: cache-first static, network-first API, background sync listener
- Exponential backoff: 2s → 5s → 15s, then mark failed after 3 retries
This commit is contained in:
2026-05-25 19:28:50 -04:00
parent 80619eb993
commit 7c8eb458ad
2 changed files with 282 additions and 0 deletions
+233
View File
@@ -4,6 +4,14 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>EXIF Scanner — Find GPS Photos</title>
<script>
// Service Worker registration
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
}
</script>
<script src="https://cdn.jsdelivr.net/npm/exifr@7/dist/lite.umd.js"></script>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
@@ -308,6 +316,9 @@
<span class="label">Tap to Select Photos</span>
<span class="hint">Pick from gallery — multi-select enabled</span>
</div>
<div id="offlineBanner" style="display:none;background:var(--amber);color:#000;text-align:center;padding:8px;border-radius:var(--radius-sm);margin-bottom:8px;font-size:12px;font-weight:600;">
📡 Offline — <span id="offlineCount">0</span> photos queued
</div>
<input type="file" id="fileInput" accept="image/*" multiple>
<!-- Options -->
@@ -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 = '<div style="text-align:center;padding:12px;">📡 Photo queued — will upload when connection returns</div>';
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 = '<span class="spinner"></span> 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 =
'<div style="text-align:center;padding:20px;color:var(--amber);">📡 ' + gpsPhotos.length + ' photos queued — will upload when connection returns</div>';
return;
}
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> 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);
</script>
</body>
</html>
+49
View File
@@ -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" });
}
}