Jake Vanderwerf
2025-11-04 42fa8304ddb811b0f725f245130f70c0f5e86a6c
assets/js/concise/DataStore.js
@@ -1,581 +1,765 @@
/**
 * 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,
         ...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();
      // Auto-initialize
      this.initDB();
      document.addEventListener('beforeUnload', () =>this.destroy());
      // Cleanup on page unload
      window.addEventListener('beforeunload', () => this.destroy());
   }
   /**
    * Initialize IndexedDB with configurable schema
    */
   async initDB() {
      if (!('indexedDB' in window)) return;
      if (!('indexedDB' in window)) {
         console.warn('IndexedDB not supported');
         return;
      }
      const request = indexedDB.open(`jvb_${this.config.name}_db`, 1);
      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' });
         }
         if (!db.objectStoreNames.contains('forms')) {
            let forms = db.createObjectStore('forms', {
               keyPath: 'formId',
         // Create main store with configurable keyPath
         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 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) => {
         console.error('IndexedDB error:', e);
         console.error(`IndexedDB error for ${dbName}:`, e);
         if (this.config.onError) {
            this.config.onError(e);
         }
      };
   }
   /**
    * Load all data from IndexedDB
    */
   async loadFromDB() {
      if (!this.db) return;
      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');
      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 = (e) => {
            e.target.result.forEach(item => {
               this.items.set(item.id, item);
            });
            this.notify('items-loaded', { items: Array.from(this.items.values()) });
            resolve();
         };
      });
   }
         request.onsuccess = async (e) => {
            const items = e.target.result;
   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);
            // 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);
               }
            });
            resolve();
               const key = this.getItemKey(item);
               this.data.set(key, item);
            }
            this.notify('data-loaded', { count: items.length });
            resolve(items);
         };
         request.onerror = (e) => reject(e);
      });
   }
   async loadHeaders() {
   /**
    * Load main data from IndexedDB
    */
   async loadData() {
      if (!this.db) return;
      return new Promise((resolve) => {
         const tx = this.db.transaction(['headers'], 'readonly');
         const store = tx.objectStore('headers');
      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(header => {
               this.httpHeaders.set(header.key, header);
            e.target.result.forEach(item => {
               // Strip DOM references if needed
               const cleaned = this.config.stripDOMReferences
                  ? this.stripDOMReferences(item)
                  : item;
               const key = this.getItemKey(cleaned);
               this.data.set(key, cleaned);
            });
            resolve();
         };
         request.onerror = (e) => reject(e);
      });
   }
   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
    * Strip DOM references from an object (recursive)
    */
   async fetch(endpoint = null, options = {}) {
   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.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');
      }
      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);
      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);
      }
      // Use provided endpoint or config endpoint
      const apiEndpoint = endpoint || this.config.endpoint;
      if (!apiEndpoint) {
         throw new Error('No endpoint specified');
      //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;
      }
      // 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 (storedHeaders && cachedData) {
         if (storedHeaders.etag) {
            requestHeaders['If-None-Match'] = storedHeaders.etag;
         }
         if (storedHeaders.lastModified) {
            requestHeaders['If-Modified-Since'] = storedHeaders.lastModified;
      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
         });
         console.log('DataStore response status: ',response.status);
         // 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;
            }
         // Handle 304 Not Modified
         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;
         }
         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();
         console.log('Fetched data: ', data);
         // Store HTTP caching headers
         if (this.config.useHttpCaching) {
            this.storeResponseHeaders(cacheKey, response);
         }
         // Cache the response
         const cacheEntry = {
            key: cacheKey,
            endpoint: apiEndpoint,
            data: data,
            timestamp: Date.now(),
            filters: cleanedFilters
            endpoint: this.config.endpoint,
            filters: filters
         };
         console.log(this.config.storeName + 'Fetched fresh from server');
         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 });
   }
   /**
    * 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);
   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');
      }
      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;
   }
      let requestBody;
      let headers = this.config.headers;
      headers['X-WP-Nonce'] = jvbSettings.nonce;
      if (item instanceof FormData) {
         item.append('user', jvbSettings.currentUser);
         requestBody = item;
   hasUnrecoverableFiles(data) {
      if (!data || typeof data !== 'object') return false;
         // 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
         // });
      if (data._wasFile || data._wasBlob) return true;
      if (Array.isArray(data)) {
         return data.some(item => this.hasUnrecoverableFiles(item));
         headers['Content-Type'] = 'application/json';
      }
      if (data instanceof FormData) {
         for (const [key, value] of data.entries()) {
            if (value instanceof File || value instanceof Blob) return true;
      const response = await fetch(
         `${this.config.apiBase}${this.config.endpoint}`,
         {
            method: 'POST',
            headers: headers,
            body: requestBody
         }
         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;
      const result = await response.json();
      this.notify(
         'saved-to-server',
         {
            success: result.ok && result.success
         }
         // 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]) => {
@@ -600,10 +784,33 @@
      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;
@@ -616,10 +823,15 @@
      // 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
    */
@@ -635,7 +847,11 @@
         // Auto-fetch if endpoint is configured
         if (this.config.endpoint) {
            this.fetch();
            window.debouncer.schedule(
               this.config.endpoint,
               this.fetch.bind(this),
               100
            );
         }
      }
   }
@@ -660,25 +876,47 @@
   }
   /**
    * 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;
      }
      this.filters = { ...this.filters, ...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
         );
      }
   }
   generateHeaderKey(url) {
      return `headers_${url}`;
   }
   isCacheValid(cacheEntry, maxAge = this.config.TTL) {
   /**
    * 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,
@@ -688,10 +926,168 @@
      };
      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);
      }
   }
   /**
    * 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);
   }
   /**
    * Load cache from IndexedDB
    */
   async loadCache() {
      if (!this.db) return;
      return new Promise((resolve) => {
         const tx = this.db.transaction(['cache'], 'readonly');
         const store = tx.objectStore('cache');
         const request = store.getAll();
         request.onsuccess = (e) => {
            e.target.result.forEach(item => {
               if (this.isCacheValid(item)) {
                  this.cache.set(item.key, item);
               }
            });
            resolve();
         };
      });
   }
   /**
    * Load HTTP headers from IndexedDB
    */
   async loadHeaders() {
      if (!this.db) return;
      return new Promise((resolve) => {
         const tx = this.db.transaction(['headers'], 'readonly');
         const store = tx.objectStore('headers');
         const request = store.getAll();
         request.onsuccess = (e) => {
            e.target.result.forEach(header => {
               this.httpHeaders.set(header.key, header);
            });
            resolve();
         };
      });
   }
   /**
    * Subscribe to store events
    */
   subscribe(callback) {
      this.subscribers.add(callback);
      return () => this.subscribers.delete(callback);
   }
   /**
    * Notify subscribers of events
    */
   notify(event, data = {}) {
      this.subscribers.forEach(callback => {
         try {
            callback(event, data);
         } catch (error) {
            console.error('Subscriber error:', error);
         }
      });
   }
   /**
    * Query items using an index
    */
   async query(indexName, value) {
      if (!this.db) return [];
      return new Promise((resolve, reject) => {
         const tx = this.db.transaction([this.config.storeName], 'readonly');
         const store = tx.objectStore(this.config.storeName);
         if (!store.indexNames.contains(indexName)) {
            reject(new Error(`Index ${indexName} does not exist`));
            return;
         }
         const index = store.index(indexName);
         const request = value !== undefined
            ? index.getAll(value)
            : index.getAll();
         request.onsuccess = (e) => {
            const results = e.target.result.map(item => {
               return this.config.stripDOMReferences
                  ? this.stripDOMReferences(item)
                  : item;
            });
            resolve(results);
         };
         request.onerror = (e) => reject(e);
      });
   }
   /**
    * Count items in store
    */
   async count() {
      if (!this.db) return this.data.size;
      return new Promise((resolve, reject) => {
         const tx = this.db.transaction([this.config.storeName], 'readonly');
         const store = tx.objectStore(this.config.storeName);
         const request = store.count();
         request.onsuccess = (e) => resolve(e.target.result);
         request.onerror = (e) => reject(e);
      });
   }
   setLoading(on) {
      console.log('Setting Loading ' + (on) ? 'on' : 'off' + ' from '.this.config.storeName);
      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();
@@ -704,231 +1100,7 @@
      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;