/** * Middleman between front-end changes and backend processing * Uses IndexedDB to store content locally before ensuring it gets sent back to the server */ class DataStore { constructor(config = {}) { this.config = { name: false, endpoint: false, autosync: true, syncInterval: 10000, //10 seconds useIndexedDB: true, apiBase: jvbSettings.api, headers: {}, operation: {}, ... config } this.headers = { 'X-WP-Nonce': jvbSettings.nonce, ... this.config.headers } if (!config.name || !config.endpoint) { return; } this.items = new Map(); this.queue = new Map(); this.filters = config.filters??{}; //Load initial filters this.subscribers = new Set(); this.db = null; this.initDB(); } async initDB() { if (!('indexedDB' in window)) return; const request = indexedDB.open(`jvb_${this.config.name}_db`, 1); request.onupgradeneeded = (e) => { const db = e.target.result; if (!db.objectStoreNames.contains('items')) { db.createObjectStore('items', { keyPath: 'id' }); } if (!db.objectStoreNames.contains('queue')) { db.createObjectStore('queue', { keyPath: 'id' }); } }; request.onsuccess = (e) => { this.db = e.target.result; this.loadFromDB(); }; } async loadFromDB() { if (!this.db) return; // Load items const itemTx = this.db.transaction(['items'], 'readonly'); const itemStore = itemTx.objectStore('items'); const itemRequest = itemStore.getAll(); itemRequest.onsuccess = (e) => { e.target.result.forEach(item => { this.items.set(item.id, item); }); this.notify('items-loaded', Array.from(this.items.values())); }; // Load queue const queueTx = this.db.transaction(['queue'], 'readonly'); const queueStore = queueTx.objectStore('queue'); const queueRequest = queueStore.getAll(); queueRequest.onsuccess = (e) => { e.target.result.forEach(item => { this.queue.set(item.id, item); }); }; } async fetchFromServer() { try { const params = new URLSearchParams(this.filters); const response = await fetch(`${jvbSettings.api}${this.config.endpoint}?${params}`, { headers: this.headers }); const data = await response.json(); console.log(data); if (data.items) { // Clear and update items this.items.clear(); data.items.forEach(item => { this.items.set(item.id, item); }); // Save to IndexedDB this.saveItemsToDB(); // Notify subscribers this.notify('items-fetched', this.getFilteredItems()); } return data; } catch (error) { console.error('Fetch error:', error); this.notify('fetch-error', error); } } saveItemsToDB() { if (!this.db) return; const tx = this.db.transaction(['items'], 'readwrite'); const store = tx.objectStore('items'); this.items.forEach(item => { store.put(item); }); } updateItem(id, changes) { const item = this.items.get(id); if (!item) return; // Apply changes optimistically const updated = { ...item, ...changes, _pending: true }; this.items.set(id, updated); // Add to queue this.addToQueue(id, changes); // Notify this.notify('item-updated', updated); return updated; } createItem(data) { const tempId = `temp_${Date.now()}`; const newItem = { id: tempId, ...data, _isNew: true, _pending: true }; this.items.set(tempId, newItem); this.addToQueue(tempId, { ...data, _action: 'create' }); this.notify('item-created', newItem); return newItem; } deleteItem(id) { const item = this.items.get(id); if (!item) return; // Mark for deletion item._deleted = true; item._pending = true; this.items.set(id, item); this.addToQueue(id, { _action: 'delete' }); this.notify('item-deleted', item); } addToQueue(id, changes) { const existing = this.queue.get(id) || {}; const merged = { ...existing, ...changes, id, timestamp: Date.now() }; this.queue.set(id, merged); // Save queue to IndexedDB if (this.db) { const tx = this.db.transaction(['queue'], 'readwrite'); tx.objectStore('queue').put(merged); } this.notify('queue-updated', this.queue.size); } async syncQueue() { if (this.queue.size === 0) return; const batch = Array.from(this.queue.values()); console.log(batch); try { const response = await fetch(`${jvbSettings.api}${this.config.endpoint}`, { method: 'POST', headers: this.headers, body: JSON.stringify({ content: this.contentType, operations: batch }) }); const result = await response.json(); if (result.success) { // Clear queue for successful items result.processed.forEach(({ tempId, newId }) => { this.queue.delete(tempId); // Update temp IDs with real IDs if (tempId !== newId) { const item = this.items.get(tempId); if (item) { item.id = newId; delete item._isNew; delete item._pending; this.items.delete(tempId); this.items.set(newId, item); } } else { // Just clear pending flag const item = this.items.get(newId); if (item) { delete item._pending; this.items.set(newId, item); } } }); // Clear queue from IndexedDB if (this.db) { const tx = this.db.transaction(['queue'], 'readwrite'); const store = tx.objectStore('queue'); result.processed.forEach(({ tempId }) => { store.delete(tempId); }); } this.notify('sync-success', result); } } catch (error) { this.notify('sync-error', error); } } setFilter(key, value) { if (value === '' || value === null) { delete this.filters[key]; } else { this.filters[key] = value; } this.notify('filters-changed', this.getFilteredItems()); } getFilteredItems() { let items = Array.from(this.items.values()); // Apply filters let filters = this.filters; delete filters.user; delete filters.content; delete filters.page; Object.entries(filters).forEach(([key, value]) => { items = items.filter(item => { if (key === 'status') { if (value === 'all') { return item.status === 'publish' || item.status === 'draft'; } return item.status === value; } if (key === 'search') { const searchLower = value.toLowerCase(); return item.post_title?.toLowerCase().includes(searchLower) || item.post_content?.toLowerCase().includes(searchLower); } // Handle taxonomy filters if (key.startsWith('tax_')) { const taxonomy = key.replace('tax_', ''); return item.taxonomies?.[taxonomy]?.includes(value); } return true; }); }); // Exclude deleted items items = items.filter(item => !item._deleted); return items; } subscribe(callback) { this.subscribers.add(callback); return () => this.subscribers.delete(callback); } notify(event, data) { this.subscribers.forEach(cb => cb(event, data)); } } window.jvbStore = DataStore; class QueueManager { constructor() { //Core Components this.a11y = window.jvbA11y; this.errors = window.jvbError; this.cache = window.jvbCache; this.debouncer = window.debouncer; //Config this.STORAGE_KEY = 'jvb_queue'; this.API = `${jvbSettings.api}queue`; this.defaultHeaders = { 'Content-Type': 'application/json', 'X-WP-Nonce': jvbSettings.nonce }; this.maxRetries = 3; this.queue = new Map(); this.hasChanges = this.cache.getItem('queueHasChanges')??false; this.isProcessing = false; this.lastPollTime = null; this.pendingUIUpdates = new Map(); this.keyHandler = this.handleEscape.bind(this); this.statuses = [ 'queued', 'localProcessing', 'uploading', 'pending', 'processing', 'completed', 'failed', 'failed_permanent' ]; this.icons = { queued: 'refresh', localProcessing: 'refresh', uploading: 'syncing', pending: 'cloud', processing: 'syncing', completed: 'synced', failed: 'error', failed_permanent: 'error' }; this.statusMessages = { 'queued': 'Waiting to send to server...', 'localProcessing': 'Processing locally...', 'uploading': 'Sending to server...', 'pending': 'Sent to server - waiting to be processed...', 'processing': 'Server is working on it...', 'completed': 'All done!', 'failed': 'There was an error', 'failed_permanent': 'Failed permanently' }; // Skip if not logged in if (!jvbSettings.currentUser) { return; } this.initElements(); this.loadQueue(); this.polling = { interval: null, base: 5000, max: 60000, consecutiveNoChanges: 0, isActive: false, lastActivity: Date.now(), startTime: null } this.setupActivityTracking(); this.initEventListeners(); this.fetchOperations(true); } initElements() { this.panel = document.querySelector('aside#queue'); if (!this.panel) return; this.elements = { queueItems: '.qitems', toggle: '.qtoggle', countdown: '.countdown', refresh: '.refreshNow', popup: '.popup', filters: '.filters', filterButtons: '.filter', retryButton: 'button.retry', dismissButton: 'button.dismiss', cancelButton: 'button.cancel' }; this.queuedItems = this.panel.querySelector(this.elements.queueItems); this.toggle = this.panel.querySelector(this.elements.toggle); this.countdown = this.panel.querySelector(this.elements.countdown); this.refresh = this.panel.querySelector(this.elements.refresh); this.popup = this.panel.querySelector(this.elements.popup); this.filters = this.panel.querySelector(this.elements.filters); this.filterButtons = this.filters.querySelectorAll(this.elements.filterButtons); this.retryButton = this.panel.querySelector(this.elements.retryButton); this.dismissButton = this.panel.querySelector(this.elements.dismissButton); this.statusButtons = {}; this.statuses.forEach(status => { this.statusButtons[status] = this.panel.querySelector(`.filter[data-filter="${status}"]`); }); } initEventListeners() { // Bind handlers to maintain context this.clickHandler = this.handleClick.bind(this); this.handleOnline = () => { this.updateNetworkIndicator(); this.maybeProcessQueue(this.hasChanges); }; this.handleOffline = () => this.updateNetworkIndicator(); this.handleBeforeUnload = (e) => { const hasPending = [...this.queue.values()].some(item => ['queued', 'localProcessing', 'uploading'].includes(item.status) ); if (hasPending) { e.preventDefault(); return 'You have unsaved changes.'; } }; this.trackActivity = () => { this.polling.lastActivity = Date.now(); }; // Add listeners document.addEventListener('click', this.clickHandler); window.addEventListener('online', this.handleOnline); window.addEventListener('offline', this.handleOffline); window.addEventListener('beforeunload', this.handleBeforeUnload); // Activity tracking const events = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart']; events.forEach(event => { document.addEventListener(event, this.trackActivity, { passive: true }); }); } /** * Update network indicator */ updateNetworkIndicator() { // Update UI based on online status if (this.panel) { this.panel.classList.toggle('offline', !navigator.onLine); } } handleClick(e) { if (window.targetCheck(e, '#queue ' + this.elements.toggle)) { this.togglePanel(); this.maybeAddEmptyState(); } // Individual item actions else if (window.targetCheck(e, '#queue .item ' + this.elements.retryButton)) { const operation = e.target.closest('.item'); if (operation?.dataset.id) { this.performItemAction(operation.dataset.id, 'retry'); } } else if (window.targetCheck(e, '#queue .item ' + this.elements.cancelButton)) { const operation = e.target.closest('.item'); if (operation?.dataset.id) { this.performItemAction(operation.dataset.id, 'cancel'); } } else if (window.targetCheck(e, '#queue .item ' + this.elements.dismissButton)) { const operation = e.target.closest('.item'); if (operation?.dataset.id) { this.performItemAction(operation.dataset.id, 'dismiss'); } } // Bulk actions else if (window.targetCheck(e, '#queue ' + this.elements.retryButton)) { this.performBulkAction('retry'); } else if (window.targetCheck(e, '#queue ' + this.elements.dismissButton)) { this.performBulkAction('dismiss'); } else if (window.targetCheck(e, '#queue ' + this.elements.filterButtons)) { this.handleFilterClick(e); } else if (window.targetCheck(e, '#queue ' + this.elements.refresh)) { this.handleRefreshClick(e); } // Close panel when clicking outside if (this.panel.classList.contains('expanded') && !this.panel.contains(e.target) && e.target !== this.toggle) { this.closePanel(); } } closePanel(message = 'Closed Queue Panel') { this.panel.classList.remove('expanded'); this.toggle.title = 'Show Queue'; this.toggle.ariaExpanded = false; this.a11y.announce(message); document.removeEventListener('keydown', this.keyHandler); } openPanel(message = 'Opened Queue Panel') { this.panel.classList.add('expanded'); this.toggle.title = 'Hide Queue'; this.toggle.ariaExpanded = true; this.a11y.announce(message); document.addEventListener('keydown', this.keyHandler); } handleEscape(e) { if (e.key === 'Escape') { this.closePanel('Closed Queue Panel with escape key'); } } maybeAddEmptyState() { let empty = this.queuedItems.querySelector('.emptyQueue'); if (empty) { empty.remove(); } if (this.queuedItems.children.length === 0) { let empty = window.getTemplate('emptyQueue'); this.queuedItems.append(empty); this.a11y.announce('Nothing queued.'); } } /** * Track user activity to adjust polling */ setupActivityTracking() { const events = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart']; events.forEach(event => { document.addEventListener(event, () => { this.polling.lastActivity = Date.now(); }, { passive: true }); }); } /** * * Queue Item Operations * */ /** * Add item to queue - Map operations work normally */ async addToQueue(operation) { console.log('Queuing operation: ', operation); let id = operation.id ?? this.generateId(); if ('append' in operation) { id = id + operation.append; } if (!operation.endpoint || !operation.data) { console.error('Invalid operation - missing endpoint or data'); return false; } const result = await this.updateItem(id, operation); this.updateStatusPanel('pending'); this.addPopup(result.item.popup); this.setChanges(true); return result.item.id; } /** * Generate a unique operation ID * * @returns {string} Unique ID */ generateId() { // Create a timestamp-based prefix const timestamp = new Date().getTime().toString(36); // Add random component const randomPart = Math.random().toString(36).substring(2, 8); // Add counter to ensure uniqueness within same millisecond this.idCounter = (this.idCounter || 0) + 1; const counter = this.idCounter.toString(36); return `u${jvbSettings.currentUser}-${timestamp}-${randomPart}-${counter}`; } async performBulkAction(action, filterFn = null) { const items = filterFn ? [...this.queue.values()].filter(filterFn) : [...this.queue.values()].filter(item => { switch(action) { case 'dismiss': return item.status === 'completed'; case 'retry': return ['failed', 'failed_permanent'].includes(item.status); case 'cancel': return ['queued', 'pending'].includes(item.status); default: return false; } }); if (!items.length) { this.addPopup(`No operations available for ${action}`); return; } try { const result = await this.performQueueAction( items.map(item => item.id), action ); // Handle results based on action if (['dismiss', 'cancel'].includes(action)) { result.processed_ids.forEach(id => this.removeItem(id, action)); } else if (action === 'retry') { result.processed_ids.forEach(id => { this.updateItem(id, { status: 'pending', retries: (this.queue.get(id)?.retries || 0) + 1, error_message: null }); }); this.setChanges(true); } this.addPopup(`${action} completed: ${result.processed_count} operations`); } catch (error) { // Centralized error handling await window.jvbError.log(error, { component: 'QueueManager', action: `bulk_${action}`, itemCount: items.length }); } } handleFilterClick(e) { this.filterButtons.forEach(btn => btn.classList.remove('active')); const button = e.target.closest(this.elements.filterButtons); button.classList.add('active'); const filter = button.dataset.filter || 'all'; this.fetchOperations(true, { status: filter }); } handleRefreshClick(e) { this.refresh.classList.add('refreshing'); this.fetchOperations(true, { force: true }).finally(() => { this.refresh.classList.remove('refreshing'); }); } togglePanel() { this.panel.classList.toggle('expanded'); if (this.panel.classList.contains('expanded')) { this.openPanel(); } else { this.closePanel(); } } async performItemAction(operationId, action, options = {}) { const item = this.queue.get(operationId); if (!item) { this.addPopup(`Operation ${operationId} not found`); return false; } // Validate action is allowed for current status const allowedActions = this.getAllowedActions(item.status); if (!allowedActions.includes(action)) { this.addPopup(`Cannot ${action} operation in current state`); return false; } try { // Update UI immediately for responsiveness const tempStatus = `${action}ing`; this.updateItem(operationId, { status: tempStatus }); this.addPopup(`${action.charAt(0).toUpperCase() + action.slice(1)}ing operation...`); const result = await this.performQueueAction(operationId, action); // Handle different action results switch (action) { case 'cancel': case 'dismiss': this.removeItem(operationId, action); break; case 'retry': this.updateItem(operationId, { status: 'pending', retries: (item.retries || 0) + 1, error_message: null, retryAfter: null }); this.setChanges(true); break; } this.addPopup(`Operation ${action}ed successfully`); return true; } catch (error) { // Revert status on error this.updateItem(operationId, { status: item.status, error_message: `${action} failed: ${error.message}` }); this.addPopup(`${action} failed: ${error.message}`); return false; } } getAllowedActions(status) { const actionMap = { 'queued': ['cancel'], 'localProcessing': ['cancel'], 'pending': ['cancel'], 'processing': [], 'completed': ['dismiss'], 'failed': ['retry', 'dismiss'], 'failed_permanent': ['retry', 'dismiss'] }; return actionMap[status] || []; } /** * Queue Operations */ /** * Fetch operations from the server * @param {boolean} refresh * @param {Object} options Filter options * @returns {Promise} Server response */ async fetchOperations(refresh = false, options = {}) { const params = new URLSearchParams(options); const url = `${this.API}?${params.toString()}`; try { const data = await this.cache.fetchWithCache(url, { method: 'GET', headers: this.defaultHeaders }, { content: 'queue', forceRefresh: refresh, maxAge: 30000 // 30 seconds for queue data } ); if (data?.operations) { this.processOperationsData(data.operations); } return data; } catch (error) { // Cache's fetchWithCache already handles errors via window.jvbError return { operations: [], error: error.message }; } } async performQueueAction(operationIds, action) { const ids = Array.isArray(operationIds) ? operationIds : [operationIds]; try { const response = await fetch(this.API, { method: 'POST', headers: this.defaultHeaders, body: JSON.stringify({ ids, action }) }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.message || `${action} failed: ${response.status}`); } const result = await response.json(); if (!result.success) { throw new Error(result.message || `${action} operation failed`); } return result; } catch (error) { const result = await window.jvbError.log(error, { component: 'QueueManager', operation: 'performQueueAction', action: action, operationIds: ids, itemCount: ids.length }, () => this.performQueueAction(operationIds, action)); // Retry callback if (result.retried) { return result; // Return successful retry result } else { throw error; // Re-throw if not retried } } } processOperationsData(operations) { if (!Array.isArray(operations)) { console.warn('Invalid operations data received:', operations); return; } const validOperations = operations.filter(op => op && typeof op === 'object' && op.id ); if (validOperations.length !== operations.length) { console.warn(`Filtered ${operations.length - validOperations.length} invalid operations`); } // 🔧 FIX: Track which server operations we received const serverIds = new Set(validOperations.map(op => op.id)); // Remove dismissed items (items not returned by server) const itemsToRemove = []; for (const [localId, localItem] of this.queue) { if (!serverIds.has(localId) && localItem.status !== 'queued') { // Item not returned by server and wasn't just queued - likely dismissed itemsToRemove.push(localId); } } // 🔧 FIX: Use centralized removeItem for (const id of itemsToRemove) { console.log(`Server dismissed operation: ${id}`); this.removeItem(id, 'server-dismissed', { skipSave: true }); // Batch save below } // 🔧 FIX: Use centralized updateItem for all server updates const updatePromises = validOperations.map(op => this.updateItem(op.id, op, { skipSave: true }) // Batch save below ); // Wait for all updates to complete, then save once Promise.all(updatePromises).then(() => { this.saveQueue(); }); } /** * * @param {boolean} on */ setChanges(on) { this.hasChanges = on; this.cache.setItem('queueHasChanges', on); this.maybeProcessQueue(on); } /** * Starts a timeout after X ms to process queue if no changes are made * @param on */ maybeProcessQueue(on) { console.log('Checking to process queue...'); if (!on) { this.debouncer.cancel('queue'); return; } this.debouncer.schedule( 'queue', () => { this.processQueue()}, 2500 ); } /** * Method to manually trigger immediate processing (bypass debounce) */ processQueueImmediately() { console.log('Processing queue immediately - bypassing debounce'); this.processQueue(); } async processQueue() { if (!navigator.onLine) { console.log('Offline - postponing queue processing'); return; } const pendingItems = this.getPendingItems(); if (pendingItems.length === 0) { this.setChanges(false); return; } try { console.log(`Processing ${pendingItems.length} queued items`); const batchSize = this.getOptimalBatchSize(); for (let i = 0; i < pendingItems.length; i += batchSize) { const batch = pendingItems.slice(i, i + batchSize); await this.processBatchWithIsolation(batch); if (i + batchSize < pendingItems.length) { await new Promise(resolve => setTimeout(resolve, 500)); } } this.setChanges(false); this.updateStatusPanel('synced'); } catch (error) { await window.jvbError.log(error, { component: 'QueueManager', operation: 'processQueue', pendingCount: pendingItems.length, batchSize: this.getOptimalBatchSize() }); this.setChanges(false); this.addPopup('Processing will retry automatically'); } } getPendingItems() { return [...this.queue.values()].filter(item => item.status === 'queued' ); } async processBatchWithIsolation(batch) { const batchResults = []; // Process each item with error isolation for (const item of batch) { try { const result = await this.processItem(item); batchResults.push({ id: item.id, success: true, result }); } catch (error) { console.error(`Failed to process item ${item.id}:`, error); batchResults.push({ id: item.id, success: false, error: error.message }); } } // Batch update all results const updates = batchResults.map(result => ({ id: result.id, data: result.success ? { status: 'pending', sent_at: Date.now() } : { status: 'failed', error_message: result.error, retries: (this.queue.get(result.id)?.retries || 0) + 1, failed_at: Date.now() } })); updates.forEach(item => this.updateItem(item.id, item.data, {skipSave: true})); await this.saveQueue(); } getOptimalBatchSize() { const systemLoad = this.getSystemLoad(); if (systemLoad < 0.3) return 5; // Low load - larger batches if (systemLoad < 0.6) return 3; // Medium load - medium batches return 1; // High load - single items } getSystemLoad() { // Simple heuristic based on queue size and active operations const queueSize = this.queue.size; const activeCount = [...this.queue.values()] .filter(item => ['localProcessing', 'uploading'].includes(item.status)).length; return Math.min((queueSize + activeCount * 2) / 20, 1); } sendUpdate(item) { if ('onUpdate' in item && typeof item.onUpdate === "function") { console.log('Calling on update for item'); item.onUpdate(item); } } sendComplete(item) { if ('onComplete' in item && typeof item.onComplete === "function") { item.onComplete(item); } } async processItem(item) { console.log('Processing item:', item.id, item.title); this.a11y.announce(`Processing ${item.title}...`); this.updateItem(item.id, {status: 'localProcessing'}); try { let result = await this.makeRequest(item); this.updateItem(item.id, { status: 'pending', result: result, sent_at: Date.now() }); this.a11y.announce(`${item.title} sent to server for processing`); // Only start polling if we're not already polling if (!this.polling.isActive) { console.log('Starting polling for pending server operations'); this.startPolling(); } } catch (error) { const result = await window.jvbError.log(error, { component: 'QueueManager', operation: 'processItem', operationId: item.id }, () => this.makeRequest(item)); // Retry callback if (!result.retried) { this.updateItem(item.id, { status: (item.retries || 0) >= this.maxRetries ? 'failed_permanent' : 'failed', error_message: result.message, retries: (item.retries || 0) + 1 }); } } } /** * Send operation to server * @param item * @returns {Promise} */ async makeRequest(item) { this.updateItem(item.id, {status: 'uploading'}); // Check if data is FormData const isFormData = item.data instanceof FormData; let headers = { 'X-WP-Nonce': jvbSettings.nonce, ...item.headers }; // Only set Content-Type for non-FormData requests if (!isFormData) { headers['Content-Type'] = 'application/json'; } console.log('Sending Data...', item.data); let requestBody; if (isFormData) { // For FormData, append user and id directly to the FormData item.data.append('user', jvbSettings.currentUser); item.data.append('id', item.id); requestBody = item.data; } else if (typeof item.data === "object") { // For regular objects, JSON stringify as before item.data['user'] = jvbSettings.currentUser; item.data['id'] = item.id; requestBody = JSON.stringify(item.data); } else { // For strings or other data types requestBody = item.data; } try { const response = await fetch (`${jvbSettings.api}${item.endpoint}`, { method: item.method, headers, body: requestBody }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.message || `Request failed with status ${response.status}`); } return await response.json(); } catch (error) { throw error; } } /** * Smart polling with backoff */ async startPolling() { if (this.polling.isActive) return; this.polling.isActive = true; this.polling.consecutiveNoChanges = 0; this.polling.startTime = Date.now(); const poll = async () => { try { if (!this.shouldContinuePolling()) { this.stopPolling(); return; } const result = await this.cache.fetchWithCache( `${this.API}?${new URLSearchParams({ polling: 'true' })}`, { method: 'GET', headers: this.defaultHeaders }, { maxAge: 30000, content: 'queue_polling', timeout: 15000 } ); if (result.operations && Array.isArray(result.operations)) { const hasChanges = this.processPollingData(result.operations); this.polling.consecutiveNoChanges = hasChanges ? 0 : this.polling.consecutiveNoChanges + 1; } else { this.polling.consecutiveNoChanges++; } const delay = this.calculatePollingDelay(); if (this.polling.isActive) { this.polling.interval = setTimeout(poll, delay); } } catch (error) { await window.jvbError.log(error, { component: 'QueueManager', operation: 'polling', consecutiveFailures: this.polling.consecutiveNoChanges, isActive: this.polling.isActive }); this.polling.consecutiveNoChanges++; if (this.polling.isActive && this.shouldContinuePolling()) { const errorDelay = Math.min(this.polling.max, 30000); this.polling.interval = setTimeout(poll, errorDelay); } else { this.stopPolling(); } } }; poll(); } calculatePollingDelay() { const baseDelay = this.polling.base; const maxDelay = this.polling.max; // Exponential backoff based on consecutive no-changes const backoffMultiplier = Math.min( Math.pow(1.5, this.polling.consecutiveNoChanges), 8 // Cap the multiplier ); // Reduce frequency if user is inactive const inactivityMultiplier = this.isUserActive() ? 1 : 2; return Math.min( baseDelay * backoffMultiplier * inactivityMultiplier, maxDelay ); } isUserActive() { return this.polling.lastActivity && (Date.now() - this.polling.lastActivity) < 60000; // 1 minute } processPollingData(operations) { if (operations.length === 0) return false; let hasChanges = false; // Update operations from server (batch at end) for (const operation of operations) { const localItem = this.queue.get(operation.id); if (!localItem) { // New operation from server this.updateItem(operation.id, operation, { skipSave: true }); hasChanges = true; } else if (localItem.status !== operation.status || localItem.progress_percentage !== operation.progress_percentage) { // Status or progress changed this.updateItem(operation.id, operation, { skipSave: true }); hasChanges = true; } } // Remove operations completed/dismissed on server const serverIds = new Set(operations.map(op => op.id)); for (const [localId, localItem] of this.queue) { if (!serverIds.has(localId) && ['pending', 'processing'].includes(localItem.status)) { this.removeItem(localId, 'server-completed', { skipSave: true }); hasChanges = true; } } // Single save if any changes if (hasChanges) { this.saveQueue(); console.debug(`Polling update completed`); } return hasChanges; } /** * Check if polling should continue * @returns {boolean} Whether to continue polling */ shouldContinuePolling() { // Stop if offline if (!navigator.onLine) { return false; } // Check for local items that need server monitoring const pendingServerItems = [...this.queue.values()].filter(item => ['pending', 'processing', 'uploading'].includes(item.status) ); // If we have items waiting on the server, keep polling if (pendingServerItems.length > 0) { return true; } // Also stop if user has been inactive for too long const isUserInactive = this.polling.lastActivity && (Date.now() - this.polling.lastActivity) > (5 * 60 * 1000); // 5 minutes if (isUserInactive) { console.log('User inactive for 5+ minutes - stopping polling'); return false; } // Stop polling if no server-pending items return false; } stopPolling() { this.polling.isActive = false; if (this.polling.interval) { clearTimeout(this.polling.interval); this.polling.interval = null; } console.log('Polling stopped'); } /** * Fetch specific operation by ID */ async fetchOperation(operationId) { return await this.fetchOperations(true, { ids: operationId, limit: 1 }); } /** * Load queue from cache - now just 12 lines! */ async loadQueue() { try { const [queueMap, metadata] = await Promise.all([ this.cache.getItem('user_queue', 'queue'), this.cache.getItem('queue_metadata', 'queue') ]); if (queueMap instanceof Map && queueMap.size > 0) { console.debug(`Loading ${queueMap.size} queue items from cache`); this.queue.clear(); const updates = Array.from(queueMap.entries()).map(([id, item]) => ({ id, data: item })); updates.forEach(item => this.updateItem(item.id, item.data, {skipSave: true})); await this.saveQueue(); } else { this.queue = new Map(); } this.updateUIFromLoadedQueue(); } catch (error) { await window.jvbError.log(error, { component: 'QueueManager', operation: 'loadQueue', action: 'cache_recovery' }); this.queue = new Map(); this.addPopup('Queue data recovered'); } } /** * Save queue to cache */ async saveQueue() { try { const filteredQueue = this.filterQueueForSaving(); const stats = this.getQueueStats(); await Promise.all([ this.cache.setItem('user_queue', filteredQueue, 'queue'), this.cache.setItem('queue_stats', stats, 'queue'), this.cache.setItem('queue_metadata', { lastSaved: Date.now(), version: '1.0', totalOperations: filteredQueue.size, hasChanges: this.hasChanges }, 'queue') ]); } catch (error) { const result = await window.jvbError.log(error, { component: 'QueueManager', operation: 'saveQueue', queueSize: this.queue.size }, () => this.saveQueue()); // Retry callback if (!result.retried) { // Fallback save try { await this.cache.setItem('user_queue', new Map(), 'queue'); } catch (fallbackError) { console.warn('Even fallback save failed'); } } } } /** * Helper: Filter queue items that should be saved */ filterQueueForSaving() { const filteredQueue = new Map(); for (const [id, item] of this.queue) { if (this.shouldSaveItem(item)) { filteredQueue.set(id, item); } } return filteredQueue; } /** * Helper: Determine if an item should be persisted */ shouldSaveItem(item) { // Always save active items if (['queued', 'localProcessing', 'uploading'].includes(item.status)) { return true; } // Save recent completed items (1 hour) if (item.status === 'completed' && item.completed_at) { const oneHourAgo = Date.now() - (60 * 60 * 1000); return new Date(item.completed_at).getTime() > oneHourAgo; } // Save failed items that can still be retried return (item.status === 'failed' || item.status === 'failed_permanent') && item.retries < this.maxRetries; } /** * Get queue statistics * * @returns {Object} Queue statistics by status */ getQueueStats() { const stats = {}; // Initialize all statuses to 0 this.statuses.forEach(status => { stats[status] = 0; }); // Count items by status for (const item of this.queue.values()) { if (stats[item.status] !== undefined) { stats[item.status]++; } } return stats; } /** * * UI Updates * */ /** * Enhanced action buttons with cancel support */ updateActionButtons(item, container) { window.removeChildren(container); switch (item.status) { case 'queued': case 'localProcessing': case 'pending': // Show cancel button for in-progress items const cancelBtn = window.getTemplate('button'); cancelBtn.classList.add('cancel'); cancelBtn.textContent = 'Cancel'; container.appendChild(cancelBtn); break; case 'failed': case 'failed_permanent': // Show retry and dismiss buttons const retryBtn = window.getTemplate('button'); const dismissBtn = window.getTemplate('button'); retryBtn.classList.add('retry'); retryBtn.textContent = 'Retry'; retryBtn.disabled = item.retries >= this.maxRetries; dismissBtn.classList.add('dismiss'); dismissBtn.textContent = 'Dismiss'; container.appendChild(retryBtn); container.appendChild(dismissBtn); break; case 'completed': // Show dismiss button only const dismissCompletedBtn = window.getTemplate('button'); dismissCompletedBtn.classList.add('dismiss'); dismissCompletedBtn.textContent = 'Dismiss'; container.appendChild(dismissCompletedBtn); break; } } /** * Update Queue Item * @param id * @param newData * @param {object} options * @param {bool} options.skipSave If you don't want to update the queue * @param {bool} options.skipUI If you don't want to update the UI * @returns {Promise<{item: any, wasNew: boolean, oldStatus: null}>} */ async updateItem(id, newData, options = {}) { const wasNew = !this.queue.has(id); let oldStatus = null; let oldItem = null; if (wasNew) { //Check queue for any item in the queue with the same endpoint and that canMerge === true let pendingItems = this.getPendingItems(); if (pendingItems.length > 0) { for (const item of pendingItems) { if (item.canMerge && item.endpoint === newData.endpoint){ let mergeData = window.deepMerge(item.data, newData.data); newData = item; id = newData.id; newData.data = mergeData; break; } } } // Create new item with defaults const item = { id: id, endpoint: false, method: 'POST', headers: {}, canMerge: true, title: 'Queue operation', popup: 'Processing...', user: jvbSettings.currentUser, started_at: Date.now(), status: 'queued', retries: 0, dependencies: [], type: false, data: false, ...newData }; this.queue.set(id, item); console.log(`Created new queue item: ${id}`); } else { // Update existing item oldItem = this.queue.get(id); oldStatus = oldItem.status; // Deep merge for data, shallow merge for other properties const updated = { ...oldItem, ...newData }; // Special handling for data merging if both exist if (oldItem.data && newData.data && typeof oldItem.data === 'object' && typeof newData.data === 'object') { updated.data = window.deepMerge(oldItem.data, newData.data); } this.queue.set(id, updated); } const item = this.queue.get(id); // Handle status change side effects if (oldStatus !== item.status) { this.handleStatusChange(item, oldStatus, wasNew); } // Always save and update UI unless explicitly skipped if (!options.skipSave) { await this.saveQueue(); } if (!options.skipUI) { this.scheduleUIUpdate(id, item); } return { item, wasNew, oldStatus }; } handleStatusChange(item, oldStatus) { // Start polling if server-pending if (['pending', 'processing'].includes(item.status) && !this.polling.isActive) { this.startPolling(); } if(oldStatus && item.status !== oldStatus) { if (['completed', 'failed_permanent'].includes(item.status)){ this.sendComplete(item); } else { this.sendUpdate(item); } } } /** * THE ONLY removeItem method - replaces all duplicates * @param id The Operation ID * @param {string} reason An optional message * @param {object} options Optional options Including: * @param {bool} options.skipSave Whether to skip saving the queue * @param {bool} options.skipUI Whether to skip UI updates */ async removeItem(id, reason = 'dismissed', options = {}) { const item = this.queue.get(id); if (!item) { console.warn(`Attempted to remove non-existent item: ${id}`); return false; } // Log removal for debugging console.log(`Removing queue item ${id} (${item.title}) - reason: ${reason}`); // Handle removal side effects this.handleItemRemoval(item, reason); // Remove from queue this.queue.delete(id); // Always save and update UI unless explicitly skipped if (!options.skipSave) { await this.saveQueue(); } if (!options.skipUI) { this.scheduleUIUpdate(id, null); } return true; } handleItemRemoval(item, reason) { // Clear any related timeouts/intervals if (item.retryTimeout) { clearTimeout(item.retryTimeout); } // Log analytics/metrics if (reason === 'completed') { console.log(`Operation completed: ${item.title} (${Date.now() - item.started_at}ms)`); } // Clean up any temporary data if (item.data instanceof FormData) { // FormData cleanup if needed console.log('Cleaned up FormData for removed item'); } } /** * Schedule UI update (batched) */ scheduleUIUpdate(itemId, item = null) { this.pendingUIUpdates.set(itemId, item); if (!this.uiUpdateScheduled) { this.uiUpdateScheduled = true; requestAnimationFrame(() => { this.processPendingUIUpdates(); this.uiUpdateScheduled = false; }); } } /** * Process all pending UI updates in a single batch */ processPendingUIUpdates() { if (this.pendingUIUpdates.size === 0) return; const updates = new Map(this.pendingUIUpdates); this.pendingUIUpdates.clear(); // Process all updates for (const [itemId, item] of updates) { if (item === null) { this.removeItemFromUI(itemId); } else { this.updateItemInUI(item); } } // Update global UI state once after all items processed this.updateGlobalUIState(); } /** * Update or create a single item in the UI */ updateItemInUI(item) { let listItem = this.queuedItems.querySelector(`.item[data-id="${item.id}"]`); if (!listItem) { listItem = this.createItemElement(item); this.queuedItems.prepend(listItem); } else { this.updateItemElement(listItem, item); } } /** * Remove item from UI with animation */ removeItemFromUI(itemId) { const element = this.queuedItems.querySelector(`.item[data-id="${itemId}"]`); if (!element) return; element.style.transition = 'opacity 0.3s ease-out, transform 0.3s ease-out'; element.style.opacity = '0'; element.style.transform = 'translateX(-100%)'; setTimeout(() => { element.remove(); this.updateGlobalUIState(); }, 300); } /** * Create a new item element */ createItemElement(item) { const listItem = window.getTemplate('queueItem'); listItem.dataset.id = item.id; this.updateItemElement(listItem, item); return listItem; } /** * Update an existing item element */ updateItemElement(element, item) { // Remove old status classes this.statuses.forEach(status => element.classList.remove(status)); element.classList.add(item.status); // Update content let timeDisplay = ''; if (item.updated_at) { // Server now sends ISO format timestamps - much more reliable! timeDisplay = window.formatTimeAgo(new Date(item.updated_at)); } else if (item.created_at) { timeDisplay = window.formatTimeAgo(new Date(item.created_at)); } const progressPercent = this.calculateProgress(item); // Update text content safely const typeEl = element.querySelector('.type'); const statusEl = element.querySelector('.status'); const detailsEl = element.querySelector('.info .details'); const timeEl = element.querySelector('.info .time'); const progressFill = element.querySelector('.progress .fill'); if (typeEl) typeEl.textContent = item.title; if (statusEl) { statusEl.querySelector('.icon')?.remove(); let status = this.getStatusLabel(item.status); statusEl.title = status; statusEl.prepend(window.getIcon(this.icons[item.status])); statusEl.querySelector('span').textContent = status; } if (detailsEl) detailsEl.textContent = this.getItemMessage(item); if (timeEl) timeEl.textContent = timeDisplay; if (progressFill) progressFill.style.width = `${progressPercent}%`; if (item.status === 'processing' && item.estimated_completion) { const estimatedEl = element.querySelector('.estimated-completion'); if (estimatedEl) { const remaining = window.formatTimeSoon(new Date(item.estimated_completion)); estimatedEl.textContent = `Est. ${remaining}`; } } // Update action buttons const actionsContainer = element.querySelector('.actions'); if (actionsContainer) { this.updateActionButtons(item, actionsContainer); } } /** * Update global UI state (status, counts, etc.) */ updateGlobalUIState() { this.updateStatusPanel(); this.updateFilterCounts(); this.maybeAddEmptyState(); } /** * Helper: Update UI after loading queue */ updateUIFromLoadedQueue() { // Schedule updates for all loaded items for (const item of this.queue.values()) { this.scheduleUIUpdate(item.id, item); } // Check if there are pending changes this.setChanges([...this.queue.values()].some(item => ['queued', 'localProcessing', 'uploading'].includes(item.status) )); if (this.hasChanges) { this.updateStatusPanel('pending'); } } /** * Update filter counts */ updateFilterCounts() { const stats = this.getQueueStats(); if (this.filterButtons) { this.filterButtons.forEach(button => { const filter = button.dataset.filter; if (!filter || filter === 'all') return; const count = stats[filter] || 0; button.dataset.count = count; // Update badge let badge = button.querySelector('.count-badge'); if (count > 0) { if (!badge) { badge = document.createElement('span'); badge.className = 'count-badge'; button.appendChild(badge); } badge.textContent = count; } else if (badge) { badge.remove(); } }); } } /** * Update status panel * * @param {string} status Special status to display */ updateStatusPanel(status) { if (!this.panel) return; // Get pending count for badge const pendingCount = [...this.queue.values()].filter(item => ['queued', 'localProcessing', 'uploading'].includes(item.status) ).length; // Update indicator status this.panel.classList.remove('offline', 'pending', 'synced', 'image_processing'); if (!navigator.onLine) { this.panel.classList.add('offline'); this.addPopup('Looks like we\'re offline...'); } else if (status === 'pending' || pendingCount > 0) { this.panel.classList.add('pending'); } else if (status === 'image_processing') { this.panel.classList.add('image_processing'); this.addPopup('Processing images...'); } else if (status === 'synced' || pendingCount === 0) { this.panel.classList.add('synced'); } // Update queue stats this.updateQueueStats(); } /** * Update queue statistics */ updateQueueStats() { // Calculate stats const stats = this.getQueueStats(); // Update filter buttons with counts Object.entries(stats).forEach(([status, count]) => { const button = this.statusButtons[status]; if (button) { button.dataset.count = count; // Update count badge if (count > 0) { let badge = button.querySelector('.count'); if (!badge) { badge = document.createElement('span'); badge.className = 'count'; button.appendChild(badge); } badge.textContent = count; } else { const badge = button.querySelector('.count'); if (badge) { badge.remove(); } } } }); // Update the status indicator badge const badgeCount = stats.queued + stats.localProcessing + stats.uploading; const statusIndicator = this.panel.querySelector('.queue-status-indicator'); const statusCount = this.panel.querySelector('.queue-status-count'); if (statusIndicator) { statusIndicator.classList.toggle('active', badgeCount > 0); } if (statusCount) { statusCount.textContent = badgeCount > 0 ? badgeCount : ''; } // Update action buttons if (this.retryButton) { this.retryButton.disabled = stats.failed === 0; } this.dismissButton.disabled = stats.completed === 0; } /** * Show popup message * * @param {string} message Message to show * @param {number} delay Time in ms to show popup */ addPopup(message, delay = 2000) { if (!this.popup) return; this.a11y.announce(message); this.popup.innerHTML = message; this.popup.classList.add('showing'); setTimeout(() => { this.popup.classList.remove('showing'); setTimeout(() => { // Clear popup content after fadeout while (this.popup.firstChild) { this.popup.removeChild(this.popup.firstChild); } }, 50); }, delay); } calculateProgress(item) { // Use server-provided progress percentage if available if (item.progress_percentage !== undefined) { return Math.max(0, Math.min(100, item.progress_percentage)); } // Fallback to status-based progress switch (item.status) { case 'queued': case 'failed': case 'failed_permanent': return 0; case 'localProcessing': return 5; case 'uploading': return 40; case 'pending': return 65; case 'processing': return 85; case 'completed': return 100; default: return 0; } } /** * Get status label for display * * @param {string} status Operation status * @returns {string} Human-readable status */ getStatusLabel(status) { switch (status) { case 'queued': return 'Received'; case 'localProcessing': return 'Processing Data'; case 'uploading': return 'Sending to Server'; case 'pending': return 'Sent to Server'; case 'processing': return 'Server processing'; case 'completed': return 'Completed'; case 'failed': return 'Failed'; case 'failed_permanent': return 'Failed'; default: return status; } } /** * Get detailed message for an item * * @param {Object} item Queue item * @returns {string} Detailed message */ getItemMessage(item) { // Server provides better error messages and status info let message = this.statusMessages[item.status] || ''; // Show progress for processing items if (item.status === 'processing' && item.progress_count && item.count) { message = `Processing ${item.progress_count} of ${item.count}`; if (item.progress_percentage) { message += ` (${item.progress_percentage}%)`; } } // Add error if failed if ((item.status === 'failed' || item.status === 'failed_permanent') && item.error_message) { message = `Error: ${item.error_message}`; } // Show estimated completion for processing items if (item.status === 'processing' && item.estimated_completion) { const remaining = window.formatTimeSoon(new Date(item.estimated_completion)); message += ` • Est. ${remaining}`; } return message; } cleanup() { // Clear all timeouts and intervals this.stopPolling(); // Remove all event listeners document.removeEventListener('click', this.clickHandler); window.removeEventListener('online', this.handleOnline); window.removeEventListener('offline', this.handleOffline); window.removeEventListener('beforeunload', this.handleBeforeUnload); document.removeEventListener('keydown', this.handleEscape); // Clear activity tracking const events = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart']; events.forEach(event => { document.removeEventListener(event, this.trackActivity); }); // Clear references this.queue.clear(); this.pendingUIUpdates.clear(); console.log('QueueManager destroyed'); } /** * Create reply button template */ createReplyButtonTemplate() { const button = document.createElement('button'); button.type = 'button'; button.className = 'reply'; button.dataset.action = 'make-response'; button.innerHTML = window.getIcon('reply') + 'Reply'; return button; } /** * Create comments button template */ createCommentsButtonTemplate() { const button = document.createElement('a'); button.className = 'button'; button.innerHTML = window.getIcon('response') + '{ }'; return button; } /** * Create vote buttons template */ createVoteButtonsTemplate() { const vote = document.createElement('div'); vote.className = 'vote'; vote.innerHTML = ` `; return vote; } } // // document.addEventListener('DOMContentLoaded', () => { // window.jvbQueue = new QueueManager(); // }); // Theme switching functionality document.addEventListener('DOMContentLoaded', function() { const themeSwitch = document.getElementById('theme-switch'); if (!themeSwitch) return; // Initialize theme from localStorage or system preference const prefersDark = window.matchMedia('(prefers-color-scheme: dark)'); const storedTheme = localStorage.getItem('theme'); if (storedTheme) { document.documentElement.classList.toggle('dark', storedTheme === 'dark'); themeSwitch.checked = storedTheme === 'dark'; } else { document.documentElement.classList.toggle('dark', prefersDark.matches); themeSwitch.checked = prefersDark.matches; } // Handle theme switch changes themeSwitch.addEventListener('change', async function () { const isDark = this.checked; document.documentElement.classList.toggle('dark', isDark); localStorage.setItem('theme', isDark ? 'dark' : 'light'); // If user is logged in, save preference if (jvbSettings.currentUser !== null) { try { await fetch(`${jvbSettings.api}settings`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': jvbSettings.nonce, 'action_nonce': jvbSettings.dash, }, body: JSON.stringify({ dark_mode: isDark }) }); } catch (error) { console.error('Failed to save theme preference:', error); } } // Update label const label = document.getElementById('theme-switch'); if (label) { label.title = isDark ? 'Toggle Light Mode' : 'Toggle Dark Mode'; } }); // Handle system theme changes prefersDark.addEventListener('change', (e) => { if (!localStorage.getItem('theme')) { const isDark = e.matches; document.documentElement.classList.toggle('dark', isDark); themeSwitch.checked = isDark; } }); }); // window.addEventListener('beforeunload', () => window.jvbQueue?.cleanup());