offline mode improvements

This commit is contained in:
Dmitri 2026-04-09 10:06:18 +04:00
parent 42cc3456eb
commit f8c3bb4a07
13 changed files with 1058 additions and 622 deletions

View File

@ -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;

View File

@ -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

View File

@ -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();

View File

@ -601,6 +601,7 @@ export default function RuntimePresentation({
projectId={project?.id || null}
projectSlug={projectSlug}
projectName={project?.name}
pages={pages}
showLabel={false}
size='small'
/>

View File

@ -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;

View File

@ -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,

View 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;

View 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,
};
}

View 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';

View File

@ -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?.();

View File

@ -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)
*/

View File

@ -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 {