From 3d25ad7de4fdbb3eba2cde2a62422e6be5d79a0a Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Tue, 25 Aug 2026 22:57:22 +0000
Subject: [PATCH] =It's been a minute. I went through the Routes and associated Managers and ensured they played together well. Reworked the Cache.php, and I think I caught all the references. Moving away from JVBase() root class, to individuall classes that are Instanced. Double checked for the ABSPATH check in every file, and added index.php to every directory that didn't already have it. Also still working on the MenuBlock to work with WooCommerce for now for Northeh
---
assets/js/concise/DataStoreOld.js | 1099 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 files changed, 1,099 insertions(+), 0 deletions(-)
diff --git a/assets/js/concise/DataStoreOld.js b/assets/js/concise/DataStoreOld.js
new file mode 100644
index 0000000..4aa4119
--- /dev/null
+++ b/assets/js/concise/DataStoreOld.js
@@ -0,0 +1,1099 @@
+/**
+ * DataStore - Singleton pattern managing multiple store namespaces
+ *
+ * Usage:
+ * window.jvbStore = new DataStore();
+ * this.store = window.jvbStore.register('feed', { config });
+ */
+class DataStoreOld {
+ 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();
+
+ // window.addEventListener('beforeunload', () => this.destroy());
+ }
+
+ 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, // Validate data is serializable
+ ...config
+ },
+ dbKey: name,
+ storeKey: storeKey,
+ data: new Map(),
+ cache: new Map(),
+ httpHeaders: new Map(),
+ subscribers: 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),
+ 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),
+ clearHttpHeaders: (key) => this.clearHttpHeaders(name, key),
+
+ // 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;
+ }
+
+ /**
+ * Initialize database for a specific store
+ */
+ 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
+ if (config.endpoint && !db.objectStoreNames.contains('cache')) {
+ const cacheStore = db.createObjectStore('cache', { keyPath: 'key' });
+ cacheStore.createIndex('timestamp', 'timestamp', { unique: false });
+ }
+
+ // HTTP headers store
+ if (config.useHttpCaching && !db.objectStoreNames.contains('headers')) {
+ db.createObjectStore('headers', { keyPath: 'key' });
+ }
+ }
+
+ loadStoreDataInBackground(name) {
+ const store = this.stores.get(name);
+ if (!store?.db) return;
+
+ const tasks = [
+ this.loadStoreData(name),
+ this.loadStoreCache(name),
+ this.loadStoreHeaders(name)
+ ];
+
+ Promise.all(tasks)
+ .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 loadStoreData(name) {
+ const store = this.stores.get(name);
+ if (!store?.db) return;
+
+ return new Promise((resolve) => {
+ const tx = store.db.transaction([store.config.storeName], 'readonly');
+ const objectStore = tx.objectStore(store.config.storeName);
+ const request = objectStore.getAll();
+
+ request.onsuccess = (e) => {
+ const items = e.target.result || [];
+ items.forEach(item => {
+ const key = this.getItemKey(item, store.config.keyPath);
+ store.data.set(key, item);
+ });
+ this.notify(name, 'data-loaded', { count: items.length });
+ resolve(items);
+ };
+
+ request.onerror = () => resolve([]);
+ });
+ }
+
+ async loadStoreCache(name) {
+ const store = this.stores.get(name);
+ if (!store?.db || !store.db.objectStoreNames.contains('cache')) return;
+
+ return new Promise((resolve) => {
+ const tx = store.db.transaction(['cache'], 'readonly');
+ const objectStore = tx.objectStore('cache');
+ const request = objectStore.getAll();
+
+ request.onsuccess = (e) => {
+ (e.target.result || []).forEach(item => {
+ if (this.isCacheValid(item, store.config.TTL)) {
+ store.cache.set(item.key, item);
+ }
+ });
+ resolve();
+ };
+
+ request.onerror = () => resolve();
+ });
+ }
+
+ async loadStoreHeaders(name) {
+ const store = this.stores.get(name);
+ if (!store?.db || !store.db.objectStoreNames.contains('headers')) return;
+
+ return new Promise((resolve) => {
+ const tx = store.db.transaction(['headers'], 'readonly');
+ const objectStore = tx.objectStore('headers');
+ const request = objectStore.getAll();
+
+ request.onsuccess = (e) => {
+ (e.target.result || []).forEach(header => {
+ store.httpHeaders.set(header.key, header);
+ });
+ resolve();
+ };
+
+ request.onerror = () => resolve();
+ });
+ }
+
+ 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);
+ }
+ }
+
+ 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 };
+ const cachedHeaders = store.httpHeaders.get(cacheKey);
+
+ if (store.config.useHttpCaching && cachedHeaders) {
+ if (cachedHeaders.etag) {
+ headers['If-None-Match'] = cachedHeaders.etag;
+ }
+ if (cachedHeaders.lastModified) {
+ headers['If-Modified-Since'] = cachedHeaders.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 can happen on first load when cache headers exist but data doesn't
+ 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();
+
+ if (store.config.useHttpCaching) {
+ this.storeResponseHeaders(name, cacheKey, response);
+ }
+ await this.processFetchedData(name, data, cacheKey);
+
+ 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) {
+ 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) {
+ const tx = store.db.transaction([store.config.storeName], 'readwrite');
+ const objectStore = tx.objectStore(store.config.storeName);
+
+ for (const item of items) {
+ try {
+ // Use shared save logic
+ const changeInfo = await this._saveItem(name, item, false);
+ changes.push(changeInfo);
+
+ // Queue for batch write
+ await objectStore.put(changeInfo.processed);
+ } catch (error) {
+ console.error(`Error processing item:`, error);
+ }
+ }
+
+ // Wait for transaction to complete
+ await new Promise((resolve, reject) => {
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ });
+ }
+
+ // Update cache
+ const cacheEntry = {
+ key: cacheKey,
+ items: items.map(item => this.getItemKey(item, store.config.keyPath)),
+ timestamp: Date.now(),
+ endpoint: store.config.endpoint,
+ filters: { ...store.filters }
+ };
+
+ store.cache.set(cacheKey, cacheEntry);
+ await this.saveToCache(name, cacheKey, 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
+ });
+ }
+ });
+ }
+
+
+ /**
+ * Internal method: Save a single item with full tracking
+ */
+ async _saveItem(name, item, emitEvent = true) {
+ 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);
+
+ // Write to IndexedDB happens in batch transaction (if provided)
+ // or individual transaction (if not)
+
+ // 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 = await this._saveItem(name, item);
+
+ // Write to IndexedDB immediately for single saves
+ if (store.db) {
+ const tx = store.db.transaction([store.config.storeName], 'readwrite');
+ const objectStore = tx.objectStore(store.config.storeName);
+ await 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') {
+ return validate ? { valid: false, error: `Function at ${path}` } : { valid: true, data: null };
+ }
+
+ // DOM elements
+ if (obj instanceof HTMLElement || obj.nodeType !== undefined) {
+ return validate ? { valid: false, error: `DOM element at ${path}` } : { valid: true, data: null };
+ }
+
+ // FormData - convert and continue
+ if (obj instanceof FormData) {
+ return validate
+ ? { valid: false, error: `FormData at ${path}` }
+ : { valid: true, data: this.formDataToObject(obj) };
+ }
+
+ // Preserve safe types
+ if (obj instanceof Date || obj instanceof ArrayBuffer || ArrayBuffer.isView(obj)) {
+ return { valid: true, data: obj };
+ }
+
+ // Convert Sets to Arrays
+ if (obj instanceof Set) {
+ const arr = Array.from(obj);
+ return this.processForStorage(arr, 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 !== null) 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 !== null) processed[key] = result.data;
+ }
+ return { valid: true, data: processed };
+ }
+
+ return validate
+ ? { valid: false, error: `Unknown type at ${path}` }
+ : { valid: true, data: null };
+ }
+
+ async delete(name, id) {
+ const store = this.stores.get(name);
+ store.data.delete(id);
+
+ if (store.db) {
+ const tx = store.db.transaction([store.config.storeName], 'readwrite');
+ const objectStore = tx.objectStore(store.config.storeName);
+ await 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());
+ }
+
+ 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();
+
+ if (store.db) {
+ const tx = store.db.transaction([store.config.storeName], 'readwrite');
+ const objectStore = tx.objectStore(store.config.storeName);
+ await objectStore.clear();
+ }
+
+ this.notify(name, 'data-cleared');
+ }
+
+ setFilter(name, key, value) {
+ const store = this.stores.get(name);
+ const oldValue = store.filters[key];
+
+ if (value === null || value === undefined || value === '') {
+ delete store.filters[key];
+ } else {
+ store.filters[key] = value;
+ }
+ this.notify(name, 'filters-changed', {
+ filters: store.filters,
+ changed: { key, oldValue, newValue: value }
+ });
+
+ if (store.config.endpoint) {
+ this.fetch(name);
+ }
+ }
+
+ 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;
+
+ store.filters = { ...store.filters, ...filters };
+
+ this.notify(name, 'filters-changed', {
+ filters: store.filters,
+ changed: filters
+ });
+
+ if (store.config.endpoint) {
+ await this.fetch(name);
+ }
+ }
+
+ removeFilter(name, key) {
+ const store = this.stores.get(name);
+ const oldValue = store.filters[key];
+
+ if (oldValue !== undefined) {
+ delete store.filters[key];
+
+ this.notify(name, 'filters-changed', {
+ filters: store.filters,
+ removed: { key, oldValue }
+ });
+
+ if (store.config.endpoint) {
+ this.fetch(name);
+ }
+ }
+ }
+
+ clearFilters(name) {
+ const store = this.stores.get(name);
+ const oldFilters = { ...store.filters };
+
+ store.filters = { ...store.config.filters };
+
+ this.notify(name, 'filters-cleared', {
+ oldFilters,
+ filters: store.filters
+ });
+
+ if (store.config.endpoint) {
+ this.fetch(name);
+ }
+ }
+
+ clearCache(name) {
+ const store = this.stores.get(name);
+ store.cache.clear();
+
+ if (store.db && store.db.objectStoreNames.contains('cache')) {
+ const tx = store.db.transaction(['cache'], 'readwrite');
+ const objectStore = tx.objectStore('cache');
+ objectStore.clear();
+ }
+
+ this.notify(name, 'cache-cleared');
+ }
+
+ clearHttpHeaders(name, cacheKey = null) {
+ const store = this.stores.get(name);
+
+ if (cacheKey) {
+ store.httpHeaders.delete(cacheKey);
+
+ if (store.db && store.db.objectStoreNames.contains('headers')) {
+ const tx = store.db.transaction(['headers'], 'readwrite');
+ const objectStore = tx.objectStore('headers');
+ objectStore.delete(cacheKey);
+ }
+ } else {
+ store.httpHeaders.clear();
+
+ if (store.db && store.db.objectStoreNames.contains('headers')) {
+ const tx = store.db.transaction(['headers'], 'readwrite');
+ const objectStore = tx.objectStore('headers');
+ objectStore.clear();
+ }
+ }
+ }
+
+ 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);
+ }
+ });
+ }
+
+ storeResponseHeaders(name, key, response) {
+ const store = this.stores.get(name);
+
+ const headers = {
+ key,
+ etag: response.headers.get('ETag'),
+ lastModified: response.headers.get('Last-Modified'),
+ timestamp: Date.now()
+ };
+
+ store.httpHeaders.set(key, headers);
+
+ if (store.db && store.db.objectStoreNames.contains('headers')) {
+ const tx = store.db.transaction(['headers'], 'readwrite');
+ const objectStore = tx.objectStore('headers');
+ objectStore.put(headers);
+ }
+ }
+
+ async saveToCache(name, key, data) {
+ const store = this.stores.get(name);
+ if (!store.db || !store.db.objectStoreNames.contains('cache')) return;
+
+ const tx = store.db.transaction(['cache'], 'readwrite');
+ const objectStore = tx.objectStore('cache');
+ await objectStore.put(data);
+ }
+
+ 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;
+ }
+
+ 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();
+ }
+ });
+});
--
Gitblit v1.10.0