/**
|
* 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 + '<span>Reply</span>';
|
return button;
|
}
|
|
/**
|
* Create comments button template
|
*/
|
createCommentsButtonTemplate() {
|
const button = document.createElement('a');
|
button.className = 'button';
|
button.innerHTML = jvbSettings.icons.response + '{ <span class="count"></span> }';
|
return button;
|
}
|
|
/**
|
* Create vote buttons template
|
*/
|
createVoteButtonsTemplate() {
|
const vote = document.createElement('div');
|
vote.className = 'vote';
|
vote.innerHTML = `
|
<button type="button" class="up" onClick="handleVote(this)"
|
data-vote="up">${jvbSettings.icons.upvoted + jvbSettings.icons.upvote}
|
<span>{ <span class="count">0</span> }</span>
|
</button>
|
<button type="button" class="down" onClick="handleVote(this)"
|
data-vote="down">${jvbSettings.icons.downvoted + jvbSettings.icons.downvote}
|
<span>{ <span class="count">0</span> }</span>
|
</button>
|
`;
|
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 = '<div class="no-operations">Everything up to date</div>';
|
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<Object>} 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 = `
|
<div class="queue-item-header">
|
<span class="queue-item-type">${typeName}</span>
|
<span class="queue-item-status ${item.status}">
|
${this.getStatusLabel(item.status)}
|
</span>
|
${['completed', 'failed', 'failed_permanent'].includes(item.status) ?
|
'<button class="queue-item-dismiss dismiss-operation" title="Dismiss">×</button>' : ''}
|
</div>
|
|
<div class="queue-item-progress">
|
<div class="progress-bar">
|
<div class="progress-fill" style="width: ${progressPercent}%"></div>
|
</div>
|
<div class="progress-details">
|
${item.progress ?
|
`${item.progress.current || 0} of ${item.progress.total || '?'}` : ''}
|
</div>
|
</div>
|
|
<div class="queue-item-details">
|
<div class="details">${this.getItemMessage(item)}</div>
|
<div class="time">
|
${jvbSettings.icons.clock}
|
<span class="started">Started: ${timeDisplay}</span>
|
<span class="completed"></span>
|
</div>
|
</div>
|
|
<div class="queue-item-actions">
|
${this.renderActionButtons(item)}
|
</div>
|
`;
|
|
// 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 `
|
<button class="retry-operation" ${item.retries >= this.maxRetries ? 'disabled' : ''}>
|
Retry
|
</button>
|
<button class="dismiss-operation">
|
Dismiss
|
</button>
|
`;
|
}
|
|
if (item.status === 'completed') {
|
// Add refresh button for content updates
|
let refresh = ['batch_creation', 'content_update'].includes(item.type)
|
? '<button class="refresh-content">Refresh</button>'
|
: '';
|
|
return `
|
${refresh}
|
<button class="dismiss-operation">
|
Dismiss
|
</button>
|
`;
|
}
|
|
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<void>}
|
*/
|
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<void>}
|
*/
|
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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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;
|
}
|
});
|
});
|