/**
|
* 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',
|
version: 1,
|
storeName: 'items',
|
keyPath: 'id',
|
indexes: [], // Array of {name, keyPath, unique}
|
|
// 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
|
useHttpCaching: true, // ETag and If-Modified-Since
|
cacheKeyStrategy: 'filters', // How to generate cache keys
|
|
// UI configuration
|
showLoading: true,
|
|
// Features
|
stripDOMReferences: true,
|
storeBlobs: false,
|
delayFetch: false,
|
|
...config
|
};
|
|
// 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._initialized = false;
|
// Cleanup on page unload
|
window.addEventListener('beforeunload', () => this.destroy());
|
}
|
|
async init() {
|
if (this._initialized) return;
|
await this.initDB();
|
this._initialized = true;
|
}
|
|
/**
|
* Initialize IndexedDB with configurable schema
|
*/
|
async initDB() {
|
if (!('indexedDB' in window)) {
|
console.warn('IndexedDB not supported');
|
return;
|
}
|
|
const dbName = `jvb_${this.config.name}_db`;
|
const request = indexedDB.open(dbName, this.config.version);
|
|
request.onupgradeneeded = (e) => {
|
const db = e.target.result;
|
|
// Create main store with configurable keyPath
|
|
if (!db.objectStoreNames.contains(this.config.storeName)) {
|
const store = db.createObjectStore(this.config.storeName, {
|
keyPath: this.config.keyPath
|
});
|
|
// Add configured indexes
|
this.config.indexes.forEach(index => {
|
store.createIndex(
|
index.name,
|
index.keyPath || index.name,
|
{ unique: index.unique || false }
|
);
|
});
|
}
|
|
// 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 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 = async (e) => {
|
this.db = e.target.result;
|
|
// Load in background without blocking
|
this.loadInBackground();
|
|
this.notify('db-init');
|
|
// Only fetch if explicitly needed
|
if (this.config.endpoint && !this.config.delayFetch) {
|
requestIdleCallback(() => this.fetch(), { timeout: 2000 });
|
}
|
};
|
|
request.onerror = (e) => {
|
console.error(`IndexedDB error for ${dbName}:`, e);
|
if (this.config.onError) {
|
this.config.onError(e);
|
}
|
};
|
}
|
|
loadInBackground() {
|
// Non-blocking background load
|
Promise.all([
|
this.loadFromDB(),
|
this.loadCache(),
|
this.loadHeaders()
|
]).then(() => {
|
this.notify('data-ready');
|
}).catch(console.error);
|
}
|
|
/**
|
* Load all data from IndexedDB
|
*/
|
async loadFromDB() {
|
if (!this.db) return;
|
|
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 = async (e) => {
|
const items = e.target.result;
|
console.log('fetched from cache');
|
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);
|
}
|
|
this.notify('data-loaded', { count: items.length });
|
resolve(items);
|
};
|
|
request.onerror = (e) => reject(e);
|
});
|
}
|
|
|
|
/**
|
* Load main data from IndexedDB
|
*/
|
async loadData() {
|
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);
|
const request = store.getAll();
|
|
request.onsuccess = (e) => {
|
e.target.result.forEach(item => {
|
const key = this.getItemKey(item);
|
this.data.set(key, item);
|
});
|
resolve();
|
};
|
|
request.onerror = (e) => reject(e);
|
});
|
}
|
|
/**
|
* Strip DOM references from an object (recursive)
|
*/
|
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.saveToServer && 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');
|
}
|
await this.init(); // Lazy init
|
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);
|
|
// 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);
|
}
|
|
//Check Cached data
|
const cachedData = this.cache.get(cacheKey);
|
if (cachedData && this.isCacheValid(cachedData)) {
|
this.isFetching = false;
|
|
if (this.config.showLoading) {
|
this.setLoading(false);
|
}
|
this.notify('data-loaded', cachedData.data);
|
return cachedData.data;
|
}
|
|
// Build request headers with HTTP caching
|
const requestHeaders = {
|
...this.headers,
|
...headers
|
};
|
|
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
|
});
|
|
// Handle 304 Not Modified
|
if (response.status === 304 && cachedData) {
|
console.log('304 response');
|
// Update timestamp but keep existing data
|
cachedData.timestamp = Date.now();
|
cachedData.fromCache = true;
|
cachedData.isError = false;
|
this.saveCache(cacheKey, cachedData);
|
|
this.lastResponse = cachedData;
|
this.notify('data-loaded', cachedData);
|
fetchResult = cachedData.data;
|
return cachedData.data;
|
}
|
|
if (!response.ok) {
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
}
|
|
const data = await response.json();
|
|
//Store full response for accesss to metadata, like stats
|
this.lastResponse = data;
|
|
// Store HTTP caching headers
|
if (this.config.useHttpCaching) {
|
this.storeResponseHeaders(cacheKey, response);
|
}
|
|
// Cache the response
|
const cacheEntry = {
|
key: cacheKey,
|
items: data.items.map(item => item.id),
|
total: data.total,
|
maxPages: data['total_pages'],
|
timestamp: Date.now(),
|
endpoint: this.config.endpoint,
|
filters: filters
|
};
|
|
this.cache.set(cacheKey, cacheEntry);
|
this.saveCache(cacheKey, cacheEntry);
|
|
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) {
|
console.error('Fetch error:', error);
|
|
// 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;
|
}
|
|
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 = [];
|
}
|
}
|
}
|
|
/**
|
* 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]) => {
|
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;
|
}
|
|
/**
|
* Generate cache key from filters
|
*/
|
generateCacheKey(filters) {
|
if (this.config.cacheKeyStrategy === 'custom' && this.config.generateCacheKey) {
|
return this.config.generateCacheKey(filters);
|
}
|
|
// 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;
|
}
|
|
this.notify('filters-changed', {
|
filters: this.filters,
|
changed: { key, oldValue, newValue: value }
|
});
|
|
// Auto-fetch if endpoint is configured
|
if (this.config.endpoint !== null) {
|
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();
|
}
|
}
|
|
/**
|
* Set multiple filters at once
|
*/
|
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) {
|
this.fetch();
|
}
|
}
|
|
getFiltered() {
|
const cacheKey = this.generateCacheKey(this.filters);
|
const cacheEntry = this.cache.get(cacheKey);
|
|
if (cacheEntry && cacheEntry.items) {
|
return cacheEntry.items.reduce((acc, id) => {
|
const item = this.data.get(id);
|
if (item) acc.push(item);
|
return acc;
|
}, []);
|
}
|
|
return Array.from(this.data.values());
|
}
|
|
/**
|
* Check if cache entry is still valid
|
*/
|
isCacheValid(cacheEntry) {
|
if (!cacheEntry || !cacheEntry.timestamp) return false;
|
|
const age = Date.now() - cacheEntry.timestamp;
|
return age < this.config.TTL;
|
}
|
|
/**
|
* Store HTTP response headers for caching
|
*/
|
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);
|
|
if (this.db && this.db.objectStoreNames.contains('headers')) {
|
const tx = this.db.transaction(['headers'], 'readwrite');
|
const store = tx.objectStore('headers');
|
store.put(headers);
|
}
|
}
|
|
/**
|
* Clear HTTP cache headers for a specific cache key or all
|
*/
|
clearHttpHeaders(cacheKey = null) {
|
if (cacheKey) {
|
this.httpHeaders.delete(cacheKey);
|
|
if (this.db && this.db.objectStoreNames.contains('headers')) {
|
const tx = this.db.transaction(['headers'], 'readwrite');
|
const store = tx.objectStore('headers');
|
store.delete(cacheKey);
|
}
|
} else {
|
// Clear all
|
this.httpHeaders.clear();
|
|
if (this.db && this.db.objectStoreNames.contains('headers')) {
|
const tx = this.db.transaction(['headers'], 'readwrite');
|
const store = tx.objectStore('headers');
|
store.clear();
|
}
|
}
|
}
|
|
/**
|
* 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);
|
}
|
|
getCurrentRequest() {
|
return this.lastResponse;
|
}
|
|
/**
|
* 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);
|
}
|
});
|
}
|
|
/**
|
* Check if store has items matching a specific filter
|
* @param {string} filterName - The filter to check
|
* @param {*} filterValue - The value to match
|
* @returns {boolean}
|
*/
|
hasItemsForFilter(filterName, filterValue) {
|
if (!this.data || this.data.size === 0) return false;
|
|
return Array.from(this.data.values()).some(item => {
|
return item[filterName] === filterValue;
|
});
|
}
|
|
/**
|
* 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) {
|
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();
|
|
if (this.db) {
|
const tx = this.db.transaction(['cache'], 'readwrite');
|
const store = tx.objectStore('cache');
|
store.clear();
|
}
|
|
this.notify('cache-cleared');
|
}
|
}
|
|
// Export for use
|
window.jvbStore = DataStore;
|