/** * Refactor statuses: * 1) queued - local queue * 1) localProcessing - any local processing that can be done in the browser * 2) uploading - sending to server * 3) pending; x in line - sent to server, waiting * 4) processing - server actively working on it * 5) completed - with optional refresh */ class QueueManagerBackup { constructor() { // localStorage.clear(); //Core components this.a11y = window.jvbA11y; this.errors = window.jvbError; this.cache = window.jvbCache; //Config this.STORAGE_KEY = 'jvb_queue'; this.API = `${jvbSettings.api}queue`; this.maxRetries = 3; //Initialize State this.queue = new Map(); this.hasChanges = false; this.processing = false; this.lastPollTime = null; //Status Tracking this.statuses = [ 'queued', // Local, waiting to be sent 'localProcessing', // Being processed client-side 'uploading', // Being sent to server 'pending', // On server, waiting to be processed 'processing', // Server is actively processing 'completed', // Successfully completed 'failed', // Failed, can be retried 'failed_permanent' // Failed too many times ]; //Initialize UI this.loadTemplates(); this.initUI(); // Operation type handlers this.processors = { 'handle_vote': this.processVote.bind(this), 'invite_artist': this.processArtistInvite.bind(this), 'new_news': this.processNewNews.bind(this), 'new_response': this.processNewResponse.bind(this), 'bio_update': this.processBioUpdate.bind(this), 'favourite_toggle': this.processFavourite.bind(this), 'favourite_notes': this.processFavouriteNotes.bind(this), 'favourite_list_create': this.processFavouriteListCreate.bind(this), 'favourite_list_add': this.processFavouriteListAddItems.bind(this), 'favourite_list_remove': this.processFavouriteListRemoveItems.bind(this), 'favourite_list_delete': this.processFavouriteListDelete.bind(this), 'favourite_list_share': this.processFavouriteListShare.bind(this), 'favourite_list_unshare': this.processFavouriteListUnshare.bind(this), 'user_settings': this.processSettingsUpdate.bind(this), 'image_upload': this.processFileUpload.bind(this), 'content_create': this.processContentCreation.bind(this), 'batch_creation': this.processBatchCreation.bind(this), 'content_update': this.processContentUpdate.bind(this), }; // Cache types that need to be cleared after operations this.cacheTypesToClear = { 'handle_vote': ['karma'], 'new_news': ['news'], 'new_response': ['responses', 'news'], 'bio_update': ['artist'], 'favourite_toggle': ['favourites', 'favouritesManager'], 'favourite_notes': ['favouritesManager'], 'favourite_list_create': ['list-item', 'favourite-lists'], 'favourite_list_add': ['list-item', 'favourite-lists'], 'favourite_list_remove': ['list-item', 'favourite-lists'], 'favourite_list_delete': ['list-item', 'favourite-lists'], 'favourite_list_share': ['list-item', 'favourite-lists'], 'favourite_list_unshare': ['list-item', 'favourite-lists'], }; // Human-readable operation names this.operationNames = { 'handle_vote': 'Adding your voice', 'new_news': 'New News', 'invite_artist': 'Sending Invites...', 'new_response': 'Sending in your response', 'image_upload': 'Image Upload', 'content_update': 'Content Update', 'content_create': 'New Content', 'user_settings': 'Settings Update', 'favourite_toggle': 'Favourite Update', 'bio_update': 'Profile Update', 'batch_creation': 'Batch Content Creation', 'favourite_notes': 'Favourite Notes', 'favourite_list_create': 'List Creation', 'favourite_list_add': 'List Update', 'favourite_list_remove': 'List Update', 'favourite_list_delete': 'List Deletion', 'favourite_list_share': 'List Sharing', 'favourite_list_unshare': 'List Share Removal' }; // Status messages for UI this.statusMessages = { 'queued': 'Waiting to send to server...', 'localProcessing': 'Processing locally...', 'uploading': 'Sending to server...', 'pending': 'In line for processing...', 'processing': 'Server is working on it...', 'completed': 'All done!', 'failed': 'There was an error', 'failed_permanent': 'Failed permanently' }; // Skip if not logged in if (!jvbSettings.currentUser) { return; } // Load queue from localStorage this.loadQueue(); // Set up polling configuration this.pollingConfig = { enabled: true, interval: 10000, minInterval: 5000, maxInterval: 60000, countdown: true, backoffFactor: 1.5, // Increase interval by this factor when no changes consecutiveNoChanges: 0, // Track consecutive polls with no changes maxNoChangesCount: 8, // Cap on increasing interval lastETag: null, lastUserActivity: Date.now(), inactiveThreshold: 5 * 60 * 1000 }; // Track user activity this.setupActivityTracking(); // Set up event listeners this.initEventListeners(); // Initial fetch of server operations this.fetchOperations(true).then(() => { this.startPolling(); }); // Process queue periodically if there are changes setInterval(() => { if (this.hasChanges) { this.processQueue(); } }, 3000); } /** * Track user activity to adjust polling */ setupActivityTracking() { const events = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart']; events.forEach(event => { document.addEventListener(event, () => { this.pollingConfig.lastUserActivity = Date.now(); }, { passive: true }); }); } /** * Initialize UI elements */ initUI() { if (!jvbSettings.currentUser) return; // Main panel elements this.panel = document.querySelector('#queue-status-panel'); if (!this.panel) return; this.panelList = this.panel.querySelector('.queue-list'); this.togglePanel = this.panel.querySelector('.queue-status-toggle'); this.countdown = this.panel.querySelector('.refresh-countdown'); this.refreshButton = this.panel.querySelector('.manual-refresh'); this.popup = this.panel.querySelector('.popup'); // Filter buttons this.filterButtonsContainer = this.panel.querySelector('.queue-filters'); this.filterButtons = this.panel.querySelectorAll('.queue-filters .filter'); // Status-specific filter buttons this.statusButtons = {}; this.statuses.forEach(status => { this.statusButtons[status] = this.panel.querySelector(`.filter[data-filter="${status}"]`); }); // Action buttons this.retryButton = this.panel.querySelector('.retry-failed'); this.clearButton = this.panel.querySelector('.dismiss-completed'); // Initialize status panel this.initStatusPanel(); } /** * Load HTML templates */ loadTemplates() { this.templates = new Map(); // Default templates this.templates.set('replyButton', this.createReplyButtonTemplate()); this.templates.set('commentsButton', this.createCommentsButtonTemplate()); this.templates.set('voteButton', this.createVoteButtonsTemplate()); // Load templates from DOM document.querySelectorAll('template').forEach(template => { const classes = Array.from(template.classList); if (classes.length > 0) { const item = template.content.cloneNode(true).firstElementChild; classes.forEach(key => { if (!this.templates.has(key)) { this.templates.set(key, item); } }); } }); } /** * Create reply button template */ createReplyButtonTemplate() { const button = document.createElement('button'); button.type = 'button'; button.className = 'reply'; button.dataset.action = 'make-response'; button.innerHTML = jvbSettings.icons.reply + 'Reply'; return button; } /** * Create comments button template */ createCommentsButtonTemplate() { const button = document.createElement('a'); button.className = 'button'; button.innerHTML = jvbSettings.icons.response + '{ }'; return button; } /** * Create vote buttons template */ createVoteButtonsTemplate() { const vote = document.createElement('div'); vote.className = 'vote'; vote.innerHTML = ` `; return vote; } /** * Initialize status panel */ initStatusPanel() { if (!this.panel) return; // Toggle panel visibility if (this.togglePanel) { this.togglePanel.addEventListener('click', () => { this.panel.classList.toggle('expanded'); this.togglePanel.title = this.panel.classList.contains('expanded') ? 'Hide Queue' : 'Show Queue'; const message = this.panel.classList.contains('expanded') ? 'Opened Queue Panel' : 'Closed Queue Panel'; this.a11y.announce(message); this.maybeAddEmptyState(); }); } // Close panel when clicking outside document.addEventListener('click', (e) => { if (this.panel.classList.contains('expanded') && !this.panel.contains(e.target) && e.target !== this.togglePanel) { this.panel.classList.remove('expanded'); } }); // Handle Escape key document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && this.panel.classList.contains('expanded')) { this.panel.classList.remove('expanded'); } }); // Retry failed operations if (this.retryButton) { this.retryButton.addEventListener('click', () => this.retryFailedOperations()); } // Clear completed operations if (this.clearButton) { this.clearButton.addEventListener('click', () => this.clearCompletedOperations()); } // Filter buttons if (this.filterButtons) { this.filterButtons.forEach(button => { button.addEventListener('click', () => { this.filterButtons.forEach(btn => btn.classList.remove('active')); button.classList.add('active'); const filter = button.dataset.filter || 'all'; this.fetchOperations(true, {status: filter}); }); }); } // Refresh button if (this.refreshButton) { this.refreshButton.addEventListener('click', () => { this.refreshButton.classList.add('refreshing'); this.fetchOperations(true, {force: true}).finally(() => { this.refreshButton.classList.remove('refreshing'); }); }); } // Item-specific actions this.panelList.addEventListener('click', (e) => { // Retry operation if (e.target.classList.contains('retry-operation')) { const operationItem = e.target.closest('.queue-item'); if (operationItem) { this.retryOperation(operationItem.dataset.id); } } // Dismiss operation else if (e.target.classList.contains('dismiss-operation')) { const operationItem = e.target.closest('.queue-item'); if (operationItem) { this.dismissOperations([operationItem.dataset.id]); } } }); // Initialize empty state this.maybeAddEmptyState(); } /** * Set up event listeners */ initEventListeners() { // Network status change window.addEventListener('online', () => { this.updateNetworkIndicator(); if (this.hasChanges) { this.processQueue(); } }); window.addEventListener('offline', () => { this.updateNetworkIndicator(); this.updateStatusPanel('offline'); }); // Before unload warning for unsaved changes window.addEventListener('beforeunload', (e) => { const hasPendingLocalChanges = [...this.queue.values()].some(item => ['queued', 'localProcessing', 'uploading'].includes(item.status) ); if (hasPendingLocalChanges) { e.preventDefault(); return e.returnValue = 'You have unsaved changes that haven\'t been sent to the server. Are you sure you want to leave?'; } }); } /** * Maybe add empty state message */ maybeAddEmptyState() { if (this.panelList.children.length === 0) { this.panelList.innerHTML = '
Everything up to date
'; this.a11y.announce('Nothing queued.'); } else if (this.panelList.children.length > 1 && this.panelList.querySelector('.no-operations')) { this.panelList.querySelector('.no-operations').remove(); } } /** * Add an operation to the queue * * @param {Object} operation Operation data * @returns {string} Operation ID */ async addToQueue(operation) { // Try to merge with similar operations const mergedOperation = this.mergeOperations(operation); operation = mergedOperation || operation; // Generate unique ID const id = this.generateId(); // Create queue item const item = { id: id, type: operation.type, data: operation.data, user: jvbSettings.currentUser, started_at: Date.now(), status: 'queued', retries: 0, dependencies: operation.dependencies || [], onUpdate: operation.onUpdate??false }; // Add to queue this.queue.set(id, item); this.saveQueue(); // Update UI this.updateStatusPanel('pending'); this.addPopup(this.getPopupMessage(item)); this.hasChanges = true; return item.id; } /** * Generate a unique operation ID * * @returns {string} Unique ID */ generateId() { // Create a timestamp-based prefix const timestamp = new Date().getTime().toString(36); // Add random component const randomPart = Math.random().toString(36).substring(2, 8); // Add counter to ensure uniqueness within same millisecond this.idCounter = (this.idCounter || 0) + 1; const counter = this.idCounter.toString(36); return `u${jvbSettings.currentUser}-${timestamp}-${randomPart}-${counter}`; } /** * Load queue from localStorage */ loadQueue() { try { const storedData = localStorage.getItem(this.STORAGE_KEY); if (!storedData) return; const queueData = JSON.parse(storedData); if (!queueData || !Array.isArray(queueData)) return; this.queue = new Map(); queueData.forEach(item => { if (item && item.id) { this.queue.set(item.id, item); } }); // Update UI for loaded items [...this.queue.values()].forEach(item => { this.updatePanelItem(item); }); // Set hasChanges if there are queued items this.hasChanges = [...this.queue.values()].some(item => ['queued', 'localProcessing', 'uploading'].includes(item.status) ); } catch (error) { console.error('Error loading queue:', error); this.queue = new Map(); } } /** * Save queue to localStorage */ saveQueue() { try { // Filter items for storage const queueToSave = [...this.queue.values()].filter(item => { // Keep local items if (['queued', 'localProcessing', 'uploading'].includes(item.status)) { return true; } // Keep completed items for a short while if (item.status === 'completed' && item.completed_at) { const hourAgo = Date.now() - (60 * 60 * 1000); return new Date(item.completed_at).getTime() > hourAgo; } // Keep failed items that might be retried return (item.status === 'failed' || item.status === 'failed_permanent') && item.retries < this.maxRetries; }); // Serialize and store localStorage.setItem(this.STORAGE_KEY, JSON.stringify(queueToSave)); // Also save stats for quick access localStorage.setItem( this.STORAGE_KEY + '_stats', JSON.stringify(this.getQueueStats()) ); } catch (error) { console.error('Error saving queue:', error); // If we hit storage limits, clean up this.emergencyQueueCleanup(); } } /** * Emergency cleanup when storage is full */ emergencyQueueCleanup() { console.warn('Emergency queue cleanup triggered'); // Keep only essential pending items const tempQueue = this.queue; this.queue = new Map(); for (const [key, item] of tempQueue.entries()) { if (item && item.status === 'pending') { this.queue.set(key, item); } } // Save the reduced queue try { localStorage.setItem(this.STORAGE_KEY, JSON.stringify([...this.queue.values()])); localStorage.removeItem(this.STORAGE_KEY + '_stats'); } catch (error) { console.error('Failed even after cleanup:', error); // Last resort - clear everything localStorage.removeItem(this.STORAGE_KEY); localStorage.removeItem(this.STORAGE_KEY + '_stats'); } } /** * Fetch operations from the server * * @param {boolean} initial Whether this is the initial fetch * @param {Object} options Filter options * @returns {Promise} Server response */ async fetchOperations(initial = false, options = {}) { this.processing = true; // Start loading indicator if (this.refreshButton) { this.refreshButton.classList.add('refreshing'); } try { // Build query parameters const params = new URLSearchParams(); if (options.status) { params.append('status', options.status); } if (options.ids) { params.append('ids', Array.isArray(options.ids) ? options.ids.join(',') : options.ids); } if (options.limit) { params.append('limit', options.limit); } const url = `${this.API}?${params.toString()}`; // Set up fetch options const fetchOptions = { method: 'GET', headers: { 'X-WP-Nonce': jvbSettings.nonce } }; // Add ETag support if (!initial && !options.force && this.pollingConfig.lastETag) { fetchOptions.headers['If-None-Match'] = this.pollingConfig.lastETag; } // Add If-Modified-Since as backup if (!initial && !options.force && this.lastPollTime) { fetchOptions.headers['If-Modified-Since'] = new Date(this.lastPollTime).toUTCString(); } // Execute fetch const response = await fetch(url, fetchOptions); // If 304 Not Modified, nothing has changed if (response.status === 304) { this.pollingConfig.consecutiveNoChanges++; return { operations: [], cached: true }; } // Store ETag for next request const etag = response.headers.get('ETag'); if (etag) { this.pollingConfig.lastETag = etag; } // Reset consecutive no changes counter this.pollingConfig.consecutiveNoChanges = 0; // Update last poll time this.lastPollTime = new Date().toISOString(); // Parse response const data = await response.json(); // Process server operations if (data.operations && Array.isArray(data.operations)) { for (const op of data.operations) { // Convert to expected format const operation = { id: op.id, type: op.type, data: op.data || {}, user: op.user_id, status: op.status, retries: op.retries || 0, started_at: op.started_at, created_at: op.created_at, completed_at: op.completed_at, estimated_completion: op.estimated_completion, queue_position: op.queue_position, error_message: op.error_message }; // Update or add to local queue this.updateItem(op.id, operation); } } // Update UI this.maybeAddEmptyState(); this.updateStatusPanel(); this.updateFilterCounts(); return data; } catch (error) { console.error('Failed to fetch operations:', error); return { operations: [], error: true }; } finally { // End loading indicator if (this.refreshButton) { this.refreshButton.classList.remove('refreshing'); } this.processing = false; } } /** * Update a queue item * * @param {string} id Item ID * @param {Object} newData New data to merge * @returns {boolean} Success */ updateItem(id, newData) { // If item doesn't exist yet, create it if (!this.queue.has(id)) { this.queue.set(id, { id, ...newData }); this.saveQueue(); this.updatePanelItem(this.queue.get(id)); return true; } // Get existing item const item = this.queue.get(id); const oldStatus = item.status; // Merge updates const updated = { ...item, ...newData }; // Start polling if server-side processing is happening if (['pending', 'processing'].includes(updated.status) && oldStatus !== updated.status) { this.startPolling(); } // Update queue this.queue.set(id, updated); this.saveQueue(); // Update UI this.updatePanelItem(updated, oldStatus); // Handle status transitions if (oldStatus !== updated.status) { this.handleStatusChange(updated, oldStatus); } return true; } /** * Handle status change for an item * * @param {Object} item Item that changed * @param {string} oldStatus Previous status */ handleStatusChange(item, oldStatus) { // Clear caches when operation completes if (item.status === 'completed' && oldStatus !== 'completed') { const cacheTypes = this.cacheTypesToClear[item.type]; if (cacheTypes && cacheTypes.length > 0) { cacheTypes.forEach(cacheType => { this.cache.clearByContent(cacheType); }); } // Show completion notification this.addPopup(`${this.getOperationTitle(item)} completed`); } // Log failures else if (item.status === 'failed' && oldStatus !== 'failed') { this.errors.logErrorToServer('operation_failed', item.error_message || 'Operation failed', { operation_id: item.id, type: item.type, content_type: item.data.content, retries: item.retries }); // Show failure notification this.addPopup(`Error: ${item.error_message || 'Unknown error'}`); } } /** * Update panel item in the UI * * @param {Object} item Item to update * @param {string} oldStatus Previous status */ updatePanelItem(item, oldStatus = null) { // Find existing panel item or create new one let panelItem = this.panelList.querySelector(`.queue-item[data-id="${item.id}"]`); if (!panelItem) { panelItem = this.createPanelItem(item); return; } // Update status classes if (oldStatus) { panelItem.classList.remove(oldStatus); } else { this.statuses.forEach(status => panelItem.classList.remove(status)); } panelItem.classList.add(item.status); // Update status text const statusElement = panelItem.querySelector('.queue-item-status'); if (statusElement) { if (oldStatus) { statusElement.classList.remove(oldStatus); } else { this.statuses.forEach(status => statusElement.classList.remove(status)); } statusElement.classList.add(item.status); statusElement.textContent = this.getStatusLabel(item.status); } // Update details const details = panelItem.querySelector('.queue-item-details'); if (details) { const detailsText = details.querySelector('.details'); if (detailsText) { detailsText.textContent = this.getItemMessage(item); } // Update completion time if available const completed = details.querySelector('.completed'); if (completed && item.completed_at) { completed.textContent = `| Finished: ${window.formatTimeAgo(item.completed_at)}`; } } // Update progress bar const progressBar = panelItem.querySelector('.progress-bar .progress-fill'); if (progressBar) { progressBar.style.width = `${this.calculateProgressPercentage(item)}%`; } // Update action buttons for completed/failed items if (['completed', 'failed', 'failed_permanent'].includes(item.status)) { const actionsContainer = panelItem.querySelector('.queue-item-actions'); if (actionsContainer) { actionsContainer.innerHTML = this.renderActionButtons(item); } } } /** * Create a new panel item * * @param {Object} item Item to create * @returns {HTMLElement} Created panel item */ createPanelItem(item) { const panelItem = document.createElement('div'); panelItem.className = `queue-item ${item.status}`; panelItem.dataset.id = item.id; panelItem.dataset.type = item.type; // Format timestamps const timestamp = item.created_at ? new Date(item.created_at).getTime() : item.started_at; const timeDisplay = timestamp ? window.formatTimeAgo(new Date(timestamp)) : ''; // Calculate progress const progressPercent = this.calculateProgressPercentage(item); // Get operation type display name let typeName = this.operationNames[item.type] || item.type; if (typeName && typeName.includes('Content') && item.data && item.data.content) { typeName = typeName.replace('Content', window.uppercaseFirst(item.data.content)); } // Build HTML panelItem.innerHTML = `
${typeName} ${this.getStatusLabel(item.status)} ${['completed', 'failed', 'failed_permanent'].includes(item.status) ? '' : ''}
${item.progress ? `${item.progress.current || 0} of ${item.progress.total || '?'}` : ''}
${this.getItemMessage(item)}
${jvbSettings.icons.clock} Started: ${timeDisplay}
${this.renderActionButtons(item)}
`; // Add to panel this.panelList.insertBefore(panelItem, this.panelList.firstChild); return panelItem; } /** * Get status label for display * * @param {string} status Operation status * @returns {string} Human-readable status */ getStatusLabel(status) { switch (status) { case 'queued': return 'Received'; case 'localProcessing': return 'Processing Data'; case 'uploading': return 'Sending to Server'; case 'pending': return 'Sent to Server'; case 'processing': return 'Server processing'; case 'completed': return 'Completed'; case 'failed': return 'Failed'; case 'failed_permanent': return 'Failed'; default: return status; } } /** * Get detailed message for an item * * @param {Object} item Queue item * @returns {string} Detailed message */ getItemMessage(item) { // Start with default message let message = this.statusMessages[item.status] || ''; // Add position info if available if (item.queue_position) { if (item.queue_position === 0) { message += ' You are next in line.'; } else { message += ` You are #${item.queue_position} in line.`; } } // Add estimated completion time if available if (item.estimated_completion) { message += `\nShould be done ${window.formatTimeSoon(item.estimated_completion)}`; } // Add error if failed if ((item.status === 'failed' || item.status === 'failed_permanent') && item.error_message) { message += `\nError: ${item.error_message}`; } return item.message || message; } /** * Render action buttons for an item * * @param {Object} item Queue item * @returns {string} HTML for action buttons */ renderActionButtons(item) { if (item.status === 'failed' || item.status === 'failed_permanent') { return ` `; } if (item.status === 'completed') { // Add refresh button for content updates let refresh = ['batch_creation', 'content_update'].includes(item.type) ? '' : ''; return ` ${refresh} `; } return ''; } /** * Calculate progress percentage * * @param {Object} operation Operation * @returns {number} Progress percentage (0-100) */ calculateProgressPercentage(operation) { switch (operation.status) { case 'queued': case 'failed': case 'failed_permanent': return 0; case 'localProcessing': // Use progress data if available if (operation.progress && operation.progress.current && operation.progress.total) { return 5 + (35 * (operation.progress.current / operation.progress.total)); } return 5; case 'uploading': return 40; case 'pending': return 65; case 'processing': if (operation.data && operation.data.progress) { return 75 + (25 * (operation.data.progress.percentage / 100)); } return 85; case 'completed': return 100; default: return 0; } } /** * Get operation title * * @param {Object} item Operation item * @returns {string} Human-readable title */ getOperationTitle(item) { let message = this.operationNames[item.type] || item.type; if (message.includes('Content') && item.data && item.data.content) { return message.replace('Content', window.uppercaseFirst(item.data.content)); } return message; } /** * Get popup message for an operation * * @param {Object} item Operation item * @returns {string} Message for popup */ getPopupMessage(item) { switch (item.type) { case 'handle_vote': return 'Adding your voice...'; case 'new_response': return 'Adding your voice...'; case 'new_news': return 'Creating new news...'; case 'image_upload': return 'Uploading image...'; case 'user_settings': return 'Updating settings...'; case 'content_update': return `Updating ${item.data.content}...`; case 'content_create': return `Creating new ${item.data.content}...`; case 'favourite_toggle': return 'Updating Favourites...'; case 'invite_artist': return 'Inviting artists...'; case 'bio_update': return 'Updating bio...'; case 'batch_creation': return `Sending ${item.data.content} batch to server...`; case 'favourite_notes': return 'Processing note...'; case 'favourite_list_create': return 'Processing new list...'; case 'favourite_list_add': return 'Processing list changes...'; case 'favourite_list_remove': return 'Processing list changes...'; case 'favourite_list_delete': return 'Processing deletion...'; case 'favourite_list_share': return 'Processing list sharing...'; case 'favourite_list_unshare': return 'Processing un-share...'; default: return `Processing ${item.type}...`; } } /** * Show popup message * * @param {string} message Message to show * @param {number} delay Time in ms to show popup */ addPopup(message, delay = 2000) { if (!this.popup) return; this.a11y.announce(message); this.popup.innerHTML = message; this.popup.classList.add('showing'); setTimeout(() => { this.popup.classList.remove('showing'); setTimeout(() => { // Clear popup content after fadeout while (this.popup.firstChild) { this.popup.removeChild(this.popup.firstChild); } }, 50); }, delay); } /** * Update network indicator */ updateNetworkIndicator() { // Update UI based on online status if (this.panel) { this.panel.classList.toggle('offline', !navigator.onLine); } } /** * Update status panel * * @param {string} status Special status to display */ updateStatusPanel(status) { if (!this.panel) return; // Get pending count for badge const pendingCount = [...this.queue.values()].filter(item => ['queued', 'localProcessing', 'uploading'].includes(item.status) ).length; // Update indicator status this.panel.classList.remove('offline', 'pending', 'synced', 'image_processing'); if (!navigator.onLine) { this.panel.classList.add('offline'); this.addPopup('Looks like we\'re offline...'); } else if (status === 'pending' || pendingCount > 0) { this.panel.classList.add('pending'); } else if (status === 'image_processing') { this.panel.classList.add('image_processing'); this.addPopup('Processing images...'); } else if (status === 'synced' || pendingCount === 0) { this.panel.classList.add('synced'); } // Update queue stats this.updateQueueStats(); } /** * Update queue statistics */ updateQueueStats() { // Calculate stats const stats = this.getQueueStats(); // Update filter buttons with counts Object.entries(stats).forEach(([status, count]) => { const button = this.statusButtons[status]; if (button) { button.dataset.count = count; // Update count badge if (count > 0) { let badge = button.querySelector('.count-badge'); if (!badge) { badge = document.createElement('span'); badge.className = 'count-badge'; button.appendChild(badge); } badge.textContent = count; } else { const badge = button.querySelector('.count-badge'); if (badge) { badge.remove(); } } } }); // Update the status indicator badge const badgeCount = stats.queued + stats.localProcessing + stats.uploading; const statusIndicator = this.panel.querySelector('.queue-status-indicator'); const statusCount = this.panel.querySelector('.queue-status-count'); if (statusIndicator) { statusIndicator.classList.toggle('active', badgeCount > 0); } if (statusCount) { statusCount.textContent = badgeCount > 0 ? badgeCount : ''; } // Update action buttons if (this.retryButton) { this.retryButton.disabled = stats.failed === 0; } if (this.clearButton) { this.clearButton.disabled = stats.completed === 0; } } /** * Get queue statistics * * @returns {Object} Queue statistics by status */ getQueueStats() { const stats = {}; // Initialize all statuses to 0 this.statuses.forEach(status => { stats[status] = 0; }); // Count items by status for (const item of this.queue.values()) { if (stats[item.status] !== undefined) { stats[item.status]++; } } return stats; } /** * Update filter counts */ updateFilterCounts() { const stats = this.getQueueStats(); if (this.filterButtons) { this.filterButtons.forEach(button => { const filter = button.dataset.filter; if (!filter || filter === 'all') return; const count = stats[filter] || 0; button.dataset.count = count; // Update badge let badge = button.querySelector('.count-badge'); if (count > 0) { if (!badge) { badge = document.createElement('span'); badge.className = 'count-badge'; button.appendChild(badge); } badge.textContent = count; } else if (badge) { badge.remove(); } }); } } /** * Process the queue * * @returns {Promise} */ async processQueue() { if (!this.hasChanges) return; // Check if we're online if (!navigator.onLine) { this.updateStatusPanel('offline'); return; } try { // Find items that are ready to process const pendingItems = [...this.queue.values()].filter(item => item.status === 'queued' && (!item.retryAfter || item.retryAfter <= Date.now()) ); if (pendingItems.length === 0) { this.hasChanges = false; return; } // Only process a batch at a time const batchSize = 5; const itemsToProcess = pendingItems.slice(0, batchSize); // Update UI this.updateStatusPanel('pending'); // Process each item for (const item of itemsToProcess) { try { this.a11y.announce(`Now processing ${this.getOperationTitle(item)} locally.`); await this.processItem(item); this.a11y.announce('Finished sending to server.'); } catch (error) { // Log error this.errors.log(error, { component: 'QueueManager', action: 'processQueue', type: item.type, operation_id: item.id }); // Update item status this.updateItem(item.id, { status: (item.retries >= this.maxRetries - 1) ? 'failed_permanent' : 'failed', error_message: error.message || 'Unknown error', retries: (item.retries || 0) + 1, retryAfter: (item.retries < this.maxRetries - 1) ? Date.now() + (5000 * Math.pow(2, item.retries || 0)) : undefined }); // Notify user this.addPopup(`Error processing ${this.getOperationTitle(item)}: ${error.message || 'Unknown error'}`); } } // Update UI this.updateStatusPanel(); // Check if there are more items to process const remainingPending = [...this.queue.values()].filter(item => ['queued', 'localProcessing'].includes(item.status) ).length; if (remainingPending > 0) { // Schedule another processing round setTimeout(() => { this.hasChanges = true; this.processQueue(); }, 1000); } else { this.hasChanges = false; this.updateStatusPanel('synced'); } } catch (error) { console.error('Error in queue processing:', error); this.addPopup('Whoops! Something went wrong'); } } /** * Process a single queue item * * @param {Object} item Queue item to process * @returns {Promise} */ async processItem(item) { this.updateItem(item.id, {status: 'localProcessing'}); try { // Find the processor for this operation type const processor = this.processors[item.type]; if (!processor) { throw new Error(`Unknown operation type: ${item.type}`); } // Process the item this.a11y.announce(`Processing ${this.getOperationTitle(item)} locally.`); const result = await processor(item); // Store result and update status this.updateItem(item.id, { result: result, status: 'pending' }); this.a11y.announce(`${this.getOperationTitle(item)} sent to server for processing.`); // Start polling for status updates this.startPolling(); } catch (error) { this.handleProcessError(item, error); throw error; } } /** * Handle processing error * * @param {Object} item Item that failed * @param {Error} error The error */ handleProcessError(item, error) { console.error(`Error processing ${item.type}:`, error); const errorContext = { operation_id: item.id, type: item.type, content_type: item.data.content, attempt: (item.retries || 0) + 1 }; // Use ErrorHandler this.errors.log(error, { component: 'QueueManager', action: item.type, ...errorContext }); // Update item this.updateItem(item.id, { status: (item.retries >= this.maxRetries - 1) ? 'failed_permanent' : 'failed', error_message: error.message || 'Unknown error', retries: (item.retries || 0) + 1, retryAfter: (item.retries < this.maxRetries - 1) ? Date.now() + (5000 * Math.pow(2, item.retries || 0)) : undefined }); // Notify user this.addPopup(`Error processing ${this.getOperationTitle(item)}: ${error.message || 'Unknown error'}`); } /** * Make API request for an operation * * @param {Object} item Operation item * @param {string} endpoint API endpoint * @param {string} method HTTP method * @param {Object|FormData} data Request data * @param {Object} additionalHeaders Additional headers * @param {string} title Operation title for messages * @returns {Promise} Response data */ async makeRequest(item, endpoint, method, data, additionalHeaders = {}, title) { this.updateItem(item.id, {status: 'uploading'}); const isFormData = data instanceof FormData; const headers = { 'X-WP-Nonce': jvbSettings.nonce, ...additionalHeaders }; if (!isFormData && method !== 'GET') { headers['Content-Type'] = 'application/json'; } console.log('Sending Data: ',data); try { const response = await fetch(`${jvbSettings.api}${endpoint}`, { method, headers, body: isFormData ? data : JSON.stringify(data) }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.message || `Request failed with s[tatus ${response.status}`); } return await response.json(); } catch (error) { this.handleProcessError(item, error); throw error; } } /** * Start polling for operation status updates */ startPolling() { this.stopPolling(); const interval = this.getPollingInterval(); // Log polling strategy (remove in production) console.log(`Polling in ${interval}ms (no-changes: ${this.pollingConfig.consecutiveNoChanges})`); if (this.pollingConfig.countdown && this.countdown) { this.startCountdown(interval / 1000); } this.pollingTimer = setTimeout(() => { this.fetchOperations(false).then((result) => { // Only continue polling if we have pending operations or recent activity const shouldContinue = this.hasServerPendingOperations() || (Date.now() - this.pollingConfig.lastUserActivity) < this.pollingConfig.inactiveThreshold; if (shouldContinue) { this.startPolling(); } else { console.log('Stopping polling: no pending operations and user inactive'); this.stopPolling(); this.updateStatusPanel('synced'); } }); }, interval); } /** * Stop polling */ stopPolling() { if (this.pollingTimer) { clearTimeout(this.pollingTimer); this.pollingTimer = null; } if (this.countdownTimer) { clearInterval(this.countdownTimer); this.countdownTimer = null; } // Remove refreshing state if (this.refreshButton) { this.refreshButton.classList.remove('refreshing'); } } /** * Get adaptive polling interval * * @returns {number} Polling interval in milliseconds */ getPollingInterval() { const now = Date.now(); const timeSinceActivity = now - this.pollingConfig.lastUserActivity; const isUserActive = timeSinceActivity < this.pollingConfig.inactiveThreshold; // Base interval let interval = this.pollingConfig.interval; // Increase interval based on consecutive no-changes const noChangesCount = Math.min( this.pollingConfig.consecutiveNoChanges, this.pollingConfig.maxNoChangesCount ); interval *= Math.pow(this.pollingConfig.backoffFactor, noChangesCount); // If user is inactive, poll less frequently if (!isUserActive) { interval *= 3; // Poll 3x less when user is inactive } // If we have pending operations, poll more frequently const hasPendingOps = this.hasServerPendingOperations(); if (hasPendingOps && isUserActive) { interval = Math.max(interval * 0.5, this.pollingConfig.minInterval); } // Clamp between min and max return Math.min( Math.max(interval, this.pollingConfig.minInterval), this.pollingConfig.maxInterval ); } /** * Start countdown timer * * @param {number} seconds Seconds to count down */ startCountdown(seconds) { // Clear existing countdown if (this.countdownTimer) { clearInterval(this.countdownTimer); } if (!this.countdown) return; // Set initial value let remainingSeconds = Math.round(seconds); this.countdown.textContent = remainingSeconds; this.countdown.classList.add('counting'); // Start countdown this.countdownTimer = setInterval(() => { remainingSeconds--; if (remainingSeconds <= 0) { clearInterval(this.countdownTimer); this.countdownTimer = null; this.countdown.textContent = ''; this.countdown.classList.remove('counting'); return; } this.countdown.textContent = remainingSeconds; }, 1000); } /** * Check if there are pending operations on the server * * @returns {boolean} Whether there are pending server operations */ hasServerPendingOperations() { return [...this.queue.values()].some(item => item.status === 'pending' || item.status === 'processing' ); } /** * Retry a failed operation * * @param {string} operationId ID of operation to retry */ retryOperation(operationId) { const operation = this.queue.get(operationId); if (operation && (operation.status === 'failed' || operation.status === 'failed_permanent')) { // Send request to server fetch(`${this.API}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': jvbSettings.nonce }, body: JSON.stringify({ ids: operationId, action: 'retry' }) }).then(response => { if (!response.ok) { throw new Error('Failed to retry operation'); } return response.json(); }).then(data => { if (data.success) { // Update local status this.updateItem(operationId, { status: 'pending', error_message: null }); // Start polling for status updates this.startPolling(); // Show notification this.addPopup('Operation queued for retry'); } }).catch(error => { console.error('Error retrying operation:', error); this.addPopup('Failed to retry operation'); }); } } /** * Retry all failed operations */ retryFailedOperations() { // Find failed operations const failedIds = [...this.queue.values()] .filter(item => (item.status === 'failed' || item.status === 'failed_permanent') && item.retries < this.maxRetries ) .map(item => item.id); if (failedIds.length === 0) return; // Confirm with user if (confirm(`Retry ${failedIds.length} failed operations?`)) { // Send request to server fetch(`${this.API}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': jvbSettings.nonce }, body: JSON.stringify({ ids: failedIds.join(','), action: 'retry' }) }).then(response => { if (!response.ok) { throw new Error('Failed to retry operations'); } return response.json(); }).then(data => { if (data.success) { // Update local status for each operation failedIds.forEach(id => { this.updateItem(id, { status: 'pending', error_message: null }); }); // Start polling for status updates this.startPolling(); // Show notification this.addPopup(`Retrying ${failedIds.length} operations...`); } }).catch(error => { console.error('Error retrying operations:', error); this.addPopup('Failed to retry operations'); }); } } /** * Clear completed operations */ clearCompletedOperations() { // Find completed operations const completedIds = [...this.queue.values()] .filter(item => item.status === 'completed') .map(item => item.id); if (completedIds.length === 0) return; // Dismiss operations this.dismissOperations(completedIds); } /** * Dismiss operations * * @param {string[]} operationIds IDs of operations to dismiss */ dismissOperations(operationIds) { if (!operationIds.length) return; // Send request to server fetch(`${this.API}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': jvbSettings.nonce }, body: JSON.stringify({ ids: operationIds.join(','), action: 'dismiss' }) }).then(response => { if (!response.ok) { throw new Error('Failed to dismiss operations'); } return response.json(); }).then(data => { if (data.success) { // Remove from local queue operationIds.forEach(id => { // Remove from UI with animation const item = this.panelList.querySelector(`.queue-item[data-id="${id}"]`); if (item) { item.style.opacity = '0'; item.style.transition = 'opacity 0.3s ease-out'; setTimeout(() => { item.remove(); this.maybeAddEmptyState(); }, 300); } // Remove from queue this.queue.delete(id); }); // Save updated queue this.saveQueue(); // Update UI this.updateQueueStats(); this.updateFilterCounts(); // Notify user this.a11y.announce('Removed completed tasks'); } }).catch(error => { console.error('Error dismissing operations:', error); this.addPopup('Failed to dismiss operations'); }); } //INDIVIDUAL PROCESSORS /** * Process vote operation * * @param {Object} item Queue item * @returns {Promise} Operation result */ async processVote(item) { item.data.id = item.id; return await this.makeRequest( item, 'vote', 'POST', item.data, {}, 'Adding Your Voice' ); } /** * Process artist invite operation * * @param {Object} item Queue item * @returns {Promise} Operation result */ async processArtistInvite(item) { const data = { user: jvbSettings.currentUser, invites: item.data, id: item.id }; return await this.makeRequest( item, 'invitations', 'POST', data, { 'action_nonce': jvbSettings.dash }, 'Inviting Artist' ); } /** * Process new news operation * * @param {Object} item Queue item * @returns {Promise} Operation result */ async processNewNews(item) { const formData = new FormData(); // Append all data from item for (const [key, value] of Object.entries(item.data)) { formData.append(key, value); } formData.append('id', item.id); return await this.makeRequest( item, 'news', 'POST', formData, { 'action_nonce': jvbSettings.dash }, 'Adding News Post' ); } /** * Process new response operation * * @param {Object} item Queue item * @returns {Promise} Operation result */ async processNewResponse(item) { item.data.id = item.id; return await this.makeRequest( item, 'response', 'POST', item.data, {}, 'Adding your response' ); } /** * Process settings update operation * * @param {Object} item Queue item * @returns {Promise} Operation result */ async processSettingsUpdate(item) { const formData = new FormData(); // Append all data fields for (const [key, value] of Object.entries(item.data)) { formData.append(key, value); } formData.append('id', item.id); return await this.makeRequest( item, 'settings', 'POST', formData, { 'action_nonce': jvbSettings.dash }, 'Updating Settings' ); } /** * Process bio update operation * * @param {Object} item Queue item * @returns {Promise} Operation result */ async processBioUpdate(item) { item.data.id = item.id; console.log(item); console.log(item.data); return await this.makeRequest( item, 'bio', 'POST', item.data, { 'action_nonce': jvbSettings.dash }, 'Updating Bio' ); } /** * Process content update operation * * @param {Object} item Queue item * @returns {Promise} Operation result */ async processContentUpdate(item) { const apiData = { id: item.id, user: item.user, posts: item.data.posts || {}, content: item.data.content }; return await this.makeRequest( item, 'set', 'POST', apiData, { 'action_nonce': jvbSettings.dash }, 'Updating Content' ); } /** * Process content creation operation * * @param {Object} item Queue item * @returns {Promise} Operation result */ async processContentCreation(item) { const apiData = { id: item.id, content: item.data.content, ...item.data }; return await this.makeRequest( item, 'create', 'POST', apiData, { 'action_nonce': jvbSettings.dash }, `Creating ${item.data.content === 'artwork' ? 'Artwork' : window.uppercaseFirst(item.data.content)+'s'}` ); } /** * Process batch creation operation * * @param {Object} item Queue item * @returns {Promise} Operation result */ async processBatchCreation(item) { const formData = new FormData(); // Add common data formData.append('content', item.data.content); formData.append('user', item.data.user); formData.append('mode', item.data.mode); formData.append('id', item.id); // Process each batch item.data.files.forEach((batch, batchIndex) => { if (!batch.files || !Array.isArray(batch.files)) { console.error('Invalid batch structure:', batch); return; } // Add files for this batch - batch.files contains objects with {file: File, metadata: {}} batch.files.forEach((fileObj, fileIndex) => { // Extract the actual File object const file = fileObj.file || fileObj.processedFile || fileObj; if (file instanceof File) { formData.append(`files[${batchIndex}][${fileIndex}]`, file); } else { console.error('Invalid file object:', fileObj); } }); // Add metadata for this batch - combine individual file metadata const batchMetadata = { type: batch.type, metadata: batch.metadata || {}, files_metadata: batch.files.map(fileObj => fileObj.metadata || {}) }; formData.append(`files_data[${batchIndex}]`, JSON.stringify(batchMetadata)); }); return await this.makeRequest( item, 'create/batch', 'POST', formData, { 'action_nonce': jvbSettings.dash }, `Creating ${item.data.content === 'artwork' ? 'Artwork' : window.uppercaseFirst(item.data.content)+'s'}` ); } /** * Process file upload operation * * @param {Object} item Queue item * @returns {Promise} Operation result */ async processFileUpload(item) { const formData = new FormData(); // Add common fields formData.append('content', item.data.content); formData.append('user', item.data.user); formData.append('id', item.id); formData.append('mode', item.data.mode); // Add optional fields if (item.data.postId) { formData.append('post_id', item.data.postId); } if (item.data.termId) { formData.append('term_id', item.data.termId); } if (item.data.fieldName) { formData.append('field_name', item.data.fieldName); } console.log('Processing files for upload:', item.data.files); // Handle files properly based on structure if (item.data.files && Array.isArray(item.data.files)) { let fileIndex = 0; item.data.files.forEach((fileGroup, groupIndex) => { console.log(`Processing group ${groupIndex}:`, fileGroup); if (fileGroup.files && Array.isArray(fileGroup.files)) { fileGroup.files.forEach((fileObj) => { // Extract the actual File object let file = null; if (fileObj instanceof File) { file = fileObj; } else if (fileObj.file instanceof File) { file = fileObj.file; } else if (fileObj.processedFile instanceof File) { file = fileObj.processedFile; } if (file) { console.log(`Adding file ${fileIndex}:`, file.name, file.size, file.type); formData.append(`files[${fileIndex}]`, file); // Add metadata if available if (fileObj.metadata) { formData.append(`metadata[${fileIndex}]`, JSON.stringify(fileObj.metadata)); } fileIndex++; } else { console.error('Invalid file object:', fileObj); } }); } else { console.error('Invalid file group structure:', fileGroup); } }); console.log(`Total files added to FormData: ${fileIndex}`); } return await this.makeRequest( item, 'uploads', 'POST', formData, { 'action_nonce': jvbSettings.dash }, 'Uploading Files' ); } /** * Process favourite toggle operation * * @param {Object} item Queue item * @returns {Promise} Operation result */ async processFavourite(item) { const batchData = { adds: [], removes: [], id: item.id, user: item.user }; // Sort adds and removes item.data.forEach(favItem => { const action = favItem.action; const itemCopy = {...favItem}; delete itemCopy.action; if (action === 'add') { batchData.adds.push(itemCopy); } else { batchData.removes.push(itemCopy); } }); return await this.makeRequest( item, 'favourites', 'POST', batchData, { 'action_nonce': jvbSettings.dash }, 'Setting Favourites' ); } /** * Process favourite notes operation * * @param {Object} item Queue item * @returns {Promise} Operation result */ async processFavouriteNotes(item) { return await this.makeRequest( item, 'favourites', 'POST', { operation: 'update_notes', type: item.data.type, target_id: item.data.target_id, notes: item.data.notes, id: item.id }, { 'action_nonce': jvbSettings.favourites }, 'Saving Note' ); } /** * Process favourite list create operation * * @param {Object} item Queue item * @returns {Promise} Operation result */ async processFavouriteListCreate(item) { return await this.makeRequest( item, 'favourites/lists', 'POST', { operation: 'create', name: item.data.name, description: item.data.description, items: item.data.items, id: item.id }, { 'action_nonce': jvbSettings.favourites }, 'Creating List' ); } /** * Process adding items to a favourite list * * @param {Object} item Queue item * @returns {Promise} Operation result */ async processFavouriteListAddItems(item) { return await this.makeRequest( item, 'favourites/lists', 'POST', { id: item.id, items: item.data.items, list_id: item.data.list_id, operation: 'add_items' }, { 'action_nonce': jvbSettings.favourites }, 'Adding to List' ); } /** * Process removing items from a favourite list * * @param {Object} item Queue item * @returns {Promise} Operation result */ async processFavouriteListRemoveItems(item) { return await this.makeRequest( item, 'favourites/lists', 'POST', { id: item.id, items: item.data.items, list_id: item.data.list_id, operation: 'remove_items' }, { 'action_nonce': jvbSettings.favourites }, 'Removing from List' ); } /** * Process favourite list delete operation * * @param {Object} item Queue item * @returns {Promise} Operation result */ async processFavouriteListDelete(item) { return await this.makeRequest( item, 'favourites/lists', 'POST', { operation: 'delete', list_id: item.id }, { 'action_nonce': jvbSettings.favourites }, 'Deleting list' ); } /** * Process favourite list share operation * * @param {Object} item Queue item * @returns {Promise} Operation result */ async processFavouriteListShare(item) { return await this.makeRequest( item, 'favourites/lists/shares', 'POST', { operation: 'add', email: item.data.email, list_id: item.id}, { 'action_nonce': jvbSettings.favourites }, 'Sharing List' ); } /** * Process favourite list unshare operation * * @param {Object} item Queue item * @returns {Promise} Operation result */ async processFavouriteListUnshare(item) { return await this.makeRequest( item, 'favourites/lists/shares', 'POST', { operation: 'remove', email: item.data.email, list_id: item.id }, { 'action_nonce': jvbSettings.favourites }, 'Removing Share Access' ); } /** * Try to merge similar operations to reduce queue size * * @param {Object} operation New operation * @returns {Object|false} Merged operation or false if no merge */ mergeOperations(operation) { // Find pending operations of the same type const pendingOperations = [...this.queue.values()].filter(item => item.status === 'queued' && item.type === operation.type ); if (pendingOperations.length === 0) return false; // Handle based on operation type switch (operation.type) { case 'content_update': return this.mergeContentUpdates(pendingOperations, operation); case 'user_settings': case 'bio_update': return this.mergeSettingsUpdates(pendingOperations, operation); case 'favourite_toggle': return this.mergeFavouriteToggles(pendingOperations, operation); case 'image_upload': return this.mergeImageUploads(pendingOperations, operation); default: return false; } } /** * Merge content update operations * * @param {Array} existingOperations Existing operations * @param {Object} newOperation New operation * @returns {Object|false} Merged operation or false */ mergeContentUpdates(existingOperations, newOperation) { if (!existingOperations.length) return false; // Start with empty set of merged posts const mergedPosts = {}; // Gather existing post data existingOperations.forEach(operation => { if (!operation.data || !operation.data.posts) return; Object.entries(operation.data.posts).forEach(([postID, postData]) => { if (!mergedPosts[postID]) { mergedPosts[postID] = { ...postData }; } else { mergedPosts[postID] = this.mergePostData(mergedPosts[postID], postData); } }); }); // Merge in new operation's posts if (newOperation.data && newOperation.data.posts) { Object.entries(newOperation.data.posts).forEach(([postID, postData]) => { if (!mergedPosts[postID]) { mergedPosts[postID] = { ...postData }; } else { mergedPosts[postID] = this.mergePostData(mergedPosts[postID], postData); } }); } // Remove all pending content updates existingOperations.forEach(op => { this.queue.delete(op.id); }); // Return merged operation return { type: 'content_update', data: { posts: mergedPosts, content: newOperation.data.content } }; } /** * Merge post data from multiple updates * * @param {Object} existing Existing post data * @param {Object} update New post data * @returns {Object} Merged post data */ mergePostData(existing, update) { const merged = { ...existing }; // Ensure content stays consistent merged.content = existing.content; // Merge fields Object.entries(update).forEach(([key, value]) => { if (key === 'taxonomies') { // Special handling for taxonomies merged.taxonomies = merged.taxonomies || {}; Object.entries(value).forEach(([taxKey, terms]) => { // Keep most recent terms merged.taxonomies[taxKey] = [...terms]; }); } else if (key !== 'content') { // For other fields, take newest value merged[key] = value; } }); return merged; } /** * Merge settings update operations * * @param {Array} existingOperations Existing operations * @param {Object} newOperation New operation * @returns {Object|false} Merged operation or false */ mergeSettingsUpdates(existingOperations, newOperation) { if (!existingOperations.length) return false; // Get most recent state of each field const mergedFields = {}; // Gather existing fields existingOperations.forEach(operation => { if (!operation.data) return; Object.entries(operation.data).forEach(([field, value]) => { // Skip user field if (field !== 'user') { if (Array.isArray(value)) { // For arrays, merge and remove duplicates mergedFields[field] = mergedFields[field] || []; mergedFields[field] = [...new Set([...mergedFields[field], ...value])]; } else { // For regular fields, take latest value mergedFields[field] = value; } } }); }); // Merge in new operation's fields if (newOperation.data) { Object.entries(newOperation.data).forEach(([field, value]) => { if (field !== 'user') { if (Array.isArray(value)) { mergedFields[field] = mergedFields[field] || []; mergedFields[field] = [...new Set([...mergedFields[field], ...value])]; } else { mergedFields[field] = value; } } }); } // Remove pending operations existingOperations.forEach(op => { this.queue.delete(op.id); }); // Return merged operation return { type: newOperation.type, data: { user: newOperation.data.user, ...mergedFields } }; } /** * Merge favourite toggle operations * * @param {Array} existingOperations Existing operations * @param {Object} newOperation New operation * @returns {Object|false} Merged operation or false */ mergeFavouriteToggles(existingOperations, newOperation) { if (!existingOperations.length || !newOperation.data || !newOperation.data[0]) return false; // Find toggle for this target const targetId = newOperation.data[0].target_id; const targetType = newOperation.data[0].type; const existingOperation = existingOperations.find(op => op.data && op.data[0] && op.data[0].target_id === targetId && op.data[0].type === targetType ); if (existingOperation) { // Use latest toggle action this.queue.delete(existingOperation.id); return newOperation; } return false; } /** * Merge image upload operations * * @param {Array} existingOperations Existing operations * @param {Object} newOperation New operation * @returns {Object|false} Merged operation or false */ mergeImageUploads(existingOperations, newOperation) { if (!existingOperations.length || !newOperation.data) return false; // Only merge if there's a groupId if (!newOperation.data.groupId) return false; // Find uploads with same groupId const groupUploads = existingOperations.filter(op => op.data && op.data.groupId === newOperation.data.groupId ); if (!groupUploads.length) return false; // Collect all files from group const files = []; // Add files from existing operations groupUploads.forEach(op => { if (op.data.files) { files.push(...op.data.files); } else if (op.data.file) { files.push(op.data.file); } }); // Add files from new operation if (newOperation.data.files) { files.push(...newOperation.data.files); } else if (newOperation.data.file) { files.push(newOperation.data.file); } // Remove individual uploads groupUploads.forEach(op => { this.queue.delete(op.id); }); // Return batch operation return { type: 'image_upload', data: { groupId: newOperation.data.groupId, content: newOperation.data.content, postId: newOperation.data.postId, fieldName: newOperation.data.fieldName, files: files } }; } //TODO: Still necessary? /** * Initialize the refresh button */ initRefreshButton() { this.refreshButton.addEventListener('click', () => { if(this.refreshedOnce && this.panelList.children.length === 1 && this.panelList.children[0].classList.contains('no-operations')){ this.addPopup('Nothing to refresh'); return; } this.refreshedOnce = true; // Show refreshing state this.refreshButton.classList.add('refreshing'); // Clear existing countdown and polling this.stopPolling(); // Get current filter const activeFilter = this.filterButtonsContainer.querySelector('.active'); const currentFilter = activeFilter ? activeFilter.dataset.filter : 'all'; // Fetch operations with current filter and force flag this.fetchOperations({ initial: false, force: true }).then(() => {}).finally(() => { // Remove refreshing state this.refreshButton.classList.remove('refreshing'); }); }); } } // Initialize QueueManager on page load document.addEventListener('DOMContentLoaded', () => { window.jvbQueue = new QueueManagerBackup(); }); // Theme switching functionality document.addEventListener('DOMContentLoaded', function() { const themeSwitch = document.getElementById('theme-switch'); if (!themeSwitch) return; // Initialize theme from localStorage or system preference const prefersDark = window.matchMedia('(prefers-color-scheme: dark)'); const storedTheme = localStorage.getItem('theme'); if (storedTheme) { document.documentElement.classList.toggle('dark', storedTheme === 'dark'); themeSwitch.checked = storedTheme === 'dark'; } else { document.documentElement.classList.toggle('dark', prefersDark.matches); themeSwitch.checked = prefersDark.matches; } // Handle theme switch changes themeSwitch.addEventListener('change', async function () { const isDark = this.checked; document.documentElement.classList.toggle('dark', isDark); localStorage.setItem('theme', isDark ? 'dark' : 'light'); // If user is logged in, save preference if (jvbSettings.currentUser !== null) { try { await fetch(`${jvbSettings.api}settings`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': jvbSettings.nonce, 'action_nonce': jvbSettings.dash, }, body: JSON.stringify({ dark_mode: isDark }) }); } catch (error) { console.error('Failed to save theme preference:', error); } } // Update label const label = document.getElementById('theme-switch'); if (label) { label.title = isDark ? 'Toggle Light Mode' : 'Toggle Dark Mode'; } }); // Handle system theme changes prefersDark.addEventListener('change', (e) => { if (!localStorage.getItem('theme')) { const isDark = e.matches; document.documentElement.classList.toggle('dark', isDark); themeSwitch.checked = isDark; } }); });