offline mode improvements
This commit is contained in:
parent
42cc3456eb
commit
f8c3bb4a07
@ -33,30 +33,4 @@ router.post(
|
||||
}),
|
||||
);
|
||||
|
||||
// Custom endpoint: Get offline manifest for PWA download
|
||||
router.get(
|
||||
'/:id/offline-manifest',
|
||||
wrapAsync(async (req, res) => {
|
||||
if (!isUuidV4(req.params.id)) {
|
||||
return res.status(400).send('Invalid project id');
|
||||
}
|
||||
|
||||
const PWAManifestService = require('../services/pwa_manifest');
|
||||
const { variant = 'desktop' } = req.query;
|
||||
|
||||
// Build base URL for asset proxy endpoints
|
||||
const protocol = req.protocol;
|
||||
const host = req.get('host');
|
||||
const baseUrl = `${protocol}://${host}`;
|
||||
|
||||
const manifest = await PWAManifestService.generateManifest(
|
||||
req.params.id,
|
||||
variant,
|
||||
baseUrl,
|
||||
);
|
||||
|
||||
res.status(200).json(manifest);
|
||||
}),
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@ -1,359 +0,0 @@
|
||||
/**
|
||||
* 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)
|
||||
* @param {string} baseUrl - Base URL for proxy endpoints (e.g., 'http://localhost:8080')
|
||||
* @returns {Object} Offline manifest
|
||||
*/
|
||||
static async generateManifest(projectId, deviceType = 'desktop', baseUrl = '') {
|
||||
// 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 convert URL to proxy URL (avoids CORS issues with S3)
|
||||
// Extracts storage key from full URLs for backend proxy
|
||||
const toProxyUrl = (url) => {
|
||||
if (!url) return url;
|
||||
|
||||
let storageKey = url;
|
||||
|
||||
// If it's a full S3 URL, extract the path after the bucket/hash
|
||||
if (url.includes('.s3.') || url.includes('s3.amazonaws.com')) {
|
||||
// URL format: https://bucket.s3.amazonaws.com/hash/path/to/file
|
||||
// or: https://s3.region.amazonaws.com/bucket/hash/path/to/file
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const pathParts = urlObj.pathname.split('/').filter(Boolean);
|
||||
// Skip the hash prefix (first path segment after bucket)
|
||||
if (pathParts.length > 1) {
|
||||
storageKey = pathParts.slice(1).join('/');
|
||||
} else {
|
||||
storageKey = pathParts.join('/');
|
||||
}
|
||||
} catch {
|
||||
// If URL parsing fails, use as-is
|
||||
}
|
||||
} else if (url.includes('storage.googleapis.com')) {
|
||||
// GCloud URL format: https://storage.googleapis.com/bucket/hash/path/to/file
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const pathParts = urlObj.pathname.split('/').filter(Boolean);
|
||||
// Skip bucket and hash (first two segments)
|
||||
if (pathParts.length > 2) {
|
||||
storageKey = pathParts.slice(2).join('/');
|
||||
}
|
||||
} catch {
|
||||
// If URL parsing fails, use as-is
|
||||
}
|
||||
} else if (url.startsWith('http')) {
|
||||
// Other external URL - return as-is (can't proxy)
|
||||
return url;
|
||||
}
|
||||
|
||||
return `${baseUrl}/api/file/download?privateUrl=${encodeURIComponent(storageKey)}`;
|
||||
};
|
||||
|
||||
// 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: toProxyUrl(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],
|
||||
);
|
||||
}
|
||||
if (page.background_audio_url) {
|
||||
addAsset(
|
||||
`page-audio-${page.id}`,
|
||||
page.background_audio_url,
|
||||
`page-${page.slug}-audio.mp3`,
|
||||
'original',
|
||||
'audio',
|
||||
'audio/mpeg',
|
||||
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;
|
||||
File diff suppressed because one or more lines are too long
@ -18,12 +18,15 @@ import BaseButton from '../BaseButton';
|
||||
import { useOfflineMode } from '../../hooks/useOfflineMode';
|
||||
import { useStorageQuota } from '../../hooks/useStorageQuota';
|
||||
import type { ProjectOfflineStatus } from '../../types/offline';
|
||||
import type { PreloadPage } from '../../types/preload';
|
||||
import { logger } from '../../lib/logger';
|
||||
|
||||
interface OfflineToggleProps {
|
||||
projectId: string | null;
|
||||
projectSlug?: string;
|
||||
projectName?: string;
|
||||
/** Pages data for frontend asset discovery (required for offline download) */
|
||||
pages?: PreloadPage[];
|
||||
className?: string;
|
||||
showLabel?: boolean;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
@ -33,6 +36,7 @@ export function OfflineToggle({
|
||||
projectId,
|
||||
projectSlug,
|
||||
projectName,
|
||||
pages,
|
||||
className = '',
|
||||
showLabel = true,
|
||||
size = 'medium',
|
||||
@ -54,6 +58,7 @@ export function OfflineToggle({
|
||||
projectId,
|
||||
projectSlug,
|
||||
projectName,
|
||||
pages,
|
||||
});
|
||||
|
||||
const { canStore, isWarning, isCritical } = useStorageQuota();
|
||||
|
||||
@ -601,6 +601,7 @@ export default function RuntimePresentation({
|
||||
projectId={project?.id || null}
|
||||
projectSlug={projectSlug}
|
||||
projectName={project?.name}
|
||||
pages={pages}
|
||||
showLabel={false}
|
||||
size='small'
|
||||
/>
|
||||
|
||||
@ -3,10 +3,18 @@
|
||||
*
|
||||
* Builds a navigation graph from page_links to determine which pages
|
||||
* are neighbors and should have their assets preloaded.
|
||||
*
|
||||
* Uses shared asset discovery from lib/assetCache for consistent extraction.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { PRELOAD_CONFIG } from '../config/preload.config';
|
||||
import {
|
||||
extractElementAssets,
|
||||
extractPageBackgroundAssets,
|
||||
extractTransitionAssets,
|
||||
toPreloadAssetInfo,
|
||||
} from '../lib/assetCache';
|
||||
import type {
|
||||
PreloadPage,
|
||||
PreloadPageLink,
|
||||
@ -50,62 +58,6 @@ interface NeighborGraphResult {
|
||||
adjacencyList: Map<string, string[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse content_json to extract asset URLs
|
||||
*/
|
||||
function extractAssetsFromContent(
|
||||
contentJson: string | undefined,
|
||||
pageId: string,
|
||||
): PreloadAssetInfo[] {
|
||||
if (!contentJson) return [];
|
||||
|
||||
try {
|
||||
const content =
|
||||
typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
|
||||
|
||||
const assets: PreloadAssetInfo[] = [];
|
||||
|
||||
// Asset URL fields in element content_json
|
||||
const urlFields = PRELOAD_CONFIG.assetFields.all as readonly string[];
|
||||
|
||||
const checkObject = (obj: Record<string, unknown>, 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)) {
|
||||
// Classify asset type based on field name
|
||||
const lowerKey = key.toLowerCase();
|
||||
|
||||
let assetType: 'video' | 'audio' | 'image' | 'transition';
|
||||
if (lowerKey.includes('transition')) {
|
||||
assetType = 'transition';
|
||||
} else if (lowerKey.includes('video')) {
|
||||
assetType = 'video';
|
||||
} else if (lowerKey.includes('audio')) {
|
||||
assetType = 'audio';
|
||||
} else {
|
||||
assetType = 'image';
|
||||
}
|
||||
|
||||
assets.push({
|
||||
url: value,
|
||||
pageId,
|
||||
assetType,
|
||||
priority: 0, // Will be calculated later
|
||||
});
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
checkObject(value as Record<string, unknown>, depth + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkObject(content);
|
||||
return assets;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function useNeighborGraph(
|
||||
options: UseNeighborGraphOptions,
|
||||
): NeighborGraphResult {
|
||||
@ -177,7 +129,7 @@ export function useNeighborGraph(
|
||||
};
|
||||
}, [adjacencyList, maxDepth]);
|
||||
|
||||
// Get assets for a set of pages
|
||||
// Get assets for a set of pages - uses shared extraction from assetDiscovery
|
||||
const getAssetsForPages = useMemo(() => {
|
||||
return (pageIds: string[]): PreloadAssetInfo[] => {
|
||||
const assets: PreloadAssetInfo[] = [];
|
||||
@ -187,74 +139,36 @@ export function useNeighborGraph(
|
||||
// Find the page to get its background assets
|
||||
const page = pages.find((p) => p.id === pageId);
|
||||
if (page) {
|
||||
// Add page background image (highest priority for page display)
|
||||
if (page.background_image_url && !seenUrls.has(page.background_image_url)) {
|
||||
seenUrls.add(page.background_image_url);
|
||||
assets.push({
|
||||
url: page.background_image_url,
|
||||
pageId,
|
||||
assetType: 'image',
|
||||
priority: 0, // Will be calculated later
|
||||
});
|
||||
}
|
||||
// Add page background video
|
||||
if (page.background_video_url && !seenUrls.has(page.background_video_url)) {
|
||||
seenUrls.add(page.background_video_url);
|
||||
assets.push({
|
||||
url: page.background_video_url,
|
||||
pageId,
|
||||
assetType: 'video',
|
||||
priority: 0,
|
||||
});
|
||||
}
|
||||
// Add page background audio
|
||||
if (page.background_audio_url && !seenUrls.has(page.background_audio_url)) {
|
||||
seenUrls.add(page.background_audio_url);
|
||||
assets.push({
|
||||
url: page.background_audio_url,
|
||||
pageId,
|
||||
assetType: 'audio',
|
||||
priority: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get elements for this page
|
||||
const pageElements = elements.filter((el) => el.pageId === pageId);
|
||||
|
||||
// Extract assets from element content
|
||||
pageElements.forEach((element) => {
|
||||
const elementAssets = extractAssetsFromContent(
|
||||
element.content_json,
|
||||
pageId,
|
||||
);
|
||||
elementAssets.forEach((asset) => {
|
||||
if (!seenUrls.has(asset.url)) {
|
||||
seenUrls.add(asset.url);
|
||||
assets.push(asset);
|
||||
// Use shared extraction for page backgrounds
|
||||
const bgAssets = extractPageBackgroundAssets(page);
|
||||
bgAssets.forEach((asset) => {
|
||||
if (!seenUrls.has(asset.originalUrl)) {
|
||||
seenUrls.add(asset.originalUrl);
|
||||
assets.push(toPreloadAssetInfo(asset));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Get elements for this page and use shared extraction
|
||||
const pageElements = elements.filter((el) => el.pageId === pageId);
|
||||
const elementAssets = extractElementAssets(pageElements, pageId);
|
||||
elementAssets.forEach((asset) => {
|
||||
if (!seenUrls.has(asset.originalUrl)) {
|
||||
seenUrls.add(asset.originalUrl);
|
||||
assets.push(toPreloadAssetInfo(asset));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Extract transition videos from page_links for preloading
|
||||
const matchingLinks = pageLinks.filter(
|
||||
(link) =>
|
||||
link.is_active !== false && pageIds.includes(link.from_pageId || ''),
|
||||
);
|
||||
|
||||
matchingLinks.forEach((link) => {
|
||||
// Extract transition video URL from link.transition
|
||||
const transition = link.transition as { video_url?: string } | undefined;
|
||||
if (transition?.video_url && !seenUrls.has(transition.video_url)) {
|
||||
seenUrls.add(transition.video_url);
|
||||
assets.push({
|
||||
url: transition.video_url,
|
||||
pageId: link.from_pageId || '',
|
||||
assetType: 'transition',
|
||||
priority: 0, // Will be calculated later with transition priority
|
||||
});
|
||||
}
|
||||
// Extract transition videos using shared extraction
|
||||
pageIds.forEach((pageId) => {
|
||||
const transitionAssets = extractTransitionAssets(pageLinks, pageId);
|
||||
transitionAssets.forEach((asset) => {
|
||||
if (!seenUrls.has(asset.originalUrl)) {
|
||||
seenUrls.add(asset.originalUrl);
|
||||
assets.push(toPreloadAssetInfo(asset));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return assets;
|
||||
|
||||
@ -2,30 +2,40 @@
|
||||
* useOfflineMode Hook
|
||||
*
|
||||
* Manages offline mode state and project download functionality.
|
||||
* Uses frontend asset discovery (same as online preloading) for consistent behavior.
|
||||
* No backend manifest dependency - single source of truth in frontend.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import axios from 'axios';
|
||||
import { downloadManager } from '../lib/offline/DownloadManager';
|
||||
import { StorageManager } from '../lib/offline/StorageManager';
|
||||
import { OfflineDbManager } from '../lib/offlineDb/OfflineDbManager';
|
||||
import { downloadEventBus } from '../lib/offline/DownloadEventBus';
|
||||
import { OFFLINE_CONFIG } from '../config/offline.config';
|
||||
import { extractStoragePath } from '../lib/assetUrl';
|
||||
import {
|
||||
queuePresignedUrls,
|
||||
resolveAssetPlaybackUrl,
|
||||
isRelativeStoragePath,
|
||||
} from '../lib/assetUrl';
|
||||
import { extractPageLinksAndElements } from '../lib/extractPageLinks';
|
||||
import { discoverProjectAssets, type AssetToCache } from '../lib/assetCache';
|
||||
import { logger } from '../lib/logger';
|
||||
import type {
|
||||
CachedAssetInfo,
|
||||
OfflineProject,
|
||||
OfflineManifest,
|
||||
ProjectOfflineStatus,
|
||||
ProjectDownloadProgressEvent,
|
||||
PreloadCompleteEvent,
|
||||
PreloadErrorEvent,
|
||||
} from '../types/offline';
|
||||
import type { PreloadPage } from '../types/preload';
|
||||
|
||||
interface UseOfflineModeOptions {
|
||||
projectId: string | null;
|
||||
projectSlug?: string;
|
||||
projectName?: string;
|
||||
/** Pages data for frontend asset discovery (required for offline download) */
|
||||
pages?: PreloadPage[];
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
@ -72,10 +82,10 @@ const formatBytes = (bytes: number): string => {
|
||||
export function useOfflineMode(
|
||||
options: UseOfflineModeOptions,
|
||||
): UseOfflineModeResult {
|
||||
const { projectId, projectSlug, projectName, enabled = true } = options;
|
||||
const { projectId, projectSlug, projectName, pages, enabled = true } = options;
|
||||
|
||||
const [projectInfo, setProjectInfo] = useState<OfflineProject | null>(null);
|
||||
const [manifest, setManifest] = useState<OfflineManifest | null>(null);
|
||||
const [discoveredAssets, setDiscoveredAssets] = useState<AssetToCache[]>([]);
|
||||
const [status, setStatus] = useState<ProjectOfflineStatus>('not_downloaded');
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [downloadedAssets, setDownloadedAssets] = useState(0);
|
||||
@ -85,8 +95,8 @@ export function useOfflineMode(
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
|
||||
// Track manifest for event-driven progress
|
||||
const manifestRef = useRef<OfflineManifest | null>(null);
|
||||
// Track assets for event-driven progress
|
||||
const assetsRef = useRef<AssetToCache[]>([]);
|
||||
const downloadedCountRef = useRef(0);
|
||||
const downloadedBytesRef = useRef(0);
|
||||
|
||||
@ -126,12 +136,12 @@ export function useOfflineMode(
|
||||
if (!projectId) return;
|
||||
|
||||
const handleComplete = (data: PreloadCompleteEvent) => {
|
||||
// Only track if we have manifest data
|
||||
if (!manifestRef.current) return;
|
||||
// Only track if we have discovered assets
|
||||
if (!assetsRef.current.length) return;
|
||||
|
||||
// Find the asset in manifest to get its size
|
||||
const asset = manifestRef.current.assets.find(
|
||||
(a) => a.id === data.assetId,
|
||||
// Find the asset to get its size
|
||||
const asset = assetsRef.current.find(
|
||||
(a) => a.storageKey === data.storageKey || `offline-${a.storageKey}` === data.assetId,
|
||||
);
|
||||
const assetSize = asset?.sizeBytes || 0;
|
||||
|
||||
@ -141,8 +151,8 @@ export function useOfflineMode(
|
||||
|
||||
const downloaded = downloadedCountRef.current;
|
||||
const dlBytes = downloadedBytesRef.current;
|
||||
const total = manifestRef.current.assets.length;
|
||||
const totalSize = manifestRef.current.totalSizeBytes;
|
||||
const total = assetsRef.current.length;
|
||||
const totalSize = assetsRef.current.reduce((sum, a) => sum + (a.sizeBytes || 0), 0);
|
||||
|
||||
// Update state
|
||||
setDownloadedAssets(downloaded);
|
||||
@ -199,7 +209,7 @@ export function useOfflineMode(
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
// Also listen for legacy project progress events (for compatibility)
|
||||
// Listen for project progress events (allows external components to sync state)
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
|
||||
@ -221,24 +231,28 @@ export function useOfflineMode(
|
||||
);
|
||||
}, [projectId]);
|
||||
|
||||
// Fetch manifest from backend
|
||||
const fetchManifest =
|
||||
useCallback(async (): Promise<OfflineManifest | null> => {
|
||||
if (!projectId) return null;
|
||||
// Discover assets using frontend logic (same as online preloading)
|
||||
const discoverAssets = useCallback((): AssetToCache[] => {
|
||||
if (!pages || pages.length === 0) {
|
||||
logger.warn('[useOfflineMode] No pages provided for asset discovery');
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`/projects/${projectId}/offline-manifest`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
'[useOfflineMode] Failed to fetch manifest:',
|
||||
err instanceof Error ? err : { error: err },
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}, [projectId]);
|
||||
// Extract pageLinks and elements from all pages
|
||||
const { pageLinks, preloadElements } = extractPageLinksAndElements(pages);
|
||||
|
||||
// Use shared asset discovery (same as online preload)
|
||||
const assets = discoverProjectAssets(pages, pageLinks, preloadElements);
|
||||
|
||||
logger.info('[useOfflineMode] Discovered assets from pages', {
|
||||
pageCount: pages.length,
|
||||
linkCount: pageLinks.length,
|
||||
elementCount: preloadElements.length,
|
||||
assetCount: assets.length,
|
||||
});
|
||||
|
||||
return assets;
|
||||
}, [pages]);
|
||||
|
||||
// Start download
|
||||
const startDownload = useCallback(async (): Promise<void> => {
|
||||
@ -249,17 +263,20 @@ export function useOfflineMode(
|
||||
setIsPaused(false);
|
||||
|
||||
try {
|
||||
// Fetch manifest
|
||||
const manifestData = await fetchManifest();
|
||||
if (!manifestData) {
|
||||
throw new Error('Failed to fetch offline manifest');
|
||||
// Discover assets from pages (frontend-only, no backend call)
|
||||
const assets = discoverAssets();
|
||||
if (assets.length === 0) {
|
||||
throw new Error('No assets discovered. Make sure pages data is provided.');
|
||||
}
|
||||
|
||||
// Store manifest for event-driven progress tracking
|
||||
manifestRef.current = manifestData;
|
||||
setManifest(manifestData);
|
||||
setTotalAssets(manifestData.assets.length);
|
||||
setTotalBytes(manifestData.totalSizeBytes);
|
||||
// Store assets for event-driven progress tracking
|
||||
assetsRef.current = assets;
|
||||
setDiscoveredAssets(assets);
|
||||
setTotalAssets(assets.length);
|
||||
|
||||
// Estimate total size (may not have exact sizes for all assets)
|
||||
const estimatedTotalSize = assets.reduce((sum, a) => sum + (a.sizeBytes || 0), 0);
|
||||
setTotalBytes(estimatedTotalSize);
|
||||
|
||||
// Create or update project record
|
||||
const projectRecord: OfflineProject = {
|
||||
@ -267,18 +284,18 @@ export function useOfflineMode(
|
||||
slug: projectSlug || '',
|
||||
name: projectName || '',
|
||||
status: 'downloading',
|
||||
totalAssets: manifestData.assets.length,
|
||||
totalAssets: assets.length,
|
||||
downloadedAssets: 0,
|
||||
totalSizeBytes: manifestData.totalSizeBytes,
|
||||
totalSizeBytes: estimatedTotalSize,
|
||||
downloadedSizeBytes: 0,
|
||||
version: manifestData.version,
|
||||
version: `v${Date.now()}`,
|
||||
};
|
||||
await OfflineDbManager.upsertProject(projectRecord);
|
||||
setProjectInfo(projectRecord);
|
||||
|
||||
// Check storage quota
|
||||
const quota = await StorageManager.getStorageQuota();
|
||||
if (!quota.canStore(manifestData.totalSizeBytes)) {
|
||||
if (estimatedTotalSize > 0 && !quota.canStore(estimatedTotalSize)) {
|
||||
throw new Error('Insufficient storage space');
|
||||
}
|
||||
|
||||
@ -286,16 +303,28 @@ export function useOfflineMode(
|
||||
let downloadedCount = 0;
|
||||
let downloadedSize = 0;
|
||||
|
||||
// First, check which assets are already cached
|
||||
const assetsToDownload: typeof manifestData.assets = [];
|
||||
for (const asset of manifestData.assets) {
|
||||
// Use canonical storage key for checking
|
||||
const storageKey = extractStoragePath(asset.url);
|
||||
const hasAsset = await StorageManager.hasAsset(storageKey);
|
||||
if (hasAsset) {
|
||||
// First, check which assets are already fully cached (not partial)
|
||||
// Partial downloads from online preload need to be re-downloaded fully for offline
|
||||
const assetsToDownload: AssetToCache[] = [];
|
||||
for (const asset of assets) {
|
||||
const assetInfo: CachedAssetInfo | null = await StorageManager.getAssetInfo(asset.storageKey);
|
||||
|
||||
if (assetInfo?.exists && !assetInfo.isPartial) {
|
||||
// Fully cached, skip
|
||||
downloadedCount++;
|
||||
downloadedSize += asset.sizeBytes;
|
||||
downloadedSize += asset.sizeBytes || 0;
|
||||
logger.info('[useOfflineMode] Asset already fully cached', {
|
||||
storageKey: asset.storageKey.slice(-50),
|
||||
});
|
||||
} else if (assetInfo?.exists && assetInfo.isPartial) {
|
||||
// Partial download - need full download for offline
|
||||
logger.info('[useOfflineMode] Upgrading partial download for offline', {
|
||||
storageKey: asset.storageKey.slice(-50),
|
||||
partialSize: assetInfo.sizeBytes,
|
||||
});
|
||||
assetsToDownload.push(asset);
|
||||
} else {
|
||||
// Not cached at all
|
||||
assetsToDownload.push(asset);
|
||||
}
|
||||
}
|
||||
@ -305,7 +334,7 @@ export function useOfflineMode(
|
||||
downloadedBytesRef.current = downloadedSize;
|
||||
|
||||
logger.info('[useOfflineMode] Assets to download:', {
|
||||
total: manifestData.assets.length,
|
||||
total: assets.length,
|
||||
alreadyCached: downloadedCount,
|
||||
toDownload: assetsToDownload.length,
|
||||
});
|
||||
@ -314,7 +343,7 @@ export function useOfflineMode(
|
||||
setDownloadedAssets(downloadedCount);
|
||||
setDownloadedBytes(downloadedSize);
|
||||
|
||||
if (downloadedCount === manifestData.assets.length) {
|
||||
if (downloadedCount === assets.length) {
|
||||
// All already downloaded
|
||||
setStatus('downloaded');
|
||||
setProgress(100);
|
||||
@ -325,19 +354,39 @@ export function useOfflineMode(
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch presigned URLs for assets that need them
|
||||
const storagePaths = assetsToDownload
|
||||
.filter((a) => isRelativeStoragePath(a.originalUrl))
|
||||
.map((a) => a.storageKey);
|
||||
|
||||
let presignedUrls: Record<string, string> = {};
|
||||
if (storagePaths.length > 0) {
|
||||
try {
|
||||
presignedUrls = await queuePresignedUrls(storagePaths);
|
||||
logger.info('[useOfflineMode] Fetched presigned URLs', {
|
||||
count: Object.keys(presignedUrls).length,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn('[useOfflineMode] Failed to fetch presigned URLs, using proxy', {
|
||||
error: err instanceof Error ? err.message : 'unknown',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Queue all remaining assets for parallel download
|
||||
// DownloadManager handles concurrency internally
|
||||
// Progress is tracked via event subscriptions (see useEffect above)
|
||||
for (const asset of assetsToDownload) {
|
||||
const storageKey = extractStoragePath(asset.url);
|
||||
// Resolve download URL - prefer presigned, fallback to proxy
|
||||
const downloadUrl = presignedUrls[asset.storageKey] || resolveAssetPlaybackUrl(asset.storageKey);
|
||||
|
||||
downloadManager
|
||||
.addJob({
|
||||
assetId: asset.id,
|
||||
assetId: `offline-${asset.storageKey}`,
|
||||
projectId,
|
||||
url: asset.url,
|
||||
filename: asset.filename,
|
||||
variantType: asset.variantType,
|
||||
url: downloadUrl,
|
||||
filename: asset.storageKey.split('/').pop() || 'asset',
|
||||
variantType: 'original',
|
||||
assetType: asset.assetType,
|
||||
priority:
|
||||
asset.assetType === 'image'
|
||||
@ -345,14 +394,14 @@ export function useOfflineMode(
|
||||
: asset.assetType === 'video'
|
||||
? 50
|
||||
: 75,
|
||||
storageKey,
|
||||
storageKey: asset.storageKey,
|
||||
createBlobUrl: true, // Create blob URL for instant display
|
||||
persist: true, // Persist for resume after page refresh
|
||||
})
|
||||
.catch((err) => {
|
||||
// Errors handled by DownloadManager retry logic and events
|
||||
logger.error('[useOfflineMode] Asset download failed', {
|
||||
assetId: asset.id,
|
||||
storageKey: asset.storageKey.slice(-50),
|
||||
error: err?.message,
|
||||
});
|
||||
});
|
||||
@ -368,7 +417,7 @@ export function useOfflineMode(
|
||||
setStatus('error');
|
||||
await OfflineDbManager.updateProjectStatus(projectId, 'error');
|
||||
}
|
||||
}, [projectId, projectSlug, projectName, enabled, fetchManifest]);
|
||||
}, [projectId, projectSlug, projectName, enabled, discoverAssets]);
|
||||
|
||||
// Pause download
|
||||
const pauseDownload = useCallback(() => {
|
||||
@ -395,7 +444,7 @@ export function useOfflineMode(
|
||||
setError(null);
|
||||
|
||||
// Reset refs
|
||||
manifestRef.current = null;
|
||||
assetsRef.current = [];
|
||||
downloadedCountRef.current = 0;
|
||||
downloadedBytesRef.current = 0;
|
||||
|
||||
@ -419,15 +468,15 @@ export function useOfflineMode(
|
||||
setTotalBytes(0);
|
||||
}, [projectId]);
|
||||
|
||||
// Check for updates
|
||||
// Check for updates by comparing discovered assets with stored project
|
||||
const checkForUpdates = useCallback(async (): Promise<boolean> => {
|
||||
if (!projectId || !projectInfo) return false;
|
||||
if (!projectId || !projectInfo || !pages) return false;
|
||||
|
||||
try {
|
||||
const latestManifest = await fetchManifest();
|
||||
if (!latestManifest) return false;
|
||||
const currentAssets = discoverAssets();
|
||||
|
||||
if (latestManifest.version !== projectInfo.version) {
|
||||
// Simple check: if asset count changed, there are updates
|
||||
if (currentAssets.length !== projectInfo.totalAssets) {
|
||||
setStatus('outdated');
|
||||
await OfflineDbManager.updateProjectStatus(projectId, 'outdated');
|
||||
return true;
|
||||
@ -437,12 +486,12 @@ export function useOfflineMode(
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [projectId, projectInfo, fetchManifest]);
|
||||
}, [projectId, projectInfo, pages, discoverAssets]);
|
||||
|
||||
// Computed values
|
||||
const isDownloaded = status === 'downloaded';
|
||||
const isDownloading = status === 'downloading' && !isPaused;
|
||||
const estimatedSize = manifest?.totalSizeBytes || totalBytes;
|
||||
const estimatedSize = discoveredAssets.reduce((sum, a) => sum + (a.sizeBytes || 0), 0) || totalBytes;
|
||||
|
||||
return {
|
||||
isOfflineCapable,
|
||||
|
||||
363
frontend/src/lib/assetCache/AssetCacheService.ts
Normal file
363
frontend/src/lib/assetCache/AssetCacheService.ts
Normal file
@ -0,0 +1,363 @@
|
||||
/**
|
||||
* Asset Cache Service
|
||||
*
|
||||
* Unified service for asset caching across online preload and offline download modes.
|
||||
* This service provides a single entry point for:
|
||||
* - Discovering assets from pages/elements/links
|
||||
* - Checking cache status (fully cached, partially cached, or missing)
|
||||
* - Queueing downloads with appropriate parameters
|
||||
* - Managing storage keys consistently
|
||||
*/
|
||||
|
||||
import { StorageManager } from '../offline/StorageManager';
|
||||
import { downloadManager } from '../offline/DownloadManager';
|
||||
import {
|
||||
discoverProjectAssets,
|
||||
getPrioritizedAssets,
|
||||
type AssetToCache,
|
||||
type AssetDiscoveryOptions,
|
||||
} from './assetDiscovery';
|
||||
import {
|
||||
queuePresignedUrls,
|
||||
resolveAssetPlaybackUrl,
|
||||
isPresignedUrl,
|
||||
markPresignedUrlFailed,
|
||||
markPresignedUrlsVerified,
|
||||
buildProxyUrl,
|
||||
isRelativeStoragePath,
|
||||
} from '../assetUrl';
|
||||
import { PRELOAD_CONFIG } from '../../config/preload.config';
|
||||
import { logger } from '../logger';
|
||||
import type {
|
||||
PreloadPage,
|
||||
PreloadPageLink,
|
||||
PreloadElement,
|
||||
} from '../../types/preload';
|
||||
import type { AssetType, AssetVariantType, CachedAssetInfo } from '../../types/offline';
|
||||
|
||||
// Re-export for convenience
|
||||
export type { CachedAssetInfo };
|
||||
|
||||
/**
|
||||
* Download progress callback
|
||||
*/
|
||||
export interface DownloadProgress {
|
||||
totalAssets: number;
|
||||
downloadedAssets: number;
|
||||
totalBytes: number;
|
||||
downloadedBytes: number;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for queueing downloads
|
||||
*/
|
||||
export interface QueueDownloadOptions {
|
||||
mode: 'online' | 'offline';
|
||||
projectId?: string;
|
||||
presignedUrls?: Record<string, string>;
|
||||
onProgress?: (progress: DownloadProgress) => void;
|
||||
currentPageId?: string; // For online mode: current page assets get full downloads
|
||||
}
|
||||
|
||||
/**
|
||||
* AssetCacheService - Unified entry point for asset caching
|
||||
*/
|
||||
export class AssetCacheService {
|
||||
/**
|
||||
* Discover all assets for a project from page data
|
||||
* Single source of truth for both online and offline
|
||||
*/
|
||||
static discoverProjectAssets(
|
||||
pages: PreloadPage[],
|
||||
pageLinks: PreloadPageLink[],
|
||||
elements: PreloadElement[],
|
||||
options?: AssetDiscoveryOptions,
|
||||
): AssetToCache[] {
|
||||
return discoverProjectAssets(pages, pageLinks, elements, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get prioritized assets for preloading based on current page
|
||||
*/
|
||||
static getPrioritizedAssets(
|
||||
currentPageId: string,
|
||||
pages: PreloadPage[],
|
||||
pageLinks: PreloadPageLink[],
|
||||
elements: PreloadElement[],
|
||||
neighborPageIds: Array<{ pageId: string; distance: number }>,
|
||||
options?: AssetDiscoveryOptions,
|
||||
): AssetToCache[] {
|
||||
return getPrioritizedAssets(
|
||||
currentPageId,
|
||||
pages,
|
||||
pageLinks,
|
||||
elements,
|
||||
neighborPageIds,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache status for a single asset
|
||||
* Returns whether it exists, if it's partial, and size info
|
||||
*/
|
||||
static async getAssetInfo(storageKey: string): Promise<CachedAssetInfo | null> {
|
||||
return StorageManager.getAssetInfo(storageKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache status for multiple assets efficiently
|
||||
* Returns a map of storageKey -> CachedAssetInfo
|
||||
*/
|
||||
static async checkCacheStatus(
|
||||
storageKeys: string[],
|
||||
): Promise<Map<string, CachedAssetInfo>> {
|
||||
const results = new Map<string, CachedAssetInfo>();
|
||||
|
||||
// Process in parallel with a concurrency limit
|
||||
const BATCH_SIZE = 20;
|
||||
for (let i = 0; i < storageKeys.length; i += BATCH_SIZE) {
|
||||
const batch = storageKeys.slice(i, i + BATCH_SIZE);
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async (storageKey) => {
|
||||
const info = await StorageManager.getAssetInfo(storageKey);
|
||||
return { storageKey, info };
|
||||
}),
|
||||
);
|
||||
|
||||
batchResults.forEach(({ storageKey, info }) => {
|
||||
if (info) {
|
||||
results.set(storageKey, info);
|
||||
} else {
|
||||
results.set(storageKey, {
|
||||
storageKey,
|
||||
exists: false,
|
||||
isPartial: false,
|
||||
sizeBytes: 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter assets to only those needing download
|
||||
* - Not cached at all -> needs download
|
||||
* - Partially cached + offline mode -> needs full download
|
||||
* - Fully cached -> skip
|
||||
*/
|
||||
static async filterAssetsNeedingDownload(
|
||||
assets: AssetToCache[],
|
||||
mode: 'online' | 'offline',
|
||||
): Promise<AssetToCache[]> {
|
||||
const storageKeys = assets.map((a) => a.storageKey);
|
||||
const cacheStatus = await this.checkCacheStatus(storageKeys);
|
||||
|
||||
return assets.filter((asset) => {
|
||||
const status = cacheStatus.get(asset.storageKey);
|
||||
if (!status) return true; // Not in cache, needs download
|
||||
|
||||
if (!status.exists) return true; // Not cached, needs download
|
||||
|
||||
// In offline mode, partial downloads need to be fully downloaded
|
||||
if (mode === 'offline' && status.isPartial) return true;
|
||||
|
||||
// Fully cached, skip
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve download URL for an asset
|
||||
* Prefers presigned URL if available, falls back to proxy
|
||||
*/
|
||||
static resolveDownloadUrl(
|
||||
storageKey: string,
|
||||
presignedUrls?: Record<string, string>,
|
||||
): string {
|
||||
// Check presigned URL cache first
|
||||
if (presignedUrls && presignedUrls[storageKey]) {
|
||||
return presignedUrls[storageKey];
|
||||
}
|
||||
|
||||
// Use resolveAssetPlaybackUrl for fallback
|
||||
return resolveAssetPlaybackUrl(storageKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue assets for download with unified parameters
|
||||
* Handles both online (partial) and offline (full) modes
|
||||
*/
|
||||
static async queueDownloads(
|
||||
assets: AssetToCache[],
|
||||
options: QueueDownloadOptions,
|
||||
): Promise<void> {
|
||||
const { mode, projectId = '', presignedUrls = {}, currentPageId } = options;
|
||||
|
||||
// Fetch presigned URLs for assets that need them
|
||||
const storagePaths = assets
|
||||
.filter((a) => isRelativeStoragePath(a.originalUrl))
|
||||
.map((a) => a.storageKey);
|
||||
|
||||
let resolvedPresignedUrls = { ...presignedUrls };
|
||||
if (storagePaths.length > 0 && mode === 'online') {
|
||||
try {
|
||||
const fetchedUrls = await queuePresignedUrls(storagePaths);
|
||||
resolvedPresignedUrls = { ...resolvedPresignedUrls, ...fetchedUrls };
|
||||
} catch (error) {
|
||||
logger.error('[AssetCacheService] Failed to fetch presigned URLs', {
|
||||
error: error instanceof Error ? error.message : 'unknown',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Queue each asset for download
|
||||
for (const asset of assets) {
|
||||
const downloadUrl = this.resolveDownloadUrl(
|
||||
asset.storageKey,
|
||||
resolvedPresignedUrls,
|
||||
);
|
||||
|
||||
if (!downloadUrl) continue;
|
||||
|
||||
// Determine download parameters based on mode
|
||||
const isOnlineMode = mode === 'online';
|
||||
const isCurrentPageAsset = currentPageId && asset.pageId === currentPageId;
|
||||
|
||||
// Online mode: use partial downloads for neighbor page media
|
||||
// Offline mode: always full downloads
|
||||
const maxBytes = this.getMaxBytesForAsset(
|
||||
asset.assetType,
|
||||
isOnlineMode,
|
||||
!isCurrentPageAsset,
|
||||
);
|
||||
|
||||
// Create blob URL for images (instant navigation) and full downloads
|
||||
const createBlobUrl =
|
||||
asset.assetType === 'image' || maxBytes === undefined;
|
||||
|
||||
try {
|
||||
await downloadManager
|
||||
.addJob({
|
||||
assetId: `${mode}-${asset.storageKey}`,
|
||||
projectId,
|
||||
url: downloadUrl,
|
||||
filename: asset.storageKey.split('/').pop() || 'asset',
|
||||
variantType: 'original' as AssetVariantType,
|
||||
assetType: asset.assetType,
|
||||
priority: asset.priority,
|
||||
storageKey: asset.storageKey,
|
||||
createBlobUrl,
|
||||
persist: mode === 'offline', // Only persist for offline mode
|
||||
maxBytes,
|
||||
})
|
||||
.then(() => {
|
||||
if (isPresignedUrl(downloadUrl)) {
|
||||
markPresignedUrlsVerified();
|
||||
}
|
||||
})
|
||||
.catch(async (err) => {
|
||||
logger.error('[AssetCacheService] Download failed', {
|
||||
storageKey: asset.storageKey.slice(-50),
|
||||
error: err?.message,
|
||||
});
|
||||
|
||||
// Retry with proxy if presigned URL failed
|
||||
if (isPresignedUrl(downloadUrl)) {
|
||||
markPresignedUrlFailed(asset.storageKey);
|
||||
const proxyUrl = buildProxyUrl(asset.storageKey);
|
||||
|
||||
try {
|
||||
await downloadManager.addJob({
|
||||
assetId: `${mode}-retry-${asset.storageKey}`,
|
||||
projectId,
|
||||
url: proxyUrl,
|
||||
filename: asset.storageKey.split('/').pop() || 'asset',
|
||||
variantType: 'original' as AssetVariantType,
|
||||
assetType: asset.assetType,
|
||||
priority: asset.priority,
|
||||
storageKey: asset.storageKey,
|
||||
createBlobUrl,
|
||||
persist: mode === 'offline',
|
||||
maxBytes,
|
||||
});
|
||||
} catch {
|
||||
// Ignore retry failures
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[AssetCacheService] Failed to queue download', {
|
||||
storageKey: asset.storageKey.slice(-50),
|
||||
error: error instanceof Error ? error.message : 'unknown',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('[AssetCacheService] Downloads queued', {
|
||||
mode,
|
||||
count: assets.length,
|
||||
projectId: projectId || 'none',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine max bytes for partial preload (online mode only)
|
||||
*/
|
||||
private static getMaxBytesForAsset(
|
||||
assetType: AssetType,
|
||||
isOnlineMode: boolean,
|
||||
isNeighborPage: boolean,
|
||||
): number | undefined {
|
||||
// Offline mode: always full downloads
|
||||
if (!isOnlineMode) return undefined;
|
||||
|
||||
// Partial preload disabled
|
||||
if (!PRELOAD_CONFIG.partialPreload.enabled) return undefined;
|
||||
|
||||
// Transitions always use partial preload
|
||||
if (assetType === 'transition') {
|
||||
return PRELOAD_CONFIG.partialPreload.transitionMaxBytes;
|
||||
}
|
||||
|
||||
// Current page assets should be fully downloaded
|
||||
if (!isNeighborPage) return undefined;
|
||||
|
||||
// Neighbor page media uses partial preload
|
||||
switch (assetType) {
|
||||
case 'video':
|
||||
return PRELOAD_CONFIG.partialPreload.videoMaxBytes;
|
||||
case 'audio':
|
||||
return PRELOAD_CONFIG.partialPreload.audioMaxBytes;
|
||||
default:
|
||||
return undefined; // Images need full download for display
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear project cache
|
||||
*/
|
||||
static async clearProjectCache(projectId: string): Promise<void> {
|
||||
await StorageManager.deleteProjectAssets(projectId);
|
||||
logger.info('[AssetCacheService] Project cache cleared', { projectId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ready blob URL for instant display (O(1) lookup)
|
||||
*/
|
||||
static getReadyBlobUrl(url: string): string | null {
|
||||
return downloadManager.getReadyBlobUrl(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if asset is preloaded (in memory blob URL cache)
|
||||
*/
|
||||
static hasReadyBlobUrl(url: string): boolean {
|
||||
return downloadManager.getReadyBlobUrl(url) !== null;
|
||||
}
|
||||
}
|
||||
|
||||
export default AssetCacheService;
|
||||
408
frontend/src/lib/assetCache/assetDiscovery.ts
Normal file
408
frontend/src/lib/assetCache/assetDiscovery.ts
Normal file
@ -0,0 +1,408 @@
|
||||
/**
|
||||
* Asset Discovery Module
|
||||
*
|
||||
* Shared asset extraction logic for both online preload and offline download systems.
|
||||
* This module provides a single source of truth for discovering assets from pages,
|
||||
* elements, and navigation links.
|
||||
*/
|
||||
|
||||
import { PRELOAD_CONFIG } from '../../config/preload.config';
|
||||
import { extractStoragePath } from '../assetUrl';
|
||||
import type {
|
||||
PreloadPage,
|
||||
PreloadPageLink,
|
||||
PreloadElement,
|
||||
PreloadAssetInfo,
|
||||
} from '../../types/preload';
|
||||
import type { AssetType } from '../../types/offline';
|
||||
|
||||
/**
|
||||
* Asset information for caching
|
||||
*/
|
||||
export interface AssetToCache {
|
||||
storageKey: string;
|
||||
originalUrl: string;
|
||||
assetType: AssetType;
|
||||
pageId: string;
|
||||
priority: number;
|
||||
sizeBytes?: number;
|
||||
isTransition?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for asset discovery
|
||||
*/
|
||||
export interface AssetDiscoveryOptions {
|
||||
includeTransitions?: boolean;
|
||||
includeBackgrounds?: boolean;
|
||||
includeElementAssets?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: AssetDiscoveryOptions = {
|
||||
includeTransitions: true,
|
||||
includeBackgrounds: true,
|
||||
includeElementAssets: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Classify asset type based on field name
|
||||
*/
|
||||
export function classifyAssetType(
|
||||
fieldName: string,
|
||||
url: string,
|
||||
): AssetType {
|
||||
const lowerField = fieldName.toLowerCase();
|
||||
const lowerUrl = url.toLowerCase();
|
||||
|
||||
// Check field name first (more reliable)
|
||||
if (lowerField.includes('transition')) {
|
||||
return 'transition';
|
||||
}
|
||||
if (lowerField.includes('video')) {
|
||||
return 'video';
|
||||
}
|
||||
if (lowerField.includes('audio')) {
|
||||
return 'audio';
|
||||
}
|
||||
if (
|
||||
lowerField.includes('image') ||
|
||||
lowerField.includes('icon') ||
|
||||
lowerField.includes('poster') ||
|
||||
lowerField.includes('thumbnail') ||
|
||||
lowerField.includes('src')
|
||||
) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
// Fallback: check URL extension
|
||||
if (lowerUrl.match(/\.(mp4|webm|mov|avi|mkv)/)) {
|
||||
return 'video';
|
||||
}
|
||||
if (lowerUrl.match(/\.(mp3|wav|ogg|aac|m4a)/)) {
|
||||
return 'audio';
|
||||
}
|
||||
if (lowerUrl.match(/\.(jpg|jpeg|png|gif|webp|avif|svg)/)) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract assets from element content_json
|
||||
*/
|
||||
export function extractElementAssets(
|
||||
elements: PreloadElement[],
|
||||
pageId?: string,
|
||||
): AssetToCache[] {
|
||||
const assets: AssetToCache[] = [];
|
||||
const seenUrls = new Set<string>();
|
||||
|
||||
const urlFields = PRELOAD_CONFIG.assetFields.all as readonly string[];
|
||||
|
||||
const processElement = (element: PreloadElement) => {
|
||||
const elementPageId = pageId || element.pageId || '';
|
||||
if (!element.content_json) return;
|
||||
|
||||
try {
|
||||
const content =
|
||||
typeof element.content_json === 'string'
|
||||
? JSON.parse(element.content_json)
|
||||
: element.content_json;
|
||||
|
||||
const checkObject = (obj: Record<string, unknown>, 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)) {
|
||||
const storageKey = extractStoragePath(value);
|
||||
if (storageKey && !seenUrls.has(storageKey)) {
|
||||
seenUrls.add(storageKey);
|
||||
const assetType = classifyAssetType(key, value);
|
||||
assets.push({
|
||||
storageKey,
|
||||
originalUrl: value,
|
||||
assetType,
|
||||
pageId: elementPageId,
|
||||
priority: 0, // Will be calculated later
|
||||
});
|
||||
}
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
checkObject(value as Record<string, unknown>, depth + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkObject(content);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
elements.forEach(processElement);
|
||||
return assets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract background assets from a page
|
||||
*/
|
||||
export function extractPageBackgroundAssets(page: PreloadPage): AssetToCache[] {
|
||||
const assets: AssetToCache[] = [];
|
||||
|
||||
if (page.background_image_url) {
|
||||
const storageKey = extractStoragePath(page.background_image_url);
|
||||
if (storageKey) {
|
||||
assets.push({
|
||||
storageKey,
|
||||
originalUrl: page.background_image_url,
|
||||
assetType: 'image',
|
||||
pageId: page.id,
|
||||
priority: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (page.background_video_url) {
|
||||
const storageKey = extractStoragePath(page.background_video_url);
|
||||
if (storageKey) {
|
||||
assets.push({
|
||||
storageKey,
|
||||
originalUrl: page.background_video_url,
|
||||
assetType: 'video',
|
||||
pageId: page.id,
|
||||
priority: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (page.background_audio_url) {
|
||||
const storageKey = extractStoragePath(page.background_audio_url);
|
||||
if (storageKey) {
|
||||
assets.push({
|
||||
storageKey,
|
||||
originalUrl: page.background_audio_url,
|
||||
assetType: 'audio',
|
||||
pageId: page.id,
|
||||
priority: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return assets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract transition video assets from page links
|
||||
*/
|
||||
export function extractTransitionAssets(
|
||||
pageLinks: PreloadPageLink[],
|
||||
fromPageId?: string,
|
||||
): AssetToCache[] {
|
||||
const assets: AssetToCache[] = [];
|
||||
const seenUrls = new Set<string>();
|
||||
|
||||
const activeLinks = pageLinks.filter((link) => {
|
||||
if (link.is_active === false) return false;
|
||||
if (fromPageId && link.from_pageId !== fromPageId) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
activeLinks.forEach((link) => {
|
||||
const transition = link.transition as { video_url?: string } | undefined;
|
||||
if (transition?.video_url) {
|
||||
const storageKey = extractStoragePath(transition.video_url);
|
||||
if (storageKey && !seenUrls.has(storageKey)) {
|
||||
seenUrls.add(storageKey);
|
||||
assets.push({
|
||||
storageKey,
|
||||
originalUrl: transition.video_url,
|
||||
assetType: 'transition',
|
||||
pageId: link.from_pageId || '',
|
||||
priority: 0,
|
||||
isTransition: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return assets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate priority based on asset type and page relationship
|
||||
*/
|
||||
export function calculateAssetPriority(
|
||||
assetType: AssetType,
|
||||
isCurrentPage: boolean,
|
||||
distance = 0,
|
||||
): number {
|
||||
const typePriority = PRELOAD_CONFIG.priority.assetType[assetType] || 0;
|
||||
|
||||
if (isCurrentPage) {
|
||||
return PRELOAD_CONFIG.priority.currentPage + typePriority;
|
||||
}
|
||||
|
||||
// For neighbor pages, priority decreases with distance
|
||||
const basePriority =
|
||||
distance > 0 ? PRELOAD_CONFIG.priority.neighborBase / distance : 0;
|
||||
|
||||
return basePriority + typePriority;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover all assets for a set of pages
|
||||
* This is the main entry point for asset discovery
|
||||
*/
|
||||
export function discoverPageAssets(
|
||||
pageIds: string[],
|
||||
pages: PreloadPage[],
|
||||
pageLinks: PreloadPageLink[],
|
||||
elements: PreloadElement[],
|
||||
options: AssetDiscoveryOptions = DEFAULT_OPTIONS,
|
||||
): AssetToCache[] {
|
||||
const assets: AssetToCache[] = [];
|
||||
const seenStorageKeys = new Set<string>();
|
||||
|
||||
const addAsset = (asset: AssetToCache) => {
|
||||
if (!seenStorageKeys.has(asset.storageKey)) {
|
||||
seenStorageKeys.add(asset.storageKey);
|
||||
assets.push(asset);
|
||||
}
|
||||
};
|
||||
|
||||
pageIds.forEach((pageId) => {
|
||||
const page = pages.find((p) => p.id === pageId);
|
||||
|
||||
// Extract background assets
|
||||
if (options.includeBackgrounds && page) {
|
||||
extractPageBackgroundAssets(page).forEach(addAsset);
|
||||
}
|
||||
|
||||
// Extract element assets
|
||||
if (options.includeElementAssets) {
|
||||
const pageElements = elements.filter((el) => el.pageId === pageId);
|
||||
extractElementAssets(pageElements, pageId).forEach(addAsset);
|
||||
}
|
||||
|
||||
// Extract transition assets
|
||||
if (options.includeTransitions) {
|
||||
extractTransitionAssets(pageLinks, pageId).forEach(addAsset);
|
||||
}
|
||||
});
|
||||
|
||||
return assets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover all assets for a project (used by offline download)
|
||||
* Includes all pages, all elements, and all transitions
|
||||
*/
|
||||
export function discoverProjectAssets(
|
||||
pages: PreloadPage[],
|
||||
pageLinks: PreloadPageLink[],
|
||||
elements: PreloadElement[],
|
||||
options: AssetDiscoveryOptions = DEFAULT_OPTIONS,
|
||||
): AssetToCache[] {
|
||||
const pageIds = pages.map((p) => p.id);
|
||||
return discoverPageAssets(pageIds, pages, pageLinks, elements, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get prioritized assets for preloading based on current page and neighbor depth
|
||||
*/
|
||||
export function getPrioritizedAssets(
|
||||
currentPageId: string,
|
||||
pages: PreloadPage[],
|
||||
pageLinks: PreloadPageLink[],
|
||||
elements: PreloadElement[],
|
||||
neighborPageIds: Array<{ pageId: string; distance: number }>,
|
||||
options: AssetDiscoveryOptions = DEFAULT_OPTIONS,
|
||||
): AssetToCache[] {
|
||||
const assets: AssetToCache[] = [];
|
||||
const seenStorageKeys = new Set<string>();
|
||||
|
||||
const addAssetWithPriority = (
|
||||
asset: AssetToCache,
|
||||
isCurrentPage: boolean,
|
||||
distance: number,
|
||||
) => {
|
||||
if (seenStorageKeys.has(asset.storageKey)) return;
|
||||
seenStorageKeys.add(asset.storageKey);
|
||||
|
||||
assets.push({
|
||||
...asset,
|
||||
priority: calculateAssetPriority(asset.assetType, isCurrentPage, distance),
|
||||
});
|
||||
};
|
||||
|
||||
// Current page assets (highest priority)
|
||||
const currentPage = pages.find((p) => p.id === currentPageId);
|
||||
if (currentPage) {
|
||||
if (options.includeBackgrounds) {
|
||||
extractPageBackgroundAssets(currentPage).forEach((asset) =>
|
||||
addAssetWithPriority(asset, true, 0),
|
||||
);
|
||||
}
|
||||
|
||||
if (options.includeElementAssets) {
|
||||
const currentPageElements = elements.filter(
|
||||
(el) => el.pageId === currentPageId,
|
||||
);
|
||||
extractElementAssets(currentPageElements, currentPageId).forEach((asset) =>
|
||||
addAssetWithPriority(asset, true, 0),
|
||||
);
|
||||
}
|
||||
|
||||
if (options.includeTransitions) {
|
||||
extractTransitionAssets(pageLinks, currentPageId).forEach((asset) =>
|
||||
addAssetWithPriority(asset, true, 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Neighbor page assets (decreasing priority with distance)
|
||||
neighborPageIds.forEach(({ pageId, distance }) => {
|
||||
const page = pages.find((p) => p.id === pageId);
|
||||
if (page) {
|
||||
if (options.includeBackgrounds) {
|
||||
extractPageBackgroundAssets(page).forEach((asset) =>
|
||||
addAssetWithPriority(asset, false, distance),
|
||||
);
|
||||
}
|
||||
|
||||
if (options.includeElementAssets) {
|
||||
const pageElements = elements.filter((el) => el.pageId === pageId);
|
||||
extractElementAssets(pageElements, pageId).forEach((asset) =>
|
||||
addAssetWithPriority(asset, false, distance),
|
||||
);
|
||||
}
|
||||
|
||||
if (options.includeTransitions) {
|
||||
extractTransitionAssets(pageLinks, pageId).forEach((asset) =>
|
||||
addAssetWithPriority(asset, false, distance),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by priority (highest first)
|
||||
return assets.sort((a, b) => b.priority - a.priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert AssetToCache to PreloadAssetInfo for backward compatibility
|
||||
*/
|
||||
export function toPreloadAssetInfo(asset: AssetToCache): PreloadAssetInfo {
|
||||
// Map 'transition' to appropriate type for PreloadAssetInfo
|
||||
const assetType =
|
||||
asset.assetType === 'transition' || asset.assetType === 'other'
|
||||
? 'other'
|
||||
: asset.assetType;
|
||||
|
||||
return {
|
||||
url: asset.originalUrl,
|
||||
pageId: asset.pageId,
|
||||
assetType: assetType as 'image' | 'video' | 'audio' | 'transition' | 'other',
|
||||
priority: asset.priority,
|
||||
};
|
||||
}
|
||||
22
frontend/src/lib/assetCache/index.ts
Normal file
22
frontend/src/lib/assetCache/index.ts
Normal file
@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Asset Cache Module
|
||||
*
|
||||
* Unified asset caching for online preload and offline download.
|
||||
*/
|
||||
|
||||
export { AssetCacheService, type DownloadProgress, type QueueDownloadOptions } from './AssetCacheService';
|
||||
export type { CachedAssetInfo } from '../../types/offline';
|
||||
|
||||
export {
|
||||
discoverProjectAssets,
|
||||
discoverPageAssets,
|
||||
getPrioritizedAssets,
|
||||
extractElementAssets,
|
||||
extractPageBackgroundAssets,
|
||||
extractTransitionAssets,
|
||||
calculateAssetPriority,
|
||||
classifyAssetType,
|
||||
toPreloadAssetInfo,
|
||||
type AssetToCache,
|
||||
type AssetDiscoveryOptions,
|
||||
} from './assetDiscovery';
|
||||
@ -84,7 +84,7 @@ class DownloadManagerClass {
|
||||
const storageKey = params.storageKey || extractStoragePath(params.url);
|
||||
const isPartialDownload = params.maxBytes !== undefined;
|
||||
|
||||
// For partial downloads, check session cache (not persisted to storage)
|
||||
// For partial downloads, check session cache first (fast path)
|
||||
if (isPartialDownload && this.partialDownloadsReady.has(storageKey)) {
|
||||
logger.info('[DownloadManager] Partial download already ready (session)', {
|
||||
storageKey: storageKey.slice(-50),
|
||||
@ -92,16 +92,29 @@ class DownloadManagerClass {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already downloaded using canonical key (full downloads only)
|
||||
if (!isPartialDownload) {
|
||||
const hasAsset = await StorageManager.hasAsset(storageKey);
|
||||
if (hasAsset) {
|
||||
// Already cached - create blob URL if requested
|
||||
// Check cache status using getAssetInfo for smart handling
|
||||
const assetInfo = await StorageManager.getAssetInfo(storageKey);
|
||||
|
||||
if (assetInfo?.exists) {
|
||||
if (isPartialDownload) {
|
||||
// For partial downloads, any cached version is sufficient
|
||||
this.partialDownloadsReady.add(storageKey);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!assetInfo.isPartial) {
|
||||
// Fully cached - create blob URL if requested
|
||||
if (params.createBlobUrl && !this.readyBlobUrls.has(storageKey)) {
|
||||
await this.createBlobUrlFromCache(storageKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Asset exists but is partial - full download requested
|
||||
// Continue to download the full asset
|
||||
logger.info('[DownloadManager] Upgrading partial to full download', {
|
||||
storageKey: storageKey.slice(-50),
|
||||
});
|
||||
}
|
||||
|
||||
// Check if already in queue (use storageKey for deduplication)
|
||||
@ -339,23 +352,18 @@ class DownloadManagerClass {
|
||||
job.progress = 100;
|
||||
}
|
||||
|
||||
// For partial downloads, don't store to cache (not useful for offline)
|
||||
// Full downloads are stored for offline access
|
||||
if (!job.isPartial) {
|
||||
// Store the asset using canonical storage key
|
||||
await StorageManager.storeAsset(job.storageKey, blob, {
|
||||
id: job.assetId,
|
||||
projectId: job.projectId,
|
||||
filename: job.filename,
|
||||
variantType: job.variantType,
|
||||
assetType: job.assetType,
|
||||
});
|
||||
// Store the asset using canonical storage key
|
||||
// Partial downloads are now stored with isPartial: true for offline mode awareness
|
||||
await StorageManager.storeAsset(job.storageKey, blob, {
|
||||
id: job.assetId,
|
||||
projectId: job.projectId,
|
||||
filename: job.filename,
|
||||
variantType: job.variantType,
|
||||
assetType: job.assetType,
|
||||
isPartial: job.isPartial || false,
|
||||
});
|
||||
|
||||
// Create blob URL if requested
|
||||
if (job.createBlobUrl) {
|
||||
await this.createBlobUrlFromCache(job.storageKey);
|
||||
}
|
||||
} else {
|
||||
if (job.isPartial) {
|
||||
// Mark partial download as ready in session cache
|
||||
this.partialDownloadsReady.add(job.storageKey);
|
||||
|
||||
@ -363,10 +371,15 @@ class DownloadManagerClass {
|
||||
// When the browser fetches the full media, SW will cache it using the storage key
|
||||
this.registerUrlForCaching(job.url, job.storageKey);
|
||||
|
||||
logger.info('[DownloadManager] Partial download complete', {
|
||||
logger.info('[DownloadManager] Partial download complete (stored)', {
|
||||
storageKey: job.storageKey.slice(-50),
|
||||
bytesLoaded: job.bytesLoaded,
|
||||
});
|
||||
} else {
|
||||
// Create blob URL if requested (full downloads only)
|
||||
if (job.createBlobUrl) {
|
||||
await this.createBlobUrlFromCache(job.storageKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as completed
|
||||
@ -378,6 +391,7 @@ class DownloadManagerClass {
|
||||
downloadEventBus.emitPreloadComplete({
|
||||
jobId: job.id,
|
||||
assetId: job.assetId,
|
||||
storageKey: job.storageKey,
|
||||
});
|
||||
|
||||
job.resolve?.();
|
||||
|
||||
@ -14,6 +14,7 @@ import type {
|
||||
AssetVariantType,
|
||||
AssetType,
|
||||
StorageQuotaInfo,
|
||||
CachedAssetInfo,
|
||||
} from '../../types/offline';
|
||||
|
||||
export class StorageManager {
|
||||
@ -97,6 +98,7 @@ export class StorageManager {
|
||||
filename: string;
|
||||
variantType: AssetVariantType;
|
||||
assetType: AssetType;
|
||||
isPartial?: boolean; // True if this is a partial download
|
||||
},
|
||||
): Promise<void> {
|
||||
// Normalize URL to canonical storage key
|
||||
@ -116,6 +118,7 @@ export class StorageManager {
|
||||
sizeBytes,
|
||||
blob,
|
||||
downloadedAt: Date.now(),
|
||||
isPartial: metadata.isPartial || false,
|
||||
};
|
||||
await OfflineDbManager.storeAsset(asset);
|
||||
} else {
|
||||
@ -127,6 +130,8 @@ export class StorageManager {
|
||||
'Content-Length': String(sizeBytes),
|
||||
'X-Asset-Id': metadata.id,
|
||||
'X-Project-Id': metadata.projectId,
|
||||
'X-Is-Partial': metadata.isPartial ? 'true' : 'false',
|
||||
'X-Downloaded-At': String(Date.now()),
|
||||
},
|
||||
});
|
||||
await cache.put(storageKey, response); // Use normalized storage key
|
||||
@ -179,6 +184,56 @@ export class StorageManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed information about a cached asset
|
||||
* Returns null if asset is not cached
|
||||
*/
|
||||
static async getAssetInfo(url: string): Promise<CachedAssetInfo | null> {
|
||||
// Normalize URL to canonical storage key
|
||||
const storageKey = extractStoragePath(url);
|
||||
|
||||
// Check IndexedDB first (for large files)
|
||||
const dbAsset = await OfflineDbManager.getAssetByUrl(storageKey);
|
||||
if (dbAsset) {
|
||||
return {
|
||||
storageKey,
|
||||
exists: true,
|
||||
isPartial: dbAsset.isPartial || false,
|
||||
sizeBytes: dbAsset.sizeBytes,
|
||||
downloadedAt: dbAsset.downloadedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// Check Cache API
|
||||
if (typeof caches !== 'undefined') {
|
||||
const cache = await caches.open(OFFLINE_CONFIG.cacheNames.assets);
|
||||
const response = await cache.match(storageKey);
|
||||
if (response) {
|
||||
const isPartial = response.headers.get('X-Is-Partial') === 'true';
|
||||
const downloadedAt = response.headers.get('X-Downloaded-At');
|
||||
const contentLength = response.headers.get('Content-Length');
|
||||
|
||||
return {
|
||||
storageKey,
|
||||
exists: true,
|
||||
isPartial,
|
||||
sizeBytes: contentLength ? parseInt(contentLength, 10) : 0,
|
||||
downloadedAt: downloadedAt ? parseInt(downloadedAt, 10) : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an asset is fully cached (not partial)
|
||||
*/
|
||||
static async hasFullAsset(url: string): Promise<boolean> {
|
||||
const info = await this.getAssetInfo(url);
|
||||
return info !== null && info.exists && !info.isPartial;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an asset from all storage locations (by normalized storage key)
|
||||
*/
|
||||
|
||||
@ -76,6 +76,16 @@ export interface OfflineAsset {
|
||||
sizeBytes: number;
|
||||
blob: Blob;
|
||||
downloadedAt: number;
|
||||
isPartial?: boolean; // True if this is a partial download (only first N bytes)
|
||||
}
|
||||
|
||||
// Cache status information for an asset
|
||||
export interface CachedAssetInfo {
|
||||
storageKey: string;
|
||||
exists: boolean;
|
||||
isPartial: boolean;
|
||||
sizeBytes: number;
|
||||
downloadedAt?: number;
|
||||
}
|
||||
|
||||
// Download queue item for persistence
|
||||
@ -113,27 +123,6 @@ export interface StorageQuotaInfo {
|
||||
canStore: (bytes: number) => boolean;
|
||||
}
|
||||
|
||||
// Offline manifest from backend
|
||||
export interface OfflineManifest {
|
||||
version: string;
|
||||
projectId: string;
|
||||
projectSlug: string;
|
||||
assets: ManifestAsset[];
|
||||
totalSizeBytes: number;
|
||||
generatedAt: number;
|
||||
}
|
||||
|
||||
export interface ManifestAsset {
|
||||
id: string;
|
||||
url: string;
|
||||
filename: string;
|
||||
variantType: AssetVariantType;
|
||||
assetType: AssetType;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
pageIds: string[];
|
||||
}
|
||||
|
||||
// Preload orchestrator state
|
||||
export interface PreloadState {
|
||||
isActive: boolean;
|
||||
@ -172,6 +161,7 @@ export interface PreloadProgressEvent {
|
||||
export interface PreloadCompleteEvent {
|
||||
jobId: string;
|
||||
assetId: string;
|
||||
storageKey: string;
|
||||
}
|
||||
|
||||
export interface PreloadErrorEvent {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user