| | |
| | | class QueueManager { |
| | | constructor(config = {}) { |
| | | this.canUpdateUI = true; |
| | | console.log('jvbSettings', jvbSettings); |
| | | this.config = { |
| | | apiBase: jvbSettings.api, |
| | | maxRetries: 3, |
| | |
| | | ...config |
| | | }; |
| | | this.user = jvbSettings.currentUser; |
| | | console.log(this.user); |
| | | |
| | | |
| | | this.headers = { |
| | |
| | | this.errors = window.jvbError; |
| | | |
| | | // Initialize DataStore for queue persistence |
| | | this.store = new window.jvbStore({ |
| | | name: 'queue', |
| | | storeName: 'operations', |
| | | this.store = window.jvbStore.register('queue', { |
| | | keyPath: 'id', |
| | | endpoint: this.config.endpoint, |
| | | TTL: Infinity, |
| | |
| | | {name: 'type', keyPath: 'type'}, |
| | | ], |
| | | showLoading: false, |
| | | getBlobs: async (ids) => { |
| | | if (window.jvbUploadBlobs) { |
| | | if (!Array.isArray(ids) && typeof ids === 'string') { |
| | | ids = [ids]; |
| | | } |
| | | // Get individual blobs (not all items) |
| | | const blobs = await Promise.all( |
| | | ids.map(id => window.jvbUploadBlobs.getBlob(id)) |
| | | ); |
| | | return blobs.filter(Boolean); // Remove nulls |
| | | } |
| | | return null; |
| | | } |
| | | delayFetch: false, // Queue should fetch immediately |
| | | }); |
| | | |
| | | this.classes = [ |
| | |
| | | // Initialize |
| | | this.initUI(); |
| | | this.initListeners(); |
| | | console.log(this.ui); |
| | | if (this.ui.panel) { |
| | | this.popup = new window.jvbPopup({ |
| | | popup: this.ui.panel, |
| | |
| | | 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.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); |
| | | } |
| | | } |
| | | if (this.hasQueuedOperations()) { |
| | | this.startPolling(); |
| | | } |
| | | break; |
| | | default: |
| | | this.updateUI(); |
| | | break; |
| | |
| | | }); |
| | | this.notify('queue-initialized', {operations: incomplete}); |
| | | } |
| | | |
| | | /** |
| | | * Handle operation status changes and notify subscribers |
| | | */ |
| | | handleOperationStatusChange(operation, oldStatus) { |
| | | if (!operation || oldStatus === operation.status) return; |
| | | |
| | | // Notify based on new status |
| | | switch(operation.status) { |
| | | case 'completed': |
| | | 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} operatio n.endpoint The endpoint, excluding the apiBase |
| | | * @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 |
| | |
| | | } |
| | | |
| | | console.log('Added to Queue: ', item); |
| | | this.store.clearCache(); |
| | | |
| | | //Add new operation to DataStore |
| | | this.setQueue(item); |
| | |
| | | // console.log(pair[0], pair[1]); |
| | | // } |
| | | |
| | | console.log('Sending to server:'); |
| | | for (var [key, value] of requestBody.entries()) { |
| | | console.log(key, value); |
| | | } |
| | | } else { |
| | | requestBody = JSON.stringify({ |
| | | ...operation.data, |
| | |
| | | |
| | | this.pollTimer = setInterval(async () => { |
| | | try { |
| | | this.store.clearCache(); |
| | | await this.store.fetch(); // Fetches from server, updates store.data |
| | | |
| | | const incomplete = this.getOperationsByStatus(['completed', 'failed_permanent'], false); |
| | |
| | | 'Content-Type': 'application/json', |
| | | ...this.headers |
| | | }, |
| | | body: JSON.stringify({ids,action}) |
| | | body: JSON.stringify({ids,action, user: jvbSettings.currentUser}) |
| | | } |
| | | ); |
| | | |
| | |
| | | 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.getOperationsByStatus('completed'); |
| | |
| | | *********************************************/ |
| | | initUI() { |
| | | this.icons = { |
| | | queued: 'refresh', localProcessing: 'refresh', uploading: 'syncing', |
| | | pending: 'cloud', processing: 'syncing', completed: 'synced', |
| | | failed: 'error', failed_permanent: 'error' |
| | | queued: 'arrows-clockwise', localProcessing: 'arrows-clockwise', uploading: 'syncing', |
| | | pending: 'cloud', processing: 'syncing', completed: 'cloud-check', |
| | | failed: 'cloud-warning', failed_permanent: 'cloud-warning' |
| | | }; |
| | | |
| | | this.selectors = { |
| | |
| | | if (!this.canUpdateUI) { |
| | | return; |
| | | } |
| | | const stats = this.getQueueStats(); |
| | | |
| | | // 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 total = stats.total - stats.completed; |
| | | this.ui.count.textContent = total > 0 ? total : ''; |
| | | this.ui.count.style.display = total > 0 ? '' : 'none'; |
| | | const activeCount = operations.length - stats.completed; |
| | | this.ui.count.textContent = activeCount > 0 ? activeCount : ''; |
| | | this.ui.count.style.display = activeCount > 0 ? '' : 'none'; |
| | | } |
| | | |
| | | // Update indicator |
| | |
| | | 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 |
| | | // 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' ? stats.total : stats[status] || 0; |
| | | const count = status === 'all' |
| | | ? operations.length |
| | | : stats[status] || 0; |
| | | const countEl = button.querySelector('.count'); |
| | | if (countEl) { |
| | | countEl.textContent = count > 0 ? count : ''; |
| | |
| | | button.setAttribute('data-count', count); |
| | | }); |
| | | |
| | | // Update operation list |
| | | // Render current operations |
| | | this.renderOperations(); |
| | | } |
| | | |
| | |
| | | return statusProgress[item.status] || 0; |
| | | } |
| | | |
| | | getQueueStats() { |
| | | const stats = {}; |
| | | this.statuses.forEach(status => { |
| | | stats[status] = 0; |
| | | }); |
| | | |
| | | Array.from(this.store.data.values()) // Change items to data |
| | | .forEach(op => { |
| | | if (stats.hasOwnProperty(op.status)) { |
| | | stats[op.status]++; |
| | | } |
| | | }); |
| | | |
| | | stats.total = Array.from(this.store.data.values()).length; // Change items to data |
| | | |
| | | return stats; |
| | | } |
| | | |
| | | renderOperations() { |
| | | if (!this.ui.itemsContainer) return; |
| | | |
| | | const activeFilter = this.getActiveFilter(); |
| | | const operations = this.getFilteredOperations(activeFilter); |
| | | const operations = this.store.getFiltered(); |
| | | |
| | | // Clear container |
| | | window.removeChildren(this.ui.itemsContainer); |
| | | |
| | | // Render each operation |
| | | // 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 { |
| | | let empty = this.ui.itemsContainer.querySelector('.emptyQueue'); |
| | | if (empty) { |
| | | empty.remove(); |
| | | } |
| | | operations.forEach(op => { |
| | | const element = this.createOperationUI(op); |
| | | this.ui.itemsContainer.appendChild(element); |
| | | this.ui.itemsContainer.append(element); |
| | | }); |
| | | } |
| | | } |
| | |
| | | FILTERS |
| | | **************************************************/ |
| | | setFilter(filter) { |
| | | // Update active button |
| | | 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.data.values()); // Change items to data |
| | | |
| | | if (filter === 'all') { |
| | | return operations; |
| | | this.store.clearFilters(); |
| | | } else { |
| | | this.store.setFilter('status', filter); |
| | | } |
| | | |
| | | return operations.filter(op => op.status === filter); |
| | | } |
| | | |
| | | /************************************************************************** |
| | |
| | | ? Array.from(this.store.data.values()).filter((item) => status.includes(item.status)) |
| | | : Array.from(this.store.data.values()).filter((item) => !status.includes(item.status)); |
| | | } |
| | | async hasQueuedOperations() { |
| | | const queued = await this.store.query('status', 'queued'); |
| | | return queued.length > 0; |
| | | hasQueuedOperations() { |
| | | return this.getOperationsByStatus('queued').length > 0; |
| | | } |
| | | subscribe(callback) { |
| | | this.subscribers.add(callback); |