From 56a9a1ccf764ff7a6af8f8a2292cb07443cb4aa7 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Thu, 28 May 2026 18:19:57 +0000
Subject: [PATCH] =New Gitbit setpu
---
assets/js/concise/DataStore.js | 2259 ++++++++++++++++++++++++++++++++++------------------------
1 files changed, 1,325 insertions(+), 934 deletions(-)
diff --git a/assets/js/concise/DataStore.js b/assets/js/concise/DataStore.js
index fe9b22e..284c913 100644
--- a/assets/js/concise/DataStore.js
+++ b/assets/js/concise/DataStore.js
@@ -1,355 +1,193 @@
/**
- * 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;
}
/**
@@ -357,12 +195,12 @@
*/
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;
}
@@ -389,6 +227,7 @@
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));
@@ -396,18 +235,13 @@
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);
}
}
@@ -416,691 +250,1248 @@
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();
+ }
+ });
+});
--
Gitblit v1.10.0