/*** * A simpler cache, using localStorage * Mainly used to store settings locally * example: * -> dark/light mode switch * -> view mode selection * -> tab navigation direction (for table views) **/ class SimpleCache { /** * * @param {string} base * @param {object} config * @param {string} config.namespace * @param {number} config.TTL * @param {number} config.maxSize */ constructor(base, config = {}) { this.base = base; this.config = { namespace: 'jvb_cache', TTL: 3600000, maxSize: 100, ...config }; // 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(); this.subscribers = new Set(); } /** * 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; } /** * Get a setting value */ async get(key) { let cacheKey = `${this.base}_${key}`; const cachedItem = await this.getCacheItem(cacheKey); if (!cachedItem) { return null; } // Deserialize data back to original type return this.deserializeData(cachedItem.data, cachedItem.dataType); } /** * Set a setting value */ async set(key, value) { let cacheKey = `${this.base}_${key}`; // Serialize data with type preservation const serializedData = this.serializeData(value); const cacheItem = { data: serializedData.data, dataType: serializedData.type, timestamp: Date.now(), }; await this.setCacheItem(cacheKey, cacheItem); // Notify subscribers this.notify('cache-saved', { key, value }); } remove(key) { let cacheKey = `${this.base}_${key}`; //TODO: Actually remove it } 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; } } /********************************************************** CACHE **********************************************************/ /** * 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 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); } /********************************************************************* BROWSER CACHE *********************************************************************/ /** * 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.config.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.config.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.config.namespace); await cache.delete(key); } catch (error) { console.warn('Error removing from Browser Cache API:', error); } } /************************************************************************* LOCAL STORAGE *************************************************************************/ /** * 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.config.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); } } /************************************************************************ CLEANUP ************************************************************************/ /** * Clean expired items from cache * * @returns {Promise} */ async cleanExpired() { const now = Date.now(); const maxAge = this.config.TTL; 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); } } /************************** OLD **********************************/ /** * Subscribe to setting changes */ subscribe(callback) { this.subscribers.add(callback); return () => this.subscribers.delete(callback); } /** * Notify subscribers */ notify(event, data) { this.subscribers.forEach(cb => cb(event, data)); } } window.jvbCache = SimpleCache;