Jake Vanderwerf
2026-01-20 7a9054bb3f033c98067b3196378311dae54c5fbf
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
@@ -138,8 +144,11 @@
         // Data methods
         fetch: () => this.fetch(name),
         save: (item) => this.save(name, item),
         saveMany: (items) => this.saveMany(name, items),
         delete: (id) => this.delete(name, id),
         deleteMany: (items) => this.deleteMany(name, items),
         get: (id) => this.get(name, id),
         getMany: (ids) => this.getMany(name, ids),
         getAll: () => this.getAll(name),
         getAllByIndex: (indexName, value) => this.getAllByIndex(name, indexName, value),
         filterByIndex: (criteria) => this.filterByIndex(name, criteria),
@@ -778,6 +787,47 @@
      return changeInfo.key;
   }
   /**
    * Save multiple items in a single transaction (batch write)
    * @param {string} name - Store name
    * @param {Array|Map} items - Array of items or Map of items to save
    * @returns {Promise<Array>} - Array of saved keys
    */
   async saveMany(name, items) {
      const store = this.stores.get(name);
      if (!store) return [];
      // Convert Map to array if needed
      const itemArray = items instanceof Map
         ? Array.from(items.values())
         : Array.isArray(items) ? items : Object.values(items);
      if (itemArray.length === 0) return [];
      const changes = [];
      // Process all items and update in-memory store
      itemArray.forEach(item => {
         const changeInfo = this._saveItem(name, item);
         changes.push(changeInfo);
      });
      // Single transaction for all writes
      await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => {
         changes.forEach(changeInfo => {
            objectStore.put(changeInfo.processed);
         });
      });
      // Notify once for batch
      this.notify(name, 'items-saved', {
         count: changes.length,
         keys: changes.map(c => c.key)
      });
      return changes.map(c => c.key);
   }
   processForStorage(obj, validate = true, path = 'root') {
      if (obj === null || obj === undefined) return { valid: true, data: obj };
@@ -791,20 +841,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) {
         console.debug(`[DataStore] Converting FormData at ${path}`);
         return { valid: true, data: this.formDataToObject(obj) };
      }
@@ -846,7 +896,7 @@
      }
      if (validate) return { valid: false, error: `Unknown type at ${path}` };
      console.debug(`[DataStore] Stripped unknown type at ${path}`);
      return { valid: true, data: undefined };
   }
@@ -865,11 +915,78 @@
      this.notify(name, 'item-deleted', { id });
   }
   /**
    * Delete multiple items in a single transaction (batch delete)
    * @param {string} name - Store name
    * @param {Array|Set} ids - Array or Set of IDs to delete
    * @returns {Promise<Array>} - Array of deleted IDs
    */
   async deleteMany(name, ids) {
      const store = this.stores.get(name);
      if (!store) return [];
      // Convert Set to array if needed
      const idArray = ids instanceof Set
         ? Array.from(ids)
         : Array.isArray(ids) ? ids : Object.keys(ids);
      if (idArray.length === 0) return [];
      // Update in-memory store
      idArray.forEach(id => {
         store.data.delete(id);
      });
      // Single transaction for all deletes
      await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => {
         idArray.forEach(id => {
            objectStore.delete(id);
         });
      });
      // Notify once for batch
      this.notify(name, 'items-deleted', {
         count: idArray.length,
         ids: idArray
      });
      return idArray;
   }
   get(name, id) {
      const store = this.stores.get(name);
      return store.data.get(id);
   }
   /**
    * Get multiple items by IDs in a single call
    * @param {string} name - Store name
    * @param {Array|Set} ids - Array or Set of IDs to retrieve
    * @param {boolean} skipMissing - If true, omit missing items; if false, include null for missing
    * @returns {Array} - Array of items (in same order as IDs)
    */
   getMany(name, ids, skipMissing = true) {
      const store = this.stores.get(name);
      if (!store) return [];
      const idArray = ids instanceof Set
         ? Array.from(ids)
         : Array.isArray(ids) ? ids : Object.keys(ids);
      if (idArray.length === 0) return [];
      if (skipMissing) {
         return idArray.reduce((acc, id) => {
            const item = store.data.get(id);
            if (item) acc.push(item);
            return acc;
         }, []);
      }
      // Preserve order, include null for missing
      return idArray.map(id => store.data.get(id) ?? null);
   }
   getAll(name) {
      const store = this.stores.get(name);
      return Array.from(store.data.values());
@@ -939,51 +1056,102 @@
      const cacheEntry = store.cache.get(cacheKey);
      // First check if we have cached results for exact filters
      if (cacheEntry && cacheEntry.items) {
         return cacheEntry.items.reduce((acc, id) => {
            const item = store.data.get(id);
            if (item) acc.push(item);
            return acc;
         }, []);
      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
         );
      }
      // If we have a search filter and complete base data, filter locally
      if (store.filters.search && store.filters.search.trim()) {
         const searchQuery = store.filters.search.toLowerCase().trim();
      const allItems = Array.from(store.data.values());
      const searchQuery = store.filters.search?.toLowerCase().trim() || '';
         // Get all items and filter them locally
         const allItems = Array.from(store.data.values());
      const filterPredicates = [];
      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;
         // Filter by current filters (excluding search and page)
         let filtered = allItems.filter(item => {
            // Apply all filters except search and page
            for (const [key, value] of Object.entries(store.filters)) {
               if (key === 'search' || key === 'page') continue;
         // Comma-separated values
         if (typeof value === 'string' && value.includes(',')) {
            const accepted = value.split(',').map(v => v.trim());
            filterPredicates.push(item => accepted.includes(String(item[key])));
            continue;
         }
               if (value !== null && value !== undefined && value !== '') {
                  if (item[key] !== value) return false;
               }
         filterPredicates.push(item => String(item[key]) === String(value));
      }
      const filtered = allItems.filter(item => {
         // Apply all non-search filters
         for (const predicate of filterPredicates) {
            if (!predicate(item)) 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);
            }
            return true;
            if (aVal < bVal) return order === 'asc' ? -1 : 1;
            if (aVal > bVal) return order === 'asc' ? 1 : -1;
            return 0;
         });
      }
      return items;
   }
         // Apply search filter to common searchable fields
         filtered = filtered.filter(item => {
            // Search in common fields: name, title, path, description
            const searchableFields = ['name', 'title', 'path', 'description', 'slug'];
            return searchableFields.some(field => {
               const value = item[field];
               if (!value) return false;
               return value.toLowerCase().includes(searchQuery);
            });
         });
         return filtered;
   searchObject(obj, search) {
      if (!obj || typeof obj !== 'object') {
         return typeof obj === 'string' && obj.toLowerCase().includes(search);
      }
      // Fallback to all data
      return this.getAll(name);
      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) {
@@ -1017,14 +1185,18 @@
         }
      });
      const shouldFetch = await this.shouldFetchWithFilters(name, updates, oldFilters);
      this.notify(name, 'filters-changed', {
         oldFilters,
         filters: store.filters,
         updates
      });
      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) {
@@ -1047,7 +1219,17 @@
         return true;
      }
      // PAGE OPTIMIZATION: Don't fetch if trying to go beyond available pages
      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;