PageSourceSearch

https://www.iwant2eat.com/js/sync.js

js iwant2eat.com collected 2026-09-24 14:46:24 UTC 33,375 bytes, 764 lines download raw bytes

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.