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