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 charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
<title>EXIF Scanner — Find GPS Photos</title>
|
<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>
|
<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" />
|
<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>
|
<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="label">Tap to Select Photos</span>
|
||||||
<span class="hint">Pick from gallery — multi-select enabled</span>
|
<span class="hint">Pick from gallery — multi-select enabled</span>
|
||||||
</div>
|
</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>
|
<input type="file" id="fileInput" accept="image/*" multiple>
|
||||||
|
|
||||||
<!-- Options -->
|
<!-- Options -->
|
||||||
@@ -743,6 +754,18 @@ async function uploadSelected() {
|
|||||||
if (selectedIdx < 0 || !allPhotos[selectedIdx]) return;
|
if (selectedIdx < 0 || !allPhotos[selectedIdx]) return;
|
||||||
const file = allPhotos[selectedIdx].file;
|
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 section = document.getElementById('serverSection');
|
||||||
const div = document.getElementById('serverResults');
|
const div = document.getElementById('serverResults');
|
||||||
section.style.display = 'block';
|
section.style.display = 'block';
|
||||||
@@ -931,6 +954,22 @@ async function startBulkProcess() {
|
|||||||
if (!gpsPhotos.length) return;
|
if (!gpsPhotos.length) return;
|
||||||
|
|
||||||
const btn = document.getElementById('bulkBtn');
|
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.disabled = true;
|
||||||
btn.innerHTML = '<span class="spinner"></span> Processing ' + gpsPhotos.length + ' photos...';
|
btn.innerHTML = '<span class="spinner"></span> Processing ' + gpsPhotos.length + ' photos...';
|
||||||
|
|
||||||
@@ -1414,6 +1453,200 @@ function renderBulkResults(summary) {
|
|||||||
setTimeout(() => { if (bulkMap) bulkMap.invalidateSize(); }, 200);
|
setTimeout(() => { if (bulkMap) bulkMap.invalidateSize(); }, 200);
|
||||||
section.scrollIntoView({behavior:'smooth'});
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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