/** * Central Cache Class * Provides caching for taxonomy terms, API responses and other data * with support for Browser Cache API (preferred) or localStorage fallback */ class Cache { constructor(options = {}) { this.options = { defaultTTL: 3600000, // 1 hour in milliseconds namespace: 'jvb_cache_', maxSize: 100, ...options }; // Check if Cache API is available this.cacheAvailable = 'caches' in window; if(!this.cacheAvailable){ console.warn('Browser Cache API unavailable, reverting to LocalStorage'); } // Initialize memory cache this._memoryCache = new Map(); // Server timestamps from wp_localize_script this.cachedContent = JSON.parse(cacheJVB.cache) || {}; this.lastTimestampUpdate = Date.now(); // Setup timestamp refresh interval (every 5 minutes) this._timestampInterval = setInterval(() => this.refreshTimestamps(), 300000); // HTTP caching metadata storage this.httpHeaders = new Map(); // Stores ETags and Last-Modified per URL this.httpHeadersTTL = 24 * 60 * 60 * 1000; // 24 hours this.httpHeadersKey = 'http_headers_cache'; this.headerSaveDebounce = null; this.headerSaveDelay = 2000; // 2 seconds // Load persisted HTTP headers on initialization this.loadHttpHeaders(); // Clean up old headers periodically this.startHeaderCleanup(); //For Image Uploads this.dbName= 'jvb_image_cache'; this.dbVersion = 1; this.imageStoreName = 'pending_uploads'; this.initIndexedDB(); } /** * * Header stuffs * */ /** * Load HTTP headers from cache on initialization */ async loadHttpHeaders() { try { const savedHeaders = await this.getItem(this.httpHeadersKey, 'system'); if (savedHeaders instanceof Map) { // Clean expired headers while loading const now = Date.now(); for (const [key, headerData] of savedHeaders) { if (this.isHeaderDataValid(headerData, now)) { this.httpHeaders.set(key, headerData); } } console.debug(`Loaded ${this.httpHeaders.size} cached HTTP headers`); } } catch (error) { console.warn('Failed to load HTTP headers from cache:', error); this.httpHeaders = new Map(); } } /** * Save HTTP headers to cache with debouncing */ saveHttpHeaders() { // Debounce saves to avoid excessive writes if (this.headerSaveDebounce) { clearTimeout(this.headerSaveDebounce); } this.headerSaveDebounce = setTimeout(async () => { try { await this.setItem(this.httpHeadersKey, this.httpHeaders, 'system'); console.debug(`Saved ${this.httpHeaders.size} HTTP headers to cache`); } catch (error) { console.warn('Failed to save HTTP headers to cache:', error); } }, this.headerSaveDelay); } /** * Enhanced store HTTP headers with automatic persistence */ storeHttpHeaders(urlHeaderKey, response) { const etag = response.headers.get('ETag'); const lastModified = response.headers.get('Last-Modified'); const cacheControl = response.headers.get('Cache-Control'); if (etag || lastModified) { const headerData = { etag, lastModified, cacheControl, storedAt: Date.now(), url: this.extractBaseUrl(urlHeaderKey) // For debugging/cleanup }; this.httpHeaders.set(urlHeaderKey, headerData); // Automatically save to persistent cache this.saveHttpHeaders(); console.debug(`Stored HTTP headers for ${urlHeaderKey}:`, { etag: etag ? etag.substring(0, 20) + '...' : null, lastModified }); } } /** * Check if header data is still valid (not expired) */ isHeaderDataValid(headerData, currentTime = Date.now()) { if (!headerData || !headerData.storedAt) { return false; } const age = currentTime - headerData.storedAt; return age < this.httpHeadersTTL; } /** * Clean up expired HTTP headers */ cleanupExpiredHeaders() { const now = Date.now(); let removedCount = 0; for (const [key, headerData] of this.httpHeaders) { if (!this.isHeaderDataValid(headerData, now)) { this.httpHeaders.delete(key); removedCount++; } } if (removedCount > 0) { console.debug(`Cleaned up ${removedCount} expired HTTP headers`); this.saveHttpHeaders(); // Persist the cleanup } return removedCount; } /** * Start periodic cleanup of old headers */ startHeaderCleanup() { // Clean up every hour this.headerCleanupInterval = setInterval(() => { this.cleanupExpiredHeaders(); }, 60 * 60 * 1000); // Clean up when page becomes visible (user returns) document.addEventListener('visibilitychange', () => { if (!document.hidden) { this.cleanupExpiredHeaders(); } }); } /** * Extract base URL from header key for debugging */ extractBaseUrl(urlHeaderKey) { const parts = urlHeaderKey.split('|'); return parts[1] || urlHeaderKey; // URL is usually second part } /** * Enhanced Cache Deletion Methods * Add these methods to your existing Cache class */ /** * Completely clear all IndexedDB data * @returns {Promise} Success status */ async clearIndexedDB() { try { if (!this.imageDB) { console.log('IndexedDB not initialized, nothing to clear'); return true; } // Close the database connection first this.imageDB.close(); // Delete the entire database await new Promise((resolve, reject) => { const deleteRequest = indexedDB.deleteDatabase(this.dbName); deleteRequest.onsuccess = () => { console.log(`IndexedDB "${this.dbName}" deleted successfully`); resolve(); }; deleteRequest.onerror = () => { console.error('Failed to delete IndexedDB:', deleteRequest.error); reject(deleteRequest.error); }; deleteRequest.onblocked = () => { console.warn('IndexedDB deletion blocked - other tabs may be using it'); // Try to resolve anyway after a timeout setTimeout(resolve, 2000); }; }); // Reset the database reference this.imageDB = null; // Reinitialize if needed (optional) // await this.initIndexedDB(); return true; } catch (error) { console.error('Error clearing IndexedDB:', error); return false; } } /** * Clear all memory cache * @returns {number} Number of items cleared */ clearMemoryCache() { const count = this._memoryCache.size; this._memoryCache.clear(); console.log(`Cleared ${count} items from memory cache`); return count; } /** * Comprehensive cache clear - everything * @param {Object} options - Options for what to clear * @returns {Promise} Summary of what was cleared */ async clearAllCache(options = {}) { const { includeBrowserCache = true, includeLocalStorage = true, includeIndexedDB = true, includeMemoryCache = true, includeHttpHeaders = true, showProgress = false } = options; const results = { browserCache: false, localStorage: 0, indexedDB: false, memoryCache: 0, httpHeaders: 0, errors: [] }; if (showProgress) { console.log('Starting comprehensive cache clear...'); } // Clear Browser Cache API if (includeBrowserCache) { try { if (this.cacheAvailable) { await caches.delete(this.options.namespace); results.browserCache = true; if (showProgress) console.log('✓ Browser Cache API cleared'); } } catch (error) { results.errors.push(`Browser Cache: ${error.message}`); if (showProgress) console.error('✗ Browser Cache API error:', error); } } // Clear localStorage if (includeLocalStorage) { try { let cleared = 0; for (let i = localStorage.length - 1; i >= 0; i--) { const key = localStorage.key(i); if (key && key.startsWith(this.options.namespace)) { localStorage.removeItem(key); cleared++; } } results.localStorage = cleared; if (showProgress) console.log(`✓ LocalStorage cleared: ${cleared} items`); } catch (error) { results.errors.push(`LocalStorage: ${error.message}`); if (showProgress) console.error('✗ LocalStorage error:', error); } } // Clear IndexedDB if (includeIndexedDB) { try { results.indexedDB = await this.clearIndexedDB(); if (showProgress) console.log('✓ IndexedDB cleared'); } catch (error) { results.errors.push(`IndexedDB: ${error.message}`); if (showProgress) console.error('✗ IndexedDB error:', error); } } // Clear Memory Cache if (includeMemoryCache) { try { results.memoryCache = this.clearMemoryCache(); if (showProgress) console.log(`✓ Memory cache cleared: ${results.memoryCache} items`); } catch (error) { results.errors.push(`Memory Cache: ${error.message}`); if (showProgress) console.error('✗ Memory cache error:', error); } } // Clear HTTP Headers if (includeHttpHeaders) { try { results.httpHeaders = this.clearHttpHeaders(); if (showProgress) console.log(`✓ HTTP headers cleared: ${results.httpHeaders} items`); } catch (error) { results.errors.push(`HTTP Headers: ${error.message}`); if (showProgress) console.error('✗ HTTP headers error:', error); } } // Clean up intervals and timeouts this.destroy(); if (showProgress) { console.log('Cache clear completed:', results); } return results; } /** * Clear specific IndexedDB stores without deleting the entire database * @param {Array} storeNames - Array of store names to clear * @returns {Promise} Number of stores cleared */ async clearIndexedDBStores(storeNames = []) { if (!this.imageDB) { console.log('IndexedDB not available'); return 0; } // Default to all known stores if none specified if (storeNames.length === 0) { storeNames = [this.imageStoreName, 'groups']; } let clearedCount = 0; try { const transaction = this.imageDB.transaction(storeNames, 'readwrite'); for (const storeName of storeNames) { try { const store = transaction.objectStore(storeName); await new Promise((resolve, reject) => { const clearRequest = store.clear(); clearRequest.onsuccess = () => { console.log(`Cleared IndexedDB store: ${storeName}`); clearedCount++; resolve(); }; clearRequest.onerror = () => reject(clearRequest.error); }); } catch (error) { console.warn(`Failed to clear store ${storeName}:`, error); } } } catch (error) { console.error('Error clearing IndexedDB stores:', error); } return clearedCount; } /** * Clear all HTTP headers cache * @returns {number} Number of headers cleared */ clearHttpHeaders() { const count = this.httpHeaders.size; this.httpHeaders.clear(); // Force save the empty headers cache this.saveHttpHeaders(); console.log(`Cleared ${count} HTTP headers from cache`); return count; } /** * Get HTTP cache statistics */ getHttpCacheStats() { const now = Date.now(); let validHeaders = 0; let expiredHeaders = 0; const urlCounts = {}; for (const [key, headerData] of this.httpHeaders) { if (this.isHeaderDataValid(headerData, now)) { validHeaders++; const baseUrl = this.extractBaseUrl(key); urlCounts[baseUrl] = (urlCounts[baseUrl] || 0) + 1; } else { expiredHeaders++; } } return { total: this.httpHeaders.size, valid: validHeaders, expired: expiredHeaders, urlBreakdown: urlCounts, memoryUsage: this.estimateHttpHeadersMemoryUsage() }; } /** * Estimate memory usage of HTTP headers cache */ estimateHttpHeadersMemoryUsage() { let totalSize = 0; for (const [key, headerData] of this.httpHeaders) { // Rough estimate: key + JSON representation of value totalSize += key.length * 2; // Unicode characters totalSize += JSON.stringify(headerData).length * 2; } return { bytes: totalSize, kb: Math.round(totalSize / 1024 * 100) / 100, entries: this.httpHeaders.size }; } /** * Force save HTTP headers (useful for page unload) */ async forceHttpHeadersSave() { if (this.headerSaveDebounce) { clearTimeout(this.headerSaveDebounce); this.headerSaveDebounce = null; } try { await this.setItem(this.httpHeadersKey, this.httpHeaders, 'system'); console.debug('Force saved HTTP headers'); } catch (error) { console.warn('Failed to force save HTTP headers:', error); } } /** * Fetch with comprehensive HTTP caching support * * @param {string} url - The API URL to fetch * @param {object} fetchOptions - Options for the fetch request * @param {object} cacheOptions - Options for caching behavior * @returns {Promise} - The response data */ async fetchWithCache(url, fetchOptions = {}, cacheOptions = {}) { // Extract cache-specific options with defaults let { maxAge = this.options.defaultTTL, forceRefresh = false, content = '', timeout = 30000, } = cacheOptions; // forceRefresh = true; const cacheKey = this.generateCacheKey(url, fetchOptions); if (content || (content = this.detectContentType(url)), !forceRefresh && !this.cacheUpdated(content)) { const cachedItem = await this.getCacheItem(cacheKey); if (cachedItem && this.isCacheItemValid(cachedItem, maxAge)) { console.debug(`Cache hit for ${url} (content: ${content})`); return this.deserializeData(cachedItem.data, cachedItem.dataType); } } // Prepare headers with caching directives const headers = { ...fetchOptions.headers }; const headerKey = this.getUrlHeaderKey(url, fetchOptions); const cachedHeaders = this.httpHeaders.get(headerKey); if (!forceRefresh && cachedHeaders?.etag) { headers["If-None-Match"] = cachedHeaders.etag; } if (!forceRefresh && cachedHeaders?.lastModified) { headers["If-Modified-Since"] = cachedHeaders.lastModified; } if (!forceRefresh && this.lastTimestampUpdate) { headers["X-Client-Cache-Timestamp"] = new Date(this.lastTimestampUpdate).toISOString(); } // Create the fetch function that log can retry const performFetch = async () => { // Set up timeout const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); try { const response = await fetch(url, { ...fetchOptions, headers, signal: controller.signal }); clearTimeout(timeoutId); // Handle 304 Not Modified if (response.status === 304) { console.debug(`304 Not Modified for ${url}`); const cachedItem = await this.getCacheItem(cacheKey); if (cachedItem) { // Update timestamp but keep existing data cachedItem.timestamp = Date.now(); await this.setCacheItem(cacheKey, cachedItem); return this.deserializeData(cachedItem.data, cachedItem.dataType); } else { console.warn(`Received 304 but no cached data for ${url}`); return null; } } if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } // Store HTTP headers for future caching this.storeHttpHeaders(headerKey, response); // Parse and cache the response const data = await response.json(); const serializedData = this.serializeData(data); const cacheItem = { data: serializedData.data, dataType: serializedData.type, timestamp: Date.now(), content, url, httpStatus: response.status, etag: response.headers.get("ETag"), lastModified: response.headers.get("Last-Modified") }; await this.setCacheItem(cacheKey, cacheItem); if (content !== "") { this.updateContentCache(content); } console.debug(`Fetched and cached ${url} (content: ${content})`); return data; } catch (error) { clearTimeout(timeoutId); throw error; } }; // Use the centralized error handling system try { return await performFetch(); } catch (error) { // Check for stale data fallback before using error handler if (error.name === "AbortError" || error.message?.includes("timeout")) { const staleData = await this.getStaleDataFallback(cacheKey); if (staleData) { console.warn(`Returning stale data for ${url} due to timeout`); return staleData; } } // Use centralized error handling with retry capability if (window.jvbError) { const result = await window.jvbError.log(error, { component: "CacheManager", action: "fetchWithCache", url: url, content: content, timeout: timeout }, performFetch); // Pass the fetch function for retries // If log returns a result from retry, use it if (result && result.success !== false) { return result; } } // If error handling didn't resolve it, try stale data as final fallback const staleData = await this.getStaleDataFallback(cacheKey); if (staleData) { console.warn(`Returning stale data for ${url} due to error:`, error.message); return staleData; } // Re-throw the error if no fallbacks work throw error; } } detectContentType(url) { const patterns = { 'queue': /\/queue/, 'notifications': /\/notifications/, 'posts': /\/(posts|content)/, 'users': /\/users/, 'taxonomy': /\/(terms|taxonomies)/, 'media': /\/(media|images|files)/, 'artists': /\/artists/, 'shops': /\/shops/, 'events': /\/events/ }; for (const [type, pattern] of Object.entries(patterns)) { if (pattern.test(url)) { return type; } } return ''; } /** * Generate a unique key for storing HTTP headers per URL/method combination */ getUrlHeaderKey(url, fetchOptions) { const method = fetchOptions.method || 'GET'; // Include relevant parts of the request that affect caching let keyComponents = [method, url]; // For POST/PUT requests, include body hash if it affects response if (method !== 'GET' && fetchOptions.body) { try { const bodyHash = this.hashString(JSON.stringify(fetchOptions.body)); keyComponents.push(bodyHash); } catch (e) { // If body isn't JSON, use string representation keyComponents.push(this.hashString(String(fetchOptions.body))); } } return keyComponents.join('|'); } /** * Check if cache item is still valid based on age */ isCacheItemValid(cacheItem, maxAge) { if (!cacheItem || !cacheItem.timestamp) { return false; } const age = Date.now() - cacheItem.timestamp; return age < maxAge; } /** * Get stale cached data as fallback when fresh fetch fails */ async getStaleDataFallback(cacheKey) { try { const cachedItem = await this.getCacheItem(cacheKey); if (cachedItem) { console.debug('Using stale cache data as fallback'); return this.deserializeData(cachedItem.data, cachedItem.dataType); } } catch (error) { console.warn('Failed to retrieve stale cache data:', error); } return null; } /** * Simple string hashing for cache keys */ hashString(str) { let hash = 0; if (str.length === 0) return hash; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32-bit integer } return hash.toString(36); } cacheUpdated(content){ if(!content || !this.cachedContent) return true; if(!this.cachedContent[content]) return true; const key = `${this.options.namespace}_cached_${content}`; let localCache = null; //Check memory cache first if (this._memoryCache && this._memoryCache.has(key)) { localCache = this._memoryCache.get(key); } else { // Check persistent storage localCache = this.cacheAvailable ? this.getBrowserCacheItem(key) : this.getLocalStorageItem(key); } if(!localCache) return true; return this.cachedContent[content] > localCache; } /** * Update the timestamp for a content type * @param {string} content - The content type to update */ updateContentCache(content) { if (!content) return; const timestamp = Date.now(); const key = `${this.options.namespace}_cached_${content}`; // Update memory cache this._memoryCache.set(key, timestamp); // Update persistent storage if (this.cacheAvailable) { this.setBrowserCacheItem(key, timestamp); } else { this.setLocalStorageItem(key, timestamp); } } /** * Clear HTTP headers for specific URLs (useful for cache invalidation) */ clearHttpHeaderPattern(urlPattern) { for (const [key, value] of this.httpHeaders.entries()) { if (key.includes(urlPattern)) { this.httpHeaders.delete(key); } } } /** * * * Custom caches * * */ /** * Enhanced setItem with automatic type preservation */ async setItem(key, data, content = '') { const cacheKey = (content === '') ? this.options.namespace + key : this.options.namespace + content + ':' + key; // Serialize data with type preservation const serializedData = this.serializeData(data); const cacheItem = { data: serializedData.data, dataType: serializedData.type, timestamp: Date.now(), content }; await this.setCacheItem(cacheKey, cacheItem); // Update timestamp for the content type if (content !== '') { this.updateContentCache(content); } } /** * Enhanced getItem with automatic type restoration */ async getItem(key, content = '') { const cacheKey = (content === '') ? this.options.namespace + key : this.options.namespace + content + ':' + key; const cachedItem = await this.getCacheItem(cacheKey); if (!cachedItem) { return null; } // Deserialize data back to original type return this.deserializeData(cachedItem.data, cachedItem.dataType); } serializeData(data) { // Handle null/undefined if (data === null || data === undefined) { return { data, type: 'primitive' }; } // Handle Maps if (data instanceof Map) { return { data: Array.from(data.entries()), type: 'Map' }; } // Handle Sets if (data instanceof Set) { return { data: Array.from(data), type: 'Set' }; } // Handle Dates if (data instanceof Date) { return { data: data.toISOString(), type: 'Date' }; } // Handle RegExp if (data instanceof RegExp) { return { data: { source: data.source, flags: data.flags }, type: 'RegExp' }; } // Handle Arrays (check before general objects) if (Array.isArray(data)) { // Recursively serialize array elements that might contain Maps/Sets return { data: data.map(item => this.serializeData(item)), type: 'Array' }; } // Handle plain objects if (data && typeof data === 'object' && data.constructor === Object) { const serializedObj = {}; for (const [key, value] of Object.entries(data)) { serializedObj[key] = this.serializeData(value); } return { data: serializedObj, type: 'Object' }; } // Handle primitives (string, number, boolean) return { data, type: 'primitive' }; } deserializeData(data, type) { if (!type || type === 'primitive') { return data; } switch (type) { case 'Map': return new Map(data); case 'Set': return new Set(data); case 'Date': return new Date(data); case 'RegExp': return new RegExp(data.source, data.flags); case 'Array': // Recursively deserialize array elements return data.map(item => this.deserializeData(item.data, item.type) ); case 'Object': const restoredObj = {}; for (const [key, value] of Object.entries(data)) { restoredObj[key] = this.deserializeData(value.data, value.type); } return restoredObj; default: console.warn(`Unknown data type: ${type}, returning as-is`); return data; } } async setMap(key, map, content = '') { if (!(map instanceof Map)) { throw new Error('setMap requires a Map instance'); } return this.setItem(key, map, content); } async getMap(key, content = '') { const result = await this.getItem(key, content); if (result === null) { return null; } if (!(result instanceof Map)) { console.warn(`Expected Map but got ${typeof result} for key: ${key}`); return null; } return result; } /** * Refreshes server timestamps from the API * * @returns {Promise} */ async refreshTimestamps() { try { const response = await fetch(`${jvbSettings.api}cachedContent`, { method: 'GET', headers: { 'If-Modified-Since': this.lastTimestampUpdate ? new Date(this.lastTimestampUpdate).toUTCString() : '' } }); if (response.status === 304) { // Not modified, nothing to do return; } if (response.ok) { this.cachedContent = await response.json(); this.lastTimestampUpdate = Date.now(); // Invalidate affected caches await this.invalidateAffectedCaches(); } } catch (error) { console.warn('Failed to refresh cache timestamps:', error); } } /** * Main method to get an item from cache * Tries Browser Cache API first, falls back to localStorage * * @param {string} key - Cache key * @returns {Promise} - Cached item or null */ async getCacheItem(key) { // Check memory cache first (ultra fast) if (this._memoryCache.has(key)) { return this._memoryCache.get(key); } // Then check persistent cache const item = this.cacheAvailable ? await this.getBrowserCacheItem(key) : this.getLocalStorageItem(key); // Store in memory cache if found if (item) { this._memoryCache.set(key, item); } return item; } /** * Main method to set an item in cache * Uses Browser Cache API if available, falls back to localStorage * * @param {string} key - Cache key * @param {any} item - Item to cache * @returns {Promise} */ async setCacheItem(key, item) { // Always store in memory cache this._memoryCache.set(key, item); return this.cacheAvailable ? await this.setBrowserCacheItem(key, item) : this.setLocalStorageItem(key, item); } /** * Remove an item from cache * * @param {string} key - Cache key * @returns {Promise} */ async removeCacheItem(key) { // Remove from memory cache this._memoryCache.delete(key); return this.cacheAvailable ? await this.removeBrowserCacheItem(key) : this.removeLocalStorageItem(key); } /** * Get item from Browser Cache API * * @param {string} key - Cache key * @returns {Promise} - Cached item or null */ async getBrowserCacheItem(key) { try { const cache = await caches.open(this.options.namespace); const response = await cache.match(key); if (!response) { return null; } return await response.json(); } catch (error) { console.warn('Error getting from Browser Cache API:', error); return null; } } /** * Set item in Browser Cache API * * @param {string} key - Cache key * @param {any} item - Item to cache * @returns {Promise} */ async setBrowserCacheItem(key, item) { try { const cache = await caches.open(this.options.namespace); const response = new Response(JSON.stringify(item), { headers: { 'Content-Type': 'application/json' } }); await cache.put(key, response); } catch (error) { console.warn('Error setting in Browser Cache API:', error); } } /** * Remove item from Browser Cache API * * @param {string} key - Cache key * @returns {Promise} */ async removeBrowserCacheItem(key) { try { const cache = await caches.open(this.options.namespace); await cache.delete(key); } catch (error) { console.warn('Error removing from Browser Cache API:', error); } } /** * Get item from localStorage * * @param {string} key - Cache key * @returns {object|null} - Cached item or null */ getLocalStorageItem(key) { try { const stored = localStorage.getItem(key); if (!stored) { return null; } return JSON.parse(stored); } catch (error) { console.warn('Error getting from localStorage:', error); return null; } } /** * Set item in localStorage * * @param {string} key - Cache key * @param {any} item - Item to cache */ setLocalStorageItem(key, item) { try { localStorage.setItem(key, JSON.stringify(item)); } catch (error) { // Handle quota exceeded if (error instanceof DOMException && error.code === 22) { this.clearOldestLocalStorageItems(); try { localStorage.setItem(key, JSON.stringify(item)); } catch (retryError) { console.warn('Still failed to set localStorage item after cleanup:', retryError); } } else { console.warn('Error setting localStorage item:', error); } } } /** * Remove item from localStorage * * @param {string} key - Cache key */ removeLocalStorageItem(key) { try { localStorage.removeItem(key); } catch (error) { console.warn('Error removing localStorage item:', error); } } /** * Clear oldest items from localStorage when quota is exceeded */ clearOldestLocalStorageItems() { try { const keysToRemove = []; // Find all our cache keys for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key.startsWith(this.options.namespace)) { try { const item = JSON.parse(localStorage.getItem(key)); keysToRemove.push({ key, timestamp: item.timestamp || 0 }); } catch (e) { // If it's not valid JSON or doesn't have a timestamp, prioritize for removal keysToRemove.push({ key, timestamp: 0 }); } } } // Sort by timestamp (oldest first) keysToRemove.sort((a, b) => a.timestamp - b.timestamp); // Remove the oldest 20% of items const removeCount = Math.max(1, Math.ceil(keysToRemove.length * 0.2)); for (let i = 0; i < removeCount; i++) { if (keysToRemove[i]) { localStorage.removeItem(keysToRemove[i].key); } } } catch (error) { console.warn('Error cleaning up localStorage:', error); } } /** * Initialize IndexedDB for image storage */ async initIndexedDB() { try { this.imageDB = await new Promise((resolve, reject) => { const request = indexedDB.open(this.dbName, 2); // Increment version request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result); request.onupgradeneeded = (event) => { const db = event.target.result; // Create pending uploads store if (!db.objectStoreNames.contains(this.imageStoreName)) { const store = db.createObjectStore(this.imageStoreName, { keyPath: 'id' }); store.createIndex('operationId', 'operationId', { unique: false }); store.createIndex('timestamp', 'timestamp', { unique: false }); store.createIndex('fieldId', 'fieldId', { unique: false }); } // Create groups store if (!db.objectStoreNames.contains('groups')) { const groupStore = db.createObjectStore('groups', { keyPath: 'id' }); groupStore.createIndex('fieldId', 'fieldId', { unique: false }); groupStore.createIndex('timestamp', 'timestamp', { unique: false }); } }; }); console.debug('IndexedDB initialized with group support'); } catch (error) { console.warn('Failed to initialize IndexedDB:', error); this.imageDB = null; } } /** * Store group data in IndexedDB */ async storeGroupData(fieldId, groupId, groupData) { if (!this.imageDB) { // Fallback to memory cache this._memoryCache.set(`group_${fieldId}_${groupId}`, groupData); return true; } try { const transaction = this.imageDB.transaction(['groups'], 'readwrite'); const store = transaction.objectStore('groups'); const groupRecord = { id: `${fieldId}_${groupId}`, fieldId, groupId, ...groupData, timestamp: Date.now() }; await new Promise((resolve, reject) => { const request = store.put(groupRecord); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); return true; } catch (error) { console.error('Failed to store group data:', error); return false; } } /** * Retrieve group data for a field */ async getGroupsForField(fieldId) { // Check memory cache first const memoryResults = []; for (const [key, value] of this._memoryCache.entries()) { if (key.startsWith(`group_${fieldId}_`)) { memoryResults.push(value); } } if (memoryResults.length > 0 || !this.imageDB) { return memoryResults; } try { const transaction = this.imageDB.transaction(['groups'], 'readonly'); const store = transaction.objectStore('groups'); const index = store.index('fieldId'); return await new Promise((resolve, reject) => { const request = index.getAll(fieldId); request.onsuccess = () => resolve(request.result || []); request.onerror = () => reject(request.error); }); } catch (error) { console.error('Failed to retrieve groups for field:', error); return []; } } /** * Store pending image in IndexedDB * @param {string} id - Unique identifier for the image * @param {File|Blob} imageData - The image file/blob * @param {Object} metadata - Associated metadata * @param {string} operationId - Queue operation ID * @returns {Promise} Success status */ async storeImagePending(id, imageData, metadata = {}, operationId = null) { if (!this.imageDB) { console.warn('IndexedDB not available, falling back to memory cache'); this._memoryCache.set(`pending_image_${id}`, { imageData, metadata, operationId, groupInfo: metadata.groupInfo || null }); return true; } try { const transaction = this.imageDB.transaction([this.imageStoreName], 'readwrite'); const store = transaction.objectStore(this.imageStoreName); const imageRecord = { id, imageData, metadata, operationId, fieldId: metadata.uploadConfig?.fieldId, groupInfo: metadata.groupInfo || null, timestamp: Date.now(), status: 'pending' }; await new Promise((resolve, reject) => { const request = store.put(imageRecord); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); return true; } catch (error) { console.error('Failed to store pending image:', error); return false; } } /** * Retrieve pending image from IndexedDB * @param {string} id - Image identifier * @returns {Promise} Image record or null */ async getImagePending(id) { // Check memory cache first const memoryResult = this._memoryCache.get(`pending_image_${id}`); if (memoryResult) { return { id, ...memoryResult }; } if (!this.imageDB) { return null; } try { const transaction = this.imageDB.transaction([this.imageStoreName], 'readonly'); const store = transaction.objectStore(this.imageStoreName); return await new Promise((resolve, reject) => { const request = store.get(id); request.onsuccess = () => resolve(request.result || null); request.onerror = () => reject(request.error); }); } catch (error) { console.error('Failed to retrieve pending image:', error); return null; } } /** * Get all pending images for an operation * @param {string} operationId - Queue operation ID * @returns {Promise} Array of image records */ async getImagesPendingByOperation() { const results = []; // Check memory cache first for (const [key, value] of this._memoryCache.entries()) { if (key.startsWith('pending_image_')) { results.push({ id: key.replace('pending_image_', ''), ...value }); } } if (results.length > 0 || !this.imageDB) { return results; } try { const transaction = this.imageDB.transaction([this.imageStoreName], 'readonly'); const store = transaction.objectStore(this.imageStoreName); return await new Promise((resolve, reject) => { const request = store.getAll(); request.onsuccess = () => resolve(request.result || []); request.onerror = () => reject(request.error); }); } catch (error) { console.error('Failed to retrieve pending images:', error); return []; } } async getImagesPendingByField(fieldId) { if (!this.imageDB) { // Fallback to memory cache const results = []; for (const [key, value] of this._memoryCache.entries()) { if (key.startsWith('pending_image_') && value.metadata?.uploadConfig?.fieldId === fieldId) { results.push({ id: key.replace('pending_image_', ''), ...value }); } } return results; } try { const transaction = this.imageDB.transaction([this.imageStoreName], 'readonly'); const store = transaction.objectStore(this.imageStoreName); const index = store.index('fieldId'); return await new Promise((resolve, reject) => { const request = index.getAll(fieldId); request.onsuccess = () => resolve(request.result || []); request.onerror = () => reject(request.error); }); } catch (error) { console.error('Failed to retrieve pending images by field:', error); return []; } } /** * Clear group data for a field */ async clearGroupsForField(fieldId) { // Clear from memory cache for (const key of this._memoryCache.keys()) { if (key.startsWith(`group_${fieldId}_`)) { this._memoryCache.delete(key); } } if (!this.imageDB) return; try { const transaction = this.imageDB.transaction(['groups'], 'readwrite'); const store = transaction.objectStore('groups'); const index = store.index('fieldId'); const range = IDBKeyRange.only(fieldId); const request = index.openCursor(range); await new Promise((resolve, reject) => { request.onsuccess = (event) => { const cursor = event.target.result; if (cursor) { cursor.delete(); cursor.continue(); } else { resolve(); } }; request.onerror = () => reject(request.error); }); } catch (error) { console.error('Failed to clear groups for field:', error); } } /** * Remove pending image from storage * @param {string} id - Image identifier * @returns {Promise} Success status */ async removeImagePending(id) { // Remove from memory cache this._memoryCache.delete(`pending_image_${id}`); if (!this.imageDB) { return true; } try { const transaction = this.imageDB.transaction([this.imageStoreName], 'readwrite'); const store = transaction.objectStore(this.imageStoreName); await new Promise((resolve, reject) => { const request = store.delete(id); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); return true; } catch (error) { console.error('Failed to remove pending image:', error); return false; } } async updateGroupData(fieldId, groupId, updates) { const groupKey = `group_${fieldId}_${groupId}`; // Update memory cache const existing = this._memoryCache.get(groupKey); if (existing) { this._memoryCache.set(groupKey, { ...existing, ...updates }); } // Update IndexedDB if (this.imageDB) { return this.storeGroupData(fieldId, groupId, updates); } return true; } /** * Clear all pending images for an operation * @param {string} operationId - Queue operation ID * @returns {Promise} Number of images removed */ async clearImagesPendingByOperation(operationId) { const images = await this.getImagesPendingByOperation(operationId); let removedCount = 0; const fieldsToCleanup = new Set(); for (const image of images) { if (await this.removeImagePending(image.id)) { removedCount++; if (image.fieldId) { fieldsToCleanup.add(image.fieldId); } } } // Clear group data for affected fields for (const fieldId of fieldsToCleanup) { await this.clearGroupsForField(fieldId); } return removedCount; } /** * Clean up old pending images (older than 24 hours) * @returns {Promise} Number of images cleaned up */ async cleanupOldPendingImages() { // const cutoffTime = Date.now() - (24 * 60 * 60 * 1000); // 24 hours const cutoffTime = Date.now(); // 24 hours let cleanedCount = 0; if (!this.imageDB) { // Clean memory cache for (const [key, value] of this._memoryCache.entries()) { if (key.startsWith('pending_image_') && value.timestamp < cutoffTime) { this._memoryCache.delete(key); cleanedCount++; } } return cleanedCount; } try { const transaction = this.imageDB.transaction([this.imageStoreName], 'readwrite'); const store = transaction.objectStore(this.imageStoreName); const index = store.index('timestamp'); const range = IDBKeyRange.upperBound(cutoffTime); const request = index.openCursor(range); await new Promise((resolve, reject) => { request.onsuccess = (event) => { const cursor = event.target.result; if (cursor) { cursor.delete(); cleanedCount++; cursor.continue(); } else { resolve(); } }; request.onerror = () => reject(request.error); }); } catch (error) { console.error('Failed to cleanup old pending images:', error); } return cleanedCount; } /** * Generate a cache key from URL and fetch options * * @param {string} url - The fetch URL * @param {object} options - The fetch options * @returns {string} - The cache key */ generateCacheKey(url, options = {}) { let body = ''; if (options.method === 'POST' && options.body) { try { body = typeof options.body === 'string' ? JSON.stringify(JSON.parse(options.body)) : JSON.stringify(options.body); } catch (e) { body = String(options.body); } } // For GET requests, use URL as is if (!options.method || options.method === 'GET') { return url; } // For other methods, include method and body in key return `${options.method}:${url}:${body}`; } /** * Invalidate caches affected by timestamp updates * * @returns {Promise} */ async invalidateAffectedCaches() { // Get all content types that need invalidation const contentToInvalidate = []; for (const [content, timestamp] of Object.entries(this.cachedContent)) { // Skip if no timestamp or invalid value if (!timestamp) continue; // Compare with local timestamp if (this.cacheUpdated(content)) { contentToInvalidate.push(content); } } // If no invalidations needed, return early if (contentToInvalidate.length === 0) return; // Clear cache for affected content types await this.clearByContent(contentToInvalidate); console.log(`Invalidated caches for content types: ${contentToInvalidate.join(', ')}`); } /** * Clear all cache entries for a specific content type * @param {string|array} content - Content type(s) to clear * @returns {Promise} */ async clearByContent(content) { // Convert to array if string is passed const typesToClear = Array.isArray(content) ? content : [content]; if (this.cacheAvailable) { try { const cache = await caches.open(this.options.namespace); const keys = await cache.keys(); for (const request of keys) { try { const response = await cache.match(request); const item = await response.json(); // Check if this item belongs to a content type we need to clear if (item.content && typesToClear.includes(item.content)) { await cache.delete(request); } } catch (e) { // Skip if we can't parse the item } } } catch (error) { console.warn('Error clearing browser cache by content type:', error); } } else { // Clear localStorage by content type try { for (let i = localStorage.length - 1; i >= 0; i--) { const key = localStorage.key(i); if (key && key.startsWith(this.options.namespace)) { try { const item = JSON.parse(localStorage.getItem(key)); if (item.content && typesToClear.includes(item.content)) { localStorage.removeItem(key); } } catch (e) { // Skip invalid items } } } } catch (error) { console.warn('Error clearing localStorage by content type:', error); } } // Also clear from memory cache for these content types for (const [key, item] of this._memoryCache.entries()) { if (item.content && typesToClear.includes(item.content)) { this._memoryCache.delete(key); } } console.log(`Cache cleared for content types: ${typesToClear.join(', ')}`); } /** * Clean expired items from cache * * @returns {Promise} */ async cleanExpired() { const now = Date.now(); const maxAge = this.options.defaultTTL; if (this.cacheAvailable) { try { const cache = await caches.open(this.options.namespace); const keys = await cache.keys(); for (const request of keys) { const response = await cache.match(request); try { const cacheItem = await response.json(); if (now - cacheItem.timestamp > maxAge) { await cache.delete(request); } } catch (e) { // If we can't parse it, just leave it alone } } } catch (error) { console.warn('Error cleaning browser cache:', error); } } else { // Clean localStorage try { for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key && key.startsWith(this.options.namespace)) { try { const item = JSON.parse(localStorage.getItem(key)); if (now - item.timestamp > maxAge) { localStorage.removeItem(key); } } catch (e) { // Skip invalid items } } } } catch (error) { console.warn('Error cleaning localStorage cache:', error); } } // Clean memory cache for (const [key, item] of this._memoryCache.entries()) { if (now - item.timestamp > maxAge) { this._memoryCache.delete(key); } } } /** * Clear all cache * * @returns {Promise} */ async clear() { // Clear memory cache this._memoryCache.clear(); if (this.cacheAvailable) { try { await caches.delete(this.options.namespace); } catch (error) { console.warn('Error clearing browser cache:', error); } } // Also clear localStorage in case we've used it as fallback this.clearLocalStorage(); } /** * Clear just localStorage cache */ clearLocalStorage() { try { for (let i = localStorage.length - 1; i >= 0; i--) { const key = localStorage.key(i); if (key && key.startsWith(this.options.namespace)) { localStorage.removeItem(key); } } } catch (error) { console.warn('Error clearing localStorage cache:', error); } } /** * Clean up */ destroy() { // Clear intervals if (this.headerCleanupInterval) { clearInterval(this.headerCleanupInterval); } if (this._timestampInterval) { clearInterval(this._timestampInterval); } // Force save any pending HTTP headers this.forceHttpHeadersSave(); // Clear debounce timeout if (this.headerSaveDebounce) { clearTimeout(this.headerSaveDebounce); } } } window.RequestManager = { controllers: new Map(), timeouts: new Map(), // Get or create a controller for a specific context getController(context = 'default') { if (!this.controllers.has(context)) { this.controllers.set(context, new AbortController()); } return this.controllers.get(context); }, // Set a timeout that will abort the controller after the specified time setTimeout(context = 'default', ms = 30000) { // Clear any existing timeout for this context this.clearTimeout(context); const controller = this.getController(context); const timeoutId = setTimeout(() => { controller.abort(); this.controllers.set(context, new AbortController()); // Create a new controller }, ms); this.timeouts.set(context, timeoutId); return controller.signal; }, // Clear timeout for a context clearTimeout(context = 'default') { if (this.timeouts.has(context)) { clearTimeout(this.timeouts.get(context)); this.timeouts.delete(context); } }, // Abort a specific context manually abort(context = 'default') { if (this.controllers.has(context)) { this.controllers.get(context).abort(); this.controllers.set(context, new AbortController()); } this.clearTimeout(context); } }; // Initialize global cache instance window.jvbCache = new Cache(); // Check cache effectiveness const stats = window.jvbCache.getHttpCacheStats(); console.log('HTTP Cache Stats:', stats); /** * Enhanced page lifecycle handling */ window.addEventListener('beforeunload', () => { if (window.jvbCache) { window.jvbCache.forceHttpHeadersSave(); } }); window.addEventListener('pagehide', () => { if (window.jvbCache) { window.jvbCache.forceHttpHeadersSave(); } });