From 7a9054bb3f033c98067b3196378311dae54c5fbf Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Tue, 20 Jan 2026 01:31:53 +0000
Subject: [PATCH] =OperationQueue refactor to the JVBase/managers/queue namespace
---
inc/managers/SchemaManager.php | 44
inc/managers/queue/Locker.php | 65 +
inc/managers/queue/Operation.php | 36
inc/managers/queue/Mergeable.php | 11
inc/managers/queue/FilteredExecutor.php | 154 ++
assets/js/min/form.min.js | 2
inc/managers/DashboardManager.php | 1
inc/rest/routes/ContentRoutes.php | 28
assets/js/min/referral.min.js | 2
assets/js/min/queue.min.js | 2
inc/blocks/CustomBlocks.php | 8
inc/rest/routes/FeedRoutes.php | 3
inc/managers/_setup.php | 3
JVBase.php | 6
inc/managers/LoginManager.php | 186 +-
inc/meta/MetaManager.php | 4
inc/managers/queue/executors/ContentExecutor.php | 580 +++++++++
assets/js/concise/Queue.js | 16
inc/integrations/Integrations.php | 5
inc/managers/IconsManager.php | 4
inc/rest/routes/NewsRoutes.php | 4
assets/js/concise/Referral.js | 3
assets/js/concise/FormController.js | 183 ++
inc/managers/queue/Executor.php | 14
inc/registry/CheckCustomTables.php | 54
inc/rest/routes/FormRoutes.php | 6
inc/managers/SEO/BreadcrumbManager.php | 4
inc/managers/queue/Processor.php | 91 +
inc/EmbedGenerator.php | 15
inc/registry/FieldRegistry.php | 4
inc/managers/queue/Progress.php | 31
inc/rest/routes/FavouritesRoutes.php | 4
inc/managers/queue/Storage.php | 372 +++++
inc/meta/MetaForm.php | 4
inc/rest/routes/UploadRoutes.php | 57
inc/managers/queue/Queue.php | 390 ++++++
inc/integrations/GoogleMyBusiness.php | 22
inc/managers/queue/Result.php | 13
inc/rest/routes/QueueRoutes.php | 296 +--
inc/managers/queue/TypeRegistry.php | 52
inc/managers/AdminPages.php | 2
inc/managers/SEOMetaManager.php | 3
inc/managers/queue/executors/UploadExecutor.php | 628 +++++++++
inc/rest/routes/Invitations.php | 18
inc/integrations/PostMark.php | 4
inc/managers/queue/TypeConfig.php | 16
inc/managers/UmamiMetrics.php | 98
inc/rest/routes/LoginRoutes.php | 23
inc/rest/routes/AdminRoutes.php | 13
inc/blocks/FormBlock.php | 4
inc/managers/queue/Metrics.php | 210 +++
inc/managers/queue/_setup.php | 30
52 files changed, 3,317 insertions(+), 511 deletions(-)
diff --git a/JVBase.php b/JVBase.php
index 0b63b08..c7a20d9 100644
--- a/JVBase.php
+++ b/JVBase.php
@@ -8,7 +8,7 @@
use JVBase\managers\ErrorHandler;
use JVBase\managers\LoginManager;
use JVBase\managers\MagicLinkManager;
-use JVBase\managers\OperationQueue;
+use JVBase\managers\queue\Queue;
use JVBase\managers\DashboardManager;
use JVBase\managers\DirectoryManager;
use JVBase\managers\ReferralManager;
@@ -88,7 +88,7 @@
$this->customBlocks = new CustomBlocks();
$this->managers = [
'errors' => new ErrorHandler(),
- 'queue' => new OperationQueue(),
+ 'queue' => new Queue(),
// 'dash' => new DashboardManager(),
'roles' => new RoleManager(),
// 'forms' => new FormManager(),
@@ -222,7 +222,7 @@
{
return $this->managers['cache'];
}
- public function queue():OperationQueue
+ public function queue():Queue
{
return $this->managers['queue'];
}
diff --git a/assets/js/concise/FormController.js b/assets/js/concise/FormController.js
index 76ab6c3..894e58b 100644
--- a/assets/js/concise/FormController.js
+++ b/assets/js/concise/FormController.js
@@ -320,8 +320,6 @@
}
let form = this.getForm(e.target);
- //Autosave
- if (!form || !form.options.cache) return;
this.updateItem(field.dataset.field, this.getFieldValue(e.target), form);
}
@@ -335,9 +333,7 @@
window.debouncer.cancel(`form:${form.id}:validate:${fieldName}`);
this.validateField(e.target);
- if (form.options.cache) {
- this.updateItem(fieldName, this.getFieldValue(e.target), form);
- }
+ this.updateItem(fieldName, this.getFieldValue(e.target), form);
}
handleInput(e){
@@ -363,10 +359,18 @@
e.preventDefault();
const storedData = await this.store.get(form.id);
- this.notify('form-submit', {
- config: form,
- data: storedData?.changes || {}
- });
+ if (form.options.cache) {
+ this.notify('form-submit', {
+ config: form,
+ data: storedData.changes
+ });
+ } else {
+ this.notify('form-submit', {
+ config: form,
+ data: this.changes.get(form.id)?.changes??{},
+ });
+ }
+
}
if (form.options.showSummary) {
@@ -396,7 +400,9 @@
let changes = this.changes.get(form.id);
changes.changes[name] = value;
this.changes.set(form.id, changes);
- this.scheduleBackup();
+ if (form.options.cache) {
+ this.scheduleBackup();
+ }
}
scheduleBackup() {
@@ -1194,6 +1200,146 @@
}
}
+ handleFormSuccess(form, data) {
+ // Clear previous errors
+ form.querySelectorAll('.error-message').forEach(el => el.remove());
+ form.querySelectorAll('.field-error').forEach(el =>
+ el.classList.remove('field-error')
+ );
+
+ // Add success class to form
+ form.classList.add('form-success');
+
+ // Show success message if provided
+ if (data.message) {
+ const success = document.createElement('div');
+ success.className = 'form-success-message success-message';
+ success.textContent = data.message;
+ form.insertBefore(success, form.firstChild);
+
+ const icon = window.getIcon?.('check-circle');
+ if (icon) {
+ icon.classList.add('success-icon');
+ success.prepend(icon);
+ }
+ }
+
+ // If there's a title/description (for registration success)
+ if (data.title || data.description) {
+ const successBox = document.createElement('div');
+ successBox.className = 'success-box';
+
+ if (data.title) {
+ const title = document.createElement('h3');
+ title.textContent = data.title;
+ successBox.appendChild(title);
+ }
+
+ if (data.description) {
+ const descriptions = Array.isArray(data.description)
+ ? data.description
+ : [data.description];
+
+ descriptions.forEach(desc => {
+ const p = document.createElement('p');
+ p.textContent = desc;
+ successBox.appendChild(p);
+ });
+ }
+
+ form.insertBefore(successBox, form.firstChild);
+ }
+
+ // DELETE CACHED FORM DATA ON SUCCESS
+ if (form.dataset.formId) {
+ this.store.delete(form.dataset.formId).catch(err => {
+ console.warn('Failed to clear form cache:', err);
+ });
+
+ // Clear form config dirty state
+ const formConfig = this.forms.get(form.dataset.formId);
+ if (formConfig) {
+ formConfig.isDirty = false;
+ formConfig.lastSaved = Date.now();
+ formConfig.data = {}; // Clear cached data
+ }
+ }
+
+ // Announce success for accessibility
+ if (window.jvbA11y) {
+ window.jvbA11y.announce(data.message || 'Form submitted successfully');
+ }
+
+ // Trigger custom event
+ form.dispatchEvent(new CustomEvent('jvb-form-success', {
+ detail: data
+ }));
+ }
+
+ handleFormError(form, data) {
+ // Clear all previous errors
+ form.querySelectorAll('.error-message').forEach(el => el.remove());
+ form.querySelectorAll('.field-error, .has-error').forEach(el => {
+ el.classList.remove('field-error', 'has-error');
+ });
+
+ // Clear validation states using existing method
+ form.querySelectorAll('.field').forEach(fieldWrapper => {
+ this.clearValidation(fieldWrapper);
+ });
+
+ // Handle field-specific errors
+ if (data.field) {
+ const fieldWrapper = form.querySelector(`[data-field="${data.field}"]`);
+ if (fieldWrapper) {
+ // Use existing showError method for consistency
+ this.showError(fieldWrapper, data.message);
+
+ // Mark as touched so validation persists
+ this.touchedFields.add(data.field);
+
+ // Scroll to error
+ fieldWrapper.scrollIntoView({ behavior: 'smooth', block: 'center' });
+
+ // Focus the input for better UX
+ const input = fieldWrapper.querySelector('input, textarea, select');
+ if (input) {
+ input.focus();
+ }
+ }
+ } else {
+ // General form error (not field-specific)
+ const error = document.createElement('div');
+ error.className = 'form-error error-message';
+ error.textContent = data.message;
+
+ // Add icon for consistency
+ const icon = window.getIcon?.('close-circle');
+ if (icon) {
+ icon.classList.add('error-icon');
+ error.prepend(icon);
+ }
+
+ form.insertBefore(error, form.firstChild);
+
+ // Scroll to top to show the error
+ form.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+
+ // Announce error for accessibility
+ if (window.jvbA11y) {
+ const announcement = data.field
+ ? `Error in ${data.field}: ${data.message}`
+ : `Form error: ${data.message}`;
+ window.jvbA11y.announce(announcement);
+ }
+
+ // Trigger custom event
+ form.dispatchEvent(new CustomEvent('jvb-form-error', {
+ detail: data
+ }));
+ }
+
/**********************************************************************
STATUS
**********************************************************************/
@@ -1375,6 +1521,23 @@
this.inputs.set(config.id, config);
}
/**********************************************************************
+ Subscription
+ **********************************************************************/
+ subscribe(callback) {
+ this.subscribers.add(callback);
+ return () => this.subscribers.delete(callback);
+ }
+
+ notify(event, data) {
+ this.subscribers.forEach(cb => {
+ try {
+ cb(event, data);
+ } catch (e) {
+ console.error('HandleSelection subscriber error:', e);
+ }
+ });
+ }
+ /**********************************************************************
Cleanup
**********************************************************************/
destroy() {
diff --git a/assets/js/concise/Queue.js b/assets/js/concise/Queue.js
index a44d3a1..4c9adbc 100644
--- a/assets/js/concise/Queue.js
+++ b/assets/js/concise/Queue.js
@@ -5,6 +5,7 @@
this.user = window.auth.getUser();
+
this.canUpdateUI = true;
this.isProcessing = false;
this.isPolling = false;
@@ -26,7 +27,7 @@
this.initElements();
this.initListeners();
this.initStore();
- if (this.canUpdateUI) {
+ if (this.canUpdateUI && this.ui.panel) {
this.popup = new window.jvbPopup({
popup: this.ui.panel,
toggle: this.ui.toggle.button,
@@ -175,6 +176,7 @@
this.updatePanel('offline');
}
handleBeforeUnload(e) {
+ if (!this.ui.panel) return;
const total = this.getQueueByStatus(this.pendingStatuses).length;
if (total > 0) {
// Modern browsers ignore custom messages, but this triggers the native dialog
@@ -283,6 +285,9 @@
{name: 'status', keyPath: 'status'},
{name: 'type', keyPath: 'type'},
],
+ filters: {
+ user: window.auth.getUser()
+ },
showLoading: false,
}
)
@@ -486,13 +491,13 @@
let requestBody;
if (operation.data instanceof FormData) {
operation.data.append('id', operation.id);
- operation.data.append('user', this.user);
+ operation.data.append('user', window.auth.getUser());
requestBody = operation.data;
} else {
requestBody = JSON.stringify({
...operation.data,
id: operation.id,
- user: this.user
+ user: window.auth.getUser()
});
operation.headers['Content-Type'] = 'application/json';
}
@@ -861,7 +866,7 @@
}
updatePanel(status = 'syncing') {
- if (!this.panelStatuses.includes(status)) return;
+ if (!this.ui.panel || !this.panelStatuses.includes(status)) return;
this.ui.panel.classList.remove(...this.panelStatuses);
this.ui.panel.classList.add(status);
}
@@ -898,7 +903,7 @@
case 'completed':
return 'Successfully completed';
case 'failed':
- return `Failed: ${item.lastError || 'Unknown error'} (Retry ${item.retries}/${this.config.maxRetries})`;
+ return `Failed: ${item.lastError || 'Unknown error'} (Retry ${item.retries}/${2})`;
case 'failed_permanent':
return `Failed: ${item.lastError || 'Unknown error'}`;
default:
@@ -906,6 +911,7 @@
}
}
toggleQueue(on = true) {
+ if (!this.ui.panel) return;
this.ui.panel.hidden = !on;
this.ui.toggle.button.hidden = !on;
}
diff --git a/assets/js/concise/Referral.js b/assets/js/concise/Referral.js
index 6435e98..f56d939 100644
--- a/assets/js/concise/Referral.js
+++ b/assets/js/concise/Referral.js
@@ -50,7 +50,7 @@
this.tabs = null;
if (this.container.querySelector('nav.tabs')) {
- this.tabs = new window.jvbTabs(this.container, {updateURL: false});
+ this.tabs = window.jvbTabs.registerTab(this.container, {updateURL: false});
}
@@ -63,7 +63,6 @@
btn.remove();
});
}
- this.formController = null;
}
initStore() {
diff --git a/assets/js/min/form.min.js b/assets/js/min/form.min.js
index e487647..1ad372e 100644
--- a/assets/js/min/form.min.js
+++ b/assets/js/min/form.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.error=window.jvbError,this.queue=window.jvbQueue,this.populate=window.jvbPopulate,this.changes=new Map,this.forms=new Map,this.inputs=new Map,this.repeaters=new Map,this.tagLists=new Map,this.charLimits=new Map,this.quantityFields=new Map,this.quillInstances=new Map,this.dependencies=new Map,this.subscribers=new Set,this.isRestoring=!1,this.hasListeners=!1,this.summaryTemplate=!1,this.init()}init(){this.templates=window.jvbTemplates,this.initElements(),this.initListeners(),this.initStore(),this.initValidators()}initElements(){this.inputSelectors="input, textarea, select",this.selectors={tabs:{nav:"nav.tabs",sections:".tab.content",progress:{progress:".progress",fill:".progress .fill",details:".progress .details",icon:".progress .icon"},buttons:"nav.tabs button"},dependsOn:"[data-depends-on]",forms:{status:{status:".fstatus",message:".fstatus .message",icon:".fstatus .icon",actions:".fstatus .actions"}},inputs:this.inputSelectors,fields:{field:".field",label:"label",success:".success",error:".success",message:".validation-message"},repeater:{repeater:".repeater",header:".repeater-row-header",remove:".remove-row",add:".add-repeater-row",template:"template",items:".repeater-items",inputs:this.inputSelectors},tagList:{tagList:".field.tag-list",input:".tag-input-row",add:".add-tag",remove:".remove-tag",label:".tag-label",items:".tag-items",inputs:this.inputSelectors,value:'input[type="hidden"]'},tag:{label:".tag-label"},number:{number:".field div.quantity",increase:"button.increase",decrease:"button.decrease",input:'input[type="number"]'},limits:{hasLimit:"[data-limit]",limit:".limit",current:".current"}}}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.blurHandler=this.handleBlur.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleSubmit.bind(this),this.quantityClick=this.handleQuantityClick.bind(this),this.repeaterClick=this.handleRepeaterClick.bind(this),this.tagListClick=this.handleTagListClick.bind(this),this.tagListInput=this.handleTagListInput.bind(this)}addFormListeners(e){e.addEventListener("click",this.clickHandler),e.addEventListener("change",this.changeHandler),e.addEventListener("input",this.inputHandler),e.addEventListener("blur",this.blurHandler),e.addEventListener("submit",this.submitHandler)}removeFormListeners(e){e.removeEventListener("click",this.clickHandler),e.removeEventListener("change",this.changeHandler),e.removeEventListener("input",this.inputHandler),e.removeEventListener("blur",this.blurHandler),e.removeEventListener("submit",this.submitHandler)}initStore(){const e=window.jvbStore.register("forms",{storeName:"forms",keyPath:"id",indexes:[{name:"src",keyPath:"src"},{name:"timestamp",keyPath:"timestamp"},{name:"formType",keyPath:"type"}],TTL:1008e4});this.store=e.forms,this.store.subscribe(((e,t)=>{if("data-loaded"===e){let e=this.store.getFiltered().filter((e=>e.src===window.location.pathname));for(let t of e)this.showPendingNotification(t.id,t.changes)}else"operation-status"===e&&"completed"===t.status&&t.config&&this.store.remove(t.config.id)}))}showPendingNotification(e,t){let s=this.forms.get(e);if(!s)return;let i=s.element;if(!i)return void console.warn(`Form element not found for: ${e}`);const a=document.createElement("div");a.className="pendingChanges",a.innerHTML=`\n\t\t\t<p>We noticed unsaved changes from last time. Would you like to restore them?</p>\n <button class="restore" data-form-id="${e}">Restore</button>\n <button class="discard" data-form-id="${e}">Discard</button>`,i.insertBefore(a,s.ui.status.status),a.querySelector(".restore").addEventListener("click",(async()=>{this.isRestoring=!0,new this.populate(i,t),this.a11y.announce("Previous changes restored"),this.isRestoring=!1,a.remove()})),a.querySelector(".discard").addEventListener("click",(async()=>{await this.store.remove(e),this.a11y.announce("Previous changes discared"),a.remove()}))}initValidators(){this.validators={email:{pattern:/^[^\s@]+@[^\s@]+\.[^\s@]+$/,message:"Please enter a valid email address"},url:{pattern:/^https?:\/\/.+\..+/,message:"Please enter a valid URL starting with https://"},phone:{pattern:/^[\d\s\-+().]+$/,message:"Please enter a valid phone number"},number:{test:(e,t)=>{const s=parseFloat(e);if(isNaN(s))return"Please enter a valid number";const i=t.dataset.min,a=t.dataset.max;return void 0!==i&&s<parseFloat(i)?`Value must be at least ${i}`:!(void 0!==a&&s>parseFloat(a))||`Value must be at most ${a}`}},text:{test:(e,t)=>{const s=t.dataset.minlength,i=t.dataset.maxlength;return s&&e.length<parseInt(s)?`Must be at least ${s} characters`:!(i&&e.length>parseInt(i))||`Must be no more than ${i} characters`}}}}validateField(e){const t=this.performValidation(e);return this.updateValidationUI(e,t),t.isValid}performValidation(e){const t=e.closest(".field"),s=this.getFieldValue(e);if(!s&&!e.required)return{isValid:!0,message:""};if(e.required&&!s)return{isValid:!1,message:"This field is required"};if(e.checkValidity&&!e.checkValidity())return{isValid:!1,message:e.validationMessage};if(s&&Object.hasOwn(t.dataset,"pattern")){if(!new RegExp(t.dataset.pattern).test(s))return{isValid:!1,message:t.dataset.validationMessage||"Invalid format"}}if(Object.hasOwn(t.dataset,"validate")||e.type){const i=this.validators[t.dataset.validate||e.type];if(i.pattern&&!i.pattern.test(s))return{isValid:!1,message:i.message};if(i.test){const e=i.test(s,t);if(!0!==e)return{isValid:!1,message:e}}}return{isValid:!0,message:""}}updateValidationUI(e,t){t.isValid?this.showSuccess(e,t.message):this.showError(e,t.message)}handleClick(e){let t=this.getForm(e.target);if(!t)return;const s=window.targetCheck(e,"[data-action]");if(s){switch(s.dataset.action){case"clear-form":this.store.delete(t.id),t.element.reset(),t.ui.status.status.hidden=!0,this.a11y.announce("Form cleared, starting fresh");break;case"dismiss-restore":t.ui.status.status.hidden=!0}}}handleChange(e){if(e.target.closest("[data-ignore]")||this.isRestoring)return;let t=this.getField(e.target);if(this.dependencies.has(t.dataset.field)){this.dependencies.get(t.dataset.field).items.forEach((e=>{this.checkFieldDependency(e,t.dataset.field)}))}let s=this.getForm(e.target);s&&s.options.cache&&this.updateItem(t.dataset.field,this.getFieldValue(e.target),s)}handleBlur(e){if(e.target.closest("[data-ignore]")||this.isRestoring)return;let t=this.getForm(e.target);if(!t)return;let s=this.getField(e.target).dataset.field;window.debouncer.cancel(`form:${t.id}:validate:${s}`),this.validateField(e.target),t.options.cache&&this.updateItem(s,this.getFieldValue(e.target),t)}handleInput(e){let t=this.getForm(e.target);if(!t||!t.options.cache)return;let s=this.getField(e.target);s&&(this.showFormStatus(t,"pending"),window.debouncer.schedule(`form:${t.id}:validate:${s.dataset.field}`,(()=>this.validateField.bind(this)),500))}async handleSubmit(e){let t=this.getForm(e.target);if(t){if(this.subscribers.size>0){e.preventDefault();const s=await this.store.get(t.id);this.notify("form-submit",{config:t,data:s?.changes||{}})}if(t.options.showSummary){const e=await this.store.get(t.id);this.showSummary(t.id,{config:t,data:e?.changes||{}})}}}updateItem(e,t,s){this.changes.has(s.id)||this.changes.set(s.id,{id:s.id,timestamp:Date.now(),src:window.location.pathname,changes:{}});let i=this.changes.get(s.id);i.changes[e]=t,this.changes.set(s.id,i),this.scheduleBackup()}scheduleBackup(){window.debouncer.schedule("form_changes",(async()=>{if(this.changes.size>0){await this.store.saveMany(this.changes);for(let e of this.changes.keys())this.showFormStatus(e,"autosaved");this.changes.clear()}}),2e3)}saveCache(e){if(!this.changes.has(e))return;let t=this.changes.get(e);0!==t.size&&(this.store.save(t).then((()=>{})),this.changes.delete(e))}registerForm(e,t){if(Object.hasOwn(e.dataset,"formId")&&this.forms.has(e.dataset.formId))return;Object.hasOwn(e.dataset,"formId")||(e.dataset.formId=window.generateID("form_"));const s=e.dataset.formId;this.addFormListeners(e);const i={element:e,id:s,status:"",options:{autoUpload:!1,delay:t.delay??1500,endpoint:t.save??e.dataset.save??"",formStatus:t.showStatus??!0,showSummary:!1,cache:t.cache??!0},ui:window.uiFromSelectors(this.selectors.forms,e)};return i.showSummary&&!this.summaryTemplate&&this.defineSummaryTemplate(),this.initializeFields(e,i),this.forms.set(s,i),i}clearForm(e){const t=this.forms.get(e);if(!t)return;t.unsubscribeTabs&&t.unsubscribeTabs(),t.tabs&&window.jvbTabs.removeTab(t.element),t.cache&&this.changes.has(e)&&this.saveCache(e);for(let[t,s]of this.inputs.entries())s.form===e&&this.inputs.delete(t);if(this.dependencies.forEach(((t,s)=>{t.items=t.items.filter((t=>t.form!==e)),0===t.items.length&&this.dependencies.delete(s)})),Object.hasOwn(t,"hasQuill")&&this.quillInstances.has(e)){this.quillInstances.get(e).forEach((e=>{e.disable(),e.off("text-change"),e.off("selection-change");const t=e.container.parentElement,s=t?.querySelector(".ql-toolbar");if(s&&s.remove(),e.setText(""),t&&t.classList.contains("editor-container")){const e=t.nextElementSibling;"TEXTAREA"===e?.tagName&&(e.style.display=""),t.remove()}})),this.quillInstances.delete(e)}let s={repeater:this.repeaters,tagList:this.tagLists,charLimit:this.charLimits,quantity:this.quantityFields};for(let[t,i]of Object.entries(s)){if(0===i.size)continue;let s=Array.from(i.values()).filter((t=>t.form===e));s.length>0&&(s.forEach((e=>{switch(t){case"repeater":this.removeRepeaterListeners(e.element);break;case"tagList":this.removeTagListListeners(e.element);break;case"charLimit":this.removeCharacterLimitListeners(e.element);break;case"quantity":this.removeQuantityListeners(e.element)}})),i.delete(item.id))}this.removeFormListeners(t.element),this.forms.delete(e),window.debouncer.cancel("form_changes")}defineSummaryTemplate(){this.summaryTemplate=!0;let e=this;this.templates.define("formSummary",{refs:{result:".result",h3:"h3",p:"p"},setup({el:t,refs:s,manyRefs:i,data:a}){const r=["sendAll",...e.ignore];for(let[e,i]of Object.entries(a.changes)){if(r.includes(e)||this.isEmptyValue(i))continue;let a=Array.from(this.inputs.values()).find((t=>t.field?.dataset.field===e));if(!a)continue;let n=s.result.cloneNode(!0),l=n.querySelector("h3"),o=n.querySelector("p");l.textContent=a.label.textContent,"string"==typeof i?o.textContent=i:Array.isArray(i)||"object"==typeof i&&(o.textContent=`${i.address}`),t.append(n)}let n=a.config?.element?.querySelectorAll("[data-upload-field]");n&&n.forEach((e=>{let i=e.querySelector("h2")?.textContent??"Upload:",a=e.querySelectorAll(".item-grid.preview img");if(a){let e=s.result.cloneNode(!0),r=field.querySelector("h3"),n=field.querySelector("p");n?.remove(),r&&(r.textContent=i),a.forEach((t=>{t=t.cloneNode(!0),e.append(t)})),t.append(e)}})),s.result?.remove(),a.config.element.after(t),window.fade(a.config.element,!1)}})}initializeFields(e,t=null){const s={"[data-editor]":()=>this.checkForQuill(e,t),"div.quantity":()=>this.checkForQuantity(e),".repeater":()=>this.checkForRepeaters(e,t),".field.tag-list":()=>this.checkForTagLists(e),"[data-depends-on]":()=>this.checkForConditionalFields(e),"[data-limit]":()=>this.checkForCharacterLimits(e),"[data-uploader]":()=>this.checkForImageUploads(e,t),"nav.tabs":()=>this.checkForTabs(e,t),'[data-type="selector"]':()=>this.checkForSelectors(e)};for(const[t,i]of Object.entries(s))e.querySelector(t)&&i();Array.from(e.querySelectorAll(this.inputSelectors)).map((e=>{this.getItem(e,t?.id)}))}checkForQuill(e,t){if(!e.querySelector("[data-editor]"))return;t&&!Object.hasOwn(t,"hasQuill")&&(t.hasQuill=!0,this.forms.set(t.id,t)),this.quillInstances.has(t.id)||this.quillInstances.set(t.id,new Set);window.jvbQuill(e).forEach((e=>{this.quillInstances.get(t.id).add(e)}))}checkForQuantity(e){e.querySelector(this.selectors.number.number)&&e.querySelectorAll(this.selectors.number.number).forEach((t=>{let s={id:window.generateID("quant"),form:e.dataset.formId,ui:window.uiFromSelectors(this.selectors.number,t),element:t};t.dataset.numId=s.id,this.quantityFields.set(s.id,s),this.addQuantityListeners(t)}))}addQuantityListeners(e){e.addEventListener("click",this.quantityClick)}removeQuantityListeners(e){e.removeEventListener("click",this.quantityClick)}handleQuantityClick(e){let t=this.quantityFields.get(e.target.closest("[data-num-id]")?.dataset.numId);if(!t)return;let s=0;if(t.increase.contains(e.target)?s++:t.decrease.contains(e.target)&&s--,0===s)return;this.getField(e.target);let i=t.input.step;i=Math.max(i,1),e.ctrlKey&&e.shiftKey?i*=50:e.ctrlKey?i*=5:e.shiftKey&&(i*=10);let a=""===t.input.value?0:parseFloat(t.input.value);t.input.value=a+i*s,a=parseFloat(t.input.value),t.input.min&&a<t.input.min?(t.input.value=t.input.min,t.decrease.disabled=!0):t.input.max&&a>t.input.max?(t.input.value=t.input.max,t.increase.disabled=!0):(t.decrease.disabled&&(t.decrease.disabled=!1),t.increase.disabled&&(t.increase.disabled=!1))}checkForRepeaters(e){e.querySelector(this.selectors.repeater.repeater)&&e.querySelectorAll(this.selectors.repeater.repeater).forEach((t=>{let s={id:t.querySelector("template").className??window.generateID("repeater"),ui:window.uiFromSelectors(this.selectors.repeater,t),form:e.dataset.formId,element:t,field:this.getField(t),sortable:!1};if(!s.ui.addButton)return;let i=t.querySelector("template");this.templates.define(i.className,{manyRefs:{inputs:this.inputSelectors},setup({el:e,refs:t,manyRefs:i,data:a}){let r=s.ui.items?.children?.length??0;e.dataset.index=r,i.inputs?.forEach((t=>{window.prefixInput(t,`${e.dataset.fieldName}:${r}:`)}))}}),window.Sortable&&(s.sortable=new Sortable(t,{handle:this.selectors.repeater.header,animation:150,onEnd:()=>{this.reindexList(t)}})),t.dataset.repeaterId=s.id,this.addRepeaterListeners(t),this.repeaters.set(s.id,s)}))}addRepeaterListeners(e){e.addEventListener("click",this.repeaterClick)}removeRepeaterListeners(e){e.removeEventListener("click",this.repeaterClick)}handleRepeaterClick(e){e.target.matches(this.selectors.repeater.add)?this.addRepeaterRow(e.target.closest("[data-repeater-id]")):e.target.matches(this.selectors.repeater.remove)&&this.removeRepeaterRow(e.target)}addRepeaterRow(e){e.append(this.templates.create(e.dataset.repeaterId)),this.a11y.announce("Row added")}removeRepeaterRow(e){let t=e.closest("[data-repeater-id]");e.remove(),this.reindexList(t),this.a11y.announce("Row removed")}checkForTagLists(e){e.querySelectorAll(this.selectors.tagList.tagList)?.forEach((t=>{let s={id:t.querySelector("template").className??window.generateID("tagList"),ui:window.uiFromSelectors(this.selectors.tagList,t),element:t,form:e.dataset.formId,format:t.dataset.tagFormat??"first_field"};if(!s.ui.input||!s.ui.add||!s.ui.items)return;t.dataset.tagListId=s.id;let i=t.querySelector("template");this.templates.define(i.className,{refs:{label:this.selectors.tagList.label},manyRefs:{inputs:this.inputSelectors},setup({el:e,refs:t,manyRefs:i,data:a}){let r=s.ui.items?.children?.length??0;e.dataset.index=r,i.inputs?.forEach((t=>{window.prefixInput(t,`${e.dataset.fieldName}:${r}:`)})),t.label&&(t.label.textContent=a.label)}}),this.tagLists.set(s.id,s),this.addTagListListeners(t)}))}addTagListListeners(e){e.addEventListener("click",this.tagListClick),e.addEventListener("keypress",this.tagListInput,{passive:!0})}removeTagListListeners(e){e.removeEventListener("click",this.tagListClick),e.removeEventListener("keypress",this.tagListInput)}handleTagListClick(e){e.target.matches(this.selectors.tagList.add)?this.addTagListItem(e.target.closest("[data-tag-list-id]")):e.target.matches(this.selectors.tagList.remove)&&this.removeTagListItem(e.target.closest(this.selectors.tagList.remove))}addTagListItem(e){let t=this.tagLists.get(e.dataset.tagListId);if(!t)return;let s,i={},a=!1;for(let e of t.ui.inputs){this.validateField(e);const t=e.name.replace("new_",""),s=this.getFieldValue(e);s&&(a=!0),i[t]=s,["checkbox","radio"].includes(e.type)?e.checked=!1:e.value="",this.clearValidation(e)}if(!a)return this.a11y.announce("Please fill in at least one field"),void t.ui.inputs[0].focus();switch(t.format){case"first_field":s=Object.values(i)[0];break;case"all_fields":s=Object.values(i).join(", ");break;default:if(format.includes("{")){let e=t.format;for(const[t,s]of Object.entries(i))e=e.replace(`{${t}}`,s)}else s=i[t.format]??Object.values(i)[0]}let r=this.templates.create(e.dataset.tagListId,{label:s});const n=t.ui.items?.children?.length??0;r?.querySelectorAll("input[type=hidden]")?.forEach((e=>{const s=e.dataset.field;e.name=`${t.element.field}:${n}:${s}`,e.value=i[s]||""})),t.ui.items.append(r),t.ui.inputs[0]?.focus(),this.a11y.announce("Item added")}removeTagListItem(e){let t=e.closest("[data-tag-list-id]");e.remove(),this.reindexList(t),this.a11y.announce("Item removed")}handleTagListInput(e){let t=e.target,s=t.closest("[data-tag-list-id]");if(!s)return;let i=this.tagLists.get(s.dataset.tagListId);if(i&&"Enter"===e.key)if(t===i.ui.inputs[i.ui.inputs.length-1])e.preventDefault(),this.addTagListItem(t.closest("[data-tag-list-id]"));else{e.preventDefault();let s=i.ui.inputs.indexOf(t);i.ui.inputs[s+1].focus()}}checkForConditionalFields(e){e.querySelectorAll(this.selectors.dependsOn).forEach((t=>{const s=t.dataset.dependsOn,i=t.dataset.dependsValue,a=t.dataset.dependsOperatior??"==";if(!this.dependencies.has(s)){let e=document.querySelector(`[field="${s}"]`);e&&this.dependencies.set(s,{element:e,items:[]})}let r=this.dependencies.get(s);r.items.push({field:t,form:e.dataset.formId,requiredValue:i,operator:a}),this.dependencies.set(s,r),this.checkFieldDependency(r,s)}))}checkFieldDependency(e,t){const s=this.dependencies.get(t);if(!s)return;const i=this.getFieldValue(s.element),a=this.evaluateCondition(i,e.requiredValue,e.operator);this.toggleFieldVisibility(e.field,a)}evaluateCondition(e,t,s){const i=String(e||""),a=String(t||"");switch(s){case"==":default:return i===a;case"!=":return i!==a;case">":return parseFloat(i)>parseFloat(a);case"<":return parseFloat(i)<parseFloat(a);case">=":return parseFloat(i)>=parseFloat(a);case"<=":return parseFloat(i)<=parseFloat(a);case"contains":return i.includes(a);case"empty":return""===i;case"not_empty":return""!==i}}toggleFieldVisibility(e,t){const s=e.closest(".field, fieldset");s&&(s.hidden=!t,s.querySelectorAll("input, select, textarea").forEach((e=>{e.disabled=!t,!t&&e.hasAttribute("required")?(e.dataset.wasRequired="true",e.removeAttribute("required")):t&&"true"===e.dataset.wasRequired&&(e.setAttribute("required",""),delete e.dataset.wasRequired)})))}checkForCharacterLimits(e){e.querySelector(this.selectors.limits.hasLimit)&&(this.countUpdaters=this.updateCount.bind(this),e.querySelectorAll(`${this.selectors.limits.hasLimit}`).forEach((t=>{let s=window.generateID("limit");t.dataset.charLimitId=s;let i={element:t,form:e.dataset.formId,ui:window.uiFromSelectors(this.selectors.limits,t.closest(".field"))};i.ui.limit.textContent=t.dataset.limit,this.charLimits.set(s,i),this.addCharacterLimitListeners(t)})))}addCharacterLimitListeners(e){e.addEventListener("input",this.countUpdaters,{passive:!0})}removeCharacterLimitListeners(e){e.removeEventListener("input",this.countUpdaters,{passive:!0})}updateCount(e){let t=e.target,s=this.charLimits.get(t.dataset.charLimitId);if(!s)return;let i=t.value.length,a=t.dataset.limit;s.ui.current&&(s.ui.current.textContent=i,s.ui.current.classList.toggle("exceeded",i>=a)),i>a&&(t.value=t.value.slice(0,a))}checkForImageUploads(e,t){window.jvbUploads.scanFields(e,t.autoUpload)}checkForTabs(e,t){window.jvbTabs&&e.querySelector("nav.tabs")&&(t.tabs=window.jvbTabs.registerTab(e,{preCheck:(e,s)=>this.validateStep(e,t)}),t.ui.tabs=window.uiFromSelectors(this.selectors.tabs,e),t.ui.tabs.sections=Array.from(e.querySelectorAll(this.selectors.tabs.sections)),t.ui.tabs.inputs={},t.ui.tabs.sections.forEach((e=>{t.ui.tabs.inputs[e.dataset.tab]=Array.from(e.querySelectorAll(this.inputs))})),t.ui.tabs.buttons=Array.from(e.querySelectorAll(this.selectors.tabs.buttons)),t.unsubscribeTabs=window.jvbTabs.subscribe(((e,s)=>{if("tab-switched"===e&&t.ui.tabs.progress){const e=t.ui.tabs.sections.filter((e=>e.dataset.tab===s.current))[0]??!1;if(!e)return;const i=e.dataset.step,a=t.ui.sections.length;window.showProgress(t.ui.tabs.progress,i,a)}})),this.forms.set(t.id,t))}validateStep(e,t){const s=e.closest("[data-form-id]")?.dataset.formId;if(!s)return!0;if(!this.forms.get(s))return!0;return Array.from(this.inputs.values()).filter((t=>t&&t.form===s&&t.section===e.dataset.tab&&!t.element.closest("[hidden]"))).every((e=>!0===this.validateField(e.element)))}checkForSelectors(e){window.jvbSelector&&window.jvbSelector.scanExistingFields(e)}reindexList(e){Array.from(e.children).forEach(((t,s)=>{t.dataset.index=`${s}`,Array.from(t.children).forEach((t=>{"hidden"===t.type&&window.prefixInput(t,`${e.dataset.field}:${s}:${t.dataset.field}`)}))}))}clearValidation(e){let t=this.getField(e);if(!t)return;let s=this.getItem(e);s&&(t.classList.remove("has-error","has-success"),s.ui.success&&(s.ui.success.hidden=!0),s.ui.error&&(s.ui.error.hidden=!0),s.ui.message&&(s.ui.message.hidden=!0,s.ui.message.textContent=""))}showError(e,t="Invalid field"){let s=this.getField(e);if(!s)return;let i=this.getItem(e);i&&(s.classList.remove("has-success"),s.classList.add("has-error"),i.ui.success&&(i.ui.success.hidden=!0),i.ui.error&&(i.ui.error.hidden=!0),i.ui.message&&(i.ui.message.hidden=!1,i.ui.message.textContent=t))}showSuccess(e,t=""){let s=this.getField(e);if(!s)return;let i=this.getItem(e);i&&(s.classList.remove("has-error"),s.classList.add("has-success"),i.ui.success&&(i.ui.success.hidden=!1),i.ui.error&&(i.ui.error.hidden=!0),i.ui.message&&(i.ui.message.hidden=""===t,i.ui.message.textContent=t))}showFormStatus(e,t,s=""){let i=this.forms.get(e);i&&i.options.showStatus&&i.ui?.status?.status&&i.status!==t&&(i.ui.status.status.hidden=!1,i.ui.status.status.classList.toggle("loading",["uploading","saving"].includes(t)),i.ui.status.message.textContent=""===s?this.getDefaultMessage(t):s,i.ui.status.icon.className="icon icon-"+this.getDefaultIcon(t),setTimeout((()=>i.ui.status.status.hidden=!0),"submitted"===t?3e3:1e4))}getDefaultMessage(e){return{saving:"Saving changes...",autosaved:"Changes saved locally. Submit form to send to server.",uploading:"Uploading your form to server",submitted:"Successfully sent to server",pending:"Unsaved changes",restored:"Welcome back! We've restored your previous entry.",error:"Failed to save changes. Refresh and try again?",offline:"Changes will be saved when online"}[e]??e}getDefaultIcon(e){return{autosaved:"check-circle",submitted:"check-circle",restored:"history",error:"close-circle",offline:"cloud-slash",pending:"exclamation-mark"}[e]??""}showSummary(e){this.templates.create("formSummary",e)}getForm(e){let t=e.closest("[data-form-id]").dataset.formId;if(!t)return!1;let s=this.forms.get(t);return s||!1}getField(e){return e.closest("[data-field]")}getFieldType(e){let t=this.getField(e);if(t)return t.dataset.fieldType}getFieldValue(e){let t=this.getFieldType(e),s=this.getItem(e),i=s.field?.dataset.field??!1;if(!i)return!1;switch(t){case"repeater":return this.getRepeaterValue(e,s);case"tag-list":return this.getTagListValue(e,s);case"group":break;case"location":return this.getLocationValue(e,s);case"selector":case"upload":return this.getHiddenInputValue(e,s,i);case"true-false":return"1"===e.value||"on"===e.value||"true"===e.value;default:return e.value}}isEmptyValue(e){return null==e||""===e||(!(!Array.isArray(e)||0!==e.length)||"object"==typeof e&&0===Object.keys(e).length)}getRepeaterValue(e,t){t.container||(t.container=t.field?.querySelector(".repeater-items"),this.saveItem(t));let s=[];return Array.from(t.container.children).forEach((e=>{let t={};e.querySelectorAll("[data-field]").forEach((e=>{t[e.dataset.field]=this.getFieldValue(e)})),s.push(t)})),s}getTagListValue(e,t){t.container||(t.container=t.field?.querySelector(".tag-items"),this.saveItem(t));let s=[];return Array.from(t.container.children).forEach((e=>{let t=e.querySelectorAll('input[type="hidden"]'),i={};t.forEach((e=>{i[e.dataset.field]=e.value})),s.push(i)})),s}getLocationValue(e,t){t.values||(t.values=Array.from(t.field?.querySelectorAll("[data-location-field]")),this.saveItem(t));let s={};return t.values.forEach((e=>{s[e.dataset.locationField]=e.value})),s}getHiddenInputValue(e,t,s){return t.value||(t.value=t.field?.querySelector(`input[type=hidden][name="${s}"]`),this.saveItem(t)),t.value.value}getItem(e,t=null){const s=Object.hasOwn(e.dataset,"ref");let i=s?e.dataset.ref:window.generateID("input");if(s||(e.dataset.ref=i),!this.inputs.has(i)){t||(t=e.closest("[data-form-id]")?.dataset.formId??!1);let s=this.getField(e);this.inputs.set(i,{id:i,element:e,form:t,field:s,section:e.closest("[data-tab]")?.dataset.tab??!1,ui:window.uiFromSelectors(this.selectors.fields,s)})}return this.inputs.get(i)}saveItem(e){this.inputs.set(e.id,e)}destroy(){this.forms.size>0&&(Array.from(this.forms.values()).forEach((e=>{this.removeFormListeners(e)})),this.forms.clear()),this.repeaters.size>0&&(Array.from(this.repeaters.values()).forEach((e=>{this.removeRepeaterListeners(e.element),e.sortable?.destroy()})),this.repeaters.clear()),this.quantityFields.size>0&&(Array.from(this.quantityFields.values()).forEach((e=>{this.removeQuantityListeners(e.element)})),this.quantityFields.clear()),this.tagLists.size>0&&(Array.from(this.tagLists.values()).forEach((e=>{this.removeTagListListeners(e.element)})),this.tagLists.clear()),this.charLimits.size>0&&Array.from(this.charLimits.values()).forEach((e=>{e.removeEventListener("input",this.countUpdaters)})),this.inputs.clear(),this.forms.clear(),this.charLimits.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbForm=new e)}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.error=window.jvbError,this.queue=window.jvbQueue,this.populate=window.jvbPopulate,this.changes=new Map,this.forms=new Map,this.inputs=new Map,this.repeaters=new Map,this.tagLists=new Map,this.charLimits=new Map,this.quantityFields=new Map,this.quillInstances=new Map,this.dependencies=new Map,this.subscribers=new Set,this.isRestoring=!1,this.hasListeners=!1,this.summaryTemplate=!1,this.init()}init(){this.templates=window.jvbTemplates,this.initElements(),this.initListeners(),this.initStore(),this.initValidators()}initElements(){this.inputSelectors="input, textarea, select",this.selectors={tabs:{nav:"nav.tabs",sections:".tab.content",progress:{progress:".progress",fill:".progress .fill",details:".progress .details",icon:".progress .icon"},buttons:"nav.tabs button"},dependsOn:"[data-depends-on]",forms:{status:{status:".fstatus",message:".fstatus .message",icon:".fstatus .icon",actions:".fstatus .actions"}},inputs:this.inputSelectors,fields:{field:".field",label:"label",success:".success",error:".success",message:".validation-message"},repeater:{repeater:".repeater",header:".repeater-row-header",remove:".remove-row",add:".add-repeater-row",template:"template",items:".repeater-items",inputs:this.inputSelectors},tagList:{tagList:".field.tag-list",input:".tag-input-row",add:".add-tag",remove:".remove-tag",label:".tag-label",items:".tag-items",inputs:this.inputSelectors,value:'input[type="hidden"]'},tag:{label:".tag-label"},number:{number:".field div.quantity",increase:"button.increase",decrease:"button.decrease",input:'input[type="number"]'},limits:{hasLimit:"[data-limit]",limit:".limit",current:".current"}}}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.blurHandler=this.handleBlur.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleSubmit.bind(this),this.quantityClick=this.handleQuantityClick.bind(this),this.repeaterClick=this.handleRepeaterClick.bind(this),this.tagListClick=this.handleTagListClick.bind(this),this.tagListInput=this.handleTagListInput.bind(this)}addFormListeners(e){e.addEventListener("click",this.clickHandler),e.addEventListener("change",this.changeHandler),e.addEventListener("input",this.inputHandler),e.addEventListener("blur",this.blurHandler),e.addEventListener("submit",this.submitHandler)}removeFormListeners(e){e.removeEventListener("click",this.clickHandler),e.removeEventListener("change",this.changeHandler),e.removeEventListener("input",this.inputHandler),e.removeEventListener("blur",this.blurHandler),e.removeEventListener("submit",this.submitHandler)}initStore(){const e=window.jvbStore.register("forms",{storeName:"forms",keyPath:"id",indexes:[{name:"src",keyPath:"src"},{name:"timestamp",keyPath:"timestamp"},{name:"formType",keyPath:"type"}],TTL:1008e4});this.store=e.forms,this.store.subscribe(((e,t)=>{if("data-loaded"===e){let e=this.store.getFiltered().filter((e=>e.src===window.location.pathname));for(let t of e)this.showPendingNotification(t.id,t.changes)}else"operation-status"===e&&"completed"===t.status&&t.config&&this.store.remove(t.config.id)}))}showPendingNotification(e,t){let s=this.forms.get(e);if(!s)return;let i=s.element;if(!i)return void console.warn(`Form element not found for: ${e}`);const a=document.createElement("div");a.className="pendingChanges",a.innerHTML=`\n\t\t\t<p>We noticed unsaved changes from last time. Would you like to restore them?</p>\n <button class="restore" data-form-id="${e}">Restore</button>\n <button class="discard" data-form-id="${e}">Discard</button>`,i.insertBefore(a,s.ui.status.status),a.querySelector(".restore").addEventListener("click",(async()=>{this.isRestoring=!0,new this.populate(i,t),this.a11y.announce("Previous changes restored"),this.isRestoring=!1,a.remove()})),a.querySelector(".discard").addEventListener("click",(async()=>{await this.store.remove(e),this.a11y.announce("Previous changes discared"),a.remove()}))}initValidators(){this.validators={email:{pattern:/^[^\s@]+@[^\s@]+\.[^\s@]+$/,message:"Please enter a valid email address"},url:{pattern:/^https?:\/\/.+\..+/,message:"Please enter a valid URL starting with https://"},phone:{pattern:/^[\d\s\-+().]+$/,message:"Please enter a valid phone number"},number:{test:(e,t)=>{const s=parseFloat(e);if(isNaN(s))return"Please enter a valid number";const i=t.dataset.min,a=t.dataset.max;return void 0!==i&&s<parseFloat(i)?`Value must be at least ${i}`:!(void 0!==a&&s>parseFloat(a))||`Value must be at most ${a}`}},text:{test:(e,t)=>{const s=t.dataset.minlength,i=t.dataset.maxlength;return s&&e.length<parseInt(s)?`Must be at least ${s} characters`:!(i&&e.length>parseInt(i))||`Must be no more than ${i} characters`}}}}validateField(e){const t=this.performValidation(e);return this.updateValidationUI(e,t),t.isValid}performValidation(e){const t=e.closest(".field"),s=this.getFieldValue(e);if(!s&&!e.required)return{isValid:!0,message:""};if(e.required&&!s)return{isValid:!1,message:"This field is required"};if(e.checkValidity&&!e.checkValidity())return{isValid:!1,message:e.validationMessage};if(s&&Object.hasOwn(t.dataset,"pattern")){if(!new RegExp(t.dataset.pattern).test(s))return{isValid:!1,message:t.dataset.validationMessage||"Invalid format"}}if(Object.hasOwn(t.dataset,"validate")||e.type){const i=this.validators[t.dataset.validate||e.type];if(i.pattern&&!i.pattern.test(s))return{isValid:!1,message:i.message};if(i.test){const e=i.test(s,t);if(!0!==e)return{isValid:!1,message:e}}}return{isValid:!0,message:""}}updateValidationUI(e,t){t.isValid?this.showSuccess(e,t.message):this.showError(e,t.message)}handleClick(e){let t=this.getForm(e.target);if(!t)return;const s=window.targetCheck(e,"[data-action]");if(s){switch(s.dataset.action){case"clear-form":this.store.delete(t.id),t.element.reset(),t.ui.status.status.hidden=!0,this.a11y.announce("Form cleared, starting fresh");break;case"dismiss-restore":t.ui.status.status.hidden=!0}}}handleChange(e){if(e.target.closest("[data-ignore]")||this.isRestoring)return;let t=this.getField(e.target);if(this.dependencies.has(t.dataset.field)){this.dependencies.get(t.dataset.field).items.forEach((e=>{this.checkFieldDependency(e,t.dataset.field)}))}let s=this.getForm(e.target);this.updateItem(t.dataset.field,this.getFieldValue(e.target),s)}handleBlur(e){if(e.target.closest("[data-ignore]")||this.isRestoring)return;let t=this.getForm(e.target);if(!t)return;let s=this.getField(e.target).dataset.field;window.debouncer.cancel(`form:${t.id}:validate:${s}`),this.validateField(e.target),this.updateItem(s,this.getFieldValue(e.target),t)}handleInput(e){let t=this.getForm(e.target);if(!t||!t.options.cache)return;let s=this.getField(e.target);s&&(this.showFormStatus(t,"pending"),window.debouncer.schedule(`form:${t.id}:validate:${s.dataset.field}`,(()=>this.validateField.bind(this)),500))}async handleSubmit(e){let t=this.getForm(e.target);if(t){if(this.subscribers.size>0){e.preventDefault();const s=await this.store.get(t.id);t.options.cache?this.notify("form-submit",{config:t,data:s.changes}):this.notify("form-submit",{config:t,data:this.changes.get(t.id)?.changes??{}})}if(t.options.showSummary){const e=await this.store.get(t.id);this.showSummary(t.id,{config:t,data:e?.changes||{}})}}}updateItem(e,t,s){this.changes.has(s.id)||this.changes.set(s.id,{id:s.id,timestamp:Date.now(),src:window.location.pathname,changes:{}});let i=this.changes.get(s.id);i.changes[e]=t,this.changes.set(s.id,i),s.options.cache&&this.scheduleBackup()}scheduleBackup(){window.debouncer.schedule("form_changes",(async()=>{if(this.changes.size>0){await this.store.saveMany(this.changes);for(let e of this.changes.keys())this.showFormStatus(e,"autosaved");this.changes.clear()}}),2e3)}saveCache(e){if(!this.changes.has(e))return;let t=this.changes.get(e);0!==t.size&&(this.store.save(t).then((()=>{})),this.changes.delete(e))}registerForm(e,t){if(Object.hasOwn(e.dataset,"formId")&&this.forms.has(e.dataset.formId))return;Object.hasOwn(e.dataset,"formId")||(e.dataset.formId=window.generateID("form_"));const s=e.dataset.formId;this.addFormListeners(e);const i={element:e,id:s,status:"",options:{autoUpload:!1,delay:t.delay??1500,endpoint:t.save??e.dataset.save??"",formStatus:t.showStatus??!0,showSummary:!1,cache:t.cache??!0},ui:window.uiFromSelectors(this.selectors.forms,e)};return i.showSummary&&!this.summaryTemplate&&this.defineSummaryTemplate(),this.initializeFields(e,i),this.forms.set(s,i),i}clearForm(e){const t=this.forms.get(e);if(!t)return;t.unsubscribeTabs&&t.unsubscribeTabs(),t.tabs&&window.jvbTabs.removeTab(t.element),t.cache&&this.changes.has(e)&&this.saveCache(e);for(let[t,s]of this.inputs.entries())s.form===e&&this.inputs.delete(t);if(this.dependencies.forEach(((t,s)=>{t.items=t.items.filter((t=>t.form!==e)),0===t.items.length&&this.dependencies.delete(s)})),Object.hasOwn(t,"hasQuill")&&this.quillInstances.has(e)){this.quillInstances.get(e).forEach((e=>{e.disable(),e.off("text-change"),e.off("selection-change");const t=e.container.parentElement,s=t?.querySelector(".ql-toolbar");if(s&&s.remove(),e.setText(""),t&&t.classList.contains("editor-container")){const e=t.nextElementSibling;"TEXTAREA"===e?.tagName&&(e.style.display=""),t.remove()}})),this.quillInstances.delete(e)}let s={repeater:this.repeaters,tagList:this.tagLists,charLimit:this.charLimits,quantity:this.quantityFields};for(let[t,i]of Object.entries(s)){if(0===i.size)continue;let s=Array.from(i.values()).filter((t=>t.form===e));s.length>0&&(s.forEach((e=>{switch(t){case"repeater":this.removeRepeaterListeners(e.element);break;case"tagList":this.removeTagListListeners(e.element);break;case"charLimit":this.removeCharacterLimitListeners(e.element);break;case"quantity":this.removeQuantityListeners(e.element)}})),i.delete(item.id))}this.removeFormListeners(t.element),this.forms.delete(e),window.debouncer.cancel("form_changes")}defineSummaryTemplate(){this.summaryTemplate=!0;let e=this;this.templates.define("formSummary",{refs:{result:".result",h3:"h3",p:"p"},setup({el:t,refs:s,manyRefs:i,data:a}){const r=["sendAll",...e.ignore];for(let[e,i]of Object.entries(a.changes)){if(r.includes(e)||this.isEmptyValue(i))continue;let a=Array.from(this.inputs.values()).find((t=>t.field?.dataset.field===e));if(!a)continue;let n=s.result.cloneNode(!0),l=n.querySelector("h3"),o=n.querySelector("p");l.textContent=a.label.textContent,"string"==typeof i?o.textContent=i:Array.isArray(i)||"object"==typeof i&&(o.textContent=`${i.address}`),t.append(n)}let n=a.config?.element?.querySelectorAll("[data-upload-field]");n&&n.forEach((e=>{let i=e.querySelector("h2")?.textContent??"Upload:",a=e.querySelectorAll(".item-grid.preview img");if(a){let e=s.result.cloneNode(!0),r=field.querySelector("h3"),n=field.querySelector("p");n?.remove(),r&&(r.textContent=i),a.forEach((t=>{t=t.cloneNode(!0),e.append(t)})),t.append(e)}})),s.result?.remove(),a.config.element.after(t),window.fade(a.config.element,!1)}})}initializeFields(e,t=null){const s={"[data-editor]":()=>this.checkForQuill(e,t),"div.quantity":()=>this.checkForQuantity(e),".repeater":()=>this.checkForRepeaters(e,t),".field.tag-list":()=>this.checkForTagLists(e),"[data-depends-on]":()=>this.checkForConditionalFields(e),"[data-limit]":()=>this.checkForCharacterLimits(e),"[data-uploader]":()=>this.checkForImageUploads(e,t),"nav.tabs":()=>this.checkForTabs(e,t),'[data-type="selector"]':()=>this.checkForSelectors(e)};for(const[t,i]of Object.entries(s))e.querySelector(t)&&i();Array.from(e.querySelectorAll(this.inputSelectors)).map((e=>{this.getItem(e,t?.id)}))}checkForQuill(e,t){if(!e.querySelector("[data-editor]"))return;t&&!Object.hasOwn(t,"hasQuill")&&(t.hasQuill=!0,this.forms.set(t.id,t)),this.quillInstances.has(t.id)||this.quillInstances.set(t.id,new Set);window.jvbQuill(e).forEach((e=>{this.quillInstances.get(t.id).add(e)}))}checkForQuantity(e){e.querySelector(this.selectors.number.number)&&e.querySelectorAll(this.selectors.number.number).forEach((t=>{let s={id:window.generateID("quant"),form:e.dataset.formId,ui:window.uiFromSelectors(this.selectors.number,t),element:t};t.dataset.numId=s.id,this.quantityFields.set(s.id,s),this.addQuantityListeners(t)}))}addQuantityListeners(e){e.addEventListener("click",this.quantityClick)}removeQuantityListeners(e){e.removeEventListener("click",this.quantityClick)}handleQuantityClick(e){let t=this.quantityFields.get(e.target.closest("[data-num-id]")?.dataset.numId);if(!t)return;let s=0;if(t.increase.contains(e.target)?s++:t.decrease.contains(e.target)&&s--,0===s)return;this.getField(e.target);let i=t.input.step;i=Math.max(i,1),e.ctrlKey&&e.shiftKey?i*=50:e.ctrlKey?i*=5:e.shiftKey&&(i*=10);let a=""===t.input.value?0:parseFloat(t.input.value);t.input.value=a+i*s,a=parseFloat(t.input.value),t.input.min&&a<t.input.min?(t.input.value=t.input.min,t.decrease.disabled=!0):t.input.max&&a>t.input.max?(t.input.value=t.input.max,t.increase.disabled=!0):(t.decrease.disabled&&(t.decrease.disabled=!1),t.increase.disabled&&(t.increase.disabled=!1))}checkForRepeaters(e){e.querySelector(this.selectors.repeater.repeater)&&e.querySelectorAll(this.selectors.repeater.repeater).forEach((t=>{let s={id:t.querySelector("template").className??window.generateID("repeater"),ui:window.uiFromSelectors(this.selectors.repeater,t),form:e.dataset.formId,element:t,field:this.getField(t),sortable:!1};if(!s.ui.addButton)return;let i=t.querySelector("template");this.templates.define(i.className,{manyRefs:{inputs:this.inputSelectors},setup({el:e,refs:t,manyRefs:i,data:a}){let r=s.ui.items?.children?.length??0;e.dataset.index=r,i.inputs?.forEach((t=>{window.prefixInput(t,`${e.dataset.fieldName}:${r}:`)}))}}),window.Sortable&&(s.sortable=new Sortable(t,{handle:this.selectors.repeater.header,animation:150,onEnd:()=>{this.reindexList(t)}})),t.dataset.repeaterId=s.id,this.addRepeaterListeners(t),this.repeaters.set(s.id,s)}))}addRepeaterListeners(e){e.addEventListener("click",this.repeaterClick)}removeRepeaterListeners(e){e.removeEventListener("click",this.repeaterClick)}handleRepeaterClick(e){e.target.matches(this.selectors.repeater.add)?this.addRepeaterRow(e.target.closest("[data-repeater-id]")):e.target.matches(this.selectors.repeater.remove)&&this.removeRepeaterRow(e.target)}addRepeaterRow(e){e.append(this.templates.create(e.dataset.repeaterId)),this.a11y.announce("Row added")}removeRepeaterRow(e){let t=e.closest("[data-repeater-id]");e.remove(),this.reindexList(t),this.a11y.announce("Row removed")}checkForTagLists(e){e.querySelectorAll(this.selectors.tagList.tagList)?.forEach((t=>{let s={id:t.querySelector("template").className??window.generateID("tagList"),ui:window.uiFromSelectors(this.selectors.tagList,t),element:t,form:e.dataset.formId,format:t.dataset.tagFormat??"first_field"};if(!s.ui.input||!s.ui.add||!s.ui.items)return;t.dataset.tagListId=s.id;let i=t.querySelector("template");this.templates.define(i.className,{refs:{label:this.selectors.tagList.label},manyRefs:{inputs:this.inputSelectors},setup({el:e,refs:t,manyRefs:i,data:a}){let r=s.ui.items?.children?.length??0;e.dataset.index=r,i.inputs?.forEach((t=>{window.prefixInput(t,`${e.dataset.fieldName}:${r}:`)})),t.label&&(t.label.textContent=a.label)}}),this.tagLists.set(s.id,s),this.addTagListListeners(t)}))}addTagListListeners(e){e.addEventListener("click",this.tagListClick),e.addEventListener("keypress",this.tagListInput,{passive:!0})}removeTagListListeners(e){e.removeEventListener("click",this.tagListClick),e.removeEventListener("keypress",this.tagListInput)}handleTagListClick(e){e.target.matches(this.selectors.tagList.add)?this.addTagListItem(e.target.closest("[data-tag-list-id]")):e.target.matches(this.selectors.tagList.remove)&&this.removeTagListItem(e.target.closest(this.selectors.tagList.remove))}addTagListItem(e){let t=this.tagLists.get(e.dataset.tagListId);if(!t)return;let s,i={},a=!1;for(let e of t.ui.inputs){this.validateField(e);const t=e.name.replace("new_",""),s=this.getFieldValue(e);s&&(a=!0),i[t]=s,["checkbox","radio"].includes(e.type)?e.checked=!1:e.value="",this.clearValidation(e)}if(!a)return this.a11y.announce("Please fill in at least one field"),void t.ui.inputs[0].focus();switch(t.format){case"first_field":s=Object.values(i)[0];break;case"all_fields":s=Object.values(i).join(", ");break;default:if(format.includes("{")){let e=t.format;for(const[t,s]of Object.entries(i))e=e.replace(`{${t}}`,s)}else s=i[t.format]??Object.values(i)[0]}let r=this.templates.create(e.dataset.tagListId,{label:s});const n=t.ui.items?.children?.length??0;r?.querySelectorAll("input[type=hidden]")?.forEach((e=>{const s=e.dataset.field;e.name=`${t.element.field}:${n}:${s}`,e.value=i[s]||""})),t.ui.items.append(r),t.ui.inputs[0]?.focus(),this.a11y.announce("Item added")}removeTagListItem(e){let t=e.closest("[data-tag-list-id]");e.remove(),this.reindexList(t),this.a11y.announce("Item removed")}handleTagListInput(e){let t=e.target,s=t.closest("[data-tag-list-id]");if(!s)return;let i=this.tagLists.get(s.dataset.tagListId);if(i&&"Enter"===e.key)if(t===i.ui.inputs[i.ui.inputs.length-1])e.preventDefault(),this.addTagListItem(t.closest("[data-tag-list-id]"));else{e.preventDefault();let s=i.ui.inputs.indexOf(t);i.ui.inputs[s+1].focus()}}checkForConditionalFields(e){e.querySelectorAll(this.selectors.dependsOn).forEach((t=>{const s=t.dataset.dependsOn,i=t.dataset.dependsValue,a=t.dataset.dependsOperatior??"==";if(!this.dependencies.has(s)){let e=document.querySelector(`[field="${s}"]`);e&&this.dependencies.set(s,{element:e,items:[]})}let r=this.dependencies.get(s);r.items.push({field:t,form:e.dataset.formId,requiredValue:i,operator:a}),this.dependencies.set(s,r),this.checkFieldDependency(r,s)}))}checkFieldDependency(e,t){const s=this.dependencies.get(t);if(!s)return;const i=this.getFieldValue(s.element),a=this.evaluateCondition(i,e.requiredValue,e.operator);this.toggleFieldVisibility(e.field,a)}evaluateCondition(e,t,s){const i=String(e||""),a=String(t||"");switch(s){case"==":default:return i===a;case"!=":return i!==a;case">":return parseFloat(i)>parseFloat(a);case"<":return parseFloat(i)<parseFloat(a);case">=":return parseFloat(i)>=parseFloat(a);case"<=":return parseFloat(i)<=parseFloat(a);case"contains":return i.includes(a);case"empty":return""===i;case"not_empty":return""!==i}}toggleFieldVisibility(e,t){const s=e.closest(".field, fieldset");s&&(s.hidden=!t,s.querySelectorAll("input, select, textarea").forEach((e=>{e.disabled=!t,!t&&e.hasAttribute("required")?(e.dataset.wasRequired="true",e.removeAttribute("required")):t&&"true"===e.dataset.wasRequired&&(e.setAttribute("required",""),delete e.dataset.wasRequired)})))}checkForCharacterLimits(e){e.querySelector(this.selectors.limits.hasLimit)&&(this.countUpdaters=this.updateCount.bind(this),e.querySelectorAll(`${this.selectors.limits.hasLimit}`).forEach((t=>{let s=window.generateID("limit");t.dataset.charLimitId=s;let i={element:t,form:e.dataset.formId,ui:window.uiFromSelectors(this.selectors.limits,t.closest(".field"))};i.ui.limit.textContent=t.dataset.limit,this.charLimits.set(s,i),this.addCharacterLimitListeners(t)})))}addCharacterLimitListeners(e){e.addEventListener("input",this.countUpdaters,{passive:!0})}removeCharacterLimitListeners(e){e.removeEventListener("input",this.countUpdaters,{passive:!0})}updateCount(e){let t=e.target,s=this.charLimits.get(t.dataset.charLimitId);if(!s)return;let i=t.value.length,a=t.dataset.limit;s.ui.current&&(s.ui.current.textContent=i,s.ui.current.classList.toggle("exceeded",i>=a)),i>a&&(t.value=t.value.slice(0,a))}checkForImageUploads(e,t){window.jvbUploads.scanFields(e,t.autoUpload)}checkForTabs(e,t){window.jvbTabs&&e.querySelector("nav.tabs")&&(t.tabs=window.jvbTabs.registerTab(e,{preCheck:(e,s)=>this.validateStep(e,t)}),t.ui.tabs=window.uiFromSelectors(this.selectors.tabs,e),t.ui.tabs.sections=Array.from(e.querySelectorAll(this.selectors.tabs.sections)),t.ui.tabs.inputs={},t.ui.tabs.sections.forEach((e=>{t.ui.tabs.inputs[e.dataset.tab]=Array.from(e.querySelectorAll(this.inputs))})),t.ui.tabs.buttons=Array.from(e.querySelectorAll(this.selectors.tabs.buttons)),t.unsubscribeTabs=window.jvbTabs.subscribe(((e,s)=>{if("tab-switched"===e&&t.ui.tabs.progress){const e=t.ui.tabs.sections.filter((e=>e.dataset.tab===s.current))[0]??!1;if(!e)return;const i=e.dataset.step,a=t.ui.sections.length;window.showProgress(t.ui.tabs.progress,i,a)}})),this.forms.set(t.id,t))}validateStep(e,t){const s=e.closest("[data-form-id]")?.dataset.formId;if(!s)return!0;if(!this.forms.get(s))return!0;return Array.from(this.inputs.values()).filter((t=>t&&t.form===s&&t.section===e.dataset.tab&&!t.element.closest("[hidden]"))).every((e=>!0===this.validateField(e.element)))}checkForSelectors(e){window.jvbSelector&&window.jvbSelector.scanExistingFields(e)}reindexList(e){Array.from(e.children).forEach(((t,s)=>{t.dataset.index=`${s}`,Array.from(t.children).forEach((t=>{"hidden"===t.type&&window.prefixInput(t,`${e.dataset.field}:${s}:${t.dataset.field}`)}))}))}clearValidation(e){let t=this.getField(e);if(!t)return;let s=this.getItem(e);s&&(t.classList.remove("has-error","has-success"),s.ui.success&&(s.ui.success.hidden=!0),s.ui.error&&(s.ui.error.hidden=!0),s.ui.message&&(s.ui.message.hidden=!0,s.ui.message.textContent=""))}showError(e,t="Invalid field"){let s=this.getField(e);if(!s)return;let i=this.getItem(e);i&&(s.classList.remove("has-success"),s.classList.add("has-error"),i.ui.success&&(i.ui.success.hidden=!0),i.ui.error&&(i.ui.error.hidden=!0),i.ui.message&&(i.ui.message.hidden=!1,i.ui.message.textContent=t))}showSuccess(e,t=""){let s=this.getField(e);if(!s)return;let i=this.getItem(e);i&&(s.classList.remove("has-error"),s.classList.add("has-success"),i.ui.success&&(i.ui.success.hidden=!1),i.ui.error&&(i.ui.error.hidden=!0),i.ui.message&&(i.ui.message.hidden=""===t,i.ui.message.textContent=t))}handleFormSuccess(e,t){if(e.querySelectorAll(".error-message").forEach((e=>e.remove())),e.querySelectorAll(".field-error").forEach((e=>e.classList.remove("field-error"))),e.classList.add("form-success"),t.message){const s=document.createElement("div");s.className="form-success-message success-message",s.textContent=t.message,e.insertBefore(s,e.firstChild);const i=window.getIcon?.("check-circle");i&&(i.classList.add("success-icon"),s.prepend(i))}if(t.title||t.description){const s=document.createElement("div");if(s.className="success-box",t.title){const e=document.createElement("h3");e.textContent=t.title,s.appendChild(e)}if(t.description){(Array.isArray(t.description)?t.description:[t.description]).forEach((e=>{const t=document.createElement("p");t.textContent=e,s.appendChild(t)}))}e.insertBefore(s,e.firstChild)}if(e.dataset.formId){this.store.delete(e.dataset.formId).catch((e=>{console.warn("Failed to clear form cache:",e)}));const t=this.forms.get(e.dataset.formId);t&&(t.isDirty=!1,t.lastSaved=Date.now(),t.data={})}window.jvbA11y&&window.jvbA11y.announce(t.message||"Form submitted successfully"),e.dispatchEvent(new CustomEvent("jvb-form-success",{detail:t}))}handleFormError(e,t){if(e.querySelectorAll(".error-message").forEach((e=>e.remove())),e.querySelectorAll(".field-error, .has-error").forEach((e=>{e.classList.remove("field-error","has-error")})),e.querySelectorAll(".field").forEach((e=>{this.clearValidation(e)})),t.field){const s=e.querySelector(`[data-field="${t.field}"]`);if(s){this.showError(s,t.message),this.touchedFields.add(t.field),s.scrollIntoView({behavior:"smooth",block:"center"});const e=s.querySelector("input, textarea, select");e&&e.focus()}}else{const s=document.createElement("div");s.className="form-error error-message",s.textContent=t.message;const i=window.getIcon?.("close-circle");i&&(i.classList.add("error-icon"),s.prepend(i)),e.insertBefore(s,e.firstChild),e.scrollIntoView({behavior:"smooth",block:"start"})}if(window.jvbA11y){const e=t.field?`Error in ${t.field}: ${t.message}`:`Form error: ${t.message}`;window.jvbA11y.announce(e)}e.dispatchEvent(new CustomEvent("jvb-form-error",{detail:t}))}showFormStatus(e,t,s=""){let i=this.forms.get(e);i&&i.options.showStatus&&i.ui?.status?.status&&i.status!==t&&(i.ui.status.status.hidden=!1,i.ui.status.status.classList.toggle("loading",["uploading","saving"].includes(t)),i.ui.status.message.textContent=""===s?this.getDefaultMessage(t):s,i.ui.status.icon.className="icon icon-"+this.getDefaultIcon(t),setTimeout((()=>i.ui.status.status.hidden=!0),"submitted"===t?3e3:1e4))}getDefaultMessage(e){return{saving:"Saving changes...",autosaved:"Changes saved locally. Submit form to send to server.",uploading:"Uploading your form to server",submitted:"Successfully sent to server",pending:"Unsaved changes",restored:"Welcome back! We've restored your previous entry.",error:"Failed to save changes. Refresh and try again?",offline:"Changes will be saved when online"}[e]??e}getDefaultIcon(e){return{autosaved:"check-circle",submitted:"check-circle",restored:"history",error:"close-circle",offline:"cloud-slash",pending:"exclamation-mark"}[e]??""}showSummary(e){this.templates.create("formSummary",e)}getForm(e){let t=e.closest("[data-form-id]").dataset.formId;if(!t)return!1;let s=this.forms.get(t);return s||!1}getField(e){return e.closest("[data-field]")}getFieldType(e){let t=this.getField(e);if(t)return t.dataset.fieldType}getFieldValue(e){let t=this.getFieldType(e),s=this.getItem(e),i=s.field?.dataset.field??!1;if(!i)return!1;switch(t){case"repeater":return this.getRepeaterValue(e,s);case"tag-list":return this.getTagListValue(e,s);case"group":break;case"location":return this.getLocationValue(e,s);case"selector":case"upload":return this.getHiddenInputValue(e,s,i);case"true-false":return"1"===e.value||"on"===e.value||"true"===e.value;default:return e.value}}isEmptyValue(e){return null==e||""===e||(!(!Array.isArray(e)||0!==e.length)||"object"==typeof e&&0===Object.keys(e).length)}getRepeaterValue(e,t){t.container||(t.container=t.field?.querySelector(".repeater-items"),this.saveItem(t));let s=[];return Array.from(t.container.children).forEach((e=>{let t={};e.querySelectorAll("[data-field]").forEach((e=>{t[e.dataset.field]=this.getFieldValue(e)})),s.push(t)})),s}getTagListValue(e,t){t.container||(t.container=t.field?.querySelector(".tag-items"),this.saveItem(t));let s=[];return Array.from(t.container.children).forEach((e=>{let t=e.querySelectorAll('input[type="hidden"]'),i={};t.forEach((e=>{i[e.dataset.field]=e.value})),s.push(i)})),s}getLocationValue(e,t){t.values||(t.values=Array.from(t.field?.querySelectorAll("[data-location-field]")),this.saveItem(t));let s={};return t.values.forEach((e=>{s[e.dataset.locationField]=e.value})),s}getHiddenInputValue(e,t,s){return t.value||(t.value=t.field?.querySelector(`input[type=hidden][name="${s}"]`),this.saveItem(t)),t.value.value}getItem(e,t=null){const s=Object.hasOwn(e.dataset,"ref");let i=s?e.dataset.ref:window.generateID("input");if(s||(e.dataset.ref=i),!this.inputs.has(i)){t||(t=e.closest("[data-form-id]")?.dataset.formId??!1);let s=this.getField(e);this.inputs.set(i,{id:i,element:e,form:t,field:s,section:e.closest("[data-tab]")?.dataset.tab??!1,ui:window.uiFromSelectors(this.selectors.fields,s)})}return this.inputs.get(i)}saveItem(e){this.inputs.set(e.id,e)}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((s=>{try{s(e,t)}catch(e){console.error("HandleSelection subscriber error:",e)}}))}destroy(){this.forms.size>0&&(Array.from(this.forms.values()).forEach((e=>{this.removeFormListeners(e)})),this.forms.clear()),this.repeaters.size>0&&(Array.from(this.repeaters.values()).forEach((e=>{this.removeRepeaterListeners(e.element),e.sortable?.destroy()})),this.repeaters.clear()),this.quantityFields.size>0&&(Array.from(this.quantityFields.values()).forEach((e=>{this.removeQuantityListeners(e.element)})),this.quantityFields.clear()),this.tagLists.size>0&&(Array.from(this.tagLists.values()).forEach((e=>{this.removeTagListListeners(e.element)})),this.tagLists.clear()),this.charLimits.size>0&&Array.from(this.charLimits.values()).forEach((e=>{e.removeEventListener("input",this.countUpdaters)})),this.inputs.clear(),this.forms.clear(),this.charLimits.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbForm=new e)}))}))})();
\ No newline at end of file
diff --git a/assets/js/min/queue.min.js b/assets/js/min/queue.min.js
index fd05a2c..73c402b 100644
--- a/assets/js/min/queue.min.js
+++ b/assets/js/min/queue.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.error=window.jvbError,this.user=window.auth.getUser(),this.canUpdateUI=!0,this.isProcessing=!1,this.isPolling=!1,this.queue=new Map,this.items=new Map,this.subscribers=new Set,this.api=jvbSettings.api,this.endpoint="queue",this.queueItems=new Map,this.init()}init(){this.headers={"X-WP-Nonce":window.auth.getNonce()},this.initElements(),this.initListeners(),this.initStore(),this.canUpdateUI&&(this.popup=new window.jvbPopup({popup:this.ui.panel,toggle:this.ui.toggle.button,name:"Queue Panel"})),this.defineTemplates()}initElements(){this.panelStatuses=["syncing","synced","pending","offline"],this.statuses=["queued","localProcessing","uploading","pending","processing","completed","failed","failed_permanent"],this.pendingStatuses=["queued","localProcessing","uploading"],this.workingStatuses=["pending","processing"],this.completedStatuses=["completed","failed","failed_permanent"],this.icons={queued:"arrows-clockwise",localProcessing:"arrows-clockwise",uploading:"syncing",pending:"cloud",processing:"syncing",completed:"cloud-check",failed:"cloud-warning",failed_permanent:"cloud-warning"},this.selectors={panel:"aside#queue",toggle:{button:"button.qtoggle",indicator:".qtoggle .indicator",count:".qtoggle .count"},refresh:{button:"#queue .refresh .refreshNow",countdown:"#queue .refresh .countdown"},popup:{popup:"#queue .popup",message:"#queue .popup span"},items:{container:"#queue .qitems"},actions:{retry:"#queue .retry-all",clear:"#queue .dismiss-all"},filters:{filter:"#queue [data-filter]",all:{label:'#queue [for="qfilter-all"]',radio:'#queue [data-filter="all"]',count:'#queue [data-filter="all"] .count'},queued:{label:'#queue [for="qfilter-queued"]',input:'#queue [data-filter="queued"]',count:'#queue [for="qfilter-queued"] .count'},localProcessing:{label:'#queue [for="qfilter-localProcessing"]',input:'#queue [data-filter="localProcessing"]',count:'#queue [for="qfilter-localProcessing"] .count'},uploading:{label:'#queue [for="qfilter-uploading"]',input:'#queue [data-filter="uploading"]',count:'#queue [for="qfilter-uploading"] .count'},pending:{label:'#queue [for="qfilter-pending"]',input:'#queue [data-filter="pending"]',count:'#queue [for="qfilter-pending"] .count'},processing:{label:'#queue [for="qfilter-processing"]',input:'#queue [data-filter="processing"]',count:'#queue [for="qfilter-processing"] .count'},completed:{label:'#queue [for="qfilter-completed"]',input:'#queue [data-filter="completed"]',count:'#queue [for="qfilter-completed"] .count'},failed:{label:'#queue [for="qfilter-failed"]',input:'#queue [data-filter="failed"]',count:'#queue [for="qfilter-failed"] .count'}},item:{type:".type",status:".status",details:".info .details",icon:".status .icon",startedAt:".started time",completed:{wrap:".completed",label:".completed span",time:".completed time"},progress:{progress:".progress",fill:".progress .fill",details:".progress .details",icon:".progress .icon"},actions:{cancel:"button.cancel",retry:"button.retry",dismiss:"button.dismiss"}}},this.ui=window.uiFromSelectors(this.selectors),this.ui.panel||(this.canUpdateUI=!1)}defineTemplates(){const e=window.jvbTemplates;e.define("emptyState"),e.define("queueItem",{setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id}})}initListeners(){this.activityListeners=null,this.clickHandler=this.handleClick.bind(this),this.onlineHandler=this.handleOnline.bind(this),this.offlineHandler=this.handleOffline.bind(this),this.unloadHandler=this.handleBeforeUnload.bind(this),document.addEventListener("click",this.clickHandler),window.addEventListener("online",this.onlineHandler),window.addEventListener("offline",this.offlineHandler),window.addEventListener("beforeunload",this.unloadHandler)}handleOnline(){this.updatePanel("synced"),this.getQueueByStatus(this.pendingStatuses).length>0&&this.processQueue()}handleOffline(){this.updatePanel("offline")}handleBeforeUnload(e){if(this.getQueueByStatus(this.pendingStatuses).length>0)return e.preventDefault(),e.returnValue="",""}handleClick(e){if(!window.targetCheck(e,this.selectors.panel+", "+this.selectors.toggle.button))return;if(window.targetCheck(e,this.selectors.refresh.button))return this.ui.refresh.button.classList.add("fetching"),this.store.clearCache(),this.store.clearFilters(),void this.store.fetch().finally((()=>{this.ui.refresh.button.classList.remove("fetching")}));if(window.targetCheck(e,this.selectors.actions.clear))return void this.opActions("completed","dismiss").then((()=>{}));if(window.targetCheck(e,this.selectors.actions.retry))return void this.opActions("failed","retry").then((()=>{}));const t=window.targetCheck(e,"[data-action]");if(t){const e=t.closest("[data-id]")?.dataset.id;return void(e&&this.opActions(e,t.dataset.action))}const s=window.targetCheck(e,this.selectors.filters.filter);s&&this.setFilter(s.dataset.filter)}setFilter(e){Object.values(this.ui.filters).forEach((t=>{t.input?.dataset.filter===e&&(t.input.checked=!0)})),"all"===e?this.store.clearFilters():this.store.setFilter("status",e)}trackActivity(){if(!this.activityListeners){const e=["mousedown","mousemove","keypress","scroll","touchstart"];this.activityListeners=e.map((e=>{const t=()=>this.resetActivityTimer();return document.addEventListener(e,t,{passive:!0}),{event:e,handler:t}}))}this.resetActivityTimer()}resetActivityTimer(){this.activityTimer&&clearTimeout(this.activityTimer),this.activityTimer=setTimeout((()=>{this.processQueue()}),1750)}stopActivityTracking(){this.activityTimer&&(clearTimeout(this.activityTimer),this.activityTimer=null),this.activityListeners&&(this.activityListeners.forEach((({event:e,handler:t})=>{document.removeEventListener(e,t)})),this.activityListeners=null)}initStore(){if(!this.user)return;const e=window.jvbStore.register("queue",{storeName:"queue",keyPath:"id",endpoint:this.endpoint,TTL:1/0,indexes:[{name:"status",keyPath:"status"},{name:"type",keyPath:"type"}],showLoading:!1});this.store=e.queue,this.store.subscribe(((e,t)=>{switch(e){case"data-loaded":case"items-save":this.maybeStartPolling(),this.updateUI();break;case"item-saved":t.previousItem&&t.previousItem.status!==t.item.status&&this.updateOperationStatus(t.item.id,t.item.status),this.maybeStartPolling()}}))}addToQueue(e){const t={id:`u${this.user}_${Date.now()}_${Math.random().toString(36).substring(2,9)}`,endpoint:null,method:"POST",headers:{},data:{},delay:!1,canMerge:!0,popup:"Saving changes...",title:"Operation",status:"queued",timestamp:Date.now(),created_at:(new Date).toISOString(),retries:0,user:this.user,...e};if(t.headers={...this.headers,...t.headers},!t.endpoint||!t.data)return null;if(t.popup&&this.ui.popup?.message&&(this.ui.popup.message.textContent=t.popup,this.ui.popup.popup.hidden=!1,setTimeout((()=>this.ui.popup.popup.hidden=!0),2e3)),!t.delay)return this.queue.set(t.id,t),this.processOperation(t).then((()=>{})),this.store.clearCache(),this.maybeStartPolling(),this.toggleQueue(),t.id;const s=Array.from(this.getAllQueue()).filter((e=>"queued"===e.status&&e.endpoint===t.endpoint&&e.canMerge));if(s.length>0){const e=s[0];return e.data=window.deepMerge(e.data,t.data),e.timestamp=Date.now(),this.updateOperationStatus(e.id,e.status),this.updateUI(),this.trackActivity(),e.id}return this.store.clearCache(),this.setQueue(t),this.updateOperationStatus(t.id,t.status),this.updateUI(),this.trackActivity(),t.id}async opActions(e,t){if(this.statuses.includes(e)?e=this.getQueueByStatus(e).map((e=>e.id)):"string"==typeof e&&(e=[e]),0===e.length)return;if(!["cancel","dismiss","retry"].includes(t))return;const s=["cancel","dismiss"].includes(t);s&&e.forEach((e=>{this.removeOperationUI(e)}));try{const i=await fetch(`${this.api}${this.endpoint}`,{method:"POST",headers:{"Content-Type":"application/json",...this.headers},body:JSON.stringify({action:t,ids:e,user:this.user})});if(!i.ok)throw new Error(`${t} failed: ${i.status}`);const n=await i.json();if(!n.success)throw new Error(n.message||`${t} operation failed`);return e.forEach((e=>{let i=this.getQueue(e);if(i&&this.notify(`${t}-operation`,i),s)this.clearQueue(e);else{let t=this.getQueue(e);t.status="queued",this.setQueue(t),this.updateOperationStatus(t.id,t.status)}})),"retry"===t&&this.trackActivity(),this.updateUI(),n}catch(s){return await window.jvbError.log(s,{component:"Queue",operation:"performQueueAction",action:t,operationIds:e,itemCount:e.length},(()=>this.opActions(e,t))),{success:!1,error:s.message}}}async processQueue(){if(this.isProcessing)return;const e=this.getQueueByStatus("queued");if(0!==e.length){this.setProcessing();for(const t of e)await this.processOperation(t);this.setProcessing(!1),this.stopActivityTracking(),this.toggleQueue(this.maybeStartPolling())}else this.stopActivityTracking()}async processOperation(e){try{this.queue.has(e.id)||this.queue.set(e.id,e);let t,s=!1;if(e.data?._isFormData&&!e.data instanceof FormData&&(s=!0,e.data=await this.store.objectToFormData(e.data)),this.updateOperationStatus(e.id,"uploading"),e.data instanceof FormData?(e.data.append("id",e.id),e.data.append("user",this.user),t=e.data):(t=JSON.stringify({...e.data,id:e.id,user:this.user}),e.headers["Content-Type"]="application/json"),null==t)return;const i=await fetch(`${this.api}${e.endpoint}`,{method:e.method,headers:e.headers,body:t}),n=await i.json();if(s&&(e.data={}),!i.ok||!n.success)throw new Error(n.message||`HTTP ${i.status}`);n.id&&e.id!==n.id?e=await this.handleServerMerge(e,n):(e.status=n.status??"pending",e.serverData=n,this.updateOperationStatus(e.id,e.status)),this.a11y.announce(`${e.title} sent to server for processing`),this.setQueue(e)}catch(t){console.error("Operation failed: ",t),e.retries++,e.lastError=t.message,e.retries>=3?e.status="failed_permanent":e.status="failed",this.updateOperationStatus(e.id,e.status),this.setQueue(e)}}async handleServerMerge(e,t){const s=this.getQueue(t.id);return s?(e.status=t.status||"pending",e.serverData=t,this.mergeOp(s,e)):(this.clearQueue(e.id),this.setQueue(t),t)}mergeOp(e,t){return e.data=window.deepMerge(e.data,t.data),e.status=t.status,Object.hasOwn(t,"serverData")&&(e.serverData=t.serverData),this.updateOperationStatus(e.id,e.status),this.removeOperationUI(t.id),this.clearQueue(t.id),e}sortByDate(e){return e.sort(((e,t)=>(e.updated_at??e.timestamp??0)-(t.updated_at??t.timestamp??0)))}sortOperations(e){const t={processing:0,uploading:1,pending:2,queued:3,localProcessing:4,failed:5,completed:6,failed_permanent:7};return e.sort(((e,s)=>{const i=(t[e.status]??99)-(t[s.status]??99);if(0!==i)return i;const n=e.updated_at??e.timestamp??0,a=s.updated_at??s.timestamp??0;return new Date(a)-new Date(n)}))}getAllQueue(){let e=[...new Set([...Array.from(this.store.data.values()),...Array.from(this.queue.values())])];return this.sortOperations(e)}getQueueByStatus(e){"string"==typeof e&&(e=[e]);let t=[...new Set([...Array.from(this.store.filterByIndex({status:e})),...Array.from(this.queue.values()).filter((t=>e.includes(t.status)))])];return this.sortOperations(t)}updateOperationStatus(e,t){let s=this.getQueue(e);s&&this.statuses.includes(t)&&(s.status=t,this.notify("operation-status",s),this.setQueue(s))}setQueue(e){this.store.save(e),this.queue.set(e.id,e)}getQueue(e){return this.queue.has(e)?this.queue.get(e):this.store.get(e)}clearQueue(e){this.queue.delete(e),this.store.delete(e)}maybeStartPolling(){return this.getQueueByStatus([...this.pendingStatuses,...this.workingStatuses]).length>0?(this.startPolling(),!0):(this.updatePanel("synced"),!1)}startPolling(){this.isPolling||(this.isPolling=!0,this.updatePanel("pending"),this.runPollCycle())}async runPollCycle(){if(this.isPolling){try{if(this.ui.refresh.button.classList.add("fetching"),this.store.clearCache(),await this.store.fetch(),this.ui.refresh.button.classList.remove("fetching"),!this.maybeStartPolling())return this.stopPolling(),void this.updatePanel("synced")}catch(e){console.error("Polling error:",e)}this.startCountdown(5,(()=>this.runPollCycle()))}}startCountdown(e,t){this.ui.refresh.countdown?(this.ui.refresh.countdown.classList.add("counting"),this.ui.refresh.countdown.textContent=e,this.countdownTimer=setInterval((()=>{--e>0?this.ui.refresh.countdown.textContent=e:(this.stopCountdown(),t&&t())}),1e3)):console.warn("Countdown element not found")}stopPolling(){this.isPolling&&(this.isPolling=!1,this.pollTimer&&(clearInterval(this.pollTimer),this.pollTimer=null),this.stopCountdown())}stopCountdown(){this.countdownTimer&&(clearInterval(this.countdownTimer),this.countdownTimer=null),this.ui.refresh.countdown.classList.remove("counting"),this.ui.refresh.countdown.textContent=""}updateUI(){this.canUpdateUI&&window.debouncer.schedule("queue-ui",this.handleUpdateUI.bind(this))}handleUpdateUI(){const e=this.getAllQueue();this.ui.actions.retry.disabled=0===e.filter((e=>"failed"===e.status)).length,this.ui.actions.clear.disabled=0===e.filter((e=>"completed"===e.status)).length;const t=e.filter((e=>[...this.pendingStatuses,...this.workingStatuses].includes(e.status))).length;this.ui.toggle.count.hidden=0===t,this.ui.toggle.count.textContent=t;for(let t of this.statuses){if("failed_permanent"===t)continue;let s=e.filter((e=>e.status===t)).length;this.ui.filters[t].label.hidden=0===s,this.ui.filters[t].input.dataset.count=`${s}`,this.ui.filters[t].count.textContent=s>0?s:""}this.renderOperations()}renderOperations(){if(!this.ui.items.container)return;const e=this.store.filters?.status??"all",t="all"===e?this.getAllQueue():this.getQueueByStatus(e),s=this.sortOperations(t);if(0===s.length){window.removeChildren(this.ui.items.container);const e=window.jvbTemplates.create("emptyQueue");return this.ui.items.container.append(e),void this.a11y.announce("No items in queue")}this.ui.items.container.querySelector(".empty-group")?.remove();const i=new Set(s.map((e=>e.id)));this.items.forEach(((e,t)=>{i.has(t)||(e.element?.remove(),this.items.delete(t))})),s.forEach(((e,t)=>{let s=this.items.get(e.id);s||(s=this.createOperationElement(e)),s?.element&&(this.updateOperationUI(e.id),this.ui.items.container.append(s.element))}))}createOperationElement(e){const t=window.jvbTemplates.create("queueItem",e),s={element:t,ui:window.uiFromSelectors(this.selectors.item,t)};return this.items.set(e.id,s),s}updateOperationUI(e){let t=this.items.has(e)?this.items.get(e):this.createOperationElement(e);if(!t)return;let s=this.getQueue(e),i=t.element;i.classList.remove(this.statuses),i.classList.add(s.status);let n=this.getProgress(s);t.ui.type&&t.ui.type.textContent!==s.title&&(t.ui.type.textContent=s.title),t.ui.status&&(t.ui.status.title=this.statusLabel(s.status)),t.ui.icon&&(t.ui.icon.className=`icon icon-${this.icons[s.status]}`),t.ui.details&&(t.ui.details.textContent=this.itemMessage(s)),t.ui.startedAt&&(t.ui.startedAt.setAttribute("datetime",s.created_at),t.ui.startedAt.textContent=window.formatTimeAgo(s.created_at));s.status;const a="completed"===s.status&&(s.completed_at||s.updated_at);if(t.ui.completed.wrap.hidden=!a,a){const e=s.completed_at??s.updated_at;t.ui.completed.label.textContent="Completed: ",t.ui.completed.time.setAttribute("datetime",e),t.ui.completed.time.textContent=window.formatTimeAgo(e)}window.showProgress(t.ui.progress,n,100,this.statusLabel(s.status)),t.ui.actions.cancel&&(t.ui.actions.cancel.hidden=this.completedStatuses.includes(s.status)),t.ui.actions.retry&&(s.retries>=3&&(t.ui.actions.retry.disabled=!0),t.ui.actions.retry.hidden="failed"!==s.status),t.ui.actions.dismiss&&(t.ui.actions.dismiss.hidden=this.pendingStatuses.includes(s.status))}getProgress(e){if(e.progress)return e.progress;if(!this.statuses.includes(e.status))return 0;return{queued:10,uploading:25,pending:40,processing:70,completed:100,failed:0,failed_permanent:0}[e.status]??0}removeOperationUI(e){let t=this.items.get(e);t&&window.fade(t.element,!1)}updatePanel(e="syncing"){this.panelStatuses.includes(e)&&(this.ui.panel.classList.remove(...this.panelStatuses),this.ui.panel.classList.add(e))}statusLabel(e){if(!this.statuses.includes(e))return"";return{queued:"Queued",localProcessing:"Processing locally",uploading:"Uploading",pending:"Waiting on server",processing:"Processing",completed:"Completed",failed:"Failed",failed_permanent:"Failed permanently"}[e]}itemMessage(e){if(Object.hasOwn(e,"message")&&""!==e.message)return e.message;if(Object.hasOwn(e,"error_message")&&e.error_message)return e.error_message;switch(e.status){case"queued":return"Waiting to send...";case"uploading":return"Sending to server...";case"pending":return e.position?`Position ${e.position} in queue`:"In server queue";case"processing":return e.progress?`${e.progress}% complete`:"Processing...";case"completed":return"Successfully completed";case"failed":return`Failed: ${e.lastError||"Unknown error"} (Retry ${e.retries}/${this.config.maxRetries})`;case"failed_permanent":return`Failed: ${e.lastError||"Unknown error"}`;default:return""}}toggleQueue(e=!0){this.ui.panel.hidden=!e,this.ui.toggle.button.hidden=!e}setProcessing(e=!0){this.isProcessing=e,this.ui.toggle.button.classList.toggle("saving",e)}subscribe(e){if(this.subscribers)return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((s=>s(e,t)))}destroy(){this.isPolling&&this.stopPolling(),this.stopActivityTracking(),document.removeEventListener("click",this.clickHandler),this.subscribers.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbQueue=new e)}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.error=window.jvbError,this.user=window.auth.getUser(),this.canUpdateUI=!0,this.isProcessing=!1,this.isPolling=!1,this.queue=new Map,this.items=new Map,this.subscribers=new Set,this.api=jvbSettings.api,this.endpoint="queue",this.queueItems=new Map,this.init()}init(){this.headers={"X-WP-Nonce":window.auth.getNonce()},this.initElements(),this.initListeners(),this.initStore(),this.canUpdateUI&&this.ui.panel&&(this.popup=new window.jvbPopup({popup:this.ui.panel,toggle:this.ui.toggle.button,name:"Queue Panel"})),this.defineTemplates()}initElements(){this.panelStatuses=["syncing","synced","pending","offline"],this.statuses=["queued","localProcessing","uploading","pending","processing","completed","failed","failed_permanent"],this.pendingStatuses=["queued","localProcessing","uploading"],this.workingStatuses=["pending","processing"],this.completedStatuses=["completed","failed","failed_permanent"],this.icons={queued:"arrows-clockwise",localProcessing:"arrows-clockwise",uploading:"syncing",pending:"cloud",processing:"syncing",completed:"cloud-check",failed:"cloud-warning",failed_permanent:"cloud-warning"},this.selectors={panel:"aside#queue",toggle:{button:"button.qtoggle",indicator:".qtoggle .indicator",count:".qtoggle .count"},refresh:{button:"#queue .refresh .refreshNow",countdown:"#queue .refresh .countdown"},popup:{popup:"#queue .popup",message:"#queue .popup span"},items:{container:"#queue .qitems"},actions:{retry:"#queue .retry-all",clear:"#queue .dismiss-all"},filters:{filter:"#queue [data-filter]",all:{label:'#queue [for="qfilter-all"]',radio:'#queue [data-filter="all"]',count:'#queue [data-filter="all"] .count'},queued:{label:'#queue [for="qfilter-queued"]',input:'#queue [data-filter="queued"]',count:'#queue [for="qfilter-queued"] .count'},localProcessing:{label:'#queue [for="qfilter-localProcessing"]',input:'#queue [data-filter="localProcessing"]',count:'#queue [for="qfilter-localProcessing"] .count'},uploading:{label:'#queue [for="qfilter-uploading"]',input:'#queue [data-filter="uploading"]',count:'#queue [for="qfilter-uploading"] .count'},pending:{label:'#queue [for="qfilter-pending"]',input:'#queue [data-filter="pending"]',count:'#queue [for="qfilter-pending"] .count'},processing:{label:'#queue [for="qfilter-processing"]',input:'#queue [data-filter="processing"]',count:'#queue [for="qfilter-processing"] .count'},completed:{label:'#queue [for="qfilter-completed"]',input:'#queue [data-filter="completed"]',count:'#queue [for="qfilter-completed"] .count'},failed:{label:'#queue [for="qfilter-failed"]',input:'#queue [data-filter="failed"]',count:'#queue [for="qfilter-failed"] .count'}},item:{type:".type",status:".status",details:".info .details",icon:".status .icon",startedAt:".started time",completed:{wrap:".completed",label:".completed span",time:".completed time"},progress:{progress:".progress",fill:".progress .fill",details:".progress .details",icon:".progress .icon"},actions:{cancel:"button.cancel",retry:"button.retry",dismiss:"button.dismiss"}}},this.ui=window.uiFromSelectors(this.selectors),this.ui.panel||(this.canUpdateUI=!1)}defineTemplates(){const e=window.jvbTemplates;e.define("emptyState"),e.define("queueItem",{setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id}})}initListeners(){this.activityListeners=null,this.clickHandler=this.handleClick.bind(this),this.onlineHandler=this.handleOnline.bind(this),this.offlineHandler=this.handleOffline.bind(this),this.unloadHandler=this.handleBeforeUnload.bind(this),document.addEventListener("click",this.clickHandler),window.addEventListener("online",this.onlineHandler),window.addEventListener("offline",this.offlineHandler),window.addEventListener("beforeunload",this.unloadHandler)}handleOnline(){this.updatePanel("synced"),this.getQueueByStatus(this.pendingStatuses).length>0&&this.processQueue()}handleOffline(){this.updatePanel("offline")}handleBeforeUnload(e){if(!this.ui.panel)return;return this.getQueueByStatus(this.pendingStatuses).length>0?(e.preventDefault(),e.returnValue="",""):void 0}handleClick(e){if(!window.targetCheck(e,this.selectors.panel+", "+this.selectors.toggle.button))return;if(window.targetCheck(e,this.selectors.refresh.button))return this.ui.refresh.button.classList.add("fetching"),this.store.clearCache(),this.store.clearFilters(),void this.store.fetch().finally((()=>{this.ui.refresh.button.classList.remove("fetching")}));if(window.targetCheck(e,this.selectors.actions.clear))return void this.opActions("completed","dismiss").then((()=>{}));if(window.targetCheck(e,this.selectors.actions.retry))return void this.opActions("failed","retry").then((()=>{}));const t=window.targetCheck(e,"[data-action]");if(t){const e=t.closest("[data-id]")?.dataset.id;return void(e&&this.opActions(e,t.dataset.action))}const s=window.targetCheck(e,this.selectors.filters.filter);s&&this.setFilter(s.dataset.filter)}setFilter(e){Object.values(this.ui.filters).forEach((t=>{t.input?.dataset.filter===e&&(t.input.checked=!0)})),"all"===e?this.store.clearFilters():this.store.setFilter("status",e)}trackActivity(){if(!this.activityListeners){const e=["mousedown","mousemove","keypress","scroll","touchstart"];this.activityListeners=e.map((e=>{const t=()=>this.resetActivityTimer();return document.addEventListener(e,t,{passive:!0}),{event:e,handler:t}}))}this.resetActivityTimer()}resetActivityTimer(){this.activityTimer&&clearTimeout(this.activityTimer),this.activityTimer=setTimeout((()=>{this.processQueue()}),1750)}stopActivityTracking(){this.activityTimer&&(clearTimeout(this.activityTimer),this.activityTimer=null),this.activityListeners&&(this.activityListeners.forEach((({event:e,handler:t})=>{document.removeEventListener(e,t)})),this.activityListeners=null)}initStore(){if(!this.user)return;const e=window.jvbStore.register("queue",{storeName:"queue",keyPath:"id",endpoint:this.endpoint,TTL:1/0,indexes:[{name:"status",keyPath:"status"},{name:"type",keyPath:"type"}],filters:{user:window.auth.getUser()},showLoading:!1});this.store=e.queue,this.store.subscribe(((e,t)=>{switch(e){case"data-loaded":case"items-save":this.maybeStartPolling(),this.updateUI();break;case"item-saved":t.previousItem&&t.previousItem.status!==t.item.status&&this.updateOperationStatus(t.item.id,t.item.status),this.maybeStartPolling()}}))}addToQueue(e){const t={id:`u${this.user}_${Date.now()}_${Math.random().toString(36).substring(2,9)}`,endpoint:null,method:"POST",headers:{},data:{},delay:!1,canMerge:!0,popup:"Saving changes...",title:"Operation",status:"queued",timestamp:Date.now(),created_at:(new Date).toISOString(),retries:0,user:this.user,...e};if(t.headers={...this.headers,...t.headers},!t.endpoint||!t.data)return null;if(t.popup&&this.ui.popup?.message&&(this.ui.popup.message.textContent=t.popup,this.ui.popup.popup.hidden=!1,setTimeout((()=>this.ui.popup.popup.hidden=!0),2e3)),!t.delay)return this.queue.set(t.id,t),this.processOperation(t).then((()=>{})),this.store.clearCache(),this.maybeStartPolling(),this.toggleQueue(),t.id;const s=Array.from(this.getAllQueue()).filter((e=>"queued"===e.status&&e.endpoint===t.endpoint&&e.canMerge));if(s.length>0){const e=s[0];return e.data=window.deepMerge(e.data,t.data),e.timestamp=Date.now(),this.updateOperationStatus(e.id,e.status),this.updateUI(),this.trackActivity(),e.id}return this.store.clearCache(),this.setQueue(t),this.updateOperationStatus(t.id,t.status),this.updateUI(),this.trackActivity(),t.id}async opActions(e,t){if(this.statuses.includes(e)?e=this.getQueueByStatus(e).map((e=>e.id)):"string"==typeof e&&(e=[e]),0===e.length)return;if(!["cancel","dismiss","retry"].includes(t))return;const s=["cancel","dismiss"].includes(t);s&&e.forEach((e=>{this.removeOperationUI(e)}));try{const i=await fetch(`${this.api}${this.endpoint}`,{method:"POST",headers:{"Content-Type":"application/json",...this.headers},body:JSON.stringify({action:t,ids:e,user:this.user})});if(!i.ok)throw new Error(`${t} failed: ${i.status}`);const n=await i.json();if(!n.success)throw new Error(n.message||`${t} operation failed`);return e.forEach((e=>{let i=this.getQueue(e);if(i&&this.notify(`${t}-operation`,i),s)this.clearQueue(e);else{let t=this.getQueue(e);t.status="queued",this.setQueue(t),this.updateOperationStatus(t.id,t.status)}})),"retry"===t&&this.trackActivity(),this.updateUI(),n}catch(s){return await window.jvbError.log(s,{component:"Queue",operation:"performQueueAction",action:t,operationIds:e,itemCount:e.length},(()=>this.opActions(e,t))),{success:!1,error:s.message}}}async processQueue(){if(this.isProcessing)return;const e=this.getQueueByStatus("queued");if(0!==e.length){this.setProcessing();for(const t of e)await this.processOperation(t);this.setProcessing(!1),this.stopActivityTracking(),this.toggleQueue(this.maybeStartPolling())}else this.stopActivityTracking()}async processOperation(e){try{this.queue.has(e.id)||this.queue.set(e.id,e);let t,s=!1;if(e.data?._isFormData&&!e.data instanceof FormData&&(s=!0,e.data=await this.store.objectToFormData(e.data)),this.updateOperationStatus(e.id,"uploading"),e.data instanceof FormData?(e.data.append("id",e.id),e.data.append("user",window.auth.getUser()),t=e.data):(t=JSON.stringify({...e.data,id:e.id,user:window.auth.getUser()}),e.headers["Content-Type"]="application/json"),null==t)return;const i=await fetch(`${this.api}${e.endpoint}`,{method:e.method,headers:e.headers,body:t}),n=await i.json();if(s&&(e.data={}),!i.ok||!n.success)throw new Error(n.message||`HTTP ${i.status}`);n.id&&e.id!==n.id?e=await this.handleServerMerge(e,n):(e.status=n.status??"pending",e.serverData=n,this.updateOperationStatus(e.id,e.status)),this.a11y.announce(`${e.title} sent to server for processing`),this.setQueue(e)}catch(t){console.error("Operation failed: ",t),e.retries++,e.lastError=t.message,e.retries>=3?e.status="failed_permanent":e.status="failed",this.updateOperationStatus(e.id,e.status),this.setQueue(e)}}async handleServerMerge(e,t){const s=this.getQueue(t.id);return s?(e.status=t.status||"pending",e.serverData=t,this.mergeOp(s,e)):(this.clearQueue(e.id),this.setQueue(t),t)}mergeOp(e,t){return e.data=window.deepMerge(e.data,t.data),e.status=t.status,Object.hasOwn(t,"serverData")&&(e.serverData=t.serverData),this.updateOperationStatus(e.id,e.status),this.removeOperationUI(t.id),this.clearQueue(t.id),e}sortByDate(e){return e.sort(((e,t)=>(e.updated_at??e.timestamp??0)-(t.updated_at??t.timestamp??0)))}sortOperations(e){const t={processing:0,uploading:1,pending:2,queued:3,localProcessing:4,failed:5,completed:6,failed_permanent:7};return e.sort(((e,s)=>{const i=(t[e.status]??99)-(t[s.status]??99);if(0!==i)return i;const n=e.updated_at??e.timestamp??0,a=s.updated_at??s.timestamp??0;return new Date(a)-new Date(n)}))}getAllQueue(){let e=[...new Set([...Array.from(this.store.data.values()),...Array.from(this.queue.values())])];return this.sortOperations(e)}getQueueByStatus(e){"string"==typeof e&&(e=[e]);let t=[...new Set([...Array.from(this.store.filterByIndex({status:e})),...Array.from(this.queue.values()).filter((t=>e.includes(t.status)))])];return this.sortOperations(t)}updateOperationStatus(e,t){let s=this.getQueue(e);s&&this.statuses.includes(t)&&(s.status=t,this.notify("operation-status",s),this.setQueue(s))}setQueue(e){this.store.save(e),this.queue.set(e.id,e)}getQueue(e){return this.queue.has(e)?this.queue.get(e):this.store.get(e)}clearQueue(e){this.queue.delete(e),this.store.delete(e)}maybeStartPolling(){return this.getQueueByStatus([...this.pendingStatuses,...this.workingStatuses]).length>0?(this.startPolling(),!0):(this.updatePanel("synced"),!1)}startPolling(){this.isPolling||(this.isPolling=!0,this.updatePanel("pending"),this.runPollCycle())}async runPollCycle(){if(this.isPolling){try{if(this.ui.refresh.button.classList.add("fetching"),this.store.clearCache(),await this.store.fetch(),this.ui.refresh.button.classList.remove("fetching"),!this.maybeStartPolling())return this.stopPolling(),void this.updatePanel("synced")}catch(e){console.error("Polling error:",e)}this.startCountdown(5,(()=>this.runPollCycle()))}}startCountdown(e,t){this.ui.refresh.countdown?(this.ui.refresh.countdown.classList.add("counting"),this.ui.refresh.countdown.textContent=e,this.countdownTimer=setInterval((()=>{--e>0?this.ui.refresh.countdown.textContent=e:(this.stopCountdown(),t&&t())}),1e3)):console.warn("Countdown element not found")}stopPolling(){this.isPolling&&(this.isPolling=!1,this.pollTimer&&(clearInterval(this.pollTimer),this.pollTimer=null),this.stopCountdown())}stopCountdown(){this.countdownTimer&&(clearInterval(this.countdownTimer),this.countdownTimer=null),this.ui.refresh.countdown.classList.remove("counting"),this.ui.refresh.countdown.textContent=""}updateUI(){this.canUpdateUI&&window.debouncer.schedule("queue-ui",this.handleUpdateUI.bind(this))}handleUpdateUI(){const e=this.getAllQueue();this.ui.actions.retry.disabled=0===e.filter((e=>"failed"===e.status)).length,this.ui.actions.clear.disabled=0===e.filter((e=>"completed"===e.status)).length;const t=e.filter((e=>[...this.pendingStatuses,...this.workingStatuses].includes(e.status))).length;this.ui.toggle.count.hidden=0===t,this.ui.toggle.count.textContent=t;for(let t of this.statuses){if("failed_permanent"===t)continue;let s=e.filter((e=>e.status===t)).length;this.ui.filters[t].label.hidden=0===s,this.ui.filters[t].input.dataset.count=`${s}`,this.ui.filters[t].count.textContent=s>0?s:""}this.renderOperations()}renderOperations(){if(!this.ui.items.container)return;const e=this.store.filters?.status??"all",t="all"===e?this.getAllQueue():this.getQueueByStatus(e),s=this.sortOperations(t);if(0===s.length){window.removeChildren(this.ui.items.container);const e=window.jvbTemplates.create("emptyQueue");return this.ui.items.container.append(e),void this.a11y.announce("No items in queue")}this.ui.items.container.querySelector(".empty-group")?.remove();const i=new Set(s.map((e=>e.id)));this.items.forEach(((e,t)=>{i.has(t)||(e.element?.remove(),this.items.delete(t))})),s.forEach(((e,t)=>{let s=this.items.get(e.id);s||(s=this.createOperationElement(e)),s?.element&&(this.updateOperationUI(e.id),this.ui.items.container.append(s.element))}))}createOperationElement(e){const t=window.jvbTemplates.create("queueItem",e),s={element:t,ui:window.uiFromSelectors(this.selectors.item,t)};return this.items.set(e.id,s),s}updateOperationUI(e){let t=this.items.has(e)?this.items.get(e):this.createOperationElement(e);if(!t)return;let s=this.getQueue(e),i=t.element;i.classList.remove(this.statuses),i.classList.add(s.status);let n=this.getProgress(s);t.ui.type&&t.ui.type.textContent!==s.title&&(t.ui.type.textContent=s.title),t.ui.status&&(t.ui.status.title=this.statusLabel(s.status)),t.ui.icon&&(t.ui.icon.className=`icon icon-${this.icons[s.status]}`),t.ui.details&&(t.ui.details.textContent=this.itemMessage(s)),t.ui.startedAt&&(t.ui.startedAt.setAttribute("datetime",s.created_at),t.ui.startedAt.textContent=window.formatTimeAgo(s.created_at));s.status;const a="completed"===s.status&&(s.completed_at||s.updated_at);if(t.ui.completed.wrap.hidden=!a,a){const e=s.completed_at??s.updated_at;t.ui.completed.label.textContent="Completed: ",t.ui.completed.time.setAttribute("datetime",e),t.ui.completed.time.textContent=window.formatTimeAgo(e)}window.showProgress(t.ui.progress,n,100,this.statusLabel(s.status)),t.ui.actions.cancel&&(t.ui.actions.cancel.hidden=this.completedStatuses.includes(s.status)),t.ui.actions.retry&&(s.retries>=3&&(t.ui.actions.retry.disabled=!0),t.ui.actions.retry.hidden="failed"!==s.status),t.ui.actions.dismiss&&(t.ui.actions.dismiss.hidden=this.pendingStatuses.includes(s.status))}getProgress(e){if(e.progress)return e.progress;if(!this.statuses.includes(e.status))return 0;return{queued:10,uploading:25,pending:40,processing:70,completed:100,failed:0,failed_permanent:0}[e.status]??0}removeOperationUI(e){let t=this.items.get(e);t&&window.fade(t.element,!1)}updatePanel(e="syncing"){this.ui.panel&&this.panelStatuses.includes(e)&&(this.ui.panel.classList.remove(...this.panelStatuses),this.ui.panel.classList.add(e))}statusLabel(e){if(!this.statuses.includes(e))return"";return{queued:"Queued",localProcessing:"Processing locally",uploading:"Uploading",pending:"Waiting on server",processing:"Processing",completed:"Completed",failed:"Failed",failed_permanent:"Failed permanently"}[e]}itemMessage(e){if(Object.hasOwn(e,"message")&&""!==e.message)return e.message;if(Object.hasOwn(e,"error_message")&&e.error_message)return e.error_message;switch(e.status){case"queued":return"Waiting to send...";case"uploading":return"Sending to server...";case"pending":return e.position?`Position ${e.position} in queue`:"In server queue";case"processing":return e.progress?`${e.progress}% complete`:"Processing...";case"completed":return"Successfully completed";case"failed":return`Failed: ${e.lastError||"Unknown error"} (Retry ${e.retries}/2)`;case"failed_permanent":return`Failed: ${e.lastError||"Unknown error"}`;default:return""}}toggleQueue(e=!0){this.ui.panel&&(this.ui.panel.hidden=!e,this.ui.toggle.button.hidden=!e)}setProcessing(e=!0){this.isProcessing=e,this.ui.toggle.button.classList.toggle("saving",e)}subscribe(e){if(this.subscribers)return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((s=>s(e,t)))}destroy(){this.isPolling&&this.stopPolling(),this.stopActivityTracking(),document.removeEventListener("click",this.clickHandler),this.subscribers.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbQueue=new e)}))}))})();
\ No newline at end of file
diff --git a/assets/js/min/referral.min.js b/assets/js/min/referral.min.js
index b712ec0..5c055c3 100644
--- a/assets/js/min/referral.min.js
+++ b/assets/js/min/referral.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.container=document.querySelector("aside.referral"),this.container&&(this.a11y=window.jvbA11y,this.toggle=document.querySelector('button[data-action="toggle-referral"]'),this.hasCopy=navigator.clipboard&&navigator.clipboard.writeText,this.initElements(),this.initStore(),this.initListeners(),this.checkForReferral())}initElements(){this.selectors={copyBtn:".copy-btn",checkCode:".check-code-btn",submit:"[type=submit]",recentList:".recent-referrals-list",stats:{codeUsed:'[data-stat="code_used"]',consultations:'[data-stat="consultations"]',treatments:'[data-stat="treatments"]',rewards:'[data-stat="total_rewards"]'}},this.forms=this.container.querySelectorAll("form"),this.popup=new window.jvbPopup({toggle:this.toggle,popup:this.container,name:"Referral Box",onOpen:()=>{this.bindEventListeners(!0)},onClose:()=>{this.bindEventListeners(!1)}}),this.tabs=null,this.container.querySelector("nav.tabs")&&(this.tabs=new window.jvbTabs(this.container,{updateURL:!1})),this.ui=window.uiFromSelectors(this.selectors),this.hasCopy||document.querySelectorAll(this.selectors.copyBtn).forEach((e=>{e.remove()})),this.formController=null}initStore(){if(!this.isLoggedIn())return;const e=window.jvbStore.register("referrals",[{storeName:"stats",keyPath:"user_id",endpoint:"referrals/stats",TTL:3e5,showLoading:!1,delayFetch:!1,filters:{type:"dashboard",user:window.auth.getUser()}},{storeName:"list",keyPath:"id",endpoint:"referrals",TTL:6e5,showLoading:!1,delayFetch:!1,filters:{user:window.auth.getUser(),status:"all",limit:50,offset:0}}]);this.statsStore=e.stats,this.listStore=e.list,this.statsStore&&this.statsStore.subscribe(this.handleStatsEvent.bind(this)),this.listStore&&this.listStore.subscribe(this.handleListEvent.bind(this))}initListeners(){this.clickHandler=this.handleClick.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleFormSubmit.bind(this)}bindEventListeners(e){const t=e?"addEventListener":"removeEventListener";this.forms.forEach((e=>{e[t]("submit",this.submitHandler)})),this.container[t]("click",this.clickHandler),this.container[t]("input",this.inputHandler)}isLoggedIn(){return Boolean(window.auth.getUser())}handleStatsEvent(e,t){switch(e){case"data-loaded":t.items&&t.items.length>0&&this.updateStatsDisplay();break;case"fetch-error":console.error("Error loading stats:",t.error)}}handleListEvent(e,t){switch(e){case"data-loaded":this.ui.recentList&&this.renderRecentReferrals();break;case"fetch-error":console.error("Error loading referrals:",t.error)}}updateStatsDisplay(){if(0===!this.statsStore.data.size)return;let e=this.statsStore.data.get(parseInt(window.auth.getUser()));const t={total:e.code_used||0,treated:e.treatments||0,pending:e.pending||0,rewards:"$"+parseFloat(e.total_rewards||0).toFixed(2)};Object.entries(t).forEach((([e,t])=>{const r=this.container.querySelector(`[data-stat="${e}"]`);r&&(r.textContent=t)}));const r=this.container.querySelectorAll(".stats .card");r.length>=4&&(r[0].querySelector(".stat-number").textContent=t.code_used,r[1].querySelector(".stat-number").textContent=t.consultations,r[2].querySelector(".stat-number").textContent=t.treatments,r[3].querySelector(".stat-number").textContent=t.total_rewards)}handleClick(e){const t=e.target.closest(".copy-btn, .check-code-btn, .attn");t&&(t.classList.contains("copy-btn")?this.handleCopyClick(t):t.classList.contains("check-code-btn")?this.handleCheckCode(e):t.classList.contains("attn")&&t.classList.remove("attn"))}handleCopyClick(e){const t=e.dataset.target,r=this.container.querySelector(`#${t}`);if(!r)return;const s=r.textContent.trim();this.hasCopy&&navigator.clipboard.writeText(s).then((()=>{e.classList.toggle("success"),setTimeout((()=>{e.classList.remove("success")}),1500)}))}handleError(e,t){const{message:r,code:s,field:a}=t;switch(a?this.showFieldError(e,a,r):this.showFormStatus(e,"error",r||"Something went wrong. Please try again."),s){case"duplicate_email":break;case"invalid_code":const t=e.querySelector('[name="referral_code"]');t&&(t.readOnly=!1,t.focus());break;case"turnstile_failed":window.turnstile&&e.querySelector(".cf-turnstile")&&window.turnstile.reset()}}showFieldError(e,t,r){let s=e.querySelector(`.field[data-field="${t}"]`);if(s||(s=e.querySelector(`.field[data-field="referral_${t}"]`)),!s)return void this.showFormStatus(e,"error",r);const a=s.querySelector("input, textarea, select"),n=s.querySelector(".validation-message"),i=s.querySelector(".validation-icon.error"),o=s.querySelector(".validation-icon.success");a?(s.classList.remove("has-success"),s.classList.add("has-error"),a.classList.add("error"),a.setAttribute("aria-invalid","true"),i&&(i.hidden=!1),o&&(o.hidden=!0),n&&(n.textContent=r,n.hidden=!1),a.focus(),this.a11y?.announce(`Error in ${t}: ${r}`)):this.showFormStatus(e,"error",r)}showFormStatus(e,t,r=""){const s=e.querySelector(".fstatus");if(!s)return void console.warn("No .fstatus element found in form");s.hidden=!1;const a=s.querySelector(".message");s.querySelector(".icon")?.remove(),s.querySelector(".actions")?.remove();const n={saving:"Sending...",submitted:"Sent successfully!",error:"Something went wrong",checking:"Checking code..."},i={submitted:"check-circle",error:"close-circle",checking:"loading"};if(i[t]&&window.getIcon){const e=window.getIcon(i[t]);e&&s.prepend(e)}a&&(a.textContent=r||n[t]||t),s.classList.toggle("loading",["saving","checking"].includes(t)),"submitted"===t&&setTimeout((()=>s.hidden=!0),3e3),this.a11y&&this.a11y.announce(r||n[t]||t)}clearFormErrors(e){e.querySelectorAll(".field.has-error, .field.has-success").forEach((e=>{this.clearFieldValidation(e)}));const t=e.querySelector(".fstatus");t&&(t.hidden=!0)}clearFieldValidation(e){if(!e)return;const t=e.querySelector("input, textarea, select"),r=e.querySelector(".validation-message"),s=e.querySelectorAll(".validation-icon");e.classList.remove("has-error","has-success"),t&&(t.classList.remove("error"),t.removeAttribute("aria-invalid")),s.forEach((e=>e.hidden=!0)),r&&(r.hidden=!0,r.textContent="")}handleInput(e){"referral_code"!==e.target.id&&"referral_code"!==e.target.name||(e.target.value=e.target.value.toUpperCase());const t=e.target.closest(".field");t&&t.classList.contains("has-error")&&this.clearFieldValidation(t)}async handleCheckCode(e){e.preventDefault();const t=e.target.closest("form"),r=t.querySelector('[name="referral_code"]'),s=t.querySelector(".code-status");if(!r||!s)return;const a=r.value.trim();if(a){s.hidden=!1,s.className="code-status loading",s.innerHTML='<span class="spinner"></span> Checking...';try{const e=await this.validateCodeOnly(a);e.success?this.showCodeStatus(s,`✓ Valid! Referred by ${e.referrer_name}`,"success"):this.showCodeStatus(s,e.message||"Invalid code","error")}catch(e){console.error("Error checking code:",e),this.showCodeStatus(s,"Error checking code","error")}}else this.showCodeStatus(s,"Please enter a code","error")}showCodeStatus(e,t,r){e.hidden=!1,e.className=`code-status ${r}`,e.textContent=t,"error"===r&&setTimeout((()=>{e.hidden=!0}),5e3)}async checkForReferral(){const e=this.getUrlParameter("ref"),t=this.getUrlParameter("rname"),r=this.getUrlParameter("remail"),s=this.getUrlParameter("seeReferral");if(!e&&!s)return;if(s&&!e)return this.popup.openPopup(),void this.removeUrlParameter("seeReferral");const a=this.container.querySelector('[name="referral_code"]');if(!a)return;const n=e.toUpperCase();if(a.value=n,a.readOnly=!0,t||r){const e=this.container.querySelector('[name="referral_name"]');e&&(e.value=t);const s=this.container.querySelector('[name="referral_email"]');s&&(s.value=r)}this.popup.openPopup();try{const e=await this.validateCodeOnly(n);if(e.success){const t=a.closest("form").querySelector(".code-status");t&&this.showCodeStatus(t,`✓ ${e.referrer_name} invited you!`,"success");const r=this.container.querySelector('[name="referral_name"]');r&&!r.value&&r.focus()}else a.readOnly=!1,this.showMessage("This referral link is invalid. Please enter a valid code.","error")}catch(e){console.error("Error validating code:",e),a.readOnly=!1}this.removeUrlParameter("ref"),this.removeUrlParameter("rname"),this.removeUrlParameter("remail")}getUrlParameter(e){return new URLSearchParams(window.location.search).get(e)}removeUrlParameter(e){const t=new URL(window.location);t.searchParams.delete(e),window.history.replaceState({},document.title,t.toString())}async validateCodeOnly(e){const t=await fetch(`${jvbSettings.api}referrals/code`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":window.auth.getNonce()},body:JSON.stringify({code:e})});return await t.json()}renderRecentReferrals(){let e=this.ui.recentList,t=Array.from(this.listStore.data.values());t&&0!==t.length?e.innerHTML=t.map((e=>`\n\t\t\t<div class="referral-item">\n\t\t\t\t<div class="referral-info">\n\t\t\t\t\t<strong>${window.escapeHtml(e.referee_name)}</strong>\n\t\t\t\t\t<span class="status-badge">${e.referral_status}</span>\n\t\t\t\t</div>\n\t\t\t\t<div class="referral-date">${window.formatTimeAgo(e.referred_at)}</div>\n\t\t\t</div>\n\t\t`)).join(""):e.innerHTML='<p class="no-referrals">Share your code to get started!</p>'}async handleFormSubmit(e){e.preventDefault();const t=e.target,r=new FormData(t);this.clearFormErrors(t),this.setFormLoading(!0,t);try{let e={success:!1,message:""};if("referral-code-form"===t.id){let t={name:r.get("referral_name"),email:r.get("referral_email"),referral_code:r.get("referral_code")};r.get("cf-turnstile-response")&&(t["cf-turnstile-response"]=r.get("cf-turnstile-response")),t.name&&t.email&&t.referral_code?e=await this.makeRequest("auth/register",t):e.message="Please fill in all fields"}else if("login-form"===t.id){let t={type:"login",email:r.get("login_email"),context:{redirect_to:window.location.href+"?seeReferral=1"}};r.get("cf-turnstile-response")&&(t["cf-turnstile-response"]=r.get("cf-turnstile-response")),t.email?e=await this.makeRequest("magic",t):e.message="Please fill in your email"}e.success?this.handleSuccess(t,e):this.handleError(t,e)}catch(e){console.error("Error submitting form:",e),this.showFormMessage(t,"Something went wrong. Please try again.","error")}finally{this.setFormLoading(!1,t)}}async makeRequest(e,t){if(!["magic","auth/register"].includes(e))return{success:!1,message:"Invalid endpoint"};const r=await fetch(`${jvbSettings.api}${e}`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":window.auth.getNonce()},body:JSON.stringify(t)});if(!r.ok){const e=await r.text();console.error("Error response:",r.status,e);try{return JSON.parse(e)}catch{return{success:!1,message:"Server error"}}}return await r.json()}handleSuccess(e,t){e.style.display="none";const r=e.nextElementSibling;r&&r.classList.contains("success-content")&&(r.hidden=!1,r.scrollIntoView({behavior:"smooth",block:"center"})),this.dispatchEvent("emailSent",{email:t.email})}showFormMessage(e,t,r="error"){const s=e.querySelector(".status");if(!s)return;const a=s.querySelector(".message");a&&(a.textContent=t),s.hidden=!1,s.className=`status ${r}`,"error"===r&&setTimeout((()=>{s.hidden=!0}),5e3)}setFormLoading(e,t){t.querySelectorAll("input, button, textarea, select").forEach((t=>t.disabled=e)),e&&this.showFormStatus(t,"saving")}dispatchEvent(e,t){const r=new CustomEvent("referralWidget:"+e,{detail:t,bubbles:!0});this.container.dispatchEvent(r)}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbReferral=new e)}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.container=document.querySelector("aside.referral"),this.container&&(this.a11y=window.jvbA11y,this.toggle=document.querySelector('button[data-action="toggle-referral"]'),this.hasCopy=navigator.clipboard&&navigator.clipboard.writeText,this.initElements(),this.initStore(),this.initListeners(),this.checkForReferral())}initElements(){this.selectors={copyBtn:".copy-btn",checkCode:".check-code-btn",submit:"[type=submit]",recentList:".recent-referrals-list",stats:{codeUsed:'[data-stat="code_used"]',consultations:'[data-stat="consultations"]',treatments:'[data-stat="treatments"]',rewards:'[data-stat="total_rewards"]'}},this.forms=this.container.querySelectorAll("form"),this.popup=new window.jvbPopup({toggle:this.toggle,popup:this.container,name:"Referral Box",onOpen:()=>{this.bindEventListeners(!0)},onClose:()=>{this.bindEventListeners(!1)}}),this.tabs=null,this.container.querySelector("nav.tabs")&&(this.tabs=window.jvbTabs.registerTab(this.container,{updateURL:!1})),this.ui=window.uiFromSelectors(this.selectors),this.hasCopy||document.querySelectorAll(this.selectors.copyBtn).forEach((e=>{e.remove()}))}initStore(){if(!this.isLoggedIn())return;const e=window.jvbStore.register("referrals",[{storeName:"stats",keyPath:"user_id",endpoint:"referrals/stats",TTL:3e5,showLoading:!1,delayFetch:!1,filters:{type:"dashboard",user:window.auth.getUser()}},{storeName:"list",keyPath:"id",endpoint:"referrals",TTL:6e5,showLoading:!1,delayFetch:!1,filters:{user:window.auth.getUser(),status:"all",limit:50,offset:0}}]);this.statsStore=e.stats,this.listStore=e.list,this.statsStore&&this.statsStore.subscribe(this.handleStatsEvent.bind(this)),this.listStore&&this.listStore.subscribe(this.handleListEvent.bind(this))}initListeners(){this.clickHandler=this.handleClick.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleFormSubmit.bind(this)}bindEventListeners(e){const t=e?"addEventListener":"removeEventListener";this.forms.forEach((e=>{e[t]("submit",this.submitHandler)})),this.container[t]("click",this.clickHandler),this.container[t]("input",this.inputHandler)}isLoggedIn(){return Boolean(window.auth.getUser())}handleStatsEvent(e,t){switch(e){case"data-loaded":t.items&&t.items.length>0&&this.updateStatsDisplay();break;case"fetch-error":console.error("Error loading stats:",t.error)}}handleListEvent(e,t){switch(e){case"data-loaded":this.ui.recentList&&this.renderRecentReferrals();break;case"fetch-error":console.error("Error loading referrals:",t.error)}}updateStatsDisplay(){if(0===!this.statsStore.data.size)return;let e=this.statsStore.data.get(parseInt(window.auth.getUser()));const t={total:e.code_used||0,treated:e.treatments||0,pending:e.pending||0,rewards:"$"+parseFloat(e.total_rewards||0).toFixed(2)};Object.entries(t).forEach((([e,t])=>{const r=this.container.querySelector(`[data-stat="${e}"]`);r&&(r.textContent=t)}));const r=this.container.querySelectorAll(".stats .card");r.length>=4&&(r[0].querySelector(".stat-number").textContent=t.code_used,r[1].querySelector(".stat-number").textContent=t.consultations,r[2].querySelector(".stat-number").textContent=t.treatments,r[3].querySelector(".stat-number").textContent=t.total_rewards)}handleClick(e){const t=e.target.closest(".copy-btn, .check-code-btn, .attn");t&&(t.classList.contains("copy-btn")?this.handleCopyClick(t):t.classList.contains("check-code-btn")?this.handleCheckCode(e):t.classList.contains("attn")&&t.classList.remove("attn"))}handleCopyClick(e){const t=e.dataset.target,r=this.container.querySelector(`#${t}`);if(!r)return;const s=r.textContent.trim();this.hasCopy&&navigator.clipboard.writeText(s).then((()=>{e.classList.toggle("success"),setTimeout((()=>{e.classList.remove("success")}),1500)}))}handleError(e,t){const{message:r,code:s,field:a}=t;switch(a?this.showFieldError(e,a,r):this.showFormStatus(e,"error",r||"Something went wrong. Please try again."),s){case"duplicate_email":break;case"invalid_code":const t=e.querySelector('[name="referral_code"]');t&&(t.readOnly=!1,t.focus());break;case"turnstile_failed":window.turnstile&&e.querySelector(".cf-turnstile")&&window.turnstile.reset()}}showFieldError(e,t,r){let s=e.querySelector(`.field[data-field="${t}"]`);if(s||(s=e.querySelector(`.field[data-field="referral_${t}"]`)),!s)return void this.showFormStatus(e,"error",r);const a=s.querySelector("input, textarea, select"),i=s.querySelector(".validation-message"),n=s.querySelector(".validation-icon.error"),o=s.querySelector(".validation-icon.success");a?(s.classList.remove("has-success"),s.classList.add("has-error"),a.classList.add("error"),a.setAttribute("aria-invalid","true"),n&&(n.hidden=!1),o&&(o.hidden=!0),i&&(i.textContent=r,i.hidden=!1),a.focus(),this.a11y?.announce(`Error in ${t}: ${r}`)):this.showFormStatus(e,"error",r)}showFormStatus(e,t,r=""){const s=e.querySelector(".fstatus");if(!s)return void console.warn("No .fstatus element found in form");s.hidden=!1;const a=s.querySelector(".message");s.querySelector(".icon")?.remove(),s.querySelector(".actions")?.remove();const i={saving:"Sending...",submitted:"Sent successfully!",error:"Something went wrong",checking:"Checking code..."},n={submitted:"check-circle",error:"close-circle",checking:"loading"};if(n[t]&&window.getIcon){const e=window.getIcon(n[t]);e&&s.prepend(e)}a&&(a.textContent=r||i[t]||t),s.classList.toggle("loading",["saving","checking"].includes(t)),"submitted"===t&&setTimeout((()=>s.hidden=!0),3e3),this.a11y&&this.a11y.announce(r||i[t]||t)}clearFormErrors(e){e.querySelectorAll(".field.has-error, .field.has-success").forEach((e=>{this.clearFieldValidation(e)}));const t=e.querySelector(".fstatus");t&&(t.hidden=!0)}clearFieldValidation(e){if(!e)return;const t=e.querySelector("input, textarea, select"),r=e.querySelector(".validation-message"),s=e.querySelectorAll(".validation-icon");e.classList.remove("has-error","has-success"),t&&(t.classList.remove("error"),t.removeAttribute("aria-invalid")),s.forEach((e=>e.hidden=!0)),r&&(r.hidden=!0,r.textContent="")}handleInput(e){"referral_code"!==e.target.id&&"referral_code"!==e.target.name||(e.target.value=e.target.value.toUpperCase());const t=e.target.closest(".field");t&&t.classList.contains("has-error")&&this.clearFieldValidation(t)}async handleCheckCode(e){e.preventDefault();const t=e.target.closest("form"),r=t.querySelector('[name="referral_code"]'),s=t.querySelector(".code-status");if(!r||!s)return;const a=r.value.trim();if(a){s.hidden=!1,s.className="code-status loading",s.innerHTML='<span class="spinner"></span> Checking...';try{const e=await this.validateCodeOnly(a);e.success?this.showCodeStatus(s,`✓ Valid! Referred by ${e.referrer_name}`,"success"):this.showCodeStatus(s,e.message||"Invalid code","error")}catch(e){console.error("Error checking code:",e),this.showCodeStatus(s,"Error checking code","error")}}else this.showCodeStatus(s,"Please enter a code","error")}showCodeStatus(e,t,r){e.hidden=!1,e.className=`code-status ${r}`,e.textContent=t,"error"===r&&setTimeout((()=>{e.hidden=!0}),5e3)}async checkForReferral(){const e=this.getUrlParameter("ref"),t=this.getUrlParameter("rname"),r=this.getUrlParameter("remail"),s=this.getUrlParameter("seeReferral");if(!e&&!s)return;if(s&&!e)return this.popup.openPopup(),void this.removeUrlParameter("seeReferral");const a=this.container.querySelector('[name="referral_code"]');if(!a)return;const i=e.toUpperCase();if(a.value=i,a.readOnly=!0,t||r){const e=this.container.querySelector('[name="referral_name"]');e&&(e.value=t);const s=this.container.querySelector('[name="referral_email"]');s&&(s.value=r)}this.popup.openPopup();try{const e=await this.validateCodeOnly(i);if(e.success){const t=a.closest("form").querySelector(".code-status");t&&this.showCodeStatus(t,`✓ ${e.referrer_name} invited you!`,"success");const r=this.container.querySelector('[name="referral_name"]');r&&!r.value&&r.focus()}else a.readOnly=!1,this.showMessage("This referral link is invalid. Please enter a valid code.","error")}catch(e){console.error("Error validating code:",e),a.readOnly=!1}this.removeUrlParameter("ref"),this.removeUrlParameter("rname"),this.removeUrlParameter("remail")}getUrlParameter(e){return new URLSearchParams(window.location.search).get(e)}removeUrlParameter(e){const t=new URL(window.location);t.searchParams.delete(e),window.history.replaceState({},document.title,t.toString())}async validateCodeOnly(e){const t=await fetch(`${jvbSettings.api}referrals/code`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":window.auth.getNonce()},body:JSON.stringify({code:e})});return await t.json()}renderRecentReferrals(){let e=this.ui.recentList,t=Array.from(this.listStore.data.values());t&&0!==t.length?e.innerHTML=t.map((e=>`\n\t\t\t<div class="referral-item">\n\t\t\t\t<div class="referral-info">\n\t\t\t\t\t<strong>${window.escapeHtml(e.referee_name)}</strong>\n\t\t\t\t\t<span class="status-badge">${e.referral_status}</span>\n\t\t\t\t</div>\n\t\t\t\t<div class="referral-date">${window.formatTimeAgo(e.referred_at)}</div>\n\t\t\t</div>\n\t\t`)).join(""):e.innerHTML='<p class="no-referrals">Share your code to get started!</p>'}async handleFormSubmit(e){e.preventDefault();const t=e.target,r=new FormData(t);this.clearFormErrors(t),this.setFormLoading(!0,t);try{let e={success:!1,message:""};if("referral-code-form"===t.id){let t={name:r.get("referral_name"),email:r.get("referral_email"),referral_code:r.get("referral_code")};r.get("cf-turnstile-response")&&(t["cf-turnstile-response"]=r.get("cf-turnstile-response")),t.name&&t.email&&t.referral_code?e=await this.makeRequest("auth/register",t):e.message="Please fill in all fields"}else if("login-form"===t.id){let t={type:"login",email:r.get("login_email"),context:{redirect_to:window.location.href+"?seeReferral=1"}};r.get("cf-turnstile-response")&&(t["cf-turnstile-response"]=r.get("cf-turnstile-response")),t.email?e=await this.makeRequest("magic",t):e.message="Please fill in your email"}e.success?this.handleSuccess(t,e):this.handleError(t,e)}catch(e){console.error("Error submitting form:",e),this.showFormMessage(t,"Something went wrong. Please try again.","error")}finally{this.setFormLoading(!1,t)}}async makeRequest(e,t){if(!["magic","auth/register"].includes(e))return{success:!1,message:"Invalid endpoint"};const r=await fetch(`${jvbSettings.api}${e}`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":window.auth.getNonce()},body:JSON.stringify(t)});if(!r.ok){const e=await r.text();console.error("Error response:",r.status,e);try{return JSON.parse(e)}catch{return{success:!1,message:"Server error"}}}return await r.json()}handleSuccess(e,t){e.style.display="none";const r=e.nextElementSibling;r&&r.classList.contains("success-content")&&(r.hidden=!1,r.scrollIntoView({behavior:"smooth",block:"center"})),this.dispatchEvent("emailSent",{email:t.email})}showFormMessage(e,t,r="error"){const s=e.querySelector(".status");if(!s)return;const a=s.querySelector(".message");a&&(a.textContent=t),s.hidden=!1,s.className=`status ${r}`,"error"===r&&setTimeout((()=>{s.hidden=!0}),5e3)}setFormLoading(e,t){t.querySelectorAll("input, button, textarea, select").forEach((t=>t.disabled=e)),e&&this.showFormStatus(t,"saving")}dispatchEvent(e,t){const r=new CustomEvent("referralWidget:"+e,{detail:t,bubbles:!0});this.container.dispatchEvent(r)}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbReferral=new e)}))}))})();
\ No newline at end of file
diff --git a/inc/EmbedGenerator.php b/inc/EmbedGenerator.php
index 1e15dce..faac311 100644
--- a/inc/EmbedGenerator.php
+++ b/inc/EmbedGenerator.php
@@ -67,7 +67,7 @@
$legacy_url = 'https://legacytattooremoval.ca';
// Build HTML with structured data for SEO
- $html = <<<HTML
+ return <<<HTML
<!-- edmonton.ink Artist Badge - Start -->
<div id="$container_id" class="jvb-badge" style="max-width:200px; margin:12px auto; text-align:center;">
<a href="$profile_url" target="_blank" rel="noopener"
@@ -117,7 +117,6 @@
</div>
<!-- edmonton.ink Artist Badge - End -->
HTML;
- return $html;
}
/**
@@ -133,7 +132,7 @@
$container_id = 'jvb-badge' . $this->user_id;
$legacy_url = 'https://legacytattooremoval.ca';
- $html = <<<HTML
+ return <<<HTML
<!-- edmonton.ink Artist Badge - Start -->
<div id="$container_id" style="max-width:150px; text-align:center;">
<a href="$profile_url" target="_blank" rel="noopener"
@@ -160,8 +159,6 @@
</div>
<!-- edmonton.ink Artist Badge - End -->
HTML;
-
- return $html;
}
/**
@@ -199,7 +196,7 @@
$container_id = 'jvb-badge-img-' . $this->user_id;
$legacy_url = 'https://legacytattooremoval.ca';
- $html = <<<HTML
+ return <<<HTML
<!-- edmonton.ink Artist Badge - Start -->
<div id="$container_id" style="display:inline-block;">
<a href="$profile_url" target="_blank" rel="noopener"
@@ -229,8 +226,6 @@
</div>
<!-- edmonton.ink Artist Badge - End -->
HTML;
-
- return $html;
}
/**
@@ -245,7 +240,7 @@
$image_only_code = htmlspecialchars($this->getImageOnlyEmbedCode(), ENT_QUOTES, 'UTF-8');
$preview = $this->getEmbedCode();
- $html = <<<HTML
+ return <<<HTML
<div class="jvb-embed-code-container">
<h3>Embed Code for Your Website</h3>
<p>Copy this code to your website to show your edmonton.ink verified status and link back to your profile.</p>
@@ -303,7 +298,5 @@
</div>
</div>
HTML;
-
- return $html;
}
}
diff --git a/inc/blocks/CustomBlocks.php b/inc/blocks/CustomBlocks.php
index f83b57d..00613e1 100644
--- a/inc/blocks/CustomBlocks.php
+++ b/inc/blocks/CustomBlocks.php
@@ -1165,11 +1165,9 @@
$classes[] = $class;
}
}
- $classes = array_filter($classes, function ($class) {
- return $class!=='' && !str_starts_with($class, 'wp');
- });
-
- return $classes;
+ return array_filter($classes, function ($class) {
+ return $class!=='' && !str_starts_with($class, 'wp');
+ });
}
protected function getClass(string $key, string|bool|array|int $value, array $attrs):string|array
{
diff --git a/inc/blocks/FormBlock.php b/inc/blocks/FormBlock.php
index edffbe7..512a9c9 100644
--- a/inc/blocks/FormBlock.php
+++ b/inc/blocks/FormBlock.php
@@ -116,9 +116,7 @@
'sections' => []
];
- $config = array_merge($defaults, $config);
-
- return $config;
+ return array_merge($defaults, $config);
}
/**
diff --git a/inc/integrations/GoogleMyBusiness.php b/inc/integrations/GoogleMyBusiness.php
index e818d5b..c838f64 100644
--- a/inc/integrations/GoogleMyBusiness.php
+++ b/inc/integrations/GoogleMyBusiness.php
@@ -1737,9 +1737,7 @@
$text = preg_replace('/\s+/', ' ', $text);
// Trim
- $text = trim($text);
-
- return $text;
+ return trim($text);
}
/**
* Format date for GMB API
@@ -1780,11 +1778,10 @@
// Validate hour and minute ranges
if ($hour >= 0 && $hour <= 23 && $minute >= 0 && $minute <= 59) {
- $result = [
+ return [
'hours' => $hour,
'minutes' => $minute
];
- return $result;
}
}
@@ -2074,9 +2071,7 @@
[],
'posts'
);
- $result = $response['foodMenus'] ?? [];
-
- return $result;
+ return $response['foodMenus'] ?? [];
}
/**
@@ -2351,12 +2346,11 @@
'dailyRange.endDate.day' => date('j', strtotime($end_date))
];
- $response = $this->getRequest(
- "/v1/{$location_name}:fetchMultiDailyMetricsTimeSeries?" . http_build_query($params),
- $params,
- 'performance'
- );
- return $response;
+ return $this->getRequest(
+ "/v1/{$location_name}:fetchMultiDailyMetricsTimeSeries?" . http_build_query($params),
+ $params,
+ 'performance'
+ );
}
/**
diff --git a/inc/integrations/Integrations.php b/inc/integrations/Integrations.php
index ff053aa..9f7f851 100644
--- a/inc/integrations/Integrations.php
+++ b/inc/integrations/Integrations.php
@@ -1626,10 +1626,7 @@
$params = $this->addOAuthParams($params);
}
- $auth_url = $this->oauth['authorize'] . '?' . http_build_query($params);
-
-
- return $auth_url;
+ return $this->oauth['authorize'] . '?' . http_build_query($params);
}
/**
diff --git a/inc/integrations/PostMark.php b/inc/integrations/PostMark.php
index f9e154c..9882d6c 100644
--- a/inc/integrations/PostMark.php
+++ b/inc/integrations/PostMark.php
@@ -419,9 +419,7 @@
// Clean up whitespace
$text = preg_replace('/\s+/', ' ', $text);
- $text = trim($text);
-
- return $text;
+ return trim($text);
}
/**
diff --git a/inc/managers/AdminPages.php b/inc/managers/AdminPages.php
index a2147a0..1e42e8c 100644
--- a/inc/managers/AdminPages.php
+++ b/inc/managers/AdminPages.php
@@ -438,7 +438,7 @@
protected function renderStatusItems():void
{
// Get queue stats
- $queue_status = JVB()->queue()->getQueueStatus();
+ $queue_status = JVB()->queue()->getStatus();
error_log('Queue Status: '.print_r($queue_status, true));
// Other system checks
diff --git a/inc/managers/DashboardManager.php b/inc/managers/DashboardManager.php
index 67844ec..20fbfd8 100644
--- a/inc/managers/DashboardManager.php
+++ b/inc/managers/DashboardManager.php
@@ -210,7 +210,6 @@
if (!in_array($page, $allowedPages)) {
error_log("User not allowed to access page: {$page}");
$this->redirectToDashboard();
- return;
}
}
}
diff --git a/inc/managers/IconsManager.php b/inc/managers/IconsManager.php
index d244340..4415bc7 100644
--- a/inc/managers/IconsManager.php
+++ b/inc/managers/IconsManager.php
@@ -635,9 +635,7 @@
// Clean up SVG for CSS usage
$svg = preg_replace("/([\n\t]+)/", ' ', $svg);
$svg = preg_replace('/>\s*</', '><', $svg);
- $svg = trim($svg);
-
- return $svg;
+ return trim($svg);
}
public function registerStyle(): void
diff --git a/inc/managers/LoginManager.php b/inc/managers/LoginManager.php
index 8b632ca..308a51c 100644
--- a/inc/managers/LoginManager.php
+++ b/inc/managers/LoginManager.php
@@ -227,12 +227,14 @@
'type' => 'email',
'label' => __('Email Address', 'jvb'),
'required' => true,
+ 'autocomplete' => 'email',
'placeholder' => 'look@me.com',
],
'user_password' => [
'type' => 'text',
'subtype'=> 'password',
'label' => __('Password', 'jvb'),
+ 'autocomplete' => 'current-password',
'required' => true,
],
'remember_me' => [
@@ -316,9 +318,7 @@
}
// Add nonce for security
- $logout_url = wp_nonce_url($logout_url, 'log-out');
-
- return $logout_url;
+ return wp_nonce_url($logout_url, 'log-out');
}
public function getLoginPage():int|false
{
@@ -401,7 +401,7 @@
protected function setup():void
{
$this->action = $this->getAction();
- if (in_array($this->action, ['logout']) || array_key_exists('loggedout', $_GET)) {
+ if ($this->action == 'logout' || array_key_exists('loggedout', $_GET)) {
wp_logout();
wp_redirect(esc_attr($_GET['redirect_to'] ?? get_home_url()));
exit;
@@ -861,115 +861,111 @@
SCRIPTS
************************************************************************/
public function enqueueScripts(): void
- {
- if (!$this->isLoginPage()) {
- return;
- }
+{
+ if (!$this->isLoginPage()) {
+ return;
+ }
- $this->maybeTurnstileScripts();
- wp_enqueue_script('jvb-form');
- $action = $this->getAction();
- ob_start();
- ?>
+ $this->maybeTurnstileScripts();
+ wp_enqueue_script('jvb-form');
+ $action = $this->getAction();
-document.addEventListener('DOMContentLoaded', async function () {
+ $redirect_to = isset($_GET['redirect_to']) ? esc_url_raw($_GET['redirect_to']) : '';
+ $has_turnstile = Features::hasIntegration('cloudflare');
+
+ ob_start();
+ ?>
+
+ document.addEventListener('DOMContentLoaded', async function () {
+ const hasTurnstile = <?= json_encode($has_turnstile) ?>;
+ const redirectTo = <?= json_encode($redirect_to) ?>;
+
window.auth.subscribe(event => {
if (event === 'auth-loaded') {
const form = document.querySelector('.login form');
- if (!form) return;
+ if (!form || !window.jvbForm) return;
- if (!window.jvbForm) {
- console.error('jvbForm not loaded');
- return;
- }
-
- window.LoginController = new window.jvbForm();
- window.LoginController.registerForm(form, {
+ window.jvbForm.registerForm(form, {
autosave: false,
- endpoint: <?= "'{$action}'" ?>,
+ endpoint: '<?= $action ?>',
formStatus: false,
cache: false,
});
- window.LoginController.subscribe((event, data) => {
+ window.jvbForm.subscribe((event, data) => {
if (event === 'form-submit') {
- handleFormSubmission(data);
+ const { config } = data;
+ const formElement = config.element;
+
+ // Collect current form data
+ const formData = new FormData(formElement);
+ const formObject = Object.fromEntries(formData.entries());
+
+ // Add redirect_to from URL
+ if (redirectTo) {
+ formObject.redirect_to = redirectTo;
+ }
+
+ const submit = formElement.querySelector('[type=submit]');
+ const oldText = submit.textContent;
+
+ window.jvbForm.showFormStatus(config.id, 'uploading');
+
+ submit.disabled = true;
+ submit.textContent = 'Loading...';
+
+ window.auth.fetch(`${jvbSettings.api}auth/<?= $action ?>`, {
+ method: 'POST',
+ body: JSON.stringify(formObject)
+ })
+ .then(response => response.json().then(result => ({ response, result })))
+ .then(({ response, result }) => {
+ if (!response.ok) {
+ window.jvbForm.showFormStatus(config.id, 'error');
+ window.jvbForm.handleFormError(formElement, result);
+ return;
+ }
+
+ window.jvbForm.showFormStatus(config.id, 'submitted');
+
+ if (result.message) {
+ window.jvbForm.handleFormSuccess(formElement, result);
+ }
+
+ if (window.auth?.handleLogin && result.auth) {
+ return window.auth.handleLogin(result.auth).then(() => {
+ if (result.redirect) {
+ setTimeout(() => {
+ window.location.href = result.redirect;
+ }, 100);
+ }
+ });
+ } else if (result.redirect) {
+ setTimeout(() => {
+ window.location.href = result.redirect;
+ }, 100);
+ }
+ })
+ .catch(error => {
+ console.error('Form submission error:', error);
+ window.jvbForm.showFormStatus(config.id, 'error');
+ window.jvbForm.handleFormError(formElement, {
+ message: 'Network error. Please check your connection and try again.',
+ code: 'network_error'
+ });
+ })
+ .finally(() => {
+ submit.textContent = oldText;
+ submit.disabled = false;
+ });
}
});
-
-
- async function handleFormSubmission(data) {
- let realFormData = data.fullData;
- const { formId, config, data: formData } = data;
-
-
- const form = config.element;
-
- const submit = form.querySelector('[type=submit]');
- let oldText = submit.textContent;
-
-
- window.LoginController.showFormStatus(formId, 'uploading');
-
- try {
- submit.disabled = true;
- submit.textContent = 'Loading...';
- const response = await window.auth.fetch(`${jvbSettings.api}auth/<?php echo $action; ?>`, {
- method: 'POST',
- body: JSON.stringify(realFormData)
- });
-
- const result = await response.json();
-
- // Handle errors
- if (!response.ok) {
- window.LoginController.showFormStatus(formId, 'error');
- window.LoginController.handleFormError(form, result);
- return;
- }
-
- // Handle success
- window.LoginController.showFormStatus(formId, 'submitted');
-
- // Show success message briefly before redirect
- if (result.message) {
- window.LoginController.handleFormSuccess(form, result);
- }
-
- if (window.auth && typeof window.auth.handleLogin === 'function' && Object.hasOwn(result, 'auth')) {
- console.log('Awaiting Auth...');
- await window.auth.handleLogin(result.auth); // Pass the full result
- }
-
- // Handle redirect
- if (result.redirect) {
- setTimeout(() => {
- window.location.href = result.redirect;
- }, 100); // Brief delay to show success message
- }
-
- } catch (error) {
- console.error('Form submission error:', error);
- window.LoginController.showFormStatus(formId, 'error');
- window.LoginController.handleFormError(form, {
- message: 'Network error. Please check your connection and try again.',
- code: 'network_error'
- });
- } finally {
- submit.textContent = oldText;
- submit.disabled = false;
- }
- }
}
});
+ });
-});
-
-
-
- <?php
+ <?php
$script = ob_get_clean();
-
wp_add_inline_script('jvb-form', $script);
}
diff --git a/inc/managers/SEO/BreadcrumbManager.php b/inc/managers/SEO/BreadcrumbManager.php
index 32e91a6..ff9ff03 100644
--- a/inc/managers/SEO/BreadcrumbManager.php
+++ b/inc/managers/SEO/BreadcrumbManager.php
@@ -134,9 +134,7 @@
}
// Add term hierarchy
- $crumbs = array_merge($crumbs, $this->buildTermHierarchy($term));
-
- return $crumbs;
+ return array_merge($crumbs, $this->buildTermHierarchy($term));
}
/**
diff --git a/inc/managers/SEOMetaManager.php b/inc/managers/SEOMetaManager.php
index 2db1d41..44d1399 100644
--- a/inc/managers/SEOMetaManager.php
+++ b/inc/managers/SEOMetaManager.php
@@ -287,8 +287,7 @@
$title_format = "{$title} | {$city}'s Best {$artist_type}";
$title = strtok($title, ' ');
- $title_format = (strlen($title_format) <= $this->character_limits['title']) ? $title_format : "{$title} | {$city}'s Best {$artist_type}";
- return $title_format;
+ return (strlen($title_format) <= $this->character_limits['title']) ? $title_format : "{$title} | {$city}'s Best {$artist_type}";
}
/**
diff --git a/inc/managers/SchemaManager.php b/inc/managers/SchemaManager.php
index c659277..1ba6204 100644
--- a/inc/managers/SchemaManager.php
+++ b/inc/managers/SchemaManager.php
@@ -1090,29 +1090,27 @@
private function getHomeSchema():array
{
// Main dataset schema for homepage
- $schema = [
- '@type' => 'WebPage',
- '@id' => get_home_url() . '/#webpage',
- 'url' => get_home_url(),
- 'name' => get_bloginfo('name') . ' | Edmonton\'s Best Tattoo Artists',
- 'description' => 'Discover Edmonton\'s top tattoo artists, shops, and styles. Your comprehensive guide to Edmonton\'s tattoo scene.',
- 'isPartOf' => [
- '@id' => get_home_url() . '/#website'
- ],
- 'about' => [
- '@type' => 'Dataset',
- 'name' => 'Edmonton Tattoo Artist Directory',
- 'description' => 'Comprehensive directory of professional tattoo artists in Edmonton, Alberta',
- 'creator' => [
- '@id' => 'https://legacytattooremoval.ca/#organization'
- ],
- 'publisher' => [
- '@id' => 'https://legacytattooremoval.ca/#organization'
- ]
- ],
- ];
-
- return $schema;
+ return [
+ '@type' => 'WebPage',
+ '@id' => get_home_url() . '/#webpage',
+ 'url' => get_home_url(),
+ 'name' => get_bloginfo('name') . ' | Edmonton\'s Best Tattoo Artists',
+ 'description' => 'Discover Edmonton\'s top tattoo artists, shops, and styles. Your comprehensive guide to Edmonton\'s tattoo scene.',
+ 'isPartOf' => [
+ '@id' => get_home_url() . '/#website'
+ ],
+ 'about' => [
+ '@type' => 'Dataset',
+ 'name' => 'Edmonton Tattoo Artist Directory',
+ 'description' => 'Comprehensive directory of professional tattoo artists in Edmonton, Alberta',
+ 'creator' => [
+ '@id' => 'https://legacytattooremoval.ca/#organization'
+ ],
+ 'publisher' => [
+ '@id' => 'https://legacytattooremoval.ca/#organization'
+ ]
+ ],
+ ];
}
/**
diff --git a/inc/managers/UmamiMetrics.php b/inc/managers/UmamiMetrics.php
index 5330133..d88df16 100644
--- a/inc/managers/UmamiMetrics.php
+++ b/inc/managers/UmamiMetrics.php
@@ -1516,56 +1516,54 @@
$month_data = $this->getUserMetrics($user_id, $month_start, $today);
// Build quick summary for dashboard
- $summary = [
- 'today' => [
- 'total_views' => $today_data['totals']['total_views'],
- 'profile_views' => $today_data['totals']['profile_views'],
- 'favourites' => $today_data['totals']['favourites'],
- 'karma' => $today_data['totals']['karma']
- ],
- 'yesterday' => [
- 'total_views' => $yesterday_data['totals']['total_views'],
- 'profile_views' => $yesterday_data['totals']['profile_views'],
- 'favourites' => $yesterday_data['totals']['favourites'],
- 'karma' => $yesterday_data['totals']['karma']
- ],
- 'this_week' => [
- 'total_views' => $week_data['totals']['total_views'],
- 'profile_views' => $week_data['totals']['profile_views'],
- 'favourites' => $week_data['totals']['favourites'],
- 'karma' => $week_data['totals']['karma']
- ],
- 'this_month' => [
- 'total_views' => $month_data['totals']['total_views'],
- 'profile_views' => $month_data['totals']['profile_views'],
- 'favourites' => $month_data['totals']['favourites'],
- 'karma' => $month_data['totals']['karma']
- ],
- 'growth' => [
- 'day_over_day' => [
- 'total_views' => $this->calculatePercentageChange(
- $yesterday_data['totals']['total_views'],
- $today_data['totals']['total_views']
- ),
- 'profile_views' => $this->calculatePercentageChange(
- $yesterday_data['totals']['profile_views'],
- $today_data['totals']['profile_views']
- ),
- 'favourites' => $this->calculatePercentageChange(
- $yesterday_data['totals']['favourites'],
- $today_data['totals']['favourites']
- ),
- 'karma' => $this->calculateAbsoluteChange(
- $yesterday_data['totals']['karma'],
- $today_data['totals']['karma']
- )
- ]
- ],
- 'top_content' => $this->simplifyTopContent($week_data['top_content']),
- 'sources' => $week_data['source_breakdown']
- ];
-
- return $summary;
+ return [
+ 'today' => [
+ 'total_views' => $today_data['totals']['total_views'],
+ 'profile_views' => $today_data['totals']['profile_views'],
+ 'favourites' => $today_data['totals']['favourites'],
+ 'karma' => $today_data['totals']['karma']
+ ],
+ 'yesterday' => [
+ 'total_views' => $yesterday_data['totals']['total_views'],
+ 'profile_views' => $yesterday_data['totals']['profile_views'],
+ 'favourites' => $yesterday_data['totals']['favourites'],
+ 'karma' => $yesterday_data['totals']['karma']
+ ],
+ 'this_week' => [
+ 'total_views' => $week_data['totals']['total_views'],
+ 'profile_views' => $week_data['totals']['profile_views'],
+ 'favourites' => $week_data['totals']['favourites'],
+ 'karma' => $week_data['totals']['karma']
+ ],
+ 'this_month' => [
+ 'total_views' => $month_data['totals']['total_views'],
+ 'profile_views' => $month_data['totals']['profile_views'],
+ 'favourites' => $month_data['totals']['favourites'],
+ 'karma' => $month_data['totals']['karma']
+ ],
+ 'growth' => [
+ 'day_over_day' => [
+ 'total_views' => $this->calculatePercentageChange(
+ $yesterday_data['totals']['total_views'],
+ $today_data['totals']['total_views']
+ ),
+ 'profile_views' => $this->calculatePercentageChange(
+ $yesterday_data['totals']['profile_views'],
+ $today_data['totals']['profile_views']
+ ),
+ 'favourites' => $this->calculatePercentageChange(
+ $yesterday_data['totals']['favourites'],
+ $today_data['totals']['favourites']
+ ),
+ 'karma' => $this->calculateAbsoluteChange(
+ $yesterday_data['totals']['karma'],
+ $today_data['totals']['karma']
+ )
+ ]
+ ],
+ 'top_content' => $this->simplifyTopContent($week_data['top_content']),
+ 'sources' => $week_data['source_breakdown']
+ ];
}
/**
diff --git a/inc/managers/_setup.php b/inc/managers/_setup.php
index fa20385..84cad06 100644
--- a/inc/managers/_setup.php
+++ b/inc/managers/_setup.php
@@ -20,7 +20,8 @@
}
}
require(JVB_DIR . '/inc/managers/ErrorHandler.php');
-require(JVB_DIR . '/inc/managers/OperationQueue.php');
+require(JVB_DIR . '/inc/managers/queue/_setup.php');
+//require(JVB_DIR . '/inc/managers/OperationQueue.php');
require(JVB_DIR . '/inc/managers/EmailManager.php');
if (Features::forSite()->has('magicLink')) {
diff --git a/inc/managers/queue/Executor.php b/inc/managers/queue/Executor.php
new file mode 100644
index 0000000..cb07357
--- /dev/null
+++ b/inc/managers/queue/Executor.php
@@ -0,0 +1,14 @@
+<?php
+namespace JVBase\managers\queue;
+if (!defined('ABSPATH')) {
+ exit;
+}
+//Executor.php
+interface Executor
+{
+ /**
+ * Execute and return result.
+ * Throw exception on fatal failure.
+ */
+ public function execute(Operation $operation, Progress $progress): Result;
+}
diff --git a/inc/managers/queue/FilteredExecutor.php b/inc/managers/queue/FilteredExecutor.php
new file mode 100644
index 0000000..a2c8682
--- /dev/null
+++ b/inc/managers/queue/FilteredExecutor.php
@@ -0,0 +1,154 @@
+<?php
+namespace JVBase\managers\queue;
+if (!defined('ABSPATH')) {
+ exit;
+}
+final class FilteredExecutor implements Executor
+{
+ public function __construct(
+ private Storage $storage
+ ) {}
+
+ public function execute(Operation $operation, Progress $progress): Result
+ {
+ $chunkKey = $operation->metadata['chunk_key'] ?? null;
+
+ // No chunking — process entire request at once
+ if (!$chunkKey) {
+ return $this->processSingle($operation, $progress);
+ }
+
+ // Chunked processing
+ return $this->processChunked($operation, $progress, $chunkKey);
+ }
+
+ private function processSingle(Operation $operation, Progress $progress): Result
+ {
+ $filterResult = $this->callFilter($operation, $operation->requestData);
+
+ $progress->advance(1);
+
+ return new Result(
+ outcome: $filterResult['success'] ? 'success' : 'failed',
+ result: $filterResult['result'] ?? null
+ );
+ }
+
+ private function processChunked(Operation $operation, Progress $progress, string|array $chunkKey): Result
+ {
+ $keys = (array) $chunkKey;
+ $chunkSize = $operation->metadata['chunk_size'] ?? 10;
+ $chunks = $this->buildChunks($operation->requestData, $keys, $chunkSize);
+ $offset = $operation->metadata['chunk_offset'] ?? 0;
+ $results = [];
+
+ foreach ($chunks as $index => $chunk) {
+ if ($index < $offset) {
+ continue;
+ }
+
+ $chunkData = array_merge(
+ // Non-chunked data
+ array_diff_key($operation->requestData, array_flip($keys)),
+ // This chunk's data
+ $chunk['data']
+ );
+
+ $filterResult = $this->callFilter($operation, $chunkData);
+
+ if (!$filterResult['success']) {
+ // Record failed items but continue
+ foreach ($chunk['data'] as $key => $items) {
+ foreach ($items as $item) {
+ $progress->failItem($item, $filterResult['message'] ?? 'Chunk failed');
+ }
+ }
+ }
+
+ $progress->advance($chunk['count']);
+ $operation->metadata['chunk_offset'] = $index + 1;
+
+ if (isset($filterResult['result'])) {
+ $results = array_merge($results, (array) $filterResult['result']);
+ }
+
+ // Save progress after each chunk
+ $this->storage->save($operation);
+ }
+
+ $outcome = 'success';
+ if ($operation->failedItems) {
+ $outcome = count($operation->failedItems) === $operation->totalItems ? 'failed' : 'partial';
+ }
+
+ return new Result(
+ outcome: $outcome,
+ result: [
+ 'processed' => $operation->processedItems,
+ 'failed' => $operation->failedItems ? count($operation->failedItems) : 0,
+ 'chunks' => count($chunks),
+ 'results' => $results,
+ ]
+ );
+ }
+
+ private function callFilter(Operation $operation, array $data): array
+ {
+ $filterResult = apply_filters(
+ BASE . 'handle_bulk_operation',
+ ['success' => false, 'message' => 'No handler for: ' . $operation->type],
+ (object) [
+ 'id' => $operation->id,
+ 'type' => $operation->type,
+ 'user_id' => $operation->userId,
+ ],
+ $data
+ );
+
+ // Normalize WP_Error
+ if (is_wp_error($filterResult)) {
+ return [
+ 'success' => false,
+ 'message' => $filterResult->get_error_message(),
+ ];
+ }
+
+ // Ensure expected format
+ if (!is_array($filterResult) || !isset($filterResult['success'])) {
+ return [
+ 'success' => false,
+ 'message' => 'Invalid handler response',
+ ];
+ }
+
+ return $filterResult;
+ }
+
+ private function buildChunks(array $data, array $keys, int $chunkSize): array
+ {
+ // Collect all items across keys
+ $allItems = [];
+ foreach ($keys as $key) {
+ if (isset($data[$key]) && is_array($data[$key])) {
+ foreach ($data[$key] as $i => $item) {
+ $allItems[] = ['key' => $key, 'index' => $i, 'item' => $item];
+ }
+ }
+ }
+
+ // Split into chunks
+ $chunks = [];
+ foreach (array_chunk($allItems, $chunkSize) as $chunkItems) {
+ $chunkData = [];
+ foreach ($chunkItems as $entry) {
+ $chunkData[$entry['key']][] = $entry['item'];
+ }
+ $chunks[] = [
+ 'data' => $chunkData,
+ 'count' => count($chunkItems),
+ ];
+ }
+
+ return $chunks;
+ }
+}
diff --git a/inc/managers/queue/Locker.php b/inc/managers/queue/Locker.php
new file mode 100644
index 0000000..cd527ad
--- /dev/null
+++ b/inc/managers/queue/Locker.php
@@ -0,0 +1,65 @@
+<?php
+namespace JVBase\managers\queue;
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+class Locker
+{
+ private string $lockKey;
+ private int $ttl;
+ private ?string $token = null;
+
+ public function __construct(string $key = 'queue_processor', int $ttl = 300)
+ {
+ $this->lockKey = BASE . '_lock_' . $key;
+ $this->ttl = $ttl;
+ }
+
+ public function acquire(): bool
+ {
+ // Generate unique token for this process
+ $this->token = uniqid(gethostname() . '_', true);
+
+ // SET NX with TTL - atomic "set if not exists"
+ $result = wp_cache_add($this->lockKey, $this->token, 'locks', $this->ttl);
+
+ return $result === true;
+ }
+
+ public function release(): void
+ {
+ if (!$this->token) {
+ return;
+ }
+
+ // Only release if we own the lock
+ $current = wp_cache_get($this->lockKey, 'locks');
+ if ($current === $this->token) {
+ wp_cache_delete($this->lockKey, 'locks');
+ }
+
+ $this->token = null;
+ }
+
+ public function isLocked(): bool
+ {
+ return wp_cache_get($this->lockKey, 'locks') !== false;
+ }
+
+ /**
+ * Execute callback with lock, auto-release after
+ */
+ public function withLock(callable $callback): mixed
+ {
+ if (!$this->acquire()) {
+ return null;
+ }
+
+ try {
+ return $callback();
+ } finally {
+ $this->release();
+ }
+ }
+}
diff --git a/inc/managers/queue/Mergeable.php b/inc/managers/queue/Mergeable.php
new file mode 100644
index 0000000..5c7a9e0
--- /dev/null
+++ b/inc/managers/queue/Mergeable.php
@@ -0,0 +1,11 @@
+<?php
+namespace JVBase\managers\queue;
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+interface Mergeable
+{
+ public function canMerge(Operation $existing, Operation $incoming): bool;
+ public function merge(Operation $existing, Operation $incoming): Operation;
+}
diff --git a/inc/managers/queue/Metrics.php b/inc/managers/queue/Metrics.php
new file mode 100644
index 0000000..a3a7b7d
--- /dev/null
+++ b/inc/managers/queue/Metrics.php
@@ -0,0 +1,210 @@
+<?php
+namespace JVBase\managers\queue;
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+final class Metrics
+{
+ public function __construct(
+ private \wpdb $db,
+ private string $queueTable,
+ private string $statsTable
+ ) {}
+
+ private function sinceDate(int $hours): string
+ {
+ return gmdate('Y-m-d H:i:s', time() - ($hours * HOUR_IN_SECONDS));
+ }
+
+ /* ---------- Live metrics (raw queue table) ---------- */
+
+ public function liveSummary(int $hours = 24): array
+ {
+ $since = $this->sinceDate($hours);
+
+ $sql = "
+ SELECT
+ COUNT(*) AS total,
+ SUM(outcome = 'success') AS success,
+ SUM(outcome = 'partial') AS partial,
+ SUM(outcome IN ('failed', 'failed_permanent')) AS failed,
+ SUM(state IN ('pending','scheduled','processing')) AS active
+ FROM {$this->queueTable}
+ WHERE created_at >= %s
+ ";
+
+ return (array) $this->db->get_row(
+ $this->db->prepare($sql, $since),
+ ARRAY_A
+ );
+ }
+
+
+ public function byHour(int $hours = 24): array
+ {
+ $since = $this->sinceDate($hours);
+
+ $sql = "
+ SELECT
+ HOUR(created_at) AS hour,
+ COUNT(*) AS total,
+ SUM(outcome = 'success') AS success,
+ SUM(outcome IN ('failed','failed_permanent')) AS failed
+ FROM {$this->queueTable}
+ WHERE created_at >= %s
+ GROUP BY hour
+ ORDER BY hour ASC
+ ";
+
+ $rows = $this->db->get_results(
+ $this->db->prepare($sql, $since),
+ ARRAY_A
+ );
+
+ $result = [];
+ foreach ($rows as $row) {
+ $key = str_pad($row['hour'], 2, '0', STR_PAD_LEFT);
+ $result[$key] = [
+ 'total' => (int) $row['total'],
+ 'success' => (int) $row['success'],
+ 'failed' => (int) $row['failed'],
+ ];
+ }
+
+ return $result;
+ }
+
+
+ public function liveByUser(int $hours = 24, int $limit = 100): array
+ {
+ $since = $this->sinceDate($hours);
+
+ $sql = "
+ SELECT
+ user_id,
+ COUNT(*) AS total,
+ SUM(outcome = 'success') AS success,
+ SUM(outcome IN ('failed','failed_permanent')) AS failed,
+ AVG(retries) AS avg_retries
+ FROM {$this->queueTable}
+ WHERE created_at >= %s
+ GROUP BY user_id
+ ORDER BY total DESC
+ LIMIT %d
+ ";
+
+ return $this->db->get_results(
+ $this->db->prepare($sql, $since, $limit),
+ ARRAY_A
+ );
+ }
+
+ public function liveQueueHealth(): array
+ {
+ $sql = "
+ SELECT
+ SUM(state = 'pending') AS pending,
+ SUM(state = 'scheduled') AS scheduled,
+ SUM(state = 'processing') AS processing,
+ MIN(scheduled_at) AS oldest_scheduled,
+ SUM(
+ state = 'processing'
+ AND started_at < DATE_SUB(NOW(), INTERVAL 15 MINUTE)
+ ) AS stuck
+ FROM {$this->queueTable}
+ ";
+
+ return (array) $this->db->get_row($sql, ARRAY_A);
+ }
+
+
+ public function liveByType(int $hours = 24): array
+ {
+ $since = gmdate('Y-m-d H:i:s', time() - $hours * HOUR_IN_SECONDS);
+
+ $sql = "
+ SELECT
+ type,
+ COUNT(*) AS total,
+ SUM(outcome = 'success') AS success,
+ SUM(outcome = 'partial') AS partial,
+ SUM(outcome IN ('failed','failed_permanent')) AS failed,
+ AVG(TIMESTAMPDIFF(SECOND, started_at, completed_at)) AS avg_duration
+ FROM {$this->queueTable}
+ WHERE completed_at IS NOT NULL
+ AND created_at >= %s
+ GROUP BY type
+ ";
+
+ return $this->db->get_results(
+ $this->db->prepare($sql, $since),
+ ARRAY_A
+ );
+ }
+
+
+ /* ---------- Historical metrics (stats table) ---------- */
+
+ public function dailySummary(
+ \DateTimeInterface $from,
+ \DateTimeInterface $to
+ ): array {
+ $sql = "
+ SELECT
+ date,
+ SUM(total_operations) AS total,
+ SUM(successful_operations) AS success,
+ SUM(partial_operations) AS partial,
+ SUM(failed_operations) AS failed,
+ SUM(failed_permanent_operations) AS failed_permanent,
+ SUM(total_items_processed) AS items,
+ AVG(average_duration) AS avg_duration,
+ MAX(max_duration) AS max_duration
+ FROM {$this->statsTable}
+ WHERE date BETWEEN %s AND %s
+ GROUP BY date
+ ORDER BY date
+ ";
+
+ return $this->db->get_results(
+ $this->db->prepare(
+ $sql,
+ $from->format('Y-m-d'),
+ $to->format('Y-m-d')
+ ),
+ ARRAY_A
+ );
+ }
+
+
+ public function dailyByType(
+ \DateTimeInterface $from,
+ \DateTimeInterface $to
+ ): array {
+ $sql = "
+ SELECT
+ date,
+ type,
+ total_operations,
+ successful_operations,
+ partial_operations,
+ failed_operations,
+ failed_permanent_operations,
+ average_duration,
+ max_duration
+ FROM {$this->statsTable}
+ WHERE date BETWEEN %s AND %s
+ ORDER BY date, type
+ ";
+
+ return $this->db->get_results(
+ $this->db->prepare(
+ $sql,
+ $from->format('Y-m-d'),
+ $to->format('Y-m-d')
+ ),
+ ARRAY_A
+ );
+ }
+}
diff --git a/inc/managers/queue/Operation.php b/inc/managers/queue/Operation.php
new file mode 100644
index 0000000..0ba6e23
--- /dev/null
+++ b/inc/managers/queue/Operation.php
@@ -0,0 +1,36 @@
+<?php
+namespace JVBase\managers\queue;
+if (!defined('ABSPATH')) {
+ exit;
+}
+//Operation.php
+final class Operation
+{
+ public string $id;
+ public string $type;
+ public int $userId;
+
+ public array $requestData;
+ public array $metadata = [];
+ public array $dependencies = [];
+
+ public int $totalItems = 1;
+ public int $processedItems = 0;
+ public ?array $failedItems = [];
+
+ public string $priority = 'normal';
+
+ public string $state = 'pending';
+ public ?string $outcome = null;
+
+ public int $retries = 0;
+ public ?string $lastErrorHash = null;
+ public ?string $errorMessage = null;
+
+ public ?string $scheduledAt = null;
+ public ?string $startedAt = null;
+ public ?string $completedAt = null;
+
+ public ?array $result = null;
+ public bool $userDismissed = false;
+}
diff --git a/inc/managers/queue/Processor.php b/inc/managers/queue/Processor.php
new file mode 100644
index 0000000..0f02650
--- /dev/null
+++ b/inc/managers/queue/Processor.php
@@ -0,0 +1,91 @@
+<?php
+namespace JVBase\managers\queue;
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+final class Processor
+{
+ public function __construct(
+ private Storage $storage,
+ private Executor $defaultExecutor,
+ private TypeRegistry $registry,
+ private Locker $locker
+ ) {}
+
+ public function run(): void
+ {
+ $this->locker->withLock(function () {
+ $ops = $this->storage->fetchRunnable(10);
+
+ foreach ($ops as $op) {
+ if (!$this->storage->markProcessing($op->id)) {
+ continue;
+ }
+
+ $this->processOne($op);
+ }
+
+ $this->storage->invalidateQueueCache();
+ });
+ }
+
+ private function processOne(Operation $op): void
+ {
+ $progress = new Progress($op);
+ $executor = $this->registry->getExecutor($op->type) ?? $this->defaultExecutor;
+
+ try {
+ $result = $executor->execute($op, $progress);
+
+ $op->state = 'completed';
+ $op->outcome = $result->outcome;
+ $op->result = $result->result;
+ $op->completedAt = current_time('mysql');
+
+ } catch (\Throwable $e) {
+ $this->handleFailure($op, $e);
+ }
+
+ $this->storage->save($op);
+ }
+
+ private function handleFailure(Operation $op, \Throwable $e): void
+ {
+ $hash = md5($e->getMessage());
+
+ if ($op->lastErrorHash === $hash) {
+ // Same error twice → permanent failure
+ $op->state = 'completed';
+ $op->outcome = 'failed_permanent';
+ $op->completedAt = current_time('mysql');
+ } else {
+ // New error → schedule retry
+ $op->retries++;
+ $op->lastErrorHash = $hash;
+ $op->state = 'scheduled';
+ $op->scheduledAt = $this->calculateBackoff($op->retries);
+ }
+
+ $op->errorMessage = $e->getMessage();
+
+ JVB()->error()->log(
+ '[Queue]:processOne',
+ $e->getMessage(),
+ [
+ 'operation_id' => $op->id,
+ 'type' => $op->type,
+ 'user_id' => $op->userId,
+ 'retries' => $op->retries,
+ ],
+ $op->outcome === 'failed_permanent' ? 'critical' : 'warning'
+ );
+ }
+
+ private function calculateBackoff(int $attempt): string
+ {
+ $delay = min(30 * pow(2, $attempt - 1), 3600);
+ $jitter = rand(0, (int)($delay * 0.1));
+ return date('Y-m-d H:i:s', time() + $delay + $jitter);
+ }
+}
diff --git a/inc/managers/queue/Progress.php b/inc/managers/queue/Progress.php
new file mode 100644
index 0000000..929e6bd
--- /dev/null
+++ b/inc/managers/queue/Progress.php
@@ -0,0 +1,31 @@
+<?php
+namespace JVBase\managers\queue;
+if (!defined('ABSPATH')) {
+ exit;
+}
+//Progress.php
+final class Progress
+{
+ private Operation $operation;
+
+ public function __construct(Operation $op)
+ {
+ $this->operation = $op;
+ }
+
+ public function advance(int $count = 1): void
+ {
+ $this->operation->processedItems = min(
+ $this->operation->processedItems + $count,
+ $this->operation->totalItems
+ );
+ }
+
+ public function failItem(mixed $item, string $reason): void
+ {
+ $this->operation->failedItems[] = [
+ 'item' => $item,
+ 'reason' => $reason,
+ ];
+ }
+}
diff --git a/inc/managers/queue/Queue.php b/inc/managers/queue/Queue.php
new file mode 100644
index 0000000..66ceed0
--- /dev/null
+++ b/inc/managers/queue/Queue.php
@@ -0,0 +1,390 @@
+<?php
+namespace JVBase\managers\queue;
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+use WP_Error;
+
+class Queue
+{
+ private Storage $storage;
+ private Processor $processor;
+ private TypeRegistry $registry;
+ private Locker $locker;
+
+ public function __construct()
+ {
+ $this->storage = new Storage();
+ $this->registry = new TypeRegistry();
+ $this->locker = new Locker('queue_processor', 300);
+
+ $executor = new FilteredExecutor($this->storage);
+ $this->processor = new Processor($this->storage, $executor, $this->registry, $this->locker);
+
+ add_action('jvb_process_queue', [$this, 'checkQueue']);
+ add_action('jvb_queue_maintenance', [$this, 'maintenance']);
+
+ if (!wp_next_scheduled('jvb_process_queue')) {
+ wp_schedule_event(time(), 'every-minute', 'jvb_process_queue');
+ }
+ if (!wp_next_scheduled('jvb_queue_maintenance')) {
+ wp_schedule_event(time(), 'hourly', 'jvb_queue_maintenance');
+ }
+ }
+
+ /**
+ * Access type registry for registering operation configs
+ */
+ public function registry(): TypeRegistry
+ {
+ return $this->registry;
+ }
+
+ /**
+ * Queue a new operation or merge into existing
+ *
+ * @param string $type Operation type
+ * @param int $userId User ID
+ * @param array $data Request data
+ * @param array $options {
+ * @type string $priority 'low', 'normal', 'high'
+ * @type int $delay Seconds to delay processing
+ * @type string $scheduled Specific datetime to process
+ * @type array|string $depends_on Operation IDs this depends on
+ * @type string|array $chunk_key Key(s) to chunk (overrides registry)
+ * @type int $chunk_size Chunk size (overrides registry)
+ * }
+ */
+ public function add(string $type, int $userId, array $data, array $options = []): array|WP_Error
+ {
+ try {
+ $incoming = $this->buildOperation($type, $userId, $data, $options);
+ $mergeable = $this->registry->getMergeable($type);
+
+ if ($mergeable) {
+ $existing = $this->storage->findMergeable($type, $userId);
+
+ if ($existing && $mergeable->canMerge($existing, $incoming)) {
+ $merged = $mergeable->merge($existing, $incoming);
+ $this->storage->save($merged);
+ $this->runQueueOnShutdown();
+
+ return [
+ 'success' => true,
+ 'operation_id' => $merged->id,
+ 'updated_existing' => true,
+ ];
+ }
+ }
+
+ $this->storage->insert($incoming);
+ $this->runQueueOnShutdown();
+
+ return [
+ 'success' => true,
+ 'operation_id' => $incoming->id,
+ 'updated_existing' => false,
+ ];
+
+ } catch (\Exception $e) {
+ JVB()->error()->log('queue', $e->getMessage(), $data, 'high');
+ return new WP_Error('queue_failed', $e->getMessage());
+ }
+ }
+
+ /**
+ * Alias for add() - backwards compatibility
+ */
+ public function queueOperation(string $type, int $userId, array $data, array $options = []): array|WP_Error
+ {
+ return $this->add($type, $userId, $data, $options);
+ }
+
+ public function checkQueue(): void
+ {
+ if (!$this->storage->getQueueInfo()['has_items']) {
+ return;
+ }
+
+ $this->processor->run();
+ }
+
+ public function maintenance(): void
+ {
+ if ($this->locker->isLocked()) {
+ return;
+ }
+
+ $this->cleanupStuck();
+ }
+
+ private function cleanupStuck(): void
+ {
+ // Operations stuck in processing > 30 min → reschedule
+ global $wpdb;
+ $table = $wpdb->prefix . BASE . '_operation_queue';
+
+ $stuck = $wpdb->get_results($wpdb->prepare("
+ SELECT id FROM {$table}
+ WHERE state = 'processing'
+ AND started_at < %s
+ ", date('Y-m-d H:i:s', strtotime('-30 minutes'))));
+
+ foreach ($stuck as $row) {
+ $op = $this->storage->find($row->id);
+ if ($op) {
+ $op->state = 'scheduled';
+ $op->scheduledAt = date('Y-m-d H:i:s', time() + 60);
+ $op->retries++;
+ $this->storage->save($op);
+ }
+ }
+ }
+
+ // === Public Getters ===
+
+ public function get(string $id): ?Operation
+ {
+ return $this->storage->find($id);
+ }
+
+ /**
+ * Alias for get() - backwards compatibility
+ */
+ public function getOperation(string $id): ?Operation
+ {
+ return $this->get($id);
+ }
+
+ /**
+ * Get a specific value from an operation - backwards compatibility
+ */
+ public function getOperationValue(string $id, string $column, bool $decodeJson = true): mixed
+ {
+ $op = $this->get($id);
+ if (!$op) {
+ return null;
+ }
+
+ return match($column) {
+ 'result' => $op->result,
+ 'request_data' => $op->requestData,
+ 'state' => $op->state,
+ 'outcome' => $op->outcome,
+ 'type' => $op->type,
+ 'user_id' => $op->userId,
+ 'metadata' => $op->metadata,
+ 'dependencies' => $op->dependencies,
+ default => null,
+ };
+ }
+
+ public function getUserOperations(int $userId, array $filters = []): array
+ {
+ return $this->storage->getUserOperations($userId, $filters);
+ }
+
+ public function getStatus(): array
+ {
+ return $this->storage->getQueueStatus();
+ }
+
+ public function getUserStats(int $userId): array
+ {
+ return $this->storage->getUserStats($userId);
+ }
+
+ public function getInfo(): array
+ {
+ return $this->storage->getQueueInfo();
+ }
+
+ public function dismiss(string $id): bool
+ {
+ return $this->storage->dismiss($id);
+ }
+
+ /**
+ * Cancel a pending/scheduled operation (deletes it)
+ *
+ * @param string $id Operation ID
+ * @param int $userId User ID (for ownership verification)
+ * @return bool True if cancelled
+ */
+ public function cancel(string $id, int $userId): bool
+ {
+ $op = $this->get($id);
+ if (!$op || $op->userId !== $userId) {
+ return false;
+ }
+
+ // Can only cancel pending or scheduled operations
+ if (!in_array($op->state, ['pending', 'scheduled'])) {
+ return false;
+ }
+
+ return $this->storage->delete($id);
+ }
+
+ /**
+ * Retry a failed operation
+ *
+ * @param string $id Operation ID
+ * @param int $userId User ID (for ownership verification)
+ * @return bool True if reset for retry
+ */
+ public function retry(string $id, int $userId): bool
+ {
+ $op = $this->get($id);
+ if (!$op || $op->userId !== $userId) {
+ return false;
+ }
+
+ // Can only retry completed operations with failed outcomes
+ if ($op->state !== 'completed' || !in_array($op->outcome, ['failed', 'failed_permanent'])) {
+ return false;
+ }
+
+ $op->state = 'pending';
+ $op->outcome = 'pending';
+ $op->errorMessage = null;
+ $op->lastErrorHash = null;
+ $op->scheduledAt = current_time('mysql');
+ $op->retries++;
+
+ $saved = $this->storage->save($op);
+
+ if ($saved) {
+ $this->runQueueOnShutdown();
+ }
+
+ return $saved;
+ }
+
+ /**
+ * Update an operation's data or metadata
+ *
+ * @param string $id Operation ID
+ * @param array $updates Fields to update (requestData, metadata, etc.)
+ * @param int|null $userId Optional user ID for ownership verification
+ * @return bool True if updated
+ */
+ public function update(string $id, array $updates, ?int $userId = null): bool
+ {
+ $op = $this->get($id);
+ if (!$op) {
+ return false;
+ }
+
+ if ($userId !== null && $op->userId !== $userId) {
+ return false;
+ }
+
+ // Apply allowed updates
+ foreach ($updates as $field => $value) {
+ match($field) {
+ 'requestData' => $op->requestData = $value,
+ 'metadata' => $op->metadata = array_merge($op->metadata, $value),
+ 'priority' => $op->priority = $value,
+ 'state' => $op->state = $value,
+ 'outcome' => $op->outcome = $value,
+ 'result' => $op->result = $value,
+ default => null,
+ };
+ }
+
+ return $this->storage->save($op);
+ }
+
+ public function isLocked(): bool
+ {
+ return $this->locker->isLocked();
+ }
+
+ // === Private Helpers ===
+
+ private function buildOperation(string $type, int $userId, array $data, array $options): Operation
+ {
+ $op = new Operation();
+
+ // Use provided operation_id or generate one
+ $op->id = !empty($options['operation_id'])
+ ? $options['operation_id']
+ : 'u' . $userId . '_' . uniqid('op_');
+
+ $op->type = $type;
+ $op->userId = $userId;
+ $op->requestData = $data;
+ $op->priority = $options['priority'] ?? 'normal';
+ $op->state = !empty($options['delay']) || !empty($options['scheduled']) ? 'scheduled' : 'pending';
+ $op->scheduledAt = $this->calculateScheduledTime($options);
+
+ // Chunk config: explicit options override registry
+ $chunkConfig = null;
+ if (!empty($options['chunk_key'])) {
+ $chunkConfig = [
+ 'key' => $options['chunk_key'],
+ 'size' => $options['chunk_size'] ?? 10,
+ ];
+ } else {
+ $chunkConfig = $this->registry->getChunkConfig($type);
+ }
+
+ if ($chunkConfig) {
+ $op->metadata['chunk_key'] = $chunkConfig['key'];
+ $op->metadata['chunk_size'] = $chunkConfig['size'];
+ $op->totalItems = $this->countItems($data, $chunkConfig['key']);
+ }
+
+ // Dependencies
+ if (!empty($options['depends_on'])) {
+ $op->dependencies = is_string($options['depends_on'])
+ ? explode(',', $options['depends_on'])
+ : $options['depends_on'];
+ }
+
+ return $op;
+ }
+
+ private function countItems(array $data, string|array $keys): int
+ {
+ $keys = (array) $keys;
+ $total = 0;
+ foreach ($keys as $key) {
+ if (isset($data[$key]) && is_array($data[$key])) {
+ $total += count($data[$key]);
+ }
+ }
+ return max(1, $total);
+ }
+
+ private function calculateScheduledTime(array $options): string
+ {
+ if (!empty($options['delay'])) {
+ return date('Y-m-d H:i:s', current_time('timestamp') + (int)$options['delay']);
+ }
+ if (!empty($options['scheduled'])) {
+ return $options['scheduled'];
+ }
+ return current_time('mysql');
+ }
+
+ private function runQueueOnShutdown(): void
+ {
+ if (!has_action('shutdown', [$this, 'processQueueOnShutdown'])) {
+ add_action('shutdown', [$this, 'processQueueOnShutdown'], 100);
+ }
+ }
+
+ public function processQueueOnShutdown(): void
+ {
+ remove_action('shutdown', [$this, 'processQueueOnShutdown']);
+
+ if (function_exists('fastcgi_finish_request')) {
+ fastcgi_finish_request();
+ }
+
+ $this->checkQueue();
+ }
+}
diff --git a/inc/managers/queue/Result.php b/inc/managers/queue/Result.php
new file mode 100644
index 0000000..8a78ab1
--- /dev/null
+++ b/inc/managers/queue/Result.php
@@ -0,0 +1,13 @@
+<?php
+namespace JVBase\managers\queue;
+if (!defined('ABSPATH')) {
+ exit;
+}
+//Result.php
+final class Result
+{
+ public function __construct(
+ public string $outcome, // success | partial | failed
+ public ?array $result = null
+ ) {}
+}
diff --git a/inc/managers/queue/Storage.php b/inc/managers/queue/Storage.php
new file mode 100644
index 0000000..2aa9ab6
--- /dev/null
+++ b/inc/managers/queue/Storage.php
@@ -0,0 +1,372 @@
+<?php
+namespace JVBase\managers\queue;
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+use JVBase\managers\CacheManager;
+
+class Storage
+{
+ private \wpdb $wpdb;
+ private string $table;
+ private CacheManager $cache;
+
+ private const CACHE_USER_PREFIX = 'user_queue_';
+ private const CACHE_QUEUE_INFO = 'queue_info';
+
+ public function __construct()
+ {
+ global $wpdb;
+ $this->wpdb = $wpdb;
+ $this->table = $wpdb->prefix . BASE . '_operation_queue';
+ $this->cache = CacheManager::for('queue', DAY_IN_SECONDS);
+ }
+
+ public function hasProcessingOperations(): bool
+ {
+ return (bool) $this->wpdb->get_var(
+ "SELECT 1 FROM {$this->table} WHERE state = 'processing' LIMIT 1"
+ );
+ }
+
+ public function fetchRunnable(int $limit = 10): array
+ {
+ $now = current_time('mysql');
+
+ $rows = $this->wpdb->get_results($this->wpdb->prepare("
+ SELECT oq.* FROM {$this->table} oq
+ WHERE oq.state IN ('pending', 'scheduled')
+ AND oq.scheduled_at <= %s
+ AND NOT EXISTS (
+ SELECT 1
+ FROM JSON_TABLE(
+ COALESCE(NULLIF(oq.dependencies, 'null'), '[]'),
+ '\$[*]' COLUMNS (dep_id VARCHAR(64) PATH '\$')
+ ) AS deps
+ JOIN {$this->table} dep ON dep.id = deps.dep_id
+ WHERE dep.state != 'completed'
+ OR dep.outcome NOT IN ('success', 'partial')
+ )
+ ORDER BY FIELD(oq.priority, 'high', 'normal', 'low'), oq.scheduled_at
+ LIMIT %d
+ ", $now, $limit));
+
+ return array_map([$this, 'rowToOperation'], $rows ?: []);
+ }
+
+
+
+ public function markProcessing(string $id): bool
+ {
+ $now = current_time('mysql');
+
+ $affected = $this->wpdb->query($this->wpdb->prepare("
+ UPDATE {$this->table}
+ SET state = 'processing', started_at = %s, updated_at = %s
+ WHERE id = %s AND state IN ('pending', 'scheduled')
+ ", $now, $now, $id));
+
+ if ($affected > 0) {
+ $op = $this->find($id);
+ if ($op) {
+ $this->invalidateUser($op->userId);
+ }
+ }
+
+ return $affected > 0;
+ }
+
+ public function save(Operation $op): bool
+ {
+ $data = [
+ 'request_data' => json_encode($op->requestData),
+ 'total_items' => $op->totalItems,
+ 'processed_items' => $op->processedItems,
+ 'failed_items' => $op->failedItems ? json_encode($op->failedItems) : null,
+ 'priority' => $op->priority,
+ 'state' => $op->state,
+ 'outcome' => $op->outcome,
+ 'retries' => $op->retries,
+ 'last_error_hash' => $op->lastErrorHash,
+ 'error_message' => $op->errorMessage,
+ 'scheduled_at' => $op->scheduledAt,
+ 'started_at' => $op->startedAt,
+ 'completed_at' => $op->completedAt,
+ 'metadata' => json_encode($op->metadata),
+ 'result' => $op->result ? json_encode($op->result) : null,
+ 'dependencies' => json_encode($op->dependencies),
+ 'user_dismissed' => $op->userDismissed ? 1 : 0,
+ 'updated_at' => current_time('mysql'),
+ ];
+
+ $result = $this->wpdb->update($this->table, $data, ['id' => $op->id]);
+
+ if ($result !== false) {
+ $this->invalidateUser($op->userId);
+ }
+
+ return $result !== false;
+ }
+
+ public function insert(Operation $op): bool
+ {
+ $result = $this->wpdb->insert($this->table, [
+ 'id' => $op->id,
+ 'type' => $op->type,
+ 'user_id' => $op->userId,
+ 'request_data' => json_encode($op->requestData),
+ 'total_items' => $op->totalItems,
+ 'processed_items' => $op->processedItems,
+ 'failed_items' => null,
+ 'priority' => $op->priority,
+ 'state' => $op->state,
+ 'outcome' => $op->outcome,
+ 'retries' => 0,
+ 'last_error_hash' => null,
+ 'error_message' => null,
+ 'scheduled_at' => $op->scheduledAt ?? current_time('mysql'),
+ 'started_at' => null,
+ 'completed_at' => null,
+ 'metadata' => json_encode($op->metadata),
+ 'result' => null,
+ 'dependencies' => json_encode($op->dependencies),
+ 'user_dismissed' => 0,
+ 'created_at' => current_time('mysql'),
+ 'updated_at' => current_time('mysql'),
+ ]);
+
+ if ($result) {
+ $this->invalidateUser($op->userId);
+ }
+
+ return $result !== false;
+ }
+
+ public function find(string $id): ?Operation
+ {
+ $row = $this->wpdb->get_row($this->wpdb->prepare(
+ "SELECT * FROM {$this->table} WHERE id = %s",
+ $id
+ ));
+
+ return $row ? $this->rowToOperation($row) : null;
+ }
+
+ public function findMergeable(string $type, int $userId): ?Operation
+ {
+ $row = $this->wpdb->get_row($this->wpdb->prepare(
+ "SELECT * FROM {$this->table}
+ WHERE type = %s AND user_id = %d AND state IN ('pending', 'scheduled')
+ ORDER BY created_at DESC LIMIT 1",
+ $type, $userId
+ ));
+
+ return $row ? $this->rowToOperation($row) : null;
+ }
+
+ public function getUserOperations(int $userId, array $filters = []): array
+ {
+ $where = ['user_id = %d'];
+ $params = [$userId];
+
+ if (!empty($filters['state'])) {
+ $where[] = 'state = %s';
+ $params[] = $filters['state'];
+ }
+ if (!empty($filters['type'])) {
+ $where[] = 'type = %s';
+ $params[] = $filters['type'];
+ }
+ if (!empty($filters['outcome'])) {
+ $outcomes = (array) $filters['outcome'];
+ $placeholders = implode(',', array_fill(0, count($outcomes), '%s'));
+ $where[] = "outcome IN ($placeholders)";
+ $params = array_merge($params, $outcomes);
+ }
+ if (!empty($filters['not_dismissed'])) {
+ $where[] = 'user_dismissed = 0';
+ }
+ if (!empty($filters['active'])) {
+ $where[] = "state IN ('pending', 'scheduled', 'processing')";
+ }
+ if (!empty($filters['has_errors'])) {
+ $where[] = "(error_message IS NOT NULL OR failed_items IS NOT NULL)";
+ }
+ if (!empty($filters['ids'])) {
+ $ids = (array) $filters['ids'];
+ $placeholders = implode(',', array_fill(0, count($ids), '%s'));
+ $where[] = "id IN ($placeholders)";
+ $params = array_merge($params, $ids);
+ }
+
+ // Order by state priority, then created_at
+ $orderBy = $filters['order_by'] ?? "FIELD(state, 'processing', 'pending', 'scheduled', 'completed'), created_at DESC";
+
+ $limit = $filters['limit'] ?? 50;
+ $params[] = $limit;
+
+ $rows = $this->wpdb->get_results($this->wpdb->prepare(
+ "SELECT * FROM {$this->table} WHERE " . implode(' AND ', $where) .
+ " ORDER BY {$orderBy} LIMIT %d",
+ ...$params
+ ));
+
+ return array_map([$this, 'rowToOperation'], $rows ?: []);
+ }
+
+ public function getQueueInfo(): array
+ {
+ $cached = $this->cache->get(self::CACHE_QUEUE_INFO);
+ if ($cached !== false) {
+ return $cached;
+ }
+
+ $now = current_time('mysql');
+ $row = $this->wpdb->get_row($this->wpdb->prepare("
+ SELECT
+ COUNT(*) as total,
+ SUM(IF(state IN ('pending', 'processing'), 1, 0)) as active,
+ SUM(IF(state = 'scheduled' AND scheduled_at <= %s, 1, 0)) as ready_scheduled
+ FROM {$this->table}
+ WHERE state IN ('pending', 'processing', 'scheduled')
+ ", $now));
+
+ $info = [
+ 'total' => (int) ($row->total ?? 0),
+ 'active' => (int) ($row->active ?? 0),
+ 'ready' => (int) ($row->active ?? 0) + (int) ($row->ready_scheduled ?? 0),
+ 'has_items' => ((int) ($row->active ?? 0) + (int) ($row->ready_scheduled ?? 0)) > 0,
+ ];
+
+ $this->cache->set(self::CACHE_QUEUE_INFO, $info, 30);
+ return $info;
+ }
+
+ private function rowToOperation(object $row): Operation
+ {
+ $op = new Operation();
+ $op->id = $row->id;
+ $op->type = $row->type;
+ $op->userId = (int) $row->user_id;
+ $op->requestData = json_decode($row->request_data ?? '{}', true) ?: [];
+ $op->metadata = json_decode($row->metadata ?? '{}', true) ?: [];
+ $op->dependencies = json_decode($row->dependencies ?? '[]', true) ?: [];
+ $op->totalItems = (int) $row->total_items;
+ $op->processedItems = (int) $row->processed_items;
+ $op->failedItems = $row->failed_items ? json_decode($row->failed_items, true) : null;
+ $op->priority = $row->priority;
+ $op->state = $row->state;
+ $op->outcome = $row->outcome;
+ $op->retries = (int) $row->retries;
+ $op->lastErrorHash = $row->last_error_hash;
+ $op->errorMessage = $row->error_message;
+ $op->scheduledAt = $row->scheduled_at;
+ $op->startedAt = $row->started_at;
+ $op->completedAt = $row->completed_at;
+ $op->result = $row->result ? json_decode($row->result, true) : null;
+ $op->userDismissed = (bool) $row->user_dismissed;
+
+ return $op;
+ }
+
+ public function getQueueStatus(): array
+ {
+ $now = current_time('mysql');
+
+ $rows = $this->wpdb->get_results($this->wpdb->prepare("
+ SELECT
+ state,
+ COUNT(*) as count,
+ SUM(IF(state = 'scheduled' AND scheduled_at <= %s, 1, 0)) as ready
+ FROM {$this->table}
+ GROUP BY state
+ ", $now), OBJECT_K);
+
+ return [
+ 'pending' => (int) ($rows['pending']->count ?? 0),
+ 'scheduled' => (int) ($rows['scheduled']->count ?? 0),
+ 'scheduled_ready' => (int) ($rows['scheduled']->ready ?? 0),
+ 'processing' => (int) ($rows['processing']->count ?? 0),
+ 'completed' => (int) ($rows['completed']->count ?? 0),
+ ];
+ }
+
+ public function getUserStats(int $userId): array
+ {
+ $rows = $this->wpdb->get_results($this->wpdb->prepare("
+ SELECT state, outcome, COUNT(*) as count
+ FROM {$this->table}
+ WHERE user_id = %d
+ GROUP BY state, outcome
+ ", $userId));
+
+ $stats = [
+ 'pending' => 0,
+ 'scheduled' => 0,
+ 'processing' => 0,
+ 'completed' => 0,
+ 'failed' => 0,
+ 'failed_permanent' => 0,
+ ];
+
+ foreach ($rows as $row) {
+ if ($row->state === 'completed') {
+ // Map outcome to frontend status
+ match($row->outcome) {
+ 'failed' => $stats['failed'] += (int) $row->count,
+ 'failed_permanent' => $stats['failed_permanent'] += (int) $row->count,
+ default => $stats['completed'] += (int) $row->count,
+ };
+ } else {
+ // Combine pending/scheduled for frontend
+ $key = $row->state === 'scheduled' ? 'pending' : $row->state;
+ if (isset($stats[$key])) {
+ $stats[$key] += (int) $row->count;
+ }
+ }
+ }
+
+ return $stats;
+ }
+
+ public function dismiss(string $id): bool
+ {
+ $result = $this->wpdb->update(
+ $this->table,
+ ['user_dismissed' => 1, 'updated_at' => current_time('mysql')],
+ ['id' => $id]
+ );
+
+ return $result !== false;
+ }
+
+ /**
+ * Delete an operation from the queue
+ */
+ public function delete(string $id): bool
+ {
+ $op = $this->find($id);
+ $userId = $op?->userId;
+
+ $result = $this->wpdb->delete($this->table, ['id' => $id]);
+
+ if ($result && $userId) {
+ $this->invalidateUser($userId);
+ }
+
+ return $result !== false;
+ }
+
+ public function invalidateQueueCache(): void
+ {
+ $this->cache->delete(self::CACHE_QUEUE_INFO);
+ $this->cache->touch();
+ }
+
+ private function invalidateUser(int $userId): void
+ {
+ CacheManager::invalidateAll("user_{$userId}");
+ $this->cache->delete(self::CACHE_QUEUE_INFO);
+ }
+}
diff --git a/inc/managers/queue/TypeConfig.php b/inc/managers/queue/TypeConfig.php
new file mode 100644
index 0000000..8c8dac4
--- /dev/null
+++ b/inc/managers/queue/TypeConfig.php
@@ -0,0 +1,16 @@
+<?php
+namespace JVBase\managers\queue;
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+final class TypeConfig
+{
+ public function __construct(
+ public ?Mergeable $mergeable = null,
+ public ?Executor $executor = null,
+ public ?string $chunkKey = null,
+ public int $chunkSize = 10,
+ public int $maxRetries = 3
+ ) {}
+}
diff --git a/inc/managers/queue/TypeRegistry.php b/inc/managers/queue/TypeRegistry.php
new file mode 100644
index 0000000..f695d75
--- /dev/null
+++ b/inc/managers/queue/TypeRegistry.php
@@ -0,0 +1,52 @@
+<?php
+namespace JVBase\managers\queue;
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+final class TypeRegistry
+{
+ private array $configs = [];
+
+ public function register(string $type, TypeConfig $config): void
+ {
+ $this->configs[$type] = $config;
+ }
+
+ public function has(string $type): bool
+ {
+ return isset($this->configs[$type]);
+ }
+
+ public function getExecutor(string $type): ?Executor
+ {
+ return $this->configs[$type]?->executor;
+ }
+
+ public function getMergeable(string $type): ?Mergeable
+ {
+ return $this->configs[$type]?->mergeable;
+ }
+
+ public function getConfig(string $type): ?TypeConfig
+ {
+ return $this->configs[$type] ?? null;
+ }
+
+ public function getChunkConfig(string $type): ?array
+ {
+ $config = $this->configs[$type] ?? null;
+ if (!$config || empty($config->chunkKey)) {
+ return null;
+ }
+ return [
+ 'key' => $config->chunkKey,
+ 'size' => $config->chunkSize,
+ ];
+ }
+
+ public function getMaxRetries(string $type): int
+ {
+ return $this->configs[$type]?->maxRetries ?? 3;
+ }
+}
diff --git a/inc/managers/queue/_setup.php b/inc/managers/queue/_setup.php
new file mode 100644
index 0000000..b19e37b
--- /dev/null
+++ b/inc/managers/queue/_setup.php
@@ -0,0 +1,30 @@
+<?php
+namespace JVBase\managers\queue;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+// Interfaces first
+require_once JVB_DIR . '/inc/managers/queue/Executor.php';
+require_once JVB_DIR . '/inc/managers/queue/Mergeable.php';
+
+// Value objects / DTOs
+require_once JVB_DIR . '/inc/managers/queue/Operation.php';
+require_once JVB_DIR . '/inc/managers/queue/Progress.php';
+require_once JVB_DIR . '/inc/managers/queue/Result.php';
+
+// Infrastructure
+require_once JVB_DIR . '/inc/managers/queue/Locker.php';
+require_once JVB_DIR . '/inc/managers/queue/Storage.php';
+require_once JVB_DIR . '/inc/managers/queue/TypeConfig.php';
+require_once JVB_DIR . '/inc/managers/queue/TypeRegistry.php';
+
+// Implementations
+require_once JVB_DIR . '/inc/managers/queue/FilteredExecutor.php';
+require_once JVB_DIR . '/inc/managers/queue/Processor.php';
+require_once JVB_DIR . '/inc/managers/queue/executors/UploadExecutor.php';
+require_once JVB_DIR . '/inc/managers/queue/executors/ContentExecutor.php';
+
+// Facade
+require_once JVB_DIR . '/inc/managers/queue/Queue.php';
diff --git a/inc/managers/queue/executors/ContentExecutor.php b/inc/managers/queue/executors/ContentExecutor.php
new file mode 100644
index 0000000..c53f4a7
--- /dev/null
+++ b/inc/managers/queue/executors/ContentExecutor.php
@@ -0,0 +1,580 @@
+<?php
+namespace JVBase\managers\queue\executors;
+
+use JVBase\managers\CacheManager;
+use JVBase\managers\queue\{Executor, Operation, Progress, Result};
+use JVBase\meta\MetaManager;
+use JVBase\utility\Features;
+use Exception;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+/**
+ * Executor for content-related queue operations.
+ * Handles: content_update, batch_creation
+ */
+final class ContentExecutor implements Executor
+{
+ private const HANDLED_TYPES = [
+ 'content_update',
+ 'batch_creation',
+ ];
+
+ private int $userId;
+ private string $postType;
+ private array $fields = [];
+ private array $timelineSharedFields = [];
+ private array $timelineUniqueFields = [];
+
+ public function execute(Operation $operation, Progress $progress): Result
+ {
+ if (!in_array($operation->type, self::HANDLED_TYPES)) {
+ throw new Exception("ContentExecutor cannot handle type: {$operation->type}");
+ }
+
+ $this->userId = $operation->userId;
+
+ try {
+ $data = $operation->requestData;
+
+ $result = match($operation->type) {
+ 'content_update' => $this->processContentUpdate($operation, $data, $progress),
+ 'batch_creation' => $this->processBatchCreation($operation, $data, $progress),
+ default => throw new Exception("Unknown type: {$operation->type}")
+ };
+
+ return $result;
+
+ } catch (Exception $e) {
+ JVB()->error()->log(
+ '[ContentExecutor]:execute',
+ $e->getMessage(),
+ [
+ 'operation_id' => $operation->id,
+ 'operation_type' => $operation->type,
+ 'user_id' => $operation->userId,
+ ]
+ );
+
+ return new Result(
+ outcome: 'failed',
+ result: ['error' => $e->getMessage()]
+ );
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // Content Update
+ // ─────────────────────────────────────────────────────────────
+
+ private function processContentUpdate(Operation $operation, array $data, Progress $progress): Result
+ {
+ $posts = $data['posts'] ?? [];
+
+ if (empty($posts)) {
+ return new Result(
+ outcome: 'failed',
+ result: ['message' => 'No posts to update']
+ );
+ }
+
+ $results = [];
+ $errors = [];
+
+ foreach ($posts as $id => $postData) {
+ try {
+ $content = $postData['content'] ?? '';
+
+ // Timeline posts
+ if (Features::forContent($content)->has('is_timeline') && isset($postData['timeline'])) {
+ $parentId = (int)$id;
+ if ($parentId === 0) {
+ $progress->failItem($id, 'Invalid parent post ID for timeline');
+ continue;
+ }
+ $results[$id] = $this->processTimelinePost($parentId, $postData);
+ $progress->advance(1);
+ continue;
+ }
+
+ // New post creation
+ if (str_starts_with((string)$id, 'new')) {
+ $newId = wp_insert_post([
+ 'post_author' => $this->userId,
+ 'post_type' => jvbCheckBase($content),
+ 'post_title' => $postData['post_title'] ?? '',
+ 'post_status' => $postData['status'] ?? 'draft',
+ ]);
+
+ if (!$newId || is_wp_error($newId)) {
+ $progress->failItem($id, 'Could not create post');
+ continue;
+ }
+
+ $this->savePostFields($newId, $postData);
+ $results[$id] = ['success' => true, 'new_id' => $newId];
+ $progress->advance(1);
+ continue;
+ }
+
+ // Existing post update
+ if (!$this->verifyOwnership((int)$id)) {
+ $progress->failItem($id, 'No permission to modify this post');
+ continue;
+ }
+
+ $this->processPostUpdate((int)$id, $postData);
+ $results[$id] = ['success' => true];
+ $progress->advance(1);
+
+ // Clear caches
+ CacheManager::for($content)->clear();
+ if (jvbSiteUsesFeedBlock()) {
+ CacheManager::for('feed')->clear();
+ }
+
+ } catch (Exception $e) {
+ $progress->failItem($id, $e->getMessage());
+ $errors[$id] = $e->getMessage();
+ }
+ }
+
+ // Send notification
+ if (jvbSiteHasNotifications()) {
+ JVB()->notification()->addNotification(
+ $this->userId,
+ 'content_update_complete',
+ null,
+ 'Content updates completed!'
+ );
+ }
+
+ $outcome = 'success';
+ if (!empty($errors)) {
+ $outcome = count($errors) === count($posts) ? 'failed' : 'partial';
+ }
+
+ return new Result(
+ outcome: $outcome,
+ result: $results
+ );
+ }
+
+ private function processPostUpdate(int $postId, array $postData): void
+ {
+ $content = $postData['content'] ?? '';
+
+ // Handle status changes
+ if (isset($postData['post_status'])) {
+ switch ($postData['post_status']) {
+ case 'publish':
+ if (user_can($this->userId, 'manage_options') || user_can($this->userId, 'skip_moderation')) {
+ wp_update_post(['ID' => $postId, 'post_status' => 'publish']);
+ }
+ unset($postData['post_status']);
+ break;
+ case 'draft':
+ wp_update_post(['ID' => $postId, 'post_status' => 'draft']);
+ break;
+ case 'trash':
+ wp_trash_post($postId);
+ return;
+ case 'delete':
+ wp_delete_post($postId, true);
+ return;
+ }
+ }
+
+ $this->savePostFields($postId, $postData);
+ }
+
+ private function savePostFields(int $postId, array $postData): bool
+ {
+ $content = $postData['content'] ?? '';
+ $fields = jvbGetFields($content);
+
+ $allowedFields = array_filter($postData, function ($key) use ($fields) {
+ return array_key_exists($key, $fields);
+ }, ARRAY_FILTER_USE_KEY);
+
+ if (empty($allowedFields)) {
+ return true;
+ }
+
+ $meta = new MetaManager($postId, 'post');
+ return $meta->setAll($allowedFields);
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // Batch Creation
+ // ─────────────────────────────────────────────────────────────
+
+ private function processBatchCreation(Operation $operation, array $data, Progress $progress): Result
+ {
+ $this->postType = BASE . $data['content'];
+
+ // Get upload results from dependency
+ $uploadOpId = $operation->id . '_upload';
+ $images = JVB()->queue()->get($uploadOpId)?->result ?? null;
+
+ if (!$images) {
+ return new Result(
+ outcome: 'failed',
+ result: ['message' => 'No upload results found']
+ );
+ }
+
+ $results = [];
+
+ if ($data['mode'] === 'selection') {
+ $results = $this->createFromSelection($operation, $data, $images, $progress);
+ } else {
+ $results = $this->createFromDirect($operation, $data, $images, $progress);
+ }
+
+ // Clear caches
+ CacheManager::for($data['content'])->clear();
+ CacheManager::for('feed')->clear();
+
+ return new Result(
+ outcome: !empty($results) ? 'success' : 'failed',
+ result: $results
+ );
+ }
+
+ private function createFromSelection(Operation $operation, array $data, array $images, Progress $progress): array
+ {
+ $results = [];
+
+ foreach ($images as $group => $files) {
+ $settings = json_decode($data['files_data'][$group] ?? '{}');
+
+ if (($settings->type ?? '') === 'group') {
+ $postId = $this->createGroupPost($operation, $data, $files, $settings);
+ } else {
+ $postId = $this->createIndividualPosts($operation, $data, $files);
+ }
+
+ if ($postId) {
+ $results = array_merge($results, (array)$postId);
+ }
+
+ $progress->advance(1);
+ }
+
+ return $results;
+ }
+
+ private function createFromDirect(Operation $operation, array $data, array $images, Progress $progress): array
+ {
+ $results = [];
+
+ foreach ($images as $img) {
+ $postId = wp_insert_post([
+ 'post_type' => $this->postType,
+ 'post_title' => $this->generatePostTitle($data['content']),
+ 'post_status' => 'draft',
+ 'post_author' => $operation->userId,
+ ]);
+
+ if ($postId && !is_wp_error($postId)) {
+ set_post_thumbnail($postId, $img['attachment_id']);
+ $results[] = $postId;
+ }
+
+ $progress->advance(1);
+ }
+
+ return $results;
+ }
+
+ private function createGroupPost(Operation $operation, array $data, array $files, object $settings): ?int
+ {
+ $featuredIndex = $settings->metadata->featuredFile ?? 0;
+ $title = $settings->metadata->title ?? $this->generatePostTitle($data['content']);
+
+ $postId = wp_insert_post([
+ 'post_type' => $this->postType,
+ 'post_title' => $title,
+ 'post_status' => 'draft',
+ 'post_author' => $operation->userId,
+ ]);
+
+ if (!$postId || is_wp_error($postId)) {
+ return null;
+ }
+
+ // Set featured image
+ set_post_thumbnail($postId, $files[$featuredIndex]['attachment_id']);
+
+ // Remaining files go to gallery
+ unset($files[$featuredIndex]);
+ if (!empty($files)) {
+ $meta = new MetaManager($postId, 'post');
+ $ids = array_column($files, 'attachment_id');
+ $meta->updateValue('gallery', implode(',', $ids));
+ }
+
+ return $postId;
+ }
+
+ private function createIndividualPosts(Operation $operation, array $data, array $files): array
+ {
+ $results = [];
+
+ foreach ($files as $img) {
+ $postId = wp_insert_post([
+ 'post_type' => $this->postType,
+ 'post_title' => $this->generatePostTitle($data['content']),
+ 'post_status' => 'draft',
+ 'post_author' => $operation->userId,
+ ]);
+
+ if ($postId && !is_wp_error($postId)) {
+ set_post_thumbnail($postId, $img['attachment_id']);
+ $results[] = $postId;
+ }
+ }
+
+ return $results;
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // Timeline Processing
+ // ─────────────────────────────────────────────────────────────
+
+ private function processTimelinePost(int $parentId, array $postData): array
+ {
+ if (!$this->verifyOwnership($parentId)) {
+ return ['success' => false, 'message' => 'No permission'];
+ }
+
+ $content = $postData['content'];
+ $this->initTimelineFields($content);
+
+ $parentPost = get_post($parentId);
+ $parentIsPublished = ($parentPost->post_status === 'publish');
+
+ // Extract shared data (excluding post_thumbnail)
+ $sharedData = array_filter($postData, function ($key) {
+ return in_array($key, $this->timelineSharedFields)
+ && !in_array($key, ['content', 'user'])
+ && $key !== 'post_thumbnail';
+ }, ARRAY_FILTER_USE_KEY);
+
+ if (!isset($sharedData['post_title']) && isset($postData['timeline'][0]['post_title'])) {
+ $sharedData['post_title'] = $postData['timeline'][0]['post_title'];
+ }
+
+ if (!isset($postData['timeline']) || !is_array($postData['timeline'])) {
+ return ['success' => false, 'message' => 'No timeline data'];
+ }
+
+ // Validate parent is in timeline
+ $index = array_search((string)$parentId, array_column($postData['timeline'], 'id'));
+ if ($index === false) {
+ return ['success' => false, 'message' => 'Missing parent id'];
+ }
+
+ // Handle parent reordering if needed
+ if ($index !== 0) {
+ $parentId = $this->reorderTimelineParent($parentId, $postData['timeline'], $index);
+ $parentPost = get_post($parentId);
+ $parentIsPublished = ($parentPost->post_status === 'publish');
+ }
+
+ // Shared taxonomies (excluding title and thumbnail)
+ $sharedTaxonomies = array_filter($sharedData, function ($key) {
+ return !in_array($key, ['post_title', 'post_thumbnail']);
+ }, ARRAY_FILTER_USE_KEY);
+
+ $existingChildren = get_children([
+ 'post_parent' => $parentId,
+ 'orderby' => 'menu_order',
+ 'post_status' => ['publish', 'draft'],
+ 'fields' => 'ids',
+ ]);
+
+ $errors = [];
+ $success = [];
+
+ foreach ($postData['timeline'] as $order => $timeline) {
+ $result = $this->processTimelineEntry(
+ $timeline,
+ $order,
+ $parentId,
+ $parentIsPublished,
+ $sharedTaxonomies,
+ $existingChildren,
+ $content
+ );
+
+ if ($result['success']) {
+ $success[] = $result;
+ if (isset($result['child_id']) && in_array($result['child_id'], $existingChildren)) {
+ unset($existingChildren[array_search($result['child_id'], $existingChildren)]);
+ }
+ } else {
+ $errors[] = $result;
+ }
+ }
+
+ // Trash orphaned children
+ foreach ($existingChildren as $orphanId) {
+ wp_trash_post($orphanId);
+ }
+
+ return [
+ 'success' => empty($errors),
+ 'updated' => count($success),
+ 'errors' => $errors,
+ ];
+ }
+
+ private function processTimelineEntry(
+ array $timeline,
+ int $order,
+ int $parentId,
+ bool $parentIsPublished,
+ array $sharedTaxonomies,
+ array &$existingChildren,
+ string $content
+ ): array {
+ $isParent = ((int)($timeline['id'] ?? 0) === $parentId);
+
+ // Get unique fields for this entry
+ $allowedFields = array_filter($timeline, function ($key) {
+ return in_array($key, $this->timelineUniqueFields) && !in_array($key, ['content', 'user']);
+ }, ARRAY_FILTER_USE_KEY);
+
+ // Determine title
+ $providedTitle = $timeline['post_title'] ?? '';
+ $autoPattern = '/^.+Treatment #?\d+$/';
+
+ if ($isParent) {
+ $allowedFields['post_title'] = $providedTitle ?: ($sharedTaxonomies['post_title'] ?? get_post($parentId)->post_title);
+ } else {
+ if (empty($providedTitle) || preg_match($autoPattern, $providedTitle)) {
+ $allowedFields['post_title'] = 'Treatment ' . $order;
+ } else {
+ $allowedFields['post_title'] = $providedTitle;
+ }
+ }
+
+ $allowedFields = array_merge($sharedTaxonomies, $allowedFields);
+
+ // Create child if needed
+ $childId = $timeline['id'] ?? null;
+ if (!$childId || !is_numeric($childId)) {
+ $childId = wp_insert_post([
+ 'post_author' => $this->userId,
+ 'post_type' => jvbCheckBase($content),
+ 'post_title' => $allowedFields['post_title'],
+ 'post_parent' => $parentId,
+ 'menu_order' => $order,
+ 'post_status' => $parentIsPublished ? 'publish' : 'draft',
+ ]);
+
+ if (!$childId || is_wp_error($childId)) {
+ return ['success' => false, 'message' => 'Could not create child post'];
+ }
+ }
+
+ // Update post
+ $postUpdates = ['ID' => $childId];
+ if (!$isParent) {
+ $postUpdates['menu_order'] = $order;
+ if ($parentIsPublished) {
+ $currentPost = get_post($childId);
+ if ($currentPost && $currentPost->post_status !== 'publish') {
+ $postUpdates['post_status'] = 'publish';
+ }
+ }
+ }
+
+ if (isset($allowedFields['post_title'])) {
+ $postUpdates['post_title'] = $allowedFields['post_title'];
+ unset($allowedFields['post_title']);
+ }
+
+ wp_update_post($postUpdates);
+
+ // Save meta fields
+ if (!empty($allowedFields)) {
+ $meta = new MetaManager($childId, 'post');
+ $meta->setAll($allowedFields);
+ }
+
+ return ['success' => true, 'child_id' => $childId];
+ }
+
+ private function reorderTimelineParent(int $currentParentId, array $timeline, int $currentIndex): int
+ {
+ $newParentId = $timeline[0]['id'] ?? null;
+
+ if (!is_numeric($newParentId) || (int)$newParentId <= 0) {
+ return $currentParentId;
+ }
+
+ $newParentId = (int)$newParentId;
+
+ // Make new parent a top-level post
+ wp_update_post(['ID' => $newParentId, 'post_parent' => 0]);
+
+ // Make old parent a child
+ wp_update_post(['ID' => $currentParentId, 'post_parent' => $newParentId]);
+
+ // Move existing children to new parent
+ $existingChildren = get_children(['post_parent' => $currentParentId, 'fields' => 'ids']);
+ foreach ($existingChildren as $childId) {
+ if ($childId !== $newParentId) {
+ wp_update_post(['ID' => $childId, 'post_parent' => $newParentId]);
+ }
+ }
+
+ return $newParentId;
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // Helpers
+ // ─────────────────────────────────────────────────────────────
+
+ private function initTimelineFields(string $content): void
+ {
+ $content = jvbNoBase($content);
+ if (!Features::forContent($content)->has('is_timeline')) {
+ return;
+ }
+
+ $config = Features::getConfig($content);
+ $this->fields = $config['fields'] ?? [];
+
+ // Shared fields (apply to all posts)
+ $this->timelineSharedFields = array_keys(array_filter($this->fields, function ($field) {
+ return !isset($field['for_all']) || $field['for_all'] === false;
+ }));
+ array_unshift($this->timelineSharedFields, 'post_thumbnail', 'post_title', 'post_status');
+
+ // Unique fields (per-entry)
+ $this->timelineUniqueFields = array_keys(array_filter($this->fields, function ($field) {
+ return isset($field['for_all']) && $field['for_all'] === true;
+ }));
+ }
+
+ private function verifyOwnership(int $postId): bool
+ {
+ $post = get_post($postId);
+ return $post && (int)$post->post_author === $this->userId;
+ }
+
+ private function generatePostTitle(string $content): string
+ {
+ $username = get_user_meta($this->userId, 'first_name', true);
+ $link = get_user_meta($this->userId, BASE . 'link', true);
+ $city = function_exists('jvbArtistCity') ? jvbArtistCity($link) : '';
+
+ return ucfirst($content) . ' by ' . ($city ? "$city artist " : '') . $username;
+ }
+}
diff --git a/inc/managers/queue/executors/UploadExecutor.php b/inc/managers/queue/executors/UploadExecutor.php
new file mode 100644
index 0000000..10accf8
--- /dev/null
+++ b/inc/managers/queue/executors/UploadExecutor.php
@@ -0,0 +1,628 @@
+<?php
+namespace JVBase\managers\queue\executors;
+
+use JVBase\managers\queue\{Executor, Operation, Progress, Result};
+use JVBase\managers\UploadManager;
+use JVBase\meta\MetaManager;
+use Exception;
+use JVBase\utility\Features;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+/**
+ * Executor for upload-related queue operations.
+ * Handles: image_upload, video_upload, document_upload,
+ * update_metadata, temporary_cleanup, attach_upload_to_content, process_upload_groups
+ */
+final class UploadExecutor implements Executor
+{
+ private const HANDLED_TYPES = [
+ 'image_upload',
+ 'video_upload',
+ 'document_upload',
+ 'update_metadata',
+ 'temporary_cleanup',
+ 'attach_upload_to_content',
+ 'process_upload_groups'
+ ];
+
+ public function execute(Operation $operation, Progress $progress): Result
+ {
+ if (!in_array($operation->type, self::HANDLED_TYPES)) {
+ throw new Exception("UploadExecutor cannot handle type: {$operation->type}");
+ }
+
+ try {
+ $data = $operation->requestData;
+
+ return match($operation->type) {
+ 'image_upload' => $this->processFileUpload($operation, $data, 'image', $progress),
+ 'video_upload' => $this->processFileUpload($operation, $data, 'video', $progress),
+ 'document_upload' => $this->processFileUpload($operation, $data, 'document', $progress),
+ 'update_metadata' => $this->processMetadataUpdate($operation, $data, $progress),
+ 'temporary_cleanup' => $this->processTemporaryCleanup($operation, $data, $progress),
+ 'attach_upload_to_content'=> $this->processAttachToContent($operation, $data, $progress),
+ 'process_upload_groups' => $this->processUploadGroups($operation, $data, $progress),
+ default => throw new Exception("Unknown type: {$operation->type}")
+ };
+
+ } catch (Exception $e) {
+ JVB()->error()->log(
+ '[UploadExecutor]:execute',
+ $e->getMessage(),
+ [
+ 'operation_id' => $operation->id,
+ 'operation_type' => $operation->type,
+ 'user_id' => $operation->userId,
+ ]
+ );
+
+ return new Result(
+ outcome: 'failed',
+ result: ['error' => $e->getMessage()]
+ );
+ }
+ }
+
+ /**
+ * Process file uploads (images, videos, documents)
+ */
+ private function processFileUpload(Operation $operation, array $data, string $fileType, Progress $progress): Result
+ {
+ $uploader = new UploadManager();
+ $processedResults = [];
+ $errors = [];
+
+ $securedFiles = $data['secured_files'] ?? [];
+
+ foreach ($securedFiles as $securedFile) {
+ try {
+ $result = $uploader->processUpload(
+ $securedFile['temp_path'],
+ [
+ 'file_type' => $fileType,
+ 'user_id' => $operation->userId,
+ 'post_id' => (int)($data['post_id'] ?? 0),
+ 'term_id' => (int)($data['term_id'] ?? 0),
+ 'original_name' => $securedFile['original_name'],
+ 'mime_type' => $securedFile['mime_type'],
+ 'content' => $data['content'] ?? '',
+ ]
+ );
+
+ if (!is_wp_error($result)) {
+ $standardized = [
+ 'attachment_id' => $result['attachment_id'],
+ 'url' => $result['url'],
+ 'file' => $result['file'],
+ 'upload_id' => $securedFile['upload_id'] ?? null,
+ ];
+
+ if ($standardized['upload_id']) {
+ $processedResults[$standardized['upload_id']] = $standardized;
+ } else {
+ $processedResults[] = $standardized;
+ }
+
+ // Apply frontend metadata if provided
+ if (!empty($securedFile['metadata'])) {
+ $this->applyMeta($standardized['attachment_id'], $securedFile['metadata']);
+ }
+
+ $progress->advance(1);
+ } else {
+ $progress->failItem($securedFile, $result->get_error_message());
+ }
+
+ } catch (Exception $e) {
+ $progress->failItem($securedFile, $e->getMessage());
+ $errors[] = $e->getMessage();
+ }
+ }
+
+ // Handle destination (meta, post, post_group)
+ $this->handleUploadDestination($data, $processedResults);
+
+ // Cleanup temp files
+ $this->cleanupTempFiles($securedFiles, $operation->userId);
+
+ $outcome = 'success';
+ if (!empty($operation->failedItems)) {
+ $outcome = count($operation->failedItems) === count($securedFiles) ? 'failed' : 'partial';
+ }
+
+ return new Result(
+ outcome: $outcome,
+ result: $processedResults
+ );
+ }
+
+ /**
+ * Attach upload results to content
+ */
+ private function processAttachToContent(Operation $operation, array $data, Progress $progress): Result
+ {
+ $uploadOpId = $data['upload'] ?? null;
+ if (!$uploadOpId) {
+ throw new Exception('No upload operation ID provided');
+ }
+
+ // Get results from the dependency
+ $uploadOp = JVB()->queue()->get($uploadOpId);
+ if (!$uploadOp || $uploadOp->outcome !== 'success') {
+ throw new Exception("Upload operation {$uploadOpId} not completed successfully");
+ }
+
+ $uploadResults = $uploadOp->result ?? [];
+ if (empty($uploadResults)) {
+ throw new Exception('No upload results found');
+ }
+
+ // Attach to content via field
+ if (!empty($data['field_name'])) {
+ $this->updateFieldValue($data, $uploadResults);
+ }
+
+ $progress->advance(1);
+
+ return new Result(
+ outcome: 'success',
+ result: ['attached' => count($uploadResults)]
+ );
+ }
+
+ /**
+ * Process metadata updates for attachments
+ */
+ private function processMetadataUpdate(Operation $operation, array $data, Progress $progress): Result
+ {
+ $updatedCount = 0;
+ $errors = [];
+
+ foreach ($data as $uploadId => $info) {
+ if (!is_array($info) || empty($info['depends_on'])) {
+ continue;
+ }
+
+ try {
+ // Get the dependency operation to find attachment ID
+ $depOp = JVB()->queue()->get($info['depends_on']);
+ if (!$depOp || !$depOp->result) {
+ $errors[] = "Dependency {$info['depends_on']} not found or has no result";
+ continue;
+ }
+
+ $attachmentId = $this->findAttachmentByUploadId($uploadId, $depOp->result);
+ if (!$attachmentId) {
+ $errors[] = "No attachment found for upload ID: {$uploadId}";
+ continue;
+ }
+
+ $this->applyMeta($attachmentId, $info);
+ $updatedCount++;
+ $progress->advance(1);
+
+ } catch (Exception $e) {
+ $progress->failItem($uploadId, $e->getMessage());
+ $errors[] = $e->getMessage();
+ }
+ }
+
+ $outcome = $updatedCount > 0 ? 'success' : 'failed';
+ if ($updatedCount > 0 && !empty($errors)) {
+ $outcome = 'partial';
+ }
+
+ return new Result(
+ outcome: $outcome,
+ result: [
+ 'updated' => $updatedCount,
+ 'errors' => $errors,
+ ]
+ );
+ }
+
+ /**
+ * Cleanup temporary upload files
+ */
+ private function processTemporaryCleanup(Operation $operation, array $data, Progress $progress): Result
+ {
+ $uploader = new UploadManager();
+
+ // Cleanup secured files
+ if (!empty($data['files'])) {
+ foreach ($data['files'] as $file) {
+ if (!empty($file['temp_path']) && file_exists($file['temp_path'])) {
+ @unlink($file['temp_path']);
+ }
+ $progress->advance(1);
+ }
+ }
+
+ // Cleanup specific temp paths
+ if (!empty($data['temp_paths']) && is_array($data['temp_paths'])) {
+ foreach ($data['temp_paths'] as $tempPath) {
+ if (file_exists($tempPath)) {
+ @unlink($tempPath);
+ }
+ }
+ }
+
+ // Cleanup empty directories
+ $uploader->cleanupEmptyTempDirs($operation->userId);
+
+ return new Result(
+ outcome: 'success',
+ result: ['cleaned' => true]
+ );
+ }
+
+ /**
+ * Process grouped uploads into posts
+ */
+ private function processUploadGroups(Operation $operation, array $data, Progress $progress): Result
+ {
+
+ $dependencies = $operation->dependencies;
+ if (empty($dependencies)) {
+ throw new Exception('No dependencies found for group uploads.');
+ }
+
+ $uploads = [];
+ foreach ($dependencies as $dependency) {
+ $res = JVB()->queue()->getOperationValue($dependency, 'result');
+ if (empty($res)) {
+ continue;
+ }
+
+ // Results are stored at root level, keyed by upload_id
+ // Filter to only include actual upload results (arrays with attachment_id)
+ foreach ($res as $key => $value) {
+ if (is_array($value) && isset($value['attachment_id'])) {
+ $uploads[$key] = $value;
+ }
+ }
+ }
+
+ if (empty($uploads)) {
+ throw new Exception('No uploads found for group uploads.');
+ }
+
+ $all_uploads = [];
+ foreach($uploads as $upload_id => $img) {
+ if (!array_key_exists('attachment_id', $img) || (int)$img['attachment_id'] === 0){
+ continue;
+ }
+ $all_uploads[$upload_id] = [
+ 'upload_id' => $upload_id,
+ 'attachment_id'=> (int)$img['attachment_id']
+ ];
+ }
+
+ $content = jvbCheckBase($data['content']);
+ if (Features::forContent($data['content'])->has('is_timeline')) {
+ return $this->processTimelineUploads($operation, $data, $progress, $all_uploads);
+ }
+
+ $user = (int)$operation->userId;
+ $createdPosts = [];
+ $usedUploads = [];
+
+ foreach($data['posts'] as $index => $post) {
+ $progress->advance();
+ $post_title = array_key_exists('post_title', $post['fields'])
+ ? sanitize_text_field($post['fields']['post_title'])
+ : 'New '. JVB_CONTENT[$data['content']]['singular'].' '.($index + 1);
+
+ $post_excerpt = array_key_exists('post_excerpt', $post['fields'])
+ ? sanitize_textarea_field($post['fields']['post_excerpt'])
+ : '';
+
+ $args = [
+ 'post_type' => $content,
+ 'post_author' => $user,
+ 'post_status' => 'draft',
+ 'post_title' => $post_title,
+ 'post_excerpt' => $post_excerpt
+ ];
+ $newPostID = wp_insert_post($args);
+ if ($newPostID && !is_wp_error($newPostID)) {
+ $createdPosts[] = $newPostID;
+
+ $featured_upload_id = $post['fields']['featured']??null;
+ $featured_attachment_id = null;
+ $gallery_attachment_ids = [];
+
+ foreach ($post['images'] as $img) {
+ $uploadId = $img['upload_id'];
+ $usedUploads[] = $uploadId;
+ if (array_key_exists($uploadId, $all_uploads)) {
+ $attachmentId = $all_uploads[$uploadId]['attachment_id'];
+
+ if ($uploadId === $featured_upload_id) {
+ $featured_attachment_id = $attachmentId;
+ } else {
+ $gallery_attachment_ids[] = $attachmentId;
+ }
+ }
+ }
+ if ($featured_attachment_id) {
+ set_post_thumbnail($newPostID, $featured_attachment_id);
+ } elseif (!empty($gallery_attachment_ids)) {
+ set_post_thumbnail($newPostID, $gallery_attachment_ids[0]);
+ array_shift($gallery_attachment_ids);
+ }
+
+ if (!empty($gallery_attachment_ids)) {
+ $meta = new MetaManager($newPostID, 'post');
+ $fields = jvbGetFields($content, 'post');
+ foreach($fields as $name => $config) {
+ if ($config['type'] === 'gallery') {
+ $meta->updateValue($name, implode(',', $gallery_attachment_ids));
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ return new Result(
+ outcome: !empty($createdPosts) ? 'success' : 'failed',
+ result: ['posts' => $createdPosts]
+ );
+ }
+
+ private function processTimelineUploads(Operation $operation, array $data, Progress $progress, array $uploads):Result
+ {
+ $user = $operation->userId;
+ $createdPosts = [];
+ $usedUploads = [];
+
+ $content = jvbCheckBase($data['content']);
+ $config = Features::getConfig($content);
+
+ $defaultTitle = 'New '.$config['singular']. ' ';
+ foreach($data['posts'] as $index => $post) {
+ $title = array_key_exists('post_title', $post['fields'])
+ ? sanitize_text_field($post['fields']['post_title'])
+ : $defaultTitle . ($index + 1);
+
+ $excerpt = array_key_exists('post_excerpt', $post['fields'])
+ ? sanitize_textarea_field($post['fields']['post_excerpt'])
+ : '';
+
+ $args = [
+ 'post_type' => $content,
+ 'post_author' => $user,
+ 'post_status' => 'draft',
+ 'post_title' => $title,
+ 'post_excerpt' => $excerpt
+ ];
+
+ $parent = wp_insert_post($args);
+ $progress->advance();
+ if ($parent && !is_wp_error($parent)) {
+ $childPosts = [];
+ $featured = $post['fields']['featured']??null;
+ $featuredID = null;
+
+ foreach ($post['images'] as $img) {
+ $uploadId = $img['upload_id'];
+ $usedUploads[] = $uploadId;
+
+ if (array_key_exists($uploadId, $uploads)) {
+ $attachmentId = (int)$uploads[$uploadId]['attachment_id'];
+ if ($uploadId === $featured) {
+ $featuredID = $attachmentId;;
+ } else {
+ $childPosts[] = $attachmentId;
+ }
+ }
+ }
+ if ($featuredID) {
+ set_post_thumbnail($parent, $featuredID);
+ } elseif (!empty($childPosts)) {
+ set_post_thumbnail($parent, (int)$childPosts[0]);
+ array_shift($childPosts);
+ }
+ if (!empty($childPosts)) {
+ $args['post_parent'] = $parent;
+ $args['post_excerpt'] = '';
+ $createdPosts[$parent] = [];
+ foreach($childPosts as $i => $imgID) {
+ $treatment = $i + 1;
+ $args ['post_title'] = $title.' - Treatment #'.$treatment;
+ $child = wp_insert_post($args);
+ if ($child && !is_wp_error($child)) {
+ $createdPosts[$parent][] = $child;
+ set_post_thumbnail($child, $imgID);
+ }
+ }
+ }
+ }
+ }
+ return new Result(
+ outcome: !empty($createdPosts) ? 'success' : 'failed',
+ result: ['posts' => $createdPosts]
+ );
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // Helper methods
+ // ─────────────────────────────────────────────────────────────
+
+ private function applyMeta(int $attachmentId, array $metadata): void
+ {
+ if (!empty($metadata['title'])) {
+ wp_update_post([
+ 'ID' => $attachmentId,
+ 'post_title' => sanitize_text_field($metadata['title']),
+ ]);
+ }
+
+ if (!empty($metadata['alt'])) {
+ update_post_meta($attachmentId, '_wp_attachment_image_alt', sanitize_text_field($metadata['alt']));
+ }
+
+ if (!empty($metadata['caption'])) {
+ wp_update_post([
+ 'ID' => $attachmentId,
+ 'post_excerpt' => sanitize_textarea_field($metadata['caption']),
+ ]);
+ }
+ }
+
+ private function handleUploadDestination(array $data, array $results): void
+ {
+ $destination = $data['destination'] ?? 'meta';
+
+ switch ($destination) {
+ case 'meta':
+ $this->saveToMeta($data, $results);
+ break;
+ case 'post':
+ $this->createIndividualPosts($data, $results);
+ break;
+ case 'post_group':
+ // Handled by process_upload_groups
+ break;
+ }
+ }
+
+ private function saveToMeta(array $data, array $results): void
+ {
+ if (empty($data['field_name'])) {
+ return;
+ }
+
+ $attachmentIds = array_column($results, 'attachment_id');
+ $meta = $this->getMetaManager($data);
+ if (!$meta) {
+ return;
+ }
+
+ $existing = $meta->getValue($data['field_name']);
+ $existingIds = !empty($existing) ? explode(',', $existing) : [];
+ $allIds = array_unique(array_merge($existingIds, $attachmentIds));
+
+ $meta->updateValue($data['field_name'], implode(',', $allIds));
+ }
+
+ private function updateFieldValue(array $data, array $results): void
+ {
+ if (empty($data['field_name'])) {
+ return;
+ }
+
+ $attachmentIds = array_column($results, 'attachment_id');
+ $meta = $this->getMetaManager($data);
+ if (!$meta) {
+ return;
+ }
+
+ $existing = $meta->getValue($data['field_name']);
+ $existingIds = !empty($existing) ? explode(',', $existing) : [];
+ $allIds = array_unique(array_merge($existingIds, $attachmentIds));
+
+ $meta->updateValue($data['field_name'], implode(',', $allIds));
+ }
+
+ private function getMetaManager(array $data): ?MetaManager
+ {
+ if (!empty($data['post_id'])) {
+ return new MetaManager($data['post_id'], 'post');
+ }
+ if (!empty($data['term_id'])) {
+ return new MetaManager($data['term_id'], 'term');
+ }
+ if (!empty($data['user'])) {
+ $link = (int)get_user_meta($data['user'], BASE . 'link', true);
+ if ($link) {
+ return new MetaManager($link, 'post');
+ }
+ }
+ return null;
+ }
+
+ private function createIndividualPosts(array $data, array $results): array
+ {
+ if (empty($data['content'])) {
+ return [];
+ }
+
+ $createdPosts = [];
+
+ foreach ($results as $result) {
+ $attachmentId = $result['attachment_id'];
+ $attachment = get_post($attachmentId);
+
+ $postData = [
+ 'post_type' => jvbCheckBase($data['content']),
+ 'post_title' => $attachment->post_title,
+ 'post_status' => 'draft',
+ 'post_author' => $data['user'] ?? get_current_user_id(),
+ ];
+
+ $postId = wp_insert_post($postData);
+
+ if (!is_wp_error($postId)) {
+ $this->attachFileToPost($postId, $attachmentId, $data);
+ $createdPosts[] = [
+ 'post_id' => $postId,
+ 'attachment_id' => $attachmentId,
+ ];
+ }
+ }
+
+ return $createdPosts;
+ }
+
+ private function attachFileToPost(int $postId, int $attachmentId, array $data): void
+ {
+ $attachment = get_post($attachmentId);
+ $mimeType = $attachment->post_mime_type;
+
+ if (str_starts_with($mimeType, 'image/')) {
+ set_post_thumbnail($postId, $attachmentId);
+ } elseif (str_starts_with($mimeType, 'video/')) {
+ $meta = new MetaManager($postId, 'post');
+ $meta->updateValue('video', $attachmentId);
+ } else {
+ $meta = new MetaManager($postId, 'post');
+ $existing = $meta->getValue('documents');
+ $existingIds = !empty($existing) ? explode(',', $existing) : [];
+ $existingIds[] = $attachmentId;
+ $meta->updateValue('documents', implode(',', $existingIds));
+ }
+ }
+
+ private function cleanupTempFiles(array $securedFiles, int $userId): void
+ {
+ $uploader = new UploadManager();
+
+ foreach ($securedFiles as $secured) {
+ if (!empty($secured['temp_path']) && file_exists($secured['temp_path'])) {
+ @unlink($secured['temp_path']);
+ }
+ }
+
+ $uploader->cleanupEmptyTempDirs($userId);
+ }
+
+ private function findAttachmentByUploadId(string $uploadId, array $results): ?int
+ {
+ if (isset($results[$uploadId]['attachment_id'])) {
+ return (int)$results[$uploadId]['attachment_id'];
+ }
+
+ foreach ($results as $result) {
+ if (isset($result['upload_id']) && $result['upload_id'] === $uploadId) {
+ return (int)$result['attachment_id'];
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/inc/meta/MetaForm.php b/inc/meta/MetaForm.php
index 5225f9b..bfb93eb 100644
--- a/inc/meta/MetaForm.php
+++ b/inc/meta/MetaForm.php
@@ -1194,7 +1194,7 @@
<?php jvbRenderProgressBar('',true) ?>
<input type="checkbox" class="upload-select" name="select-item" id="select-item<?=$addID?>">
<label for="select-item<?=$addID?>" aria-label="Select image">
- <?= ($attachment) ? $attachment : '<img>
+ <?= ($attachment) ?: '<img>
<video></video>
<span></span>'; ?>
</label>
@@ -1267,7 +1267,7 @@
<?php jvbRenderProgressBar('',true) ?>
<input type="checkbox" class="upload-select" name="select-item" id="select-item<?=$addID?>">
<label for="select-item<?=$addID?>" aria-label="Select image">
- <?= ($attachment) ? $attachment : '<img>
+ <?= ($attachment) ?: '<img>
<video></video>
<span></span>'; ?>
</label>
diff --git a/inc/meta/MetaManager.php b/inc/meta/MetaManager.php
index 89029d2..e3f5e1f 100644
--- a/inc/meta/MetaManager.php
+++ b/inc/meta/MetaManager.php
@@ -815,9 +815,7 @@
return '';
}
- $out = apply_filters('jvbMetaTypeTemplate', $out, $type);
-
- return $out;
+ return apply_filters('jvbMetaTypeTemplate', $out, $type);
}
public function getDefaultValue(string $type):mixed {
diff --git a/inc/registry/CheckCustomTables.php b/inc/registry/CheckCustomTables.php
index bf0f1bf..a54967e 100644
--- a/inc/registry/CheckCustomTables.php
+++ b/inc/registry/CheckCustomTables.php
@@ -445,16 +445,15 @@
`type` varchar(50) NOT NULL,
`user_id` {$this->userIDType} NOT NULL,
- `request_data` JSON NOT NULL,
+ `request_data` JSON NOT NULL CHECK (JSON_VALID(request_data)),
`total_items` int(11) NOT NULL DEFAULT 1,
`processed_items` int(11) DEFAULT 0,
`failed_items` JSON,
- `priority` enum('low', 'normal', 'high') DEFAULT 'normal',
-// `status` enum('pending', 'scheduled', 'processing','failed', 'failed_permanent', 'completed', 'completed_with_errors') DEFAULT 'pending',
- `state` enum('pending', 'scheduled', 'processing', 'completed'),
- `outcome` enum('success', 'partial', 'failed','failed_permanent'),
+ `priority` ENUM('high', 'normal', 'low') DEFAULT 'normal',
+ `state` enum('pending', 'scheduled', 'processing', 'completed') DEFAULT 'pending',
+ `outcome` enum('pending', 'success', 'partial', 'failed','failed_permanent') DEFAULT 'pending',
`retries` int(11) DEFAULT 0,
`last_error_hash` CHAR(32) DEFAULT NULL,
@@ -467,7 +466,6 @@
`metadata` JSON DEFAULT NULL,
`result` JSON,
`dependencies` JSON,
-// `merge` enum('merge', 'append', 'replace') DEFAULT 'merge',
`user_dismissed` tinyint(1) DEFAULT 0,
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
@@ -476,26 +474,36 @@
KEY `idx_run_queue` (state, priority, scheduled_at),
KEY `idx_user_ops` (user_id, state),
KEY `idx_user_type_pending` (user_id, type, state),
- KEY `idx_completed_at` (completed_at)
+ KEY `idx_completed_at` (completed_at),
+ KEY `idx_processing_stuck` (`state`, `started_at`)
)",
'stats__operation_queue' => "(
- `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
- `date` date NOT NULL,
- `type` varchar(50) NOT NULL,
- `total_operations` int NOT NULL DEFAULT 0,
- `successful_operations` int NOT NULL DEFAULT 0,
- `failed_operations` int NOT NULL DEFAULT 0,
- `average_duration` float DEFAULT NULL,
- `total_items_processed` int NOT NULL DEFAULT 0,
- `peak_queue_size` int NOT NULL DEFAULT 0,
- `peak_memory_usage` int DEFAULT NULL,
- `peak_cpu_usage` float DEFAULT NULL,
- `created_at` timestamp DEFAULT CURRENT_TIMESTAMP,
- PRIMARY KEY (`id`),
- UNIQUE KEY (`date`, `type`),
- KEY `date_idx` (`date`),
- KEY `type_idx` (`type`)
+ `id` bigint unsigned AUTO_INCREMENT,
+ `date` date NOT NULL,
+ `type` varchar(50) NOT NULL,
+
+ `total_operations` int NOT NULL DEFAULT 0,
+ `successful_operations` int NOT NULL DEFAULT 0,
+ `partial_operations` int NOT NULL DEFAULT 0,
+ `failed_operations` int NOT NULL DEFAULT 0,
+ `failed_permanent_operations` int NOT NULL DEFAULT 0,
+
+ `total_items_processed` int NOT NULL DEFAULT 0,
+
+ `average_duration` float DEFAULT NULL,
+ `max_duration` int DEFAULT NULL,
+
+ `peak_queue_size` int NOT NULL DEFAULT 0,
+
+ `peak_memory_usage` int DEFAULT NULL,
+ `peak_cpu_usage` float DEFAULT NULL,
+
+ `created_at` timestamp DEFAULT CURRENT_TIMESTAMP,
+
+ UNIQUE KEY (date, type),
+ KEY `date_idx` (date),
+ KEY `type_idx` (type)
)"
];
}
diff --git a/inc/registry/FieldRegistry.php b/inc/registry/FieldRegistry.php
index 996e99b..372a1f9 100644
--- a/inc/registry/FieldRegistry.php
+++ b/inc/registry/FieldRegistry.php
@@ -121,9 +121,7 @@
// Apply filters for extensibility
$fields = apply_filters(BASE . 'fields', $fields, $type, $object_type);
- $fields = apply_filters(BASE . "{$type}_fields", $fields, $object_type);
-
- return $fields;
+ return apply_filters(BASE . "{$type}_fields", $fields, $object_type);
}
/**
diff --git a/inc/rest/routes/AdminRoutes.php b/inc/rest/routes/AdminRoutes.php
index cf58a34..a5593cb 100644
--- a/inc/rest/routes/AdminRoutes.php
+++ b/inc/rest/routes/AdminRoutes.php
@@ -598,13 +598,12 @@
$query = new WP_Query($args);
$items = array_map([$this, 'formatItem'], $query->posts);
- $results = [
- 'items' => $items,
- 'has_more' => $query->max_num_pages > $args['paged'],
- 'total_items' => $query->found_posts,
- 'total_pages' => $query->max_num_pages
- ];
- return $results;
+ return [
+ 'items' => $items,
+ 'has_more' => $query->max_num_pages > $args['paged'],
+ 'total_items' => $query->found_posts,
+ 'total_pages' => $query->max_num_pages
+ ];
}
/**
diff --git a/inc/rest/routes/ContentRoutes.php b/inc/rest/routes/ContentRoutes.php
index 43b1f9f..8ff8c78 100644
--- a/inc/rest/routes/ContentRoutes.php
+++ b/inc/rest/routes/ContentRoutes.php
@@ -3,6 +3,8 @@
namespace JVBase\rest\routes;
use JVBase\JVB;
+use JVBase\managers\queue\executors\ContentExecutor;
+use JVBase\managers\queue\TypeConfig;
use JVBase\rest\RestRouteManager;
use JVBase\managers\CacheManager;
use JVBase\meta\MetaManager;
@@ -30,9 +32,6 @@
protected array $timelineSharedFields = [];
protected array $timelineUniqueFields = [];
- //TODO: Ensure we are handling the bulk operations for all processes
- //TODO: Also invalidate feed caches on updates!!
-
public function __construct()
{
$this->cache_name = 'user_content_' . get_current_user_id();
@@ -40,7 +39,28 @@
$this->cache->clear();
$this->action = 'dash-';
$this->operation_type = 'content_update';
- add_filter(BASE . 'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
+ add_action('init', [$this, 'registerContentExecutors'], 5);
+ }
+
+ /**
+ * Register content operation types with the queue's TypeRegistry
+ */
+ public function registerContentExecutors(): void
+ {
+ $registry = JVB()->queue()->registry();
+ $executor = new ContentExecutor();
+
+ // Content updates - chunked at 10 posts
+ $registry->register('content_update', new TypeConfig(
+ executor: $executor,
+ chunkKey: 'posts',
+ chunkSize: 10
+ ));
+
+ // Batch creation (from uploads)
+ $registry->register('batch_creation', new TypeConfig(
+ executor: $executor
+ ));
}
/**
diff --git a/inc/rest/routes/FavouritesRoutes.php b/inc/rest/routes/FavouritesRoutes.php
index 29112b5..a5de911 100644
--- a/inc/rest/routes/FavouritesRoutes.php
+++ b/inc/rest/routes/FavouritesRoutes.php
@@ -273,14 +273,12 @@
$by_type[$type][] = (int)$fav->target_id;
}
- $response_data = [
+ return [
'success' => true,
'items' => $by_type,
'has_more' => false,
];
- return $response_data;
-
} catch (Exception $e) {
$this->logError(
$e->getMessage(),
diff --git a/inc/rest/routes/FeedRoutes.php b/inc/rest/routes/FeedRoutes.php
index de35a83..05a97a7 100644
--- a/inc/rest/routes/FeedRoutes.php
+++ b/inc/rest/routes/FeedRoutes.php
@@ -381,8 +381,7 @@
$args = $this->applyOrderFilters($args, $data);
$args = $this->applyDateFilters($args, $data);
- $args = $this->applyFavouritesFilter($args, $data);
- return $args;
+ return $this->applyFavouritesFilter($args, $data);
}
protected function applyTaxonomyFilters(array $args, array $data): array
diff --git a/inc/rest/routes/FormRoutes.php b/inc/rest/routes/FormRoutes.php
index f1efa39..1201a8e 100644
--- a/inc/rest/routes/FormRoutes.php
+++ b/inc/rest/routes/FormRoutes.php
@@ -323,7 +323,7 @@
$form = [];
foreach ($form_config['fields'] as $field_name => $config) {
// Skip file upload fields
- if (in_array($config['type'], ['upload'])) {
+ if ($config['type'] == 'upload') {
continue;
}
@@ -354,7 +354,7 @@
}
// Send the email
- $email_sent = JVB()->email()->sendEmail(
+ return JVB()->email()->sendEmail(
$to,
$subject,
$body,
@@ -362,8 +362,6 @@
$headers,
$attachments
);
-
- return $email_sent;
}
/**
diff --git a/inc/rest/routes/Invitations.php b/inc/rest/routes/Invitations.php
index 4022790..5c7638a 100644
--- a/inc/rest/routes/Invitations.php
+++ b/inc/rest/routes/Invitations.php
@@ -86,16 +86,14 @@
$toTerm = (array_key_exists('to_term', $data)) ? (int)$data['to_term'] : false;
$taxonomy = (array_key_exists('taxonomy', $data) && in_array($data['taxonomy'], $this->inviteTypes[$role]['to_terms']??[])) ? $data['taxonomy'] : false;
- $args = [
- 'user' => (array_key_exists('user', $data)) ? (int)$data['user'] : false,
- 'role' => $role,
- 'to_term' => $toTerm,
- 'taxonomy' => $taxonomy,
- 'status' => array_key_exists('status', $data) && in_array($data['status'], ['all', 'pending', 'accepted', 'rejected', 'expired', 'revoked']) ? $data['status'] : 'all',
- 'page' => array_key_exists('page', $data) ? (int)$data['page'] : 1,
- ];
-
- return $args;
+ return [
+ 'user' => (array_key_exists('user', $data)) ? (int)$data['user'] : false,
+ 'role' => $role,
+ 'to_term' => $toTerm,
+ 'taxonomy' => $taxonomy,
+ 'status' => array_key_exists('status', $data) && in_array($data['status'], ['all', 'pending', 'accepted', 'rejected', 'expired', 'revoked']) ? $data['status'] : 'all',
+ 'page' => array_key_exists('page', $data) ? (int)$data['page'] : 1,
+ ];
}
/**
* @param object $request the request object
diff --git a/inc/rest/routes/LoginRoutes.php b/inc/rest/routes/LoginRoutes.php
index 226692b..9762e9f 100644
--- a/inc/rest/routes/LoginRoutes.php
+++ b/inc/rest/routes/LoginRoutes.php
@@ -31,23 +31,7 @@
register_rest_route($this->namespace, '/auth/login', [
'methods' => 'POST',
'callback' => [$this, 'handleLogin'],
- 'permission_callback' => [$this, 'checkRateLimit'],
- 'args' => [
- 'user_email' => [
- 'required' => true,
- 'type' => 'string',
- 'sanitize_callback' => 'sanitize_email'
- ],
- 'user_password' => [
- 'required' => true,
- 'type' => 'string'
- ],
- 'remember_me' => [
- 'required' => false,
- 'type' => 'boolean',
- 'default' => false
- ]
- ]
+ 'permission_callback' => [$this, 'checkRateLimit']
]);
// Logout endpoint
@@ -125,7 +109,8 @@
public function handleLogin(WP_REST_Request $request): WP_REST_Response
{
- $data = $request->get_json_params();
+ $data = $request->get_params();
+ error_log('Data: '.print_r($data, true));
// Verify Turnstile
if (!$this->verifyTurnstile($data['cf-turnstile-response'] ?? '')) {
return $this->error('Security verification failed', 'turnstile_failed', 403);
@@ -584,7 +569,7 @@
];
}
- protected function getRedirect(WP_User $user, string $url, string $context = 'login'):string
+ protected function getRedirect(WP_User $user, ?string $url=null, string $context = 'login'):string
{
if (!empty($url)) {
$url = sanitize_url($url);
diff --git a/inc/rest/routes/NewsRoutes.php b/inc/rest/routes/NewsRoutes.php
index a985b68..0811cec 100644
--- a/inc/rest/routes/NewsRoutes.php
+++ b/inc/rest/routes/NewsRoutes.php
@@ -128,9 +128,7 @@
$args = $this->applyTaxonomyFilters($args, $data);
$args = $this->applyOrderFilters($args, $data);
$args = $this->applyDateFilters($args, $data);
- $args = $this->applyWatchedFilter($args, $data);
-
- return $args;
+ return $this->applyWatchedFilter($args, $data);
}
/**
diff --git a/inc/rest/routes/QueueRoutes.php b/inc/rest/routes/QueueRoutes.php
index 21bce8f..824ec55 100644
--- a/inc/rest/routes/QueueRoutes.php
+++ b/inc/rest/routes/QueueRoutes.php
@@ -16,23 +16,21 @@
class QueueRoutes extends RestRouteManager
{
-
- protected string $table = BASE.'_operation_queue';
- protected string $metricsTable = BASE.'stats__operation_queue';
-
public function __construct()
{
$this->cache_name = 'queue';
$this->cache_ttl = 300;
parent::__construct();
+
+ $this->cache->clear();
}
/**
* Registers queue routes
* @return void
*/
- public function registerRoutes():void
- {
+ public function registerRoutes():void
+ {
register_rest_route($this->namespace, '/queue', [
[
'methods' => 'GET',
@@ -79,7 +77,7 @@
]
]
]);
- }
+ }
/**
* Get queue operations with optional filtering
@@ -89,62 +87,45 @@
*/
public function getQueue(WP_REST_Request $request): WP_REST_Response
{
- $user_id = get_current_user_id();
- $status = $request->get_param('status');
+ $user_id = $request->get_param('user');
+ $status = sanitize_text_field($request->get_param('status'));
$ids = $request->get_param('ids');
$limit = intval($request->get_param('limit'));
-
// Use base class user-specific header checking
- // This checks both 'queue' and 'user_{$user_id}' timestamps
$cache_check = $this->checkUserHeaders($request, $user_id, 'queue');
if ($cache_check) {
- return $cache_check; // Returns 304 Not Modified
+ return $cache_check;
}
- global $wpdb;
- $table = $wpdb->prefix . $this->table;
+ // Build filters for getUserOperations
+ $filters = [
+ 'not_dismissed' => true,
+ 'limit' => $limit ?: 50,
+ ];
- $sql = "SELECT * FROM $table WHERE user_id = %d AND COALESCE(user_dismissed, 0) = 0";
- $params = [$user_id];
-
- if ($status !== 'all') {
- $sql .= " AND status = %s";
- $params[] = $status;
+ if ($status && $status !== 'all') {
+ $filters['state'] = $status;
}
if (!empty($ids)) {
- $id_array = array_map('trim', explode(',', $ids));
- if (!empty($id_array)) {
- $placeholders = implode(',', array_fill(0, count($id_array), '%s'));
- $sql .= " AND id IN ($placeholders)";
- $params = array_merge($params, $id_array);
- }
+ $filters['ids'] = array_map('trim', explode(',', $ids));
}
- $sql .= " ORDER BY FIELD(status, 'processing', 'pending', 'failed', 'completed'), created_at DESC";
+ // Get operations via Queue
+ $operations = JVB()->queue()->getUserOperations($user_id, $filters);
- if ($limit > 0) {
- $sql .= " LIMIT %d";
- $params[] = $limit;
- }
-
- $operations = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
-
- // Format operations
- foreach ($operations as &$op) {
- $op = $this->formatOperation($op);
- }
+ // Format operations for API response
+ $formatted = array_map([$this, 'formatOperationFromObject'], $operations);
$response = new WP_REST_Response([
- 'items' => $operations,
- 'total' => count($operations),
+ 'items' => $formatted,
+ 'total' => count($formatted),
'timestamp' => date('c'),
- 'has_more' => count($operations) === $limit,
+ 'has_more' => count($formatted) === ($limit ?: 50),
'queue_stats' => $this->getQueueStats($user_id),
'server_time' => date('c')
]);
- // Add cache headers (ETag, Last-Modified)
return $this->addCacheHeaders($response);
}
@@ -154,76 +135,75 @@
*/
protected function getQueueStats(int $user_id): array
{
- global $wpdb;
- $table = $wpdb->prefix . $this->table;
+ $stats = JVB()->queue()->getUserStats($user_id);
- $stats = $wpdb->get_results($wpdb->prepare(
- "SELECT status, COUNT(*) as count FROM $table WHERE user_id = %d GROUP BY status",
- $user_id
- ), OBJECT_K);
-
- $formatted_stats = [
+ // Add frontend-only statuses that don't exist in backend
+ return array_merge([
'queued' => 0,
'localProcessing' => 0,
'uploading' => 0,
- 'pending' => 0,
- 'processing' => 0,
- 'completed' => 0,
- 'failed' => 0,
- 'failed_permanent' => 0
- ];
+ ], $stats);
+ }
- foreach ($stats as $status => $data) {
- if (isset($formatted_stats[$status])) {
- $formatted_stats[$status] = intval($data->count);
- }
+ /**
+ * Map backend state/outcome to frontend status
+ * Backend uses: state (pending, scheduled, processing, completed) + outcome (pending, success, partial, failed, failed_permanent)
+ * Frontend expects: queued, pending, processing, completed, failed, failed_permanent
+ */
+ protected function mapStateToStatus(string $state, ?string $outcome): string
+ {
+ // If completed, check outcome for failure states
+ if ($state === 'completed') {
+ return match($outcome) {
+ 'failed' => 'failed',
+ 'failed_permanent' => 'failed_permanent',
+ 'partial' => 'completed', // or could be 'partial' if JS supports it
+ default => 'completed'
+ };
}
- return $formatted_stats;
+ // Map other states directly
+ return match($state) {
+ 'scheduled' => 'pending',
+ default => $state
+ };
}
+
/**
- * Format operation for API response
+ * Format Operation object for API response
*/
- protected function formatOperation(array $operation): array
+ protected function formatOperationFromObject(\JVBase\managers\queue\Operation $op): array
{
$formatted = [
- 'id' => $operation['id'],
- 'type' => $operation['type'],
- 'status' => $operation['status'],
- 'progress_count' => intval($operation['progress_count'] ?? 0),
- 'count' => intval($operation['count'] ?? 1),
- 'retries' => intval($operation['retries'] ?? 0),
- 'data' => json_decode($operation['request_data'] ?? '{}', true),
- 'result' => json_decode($operation['result']??'{}', true)
+ 'id' => $op->id,
+ 'type' => $op->type,
+ 'status' => $this->mapStateToStatus($op->state, $op->outcome),
+ 'progress_count' => $op->processedItems,
+ 'count' => $op->totalItems,
+ 'retries' => $op->retries,
+ 'data' => $op->requestData,
+ 'result' => $op->result ?? [],
];
- // Convert timestamps to ISO 8601 format with proper timezone
- $formatted['created_at'] = $this->formatTimestamp($operation['created_at']);
- $formatted['updated_at'] = $this->formatTimestamp($operation['updated_at']);
+ $formatted['created_at'] = $this->formatTimestamp($op->scheduledAt);
+ $formatted['updated_at'] = $this->formatTimestamp($op->completedAt ?? $op->startedAt ?? $op->scheduledAt);
- // Add completed_at if status is completed
- if ($operation['status'] === 'completed' && !empty($operation['completed_at'])) {
- $formatted['completed_at'] = $this->formatTimestamp($operation['completed_at']);
+ if ($op->state === 'completed' && $op->completedAt) {
+ $formatted['completed_at'] = $this->formatTimestamp($op->completedAt);
}
- // Add error message if failed
- if (!empty($operation['error_message'])) {
- $formatted['error_message'] = $operation['error_message'];
+ if ($op->errorMessage) {
+ $formatted['error_message'] = $op->errorMessage;
}
- // Simple progress percentage calculation
if ($formatted['count'] > 0) {
$formatted['progress_percentage'] = round(
($formatted['progress_count'] / $formatted['count']) * 100
);
}
- // Add human-readable title for easier frontend display
- $formatted['title'] = $this->getOperationTitle($operation['type'], $formatted['data']);
-
-
- // Add user dismissal status
- $formatted['user_dismissed'] = !empty($operation['user_dismissed']);
+ $formatted['title'] = $this->getOperationTitle($op->type, $op->requestData);
+ $formatted['user_dismissed'] = $op->userDismissed;
return $formatted;
}
@@ -270,10 +250,8 @@
$data = $request->get_json_params();
$ids = $data['ids'] ?? [];
$action = $data['action'] ?? '';
-
$user_id = (int)$data['user'];
-
// Validate input
if (empty($ids) || !is_array($ids)) {
return new WP_REST_Response([
@@ -289,31 +267,22 @@
], 400);
}
- global $wpdb;
- $table = $wpdb->prefix . $this->table;
+ // Get operations via Queue - verifies ownership
+ $operations = JVB()->queue()->getUserOperations($user_id, [
+ 'ids' => $ids,
+ 'limit' => count($ids),
+ ]);
- // Verify operations exist and belong to user
- $placeholders = implode(',', array_fill(0, count($ids), '%s'));
- $valid_operations = $wpdb->get_results($wpdb->prepare(
- "SELECT id, status FROM $table WHERE id IN ($placeholders) AND user_id = %d",
- array_merge($ids, [$user_id])
- ));
-
- if (empty($valid_operations)) {
+ if (empty($operations)) {
return new WP_REST_Response([
'success' => false,
'message' => 'No valid operations found'
], 404);
}
- $valid_ids = array_column($valid_operations, 'id');
- $placeholders = implode(',', array_fill(0, count($valid_ids), '%s'));
-
- // Process action using foreach approach as suggested
- $result = $this->processQueueAction($action, $valid_operations, $user_id);
+ $result = $this->processQueueAction($action, $operations, $user_id);
if ($result['success']) {
- // Update timestamp for this user's queue
CacheManager::updateTimestamp("user_{$user_id}");
}
@@ -322,82 +291,36 @@
protected function processQueueAction(string $action, array $operations, int $user_id): array
{
- global $wpdb;
- $table = $wpdb->prefix . $this->table;
-
+ $queue = JVB()->queue();
$processed_count = 0;
$errors = [];
$valid_ids = [];
- // Process each operation individually
- foreach ($operations as $operation) {
- $operation_id = $operation->id;
- $operation_status = $operation->status;
-
+ foreach ($operations as $op) {
try {
- $result = false;
+ $result = match($action) {
+ 'dismiss' => $queue->dismiss($op->id),
+ 'retry' => $queue->retry($op->id, $user_id),
+ 'cancel' => $queue->cancel($op->id, $user_id),
+ default => false,
+ };
- switch ($action) {
- case 'dismiss':
- // Can dismiss any operation
- $result = $wpdb->update(
- $table,
- ['user_dismissed' => 1],
- ['id' => $operation_id, 'user_id' => $user_id]
- );
- break;
-
- case 'retry':
- // Can only retry failed operations
- if (!in_array($operation_status, ['failed', 'failed_permanent'])) {
- $errors[] = "Operation {$operation_id} cannot be retried (status: {$operation_status})";
- continue 2;
- }
-
- $result = $wpdb->update(
- $table,
- [
- 'status' => 'pending',
- 'error_message' => null,
- 'updated_at' => current_time('mysql'),
- 'retries' => $wpdb->get_var($wpdb->prepare(
- "SELECT retries FROM $table WHERE id = %s",
- $operation_id
- )) + 1
- ],
- ['id' => $operation_id, 'user_id' => $user_id]
- );
- break;
-
- case 'cancel':
- // Can only cancel pending/queued operations
- if (!in_array($operation_status, ['pending', 'queued'])) {
-// $errors[] = "Operation {$operation_id} cannot be cancelled (status: {$operation_status})";
- continue 2;
- }
-
- $result = $wpdb->delete(
- $table,
- ['id' => $operation_id, 'user_id' => $user_id]
- );
- break;
- }
-
- if ($result !== false) {
+ if ($result) {
$processed_count++;
- $valid_ids[] = $operation_id;
+ $valid_ids[] = $op->id;
} else {
- $errors[] = "Failed to {$action} operation {$operation_id}";
+ // Only add errors for meaningful failures
+ if ($action === 'retry' && ($op->state !== 'completed' || !in_array($op->outcome, ['failed', 'failed_permanent']))) {
+ $errors[] = "Operation {$op->id} cannot be retried (state: {$op->state}, outcome: {$op->outcome})";
+ }
+ // Silently skip cancel failures (can't cancel processing/completed)
}
-
} catch (Exception $e) {
- $errors[] = "Error processing operation {$operation_id}: " . $e->getMessage();
+ $errors[] = "Error processing operation {$op->id}: " . $e->getMessage();
}
}
- // Prepare response
$message = $this->getActionMessage($action, $processed_count);
-
if (!empty($errors)) {
$message .= ". Errors: " . implode(', ', $errors);
}
@@ -435,27 +358,30 @@
{
$user_id = get_current_user_id();
- global $wpdb;
- $table = $wpdb->prefix . $this->table;
+ $operations = JVB()->queue()->getUserOperations($user_id, [
+ 'state' => 'completed',
+ 'outcome' => ['failed', 'failed_permanent', 'partial'],
+ 'has_errors' => true,
+ 'order_by' => 'updated_at DESC',
+ 'limit' => 20,
+ ]);
- $failed_operations = $wpdb->get_results($wpdb->prepare("
- SELECT id, type, error_message, failed_items, retries, created_at, updated_at
- FROM $table
- WHERE user_id = %d
- AND status IN ('failed', 'completed_with_errors')
- AND (error_message IS NOT NULL OR failed_items IS NOT NULL)
- ORDER BY updated_at DESC
- LIMIT 20
- ", $user_id), ARRAY_A);
-
- foreach ($failed_operations as &$op) {
- $op['failed_items'] = json_decode($op['failed_items'] ?? '[]', true);
- $op['error_details'] = $this->parseErrorMessage($op['error_message']);
- }
+ $formatted = array_map(function($op) {
+ return [
+ 'id' => $op->id,
+ 'type' => $op->type,
+ 'error_message' => $op->errorMessage,
+ 'failed_items' => $op->failedItems ?? [],
+ 'retries' => $op->retries,
+ 'created_at' => $op->scheduledAt,
+ 'updated_at' => $op->completedAt,
+ 'error_details' => $this->parseErrorMessage($op->errorMessage ?? ''),
+ ];
+ }, $operations);
return new WP_REST_Response([
- 'errors' => $failed_operations,
- 'total' => count($failed_operations)
+ 'errors' => $formatted,
+ 'total' => count($formatted)
]);
}
protected function parseErrorMessage(string $error_message): array
diff --git a/inc/rest/routes/UploadRoutes.php b/inc/rest/routes/UploadRoutes.php
index 941207b..eb3c211 100644
--- a/inc/rest/routes/UploadRoutes.php
+++ b/inc/rest/routes/UploadRoutes.php
@@ -2,6 +2,8 @@
namespace JVBase\rest\routes;
use JVBase\JVB;
+use JVBase\managers\queue\executors\UploadExecutor;
+use JVBase\managers\queue\TypeConfig;
use JVBase\rest\RestRouteManager;
use JVBase\meta\MetaManager;
use JVBase\managers\UploadManager;
@@ -21,9 +23,62 @@
{
$this->action = 'dash-';
parent::__construct();
- add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
+
+ add_action('init', [$this, 'registerUploadExecutors'], 5);
}
+ /**
+ * Register upload operation types with the queue's TypeRegistry
+ */
+ public function registerUploadExecutors(): void
+ {
+ $registry = JVB()->queue()->registry();
+ $executor = new UploadExecutor();
+
+ // Image uploads - chunked at 5 files
+ $registry->register('image_upload', new TypeConfig(
+ executor: $executor,
+ chunkKey: 'secured_files',
+ chunkSize: 5
+ ));
+
+ // Video uploads - one at a time (heavy processing)
+ $registry->register('video_upload', new TypeConfig(
+ executor: $executor,
+ chunkKey: 'secured_files',
+ chunkSize: 1
+ ));
+
+ // Document uploads - chunked at 10
+ $registry->register('document_upload', new TypeConfig(
+ executor: $executor,
+ chunkKey: 'secured_files',
+ chunkSize: 10
+ ));
+
+ // Metadata updates
+ $registry->register('update_metadata', new TypeConfig(
+ executor: $executor
+ ));
+
+ // Cleanup - chunked at 5
+ $registry->register('temporary_cleanup', new TypeConfig(
+ executor: $executor,
+ chunkKey: 'files',
+ chunkSize: 5
+ ));
+
+ // Attach to content (depends on upload completing)
+ $registry->register('attach_upload_to_content', new TypeConfig(
+ executor: $executor
+ ));
+
+ // Process upload groups into posts
+ $registry->register('process_upload_groups', new TypeConfig(
+ executor: $executor
+ ));
+ }
+
/**
* Registers upload routes
* @return void
--
Gitblit v1.10.0