Jake Vanderwerf
2026-01-11 474109a5df0a06f5343ab184838fe2d80e3872a8
assets/js/concise/DataStore.js
@@ -82,6 +82,7 @@
               endpoint: null,
               apiBase: jvbSettings.api,
               filters: {},
               ignore: [],       //any filters to ignore when filtering store locally
               required: null,
               // Cache
@@ -105,6 +106,11 @@
            _initialized: false
         };
         store.ignoreFilters = new Set([
            ... ['search', 'page', 'per_page', 'orderby', 'order'],
            ... store.config.ignore
         ]);
         store.config.headers = {
            'X-WP-Nonce': window.auth.getNonce(),
            ...store.config.headers
@@ -648,7 +654,8 @@
         endpoint: store.config.endpoint,
         filters: { ...store.filters },
         etag: response.headers.get('ETag'),
         lastModified: response.headers.get('Last-Modified')
         lastModified: response.headers.get('Last-Modified'),
         has_more: data.has_more || false
      };
      store.cache.set(cacheKey, cacheEntry);
@@ -669,6 +676,12 @@
         queue_stats: data.queue_stats || {}
      };
      for (let [key, value] of Object.entries(store.filters)) {
         if (typeof value === 'string' && value.includes(',')) {
            this.createSplitCacheEntries(name, items, key, store.filters, response);
         }
      }
      // Emit events for items with status changes
      changes.forEach(changeInfo => {
         if (changeInfo.statusChanged) {
@@ -681,6 +694,39 @@
      });
   }
   createSplitCacheEntries(name, items, key, filters, response) {
      const store = this.stores.get(name);
      const keys = filters[key].split(',').map(v => v.trim());
      keys.forEach(value => {
         let temp = {};
         temp[key] = value;
         const newFilters = {
            ... filters,
            [key]: value
         };
         const cacheKey = this.generateCacheKey(newFilters);
         if(store.cache.has(cacheKey)) return;
         let filteredItems = this.filterByIndex(name,temp).map(item => this.getItemKey(item, store.config.keyPath));
         const entry = {
            key: cacheKey,
            items: filteredItems,
            timestamp: Date.now(),
            endpoint: store.config.endpoint,
            filters: newFilters,
            etag: response.headers.get('Etag'),
            lastModified: response.headers.get('Last-Modified'),
            has_more: filteredItems.length === 20,
         }
         store.cache.set(cacheKey, entry);
         if (store.db?.objectStoreNames.contains('cache')) {
            this.withTransaction(name, 'cache', 'readwrite', (objectStore) =>{
               objectStore.put(entry);
            });
         }
      })
   }
   /***********************************************************************
    * SAVE OPERATIONS
    ***********************************************************************/
@@ -751,21 +797,20 @@
      // Reject functions
      if (type === 'function') {
         if (validate) return { valid: false, error: `Function at ${path}` };
         console.debug(`[DataStore] Stripped function at ${path}`);
         return { valid: true, data: undefined };
      }
      // DOM elements
      if (obj instanceof HTMLElement || obj.nodeType !== undefined) {
         if (validate) return { valid: false, error: `DOM element at ${path}` };
         console.debug(`[DataStore] Stripped DOM element at ${path}`);
         return { valid: true, data: undefined };
      }
      // FormData - convert and continue
      if (obj instanceof FormData) {
         if (validate) return { valid: false, error: `FormData at ${path}` };
         console.debug(`[DataStore] Converted FormData at ${path}`);
         return { valid: true, data: this.formDataToObject(obj) };
      }
@@ -807,7 +852,7 @@
      }
      if (validate) return { valid: false, error: `Unknown type at ${path}` };
      console.debug(`[DataStore] Stripped unknown type at ${path}`);
      return { valid: true, data: undefined };
   }
@@ -899,15 +944,98 @@
      const cacheKey = this.generateCacheKey(store.filters);
      const cacheEntry = store.cache.get(cacheKey);
      if (cacheEntry && cacheEntry.items) {
         return cacheEntry.items.reduce((acc, id) => {
            const item = store.data.get(id);
            if (item) acc.push(item);
            return acc;
         }, []);
      // First check if we have cached results for exact filters
      if (cacheEntry?.items) {
         return this.applyOrdering(
            cacheEntry.items.reduce((acc, id) => {
               const item = store.data.get(id);
               if (item) acc.push(item);
               return acc;
            }, []),
            store
         );
      }
      return this.getAll(name);
      const allItems = Array.from(store.data.values());
      const searchQuery = store.filters.search?.toLowerCase().trim() || '';
      const filtered = allItems.filter(item => {
         // Apply all non-ignored filters
         for (const [key, value] of Object.entries(store.filters)) {
            if (store.ignoreFilters.has(key)) continue;
            if (value === null || value === undefined || value === '') continue;
            if (value === 'all') continue;
            // Comma-separated values
            if (typeof value === 'string' && value.includes(',')) {
               const accepted = value.split(',').map(v => v.trim());
               if (!accepted.includes(String(item[key]))) return false;
               continue;
            }
            if (String(item[key]) !== String(value)) return false;
         }
         // Apply search if present
         return !(searchQuery && !this.searchObject(item, searchQuery));
      });
      return this.applyOrdering(filtered, store);
   }
   applyOrdering(items, store) {
      if (!Array.isArray(items)) items = Array.from(items);
      if (items.length === 0) return items;
      if (store.filters.orderby || store.filters.order) {
         const orderby = store.filters.orderby || 'date';
         const order = (store.filters.order || 'desc').toLowerCase();
         items.sort((a, b) => {
            let aVal, bVal;
            switch (orderby) {
               case 'alphabetical':
               case 'title':
                  aVal = (a.fields?.post_title || a.title || a.name || '').toLowerCase();
                  bVal = (b.fields?.post_title || b.title || b.name || '').toLowerCase();
                  break;
               case 'modified':
                  aVal = new Date(a.modified || 0);
                  bVal = new Date(b.modified || 0);
                  break;
               case 'date':
               default:
                  aVal = new Date(a.date || 0);
                  bVal = new Date(b.date || 0);
            }
            if (aVal < bVal) return order === 'asc' ? -1 : 1;
            if (aVal > bVal) return order === 'asc' ? 1 : -1;
            return 0;
         });
      }
      return items;
   }
   searchObject(obj, search) {
      if (!obj || typeof obj !== 'object') return false;
      for (const value of Object.values(obj)) {
         if (value === null || value === undefined) continue;
         if (typeof value === 'object') {
            if (this.searchObject(value, search)) return true;
            continue;
         }
         if (typeof value === 'string' && value.toLowerCase().includes(search)) {
            return true;
         }
      }
      return false;
   }
   async clear(name) {
@@ -931,28 +1059,125 @@
      if (clearAll) {
         store.filters = { ...store.config.filters };
      } else {
         // Apply updates (null/undefined/'' = delete)
         Object.entries(updates).forEach(([key, value]) => {
            if (value === null || value === undefined || value === '') {
               delete store.filters[key];
            } else {
               store.filters[key] = value;
            }
         });
      }
      Object.entries(updates).forEach(([key, value]) => {
         if (value === null || value === undefined || value === '') {
            delete store.filters[key];
         } else {
            store.filters[key] = value;
         }
      });
      this.notify(name, 'filters-changed', {
         oldFilters,
         filters: store.filters,
         updates
      });
      if (store.config.endpoint) {
      this.notify(name, 'data-loaded', {
         cached: true,
         items: this.getFiltered(name)
      });
      const shouldFetch = await this.shouldFetchWithFilters(name, updates, oldFilters);
      if (store.config.endpoint && shouldFetch) {
         await this.fetch(name);
      } else if (store.config.endpoint) {
         this.notify(name, 'data-loaded');
      }
   }
   /**
    * Determine if we need to fetch or can use local data
    * @param {string} name - Store name
    * @param {object} updates - Filter updates being applied
    * @param {object} oldFilters - Previous filter state
    * @returns {Promise<boolean>} - True if fetch is needed, false if local filtering suffices
    */
   async shouldFetchWithFilters(name, updates, oldFilters) {
      const store = this.stores.get(name);
      // If no endpoint or no lastResponse, always fetch
      if (!store.config.endpoint || !store.lastResponse) {
         return true;
      }
      if (store.lastResponse.has_more === false) {
         // Check if new filters are a subset of what we have
         const isSubsetFilter = Object.entries(updates).every(([key, value]) => {
            if (store.ignoreFilters.has(key)) return true;
            if (key === 'page') return true; // Handle pagination locally
            return true; // We have all data, can filter locally
         });
         if (isSubsetFilter) return false;
      }
      if ('page' in updates) {
         const newPage = updates.page;
         const oldPage = oldFilters.page || 1;
         // If trying to go to a higher page but no more data available
         if (newPage > oldPage && !store.lastResponse.has_more) {
            // Reset page to last valid page
            store.filters.page = oldPage;
            return false;
         }
      }
      // SEARCH OPTIMIZATION: Check if we need to fetch for search
      if ('search' in updates) {
         const searchQuery = updates.search?.trim() || '';
         const oldSearch = oldFilters.search?.trim() || '';
         // If search is being cleared, we might already have the data
         if (!searchQuery && oldSearch) {
            // Check if we have all base data (without search)
            const baseFilters = { ...store.filters };
            delete baseFilters.search;
            baseFilters.page = 1;
            // If we have complete base data, no need to fetch
            if (this.hasCompleteData(store, baseFilters)) {
               return false;
            }
         }
         // If search is new or changed, check if we have all data to filter locally
         if (searchQuery && searchQuery !== oldSearch) {
            // Check: do we have all data for base filters (no search, page 1)?
            const baseFilters = { ...store.filters };
            delete baseFilters.search;
            baseFilters.page = 1;
            // If we have complete base data, we can filter locally
            if (this.hasCompleteData(store, baseFilters)) {
               return false;
            }
         }
      }
      // Default: fetch is needed
      return true;
   }
   /**
    * Check if we have complete data for given filters
    * @param {object} store - Store instance
    * @param {object} filters - Filters to check
    * @returns {boolean} - True if we have all data
    */
   hasCompleteData(store, filters) {
      const cacheKey = this.generateCacheKey(filters);
      const cached = store.cache.get(cacheKey);
      if (!cached) return false;
      // Check if cache indicates no more data
      return cached.has_more === false || store.lastResponse?.has_more === false;
   }
   setFilter(name, key, value) {
      return this.updateFilters(name, { [key]: value });
   }
@@ -962,6 +1187,8 @@
      const hasChanges = Object.keys(filters).some(
         key => store.filters[key] !== filters[key]
      ) || Object.keys(store.filters).some(
         key => !(key in filters) && filters !== store.config.filters
      );
      if (!hasChanges) return;