39948-vm/backend/src/services/pwa_manifest.js
2026-03-26 21:19:18 +04:00

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;