/** * 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 * * All notifications: * this.store.subscribe((event, data) => { switch (event) { case 'data-loaded': break; case 'item-saved': break; case 'items-saved': break; case 'item-deleted': break; case 'data-cleared': break; case 'filters-changed': break; case 'filters-cleared': break; case 'cache-cleared': break; } }); */ 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, saveToServer: false, apiBase: jvbSettings.api, headers: {}, filters: {}, required: null, //any required filters before fetching icon: null, getBlobs: null, // 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, delayFetch: false, ...config }; // Initialize base properties this.db = null; this.data = new Map(); this.cache = new Map(); this.isFetching = false; this.pendingFetch = null; 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'); this._initialized = false; // Cleanup on page unload window.addEventListener('beforeunload', () => this.destroy()); } async init() { if (this._initialized) return; await this.initDB(); this._initialized = true; } /** * 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 = async (e) => { this.db = e.target.result; // Load in background without blocking this.loadInBackground(); this.notify('db-init'); // Only fetch if explicitly needed if (this.config.endpoint && !this.config.delayFetch) { requestIdleCallback(() => this.fetch(), { timeout: 2000 }); } }; request.onerror = (e) => { console.error(`IndexedDB error for ${dbName}:`, e); if (this.config.onError) { this.config.onError(e); } }; } loadInBackground() { // Non-blocking background load Promise.all([ this.loadFromDB(), this.loadCache(), this.loadHeaders() ]).then(() => { this.notify('data-ready'); }).catch(console.error); } /** * Load all data from IndexedDB */ async loadFromDB() { if (!this.db) return; return new Promise(async (resolve, reject) => { const tx = this.db.transaction([this.config.storeName], 'readonly'); const store = tx.objectStore(this.config.storeName); const request = store.getAll(); request.onsuccess = async (e) => { const items = e.target.result; console.log('fetched from cache'); for (const item of items) { if (item.data?._isFormData && this.config.getBlobs) { item.data = await this.objectToFormData(item.data); } const key = this.getItemKey(item); this.data.set(key, item); } this.notify('data-loaded', { count: items.length }); resolve(items); }; request.onerror = (e) => reject(e); }); } /** * 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 => { const key = this.getItemKey(item); this.data.set(key, item); }); 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 - use exact match or word boundaries const domKeys = ['element', 'el', 'dom', 'node', 'ui', 'container', 'wrapper']; const lowerKey = key.toLowerCase(); // Only match if it's the exact key OR starts/ends with the pattern if (domKeys.includes(lowerKey) || domKeys.some(k => lowerKey === k || lowerKey.startsWith(k + '_') || lowerKey.endsWith('_' + 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 */ /** * Save a single item */ async save(item) { const key = this.getItemKey(item); // Keep ORIGINAL item in memory (with FormData intact) this.data.set(key, item); // ← Store original // Create cleaned version ONLY for IndexedDB let cleaned = { ...item }; if (cleaned.data instanceof FormData) { cleaned.data = this.formDataToObject(cleaned.data); } if (this.config.stripDOMReferences) { cleaned = this.stripDOMReferences(cleaned); } // Persist cleaned version to IndexedDB await this.saveToDB(cleaned); if(this.config.saveToServer && this.config.endpoint){ this.saveToServer(item); } this.notify('item-saved', { item: cleaned, key }); return cleaned; } /** * Convert FormData to plain object for storage */ formDataToObject(formData) { const obj = { _isFormData: true, // Flag to reconstruct later entries: {} }; for (const [key, value] of formData.entries()) { // Skip File/Blob objects - they're stored separately if (value instanceof File || value instanceof Blob) { continue; } // Handle multiple values for same key if (obj.entries[key]) { if (!Array.isArray(obj.entries[key])) { obj.entries[key] = [obj.entries[key]]; } obj.entries[key].push(value); } else { obj.entries[key] = value; } } return obj; } /** * Convert stored object back to FormData */ async objectToFormData(obj) { if (!obj._isFormData) return obj; const formData = new FormData(); for (const [key, value] of Object.entries(obj.entries)) { if (Array.isArray(value)) { value.forEach(v => formData.append(key, v)); } else { formData.append(key, value); } } // Restore files from external blob store (UploadManager) if (this.config.getBlobs && obj.entries.upload_ids) { const uploadIds = JSON.parse(obj.entries.upload_ids); const blobs = await this.config.getBlobs(uploadIds); // ← Await here for (const blobData of blobs) { if (blobData) { const file = new File( [blobData.data], blobData.name, { type: blobData.type, lastModified: blobData.lastModified } ); formData.append('files[]', file); } } } return formData; } /** * 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); // ← Returns original with FormData } /** * 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({ uploadId: key, // Match keyPath data: blob, type: blob.type, name: blob.name, lastModified: blob.lastModified || Date.now() }); } 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'); } await this.init(); // Lazy init const { filters = this.filters, headers = {}, } = options; if (this.config.required && this.filters[this.config.required] === ''){ console.log(this.config.storeName+ ': Not fetch as we don\'t have the required items'); return; } // PREVENT CONCURRENT FETCHES FOR SAME DATA const cacheKey = this.generateCacheKey(filters); // If already fetching this exact query, return a promise that resolves when done if (this.isFetching && this.currentCacheKey === cacheKey) { return new Promise((resolve) => { // Store multiple waiting promises if needed if (!this.pendingFetches) { this.pendingFetches = []; } this.pendingFetches.push(resolve); }); } this.isFetching = true; this.currentCacheKey = cacheKey; let fetchResult = null; // Capture result for pending fetches if (this.config.showLoading) { this.setLoading(true); } //Check Cached data const cachedData = this.cache.get(cacheKey); if (cachedData && this.isCacheValid(cachedData)) { this.isFetching = false; if (this.config.showLoading) { this.setLoading(false); } this.notify('data-loaded', cachedData.data); 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) { console.log('304 response'); // Update timestamp but keep existing data cachedData.timestamp = Date.now(); cachedData.fromCache = true; cachedData.isError = false; this.saveCache(cacheKey, cachedData); this.lastResponse = cachedData; this.notify('data-loaded', cachedData); fetchResult = cachedData.data; return cachedData.data; } if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); //Store full response for accesss to metadata, like stats this.lastResponse = data; // Store HTTP caching headers if (this.config.useHttpCaching) { this.storeResponseHeaders(cacheKey, response); } // Cache the response const cacheEntry = { key: cacheKey, items: data.items.map(item => item.id), total: data.total, maxPages: data['total_pages'], timestamp: Date.now(), endpoint: this.config.endpoint, filters: filters }; this.cache.set(cacheKey, cacheEntry); this.saveCache(cacheKey, cacheEntry); let items = (Array.isArray(data)) ? data : data.items; await this.saveMany(items); this.notify('data-loaded', { data: { items: items, ...data }, count: items.length, filters: filters, fromCache: false, isError: false }); fetchResult = data; 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'); cachedData.isError = true; this.notify('data-loaded', cachedData); fetchResult = cachedData.data; return cachedData.data; } throw error; } finally { if (this.config.showLoading) { this.setLoading(false); } this.isFetching = false; this.currentCacheKey = null; // Resolve any pending fetches that were waiting if (this.pendingFetches && this.pendingFetches.length > 0) { this.pendingFetches.forEach(resolve => resolve(fetchResult)); this.pendingFetches = []; } } } /** * Fetch data from server with HTTP caching */ async saveToServer(item) { if (!this.config.saveToServer || !jvbSettings.currentUser) { return; } if (!this.config.endpoint && this.config.saveToServer) { throw new Error('No endpoint configured for saving to server'); } let requestBody; let headers = this.config.headers; headers['X-WP-Nonce'] = jvbSettings.nonce; if (item instanceof FormData) { item.append('user', jvbSettings.currentUser); requestBody = item; // console.log('Sending formData: '); // for (const pair of requestBody.entries()) { // console.log(pair[0], pair[1]); // } } else { requestBody = JSON.stringify({ ...item, user: jvbSettings.currentUser }); // console.log('Sending data: ', { // ...operation.data, // id: operation.id, // user: this.user // }); headers['Content-Type'] = 'application/json'; } const response = await fetch( `${this.config.apiBase}${this.config.endpoint}`, { method: 'POST', headers: headers, body: requestBody } ); const result = await response.json(); this.notify( 'saved-to-server', { success: result.ok && result.success } ); } 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 (oldValue === value) { return; }else 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 !== null) { 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 */ async setFilters(filters) { const hasChanges = Object.keys(filters).some( key => this.filters[key] !== filters[key] ); if (!hasChanges) { return; } this.filters = { ...this.filters, ...filters }; this.notify('filters-changed', { filters: this.filters, changed: filters, }); // Only fetch if endpoint configured if (this.config.endpoint) { this.fetch(); } } getFiltered() { const cacheKey = this.generateCacheKey(this.filters); const cacheEntry = this.cache.get(cacheKey); if (cacheEntry && cacheEntry.items) { return cacheEntry.items.reduce((acc, id) => { const item = this.data.get(id); if (item) acc.push(item); return acc; }, []); } return Array.from(this.data.values()); } /** * 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 && this.db.objectStoreNames.contains('headers')) { const tx = this.db.transaction(['headers'], 'readwrite'); const store = tx.objectStore('headers'); store.put(headers); } } /** * Clear HTTP cache headers for a specific cache key or all */ clearHttpHeaders(cacheKey = null) { if (cacheKey) { this.httpHeaders.delete(cacheKey); if (this.db && this.db.objectStoreNames.contains('headers')) { const tx = this.db.transaction(['headers'], 'readwrite'); const store = tx.objectStore('headers'); store.delete(cacheKey); } } else { // Clear all this.httpHeaders.clear(); if (this.db && this.db.objectStoreNames.contains('headers')) { const tx = this.db.transaction(['headers'], 'readwrite'); const store = tx.objectStore('headers'); store.clear(); } } } /** * Save cache entry to IndexedDB */ async saveCache(key, data) { if (!this.db || !this.db.objectStoreNames.contains('cache')) return; const tx = this.db.transaction(['cache'], 'readwrite'); const store = tx.objectStore('cache'); await store.put(data); } getCurrentRequest() { return this.lastResponse; } /** * 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); } }); } /** * Check if store has items matching a specific filter * @param {string} filterName - The filter to check * @param {*} filterValue - The value to match * @returns {boolean} */ hasItemsForFilter(filterName, filterValue) { if (!this.data || this.data.size === 0) return false; return Array.from(this.data.values()).some(item => { return item[filterName] === filterValue; }); } /** * 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;