/** * Handles GET Requests, storing responses by a key of filters, set with setFilter method * Stores: * - Items: the individual item data, mapped by postID/termID * - Cache: the cacheKey generated by filters, and the results in the value * - httpHeaders: If Modified Since tracking * - domCache: rendered DOM elements, to reduce on-page rendering */ class DataStore { constructor(config = {}) { this.config = { name: 'default', endpoint: false, apiBase: jvbSettings.api, TTL: 3600000, // 1 hour default showLoading: true, headers: {}, filters: {}, ...config }; if (!this.config.endpoint) { console.warn('No endpoint set. Only saving locally'); } this.body = document.body; this.loading = document.querySelector('dialog.loading'); this.headers = { 'X-WP-Nonce': jvbSettings.nonce, ...this.config.headers }; // Data stores this.items = new Map(); this.cache = new Map(); //TODO: call this resultsCache this.httpHeaders = new Map(); this.domCache = new Map(); this.forms = new Map(); // State management this.filters = config.filters ?? {}; this.subscribers = new Set(); this.db = null; this.currentRequest = null; // Server Timestamps - needed? this.cachedContent = JSON.parse(cacheJVB.cache) || {}; this.lastTimestampUpdate = Date.now(); this.initDB(); document.addEventListener('beforeUnload', () =>this.destroy()); } async initDB() { if (!('indexedDB' in window)) return; const request = indexedDB.open(`jvb_${this.config.name}_db`, 1); request.onupgradeneeded = (e) => { const db = e.target.result; // Items store if (!db.objectStoreNames.contains('items')) { db.createObjectStore('items', { keyPath: 'id' }); } // DOM cache for rendered elements if (!db.objectStoreNames.contains('dom')) { db.createObjectStore('dom', { keyPath: 'id' }); } if (!db.objectStoreNames.contains('forms')) { let forms = db.createObjectStore('forms', { keyPath: 'formId', }); forms.createIndex('status', 'status', {unique:false}); forms.createIndex('operationId', 'operationId', {unique:false}); forms.createIndex('timestamp', 'timestamp', {unique:false}); } // Cache store for GET requests with endpoint index if (!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 if (!db.objectStoreNames.contains('headers')) { db.createObjectStore('headers', { keyPath: 'key' }); } }; request.onsuccess = (e) => { this.db = e.target.result; this.loadFromDB(); }; request.onerror = (e) => { console.error('IndexedDB error:', e); }; } async loadFromDB() { if (!this.db) return; try { await Promise.all([ this.loadItems(), this.loadCache(), this.loadHeaders(), this.loadDOMCache(), this.loadForms() ]); } catch (error) { console.error('Error loading from DB:', error); } } async loadItems() { if (!this.db) return; return new Promise((resolve) => { const tx = this.db.transaction(['items'], 'readonly'); const store = tx.objectStore('items'); const request = store.getAll(); request.onsuccess = (e) => { e.target.result.forEach(item => { this.items.set(item.id, item); }); this.notify('items-loaded', { items: Array.from(this.items.values()) }); resolve(); }; }); } 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(); }; }); } 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(); }; }); } async loadDOMCache() { if (!this.db) return; return new Promise((resolve) => { const tx = this.db.transaction(['dom'], 'readonly'); const store = tx.objectStore('dom'); const request = store.getAll(); request.onsuccess = (e) => { e.target.result.forEach(domEntry => { // Convert stored HTML back to DOM elements const reconstructed = {}; Object.entries(domEntry.views).forEach(([viewName, html]) => { const temp = document.createElement('div'); temp.innerHTML = html; reconstructed[viewName] = temp.firstElementChild; }); this.domCache.set(domEntry.id, reconstructed); }); resolve(); }; }); } async loadForms() { if (!this.db) return; return new Promise((resolve) => { const tx = this.db.transaction(['forms'], 'readonly'); const store = tx.objectStore('forms'); const request = store.getAll(); request.onsuccess = (e) => { e.target.result.forEach(form => { this.forms.set(form.key, form); }); resolve(); }; }); } setLoading(on) { this.body.classList.toggle('loading', on); if (on) { this.loading.showModal(); } else { this.loading.close(); } } /** * Main fetch method with caching and conditional requests */ async fetch(endpoint = null, options = {}) { const { filters = this.filters, headers = {}, } = options; if (this.config.showLoading) { this.setLoading(true); } // Use provided endpoint or config endpoint const apiEndpoint = endpoint || this.config.endpoint; if (!apiEndpoint) { throw new Error('No endpoint specified'); } // Generate cache key from endpoint and filters const cacheKey = this.generateCacheKey(apiEndpoint, filters); const cleanedFilters = this.cleanFilters(filters); // Build request URL const params = new URLSearchParams(cleanedFilters); const url = `${this.config.apiBase}${apiEndpoint}${params.toString() ? '?' + params : ''}`; // Prepare headers with conditional requests const requestHeaders = { ...this.headers, ...headers }; // Add conditional headers from stored data const headerKey = this.generateHeaderKey(url); const storedHeaders = this.httpHeaders.get(headerKey); const cachedData = this.cache.get(cacheKey); if (storedHeaders && cachedData) { if (storedHeaders.etag) { requestHeaders['If-None-Match'] = storedHeaders.etag; } if (storedHeaders.lastModified) { requestHeaders['If-Modified-Since'] = storedHeaders.lastModified; } } try { const response = await fetch(url, { method: 'GET', headers: requestHeaders }); // Handle 304 Not Modified - return cached data if (response.status === 304) { console.debug(`304 Not Modified for ${url}`); if (cachedData) { // Update timestamp but keep data cachedData.timestamp = Date.now(); this.cache.set(cacheKey, cachedData); await this.saveCacheToDB(cacheKey, cachedData); // Store current request info this.currentRequest = { filters: cleanedFilters, data: cachedData.data, cached: true }; //TODO: should this be items-loaded? this.notify('data-cached', { data: cachedData.data, filters: cleanedFilters, cached: true }); return cachedData.data; } } if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } // Store response headers for future conditional requests this.storeResponseHeaders(headerKey, response); const data = await response.json(); // Cache the response const cacheEntry = { key: cacheKey, endpoint: apiEndpoint, data: data, timestamp: Date.now(), filters: cleanedFilters }; this.cache.set(cacheKey, cacheEntry); await this.saveCacheToDB(cacheKey, cacheEntry); // Update items if data contains them if (data.items && this.config.endpoint === apiEndpoint) { this.updateItems(data.items); } // Store current request info this.currentRequest = { filters: cleanedFilters, data: data, cached: false }; this.notify('data-fetched', { endpoint: apiEndpoint, data: data, filters: cleanedFilters }); return data; } catch (error) { console.error('Fetch error:', error); // Try to return stale cache on error if (cachedData) { console.warn('Returning stale cache due to fetch error'); this.currentRequest = { filters: cleanedFilters, data: cachedData.data, cached: true, stale: true }; this.notify('stale-cache-used', { data: cachedData.data, filters: cleanedFilters }); return cachedData.data; } this.notify('fetch-error', { error, filters: cleanedFilters }); throw error; } finally { if (this.config.showLoading) { this.setLoading(false); } } } /** * Update items in local store */ updateItems(items) { this.items.clear(); items.forEach(item => { this.items.set(item.id, item); }); this.saveItemsToDB(); this.notify('items-updated', { items }); } /** * Get current request data and state */ getCurrentRequest() { return this.currentRequest; } /** * Get a specific item by ID */ getItem(id) { let check = parseInt(id); id = isNaN(check) ? id : check; const item = this.items.get(id); return item ? this.unserializeData(item) : null; } setItem(id, data, mergeExisting = true) { if (mergeExisting && this.items.has(id)) { let existing = this.getItem(id); // Get unserialized version data = window.deepMerge(existing, data); } const serialized = this.serializeData(data); this.items.set(id, serialized); // Store serialized version this.saveItemsToDB(); this.notify('item-stored', data); // Notify with original data return data; } hasUnrecoverableFiles(data) { if (!data || typeof data !== 'object') return false; if (data._wasFile || data._wasBlob) return true; if (Array.isArray(data)) { return data.some(item => this.hasUnrecoverableFiles(item)); } if (data instanceof FormData) { for (const [key, value] of data.entries()) { if (value instanceof File || value instanceof Blob) return true; } return false; } return Object.values(data).some(value => this.hasUnrecoverableFiles(value)); } serializeFormData(formData) { const obj = {}; for (const [key, value] of formData.entries()) { // Handle file metadata (can't store actual file) if (value instanceof File) { continue; } // Check if key already exists (for multiple values) if (key in obj) { // Convert to array if not already if (!Array.isArray(obj[key])) { obj[key] = [obj[key]]; } obj[key].push(value); } else { obj[key] = value; } } return obj; } serializeData(data) { if (!data) return null; if (data instanceof HTMLElement) { return null; } if (typeof data !== 'object') return data; if (data === null) return null; if (data instanceof FormData) { return { _type: 'FormData', ... this.serializeFormData(data) }; } // Handle Arrays if (Array.isArray(data)) { return data.map(item => this.serializeData(item)); } // Handle Date objects if (data instanceof Date) { return { _type: 'Date', value: data.toISOString() }; } // Handle plain objects const output = {}; for (const [key, value] of Object.entries(data)) { output[key] = this.serializeData(value); } return output; } unserializeData(data) { if (!data || typeof data !== 'object') return data; if (data === null) return null; // Check for special types if (data._type) { switch (data._type) { case 'FormData': return this.unserializeFormData(data); case 'File': // Can't reconstruct File, return metadata with warning flag return { _wasFile: true, _fileMetadata: data, name: data.name, type: data.type, size: data.size }; case 'Blob': // Can't reconstruct Blob return { _wasBlob: true, _blobMetadata: data, type: data.type, size: data.size }; case 'Date': return new Date(data.value); } } // Handle Arrays if (Array.isArray(data)) { return data.map(item => this.unserializeData(item)); } // Handle plain objects const output = {}; for (const [key, value] of Object.entries(data)) { // Fixed: 'of' not 'in' output[key] = this.unserializeData(value); } return output; } unserializeFormData(data) { const formData = new FormData(); for (const [key, value] of Object.entries(data)) { if (Array.isArray(value)) { value.forEach(item => { if (item?._isFile) { console.warn(`Cannot restore file "${item.name}" from stored data`); // Optionally append metadata as JSON string for reference formData.append(key + '_was_file', JSON.stringify(item)); } else { formData.append(key, item); } }); } else if (value?._isFile) { console.warn(`Cannot restore file "${value.name}" from stored data`); // Optionally append metadata as JSON string for reference formData.append(key + '_was_file', JSON.stringify(value)); } else if (value !== null && value !== undefined) { formData.append(key, value); } } return formData; } clearItem(key) { this.items.delete(key); if (this.db) { const tx = this.db.transaction(['items'], 'readwrite'); const store = tx.objectStore('items'); store.delete(key); } } /** * Filter helpers */ 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; } setFilter(key, value) { 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(); } } /** * Cache management */ generateCacheKey(endpoint, filters) { const sorted = Object.keys(filters).sort().reduce((obj, key) => { obj[key] = filters[key]; return obj; }, {}); return `${endpoint}_${JSON.stringify(sorted)}`; } generateHeaderKey(url) { return `headers_${url}`; } isCacheValid(cacheEntry, maxAge = this.config.TTL) { if (!cacheEntry || !cacheEntry.timestamp) return false; return (Date.now() - cacheEntry.timestamp) < maxAge; } 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); this.saveHeadersToDB(key, headers); } 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'); } invalidateCache(pattern) { const keysToDelete = []; this.cache.forEach((value, key) => { if (typeof pattern === 'string' && key.includes(pattern)) { keysToDelete.push(key); } else if (pattern instanceof RegExp && pattern.test(key)) { keysToDelete.push(key); } }); keysToDelete.forEach(key => { this.cache.delete(key); if (this.db) { const tx = this.db.transaction(['cache'], 'readwrite'); const store = tx.objectStore('cache'); store.delete(key); } }); this.notify('cache-invalidated', { count: keysToDelete.length }); } /** * DOM Cache Management */ /** * Store rendered DOM element for a specific item and view */ storeDOMElement(itemId, viewName, element) { if (!this.domCache.has(itemId)) { this.domCache.set(itemId, {}); } const itemCache = this.domCache.get(itemId); itemCache[viewName] = element.cloneNode(true); this.domCache.set(itemId, itemCache); // Save to IndexedDB this.saveDOMCacheToDB(itemId, itemCache); } /** * Retrieve cached DOM element for a specific item and view */ getDOMElement(itemId, viewName) { const itemCache = this.domCache.get(itemId); if (itemCache && itemCache[viewName]) { return itemCache[viewName].cloneNode(true); } return null; } /** * Check if DOM element exists in cache */ hasDOMElement(itemId, viewName) { const itemCache = this.domCache.get(itemId); return itemCache && itemCache[viewName]; } /** * Clear DOM cache for a specific item */ clearDOMCache(itemId) { this.domCache.delete(itemId); if (this.db) { const tx = this.db.transaction(['dom'], 'readwrite'); const store = tx.objectStore('dom'); store.delete(itemId); } } /** * Clear all DOM cache */ clearAllDOMCache() { this.domCache.clear(); if (this.db) { const tx = this.db.transaction(['dom'], 'readwrite'); const store = tx.objectStore('dom'); store.clear(); } } /** * Helper method to render or retrieve cached DOM elements */ renderOrRetrieve(item, viewName, renderFunction) { // Check cache first const cached = this.getDOMElement(item.id, viewName); if (cached) { return cached; } // Render new element const element = renderFunction(item); // Cache the rendered element this.storeDOMElement(item.id, viewName, element); return element; } /** * Database operations */ async saveItemsToDB() { if (!this.db) return; const tx = this.db.transaction(['items'], 'readwrite'); const store = tx.objectStore('items'); store.clear(); this.items.forEach(item => { if (!item._deleted) { store.put(item); } }); } async saveCacheToDB(key, data) { if (!this.db) return; const tx = this.db.transaction(['cache'], 'readwrite'); const store = tx.objectStore('cache'); store.put(data); } async saveHeadersToDB(key, headers) { if (!this.db) return; const tx = this.db.transaction(['headers'], 'readwrite'); const store = tx.objectStore('headers'); store.put(headers); } async saveDOMCacheToDB(itemId, domCache) { if (!this.db) return; // Convert DOM elements to HTML strings for storage const serialized = { id: itemId, views: {} }; Object.entries(domCache).forEach(([viewName, element]) => { if (element && element.outerHTML) { serialized.views[viewName] = element.outerHTML; } }); const tx = this.db.transaction(['dom'], 'readwrite'); const store = tx.objectStore('dom'); store.put(serialized); } async saveFormsToDB(key, form) { if (!this.db) return; const tx = this.db.transaction(['forms'], 'readwrite'); const store = tx.objectStore('forms'); store.put(form); } storeForm(key, form) { this.forms.set(key, form); this.saveFormsToDB(key, form); } getForm(key) { return this.forms.has(key) ? this.forms.get(key) : null; } getAllForms() { return this.forms; } clearForm(key) { this.forms.delete(key); if (this.db) { const tx = this.db.transaction(['forms'], 'readwrite'); const store = tx.objectStore('forms'); store.delete(key); } } clearAllForms() { this.forms.clear(); if (this.db) { const tx = this.db.transaction(['forms'], 'readwrite'); const store = tx.objectStore('dom'); store.clear(); } } /** * Event system */ subscribe(callback) { this.subscribers.add(callback); return () => this.subscribers.delete(callback); } notify(event, data) { this.subscribers.forEach(cb => cb(event, data)); } /** * Cleanup */ destroy() { if (this.db) { this.db.close(); } this.subscribers.clear(); this.items.clear(); this.cache.clear(); this.domCache.clear(); this.httpHeaders.clear(); } } window.jvbStore = DataStore;