| | |
| | | * @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 |
| | |
| | | ignore: [], //any filters to ignore when filtering store locally |
| | | required: null, |
| | | |
| | | isAuth: false, |
| | | |
| | | // Cache |
| | | TTL: 3600000, // 1 hour |
| | | useHttpCaching: true, |
| | |
| | | |
| | | store.ignoreFilters = new Set([ |
| | | ... ['search', 'page', 'per_page', 'orderby', 'order'], |
| | | ... ['context', 'source'], |
| | | ... store.config.ignore |
| | | ]); |
| | | |
| | |
| | | |
| | | 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) { |
| | |
| | | } |
| | | |
| | | 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; |
| | | |
| | |
| | | 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 (!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]); |
| | |
| | | |
| | | // First check if we have cached results for exact filters |
| | | 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 |
| | | ); |
| | | 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 (store.ignoreFilters.has(key)) continue; |
| | | 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; |
| | | |
| | | // 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; |
| | | } else { |
| | | filterPredicates.push(item => String(item[key]) === String(value)); |
| | | } |
| | | |
| | | 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)); |
| | | }); |
| | | |
| | |
| | | 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(); |
| | | 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); |
| | | } |
| | | |
| | | if (aVal < bVal) return order === 'asc' ? -1 : 1; |
| | | if (aVal > bVal) return order === 'asc' ? 1 : -1; |
| | | return 0; |
| | | }); |
| | | // 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); |
| | |
| | | store.filters[key] = value; |
| | | } |
| | | }); |
| | | |
| | | 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) { |
| | | this.notify(name, 'data-loaded'); |
| | | } else { |
| | | const filtered = this.getFiltered(name); |
| | | this.notify(name, 'data-loaded', { |
| | | cached: true, |
| | | items: filtered |
| | | }); |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | 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 (this.hasCompleteData(store, store.filters)) { |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | if ('page' in updates) { |