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 |  375 ++++++++++++++++++++++++++++++++++++++++++++++-------
 1 files changed, 325 insertions(+), 50 deletions(-)

diff --git a/assets/js/concise/DataStore.js b/assets/js/concise/DataStore.js
index 946bf0e..fe9b22e 100644
--- a/assets/js/concise/DataStore.js
+++ b/assets/js/concise/DataStore.js
@@ -6,6 +6,29 @@
  * - 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 = {}) {
@@ -20,9 +43,13 @@
 
 			// API configuration
 			endpoint: null,
+			saveToServer: false,
 			apiBase: jvbSettings.api,
 			headers: {},
 			filters: {},
+			required:  null, //any required filters before fetching
+			icon: null,
+			getBlobs: null,
 
 			// Cache configuration
 			TTL: 3600000, // 1 hour default
@@ -43,6 +70,8 @@
 		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;
@@ -118,9 +147,28 @@
 			}
 		};
 
-		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) => {
@@ -137,29 +185,33 @@
 	async loadFromDB() {
 		if (!this.db) return;
 
-		const loadPromises = [
-			this.loadData()
-		];
+		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();
 
-		if (this.config.endpoint) {
-			loadPromises.push(this.loadCache());
-		}
+			request.onsuccess = async (e) => {
+				const items = e.target.result;
 
-		if (this.config.useHttpCaching) {
-			loadPromises.push(this.loadHeaders());
-		}
+				// 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);
+					}
+					const key = this.getItemKey(item);
+					this.data.set(key, item);
+				}
 
-		try {
-			await Promise.all(loadPromises);
-			this.notify('data-loaded', {
-				count: this.data.size,
-				store: this.config.storeName
-			});
-		} catch (error) {
-			console.error('Error loading from DB:', error);
-		}
+				this.notify('data-loaded', { count: items.length });
+				resolve(items);
+			};
+
+			request.onerror = (e) => reject(e);
+		});
 	}
 
+
+
 	/**
 	 * Load main data from IndexedDB
 	 */
@@ -234,9 +286,13 @@
 			return true;
 		}
 
-		// Check key names
+		// Check key names - use exact match or word boundaries
 		const domKeys = ['element', 'el', 'dom', 'node', 'ui', 'container', 'wrapper'];
-		if (domKeys.some(k => key.toLowerCase().includes(k))) {
+		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;
 		}
 
@@ -265,27 +321,102 @@
 	/**
 	 * Save a single item
 	 */
+	/**
+	 * Save a single item
+	 */
 	async save(item) {
 		const key = this.getItemKey(item);
 
-		// Strip DOM references if configured
-		const cleaned = this.config.stripDOMReferences
-			? this.stripDOMReferences(item)
-			: item;
+		// Keep ORIGINAL item in memory (with FormData intact)
+		this.data.set(key, item);  // ← Store original
 
-		// Store in memory
-		this.data.set(key, cleaned);
+		// Create cleaned version ONLY for IndexedDB
+		let cleaned = { ...item };
+		if (cleaned.data instanceof FormData) {
+			cleaned.data = this.formDataToObject(cleaned.data);
+		}
 
-		// Persist to IndexedDB
+		if (this.config.stripDOMReferences) {
+			cleaned = this.stripDOMReferences(cleaned);
+		}
+
+		// Persist cleaned version to IndexedDB
 		await this.saveToDB(cleaned);
 
-		// Notify subscribers
+		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) {
@@ -329,7 +460,7 @@
 	 * Get a single item
 	 */
 	get(key) {
-		return this.data.get(key);
+		return this.data.get(key);  // ← Returns original with FormData
 	}
 
 	/**
@@ -362,7 +493,13 @@
 
 		const tx = this.db.transaction(['blobs'], 'readwrite');
 		const store = tx.objectStore('blobs');
-		await store.put({ key, data: blob, type: blob.type, name: blob.name });
+		await store.put({
+			uploadId: key,  // Match keyPath
+			data: blob,
+			type: blob.type,
+			name: blob.name,
+			lastModified: blob.lastModified || Date.now()
+		});
 	}
 
 	async getBlob(key) {
@@ -416,15 +553,44 @@
 			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);
 		}
 
-		const cacheKey = this.generateCacheKey(filters);
-
 		//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;
 		}
 
@@ -447,7 +613,6 @@
 		}
 
 		// 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 : ''}`;
@@ -462,7 +627,12 @@
 			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;
 			}
 
@@ -485,17 +655,26 @@
 				endpoint: this.config.endpoint,
 				filters: filters
 			};
+			console.log(this.config.storeName + 'Fetched fresh from server');
 
 			this.cache.set(cacheKey, cacheEntry);
 			this.saveCache(cacheKey, cacheEntry);
 
-			// Process and store items
-			if (Array.isArray(data)) {
-				await this.saveMany(data);
-			} else if (data.items) {
-				await this.saveMany(data.items);
-			}
+			let items = (Array.isArray(data)) ? data : data.items;
+			await this.saveMany(items);
 
+			this.notify('data-loaded', {
+				data: {
+					items: items,
+					...data
+				},
+				count: items.length,
+				filters: filters,
+				fromCache: false,
+				isError: false
+			});
+
+			fetchResult = data;
 			return data;
 
 		} catch (error) {
@@ -504,6 +683,9 @@
 			// Return cached data if available, even if expired
 			if (cachedData) {
 				console.warn('Using stale cache due to fetch error');
+				cachedData.isError = true;
+				this.notify('data-loaded', cachedData);
+				fetchResult = cachedData.data;
 				return cachedData.data;
 			}
 
@@ -512,9 +694,72 @@
 			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 = [];
+			}
 		}
 	}
 
+	/**
+	 * Fetch data from server with HTTP caching
+	 */
+	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');
+		}
+
+		let requestBody;
+		let headers = this.config.headers;
+		headers['X-WP-Nonce'] = jvbSettings.nonce;
+		if (item instanceof FormData) {
+			item.append('user', jvbSettings.currentUser);
+			requestBody = item;
+
+			// 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
+			// });
+
+			headers['Content-Type'] = 'application/json';
+		}
+
+		const response = await fetch(
+			`${this.config.apiBase}${this.config.endpoint}`,
+			{
+				method: 'POST',
+				headers: headers,
+				body: requestBody
+			}
+		);
+
+		const result = await response.json();
+		this.notify(
+			'saved-to-server',
+			{
+				success: result.ok && result.success
+			}
+		);
+	}
+
 	cleanFilters(filters) {
 		const cleaned = {};
 		Object.entries(filters).forEach(([key, value]) => {
@@ -563,8 +808,9 @@
 			this.filters = {};
 		}
 		const oldValue = this.filters[key];
-
-		if (value === '' || value === null || value === undefined) {
+		if (oldValue === value) {
+			return;
+		}else if (value === '' || value === null || value === undefined) {
 			delete this.filters[key];
 		} else {
 			this.filters[key] = value;
@@ -577,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
 	 */
@@ -596,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
+				);
 			}
 		}
 	}
@@ -623,10 +878,29 @@
 	/**
 	 * Set multiple filters at once
 	 */
-	setFilters(filters) {
+	async setFilters(filters) {
+		const hasChanges = Object.keys(filters).some(
+			key => this.filters[key] !== filters[key]
+		);
+
+		if (!hasChanges) {
+			return;
+		}
+
 		this.filters = { ...this.filters, ...filters };
-		if (this.config.autoFetch !== false) {
-			return this.fetch(this.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
+			);
 		}
 	}
 
@@ -653,7 +927,7 @@
 
 		this.httpHeaders.set(key, headers);
 
-		if (this.db) {
+		if (this.db && this.db.objectStoreNames.contains('headers')) {
 			const tx = this.db.transaction(['headers'], 'readwrite');
 			const store = tx.objectStore('headers');
 			store.put(headers);
@@ -664,7 +938,7 @@
 	 * Save cache entry to IndexedDB
 	 */
 	async saveCache(key, data) {
-		if (!this.db) return;
+		if (!this.db || !this.db.objectStoreNames.contains('cache')) return;
 
 		const tx = this.db.transaction(['cache'], 'readwrite');
 		const store = tx.objectStore('cache');
@@ -786,6 +1060,7 @@
 
 
 	setLoading(on) {
+		console.log('Setting Loading ' + (on) ? 'on' : 'off' + ' from '.this.config.storeName);
 		this.body.classList.toggle('loading', on);
 		if (on) {
 			this.loading.showModal();

--
Gitblit v1.10.0