/** * ExtendedDataStore - A flexible IndexedDB wrapper with HTTP caching * * Configuration-based approach for different storage needs: * - Configurable endpoint, keyPath, and indexes * - Built-in ETag and If-Modified-Since support * - Automatic DOM reference stripping * - TTL-based cache invalidation */ class DataStore { constructor(config = {}) { // Core configuration with sensible defaults this.config = { // Storage configuration name: 'default', version: 1, storeName: 'items', keyPath: 'id', indexes: [], // Array of {name, keyPath, unique} // API configuration endpoint: null, apiBase: jvbSettings.api, headers: {}, filters: {}, // Cache configuration TTL: 3600000, // 1 hour default useHttpCaching: true, // ETag and If-Modified-Since cacheKeyStrategy: 'filters', // How to generate cache keys // UI configuration showLoading: true, // Features stripDOMReferences: true, storeBlobs: false, ...config }; // Initialize base properties this.db = null; this.data = new Map(); this.cache = new Map(); this.httpHeaders = new Map(); this.subscribers = new Set(); this.currentRequest = null; this.filters = this.config.filters??{}; // Set up headers this.headers = { 'X-WP-Nonce': jvbSettings?.nonce, ...this.config.headers }; this.body = document.body; this.loading = document.querySelector('dialog.loading'); // Auto-initialize this.initDB(); // Cleanup on page unload window.addEventListener('beforeunload', () => this.destroy()); } /** * Initialize IndexedDB with configurable schema */ async initDB() { if (!('indexedDB' in window)) { console.warn('IndexedDB not supported'); return; } const dbName = `jvb_${this.config.name}_db`; const request = indexedDB.open(dbName, this.config.version); request.onupgradeneeded = (e) => { const db = e.target.result; // Create main store with configurable keyPath if (!db.objectStoreNames.contains(this.config.storeName)) { const store = db.createObjectStore(this.config.storeName, { keyPath: this.config.keyPath }); // Add configured indexes this.config.indexes.forEach(index => { store.createIndex( index.name, index.keyPath || index.name, { unique: index.unique || false } ); }); } // Cache store for HTTP responses if (this.config.endpoint && !db.objectStoreNames.contains('cache')) { const cacheStore = db.createObjectStore('cache', { keyPath: 'key' }); cacheStore.createIndex('timestamp', 'timestamp', { unique: false }); cacheStore.createIndex('endpoint', 'endpoint', { unique: false }); cacheStore.createIndex('filters', 'filters', { unique: false }); } // HTTP headers store for ETag/If-Modified-Since if (this.config.useHttpCaching && !db.objectStoreNames.contains('headers')) { db.createObjectStore('headers', { keyPath: 'key' }); } if (this.config.storeBlobs && !db.objectStoreNames.contains('blobs')) { db.createObjectStore('blobs', { keyPath: 'uploadId' }); } // Call optional schema extension if (this.config.onUpgrade) { this.config.onUpgrade(db, e.oldVersion, e.newVersion); } }; request.onsuccess = (e) => { this.db = e.target.result; this.loadFromDB(); }; request.onerror = (e) => { console.error(`IndexedDB error for ${dbName}:`, e); if (this.config.onError) { this.config.onError(e); } }; } /** * Load all data from IndexedDB */ async loadFromDB() { if (!this.db) return; const loadPromises = [ this.loadData() ]; if (this.config.endpoint) { loadPromises.push(this.loadCache()); } if (this.config.useHttpCaching) { loadPromises.push(this.loadHeaders()); } try { await Promise.all(loadPromises); this.notify('data-loaded', { count: this.data.size, store: this.config.storeName }); } catch (error) { console.error('Error loading from DB:', error); } } /** * Load main data from IndexedDB */ async loadData() { if (!this.db) return; return new Promise((resolve, reject) => { const tx = this.db.transaction([this.config.storeName], 'readonly'); const store = tx.objectStore(this.config.storeName); const request = store.getAll(); request.onsuccess = (e) => { e.target.result.forEach(item => { // Strip DOM references if needed const cleaned = this.config.stripDOMReferences ? this.stripDOMReferences(item) : item; const key = this.getItemKey(cleaned); this.data.set(key, cleaned); }); resolve(); }; request.onerror = (e) => reject(e); }); } /** * Strip DOM references from an object (recursive) */ stripDOMReferences(obj) { if (!obj || typeof obj !== 'object') return obj; // Handle arrays if (Array.isArray(obj)) { return obj.map(item => this.stripDOMReferences(item)); } // Handle objects const cleaned = {}; for (const [key, value] of Object.entries(obj)) { // Skip DOM-related properties if (this.isDOMReference(key, value)) { continue; } // Handle Set/Map collections if (value instanceof Set) { cleaned[key] = Array.from(value); } else if (value instanceof Map) { cleaned[key] = Object.fromEntries(value); } else if (typeof value === 'object' && value !== null) { cleaned[key] = this.stripDOMReferences(value); } else { cleaned[key] = value; } } return cleaned; } /** * Check if a property is a DOM reference */ isDOMReference(key, value) { // Check value types if (value instanceof HTMLElement || value instanceof NodeList || value instanceof HTMLCollection || (value && value.nodeType !== undefined)) { return true; } // Check key names const domKeys = ['element', 'el', 'dom', 'node', 'ui', 'container', 'wrapper']; if (domKeys.some(k => key.toLowerCase().includes(k))) { return true; } return false; } /** * Get the key for an item based on configured keyPath */ getItemKey(item) { if (typeof this.config.keyPath === 'function') { return this.config.keyPath(item); } // Support nested keypaths like 'meta.id' const keys = this.config.keyPath.split('.'); let value = item; for (const key of keys) { value = value?.[key]; } return value; } /** * Save a single item */ async save(item) { const key = this.getItemKey(item); // Strip DOM references if configured const cleaned = this.config.stripDOMReferences ? this.stripDOMReferences(item) : item; // Store in memory this.data.set(key, cleaned); // Persist to IndexedDB await this.saveToDB(cleaned); // Notify subscribers this.notify('item-saved', { item: cleaned, key }); return cleaned; } /** * Save item to IndexedDB */ async saveToDB(item) { if (!this.db) return; return new Promise((resolve, reject) => { const tx = this.db.transaction([this.config.storeName], 'readwrite'); const store = tx.objectStore(this.config.storeName); const request = store.put(item); request.onsuccess = () => resolve(); request.onerror = (e) => reject(e); }); } /** * Batch save multiple items */ async saveMany(items) { if (!this.db) return; const tx = this.db.transaction([this.config.storeName], 'readwrite'); const store = tx.objectStore(this.config.storeName); const promises = items.map(item => { const cleaned = this.config.stripDOMReferences ? this.stripDOMReferences(item) : item; const key = this.getItemKey(cleaned); this.data.set(key, cleaned); return store.put(cleaned); }); await Promise.all(promises); this.notify('items-saved', { count: items.length }); } /** * Get a single item */ get(key) { return this.data.get(key); } /** * Get all items */ getAll() { return Array.from(this.data.values()); } /** * Delete an item */ async delete(key, storeName = null) { this.data.delete(key); if (!storeName) { storeName = this.config.storeName; } if (this.db) { const tx = this.db.transaction([storeName], 'readwrite'); const store = tx.objectStore(storeName); await store.delete(key); } this.notify('item-deleted', { key }); } async saveBlob(key, blob) { if (!this.db) return; const tx = this.db.transaction(['blobs'], 'readwrite'); const store = tx.objectStore('blobs'); await store.put({ key, data: blob, type: blob.type, name: blob.name }); } async getBlob(key) { if (!this.db) return null; return new Promise(resolve => { const tx = this.db.transaction(['blobs'], 'readonly'); const request = tx.objectStore('blobs').get(key); request.onsuccess = () => resolve(request.result); request.onerror = () => resolve(null); }); } /** * Clear all data */ async clear() { this.data.clear(); this.cache.clear(); this.httpHeaders.clear(); if (this.domCache) { this.domCache.clear(); } if (this.db) { const stores = [this.config.storeName]; if (this.config.endpoint) stores.push('cache'); if (this.config.useHttpCaching) stores.push('headers'); const tx = this.db.transaction(stores, 'readwrite'); stores.forEach(storeName => { if (this.db.objectStoreNames.contains(storeName)) { tx.objectStore(storeName).clear(); } }); } this.notify('data-cleared'); } /** * Fetch data from server with HTTP caching */ async fetch(options = {}) { if (!this.config.endpoint) { throw new Error('No endpoint configured for fetch'); } const { filters = this.filters, headers = {}, } = options; if (this.config.showLoading) { this.setLoading(true); } const cacheKey = this.generateCacheKey(filters); //Check Cached data const cachedData = this.cache.get(cacheKey); if (cachedData && this.isCacheValid(cachedData)) { return cachedData.data; } // Build request headers with HTTP caching const requestHeaders = { ...this.headers, ...headers }; if (this.config.useHttpCaching) { const httpCache = this.httpHeaders.get(cacheKey); if (httpCache) { if (httpCache.etag) { requestHeaders['If-None-Match'] = httpCache.etag; } if (httpCache.lastModified) { requestHeaders['If-Modified-Since'] = httpCache.lastModified; } } } // Build URL with filters const cleanedFilters = this.cleanFilters(filters); const params = new URLSearchParams(cleanedFilters); const url = `${this.config.apiBase}${this.config.endpoint}${params.toString() ? '?' + params : ''}`; try { const response = await fetch(url, { method: 'GET', headers: requestHeaders }); // Handle 304 Not Modified if (response.status === 304 && cachedData) { // Update timestamp but keep existing data cachedData.timestamp = Date.now(); this.saveCache(cacheKey, cachedData); return cachedData.data; } if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); // Store HTTP caching headers if (this.config.useHttpCaching) { this.storeResponseHeaders(cacheKey, response); } // Cache the response const cacheEntry = { key: cacheKey, data: data, timestamp: Date.now(), endpoint: this.config.endpoint, filters: filters }; this.cache.set(cacheKey, cacheEntry); this.saveCache(cacheKey, cacheEntry); // Process and store items if (Array.isArray(data)) { await this.saveMany(data); } else if (data.items) { await this.saveMany(data.items); } return data; } catch (error) { console.error('Fetch error:', error); // Return cached data if available, even if expired if (cachedData) { console.warn('Using stale cache due to fetch error'); return cachedData.data; } throw error; } finally { if (this.config.showLoading) { this.setLoading(false); } } } cleanFilters(filters) { const cleaned = {}; Object.entries(filters).forEach(([key, value]) => { if (value !== null && value !== undefined && value !== '') { // Handle special cases based on existing patterns if (key === 'taxonomies' && typeof value === 'object') { Object.entries(value).forEach(([taxName, terms]) => { if (Array.isArray(terms) && terms.length > 0) { cleaned[`tax_${taxName}`] = terms.join(','); } else if (terms) { cleaned[`tax_${taxName}`] = terms; } }); } else if (key === 'date' && typeof value === 'object') { if (value.after) cleaned.after = value.after; if (value.before) cleaned.before = value.before; } else { cleaned[key] = value; } } }); return cleaned; } /** * Generate cache key from filters */ generateCacheKey(filters) { if (this.config.cacheKeyStrategy === 'custom' && this.config.generateCacheKey) { return this.config.generateCacheKey(filters); } // Default strategy: sort keys and create string const sorted = Object.keys(filters) .sort() .reduce((acc, key) => { acc[key] = filters[key]; return acc; }, {}); return JSON.stringify(sorted); } setFilter(key, value) { if (!this.filters) { this.filters = {}; } const oldValue = this.filters[key]; if (value === '' || value === null || value === undefined) { delete this.filters[key]; } else { this.filters[key] = value; } this.notify('filters-changed', { filters: this.filters, changed: { key, oldValue, newValue: value } }); // Auto-fetch if endpoint is configured if (this.config.endpoint) { this.fetch(); } } /** * Remove a filter */ removeFilter(key) { const oldValue = this.filters[key]; if (oldValue !== undefined) { delete this.filters[key]; this.notify('filters-changed', { filters: this.filters, removed: { key, oldValue } }); // Auto-fetch if endpoint is configured if (this.config.endpoint) { this.fetch(); } } } /** * Clear all filters */ clearFilters() { const oldFilters = { ...this.filters }; //Restore baseline filters this.filters = this.config.filters; this.notify('filters-cleared', { oldFilters, filters: this.filters }); // Auto-fetch if endpoint is configured if (this.config.endpoint) { this.fetch(); } } /** * Set multiple filters at once */ setFilters(filters) { this.filters = { ...this.filters, ...filters }; if (this.config.autoFetch !== false) { return this.fetch(this.filters); } } /** * Check if cache entry is still valid */ isCacheValid(cacheEntry) { if (!cacheEntry || !cacheEntry.timestamp) return false; const age = Date.now() - cacheEntry.timestamp; return age < this.config.TTL; } /** * Store HTTP response headers for caching */ storeResponseHeaders(key, response) { const headers = { key, etag: response.headers.get('ETag'), lastModified: response.headers.get('Last-Modified'), timestamp: Date.now() }; this.httpHeaders.set(key, headers); if (this.db) { const tx = this.db.transaction(['headers'], 'readwrite'); const store = tx.objectStore('headers'); store.put(headers); } } /** * Save cache entry to IndexedDB */ async saveCache(key, data) { if (!this.db) return; const tx = this.db.transaction(['cache'], 'readwrite'); const store = tx.objectStore('cache'); await store.put(data); } /** * Load cache from IndexedDB */ async loadCache() { if (!this.db) return; return new Promise((resolve) => { const tx = this.db.transaction(['cache'], 'readonly'); const store = tx.objectStore('cache'); const request = store.getAll(); request.onsuccess = (e) => { e.target.result.forEach(item => { if (this.isCacheValid(item)) { this.cache.set(item.key, item); } }); resolve(); }; }); } /** * Load HTTP headers from IndexedDB */ async loadHeaders() { if (!this.db) return; return new Promise((resolve) => { const tx = this.db.transaction(['headers'], 'readonly'); const store = tx.objectStore('headers'); const request = store.getAll(); request.onsuccess = (e) => { e.target.result.forEach(header => { this.httpHeaders.set(header.key, header); }); resolve(); }; }); } /** * Subscribe to store events */ subscribe(callback) { this.subscribers.add(callback); return () => this.subscribers.delete(callback); } /** * Notify subscribers of events */ notify(event, data = {}) { this.subscribers.forEach(callback => { try { callback(event, data); } catch (error) { console.error('Subscriber error:', error); } }); } /** * Query items using an index */ async query(indexName, value) { if (!this.db) return []; return new Promise((resolve, reject) => { const tx = this.db.transaction([this.config.storeName], 'readonly'); const store = tx.objectStore(this.config.storeName); if (!store.indexNames.contains(indexName)) { reject(new Error(`Index ${indexName} does not exist`)); return; } const index = store.index(indexName); const request = value !== undefined ? index.getAll(value) : index.getAll(); request.onsuccess = (e) => { const results = e.target.result.map(item => { return this.config.stripDOMReferences ? this.stripDOMReferences(item) : item; }); resolve(results); }; request.onerror = (e) => reject(e); }); } /** * Count items in store */ async count() { if (!this.db) return this.data.size; return new Promise((resolve, reject) => { const tx = this.db.transaction([this.config.storeName], 'readonly'); const store = tx.objectStore(this.config.storeName); const request = store.count(); request.onsuccess = (e) => resolve(e.target.result); request.onerror = (e) => reject(e); }); } setLoading(on) { this.body.classList.toggle('loading', on); if (on) { this.loading.showModal(); } else { this.loading.close(); } } /** * Cleanup and destroy */ destroy() { if (this.currentRequest) { this.currentRequest.abort(); } this.subscribers.clear(); this.data.clear(); this.cache.clear(); this.httpHeaders.clear(); if (this.db) { this.db.close(); this.db = null; } } clearCache() { this.cache.clear(); if (this.db) { const tx = this.db.transaction(['cache'], 'readwrite'); const store = tx.objectStore('cache'); store.clear(); } this.notify('cache-cleared'); } } // Export for use window.jvbStore = DataStore;