/**
|
* Middleman between front-end changes and backend processing
|
* Uses IndexedDB to store content locally before ensuring it gets sent back to the server
|
*/
|
class DataStore {
|
constructor(config = {}) {
|
this.config = {
|
name: false,
|
endpoint: false,
|
autosync: true,
|
syncInterval: 10000, //10 seconds
|
useIndexedDB: true,
|
apiBase: jvbSettings.api,
|
headers: {},
|
operation: {},
|
... config
|
}
|
this.headers = {
|
'X-WP-Nonce': jvbSettings.nonce,
|
... this.config.headers
|
}
|
if (!config.name || !config.endpoint) {
|
return;
|
}
|
|
this.items = new Map();
|
this.queue = new Map();
|
this.filters = config.filters??{}; //Load initial filters
|
this.subscribers = new Set();
|
this.db = null;
|
|
this.initDB();
|
}
|
|
async initDB() {
|
if (!('indexedDB' in window)) return;
|
|
const request = indexedDB.open(`jvb_${this.config.name}_db`, 1);
|
|
request.onupgradeneeded = (e) => {
|
const db = e.target.result;
|
|
if (!db.objectStoreNames.contains('items')) {
|
db.createObjectStore('items', { keyPath: 'id' });
|
}
|
|
if (!db.objectStoreNames.contains('queue')) {
|
db.createObjectStore('queue', { keyPath: 'id' });
|
}
|
};
|
|
request.onsuccess = (e) => {
|
this.db = e.target.result;
|
this.loadFromDB();
|
};
|
}
|
|
async loadFromDB() {
|
if (!this.db) return;
|
|
// Load items
|
const itemTx = this.db.transaction(['items'], 'readonly');
|
const itemStore = itemTx.objectStore('items');
|
const itemRequest = itemStore.getAll();
|
|
itemRequest.onsuccess = (e) => {
|
e.target.result.forEach(item => {
|
this.items.set(item.id, item);
|
});
|
this.notify('items-loaded', Array.from(this.items.values()));
|
};
|
|
// Load queue
|
const queueTx = this.db.transaction(['queue'], 'readonly');
|
const queueStore = queueTx.objectStore('queue');
|
const queueRequest = queueStore.getAll();
|
|
queueRequest.onsuccess = (e) => {
|
e.target.result.forEach(item => {
|
this.queue.set(item.id, item);
|
});
|
};
|
}
|
|
async fetchFromServer() {
|
try {
|
const params = new URLSearchParams(this.filters);
|
|
const response = await fetch(`${jvbSettings.api}${this.config.endpoint}?${params}`, {
|
headers: this.headers
|
});
|
|
const data = await response.json();
|
console.log(data);
|
if (data.items) {
|
// Clear and update items
|
this.items.clear();
|
data.items.forEach(item => {
|
this.items.set(item.id, item);
|
});
|
|
// Save to IndexedDB
|
this.saveItemsToDB();
|
|
// Notify subscribers
|
this.notify('items-fetched', this.getFilteredItems());
|
}
|
|
return data;
|
} catch (error) {
|
console.error('Fetch error:', error);
|
this.notify('fetch-error', error);
|
}
|
}
|
|
saveItemsToDB() {
|
if (!this.db) return;
|
|
const tx = this.db.transaction(['items'], 'readwrite');
|
const store = tx.objectStore('items');
|
|
this.items.forEach(item => {
|
store.put(item);
|
});
|
}
|
|
updateItem(id, changes) {
|
const item = this.items.get(id);
|
if (!item) return;
|
|
// Apply changes optimistically
|
const updated = { ...item, ...changes, _pending: true };
|
this.items.set(id, updated);
|
|
// Add to queue
|
this.addToQueue(id, changes);
|
|
// Notify
|
this.notify('item-updated', updated);
|
|
return updated;
|
}
|
|
createItem(data) {
|
const tempId = `temp_${Date.now()}`;
|
const newItem = {
|
id: tempId,
|
...data,
|
_isNew: true,
|
_pending: true
|
};
|
|
this.items.set(tempId, newItem);
|
this.addToQueue(tempId, { ...data, _action: 'create' });
|
this.notify('item-created', newItem);
|
|
return newItem;
|
}
|
|
deleteItem(id) {
|
const item = this.items.get(id);
|
if (!item) return;
|
|
// Mark for deletion
|
item._deleted = true;
|
item._pending = true;
|
this.items.set(id, item);
|
|
this.addToQueue(id, { _action: 'delete' });
|
this.notify('item-deleted', item);
|
}
|
|
addToQueue(id, changes) {
|
const existing = this.queue.get(id) || {};
|
const merged = { ...existing, ...changes, id, timestamp: Date.now() };
|
|
this.queue.set(id, merged);
|
|
// Save queue to IndexedDB
|
if (this.db) {
|
const tx = this.db.transaction(['queue'], 'readwrite');
|
tx.objectStore('queue').put(merged);
|
}
|
|
this.notify('queue-updated', this.queue.size);
|
}
|
|
async syncQueue() {
|
if (this.queue.size === 0) return;
|
|
const batch = Array.from(this.queue.values());
|
|
console.log(batch);
|
|
try {
|
const response = await fetch(`${jvbSettings.api}${this.config.endpoint}`, {
|
method: 'POST',
|
headers: this.headers,
|
body: JSON.stringify({
|
content: this.contentType,
|
operations: batch
|
})
|
});
|
|
const result = await response.json();
|
|
if (result.success) {
|
// Clear queue for successful items
|
result.processed.forEach(({ tempId, newId }) => {
|
this.queue.delete(tempId);
|
|
// Update temp IDs with real IDs
|
if (tempId !== newId) {
|
const item = this.items.get(tempId);
|
if (item) {
|
item.id = newId;
|
delete item._isNew;
|
delete item._pending;
|
this.items.delete(tempId);
|
this.items.set(newId, item);
|
}
|
} else {
|
// Just clear pending flag
|
const item = this.items.get(newId);
|
if (item) {
|
delete item._pending;
|
this.items.set(newId, item);
|
}
|
}
|
});
|
|
// Clear queue from IndexedDB
|
if (this.db) {
|
const tx = this.db.transaction(['queue'], 'readwrite');
|
const store = tx.objectStore('queue');
|
result.processed.forEach(({ tempId }) => {
|
store.delete(tempId);
|
});
|
}
|
|
this.notify('sync-success', result);
|
}
|
} catch (error) {
|
this.notify('sync-error', error);
|
}
|
}
|
|
setFilter(key, value) {
|
if (value === '' || value === null) {
|
delete this.filters[key];
|
} else {
|
this.filters[key] = value;
|
}
|
|
this.notify('filters-changed', this.getFilteredItems());
|
}
|
|
getFilteredItems() {
|
let items = Array.from(this.items.values());
|
// Apply filters
|
|
let filters = this.filters;
|
delete filters.user;
|
delete filters.content;
|
delete filters.page;
|
Object.entries(filters).forEach(([key, value]) => {
|
items = items.filter(item => {
|
if (key === 'status') {
|
if (value === 'all') {
|
return item.status === 'publish' || item.status === 'draft';
|
}
|
return item.status === value;
|
}
|
if (key === 'search') {
|
const searchLower = value.toLowerCase();
|
return item.post_title?.toLowerCase().includes(searchLower) ||
|
item.post_content?.toLowerCase().includes(searchLower);
|
}
|
// Handle taxonomy filters
|
if (key.startsWith('tax_')) {
|
const taxonomy = key.replace('tax_', '');
|
return item.taxonomies?.[taxonomy]?.includes(value);
|
}
|
return true;
|
});
|
});
|
|
// Exclude deleted items
|
items = items.filter(item => !item._deleted);
|
|
return items;
|
}
|
|
subscribe(callback) {
|
this.subscribers.add(callback);
|
return () => this.subscribers.delete(callback);
|
}
|
|
notify(event, data) {
|
this.subscribers.forEach(cb => cb(event, data));
|
}
|
}
|
window.jvbStore = DataStore;
|
|
class QueueManager {
|
constructor() {
|
//Core Components
|
this.a11y = window.jvbA11y;
|
this.errors = window.jvbError;
|
this.cache = window.jvbCache;
|
this.debouncer = window.debouncer;
|
//Config
|
this.STORAGE_KEY = 'jvb_queue';
|
this.API = `${jvbSettings.api}queue`;
|
this.defaultHeaders = {
|
'Content-Type': 'application/json',
|
'X-WP-Nonce': jvbSettings.nonce
|
};
|
this.maxRetries = 3;
|
|
this.queue = new Map();
|
this.hasChanges = this.cache.getItem('queueHasChanges')??false;
|
this.isProcessing = false;
|
this.lastPollTime = null;
|
this.pendingUIUpdates = new Map();
|
|
this.keyHandler = this.handleEscape.bind(this);
|
this.statuses = [
|
'queued', 'localProcessing', 'uploading',
|
'pending', 'processing', 'completed',
|
'failed', 'failed_permanent'
|
];
|
|
this.icons = {
|
queued: 'refresh', localProcessing: 'refresh', uploading: 'syncing',
|
pending: 'cloud', processing: 'syncing', completed: 'synced',
|
failed: 'error', failed_permanent: 'error'
|
};
|
|
|
|
this.statusMessages = {
|
'queued': 'Waiting to send to server...',
|
'localProcessing': 'Processing locally...',
|
'uploading': 'Sending to server...',
|
'pending': 'Sent to server - waiting to be processed...',
|
'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;
|
}
|
|
this.initElements();
|
this.loadQueue();
|
|
this.polling = {
|
interval: null, base: 5000, max: 60000,
|
consecutiveNoChanges: 0, isActive: false,
|
lastActivity: Date.now(), startTime: null
|
}
|
|
this.setupActivityTracking();
|
this.initEventListeners();
|
this.fetchOperations(true);
|
|
}
|
|
|
initElements() {
|
this.panel = document.querySelector('aside#queue');
|
if (!this.panel) return;
|
|
this.elements = {
|
queueItems: '.qitems',
|
toggle: '.qtoggle',
|
countdown: '.countdown',
|
refresh: '.refreshNow',
|
popup: '.popup',
|
filters: '.filters',
|
filterButtons: '.filter',
|
retryButton: 'button.retry',
|
dismissButton: 'button.dismiss',
|
cancelButton: 'button.cancel'
|
};
|
|
this.queuedItems = this.panel.querySelector(this.elements.queueItems);
|
this.toggle = this.panel.querySelector(this.elements.toggle);
|
this.countdown = this.panel.querySelector(this.elements.countdown);
|
this.refresh = this.panel.querySelector(this.elements.refresh);
|
this.popup = this.panel.querySelector(this.elements.popup);
|
this.filters = this.panel.querySelector(this.elements.filters);
|
this.filterButtons = this.filters.querySelectorAll(this.elements.filterButtons);
|
this.retryButton = this.panel.querySelector(this.elements.retryButton);
|
this.dismissButton = this.panel.querySelector(this.elements.dismissButton);
|
|
this.statusButtons = {};
|
this.statuses.forEach(status => {
|
this.statusButtons[status] = this.panel.querySelector(`.filter[data-filter="${status}"]`);
|
});
|
}
|
|
initEventListeners() {
|
// Bind handlers to maintain context
|
this.clickHandler = this.handleClick.bind(this);
|
this.handleOnline = () => {
|
this.updateNetworkIndicator();
|
this.maybeProcessQueue(this.hasChanges);
|
};
|
this.handleOffline = () => this.updateNetworkIndicator();
|
this.handleBeforeUnload = (e) => {
|
const hasPending = [...this.queue.values()].some(item =>
|
['queued', 'localProcessing', 'uploading'].includes(item.status)
|
);
|
if (hasPending) {
|
e.preventDefault();
|
return 'You have unsaved changes.';
|
}
|
};
|
this.trackActivity = () => {
|
this.polling.lastActivity = Date.now();
|
};
|
|
// Add listeners
|
document.addEventListener('click', this.clickHandler);
|
window.addEventListener('online', this.handleOnline);
|
window.addEventListener('offline', this.handleOffline);
|
window.addEventListener('beforeunload', this.handleBeforeUnload);
|
|
// Activity tracking
|
const events = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart'];
|
events.forEach(event => {
|
document.addEventListener(event, this.trackActivity, { passive: true });
|
});
|
}
|
/**
|
* Update network indicator
|
*/
|
updateNetworkIndicator() {
|
// Update UI based on online status
|
if (this.panel) {
|
this.panel.classList.toggle('offline', !navigator.onLine);
|
}
|
}
|
handleClick(e) {
|
if (window.targetCheck(e, '#queue ' + this.elements.toggle)) {
|
this.togglePanel();
|
this.maybeAddEmptyState();
|
}
|
// Individual item actions
|
else if (window.targetCheck(e, '#queue .item ' + this.elements.retryButton)) {
|
const operation = e.target.closest('.item');
|
if (operation?.dataset.id) {
|
this.performItemAction(operation.dataset.id, 'retry');
|
}
|
}
|
else if (window.targetCheck(e, '#queue .item ' + this.elements.cancelButton)) {
|
const operation = e.target.closest('.item');
|
if (operation?.dataset.id) {
|
this.performItemAction(operation.dataset.id, 'cancel');
|
}
|
}
|
else if (window.targetCheck(e, '#queue .item ' + this.elements.dismissButton)) {
|
const operation = e.target.closest('.item');
|
if (operation?.dataset.id) {
|
this.performItemAction(operation.dataset.id, 'dismiss');
|
}
|
}
|
// Bulk actions
|
else if (window.targetCheck(e, '#queue ' + this.elements.retryButton)) {
|
this.performBulkAction('retry');
|
}
|
else if (window.targetCheck(e, '#queue ' + this.elements.dismissButton)) {
|
this.performBulkAction('dismiss');
|
}
|
else if (window.targetCheck(e, '#queue ' + this.elements.filterButtons)) {
|
this.handleFilterClick(e);
|
}
|
else if (window.targetCheck(e, '#queue ' + this.elements.refresh)) {
|
this.handleRefreshClick(e);
|
}
|
|
// Close panel when clicking outside
|
if (this.panel.classList.contains('expanded') &&
|
!this.panel.contains(e.target) &&
|
e.target !== this.toggle) {
|
this.closePanel();
|
}
|
}
|
|
closePanel(message = 'Closed Queue Panel') {
|
this.panel.classList.remove('expanded');
|
this.toggle.title = 'Show Queue';
|
this.toggle.ariaExpanded = false;
|
this.a11y.announce(message);
|
document.removeEventListener('keydown', this.keyHandler);
|
}
|
|
openPanel(message = 'Opened Queue Panel') {
|
this.panel.classList.add('expanded');
|
this.toggle.title = 'Hide Queue';
|
this.toggle.ariaExpanded = true;
|
this.a11y.announce(message);
|
document.addEventListener('keydown', this.keyHandler);
|
}
|
|
handleEscape(e) {
|
if (e.key === 'Escape') {
|
this.closePanel('Closed Queue Panel with escape key');
|
}
|
}
|
|
maybeAddEmptyState() {
|
let empty = this.queuedItems.querySelector('.emptyQueue');
|
if (empty) {
|
empty.remove();
|
}
|
if (this.queuedItems.children.length === 0) {
|
let empty = window.getTemplate('emptyQueue');
|
this.queuedItems.append(empty);
|
this.a11y.announce('Nothing queued.');
|
}
|
}
|
|
|
/**
|
* Track user activity to adjust polling
|
*/
|
setupActivityTracking() {
|
const events = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart'];
|
|
events.forEach(event => {
|
document.addEventListener(event, () => {
|
this.polling.lastActivity = Date.now();
|
}, { passive: true });
|
});
|
}
|
|
/**
|
*
|
* Queue Item Operations
|
*
|
*/
|
/**
|
* Add item to queue - Map operations work normally
|
*/
|
async addToQueue(operation) {
|
console.log('Queuing operation: ', operation);
|
let id = operation.id ?? this.generateId();
|
if ('append' in operation) {
|
id = id + operation.append;
|
}
|
|
if (!operation.endpoint || !operation.data) {
|
console.error('Invalid operation - missing endpoint or data');
|
return false;
|
}
|
|
const result = await this.updateItem(id, operation);
|
|
this.updateStatusPanel('pending');
|
this.addPopup(result.item.popup);
|
this.setChanges(true);
|
|
return result.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}`;
|
}
|
|
|
async performBulkAction(action, filterFn = null) {
|
const items = filterFn ?
|
[...this.queue.values()].filter(filterFn) :
|
[...this.queue.values()].filter(item => {
|
switch(action) {
|
case 'dismiss': return item.status === 'completed';
|
case 'retry': return ['failed', 'failed_permanent'].includes(item.status);
|
case 'cancel': return ['queued', 'pending'].includes(item.status);
|
default: return false;
|
}
|
});
|
|
if (!items.length) {
|
this.addPopup(`No operations available for ${action}`);
|
return;
|
}
|
|
try {
|
const result = await this.performQueueAction(
|
items.map(item => item.id),
|
action
|
);
|
|
// Handle results based on action
|
if (['dismiss', 'cancel'].includes(action)) {
|
result.processed_ids.forEach(id => this.removeItem(id, action));
|
} else if (action === 'retry') {
|
result.processed_ids.forEach(id => {
|
this.updateItem(id, {
|
status: 'pending',
|
retries: (this.queue.get(id)?.retries || 0) + 1,
|
error_message: null
|
});
|
});
|
this.setChanges(true);
|
}
|
|
this.addPopup(`${action} completed: ${result.processed_count} operations`);
|
} catch (error) {
|
// Centralized error handling
|
await window.jvbError.log(error, {
|
component: 'QueueManager',
|
action: `bulk_${action}`,
|
itemCount: items.length
|
});
|
}
|
}
|
|
handleFilterClick(e) {
|
this.filterButtons.forEach(btn => btn.classList.remove('active'));
|
const button = e.target.closest(this.elements.filterButtons);
|
button.classList.add('active');
|
const filter = button.dataset.filter || 'all';
|
this.fetchOperations(true, { status: filter });
|
}
|
|
handleRefreshClick(e) {
|
this.refresh.classList.add('refreshing');
|
this.fetchOperations(true, { force: true }).finally(() => {
|
this.refresh.classList.remove('refreshing');
|
});
|
}
|
|
togglePanel() {
|
this.panel.classList.toggle('expanded');
|
if (this.panel.classList.contains('expanded')) {
|
this.openPanel();
|
} else {
|
this.closePanel();
|
}
|
}
|
|
async performItemAction(operationId, action, options = {}) {
|
const item = this.queue.get(operationId);
|
if (!item) {
|
this.addPopup(`Operation ${operationId} not found`);
|
return false;
|
}
|
|
// Validate action is allowed for current status
|
const allowedActions = this.getAllowedActions(item.status);
|
if (!allowedActions.includes(action)) {
|
this.addPopup(`Cannot ${action} operation in current state`);
|
return false;
|
}
|
|
try {
|
// Update UI immediately for responsiveness
|
const tempStatus = `${action}ing`;
|
this.updateItem(operationId, { status: tempStatus });
|
this.addPopup(`${action.charAt(0).toUpperCase() + action.slice(1)}ing operation...`);
|
|
const result = await this.performQueueAction(operationId, action);
|
|
// Handle different action results
|
switch (action) {
|
case 'cancel':
|
case 'dismiss':
|
this.removeItem(operationId, action);
|
break;
|
case 'retry':
|
this.updateItem(operationId, {
|
status: 'pending',
|
retries: (item.retries || 0) + 1,
|
error_message: null,
|
retryAfter: null
|
});
|
this.setChanges(true);
|
break;
|
}
|
|
this.addPopup(`Operation ${action}ed successfully`);
|
return true;
|
|
} catch (error) {
|
// Revert status on error
|
this.updateItem(operationId, {
|
status: item.status,
|
error_message: `${action} failed: ${error.message}`
|
});
|
this.addPopup(`${action} failed: ${error.message}`);
|
return false;
|
}
|
}
|
|
getAllowedActions(status) {
|
const actionMap = {
|
'queued': ['cancel'],
|
'localProcessing': ['cancel'],
|
'pending': ['cancel'],
|
'processing': [],
|
'completed': ['dismiss'],
|
'failed': ['retry', 'dismiss'],
|
'failed_permanent': ['retry', 'dismiss']
|
};
|
return actionMap[status] || [];
|
}
|
|
/**
|
* Queue Operations
|
*/
|
/**
|
* Fetch operations from the server
|
* @param {boolean} refresh
|
* @param {Object} options Filter options
|
* @returns {Promise<Object>} Server response
|
*/
|
async fetchOperations(refresh = false, options = {}) {
|
const params = new URLSearchParams(options);
|
const url = `${this.API}?${params.toString()}`;
|
|
try {
|
const data = await this.cache.fetchWithCache(url,
|
{ method: 'GET', headers: this.defaultHeaders },
|
{
|
content: 'queue',
|
forceRefresh: refresh,
|
maxAge: 30000 // 30 seconds for queue data
|
}
|
);
|
|
if (data?.operations) {
|
this.processOperationsData(data.operations);
|
}
|
return data;
|
} catch (error) {
|
// Cache's fetchWithCache already handles errors via window.jvbError
|
return { operations: [], error: error.message };
|
}
|
}
|
|
async performQueueAction(operationIds, action) {
|
const ids = Array.isArray(operationIds) ? operationIds : [operationIds];
|
|
try {
|
const response = await fetch(this.API, {
|
method: 'POST',
|
headers: this.defaultHeaders,
|
body: JSON.stringify({ ids, action })
|
});
|
|
if (!response.ok) {
|
const errorData = await response.json().catch(() => ({}));
|
throw new Error(errorData.message || `${action} failed: ${response.status}`);
|
}
|
|
const result = await response.json();
|
if (!result.success) {
|
throw new Error(result.message || `${action} operation failed`);
|
}
|
|
return result;
|
|
} catch (error) {
|
const result = await window.jvbError.log(error, {
|
component: 'QueueManager',
|
operation: 'performQueueAction',
|
action: action,
|
operationIds: ids,
|
itemCount: ids.length
|
}, () => this.performQueueAction(operationIds, action)); // Retry callback
|
|
if (result.retried) {
|
return result; // Return successful retry result
|
} else {
|
throw error; // Re-throw if not retried
|
}
|
}
|
}
|
|
processOperationsData(operations) {
|
if (!Array.isArray(operations)) {
|
console.warn('Invalid operations data received:', operations);
|
return;
|
}
|
|
const validOperations = operations.filter(op =>
|
op && typeof op === 'object' && op.id
|
);
|
|
if (validOperations.length !== operations.length) {
|
console.warn(`Filtered ${operations.length - validOperations.length} invalid operations`);
|
}
|
|
// 🔧 FIX: Track which server operations we received
|
const serverIds = new Set(validOperations.map(op => op.id));
|
|
// Remove dismissed items (items not returned by server)
|
const itemsToRemove = [];
|
for (const [localId, localItem] of this.queue) {
|
if (!serverIds.has(localId) && localItem.status !== 'queued') {
|
// Item not returned by server and wasn't just queued - likely dismissed
|
itemsToRemove.push(localId);
|
}
|
}
|
|
// 🔧 FIX: Use centralized removeItem
|
for (const id of itemsToRemove) {
|
console.log(`Server dismissed operation: ${id}`);
|
this.removeItem(id, 'server-dismissed', { skipSave: true }); // Batch save below
|
}
|
|
// 🔧 FIX: Use centralized updateItem for all server updates
|
const updatePromises = validOperations.map(op =>
|
this.updateItem(op.id, op, { skipSave: true }) // Batch save below
|
);
|
|
// Wait for all updates to complete, then save once
|
Promise.all(updatePromises).then(() => {
|
this.saveQueue();
|
});
|
}
|
|
/**
|
*
|
* @param {boolean} on
|
*/
|
setChanges(on) {
|
this.hasChanges = on;
|
this.cache.setItem('queueHasChanges', on);
|
this.maybeProcessQueue(on);
|
}
|
|
/**
|
* Starts a timeout after X ms to process queue if no changes are made
|
* @param on
|
*/
|
maybeProcessQueue(on) {
|
console.log('Checking to process queue...');
|
if (!on) {
|
this.debouncer.cancel('queue');
|
return;
|
}
|
|
this.debouncer.schedule(
|
'queue',
|
() => { this.processQueue()},
|
2500
|
);
|
}
|
|
/**
|
* Method to manually trigger immediate processing (bypass debounce)
|
*/
|
processQueueImmediately() {
|
console.log('Processing queue immediately - bypassing debounce');
|
this.processQueue();
|
}
|
|
async processQueue() {
|
|
if (!navigator.onLine) {
|
console.log('Offline - postponing queue processing');
|
return;
|
}
|
|
const pendingItems = this.getPendingItems();
|
|
if (pendingItems.length === 0) {
|
this.setChanges(false);
|
return;
|
}
|
|
try {
|
console.log(`Processing ${pendingItems.length} queued items`);
|
|
const batchSize = this.getOptimalBatchSize();
|
for (let i = 0; i < pendingItems.length; i += batchSize) {
|
const batch = pendingItems.slice(i, i + batchSize);
|
await this.processBatchWithIsolation(batch);
|
|
if (i + batchSize < pendingItems.length) {
|
await new Promise(resolve => setTimeout(resolve, 500));
|
}
|
}
|
|
this.setChanges(false);
|
this.updateStatusPanel('synced');
|
|
} catch (error) {
|
await window.jvbError.log(error, {
|
component: 'QueueManager',
|
operation: 'processQueue',
|
pendingCount: pendingItems.length,
|
batchSize: this.getOptimalBatchSize()
|
});
|
|
this.setChanges(false);
|
this.addPopup('Processing will retry automatically');
|
}
|
}
|
|
getPendingItems() {
|
return [...this.queue.values()].filter(item =>
|
item.status === 'queued'
|
);
|
}
|
|
async processBatchWithIsolation(batch) {
|
const batchResults = [];
|
|
// Process each item with error isolation
|
for (const item of batch) {
|
try {
|
const result = await this.processItem(item);
|
batchResults.push({ id: item.id, success: true, result });
|
} catch (error) {
|
console.error(`Failed to process item ${item.id}:`, error);
|
batchResults.push({ id: item.id, success: false, error: error.message });
|
}
|
}
|
|
// Batch update all results
|
const updates = batchResults.map(result => ({
|
id: result.id,
|
data: result.success ?
|
{ status: 'pending', sent_at: Date.now() } :
|
{
|
status: 'failed',
|
error_message: result.error,
|
retries: (this.queue.get(result.id)?.retries || 0) + 1,
|
failed_at: Date.now()
|
}
|
}));
|
|
updates.forEach(item => this.updateItem(item.id, item.data, {skipSave: true}));
|
await this.saveQueue();
|
}
|
|
getOptimalBatchSize() {
|
const systemLoad = this.getSystemLoad();
|
|
if (systemLoad < 0.3) return 5; // Low load - larger batches
|
if (systemLoad < 0.6) return 3; // Medium load - medium batches
|
return 1; // High load - single items
|
}
|
|
getSystemLoad() {
|
// Simple heuristic based on queue size and active operations
|
const queueSize = this.queue.size;
|
const activeCount = [...this.queue.values()]
|
.filter(item => ['localProcessing', 'uploading'].includes(item.status)).length;
|
|
return Math.min((queueSize + activeCount * 2) / 20, 1);
|
}
|
|
sendUpdate(item) {
|
if ('onUpdate' in item && typeof item.onUpdate === "function") {
|
console.log('Calling on update for item');
|
item.onUpdate(item);
|
}
|
}
|
sendComplete(item) {
|
if ('onComplete' in item && typeof item.onComplete === "function") {
|
item.onComplete(item);
|
}
|
}
|
|
async processItem(item) {
|
console.log('Processing item:', item.id, item.title);
|
this.a11y.announce(`Processing ${item.title}...`);
|
this.updateItem(item.id, {status: 'localProcessing'});
|
|
try {
|
let result = await this.makeRequest(item);
|
|
this.updateItem(item.id, {
|
status: 'pending',
|
result: result,
|
sent_at: Date.now()
|
});
|
|
this.a11y.announce(`${item.title} sent to server for processing`);
|
|
// Only start polling if we're not already polling
|
if (!this.polling.isActive) {
|
console.log('Starting polling for pending server operations');
|
this.startPolling();
|
}
|
|
} catch (error) {
|
const result = await window.jvbError.log(error, {
|
component: 'QueueManager',
|
operation: 'processItem',
|
operationId: item.id
|
}, () => this.makeRequest(item)); // Retry callback
|
|
if (!result.retried) {
|
this.updateItem(item.id, {
|
status: (item.retries || 0) >= this.maxRetries ? 'failed_permanent' : 'failed',
|
error_message: result.message,
|
retries: (item.retries || 0) + 1
|
});
|
}
|
}
|
}
|
|
/**
|
* Send operation to server
|
* @param item
|
* @returns {Promise<any>}
|
*/
|
async makeRequest(item) {
|
this.updateItem(item.id, {status: 'uploading'});
|
|
// Check if data is FormData
|
const isFormData = item.data instanceof FormData;
|
|
let headers = {
|
'X-WP-Nonce': jvbSettings.nonce,
|
...item.headers
|
};
|
|
// Only set Content-Type for non-FormData requests
|
if (!isFormData) {
|
headers['Content-Type'] = 'application/json';
|
}
|
|
console.log('Sending Data...', item.data);
|
|
let requestBody;
|
|
if (isFormData) {
|
// For FormData, append user and id directly to the FormData
|
item.data.append('user', jvbSettings.currentUser);
|
item.data.append('id', item.id);
|
requestBody = item.data;
|
} else if (typeof item.data === "object") {
|
// For regular objects, JSON stringify as before
|
item.data['user'] = jvbSettings.currentUser;
|
item.data['id'] = item.id;
|
requestBody = JSON.stringify(item.data);
|
} else {
|
// For strings or other data types
|
requestBody = item.data;
|
}
|
|
try {
|
const response = await fetch (`${jvbSettings.api}${item.endpoint}`,
|
{
|
method: item.method,
|
headers,
|
body: requestBody
|
});
|
if (!response.ok) {
|
const errorData = await response.json().catch(() => ({}));
|
throw new Error(errorData.message || `Request failed with status ${response.status}`);
|
}
|
|
return await response.json();
|
} catch (error) {
|
throw error;
|
}
|
|
}
|
|
/**
|
* Smart polling with backoff
|
*/
|
async startPolling() {
|
if (this.polling.isActive) return;
|
|
this.polling.isActive = true;
|
this.polling.consecutiveNoChanges = 0;
|
this.polling.startTime = Date.now();
|
|
const poll = async () => {
|
try {
|
if (!this.shouldContinuePolling()) {
|
this.stopPolling();
|
return;
|
}
|
|
const result = await this.cache.fetchWithCache(
|
`${this.API}?${new URLSearchParams({ polling: 'true' })}`,
|
{ method: 'GET', headers: this.defaultHeaders },
|
{
|
maxAge: 30000,
|
content: 'queue_polling',
|
timeout: 15000
|
}
|
);
|
|
if (result.operations && Array.isArray(result.operations)) {
|
const hasChanges = this.processPollingData(result.operations);
|
this.polling.consecutiveNoChanges = hasChanges ? 0 : this.polling.consecutiveNoChanges + 1;
|
} else {
|
this.polling.consecutiveNoChanges++;
|
}
|
|
const delay = this.calculatePollingDelay();
|
if (this.polling.isActive) {
|
this.polling.interval = setTimeout(poll, delay);
|
}
|
|
} catch (error) {
|
await window.jvbError.log(error, {
|
component: 'QueueManager',
|
operation: 'polling',
|
consecutiveFailures: this.polling.consecutiveNoChanges,
|
isActive: this.polling.isActive
|
});
|
|
this.polling.consecutiveNoChanges++;
|
|
if (this.polling.isActive && this.shouldContinuePolling()) {
|
const errorDelay = Math.min(this.polling.max, 30000);
|
this.polling.interval = setTimeout(poll, errorDelay);
|
} else {
|
this.stopPolling();
|
}
|
}
|
};
|
|
poll();
|
}
|
|
calculatePollingDelay() {
|
const baseDelay = this.polling.base;
|
const maxDelay = this.polling.max;
|
|
// Exponential backoff based on consecutive no-changes
|
const backoffMultiplier = Math.min(
|
Math.pow(1.5, this.polling.consecutiveNoChanges),
|
8 // Cap the multiplier
|
);
|
|
// Reduce frequency if user is inactive
|
const inactivityMultiplier = this.isUserActive() ? 1 : 2;
|
|
return Math.min(
|
baseDelay * backoffMultiplier * inactivityMultiplier,
|
maxDelay
|
);
|
}
|
|
isUserActive() {
|
return this.polling.lastActivity &&
|
(Date.now() - this.polling.lastActivity) < 60000; // 1 minute
|
}
|
|
processPollingData(operations) {
|
if (operations.length === 0) return false;
|
|
let hasChanges = false;
|
|
// Update operations from server (batch at end)
|
for (const operation of operations) {
|
const localItem = this.queue.get(operation.id);
|
|
if (!localItem) {
|
// New operation from server
|
this.updateItem(operation.id, operation, { skipSave: true });
|
hasChanges = true;
|
} else if (localItem.status !== operation.status ||
|
localItem.progress_percentage !== operation.progress_percentage) {
|
// Status or progress changed
|
this.updateItem(operation.id, operation, { skipSave: true });
|
hasChanges = true;
|
}
|
}
|
|
// Remove operations completed/dismissed on server
|
const serverIds = new Set(operations.map(op => op.id));
|
for (const [localId, localItem] of this.queue) {
|
if (!serverIds.has(localId) &&
|
['pending', 'processing'].includes(localItem.status)) {
|
this.removeItem(localId, 'server-completed', { skipSave: true });
|
hasChanges = true;
|
}
|
}
|
|
// Single save if any changes
|
if (hasChanges) {
|
this.saveQueue();
|
console.debug(`Polling update completed`);
|
}
|
|
return hasChanges;
|
}
|
/**
|
* Check if polling should continue
|
* @returns {boolean} Whether to continue polling
|
*/
|
shouldContinuePolling() {
|
// Stop if offline
|
if (!navigator.onLine) {
|
return false;
|
}
|
|
// Check for local items that need server monitoring
|
const pendingServerItems = [...this.queue.values()].filter(item =>
|
['pending', 'processing', 'uploading'].includes(item.status)
|
);
|
|
// If we have items waiting on the server, keep polling
|
if (pendingServerItems.length > 0) {
|
return true;
|
}
|
|
// Also stop if user has been inactive for too long
|
const isUserInactive = this.polling.lastActivity &&
|
(Date.now() - this.polling.lastActivity) > (5 * 60 * 1000); // 5 minutes
|
|
if (isUserInactive) {
|
console.log('User inactive for 5+ minutes - stopping polling');
|
return false;
|
}
|
|
// Stop polling if no server-pending items
|
return false;
|
}
|
|
stopPolling() {
|
this.polling.isActive = false;
|
if (this.polling.interval) {
|
clearTimeout(this.polling.interval);
|
this.polling.interval = null;
|
}
|
console.log('Polling stopped');
|
}
|
|
|
/**
|
* Fetch specific operation by ID
|
*/
|
async fetchOperation(operationId) {
|
return await this.fetchOperations(true, {
|
ids: operationId,
|
limit: 1
|
});
|
}
|
|
|
/**
|
* Load queue from cache - now just 12 lines!
|
*/
|
async loadQueue() {
|
try {
|
const [queueMap, metadata] = await Promise.all([
|
this.cache.getItem('user_queue', 'queue'),
|
this.cache.getItem('queue_metadata', 'queue')
|
]);
|
|
if (queueMap instanceof Map && queueMap.size > 0) {
|
console.debug(`Loading ${queueMap.size} queue items from cache`);
|
this.queue.clear();
|
|
const updates = Array.from(queueMap.entries()).map(([id, item]) => ({
|
id, data: item
|
}));
|
|
updates.forEach(item => this.updateItem(item.id, item.data, {skipSave: true}));
|
await this.saveQueue();
|
} else {
|
this.queue = new Map();
|
}
|
|
this.updateUIFromLoadedQueue();
|
|
} catch (error) {
|
await window.jvbError.log(error, {
|
component: 'QueueManager',
|
operation: 'loadQueue',
|
action: 'cache_recovery'
|
});
|
|
this.queue = new Map();
|
this.addPopup('Queue data recovered');
|
}
|
}
|
|
/**
|
* Save queue to cache
|
*/
|
async saveQueue() {
|
try {
|
const filteredQueue = this.filterQueueForSaving();
|
const stats = this.getQueueStats();
|
|
await Promise.all([
|
this.cache.setItem('user_queue', filteredQueue, 'queue'),
|
this.cache.setItem('queue_stats', stats, 'queue'),
|
this.cache.setItem('queue_metadata', {
|
lastSaved: Date.now(),
|
version: '1.0',
|
totalOperations: filteredQueue.size,
|
hasChanges: this.hasChanges
|
}, 'queue')
|
]);
|
|
} catch (error) {
|
const result = await window.jvbError.log(error, {
|
component: 'QueueManager',
|
operation: 'saveQueue',
|
queueSize: this.queue.size
|
}, () => this.saveQueue()); // Retry callback
|
|
if (!result.retried) {
|
// Fallback save
|
try {
|
await this.cache.setItem('user_queue', new Map(), 'queue');
|
} catch (fallbackError) {
|
console.warn('Even fallback save failed');
|
}
|
}
|
}
|
}
|
|
/**
|
* Helper: Filter queue items that should be saved
|
*/
|
filterQueueForSaving() {
|
const filteredQueue = new Map();
|
|
for (const [id, item] of this.queue) {
|
if (this.shouldSaveItem(item)) {
|
filteredQueue.set(id, item);
|
}
|
}
|
|
return filteredQueue;
|
}
|
|
/**
|
* Helper: Determine if an item should be persisted
|
*/
|
shouldSaveItem(item) {
|
// Always save active items
|
if (['queued', 'localProcessing', 'uploading'].includes(item.status)) {
|
return true;
|
}
|
|
// Save recent completed items (1 hour)
|
if (item.status === 'completed' && item.completed_at) {
|
const oneHourAgo = Date.now() - (60 * 60 * 1000);
|
return new Date(item.completed_at).getTime() > oneHourAgo;
|
}
|
|
// Save failed items that can still be retried
|
return (item.status === 'failed' || item.status === 'failed_permanent') &&
|
item.retries < this.maxRetries;
|
}
|
|
/**
|
* 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;
|
}
|
|
/**
|
*
|
* UI Updates
|
*
|
*/
|
/**
|
* Enhanced action buttons with cancel support
|
*/
|
updateActionButtons(item, container) {
|
window.removeChildren(container);
|
|
switch (item.status) {
|
case 'queued':
|
case 'localProcessing':
|
case 'pending':
|
// Show cancel button for in-progress items
|
const cancelBtn = window.getTemplate('button');
|
cancelBtn.classList.add('cancel');
|
cancelBtn.textContent = 'Cancel';
|
container.appendChild(cancelBtn);
|
break;
|
|
case 'failed':
|
case 'failed_permanent':
|
// Show retry and dismiss buttons
|
const retryBtn = window.getTemplate('button');
|
const dismissBtn = window.getTemplate('button');
|
|
retryBtn.classList.add('retry');
|
retryBtn.textContent = 'Retry';
|
retryBtn.disabled = item.retries >= this.maxRetries;
|
|
dismissBtn.classList.add('dismiss');
|
dismissBtn.textContent = 'Dismiss';
|
|
container.appendChild(retryBtn);
|
container.appendChild(dismissBtn);
|
break;
|
|
case 'completed':
|
// Show dismiss button only
|
const dismissCompletedBtn = window.getTemplate('button');
|
dismissCompletedBtn.classList.add('dismiss');
|
dismissCompletedBtn.textContent = 'Dismiss';
|
container.appendChild(dismissCompletedBtn);
|
break;
|
}
|
}
|
|
/**
|
* Update Queue Item
|
* @param id
|
* @param newData
|
* @param {object} options
|
* @param {bool} options.skipSave If you don't want to update the queue
|
* @param {bool} options.skipUI If you don't want to update the UI
|
* @returns {Promise<{item: any, wasNew: boolean, oldStatus: null}>}
|
*/
|
async updateItem(id, newData, options = {}) {
|
const wasNew = !this.queue.has(id);
|
let oldStatus = null;
|
let oldItem = null;
|
|
if (wasNew) {
|
//Check queue for any item in the queue with the same endpoint and that canMerge === true
|
let pendingItems = this.getPendingItems();
|
if (pendingItems.length > 0) {
|
for (const item of pendingItems) {
|
if (item.canMerge && item.endpoint === newData.endpoint){
|
let mergeData = window.deepMerge(item.data, newData.data);
|
newData = item;
|
id = newData.id;
|
newData.data = mergeData;
|
break;
|
}
|
}
|
}
|
// Create new item with defaults
|
const item = {
|
id: id,
|
endpoint: false,
|
method: 'POST',
|
headers: {},
|
canMerge: true,
|
title: 'Queue operation',
|
popup: 'Processing...',
|
user: jvbSettings.currentUser,
|
started_at: Date.now(),
|
status: 'queued',
|
retries: 0,
|
dependencies: [],
|
type: false,
|
data: false,
|
...newData
|
};
|
this.queue.set(id, item);
|
console.log(`Created new queue item: ${id}`);
|
} else {
|
// Update existing item
|
oldItem = this.queue.get(id);
|
oldStatus = oldItem.status;
|
|
// Deep merge for data, shallow merge for other properties
|
const updated = {
|
...oldItem,
|
...newData
|
};
|
|
// Special handling for data merging if both exist
|
if (oldItem.data && newData.data && typeof oldItem.data === 'object' && typeof newData.data === 'object') {
|
updated.data = window.deepMerge(oldItem.data, newData.data);
|
}
|
|
this.queue.set(id, updated);
|
}
|
|
const item = this.queue.get(id);
|
|
// Handle status change side effects
|
if (oldStatus !== item.status) {
|
this.handleStatusChange(item, oldStatus, wasNew);
|
}
|
|
// Always save and update UI unless explicitly skipped
|
if (!options.skipSave) {
|
await this.saveQueue();
|
}
|
if (!options.skipUI) {
|
this.scheduleUIUpdate(id, item);
|
}
|
|
return { item, wasNew, oldStatus };
|
}
|
|
handleStatusChange(item, oldStatus) {
|
// Start polling if server-pending
|
if (['pending', 'processing'].includes(item.status) && !this.polling.isActive) {
|
this.startPolling();
|
}
|
|
if(oldStatus && item.status !== oldStatus) {
|
if (['completed', 'failed_permanent'].includes(item.status)){
|
this.sendComplete(item);
|
} else {
|
this.sendUpdate(item);
|
}
|
}
|
}
|
|
/**
|
* THE ONLY removeItem method - replaces all duplicates
|
* @param id The Operation ID
|
* @param {string} reason An optional message
|
* @param {object} options Optional options Including:
|
* @param {bool} options.skipSave Whether to skip saving the queue
|
* @param {bool} options.skipUI Whether to skip UI updates
|
*/
|
async removeItem(id, reason = 'dismissed', options = {}) {
|
const item = this.queue.get(id);
|
if (!item) {
|
console.warn(`Attempted to remove non-existent item: ${id}`);
|
return false;
|
}
|
|
// Log removal for debugging
|
console.log(`Removing queue item ${id} (${item.title}) - reason: ${reason}`);
|
|
// Handle removal side effects
|
this.handleItemRemoval(item, reason);
|
|
// Remove from queue
|
this.queue.delete(id);
|
|
// Always save and update UI unless explicitly skipped
|
if (!options.skipSave) {
|
await this.saveQueue();
|
}
|
if (!options.skipUI) {
|
this.scheduleUIUpdate(id, null);
|
}
|
|
return true;
|
}
|
|
handleItemRemoval(item, reason) {
|
// Clear any related timeouts/intervals
|
if (item.retryTimeout) {
|
clearTimeout(item.retryTimeout);
|
}
|
|
// Log analytics/metrics
|
if (reason === 'completed') {
|
console.log(`Operation completed: ${item.title} (${Date.now() - item.started_at}ms)`);
|
}
|
|
// Clean up any temporary data
|
if (item.data instanceof FormData) {
|
// FormData cleanup if needed
|
console.log('Cleaned up FormData for removed item');
|
}
|
}
|
|
/**
|
* Schedule UI update (batched)
|
*/
|
scheduleUIUpdate(itemId, item = null) {
|
this.pendingUIUpdates.set(itemId, item);
|
if (!this.uiUpdateScheduled) {
|
this.uiUpdateScheduled = true;
|
requestAnimationFrame(() => {
|
this.processPendingUIUpdates();
|
this.uiUpdateScheduled = false;
|
});
|
}
|
}
|
|
/**
|
* Process all pending UI updates in a single batch
|
*/
|
processPendingUIUpdates() {
|
if (this.pendingUIUpdates.size === 0) return;
|
|
const updates = new Map(this.pendingUIUpdates);
|
this.pendingUIUpdates.clear();
|
|
// Process all updates
|
for (const [itemId, item] of updates) {
|
if (item === null) {
|
this.removeItemFromUI(itemId);
|
} else {
|
this.updateItemInUI(item);
|
}
|
}
|
|
// Update global UI state once after all items processed
|
this.updateGlobalUIState();
|
}
|
|
/**
|
* Update or create a single item in the UI
|
*/
|
updateItemInUI(item) {
|
let listItem = this.queuedItems.querySelector(`.item[data-id="${item.id}"]`);
|
|
if (!listItem) {
|
listItem = this.createItemElement(item);
|
this.queuedItems.prepend(listItem);
|
} else {
|
this.updateItemElement(listItem, item);
|
}
|
}
|
|
/**
|
* Remove item from UI with animation
|
*/
|
removeItemFromUI(itemId) {
|
const element = this.queuedItems.querySelector(`.item[data-id="${itemId}"]`);
|
if (!element) return;
|
|
element.style.transition = 'opacity 0.3s ease-out, transform 0.3s ease-out';
|
element.style.opacity = '0';
|
element.style.transform = 'translateX(-100%)';
|
|
setTimeout(() => {
|
element.remove();
|
this.updateGlobalUIState();
|
}, 300);
|
}
|
|
/**
|
* Create a new item element
|
*/
|
createItemElement(item) {
|
const listItem = window.getTemplate('queueItem');
|
listItem.dataset.id = item.id;
|
this.updateItemElement(listItem, item);
|
return listItem;
|
}
|
|
/**
|
* Update an existing item element
|
*/
|
updateItemElement(element, item) {
|
// Remove old status classes
|
this.statuses.forEach(status => element.classList.remove(status));
|
element.classList.add(item.status);
|
|
// Update content
|
let timeDisplay = '';
|
|
if (item.updated_at) {
|
// Server now sends ISO format timestamps - much more reliable!
|
timeDisplay = window.formatTimeAgo(new Date(item.updated_at));
|
} else if (item.created_at) {
|
timeDisplay = window.formatTimeAgo(new Date(item.created_at));
|
}
|
const progressPercent = this.calculateProgress(item);
|
|
// Update text content safely
|
const typeEl = element.querySelector('.type');
|
const statusEl = element.querySelector('.status');
|
const detailsEl = element.querySelector('.info .details');
|
const timeEl = element.querySelector('.info .time');
|
const progressFill = element.querySelector('.progress .fill');
|
|
if (typeEl) typeEl.textContent = item.title;
|
if (statusEl) {
|
statusEl.querySelector('.icon')?.remove();
|
let status = this.getStatusLabel(item.status);
|
statusEl.title = status;
|
statusEl.prepend(window.getIcon(this.icons[item.status]));
|
statusEl.querySelector('span').textContent = status;
|
}
|
if (detailsEl) detailsEl.textContent = this.getItemMessage(item);
|
if (timeEl) timeEl.textContent = timeDisplay;
|
if (progressFill) progressFill.style.width = `${progressPercent}%`;
|
|
if (item.status === 'processing' && item.estimated_completion) {
|
const estimatedEl = element.querySelector('.estimated-completion');
|
if (estimatedEl) {
|
const remaining = window.formatTimeSoon(new Date(item.estimated_completion));
|
estimatedEl.textContent = `Est. ${remaining}`;
|
}
|
}
|
|
// Update action buttons
|
const actionsContainer = element.querySelector('.actions');
|
if (actionsContainer) {
|
this.updateActionButtons(item, actionsContainer);
|
}
|
}
|
|
/**
|
* Update global UI state (status, counts, etc.)
|
*/
|
updateGlobalUIState() {
|
this.updateStatusPanel();
|
this.updateFilterCounts();
|
this.maybeAddEmptyState();
|
}
|
|
/**
|
* Helper: Update UI after loading queue
|
*/
|
updateUIFromLoadedQueue() {
|
// Schedule updates for all loaded items
|
for (const item of this.queue.values()) {
|
this.scheduleUIUpdate(item.id, item);
|
}
|
|
// Check if there are pending changes
|
this.setChanges([...this.queue.values()].some(item =>
|
['queued', 'localProcessing', 'uploading'].includes(item.status)
|
));
|
|
if (this.hasChanges) {
|
this.updateStatusPanel('pending');
|
}
|
}
|
/**
|
* 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();
|
}
|
});
|
}
|
}
|
|
/**
|
* 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');
|
if (!badge) {
|
badge = document.createElement('span');
|
badge.className = 'count';
|
button.appendChild(badge);
|
}
|
badge.textContent = count;
|
} else {
|
const badge = button.querySelector('.count');
|
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;
|
}
|
|
this.dismissButton.disabled = stats.completed === 0;
|
}
|
/**
|
* 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);
|
}
|
|
|
calculateProgress(item) {
|
// Use server-provided progress percentage if available
|
if (item.progress_percentage !== undefined) {
|
return Math.max(0, Math.min(100, item.progress_percentage));
|
}
|
|
// Fallback to status-based progress
|
switch (item.status) {
|
case 'queued':
|
case 'failed':
|
case 'failed_permanent':
|
return 0;
|
case 'localProcessing':
|
return 5;
|
case 'uploading':
|
return 40;
|
case 'pending':
|
return 65;
|
case 'processing':
|
return 85;
|
case 'completed':
|
return 100;
|
default:
|
return 0;
|
}
|
}
|
/**
|
* 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) {
|
// Server provides better error messages and status info
|
let message = this.statusMessages[item.status] || '';
|
|
// Show progress for processing items
|
if (item.status === 'processing' && item.progress_count && item.count) {
|
message = `Processing ${item.progress_count} of ${item.count}`;
|
if (item.progress_percentage) {
|
message += ` (${item.progress_percentage}%)`;
|
}
|
}
|
|
// Add error if failed
|
if ((item.status === 'failed' || item.status === 'failed_permanent') && item.error_message) {
|
message = `Error: ${item.error_message}`;
|
}
|
|
// Show estimated completion for processing items
|
if (item.status === 'processing' && item.estimated_completion) {
|
const remaining = window.formatTimeSoon(new Date(item.estimated_completion));
|
message += ` • Est. ${remaining}`;
|
}
|
|
return message;
|
}
|
|
cleanup() {
|
// Clear all timeouts and intervals
|
this.stopPolling();
|
|
// Remove all event listeners
|
document.removeEventListener('click', this.clickHandler);
|
window.removeEventListener('online', this.handleOnline);
|
window.removeEventListener('offline', this.handleOffline);
|
window.removeEventListener('beforeunload', this.handleBeforeUnload);
|
document.removeEventListener('keydown', this.handleEscape);
|
|
// Clear activity tracking
|
const events = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart'];
|
events.forEach(event => {
|
document.removeEventListener(event, this.trackActivity);
|
});
|
|
// Clear references
|
this.queue.clear();
|
this.pendingUIUpdates.clear();
|
|
console.log('QueueManager destroyed');
|
}
|
|
/**
|
* Create reply button template
|
*/
|
createReplyButtonTemplate() {
|
const button = document.createElement('button');
|
button.type = 'button';
|
button.className = 'reply';
|
button.dataset.action = 'make-response';
|
button.innerHTML = window.getIcon('reply') + '<span>Reply</span>';
|
return button;
|
}
|
|
/**
|
* Create comments button template
|
*/
|
createCommentsButtonTemplate() {
|
const button = document.createElement('a');
|
button.className = 'button';
|
button.innerHTML = window.getIcon('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">${window.getIcon('upvoted') + window.getIcon('upvote')}
|
<span>{ <span class="count">0</span> }</span>
|
</button>
|
<button type="button" class="down" onClick="handleVote(this)"
|
data-vote="down">${window.getIcon('downvoted') + window.getIcon('downvote')}
|
<span>{ <span class="count">0</span> }</span>
|
</button>
|
`;
|
return vote;
|
}
|
}
|
//
|
// document.addEventListener('DOMContentLoaded', () => {
|
// window.jvbQueue = new QueueManager();
|
// });
|
|
// 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;
|
}
|
});
|
});
|
|
// window.addEventListener('beforeunload', () => window.jvbQueue?.cleanup());
|