| | |
| | | endpoint: 'queue', |
| | | ...config |
| | | }; |
| | | this.user = jvbSettings.currentUser; |
| | | |
| | | // 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' |
| | | ]; |
| | | |
| | | this.user = window.auth.getUser(); |
| | | |
| | | if (!this.user) { |
| | | console.log('Queue: User not logged in, queue disabled'); |
| | | this.store = null; |
| | | this.canUpdateUI = false; |
| | | return; |
| | | } |
| | | |
| | | this.headers = { |
| | | 'X-WP-Nonce': jvbSettings.nonce, |
| | | 'X-WP-Nonce': window.auth.getNonce(), |
| | | ...config.headers |
| | | }; |
| | | |
| | |
| | | '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(); |
| | |
| | | name: 'Queue Panel', |
| | | }); |
| | | } |
| | | |
| | | this.updateUI = () => window.debouncer.schedule('queue-ui-update', this._updateUI.bind(this), 100); |
| | | 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 { |
| | | let polling = this.maybeStartPolling(); |
| | | if (!polling) { |
| | | this.updateStatusPanel('synced'); |
| | | } |
| | | |
| | | |
| | | this.store.subscribe((event, data) => { |
| | | switch (event) { |
| | | case 'data-loaded': |
| | | case 'items-saved': |
| | | // Initial load from IndexedDB |
| | | const incomplete = this.getOperationsByStatus(['completed', 'failed_permanent'], false); |
| | | if (incomplete.length > 0) { |
| | | this.startPolling(); |
| | | } |
| | | this.maybeStartPolling(); |
| | | this.updateUI(); |
| | | break; |
| | | case 'item-saved': |
| | | // Check for status changes |
| | | if (data.item) { |
| | | const oldItem = this.store.data.get(data.item.id); |
| | | if (oldItem && oldItem.status !== data.item.status) { |
| | | this.handleOperationStatusChange(data.item, oldItem.status); |
| | | } |
| | | console.log(data,'Item saved data'); |
| | | if (data.previousItem && data.previousItem.status !== data.item.status) { |
| | | this.handleOperationStatusChange(data.item, data.previousItem.status); |
| | | } |
| | | if (this.hasQueuedOperations()) { |
| | | this.startPolling(); |
| | | } |
| | | this.maybeStartPolling(); |
| | | break; |
| | | default: |
| | | this.updateUI(); |
| | |
| | | } |
| | | |
| | | }); |
| | | this.notify('queue-initialized', {operations: incomplete}); |
| | | } |
| | | |
| | | maybeStartPolling() |
| | | { |
| | | const incomplete = this.getOperationsByStatus(['completed', 'failed_permanent'], false); |
| | | if (incomplete.length > 0) { |
| | | this.startPolling(); |
| | | return true; |
| | | } |
| | | return false; |
| | | } |
| | | |
| | | /** |
| | | * Handle operation status changes and notify subscribers |
| | | */ |
| | | handleOperationStatusChange(operation, oldStatus) { |
| | | if (!operation || oldStatus === operation.status) return; |
| | | handleOperationStatusChange(operation) { |
| | | |
| | | // Notify based on new status |
| | | switch(operation.status) { |
| | | case 'completed': |
| | | console.log(operation); |
| | | this.notify('operation-completed', operation); |
| | | break; |
| | | case 'failed': |
| | |
| | | method: 'POST', |
| | | headers: {}, |
| | | data: {}, |
| | | sendNow: false, // true = process immediately |
| | | canMerge: true, |
| | | popup: 'Saving changes...', |
| | | title: 'Operation', |
| | |
| | | return null; |
| | | } |
| | | |
| | | if (item.sendNow) { |
| | | this.processOperation(item).then(()=> {}); |
| | | this.store.clearCache(); |
| | | window.debouncer.schedule('fastQueue', this.startPolling.bind(this), 200); |
| | | this.showQueue(); |
| | | return item.id; |
| | | } |
| | | |
| | | const existingOps = Array.from(this.store.data.values()).filter(op=> |
| | | op.status === 'queued' && |
| | | op.endpoint === item.endpoint && |
| | |
| | | return existing.id; |
| | | } |
| | | |
| | | console.log('Added to Queue: ', item); |
| | | this.store.clearCache(); |
| | | |
| | | //Add new operation to DataStore |
| | |
| | | |
| | | } |
| | | |
| | | |
| | | setQueue(item) { |
| | | this.store.save(item); // Remove first parameter |
| | | this.store.save(item); |
| | | } |
| | | |
| | | updateOperationStatus(itemID, status) { |
| | | let item = this.store.get(itemID); |
| | | if (!item){ |
| | | return; |
| | | } |
| | | if (!item) return; |
| | | |
| | | // Update status |
| | | item.status = status; |
| | | |
| | | this.notify('operation-status', item); |
| | |
| | | } |
| | | |
| | | resetActivityTimer() { |
| | | this.lastActivity = Date.now(); |
| | | |
| | | if (this.activityTimer) { |
| | | clearTimeout(this.activityTimer); |
| | | } |
| | |
| | | } |
| | | } |
| | | |
| | | hideQueue(){ |
| | | this.ui.panel.hidden = true; |
| | | this.ui.toggle.hidden = true; |
| | | } |
| | | showQueue() { |
| | | this.ui.panel.hidden = false; |
| | | this.ui.toggle.hidden = false; |
| | | } |
| | | |
| | | setProcessing(on) { |
| | | this.isProcessing = on; |
| | | this.ui.toggle.classList.toggle('saving', on); |
| | |
| | | this.setProcessing(false); |
| | | this.stopActivityTracking(); |
| | | |
| | | const pending = this.getOperationsByStatus(['queued', 'completed', 'failed_permanent'], false); |
| | | if (pending.length > 0) { |
| | | this.startPolling(); |
| | | } |
| | | this.maybeStartPolling() ? this.showQueue() : this.hideQueue(); |
| | | } |
| | | |
| | | async processOperation(operation) { |
| | | async processOperation(operation, skip = false) { |
| | | try { |
| | | this.updateOperationStatus(operation.id, 'uploading'); |
| | | if (!skip) { |
| | | this.updateOperationStatus(operation.id, 'uploading'); |
| | | |
| | | if (operation.data?._isFormData) { |
| | | operation.data = await this.store.objectToFormData(operation.data); |
| | | if (operation.data?._isFormData) { |
| | | operation.data = await this.store.objectToFormData(operation.data); |
| | | } |
| | | } |
| | | |
| | | const url = `${this.config.apiBase}${operation.endpoint}`; |
| | |
| | | }); |
| | | operation.headers['Content-Type'] = 'application/json'; |
| | | } |
| | | |
| | | const response = await fetch(url, { |
| | | method: operation.method, |
| | | headers: operation.headers, |
| | |
| | | }); |
| | | |
| | | const result = await response.json(); |
| | | |
| | | if (skip) { |
| | | operation.data = {}; |
| | | } |
| | | 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); |
| | | } |
| | | operation = await this.handleServerMerge(operation, result); |
| | | } else { |
| | | // Normal processing - no merge |
| | | operation.status = 'pending'; |
| | | operation.status = result.status || 'pending'; |
| | | operation.serverData = result; |
| | | this.updateOperationStatus(operation.id, 'pending'); |
| | | this.setQueue(operation); |
| | | this.updateOperationStatus(operation.id, operation.status); |
| | | } |
| | | |
| | | this.a11y.announce(`${operation.title} sent to server for processing.`); |
| | |
| | | } |
| | | } |
| | | |
| | | async handleServerMerge(operation, result) { |
| | | const existingOp = this.getQueue(result.id); |
| | | |
| | | if (existingOp) { |
| | | // Merge with existing local operation |
| | | existingOp.data = window.deepMerge(existingOp.data, operation.data); |
| | | existingOp.status = result.status || 'pending'; |
| | | existingOp.serverData = result; |
| | | this.updateOperationStatus(existingOp.id, existingOp.status); |
| | | this.removeOperationFromUI(operation.id); |
| | | this.clearQueue(operation.id); |
| | | return existingOp; |
| | | } else { |
| | | // Server merged with unknown operation |
| | | this.clearQueue(operation.id); |
| | | operation.id = result.id; |
| | | operation.status = result.status || 'pending'; |
| | | operation.serverData = result; |
| | | this.updateOperationStatus(operation.id, operation.status); |
| | | return operation; |
| | | } |
| | | } |
| | | |
| | | startPolling() { |
| | | if (this.isPolling) return; |
| | | |
| | |
| | | this.store.clearCache(); |
| | | await this.store.fetch(); // Fetches from server, updates store.data |
| | | |
| | | const incomplete = this.getOperationsByStatus(['completed', 'failed_permanent'], false); |
| | | if (incomplete.length === 0) { |
| | | if (!this.maybeStartPolling()) { |
| | | this.stopPolling(); |
| | | this.updateStatusPanel('synced'); |
| | | } |
| | |
| | | this.countdownTimer = null; |
| | | } |
| | | } |
| | | |
| | | getOperationIds(operations) { |
| | | return operations.map(op => op.id); |
| | | } |
| | | /*********************************************************** |
| | | USER ACTIONS |
| | | ***********************************************************/ |
| | |
| | | * @returns {Promise<void>} |
| | | */ |
| | | async updateServerOperations(ids, action) { |
| | | //ensure ids are in an array |
| | | ids = Array.isArray(ids) ? ids : ((ids.includes(',')) ? ids.split(',') : [ids]); |
| | | ids = ids.filter((id) => { |
| | | 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 (ids.length === 0) return; |
| | | |
| | | if (['cancel', 'dismiss'].includes(action)) { |
| | | ids.forEach(id => { |
| | | this.removeOperationFromUI(id); |
| | | }); |
| | | // SINGLE place to handle UI removal |
| | | const shouldRemove = ['cancel', 'dismiss'].includes(action); |
| | | if (shouldRemove) { |
| | | 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, user: jvbSettings.currentUser}) |
| | | } |
| | | ); |
| | | const response = await fetch(`${this.config.apiBase}${this.config.endpoint}`, { |
| | | method: 'POST', |
| | | headers: { 'Content-Type': 'application/json', ...this.headers }, |
| | | body: JSON.stringify({ ids, action, user: window.auth.getUser() }) |
| | | }); |
| | | |
| | | if (!response.ok) { |
| | | const errorData = await response.json().catch(()=>{}); |
| | | throw new Error(errorData.message || `${action} failed: ${response.status}`); |
| | | throw new Error(`${action} failed: ${response.status}`); |
| | | } |
| | | |
| | | const result = await response.json(); |
| | |
| | | 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); |
| | | // SINGLE place to handle store updates |
| | | ids.forEach(id => { |
| | | let item = this.getQueue(id); |
| | | this.notify(`${action}-operation`, item); |
| | | |
| | | if (shouldRemove) { |
| | | this.clearQueue(id); |
| | | } else { |
| | | item.status = 'queued'; |
| | | item.retries = 0; |
| | | this.setQueue(item); |
| | | this.updateOperationStatus(item.id, item.status); |
| | | }); |
| | | } |
| | | }); |
| | | |
| | | if (action === 'retry') { |
| | | this.startActivityTracking(); |
| | | } |
| | | this.updateUI(); |
| | | |
| | | this.updateUI(); |
| | | return result; |
| | | |
| | | } catch (error) { |
| | | const result = await window.jvbError.log(error, { |
| | | // Log and let jvbError handle retry |
| | | await window.jvbError.log(error, { |
| | | component: 'QueueManager', |
| | | operation: 'performQueueAction', |
| | | action: action, |
| | | operationIds: ids, |
| | | itemCount: ids.length |
| | | }, () => this.updateServerOperations(ids, action)); // Retry callback |
| | | }, () => this.updateServerOperations(ids, action)); |
| | | |
| | | if (result.retried) { |
| | | return result; // Return successful retry result |
| | | } else { |
| | | throw error; // Re-throw if not retried |
| | | } |
| | | // Don't re-throw - error is logged and handled |
| | | return { success: false, error: error.message }; |
| | | } |
| | | } |
| | | |
| | |
| | | *********************************************/ |
| | | 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(); |
| | |
| | | }; |
| | | this.handleOffline = () => this.updateStatusPanel('offline'); |
| | | this.handleBeforeUnload = (e) => { |
| | | const hasPending = this.getOperationsByStatus(['queued', 'uploading']); |
| | | if (hasPending.length > 0) { |
| | | if (this.isPolling || this.isProcessing) { |
| | | e.preventDefault(); |
| | | return 'You have unsaved changes in the queue.'; |
| | | return 'You have unsaved changes in the queue. Proceed?'; |
| | | } |
| | | }; |
| | | |
| | |
| | | this.store.clearHttpHeaders(); // Clear cached headers first |
| | | this.store.fetch(); |
| | | } else if (e.target.closest(this.selectors.clearButton)) { |
| | | const completedOps = this.getOperationsByStatus('completed'); |
| | | const completedOps = this.getOperationIds(this.getOperationsByStatus('completed')); |
| | | if (completedOps.length > 0) { |
| | | const ids = completedOps.map(op => op.id); |
| | | this.updateServerOperations(ids, 'dismiss'); |
| | | this.updateServerOperations(completedOps, 'dismiss'); |
| | | } |
| | | } else if (e.target.closest(this.selectors.retryButton)) { |
| | | const failedOps = this.getOperationsByStatus('failed'); |
| | | const failedOps = this.getOperationIds(this.getOperationsByStatus('failed')); |
| | | if (failedOps.length > 0) { |
| | | const ids = failedOps.map(op => op.id); |
| | | this.updateServerOperations(ids, 'retry'); |
| | | this.updateServerOperations(failedOps, 'retry'); |
| | | } |
| | | } else if (e.target.closest('[data-action]')) { |
| | | const button = e.target.closest('[data-action]'); |
| | |
| | | |
| | | } |
| | | |
| | | handleChange(e) { |
| | | } |
| | | |
| | | /********************************************* |
| | | UI |
| | | *********************************************/ |
| | |
| | | } |
| | | }; |
| | | |
| | | 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), |
| | | }; |
| | | this.ui = window.uiFromSelectors(this.selectors); |
| | | 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() { |
| | | _updateUI() { |
| | | if (!this.canUpdateUI) { |
| | | return; |
| | | } |
| | |
| | | } |
| | | } |
| | | |
| | | 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)) { |
| | |
| | | } |
| | | |
| | | /************************************************************************** |
| | | 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) { |
| | |
| | | return this.getOperationsByStatus('queued').length > 0; |
| | | } |
| | | subscribe(callback) { |
| | | if (!this.subscribers) { |
| | | return; |
| | | } |
| | | this.subscribers.add(callback); |
| | | return () => this.subscribers.delete(callback); |
| | | } |
| | |
| | | } |
| | | } |
| | | |
| | | document.addEventListener('DOMContentLoaded', function() { |
| | | window.jvbQueue = new QueueManager(); |
| | | document.addEventListener('DOMContentLoaded', async function() { |
| | | window.auth.subscribe((event) => { |
| | | if (event === 'auth-loaded') { |
| | | window.jvbQueue = new QueueManager(); |
| | | } |
| | | }); |
| | | }); |