PageSourceSearch

https://smrtbld.ai/js/offline/offline-manager.js

js smrtbld.ai collected 2026-10-05 15:09:07 UTC 15,989 bytes, 424 lines download raw bytes

1/**
2 * Offline Manager — Download orchestration for offline markups.
3 *
4 * Coordinates between:
5 *   - markupsApi (fetches PDF blobs directly)
6 *   - offlineStore (Dexie for metadata, annotations, PDF blobs)
7 *   - storageBudget (quota detection and budget enforcement)
8 *
9 * PDFs are stored as blobs in IndexedDB (Dexie markups table), NOT in the
10 * Cache API. This avoids cross-origin issues with the Service Worker and
11 * lets WebViewer load directly from blob via UI.loadDocument(blob).
12 *
13 * Usage:
14 *   import { offlineManager } from '../offline/offline-manager.js';
15 *   await offlineManager.downloadMarkup(markupId, projectId, companyCode);
16 *   const status = await offlineManager.getOfflineStatus(markupId);
17 */
18
19import { offlineStore } from './offline-store.js';
20import { storageBudget } from '../utils/storage-budget.js';
21import { markupsApi } from '../api/markups.js';
22import { projectsApi } from '../api/projects.js';
23import { crossTab } from './cross-tab.js';
24
25// Must match WEBVIEWER_VERSION in sw-src.js — bump both together when upgrading
26// the @pdftron/webviewer package so old cached chunks get evicted on SW activate.
27const WEBVIEWER_CACHE_NAME = 'webviewer-assets-v11';
28
29// ─── Download / Remove ───────────────────────────────────────────────
30
31/**
32 * Download a markup for offline use.
33 * Fetches metadata + PDF blob + annotations, stores everything in Dexie.
34 *
35 * @param {number} markupId
36 * @param {number} projectId
37 * @param {string} companyCode
38 * @param {object} [options]
39 * @param {function} [options.onProgress] — callback({ stage, detail })
40 * @param {AbortSignal} [options.signal] — abort in-flight fetches (Field Trip cancel)
41 * @returns {Promise<{ success: boolean, sizeBytes?: number, error?: string, aborted?: boolean }>}
42 */
43async function downloadMarkup(markupId, projectId, companyCode, options = {}) {
44    const { onProgress, signal } = options;
45    const notify = (stage, detail) => onProgress?.({ stage, detail });
46
47    try {
48        const lockResult = await crossTab.withDownloadLock(markupId, async () => {
49            return _doDownload(markupId, projectId, companyCode, notify, signal);
50        });
51
52        if (lockResult?.skipped) {
53            return { success: false, error: lockResult.reason };
54        }
55
56        return lockResult;
57    } catch (err) {
58        if (err.name === 'AbortError') {
59            return { success: false, error: 'Download cancelled', aborted: true };
60        }
61        console.error('[OfflineManager] Download failed:', err);
62        crossTab.notifyDownloadFailed(markupId, err.message);
63        return { success: false, error: err.message };
64    }
65}
66
67/** Internal download implementation (runs under Web Lock). */
68async function _doDownload(markupId, projectId, companyCode, notify, signal) {
69    const checkAbort = () => {
70        if (signal?.aborted) {
71            const err = new Error('Download cancelled');
72            err.name = 'AbortError';
73            throw err;
74        }
75    };
76    try {
77        crossTab.notifyDownloadStart(markupId);
78
79        // 1. Fetch markup metadata
80        checkAbort();
81        notify('metadata', 'Fetching drawing info...');
82        const markupDetail = await markupsApi.getMarkup(markupId);
83        if (!markupDetail?.markup) {
84            return { success: false, error: 'Failed to load markup metadata' };
85        }
86
87        // 2. Fetch per-page layer data (not included in getMarkup response)
88        checkAbort();
89        notify('layers', 'Caching layer data...');
90        if (markupDetail.pages?.length) {
91            const layerCache = {};
92            for (const page of markupDetail.pages) {
93                checkAbort();
94                try {
95                    const result = await markupsApi.getLayersByPage(markupId, page.pageID);
96                    layerCache[page.pageID] = result;
97                } catch { /* non-fatal — layers just won't show offline for this page */ }
98            }
99            markupDetail._layerCache = layerCache;
100        }
101
102        // 3. Fetch the full PDF as a blob — signal lets Field Trip cancel mid-fetch.
103        //    (Previously probed size via HEAD first, but the API doesn't support HEAD.
104        //    The post-download budget check below is the enforcement point.)
105        checkAbort();
106        notify('pdf', 'Downloading PDF...');
107        const { blob: pdfBlob } = await markupsApi.fetchPdfBlob(markupId, companyCode, { signal });
108        const sizeBytes = pdfBlob.size;
109
110        // 4. Budget check — reject if the blob won't fit
111        const currentUsage = await offlineStore.getTotalCachedSize();
112        const budgetCheck = await storageBudget.checkBudget(currentUsage, sizeBytes);
113        if (!budgetCheck.allowed) {
114            return {
115                success: false,
116                error: `Not enough storage. Need ${storageBudget.formatBytes(sizeBytes)}, ` +
117                    `only ${storageBudget.formatBytes(budgetCheck.budget.markupBudget - currentUsage)} available.`,
118            };
119        }
120
121        // 5. Save metadata + PDF blob to Dexie
122        notify('metadata', 'Saving drawing data...');
123        await offlineStore.saveMarkup(markupId, markupDetail, sizeBytes, pdfBlob);
124
125        // 5b. Pre-cache the navigation path back to this markup. Guarantees
126        //    the user can re-open it offline even if they deep-linked to the
127        //    markups page and never visited the parent list.
128        //    - Project detail (`/<module>/:id` renders from this)
129        //    - Contract-admin stage list (top of the nav tree — if they typed
130        //      the URL, they wouldn't have visited the list, and we can't know
131        //      which stage without fetching the detail first)
132        //    All fire-and-forget — failure doesn't abort the markup download.
133        projectsApi.getProject(projectId).catch(() => {});
134
135        // 6. Save annotations to Dexie — but only if no dirty local edits exist
136        //    (don't clobber in-flight work with a fresh server snapshot).
137        if (markupDetail.xmlData || markupDetail.xmlDataLinks) {
138            const existingAnnot = await offlineStore.getAnnotations(markupId);
139            if (!existingAnnot?.isDirty) {
140                await offlineStore.replaceAnnotationsCleanIfCurrent(
141                    markupId,
142                    projectId,
143                    markupDetail.xmlData || '',
144                    markupDetail.xmlDataLinks || '',
145                    markupDetail.xmlData || '', // base XFDF for merge
146                    offlineStore.getAnnotationCaptureToken(existingAnnot),
147                );
148            }
149        }
150
151        notify('done', 'Complete');
152        crossTab.notifyDownloadComplete(markupId, sizeBytes);
153        return { success: true, sizeBytes };
154
155    } catch (err) {
156        console.error('[OfflineManager] Download failed:', err);
157        crossTab.notifyDownloadFailed(markupId, err.message);
158        return { success: false, error: err.message };
159    }
160}
161
162/**
163 * Remove a markup's offline data (PDF blob + metadata from Dexie).
164 * @param {number} markupId
165 * @returns {Promise<boolean>}
166 */
167async function removeMarkup(markupId) {
168    try {
169        await offlineStore.purgeMarkup(markupId);
170        crossTab.notifyCachePurged(markupId);
171        return true;
172    } catch (err) {
173        console.error('[OfflineManager] Remove failed:', err);
174        return false;
175    }
176}
177
178/**
179 * Remove ALL offline data for a project.
180 * @param {number} projectId
181 * @returns {Promise<boolean>}
182 */
183async function removeProject(projectId) {
184    try {
185        await offlineStore.purgeProject(projectId);
186        crossTab.notifyProjectPurged(projectId);
187        return true;
188    } catch (err) {
189        console.error('[OfflineManager] Remove project failed:', err);
190        return false;
191    }
192}
193
194/**
195 * Remove ALL offline data.
196 * @returns {Promise<boolean>}
197 */
198async function removeAll() {
199    try {
200        await offlineStore.purgeAll();
201        crossTab.notifyAllPurged();
202        return true;
203    } catch (err) {
204        console.error('[OfflineManager] Remove all failed:', err);
205        return false;
206    }
207}
208
209// ─── Status Queries ──────────────────────────────────────────────────
210
211/**
212 * Get the offline status for a single markup.
213 * @param {number} markupId
214 * @returns {Promise<{ cached: boolean, sizeBytes: number, cachedAt: number|null, isPinned: boolean }>}
215 */
216async function getOfflineStatus(markupId) {
217    const record = await offlineStore.getMarkup(markupId);
218    if (!record) {
219        return { cached: false, sizeBytes: 0, cachedAt: null, isPinned: false };
220    }
221    return {
222        cached: true,
223        sizeBytes: record.sizeBytes || 0,
224        cachedAt: record.cachedAt,
225        isPinned: !!record.isPinned,
226    };
227}
228
229/**
230 * Get offline status for multiple markups at once (batch check).
231 * @param {number[]} markupIds
232 * @returns {Promise<Map<number, { cached: boolean, sizeBytes: number }>>}
233 */
234async function getBatchOfflineStatus(markupIds) {
235    const result = new Map();
236    const cachedIds = new Set(await offlineStore.getCachedMarkupIds());
237
238    for (const id of markupIds) {
239        if (cachedIds.has(id)) {
240            const record = await offlineStore.getMarkup(id);
241            result.set(id, { cached: true, sizeBytes: record?.sizeBytes || 0 });
242        } else {
243            result.set(id, { cached: false, sizeBytes: 0 });
244        }
245    }
246    return result;
247}
248
249/**
250 * Get a full storage summary for the UI.
251 */
252async function getStorageSummary() {
253    const [totalCachedBytes, budget, projectBreakdown] = await Promise.all([
254        offlineStore.getTotalCachedSize(),
255        storageBudget.getBudget(),
256        offlineStore.getUsageByProject(),
257    ]);
258
259    const markupCount = projectBreakdown.reduce((sum, p) => sum + p.markupCount, 0);
260    const usedPercent = budget.markupBudget > 0
261        ? Math.round((totalCachedBytes / budget.markupBudget) * 100)
262        : 0;
263
264    return {
265        totalCachedBytes,
266        markupCount,
267        budget,
268        usedPercent,
269        projectBreakdown,
270        summaryText: `${storageBudget.formatBytes(totalCachedBytes)} / ${storageBudget.formatBytes(budget.markupBudget)}`,
271    };
272}
273
274/**
275 * Check if the offline infrastructure is available in this browser.
276 * Needs IndexedDB for document storage and a service worker for the
277 * viewer-asset cache. Caches API is no longer required for PDFs.
278 *
279 * Also checks that Dexie actually opened — in strict-privacy / incognito,
280 * IndexedDB can be present but blocked, and we'd rather show "offline
281 * features unavailable" than crash on every call.
282 */
283function isAvailable() {
284    return 'serviceWorker' in navigator
285        && 'indexedDB' in self
286        && offlineStore.isOpen();
287}
288
289// ─── Eviction ────────────────────────────────────────────────────────
290
291/**
292 * Run automatic eviction if over budget.
293 * Removes least-recently-accessed unpinned markups until at 80% of budget.
294 * @returns {Promise<number>} — number of markups evicted
295 */
296async function enforceStorageBudget() {
297    const budget = await storageBudget.getBudget();
298    let used = await offlineStore.getTotalCachedSize();
299
300    if (used <= budget.markupBudget) return 0;
301
302    const target = Math.floor(budget.markupBudget * 0.8);
303    const candidates = await offlineStore.getEvictionCandidates();
304    let evicted = 0;
305
306    for (const candidate of candidates) {
307        if (used <= target) break;
308        await offlineStore.purgeMarkup(candidate.markupId);
309        used -= candidate.sizeBytes || 0;
310        evicted++;
311    }
312
313    return evicted;
314}
315
316// ─── WebViewer Cache Warming ────────────────────────────────────────
317
318/**
319 * Check if WebViewer assets are already cached.
320 *
321 * We always hit the Cache API — the previous session-wide boolean flag went
322 * stale after cache eviction and left the UI thinking the viewer was warm
323 * when it wasn't. A couple of cache.match() calls are cheap.
324 *
325 * Heuristic: the root script + a sample of entries from the offline manifest.
326 * If even one is missing, we treat the cache as cold so WARM_WEBVIEWER runs
327 * again. This is more robust than hardcoding a single chunk filename that
328 * breaks on every WebViewer version bump.
329 */
330async function isWebViewerCached() {
331    try {
332        const cache = await caches.open(WEBVIEWER_CACHE_NAME);
333        const rootHit = await cache.match('/lib/webviewer/webviewer.min.js');
334        if (!rootHit) return false;
335
336        // Sample a few manifest entries — all must be present
337        try {
338            const manifestResp = await fetch('/lib/webviewer/offline-manifest.json', { cache: 'no-store' });
339            if (!manifestResp.ok) return true; // root is cached; good enough
340            const urls = await manifestResp.json();
341            if (!Array.isArray(urls) || urls.length === 0) return true;
342
343            const samples = [
344                urls[0],
345                urls[Math.floor(urls.length / 2)],
346                urls[urls.length - 1],
347            ];
348            for (const url of samples) {
349                const hit = await cache.match(url);
350                if (!hit) return false;
351            }
352            return true;
353        } catch {
354            return true; // manifest fetch failed but root is cached; assume ok
355        }
356    } catch {
357        return false;
358    }
359}
360
361/**
362 * Warm the WebViewer cache with all assets from the manifest.
363 *
364 * Service-worker lookup is robust to two real-world cases we see on Azure:
365 *   1. SW has activated but has not yet taken control of this page
366 *      (first visit — `clients.claim()` hasn't reached us yet).
367 *   2. The page was hard-reloaded (Ctrl+Shift+R) and is running uncontrolled
368 *      even though the SW itself is installed and active.
369 * In both cases `navigator.serviceWorker.controller` is null but
370 * `registration.active` is a perfectly good postMessage target.
371 *
372 * @param {function} [onProgress] — callback({ cached, total, done, skipped })
373 */
374async function warmWebViewerCache(onProgress) {
375    let sw = navigator.serviceWorker?.controller;
376
377    if (!sw && 'serviceWorker' in navigator) {
378        try {
379            const reg = await navigator.serviceWorker.ready;
380            sw = navigator.serviceWorker.controller || reg?.active;
381        } catch { /* ready rejected — no SW support or registration failed */ }
382    }
383
384    if (!sw) {
385        throw new Error('Service worker not ready — please refresh the page and try again.');
386    }
387
388    return new Promise((resolve, reject) => {
389        const handler = (event) => {
390            if (event.data?.type !== 'WARM_WEBVIEWER_PROGRESS') return;
391            const { cached, total, done, error, skipped } = event.data;
392
393            onProgress?.({ cached, total, done, skipped });
394
395            if (done) {
396                navigator.serviceWorker.removeEventListener('message', handler);
397                if (error) {
398                    reject(new Error(error));
399                } else {
400                    resolve({ cached, total, skipped });
401                }
402            }
403        };
404
405        navigator.serviceWorker.addEventListener('message', handler);
406        sw.postMessage({ type: 'WARM_WEBVIEWER' });
407    });
408}
409
410// ─── Export ──────────────────────────────────────────────────────────
411
412export const offlineManager = {
413    downloadMarkup,
414    removeMarkup,
415    removeProject,
416    removeAll,
417    getOfflineStatus,
418    getBatchOfflineStatus,
419    getStorageSummary,
420    isAvailable,
421    enforceStorageBudget,
422    isWebViewerCached,
423    warmWebViewerCache,
424};

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.