| | |
| | | /** |
| | | * QueueManager |
| | | * Uses DataStore for persistent storage |
| | | */ |
| | | class QueueManager { |
| | | constructor(config = {}) { |
| | | this.canUpdateUI = true; |
| | | this.config = { |
| | | apiBase: jvbSettings.api, |
| | | maxRetries: 3, |
| | | pollInterval: 5000, |
| | | activityDelay: 2000, //2 seconds |
| | | autosync: true, |
| | | endpoint: 'queue', |
| | | ...config |
| | | }; |
| | | |
| | | // 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' |
| | | ]; |
| | | constructor() { |
| | | this.a11y = window.jvbA11y; |
| | | this.error = window.jvbError; |
| | | |
| | | 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.canUpdateUI = true; |
| | | this.isProcessing = false; |
| | | this.isPolling = false; |
| | | this.queue = new Map(); |
| | | this.items = new Map(); |
| | | this.subscribers = new Set(); |
| | | this.loadFromStorage = false; |
| | | |
| | | this.api = jvbSettings.api; |
| | | this.endpoint = 'queue'; |
| | | |
| | | this.init(); |
| | | } |
| | | init() { |
| | | this.headers = { |
| | | 'X-WP-Nonce': window.auth.getNonce(), |
| | | ...config.headers |
| | | }; |
| | | |
| | | this.a11y = window.jvbA11y; |
| | | this.errors = window.jvbError; |
| | | |
| | | // Initialize DataStore for queue persistence |
| | | const store = window.jvbStore.register('queue', { |
| | | storeName: 'queue', |
| | | keyPath: 'id', |
| | | endpoint: this.config.endpoint, |
| | | TTL: Infinity, |
| | | indexes: [ |
| | | {name: 'status', keyPath: 'status'}, |
| | | {name: 'type', keyPath: 'type'}, |
| | | ], |
| | | showLoading: false, |
| | | delayFetch: false, // Queue should fetch immediately |
| | | }); |
| | | this.store = store.queue; |
| | | |
| | | this.classes = [ |
| | | 'offline', |
| | | 'synced', |
| | | 'pending' |
| | | ]; |
| | | |
| | | |
| | | |
| | | // Initialize |
| | | this.initUI(); |
| | | this.initElements(); |
| | | this.initListeners(); |
| | | if (this.ui.panel) { |
| | | this.popup = new window.jvbPopup({ |
| | | this.initStore(); |
| | | if (this.canUpdateUI && this.ui.panel) { |
| | | this.popup = window.jvbPopup.registerPopup({ |
| | | popup: this.ui.panel, |
| | | toggle: this.ui.toggle, |
| | | toggle: this.ui.toggle.button, |
| | | name: 'Queue Panel', |
| | | }); |
| | | } |
| | | this.updateUI = () => window.debouncer.schedule('queue-ui-update', this._updateUI.bind(this), 100); |
| | | this.initQueue(); |
| | | this.defineTemplates(); |
| | | } |
| | | |
| | | async initQueue() { |
| | | let polling = this.maybeStartPolling(); |
| | | if (!polling) { |
| | | this.updateStatusPanel('synced'); |
| | | } |
| | | |
| | | |
| | | this.store.subscribe((event, data) => { |
| | | switch (event) { |
| | | case 'data-loaded': |
| | | case 'items-saved': |
| | | this.maybeStartPolling(); |
| | | this.updateUI(); |
| | | break; |
| | | case 'item-saved': |
| | | console.log(data,'Item saved data'); |
| | | if (data.previousItem && data.previousItem.status !== data.item.status) { |
| | | this.handleOperationStatusChange(data.item, data.previousItem.status); |
| | | } |
| | | this.maybeStartPolling(); |
| | | break; |
| | | default: |
| | | this.updateUI(); |
| | | break; |
| | | } |
| | | |
| | | }); |
| | | } |
| | | |
| | | 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) { |
| | | |
| | | // Notify based on new status |
| | | switch(operation.status) { |
| | | case 'completed': |
| | | console.log(operation); |
| | | this.notify('operation-completed', operation); |
| | | break; |
| | | case 'failed': |
| | | this.notify('operation-failed', operation); |
| | | break; |
| | | case 'failed_permanent': |
| | | this.notify('operation-failed-permanent', operation); |
| | | break; |
| | | } |
| | | } |
| | | /** |
| | | * |
| | | * @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: {}, |
| | | sendNow: false, // true = process immediately |
| | | 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; |
| | | } |
| | | |
| | | 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 && |
| | | 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; |
| | | } |
| | | |
| | | this.store.clearCache(); |
| | | |
| | | //Add new operation to DataStore |
| | | this.setQueue(item); |
| | | |
| | | this.updateOperationStatus(item.id, item.status); |
| | | this.updateUI(); |
| | | |
| | | this.startActivityTracking(); |
| | | return item.id; |
| | | |
| | | |
| | | } |
| | | |
| | | |
| | | setQueue(item) { |
| | | this.store.save(item); |
| | | } |
| | | |
| | | updateOperationStatus(itemID, status) { |
| | | let item = this.store.get(itemID); |
| | | if (!item) return; |
| | | |
| | | // Update status |
| | | item.status = status; |
| | | |
| | | this.notify('operation-status', item); |
| | | this.updateOperationUI(item); |
| | | } |
| | | |
| | | getQueue(itemID) { |
| | | return this.store.get(itemID); |
| | | } |
| | | |
| | | clearQueue(itemID) { |
| | | this.store.delete(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() { |
| | | 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; |
| | | } |
| | | } |
| | | |
| | | 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); |
| | | } |
| | | /** |
| | | * Send any queued operations to the server |
| | | * @returns {Promise<void>} |
| | | */ |
| | | 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(); |
| | | |
| | | this.maybeStartPolling() ? this.showQueue() : this.hideQueue(); |
| | | } |
| | | |
| | | async processOperation(operation, skip = false) { |
| | | try { |
| | | if (!skip) { |
| | | this.updateOperationStatus(operation.id, 'uploading'); |
| | | |
| | | if (operation.data?._isFormData) { |
| | | operation.data = await this.store.objectToFormData(operation.data); |
| | | } |
| | | } |
| | | |
| | | 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; |
| | | } else { |
| | | requestBody = JSON.stringify({ |
| | | ...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 (skip) { |
| | | operation.data = {}; |
| | | } |
| | | if (response.ok && result.success !== false) { |
| | | // Handle server-side merge |
| | | if (result.id && operation.id !== result.id) { |
| | | operation = await this.handleServerMerge(operation, result); |
| | | } else { |
| | | operation.status = result.status || 'pending'; |
| | | operation.serverData = result; |
| | | this.updateOperationStatus(operation.id, operation.status); |
| | | } |
| | | |
| | | 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); |
| | | } |
| | | } |
| | | |
| | | 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.isPolling = true; |
| | | this.updateStatusPanel('pending'); |
| | | |
| | | this.pollTimer = setInterval(async () => { |
| | | try { |
| | | this.store.clearCache(); |
| | | await this.store.fetch(); // Fetches from server, updates store.data |
| | | |
| | | if (!this.maybeStartPolling()) { |
| | | this.stopPolling(); |
| | | this.updateStatusPanel('synced'); |
| | | } |
| | | } catch (error) { |
| | | console.error('Polling error:', error); |
| | | } |
| | | }, this.config.pollInterval); |
| | | } |
| | | |
| | | 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; |
| | | } |
| | | } |
| | | getOperationIds(operations) { |
| | | return operations.map(op => op.id); |
| | | } |
| | | /*********************************************************** |
| | | USER ACTIONS |
| | | ***********************************************************/ |
| | | |
| | | /** |
| | | * |
| | | * @param {array} ids |
| | | * @param {string }action |
| | | * @returns {Promise<void>} |
| | | */ |
| | | async updateServerOperations(ids, action) { |
| | | 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; |
| | | |
| | | // SINGLE place to handle UI removal |
| | | const shouldRemove = ['cancel', 'dismiss'].includes(action); |
| | | if (shouldRemove) { |
| | | ids.forEach(id => this.removeOperationFromUI(id)); |
| | | } |
| | | |
| | | try { |
| | | 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) { |
| | | throw new Error(`${action} failed: ${response.status}`); |
| | | } |
| | | |
| | | const result = await response.json(); |
| | | if (!result.success) { |
| | | throw new Error(result.message || `${action} operation failed`); |
| | | } |
| | | |
| | | // 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(); |
| | | return result; |
| | | |
| | | } catch (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)); |
| | | |
| | | // Don't re-throw - error is logged and handled |
| | | return { success: false, error: error.message }; |
| | | } |
| | | } |
| | | |
| | | 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); |
| | | |
| | | document.addEventListener('click', this.clickHandler); |
| | | |
| | | this.handleOnline = () => { |
| | | this.updateStatusPanel(); |
| | | if (this.hasQueuedOperations()) { |
| | | this.processQueue(); |
| | | } |
| | | }; |
| | | this.handleOffline = () => this.updateStatusPanel('offline'); |
| | | this.handleBeforeUnload = (e) => { |
| | | if (this.isPolling || this.isProcessing) { |
| | | e.preventDefault(); |
| | | return 'You have unsaved changes in the queue. Proceed?'; |
| | | } |
| | | }; |
| | | |
| | | window.addEventListener('online', this.handleOnline); |
| | | window.addEventListener('offline', this.handleOffline); |
| | | window.addEventListener('beforeunload', this.handleBeforeUnload); |
| | | } |
| | | handleClick(e) { |
| | | if (!e.target.closest(this.selectors.panel, this.selectors.toggle)) { |
| | | return; |
| | | } |
| | | if (e.target.closest(this.selectors.refreshButton)) { |
| | | this.store.clearCache(); |
| | | this.store.clearHttpHeaders(); // Clear cached headers first |
| | | this.store.fetch(); |
| | | } else if (e.target.closest(this.selectors.clearButton)) { |
| | | const completedOps = this.getOperationIds(this.getOperationsByStatus('completed')); |
| | | if (completedOps.length > 0) { |
| | | this.updateServerOperations(completedOps, 'dismiss'); |
| | | } |
| | | } else if (e.target.closest(this.selectors.retryButton)) { |
| | | const failedOps = this.getOperationIds(this.getOperationsByStatus('failed')); |
| | | if (failedOps.length > 0) { |
| | | this.updateServerOperations(failedOps, '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); |
| | | } |
| | | |
| | | } |
| | | initElements() { |
| | | this.panelStatuses = ['syncing', 'synced', 'pending', 'offline']; |
| | | this.statuses = ['queued', 'localProcessing', 'uploading', 'pending', 'processing', 'completed', 'failed', 'failed_permanent']; |
| | | this.pendingStatuses = ['queued', 'localProcessing', 'uploading']; |
| | | this.workingStatuses = ['pending','processing']; |
| | | this.completedStatuses = ['completed', 'failed', 'failed_permanent']; |
| | | |
| | | /********************************************* |
| | | UI |
| | | *********************************************/ |
| | | initUI() { |
| | | this.icons = { |
| | | queued: 'arrows-clockwise', localProcessing: 'arrows-clockwise', uploading: 'syncing', |
| | | pending: 'cloud', processing: 'syncing', completed: 'cloud-check', |
| | | failed: 'cloud-warning', failed_permanent: 'cloud-warning' |
| | | }; |
| | | |
| | | 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', |
| | | toggle: { |
| | | button: 'button.qtoggle', |
| | | indicator: '.qtoggle .indicator', |
| | | count: '.qtoggle .count' |
| | | }, |
| | | refresh: { |
| | | button: '#queue .m-actions .refresh', |
| | | countdown: '#queue .m-actions .refresh .countdown' |
| | | }, |
| | | popup: { |
| | | popup: '#queue .popup', |
| | | message: '#queue .popup span' |
| | | }, |
| | | items: { |
| | | container: '#queue .qitems', |
| | | }, |
| | | actions: { |
| | | retry: '#queue .retry-all', |
| | | clear: '#queue .dismiss-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"]', |
| | | } |
| | | filter: '#queue [data-filter]', |
| | | all: { |
| | | label: '#queue [for="qfilter-all"]', |
| | | radio: '#queue [data-filter="all"]', |
| | | count: '#queue [data-filter="all"] .count' |
| | | }, |
| | | queued: { |
| | | label: '#queue [for="qfilter-queued"]', |
| | | input: '#queue [data-filter="queued"]', |
| | | count: '#queue [for="qfilter-queued"] .count' |
| | | }, |
| | | localProcessing: { |
| | | label: '#queue [for="qfilter-localProcessing"]', |
| | | input: '#queue [data-filter="localProcessing"]', |
| | | count: '#queue [for="qfilter-localProcessing"] .count', |
| | | }, |
| | | uploading: { |
| | | label: '#queue [for="qfilter-uploading"]', |
| | | input: '#queue [data-filter="uploading"]', |
| | | count: '#queue [for="qfilter-uploading"] .count', |
| | | }, |
| | | pending: { |
| | | label: '#queue [for="qfilter-pending"]', |
| | | input: '#queue [data-filter="pending"]', |
| | | count: '#queue [for="qfilter-pending"] .count', |
| | | }, |
| | | processing: { |
| | | label: '#queue [for="qfilter-processing"]', |
| | | input: '#queue [data-filter="processing"]', |
| | | count: '#queue [for="qfilter-processing"] .count', |
| | | }, |
| | | completed: { |
| | | label: '#queue [for="qfilter-completed"]', |
| | | input: '#queue [data-filter="completed"]', |
| | | count: '#queue [for="qfilter-completed"] .count', |
| | | }, |
| | | failed: { |
| | | label: '#queue [for="qfilter-failed"]', |
| | | input: '#queue [data-filter="failed"]', |
| | | count: '#queue [for="qfilter-failed"] .count', |
| | | }, |
| | | }, |
| | | item: { |
| | | type: '.type', |
| | | status: '.status', |
| | | details: '.info .details', |
| | | icon: '.status .icon', |
| | | startedAt: '.started time', |
| | | completed: { |
| | | wrap: '.completed', |
| | | label: '.completed span', |
| | | time: '.completed time', |
| | | }, |
| | | progress: { |
| | | progress: '.progress', |
| | | fill: '.progress .fill', |
| | | details: '.progress .details', |
| | | icon: '.progress .icon' |
| | | }, |
| | | actions: { |
| | | cancel: 'button.cancel', |
| | | retry: 'button.retry', |
| | | refresh: 'button.refresh', |
| | | dismiss: 'button.dismiss', |
| | | } |
| | | }, |
| | | }; |
| | | |
| | | this.ui = window.uiFromSelectors(this.selectors); |
| | | if (!this.ui.panel) { |
| | | this.canUpdateUI = false; |
| | | } |
| | | if (!this.ui.panel) this.canUpdateUI = false; |
| | | } |
| | | |
| | | _updateUI() { |
| | | if (!this.canUpdateUI) { |
| | | return; |
| | | } |
| | | defineTemplates() { |
| | | const T = window.jvbTemplates; |
| | | |
| | | // Get current operations from store |
| | | const operations = Array.from(this.store.data.values()); |
| | | |
| | | // Get stats from last fetch response (server-provided) |
| | | const stats = this.store.lastResponse?.queue_stats || { |
| | | queued: 0, |
| | | localProcessing: 0, |
| | | uploading: 0, |
| | | pending: 0, |
| | | processing: 0, |
| | | completed: 0, |
| | | failed: 0, |
| | | failed_permanent: 0 |
| | | }; |
| | | |
| | | // Update count badge |
| | | if (this.ui.count) { |
| | | const activeCount = operations.length - stats.completed; |
| | | this.ui.count.textContent = activeCount > 0 ? activeCount : ''; |
| | | this.ui.count.style.display = activeCount > 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); |
| | | } |
| | | |
| | | // Update button states |
| | | this.ui.clearButton.disabled = this.getOperationsByStatus('completed').length === 0; |
| | | this.ui.retryButton.disabled = this.getOperationsByStatus('failed').length === 0 && this.getOperationsByStatus('failed_permanent').length === 0; |
| | | |
| | | // Update filter counts (from server stats) |
| | | Object.entries(this.ui.filters).forEach(([status, button]) => { |
| | | const count = status === 'all' |
| | | ? operations.length |
| | | : stats[status] || 0; |
| | | const countEl = button.querySelector('.count'); |
| | | if (countEl) { |
| | | countEl.textContent = count > 0 ? count : ''; |
| | | T.define('emptyState'); |
| | | T.define('queueItem', { |
| | | setup({el, refs, manyRefs, data}) { |
| | | el.dataset.id = data.id; |
| | | } |
| | | button.setAttribute('data-count', count); |
| | | }); |
| | | |
| | | // Render current operations |
| | | 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; |
| | | } |
| | | |
| | | |
| | | renderOperations() { |
| | | if (!this.ui.itemsContainer) return; |
| | | initListeners() { |
| | | this.activityListeners = null; |
| | | this.clickHandler = this.handleClick.bind(this); |
| | | this.onlineHandler = this.handleOnline.bind(this); |
| | | this.offlineHandler = this.handleOffline.bind(this); |
| | | this.unloadHandler = this.handleBeforeUnload.bind(this); |
| | | this.visibilityHandler = this.handleVisibilityChange.bind(this); |
| | | |
| | | const operations = this.store.getFiltered(); |
| | | document.addEventListener('click', this.clickHandler); |
| | | window.addEventListener('online', this.onlineHandler); |
| | | window.addEventListener('offline', this.offlineHandler); |
| | | |
| | | // Clear container |
| | | window.removeChildren(this.ui.itemsContainer); |
| | | // window.addEventListener('beforeunload', this.unloadHandler); |
| | | |
| | | // Render operations or empty state |
| | | if (operations.length === 0) { |
| | | let empty = window.getTemplate('emptyQueue'); |
| | | this.ui.itemsContainer.append(empty); |
| | | this.a11y.announce('Nothing queued.'); |
| | | } else { |
| | | operations.forEach(op => { |
| | | const element = this.createOperationUI(op); |
| | | this.ui.itemsContainer.append(element); |
| | | }); |
| | | document.addEventListener('visibilitychange', this.visibilityHandler); |
| | | } |
| | | handleOnline() { |
| | | this.updatePanel('synced'); |
| | | if (this.getQueueByStatus(this.pendingStatuses).length > 0) { |
| | | this.processQueue(); |
| | | } |
| | | } |
| | | handleOffline() { |
| | | this.updatePanel('offline'); |
| | | } |
| | | |
| | | handleVisibilityChange(e) { |
| | | if (this.isPolling && document.hidden) { |
| | | this.stopPolling(); |
| | | } else { |
| | | this.maybeStartPolling(); |
| | | } |
| | | } |
| | | handleBeforeUnload(e) { |
| | | if (!this.ui.panel) return; |
| | | const total = this.getQueueByStatus(this.pendingStatuses).length; |
| | | if (total > 0) { |
| | | // Modern browsers ignore custom messages, but this triggers the native dialog |
| | | e.preventDefault(); |
| | | e.returnValue = ''; // Required for Chrome |
| | | return ''; // Required for some older browsers |
| | | } |
| | | } |
| | | handleClick(e) { |
| | | if (!window.targetCheck(e, this.selectors.panel+', '+this.selectors.toggle.button)) return; |
| | | const refresh = window.targetCheck(e, this.selectors.refresh.button); |
| | | if (refresh) { |
| | | this.ui.refresh.button.classList.add('fetching'); |
| | | this.store.clearCache(); |
| | | this.store.clearFilters(); |
| | | this.store.fetch().finally(() => { |
| | | this.ui.refresh.button.classList.remove('fetching'); |
| | | }); |
| | | return; |
| | | } |
| | | |
| | | createOperationUI(operation) { |
| | | const listItem = window.getTemplate('queueItem'); |
| | | listItem.dataset.id = operation.id; |
| | | |
| | | this.updateOperationUI(operation, listItem); |
| | | return listItem; |
| | | } |
| | | const refreshPage = window.targetCheck(e, this.selectors.actions.refresh); |
| | | if (refreshPage) { |
| | | this.handleRefresh(opId); |
| | | return; |
| | | } |
| | | |
| | | updateOperationUI(item, element = null) { |
| | | if (!element) { |
| | | element = this.ui.itemsContainer?.querySelector(`[data-id="${item.id}"]`); |
| | | } |
| | | if (!element) { |
| | | element = this.createOperationUI(item); |
| | | const clear = window.targetCheck(e, this.selectors.actions.clear); |
| | | if (clear) { |
| | | this.opActions('completed', 'dismiss').then(()=>{}); |
| | | return; |
| | | } |
| | | |
| | | const retry = window.targetCheck(e, this.selectors.actions.retry); |
| | | if (retry) { |
| | | this.opActions('failed', 'retry').then(()=>{}); |
| | | return; |
| | | } |
| | | |
| | | const action = window.targetCheck(e, '[data-action]'); |
| | | if (action) { |
| | | const opId = action.closest('[data-id]')?.dataset.id; |
| | | if (opId) { |
| | | this.opActions(opId, action.dataset.action); |
| | | } |
| | | return; |
| | | } |
| | | |
| | | const filter = window.targetCheck(e, this.selectors.filters.filter); |
| | | if (filter) { |
| | | this.setFilter(filter.dataset.filter); |
| | | } |
| | | } |
| | | |
| | | // 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); |
| | | } |
| | | } |
| | | |
| | | updateStatusPanel(status) { |
| | | this.ui.panel?.classList.remove(...this.classes); |
| | | if (!this.classes.includes(status)) { |
| | | return; |
| | | } |
| | | this.ui.panel?.classList.add(status); |
| | | } |
| | | |
| | | /*************************************************** |
| | | FILTERS |
| | | **************************************************/ |
| | | setFilter(filter) { |
| | | // Update active button |
| | | Object.values(this.ui.filters).forEach(button => { |
| | | if (button) { |
| | | button.classList.toggle('active', button.dataset.filter === filter); |
| | | Object.values(this.ui.filters).forEach(filterObj => { |
| | | if (filterObj.input?.dataset.filter === filter) { |
| | | filterObj.input.checked = true; |
| | | } |
| | | }); |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | /************************************************************************** |
| | | HELPERS |
| | | **************************************************************************/ |
| | | getOperationsByStatus(status, include = true) { |
| | | trackActivity() { |
| | | if (!this.activityListeners) { |
| | | const events = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart']; |
| | | this.activityListeners = events.map(event => { |
| | | const handler = () => this.resetActivityTimer(); |
| | | document.addEventListener(event, handler, {passive: true}); |
| | | return {event, handler}; |
| | | }); |
| | | } |
| | | this.resetActivityTimer(); |
| | | } |
| | | resetActivityTimer() { |
| | | if (this.activityTimer) { |
| | | clearTimeout(this.activityTimer); |
| | | } |
| | | this.activityTimer = setTimeout(() => { |
| | | this.processQueue(); |
| | | }, 1750); |
| | | } |
| | | 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; |
| | | } |
| | | } |
| | | |
| | | if (!Array.isArray(status) && typeof status === 'string') { |
| | | initStore() { |
| | | if (!this.user) return; |
| | | const store = window.jvbStore.register( |
| | | 'queue', |
| | | { |
| | | storeName: 'queue', |
| | | keyPath: 'id', |
| | | endpoint: this.endpoint, |
| | | TTL: Infinity, |
| | | isAuth: true, |
| | | indexes: [ |
| | | {name: 'status', keyPath: 'status'}, |
| | | {name: 'type', keyPath: 'type'}, |
| | | ], |
| | | filters: { |
| | | user: window.auth.getUser() |
| | | }, |
| | | showLoading: false, |
| | | } |
| | | ) |
| | | this.store = store.queue; |
| | | this.store.subscribe((event, data) => { |
| | | switch (event) { |
| | | case 'data-loaded': |
| | | const serverOps = this.store.getAll(); |
| | | |
| | | serverOps.forEach(serverOp => { |
| | | const localOp = this.queue.get(serverOp.id); |
| | | const mapped = this.mapServerOperation(serverOp); |
| | | |
| | | this.queue.set(mapped.id, mapped); |
| | | |
| | | // Notify if changed |
| | | if (localOp && localOp.status !== mapped.status) { |
| | | this.notify('operation-status', mapped); |
| | | } |
| | | }); |
| | | |
| | | this.maybeStartPolling(); |
| | | this.updateUI(); |
| | | break; |
| | | |
| | | case 'items-save': |
| | | this.maybeStartPolling(); |
| | | this.updateUI(); |
| | | break; |
| | | |
| | | case 'item-saved': |
| | | if (data.item) { |
| | | this.queue.set(data.item.id, data.item); |
| | | if (data.previousItem?.status !== data.item.status) { |
| | | this.notify('operation-status', data.item); |
| | | } |
| | | } |
| | | this.maybeStartPolling(); |
| | | break; |
| | | } |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Handle refresh button click - clears cache for the relevant store |
| | | */ |
| | | handleRefresh(opId) { |
| | | const op = this.getQueue(opId); |
| | | if (!op) return; |
| | | |
| | | // Determine which store to refresh based on operation type |
| | | let storeName = null; |
| | | |
| | | // Map operation types to store names |
| | | const typeToStore = { |
| | | 'content_update': op.data?.posts ? Object.values(op.data.posts)[0]?.content : null, |
| | | 'batch_creation': op.data?.content, |
| | | 'image_upload': 'uploads', |
| | | 'video_upload': 'uploads', |
| | | 'document_upload': 'uploads', |
| | | }; |
| | | |
| | | storeName = typeToStore[op.type]; |
| | | |
| | | // If we found a store name, clear its cache |
| | | if (storeName && window.jvbStore) { |
| | | const store = window.jvbStore.stores.get(storeName); |
| | | if (store) { |
| | | window.jvbStore.clearCache(storeName); |
| | | window.jvbStore.fetch(storeName); |
| | | |
| | | // Give visual feedback |
| | | const button = this.items.get(opId)?.ui?.actions?.refresh; |
| | | if (button) { |
| | | const originalText = button.querySelector('span').textContent; |
| | | button.querySelector('span').textContent = 'Refreshed!'; |
| | | button.disabled = true; |
| | | |
| | | setTimeout(() => { |
| | | button.querySelector('span').textContent = originalText; |
| | | button.disabled = false; |
| | | }, 2000); |
| | | } |
| | | } |
| | | } else { |
| | | // Fallback: just reload the page if we can't determine the store |
| | | if (confirm('Refresh the page to see changes?')) { |
| | | window.location.reload(); |
| | | } |
| | | } |
| | | } |
| | | /**************************************************************************** |
| | | OPERATIONS |
| | | ****************************************************************************/ |
| | | addToQueue(operation) { |
| | | const item = { |
| | | id: `u${this.user}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, |
| | | endpoint: null, |
| | | method: 'POST', |
| | | headers: {}, |
| | | data: {}, |
| | | delay: false, |
| | | canMerge: true, |
| | | popup: 'Saving changes...', |
| | | title: 'Operation', |
| | | status: 'queued', |
| | | timestamp: Date.now(), |
| | | created_at: new Date().toISOString(), |
| | | retries: 0, |
| | | user: this.user, |
| | | ... operation |
| | | }; |
| | | |
| | | item.headers = { |
| | | ... this.headers, |
| | | ... item.headers |
| | | } |
| | | if (!item.endpoint || !item.data) return null; |
| | | |
| | | if (item.popup && this.ui.popup?.message) { // Add popup support |
| | | this.ui.popup.message.textContent = item.popup; |
| | | this.ui.popup.popup.hidden = false; |
| | | setTimeout(() => this.ui.popup.popup.hidden = true, 2000); |
| | | } |
| | | |
| | | if (!item.delay) { |
| | | this.queue.set(item.id, item); |
| | | this.processOperation(item).then(()=> {}); |
| | | this.store.clearCache(); |
| | | this.maybeStartPolling(); |
| | | this.toggleQueue(); |
| | | return item.id; |
| | | } |
| | | |
| | | const existingOps = Array.from(this.getAllQueue()).filter(op=> { |
| | | return 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.setQueue(existing); |
| | | |
| | | this.updateOperationStatus(existing.id, existing.status); |
| | | this.updateUI(); |
| | | this.trackActivity(); |
| | | return existing.id; |
| | | } |
| | | |
| | | this.store.clearCache(); |
| | | this.setQueue(item); |
| | | this.updateOperationStatus(item.id, item.status); |
| | | this.updateUI(); |
| | | this.trackActivity(); |
| | | return item.id; |
| | | } |
| | | |
| | | async opActions(statusOrId, action) { |
| | | //Extract ids based on status, if it exists |
| | | if (this.statuses.includes(statusOrId)) { |
| | | statusOrId = this.getQueueByStatus(statusOrId).map(op => op.id); |
| | | } else if (typeof statusOrId === 'string') { |
| | | //If it's still a string, wrap the id inside an array |
| | | statusOrId = [statusOrId]; |
| | | } |
| | | if (statusOrId.length ===0) return; |
| | | if (!['cancel', 'dismiss', 'retry'].includes(action)) return; |
| | | |
| | | const shouldRemove = ['cancel', 'dismiss'].includes(action); |
| | | if (shouldRemove) { |
| | | statusOrId.forEach(id => { |
| | | this.removeOperationUI(id) |
| | | }); |
| | | } |
| | | |
| | | try { |
| | | const response = await window.auth.fetch( |
| | | `${this.api}${this.endpoint}`, |
| | | { |
| | | method: 'POST', |
| | | headers: { |
| | | 'Content-Type': 'application/json', |
| | | ... this.headers |
| | | }, |
| | | body: JSON.stringify({ |
| | | action, |
| | | ids: Array.isArray(statusOrId) ? statusOrId : [statusOrId], |
| | | user: this.user |
| | | }) |
| | | } |
| | | ); |
| | | if (!response.ok) { |
| | | throw new Error(`${action} failed: ${response.status}`); |
| | | } |
| | | const result = await response.json(); |
| | | if (!result.success) { |
| | | throw new Error(result.message || `${action} operation failed`); |
| | | } |
| | | statusOrId.forEach(id => { |
| | | let item = this.getQueue(id); |
| | | if (item) { |
| | | this.notify(`${action}-operation`, item); |
| | | } |
| | | |
| | | if (shouldRemove) { |
| | | this.clearQueue(id); |
| | | } else { |
| | | let item = this.getQueue(id); |
| | | item.status = 'queued'; |
| | | this.setQueue(item); |
| | | this.updateOperationStatus(item.id, item.status); |
| | | } |
| | | }); |
| | | |
| | | if (action === 'retry') { |
| | | this.trackActivity(); |
| | | } |
| | | this.updateUI(); |
| | | return result; |
| | | } catch (error) { |
| | | await window.jvbError.log(error, { |
| | | component: 'Queue', |
| | | operation: 'performQueueAction', |
| | | action: action, |
| | | operationIds: statusOrId, |
| | | itemCount: statusOrId.length |
| | | }, () => this.opActions(statusOrId, action)); |
| | | return {success: false, error: error.message}; |
| | | } |
| | | } |
| | | |
| | | |
| | | async processQueue() { |
| | | if (this.isProcessing) return; |
| | | |
| | | const queue = this.getQueueByStatus('queued'); |
| | | |
| | | if (queue.length === 0) { |
| | | this.stopActivityTracking(); |
| | | return; |
| | | } |
| | | this.setProcessing(); |
| | | |
| | | for (const operation of queue) { |
| | | await this.processOperation(operation); |
| | | } |
| | | |
| | | this.setProcessing(false); |
| | | const remainingQueue = this.getQueueByStatus('queued'); |
| | | if (remainingQueue.length === 0) { |
| | | this.stopActivityTracking(); |
| | | } else { |
| | | // Still have queued items, restart activity tracking |
| | | this.trackActivity(); |
| | | } |
| | | |
| | | this.toggleQueue(this.maybeStartPolling()); |
| | | } |
| | | |
| | | async processOperation(operation) { |
| | | try { |
| | | //Add it to memory if it isn't already there |
| | | if (!this.queue.has(operation.id)) { |
| | | this.queue.set(operation.id, operation); |
| | | } |
| | | let skip = false; |
| | | if (operation.data?._isFormData && !operation.data instanceof FormData) { |
| | | skip = true; |
| | | operation.data = await this.store.objectToFormData(operation.data); |
| | | } |
| | | |
| | | this.updateOperationStatus(operation.id, 'uploading'); |
| | | |
| | | let requestBody; |
| | | let req; |
| | | if (operation.data instanceof FormData) { |
| | | operation.data.append('id', operation.id); |
| | | operation.data.append('user', window.auth.getUser()); |
| | | requestBody = operation.data; |
| | | req = operation.data; |
| | | } else { |
| | | req = { |
| | | ...operation.data, |
| | | id: operation.id, |
| | | user: window.auth.getUser() |
| | | }; |
| | | requestBody = JSON.stringify(req); |
| | | operation.headers['Content-Type'] = 'application/json'; |
| | | } |
| | | if (operation.endpoint === 'unknown' || requestBody === undefined || requestBody === null) return; |
| | | |
| | | |
| | | const response = await window.auth.fetch( |
| | | `${this.api}${operation.endpoint}`, |
| | | { |
| | | method: operation.method, |
| | | headers: operation.headers, |
| | | body: requestBody |
| | | } |
| | | ); |
| | | console.log('Sending request with data: ', req); |
| | | const result = await response.json(); |
| | | if (skip) { |
| | | operation.data = {}; |
| | | } |
| | | console.log('Result: ', result); |
| | | if (response.ok && result.success) { |
| | | this.notify('sent-to-server', req); |
| | | if (result.id && operation.id !== result.id) { |
| | | operation = await this.handleServerMerge(operation, result); |
| | | } else { |
| | | operation.status = result.status??'failed'; |
| | | operation.serverData = result; |
| | | this.updateOperationStatus(operation.id, operation.status); |
| | | } |
| | | this.a11y.announce(`${operation.title} sent to server for processing`); |
| | | } else { |
| | | throw new Error(result.message || `HTTP ${response.status}`); |
| | | } |
| | | this.setQueue(operation); |
| | | } catch (error) { |
| | | console.error('Operation failed: ', error); |
| | | operation.retries++; |
| | | operation.lastError = error.message; |
| | | if (operation.retries >= 3) { |
| | | operation.status = 'failed_permanent'; |
| | | } else { |
| | | operation.status = 'failed'; |
| | | } |
| | | this.updateOperationStatus(operation.id, operation.status); |
| | | this.setQueue(operation); |
| | | } |
| | | } |
| | | |
| | | async handleServerMerge(operation, result) { |
| | | const existingOp = this.getQueue(result.id); |
| | | if (existingOp) { |
| | | operation.status = result.status||'pending'; |
| | | operation.serverData = result; |
| | | return this.mergeOp(existingOp, operation); |
| | | } else { |
| | | this.clearQueue(operation.id); |
| | | this.setQueue(result); |
| | | return result; |
| | | } |
| | | } |
| | | |
| | | mergeOp(oldOp, newOp) { |
| | | oldOp.data = window.deepMerge(oldOp.data, newOp.data); |
| | | oldOp.status = newOp.status; |
| | | if (Object.hasOwn(newOp, 'serverData')) { |
| | | oldOp.serverData = newOp.serverData; |
| | | } |
| | | this.updateOperationStatus(oldOp.id, oldOp.status); |
| | | this.removeOperationUI(newOp.id); |
| | | this.clearQueue(newOp.id); |
| | | return oldOp; |
| | | } |
| | | sortByDate(ops) { |
| | | return ops.sort((a, b) => { |
| | | const aTime = a.updated_at ?? a.timestamp ?? 0; |
| | | const bTime = b.updated_at ?? b.timestamp ?? 0; |
| | | return aTime - bTime; |
| | | }); |
| | | } |
| | | |
| | | sortOperations(ops) { |
| | | const statusPriority = { |
| | | 'processing': 0, |
| | | 'uploading': 1, |
| | | 'pending': 2, |
| | | 'queued': 3, |
| | | 'localProcessing': 4, |
| | | 'failed': 5, |
| | | 'completed': 6, |
| | | 'failed_permanent': 7 |
| | | }; |
| | | |
| | | return ops.sort((a, b) => { |
| | | // First by status priority |
| | | const priorityDiff = (statusPriority[a.status] ?? 99) - (statusPriority[b.status] ?? 99); |
| | | if (priorityDiff !== 0) return priorityDiff; |
| | | |
| | | // Then by updated_at (most recent first) |
| | | const aTime = a.updated_at ?? a.timestamp ?? 0; |
| | | const bTime = b.updated_at ?? b.timestamp ?? 0; |
| | | return new Date(bTime) - new Date(aTime); |
| | | }); |
| | | } |
| | | |
| | | getAllQueue() { |
| | | let index = new Set(); |
| | | |
| | | let ops = [ |
| | | ... Array.from(this.queue.values()) |
| | | ]; |
| | | if (!this.loadFromStorage) { |
| | | this.loadFromStorage = true; |
| | | ops = [ |
| | | ... ops, |
| | | ...Array.from(this.store.data.values()) |
| | | ]; |
| | | |
| | | ops = ops.filter(el => { |
| | | const isAdded = index.has(el.id); |
| | | index.add(el.id); |
| | | return !isAdded; |
| | | }); |
| | | } |
| | | //Sort operations by operation updated_at |
| | | return this.sortOperations(ops); |
| | | } |
| | | |
| | | getQueueByStatus(status) { |
| | | if (typeof status === 'string') { |
| | | status = [status]; |
| | | } |
| | | return (include) |
| | | ? Array.from(this.store.data.values()).filter((item) => status.includes(item.status)) |
| | | : Array.from(this.store.data.values()).filter((item) => !status.includes(item.status)); |
| | | |
| | | let ops = this.getAllQueue(); |
| | | return ops.filter(op => status.includes(op.status)); |
| | | } |
| | | hasQueuedOperations() { |
| | | return this.getOperationsByStatus('queued').length > 0; |
| | | |
| | | |
| | | updateOperationStatus(itemID, status) { |
| | | let item = this.getQueue(itemID); |
| | | if (!item) return; |
| | | if (!this.statuses.includes(status)) { |
| | | console.log('Invalid status: ', status); |
| | | return; |
| | | } |
| | | |
| | | item.status = status; |
| | | this.notify('operation-status', item); |
| | | this.setQueue(item); |
| | | } |
| | | setQueue(item) { |
| | | this.store.save(item); |
| | | this.queue.set(item.id, item); |
| | | } |
| | | getQueue(itemID) { |
| | | return this.queue.has(itemID) ? this.queue.get(itemID) : this.store.get(itemID); |
| | | } |
| | | clearQueue(itemID) { |
| | | this.queue.delete(itemID); |
| | | this.store.delete(itemID); |
| | | } |
| | | /**************************************************************************** |
| | | POLLING |
| | | ****************************************************************************/ |
| | | maybeStartPolling() { |
| | | const incomplete = this.getQueueByStatus([...this.pendingStatuses, ...this.workingStatuses]); |
| | | if (incomplete.length > 0) { |
| | | this.startPolling(); |
| | | return true; |
| | | } |
| | | this.updatePanel('synced'); |
| | | return false; |
| | | } |
| | | startPolling() { |
| | | if (this.isPolling) return; |
| | | this.isPolling = true; |
| | | this.updatePanel('pending'); |
| | | this.runPollCycle(); |
| | | } |
| | | |
| | | async runPollCycle() { |
| | | if (!this.isPolling) return; |
| | | |
| | | try { |
| | | this.ui.refresh.button.classList.add('fetching'); |
| | | this.store.clearCache(); |
| | | await this.store.fetch(); |
| | | this.ui.refresh.button.classList.remove('fetching'); |
| | | if (!this.maybeStartPolling()) { |
| | | this.stopPolling(); |
| | | this.updatePanel('synced'); |
| | | return; |
| | | } |
| | | } catch (error) { |
| | | console.error('Polling error:', error); |
| | | } |
| | | |
| | | // Schedule next poll with countdown |
| | | this.startCountdown(5, () => this.runPollCycle()); |
| | | } |
| | | |
| | | startCountdown(count, onComplete) { |
| | | if (!this.ui.refresh.countdown) { |
| | | console.warn('Countdown element not found'); |
| | | return; |
| | | } |
| | | this.ui.refresh.countdown.classList.add('counting'); |
| | | this.ui.refresh.countdown.textContent = count; |
| | | |
| | | this.countdownTimer = setInterval(() => { |
| | | count--; |
| | | if (count > 0) { |
| | | this.ui.refresh.countdown.textContent = count; |
| | | } else { |
| | | this.stopCountdown(); |
| | | if (onComplete) onComplete(); |
| | | } |
| | | }, 1000); |
| | | } |
| | | |
| | | stopPolling() { |
| | | if (!this.isPolling) return; |
| | | this.isPolling = false; |
| | | if (this.pollTimer) { |
| | | clearInterval(this.pollTimer); |
| | | this.pollTimer = null; |
| | | } |
| | | this.stopCountdown(); |
| | | } |
| | | |
| | | stopCountdown() { |
| | | if (this.countdownTimer) { |
| | | clearInterval(this.countdownTimer); |
| | | this.countdownTimer = null; |
| | | } |
| | | this.ui.refresh.countdown.classList.remove('counting'); |
| | | this.ui.refresh.countdown.textContent = ''; |
| | | } |
| | | /**************************************************************************** |
| | | UI |
| | | ****************************************************************************/ |
| | | updateUI() { |
| | | if (!this.canUpdateUI) return; |
| | | |
| | | window.debouncer.schedule( |
| | | 'queue-ui', |
| | | this.handleUpdateUI.bind(this) |
| | | ) |
| | | } |
| | | handleUpdateUI() { |
| | | const operations = this.getAllQueue(); |
| | | this.ui.actions.retry.disabled = operations.filter(op => op.status === 'failed').length === 0; |
| | | this.ui.actions.clear.disabled = operations.filter(op => op.status === 'completed').length ===0; |
| | | |
| | | let activeCount = operations.filter(op => |
| | | [...this.pendingStatuses, ...this.workingStatuses].includes(op.status) |
| | | ); |
| | | activeCount = activeCount.length; |
| | | this.ui.toggle.count.hidden = activeCount === 0; |
| | | this.ui.toggle.count.textContent = activeCount; |
| | | |
| | | for (let status of this.statuses) { |
| | | if (status === 'failed_permanent') continue; |
| | | let total = operations.filter(op => op.status === status).length; |
| | | this.ui.filters[status].label.hidden = total === 0; |
| | | this.ui.filters[status].input.dataset.count = `${total}`; |
| | | if (total > 0) { |
| | | this.ui.filters[status].count.textContent = total; |
| | | } else { |
| | | this.ui.filters[status].count.textContent = ''; |
| | | } |
| | | } |
| | | |
| | | this.renderOperations(); |
| | | } |
| | | |
| | | renderOperations() { |
| | | if (!this.ui.items.container) return; |
| | | |
| | | const status = this.store.filters?.status ?? 'all'; |
| | | const operations = (status === 'all') ? this.getAllQueue() : this.getQueueByStatus(status); |
| | | const sortedOps = this.sortOperations(operations); |
| | | |
| | | if (sortedOps.length === 0) { |
| | | window.removeChildren(this.ui.items.container); |
| | | const empty = window.jvbTemplates.create('emptyQueue'); |
| | | this.ui.items.container.append(empty); |
| | | this.a11y.announce('No items in queue'); |
| | | return; |
| | | } else { |
| | | this.ui.items.container.querySelector('.empty-group')?.remove(); |
| | | } |
| | | |
| | | // Track which items should exist |
| | | const expectedIds = new Set(sortedOps.map(op => op.id)); |
| | | |
| | | // Remove items that shouldn't exist |
| | | this.items.forEach((item, id) => { |
| | | if (!expectedIds.has(id)) { |
| | | item.element?.remove(); |
| | | this.items.delete(id); |
| | | } |
| | | }); |
| | | |
| | | // Update/add items in order |
| | | sortedOps.forEach((op, index) => { |
| | | let item = this.items.get(op.id); |
| | | if (!item) { |
| | | item = this.createOperationElement(op); |
| | | } |
| | | if (item?.element) { |
| | | this.updateOperationUI(op.id); |
| | | // Reorder by re-appending (moves to end in correct order) |
| | | this.ui.items.container.append(item.element); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | createOperationElement(op) { |
| | | const el = window.jvbTemplates.create('queueItem', op); |
| | | const item = { |
| | | element: el, |
| | | ui: window.uiFromSelectors(this.selectors.item, el) |
| | | }; |
| | | |
| | | this.items.set(op.id, item); |
| | | return item; |
| | | } |
| | | |
| | | updateOperationUI(opId) { |
| | | let item = (this.items.has(opId)) ? this.items.get(opId) : this.createOperationElement(opId); |
| | | if (!item) return; |
| | | let op = this.getQueue(opId); |
| | | |
| | | let element = item.element; |
| | | |
| | | element.classList.remove(... this.statuses); |
| | | element.classList.add(op.status); |
| | | |
| | | let progress = this.getProgress(op); |
| | | if (item.ui.type && item.ui.type.textContent !== op.title) item.ui.type.textContent = op.title; |
| | | if (item.ui.status) { |
| | | item.ui.status.title = this.statusLabel(op.status); |
| | | } |
| | | if (item.ui.icon) { |
| | | item.ui.icon.className = `icon icon-${this.icons[op.status]}`; |
| | | } |
| | | if (item.ui.details) item.ui.details.textContent = this.itemMessage(op); |
| | | if (item.ui.startedAt) { |
| | | item.ui.startedAt.setAttribute('datetime', op.created_at); |
| | | item.ui.startedAt.textContent = window.formatTimeAgo(op.created_at); |
| | | } |
| | | let text = op.status === 'completed' ? 'Completed: ' : 'Last updated: '; |
| | | const shouldShowCompleted = op.status === 'completed' && (op.completed_at || op.updated_at); |
| | | item.ui.completed.wrap.hidden = !shouldShowCompleted; |
| | | if (shouldShowCompleted) { |
| | | const completedTime = op.completed_at ?? op.updated_at; |
| | | item.ui.completed.label.textContent = 'Completed: '; |
| | | item.ui.completed.time.setAttribute('datetime', completedTime); |
| | | item.ui.completed.time.textContent = window.formatTimeAgo(completedTime); |
| | | } |
| | | |
| | | window.showProgress(item.ui.progress, progress, 100, this.statusLabel(op.status)); |
| | | if (item.ui.actions.cancel) item.ui.actions.cancel.hidden = this.completedStatuses.includes(op.status); |
| | | if (item.ui.actions['retry']) { |
| | | if (op.retries >= 3) item.ui.actions['retry'].disabled = true; |
| | | item.ui.actions['retry'].hidden = op.status !=='failed'; |
| | | } |
| | | if (item.ui.actions.dismiss) item.ui.actions.dismiss.hidden = this.pendingStatuses.includes(op.status); |
| | | if (item.ui.actions.refresh) { |
| | | item.ui.actions.refresh.hidden = op.status !== 'completed'; |
| | | } |
| | | } |
| | | getProgress(op) { |
| | | // Check server-provided percentage first |
| | | if (op.progress_percentage !== undefined) { |
| | | return op.progress_percentage; |
| | | } |
| | | // Legacy: check old 'progress' field |
| | | if (op.progress !== undefined) { |
| | | return op.progress; |
| | | } |
| | | // Fallback to status-based calculation |
| | | if (!this.statuses.includes(op.status)) return 0; |
| | | const statusProgress = { |
| | | 'queued': 10, |
| | | 'uploading': 25, |
| | | 'pending': 40, |
| | | 'processing': 70, |
| | | 'completed': 100, |
| | | 'failed': 0, |
| | | 'failed_permanent': 0 |
| | | }; |
| | | return statusProgress[op.status] ?? 0; |
| | | } |
| | | removeOperationUI(opId) { |
| | | let op = this.items.get(opId); |
| | | if (!op) return; |
| | | window.fade(op.element, false); |
| | | } |
| | | |
| | | updatePanel(status = 'syncing') { |
| | | if (!this.ui.panel || !this.panelStatuses.includes(status)) return; |
| | | this.ui.panel.classList.remove(...this.panelStatuses); |
| | | this.ui.panel.classList.add(status); |
| | | } |
| | | /**************************************************************************** |
| | | UTILITY |
| | | ****************************************************************************/ |
| | | statusLabel(status) { |
| | | if (!this.statuses.includes(status)) return''; |
| | | const labels = { |
| | | 'queued': 'Queued', |
| | | 'localProcessing': 'Processing locally', |
| | | 'uploading': 'Uploading', |
| | | 'pending': 'Waiting on server', |
| | | 'processing': 'Processing', |
| | | 'completed': 'Completed', |
| | | 'failed': 'Failed', |
| | | 'failed_permanent': 'Failed permanently', |
| | | 'merged': 'Merged' |
| | | }; |
| | | return labels[status]; |
| | | } |
| | | itemMessage(item) { |
| | | if (Object.hasOwn(item, 'message') && item.message !== '') return item.message; |
| | | if (Object.hasOwn(item, 'error_message') && 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': |
| | | // Show progress count if available |
| | | if (item.count && item.progress_count !== undefined) { |
| | | const processed = item.progress_count; |
| | | const total = item.count; |
| | | const percentage = Math.round((processed / total) * 100); |
| | | return `Processing ${processed}/${total} items (${percentage}%)`; |
| | | } |
| | | // Fallback to percentage only |
| | | if (item.progress_percentage !== undefined) { |
| | | return `${item.progress_percentage}% complete`; |
| | | } |
| | | return 'Processing...'; |
| | | case 'completed': |
| | | return 'Successfully completed. Refresh to see changes.'; |
| | | case 'merged': |
| | | return item.merged_into |
| | | ? `Merged with another operation (${item.merged_into.substring(0, 8)}...)` |
| | | : 'Merged with another operation'; |
| | | case 'failed': |
| | | return `Failed: ${item.lastError || 'Unknown error'} (Retry ${item.retries}/${2})`; |
| | | case 'failed_permanent': |
| | | return `Failed: ${item.lastError || 'Unknown error'}`; |
| | | default: |
| | | return ''; |
| | | } |
| | | } |
| | | toggleQueue(on = true) { |
| | | if (!this.ui.panel) return; |
| | | this.ui.panel.hidden = !on; |
| | | this.ui.toggle.button.hidden = !on; |
| | | } |
| | | setProcessing(on = true) { |
| | | this.isProcessing = on; |
| | | this.ui.toggle.button.classList.toggle('saving', on); |
| | | } |
| | | |
| | | /** |
| | | * Map server operation format to frontend format |
| | | * Server uses: type, data (requestData), status (from state/outcome) |
| | | * Frontend uses: endpoint, data, status, headers, method, etc. |
| | | */ |
| | | mapServerOperation(serverOp) { |
| | | const localOp = this.queue.get(serverOp.id); |
| | | |
| | | // If we have local operation data, preserve it |
| | | if (localOp && localOp.endpoint) { |
| | | const mappedOp = { |
| | | ...localOp, |
| | | ...serverOp, |
| | | endpoint: localOp.endpoint, |
| | | method: localOp.method, |
| | | headers: localOp.headers, |
| | | progress_percentage: serverOp.progress_percentage, |
| | | progress_count: serverOp.progress_count, |
| | | count: serverOp.count |
| | | }; |
| | | |
| | | if (serverOp.merged_into) { |
| | | this.handleMergedOperation(mappedOp); |
| | | } |
| | | } |
| | | |
| | | |
| | | // Minimal mapping for server-only operations |
| | | // Extract endpoint from type if possible, otherwise use type |
| | | const endpoint = serverOp.type ? serverOp.type.replace('_update', '').replace('_', '/') : 'unknown'; |
| | | |
| | | const mappedOp = { |
| | | ...serverOp, |
| | | endpoint: endpoint, |
| | | method: 'POST', |
| | | headers: { ...this.headers }, |
| | | }; |
| | | if (serverOp.merged_into) { |
| | | this.handleMergedOperation(mappedOp); |
| | | } |
| | | return mappedOp |
| | | } |
| | | |
| | | /** |
| | | * Handle merged operations |
| | | * The target operation already has merged data from server, |
| | | * so we just need to clean up the merged operation locally |
| | | */ |
| | | handleMergedOperation(operation) { |
| | | if (!operation.merged_into) return; |
| | | |
| | | console.log(`[Queue] Operation ${operation.id} merged into ${operation.merged_into}`); |
| | | |
| | | // Auto-dismiss merged operation after brief display |
| | | // The target operation already has all the merged data from server |
| | | setTimeout(() => { |
| | | this.clearQueue(operation.id); |
| | | this.removeOperationFromUI(operation.id); |
| | | }, 3000); |
| | | } |
| | | |
| | | /**************************************************************************** |
| | | SUBSCRIPTION |
| | | ****************************************************************************/ |
| | | subscribe(callback) { |
| | | if (!this.subscribers) { |
| | | return; |
| | |
| | | notify(event, data) { |
| | | this.subscribers.forEach(cb => cb(event, data)); |
| | | } |
| | | |
| | | /************************************************************************** |
| | | /**************************************************************************** |
| | | CLEANUP |
| | | **************************************************************************/ |
| | | ****************************************************************************/ |
| | | destroy() { |
| | | this.stopPolling(); |
| | | if (this.isPolling) { |
| | | this.stopPolling(); |
| | | } |
| | | this.stopActivityTracking(); |
| | | |
| | | if (this.clickHandler) { |
| | | document.removeEventListener('click', this.clickHandler); |
| | | } |
| | | |
| | | if (this.keyHandler) { |
| | | document.removeEventListener('keydown', this.keyHandler); |
| | | } |
| | | |
| | | document.removeEventListener('click', this.clickHandler); |
| | | this.subscribers.clear(); |
| | | } |
| | | } |
| | | |
| | | document.addEventListener('DOMContentLoaded', async function() { |
| | | window.auth.subscribe((event) => { |
| | | if (event === 'auth-loaded') { |
| | |
| | | } |
| | | }); |
| | | }); |
| | | |