| | |
| | | /** |
| | | * 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 |
| | | * 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', |
| | | endpoint: false, |
| | | version: 1, |
| | | storeName: 'items', |
| | | keyPath: 'id', |
| | | indexes: [], // Array of {name, keyPath, unique} |
| | | |
| | | // API configuration |
| | | endpoint: null, |
| | | saveToServer: false, |
| | | apiBase: jvbSettings.api, |
| | | TTL: 3600000, // 1 hour default |
| | | showLoading: true, |
| | | 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 |
| | | }; |
| | | if (!this.config.endpoint) { |
| | | console.warn('No endpoint set. Only saving locally'); |
| | | } |
| | | |
| | | // 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.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()); |
| | | this._initialized = false; |
| | | // Cleanup on page unload |
| | | window.addEventListener('beforeunload', () => this.destroy()); |
| | | } |
| | | |
| | | async initDB() { |
| | | if (!('indexedDB' in window)) return; |
| | | async init() { |
| | | if (this._initialized) return; |
| | | await this.initDB(); |
| | | this._initialized = true; |
| | | } |
| | | |
| | | const request = indexedDB.open(`jvb_${this.config.name}_db`, 1); |
| | | /** |
| | | * 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; |
| | | // 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' }); |
| | | } |
| | | // Create main store with configurable keyPath |
| | | |
| | | if (!db.objectStoreNames.contains('forms')) { |
| | | let forms = db.createObjectStore('forms', { |
| | | keyPath: 'formId', |
| | | if (!db.objectStoreNames.contains(this.config.storeName)) { |
| | | const store = db.createObjectStore(this.config.storeName, { |
| | | keyPath: this.config.keyPath |
| | | }); |
| | | forms.createIndex('status', 'status', {unique:false}); |
| | | forms.createIndex('operationId', 'operationId', {unique:false}); |
| | | forms.createIndex('timestamp', 'timestamp', {unique:false}); |
| | | |
| | | // Add configured indexes |
| | | this.config.indexes.forEach(index => { |
| | | store.createIndex( |
| | | index.name, |
| | | index.keyPath || index.name, |
| | | { unique: index.unique || false } |
| | | ); |
| | | }); |
| | | } |
| | | |
| | | // Cache store for GET requests with endpoint index |
| | | if (!db.objectStoreNames.contains('cache')) { |
| | | // 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 |
| | | if (!db.objectStoreNames.contains('headers')) { |
| | | // 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) => { |
| | | request.onsuccess = async (e) => { |
| | | this.db = e.target.result; |
| | | this.loadFromDB(); |
| | | |
| | | // 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:', e); |
| | | console.error(`IndexedDB error for ${dbName}:`, e); |
| | | if (this.config.onError) { |
| | | this.config.onError(e); |
| | | } |
| | | }; |
| | | } |
| | | |
| | | async loadFromDB() { |
| | | if (!this.db) return; |
| | | |
| | | try { |
| | | await Promise.all([ |
| | | this.loadItems(), |
| | | loadInBackground() { |
| | | // Non-blocking background load |
| | | Promise.all([ |
| | | this.loadFromDB(), |
| | | 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(); |
| | | } |
| | | |
| | | this.loadHeaders() |
| | | ]).then(() => { |
| | | this.notify('data-ready'); |
| | | }).catch(console.error); |
| | | } |
| | | |
| | | /** |
| | | * Main fetch method with caching and conditional requests |
| | | * Load all data from IndexedDB |
| | | */ |
| | | async fetch(endpoint = null, options = {}) { |
| | | 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; |
| | | |
| | | // Use provided endpoint or config endpoint |
| | | const apiEndpoint = endpoint || this.config.endpoint; |
| | | if (!apiEndpoint) { |
| | | throw new Error('No endpoint specified'); |
| | | if (this.config.showLoading) { |
| | | this.setLoading(false); |
| | | } |
| | | this.notify('data-loaded', cachedData.data); |
| | | return cachedData.data; |
| | | } |
| | | |
| | | // 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 |
| | | // Build request headers with HTTP caching |
| | | 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 (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; |
| | | } |
| | | } |
| | | } |
| | | |
| | | if (storedHeaders && cachedData) { |
| | | if (storedHeaders.etag) { |
| | | requestHeaders['If-None-Match'] = storedHeaders.etag; |
| | | } |
| | | if (storedHeaders.lastModified) { |
| | | requestHeaders['If-Modified-Since'] = storedHeaders.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, { |
| | |
| | | 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 |
| | | // Handle 304 Not Modified |
| | | if (response.status === 304 && cachedData) { |
| | | console.log('304 response'); |
| | | // Update timestamp but keep existing data |
| | | cachedData.timestamp = Date.now(); |
| | | this.cache.set(cacheKey, cachedData); |
| | | await this.saveCacheToDB(cacheKey, cachedData); |
| | | cachedData.fromCache = true; |
| | | cachedData.isError = false; |
| | | this.saveCache(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 |
| | | }); |
| | | 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}`); |
| | | } |
| | | |
| | | // Store response headers for future conditional requests |
| | | this.storeResponseHeaders(headerKey, response); |
| | | |
| | | 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, |
| | | endpoint: apiEndpoint, |
| | | data: data, |
| | | items: data.items.map(item => item.id), |
| | | total: data.total, |
| | | maxPages: data['total_pages'], |
| | | timestamp: Date.now(), |
| | | filters: cleanedFilters |
| | | endpoint: this.config.endpoint, |
| | | filters: filters |
| | | }; |
| | | |
| | | this.cache.set(cacheKey, cacheEntry); |
| | | await this.saveCacheToDB(cacheKey, cacheEntry); |
| | | this.saveCache(cacheKey, cacheEntry); |
| | | |
| | | // Update items if data contains them |
| | | if (data.items && this.config.endpoint === apiEndpoint) { |
| | | this.updateItems(data.items); |
| | | } |
| | | let items = (Array.isArray(data)) ? data : data.items; |
| | | await this.saveMany(items); |
| | | |
| | | // Store current request info |
| | | this.currentRequest = { |
| | | filters: cleanedFilters, |
| | | data: data, |
| | | cached: false |
| | | }; |
| | | |
| | | this.notify('data-fetched', { |
| | | endpoint: apiEndpoint, |
| | | data: data, |
| | | filters: cleanedFilters |
| | | 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); |
| | | |
| | | // Try to return stale cache on error |
| | | // Return cached data if available, even if expired |
| | | 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 |
| | | }); |
| | | console.warn('Using stale cache due to fetch error'); |
| | | cachedData.isError = true; |
| | | this.notify('data-loaded', cachedData); |
| | | fetchResult = cachedData.data; |
| | | return cachedData.data; |
| | | } |
| | | |
| | | this.notify('fetch-error', { error, filters: cleanedFilters }); |
| | | 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 = []; |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Update items in local store |
| | | * Fetch data from server with HTTP caching |
| | | */ |
| | | updateItems(items) { |
| | | this.items.clear(); |
| | | items.forEach(item => { |
| | | this.items.set(item.id, item); |
| | | }); |
| | | this.saveItemsToDB(); |
| | | this.notify('items-updated', { items }); |
| | | 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'); |
| | | } |
| | | |
| | | /** |
| | | * Get current request data and state |
| | | */ |
| | | getCurrentRequest() { |
| | | return this.currentRequest; |
| | | } |
| | | let requestBody; |
| | | let headers = this.config.headers; |
| | | headers['X-WP-Nonce'] = jvbSettings.nonce; |
| | | if (item instanceof FormData) { |
| | | item.append('user', jvbSettings.currentUser); |
| | | requestBody = item; |
| | | |
| | | /** |
| | | * 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); |
| | | // console.log('Sending formData: '); |
| | | // for (const pair of requestBody.entries()) { |
| | | // console.log(pair[0], pair[1]); |
| | | // } |
| | | } 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); |
| | | } |
| | | requestBody = JSON.stringify({ |
| | | ...item, |
| | | user: jvbSettings.currentUser |
| | | }); |
| | | } 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); |
| | | } |
| | | // console.log('Sending data: ', { |
| | | // ...operation.data, |
| | | // id: operation.id, |
| | | // user: this.user |
| | | // }); |
| | | |
| | | headers['Content-Type'] = 'application/json'; |
| | | } |
| | | |
| | | return formData; |
| | | 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 |
| | | } |
| | | ); |
| | | } |
| | | |
| | | |
| | | 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]) => { |
| | |
| | | return cleaned; |
| | | } |
| | | |
| | | setFilter(key, value) { |
| | | const oldValue = this.filters[key]; |
| | | /** |
| | | * Generate cache key from filters |
| | | */ |
| | | generateCacheKey(filters) { |
| | | if (this.config.cacheKeyStrategy === 'custom' && this.config.generateCacheKey) { |
| | | return this.config.generateCacheKey(filters); |
| | | } |
| | | |
| | | if (value === '' || value === null || value === undefined) { |
| | | // 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; |
| | |
| | | }); |
| | | |
| | | // Auto-fetch if endpoint is configured |
| | | if (this.config.endpoint) { |
| | | if (this.config.endpoint !== null) { |
| | | this.fetch(); |
| | | } |
| | | } |
| | | |
| | | |
| | | /** |
| | | * Remove a filter |
| | | */ |
| | |
| | | } |
| | | |
| | | /** |
| | | * Cache management |
| | | * Set multiple filters at once |
| | | */ |
| | | generateCacheKey(endpoint, filters) { |
| | | const sorted = Object.keys(filters).sort().reduce((obj, key) => { |
| | | obj[key] = filters[key]; |
| | | return obj; |
| | | }, {}); |
| | | return `${endpoint}_${JSON.stringify(sorted)}`; |
| | | async setFilters(filters) { |
| | | const hasChanges = Object.keys(filters).some( |
| | | key => this.filters[key] !== filters[key] |
| | | ); |
| | | |
| | | if (!hasChanges) { |
| | | return; |
| | | } |
| | | |
| | | generateHeaderKey(url) { |
| | | return `headers_${url}`; |
| | | 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(); |
| | | } |
| | | } |
| | | |
| | | isCacheValid(cacheEntry, maxAge = this.config.TTL) { |
| | | 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; |
| | | return (Date.now() - cacheEntry.timestamp) < maxAge; |
| | | |
| | | const age = Date.now() - cacheEntry.timestamp; |
| | | return age < this.config.TTL; |
| | | } |
| | | |
| | | /** |
| | | * Store HTTP response headers for caching |
| | | */ |
| | | storeResponseHeaders(key, response) { |
| | | const headers = { |
| | | key, |
| | |
| | | }; |
| | | |
| | | this.httpHeaders.set(key, headers); |
| | | this.saveHeadersToDB(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(); |
| | |
| | | |
| | | 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(); |
| | | } |
| | | } |
| | | |
| | | // Export for use |
| | | window.jvbStore = DataStore; |