1 // Offline storage utilities for repair guides & technical manuals
2 import type { RepairGuide, TroubleshootingFlow, DeviceComponent } from "@shared/schema";
3 import { TECHNICAL_MANUALS, type TechnicalManual } from "../../shared/technical-manuals";
4
5 const STORAGE_KEYS = {
6 GUIDES: 'jcr_offline_guides',
7 MANUALS: 'jcr_offline_manuals',
8 FLOWS: 'jcr_offline_flows',
9 COMPONENTS: 'jcr_offline_components',
10 FAVORITES: 'jcr_offline_favorites',
11 LAST_SYNC: 'jcr_last_sync',
12 USER_GUIDES: 'jcr_user_guides',
13 DOWNLOADS: 'jcr_downloaded_files',
14 VIDEOS: 'jcr_offline_videos',
15 IMAGES: 'jcr_cached_images',
16 } as const;
17
18 export interface OfflineData {
19 guides: RepairGuide[];
20 manuals: TechnicalManual[];
21 flows: TroubleshootingFlow[];
22 components: DeviceComponent[];
23 favorites: string[];
24 userGuides: RepairGuide[];
25 lastSync: string;
26 }
27
28 export interface StorageUsage {
29 used: number;
30 total: number;
31 percentage: number;
32 breakdown: {
33 guides: number;
34 manuals: number;
35 flows: number;
36 components: number;
37 favorites: number;
38 userGuides: number;
39 downloads: number;
40 videos: number;
41 images: number;
42 analytics?: number;
43 };
44 }
45
46 export interface DownloadedFile {
47 id: string;
48 name: string;
49 type: 'guide' | 'video' | 'image' | 'document';
50 size: number;
51 downloadDate: string;
52 lastAccessed: string;
53 format: string;
54 relatedGuideId?: string;
55 }
56
57 // In-memory fallback if storage is restricted
58 let inMemoryData: OfflineData = {
59 guides: [],
60 manuals: [...TECHNICAL_MANUALS],
61 flows: [],
62 components: [],
63 favorites: [],
64 userGuides: [],
65 lastSync: ''
66 };
67
68 // Check if browser supports offline storage
69 export const isOfflineSupported = (): boolean => {
70 try {
71 return typeof localStorage !== 'undefined' &&
72 typeof navigator !== 'undefined';
73 } catch {
74 return false;
75 }
76 };
77
78 // Open IndexedDB database
79 function openIndexedDB(): Promise<IDBDatabase | null> {
80 return new Promise((resolve) => {
81 if (typeof indexedDB === 'undefined') {
82 return resolve(null);
83 }
84 const request = indexedDB.open("jcr_offline_db_v3", 1);
85 request.onupgradeneeded = (e: any) => {
86 const db = e.target.result;
87 if (!db.objectStoreNames.contains("offline_data")) {
88 db.createObjectStore("offline_data");
89 }
90 };
91 request.onsuccess = (e: any) => resolve(e.target.result);
92 request.onerror = () => resolve(null);
93 });
94 }
95
96 // Save to IndexedDB
97 async function saveToIDB(key: string, data: any): Promise<boolean> {
98 try {
99 const db = await openIndexedDB();
100 if (!db) return false;
101 return new Promise((resolve) => {
102 const tx = db.transaction("offline_data", "readwrite");
103 const store = tx.objectStore("offline_data");
104 store.put(data, key);
105 tx.oncomplete = () => resolve(true);
106 tx.onerror = () => resolve(false);
107 });
108 } catch {
109 return false;
110 }
111 }
112
113 // Load from IndexedDB
114 async function loadFromIDB<T>(key: string): Promise<T | null> {
115 try {
116 const db = await openIndexedDB();
117 if (!db) return null;
118 return new Promise((resolve) => {
119 const tx = db.transaction("offline_data", "readonly");
120 const store = tx.objectStore("offline_data");
121 const request = store.get(key);
122 request.onsuccess = () => resolve(request.result || null);
123 request.onerror = () => resolve(null);
124 });
125 } catch {
126 return null;
127 }
128 }
129
130 // Save data for offline use
131 export const saveOfflineData = async (data: Partial<OfflineData>): Promise<void> => {
132 if (!isOfflineSupported()) {
133 inMemoryData = { ...inMemoryData, ...data, lastSync: new Date().toISOString() };
134 return;
135 }
136
137 try {
138 if (data.guides) {
139 await saveToIDB(STORAGE_KEYS.GUIDES, data.guides);
140 try {
141 localStorage.setItem(STORAGE_KEYS.GUIDES, JSON.stringify(data.guides.slice(0, 100))); // Cache subset in LS
142 } catch (e) {
143 // Ignore LS quota error as IDB has it
144 }
145 }
146 if (data.manuals) {
147 await saveToIDB(STORAGE_KEYS.MANUALS, data.manuals);
148 try {
149 localStorage.setItem(STORAGE_KEYS.MANUALS, JSON.stringify(data.manuals));
150 } catch (e) {}
151 }
152 if (data.flows) {
153 await saveToIDB(STORAGE_KEYS.FLOWS, data.flows);
154 try {
155 localStorage.setItem(STORAGE_KEYS.FLOWS, JSON.stringify(data.flows));
156 } catch (e) {}
157 }
158 if (data.components) {
159 await saveToIDB(STORAGE_KEYS.COMPONENTS, data.components);
160 try {
161 localStorage.setItem(STORAGE_KEYS.COMPONENTS, JSON.stringify(data.components));
162 } catch (e) {}
163 }
164 if (data.favorites) {
165 localStorage.setItem(STORAGE_KEYS.FAVORITES, JSON.stringify(data.favorites));
166 }
167 if (data.userGuides) {
168 localStorage.setItem(STORAGE_KEYS.USER_GUIDES, JSON.stringify(data.userGuides));
169 }
170
171 const now = new Date().toISOString();
172 localStorage.setItem(STORAGE_KEYS.LAST_SYNC, now);
173 inMemoryData = { ...inMemoryData, ...data, lastSync: now };
174 } catch (error) {
175 console.error('Failed to save offline data:', error);
176 }
177 };
178
179 // Load offline data synchronously with memory fallback
180 export const loadOfflineData = (): OfflineData => {
181 const defaultData: OfflineData = {
182 guides: inMemoryData.guides.length > 0 ? inMemoryData.guides : [],
183 manuals: inMemoryData.manuals.length > 0 ? inMemoryData.manuals : [...TECHNICAL_MANUALS],
184 flows: inMemoryData.flows,
185 components: inMemoryData.components,
186 favorites: inMemoryData.favorites,
187 userGuides: inMemoryData.userGuides,
188 lastSync: inMemoryData.lastSync || (typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEYS.LAST_SYNC) || '' : ''),
189 };
190
191 if (!isOfflineSupported()) return defaultData;
192
193 try {
194 const lsGuides = localStorage.getItem(STORAGE_KEYS.GUIDES);
195 const lsManuals = localStorage.getItem(STORAGE_KEYS.MANUALS);
196 const lsFlows = localStorage.getItem(STORAGE_KEYS.FLOWS);
197 const lsComponents = localStorage.getItem(STORAGE_KEYS.COMPONENTS);
198 const lsFavorites = localStorage.getItem(STORAGE_KEYS.FAVORITES);
199 const lsUserGuides = localStorage.getItem(STORAGE_KEYS.USER_GUIDES);
200 const lsLastSync = localStorage.getItem(STORAGE_KEYS.LAST_SYNC);
201
202 return {
203 guides: inMemoryData.guides.length > 0 ? inMemoryData.guides : (lsGuides ? JSON.parse(lsGuides) : []),
204 manuals: inMemoryData.manuals.length > 0 ? inMemoryData.manuals : (lsManuals ? JSON.parse(lsManuals) : [...TECHNICAL_MANUALS]),
205 flows: inMemoryData.flows.length > 0 ? inMemoryData.flows : (lsFlows ? JSON.parse(lsFlows) : []),
206 components: inMemoryData.components.length > 0 ? inMemoryData.components : (lsComponents ? JSON.parse(lsComponents) : []),
207 favorites: lsFavorites ? JSON.parse(lsFavorites) : [],
208 userGuides: lsUserGuides ? JSON.parse(lsUserGuides) : [],
209 lastSync: lsLastSync || inMemoryData.lastSync || '',
210 };
211 } catch (error) {
212 console.error('Failed to load offline data:', error);
213 return defaultData;
214 }
215 };
216
217 // Async loader to get all 624 guides from IndexedDB if present
218 export const loadAllOfflineDataAsync = async (): Promise<OfflineData> => {
219 const syncData = loadOfflineData();
220 try {
221 const idbGuides = await loadFromIDB<RepairGuide[]>(STORAGE_KEYS.GUIDES);
222 const idbManuals = await loadFromIDB<TechnicalManual[]>(STORAGE_KEYS.MANUALS);
223 const idbFlows = await loadFromIDB<TroubleshootingFlow[]>(STORAGE_KEYS.FLOWS);
224 const idbComponents = await loadFromIDB<DeviceComponent[]>(STORAGE_KEYS.COMPONENTS);
225
226 const fullData: OfflineData = {
227 ...syncData,
228 guides: (idbGuides && idbGuides.length > 0) ? idbGuides : syncData.guides,
229 manuals: (idbManuals && idbManuals.length > 0) ? idbManuals : syncData.manuals,
230 flows: (idbFlows && idbFlows.length > 0) ? idbFlows : syncData.flows,
231 components: (idbComponents && idbComponents.length > 0) ? idbComponents : syncData.components,
232 };
233 inMemoryData = fullData;
234 return fullData;
235 } catch {
236 return syncData;
237 }
238 };
239
240 // Comprehensive Offline Library Sync
241 export const syncAllOfflineData = async (
242 onProgress?: (progressPct: number, statusText: string) => void
243 ): Promise<{ success: boolean; guidesCount: number; manualsCount: number }> => {
244 if (!isOnline()) {
245 throw new Error("Device is offline. Connect to network to sync repair library.");
246 }
247
248 onProgress?.(10, "Connecting to repair API endpoints...");
249
250 try {
251 // 1. Fetch all repair guides (624)
252 onProgress?.(25, "Fetching all 624 multi-brand repair guides...");
253 const guidesRes = await fetch("/api/repair-guides");
254 if (!guidesRes.ok) throw new Error("Failed to download repair guides.");
255 const guides: RepairGuide[] = await guidesRes.json();
256
257 // 2. Fetch technical manuals (8)
258 onProgress?.(50, "Fetching 8 in-depth technical manuals & schematics...");
259 let manuals: TechnicalManual[] = [...TECHNICAL_MANUALS];
260 try {
261 const manualsRes = await fetch("/api/technical-manuals");
262 if (manualsRes.ok) {
263 manuals = await manualsRes.json();
264 }
265 } catch (e) {
266 // Use fallback
267 }
268
269 // 3. Fetch troubleshooting flows
270 onProgress?.(70, "Fetching interactive diagnostic flows...");
271 let flows: TroubleshootingFlow[] = [];
272 try {
273 const flowsRes = await fetch("/api/troubleshooting-flows");
274 if (flowsRes.ok) flows = await flowsRes.json();
275 } catch (e) {}
276
277 // 4. Fetch device components
278 onProgress?.(85, "Fetching component pinouts & specifications...");
279 let components: DeviceComponent[] = [];
280 try {
281 const compRes = await fetch("/api/device-components");
282 if (compRes.ok) components = await compRes.json();
283 } catch (e) {}
284
285 // 5. Persist to IndexedDB and LocalStorage
286 onProgress?.(95, "Committing offline database index to local cache...");
287 await saveOfflineData({
288 guides,
289 manuals,
290 flows,
291 components,
292 });
293
294 onProgress?.(100, `Sync complete! ${guides.length} guides and ${manuals.length} manuals stored.`);
295 return {
296 success: true,
297 guidesCount: guides.length,
298 manualsCount: manuals.length
299 };
300 } catch (error: any) {
301 console.error("Full offline sync failed:", error);
302 onProgress?.(100, `Sync failed: ${error?.message || "Unknown error"}`);
303 return {
304 success: false,
305 guidesCount: 0,
306 manualsCount: 0
307 };
308 }
309 };
310
311 // Clear all offline cache (IndexedDB, LocalStorage, and CacheStorage)
312 export const clearAllOfflineCache = async (): Promise<void> => {
313 try {
314 // Clear LocalStorage keys
315 Object.values(STORAGE_KEYS).forEach(key => {
316 try {
317 localStorage.removeItem(key);
318 } catch (e) {}
319 });
320
321 // Clear in-memory
322 inMemoryData = {
323 guides: [],
324 manuals: [...TECHNICAL_MANUALS],
325 flows: [],
326 components: [],
327 favorites: [],
328 userGuides: [],
329 lastSync: ''
330 };
331
332 // Clear IndexedDB
333 try {
334 const db = await openIndexedDB();
335 if (db) {
336 const tx = db.transaction("offline_data", "readwrite");
337 tx.objectStore("offline_data").clear();
338 }
339 } catch (e) {
340 console.warn("IndexedDB clear warning:", e);
341 }
342
343 // Clear CacheStorage if present
344 if (typeof window !== "undefined" && "caches" in window) {
345 try {
346 const cacheKeys = await window.caches.keys();
347 await Promise.all(cacheKeys.map(k => window.caches.delete(k)));
348 } catch (e) {
349 console.warn("CacheStorage clear warning:", e);
350 }
351 }
352 } catch (error) {
353 console.error('Failed to completely clear offline cache:', error);
354 }
355 };
356
357 // Save user-created guide locally
358 export const saveUserGuide = (guide: RepairGuide): void => {
359 if (!isOfflineSupported()) return;
360
361 try {
362 const userGuides = loadOfflineData().userGuides;
363 const updatedGuides = [...userGuides, { ...guide, id: `user_${Date.now()}` }];
364 localStorage.setItem(STORAGE_KEYS.USER_GUIDES, JSON.stringify(updatedGuides));
365 } catch (error) {
366 console.error('Failed to save user guide:', error);
367 }
368 };
369
370 // Get user-created guides
371 export const getUserGuides = (): RepairGuide[] => {
372 if (!isOfflineSupported()) return [];
373 return loadOfflineData().userGuides;
374 };
375
376 // Clear all offline data
377 export const clearOfflineData = (): void => {
378 if (!isOfflineSupported()) return;
379
380 try {
381 Object.values(STORAGE_KEYS).forEach(key => {
382 localStorage.removeItem(key);
383 });
384 } catch (error) {
385 console.error('Failed to clear offline data:', error);
386 }
387 };
388
389 // Get comprehensive storage usage info
390 export const getStorageUsage = (): StorageUsage => {
391 if (!isOfflineSupported()) {
392 return {
393 used: 0,
394 total: 0,
395 percentage: 0,
396 breakdown: {
397 guides: 0,
398 manuals: 0,
399 flows: 0,
400 components: 0,
401 favorites: 0,
402 userGuides: 0,
403 downloads: 0,
404 videos: 0,
405 images: 0,
406 analytics: 0,
407 }
408 };
409 }
410
411 try {
412 const breakdown = {
413 guides: getItemSize(STORAGE_KEYS.GUIDES),
414 manuals: getItemSize(STORAGE_KEYS.MANUALS),
415 flows: getItemSize(STORAGE_KEYS.FLOWS),
416 components: getItemSize(STORAGE_KEYS.COMPONENTS),
417 favorites: getItemSize(STORAGE_KEYS.FAVORITES),
418 userGuides: getItemSize(STORAGE_KEYS.USER_GUIDES),
419 downloads: getItemSize(STORAGE_KEYS.DOWNLOADS),
420 videos: getItemSize(STORAGE_KEYS.VIDEOS),
421 images: getItemSize(STORAGE_KEYS.IMAGES),
422 };
423
424 const used = Object.values(breakdown).reduce((sum, size) => sum + size, 0);
425 const total = 50 * 1024 * 1024; // 50MB increased capacity
426 const percentage = Math.min((used / total) * 100, 100);
427
428 return { used, total, percentage, breakdown };
429 } catch (error) {
430 console.error('Failed to get storage usage:', error);
431 return {
432 used: 0,
433 total: 50 * 1024 * 1024,
434 percentage: 0,
435 breakdown: {
436 guides: 0,
437 manuals: 0,
438 flows: 0,
439 components: 0,
440 favorites: 0,
441 userGuides: 0,
442 downloads: 0,
443 videos: 0,
444 images: 0,
445 analytics: 0,
446 }
447 };
448 }
449 };
450
451 // Helper function to get size of localStorage item
452 const getItemSize = (key: string): number => {
453 try {
454 const item = localStorage.getItem(key);
455 return item ? new Blob([item]).size : 0;
456 } catch {
457 return 0;
458 }
459 };
460
461 // Check if device is online
462 export const isOnline = (): boolean => {
463 return typeof navigator !== 'undefined' ? navigator.onLine : true;
464 };
465
466 // Sync data when connection is restored
467 export const syncWhenOnline = async (): Promise<void> => {
468 if (!isOnline() || !isOfflineSupported()) return;
469
470 try {
471 // Get pending user guides
472 const userGuides = getUserGuides();
473 const pendingGuides = userGuides.filter(guide => guide.id.startsWith('user_'));
474
475 // Attempt to sync pending guides to server
476 for (const guide of pendingGuides) {
477 try {
478 const response = await fetch('/api/repair-guides', {
479 method: 'POST',
480 headers: { 'Content-Type': 'application/json' },
481 body: JSON.stringify(guide),
482 });
483
484 if (response.ok) {
485 // Remove from local storage after successful sync
486 const remainingGuides = userGuides.filter(g => g.id !== guide.id);
487 localStorage.setItem(STORAGE_KEYS.USER_GUIDES, JSON.stringify(remainingGuides));
488 }
489 } catch (error) {
490 console.error('Failed to sync guide:', error);
491 // Keep guide in local storage for next sync attempt
492 }
493 }
494
495 // Refresh cached data
496 const [guides, flows, components] = await Promise.all([
497 fetch('/api/repair-guides').then(res => res.json()).catch(() => []),
498 fetch('/api/troubleshooting-flows').then(res => res.json()).catch(() => []),
499 fetch('/api/device-components').then(res => res.json()).catch(() => []),
500 ]);
501
502 await saveOfflineData({ guides, flows, components });
503 } catch (error) {
504 console.error('Sync failed:', error);
505 }
506 };
507
508 // Format storage size for display
509 export const formatStorageSize = (bytes: number): string => {
510 if (bytes === 0) return '0 B';
511 const k = 1024;
512 const sizes = ['B', 'KB', 'MB', 'GB'];
513 const i = Math.floor(Math.log(bytes) / Math.log(k));
514 return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
515 };
516
517 // Save downloaded file record
518 export const saveDownloadedFile = (file: DownloadedFile): void => {
519 if (!isOfflineSupported()) return;
520
521 try {
522 const downloads = getDownloadedFiles();
523 const updatedDownloads = [...downloads, { ...file, lastAccessed: new Date().toISOString() }];
524 localStorage.setItem(STORAGE_KEYS.DOWNLOADS, JSON.stringify(updatedDownloads));
525
526 } catch (error) {
527 console.error('Failed to save downloaded file record:', error);
528 }
529 };
530
531 // Get all downloaded files
532 export const getDownloadedFiles = (): DownloadedFile[] => {
533 if (!isOfflineSupported()) return [];
534
535 try {
536 const downloads = localStorage.getItem(STORAGE_KEYS.DOWNLOADS);
537 return downloads ? JSON.parse(downloads) : [];
538 } catch (error) {
539 console.error('Failed to get downloaded files:', error);
540 return [];
541 }
542 };
543
544 // Update file access time
545 export const updateFileAccess = (fileId: string): void => {
546 if (!isOfflineSupported()) return;
547
548 try {
549 const downloads = getDownloadedFiles();
550 const updated = downloads.map(file =>
551 file.id === fileId
552 ? { ...file, lastAccessed: new Date().toISOString() }
553 : file
554 );
555 localStorage.setItem(STORAGE_KEYS.DOWNLOADS, JSON.stringify(updated));
556 } catch (error) {
557 console.error('Failed to update file access:', error);
558 }
559 };
560
561 // Remove downloaded file record
562 export const removeDownloadedFile = (fileId: string): void => {
563 if (!isOfflineSupported()) return;
564
565 try {
566 const downloads = getDownloadedFiles();
567 const filtered = downloads.filter(file => file.id !== fileId);
568 localStorage.setItem(STORAGE_KEYS.DOWNLOADS, JSON.stringify(filtered));
569 } catch (error) {
570 console.error('Failed to remove downloaded file:', error);
571 }
572 };
573
574
575
576
577
578
579
580 // Clean up old downloads based on usage
581 export const cleanupOldDownloads = (maxAgeInDays: number = 30): void => {
582 if (!isOfflineSupported()) return;
583
584 try {
585 const downloads = getDownloadedFiles();
586 const cutoffDate = new Date();
587 cutoffDate.setDate(cutoffDate.getDate() - maxAgeInDays);
588
589 const activeDownloads = downloads.filter(file => {
590 const lastAccessed = new Date(file.lastAccessed);
591 return lastAccessed > cutoffDate;
592 });
593
594 localStorage.setItem(STORAGE_KEYS.DOWNLOADS, JSON.stringify(activeDownloads));
595 } catch (error) {
596 console.error('Failed to cleanup old downloads:', error);
597 }
598 };
599
600 // Get storage recommendations
601 export const getStorageRecommendations = (): string[] => {
602 const usage = getStorageUsage();
603 const recommendations: string[] = [];
604
605 if (usage.percentage > 90) {
606 recommendations.push('Storage is almost full. Consider cleaning up old downloads.');
607 }
608
609 if (usage.percentage > 75) {
610 recommendations.push('Storage usage is high. Remove unused content to free up space.');
611 }
612
613 if (usage.breakdown.videos > usage.breakdown.guides) {
614 recommendations.push('Videos take up more space than guides. Consider downloading fewer videos.');
615 }
616
617 if (usage.breakdown.images > 5 * 1024 * 1024) { // 5MB
618 recommendations.push('Cached images are using significant space. Clear image cache if needed.');
619 }
620
621
622
623 return recommendations;
624 };
625
626 // Export cache management for videos and images
627 // Memory-optimized video caching with size limits
628 export const cacheVideo = (videoId: string, videoData: Blob): Promise<boolean> => {
629 return new Promise((resolve) => {
630 if (!isOfflineSupported()) {
631 resolve(false);
632 return;
633 }
634
635 // Check video size limit (max 10MB per video)
636 const MAX_VIDEO_SIZE = 10 * 1024 * 1024;
637 if (videoData.size > MAX_VIDEO_SIZE) {
638 console.warn(`Video ${videoId} too large (${videoData.size} bytes), skipping cache`);
639 resolve(false);
640 return;
641 }
642
643 try {
644 // Check if we need to clear space first
645 const currentUsage = getStorageUsage();
646 const estimatedNewSize = videoData.size * 1.4; // Base64 overhead
647
648 if (currentUsage.used + estimatedNewSize > currentUsage.total * 0.9) {
649 // Clean up old videos if approaching limit
650 cleanupOldVideos();
651 }
652
653 // Store metadata instead of full data for large videos
654 const videos = JSON.parse(localStorage.getItem(STORAGE_KEYS.VIDEOS) || '{}');
655
656 // For videos larger than 5MB, store only metadata
657 if (videoData.size > 5 * 1024 * 1024) {
658 videos[videoId] = {
659 metadata: {
660 size: videoData.size,
661 type: videoData.type,
662 cached: false,
663 timestamp: Date.now()
664 }
665 };
666 } else {
667 // Store smaller videos as before but with compression info
668 const reader = new FileReader();
669 reader.onload = () => {
670 try {
671 videos[videoId] = {
672 data: reader.result,
673 metadata: {
674 size: videoData.size,
675 type: videoData.type,
676 cached: true,
677 timestamp: Date.now()
678 }
679 };
680 localStorage.setItem(STORAGE_KEYS.VIDEOS, JSON.stringify(videos));
681 resolve(true);
682 } catch (error) {
683 console.error('Failed to cache video:', error);
684 resolve(false);
685 }
686 };
687 reader.onerror = () => resolve(false);
688 reader.readAsDataURL(videoData);
689 return;
690 }
691
692 localStorage.setItem(STORAGE_KEYS.VIDEOS, JSON.stringify(videos));
693 resolve(true);
694 } catch (error) {
695 console.error('Failed to cache video:', error);
696 resolve(false);
697 }
698 });
699 };
700
701 export const getCachedVideo = (videoId: string): string | null => {
702 if (!isOfflineSupported()) return null;
703
704 try {
705 const videos = JSON.parse(localStorage.getItem(STORAGE_KEYS.VIDEOS) || '{}');
706 const video = videos[videoId];
707
708 if (!video) return null;
709
710 // Return actual data if cached, null if only metadata
711 if (video.metadata && video.metadata.cached && video.data) {
712 return video.data;
713 }
714
715 return null;
716 } catch (error) {
717 console.error('Failed to get cached video:', error);
718 return null;
719 }
720 };
721
722 // Memory-optimized image caching with compression and limits
723 export const cacheImage = (imageUrl: string, imageData: Blob): Promise<boolean> => {
724 return new Promise((resolve) => {
725 if (!isOfflineSupported()) {
726 resolve(false);
727 return;
728 }
729
730 // Check image size limit (max 2MB per image)
731 const MAX_IMAGE_SIZE = 2 * 1024 * 1024;
732 if (imageData.size > MAX_IMAGE_SIZE) {
733 console.warn(`Image ${imageUrl} too large (${imageData.size} bytes), skipping cache`);
734 resolve(false);
735 return;
736 }
737
738 try {
739 // Check if we need to clear space first
740 const currentUsage = getStorageUsage();
741 const estimatedNewSize = imageData.size * 1.4; // Base64 overhead
742
743 if (currentUsage.used + estimatedNewSize > currentUsage.total * 0.9) {
744 // Clean up old images if approaching limit
745 cleanupOldImages();
746 }
747
748 // Compress image if it's large
749 if (imageData.size > 500 * 1024) { // 500KB
750 compressAndCacheImage(imageUrl, imageData, resolve);
751 } else {
752 // Store smaller images directly
753 const reader = new FileReader();
754 reader.onload = () => {
755 try {
756 const images = JSON.parse(localStorage.getItem(STORAGE_KEYS.IMAGES) || '{}');
757 images[imageUrl] = {
758 data: reader.result,
759 metadata: {
760 size: imageData.size,
761 type: imageData.type,
762 timestamp: Date.now(),
763 compressed: false
764 }
765 };
766 localStorage.setItem(STORAGE_KEYS.IMAGES, JSON.stringify(images));
767 resolve(true);
768 } catch (error) {
769 console.error('Failed to cache image:', error);
770 resolve(false);
771 }
772 };
773 reader.onerror = () => resolve(false);
774 reader.readAsDataURL(imageData);
775 }
776 } catch (error) {
777 console.error('Failed to cache image:', error);
778 resolve(false);
779 }
780 });
781 };
782
783 export const getCachedImage = (imageUrl: string): string | null => {
784 if (!isOfflineSupported()) return null;
785
786 try {
787 const images = JSON.parse(localStorage.getItem(STORAGE_KEYS.IMAGES) || '{}');
788 const image = images[imageUrl];
789
790 if (!image) return null;
791
792 // Handle both old format (direct data) and new format (with metadata)
793 if (typeof image === 'string') {
794 return image; // Legacy format
795 }
796
797 if (image.data) {
798 // Update access time for LRU cleanup
799 image.metadata.lastAccessed = Date.now();
800 images[imageUrl] = image;
801 localStorage.setItem(STORAGE_KEYS.IMAGES, JSON.stringify(images));
802 return image.data;
803 }
804
805 return null;
806 } catch (error) {
807 console.error('Failed to get cached image:', error);
808 return null;
809 }
810 };
811
812 // Helper function for image compression during caching
813 function compressAndCacheImage(imageUrl: string, imageData: Blob, resolve: (value: boolean) => void): void {
814 const canvas = document.createElement('canvas');
815 const ctx = canvas.getContext('2d');
816 const img = new Image();
817
818 img.onload = () => {
819 // Calculate compressed dimensions (max 1024x1024)
820 const MAX_SIZE = 1024;
821 let { width, height } = img;
822
823 if (width > MAX_SIZE || height > MAX_SIZE) {
824 if (width > height) {
825 height = (height * MAX_SIZE) / width;
826 width = MAX_SIZE;
827 } else {
828 width = (width * MAX_SIZE) / height;
829 height = MAX_SIZE;
830 }
831 }
832
833 canvas.width = width;
834 canvas.height = height;
835 ctx?.drawImage(img, 0, 0, width, height);
836
837 canvas.toBlob((compressedBlob) => {
838 if (compressedBlob) {
839 const reader = new FileReader();
840 reader.onload = () => {
841 try {
842 const images = JSON.parse(localStorage.getItem(STORAGE_KEYS.IMAGES) || '{}');
843 images[imageUrl] = {
844 data: reader.result,
845 metadata: {
846 originalSize: imageData.size,
847 compressedSize: compressedBlob.size,
848 type: compressedBlob.type,
849 timestamp: Date.now(),
850 compressed: true
851 }
852 };
853 localStorage.setItem(STORAGE_KEYS.IMAGES, JSON.stringify(images));
854 resolve(true);
855 } catch (error) {
856 console.error('Failed to cache compressed image:', error);
857 resolve(false);
858 }
859 };
860 reader.readAsDataURL(compressedBlob);
861 } else {
862 resolve(false);
863 }
864 }, 'image/jpeg', 0.8);
865 };
866
867 img.onerror = () => resolve(false);
868 img.src = URL.createObjectURL(imageData);
869 }
870
871 // Cleanup old videos using LRU strategy
872 function cleanupOldVideos(): void {
873 try {
874 const videos = JSON.parse(localStorage.getItem(STORAGE_KEYS.VIDEOS) || '{}');
875 const videoEntries = Object.entries(videos)
876 .map(([id, data]: [string, any]) => ({
877 id,
878 timestamp: data.metadata?.timestamp || 0
879 }))
880 .sort((a, b) => a.timestamp - b.timestamp); // Oldest first
881
882 // Remove oldest 30% of videos
883 const removeCount = Math.ceil(videoEntries.length * 0.3);
884 const toRemove = videoEntries.slice(0, removeCount);
885
886 toRemove.forEach(({ id }) => {
887 delete videos[id];
888 });
889
890 localStorage.setItem(STORAGE_KEYS.VIDEOS, JSON.stringify(videos));
891 console.log(`Cleaned up ${removeCount} old videos`);
892 } catch (error) {
893 console.error('Failed to cleanup old videos:', error);
894 }
895 }
896
897 // Cleanup old images using LRU strategy
898 function cleanupOldImages(): void {
899 try {
900 const images = JSON.parse(localStorage.getItem(STORAGE_KEYS.IMAGES) || '{}');
901 const imageEntries = Object.entries(images)
902 .map(([url, data]: [string, any]) => {
903 const timestamp = typeof data === 'object' && data.metadata
904 ? data.metadata.lastAccessed || data.metadata.timestamp || 0
905 : 0;
906 return { url, timestamp };
907 })
908 .sort((a, b) => a.timestamp - b.timestamp); // Oldest first
909
910 // Remove oldest 30% of images
911 const removeCount = Math.ceil(imageEntries.length * 0.3);
912 const toRemove = imageEntries.slice(0, removeCount);
913
914 toRemove.forEach(({ url }) => {
915 delete images[url];
916 });
917
918 localStorage.setItem(STORAGE_KEYS.IMAGES, JSON.stringify(images));
919 console.log(`Cleaned up ${removeCount} old images`);
920 } catch (error) {
921 console.error('Failed to cleanup old images:', error);
922 }
923 }
924
925 // Get memory usage summary for monitoring
926 export const getMemoryUsageSummary = (): string => {
927 const usage = getStorageUsage();
928 const breakdown = Object.entries(usage.breakdown)
929 .filter(([_, size]) => size > 0)
930 .map(([type, size]) => `${type}: ${formatStorageSize(size)}`)
931 .join(', ');
932
933 return `Total: ${formatStorageSize(usage.used)} / ${formatStorageSize(usage.total)} (${usage.percentage.toFixed(1)}%). Breakdown: ${breakdown}`;
934 };
935
936 // Force cleanup when storage is nearly full
937 export const forceCleanupIfNeeded = (): boolean => {
938 const usage = getStorageUsage();
939
940 if (usage.percentage > 90) {
941 console.warn('Storage nearly full, forcing cleanup...');
942 cleanupOldImages();
943 cleanupOldVideos();
944
945 // Also clean up old downloaded files
946 try {
947 const downloads = getDownloadedFiles();
948 const oldDownloads = downloads
949 .filter(file => {
950 const daysSinceAccess = (Date.now() - new Date(file.lastAccessed).getTime()) / (1000 * 60 * 60 * 24);
951 return daysSinceAccess > 30; // Older than 30 days
952 })
953 .map(file => file.id);
954
955 oldDownloads.forEach(fileId => removeDownloadedFile(fileId));
956
957 if (oldDownloads.length > 0) {
958 console.log(`Cleaned up ${oldDownloads.length} old download records`);
959 }
960 } catch (error) {
961 console.error('Failed to cleanup old downloads:', error);
962 }
963
964 return true;
965 }
966
967 return false;
968 };