From 42fa8304ddb811b0f725f245130f70c0f5e86a6c Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Tue, 04 Nov 2025 06:12:02 +0000
Subject: [PATCH] =Refactored LoginManager to be more extensible and configurable, as well as an AjaxRateLimiter

---
 assets/js/concise/DataStore.js | 1530 ++++++++++++++++++++++++++++++++--------------------------
 1 files changed, 851 insertions(+), 679 deletions(-)

diff --git a/assets/js/concise/DataStore.js b/assets/js/concise/DataStore.js
index 4f4ba59..fe9b22e 100644
--- a/assets/js/concise/DataStore.js
+++ b/assets/js/concise/DataStore.js
@@ -1,581 +1,765 @@
 /**
- * Handles GET Requests, storing responses by a key of filters, set with setFilter method
- * Stores:
- * 		- Items: the individual item data, mapped by postID/termID
- * 		- Cache: the cacheKey generated by filters, and the results in the value
- * 		- httpHeaders: If Modified Since tracking
- * 		- domCache: rendered DOM elements, to reduce on-page rendering
+ * ExtendedDataStore - A flexible IndexedDB wrapper with HTTP caching
+ *
+ * Configuration-based approach for different storage needs:
+ * - Configurable endpoint, keyPath, and indexes
+ * - Built-in ETag and If-Modified-Since support
+ * - Automatic DOM reference stripping
+ * - TTL-based cache invalidation
+ *
+ * All notifications:
+ *
+  		this.store.subscribe((event, data) => {
+  			switch (event) {
+  				case 'data-loaded':
+  					break;
+  				case 'item-saved':
+  					break;
+  				case 'items-saved':
+  					break;
+  				case 'item-deleted':
+  					break;
+  				case 'data-cleared':
+  					break;
+  				case 'filters-changed':
+  					break;
+  				case 'filters-cleared':
+  					break;
+  				case 'cache-cleared':
+  					break;
+  			}
+  		});
  */
 class DataStore {
 	constructor(config = {}) {
+		// Core configuration with sensible defaults
 		this.config = {
+			// Storage configuration
 			name: 'default',
-			endpoint: false,
+			version: 1,
+			storeName: 'items',
+			keyPath: 'id',
+			indexes: [], // Array of {name, keyPath, unique}
+
+			// API configuration
+			endpoint: null,
+			saveToServer: false,
 			apiBase: jvbSettings.api,
-			TTL: 3600000, // 1 hour default
-			showLoading: true,
 			headers: {},
 			filters: {},
+			required:  null, //any required filters before fetching
+			icon: null,
+			getBlobs: null,
+
+			// Cache configuration
+			TTL: 3600000, // 1 hour default
+			useHttpCaching: true, // ETag and If-Modified-Since
+			cacheKeyStrategy: 'filters', // How to generate cache keys
+
+			// UI configuration
+			showLoading: true,
+
+			// Features
+			stripDOMReferences: true,
+			storeBlobs: false,
+
 			...config
 		};
-		if (!this.config.endpoint) {
-			console.warn('No endpoint set. Only saving locally');
-		}
+
+		// Initialize base properties
+		this.db = null;
+		this.data = new Map();
+		this.cache = new Map();
+		this.isFetching = false;
+		this.pendingFetch = null;
+		this.httpHeaders = new Map();
+		this.subscribers = new Set();
+		this.currentRequest = null;
+		this.filters = this.config.filters??{};
+
+		// Set up headers
+		this.headers = {
+			'X-WP-Nonce': jvbSettings?.nonce,
+			...this.config.headers
+		};
 
 		this.body = document.body;
 		this.loading = document.querySelector('dialog.loading');
 
-		this.headers = {
-			'X-WP-Nonce': jvbSettings.nonce,
-			...this.config.headers
-		};
-
-		// Data stores
-		this.items = new Map();
-		this.cache = new Map(); //TODO: call this resultsCache
-		this.httpHeaders = new Map();
-		this.domCache = new Map();
-		this.forms = new Map();
-
-		// State management
-		this.filters = config.filters ?? {};
-		this.subscribers = new Set();
-		this.db = null;
-		this.currentRequest = null;
-
-		// Server Timestamps - needed?
-		this.cachedContent = JSON.parse(cacheJVB.cache) || {};
-		this.lastTimestampUpdate = Date.now();
-
+		// Auto-initialize
 		this.initDB();
-		document.addEventListener('beforeUnload', () =>this.destroy());
+
+		// Cleanup on page unload
+		window.addEventListener('beforeunload', () => this.destroy());
 	}
 
+	/**
+	 * Initialize IndexedDB with configurable schema
+	 */
 	async initDB() {
-		if (!('indexedDB' in window)) return;
+		if (!('indexedDB' in window)) {
+			console.warn('IndexedDB not supported');
+			return;
+		}
 
-		const request = indexedDB.open(`jvb_${this.config.name}_db`, 1);
+		const dbName = `jvb_${this.config.name}_db`;
+		const request = indexedDB.open(dbName, this.config.version);
 
 		request.onupgradeneeded = (e) => {
 			const db = e.target.result;
-			// Items store
-			if (!db.objectStoreNames.contains('items')) {
-				db.createObjectStore('items', { keyPath: 'id' });
-			}
 
-			// DOM cache for rendered elements
-			if (!db.objectStoreNames.contains('dom')) {
-				db.createObjectStore('dom', { keyPath: 'id' });
-			}
-
-			if (!db.objectStoreNames.contains('forms')) {
-				let forms = db.createObjectStore('forms', {
-					keyPath: 'formId',
+			// Create main store with configurable keyPath
+			if (!db.objectStoreNames.contains(this.config.storeName)) {
+				const store = db.createObjectStore(this.config.storeName, {
+					keyPath: this.config.keyPath
 				});
-				forms.createIndex('status', 'status', {unique:false});
-				forms.createIndex('operationId', 'operationId', {unique:false});
-				forms.createIndex('timestamp', 'timestamp', {unique:false});
+
+				// Add configured indexes
+				this.config.indexes.forEach(index => {
+					store.createIndex(
+						index.name,
+						index.keyPath || index.name,
+						{ unique: index.unique || false }
+					);
+				});
 			}
 
-			// Cache store for GET requests with endpoint index
-			if (!db.objectStoreNames.contains('cache')) {
+			// Cache store for HTTP responses
+			if (this.config.endpoint && !db.objectStoreNames.contains('cache')) {
 				const cacheStore = db.createObjectStore('cache', { keyPath: 'key' });
 				cacheStore.createIndex('timestamp', 'timestamp', { unique: false });
 				cacheStore.createIndex('endpoint', 'endpoint', { unique: false });
 				cacheStore.createIndex('filters', 'filters', { unique: false });
 			}
 
-			// HTTP headers store
-			if (!db.objectStoreNames.contains('headers')) {
+			// HTTP headers store for ETag/If-Modified-Since
+			if (this.config.useHttpCaching && !db.objectStoreNames.contains('headers')) {
 				db.createObjectStore('headers', { keyPath: 'key' });
 			}
+
+			if (this.config.storeBlobs && !db.objectStoreNames.contains('blobs')) {
+				db.createObjectStore('blobs', { keyPath: 'uploadId' });
+			}
+
+			// Call optional schema extension
+			if (this.config.onUpgrade) {
+				this.config.onUpgrade(db, e.oldVersion, e.newVersion);
+			}
 		};
 
-		request.onsuccess = (e) => {
+		request.onsuccess = async (e) => {
 			this.db = e.target.result;
-			this.loadFromDB();
+
+			// Load cache and headers BEFORE fetching (only if stores exist)
+			const loadTasks = [this.loadFromDB()];
+
+			if (this.db.objectStoreNames.contains('cache')) {
+				loadTasks.push(this.loadCache());
+			}
+
+			if (this.config.useHttpCaching && this.db.objectStoreNames.contains('headers')) {
+				loadTasks.push(this.loadHeaders());
+			}
+
+			await Promise.all(loadTasks);
+
+			this.notify('db-init');
+
+			// Now fetch if needed (cache might already have data)
+			if (this.config.endpoint) {
+				this.fetch();
+			}
 		};
 
 		request.onerror = (e) => {
-			console.error('IndexedDB error:', e);
+			console.error(`IndexedDB error for ${dbName}:`, e);
+			if (this.config.onError) {
+				this.config.onError(e);
+			}
 		};
 	}
 
+	/**
+	 * Load all data from IndexedDB
+	 */
 	async loadFromDB() {
 		if (!this.db) return;
 
-		try {
-			await Promise.all([
-				this.loadItems(),
-				this.loadCache(),
-				this.loadHeaders(),
-				this.loadDOMCache(),
-				this.loadForms()
-			]);
-		} catch (error) {
-			console.error('Error loading from DB:', error);
-		}
-	}
-
-	async loadItems() {
-		if (!this.db) return;
-
-		return new Promise((resolve) => {
-			const tx = this.db.transaction(['items'], 'readonly');
-			const store = tx.objectStore('items');
+		return new Promise(async (resolve, reject) => {
+			const tx = this.db.transaction([this.config.storeName], 'readonly');
+			const store = tx.objectStore(this.config.storeName);
 			const request = store.getAll();
 
-			request.onsuccess = (e) => {
-				e.target.result.forEach(item => {
-					this.items.set(item.id, item);
-				});
-				this.notify('items-loaded', { items: Array.from(this.items.values()) });
-				resolve();
-			};
-		});
-	}
+			request.onsuccess = async (e) => {
+				const items = e.target.result;
 
-	async loadCache() {
-		if (!this.db) return;
-
-		return new Promise((resolve) => {
-			const tx = this.db.transaction(['cache'], 'readonly');
-			const store = tx.objectStore('cache');
-			const request = store.getAll();
-
-			request.onsuccess = (e) => {
-				e.target.result.forEach(item => {
-					if (this.isCacheValid(item)) {
-						this.cache.set(item.key, item);
+				// Restore FormData for ALL items on startup
+				for (const item of items) {
+					if (item.data?._isFormData && this.config.getBlobs) {
+						item.data = await this.objectToFormData(item.data);
 					}
-				});
-				resolve();
+					const key = this.getItemKey(item);
+					this.data.set(key, item);
+				}
+
+				this.notify('data-loaded', { count: items.length });
+				resolve(items);
 			};
+
+			request.onerror = (e) => reject(e);
 		});
 	}
 
-	async loadHeaders() {
+
+
+	/**
+	 * Load main data from IndexedDB
+	 */
+	async loadData() {
 		if (!this.db) return;
 
-		return new Promise((resolve) => {
-			const tx = this.db.transaction(['headers'], 'readonly');
-			const store = tx.objectStore('headers');
+		return new Promise((resolve, reject) => {
+			const tx = this.db.transaction([this.config.storeName], 'readonly');
+			const store = tx.objectStore(this.config.storeName);
 			const request = store.getAll();
 
 			request.onsuccess = (e) => {
-				e.target.result.forEach(header => {
-					this.httpHeaders.set(header.key, header);
+				e.target.result.forEach(item => {
+					// Strip DOM references if needed
+					const cleaned = this.config.stripDOMReferences
+						? this.stripDOMReferences(item)
+						: item;
+
+					const key = this.getItemKey(cleaned);
+					this.data.set(key, cleaned);
 				});
 				resolve();
 			};
+
+			request.onerror = (e) => reject(e);
 		});
 	}
 
-	async loadDOMCache() {
-		if (!this.db) return;
-
-		return new Promise((resolve) => {
-			const tx = this.db.transaction(['dom'], 'readonly');
-			const store = tx.objectStore('dom');
-			const request = store.getAll();
-
-			request.onsuccess = (e) => {
-				e.target.result.forEach(domEntry => {
-					// Convert stored HTML back to DOM elements
-					const reconstructed = {};
-					Object.entries(domEntry.views).forEach(([viewName, html]) => {
-						const temp = document.createElement('div');
-						temp.innerHTML = html;
-						reconstructed[viewName] = temp.firstElementChild;
-					});
-					this.domCache.set(domEntry.id, reconstructed);
-				});
-				resolve();
-			};
-		});
-	}
-
-	async loadForms() {
-		if (!this.db) return;
-
-		return new Promise((resolve) => {
-			const tx = this.db.transaction(['forms'], 'readonly');
-			const store = tx.objectStore('forms');
-			const request = store.getAll();
-
-			request.onsuccess = (e) => {
-				e.target.result.forEach(form => {
-					this.forms.set(form.key, form);
-				});
-				resolve();
-			};
-		});
-	}
-
-	setLoading(on) {
-		this.body.classList.toggle('loading', on);
-		if (on) {
-			this.loading.showModal();
-		} else {
-			this.loading.close();
-		}
-
-	}
-
 	/**
-	 * Main fetch method with caching and conditional requests
+	 * Strip DOM references from an object (recursive)
 	 */
-	async fetch(endpoint = null, options = {}) {
+	stripDOMReferences(obj) {
+		if (!obj || typeof obj !== 'object') return obj;
+
+		// Handle arrays
+		if (Array.isArray(obj)) {
+			return obj.map(item => this.stripDOMReferences(item));
+		}
+
+		// Handle objects
+		const cleaned = {};
+		for (const [key, value] of Object.entries(obj)) {
+			// Skip DOM-related properties
+			if (this.isDOMReference(key, value)) {
+				continue;
+			}
+
+			// Handle Set/Map collections
+			if (value instanceof Set) {
+				cleaned[key] = Array.from(value);
+			} else if (value instanceof Map) {
+				cleaned[key] = Object.fromEntries(value);
+			} else if (typeof value === 'object' && value !== null) {
+				cleaned[key] = this.stripDOMReferences(value);
+			} else {
+				cleaned[key] = value;
+			}
+		}
+
+		return cleaned;
+	}
+
+	/**
+	 * Check if a property is a DOM reference
+	 */
+	isDOMReference(key, value) {
+		// Check value types
+		if (value instanceof HTMLElement ||
+			value instanceof NodeList ||
+			value instanceof HTMLCollection ||
+			(value && value.nodeType !== undefined)) {
+			return true;
+		}
+
+		// Check key names - use exact match or word boundaries
+		const domKeys = ['element', 'el', 'dom', 'node', 'ui', 'container', 'wrapper'];
+		const lowerKey = key.toLowerCase();
+
+		// Only match if it's the exact key OR starts/ends with the pattern
+		if (domKeys.includes(lowerKey) ||
+			domKeys.some(k => lowerKey === k || lowerKey.startsWith(k + '_') || lowerKey.endsWith('_' + k))) {
+			return true;
+		}
+
+		return false;
+	}
+
+	/**
+	 * Get the key for an item based on configured keyPath
+	 */
+	getItemKey(item) {
+		if (typeof this.config.keyPath === 'function') {
+			return this.config.keyPath(item);
+		}
+
+		// Support nested keypaths like 'meta.id'
+		const keys = this.config.keyPath.split('.');
+		let value = item;
+
+		for (const key of keys) {
+			value = value?.[key];
+		}
+
+		return value;
+	}
+
+	/**
+	 * Save a single item
+	 */
+	/**
+	 * Save a single item
+	 */
+	async save(item) {
+		const key = this.getItemKey(item);
+
+		// Keep ORIGINAL item in memory (with FormData intact)
+		this.data.set(key, item);  // ← Store original
+
+		// Create cleaned version ONLY for IndexedDB
+		let cleaned = { ...item };
+		if (cleaned.data instanceof FormData) {
+			cleaned.data = this.formDataToObject(cleaned.data);
+		}
+
+		if (this.config.stripDOMReferences) {
+			cleaned = this.stripDOMReferences(cleaned);
+		}
+
+		// Persist cleaned version to IndexedDB
+		await this.saveToDB(cleaned);
+
+		if(this.config.endpoint){
+			this.saveToServer(item);
+		}
+
+		this.notify('item-saved', { item: cleaned, key });
+
+		return cleaned;
+	}
+
+	/**
+	 * Convert FormData to plain object for storage
+	 */
+	formDataToObject(formData) {
+		const obj = {
+			_isFormData: true, // Flag to reconstruct later
+			entries: {}
+		};
+
+		for (const [key, value] of formData.entries()) {
+			// Skip File/Blob objects - they're stored separately
+			if (value instanceof File || value instanceof Blob) {
+				continue;
+			}
+
+			// Handle multiple values for same key
+			if (obj.entries[key]) {
+				if (!Array.isArray(obj.entries[key])) {
+					obj.entries[key] = [obj.entries[key]];
+				}
+				obj.entries[key].push(value);
+			} else {
+				obj.entries[key] = value;
+			}
+		}
+
+		return obj;
+	}
+
+	/**
+	 * Convert stored object back to FormData
+	 */
+	async objectToFormData(obj) {
+		if (!obj._isFormData) return obj;
+
+		const formData = new FormData();
+
+		for (const [key, value] of Object.entries(obj.entries)) {
+			if (Array.isArray(value)) {
+				value.forEach(v => formData.append(key, v));
+			} else {
+				formData.append(key, value);
+			}
+		}
+		// Restore files from external blob store (UploadManager)
+		if (this.config.getBlobs && obj.entries.upload_ids) {
+			const uploadIds = JSON.parse(obj.entries.upload_ids);
+			const blobs = await this.config.getBlobs(uploadIds);  // ← Await here
+
+			for (const blobData of blobs) {
+				if (blobData) {
+					const file = new File(
+						[blobData.data],
+						blobData.name,
+						{ type: blobData.type, lastModified: blobData.lastModified }
+					);
+					formData.append('files[]', file);
+				}
+			}
+		}
+
+		return formData;
+	}
+
+	/**
+	 * Save item to IndexedDB
+	 */
+	async saveToDB(item) {
+		if (!this.db) return;
+
+		return new Promise((resolve, reject) => {
+			const tx = this.db.transaction([this.config.storeName], 'readwrite');
+			const store = tx.objectStore(this.config.storeName);
+			const request = store.put(item);
+
+			request.onsuccess = () => resolve();
+			request.onerror = (e) => reject(e);
+		});
+	}
+
+	/**
+	 * Batch save multiple items
+	 */
+	async saveMany(items) {
+		if (!this.db) return;
+
+		const tx = this.db.transaction([this.config.storeName], 'readwrite');
+		const store = tx.objectStore(this.config.storeName);
+
+		const promises = items.map(item => {
+			const cleaned = this.config.stripDOMReferences
+				? this.stripDOMReferences(item)
+				: item;
+
+			const key = this.getItemKey(cleaned);
+			this.data.set(key, cleaned);
+
+			return store.put(cleaned);
+		});
+
+		await Promise.all(promises);
+		this.notify('items-saved', { count: items.length });
+	}
+
+	/**
+	 * Get a single item
+	 */
+	get(key) {
+		return this.data.get(key);  // ← Returns original with FormData
+	}
+
+	/**
+	 * Get all items
+	 */
+	getAll() {
+		return Array.from(this.data.values());
+	}
+
+	/**
+	 * Delete an item
+	 */
+	async delete(key, storeName = null) {
+		this.data.delete(key);
+
+		if (!storeName) {
+			storeName = this.config.storeName;
+		}
+		if (this.db) {
+			const tx = this.db.transaction([storeName], 'readwrite');
+			const store = tx.objectStore(storeName);
+			await store.delete(key);
+		}
+
+		this.notify('item-deleted', { key });
+	}
+
+	async saveBlob(key, blob) {
+		if (!this.db) return;
+
+		const tx = this.db.transaction(['blobs'], 'readwrite');
+		const store = tx.objectStore('blobs');
+		await store.put({
+			uploadId: key,  // Match keyPath
+			data: blob,
+			type: blob.type,
+			name: blob.name,
+			lastModified: blob.lastModified || Date.now()
+		});
+	}
+
+	async getBlob(key) {
+		if (!this.db) return null;
+
+		return new Promise(resolve => {
+			const tx = this.db.transaction(['blobs'], 'readonly');
+			const request = tx.objectStore('blobs').get(key);
+			request.onsuccess = () => resolve(request.result);
+			request.onerror = () => resolve(null);
+		});
+	}
+
+	/**
+	 * Clear all data
+	 */
+	async clear() {
+		this.data.clear();
+		this.cache.clear();
+		this.httpHeaders.clear();
+
+		if (this.domCache) {
+			this.domCache.clear();
+		}
+
+		if (this.db) {
+			const stores = [this.config.storeName];
+			if (this.config.endpoint) stores.push('cache');
+			if (this.config.useHttpCaching) stores.push('headers');
+
+			const tx = this.db.transaction(stores, 'readwrite');
+			stores.forEach(storeName => {
+				if (this.db.objectStoreNames.contains(storeName)) {
+					tx.objectStore(storeName).clear();
+				}
+			});
+		}
+
+		this.notify('data-cleared');
+	}
+
+	/**
+	 * Fetch data from server with HTTP caching
+	 */
+	async fetch(options = {}) {
+		if (!this.config.endpoint) {
+			throw new Error('No endpoint configured for fetch');
+		}
 		const {
 			filters = this.filters,
 			headers = {},
 		} = options;
 
+		if (this.config.required && this.filters[this.config.required] === ''){
+			console.log(this.config.storeName+ ': Not fetch as we don\'t have the required items');
+			return;
+		}
+
+		// PREVENT CONCURRENT FETCHES FOR SAME DATA
+		const cacheKey = this.generateCacheKey(filters);
+		console.log('CacheKey: ', cacheKey);
+
+		// If already fetching this exact query, return a promise that resolves when done
+		if (this.isFetching && this.currentCacheKey === cacheKey) {
+			return new Promise((resolve) => {
+				// Store multiple waiting promises if needed
+				if (!this.pendingFetches) {
+					this.pendingFetches = [];
+				}
+				this.pendingFetches.push(resolve);
+			});
+		}
+
+		this.isFetching = true;
+		this.currentCacheKey = cacheKey;
+		let fetchResult = null; // Capture result for pending fetches
+
 		if (this.config.showLoading) {
 			this.setLoading(true);
 		}
 
-
-		// Use provided endpoint or config endpoint
-		const apiEndpoint = endpoint || this.config.endpoint;
-		if (!apiEndpoint) {
-			throw new Error('No endpoint specified');
+		//Check Cached data
+		const cachedData = this.cache.get(cacheKey);
+		console.log('Cached Data: ', cachedData);
+		if (cachedData && this.isCacheValid(cachedData)) {
+			console.log('Returning cached data: ');
+			this.isFetching = false;
+			this.currentCacheKey = null;
+			if (this.config.showLoading) {
+				this.setLoading(false);
+			}
+			return cachedData.data;
 		}
 
-		// Generate cache key from endpoint and filters
-		const cacheKey = this.generateCacheKey(apiEndpoint, filters);
-		const cleanedFilters = this.cleanFilters(filters);
-
-		// Build request URL
-		const params = new URLSearchParams(cleanedFilters);
-		const url = `${this.config.apiBase}${apiEndpoint}${params.toString() ? '?' + params : ''}`;
-
-		// Prepare headers with conditional requests
+		// Build request headers with HTTP caching
 		const requestHeaders = {
 			...this.headers,
 			...headers
 		};
 
-		// Add conditional headers from stored data
-		const headerKey = this.generateHeaderKey(url);
-		const storedHeaders = this.httpHeaders.get(headerKey);
-		const cachedData = this.cache.get(cacheKey);
-
-		if (storedHeaders && cachedData) {
-			if (storedHeaders.etag) {
-				requestHeaders['If-None-Match'] = storedHeaders.etag;
-			}
-			if (storedHeaders.lastModified) {
-				requestHeaders['If-Modified-Since'] = storedHeaders.lastModified;
+		if (this.config.useHttpCaching) {
+			const httpCache = this.httpHeaders.get(cacheKey);
+			if (httpCache) {
+				if (httpCache.etag) {
+					requestHeaders['If-None-Match'] = httpCache.etag;
+				}
+				if (httpCache.lastModified) {
+					requestHeaders['If-Modified-Since'] = httpCache.lastModified;
+				}
 			}
 		}
 
+		// Build URL with filters
+		const cleanedFilters = this.cleanFilters(filters);
+		const params = new URLSearchParams(cleanedFilters);
+		const url = `${this.config.apiBase}${this.config.endpoint}${params.toString() ? '?' + params : ''}`;
+
 		try {
 			const response = await fetch(url, {
 				method: 'GET',
 				headers: requestHeaders
 			});
 
-			console.log('DataStore response status: ',response.status);
-			// Handle 304 Not Modified - return cached data
-			if (response.status === 304) {
-				console.debug(`304 Not Modified for ${url}`);
-				if (cachedData) {
-					// Update timestamp but keep data
-					cachedData.timestamp = Date.now();
-					this.cache.set(cacheKey, cachedData);
-					await this.saveCacheToDB(cacheKey, cachedData);
-
-					// Store current request info
-					this.currentRequest = {
-						filters: cleanedFilters,
-						data: cachedData.data,
-						cached: true
-					};
-
-					//TODO: should this be items-loaded?
-					this.notify('data-cached', {
-						data: cachedData.data,
-						filters: cleanedFilters,
-						cached: true
-					});
-					return cachedData.data;
-				}
+			// Handle 304 Not Modified
+			if (response.status === 304 && cachedData) {
+				// Update timestamp but keep existing data
+				cachedData.timestamp = Date.now();
+				cachedData.fromCache = true;
+				cachedData.isError = false;
+				this.saveCache(cacheKey, cachedData);
+				console.log(this.config.storeName+' Data loaded from cache');
+				this.notify('data-loaded', cachedData);
+				fetchResult = cachedData.data;
+				return cachedData.data;
 			}
 
 			if (!response.ok) {
 				throw new Error(`HTTP ${response.status}: ${response.statusText}`);
 			}
 
-			// Store response headers for future conditional requests
-			this.storeResponseHeaders(headerKey, response);
-
 			const data = await response.json();
 
-			console.log('Fetched data: ', data);
+			// Store HTTP caching headers
+			if (this.config.useHttpCaching) {
+				this.storeResponseHeaders(cacheKey, response);
+			}
 
 			// Cache the response
 			const cacheEntry = {
 				key: cacheKey,
-				endpoint: apiEndpoint,
 				data: data,
 				timestamp: Date.now(),
-				filters: cleanedFilters
+				endpoint: this.config.endpoint,
+				filters: filters
 			};
+			console.log(this.config.storeName + 'Fetched fresh from server');
 
 			this.cache.set(cacheKey, cacheEntry);
-			await this.saveCacheToDB(cacheKey, cacheEntry);
+			this.saveCache(cacheKey, cacheEntry);
 
-			// Update items if data contains them
-			if (data.items && this.config.endpoint === apiEndpoint) {
-				this.updateItems(data.items);
-			}
+			let items = (Array.isArray(data)) ? data : data.items;
+			await this.saveMany(items);
 
-			// Store current request info
-			this.currentRequest = {
-				filters: cleanedFilters,
-				data: data,
-				cached: false
-			};
-
-			this.notify('data-fetched', {
-				endpoint: apiEndpoint,
-				data: data,
-				filters: cleanedFilters
+			this.notify('data-loaded', {
+				data: {
+					items: items,
+					...data
+				},
+				count: items.length,
+				filters: filters,
+				fromCache: false,
+				isError: false
 			});
+
+			fetchResult = data;
 			return data;
 
 		} catch (error) {
 			console.error('Fetch error:', error);
 
-			// Try to return stale cache on error
+			// Return cached data if available, even if expired
 			if (cachedData) {
-				console.warn('Returning stale cache due to fetch error');
-				this.currentRequest = {
-					filters: cleanedFilters,
-					data: cachedData.data,
-					cached: true,
-					stale: true
-				};
-				this.notify('stale-cache-used', {
-					data: cachedData.data,
-					filters: cleanedFilters
-				});
+				console.warn('Using stale cache due to fetch error');
+				cachedData.isError = true;
+				this.notify('data-loaded', cachedData);
+				fetchResult = cachedData.data;
 				return cachedData.data;
 			}
 
-			this.notify('fetch-error', { error, filters: cleanedFilters });
 			throw error;
 		} finally {
 			if (this.config.showLoading) {
 				this.setLoading(false);
 			}
+
+			this.isFetching = false;
+			this.currentCacheKey = null;
+
+			// Resolve any pending fetches that were waiting
+			if (this.pendingFetches && this.pendingFetches.length > 0) {
+				this.pendingFetches.forEach(resolve => resolve(fetchResult));
+				this.pendingFetches = [];
+			}
 		}
 	}
 
 	/**
-	 * Update items in local store
+	 * Fetch data from server with HTTP caching
 	 */
-	updateItems(items) {
-		this.items.clear();
-		items.forEach(item => {
-			this.items.set(item.id, item);
-		});
-		this.saveItemsToDB();
-		this.notify('items-updated', { items });
-	}
-
-	/**
-	 * Get current request data and state
-	 */
-	getCurrentRequest() {
-		return this.currentRequest;
-	}
-
-	/**
-	 * Get a specific item by ID
-	 */
-	getItem(id) {
-		let check = parseInt(id);
-		id = isNaN(check) ? id : check;
-		const item = this.items.get(id);
-		return item ? this.unserializeData(item) : null;
-	}
-
-	setItem(id, data, mergeExisting = true) {
-		if (mergeExisting && this.items.has(id)) {
-			let existing = this.getItem(id); // Get unserialized version
-			data = window.deepMerge(existing, data);
+	async saveToServer(item) {
+		if (!this.config.saveToServer || !jvbSettings.currentUser) {
+			return;
+		}
+		if (!this.config.endpoint && this.config.saveToServer) {
+			throw new Error('No endpoint configured for saving to server');
 		}
 
-		const serialized = this.serializeData(data);
-		this.items.set(id, serialized); // Store serialized version
-		this.saveItemsToDB();
-		this.notify('item-stored', data); // Notify with original data
-		return data;
-	}
+		let requestBody;
+		let headers = this.config.headers;
+		headers['X-WP-Nonce'] = jvbSettings.nonce;
+		if (item instanceof FormData) {
+			item.append('user', jvbSettings.currentUser);
+			requestBody = item;
 
-	hasUnrecoverableFiles(data) {
-		if (!data || typeof data !== 'object') return false;
+			// console.log('Sending formData: ');
+			// for (const pair of requestBody.entries()) {
+			// 	console.log(pair[0], pair[1]);
+			// }
+		} else {
+			requestBody = JSON.stringify({
+				...item,
+				user: jvbSettings.currentUser
+			});
+			// console.log('Sending data: ', {
+			// 	...operation.data,
+			// 	id: operation.id,
+			// 	user: this.user
+			// });
 
-		if (data._wasFile || data._wasBlob) return true;
-
-		if (Array.isArray(data)) {
-			return data.some(item => this.hasUnrecoverableFiles(item));
+			headers['Content-Type'] = 'application/json';
 		}
 
-		if (data instanceof FormData) {
-			for (const [key, value] of data.entries()) {
-				if (value instanceof File || value instanceof Blob) return true;
+		const response = await fetch(
+			`${this.config.apiBase}${this.config.endpoint}`,
+			{
+				method: 'POST',
+				headers: headers,
+				body: requestBody
 			}
-			return false;
-		}
+		);
 
-		return Object.values(data).some(value => this.hasUnrecoverableFiles(value));
-	}
-
-	serializeFormData(formData) {
-		const obj = {};
-
-		for (const [key, value] of formData.entries()) {
-			// Handle file metadata (can't store actual file)
-			if (value instanceof File) {
-				continue;
+		const result = await response.json();
+		this.notify(
+			'saved-to-server',
+			{
+				success: result.ok && result.success
 			}
-			// Check if key already exists (for multiple values)
-			if (key in obj) {
-				// Convert to array if not already
-				if (!Array.isArray(obj[key])) {
-					obj[key] = [obj[key]];
-				}
-				obj[key].push(value);
-			} else {
-				obj[key] = value;
-			}
-		}
-		return obj;
+		);
 	}
 
-	serializeData(data) {
-		if (!data) return null;
-
-		if (data instanceof HTMLElement) {
-			return null;
-		}
-		if (typeof data !== 'object') return data;
-
-		if (data === null) return null;
-
-		if (data instanceof FormData) {
-			return {
-				_type: 'FormData',
-				... this.serializeFormData(data)
-			};
-		}
-
-		// Handle Arrays
-		if (Array.isArray(data)) {
-			return data.map(item => this.serializeData(item));
-		}
-
-		// Handle Date objects
-		if (data instanceof Date) {
-			return {
-				_type: 'Date',
-				value: data.toISOString()
-			};
-		}
-
-		// Handle plain objects
-		const output = {};
-		for (const [key, value] of Object.entries(data)) {
-			output[key] = this.serializeData(value);
-		}
-		return output;
-	}
-
-	unserializeData(data) {
-		if (!data || typeof data !== 'object') return data;
-		if (data === null) return null;
-
-		// Check for special types
-		if (data._type) {
-			switch (data._type) {
-				case 'FormData':
-					return this.unserializeFormData(data);
-				case 'File':
-					// Can't reconstruct File, return metadata with warning flag
-					return {
-						_wasFile: true,
-						_fileMetadata: data,
-						name: data.name,
-						type: data.type,
-						size: data.size
-					};
-				case 'Blob':
-					// Can't reconstruct Blob
-					return {
-						_wasBlob: true,
-						_blobMetadata: data,
-						type: data.type,
-						size: data.size
-					};
-				case 'Date':
-					return new Date(data.value);
-			}
-		}
-
-		// Handle Arrays
-		if (Array.isArray(data)) {
-			return data.map(item => this.unserializeData(item));
-		}
-
-		// Handle plain objects
-		const output = {};
-		for (const [key, value] of Object.entries(data)) { // Fixed: 'of' not 'in'
-			output[key] = this.unserializeData(value);
-		}
-		return output;
-	}
-	unserializeFormData(data) {
-		const formData = new FormData();
-
-		for (const [key, value] of Object.entries(data)) {
-			if (Array.isArray(value)) {
-				value.forEach(item => {
-					if (item?._isFile) {
-						console.warn(`Cannot restore file "${item.name}" from stored data`);
-						// Optionally append metadata as JSON string for reference
-						formData.append(key + '_was_file', JSON.stringify(item));
-					} else {
-						formData.append(key, item);
-					}
-				});
-			} else if (value?._isFile) {
-				console.warn(`Cannot restore file "${value.name}" from stored data`);
-				// Optionally append metadata as JSON string for reference
-				formData.append(key + '_was_file', JSON.stringify(value));
-			} else if (value !== null && value !== undefined) {
-				formData.append(key, value);
-			}
-		}
-
-		return formData;
-	}
-
-
-	clearItem(key) {
-		this.items.delete(key);
-		if (this.db) {
-			const tx = this.db.transaction(['items'], 'readwrite');
-			const store = tx.objectStore('items');
-			store.delete(key);
-		}
-	}
-
-	/**
-	 * Filter helpers
-	 */
 	cleanFilters(filters) {
 		const cleaned = {};
 		Object.entries(filters).forEach(([key, value]) => {
@@ -600,10 +784,33 @@
 		return cleaned;
 	}
 
-	setFilter(key, value) {
-		const oldValue = this.filters[key];
+	/**
+	 * Generate cache key from filters
+	 */
+	generateCacheKey(filters) {
+		if (this.config.cacheKeyStrategy === 'custom' && this.config.generateCacheKey) {
+			return this.config.generateCacheKey(filters);
+		}
 
-		if (value === '' || value === null || value === undefined) {
+		// Default strategy: sort keys and create string
+		const sorted = Object.keys(filters)
+			.sort()
+			.reduce((acc, key) => {
+				acc[key] = filters[key];
+				return acc;
+			}, {});
+
+		return JSON.stringify(sorted);
+	}
+
+	setFilter(key, value) {
+		if (!this.filters) {
+			this.filters = {};
+		}
+		const oldValue = this.filters[key];
+		if (oldValue === value) {
+			return;
+		}else if (value === '' || value === null || value === undefined) {
 			delete this.filters[key];
 		} else {
 			this.filters[key] = value;
@@ -616,10 +823,15 @@
 
 		// Auto-fetch if endpoint is configured
 		if (this.config.endpoint) {
-			this.fetch();
+			window.debouncer.schedule(
+				this.config.endpoint,
+				this.fetch.bind(this),
+				100
+			);
 		}
 	}
 
+
 	/**
 	 * Remove a filter
 	 */
@@ -635,7 +847,11 @@
 
 			// Auto-fetch if endpoint is configured
 			if (this.config.endpoint) {
-				this.fetch();
+				window.debouncer.schedule(
+					this.config.endpoint,
+					this.fetch.bind(this),
+					100
+				);
 			}
 		}
 	}
@@ -660,25 +876,47 @@
 	}
 
 	/**
-	 * Cache management
+	 * Set multiple filters at once
 	 */
-	generateCacheKey(endpoint, filters) {
-		const sorted = Object.keys(filters).sort().reduce((obj, key) => {
-			obj[key] = filters[key];
-			return obj;
-		}, {});
-		return `${endpoint}_${JSON.stringify(sorted)}`;
+	async setFilters(filters) {
+		const hasChanges = Object.keys(filters).some(
+			key => this.filters[key] !== filters[key]
+		);
+
+		if (!hasChanges) {
+			return;
+		}
+
+		this.filters = { ...this.filters, ...filters };
+
+		this.notify('filters-changed', {
+			filters: this.filters,
+			changed: filters,
+		});
+
+		// Only fetch if endpoint configured
+		if (this.config.endpoint) {
+			window.debouncer.schedule(
+				this.config.endpoint,
+				this.fetch.bind(this),
+				100
+			);
+		}
 	}
 
-	generateHeaderKey(url) {
-		return `headers_${url}`;
-	}
-
-	isCacheValid(cacheEntry, maxAge = this.config.TTL) {
+	/**
+	 * Check if cache entry is still valid
+	 */
+	isCacheValid(cacheEntry) {
 		if (!cacheEntry || !cacheEntry.timestamp) return false;
-		return (Date.now() - cacheEntry.timestamp) < maxAge;
+
+		const age = Date.now() - cacheEntry.timestamp;
+		return age < this.config.TTL;
 	}
 
+	/**
+	 * Store HTTP response headers for caching
+	 */
 	storeResponseHeaders(key, response) {
 		const headers = {
 			key,
@@ -688,10 +926,168 @@
 		};
 
 		this.httpHeaders.set(key, headers);
-		this.saveHeadersToDB(key, headers);
+
+		if (this.db && this.db.objectStoreNames.contains('headers')) {
+			const tx = this.db.transaction(['headers'], 'readwrite');
+			const store = tx.objectStore('headers');
+			store.put(headers);
+		}
+	}
+
+	/**
+	 * Save cache entry to IndexedDB
+	 */
+	async saveCache(key, data) {
+		if (!this.db || !this.db.objectStoreNames.contains('cache')) return;
+
+		const tx = this.db.transaction(['cache'], 'readwrite');
+		const store = tx.objectStore('cache');
+		await store.put(data);
+	}
+
+	/**
+	 * Load cache from IndexedDB
+	 */
+	async loadCache() {
+		if (!this.db) return;
+
+		return new Promise((resolve) => {
+			const tx = this.db.transaction(['cache'], 'readonly');
+			const store = tx.objectStore('cache');
+			const request = store.getAll();
+
+			request.onsuccess = (e) => {
+				e.target.result.forEach(item => {
+					if (this.isCacheValid(item)) {
+						this.cache.set(item.key, item);
+					}
+				});
+				resolve();
+			};
+		});
+	}
+
+	/**
+	 * Load HTTP headers from IndexedDB
+	 */
+	async loadHeaders() {
+		if (!this.db) return;
+
+		return new Promise((resolve) => {
+			const tx = this.db.transaction(['headers'], 'readonly');
+			const store = tx.objectStore('headers');
+			const request = store.getAll();
+
+			request.onsuccess = (e) => {
+				e.target.result.forEach(header => {
+					this.httpHeaders.set(header.key, header);
+				});
+				resolve();
+			};
+		});
 	}
 
 
+	/**
+	 * Subscribe to store events
+	 */
+	subscribe(callback) {
+		this.subscribers.add(callback);
+		return () => this.subscribers.delete(callback);
+	}
+
+	/**
+	 * Notify subscribers of events
+	 */
+	notify(event, data = {}) {
+		this.subscribers.forEach(callback => {
+			try {
+				callback(event, data);
+			} catch (error) {
+				console.error('Subscriber error:', error);
+			}
+		});
+	}
+
+	/**
+	 * Query items using an index
+	 */
+	async query(indexName, value) {
+		if (!this.db) return [];
+
+		return new Promise((resolve, reject) => {
+			const tx = this.db.transaction([this.config.storeName], 'readonly');
+			const store = tx.objectStore(this.config.storeName);
+
+			if (!store.indexNames.contains(indexName)) {
+				reject(new Error(`Index ${indexName} does not exist`));
+				return;
+			}
+
+			const index = store.index(indexName);
+			const request = value !== undefined
+				? index.getAll(value)
+				: index.getAll();
+
+			request.onsuccess = (e) => {
+				const results = e.target.result.map(item => {
+					return this.config.stripDOMReferences
+						? this.stripDOMReferences(item)
+						: item;
+				});
+				resolve(results);
+			};
+
+			request.onerror = (e) => reject(e);
+		});
+	}
+
+	/**
+	 * Count items in store
+	 */
+	async count() {
+		if (!this.db) return this.data.size;
+
+		return new Promise((resolve, reject) => {
+			const tx = this.db.transaction([this.config.storeName], 'readonly');
+			const store = tx.objectStore(this.config.storeName);
+			const request = store.count();
+
+			request.onsuccess = (e) => resolve(e.target.result);
+			request.onerror = (e) => reject(e);
+		});
+	}
+
+
+	setLoading(on) {
+		console.log('Setting Loading ' + (on) ? 'on' : 'off' + ' from '.this.config.storeName);
+		this.body.classList.toggle('loading', on);
+		if (on) {
+			this.loading.showModal();
+		} else {
+			this.loading.close();
+		}
+
+	}
+
+	/**
+	 * Cleanup and destroy
+	 */
+	destroy() {
+		if (this.currentRequest) {
+			this.currentRequest.abort();
+		}
+
+		this.subscribers.clear();
+		this.data.clear();
+		this.cache.clear();
+		this.httpHeaders.clear();
+
+		if (this.db) {
+			this.db.close();
+			this.db = null;
+		}
+	}
 
 	clearCache() {
 		this.cache.clear();
@@ -704,231 +1100,7 @@
 
 		this.notify('cache-cleared');
 	}
-
-	invalidateCache(pattern) {
-		const keysToDelete = [];
-
-		this.cache.forEach((value, key) => {
-			if (typeof pattern === 'string' && key.includes(pattern)) {
-				keysToDelete.push(key);
-			} else if (pattern instanceof RegExp && pattern.test(key)) {
-				keysToDelete.push(key);
-			}
-		});
-
-		keysToDelete.forEach(key => {
-			this.cache.delete(key);
-			if (this.db) {
-				const tx = this.db.transaction(['cache'], 'readwrite');
-				const store = tx.objectStore('cache');
-				store.delete(key);
-			}
-		});
-
-		this.notify('cache-invalidated', { count: keysToDelete.length });
-	}
-
-	/**
-	 * DOM Cache Management
-	 */
-
-	/**
-	 * Store rendered DOM element for a specific item and view
-	 */
-	storeDOMElement(itemId, viewName, element) {
-		if (!this.domCache.has(itemId)) {
-			this.domCache.set(itemId, {});
-		}
-
-		const itemCache = this.domCache.get(itemId);
-		itemCache[viewName] = element.cloneNode(true);
-		this.domCache.set(itemId, itemCache);
-
-		// Save to IndexedDB
-		this.saveDOMCacheToDB(itemId, itemCache);
-	}
-
-
-	/**
-	 * Retrieve cached DOM element for a specific item and view
-	 */
-	getDOMElement(itemId, viewName) {
-		const itemCache = this.domCache.get(itemId);
-		if (itemCache && itemCache[viewName]) {
-			return itemCache[viewName].cloneNode(true);
-		}
-		return null;
-	}
-
-	/**
-	 * Check if DOM element exists in cache
-	 */
-	hasDOMElement(itemId, viewName) {
-		const itemCache = this.domCache.get(itemId);
-		return itemCache && itemCache[viewName];
-	}
-
-	/**
-	 * Clear DOM cache for a specific item
-	 */
-	clearDOMCache(itemId) {
-		this.domCache.delete(itemId);
-
-		if (this.db) {
-			const tx = this.db.transaction(['dom'], 'readwrite');
-			const store = tx.objectStore('dom');
-			store.delete(itemId);
-		}
-	}
-
-	/**
-	 * Clear all DOM cache
-	 */
-	clearAllDOMCache() {
-		this.domCache.clear();
-
-		if (this.db) {
-			const tx = this.db.transaction(['dom'], 'readwrite');
-			const store = tx.objectStore('dom');
-			store.clear();
-		}
-	}
-
-	/**
-	 * Helper method to render or retrieve cached DOM elements
-	 */
-	renderOrRetrieve(item, viewName, renderFunction) {
-		// Check cache first
-		const cached = this.getDOMElement(item.id, viewName);
-		if (cached) {
-			return cached;
-		}
-
-		// Render new element
-		const element = renderFunction(item);
-
-		// Cache the rendered element
-		this.storeDOMElement(item.id, viewName, element);
-
-		return element;
-	}
-	/**
-	 * Database operations
-	 */
-	async saveItemsToDB() {
-		if (!this.db) return;
-
-		const tx = this.db.transaction(['items'], 'readwrite');
-		const store = tx.objectStore('items');
-
-		store.clear();
-		this.items.forEach(item => {
-			if (!item._deleted) {
-				store.put(item);
-			}
-		});
-	}
-
-	async saveCacheToDB(key, data) {
-		if (!this.db) return;
-
-		const tx = this.db.transaction(['cache'], 'readwrite');
-		const store = tx.objectStore('cache');
-		store.put(data);
-	}
-
-	async saveHeadersToDB(key, headers) {
-		if (!this.db) return;
-
-		const tx = this.db.transaction(['headers'], 'readwrite');
-		const store = tx.objectStore('headers');
-		store.put(headers);
-	}
-
-	async saveDOMCacheToDB(itemId, domCache) {
-		if (!this.db) return;
-
-		// Convert DOM elements to HTML strings for storage
-		const serialized = {
-			id: itemId,
-			views: {}
-		};
-
-		Object.entries(domCache).forEach(([viewName, element]) => {
-			if (element && element.outerHTML) {
-				serialized.views[viewName] = element.outerHTML;
-			}
-		});
-
-		const tx = this.db.transaction(['dom'], 'readwrite');
-		const store = tx.objectStore('dom');
-		store.put(serialized);
-	}
-
-	async saveFormsToDB(key, form) {
-		if (!this.db) return;
-
-		const tx = this.db.transaction(['forms'], 'readwrite');
-		const store = tx.objectStore('forms');
-		store.put(form);
-	}
-
-	storeForm(key, form) {
-		this.forms.set(key, form);
-		this.saveFormsToDB(key, form);
-	}
-
-	getForm(key) {
-		return this.forms.has(key) ? this.forms.get(key) : null;
-	}
-
-	getAllForms() {
-		return this.forms;
-	}
-
-	clearForm(key) {
-		this.forms.delete(key);
-		if (this.db) {
-			const tx = this.db.transaction(['forms'], 'readwrite');
-			const store = tx.objectStore('forms');
-			store.delete(key);
-		}
-	}
-
-	clearAllForms() {
-		this.forms.clear();
-		if (this.db) {
-			const tx = this.db.transaction(['forms'], 'readwrite');
-			const store = tx.objectStore('dom');
-			store.clear();
-		}
-	}
-
-	/**
-	 * Event system
-	 */
-	subscribe(callback) {
-		this.subscribers.add(callback);
-		return () => this.subscribers.delete(callback);
-	}
-
-	notify(event, data) {
-		this.subscribers.forEach(cb => cb(event, data));
-	}
-
-	/**
-	 * Cleanup
-	 */
-	destroy() {
-		if (this.db) {
-			this.db.close();
-		}
-		this.subscribers.clear();
-		this.items.clear();
-		this.cache.clear();
-		this.domCache.clear();
-		this.httpHeaders.clear();
-	}
 }
 
+// Export for use
 window.jvbStore = DataStore;

--
Gitblit v1.10.0