| | |
| | | * - 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 = {}) { |
| | |
| | | |
| | | // 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 |
| | |
| | | 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; |
| | |
| | | } |
| | | }; |
| | | |
| | | request.onsuccess = (e) => { |
| | | request.onsuccess = async (e) => { |
| | | this.db = e.target.result; |
| | | this.loadFromDB(); |
| | | |
| | | // Load cache and headers BEFORE fetching (only if stores exist) |
| | | const loadTasks = [this.loadFromDB()]; |
| | | |
| | | if (this.db.objectStoreNames.contains('cache')) { |
| | | loadTasks.push(this.loadCache()); |
| | | } |
| | | |
| | | if (this.config.useHttpCaching && this.db.objectStoreNames.contains('headers')) { |
| | | loadTasks.push(this.loadHeaders()); |
| | | } |
| | | |
| | | await Promise.all(loadTasks); |
| | | |
| | | this.notify('db-init'); |
| | | |
| | | // Now fetch if needed (cache might already have data) |
| | | if (this.config.endpoint) { |
| | | this.fetch(); |
| | | } |
| | | }; |
| | | |
| | | request.onerror = (e) => { |
| | |
| | | async loadFromDB() { |
| | | if (!this.db) return; |
| | | |
| | | const loadPromises = [ |
| | | this.loadData() |
| | | ]; |
| | | 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(); |
| | | |
| | | if (this.config.endpoint) { |
| | | loadPromises.push(this.loadCache()); |
| | | request.onsuccess = async (e) => { |
| | | const items = e.target.result; |
| | | |
| | | // Restore FormData for ALL items on startup |
| | | 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); |
| | | } |
| | | |
| | | if (this.config.useHttpCaching) { |
| | | loadPromises.push(this.loadHeaders()); |
| | | } |
| | | this.notify('data-loaded', { count: items.length }); |
| | | resolve(items); |
| | | }; |
| | | |
| | | try { |
| | | await Promise.all(loadPromises); |
| | | this.notify('data-loaded', { |
| | | count: this.data.size, |
| | | store: this.config.storeName |
| | | request.onerror = (e) => reject(e); |
| | | }); |
| | | } catch (error) { |
| | | console.error('Error loading from DB:', error); |
| | | } |
| | | } |
| | | |
| | | |
| | | |
| | | /** |
| | | * Load main data from IndexedDB |
| | |
| | | return true; |
| | | } |
| | | |
| | | // Check key names |
| | | // Check key names - use exact match or word boundaries |
| | | const domKeys = ['element', 'el', 'dom', 'node', 'ui', 'container', 'wrapper']; |
| | | if (domKeys.some(k => key.toLowerCase().includes(k))) { |
| | | 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; |
| | | } |
| | | |
| | |
| | | /** |
| | | * Save a single item |
| | | */ |
| | | /** |
| | | * Save a single item |
| | | */ |
| | | async save(item) { |
| | | const key = this.getItemKey(item); |
| | | |
| | | // Strip DOM references if configured |
| | | const cleaned = this.config.stripDOMReferences |
| | | ? this.stripDOMReferences(item) |
| | | : item; |
| | | // Keep ORIGINAL item in memory (with FormData intact) |
| | | this.data.set(key, item); // ← Store original |
| | | |
| | | // Store in memory |
| | | this.data.set(key, cleaned); |
| | | // Create cleaned version ONLY for IndexedDB |
| | | let cleaned = { ...item }; |
| | | if (cleaned.data instanceof FormData) { |
| | | cleaned.data = this.formDataToObject(cleaned.data); |
| | | } |
| | | |
| | | // Persist to IndexedDB |
| | | if (this.config.stripDOMReferences) { |
| | | cleaned = this.stripDOMReferences(cleaned); |
| | | } |
| | | |
| | | // Persist cleaned version to IndexedDB |
| | | await this.saveToDB(cleaned); |
| | | |
| | | // Notify subscribers |
| | | if(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) { |
| | |
| | | * Get a single item |
| | | */ |
| | | get(key) { |
| | | return this.data.get(key); |
| | | return this.data.get(key); // ← Returns original with FormData |
| | | } |
| | | |
| | | /** |
| | |
| | | |
| | | const tx = this.db.transaction(['blobs'], 'readwrite'); |
| | | const store = tx.objectStore('blobs'); |
| | | await store.put({ key, data: blob, type: blob.type, name: blob.name }); |
| | | await store.put({ |
| | | uploadId: key, // Match keyPath |
| | | data: blob, |
| | | type: blob.type, |
| | | name: blob.name, |
| | | lastModified: blob.lastModified || Date.now() |
| | | }); |
| | | } |
| | | |
| | | async getBlob(key) { |
| | |
| | | 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); |
| | | console.log('CacheKey: ', cacheKey); |
| | | |
| | | // 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); |
| | | } |
| | | |
| | | const cacheKey = this.generateCacheKey(filters); |
| | | |
| | | //Check Cached data |
| | | const cachedData = this.cache.get(cacheKey); |
| | | console.log('Cached Data: ', cachedData); |
| | | if (cachedData && this.isCacheValid(cachedData)) { |
| | | console.log('Returning cached data: '); |
| | | this.isFetching = false; |
| | | this.currentCacheKey = null; |
| | | if (this.config.showLoading) { |
| | | this.setLoading(false); |
| | | } |
| | | return cachedData.data; |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | // 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 : ''}`; |
| | |
| | | if (response.status === 304 && cachedData) { |
| | | // Update timestamp but keep existing data |
| | | cachedData.timestamp = Date.now(); |
| | | cachedData.fromCache = true; |
| | | cachedData.isError = false; |
| | | this.saveCache(cacheKey, cachedData); |
| | | console.log(this.config.storeName+' Data loaded from cache'); |
| | | this.notify('data-loaded', cachedData); |
| | | fetchResult = cachedData.data; |
| | | return cachedData.data; |
| | | } |
| | | |
| | |
| | | endpoint: this.config.endpoint, |
| | | filters: filters |
| | | }; |
| | | console.log(this.config.storeName + 'Fetched fresh from server'); |
| | | |
| | | this.cache.set(cacheKey, cacheEntry); |
| | | this.saveCache(cacheKey, cacheEntry); |
| | | |
| | | // Process and store items |
| | | if (Array.isArray(data)) { |
| | | await this.saveMany(data); |
| | | } else if (data.items) { |
| | | await this.saveMany(data.items); |
| | | } |
| | | 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) { |
| | |
| | | // 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; |
| | | } |
| | | |
| | |
| | | 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 = {}; |
| | |
| | | this.filters = {}; |
| | | } |
| | | const oldValue = this.filters[key]; |
| | | |
| | | if (value === '' || value === null || value === undefined) { |
| | | 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) { |
| | | this.fetch(); |
| | | window.debouncer.schedule( |
| | | this.config.endpoint, |
| | | this.fetch.bind(this), |
| | | 100 |
| | | ); |
| | | } |
| | | } |
| | | |
| | | |
| | | /** |
| | | * Remove a filter |
| | | */ |
| | |
| | | |
| | | // Auto-fetch if endpoint is configured |
| | | if (this.config.endpoint) { |
| | | this.fetch(); |
| | | window.debouncer.schedule( |
| | | this.config.endpoint, |
| | | this.fetch.bind(this), |
| | | 100 |
| | | ); |
| | | } |
| | | } |
| | | } |
| | |
| | | /** |
| | | * Set multiple filters at once |
| | | */ |
| | | setFilters(filters) { |
| | | async setFilters(filters) { |
| | | const hasChanges = Object.keys(filters).some( |
| | | key => this.filters[key] !== filters[key] |
| | | ); |
| | | |
| | | if (!hasChanges) { |
| | | return; |
| | | } |
| | | |
| | | this.filters = { ...this.filters, ...filters }; |
| | | if (this.config.autoFetch !== false) { |
| | | return this.fetch(this.filters); |
| | | |
| | | this.notify('filters-changed', { |
| | | filters: this.filters, |
| | | changed: filters, |
| | | }); |
| | | |
| | | // Only fetch if endpoint configured |
| | | if (this.config.endpoint) { |
| | | window.debouncer.schedule( |
| | | this.config.endpoint, |
| | | this.fetch.bind(this), |
| | | 100 |
| | | ); |
| | | } |
| | | } |
| | | |
| | |
| | | |
| | | this.httpHeaders.set(key, headers); |
| | | |
| | | if (this.db) { |
| | | if (this.db && this.db.objectStoreNames.contains('headers')) { |
| | | const tx = this.db.transaction(['headers'], 'readwrite'); |
| | | const store = tx.objectStore('headers'); |
| | | store.put(headers); |
| | |
| | | * Save cache entry to IndexedDB |
| | | */ |
| | | async saveCache(key, data) { |
| | | if (!this.db) return; |
| | | if (!this.db || !this.db.objectStoreNames.contains('cache')) return; |
| | | |
| | | const tx = this.db.transaction(['cache'], 'readwrite'); |
| | | const store = tx.objectStore('cache'); |
| | |
| | | |
| | | |
| | | setLoading(on) { |
| | | console.log('Setting Loading ' + (on) ? 'on' : 'off' + ' from '.this.config.storeName); |
| | | this.body.classList.toggle('loading', on); |
| | | if (on) { |
| | | this.loading.showModal(); |