From e6287fd606e6e3220261fd68c394989e6ade0f90 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Fri, 02 Jan 2026 23:41:57 +0000
Subject: [PATCH] Merge branch 'main' of https://github.com/jakevdwerf/jvb
---
assets/js/concise/Queue.js | 372 ++++++++++++++++++++++------------------------------
1 files changed, 158 insertions(+), 214 deletions(-)
diff --git a/assets/js/concise/Queue.js b/assets/js/concise/Queue.js
index 297885d..7e3033c 100644
--- a/assets/js/concise/Queue.js
+++ b/assets/js/concise/Queue.js
@@ -14,11 +14,35 @@
endpoint: 'queue',
...config
};
- this.user = jvbSettings.currentUser;
+ // Queue state
+ this.isProcessing = false;
+ this.isPolling = false;
+ this.subscribers = new Set();
+
+ // Status definitions
+ this.statuses = [
+ 'queued',
+ 'localProcessing',
+ 'uploading',
+ 'pending',
+ 'processing',
+ 'completed',
+ 'failed',
+ 'failed_permanent'
+ ];
+
+ this.user = window.auth.getUser();
+
+ if (!this.user) {
+ console.log('Queue: User not logged in, queue disabled');
+ this.store = null;
+ this.canUpdateUI = false;
+ return;
+ }
this.headers = {
- 'X-WP-Nonce': jvbSettings.nonce,
+ 'X-WP-Nonce': window.auth.getNonce(),
...config.headers
};
@@ -46,22 +70,7 @@
'pending'
];
- // Queue state
- this.isProcessing = false;
- this.isPolling = false;
- this.subscribers = new Set();
- // Status definitions
- this.statuses = [
- 'queued',
- 'localProcessing',
- 'uploading',
- 'pending',
- 'processing',
- 'completed',
- 'failed',
- 'failed_permanent'
- ];
// Initialize
this.initUI();
@@ -73,46 +82,30 @@
name: 'Queue Panel',
});
}
-
+ this.updateUI = () => window.debouncer.schedule('queue-ui-update', this._updateUI.bind(this), 100);
this.initQueue();
-
- if (this.user) {
- this.ui.toggle.hidden = false;
- this.ui.panel.hidden = false;
- }
}
async initQueue() {
- const incomplete = this.getOperationsByStatus(['completed', 'failed_permanent'], false)
-
- if (incomplete.length > 0) {
- this.startPolling();
- } else {
+ let polling = this.maybeStartPolling();
+ if (!polling) {
this.updateStatusPanel('synced');
}
+
this.store.subscribe((event, data) => {
switch (event) {
case 'data-loaded':
case 'items-saved':
- // Initial load from IndexedDB
- const incomplete = this.getOperationsByStatus(['completed', 'failed_permanent'], false);
- if (incomplete.length > 0) {
- this.startPolling();
- }
+ this.maybeStartPolling();
this.updateUI();
break;
case 'item-saved':
- // Check for status changes
- if (data.item) {
- const oldItem = this.store.data.get(data.item.id);
- if (oldItem && oldItem.status !== data.item.status) {
- this.handleOperationStatusChange(data.item, oldItem.status);
- }
+ console.log(data,'Item saved data');
+ if (data.previousItem && data.previousItem.status !== data.item.status) {
+ this.handleOperationStatusChange(data.item, data.previousItem.status);
}
- if (this.hasQueuedOperations()) {
- this.startPolling();
- }
+ this.maybeStartPolling();
break;
default:
this.updateUI();
@@ -120,18 +113,27 @@
}
});
- this.notify('queue-initialized', {operations: incomplete});
+ }
+
+ maybeStartPolling()
+ {
+ const incomplete = this.getOperationsByStatus(['completed', 'failed_permanent'], false);
+ if (incomplete.length > 0) {
+ this.startPolling();
+ return true;
+ }
+ return false;
}
/**
* Handle operation status changes and notify subscribers
*/
- handleOperationStatusChange(operation, oldStatus) {
- if (!operation || oldStatus === operation.status) return;
+ handleOperationStatusChange(operation) {
// Notify based on new status
switch(operation.status) {
case 'completed':
+ console.log(operation);
this.notify('operation-completed', operation);
break;
case 'failed':
@@ -161,6 +163,7 @@
method: 'POST',
headers: {},
data: {},
+ sendNow: false, // true = process immediately
canMerge: true,
popup: 'Saving changes...',
title: 'Operation',
@@ -181,6 +184,14 @@
return null;
}
+ if (item.sendNow) {
+ this.processOperation(item).then(()=> {});
+ this.store.clearCache();
+ window.debouncer.schedule('fastQueue', this.startPolling.bind(this), 200);
+ this.showQueue();
+ return item.id;
+ }
+
const existingOps = Array.from(this.store.data.values()).filter(op=>
op.status === 'queued' &&
op.endpoint === item.endpoint &&
@@ -199,7 +210,6 @@
return existing.id;
}
- console.log('Added to Queue: ', item);
this.store.clearCache();
//Add new operation to DataStore
@@ -214,15 +224,16 @@
}
+
setQueue(item) {
- this.store.save(item); // Remove first parameter
+ this.store.save(item);
}
updateOperationStatus(itemID, status) {
let item = this.store.get(itemID);
- if (!item){
- return;
- }
+ if (!item) return;
+
+ // Update status
item.status = status;
this.notify('operation-status', item);
@@ -250,8 +261,6 @@
}
resetActivityTimer() {
- this.lastActivity = Date.now();
-
if (this.activityTimer) {
clearTimeout(this.activityTimer);
}
@@ -274,6 +283,15 @@
}
}
+ hideQueue(){
+ this.ui.panel.hidden = true;
+ this.ui.toggle.hidden = true;
+ }
+ showQueue() {
+ this.ui.panel.hidden = false;
+ this.ui.toggle.hidden = false;
+ }
+
setProcessing(on) {
this.isProcessing = on;
this.ui.toggle.classList.toggle('saving', on);
@@ -300,18 +318,17 @@
this.setProcessing(false);
this.stopActivityTracking();
- const pending = this.getOperationsByStatus(['queued', 'completed', 'failed_permanent'], false);
- if (pending.length > 0) {
- this.startPolling();
- }
+ this.maybeStartPolling() ? this.showQueue() : this.hideQueue();
}
- async processOperation(operation) {
+ async processOperation(operation, skip = false) {
try {
- this.updateOperationStatus(operation.id, 'uploading');
+ if (!skip) {
+ this.updateOperationStatus(operation.id, 'uploading');
- if (operation.data?._isFormData) {
- operation.data = await this.store.objectToFormData(operation.data);
+ if (operation.data?._isFormData) {
+ operation.data = await this.store.objectToFormData(operation.data);
+ }
}
const url = `${this.config.apiBase}${operation.endpoint}`;
@@ -329,7 +346,6 @@
});
operation.headers['Content-Type'] = 'application/json';
}
-
const response = await fetch(url, {
method: operation.method,
headers: operation.headers,
@@ -337,43 +353,17 @@
});
const result = await response.json();
-
+ if (skip) {
+ operation.data = {};
+ }
if (response.ok && result.success !== false) {
// Handle server-side merge
if (result.id && operation.id !== result.id) {
-
- // Check if the returned ID exists locally
- const existingOp = this.getQueue(result.id);
-
- if (existingOp) {
- // Merge data from both operations
- existingOp.data = window.deepMerge(existingOp.data, operation.data);
- existingOp.status = 'pending';
- existingOp.serverData = result;
- this.updateOperationStatus(existingOp.id, existingOp.status);
- // Update the existing operation
- this.setQueue(existingOp);
-
- this.removeOperationFromUI(operation.id);
-
- // Switch reference to the merged operation
- operation = existingOp;
- } else {
- // Server merged with an operation we don't have locally
- // Update the ID and continue
- this.clearQueue(operation.id);
- operation.id = result.id;
- operation.status = 'pending';
- operation.serverData = result;
- this.updateOperationStatus(operation.id, operation.status);
- this.setQueue(operation);
- }
+ operation = await this.handleServerMerge(operation, result);
} else {
- // Normal processing - no merge
- operation.status = 'pending';
+ operation.status = result.status || 'pending';
operation.serverData = result;
- this.updateOperationStatus(operation.id, 'pending');
- this.setQueue(operation);
+ this.updateOperationStatus(operation.id, operation.status);
}
this.a11y.announce(`${operation.title} sent to server for processing.`);
@@ -399,6 +389,29 @@
}
}
+ async handleServerMerge(operation, result) {
+ const existingOp = this.getQueue(result.id);
+
+ if (existingOp) {
+ // Merge with existing local operation
+ existingOp.data = window.deepMerge(existingOp.data, operation.data);
+ existingOp.status = result.status || 'pending';
+ existingOp.serverData = result;
+ this.updateOperationStatus(existingOp.id, existingOp.status);
+ this.removeOperationFromUI(operation.id);
+ this.clearQueue(operation.id);
+ return existingOp;
+ } else {
+ // Server merged with unknown operation
+ this.clearQueue(operation.id);
+ operation.id = result.id;
+ operation.status = result.status || 'pending';
+ operation.serverData = result;
+ this.updateOperationStatus(operation.id, operation.status);
+ return operation;
+ }
+ }
+
startPolling() {
if (this.isPolling) return;
@@ -410,8 +423,7 @@
this.store.clearCache();
await this.store.fetch(); // Fetches from server, updates store.data
- const incomplete = this.getOperationsByStatus(['completed', 'failed_permanent'], false);
- if (incomplete.length === 0) {
+ if (!this.maybeStartPolling()) {
this.stopPolling();
this.updateStatusPanel('synced');
}
@@ -433,7 +445,9 @@
this.countdownTimer = null;
}
}
-
+ getOperationIds(operations) {
+ return operations.map(op => op.id);
+ }
/***********************************************************
USER ACTIONS
***********************************************************/
@@ -445,41 +459,29 @@
* @returns {Promise<void>}
*/
async updateServerOperations(ids, action) {
- //ensure ids are in an array
- ids = Array.isArray(ids) ? ids : ((ids.includes(',')) ? ids.split(',') : [ids]);
- ids = ids.filter((id) => {
+ ids = Array.isArray(ids) ? ids : (ids.includes(',') ? ids.split(',') : [ids]);
+ ids = ids.filter(id => {
let item = this.getQueue(id);
return this.getAllowedActions(item.status).includes(action);
});
- if (ids.length === 0) {
- return;
- }
+ if (ids.length === 0) return;
- if (['cancel', 'dismiss'].includes(action)) {
- ids.forEach(id => {
- this.removeOperationFromUI(id);
- });
+ // SINGLE place to handle UI removal
+ const shouldRemove = ['cancel', 'dismiss'].includes(action);
+ if (shouldRemove) {
+ ids.forEach(id => this.removeOperationFromUI(id));
}
try {
- const url = `${this.config.apiBase}${this.config.endpoint}`;
-
- const response = await fetch(
- url,
- {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- ...this.headers
- },
- body: JSON.stringify({ids,action, user: jvbSettings.currentUser})
- }
- );
+ const response = await fetch(`${this.config.apiBase}${this.config.endpoint}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...this.headers },
+ body: JSON.stringify({ ids, action, user: window.auth.getUser() })
+ });
if (!response.ok) {
- const errorData = await response.json().catch(()=>{});
- throw new Error(errorData.message || `${action} failed: ${response.status}`);
+ throw new Error(`${action} failed: ${response.status}`);
}
const result = await response.json();
@@ -487,41 +489,40 @@
throw new Error(result.message || `${action} operation failed`);
}
- if (['cancel', 'dismiss'].includes(action)) {
- ids.forEach(id => {
- let item = this.getQueue(id);
- this.notify(`${action}-operation`, item);
- this.clearQueue(id);
- });
- } else {
- ids.forEach(id => {
- let item = this.getQueue(id);
- this.notify(`${action}-operation`, item);
+ // SINGLE place to handle store updates
+ ids.forEach(id => {
+ let item = this.getQueue(id);
+ this.notify(`${action}-operation`, item);
+ if (shouldRemove) {
+ this.clearQueue(id);
+ } else {
item.status = 'queued';
item.retries = 0;
this.setQueue(item);
this.updateOperationStatus(item.id, item.status);
- });
+ }
+ });
+
+ if (action === 'retry') {
this.startActivityTracking();
}
- this.updateUI();
+ this.updateUI();
return result;
+
} catch (error) {
- const result = await window.jvbError.log(error, {
+ // Log and let jvbError handle retry
+ await window.jvbError.log(error, {
component: 'QueueManager',
operation: 'performQueueAction',
action: action,
operationIds: ids,
itemCount: ids.length
- }, () => this.updateServerOperations(ids, action)); // Retry callback
+ }, () => this.updateServerOperations(ids, action));
- if (result.retried) {
- return result; // Return successful retry result
- } else {
- throw error; // Re-throw if not retried
- }
+ // Don't re-throw - error is logged and handled
+ return { success: false, error: error.message };
}
}
@@ -544,10 +545,8 @@
*********************************************/
initListeners() {
this.clickHandler = this.handleClick.bind(this);
- this.changeHandler = this.handleChange.bind(this);
document.addEventListener('click', this.clickHandler);
- this.ui.panel?.addEventListener('change', this.changeHandler);
this.handleOnline = () => {
this.updateStatusPanel();
@@ -557,10 +556,9 @@
};
this.handleOffline = () => this.updateStatusPanel('offline');
this.handleBeforeUnload = (e) => {
- const hasPending = this.getOperationsByStatus(['queued', 'uploading']);
- if (hasPending.length > 0) {
+ if (this.isPolling || this.isProcessing) {
e.preventDefault();
- return 'You have unsaved changes in the queue.';
+ return 'You have unsaved changes in the queue. Proceed?';
}
};
@@ -577,16 +575,14 @@
this.store.clearHttpHeaders(); // Clear cached headers first
this.store.fetch();
} else if (e.target.closest(this.selectors.clearButton)) {
- const completedOps = this.getOperationsByStatus('completed');
+ const completedOps = this.getOperationIds(this.getOperationsByStatus('completed'));
if (completedOps.length > 0) {
- const ids = completedOps.map(op => op.id);
- this.updateServerOperations(ids, 'dismiss');
+ this.updateServerOperations(completedOps, 'dismiss');
}
} else if (e.target.closest(this.selectors.retryButton)) {
- const failedOps = this.getOperationsByStatus('failed');
+ const failedOps = this.getOperationIds(this.getOperationsByStatus('failed'));
if (failedOps.length > 0) {
- const ids = failedOps.map(op => op.id);
- this.updateServerOperations(ids, 'retry');
+ this.updateServerOperations(failedOps, 'retry');
}
} else if (e.target.closest('[data-action]')) {
const button = e.target.closest('[data-action]');
@@ -601,9 +597,6 @@
}
- handleChange(e) {
- }
-
/*********************************************
UI
*********************************************/
@@ -637,33 +630,13 @@
}
};
- this.ui = {
- panel: document.querySelector(this.selectors.panel),
- toggle: document.querySelector(this.selectors.toggle),
- count: document.querySelector(this.selectors.count),
- indicator: document.querySelector(this.selectors.indicator),
- };
+ this.ui = window.uiFromSelectors(this.selectors);
if (!this.ui.panel) {
this.canUpdateUI = false;
- return;
- }
-
- for (let [key, selector] of Object.entries(this.selectors)) {
- if (['panel', 'toggle', 'count', 'indicator'].includes(key)) {
- continue;
- }
- if (typeof selector === 'object') {
- this.ui[key] = {};
- for (let [k, s] of Object.entries(selector)) {
- this.ui[key][k] = this.ui.panel.querySelector(s);
- }
- }else {
- this.ui[key] = this.ui.panel.querySelector(selector);
- }
}
}
- updateUI() {
+ _updateUI() {
if (!this.canUpdateUI) {
return;
}
@@ -905,25 +878,6 @@
}
}
- updateCountdown() {
- if (!this.ui.countdown || !this.isPolling) return;
-
- let seconds = this.config.pollInterval / 1000;
-
- this.countdownTimer = setInterval(() => {
- seconds--;
-
- this.ui.countdown.textContent = seconds;
-
- if (seconds <= 0) {
- clearInterval(this.countdownTimer);
- if (this.isPolling) {
- setTimeout(() => this.updateCountdown(), 100);
- }
- }
- }, 1000);
- }
-
updateStatusPanel(status) {
this.ui.panel?.classList.remove(...this.classes);
if (!this.classes.includes(status)) {
@@ -951,23 +905,6 @@
}
/**************************************************************************
- NOTIFICATIONS
- **************************************************************************/
- showPopup(message, type = 'success') {
- if (!this.ui.popup) return;
-
- const span = this.ui.popup.querySelector('span');
- if (span) {
- span.textContent = message;
- }
-
- this.ui.popup.className = `popup ${type} show`;
-
- setTimeout(() => {
- this.ui.popup.classList.remove('show');
- }, 3000);
- }
- /**************************************************************************
HELPERS
**************************************************************************/
getOperationsByStatus(status, include = true) {
@@ -983,6 +920,9 @@
return this.getOperationsByStatus('queued').length > 0;
}
subscribe(callback) {
+ if (!this.subscribers) {
+ return;
+ }
this.subscribers.add(callback);
return () => this.subscribers.delete(callback);
}
@@ -1010,6 +950,10 @@
}
}
-document.addEventListener('DOMContentLoaded', function() {
- window.jvbQueue = new QueueManager();
+document.addEventListener('DOMContentLoaded', async function() {
+ window.auth.subscribe((event) => {
+ if (event === 'auth-loaded') {
+ window.jvbQueue = new QueueManager();
+ }
+ });
});
--
Gitblit v1.10.0