1// Cloud-to-device sync + offline read-API for the PWA. 2// 3// The site is Blazor Server, so the live pages can't render without 4// SignalR. This script drives the offline-cache half: it talks to 5// /api/sync/* on demand, mirrors the user's recipes, shopping lists and 6// meal plans into IndexedDB, then exposes a tiny read-only API the static 7// /offline-viewer/ pages call when the network is dead. 8// 9// Pull-only in v1 â local edits don't exist (the offline viewer is r/o) 10// so there's nothing to push back. 11// 12// Exposes window.iwe2eat = { sync, offline }. 13(function () { 14 'use strict'; 15 16 // IndexedDB is already origin-scoped, so prod (iwant2eat.com) and 17 // stage (stage.iwant2eat.com) get different stores out of the box â 18 // but we suffix the DB name with the hostname anyway so DevTools 19 // makes the distinction obvious to anyone debugging across both 20 // installs side-by-side. A user with PWAs from both origins on one 21 // device will see two clearly-named DBs instead of one ambiguous one. 22 const DB_NAME = 'iwe2eat-offline-' + (typeof location !== 'undefined' ? location.hostname : 'unknown'); 23 const DB_VERSION = 1; 24 const STORES = ['meta', 'recipes', 'shoppingLists', 'mealSlots', 'thumbs']; 25 // Must match the server-side cap in SyncApi.MaxBatchSize â the 26 // server silently truncates each request to that many ids, so a 27 // larger client batch would silently under-fetch. 28 const BATCH_SIZE = 50; 29 // Width to fetch thumbnails at. The server resizes on the fly via 30 // /recipe-images/{id}?w=N. 31 const THUMB_WIDTH = 400; 32 33 // -- IndexedDB plumbing ------------------------------------------------- 34 35 // Shared singleton DB handle. Every openDb() call used to open a 36 // fresh IDBDatabase and never close it; on iOS each unclosed handle 37 // pins the connection's native state in memory, so a flurry of 38 // toggle-off-then-on cycles (each one fires ~10 openDb() calls 39 // across setAutoSync/preview/apply/status/storageEstimate) leaked 40 // ~2 MB per cycle. A singleton kills the accumulation, and lets 41 // clearLocal() actually close the connection before deleteDatabase 42 // (otherwise the delete is blocked by the leaked handles, the 3 s 43 // watchdog masks it, and the DB never really clears). 44 let _db = null; 45 46 function openDb() { 47 if (_db) return Promise.resolve(_db); 48 return new Promise((resolve, reject) => { 49 let settled = false; 50 const settle = (fn, val) => { if (!settled) { settled = true; fn(val); } }; 51 // Watchdog â IndexedDB has been observed to silently never 52 // fire onsuccess on iOS PWAs after long backgrounding. Reject 53 // after 5s so callers fail loudly instead of hanging the UI. 54 const watchdog = setTimeout( 55 () => settle(reject, new Error('openDb timed out (' + DB_NAME + ')')), 56 5000); 57 try { 58 const req = indexedDB.open(DB_NAME, DB_VERSION); 59 req.onupgradeneeded = () => { 60 const db = req.result; 61 for (const name of STORES) { 62 if (!db.objectStoreNames.contains(name)) { 63 db.createObjectStore(name, { keyPath: name === 'meta' ? 'key' : 'id' }); 64 } 65 } 66 }; 67 req.onsuccess = () => { 68 clearTimeout(watchdog); 69 const db = req.result; 70 // Drop the cached handle if the browser closes it 71 // out from under us (another tab deletes/upgrades the 72 // DB, or the user clears site data). Without this the 73 // next openDb() would hand back a dead connection. 74 db.onclose = () => { if (_db === db) _db = null; }; 75 db.onversionchange = () => { 76 try { db.close(); } catch (_) {} 77 if (_db === db) _db = null; 78 }; 79 _db = db; 80 settle(resolve, db); 81 }; 82 req.onerror = () => { clearTimeout(watchdog); settle(reject, req.error || new Error('openDb error')); }; 83 // onblocked fires when another tab is holding the DB open 84 // at a different version â without this handler the 85 // promise sits there forever. 86 req.onblocked = () => { clearTimeout(watchdog); settle(reject, new Error('openDb blocked')); }; 87 } catch (e) { 88 clearTimeout(watchdog); 89 settle(reject, e); 90 } 91 }); 92 } 93 94 function closeDb() { 95 if (_db) { 96 try { _db.close(); } catch (_) {} 97 _db = null; 98 } 99 } 100 101 function reqp(req) { 102 return new Promise((resolve, reject) => { 103 req.onsuccess = () => resolve(req.result); 104 req.onerror = () => reject(req.error); 105 }); 106 } 107 108 function txDone(tx) { 109 return new Promise((resolve, reject) => { 110 tx.oncomplete = () => resolve(); 111 tx.onerror = () => reject(tx.error); 112 tx.onabort = () => reject(tx.error); 113 }); 114 } 115 116 async function getAll(db, store) { 117 return reqp(db.transaction(store, 'readonly').objectStore(store).getAll()); 118 } 119 120 // Cursor-based projection: walk a store and pull only the named 121 // fields out of each row. preview() only needs (id, updatedAt[, 122 // title]) to compute the diff â pulling the full record (instructions, 123 // ingredients, prose) drags multi-MB of recipe bodies through the JS 124 // heap on every toggle, and on iOS those graphs hang around longer 125 // than they should. Slim projections cut the peak to a few KB. 126 async function projectAll(db, store, fields) { 127 return new Promise((resolve, reject) => { 128 const out = []; 129 const req = db.transaction(store, 'readonly').objectStore(store).openCursor(); 130 req.onsuccess = () => { 131 const cur = req.result; 132 if (!cur) { resolve(out); return; } 133 const v = cur.value; 134 const slim = {}; 135 for (const f of fields) slim[f] = v[f]; 136 out.push(slim); 137 cur.continue(); 138 }; 139 req.onerror = () => reject(req.error); 140 }); 141 } 142 143 async function getOne(db, store, id) { 144 return reqp(db.transaction(store, 'readonly').objectStore(store).get(id)); 145 } 146 147 async function getMeta(db, key) {
148 const row = await reqp(db.transaction('meta', 'readonly').objectStore('meta').get(key)); 149 return row ? row.value : null; 150 } 151 152 async function setMeta(db, key, value) { 153 const tx = db.transaction('meta', 'readwrite'); 154 tx.objectStore('meta').put({ key, value }); 155 return txDone(tx); 156 } 157 158 async function clearAllStores(db) { 159 const tx = db.transaction(STORES, 'readwrite'); 160 for (const s of STORES) tx.objectStore(s).clear(); 161 return txDone(tx); 162 } 163 164 // -- Diff --------------------------------------------------------------- 165 166 // The diff is what we show the user before they confirm. Ids returned 167 // here are what we'll fetch / delete on apply(). 168 function computeDiff(manifest, local) { 169 const localRecipes = new Map(local.recipes.map(r => [r.id, r])); 170 const localLists = new Map(local.shoppingLists.map(s => [s.id, s])); 171 const manifestRecipeIds = new Set(manifest.recipes.map(r => r.id)); 172 const manifestListIds = new Set(manifest.shoppingLists.map(s => s.id)); 173 174 const recipesToFetch = []; 175 const recipesToDelete = []; 176 const recipeAddedTitles = []; 177 const recipeUpdatedTitles = []; 178 const recipeDeletedTitles = []; 179 180 for (const m of manifest.recipes) { 181 if (m.deletedAt) { 182 if (localRecipes.has(m.id)) { 183 recipesToDelete.push(m.id); 184 recipeDeletedTitles.push(m.title); 185 } 186 continue; 187 } 188 const local = localRecipes.get(m.id); 189 if (!local) { 190 recipesToFetch.push(m.id); 191 recipeAddedTitles.push(m.title); 192 } else if (new Date(m.updatedAt) > new Date(local.updatedAt)) { 193 recipesToFetch.push(m.id); 194 recipeUpdatedTitles.push(m.title); 195 } 196 } 197 // Anything local but not on the server (and not soft-deleted listed 198 // above) was hard-deleted â drop it locally. 199 for (const local of localRecipes.values()) { 200 if (!manifestRecipeIds.has(local.id)) { 201 recipesToDelete.push(local.id); 202 recipeDeletedTitles.push(local.title || local.id); 203 } 204 } 205 206 const listsToFetch = []; 207 const listsToDelete = []; 208 for (const m of manifest.shoppingLists) { 209 if (m.deletedAt) { 210 if (localLists.has(m.id)) listsToDelete.push(m.id); 211 continue; 212 } 213 const local = localLists.get(m.id); 214 if (!local) listsToFetch.push(m.id); 215 else if (new Date(m.updatedAt) > new Date(local.updatedAt)) listsToFetch.push(m.id); 216 } 217 for (const local of localLists.values()) { 218 if (!manifestListIds.has(local.id)) listsToDelete.push(local.id); 219 } 220 221 return { 222 recipes: { 223 add: recipesToFetch.length, 224 addTitles: recipeAddedTitles, 225 updated: recipeUpdatedTitles.length, 226 updatedTitles: recipeUpdatedTitles, 227 deleted: recipesToDelete.length, 228 deletedTitles: recipeDeletedTitles, 229 fetchIds: recipesToFetch, 230 deleteIds: recipesToDelete, 231 }, 232 shoppingLists: { 233 add: listsToFetch.filter(id => !localLists.has(id)).length, 234 updated: listsToFetch.filter(id => localLists.has(id)).length, 235 deleted: listsToDelete.length, 236 fetchIds: listsToFetch, 237 deleteIds: listsToDelete, 238 }, 239 mealSlots: { 240 // Full replace â count of remote slots is the count after sync. 241 total: manifest.mealSlots.length, 242 }, 243 // Carries through to apply() so we don't have to refetch. 244 manifest: manifest, 245 }; 246 } 247 248 function chunk(arr, size) { 249 const out = []; 250 for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); 251 return out; 252 } 253 254 // A 401 from /api/sync/* is the only reliable signal the page gets that 255 // the cookie is gone. Nothing else notices: the chrome around it was 256 // rendered (or served from cache) while signed in, so it keeps showing 257 // an avatar, initials and the user's own recipes out of IndexedDB, and 258 // offers no way to sign in because a signed-in page has no Sign In
259 // button. Auto-sync then retried behind that facade indefinitely â 260 // syncIfDue throttles on lastSyncAt, which a failing sync never writes, 261 // so every page load fired another attempt and every one of them 401'd. 262 // 263 // Make the expiry visible and self-clearing instead: drop the identity 264 // we're still carrying, ask the SW to drop the HTML that has it baked 265 // in, and put a real link to the sign-in page on screen. Fires once. 266 let _sessionLostHandled = false; 267 async function onSessionLost() { 268 if (_sessionLostHandled) return; 269 _sessionLostHandled = true; 270 271 // The stored userId deliberately stays put. It looks like identity 272 // worth clearing, but apply() diffs it against the manifest to catch 273 // "a different account signed in on this device" and wipe the local 274 // vault â clearing it here would blind that check and leak the 275 // previous user's recipes to the next one. The identity the user 276 // actually SEES comes from the cached HTML, which the SW drops below. 277 // The vault itself also stays: an expired cookie is not a sign-out, 278 // and she may sign straight back into the same account. 279 try { 280 if (navigator.serviceWorker && navigator.serviceWorker.controller) { 281 navigator.serviceWorker.controller.postMessage({ type: 'iwe-session-lost' }); 282 } 283 } catch (_) { /* best-effort */ } 284 285 showSessionLostBanner(); 286 } 287 288 // Built with createElement rather than innerHTML: the site sends a CSP 289 // and this has to work on the offline WASM pages too, where there is no 290 // Blazor circuit to render anything for us. 291 function showSessionLostBanner() { 292 try { 293 if (typeof document === 'undefined' || !document.body) return; 294 if (document.getElementById('iwe-session-lost')) return; 295 296 const meta = (n, fallback) => { 297 const m = document.querySelector('meta[name="' + n + '"]'); 298 const v = m && m.getAttribute('content'); 299 return v || fallback; 300 }; 301 302 const bar = document.createElement('div'); 303 bar.id = 'iwe-session-lost'; 304 bar.setAttribute('role', 'status'); 305 bar.style.cssText = 'position:fixed;left:0;right:0;bottom:0;z-index:2147483000;' 306 + 'display:flex;gap:.75rem;
306align-items:center;justify-content:center;flex-wrap:wrap;' 307 + 'padding:.85rem 1rem;background:#2a221a;color:#fff;font-size:.95rem;' 308 + 'box-shadow:0 -2px 12px rgba(0,0,0,.25)'; 309 310 const msg = document.createElement('span'); 311 msg.textContent = meta('iwe-session-lost-text', 312 'Your session has ended. Sign in again to see your recipes.'); 313 314 // A plain full-page navigation, not a router link: the whole 315 // point is to leave the cached offline shell behind and reach 316 // the real server. 317 const cta = document.createElement('a'); 318 cta.href = '/Account/Login'; 319 cta.textContent = meta('iwe-session-lost-cta', 'Sign in'); 320 cta.style.cssText = 'background:#c8442a;color:#fff;text-decoration:none;' 321 + 'padding:.5rem 1.1rem;border-radius:999px;font-weight:600'; 322 323 bar.appendChild(msg); 324 bar.appendChild(cta); 325 document.body.appendChild(bar); 326 } catch (_) { /* never let the banner break the page */ } 327 } 328 329 async function fetchJson(url, init) { 330 const r = await fetch(url, Object.assign({ credentials: 'same-origin' }, init || {})); 331 if (r.status === 401) { 332 onSessionLost(); 333 throw new Error('Not signed in. Sign in and try again.'); 334 } 335 if (!r.ok) throw new Error('Server error (' + r.status + ').'); 336 return r.json(); 337 } 338 339 async function fetchManifest() { 340 return fetchJson('/api/sync/manifest'); 341 } 342 343 async function fetchRecipeBatch(ids) { 344 return fetchJson('/api/sync/recipes', { 345 method: 'POST', 346 headers: { 'Content-Type': 'application/json' }, 347 body: JSON.stringify({ ids }), 348 }); 349 } 350 351 async function fetchListBatch(ids) { 352 return fetchJson('/api/sync/shopping-lists', { 353 method: 'POST', 354 headers: { 'Content-Type': 'application/json' }, 355 body: JSON.stringify({ ids }), 356 }); 357 } 358 359 // Fetch a single thumbnail and return it as a Blob. Returns null on 360 // failure â a missing thumbnail is non-fatal, the recipe still syncs. 361 async function fetchThumb(imageId) { 362 try { 363 const r = await fetch('/recipe-images/' + imageId + '?w=' + THUMB_WIDTH, { credentials: 'same-origin' }); 364 if (!r.ok) return null; 365 const blob = await r.blob(); 366 return { blob, mime: blob.type || 'image/jpeg' }; 367 } catch (e) { 368 return null; 369 } 370 } 371 372 // Fetch the user's profile photo as a Blob. Returns null on 404 373 // (user has no avatar) or on any network error. Stored alongside 374 // the synced data so it's available offline regardless of SW 375 // cache state. 376 async function fetchAvatar(userId) { 377 try { 378 const r = await fetch('/avatar/' + userId, { credentials: 'same-origin' }); 379 if (!r.ok) return null; 380 const blob = await r.blob(); 381 if (!blob || !blob.size) return null; 382 return { blob, mime: blob.type || 'image/png' }; 383 } catch (_) { return null; } 384 } 385 386 // -- Sync --------------------------------------------------------------- 387 388 async function preview() { 389 const db = await openDb(); 390 const manifest = await fetchManifest(); 391 const previousUserId = await getMeta(db, 'userId'); 392 const userChanged = previousUserId && previousUserId !== manifest.userId; 393 394 // If the signed-in user changed since last sync (e.g. switched 395 // accounts on this browser), pretend the local cache is empty so 396 // the diff shows a fresh-install pull and apply() wipes the 397 // previous user's data first. 398 // mealSlots are full-replaced by apply() from the manifest, so 399 // computeDiff doesn't need them at all â don't deserialize them. 400 // For recipes/lists we read only the fields the diff touches. 401 const local = userChanged 402 ? { recipes: [], shoppingLists: [] } 403 : { 404 recipes: await projectAll(db, 'recipes', ['id', 'updatedAt', 'title']), 405 shoppingLists: await projectAll(db, 'shoppingLists', ['id', 'updatedAt']), 406 }; 407 408 const diff = computeDiff(manifest, local); 409 diff.userChanged = !!userChanged; 410 diff.previousUserId = previousUserId || null; 411 return diff; 412 } 413 414 async function apply(diff, onProgress) { 415 const db = await openDb(); 416 const manifest = diff.manifest; 417 const report = (stage, current, total) => { 418 if (typeof onProgress === 'function') onProgress({ stage, current, total }); 419 }; 420 421 if (diff.userChanged) { 422 report('wiping', 0, 1); 423 await clearAllStores(db); 424 report('wiping', 1, 1); 425 } 426 427 // 1. Recipes: fetch and upsert. We only carry forward the thumb 428 // GUIDs we need to fetch in step 3 â keeping the whole recipe 429 // array (instructions, ingredients, prose) alive through the rest 430 // of apply() pinned multi-MB in the JS heap on iOS. GUIDs are 431 // tiny and sufficient. 432 const recipeBatches = chunk(diff.recipes.fetchIds, BATCH_SIZE); 433 let fetched = 0; 434 const thumbIdsToFetch = []; 435 for (const batch of recipeBatches) { 436 const recipes = await fetchRecipeBatch(batch); 437 const tx = db.transaction('recipes', 'readwrite'); 438 for (const r of recipes) { 439 tx.objectStore('recipes').put(r); 440 if (r.thumbImageId) thumbIdsToFetch.push(r.thumbImageId); 441 } 442 await txDone(tx); 443 fetched += recipes.length; 444 report('recipes', fetched, diff.recipes.fetchIds.length); 445 } 446 447 // 2. Recipe deletes (also clean up their thumbnails). 448 if (diff.recipes.deleteIds.length > 0) { 449 // Look up the thumb ids of the about-to-be-deleted recipes so 450 // we can drop their thumbnail blobs in the same pass. 451 const thumbIdsToRemove = []; 452 for (const id of diff.recipes.deleteIds) { 453 const r = await getOne(db, 'recipes', id); 454 if (r && r.thumbImageId) thumbIdsToRemove.push(r.thumbImageId); 455 } 456 const tx = db.transaction(['recipes', 'thumbs'], 'readwrite'); 457 for (const id of diff.recipes.deleteIds) tx.objectStore('recipes').delete(id); 458 for (const id of thumbIdsToRemove) tx.objectStore('thumbs').delete(id); 459 await txDone(tx); 460 } 461
462 // 3. Thumbnails for added/updated recipes â one network call each. 463 // We do these AFTER the recipe write so a failure here still leaves 464 // the recipe usable (just without a thumbnail). 465 let thumbsDone = 0; 466 for (const thumbImageId of thumbIdsToFetch) { 467 const existing = await getOne(db, 'thumbs', thumbImageId); 468 if (!existing) { 469 const t = await fetchThumb(thumbImageId); 470 if (t) { 471 const tx = db.transaction('thumbs', 'readwrite'); 472 tx.objectStore('thumbs').put({ id: thumbImageId, blob: t.blob, mime: t.mime }); 473 await txDone(tx); 474 } 475 } 476 thumbsDone += 1; 477 report('thumbnails', thumbsDone, thumbIdsToFetch.length); 478 } 479 480 // 4. Shopping lists. 481 const listBatches = chunk(diff.shoppingLists.fetchIds, BATCH_SIZE); 482 let listsFetched = 0; 483 for (const batch of listBatches) { 484 const lists = await fetchListBatch(batch); 485 const tx = db.transaction('shoppingLists', 'readwrite'); 486 for (const s of lists) tx.objectStore('shoppingLists').put(s); 487 await txDone(tx); 488 listsFetched += lists.length; 489 report('shoppingLists', listsFetched, diff.shoppingLists.fetchIds.length); 490 } 491 if (diff.shoppingLists.deleteIds.length > 0) { 492 const tx = db.transaction('shoppingLists', 'readwrite'); 493 for (const id of diff.shoppingLists.deleteIds) tx.objectStore('shoppingLists').delete(id); 494 await txDone(tx); 495 } 496 497 // 5. Meal slots â full replace from the manifest. They have no 498 // UpdatedAt to diff on; fetching them in the manifest already gave 499 // us the full payload. 500 { 501 const tx = db.transaction('mealSlots', 'readwrite'); 502 tx.objectStore('mealSlots').clear(); 503 for (const m of manifest.mealSlots) tx.objectStore('mealSlots').put(m); 504 await txDone(tx); 505 } 506 507 // 6. Avatar â keep it next to the synced data so it survives 508 // SW cache evictions / CACHE_NAME bumps. The browser image 509 // loader still hits /avatar/{userId}; the SW image handler 510 // falls back to this blob (mirrors what it does for thumbs). 511 if (manifest.userId) { 512 try { 513 const av = await fetchAvatar(manifest.userId); 514 if (av) { 515 await setMeta(db, 'avatar', { 516 userId: manifest.userId, 517 blob: av.blob, 518 mime: av.mime, 519 }); 520 } else { 521 // User has no avatar uploaded â drop any stale blob. 522 await setMeta(db, 'avatar', null); 523 } 524 } catch (_) { /* swallow; avatar is best-effort */ } 525 } 526 527 // 7. Stamp the sync. 528 await setMeta(db, 'userId', manifest.userId); 529 await setMeta(db, 'lastSyncAt', manifest.syncedAt); 530 await setMeta(db, 'syncSummary', { 531 recipes: diff.recipes.add + diff.recipes.updated + (manifest.recipes.length 532 - diff.recipes.add - diff.recipes.updated - diff.recipes.deleted), 533 shoppingLists: manifest.shoppingLists.filter(s => !s.deletedAt).length, 534 mealSlots: manifest.mealSlots.length, 535 }); 536 537 report('done', 1, 1); 538 return { ok: true }; 539 } 540 541 async function status() { 542 const db = await openDb(); 543 return { 544 userId: await getMeta(db, 'userId'), 545 lastSyncAt: await getMeta(db, 'lastSyncAt'), 546 summary: await getMeta(db, 'syncSummary'), 547 }; 548 } 549 550 async function clearLocal() { 551 // IndexedDB's per-store clear() empties records but does NOT 552 // shrink the underlying database file â the allocated pages 553 // get reused on next write, but the on-disk size stays at the 554 // high-water mark forever. Repeated clear+resync cycles on iOS 555 // were growing navigator.storage.estimate() by ~2 MB each time 556 // because the WAL / index pages from the previous cycle never 557 // got reclaimed. 558 // 559 // Fix: delete the entire database. The next openDb() call 560 // (from preview/apply/getAutoSync etc.) recreates it with 561 // fresh, empty stores. Disk usage drops to ~0 for the IDB 562 // portion until real data is written again. 563 try { 564 // Close the singleton connection so deleteDatabase doesn't 565 // get blocked. Subsequent openDb() recreates it. 566 closeDb(); 567 await new Promise((resolve) => { 568 const req = indexedDB.deleteDatabase(DB_NAME); 569 const finish = () => resolve(); 570 req.onsuccess = finish; 571 req.onerror = finish; 572 req.onblocked = finish; 573 // Hard timeout: another tab holding the DB open will 574 // block the delete forever. Don't wedge the UI on it. 575 setTimeout(finish, 3000); 576 }); 577 } catch (_) { /* fall through; storage will still be partially cleared */ } 578 579 // Also drop /recipe-images/{guid} and /avatar/* entries from 580 // the SW cache. Without this, the SW continued to hold every 581 // thumb we'd ever fetched, and the storage estimate barely 582 // shrank. We deliberately leave /_framework/* and /brand/* 583 // alone (the app needs those to boot offline), and the 584 // navigation cache for /recipes(/{id}) (those help offline 585 // reload of recently-visited pages, not the per-recipe-data 586 // feature being toggled). 587 try { 588 if (typeof caches !== 'undefined' && caches.keys) { 589 const cacheNames = await caches.keys(); 590 for (const name of cacheNames) { 591 const c = await caches.open(name); 592 const reqs = await c.keys(); 593 for (const req of reqs) { 594 try { 595 const u = new URL(req.url);
596 if (u.pathname.startsWith('/recipe-images/') 597 || u.pathname.startsWith('/avatar/')) { 598 await c.delete(req); 599 } 600 } catch (_) { /* skip malformed */ } 601 } 602 } 603 } 604 } catch (_) { /* swallow â IndexedDB delete is the must-have */ } 605 } 606 607 // Bytes the sync feature has put on this device. We deliberately do 608 // NOT use navigator.storage.estimate(): on iOS it reports the entire 609 // origin's footprint (Blazor framework cache, service-worker cache, 610 // fonts, the WASM runtime, ...) AND it's a high-water mark that 611 // ratchets up across delete+repopulate cycles even when the live 612 // data didn't grow. Users toggling sync off and on saw the displayed 613 // size climb by a few MB each cycle and read it as a memory leak â 614 // which it isn't, but the number was an unhelpful proxy regardless. 615 // 616 // Instead we walk our own IDB stores and sum per-record byte counts 617 // (Blob.size for thumbnails, JSON-string length for everything 618 // else). Approximate â IndexedDB adds index + metadata overhead we 619 // don't see â but it's stable across cycles and reflects only what 620 // this feature is actually responsible for, which is what the user 621 // is trying to find out. 622 async function storageEstimate() { 623 try { 624 const db = await openDb(); 625 let bytes = 0; 626 for (const store of STORES) { 627 let rows; 628 try { rows = await getAll(db, store); } 629 catch (_) { continue; } 630 for (const rec of rows) { 631 if (store === 'thumbs' && rec && rec.blob && typeof rec.blob.size === 'number') { 632 bytes += rec.blob.size + 64; // small fudge for the row wrapper 633 } else { 634 try { bytes += JSON.stringify(rec).length; } catch (_) { /* skip */ } 635 } 636 } 637 } 638 // Quota is still useful info if the platform offers it; pull 639 // just that field, not usage. 640 let quota = 0; 641 try { 642 if (navigator.storage && typeof navigator.storage.estimate === 'function') { 643 const est = await navigator.storage.estimate(); 644 quota = (est && est.quota) || 0; 645 } 646 } catch (_) { /* ignore */ } 647 return { usage: bytes, quota, source: 'walk' }; 648 } catch (_) { 649 return null; 650 } 651 } 652 653 // -- Auto-sync preference (persisted in IndexedDB meta) ---------------- 654 655 async function getAutoSync() { 656 const db = await openDb(); 657 return Boolean(await getMeta(db, 'autoSync')); 658 } 659 660 async function setAutoSync(enabled) { 661 const db = await openDb(); 662 await setMeta(db, 'autoSync', !!enabled); 663 } 664 665 // Background-sync entry point. Cheap to call; bails when there's 666 // nothing to do. Used by /js/sync-auto.js on a timer and on 667 // visibility changes. 668 async function syncIfDue(minIntervalMs) { 669 const db = await openDb(); 670 const enabled = Boolean(await getMeta(db, 'autoSync')); 671 if (!enabled) return { skipped: 'disabled' }; 672 if (typeof minIntervalMs === 'number' && minIntervalMs > 0) { 673 const last = await getMeta(db, 'lastSyncAt'); 674 if (last) { 675 const lastMs = new Date(last).getTime(); 676 if (Date.now() - lastMs < minIntervalMs) return { skipped: 'throttled' }; 677 } 678 } 679 const diff = await preview(); 680 await apply(diff); 681 return { ok: true }; 682 } 683 684 // -- Offline read-API (used by /offline-viewer/) ------------------------ 685 686 async function listRecipes() { 687 const db = await openDb(); 688 const all = await getAll(db, 'recipes'); 689 all.sort((a, b) => (a.title || '').localeCompare(b.title || '')); 690 return all; 691 } 692 693 async function getRecipe(id) { 694 const db = await openDb(); 695 return getOne(db, 'recipes', id); 696 } 697 698 async function listShoppingLists() { 699 const db = await openDb(); 700 const all = await getAll(db, 'shoppingLists'); 701 all.sort((a, b) => (a.name || '').localeCompare(b.name || '')); 702 return all; 703 } 704 705 async function getShoppingList(id) { 706 const db = await openDb(); 707 return getOne(db, 'shoppingLists', id); 708 } 709 710 async function listMealSlots() { 711 const db = await openDb(); 712 const all = await getAll(db, 'mealSlots'); 713 all.sort((a, b) => { 714 if (a.date !== b.date) return a.date < b.date ? -1 : 1; 715 if (a.meal !== b.meal) return a.meal - b.meal; 716 return a.position - b.position; 717 }); 718 return all; 719 } 720 721 // Returns an object URL for a thumbnail, or null if not cached. The 722 // offline viewer is responsible for revoking the URL when the element 723 // is removed from the page (or simply leaving it: the URL dies with 724 // the document anyway). 725 async function thumbUrl(imageId) { 726 if (!imageId) return null; 727 const db = await openDb(); 728 const t = await getOne(db, 'thumbs', imageId); 729 if (!t || !t.blob) return null; 730 return URL.createObjectURL(t.blob); 731 } 732 733 // Full device wipe for sign-out: the offline recipe store and EVERY 734 // service-worker cache (including cached server-rendered recipe pages 735 // and navigation entries that clearLocal deliberately keeps). Without 736 // this, the next person on a shared device could read the previous 737 // user's recipes offline after they signed out. The framework/brand 738 // caches go too â they're re-fetched on the next visit; privacy on a 739 // shared device beats one warm-cache page load. 740 async function wipeDeviceData() { 741 await clearLocal(); // deletes the IndexedDB database 742 try { 743 if (typeof caches !== 'undefined' && caches.keys) { 744 const names = await caches.keys(); 745 await Promise.all(names.map((n) => caches.delete(n))); 746 } 747 } catch (_) { /* best-effort */ } 748 } 749 750 window.iwe2eat = { 751 sync: { preview, apply, status, clearLocal, getAutoSync, setAutoSync, syncIfDue, storageEstimate, wipeDeviceData }, 752 offline: { listRecipes, getRecipe, listShoppingLists, getShoppingList, listMealSlots, thumbUrl }, 753 }; 754 755 // Auto-wipe when the browser lands on the signed-out page. /api/logout 756 // (the user-menu form) 302s here with a full document load, so this 757 // runs for every real sign-out regardless of which UI initiated it. 758 try { 759 if (typeof location !== 'undefined' 760 && location.pathname.toLowerCase().startsWith('/account/logout')) { 761 wipeDeviceData(); 762 } 763 } catch (_) { /* never block page load */ } 764})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.