/** * DataStore - Singleton pattern managing multiple store namespaces * * Usage: * window.jvbStore = new DataStore(); * this.store = window.jvbStore.register('feed', { config }); */ class DataStore { constructor() { // Singleton pattern if (DataStore.instance) { return DataStore.instance; } DataStore.instance = this; // Shared resources this.dbConfig = new Map(); // Definitions for the databases this.databases = new Map(); // Shared IndexedDB connections this.stores = new Map(); // Registered store namespaces this.subscribers = new Map(); // Per-store event subscribers this.pendingInits = new Map(); // Track initialization promises this.fetchQueue = []; // Global state this._initialized = false; this.body = document.body; this.loading = document.querySelector('dialog.loading'); this.init(); } async init() { if (this._initialized) return; this._initialized = true; if (!('indexedDB' in window)) { console.warn('IndexedDB not supported'); } } /** * Register a new store namespace * @param {string} name Database Name * @param {object|array} configs An object defining the store, or an array of objects defining the stores * @param {number} version the database version */ register(name, configs = [], version = 1.1) { if (!Array.isArray(configs)) configs = [configs]; if (configs.length === 0) return; if (!this.dbConfig.has(name)) { this.dbConfig.set(name, { dbName: `jvb_${name}`, version: version, stores: {}, _initialized: false }); } let dbEntry = this.dbConfig.get(name); configs.forEach(config => { if (!config.storeName) { throw new Error(`Store config for "${name}" missing storeName`); } if (!config.keyPath) { throw new Error(`Store "${config.storeName}" requires keyPath`); } const storeKey = `${name}_${config.storeName}`; const store = { config: { // Storage dbName: dbEntry.dbName, storeName: 'items', keyPath: 'id', indexes: [], // API endpoint: null, apiBase: jvbSettings.api, filters: {}, required: null, // Cache TTL: 3600000, // 1 hour useHttpCaching: true, // Behavior showLoading: false, delayFetch: true, validateData: true, ...config }, dbKey: name, storeKey: storeKey, data: new Map(), cache: new Map(), filters: {...(config.filters || {})}, isFetching: false, currentRequest: null, lastResponse: null, _initialized: false }; store.config.headers = { 'X-WP-Nonce': window.auth.getNonce(), ...store.config.headers }; dbEntry.stores[config.storeName] = storeKey; this.stores.set(storeKey, store); if (!this.subscribers.has(storeKey)) { this.subscribers.set(storeKey, new Set()); } }); // Initialize database asynchronously this.initDB(name).catch(error => { console.error(`Failed to initialize store "${name}":`, error); }); const apis = {}; for (const [storeName, storeKey] of Object.entries(dbEntry.stores)) { apis[storeName] = this.getStoreAPI(storeKey); } return apis; } /** * Get the API object for a registered store */ getStoreAPI(name) { const api = { // Data methods fetch: () => this.fetch(name), save: (item) => this.save(name, item), delete: (id) => this.delete(name, id), get: (id) => this.get(name, id), getAll: () => this.getAll(name), getAllByIndex: (indexName, value) => this.getAllByIndex(name, indexName, value), filterByIndex: (criteria) => this.filterByIndex(name, criteria), getFiltered: () => this.getFiltered(name), clear: () => this.clear(name), // Filter methods setFilter: (key, value) => this.setFilter(name, key, value), setFilters: (filters) => this.setFilters(name, filters), removeFilter: (key) => this.removeFilter(name, key), clearFilters: () => this.clearFilters(name), // Cache methods clearCache: () => this.clearCache(name), // Event methods subscribe: (callback) => this.subscribe(name, callback), // Utility ensureInitialized: () => this.ensureStoreInitialized(name), // Exposed properties (read-only) get filters() { return { ...api.getStore().filters }; }, get lastResponse() { return api.getStore().lastResponse; }, get data() { return api.getStore().data; }, getStore: () => this.stores.get(name) }; return api; } /** * Convert FormData to plain object for storage */ formDataToObject(formData) { const obj = { _isFormData: true, entries: {} }; for (const [key, value] of formData.entries()) { // Skip File/Blob objects - they're stored separately in UploadManager if (value instanceof File || value instanceof Blob) { continue; } // Handle multiple values for same key if (obj.entries[key]) { if (!Array.isArray(obj.entries[key])) { obj.entries[key] = [obj.entries[key]]; } obj.entries[key].push(value); } else { obj.entries[key] = value; } } return obj; } /** * Convert stored object back to FormData */ async objectToFormData(obj) { if (!obj._isFormData) return obj; const formData = new FormData(); // Restore text entries for (const [key, value] of Object.entries(obj.entries)) { if (Array.isArray(value)) { value.forEach(v => formData.append(key, v)); } else { formData.append(key, value); } } if (window.jvbUploads && obj.entries.upload_ids) { const uploadIds = JSON.parse(obj.entries.upload_ids); for (const uploadId of uploadIds) { const file = await window.jvbUploads.getBlobData(uploadId); if (file) { formData.append('files[]', file); } } } return formData; } /*********************************************************************** * DATABASE INITIALIZATION ***********************************************************************/ async initDB(name) { const db = this.dbConfig.get(name); if (!db || db._initialized) return; if (this.pendingInits.has(name)) { return this.pendingInits.get(name); } const initPromise = this._performDBInit(name); this.pendingInits.set(name, initPromise); try { await initPromise; db._initialized = true; } finally { this.pendingInits.delete(name); } } async _performDBInit(name) { const database = this.dbConfig.get(name); const { dbName, version } = database; const stores = Object.values(database.stores); try { if (!this.databases.has(dbName)) { const db = await this.openDatabase(dbName, version, (db) => { stores.forEach(store => { let storeObj = this.stores.get(store); if (storeObj) { this.setupStores(db, storeObj.config); } }); }); this.databases.set(dbName, db); } stores.forEach(storeName => { let store = this.stores.get(storeName); if (store) { store.db = this.databases.get(dbName); store._initialized = true; this.loadStoreDataInBackground(storeName); this.notify(storeName, 'db-init'); } }); } catch (error) { console.error(`Failed to initialize database for store "${name}":`, error); throw error; } } openDatabase(dbName, version, onUpgrade) { return new Promise((resolve, reject) => { const request = indexedDB.open(dbName, version); request.onupgradeneeded = (e) => { if (onUpgrade) { onUpgrade(e.target.result, e.oldVersion, e.newVersion); } }; request.onsuccess = (e) => resolve(e.target.result); request.onerror = (e) => reject(e.target.error); request.onblocked = () => { console.warn(`Database ${dbName} blocked. Close other tabs.`); }; }); } setupStores(db, config) { // Main store if (!db.objectStoreNames.contains(config.storeName)) { const store = db.createObjectStore(config.storeName, { keyPath: config.keyPath }); config.indexes.forEach(index => { store.createIndex( index.name, index.keyPath || index.name, { unique: index.unique || false } ); }); } // Cache store (now includes HTTP headers) if (config.endpoint && !db.objectStoreNames.contains('cache')) { const cacheStore = db.createObjectStore('cache', { keyPath: 'key' }); cacheStore.createIndex('timestamp', 'timestamp', { unique: false }); } } /** * Generic loader for any object store */ async loadFromObjectStore(name, storeName, processItem) { const store = this.stores.get(name); if (!store?.db || !store.db.objectStoreNames.contains(storeName)) { return []; } return new Promise((resolve) => { const tx = store.db.transaction([storeName], 'readonly'); const objectStore = tx.objectStore(storeName); const request = objectStore.getAll(); request.onsuccess = (e) => { const items = e.target.result || []; items.forEach(processItem); resolve(items); }; request.onerror = () => resolve([]); }); } loadStoreDataInBackground(name) { const store = this.stores.get(name); if (!store?.db) return; Promise.all([ // Load main data this.loadFromObjectStore(name, store.config.storeName, (item) => { const key = this.getItemKey(item, store.config.keyPath); store.data.set(key, item); }), // Load cache (includes HTTP headers now!) this.loadFromObjectStore(name, 'cache', (item) => { if (this.isCacheValid(item, store.config.TTL)) { store.cache.set(item.key, item); } }) ]) .then(() => { this.notify(name, 'data-ready'); // Add to fetch queue instead of immediate fetch if (store.config.endpoint && store.config.delayFetch) { this.fetchQueue.push(name); // Start processing queue if not already running if (this.fetchQueue.length === 1) { this.processFetchQueue(); } } else if (store.config.endpoint && !store.config.delayFetch) { // Immediate fetch if ('requestIdleCallback' in window) { requestIdleCallback(() => this.fetch(name), { timeout: 2000 }); } else { setTimeout(() => this.fetch(name), 100); } } }) .catch(error => { console.error(`Background load error for store "${name}":`, error); }); } async processFetchQueue() { if (this.fetchQueue.length === 0) return; const name = this.fetchQueue.shift(); const store = this.stores.get(name); if (!store) { // Store was removed, continue with next return this.processFetchQueue(); } try { await this.fetch(name); } catch (error) { console.error(`Queue fetch error for "${name}":`, error); } // Process next item with idle callback if (this.fetchQueue.length > 0) { if ('requestIdleCallback' in window) { requestIdleCallback(() => this.processFetchQueue(), { timeout: 2000 }); } else { setTimeout(() => this.processFetchQueue(), 50); } } } async ensureStoreInitialized(name) { const store = this.stores.get(name); if (!store) { throw new Error(`Store "${name}" not registered`); } if (!store._initialized) { await this.initDB(store.dbKey); } } /*********************************************************************** * TRANSACTION HELPER ***********************************************************************/ /** * Create transaction helper - reduces boilerplate */ async withTransaction(name, storeNames, mode, callback) { const store = this.stores.get(name); if (!store?.db) return null; // Ensure storeNames is an array if (typeof storeNames === 'string') storeNames = [storeNames]; return new Promise((resolve, reject) => { const tx = store.db.transaction(storeNames, mode); const stores = storeNames.map(name => tx.objectStore(name)); const objectStore = stores.length === 1 ? stores[0] : stores; let result; tx.oncomplete = () => resolve(result); tx.onerror = () => reject(tx.error); // Call callback immediately to queue operations try { result = callback(objectStore, tx); } catch (error) { reject(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)) { this.notify(name, 'data-loaded', { cached: true, items: cached.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; const response = await fetch(url, { method: 'GET', headers, signal: controller.signal }); if (response.status === 304) { // 304 means "Not Modified" - use cached data if available if (cached) { this.notify(name, 'data-loaded', { cached: true, notModified: true, items: cached.items || [] }); return cached; } // No cached data but server says not modified - return empty result this.notify(name, 'data-loaded', { cached: false, notModified: true, items: [] }); // Initialize empty lastResponse store.lastResponse = { has_more: false, total: 0, pages: 1, queue_stats: {} }; return { items: [] }; } // Now check for other non-OK responses if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); await this.processFetchedData(name, data, cacheKey, response); this.notify(name, 'data-loaded', { cached: false, items: data.items || [] }); return data; } catch (error) { if (error.name !== 'AbortError') { console.error(`Fetch error for store "${name}":`, error); this.notify(name, 'fetch-error', { error }); } throw error; } finally { store.isFetching = false; store.currentRequest = null; if (store.config.showLoading) { this.setLoading(false); } } } buildFetchUrl(name) { const store = this.stores.get(name); const params = new URLSearchParams(); Object.entries(store.filters).forEach(([key, value]) => { if (value !== null && value !== undefined && value !== '') { if (typeof value === 'object') { params.set(key, JSON.stringify(value)); } else { params.set(key, value); } } }); const baseUrl = store.config.apiBase + store.config.endpoint; return params.toString() ? `${baseUrl}?${params}` : baseUrl; } /** * Process fetched data (batch from server) */ async processFetchedData(name, data, cacheKey, response) { const store = this.stores.get(name); const items = data.items || []; const changes = []; // Track all changes // Batch process with single transaction if (store.db && items.length > 0) { await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => { items.forEach(item => { try { // Use shared save logic const changeInfo = this._saveItem(name, item); changes.push(changeInfo); // Queue for batch write objectStore.put(changeInfo.processed); } catch (error) { console.error(`Error processing item:`, error); } }); }); } // Update cache (now includes HTTP headers!) const cacheEntry = { key: cacheKey, items: items.map(item => this.getItemKey(item, store.config.keyPath)), timestamp: Date.now(), endpoint: store.config.endpoint, filters: { ...store.filters }, etag: response.headers.get('ETag'), lastModified: response.headers.get('Last-Modified') }; 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 || {} }; // 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 }); } }); } /*********************************************************************** * 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; } processForStorage(obj, validate = true, path = 'root') { if (obj === null || obj === undefined) return { valid: true, data: obj }; 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}` }; console.debug(`[DataStore] Stripped function at ${path}`); return { valid: true, data: undefined }; } // DOM elements if (obj instanceof HTMLElement || obj.nodeType !== undefined) { if (validate) return { valid: false, error: `DOM element at ${path}` }; console.debug(`[DataStore] Stripped DOM element at ${path}`); return { valid: true, data: undefined }; } // FormData - convert and continue if (obj instanceof FormData) { if (validate) return { valid: false, error: `FormData at ${path}` }; console.debug(`[DataStore] Converted FormData at ${path}`); return { valid: true, data: this.formDataToObject(obj) }; } // 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)) { const result = this.processForStorage(value, validate, `${path}.${key}`); if (!result.valid) return result; if (result.data !== undefined) 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 }; } /*********************************************************************** * 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 }); } get(name, id) { const store = this.stores.get(name); return store.data.get(id); } 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 => { return Object.entries(criteria).every(([key, value]) => { const accepted = Array.isArray(value) ? value : [value]; return accepted.includes(item[key]); }); }); } /** * Get all items matching an index value * @param {string} name - Store name * @param {string} indexName - Name of the index to query * @param {*} value - Value to match * @returns {Promise} - Matching items */ async getAllByIndex(name, indexName, value) { const store = this.stores.get(name); const values = Array.isArray(value) ? value : [value]; // Try IndexedDB index query first (more efficient for large datasets) if (store.db && store.db.objectStoreNames.contains(store.config.storeName)) { try { const tx = store.db.transaction([store.config.storeName], 'readonly'); const objectStore = tx.objectStore(store.config.storeName); if (objectStore.indexNames.contains(indexName)) { const index = objectStore.index(indexName); const results = await Promise.all( values.map(v => new Promise((resolve, reject) => { const request = index.getAll(v); request.onsuccess = () => resolve(request.result || []); request.onerror = () => reject(request.error); })) ); return results.flat(); } } catch (error) { console.warn(`Index query failed for "${indexName}", falling back to filter:`, error); } } // Fallback: filter in-memory data return Array.from(store.data.values()).filter(item => values.includes(item[indexName])); } getFiltered(name) { const store = this.stores.get(name); const cacheKey = this.generateCacheKey(store.filters); const cacheEntry = store.cache.get(cacheKey); if (cacheEntry && cacheEntry.items) { return cacheEntry.items.reduce((acc, id) => { const item = store.data.get(id); if (item) acc.push(item); return acc; }, []); } return this.getAll(name); } 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 }; } else { // Apply updates (null/undefined/'' = delete) 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 }); if (store.config.endpoint) { await this.fetch(name); } } 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] ); if (!hasChanges) return; return this.updateFilters(name, filters); } removeFilter(name, key) { return this.updateFilters(name, { [key]: null }); } clearFilters(name) { return this.updateFilters(name, {}, true); } /*********************************************************************** * CACHE OPERATIONS ***********************************************************************/ clearCache(name) { const store = this.stores.get(name); store.cache.clear(); if (store.db?.objectStoreNames.contains('cache')) { this.withTransaction(name, 'cache', 'readwrite', (objectStore) => { objectStore.clear(); }); } this.notify(name, 'cache-cleared'); } generateCacheKey(filters) { const normalized = Object.keys(filters) .sort() .reduce((acc, key) => { acc[key] = filters[key]; return acc; }, {}); return JSON.stringify(normalized); } isCacheValid(entry, ttl) { if (!entry || !entry.timestamp) return false; const age = Date.now() - entry.timestamp; return age < ttl; } /*********************************************************************** * EVENT SYSTEM ***********************************************************************/ subscribe(name, callback) { if (!this.subscribers.has(name)) { this.subscribers.set(name, new Set()); } const subscribers = this.subscribers.get(name); subscribers.add(callback); return () => subscribers.delete(callback); } notify(name, event, data = {}) { const subscribers = this.subscribers.get(name); if (!subscribers) return; subscribers.forEach(callback => { try { callback(event, data); } catch (error) { console.error(`Subscriber error for store "${name}":`, error); } }); } /*********************************************************************** * UTILITIES ***********************************************************************/ getItemKey(item, keyPath) { if (typeof keyPath === 'function') { return keyPath(item); } const keys = keyPath.split('.'); let value = item; for (const key of keys) { value = value?.[key]; } return value; } setLoading(on) { this.body.classList.toggle('loading', on); if (on) { this.loading?.showModal(); } else { this.loading?.close(); } } destroy() { this.stores.forEach(store => { if (store.currentRequest) { store.currentRequest.abort(); } }); this.databases.forEach(db => db.close()); this.stores.clear(); this.subscribers.clear(); this.databases.clear(); this.pendingInits.clear(); } } // Initialize singleton on DOMContentLoaded document.addEventListener('DOMContentLoaded', async function() { window.auth.subscribe((event) => { if (event === 'auth-loaded') { window.jvbStore = new DataStore(); } }); });