/** * PWA Manifest Service * * Generates offline manifests for PWA asset downloads. * * SIMPLIFIED: Elements are now stored in ui_schema_json within tour_pages, * and transitions are stored as transitionVideoUrl in element content. * No need to query separate page_elements or transitions tables. */ const AssetsDBApi = require('../db/api/assets'); const AssetVariantsDBApi = require('../db/api/asset_variants'); const TourPagesDBApi = require('../db/api/tour_pages'); /** * Get asset type from MIME type or filename */ function getAssetType(mimeType, filename) { if (!mimeType && !filename) return 'other'; const mime = (mimeType || '').toLowerCase(); const name = (filename || '').toLowerCase(); if (mime.startsWith('image/') || /\.(jpg|jpeg|png|gif|webp|svg)$/.test(name)) { return 'image'; } if (mime.startsWith('video/') || /\.(mp4|webm|mov)$/.test(name)) { return 'video'; } if (mime.startsWith('audio/') || /\.(mp3|wav|ogg|m4a)$/.test(name)) { return 'audio'; } return 'other'; } /** * Extract URLs from element content JSON */ function extractUrlsFromContent(contentJson) { if (!contentJson) return []; try { const content = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson; const urls = []; const urlFields = [ 'iconUrl', 'imageUrl', 'mediaUrl', 'videoUrl', 'audioUrl', 'transitionVideoUrl', 'backgroundImageUrl', 'reverseVideoUrl', 'carouselPrevIconUrl', 'carouselNextIconUrl', 'src', 'url', 'poster', 'thumbnail', ]; const checkObject = (obj, depth = 0) => { if (depth > 5 || !obj || typeof obj !== 'object') return; for (const [key, value] of Object.entries(obj)) { if (typeof value === 'string' && value && urlFields.includes(key)) { if (value.startsWith('http') || value.startsWith('/')) { urls.push({ url: value, fieldType: key, }); } } else if (typeof value === 'object' && value !== null) { checkObject(value, depth + 1); } } }; checkObject(content); return urls; } catch { return []; } } class PWAManifestService { /** * Generate offline manifest for a project * @param {string} projectId - Project ID * @param {string} deviceType - 'mobile' or 'desktop' (affects variant selection) * @returns {Object} Offline manifest */ static async generateManifest(projectId, deviceType = 'desktop') { // Fetch all project data const [assetsResult, pagesResult] = await Promise.all([ AssetsDBApi.findAll({ project: projectId }, {}), TourPagesDBApi.findAll({ project: projectId }, {}), ]); const assets = assetsResult?.rows || []; const pages = pagesResult?.rows || []; // Build asset manifest entries const manifestAssets = []; const seenUrls = new Set(); // Helper to convert size_mb to bytes const mbToBytes = (sizeMb) => { if (!sizeMb || isNaN(sizeMb)) return 0; return Math.round(parseFloat(sizeMb) * 1024 * 1024); }; // Helper to add an asset to the manifest const addAsset = (id, url, filename, variantType, assetType, mimeType, sizeBytes, pageIds) => { if (!url || seenUrls.has(url)) return; seenUrls.add(url); manifestAssets.push({ id: id || `url-${Date.now()}-${Math.random().toString(36).slice(2)}`, url, filename: filename || url.split('/').pop() || 'unknown', variantType: variantType || 'original', assetType: assetType || getAssetType(mimeType, filename), mimeType: mimeType || 'application/octet-stream', sizeBytes: sizeBytes || 0, pageIds: pageIds || [], }); }; // Add assets with their variants for (const asset of assets) { // Get asset variants const variants = await AssetVariantsDBApi.findAll({ asset: asset.id }, {}); const variantRows = variants?.rows || []; // Select appropriate variants based on device type const selectedVariants = this.selectVariants(variantRows, deviceType); for (const variant of selectedVariants) { addAsset( variant.id, variant.url, variant.filename || asset.filename, variant.variant_type, asset.type || getAssetType(asset.mime_type, asset.filename), variant.mime_type || asset.mime_type, variant.size_bytes || mbToBytes(variant.size_mb), asset.pages?.map((p) => p.id) || [] ); } // If no variants, add original (use cdn_url as primary, fall back to storage_key) if (selectedVariants.length === 0 && (asset.cdn_url || asset.storage_key)) { addAsset( asset.id, asset.cdn_url || asset.storage_key, asset.name || asset.filename, 'original', asset.type || getAssetType(asset.mime_type, asset.name), asset.mime_type, mbToBytes(asset.size_mb), asset.pages?.map((p) => p.id) || [] ); } } // Add page background images/videos and extract element URLs from ui_schema_json for (const page of pages) { if (page.background_image_url) { addAsset( `page-bg-${page.id}`, page.background_image_url, `page-${page.slug}-bg.jpg`, 'original', 'image', 'image/jpeg', 0, [page.id] ); } if (page.background_video_url) { addAsset( `page-video-${page.id}`, page.background_video_url, `page-${page.slug}-video.mp4`, 'original', 'video', 'video/mp4', 0, [page.id] ); } // Extract URLs from ui_schema_json elements try { const uiSchema = typeof page.ui_schema_json === 'string' ? JSON.parse(page.ui_schema_json) : page.ui_schema_json; const elements = Array.isArray(uiSchema?.elements) ? uiSchema.elements : []; for (const element of elements) { const contentUrls = extractUrlsFromContent(element); for (const { url, fieldType } of contentUrls) { const assetType = fieldType.includes('video') || fieldType.includes('transition') ? 'video' : fieldType.includes('audio') ? 'audio' : 'image'; addAsset( `element-${page.id}-${element.id || fieldType}`, url, url.split('/').pop() || 'unknown', 'original', assetType, null, 0, [page.id] ); } } } catch { // Skip pages with invalid ui_schema_json } } // Calculate total size const totalSizeBytes = manifestAssets.reduce((sum, a) => sum + (a.sizeBytes || 0), 0); return { version: `v${Date.now()}`, projectId, projectSlug: '', // Would need project data assets: manifestAssets, totalSizeBytes, generatedAt: Date.now(), }; } /** * Select appropriate variants based on device type */ static selectVariants(variants, deviceType) { if (!variants || variants.length === 0) return []; const selected = []; // Prioritize variants based on device type const priority = deviceType === 'mobile' ? ['mp4_low', 'webp', 'thumbnail', 'preview', 'mp4_high', 'original'] : ['mp4_high', 'webp', 'preview', 'mp4_low', 'thumbnail', 'original']; // Group variants by base asset const variantMap = new Map(); for (const variant of variants) { const type = variant.variant_type; if (priority.includes(type)) { variantMap.set(type, variant); } } // Select best variant for each type for (const type of priority) { if (variantMap.has(type)) { selected.push(variantMap.get(type)); break; // Take first matching priority } } return selected; } } module.exports = PWAManifestService;