| | |
| | | /** |
| | | * ExtendedDataStore - A flexible IndexedDB wrapper with HTTP caching |
| | | * DataStore - Singleton pattern managing multiple store namespaces |
| | | * |
| | | * 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; |
| | | } |
| | | }); |
| | | * Usage: |
| | | * window.jvbStore = new DataStore(); |
| | | * this.store = window.jvbStore.register('feed', { config }); |
| | | */ |
| | | 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, |
| | | constructor() { |
| | | // Singleton pattern |
| | | if (DataStore.instance) { |
| | | return DataStore.instance; |
| | | } |
| | | DataStore.instance = this; |
| | | |
| | | // Cache configuration |
| | | TTL: 3600000, // 1 hour default |
| | | useHttpCaching: true, // ETag and If-Modified-Since |
| | | cacheKeyStrategy: 'filters', // How to generate cache keys |
| | | // 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 = []; |
| | | |
| | | // UI configuration |
| | | showLoading: true, |
| | | |
| | | // Features |
| | | stripDOMReferences: true, |
| | | storeBlobs: 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 |
| | | }; |
| | | |
| | | // Global state |
| | | this._initialized = false; |
| | | this.body = document.body; |
| | | this.loading = document.querySelector('dialog.loading'); |
| | | |
| | | // Auto-initialize |
| | | this.initDB(); |
| | | |
| | | // Cleanup on page unload |
| | | window.addEventListener('beforeunload', () => this.destroy()); |
| | | this.init(); |
| | | } |
| | | |
| | | /** |
| | | * Initialize IndexedDB with configurable schema |
| | | */ |
| | | async initDB() { |
| | | async init() { |
| | | if (this._initialized) return; |
| | | this._initialized = true; |
| | | |
| | | 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 cache and headers BEFORE fetching (only if stores exist) |
| | | const loadTasks = [this.loadFromDB()]; |
| | | |
| | | if (this.db.objectStoreNames.contains('cache')) { |
| | | loadTasks.push(this.loadCache()); |
| | | } |
| | | |
| | | if (this.config.useHttpCaching && this.db.objectStoreNames.contains('headers')) { |
| | | loadTasks.push(this.loadHeaders()); |
| | | } |
| | | |
| | | await Promise.all(loadTasks); |
| | | |
| | | this.notify('db-init'); |
| | | |
| | | // Now fetch if needed (cache might already have data) |
| | | if (this.config.endpoint) { |
| | | this.fetch(); |
| | | } |
| | | }; |
| | | |
| | | request.onerror = (e) => { |
| | | console.error(`IndexedDB error for ${dbName}:`, e); |
| | | if (this.config.onError) { |
| | | this.config.onError(e); |
| | | } |
| | | }; |
| | | } |
| | | |
| | | /** |
| | | * Load all data from IndexedDB |
| | | * 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 |
| | | */ |
| | | async loadFromDB() { |
| | | if (!this.db) return; |
| | | register(name, configs = [], version = 1.25) { |
| | | if (!Array.isArray(configs)) configs = [configs]; |
| | | if (configs.length === 0) 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(); |
| | | if (!this.dbConfig.has(name)) { |
| | | this.dbConfig.set(name, { |
| | | dbName: `${jvbBase.base}${name}`, |
| | | version: version, |
| | | stores: {}, |
| | | _initialized: false |
| | | }); |
| | | } |
| | | |
| | | request.onsuccess = async (e) => { |
| | | const items = e.target.result; |
| | | let dbEntry = this.dbConfig.get(name); |
| | | |
| | | // Restore FormData for ALL items on startup |
| | | 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); |
| | | } |
| | | 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`); |
| | | } |
| | | |
| | | this.notify('data-loaded', { count: items.length }); |
| | | resolve(items); |
| | | 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 |
| | | }; |
| | | |
| | | request.onerror = (e) => reject(e); |
| | | }); |
| | | } |
| | | store.ignoreFilters = new Set([ |
| | | ... ['search', 'page', 'per_page', 'orderby', 'order'], |
| | | ... ['context', 'source'], |
| | | ... store.config.ignore |
| | | ]); |
| | | |
| | | |
| | | |
| | | /** |
| | | * 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 => { |
| | | // Strip DOM references if needed |
| | | const cleaned = this.config.stripDOMReferences |
| | | ? this.stripDOMReferences(item) |
| | | : item; |
| | | |
| | | const key = this.getItemKey(cleaned); |
| | | this.data.set(key, cleaned); |
| | | }); |
| | | resolve(); |
| | | store.config.headers = { |
| | | 'X-WP-Nonce': window.auth.getNonce(), |
| | | ...store.config.headers |
| | | }; |
| | | |
| | | request.onerror = (e) => reject(e); |
| | | 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; |
| | | } |
| | | |
| | | /** |
| | | * Strip DOM references from an object (recursive) |
| | | * Get the API object for a registered store |
| | | */ |
| | | stripDOMReferences(obj) { |
| | | if (!obj || typeof obj !== 'object') return obj; |
| | | 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), |
| | | |
| | | // Handle arrays |
| | | if (Array.isArray(obj)) { |
| | | return obj.map(item => this.stripDOMReferences(item)); |
| | | } |
| | | // 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), |
| | | |
| | | // Handle objects |
| | | const cleaned = {}; |
| | | for (const [key, value] of Object.entries(obj)) { |
| | | // Skip DOM-related properties |
| | | if (this.isDOMReference(key, value)) { |
| | | continue; |
| | | } |
| | | // Cache methods |
| | | clearCache: () => this.clearCache(name), |
| | | |
| | | // 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; |
| | | } |
| | | } |
| | | // Event methods |
| | | subscribe: (callback) => this.subscribe(name, callback), |
| | | |
| | | return cleaned; |
| | | } |
| | | // Utility |
| | | ensureInitialized: () => this.ensureStoreInitialized(name), |
| | | |
| | | /** |
| | | * 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; |
| | | } |
| | | // Exposed properties (read-only) |
| | | get filters() { |
| | | return { ...api.getStore().filters }; |
| | | }, |
| | | get lastResponse() { |
| | | return api.getStore().lastResponse; |
| | | }, |
| | | get data() { |
| | | return api.getStore().data; |
| | | }, |
| | | |
| | | // Check key names - use exact match or word boundaries |
| | | const domKeys = ['element', 'el', 'dom', 'node', 'ui', 'container', 'wrapper']; |
| | | const lowerKey = key.toLowerCase(); |
| | | getStore: () => this.stores.get(name) |
| | | }; |
| | | |
| | | // 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.endpoint){ |
| | | this.saveToServer(item); |
| | | } |
| | | |
| | | this.notify('item-saved', { item: cleaned, key }); |
| | | |
| | | return cleaned; |
| | | return api; |
| | | } |
| | | |
| | | /** |
| | |
| | | */ |
| | | formDataToObject(formData) { |
| | | const obj = { |
| | | _isFormData: true, // Flag to reconstruct later |
| | | _isFormData: true, |
| | | entries: {} |
| | | }; |
| | | |
| | | for (const [key, value] of formData.entries()) { |
| | | // Skip File/Blob objects - they're stored separately |
| | | // Skip File/Blob objects - they're stored separately in UploadManager |
| | | if (value instanceof File || value instanceof Blob) { |
| | | continue; |
| | | } |
| | |
| | | |
| | | 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)); |
| | |
| | | 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 } |
| | | ); |
| | | 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; |
| | | } |
| | | |
| | | /** |
| | | * Save item to IndexedDB |
| | | */ |
| | | async saveToDB(item) { |
| | | if (!this.db) return; |
| | | /*********************************************************************** |
| | | * DATABASE INITIALIZATION |
| | | ***********************************************************************/ |
| | | |
| | | 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); |
| | | async initDB(name) { |
| | | const db = this.dbConfig.get(name); |
| | | if (!db || db._initialized) return; |
| | | |
| | | 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); |
| | | if (this.pendingInits.has(name)) { |
| | | return this.pendingInits.get(name); |
| | | } |
| | | |
| | | 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'); |
| | | } |
| | | 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); |
| | | console.log('CacheKey: ', cacheKey); |
| | | |
| | | // 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); |
| | | console.log('Cached Data: ', cachedData); |
| | | if (cachedData && this.isCacheValid(cachedData)) { |
| | | console.log('Returning cached data: '); |
| | | this.isFetching = false; |
| | | this.currentCacheKey = null; |
| | | if (this.config.showLoading) { |
| | | this.setLoading(false); |
| | | } |
| | | 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 : ''}`; |
| | | const initPromise = this._performDBInit(name); |
| | | this.pendingInits.set(name, initPromise); |
| | | |
| | | try { |
| | | const response = await fetch(url, { |
| | | method: 'GET', |
| | | headers: requestHeaders |
| | | 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'); |
| | | } |
| | | }); |
| | | |
| | | // Handle 304 Not Modified |
| | | if (response.status === 304 && cachedData) { |
| | | // Update timestamp but keep existing data |
| | | cachedData.timestamp = Date.now(); |
| | | cachedData.fromCache = true; |
| | | cachedData.isError = false; |
| | | this.saveCache(cacheKey, cachedData); |
| | | console.log(this.config.storeName+' Data loaded from cache'); |
| | | this.notify('data-loaded', cachedData); |
| | | fetchResult = cachedData.data; |
| | | return cachedData.data; |
| | | } 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(); |
| | | |
| | | // Store HTTP caching headers |
| | | if (this.config.useHttpCaching) { |
| | | this.storeResponseHeaders(cacheKey, response); |
| | | } |
| | | await this.processFetchedData(name, data, cacheKey, response); |
| | | |
| | | // Cache the response |
| | | const cacheEntry = { |
| | | key: cacheKey, |
| | | data: data, |
| | | timestamp: Date.now(), |
| | | endpoint: this.config.endpoint, |
| | | filters: filters |
| | | }; |
| | | console.log(this.config.storeName + 'Fetched fresh from server'); |
| | | |
| | | 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 |
| | | this.notify(name, 'data-loaded', { |
| | | cached: false, |
| | | items: data.items || [] |
| | | }); |
| | | |
| | | fetchResult = data; |
| | | return data; |
| | | |
| | | } catch (error) { |
| | | console.error('Fetch error:', error); |
| | | const isAbortError = error?.name === 'AbortError'; |
| | | |
| | | // 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; |
| | | 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 { |
| | | if (this.config.showLoading) { |
| | | store.isFetching = false; |
| | | store.currentRequest = null; |
| | | |
| | | if (store.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'); |
| | | } |
| | | buildFetchUrl(name) { |
| | | const store = this.stores.get(name); |
| | | const params = new URLSearchParams(); |
| | | |
| | | 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]) => { |
| | | Object.entries(store.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; |
| | | if (typeof value === 'object') { |
| | | params.set(key, JSON.stringify(value)); |
| | | } else { |
| | | cleaned[key] = value; |
| | | params.set(key, value); |
| | | } |
| | | } |
| | | }); |
| | | return cleaned; |
| | | |
| | | const baseUrl = store.config.apiBase + store.config.endpoint; |
| | | return params.toString() ? `${baseUrl}?${params}` : baseUrl; |
| | | } |
| | | |
| | | /** |
| | | * Generate cache key from filters |
| | | * Process fetched data (batch from server) |
| | | */ |
| | | generateCacheKey(filters) { |
| | | if (this.config.cacheKeyStrategy === 'custom' && this.config.generateCacheKey) { |
| | | return this.config.generateCacheKey(filters); |
| | | 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); |
| | | } |
| | | }); |
| | | }); |
| | | } |
| | | |
| | | // Default strategy: sort keys and create string |
| | | const sorted = Object.keys(filters) |
| | | // 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>} - 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>} - 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<Array>} - 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<boolean>} - 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(sorted); |
| | | return JSON.stringify(normalized); |
| | | } |
| | | |
| | | setFilter(key, value) { |
| | | if (!this.filters) { |
| | | this.filters = {}; |
| | | 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 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) { |
| | | window.debouncer.schedule( |
| | | this.config.endpoint, |
| | | this.fetch.bind(this), |
| | | 100 |
| | | ); |
| | | } |
| | | 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; |
| | | |
| | | /** |
| | | * 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) { |
| | | window.debouncer.schedule( |
| | | this.config.endpoint, |
| | | this.fetch.bind(this), |
| | | 100 |
| | | ); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 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) { |
| | | window.debouncer.schedule( |
| | | this.config.endpoint, |
| | | this.fetch.bind(this), |
| | | 100 |
| | | ); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 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); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 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); |
| | | } |
| | | |
| | | /** |
| | | * 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 => { |
| | | subscribers.forEach(callback => { |
| | | try { |
| | | callback(event, data); |
| | | } catch (error) { |
| | | console.error('Subscriber error:', error); |
| | | console.error(`Subscriber error for store "${name}":`, error); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Query items using an index |
| | | */ |
| | | async query(indexName, value) { |
| | | if (!this.db) return []; |
| | | /*********************************************************************** |
| | | * UTILITIES |
| | | ***********************************************************************/ |
| | | |
| | | return new Promise((resolve, reject) => { |
| | | const tx = this.db.transaction([this.config.storeName], 'readonly'); |
| | | const store = tx.objectStore(this.config.storeName); |
| | | getItemKey(item, keyPath) { |
| | | if (typeof keyPath === 'function') { |
| | | return keyPath(item); |
| | | } |
| | | |
| | | if (!store.indexNames.contains(indexName)) { |
| | | reject(new Error(`Index ${indexName} does not exist`)); |
| | | return; |
| | | } |
| | | const keys = keyPath.split('.'); |
| | | let value = item; |
| | | |
| | | const index = store.index(indexName); |
| | | const request = value !== undefined |
| | | ? index.getAll(value) |
| | | : index.getAll(); |
| | | for (const key of keys) { |
| | | value = value?.[key]; |
| | | } |
| | | |
| | | 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); |
| | | }); |
| | | return value; |
| | | } |
| | | |
| | | /** |
| | | * 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) { |
| | | console.log('Setting Loading ' + (on) ? 'on' : 'off' + ' from '.this.config.storeName); |
| | | this.body.classList.toggle('loading', on); |
| | | if (on) { |
| | | this.loading.showModal(); |
| | | this.loading?.showModal(); |
| | | } else { |
| | | this.loading.close(); |
| | | this.loading?.close(); |
| | | } |
| | | |
| | | } |
| | | |
| | | /** |
| | | * Cleanup and destroy |
| | | */ |
| | | destroy() { |
| | | if (this.currentRequest) { |
| | | this.currentRequest.abort(); |
| | | } |
| | | this.stores.forEach(store => { |
| | | if (store.currentRequest) { |
| | | store.currentRequest.abort(); |
| | | } |
| | | }); |
| | | |
| | | this.databases.forEach(db => db.close()); |
| | | this.stores.clear(); |
| | | 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'); |
| | | this.databases.clear(); |
| | | this.pendingInits.clear(); |
| | | } |
| | | } |
| | | |
| | | // Export for use |
| | | window.jvbStore = DataStore; |
| | | // Initialize singleton on DOMContentLoaded |
| | | document.addEventListener('DOMContentLoaded', async function() { |
| | | window.auth.subscribe((event) => { |
| | | if (event === 'auth-loaded') { |
| | | window.jvbStore = new DataStore(); |
| | | } |
| | | }); |
| | | }); |