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.