|
1
|
// Offline storage utilities for repair guides |
|
2
|
import type { RepairGuide, TroubleshootingFlow, DeviceComponent } from "@shared/schema"; |
|
3
|
|
|
4
|
const STORAGE_KEYS = { |
|
5
|
GUIDES: 'jcr_offline_guides', |
|
6
|
FLOWS: 'jcr_offline_flows', |
|
7
|
COMPONENTS: 'jcr_offline_components', |
|
8
|
FAVORITES: 'jcr_offline_favorites', |
|
9
|
LAST_SYNC: 'jcr_last_sync', |
|
10
|
USER_GUIDES: 'jcr_user_guides', |
|
11
|
DOWNLOADS: 'jcr_downloaded_files', |
|
12
|
VIDEOS: 'jcr_offline_videos', |
|
13
|
IMAGES: 'jcr_cached_images', |
|
14
|
|
|
15
|
} as const; |
|
16
|
|
|
17
|
export interface OfflineData { |
|
18
|
guides: RepairGuide[]; |
|
19
|
flows: TroubleshootingFlow[]; |
|
20
|
components: DeviceComponent[]; |
|
21
|
favorites: string[]; |
|
22
|
userGuides: RepairGuide[]; |
|
23
|
lastSync: string; |
|
24
|
} |
|
25
|
|
|
26
|
export interface StorageUsage { |
|
27
|
used: number; |
|
28
|
total: number; |
|
29
|
percentage: number; |
|
30
|
breakdown: { |
|
31
|
guides: number; |
|
32
|
flows: number; |
|
33
|
components: number; |
|
34
|
favorites: number; |
|
35
|
userGuides: number; |
|
36
|
downloads: number; |
|
37
|
videos: number; |
|
38
|
images: number; |
|
39
|
|
|
40
|
}; |
|
41
|
} |
|
42
|
|
|
43
|
export interface DownloadedFile { |
|
44
|
id: string; |
|
45
|
name: string; |
|
46
|
type: 'guide' | 'video' | 'image' | 'document'; |
|
47
|
size: number; |
|
48
|
downloadDate: string; |
|
49
|
lastAccessed: string; |
|
50
|
format: string; |
|
51
|
relatedGuideId?: string; |
|
52
|
} |
|
53
|
|
|
54
|
|
|
55
|
|
|
56
|
// Check if browser supports offline storage |
|
57
|
export const isOfflineSupported = (): boolean => { |
|
58
|
try { |
|
59
|
return typeof localStorage !== 'undefined' && |
|
60
|
typeof navigator !== 'undefined' && |
|
61
|
'serviceWorker' in navigator; |
|
62
|
} catch { |
|
63
|
return false; |
|
64
|
} |
|
65
|
}; |
|
66
|
|
|
67
|
// Save data for offline use |
|
68
|
export const saveOfflineData = async (data: Partial<OfflineData>): Promise<void> => { |
|
69
|
if (!isOfflineSupported()) return; |
|
70
|
|
|
71
|
try { |
|
72
|
if (data.guides) { |
|
73
|
localStorage.setItem(STORAGE_KEYS.GUIDES, JSON.stringify(data.guides)); |
|
74
|
} |
|
75
|
if (data.flows) { |
|
76
|
localStorage.setItem(STORAGE_KEYS.FLOWS, JSON.stringify(data.flows)); |
|
77
|
} |
|
78
|
if (data.components) { |
|
79
|
localStorage.setItem(STORAGE_KEYS.COMPONENTS, JSON.stringify(data.components)); |
|
80
|
} |
|
81
|
if (data.favorites) { |
|
82
|
localStorage.setItem(STORAGE_KEYS.FAVORITES, JSON.stringify(data.favorites)); |
|
83
|
} |
|
84
|
if (data.userGuides) { |
|
85
|
localStorage.setItem(STORAGE_KEYS.USER_GUIDES, JSON.stringify(data.userGuides)); |
|
86
|
} |
|
87
|
|
|
88
|
localStorage.setItem(STORAGE_KEYS.LAST_SYNC, new Date().toISOString()); |
|
89
|
} catch (error) { |
|
90
|
console.error('Failed to save offline data:', error); |
|
91
|
} |
|
92
|
}; |
|
93
|
|
|
94
|
// Load offline data |
|
95
|
export const loadOfflineData = (): OfflineData => { |
|
96
|
const defaultData: OfflineData = { |
|
97
|
guides: [], |
|
98
|
flows: [], |
|
99
|
components: [], |
|
100
|
favorites: [], |
|
101
|
userGuides: [], |
|
102
|
lastSync: '', |
|
103
|
}; |
|
104
|
|
|
105
|
if (!isOfflineSupported()) return defaultData; |
|
106
|
|
|
107
|
try { |
|
108
|
return { |
|
109
|
guides: JSON.parse(localStorage.getItem(STORAGE_KEYS.GUIDES) || '[]'), |
|
110
|
flows: JSON.parse(localStorage.getItem(STORAGE_KEYS.FLOWS) || '[]'), |
|
111
|
components: JSON.parse(localStorage.getItem(STORAGE_KEYS.COMPONENTS) || '[]'), |
|
112
|
favorites: JSON.parse(localStorage.getItem(STORAGE_KEYS.FAVORITES) || '[]'), |
|
113
|
userGuides: JSON.parse(localStorage.getItem(STORAGE_KEYS.USER_GUIDES) || '[]'), |
|
114
|
lastSync: localStorage.getItem(STORAGE_KEYS.LAST_SYNC) || '', |
|
115
|
}; |
|
116
|
} catch (error) { |
|
117
|
console.error('Failed to load offline data:', error); |
|
118
|
return defaultData; |
|
119
|
} |
|
120
|
}; |
|
121
|
|
|
122
|
// Save user-created guide locally |
|
123
|
export const saveUserGuide = (guide: RepairGuide): void => { |
|
124
|
if (!isOfflineSupported()) return; |
|
125
|
|
|
126
|
try { |
|
127
|
const userGuides = loadOfflineData().userGuides; |
|
128
|
const updatedGuides = [...userGuides, { ...guide, id: `user_${Date.now()}` }]; |
|
129
|
localStorage.setItem(STORAGE_KEYS.USER_GUIDES, JSON.stringify(updatedGuides)); |
|
130
|
} catch (error) { |
|
131
|
console.error('Failed to save user guide:', error); |
|
132
|
} |
|
133
|
}; |
|
134
|
|
|
135
|
// Get user-created guides |
|
136
|
export const getUserGuides = (): RepairGuide[] => { |
|
137
|
if (!isOfflineSupported()) return []; |
|
138
|
return loadOfflineData().userGuides; |
|
139
|
}; |
|
140
|
|
|
141
|
// Clear all offline data |
|
142
|
export const clearOfflineData = (): void => { |
|
143
|
if (!isOfflineSupported()) return; |
|
144
|
|
|
145
|
try { |
|
146
|
Object.values(STORAGE_KEYS).forEach(key => { |
|
147
|
localStorage.removeItem(key); |
|
148
|
}); |
|
149
|
} catch (error) { |
|
150
|
console.error('Failed to clear offline data:', error); |
|
151
|
} |
|
152
|
}; |
|
153
|
|
|
154
|
// Get comprehensive storage usage info |
|
155
|
export const getStorageUsage = (): StorageUsage => { |
|
156
|
if (!isOfflineSupported()) { |
|
157
|
return { |
|
158
|
used: 0, |
|
159
|
total: 0, |
|
160
|
percentage: 0, |
|
161
|
breakdown: { |
|
162
|
guides: 0, |
|
163
|
flows: 0, |
|
164
|
components: 0, |
|
165
|
favorites: 0, |
|
166
|
userGuides: 0, |
|
167
|
downloads: 0, |
|
168
|
videos: 0, |
|
169
|
images: 0, |
|
170
|
analytics: 0, |
|
171
|
} |
|
172
|
}; |
|
173
|
} |
|
174
|
|
|
175
|
try { |
|
176
|
const breakdown = { |
|
177
|
guides: getItemSize(STORAGE_KEYS.GUIDES), |
|
178
|
flows: getItemSize(STORAGE_KEYS.FLOWS), |
|
179
|
components: getItemSize(STORAGE_KEYS.COMPONENTS), |
|
180
|
favorites: getItemSize(STORAGE_KEYS.FAVORITES), |
|
181
|
userGuides: getItemSize(STORAGE_KEYS.USER_GUIDES), |
|
182
|
downloads: getItemSize(STORAGE_KEYS.DOWNLOADS), |
|
183
|
videos: getItemSize(STORAGE_KEYS.VIDEOS), |
|
184
|
images: getItemSize(STORAGE_KEYS.IMAGES), |
|
185
|
|
|
186
|
}; |
|
187
|
|
|
188
|
const used = Object.values(breakdown).reduce((sum, size) => sum + size, 0); |
|
189
|
const total = 50 * 1024 * 1024; // 50MB increased capacity |
|
190
|
const percentage = Math.min((used / total) * 100, 100); |
|
191
|
|
|
192
|
return { used, total, percentage, breakdown }; |
|
193
|
} catch (error) { |
|
194
|
console.error('Failed to get storage usage:', error); |
|
195
|
return { |
|
196
|
used: 0, |
|
197
|
total: 50 * 1024 * 1024, |
|
198
|
percentage: 0, |
|
199
|
breakdown: { |
|
200
|
guides: 0, |
|
201
|
flows: 0, |
|
202
|
components: 0, |
|
203
|
favorites: 0, |
|
204
|
userGuides: 0, |
|
205
|
downloads: 0, |
|
206
|
videos: 0, |
|
207
|
images: 0, |
|
208
|
analytics: 0, |
|
209
|
} |
|
210
|
}; |
|
211
|
} |
|
212
|
}; |
|
213
|
|
|
214
|
// Helper function to get size of localStorage item |
|
215
|
const getItemSize = (key: string): number => { |
|
216
|
try { |
|
217
|
const item = localStorage.getItem(key); |
|
218
|
return item ? new Blob([item]).size : 0; |
|
219
|
} catch { |
|
220
|
return 0; |
|
221
|
} |
|
222
|
}; |
|
223
|
|
|
224
|
// Check if device is online |
|
225
|
export const isOnline = (): boolean => { |
|
226
|
return typeof navigator !== 'undefined' ? navigator.onLine : true; |
|
227
|
}; |
|
228
|
|
|
229
|
// Sync data when connection is restored |
|
230
|
export const syncWhenOnline = async (): Promise<void> => { |
|
231
|
if (!isOnline() || !isOfflineSupported()) return; |
|
232
|
|
|
233
|
try { |
|
234
|
// Get pending user guides |
|
235
|
const userGuides = getUserGuides(); |
|
236
|
const pendingGuides = userGuides.filter(guide => guide.id.startsWith('user_')); |
|
237
|
|
|
238
|
// Attempt to sync pending guides to server |
|
239
|
for (const guide of pendingGuides) { |
|
240
|
try { |
|
241
|
const response = await fetch('/api/repair-guides', { |
|
242
|
method: 'POST', |
|
243
|
headers: { 'Content-Type': 'application/json' }, |
|
244
|
body: JSON.stringify(guide), |
|
245
|
}); |
|
246
|
|
|
247
|
if (response.ok) { |
|
248
|
// Remove from local storage after successful sync |
|
249
|
const remainingGuides = userGuides.filter(g => g.id !== guide.id); |
|
250
|
localStorage.setItem(STORAGE_KEYS.USER_GUIDES, JSON.stringify(remainingGuides)); |
|
251
|
} |
|
252
|
} catch (error) { |
|
253
|
console.error('Failed to sync guide:', error); |
|
254
|
// Keep guide in local storage for next sync attempt |
|
255
|
} |
|
256
|
} |
|
257
|
|
|
258
|
// Refresh cached data |
|
259
|
const [guides, flows, components] = await Promise.all([ |
|
260
|
fetch('/api/repair-guides').then(res => res.json()).catch(() => []), |
|
261
|
fetch('/api/troubleshooting-flows').then(res => res.json()).catch(() => []), |
|
262
|
fetch('/api/device-components').then(res => res.json()).catch(() => []), |
|
263
|
]); |
|
264
|
|
|
265
|
await saveOfflineData({ guides, flows, components }); |
|
266
|
} catch (error) { |
|
267
|
console.error('Sync failed:', error); |
|
268
|
} |
|
269
|
}; |
|
270
|
|
|
271
|
// Format storage size for display |
|
272
|
export const formatStorageSize = (bytes: number): string => { |
|
273
|
if (bytes === 0) return '0 B'; |
|
274
|
const k = 1024; |
|
275
|
const sizes = ['B', 'KB', 'MB', 'GB']; |
|
276
|
const i = Math.floor(Math.log(bytes) / Math.log(k)); |
|
277
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; |
|
278
|
}; |
|
279
|
|
|
280
|
// Save downloaded file record |
|
281
|
export const saveDownloadedFile = (file: DownloadedFile): void => { |
|
282
|
if (!isOfflineSupported()) return; |
|
283
|
|
|
284
|
try { |
|
285
|
const downloads = getDownloadedFiles(); |
|
286
|
const updatedDownloads = [...downloads, { ...file, lastAccessed: new Date().toISOString() }]; |
|
287
|
localStorage.setItem(STORAGE_KEYS.DOWNLOADS, JSON.stringify(updatedDownloads)); |
|
288
|
|
|
289
|
} catch (error) { |
|
290
|
console.error('Failed to save downloaded file record:', error); |
|
291
|
} |
|
292
|
}; |
|
293
|
|
|
294
|
// Get all downloaded files |
|
295
|
export const getDownloadedFiles = (): DownloadedFile[] => { |
|
296
|
if (!isOfflineSupported()) return []; |
|
297
|
|
|
298
|
try { |
|
299
|
const downloads = localStorage.getItem(STORAGE_KEYS.DOWNLOADS); |
|
300
|
return downloads ? JSON.parse(downloads) : []; |
|
301
|
} catch (error) { |
|
302
|
console.error('Failed to get downloaded files:', error); |
|
303
|
return []; |
|
304
|
} |
|
305
|
}; |
|
306
|
|
|
307
|
// Update file access time |
|
308
|
export const updateFileAccess = (fileId: string): void => { |
|
309
|
if (!isOfflineSupported()) return; |
|
310
|
|
|
311
|
try { |
|
312
|
const downloads = getDownloadedFiles(); |
|
313
|
const updated = downloads.map(file => |
|
314
|
file.id === fileId |
|
315
|
? { ...file, lastAccessed: new Date().toISOString() } |
|
316
|
: file |
|
317
|
); |
|
318
|
localStorage.setItem(STORAGE_KEYS.DOWNLOADS, JSON.stringify(updated)); |
|
319
|
} catch (error) { |
|
320
|
console.error('Failed to update file access:', error); |
|
321
|
} |
|
322
|
}; |
|
323
|
|
|
324
|
// Remove downloaded file record |
|
325
|
export const removeDownloadedFile = (fileId: string): void => { |
|
326
|
if (!isOfflineSupported()) return; |
|
327
|
|
|
328
|
try { |
|
329
|
const downloads = getDownloadedFiles(); |
|
330
|
const filtered = downloads.filter(file => file.id !== fileId); |
|
331
|
localStorage.setItem(STORAGE_KEYS.DOWNLOADS, JSON.stringify(filtered)); |
|
332
|
} catch (error) { |
|
333
|
console.error('Failed to remove downloaded file:', error); |
|
334
|
} |
|
335
|
}; |
|
336
|
|
|
337
|
|
|
338
|
|
|
339
|
|
|
340
|
|
|
341
|
|
|
342
|
|
|
343
|
// Clean up old downloads based on usage |
|
344
|
export const cleanupOldDownloads = (maxAgeInDays: number = 30): void => { |
|
345
|
if (!isOfflineSupported()) return; |
|
346
|
|
|
347
|
try { |
|
348
|
const downloads = getDownloadedFiles(); |
|
349
|
const cutoffDate = new Date(); |
|
350
|
cutoffDate.setDate(cutoffDate.getDate() - maxAgeInDays); |
|
351
|
|
|
352
|
const activeDownloads = downloads.filter(file => { |
|
353
|
const lastAccessed = new Date(file.lastAccessed); |
|
354
|
return lastAccessed > cutoffDate; |
|
355
|
}); |
|
356
|
|
|
357
|
localStorage.setItem(STORAGE_KEYS.DOWNLOADS, JSON.stringify(activeDownloads)); |
|
358
|
} catch (error) { |
|
359
|
console.error('Failed to cleanup old downloads:', error); |
|
360
|
} |
|
361
|
}; |
|
362
|
|
|
363
|
// Get storage recommendations |
|
364
|
export const getStorageRecommendations = (): string[] => { |
|
365
|
const usage = getStorageUsage(); |
|
366
|
const recommendations: string[] = []; |
|
367
|
|
|
368
|
if (usage.percentage > 90) { |
|
369
|
recommendations.push('Storage is almost full. Consider cleaning up old downloads.'); |
|
370
|
} |
|
371
|
|
|
372
|
if (usage.percentage > 75) { |
|
373
|
recommendations.push('Storage usage is high. Remove unused content to free up space.'); |
|
374
|
} |
|
375
|
|
|
376
|
if (usage.breakdown.videos > usage.breakdown.guides) { |
|
377
|
recommendations.push('Videos take up more space than guides. Consider downloading fewer videos.'); |
|
378
|
} |
|
379
|
|
|
380
|
if (usage.breakdown.images > 5 * 1024 * 1024) { // 5MB |
|
381
|
recommendations.push('Cached images are using significant space. Clear image cache if needed.'); |
|
382
|
} |
|
383
|
|
|
384
|
|
|
385
|
|
|
386
|
return recommendations; |
|
387
|
}; |
|
388
|
|
|
389
|
// Export cache management for videos and images |
|
390
|
// Memory-optimized video caching with size limits |
|
391
|
export const cacheVideo = (videoId: string, videoData: Blob): Promise<boolean> => { |
|
392
|
return new Promise((resolve) => { |
|
393
|
if (!isOfflineSupported()) { |
|
394
|
resolve(false); |
|
395
|
return; |
|
396
|
} |
|
397
|
|
|
398
|
// Check video size limit (max 10MB per video) |
|
399
|
const MAX_VIDEO_SIZE = 10 * 1024 * 1024; |
|
400
|
if (videoData.size > MAX_VIDEO_SIZE) { |
|
401
|
console.warn(`Video ${videoId} too large (${videoData.size} bytes), skipping cache`); |
|
402
|
resolve(false); |
|
403
|
return; |
|
404
|
} |
|
405
|
|
|
406
|
try { |
|
407
|
// Check if we need to clear space first |
|
408
|
const currentUsage = getStorageUsage(); |
|
409
|
const estimatedNewSize = videoData.size * 1.4; // Base64 overhead |
|
410
|
|
|
411
|
if (currentUsage.used + estimatedNewSize > currentUsage.total * 0.9) { |
|
412
|
// Clean up old videos if approaching limit |
|
413
|
cleanupOldVideos(); |
|
414
|
} |
|
415
|
|
|
416
|
// Store metadata instead of full data for large videos |
|
417
|
const videos = JSON.parse(localStorage.getItem(STORAGE_KEYS.VIDEOS) || '{}'); |
|
418
|
|
|
419
|
// For videos larger than 5MB, store only metadata |
|
420
|
if (videoData.size > 5 * 1024 * 1024) { |
|
421
|
videos[videoId] = { |
|
422
|
metadata: { |
|
423
|
size: videoData.size, |
|
424
|
type: videoData.type, |
|
425
|
cached: false, |
|
426
|
timestamp: Date.now() |
|
427
|
} |
|
428
|
}; |
|
429
|
} else { |
|
430
|
// Store smaller videos as before but with compression info |
|
431
|
const reader = new FileReader(); |
|
432
|
reader.onload = () => { |
|
433
|
try { |
|
434
|
videos[videoId] = { |
|
435
|
data: reader.result, |
|
436
|
metadata: { |
|
437
|
size: videoData.size, |
|
438
|
type: videoData.type, |
|
439
|
cached: true, |
|
440
|
timestamp: Date.now() |
|
441
|
} |
|
442
|
}; |
|
443
|
localStorage.setItem(STORAGE_KEYS.VIDEOS, JSON.stringify(videos)); |
|
444
|
resolve(true); |
|
445
|
} catch (error) { |
|
446
|
console.error('Failed to cache video:', error); |
|
447
|
resolve(false); |
|
448
|
} |
|
449
|
}; |
|
450
|
reader.onerror = () => resolve(false); |
|
451
|
reader.readAsDataURL(videoData); |
|
452
|
return; |
|
453
|
} |
|
454
|
|
|
455
|
localStorage.setItem(STORAGE_KEYS.VIDEOS, JSON.stringify(videos)); |
|
456
|
resolve(true); |
|
457
|
} catch (error) { |
|
458
|
console.error('Failed to cache video:', error); |
|
459
|
resolve(false); |
|
460
|
} |
|
461
|
}); |
|
462
|
}; |
|
463
|
|
|
464
|
export const getCachedVideo = (videoId: string): string | null => { |
|
465
|
if (!isOfflineSupported()) return null; |
|
466
|
|
|
467
|
try { |
|
468
|
const videos = JSON.parse(localStorage.getItem(STORAGE_KEYS.VIDEOS) || '{}'); |
|
469
|
const video = videos[videoId]; |
|
470
|
|
|
471
|
if (!video) return null; |
|
472
|
|
|
473
|
// Return actual data if cached, null if only metadata |
|
474
|
if (video.metadata && video.metadata.cached && video.data) { |
|
475
|
return video.data; |
|
476
|
} |
|
477
|
|
|
478
|
return null; |
|
479
|
} catch (error) { |
|
480
|
console.error('Failed to get cached video:', error); |
|
481
|
return null; |
|
482
|
} |
|
483
|
}; |
|
484
|
|
|
485
|
// Memory-optimized image caching with compression and limits |
|
486
|
export const cacheImage = (imageUrl: string, imageData: Blob): Promise<boolean> => { |
|
487
|
return new Promise((resolve) => { |
|
488
|
if (!isOfflineSupported()) { |
|
489
|
resolve(false); |
|
490
|
return; |
|
491
|
} |
|
492
|
|
|
493
|
// Check image size limit (max 2MB per image) |
|
494
|
const MAX_IMAGE_SIZE = 2 * 1024 * 1024; |
|
495
|
if (imageData.size > MAX_IMAGE_SIZE) { |
|
496
|
console.warn(`Image ${imageUrl} too large (${imageData.size} bytes), skipping cache`); |
|
497
|
resolve(false); |
|
498
|
return; |
|
499
|
} |
|
500
|
|
|
501
|
try { |
|
502
|
// Check if we need to clear space first |
|
503
|
const currentUsage = getStorageUsage(); |
|
504
|
const estimatedNewSize = imageData.size * 1.4; // Base64 overhead |
|
505
|
|
|
506
|
if (currentUsage.used + estimatedNewSize > currentUsage.total * 0.9) { |
|
507
|
// Clean up old images if approaching limit |
|
508
|
cleanupOldImages(); |
|
509
|
} |
|
510
|
|
|
511
|
// Compress image if it's large |
|
512
|
if (imageData.size > 500 * 1024) { // 500KB |
|
513
|
compressAndCacheImage(imageUrl, imageData, resolve); |
|
514
|
} else { |
|
515
|
// Store smaller images directly |
|
516
|
const reader = new FileReader(); |
|
517
|
reader.onload = () => { |
|
518
|
try { |
|
519
|
const images = JSON.parse(localStorage.getItem(STORAGE_KEYS.IMAGES) || '{}'); |
|
520
|
images[imageUrl] = { |
|
521
|
data: reader.result, |
|
522
|
metadata: { |
|
523
|
size: imageData.size, |
|
524
|
type: imageData.type, |
|
525
|
timestamp: Date.now(), |
|
526
|
compressed: false |
|
527
|
} |
|
528
|
}; |
|
529
|
localStorage.setItem(STORAGE_KEYS.IMAGES, JSON.stringify(images)); |
|
530
|
resolve(true); |
|
531
|
} catch (error) { |
|
532
|
console.error('Failed to cache image:', error); |
|
533
|
resolve(false); |
|
534
|
} |
|
535
|
}; |
|
536
|
reader.onerror = () => resolve(false); |
|
537
|
reader.readAsDataURL(imageData); |
|
538
|
} |
|
539
|
} catch (error) { |
|
540
|
console.error('Failed to cache image:', error); |
|
541
|
resolve(false); |
|
542
|
} |
|
543
|
}); |
|
544
|
}; |
|
545
|
|
|
546
|
export const getCachedImage = (imageUrl: string): string | null => { |
|
547
|
if (!isOfflineSupported()) return null; |
|
548
|
|
|
549
|
try { |
|
550
|
const images = JSON.parse(localStorage.getItem(STORAGE_KEYS.IMAGES) || '{}'); |
|
551
|
const image = images[imageUrl]; |
|
552
|
|
|
553
|
if (!image) return null; |
|
554
|
|
|
555
|
// Handle both old format (direct data) and new format (with metadata) |
|
556
|
if (typeof image === 'string') { |
|
557
|
return image; // Legacy format |
|
558
|
} |
|
559
|
|
|
560
|
if (image.data) { |
|
561
|
// Update access time for LRU cleanup |
|
562
|
image.metadata.lastAccessed = Date.now(); |
|
563
|
images[imageUrl] = image; |
|
564
|
localStorage.setItem(STORAGE_KEYS.IMAGES, JSON.stringify(images)); |
|
565
|
return image.data; |
|
566
|
} |
|
567
|
|
|
568
|
return null; |
|
569
|
} catch (error) { |
|
570
|
console.error('Failed to get cached image:', error); |
|
571
|
return null; |
|
572
|
} |
|
573
|
}; |
|
574
|
|
|
575
|
// Helper function for image compression during caching |
|
576
|
function compressAndCacheImage(imageUrl: string, imageData: Blob, resolve: (value: boolean) => void): void { |
|
577
|
const canvas = document.createElement('canvas'); |
|
578
|
const ctx = canvas.getContext('2d'); |
|
579
|
const img = new Image(); |
|
580
|
|
|
581
|
img.onload = () => { |
|
582
|
// Calculate compressed dimensions (max 1024x1024) |
|
583
|
const MAX_SIZE = 1024; |
|
584
|
let { width, height } = img; |
|
585
|
|
|
586
|
if (width > MAX_SIZE || height > MAX_SIZE) { |
|
587
|
if (width > height) { |
|
588
|
height = (height * MAX_SIZE) / width; |
|
589
|
width = MAX_SIZE; |
|
590
|
} else { |
|
591
|
width = (width * MAX_SIZE) / height; |
|
592
|
height = MAX_SIZE; |
|
593
|
} |
|
594
|
} |
|
595
|
|
|
596
|
canvas.width = width; |
|
597
|
canvas.height = height; |
|
598
|
ctx?.drawImage(img, 0, 0, width, height); |
|
599
|
|
|
600
|
canvas.toBlob((compressedBlob) => { |
|
601
|
if (compressedBlob) { |
|
602
|
const reader = new FileReader(); |
|
603
|
reader.onload = () => { |
|
604
|
try { |
|
605
|
const images = JSON.parse(localStorage.getItem(STORAGE_KEYS.IMAGES) || '{}'); |
|
606
|
images[imageUrl] = { |
|
607
|
data: reader.result, |
|
608
|
metadata: { |
|
609
|
originalSize: imageData.size, |
|
610
|
compressedSize: compressedBlob.size, |
|
611
|
type: compressedBlob.type, |
|
612
|
timestamp: Date.now(), |
|
613
|
compressed: true |
|
614
|
} |
|
615
|
}; |
|
616
|
localStorage.setItem(STORAGE_KEYS.IMAGES, JSON.stringify(images)); |
|
617
|
resolve(true); |
|
618
|
} catch (error) { |
|
619
|
console.error('Failed to cache compressed image:', error); |
|
620
|
resolve(false); |
|
621
|
} |
|
622
|
}; |
|
623
|
reader.readAsDataURL(compressedBlob); |
|
624
|
} else { |
|
625
|
resolve(false); |
|
626
|
} |
|
627
|
}, 'image/jpeg', 0.8); |
|
628
|
}; |
|
629
|
|
|
630
|
img.onerror = () => resolve(false); |
|
631
|
img.src = URL.createObjectURL(imageData); |
|
632
|
} |
|
633
|
|
|
634
|
// Cleanup old videos using LRU strategy |
|
635
|
function cleanupOldVideos(): void { |
|
636
|
try { |
|
637
|
const videos = JSON.parse(localStorage.getItem(STORAGE_KEYS.VIDEOS) || '{}'); |
|
638
|
const videoEntries = Object.entries(videos) |
|
639
|
.map(([id, data]: [string, any]) => ({ |
|
640
|
id, |
|
641
|
timestamp: data.metadata?.timestamp || 0 |
|
642
|
})) |
|
643
|
.sort((a, b) => a.timestamp - b.timestamp); // Oldest first |
|
644
|
|
|
645
|
// Remove oldest 30% of videos |
|
646
|
const removeCount = Math.ceil(videoEntries.length * 0.3); |
|
647
|
const toRemove = videoEntries.slice(0, removeCount); |
|
648
|
|
|
649
|
toRemove.forEach(({ id }) => { |
|
650
|
delete videos[id]; |
|
651
|
}); |
|
652
|
|
|
653
|
localStorage.setItem(STORAGE_KEYS.VIDEOS, JSON.stringify(videos)); |
|
654
|
console.log(`Cleaned up ${removeCount} old videos`); |
|
655
|
} catch (error) { |
|
656
|
console.error('Failed to cleanup old videos:', error); |
|
657
|
} |
|
658
|
} |
|
659
|
|
|
660
|
// Cleanup old images using LRU strategy |
|
661
|
function cleanupOldImages(): void { |
|
662
|
try { |
|
663
|
const images = JSON.parse(localStorage.getItem(STORAGE_KEYS.IMAGES) || '{}'); |
|
664
|
const imageEntries = Object.entries(images) |
|
665
|
.map(([url, data]: [string, any]) => { |
|
666
|
const timestamp = typeof data === 'object' && data.metadata |
|
667
|
? data.metadata.lastAccessed || data.metadata.timestamp || 0 |
|
668
|
: 0; |
|
669
|
return { url, timestamp }; |
|
670
|
}) |
|
671
|
.sort((a, b) => a.timestamp - b.timestamp); // Oldest first |
|
672
|
|
|
673
|
// Remove oldest 30% of images |
|
674
|
const removeCount = Math.ceil(imageEntries.length * 0.3); |
|
675
|
const toRemove = imageEntries.slice(0, removeCount); |
|
676
|
|
|
677
|
toRemove.forEach(({ url }) => { |
|
678
|
delete images[url]; |
|
679
|
}); |
|
680
|
|
|
681
|
localStorage.setItem(STORAGE_KEYS.IMAGES, JSON.stringify(images)); |
|
682
|
console.log(`Cleaned up ${removeCount} old images`); |
|
683
|
} catch (error) { |
|
684
|
console.error('Failed to cleanup old images:', error); |
|
685
|
} |
|
686
|
} |
|
687
|
|
|
688
|
// Get memory usage summary for monitoring |
|
689
|
export const getMemoryUsageSummary = (): string => { |
|
690
|
const usage = getStorageUsage(); |
|
691
|
const breakdown = Object.entries(usage.breakdown) |
|
692
|
.filter(([_, size]) => size > 0) |
|
693
|
.map(([type, size]) => `${type}: ${formatStorageSize(size)}`) |
|
694
|
.join(', '); |
|
695
|
|
|
696
|
return `Total: ${formatStorageSize(usage.used)} / ${formatStorageSize(usage.total)} (${usage.percentage.toFixed(1)}%). Breakdown: ${breakdown}`; |
|
697
|
}; |
|
698
|
|
|
699
|
// Force cleanup when storage is nearly full |
|
700
|
export const forceCleanupIfNeeded = (): boolean => { |
|
701
|
const usage = getStorageUsage(); |
|
702
|
|
|
703
|
if (usage.percentage > 90) { |
|
704
|
console.warn('Storage nearly full, forcing cleanup...'); |
|
705
|
cleanupOldImages(); |
|
706
|
cleanupOldVideos(); |
|
707
|
|
|
708
|
// Also clean up old downloaded files |
|
709
|
try { |
|
710
|
const downloads = getDownloadedFiles(); |
|
711
|
const oldDownloads = downloads |
|
712
|
.filter(file => { |
|
713
|
const daysSinceAccess = (Date.now() - new Date(file.lastAccessed).getTime()) / (1000 * 60 * 60 * 24); |
|
714
|
return daysSinceAccess > 30; // Older than 30 days |
|
715
|
}) |
|
716
|
.map(file => file.id); |
|
717
|
|
|
718
|
oldDownloads.forEach(fileId => removeDownloadedFile(fileId)); |
|
719
|
|
|
720
|
if (oldDownloads.length > 0) { |
|
721
|
console.log(`Cleaned up ${oldDownloads.length} old download records`); |
|
722
|
} |
|
723
|
} catch (error) { |
|
724
|
console.error('Failed to cleanup old downloads:', error); |
|
725
|
} |
|
726
|
|
|
727
|
return true; |
|
728
|
} |
|
729
|
|
|
730
|
return false; |
|
731
|
}; |