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 | 267 ++++++++++++++++++++++++++++++++++++++++++++--------
1 files changed, 224 insertions(+), 43 deletions(-)
diff --git a/assets/js/concise/DataStore.js b/assets/js/concise/DataStore.js
index 11f479f..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),
@@ -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,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) };
}
@@ -847,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 };
}
@@ -866,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());
@@ -940,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) {
@@ -1018,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) {
@@ -1048,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;
--
Gitblit v1.10.0