From 7a9054bb3f033c98067b3196378311dae54c5fbf Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Tue, 20 Jan 2026 01:31:53 +0000
Subject: [PATCH] =OperationQueue refactor to the JVBase/managers/queue namespace

---
 assets/js/concise/DataStore.js |  324 +++++++++++++++++++++++++++++++++++++++++++++--------
 1 files changed, 273 insertions(+), 51 deletions(-)

diff --git a/assets/js/concise/DataStore.js b/assets/js/concise/DataStore.js
index fd5d3cb..3610c26 100644
--- a/assets/js/concise/DataStore.js
+++ b/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),
@@ -670,6 +679,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) {
@@ -682,6 +697,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
 	 ***********************************************************************/
@@ -739,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 };
 
@@ -752,21 +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) {
-			if (validate) return { valid: false, error: `FormData at ${path}` };
-			console.debug(`[DataStore] Converted FormData at ${path}`);
+
 			return { valid: true, data: this.formDataToObject(obj) };
 		}
 
@@ -808,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 };
 	}
 
@@ -827,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());
@@ -901,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) {
@@ -969,17 +1175,15 @@
 
 		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;
-				}
-			});
 		}
-		const shouldFetch = await this.shouldFetchWithFilters(name, updates, oldFilters);
+
+		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,
@@ -987,6 +1191,12 @@
 			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) {
@@ -1009,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;
@@ -1083,6 +1303,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;

--
Gitblit v1.10.0