| | |
| | | * @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.1) { |
| | | 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: `jvb_${name}`, |
| | | dbName: `${jvbBase.base}${name}`, |
| | | version: version, |
| | | stores: {}, |
| | | _initialized: false |
| | |
| | | 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, |
| | |
| | | _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 |
| | |
| | | // 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), |
| | |
| | | |
| | | let result; |
| | | tx.oncomplete = () => resolve(result); |
| | | tx.onerror = () => reject(tx.error); |
| | | 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); |
| | | reject(error || new Error('Callback failed with unknown error')); |
| | | } |
| | | }); |
| | | } |
| | |
| | | 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: cached.items || [] |
| | | items: items??[] |
| | | }); |
| | | return cached; |
| | | } |
| | |
| | | const controller = new AbortController(); |
| | | store.currentRequest = controller; |
| | | |
| | | const response = await fetch(url, { |
| | | method: 'GET', |
| | | headers, |
| | | signal: controller.signal |
| | | }); |
| | | 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 (!response.ok) { |
| | | throw new Error(`HTTP ${response.status}: ${response.statusText}`); |
| | | } |
| | | |
| | | const data = await response.json(); |
| | | |
| | | await this.processFetchedData(name, data, cacheKey, response); |
| | |
| | | return data; |
| | | |
| | | } catch (error) { |
| | | if (error.name !== 'AbortError') { |
| | | console.error(`Fetch error for store "${name}":`, 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; |
| | | } |
| | | throw error; |
| | | |
| | | } finally { |
| | | store.isFetching = false; |
| | |
| | | */ |
| | | async processFetchedData(name, data, cacheKey, response) { |
| | | const store = this.stores.get(name); |
| | | const items = data.items || []; |
| | | const changes = []; // Track all changes |
| | | const items = (data.items || []).filter(item => item && typeof item === 'object'); |
| | | const changes = []; |
| | | |
| | | // Batch process with single transaction |
| | | if (store.db && items.length > 0) { |
| | |
| | | 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 }; |
| | | 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; |
| | | |
| | |
| | | // 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) { |
| | | console.debug(`[DataStore] Converting FormData at ${path}`); |
| | | |
| | | return { valid: true, data: this.formDataToObject(obj) }; |
| | | } |
| | | |
| | |
| | | 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; |
| | | if (result.data !== undefined) processed[key] = result.data; |
| | | // 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}` }; |
| | | 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()); |
| | |
| | | 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]); |
| | |
| | | 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) => { |
| | | 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); |
| | | } |
| | | |
| | | // 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()); |
| | | |
| | | // Get all items and filter them locally |
| | | const allItems = Array.from(store.data.values()); |
| | | const searchQuery = store.filters.search?.toLowerCase().trim() || ''; |
| | | |
| | | // 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; |
| | | const filterPredicates = []; |
| | | |
| | | if (value !== null && value !== undefined && value !== '') { |
| | | if (item[key] !== value) return false; |
| | | // 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; |
| | | } |
| | | } |
| | | return true; |
| | | }); |
| | | |
| | | // 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); |
| | | const itemTermIds = Object.keys(item.taxonomies[taxonomy]).map(id => parseInt(id)); |
| | | const matches = acceptedTermIds.some(termId => itemTermIds.includes(parseInt(termId))); |
| | | return matches; |
| | | }); |
| | | }); |
| | | |
| | | return filtered; |
| | | } |
| | | |
| | | // Fallback to all data |
| | | return this.getAll(name); |
| | | // 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) { |
| | |
| | | store.filters[key] = value; |
| | | } |
| | | }); |
| | | |
| | | const shouldFetch = await this.shouldFetchWithFilters(name, updates, oldFilters); |
| | | |
| | | 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 if (store.config.endpoint) { |
| | | this.notify(name, 'data-loaded'); |
| | | } else { |
| | | const filtered = this.getFiltered(name); |
| | | this.notify(name, 'data-loaded', { |
| | | cached: true, |
| | | items: filtered |
| | | }); |
| | | } |
| | | } |
| | | |
| | |
| | | return true; |
| | | } |
| | | |
| | | // PAGE OPTIMIZATION: Don't fetch if trying to go beyond available pages |
| | | 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; |