/** * DataStore - Singleton pattern managing multiple store namespaces * * Usage: * window.jvbStore = new DataStore(); * this.store = window.jvbStore.register('feed', { config }); */ class DataStore { constructor() { // Singleton pattern if (DataStore.instance) { return DataStore.instance; } DataStore.instance = this; // Shared resources this.dbConfig = new Map(); // Definitions for the databases this.databases = new Map(); // Shared IndexedDB connections this.stores = new Map(); // Registered store namespaces this.subscribers = new Map(); // Per-store event subscribers this.pendingInits = new Map(); // Track initialization promises this.fetchQueue = []; // Global state this._initialized = false; this.body = document.body; this.loading = document.querySelector('dialog.loading'); this.init(); } async init() { if (this._initialized) return; this._initialized = true; if (!('indexedDB' in window)) { console.warn('IndexedDB not supported'); } } /** * Register a new store namespace * @param {string} name Database Name * @param {object|array} configs An object defining the store, or an array of objects defining the stores * @param {number} version the database version */ register(name, configs = [], version = 1.25) { if (!Array.isArray(configs)) configs = [configs]; if (configs.length === 0) return; if (!this.dbConfig.has(name)) { this.dbConfig.set(name, { dbName: `${jvbBase.base}${name}`, version: version, stores: {}, _initialized: false }); } let dbEntry = this.dbConfig.get(name); configs.forEach(config => { if (!config.storeName) { throw new Error(`Store config for "${name}" missing storeName`); } if (!config.keyPath) { throw new Error(`Store "${config.storeName}" requires keyPath`); } const storeKey = `${name}_${config.storeName}`; const store = { config: { // Storage dbName: dbEntry.dbName, storeName: 'items', keyPath: 'id', indexes: [], // API endpoint: null, apiBase: jvbSettings.api, filters: {}, ignore: [], //any filters to ignore when filtering store locally required: null, isAuth: false, // Cache TTL: 3600000, // 1 hour useHttpCaching: true, // Behavior showLoading: false, delayFetch: true, validateData: true, ...config }, dbKey: name, storeKey: storeKey, data: new Map(), cache: new Map(), filters: {...(config.filters || {})}, isFetching: false, currentRequest: null, lastResponse: null, _initialized: false }; store.ignoreFilters = new Set([ ... ['search', 'page', 'per_page', 'orderby', 'order'], ... ['context', 'source'], ... store.config.ignore ]); store.config.headers = { 'X-WP-Nonce': window.auth.getNonce(), ...store.config.headers }; dbEntry.stores[config.storeName] = storeKey; this.stores.set(storeKey, store); if (!this.subscribers.has(storeKey)) { this.subscribers.set(storeKey, new Set()); } }); // Initialize database asynchronously this.initDB(name).catch(error => { console.error(`Failed to initialize store "${name}":`, error); }); const apis = {}; for (const [storeName, storeKey] of Object.entries(dbEntry.stores)) { apis[storeName] = this.getStoreAPI(storeKey); } return apis; } /** * Get the API object for a registered store */ getStoreAPI(name) { const api = { // Data methods fetch: () => this.fetch(name), save: (item) => this.save(name, item), saveMany: (items) => this.saveMany(name, items), delete: (id) => this.delete(name, id), deleteMany: (items) => this.deleteMany(name, items), get: (id) => this.get(name, id), getMany: (ids) => this.getMany(name, ids), getAll: () => this.getAll(name), getAllByIndex: (indexName, value) => this.getAllByIndex(name, indexName, value), filterByIndex: (criteria) => this.filterByIndex(name, criteria), getFiltered: () => this.getFiltered(name), clear: () => this.clear(name), // Filter methods setFilter: (key, value) => this.setFilter(name, key, value), setFilters: (filters) => this.setFilters(name, filters), removeFilter: (key) => this.removeFilter(name, key), clearFilters: () => this.clearFilters(name), // Cache methods clearCache: () => this.clearCache(name), // Event methods subscribe: (callback) => this.subscribe(name, callback), // Utility ensureInitialized: () => this.ensureStoreInitialized(name), // Exposed properties (read-only) get filters() { return { ...api.getStore().filters }; }, get lastResponse() { return api.getStore().lastResponse; }, get data() { return api.getStore().data; }, getStore: () => this.stores.get(name) }; return api; } /** * Convert FormData to plain object for storage */ formDataToObject(formData) { const obj = { _isFormData: true, entries: {} }; for (const [key, value] of formData.entries()) { // Skip File/Blob objects - they're stored separately in UploadManager 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(); // Restore text entries 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); } } if (window.jvbUploads && obj.entries.upload_ids) { const uploadIds = JSON.parse(obj.entries.upload_ids); for (const uploadId of uploadIds) { const file = await window.jvbUploads.getBlobData(uploadId); if (file) { formData.append('files[]', file); } } } return formData; } /*********************************************************************** * DATABASE INITIALIZATION ***********************************************************************/ async initDB(name) { const db = this.dbConfig.get(name); if (!db || db._initialized) return; if (this.pendingInits.has(name)) { return this.pendingInits.get(name); } const initPromise = this._performDBInit(name); this.pendingInits.set(name, initPromise); try { await initPromise; db._initialized = true; } finally { this.pendingInits.delete(name); } } async _performDBInit(name) { const database = this.dbConfig.get(name); const { dbName, version } = database; const stores = Object.values(database.stores); try { if (!this.databases.has(dbName)) { const db = await this.openDatabase(dbName, version, (db) => { stores.forEach(store => { let storeObj = this.stores.get(store); if (storeObj) { this.setupStores(db, storeObj.config); } }); }); this.databases.set(dbName, db); } stores.forEach(storeName => { let store = this.stores.get(storeName); if (store) { store.db = this.databases.get(dbName); store._initialized = true; this.loadStoreDataInBackground(storeName); this.notify(storeName, 'db-init'); } }); } catch (error) { console.error(`Failed to initialize database for store "${name}":`, error); throw error; } } openDatabase(dbName, version, onUpgrade) { return new Promise((resolve, reject) => { const request = indexedDB.open(dbName, version); request.onupgradeneeded = (e) => { if (onUpgrade) { onUpgrade(e.target.result, e.oldVersion, e.newVersion); } }; request.onsuccess = (e) => resolve(e.target.result); request.onerror = (e) => reject(e.target.error); request.onblocked = () => { console.warn(`Database ${dbName} blocked. Close other tabs.`); }; }); } setupStores(db, config) { // Main store if (!db.objectStoreNames.contains(config.storeName)) { const store = db.createObjectStore(config.storeName, { keyPath: config.keyPath }); config.indexes.forEach(index => { store.createIndex( index.name, index.keyPath || index.name, { unique: index.unique || false } ); }); } // Cache store (now includes HTTP headers) if (config.endpoint && !db.objectStoreNames.contains('cache')) { const cacheStore = db.createObjectStore('cache', { keyPath: 'key' }); cacheStore.createIndex('timestamp', 'timestamp', { unique: false }); } } /** * Generic loader for any object store */ async loadFromObjectStore(name, storeName, processItem) { const store = this.stores.get(name); if (!store?.db || !store.db.objectStoreNames.contains(storeName)) { return []; } return new Promise((resolve) => { const tx = store.db.transaction([storeName], 'readonly'); const objectStore = tx.objectStore(storeName); const request = objectStore.getAll(); request.onsuccess = (e) => { const items = e.target.result || []; items.forEach(processItem); resolve(items); }; request.onerror = () => resolve([]); }); } loadStoreDataInBackground(name) { const store = this.stores.get(name); if (!store?.db) return; Promise.all([ // Load main data this.loadFromObjectStore(name, store.config.storeName, (item) => { const key = this.getItemKey(item, store.config.keyPath); store.data.set(key, item); }), // Load cache (includes HTTP headers now!) this.loadFromObjectStore(name, 'cache', (item) => { if (this.isCacheValid(item, store.config.TTL)) { store.cache.set(item.key, item); } }) ]) .then(() => { this.notify(name, 'data-ready'); // Add to fetch queue instead of immediate fetch if (store.config.endpoint && store.config.delayFetch) { this.fetchQueue.push(name); // Start processing queue if not already running if (this.fetchQueue.length === 1) { this.processFetchQueue(); } } else if (store.config.endpoint && !store.config.delayFetch) { // Immediate fetch if ('requestIdleCallback' in window) { requestIdleCallback(() => this.fetch(name), { timeout: 2000 }); } else { setTimeout(() => this.fetch(name), 100); } } }) .catch(error => { console.error(`Background load error for store "${name}":`, error); }); } async processFetchQueue() { if (this.fetchQueue.length === 0) return; const name = this.fetchQueue.shift(); const store = this.stores.get(name); if (!store) { // Store was removed, continue with next return this.processFetchQueue(); } try { await this.fetch(name); } catch (error) { console.error(`Queue fetch error for "${name}":`, error); } // Process next item with idle callback if (this.fetchQueue.length > 0) { if ('requestIdleCallback' in window) { requestIdleCallback(() => this.processFetchQueue(), { timeout: 2000 }); } else { setTimeout(() => this.processFetchQueue(), 50); } } } async ensureStoreInitialized(name) { const store = this.stores.get(name); if (!store) { throw new Error(`Store "${name}" not registered`); } if (!store._initialized) { await this.initDB(store.dbKey); } } /*********************************************************************** * TRANSACTION HELPER ***********************************************************************/ /** * Create transaction helper - reduces boilerplate */ async withTransaction(name, storeNames, mode, callback) { const store = this.stores.get(name); if (!store?.db) return null; // Ensure storeNames is an array if (typeof storeNames === 'string') storeNames = [storeNames]; return new Promise((resolve, reject) => { const tx = store.db.transaction(storeNames, mode); const stores = storeNames.map(name => tx.objectStore(name)); const objectStore = stores.length === 1 ? stores[0] : stores; let result; tx.oncomplete = () => resolve(result); tx.onerror = () => { const error = tx.error || new Error('Transaction failed with unknown error'); reject(error); }; // Call callback immediately to queue operations try { result = callback(objectStore, tx); } catch (error) { reject(error || new Error('Callback failed with unknown error')); } }); } /*********************************************************************** * FETCH & DATA PROCESSING ***********************************************************************/ async fetch(name) { await this.ensureStoreInitialized(name); const store = this.stores.get(name); if (store.isFetching) return; // Check required filters if (store.config.required) { const required = Array.isArray(store.config.required) ? store.config.required : [store.config.required]; const missing = required.some(key => !store.filters[key] || store.filters[key] === '' ); if (missing) return; } store.isFetching = true; try { // Check cache const cacheKey = this.generateCacheKey(store.filters); const cached = store.cache.get(cacheKey); if (cached && this.isCacheValid(cached, store.config.TTL)) { let items = cached.items.map(itemId => this.get(name, itemId)); this.notify(name, 'data-loaded', { cached: true, items: items??[] }); return cached; } if (store.config.showLoading) { this.setLoading(true); } const url = this.buildFetchUrl(name); const headers = { ...store.config.headers }; // Use HTTP cache headers from cache entry if (store.config.useHttpCaching && cached) { if (cached.etag) headers['If-None-Match'] = cached.etag; if (cached.lastModified) headers['If-Modified-Since'] = cached.lastModified; } const controller = new AbortController(); store.currentRequest = controller; let response; if (store.isAuth) { response = await window.auth.fetch(url, { method: 'GET', headers, signal: controller.signal }); } else { response = await fetch(url, { method: 'GET', headers, signal: controller.signal }); } if (!response.ok) { // Access the error details from the response body const errorBody = await response.text(); // Throw a new error with a descriptive message throw new Error(`HTTP error! status: ${response.status}, message: ${errorBody}`); } if (response.status === 304) { // 304 means "Not Modified" - use cached data if available if (cached) { this.notify(name, 'data-loaded', { cached: true, notModified: true, items: cached.items || [] }); return cached; } // No cached data but server says not modified - return empty result this.notify(name, 'data-loaded', { cached: false, notModified: true, items: [] }); // Initialize empty lastResponse store.lastResponse = { has_more: false, total: 0, pages: 1, queue_stats: {} }; return { items: [] }; } // Now check for other non-OK responses if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); await this.processFetchedData(name, data, cacheKey, response); this.notify(name, 'data-loaded', { cached: false, items: data.items || [] }); return data; } catch (error) { const isAbortError = error?.name === 'AbortError'; if (!isAbortError) { console.error(`Fetch error for store "${name}":`, error.message); console.dir(error); this.notify(name, 'fetch-error', { error }); throw error; } } finally { store.isFetching = false; store.currentRequest = null; if (store.config.showLoading) { this.setLoading(false); } } } buildFetchUrl(name) { const store = this.stores.get(name); const params = new URLSearchParams(); Object.entries(store.filters).forEach(([key, value]) => { if (value !== null && value !== undefined && value !== '') { if (typeof value === 'object') { params.set(key, JSON.stringify(value)); } else { params.set(key, value); } } }); const baseUrl = store.config.apiBase + store.config.endpoint; return params.toString() ? `${baseUrl}?${params}` : baseUrl; } /** * Process fetched data (batch from server) */ async processFetchedData(name, data, cacheKey, response) { const store = this.stores.get(name); const items = (data.items || []).filter(item => item && typeof item === 'object'); const changes = []; // Batch process with single transaction if (store.db && items.length > 0) { await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => { items.forEach(item => { try { // Use shared save logic const changeInfo = this._saveItem(name, item); changes.push(changeInfo); // Queue for batch write objectStore.put(changeInfo.processed); } catch (error) { console.error(`Error processing item:`, error); } }); }); } // Update cache (now includes HTTP headers!) const cacheEntry = { key: cacheKey, items: items.map(item => this.getItemKey(item, store.config.keyPath)), timestamp: Date.now(), endpoint: store.config.endpoint, filters: { ...store.filters }, etag: response.headers.get('ETag'), lastModified: response.headers.get('Last-Modified'), has_more: data.has_more || false }; store.cache.set(cacheKey, cacheEntry); // Save cache to IndexedDB if (store.db?.objectStoreNames.contains('cache')) { await this.withTransaction(name, 'cache', 'readwrite', (objectStore) => { objectStore.put(cacheEntry); }); } // Update lastResponse metadata store.lastResponse = { ...data, has_more: data.has_more || false, total: data.total || items.length, pages: data.pages || 1, queue_stats: data.queue_stats || {} }; for (let [key, value] of Object.entries(store.filters)) { if (typeof value === 'string' && value.includes(',')) { this.createSplitCacheEntries(name, items, key, store.filters, response); } } // Emit events for items with status changes changes.forEach(changeInfo => { if (changeInfo.statusChanged) { this.notify(name, 'item-saved', { item: changeInfo.item, key: changeInfo.key, previousItem: changeInfo.previousItem }); } }); } createSplitCacheEntries(name, items, key, filters, response) { const store = this.stores.get(name); const keys = filters[key].split(',').map(v => v.trim()); keys.forEach(value => { let temp = {}; temp[key] = value; const newFilters = { ... filters, [key]: value }; const cacheKey = this.generateCacheKey(newFilters); if(store.cache.has(cacheKey)) return; let filteredItems = this.filterByIndex(name,temp).map(item => this.getItemKey(item, store.config.keyPath)); const entry = { key: cacheKey, items: filteredItems, timestamp: Date.now(), endpoint: store.config.endpoint, filters: newFilters, etag: response.headers.get('Etag'), lastModified: response.headers.get('Last-Modified'), has_more: filteredItems.length === 20, } store.cache.set(cacheKey, entry); if (store.db?.objectStoreNames.contains('cache')) { this.withTransaction(name, 'cache', 'readwrite', (objectStore) =>{ objectStore.put(entry); }); } }) } /*********************************************************************** * SAVE OPERATIONS ***********************************************************************/ /** * Internal method: Save a single item with full tracking * Returns change info without writing to IndexedDB (caller handles that) */ _saveItem(name, item) { const store = this.stores.get(name); const result = this.processForStorage(item, store.config.validateData); if (!result.valid) { throw new Error(`Non-serializable data: ${result.error}`); } const processed = result.data; const key = this.getItemKey(processed, store.config.keyPath); // Capture previous state const previousItem = store.data.get(key); // Update in-memory store (with original data intact) store.data.set(key, item); // Return change info for event emission return { item, previousItem, key, processed, statusChanged: previousItem && previousItem.status !== item.status }; } /** * Save single item (public API) */ async save(name, item) { const store = this.stores.get(name); const changeInfo = this._saveItem(name, item); // Write to IndexedDB immediately for single saves await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => { objectStore.put(changeInfo.processed); }); // Always emit for explicit saves this.notify(name, 'item-saved', { item: changeInfo.item, key: changeInfo.key, previousItem: changeInfo.previousItem }); return changeInfo.key; } /** * Save multiple items in a single transaction (batch write) * @param {string} name - Store name * @param {Array|Map} items - Array of items or Map of items to save * @returns {Promise} - Array of saved keys */ async saveMany(name, items) { const store = this.stores.get(name); if (!store) return []; // Convert Map to array if needed const itemArray = items instanceof Map ? Array.from(items.values()) : Array.isArray(items) ? items : Object.values(items); if (itemArray.length === 0) return []; const changes = []; // Process all items and update in-memory store itemArray.forEach(item => { const changeInfo = this._saveItem(name, item); changes.push(changeInfo); }); // Single transaction for all writes await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => { changes.forEach(changeInfo => { objectStore.put(changeInfo.processed); }); }); // Notify once for batch this.notify(name, 'items-saved', { count: changes.length, keys: changes.map(c => c.key) }); return changes.map(c => c.key); } processForStorage(obj, validate = true, path = 'root') { if (obj === null) { return { valid: true, data: null }; } if (obj === undefined) { if (validate) { return { valid: false, error: `Undefined value at ${path}` }; } return { valid: true, data: undefined }; } const type = typeof obj; // Handle primitives if (['string', 'number', 'boolean'].includes(type)) { return { valid: true, data: obj }; } // Reject functions if (type === 'function') { if (validate) return { valid: false, error: `Function at ${path}` }; return { valid: true, data: undefined }; } // DOM elements if (obj instanceof HTMLElement || obj.nodeType !== undefined) { if (validate) return { valid: false, error: `DOM element at ${path}` }; return { valid: true, data: undefined }; } // FormData - convert and continue if (obj instanceof FormData) { return { valid: true, data: this.formDataToObject(obj) }; } // Preserve safe types if (obj instanceof Date || obj instanceof ArrayBuffer || ArrayBuffer.isView(obj) || obj instanceof Blob) { return { valid: true, data: obj }; } // Convert Sets to Arrays if (obj instanceof Set) { return this.processForStorage(Array.from(obj), validate, path); } // Convert Maps to Objects if (obj instanceof Map) { obj = Object.fromEntries(obj); } // Arrays if (Array.isArray(obj)) { const processed = []; for (let i = 0; i < obj.length; i++) { const result = this.processForStorage(obj[i], validate, `${path}[${i}]`); if (!result.valid) return result; if (result.data !== undefined) processed.push(result.data); } return { valid: true, data: processed }; } // Objects if (type === 'object') { const processed = {}; for (const [key, value] of Object.entries(obj)) { if (value === undefined) continue; const result = this.processForStorage(value, validate, `${path}.${key}`); if (!result.valid) return result; // Include null values, skip undefined if (result.data !== undefined || value === null) { processed[key] = result.data; } } return { valid: true, data: processed }; } if (validate) return { valid: false, error: `Unknown type at ${path}` }; return { valid: true, data: undefined }; } /*********************************************************************** * DATA ACCESS ***********************************************************************/ async delete(name, id) { const store = this.stores.get(name); store.data.delete(id); await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => { objectStore.delete(id); }); this.notify(name, 'item-deleted', { id }); } /** * Delete multiple items in a single transaction (batch delete) * @param {string} name - Store name * @param {Array|Set} ids - Array or Set of IDs to delete * @returns {Promise} - Array of deleted IDs */ async deleteMany(name, ids) { const store = this.stores.get(name); if (!store) return []; // Convert Set to array if needed const idArray = ids instanceof Set ? Array.from(ids) : Array.isArray(ids) ? ids : Object.keys(ids); if (idArray.length === 0) return []; // Update in-memory store idArray.forEach(id => { store.data.delete(id); }); // Single transaction for all deletes await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => { idArray.forEach(id => { objectStore.delete(id); }); }); // Notify once for batch this.notify(name, 'items-deleted', { count: idArray.length, ids: idArray }); return idArray; } get(name, id) { const store = this.stores.get(name); return store.data.get(id); } /** * Get multiple items by IDs in a single call * @param {string} name - Store name * @param {Array|Set} ids - Array or Set of IDs to retrieve * @param {boolean} skipMissing - If true, omit missing items; if false, include null for missing * @returns {Array} - Array of items (in same order as IDs) */ getMany(name, ids, skipMissing = true) { const store = this.stores.get(name); if (!store) return []; const idArray = ids instanceof Set ? Array.from(ids) : Array.isArray(ids) ? ids : Object.keys(ids); if (idArray.length === 0) return []; if (skipMissing) { return idArray.reduce((acc, id) => { const item = store.data.get(id); if (item) acc.push(item); return acc; }, []); } // Preserve order, include null for missing return idArray.map(id => store.data.get(id) ?? null); } getAll(name) { const store = this.stores.get(name); return Array.from(store.data.values()); } /** * Filter in-memory data by multiple index/value pairs * @param {string} name - Store name * @param {Object} criteria - Object of { indexName: acceptedValue(s) } * @returns {Array} - Items matching ALL criteria * * @example * filterByIndex(name, { field: 'upload_123', status: ['queued', 'uploading'] }) */ filterByIndex(name, criteria) { const store = this.stores.get(name); if (!store) return []; return Array.from(store.data.values()).filter(item => { if (!item || typeof item !== 'object') return false; return Object.entries(criteria).every(([key, value]) => { const accepted = Array.isArray(value) ? value : [value]; return accepted.includes(item[key]); }); }); } /** * Get all items matching an index value * @param {string} name - Store name * @param {string} indexName - Name of the index to query * @param {*} value - Value to match * @returns {Promise} - Matching items */ async getAllByIndex(name, indexName, value) { const store = this.stores.get(name); const values = Array.isArray(value) ? value : [value]; // Try IndexedDB index query first (more efficient for large datasets) if (store.db && store.db.objectStoreNames.contains(store.config.storeName)) { try { const tx = store.db.transaction([store.config.storeName], 'readonly'); const objectStore = tx.objectStore(store.config.storeName); if (objectStore.indexNames.contains(indexName)) { const index = objectStore.index(indexName); const results = await Promise.all( values.map(v => new Promise((resolve, reject) => { const request = index.getAll(v); request.onsuccess = () => resolve(request.result || []); request.onerror = () => reject(request.error); })) ); return results.flat(); } } catch (error) { console.warn(`Index query failed for "${indexName}", falling back to filter:`, error); } } // Fallback: filter in-memory data return Array.from(store.data.values()).filter(item => values.includes(item[indexName])); } getFiltered(name) { const store = this.stores.get(name); const cacheKey = this.generateCacheKey(store.filters); const cacheEntry = store.cache.get(cacheKey); // First check if we have cached results for exact filters if (cacheEntry?.items) { const items = cacheEntry.items.reduce((acc, id) => { const item = store.data.get(id); if (item) acc.push(item); return acc; }, []); return this.applyOrdering(items, store); } const allItems = Array.from(store.data.values()); const searchQuery = store.filters.search?.toLowerCase().trim() || ''; const filterPredicates = []; // Handle taxonomy filters separately if (store.filters.taxonomy && typeof store.filters.taxonomy === 'object') { Object.entries(store.filters.taxonomy).forEach(([taxonomy, termIds]) => { const acceptedTermIds = Array.isArray(termIds) ? termIds : [termIds]; filterPredicates.push(item => { if (!item.taxonomies || !item.taxonomies[taxonomy]) { return false; } const itemTermIds = Object.keys(item.taxonomies[taxonomy]).map(id => parseInt(id)); const matches = acceptedTermIds.some(termId => itemTermIds.includes(parseInt(termId))); return matches; }); }); } // Handle other filters for (const [key, value] of Object.entries(store.filters)) { if (key === 'taxonomy') { if (typeof value === 'string' && !value.includes(',')) { filterPredicates.push(item => item.taxonomy === value); } continue; } if (store.ignoreFilters.has(key)) { continue; } if (value === null || value === undefined || value === '') continue; if (value === 'all') continue; if (typeof value === 'string' && value.includes(',')) { const accepted = value.split(',').map(v => v.trim()); filterPredicates.push(item => accepted.includes(String(item[key]))); } else { filterPredicates.push(item => String(item[key]) === String(value)); } } const filtered = allItems.filter(item => { for (const predicate of filterPredicates) { if (!predicate(item)) return false; } return !(searchQuery && !this.searchObject(item, searchQuery)); }); return this.applyOrdering(filtered, store); } applyOrdering(items, store) { if (!Array.isArray(items)) items = Array.from(items); if (items.length === 0) return items; const orderby = store.filters.orderby || 'date'; const order = (store.filters.order || 'desc').toLowerCase(); // Handle random ordering if (['random', 'rand'].includes(orderby) || ['random', 'rand'].includes(order)) { return this.shuffle(items); } items.sort((a, b) => { let aVal, bVal; switch (orderby) { case 'alphabetical': case 'title': aVal = (a.title || a.name || '').toLowerCase(); bVal = (b.title || b.name || '').toLowerCase(); break; case 'modified': aVal = new Date(a.modified || a.date || 0); bVal = new Date(b.modified || b.date || 0); break; case 'date': default: aVal = new Date(a.date || a.modified || 0); bVal = new Date(b.date || b.modified || 0); } if (aVal < bVal) return order === 'asc' ? -1 : 1; if (aVal > bVal) return order === 'asc' ? 1 : -1; return 0; }); return items; } shuffle(items) { const array = items.slice(); for (let i = array.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [array[i], array[j]] = [array[j], array[i]]; } return array; } searchObject(obj, search) { if (!obj || typeof obj !== 'object') { return typeof obj === 'string' && obj.toLowerCase().includes(search); } for (const value of Object.values(obj)) { if (value === null || value === undefined) continue; if (typeof value === 'object') { if (this.searchObject(value, search)) return true; continue; } if (typeof value === 'string' && value.toLowerCase().includes(search)) { return true; } } return false; } async clear(name) { const store = this.stores.get(name); store.data.clear(); store.cache.clear(); await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => { objectStore.clear(); }); this.notify(name, 'data-cleared'); } /*********************************************************************** * FILTER OPERATIONS ***********************************************************************/ async updateFilters(name, updates, clearAll = false) { const store = this.stores.get(name); const oldFilters = { ...store.filters }; if (clearAll) { store.filters = { ...store.config.filters }; } Object.entries(updates).forEach(([key, value]) => { if (value === null || value === undefined || value === '') { delete store.filters[key]; } else { store.filters[key] = value; } }); this.notify(name, 'filters-changed', { oldFilters, filters: store.filters, updates }); const shouldFetch = await this.shouldFetchWithFilters(name, updates, oldFilters); if (store.config.endpoint && shouldFetch) { await this.fetch(name); } else { const filtered = this.getFiltered(name); this.notify(name, 'data-loaded', { cached: true, items: filtered }); } } /** * Determine if we need to fetch or can use local data * @param {string} name - Store name * @param {object} updates - Filter updates being applied * @param {object} oldFilters - Previous filter state * @returns {Promise} - True if fetch is needed, false if local filtering suffices */ async shouldFetchWithFilters(name, updates, oldFilters) { const store = this.stores.get(name); // If no endpoint or no lastResponse, always fetch if (!store.config.endpoint || !store.lastResponse) { return true; } if (store.lastResponse.has_more === false) { if (this.hasCompleteData(store, store.filters)) { return false; } } if ('page' in updates) { const newPage = updates.page; const oldPage = oldFilters.page || 1; // If trying to go to a higher page but no more data available if (newPage > oldPage && !store.lastResponse.has_more) { // Reset page to last valid page store.filters.page = oldPage; return false; } } // SEARCH OPTIMIZATION: Check if we need to fetch for search if ('search' in updates) { const searchQuery = updates.search?.trim() || ''; const oldSearch = oldFilters.search?.trim() || ''; // If search is being cleared, we might already have the data if (!searchQuery && oldSearch) { // Check if we have all base data (without search) const baseFilters = { ...store.filters }; delete baseFilters.search; baseFilters.page = 1; // If we have complete base data, no need to fetch if (this.hasCompleteData(store, baseFilters)) { return false; } } // If search is new or changed, check if we have all data to filter locally if (searchQuery && searchQuery !== oldSearch) { // Check: do we have all data for base filters (no search, page 1)? const baseFilters = { ...store.filters }; delete baseFilters.search; baseFilters.page = 1; // If we have complete base data, we can filter locally if (this.hasCompleteData(store, baseFilters)) { return false; } } } // Default: fetch is needed return true; } /** * Check if we have complete data for given filters * @param {object} store - Store instance * @param {object} filters - Filters to check * @returns {boolean} - True if we have all data */ hasCompleteData(store, filters) { const cacheKey = this.generateCacheKey(filters); const cached = store.cache.get(cacheKey); if (!cached) return false; // Check if cache indicates no more data return cached.has_more === false || store.lastResponse?.has_more === false; } setFilter(name, key, value) { return this.updateFilters(name, { [key]: value }); } async setFilters(name, filters) { const store = this.stores.get(name); const hasChanges = Object.keys(filters).some( key => store.filters[key] !== filters[key] ) || Object.keys(store.filters).some( key => !(key in filters) && filters !== store.config.filters ); if (!hasChanges) return; return this.updateFilters(name, filters); } removeFilter(name, key) { return this.updateFilters(name, { [key]: null }); } clearFilters(name) { return this.updateFilters(name, {}, true); } /*********************************************************************** * CACHE OPERATIONS ***********************************************************************/ clearCache(name) { const store = this.stores.get(name); store.cache.clear(); if (store.db?.objectStoreNames.contains('cache')) { this.withTransaction(name, 'cache', 'readwrite', (objectStore) => { objectStore.clear(); }); } this.notify(name, 'cache-cleared'); } generateCacheKey(filters) { const normalized = Object.keys(filters) .sort() .reduce((acc, key) => { acc[key] = filters[key]; return acc; }, {}); return JSON.stringify(normalized); } isCacheValid(entry, ttl) { if (!entry || !entry.timestamp) return false; const age = Date.now() - entry.timestamp; return age < ttl; } /*********************************************************************** * EVENT SYSTEM ***********************************************************************/ subscribe(name, callback) { if (!this.subscribers.has(name)) { this.subscribers.set(name, new Set()); } const subscribers = this.subscribers.get(name); subscribers.add(callback); return () => subscribers.delete(callback); } notify(name, event, data = {}) { const subscribers = this.subscribers.get(name); if (!subscribers) return; subscribers.forEach(callback => { try { callback(event, data); } catch (error) { console.error(`Subscriber error for store "${name}":`, error); } }); } /*********************************************************************** * UTILITIES ***********************************************************************/ getItemKey(item, keyPath) { if (typeof keyPath === 'function') { return keyPath(item); } const keys = keyPath.split('.'); let value = item; for (const key of keys) { value = value?.[key]; } return value; } setLoading(on) { this.body.classList.toggle('loading', on); if (on) { this.loading?.showModal(); } else { this.loading?.close(); } } destroy() { this.stores.forEach(store => { if (store.currentRequest) { store.currentRequest.abort(); } }); this.databases.forEach(db => db.close()); this.stores.clear(); this.subscribers.clear(); this.databases.clear(); this.pendingInits.clear(); } } // Initialize singleton on DOMContentLoaded document.addEventListener('DOMContentLoaded', async function() { window.auth.subscribe((event) => { if (event === 'auth-loaded') { window.jvbStore = new DataStore(); } }); });