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