/**
|
* 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<boolean>} 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<Object>} 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<string>} storeNames - Array of store names to clear
|
* @returns {Promise<number>} 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<any>} - 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<void>}
|
*/
|
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<any>} - 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<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);
|
}
|
|
/**
|
* 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.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<void>}
|
*/
|
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<void>}
|
*/
|
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<boolean>} 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<Object|null>} 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>} 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<boolean>} 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>} 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>} 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<void>}
|
*/
|
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<void>}
|
*/
|
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<void>}
|
*/
|
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<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);
|
}
|
}
|
|
/**
|
* 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();
|
}
|
});
|