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