From a9b3b28d001941921aa70d37fdc87c758a163a44 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Fri, 05 Jun 2026 16:47:03 +0000
Subject: [PATCH] =Some hefty changes to FeedBlock. Transitioning to loading first page in php to save on extra requests. Got a bit to do yet, but I have to work on Northeh for a bit here.

---
 assets/js/concise/DataStore.js | 1266 ++++++++++++++++++++++++++++++++++++--------------------
 1 files changed, 807 insertions(+), 459 deletions(-)

diff --git a/assets/js/concise/DataStore.js b/assets/js/concise/DataStore.js
index ffe1891..539c82b 100644
--- a/assets/js/concise/DataStore.js
+++ b/assets/js/concise/DataStore.js
@@ -6,6 +6,7 @@
  *   this.store = window.jvbStore.register('feed', { config });
  */
 class DataStore {
+
 	constructor() {
 		// Singleton pattern
 		if (DataStore.instance) {
@@ -14,7 +15,7 @@
 		DataStore.instance = this;
 
 		// Shared resources
-		this.dbConfig = new Map();		// Definitions for the databases
+		this.dbConfig = new Map();      // Definitions for the databases
 		this.databases = new Map();     // Shared IndexedDB connections
 		this.stores = new Map();        // Registered store namespaces
 		this.subscribers = new Map();   // Per-store event subscribers
@@ -27,8 +28,6 @@
 		this.loading = document.querySelector('dialog.loading');
 
 		this.init();
-
-		// window.addEventListener('beforeunload', () => this.destroy());
 	}
 
 	async init() {
@@ -46,13 +45,12 @@
 	 * @param {object|array} configs An object defining the store, or an array of objects defining the stores
 	 * @param {number} version the database version
 	 */
-	register(name, configs = [], version = 1.1) {
+	register(name, configs = [], version = 1.25) {
 		if (!Array.isArray(configs)) configs = [configs];
 		if (configs.length === 0) return;
-
 		if (!this.dbConfig.has(name)) {
 			this.dbConfig.set(name, {
-				dbName: `jvb_${name}`,
+				dbName: `${jvbBase.base}${name}`,
 				version: version,
 				stores: {},
 				_initialized: false
@@ -69,7 +67,6 @@
 				throw new Error(`Store "${config.storeName}" requires keyPath`);
 			}
 
-
 			const storeKey = `${name}_${config.storeName}`;
 
 			const store = {
@@ -84,8 +81,11 @@
 					endpoint: null,
 					apiBase: jvbSettings.api,
 					filters: {},
+					ignore: [],			//any filters to ignore when filtering store locally
 					required: null,
 
+					isAuth: false,
+
 					// Cache
 					TTL: 3600000, // 1 hour
 					useHttpCaching: true,
@@ -93,24 +93,28 @@
 					// Behavior
 					showLoading: false,
 					delayFetch: true,
-					validateData: true, // Validate data is serializable
+					validateData: true,
 					...config
 				},
 				dbKey: name,
 				storeKey: storeKey,
 				data: new Map(),
 				cache: new Map(),
-				httpHeaders: new Map(),
-				subscribers: new Map(),
-				filters: {...(config.filters || {}) },
+				filters: {...(config.filters || {})},
 				isFetching: false,
 				currentRequest: null,
 				lastResponse: null,
 				_initialized: false
 			};
 
+			store.ignoreFilters = new Set([
+				... ['search', 'page', 'per_page', 'orderby', 'order'],
+				... ['context', 'source'],
+				... store.config.ignore
+			]);
+
 			store.config.headers = {
-				'X-WP-Nonce': jvbSettings?.nonce,
+				'X-WP-Nonce': window.auth.getNonce(),
 				...store.config.headers
 			};
 
@@ -143,9 +147,14 @@
 			// 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),
 			getFiltered: () => this.getFiltered(name),
 			clear: () => this.clear(name),
 
@@ -157,7 +166,6 @@
 
 			// Cache methods
 			clearCache: () => this.clearCache(name),
-			clearHttpHeaders: (key) => this.clearHttpHeaders(name, key),
 
 			// Event methods
 			subscribe: (callback) => this.subscribe(name, callback),
@@ -183,49 +191,6 @@
 	}
 
 	/**
-	 * Normalize data before saving - convert Sets/Maps automatically
-	 */
-	normalizeForStorage(obj) {
-		if (obj === null || obj === undefined) return obj;
-
-		// Convert Set to Array
-		if (obj instanceof Set) {
-			return Array.from(obj);
-		}
-
-		// Convert Map to Object
-		if (obj instanceof Map) {
-			return Object.fromEntries(obj);
-		}
-
-		// Preserve ArrayBuffer and TypedArrays (needed for blob storage)
-		if (obj instanceof ArrayBuffer || ArrayBuffer.isView(obj)) {
-			return obj;
-		}
-
-		// Preserve Date objects
-		if (obj instanceof Date) {
-			return obj;
-		}
-
-		// Handle Arrays
-		if (Array.isArray(obj)) {
-			return obj.map(item => this.normalizeForStorage(item));
-		}
-
-		// Handle Objects
-		if (typeof obj === 'object') {
-			const normalized = {};
-			for (const [key, value] of Object.entries(obj)) {
-				normalized[key] = this.normalizeForStorage(value);
-			}
-			return normalized;
-		}
-
-		return obj;
-	}
-
-	/**
 	 * Convert FormData to plain object for storage
 	 */
 	formDataToObject(formData) {
@@ -285,66 +250,10 @@
 		return formData;
 	}
 
-	/**
-	 * Strip DOM references from object
-	 */
-	stripDOMReferences(obj, visited = new WeakSet()) {
-		if (obj === null || obj === undefined) return obj;
+	/***********************************************************************
+	 * DATABASE INITIALIZATION
+	 ***********************************************************************/
 
-		const type = typeof obj;
-		if (type === 'string' || type === 'number' || type === 'boolean') {
-			return obj;
-		}
-
-		// Prevent circular references
-		if (type === 'object' && visited.has(obj)) {
-			return '[Circular]';
-		}
-
-		// Remove DOM elements
-		if (obj instanceof HTMLElement ||
-			obj instanceof NodeList ||
-			obj instanceof HTMLCollection ||
-			obj.nodeType !== undefined) {
-			return null;
-		}
-
-		// ✅ PRESERVE ArrayBuffer and TypedArrays (needed for blob storage)
-		if (obj instanceof ArrayBuffer ||
-			ArrayBuffer.isView(obj)) {
-			return obj;
-		}
-
-		// Handle Date
-		if (obj instanceof Date) {
-			return obj;
-		}
-
-		// Handle Arrays
-		if (Array.isArray(obj)) {
-			visited.add(obj);
-			return obj.map(item => this.stripDOMReferences(item, visited)).filter(v => v !== null);
-		}
-
-		// Handle Objects
-		if (type === 'object') {
-			visited.add(obj);
-			const cleaned = {};
-			for (const [key, value] of Object.entries(obj)) {
-				const cleanedValue = this.stripDOMReferences(value, visited);
-				if (cleanedValue !== null) {
-					cleaned[key] = cleanedValue;
-				}
-			}
-			return cleaned;
-		}
-
-		return obj;
-	}
-
-	/**
-	 * Initialize database for a specific store
-	 */
 	async initDB(name) {
 		const db = this.dbConfig.get(name);
 		if (!db || db._initialized) return;
@@ -390,7 +299,7 @@
 					this.loadStoreDataInBackground(storeName);
 					this.notify(storeName, 'db-init');
 				}
-			})
+			});
 
 		} catch (error) {
 			console.error(`Failed to initialize database for store "${name}":`, error);
@@ -432,29 +341,55 @@
 			});
 		}
 
-		// Cache store
+		// Cache store (now includes HTTP headers)
 		if (config.endpoint && !db.objectStoreNames.contains('cache')) {
 			const cacheStore = db.createObjectStore('cache', { keyPath: 'key' });
 			cacheStore.createIndex('timestamp', 'timestamp', { unique: false });
 		}
+	}
 
-		// HTTP headers store
-		if (config.useHttpCaching && !db.objectStoreNames.contains('headers')) {
-			db.createObjectStore('headers', { keyPath: 'key' });
+	/**
+	 * Generic loader for any object store
+	 */
+	async loadFromObjectStore(name, storeName, processItem) {
+		const store = this.stores.get(name);
+		if (!store?.db || !store.db.objectStoreNames.contains(storeName)) {
+			return [];
 		}
+
+		return new Promise((resolve) => {
+			const tx = store.db.transaction([storeName], 'readonly');
+			const objectStore = tx.objectStore(storeName);
+			const request = objectStore.getAll();
+
+			request.onsuccess = (e) => {
+				const items = e.target.result || [];
+				items.forEach(processItem);
+				resolve(items);
+			};
+
+			request.onerror = () => resolve([]);
+		});
 	}
 
 	loadStoreDataInBackground(name) {
 		const store = this.stores.get(name);
 		if (!store?.db) return;
 
-		const tasks = [
-			this.loadStoreData(name),
-			this.loadStoreCache(name),
-			this.loadStoreHeaders(name)
-		];
+		Promise.all([
+			// Load main data
+			this.loadFromObjectStore(name, store.config.storeName, (item) => {
+				const key = this.getItemKey(item, store.config.keyPath);
+				store.data.set(key, item);
+			}),
 
-		Promise.all(tasks)
+			// Load cache (includes HTTP headers now!)
+			this.loadFromObjectStore(name, 'cache', (item) => {
+				if (this.isCacheValid(item, store.config.TTL)) {
+					store.cache.set(item.key, item);
+				}
+			})
+		])
 			.then(() => {
 				this.notify(name, 'data-ready');
 
@@ -507,71 +442,6 @@
 		}
 	}
 
-	async loadStoreData(name) {
-		const store = this.stores.get(name);
-		if (!store?.db) return;
-
-		return new Promise((resolve) => {
-			const tx = store.db.transaction([store.config.storeName], 'readonly');
-			const objectStore = tx.objectStore(store.config.storeName);
-			const request = objectStore.getAll();
-
-			request.onsuccess = (e) => {
-				const items = e.target.result || [];
-				items.forEach(item => {
-					const key = this.getItemKey(item, store.config.keyPath);
-					store.data.set(key, item);
-				});
-				this.notify(name, 'data-loaded', { count: items.length });
-				resolve(items);
-			};
-
-			request.onerror = () => resolve([]);
-		});
-	}
-
-	async loadStoreCache(name) {
-		const store = this.stores.get(name);
-		if (!store?.db || !store.db.objectStoreNames.contains('cache')) return;
-
-		return new Promise((resolve) => {
-			const tx = store.db.transaction(['cache'], 'readonly');
-			const objectStore = tx.objectStore('cache');
-			const request = objectStore.getAll();
-
-			request.onsuccess = (e) => {
-				(e.target.result || []).forEach(item => {
-					if (this.isCacheValid(item, store.config.TTL)) {
-						store.cache.set(item.key, item);
-					}
-				});
-				resolve();
-			};
-
-			request.onerror = () => resolve();
-		});
-	}
-
-	async loadStoreHeaders(name) {
-		const store = this.stores.get(name);
-		if (!store?.db || !store.db.objectStoreNames.contains('headers')) return;
-
-		return new Promise((resolve) => {
-			const tx = store.db.transaction(['headers'], 'readonly');
-			const objectStore = tx.objectStore('headers');
-			const request = objectStore.getAll();
-
-			request.onsuccess = (e) => {
-				(e.target.result || []).forEach(header => {
-					store.httpHeaders.set(header.key, header);
-				});
-				resolve();
-			};
-
-			request.onerror = () => resolve();
-		});
-	}
-
 	async ensureStoreInitialized(name) {
 		const store = this.stores.get(name);
 		if (!store) {
@@ -583,6 +453,45 @@
 		}
 	}
 
+	/***********************************************************************
+	 * TRANSACTION HELPER
+	 ***********************************************************************/
+
+	/**
+	 * Create transaction helper - reduces boilerplate
+	 */
+	async withTransaction(name, storeNames, mode, callback) {
+		const store = this.stores.get(name);
+		if (!store?.db) return null;
+
+		// Ensure storeNames is an array
+		if (typeof storeNames === 'string') storeNames = [storeNames];
+
+		return new Promise((resolve, reject) => {
+			const tx = store.db.transaction(storeNames, mode);
+			const stores = storeNames.map(name => tx.objectStore(name));
+			const objectStore = stores.length === 1 ? stores[0] : stores;
+
+			let result;
+			tx.oncomplete = () => resolve(result);
+			tx.onerror = () => {
+				const error = tx.error || new Error('Transaction failed with unknown error');
+				reject(error);
+			};
+
+			// Call callback immediately to queue operations
+			try {
+				result = callback(objectStore, tx);
+			} catch (error) {
+				reject(error || new Error('Callback failed with unknown error'));
+			}
+		});
+	}
+
+	/***********************************************************************
+	 * FETCH & DATA PROCESSING
+	 ***********************************************************************/
+
 	async fetch(name) {
 		await this.ensureStoreInitialized(name);
 
@@ -611,9 +520,10 @@
 			const cached = store.cache.get(cacheKey);
 
 			if (cached && this.isCacheValid(cached, store.config.TTL)) {
+				let items = cached.items.map(itemId => this.get(name, itemId));
 				this.notify(name, 'data-loaded', {
 					cached: true,
-					items: cached.items || []
+					items: items??[]
 				});
 				return cached;
 			}
@@ -624,46 +534,74 @@
 
 			const url = this.buildFetchUrl(name);
 			const headers = { ...store.config.headers };
-			const cachedHeaders = store.httpHeaders.get(cacheKey);
 
-			if (store.config.useHttpCaching && cachedHeaders) {
-				if (cachedHeaders.etag) {
-					headers['If-None-Match'] = cachedHeaders.etag;
-				}
-				if (cachedHeaders.lastModified) {
-					headers['If-Modified-Since'] = cachedHeaders.lastModified;
-				}
+			// Use HTTP cache headers from cache entry
+			if (store.config.useHttpCaching && cached) {
+				if (cached.etag) headers['If-None-Match'] = cached.etag;
+				if (cached.lastModified) headers['If-Modified-Since'] = cached.lastModified;
 			}
 
 			const controller = new AbortController();
 			store.currentRequest = controller;
 
-			const response = await fetch(url, {
-				method: 'GET',
-				headers,
-				signal: controller.signal
-			});
-
-			if (response.status === 304 && cached) {
-				this.notify(name, 'data-loaded', {
-					cached: true,
-					notModified: true,
-					items: cached.items || []
+			let response;
+			if (store.isAuth) {
+				response = await window.auth.fetch(url, {
+					method: 'GET',
+					headers,
+					signal: controller.signal
 				});
-				return cached;
+			} else {
+				response = await fetch(url, {
+					method: 'GET',
+					headers,
+					signal: controller.signal
+				});
 			}
 
 			if (!response.ok) {
+				// Access the error details from the response body
+				const errorBody = await response.text();
+				// Throw a new error with a descriptive message
+				throw new Error(`HTTP error! status: ${response.status}, message: ${errorBody}`);
+			}
+
+			if (response.status === 304) {
+				// 304 means "Not Modified" - use cached data if available
+				if (cached) {
+					this.notify(name, 'data-loaded', {
+						cached: true,
+						notModified: true,
+						items: cached.items || []
+					});
+					return cached;
+				}
+
+				// No cached data but server says not modified - return empty result
+				this.notify(name, 'data-loaded', {
+					cached: false,
+					notModified: true,
+					items: []
+				});
+
+				// Initialize empty lastResponse
+				store.lastResponse = {
+					has_more: false,
+					total: 0,
+					pages: 1,
+					queue_stats: {}
+				};
+
+				return { items: [] };
+			}
+
+			// Now check for other non-OK responses
+			if (!response.ok) {
 				throw new Error(`HTTP ${response.status}: ${response.statusText}`);
 			}
-
 			const data = await response.json();
 
-			if (store.config.useHttpCaching) {
-				this.storeResponseHeaders(name, cacheKey, response);
-			}
-
-			await this.processFetchedData(name, data, cacheKey);
+			await this.processFetchedData(name, data, cacheKey, response);
 
 			this.notify(name, 'data-loaded', {
 				cached: false,
@@ -673,11 +611,14 @@
 			return data;
 
 		} catch (error) {
-			if (error.name !== 'AbortError') {
-				console.error(`Fetch error for store "${name}":`, error);
+			const isAbortError = error?.name === 'AbortError';
+
+			if (!isAbortError) {
+				console.error(`Fetch error for store "${name}":`, error.message);
+				console.dir(error);
 				this.notify(name, 'fetch-error', { error });
+				throw error;
 			}
-			throw error;
 
 		} finally {
 			store.isFetching = false;
@@ -707,211 +648,581 @@
 		return params.toString() ? `${baseUrl}?${params}` : baseUrl;
 	}
 
-	async processFetchedData(name, data, cacheKey) {
+	/**
+	 * Process fetched data (batch from server)
+	 */
+	async processFetchedData(name, data, cacheKey, response) {
 		const store = this.stores.get(name);
-		const items = data.items || [];
+		const items = (data.items || []).filter(item => item && typeof item === 'object');
+		const changes = [];
 
-		for (const item of items) {
-			await this.save(name, item);
+		// Batch process with single transaction
+		if (store.db && items.length > 0) {
+			await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => {
+				items.forEach(item => {
+					try {
+						// Use shared save logic
+						const changeInfo = this._saveItem(name, item);
+						changes.push(changeInfo);
+
+						// Queue for batch write
+						objectStore.put(changeInfo.processed);
+					} catch (error) {
+						console.error(`Error processing item:`, error);
+					}
+				});
+			});
 		}
 
+		// Update cache (now includes HTTP headers!)
 		const cacheEntry = {
 			key: cacheKey,
 			items: items.map(item => this.getItemKey(item, store.config.keyPath)),
 			timestamp: Date.now(),
 			endpoint: store.config.endpoint,
-			filters: { ...store.filters }
+			filters: { ...store.filters },
+			etag: response.headers.get('ETag'),
+			lastModified: response.headers.get('Last-Modified'),
+			has_more: data.has_more || false
 		};
 
 		store.cache.set(cacheKey, cacheEntry);
-		await this.saveToCache(name, cacheKey, cacheEntry);
 
+		// Save cache to IndexedDB
+		if (store.db?.objectStoreNames.contains('cache')) {
+			await this.withTransaction(name, 'cache', 'readwrite', (objectStore) => {
+				objectStore.put(cacheEntry);
+			});
+		}
+
+		// Update lastResponse metadata
 		store.lastResponse = {
+			...data,
 			has_more: data.has_more || false,
 			total: data.total || items.length,
-			pages: data.pages || 1
+			pages: data.pages || 1,
+			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) {
+				this.notify(name, 'item-saved', {
+					item: changeInfo.item,
+					key: changeInfo.key,
+					previousItem: changeInfo.previousItem
+				});
+			}
+		});
+	}
+
+	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
+	 ***********************************************************************/
+
+	/**
+	 * Internal method: Save a single item with full tracking
+	 * Returns change info without writing to IndexedDB (caller handles that)
+	 */
+	_saveItem(name, item) {
+		const store = this.stores.get(name);
+
+		const result = this.processForStorage(item, store.config.validateData);
+		if (!result.valid) {
+			throw new Error(`Non-serializable data: ${result.error}`);
+		}
+		const processed = result.data;
+
+		const key = this.getItemKey(processed, store.config.keyPath);
+
+		// Capture previous state
+		const previousItem = store.data.get(key);
+
+		// Update in-memory store (with original data intact)
+		store.data.set(key, item);
+
+		// Return change info for event emission
+		return {
+			item,
+			previousItem,
+			key,
+			processed,
+			statusChanged: previousItem && previousItem.status !== item.status
 		};
 	}
 
 	/**
-	 * Save item to store
-	 * IMPORTANT: Item must be serializable (no DOM, FormData, Blobs)
+	 * Save single item (public API)
 	 */
 	async save(name, item) {
 		const store = this.stores.get(name);
+		const changeInfo = this._saveItem(name, item);
 
-		// Auto-normalize Sets/Maps
-		let processed = this.normalizeForStorage(item);
+		// Write to IndexedDB immediately for single saves
+		await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => {
+			objectStore.put(changeInfo.processed);
+		});
 
-		if (processed.data instanceof FormData) {
-			processed = {
-				...processed,
-				data: this.formDataToObject(processed.data)
-			};
-		}
+		// Always emit for explicit saves
+		this.notify(name, 'item-saved', {
+			item: changeInfo.item,
+			key: changeInfo.key,
+			previousItem: changeInfo.previousItem
+		});
 
-		processed = this.stripDOMReferences(processed);
-
-		// Validate data is serializable
-		if (store.config.validateData) {
-			const validation = this.validateSerializable(processed);
-			if (!validation.valid) {
-				console.error(`Cannot save non-serializable data to store "${name}":`, validation.error);
-				throw new Error(`Non-serializable data: ${validation.error}`);
-			}
-		}
-
-		const key = this.getItemKey(processed, store.config.keyPath);
-
-		// Store the original in memory (with original data intact)
-		store.data.set(key, item);
-
-		// Store processed in IndexedDB
-		if (store.db) {
-			const tx = store.db.transaction([store.config.storeName], 'readwrite');
-			const objectStore = tx.objectStore(store.config.storeName);
-			await objectStore.put(processed);
-		}
-
-		this.notify(name, 'item-saved', { item, key });
-		return key;
+		return changeInfo.key;
 	}
 
 	/**
-	 * Validate that data is IndexedDB-serializable
-	 * Rejects: DOM elements, FormData, Blobs, Functions, etc.
+	 * 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
 	 */
-	validateSerializable(obj, path = 'root') {
-		// Primitives are fine
-		if (obj === null || obj === undefined) {
-			return { valid: true };
+	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) {
+			return { valid: true, data: null };
+		}
+		if (obj === undefined) {
+			if (validate) {
+				return { valid: false, error: `Undefined value at ${path}` };
+			}
+			return { valid: true, data: undefined };
 		}
 
 		const type = typeof obj;
-		if (type === 'string' || type === 'number' || type === 'boolean') {
-			return { valid: true };
+
+		// Handle primitives
+		if (['string', 'number', 'boolean'].includes(type)) {
+			return { valid: true, data: obj };
 		}
 
-		// Functions cannot be serialized
+		// Reject functions
 		if (type === 'function') {
-			return {
-				valid: false,
-				error: `Function at ${path}`
-			};
+			if (validate) return { valid: false, error: `Function at ${path}` };
+
+			return { valid: true, data: undefined };
 		}
 
-		// Date is serializable
-		if (obj instanceof Date) {
-			return { valid: true };
+		// DOM elements
+		if (obj instanceof HTMLElement || obj.nodeType !== undefined) {
+			if (validate) return { valid: false, error: `DOM element at ${path}` };
+
+			return { valid: true, data: undefined };
 		}
 
-		if (obj instanceof ArrayBuffer || ArrayBuffer.isView(obj)) {
-			return { valid: true };
-		}
-
-		// Reject DOM elements
-		if (obj instanceof HTMLElement ||
-			obj instanceof NodeList ||
-			obj instanceof HTMLCollection ||
-			(obj.nodeType !== undefined)) {
-			return {
-				valid: false,
-				error: `DOM element at ${path}`
-			};
-		}
-
-		// Reject FormData
+		// FormData - convert and continue
 		if (obj instanceof FormData) {
-			return {
-				valid: false,
-				error: `FormData at ${path}. Convert to object first.`
-			};
+
+			return { valid: true, data: this.formDataToObject(obj) };
 		}
 
-		// Reject Blobs/Files
-		if (obj instanceof Blob || obj instanceof File) {
-			return {
-				valid: false,
-				error: `Blob/File at ${path}. Handle file uploads separately.`
-			};
+		// Preserve safe types
+		if (obj instanceof Date || obj instanceof ArrayBuffer || ArrayBuffer.isView(obj) || obj instanceof Blob) {
+			return { valid: true, data: obj };
+		}
+
+		// Convert Sets to Arrays
+		if (obj instanceof Set) {
+			return this.processForStorage(Array.from(obj), validate, path);
+		}
+
+		// Convert Maps to Objects
+		if (obj instanceof Map) {
+			obj = Object.fromEntries(obj);
 		}
 
 		// Arrays
 		if (Array.isArray(obj)) {
+			const processed = [];
 			for (let i = 0; i < obj.length; i++) {
-				const result = this.validateSerializable(obj[i], `${path}[${i}]`);
+				const result = this.processForStorage(obj[i], validate, `${path}[${i}]`);
 				if (!result.valid) return result;
+				if (result.data !== undefined) processed.push(result.data);
 			}
-			return { valid: true };
+			return { valid: true, data: processed };
 		}
 
-		// Plain objects
+		// Objects
 		if (type === 'object') {
-			// Check for Sets/Maps (IndexedDB doesn't support them)
-			if (obj instanceof Set) {
-				return {
-					valid: false,
-					error: `Set at ${path}. Convert to Array first: Array.from(set)`
-				};
-			}
-			if (obj instanceof Map) {
-				return {
-					valid: false,
-					error: `Map at ${path}. Convert to Object first: Object.fromEntries(map)`
-				};
-			}
-
-			// Check all properties
+			const processed = {};
 			for (const [key, value] of Object.entries(obj)) {
-				const result = this.validateSerializable(value, `${path}.${key}`);
+				if (value === undefined) continue;
+				const result = this.processForStorage(value, validate, `${path}.${key}`);
 				if (!result.valid) return result;
+				// Include null values, skip undefined
+				if (result.data !== undefined || value === null) {
+					processed[key] = result.data;
+				}
 			}
-			return { valid: true };
+			return { valid: true, data: processed };
 		}
 
-		return {
-			valid: false,
-			error: `Unknown type at ${path}: ${type}`
-		};
+		if (validate) return { valid: false, error: `Unknown type at ${path}` };
+
+		return { valid: true, data: undefined };
 	}
 
+	/***********************************************************************
+	 * DATA ACCESS
+	 ***********************************************************************/
+
 	async delete(name, id) {
 		const store = this.stores.get(name);
 		store.data.delete(id);
 
-		if (store.db) {
-			const tx = store.db.transaction([store.config.storeName], 'readwrite');
-			const objectStore = tx.objectStore(store.config.storeName);
-			await objectStore.delete(id);
-		}
+		await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => {
+			objectStore.delete(id);
+		});
 
 		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());
 	}
+	/**
+	 * Filter in-memory data by multiple index/value pairs
+	 * @param {string} name - Store name
+	 * @param {Object} criteria - Object of { indexName: acceptedValue(s) }
+	 * @returns {Array} - Items matching ALL criteria
+	 *
+	 * @example
+	 * filterByIndex(name, { field: 'upload_123', status: ['queued', 'uploading'] })
+	 */
+	filterByIndex(name, criteria) {
+		const store = this.stores.get(name);
+		if (!store) return [];
+
+		return Array.from(store.data.values()).filter(item => {
+			if (!item || typeof item !== 'object') return false;
+			return Object.entries(criteria).every(([key, value]) => {
+				const accepted = Array.isArray(value) ? value : [value];
+				return accepted.includes(item[key]);
+			});
+		});
+	}
+	/**
+	 * Get all items matching an index value
+	 * @param {string} name - Store name
+	 * @param {string} indexName - Name of the index to query
+	 * @param {*} value - Value to match
+	 * @returns {Promise<Array>} - Matching items
+	 */
+	async getAllByIndex(name, indexName, value) {
+		const store = this.stores.get(name);
+		const values = Array.isArray(value) ? value : [value];
+
+		// Try IndexedDB index query first (more efficient for large datasets)
+		if (store.db && store.db.objectStoreNames.contains(store.config.storeName)) {
+			try {
+				const tx = store.db.transaction([store.config.storeName], 'readonly');
+				const objectStore = tx.objectStore(store.config.storeName);
+
+				if (objectStore.indexNames.contains(indexName)) {
+					const index = objectStore.index(indexName);
+
+					const results = await Promise.all(
+						values.map(v => new Promise((resolve, reject) => {
+							const request = index.getAll(v);
+							request.onsuccess = () => resolve(request.result || []);
+							request.onerror = () => reject(request.error);
+						}))
+					);
+
+					return results.flat();
+				}
+			} catch (error) {
+				console.warn(`Index query failed for "${indexName}", falling back to filter:`, error);
+			}
+		}
+
+		// Fallback: filter in-memory data
+		return Array.from(store.data.values()).filter(item => values.includes(item[indexName]));
+	}
 
 	getFiltered(name) {
 		const store = this.stores.get(name);
 		const cacheKey = this.generateCacheKey(store.filters);
 		const cacheEntry = store.cache.get(cacheKey);
 
-		if (cacheEntry && cacheEntry.items) {
-			return cacheEntry.items.reduce((acc, id) => {
+		// First check if we have cached results for exact filters
+		if (cacheEntry?.items) {
+			const items = cacheEntry.items.reduce((acc, id) => {
 				const item = store.data.get(id);
 				if (item) acc.push(item);
 				return acc;
 			}, []);
+			return this.applyOrdering(items, store);
 		}
 
-		return this.getAll(name);
+		const allItems = Array.from(store.data.values());
+
+		const searchQuery = store.filters.search?.toLowerCase().trim() || '';
+
+		const filterPredicates = [];
+
+		// Handle taxonomy filters separately
+		if (store.filters.taxonomy && typeof store.filters.taxonomy === 'object') {
+			Object.entries(store.filters.taxonomy).forEach(([taxonomy, termIds]) => {
+				const acceptedTermIds = Array.isArray(termIds) ? termIds : [termIds];
+
+				filterPredicates.push(item => {
+					if (!item.taxonomies || !item.taxonomies[taxonomy]) {
+						return false;
+					}
+					const itemTermIds = Object.keys(item.taxonomies[taxonomy]).map(id => parseInt(id));
+					const matches = acceptedTermIds.some(termId => itemTermIds.includes(parseInt(termId)));
+					return matches;
+				});
+			});
+		}
+
+		// Handle other filters
+		for (const [key, value] of Object.entries(store.filters)) {
+			if (key === 'taxonomy') {
+				if (typeof value === 'string' && !value.includes(',')) {
+					filterPredicates.push(item => item.taxonomy === value);
+				}
+				continue;
+			}
+			if (store.ignoreFilters.has(key)) {
+				continue;
+			}
+			if (value === null || value === undefined || value === '') continue;
+			if (value === 'all') continue;
+
+			if (typeof value === 'string' && value.includes(',')) {
+				const accepted = value.split(',').map(v => v.trim());
+				filterPredicates.push(item => accepted.includes(String(item[key])));
+			} else {
+				filterPredicates.push(item => String(item[key]) === String(value));
+			}
+		}
+
+		const filtered = allItems.filter(item => {
+			for (const predicate of filterPredicates) {
+				if (!predicate(item)) return false;
+			}
+			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;
+
+		const orderby = store.filters.orderby || 'date';
+		const order = (store.filters.order || 'desc').toLowerCase();
+
+		// Handle random ordering
+		if (['random', 'rand'].includes(orderby) || ['random', 'rand'].includes(order)) {
+			return this.shuffle(items);
+		}
+
+		items.sort((a, b) => {
+			let aVal, bVal;
+
+			switch (orderby) {
+				case 'alphabetical':
+				case 'title':
+					aVal = (a.title || a.name || '').toLowerCase();
+					bVal = (b.title || b.name || '').toLowerCase();
+					break;
+				case 'modified':
+					aVal = new Date(a.modified || a.date || 0);
+					bVal = new Date(b.modified || b.date || 0);
+					break;
+				case 'date':
+				default:
+					aVal = new Date(a.date || a.modified || 0);
+					bVal = new Date(b.date || b.modified || 0);
+			}
+
+			if (aVal < bVal) return order === 'asc' ? -1 : 1;
+			if (aVal > bVal) return order === 'asc' ? 1 : -1;
+			return 0;
+		});
+
+		return items;
+	}
+
+	shuffle(items) {
+		const array = items.slice();
+		for (let i = array.length - 1; i > 0; i--) {
+			const j = Math.floor(Math.random() * (i + 1));
+			[array[i], array[j]] = [array[j], array[i]];
+		}
+		return array;
+	}
+
+	searchObject(obj, search) {
+		if (!obj || typeof obj !== 'object') {
+			return typeof obj === 'string' && obj.toLowerCase().includes(search);
+		}
+
+		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) {
@@ -919,124 +1230,198 @@
 		store.data.clear();
 		store.cache.clear();
 
-		if (store.db) {
-			const tx = store.db.transaction([store.config.storeName], 'readwrite');
-			const objectStore = tx.objectStore(store.config.storeName);
-			await objectStore.clear();
-		}
+		await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => {
+			objectStore.clear();
+		});
 
 		this.notify(name, 'data-cleared');
 	}
 
-	setFilter(name, key, value) {
+	/***********************************************************************
+	 * FILTER OPERATIONS
+	 ***********************************************************************/
+	async updateFilters(name, updates, clearAll = false) {
 		const store = this.stores.get(name);
-		const oldValue = store.filters[key];
+		const oldFilters = { ...store.filters };
 
-		if (value === null || value === undefined || value === '') {
-			delete store.filters[key];
-		} else {
-			store.filters[key] = value;
+		if (clearAll) {
+			store.filters = { ...store.config.filters };
 		}
+
+		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,
-			changed: { key, oldValue, newValue: value }
+			updates
 		});
 
-		if (store.config.endpoint) {
-			this.fetch(name);
+		const shouldFetch = await this.shouldFetchWithFilters(name, updates, oldFilters);
+
+		if (store.config.endpoint && shouldFetch) {
+			await this.fetch(name);
+		} else {
+			const filtered = this.getFiltered(name);
+			this.notify(name, 'data-loaded', {
+				cached: true,
+				items: filtered
+			});
 		}
 	}
 
+	/**
+	 * 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) {
+			if (this.hasCompleteData(store, store.filters)) {
+				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 });
+	}
+
 	async setFilters(name, filters) {
 		const store = this.stores.get(name);
 
 		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;
 
-		store.filters = { ...store.filters, ...filters };
-
-		this.notify(name, 'filters-changed', {
-			filters: store.filters,
-			changed: filters
-		});
-
-		if (store.config.endpoint) {
-			await this.fetch(name);
-		}
+		return this.updateFilters(name, filters);
 	}
 
 	removeFilter(name, key) {
-		const store = this.stores.get(name);
-		const oldValue = store.filters[key];
-
-		if (oldValue !== undefined) {
-			delete store.filters[key];
-
-			this.notify(name, 'filters-changed', {
-				filters: store.filters,
-				removed: { key, oldValue }
-			});
-
-			if (store.config.endpoint) {
-				this.fetch(name);
-			}
-		}
+		return this.updateFilters(name, { [key]: null });
 	}
 
 	clearFilters(name) {
-		const store = this.stores.get(name);
-		const oldFilters = { ...store.filters };
-
-		store.filters = { ...store.config.filters };
-
-		this.notify(name, 'filters-cleared', {
-			oldFilters,
-			filters: store.filters
-		});
-
-		if (store.config.endpoint) {
-			this.fetch(name);
-		}
+		return this.updateFilters(name, {}, true);
 	}
 
+	/***********************************************************************
+	 * CACHE OPERATIONS
+	 ***********************************************************************/
+
 	clearCache(name) {
 		const store = this.stores.get(name);
 		store.cache.clear();
 
-		if (store.db && store.db.objectStoreNames.contains('cache')) {
-			const tx = store.db.transaction(['cache'], 'readwrite');
-			const objectStore = tx.objectStore('cache');
-			objectStore.clear();
+		if (store.db?.objectStoreNames.contains('cache')) {
+			this.withTransaction(name, 'cache', 'readwrite', (objectStore) => {
+				objectStore.clear();
+			});
 		}
 
 		this.notify(name, 'cache-cleared');
 	}
 
-	clearHttpHeaders(name, cacheKey = null) {
-		const store = this.stores.get(name);
-
-		if (cacheKey) {
-			store.httpHeaders.delete(cacheKey);
-
-			if (store.db && store.db.objectStoreNames.contains('headers')) {
-				const tx = store.db.transaction(['headers'], 'readwrite');
-				const objectStore = tx.objectStore('headers');
-				objectStore.delete(cacheKey);
-			}
-		} else {
-			store.httpHeaders.clear();
-
-			if (store.db && store.db.objectStoreNames.contains('headers')) {
-				const tx = store.db.transaction(['headers'], 'readwrite');
-				const objectStore = tx.objectStore('headers');
-				objectStore.clear();
-			}
-		}
+	generateCacheKey(filters) {
+		const normalized = Object.keys(filters)
+			.sort()
+			.reduce((acc, key) => {
+				acc[key] = filters[key];
+				return acc;
+			}, {});
+		return JSON.stringify(normalized);
 	}
 
+	isCacheValid(entry, ttl) {
+		if (!entry || !entry.timestamp) return false;
+		const age = Date.now() - entry.timestamp;
+		return age < ttl;
+	}
+
+	/***********************************************************************
+	 * EVENT SYSTEM
+	 ***********************************************************************/
+
 	subscribe(name, callback) {
 		if (!this.subscribers.has(name)) {
 			this.subscribers.set(name, new Set());
@@ -1059,50 +1444,9 @@
 		});
 	}
 
-	storeResponseHeaders(name, key, response) {
-		const store = this.stores.get(name);
-
-		const headers = {
-			key,
-			etag: response.headers.get('ETag'),
-			lastModified: response.headers.get('Last-Modified'),
-			timestamp: Date.now()
-		};
-
-		store.httpHeaders.set(key, headers);
-
-		if (store.db && store.db.objectStoreNames.contains('headers')) {
-			const tx = store.db.transaction(['headers'], 'readwrite');
-			const objectStore = tx.objectStore('headers');
-			objectStore.put(headers);
-		}
-	}
-
-	async saveToCache(name, key, data) {
-		const store = this.stores.get(name);
-		if (!store.db || !store.db.objectStoreNames.contains('cache')) return;
-
-		const tx = store.db.transaction(['cache'], 'readwrite');
-		const objectStore = tx.objectStore('cache');
-		await objectStore.put(data);
-	}
-
-	generateCacheKey(filters) {
-		const normalized = Object.keys(filters)
-			.sort()
-			.reduce((acc, key) => {
-				acc[key] = filters[key];
-				return acc;
-			}, {});
-
-		return JSON.stringify(normalized);
-	}
-
-	isCacheValid(entry, ttl) {
-		if (!entry || !entry.timestamp) return false;
-		const age = Date.now() - entry.timestamp;
-		return age < ttl;
-	}
+	/***********************************************************************
+	 * UTILITIES
+	 ***********************************************************************/
 
 	getItemKey(item, keyPath) {
 		if (typeof keyPath === 'function') {
@@ -1144,6 +1488,10 @@
 }
 
 // Initialize singleton on DOMContentLoaded
-document.addEventListener('DOMContentLoaded', function() {
-	window.jvbStore = new DataStore();
+document.addEventListener('DOMContentLoaded', async function() {
+	window.auth.subscribe((event) => {
+		if (event === 'auth-loaded') {
+			window.jvbStore = new DataStore();
+		}
+	});
 });

--
Gitblit v1.10.0