/** * QueueManager * Uses DataStore for persistent storage */ class QueueManager { constructor(config = {}) { this.canUpdateUI = true; console.log('jvbSettings', jvbSettings); this.config = { apiBase: jvbSettings.api, maxRetries: 3, pollInterval: 5000, activityDelay: 2000, //2 seconds autosync: true, endpoint: 'queue', ...config }; this.user = jvbSettings.currentUser; this.headers = { 'X-WP-Nonce': jvbSettings.nonce, ...config.headers }; this.a11y = window.jvbA11y; this.errors = window.jvbError; // Initialize DataStore for queue persistence this.store = new window.jvbStore({ name: 'queue', storeName: 'operations', keyPath: 'id', endpoint: this.config.endpoint, TTL: Infinity, //Queue data doesn't expire, indexes: [ {name: 'status', keyPath: 'status'}, {name: 'type', keyPath: 'type'}, ], showLoading: false, }); this.queue = new Map(); this.classes = [ 'offline', 'synced', 'pending' ]; // Queue state this.isProcessing = false; this.isPolling = false; this.subscribers = new Set(); // Status definitions this.statuses = [ 'queued', 'localProcessing', 'uploading', 'pending', 'processing', 'completed', 'failed', 'failed_permanent' ]; // Initialize this.initUI(); this.initListeners(); console.log(this.ui); if (this.ui.panel) { this.popup = new window.jvbPopup({ popup: this.ui.panel, toggle: this.ui.toggle, name: 'Queue Panel', }); } this.initQueue(); if (this.user) { this.ui.toggle.hidden = false; this.ui.panel.hidden = false; } } async initQueue() { const incomplete = this.getOperationsByStatus(['completed', 'failed_permanent'], false) if (incomplete.length > 0) { this.startPolling(); } else { this.updateStatusPanel('synced'); } this.store.subscribe((event, data) => { switch (event) { case 'data-fetched': case 'data-cached': this.updateOperationsFromServer(data.data.items); break; case 'items-updated': this.updateOperationsFromServer(data.items); break; case 'item-stored': this.updateOperationsFromServer([data]) break; } }); this.store.fetch(); this.notify('queue-initialized', {operations: incomplete}); } /** * * @param {object} operation * @param {string} operation.endpoint The endpoint, excluding the apiBase * @param {object} operation.data The data to save * @param {boolean} operation.canMerge Whether data can merge * @param {string} operation.title The title of the operation for the Queue Panel * @param {string} operation.popup The string to show in the popup * @param {object} operation.headers Optional additional headers. Defaults to the API nonce * * @returns {string|null} Returns the operation id, for reference */ addToQueue(operation) { const item = { id: `u${this.user}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, endpoint: null, method: 'POST', headers: {}, data: {}, canMerge: true, popup: 'Saving changes...', title: 'Operation', status: 'queued', timestamp: Date.now(), retries: 0, user: this.user, ... operation }; item.headers = { ...this.headers, ...item.headers }; if (!item.endpoint || !item.data) { console.error('Invalid operation queued: missing endpoint or data'); return null; } const existingOps = Array.from(this.queue.values()).filter(op=> op.status === 'queued' && op.endpoint === item.endpoint && op.canMerge ); if (existingOps.length > 0) { const existing = existingOps[0]; existing.data = window.deepMerge(existing.data, item.data); existing.timestamp = Date.now(); this.updateOperationStatus(existing.id, existing.status); this.updateUI(); this.startActivityTracking(); return existing.id; } console.log('Added to Queue: ', item); //Add new operation to DataStore this.setQueue(item); this.updateOperationStatus(item.id, item.status); this.updateUI(); this.startActivityTracking(); return item.id; } setQueue(item) { this.queue.set(item.id, item); this.store.save(item.id, item); } updateOperationStatus(itemID, status) { let item = this.queue.get(itemID); if (!item){ return; } item.status = status; this.notify('operation-status', item); this.updateOperationUI(item); } getQueue(itemID) { if (this.queue.has(itemID)) { return this.queue.get(itemID); } return this.store.getItem(itemID); } clearQueue(itemID) { if (this.queue.has(itemID)) { this.queue.delete(itemID); } this.store.clearItem(itemID); } startActivityTracking() { if (!this.activityListeners) { const activityEvents = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart']; this.activityListeners = activityEvents.map(event => { const handler = () => this.resetActivityTimer(); document.addEventListener(event, handler, {passive: true}); return {event, handler}; }); } this.resetActivityTimer(); } resetActivityTimer() { this.lastActivity = Date.now(); if (this.activityTimer) { clearTimeout(this.activityTimer); } this.activityTimer = setTimeout(() => { this.processQueue(); }, this.config.activityDelay); } stopActivityTracking() { if (this.activityTimer) { clearTimeout(this.activityTimer); this.activityTimer = null; } if (this.activityListeners) { this.activityListeners.forEach(({event, handler}) => { document.removeEventListener(event, handler); }); this.activityListeners = null; } } setProcessing(on) { this.isProcessing = on; this.ui.toggle.classList.toggle('saving', on); } /** * Send any queued operations to the server * @returns {Promise} */ async processQueue() { if (this.isProcessing) return; const queue = this.getOperationsByStatus('queued'); if (queue.length === 0) { this.stopActivityTracking(); return; } this.setProcessing(true); for (const operation of queue) { await this.processOperation(operation); } this.setProcessing(false); this.stopActivityTracking(); const pending = this.getOperationsByStatus(['queued', 'completed', 'failed_permanent'], false); if (pending.length > 0) { this.startPolling(); } } async processOperation(operation) { try { //update to uploading this.updateOperationStatus(operation.id, 'uploading'); //build request const url = `${this.config.apiBase}${operation.endpoint}`; let requestBody; if (operation.data instanceof FormData) { operation.data.append('id', operation.id); operation.data.append('user', this.user); requestBody = operation.data; // console.log('Sending formData: '); // for (const pair of requestBody.entries()) { // console.log(pair[0], pair[1]); // } } else { requestBody = JSON.stringify({ ...operation.data, id: operation.id, user: this.user }); // console.log('Sending data: ', { // ...operation.data, // id: operation.id, // user: this.user // }); operation.headers['Content-Type'] = 'application/json'; } const response = await fetch(url, { method: operation.method, headers: operation.headers, body: requestBody }); const result = await response.json(); if (response.ok && result.success !== false) { // Handle server-side merge if (result.id && operation.id !== result.id) { // Check if the returned ID exists locally const existingOp = this.getQueue(result.id); if (existingOp) { // Merge data from both operations existingOp.data = window.deepMerge(existingOp.data, operation.data); existingOp.status = 'pending'; existingOp.serverData = result; this.updateOperationStatus(existingOp.id, existingOp.status); // Update the existing operation this.setQueue(existingOp); this.removeOperationFromUI(operation.id); // Switch reference to the merged operation operation = existingOp; } else { // Server merged with an operation we don't have locally // Update the ID and continue this.clearQueue(operation.id); operation.id = result.id; operation.status = 'pending'; operation.serverData = result; this.updateOperationStatus(operation.id, operation.status); this.setQueue(operation); } } else { // Normal processing - no merge operation.status = 'pending'; operation.serverData = result; this.updateOperationStatus(operation.id, 'pending'); this.setQueue(operation); } this.a11y.announce(`${operation.title} sent to server for processing.`); } else { throw new Error(result.message || `HTTP ${response.status}`); } } catch (error) { console.error('Operation failed:', error); operation.retries++; operation.lastError = error.message; if (operation.retries >= this.config.maxRetries) { operation.status = 'failed_permanent'; } else { operation.status = 'failed'; operation.nextRetry = Date.now() + (Math.pow(2, operation.retries) * 1000); } this.updateOperationStatus(operation.id, operation.status); this.setQueue(operation); } } startPolling() { if (this.isPolling) return; this.isPolling = true; this.pollServer(); this.pollTimer = setInterval(() => { this.pollServer(); }, this.config.pollInterval); this.updateCountdown(); } pollServer(force = false) { const operations = this.getOperationsByStatus(['pending', 'processing', 'uploading']); if (operations.length === 0 && !force) { this.stopPolling(); return; } this.updateStatusPanel('pending'); try { // const operationIds = operations.map(op => op.id); // this.store.setFilter('operation_ids', operationIds.join(',')); this.store.fetch(); } catch (error) { console.error('Polling error:', error); } finally { this.updateStatusPanel(); } } async updateOperationsFromServer(serverOperations) { let hasChanges = false; const processedIds = new Set(); for (const serverOp of serverOperations) { let operation = (this.queue.has(serverOp.id)) ? this.queue.get(serverOp.id) : {}; processedIds.add(serverOp.id); if (serverOp.status !== operation.status) { operation = { ... operation, ... serverOp }; // Update in DataStore this.queue.set(operation.id, operation); // Update UI for this operation this.updateOperationStatus(operation.id, operation.status); } } // Clean up operations that were completed/dismissed on server const localOps = this.getOperationsByStatus(['pending', 'processing', 'uploading']); for (const localOp of localOps) { if (!processedIds.has(localOp.id)) { localOp.status = 'completed'; localOp.completedAt = Date.now(); this.setQueue(localOp); hasChanges = true; this.updateOperationStatus(localOp.id, localOp.status); } } // Check if all operations are completed const pendingOps = this.getOperationsByStatus(['pending', 'processing', 'uploading']); if (pendingOps.length === 0) { this.stopPolling(); } this.updateUI(); } stopPolling() { if (!this.isPolling) return; this.isPolling = false; if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; } if (this.countdownTimer) { clearInterval(this.countdownTimer); this.countdownTimer = null; } } /*********************************************************** USER ACTIONS ***********************************************************/ /** * * @param {array} ids * @param {string }action * @returns {Promise} */ async updateServerOperations(ids, action) { //ensure ids are in an array ids = Array.isArray(ids) ? ids : ((ids.includes(',')) ? ids.split(',') : [ids]); ids = ids.filter((id) => { let item = this.getQueue(id); return this.getAllowedActions(item.status).includes(action); }); if (ids.length === 0) { return; } if (['cancel', 'dismiss'].includes(action)) { ids.forEach(id => { this.removeOperationFromUI(id); }); } try { const url = `${this.config.apiBase}${this.config.endpoint}`; const response = await fetch( url, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.headers }, 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`); } if (['cancel', 'dismiss'].includes(action)) { ids.forEach(id => { let item = this.getQueue(id); this.notify(`${action}-operation`, item); this.clearQueue(id); }); } else { ids.forEach(id => { let item = this.getQueue(id); this.notify(`${action}-operation`, item); item.status = 'queued'; item.retries = 0; this.setQueue(item); this.updateOperationStatus(item.id, item.status); }); this.startActivityTracking(); } this.updateUI(); return result; } catch (error) { const result = await window.jvbError.log(error, { component: 'QueueManager', operation: 'performQueueAction', action: action, operationIds: ids, itemCount: ids.length }, () => this.updateServerOperations(ids, action)); // Retry callback if (result.retried) { return result; // Return successful retry result } else { throw error; // Re-throw if not retried } } } getAllowedActions(status) { const actionMap = { 'queued': ['cancel'], 'localProcessing': ['cancel'], 'pending': ['cancel'], 'processing': [], 'completed': ['dismiss'], 'failed': ['retry', 'dismiss'], 'failed_permanent': ['dismiss'] }; return actionMap[status] || []; } /********************************************* LISTENERS *********************************************/ initListeners() { this.clickHandler = this.handleClick.bind(this); this.changeHandler = this.handleChange.bind(this); document.addEventListener('click', this.clickHandler); this.ui.panel?.addEventListener('change', this.changeHandler); this.handleOnline = () => { this.updateStatusPanel(); if (this.hasQueuedOperations()) { this.processQueue(); } }; this.handleOffline = () => this.updateStatusPanel('offline'); this.handleBeforeUnload = (e) => { const hasPending = this.getOperationsByStatus(['queued', 'uploading']); if (hasPending.length > 0) { e.preventDefault(); return 'You have unsaved changes in the queue.'; } }; window.addEventListener('online', this.handleOnline); window.addEventListener('offline', this.handleOffline); window.addEventListener('beforeunload', this.handleBeforeUnload); } handleClick(e) { if (e.target.closest(this.selectors.refreshButton)) { this.pollServer(true); } else if (e.target.closest(this.selectors.clearButton)) { const completedOps = this.getOperationsByStatus('completed'); if (completedOps.length > 0) { const ids = completedOps.map(op => op.id); this.updateServerOperations(ids, 'dismiss'); } } else if (e.target.closest(this.selectors.retryButton)) { const failedOps = this.getOperationsByStatus('failed'); if (failedOps.length > 0) { const ids = failedOps.map(op => op.id); this.updateServerOperations(ids, 'retry'); } } else if (e.target.closest('[data-action]')) { const button = e.target.closest('[data-action]'); const operationId = button.closest('[data-id]')?.dataset.id; if (operationId) { this.updateServerOperations(operationId, button.dataset.action); } } else if (e.target.closest('.filters [data-filter]')) { const filter = e.target.closest('[data-filter]').dataset.filter; this.setFilter(filter); } } handleChange(e) { } /********************************************* UI *********************************************/ initUI() { this.icons = { queued: 'refresh', localProcessing: 'refresh', uploading: 'syncing', pending: 'cloud', processing: 'syncing', completed: 'synced', failed: 'error', failed_permanent: 'error' }; this.selectors = { panel: 'aside#queue', toggle: 'button.qtoggle', refreshButton: 'button.refreshNow', countdown: '.countdown', indicator: '.qtoggle .indicator', count: '.qtoggle .count', popup: '.popup', itemsContainer: '.qitems', clearButton: '.dismiss-all', retryButton: '.retry-all', filters: { all: '.filters [data-filter="all"]', received: '.filters [data-filter="queued"]', localProcessing: '.filters [data-filter="localProcessing"]', uploading: '.filters [data-filter="uploading"]', pending: '.filters [data-filter="pending"]', processing: '.filters [data-filter="processing"]', completed: '.filters [data-filter="completed"]', failed: '.filters [data-filter="failed"]', } }; this.ui = { panel: document.querySelector(this.selectors.panel), toggle: document.querySelector(this.selectors.toggle), count: document.querySelector(this.selectors.count), indicator: document.querySelector(this.selectors.indicator), }; if (!this.ui.panel) { this.canUpdateUI = false; return; } for (let [key, selector] of Object.entries(this.selectors)) { if (['panel', 'toggle', 'count', 'indicator'].includes(key)) { continue; } if (typeof selector === 'object') { this.ui[key] = {}; for (let [k, s] of Object.entries(selector)) { this.ui[key][k] = this.ui.panel.querySelector(s); } }else { this.ui[key] = this.ui.panel.querySelector(selector); } } } updateUI() { if (!this.canUpdateUI) { return; } const stats = this.getQueueStats(); // Update count badge if (this.ui.count) { const total = stats.total - stats.completed; this.ui.count.textContent = total > 0 ? total : ''; this.ui.count.style.display = total > 0 ? '' : 'none'; } // Update indicator if (this.ui.indicator) { const hasActive = stats.queued > 0 || stats.uploading > 0 || stats.pending > 0 || stats.processing > 0; this.ui.indicator.classList.toggle('active', hasActive); } let failed = this.getOperationsByStatus('failed'); let completed = this.getOperationsByStatus('completed'); this.ui.clearButton.disabled = completed.length === 0; this.ui.retryButton.disabled = failed.length === 0; // Update filter counts Object.entries(this.ui.filters).forEach(([status, button]) => { const count = status === 'all' ? stats.total : stats[status] || 0; const countEl = button.querySelector('.count'); if (countEl) { countEl.textContent = count > 0 ? count : ''; } button.setAttribute('data-count', count); }); // Update operation list this.renderOperations(); } getStatusLabel(status) { const labels = { 'queued': 'Queued', 'localProcessing': 'Processing locally', 'uploading': 'Uploading', 'pending': 'Waiting on server', 'processing': 'Processing', 'completed': 'Completed', 'failed': 'Failed (will retry)', 'failed_permanent': 'Failed permanently' }; return labels[status] || status; } getItemMessage(item) { if (item.message) return item.message; if (item.error_message) return item.error_message; switch(item.status) { case 'queued': return 'Waiting to send...'; case 'uploading': return 'Sending to server...'; case 'pending': return item.position ? `Position ${item.position} in queue` : 'In server queue'; case 'processing': return item.progress ? `${item.progress}% complete` : 'Processing...'; case 'completed': return 'Successfully completed'; case 'failed': return `Failed: ${item.lastError || 'Unknown error'} (Retry ${item.retries}/${this.config.maxRetries})`; case 'failed_permanent': return `Failed: ${item.lastError || 'Unknown error'}`; default: return ''; } } calculateProgress(item) { if (item.progress) return item.progress; // Estimate progress based on status const statusProgress = { 'queued': 10, 'uploading': 25, 'pending': 40, 'processing': 70, 'completed': 100, 'failed': 0, 'failed_permanent': 0 }; return statusProgress[item.status] || 0; } getQueueStats() { const stats = {}; this.statuses.forEach(status => { stats[status] = 0; }); Array.from(this.store.items.values()) .forEach(op => { if (stats.hasOwnProperty(op.status)) { stats[op.status]++; } }); stats.total = Array.from(this.store.items.values()).length; return stats; } renderOperations() { if (!this.ui.itemsContainer) return; const activeFilter = this.getActiveFilter(); const operations = this.getFilteredOperations(activeFilter); // Clear container window.removeChildren(this.ui.itemsContainer); // Render each operation if (operations.length === 0) { let empty = window.getTemplate('emptyQueue'); this.ui.itemsContainer.append(empty); this.a11y.announce('Nothing queued.'); } else { let empty = this.ui.itemsContainer.querySelector('.emptyQueue'); if (empty) { empty.remove(); } operations.forEach(op => { const element = this.createOperationUI(op); this.ui.itemsContainer.appendChild(element); }); } } createOperationUI(operation) { const listItem = window.getTemplate('queueItem'); listItem.dataset.id = operation.id; this.updateOperationUI(operation, listItem); return listItem; } updateOperationUI(item, element = null) { if (!element) { element = this.ui.itemsContainer?.querySelector(`[data-id="${item.id}"]`); } if (!element) { element = this.createOperationUI(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}%`; // Update action buttons const actionsContainer = element.querySelector('.actions'); if (actionsContainer) { this.updateActionButtons(item, actionsContainer); } } 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.dataset.action = '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; retryBtn.dataset.action = 'retry'; dismissBtn.classList.add('dismiss'); dismissBtn.textContent = 'Dismiss'; dismissBtn.dataset.action = 'dismiss'; container.appendChild(retryBtn); container.appendChild(dismissBtn); break; case 'completed': // Show dismiss button only const dismissCompletedBtn = window.getTemplate('button'); dismissCompletedBtn.dataset.action = 'dismiss'; dismissCompletedBtn.classList.add('dismiss'); dismissCompletedBtn.textContent = 'Dismiss'; container.appendChild(dismissCompletedBtn); break; } } removeOperationFromUI(operationId) { const element = this.ui.itemsContainer?.querySelector(`[data-id="${operationId}"]`); if (element) { element.style.opacity = '0'; element.style.transform = 'scale(0.9)'; setTimeout(() => element.remove(), 300); } } updateCountdown() { if (!this.ui.countdown || !this.isPolling) return; let seconds = this.config.pollInterval / 1000; this.countdownTimer = setInterval(() => { seconds--; this.ui.countdown.textContent = seconds; if (seconds <= 0) { clearInterval(this.countdownTimer); if (this.isPolling) { setTimeout(() => this.updateCountdown(), 100); } } }, 1000); } updateStatusPanel(status) { this.ui.panel?.classList.remove(...this.classes); if (!this.classes.includes(status)) { return; } this.ui.panel?.classList.add(status); } /*************************************************** FILTERS **************************************************/ setFilter(filter) { Object.values(this.ui.filters).forEach(button => { if (button) { button.classList.toggle('active', button.dataset.filter === filter); } }); this.activeFilter = filter; this.renderOperations(); } getActiveFilter() { const activeButton = this.ui.panel?.querySelector('.filter.active'); return activeButton?.dataset.filter || 'all'; } getFilteredOperations(filter) { const operations = Array.from(this.store.items.values()); if (filter === 'all') { return operations; } return operations.filter(op => op.status === filter); } /************************************************************************** NOTIFICATIONS **************************************************************************/ showPopup(message, type = 'success') { if (!this.ui.popup) return; const span = this.ui.popup.querySelector('span'); if (span) { span.textContent = message; } this.ui.popup.className = `popup ${type} show`; setTimeout(() => { this.ui.popup.classList.remove('show'); }, 3000); } /************************************************************************** HELPERS **************************************************************************/ getOperationsByStatus(status, include = true) { status = Array.isArray(status) ? status : ((status.includes(',')) ? status.split(',') : [status]); if (include) { return Array.from(this.queue.values()).filter(op => status.includes(op.status) ); } return Array.from(this.queue.values()).filter(op => !status.includes(op.status) ); } hasQueuedOperations() { return this.queue.some(op => op.status === 'queued' ); } subscribe(callback) { this.subscribers.add(callback); return () => this.subscribers.delete(callback); } notify(event, data) { this.subscribers.forEach(cb => cb(event, data)); } /************************************************************************** CLEANUP **************************************************************************/ destroy() { this.stopPolling(); this.stopActivityTracking(); if (this.clickHandler) { document.removeEventListener('click', this.clickHandler); } if (this.keyHandler) { document.removeEventListener('keydown', this.keyHandler); } this.subscribers.clear(); } } document.addEventListener('DOMContentLoaded', function() { window.jvbQueue = new QueueManager(); });