/**
|
* 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
|
*/
|
class DataStore {
|
constructor(config = {}) {
|
this.config = {
|
name: 'default',
|
endpoint: false,
|
apiBase: jvbSettings.api,
|
TTL: 3600000, // 1 hour default
|
showLoading: true,
|
headers: {},
|
filters: {},
|
...config
|
};
|
if (!this.config.endpoint) {
|
console.warn('No endpoint set. Only saving locally');
|
}
|
|
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();
|
|
this.initDB();
|
document.addEventListener('beforeUnload', () =>this.destroy());
|
}
|
|
async initDB() {
|
if (!('indexedDB' in window)) return;
|
|
const request = indexedDB.open(`jvb_${this.config.name}_db`, 1);
|
|
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',
|
});
|
forms.createIndex('status', 'status', {unique:false});
|
forms.createIndex('operationId', 'operationId', {unique:false});
|
forms.createIndex('timestamp', 'timestamp', {unique:false});
|
}
|
|
// Cache store for GET requests with endpoint index
|
if (!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')) {
|
db.createObjectStore('headers', { keyPath: 'key' });
|
}
|
};
|
|
request.onsuccess = (e) => {
|
this.db = e.target.result;
|
this.loadFromDB();
|
};
|
|
request.onerror = (e) => {
|
console.error('IndexedDB error:', e);
|
};
|
}
|
|
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');
|
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();
|
};
|
});
|
}
|
|
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();
|
};
|
});
|
}
|
|
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();
|
};
|
});
|
}
|
|
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
|
*/
|
async fetch(endpoint = null, options = {}) {
|
const {
|
filters = this.filters,
|
headers = {},
|
} = options;
|
|
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');
|
}
|
|
// 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
|
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;
|
}
|
}
|
|
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;
|
}
|
}
|
|
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);
|
|
// Cache the response
|
const cacheEntry = {
|
key: cacheKey,
|
endpoint: apiEndpoint,
|
data: data,
|
timestamp: Date.now(),
|
filters: cleanedFilters
|
};
|
|
this.cache.set(cacheKey, cacheEntry);
|
await this.saveCacheToDB(cacheKey, cacheEntry);
|
|
// Update items if data contains them
|
if (data.items && this.config.endpoint === apiEndpoint) {
|
this.updateItems(data.items);
|
}
|
|
// Store current request info
|
this.currentRequest = {
|
filters: cleanedFilters,
|
data: data,
|
cached: false
|
};
|
|
this.notify('data-fetched', {
|
endpoint: apiEndpoint,
|
data: data,
|
filters: cleanedFilters
|
});
|
return data;
|
|
} catch (error) {
|
console.error('Fetch error:', error);
|
|
// Try to return stale cache on error
|
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
|
});
|
return cachedData.data;
|
}
|
|
this.notify('fetch-error', { error, filters: cleanedFilters });
|
throw error;
|
} finally {
|
if (this.config.showLoading) {
|
this.setLoading(false);
|
}
|
}
|
}
|
|
/**
|
* Update items in local store
|
*/
|
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);
|
}
|
|
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;
|
}
|
|
hasUnrecoverableFiles(data) {
|
if (!data || typeof data !== 'object') return false;
|
|
if (data._wasFile || data._wasBlob) return true;
|
|
if (Array.isArray(data)) {
|
return data.some(item => this.hasUnrecoverableFiles(item));
|
}
|
|
if (data instanceof FormData) {
|
for (const [key, value] of data.entries()) {
|
if (value instanceof File || value instanceof Blob) return true;
|
}
|
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;
|
}
|
// 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]) => {
|
if (value !== null && value !== undefined && value !== '') {
|
// Handle special cases based on existing patterns
|
if (key === 'taxonomies' && typeof value === 'object') {
|
Object.entries(value).forEach(([taxName, terms]) => {
|
if (Array.isArray(terms) && terms.length > 0) {
|
cleaned[`tax_${taxName}`] = terms.join(',');
|
} else if (terms) {
|
cleaned[`tax_${taxName}`] = terms;
|
}
|
});
|
} else if (key === 'date' && typeof value === 'object') {
|
if (value.after) cleaned.after = value.after;
|
if (value.before) cleaned.before = value.before;
|
} else {
|
cleaned[key] = value;
|
}
|
}
|
});
|
return cleaned;
|
}
|
|
setFilter(key, value) {
|
const oldValue = this.filters[key];
|
|
if (value === '' || value === null || value === undefined) {
|
delete this.filters[key];
|
} else {
|
this.filters[key] = value;
|
}
|
|
this.notify('filters-changed', {
|
filters: this.filters,
|
changed: { key, oldValue, newValue: value }
|
});
|
|
// Auto-fetch if endpoint is configured
|
if (this.config.endpoint) {
|
this.fetch();
|
}
|
}
|
|
/**
|
* Remove a filter
|
*/
|
removeFilter(key) {
|
const oldValue = this.filters[key];
|
|
if (oldValue !== undefined) {
|
delete this.filters[key];
|
this.notify('filters-changed', {
|
filters: this.filters,
|
removed: { key, oldValue }
|
});
|
|
// Auto-fetch if endpoint is configured
|
if (this.config.endpoint) {
|
this.fetch();
|
}
|
}
|
}
|
|
/**
|
* Clear all filters
|
*/
|
clearFilters() {
|
const oldFilters = { ...this.filters };
|
//Restore baseline filters
|
this.filters = this.config.filters;
|
|
this.notify('filters-cleared', {
|
oldFilters,
|
filters: this.filters
|
});
|
|
// Auto-fetch if endpoint is configured
|
if (this.config.endpoint) {
|
this.fetch();
|
}
|
}
|
|
/**
|
* Cache management
|
*/
|
generateCacheKey(endpoint, filters) {
|
const sorted = Object.keys(filters).sort().reduce((obj, key) => {
|
obj[key] = filters[key];
|
return obj;
|
}, {});
|
return `${endpoint}_${JSON.stringify(sorted)}`;
|
}
|
|
generateHeaderKey(url) {
|
return `headers_${url}`;
|
}
|
|
isCacheValid(cacheEntry, maxAge = this.config.TTL) {
|
if (!cacheEntry || !cacheEntry.timestamp) return false;
|
return (Date.now() - cacheEntry.timestamp) < maxAge;
|
}
|
|
storeResponseHeaders(key, response) {
|
const headers = {
|
key,
|
etag: response.headers.get('ETag'),
|
lastModified: response.headers.get('Last-Modified'),
|
timestamp: Date.now()
|
};
|
|
this.httpHeaders.set(key, headers);
|
this.saveHeadersToDB(key, headers);
|
}
|
|
|
|
clearCache() {
|
this.cache.clear();
|
|
if (this.db) {
|
const tx = this.db.transaction(['cache'], 'readwrite');
|
const store = tx.objectStore('cache');
|
store.clear();
|
}
|
|
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();
|
}
|
}
|
|
window.jvbStore = DataStore;
|