/***
|
* 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<any>} - 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<void>}
|
*/
|
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<void>}
|
*/
|
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<any>} - 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<void>}
|
*/
|
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<void>}
|
*/
|
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<void>}
|
*/
|
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<void>}
|
*/
|
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;
|