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:
@@ -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>
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user