| | |
| | | endpoint: null, |
| | | apiBase: jvbSettings.api, |
| | | filters: {}, |
| | | ignore: [], //any filters to ignore when filtering store locally |
| | | required: null, |
| | | |
| | | // Cache |
| | |
| | | _initialized: false |
| | | }; |
| | | |
| | | store.ignoreFilters = new Set([ |
| | | ... ['search', 'page', 'per_page', 'orderby', 'order'], |
| | | ... store.config.ignore |
| | | ]); |
| | | |
| | | store.config.headers = { |
| | | 'X-WP-Nonce': window.auth.getNonce(), |
| | | ...store.config.headers |
| | |
| | | // 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), |
| | |
| | | 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>} - 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 || obj === undefined) return { valid: true, data: obj }; |
| | | |
| | |
| | | // Reject functions |
| | | if (type === 'function') { |
| | | if (validate) return { valid: false, error: `Function at ${path}` }; |
| | | console.debug(`[DataStore] Stripped 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}` }; |
| | | console.debug(`[DataStore] Stripped DOM element at ${path}`); |
| | | |
| | | return { valid: true, data: undefined }; |
| | | } |
| | | |
| | | // FormData - convert and continue |
| | | if (obj instanceof FormData) { |
| | | if (validate) return { valid: false, error: `FormData at ${path}` }; |
| | | console.debug(`[DataStore] Converted FormData at ${path}`); |
| | | |
| | | return { valid: true, data: this.formDataToObject(obj) }; |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | if (validate) return { valid: false, error: `Unknown type at ${path}` }; |
| | | console.debug(`[DataStore] Stripped unknown type at ${path}`); |
| | | |
| | | return { valid: true, data: undefined }; |
| | | } |
| | | |
| | |
| | | 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>} - 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()); |
| | |
| | | const cacheEntry = store.cache.get(cacheKey); |
| | | |
| | | // First check if we have cached results for exact filters |
| | | if (cacheEntry && cacheEntry.items) { |
| | | return cacheEntry.items.reduce((acc, id) => { |
| | | const item = store.data.get(id); |
| | | if (item) acc.push(item); |
| | | return acc; |
| | | }, []); |
| | | if (cacheEntry?.items) { |
| | | return this.applyOrdering( |
| | | cacheEntry.items.reduce((acc, id) => { |
| | | const item = store.data.get(id); |
| | | if (item) acc.push(item); |
| | | return acc; |
| | | }, []), |
| | | store |
| | | ); |
| | | } |
| | | |
| | | // If we have a search filter and complete base data, filter locally |
| | | if (store.filters.search && store.filters.search.trim()) { |
| | | const searchQuery = store.filters.search.toLowerCase().trim(); |
| | | const allItems = Array.from(store.data.values()); |
| | | const searchQuery = store.filters.search?.toLowerCase().trim() || ''; |
| | | |
| | | // Get all items and filter them locally |
| | | const allItems = Array.from(store.data.values()); |
| | | const filterPredicates = []; |
| | | for (const [key, value] of Object.entries(store.filters)) { |
| | | if (store.ignoreFilters.has(key)) continue; |
| | | if (value === null || value === undefined || value === '') continue; |
| | | if (value === 'all') continue; |
| | | |
| | | // Filter by current filters (excluding search and page) |
| | | let filtered = allItems.filter(item => { |
| | | // Apply all filters except search and page |
| | | for (const [key, value] of Object.entries(store.filters)) { |
| | | if (key === 'search' || key === 'page') continue; |
| | | // Comma-separated values |
| | | if (typeof value === 'string' && value.includes(',')) { |
| | | const accepted = value.split(',').map(v => v.trim()); |
| | | filterPredicates.push(item => accepted.includes(String(item[key]))); |
| | | continue; |
| | | } |
| | | |
| | | if (value !== null && value !== undefined && value !== '') { |
| | | if (item[key] !== value) return false; |
| | | } |
| | | filterPredicates.push(item => String(item[key]) === String(value)); |
| | | } |
| | | |
| | | const filtered = allItems.filter(item => { |
| | | // Apply all non-search filters |
| | | for (const predicate of filterPredicates) { |
| | | if (!predicate(item)) return false; |
| | | } |
| | | |
| | | // Apply search if present |
| | | 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; |
| | | |
| | | if (store.filters.orderby || store.filters.order) { |
| | | const orderby = store.filters.orderby || 'date'; |
| | | const order = (store.filters.order || 'desc').toLowerCase(); |
| | | |
| | | items.sort((a, b) => { |
| | | let aVal, bVal; |
| | | |
| | | switch (orderby) { |
| | | case 'alphabetical': |
| | | case 'title': |
| | | aVal = (a.fields?.post_title || a.title || a.name || '').toLowerCase(); |
| | | bVal = (b.fields?.post_title || b.title || b.name || '').toLowerCase(); |
| | | break; |
| | | case 'modified': |
| | | aVal = new Date(a.modified || 0); |
| | | bVal = new Date(b.modified || 0); |
| | | break; |
| | | case 'date': |
| | | default: |
| | | aVal = new Date(a.date || 0); |
| | | bVal = new Date(b.date || 0); |
| | | } |
| | | return true; |
| | | |
| | | if (aVal < bVal) return order === 'asc' ? -1 : 1; |
| | | if (aVal > bVal) return order === 'asc' ? 1 : -1; |
| | | return 0; |
| | | }); |
| | | } |
| | | return items; |
| | | } |
| | | |
| | | // Apply search filter to common searchable fields |
| | | filtered = filtered.filter(item => { |
| | | // Search in common fields: name, title, path, description |
| | | const searchableFields = ['name', 'title', 'path', 'description', 'slug']; |
| | | |
| | | return searchableFields.some(field => { |
| | | const value = item[field]; |
| | | if (!value) return false; |
| | | return value.toLowerCase().includes(searchQuery); |
| | | }); |
| | | }); |
| | | |
| | | return filtered; |
| | | searchObject(obj, search) { |
| | | if (!obj || typeof obj !== 'object') { |
| | | return typeof obj === 'string' && obj.toLowerCase().includes(search); |
| | | } |
| | | |
| | | // Fallback to all data |
| | | return this.getAll(name); |
| | | 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 shouldFetch = await this.shouldFetchWithFilters(name, updates, oldFilters); |
| | | |
| | | this.notify(name, 'filters-changed', { |
| | | oldFilters, |
| | | filters: store.filters, |
| | | updates |
| | | }); |
| | | |
| | | this.notify(name, 'data-loaded', { |
| | | cached: true, |
| | | items: this.getFiltered(name) |
| | | }); |
| | | |
| | | const shouldFetch = await this.shouldFetchWithFilters(name, updates, oldFilters); |
| | | if (store.config.endpoint && shouldFetch) { |
| | | await this.fetch(name); |
| | | } else if (store.config.endpoint) { |
| | |
| | | return true; |
| | | } |
| | | |
| | | // PAGE OPTIMIZATION: Don't fetch if trying to go beyond available pages |
| | | if (store.lastResponse.has_more === false) { |
| | | // Check if new filters are a subset of what we have |
| | | const isSubsetFilter = Object.entries(updates).every(([key, value]) => { |
| | | if (store.ignoreFilters.has(key)) return true; |
| | | if (key === 'page') return true; // Handle pagination locally |
| | | return true; // We have all data, can filter locally |
| | | }); |
| | | |
| | | if (isSubsetFilter) return false; |
| | | } |
| | | |
| | | if ('page' in updates) { |
| | | const newPage = updates.page; |
| | | const oldPage = oldFilters.page || 1; |