281 lines
8.0 KiB
JavaScript
281 lines
8.0 KiB
JavaScript
/**
|
|
* 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;
|