Jake Vanderwerf
2025-11-04 42fa8304ddb811b0f725f245130f70c0f5e86a6c
assets/js/concise/DataStore.js
@@ -6,6 +6,29 @@
 * - 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 = {}) {
@@ -20,9 +43,13 @@
         // 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
@@ -43,6 +70,8 @@
      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;
@@ -118,9 +147,28 @@
         }
      };
      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) => {
@@ -137,29 +185,33 @@
   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;
      if (this.config.useHttpCaching) {
         loadPromises.push(this.loadHeaders());
      }
            // 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);
            }
      try {
         await Promise.all(loadPromises);
         this.notify('data-loaded', {
            count: this.data.size,
            store: this.config.storeName
         });
      } catch (error) {
         console.error('Error loading from DB:', error);
      }
            this.notify('data-loaded', { count: items.length });
            resolve(items);
         };
         request.onerror = (e) => reject(e);
      });
   }
   /**
    * Load main data from IndexedDB
    */
@@ -234,9 +286,13 @@
         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;
      }
@@ -265,27 +321,102 @@
   /**
    * 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) {
@@ -329,7 +460,7 @@
    * Get a single item
    */
   get(key) {
      return this.data.get(key);
      return this.data.get(key);  // ← Returns original with FormData
   }
   /**
@@ -362,7 +493,13 @@
      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) {
@@ -416,15 +553,44 @@
         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;
      }
@@ -447,7 +613,6 @@
      }
      // 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 : ''}`;
@@ -462,7 +627,12 @@
         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;
         }
@@ -485,17 +655,26 @@
            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) {
@@ -504,6 +683,9 @@
         // 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;
         }
@@ -512,9 +694,72 @@
         if (this.config.showLoading) {
            this.setLoading(false);
         }
         this.isFetching = false;
         this.currentCacheKey = null;
         // Resolve any pending fetches that were waiting
         if (this.pendingFetches && this.pendingFetches.length > 0) {
            this.pendingFetches.forEach(resolve => resolve(fetchResult));
            this.pendingFetches = [];
         }
      }
   }
   /**
    * Fetch data from server with HTTP caching
    */
   async saveToServer(item) {
      if (!this.config.saveToServer || !jvbSettings.currentUser) {
         return;
      }
      if (!this.config.endpoint && this.config.saveToServer) {
         throw new Error('No endpoint configured for saving to server');
      }
      let requestBody;
      let headers = this.config.headers;
      headers['X-WP-Nonce'] = jvbSettings.nonce;
      if (item instanceof FormData) {
         item.append('user', jvbSettings.currentUser);
         requestBody = item;
         // console.log('Sending formData: ');
         // for (const pair of requestBody.entries()) {
         //    console.log(pair[0], pair[1]);
         // }
      } else {
         requestBody = JSON.stringify({
            ...item,
            user: jvbSettings.currentUser
         });
         // console.log('Sending data: ', {
         //    ...operation.data,
         //    id: operation.id,
         //    user: this.user
         // });
         headers['Content-Type'] = 'application/json';
      }
      const response = await fetch(
         `${this.config.apiBase}${this.config.endpoint}`,
         {
            method: 'POST',
            headers: headers,
            body: requestBody
         }
      );
      const result = await response.json();
      this.notify(
         'saved-to-server',
         {
            success: result.ok && result.success
         }
      );
   }
   cleanFilters(filters) {
      const cleaned = {};
      Object.entries(filters).forEach(([key, value]) => {
@@ -563,8 +808,9 @@
         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;
@@ -577,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
    */
@@ -596,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
            );
         }
      }
   }
@@ -623,10 +878,29 @@
   /**
    * 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
         );
      }
   }
@@ -653,7 +927,7 @@
      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);
@@ -664,7 +938,7 @@
    * 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');
@@ -786,6 +1060,7 @@
   setLoading(on) {
      console.log('Setting Loading ' + (on) ? 'on' : 'off' + ' from '.this.config.storeName);
      this.body.classList.toggle('loading', on);
      if (on) {
         this.loading.showModal();