=OperationQueue refactor to the JVBase/managers/queue namespace
36 files modified
16 files added
| | |
| | | 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; |
| | |
| | | $this->customBlocks = new CustomBlocks(); |
| | | $this->managers = [ |
| | | 'errors' => new ErrorHandler(), |
| | | 'queue' => new OperationQueue(), |
| | | 'queue' => new Queue(), |
| | | // 'dash' => new DashboardManager(), |
| | | 'roles' => new RoleManager(), |
| | | // 'forms' => new FormManager(), |
| | |
| | | { |
| | | return $this->managers['cache']; |
| | | } |
| | | public function queue():OperationQueue |
| | | public function queue():Queue |
| | | { |
| | | return $this->managers['queue']; |
| | | } |
| | |
| | | } |
| | | |
| | | let form = this.getForm(e.target); |
| | | //Autosave |
| | | if (!form || !form.options.cache) return; |
| | | this.updateItem(field.dataset.field, this.getFieldValue(e.target), form); |
| | | } |
| | | |
| | |
| | | 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){ |
| | |
| | | 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) { |
| | |
| | | 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() { |
| | |
| | | } |
| | | } |
| | | |
| | | 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 |
| | | **********************************************************************/ |
| | |
| | | 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() { |
| | |
| | | |
| | | this.user = window.auth.getUser(); |
| | | |
| | | |
| | | this.canUpdateUI = true; |
| | | this.isProcessing = false; |
| | | this.isPolling = false; |
| | |
| | | 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, |
| | |
| | | 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 |
| | |
| | | {name: 'status', keyPath: 'status'}, |
| | | {name: 'type', keyPath: 'type'}, |
| | | ], |
| | | filters: { |
| | | user: window.auth.getUser() |
| | | }, |
| | | showLoading: false, |
| | | } |
| | | ) |
| | |
| | | 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'; |
| | | } |
| | |
| | | } |
| | | |
| | | 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); |
| | | } |
| | |
| | | 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: |
| | |
| | | } |
| | | } |
| | | toggleQueue(on = true) { |
| | | if (!this.ui.panel) return; |
| | | this.ui.panel.hidden = !on; |
| | | this.ui.toggle.button.hidden = !on; |
| | | } |
| | |
| | | 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}); |
| | | } |
| | | |
| | | |
| | |
| | | btn.remove(); |
| | | }); |
| | | } |
| | | this.formController = null; |
| | | } |
| | | |
| | | initStore() { |
| | |
| | | (()=>{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)}))}))})(); |
| | | (()=>{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)}))}))})(); |
| | |
| | | (()=>{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)}))}))})(); |
| | | (()=>{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)}))}))})(); |
| | |
| | | (()=>{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)}))}))})(); |
| | | (()=>{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)}))}))})(); |
| | |
| | | $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" |
| | |
| | | </div> |
| | | <!-- edmonton.ink Artist Badge - End --> |
| | | HTML; |
| | | return $html; |
| | | } |
| | | |
| | | /** |
| | |
| | | $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" |
| | |
| | | </div> |
| | | <!-- edmonton.ink Artist Badge - End --> |
| | | HTML; |
| | | |
| | | return $html; |
| | | } |
| | | |
| | | /** |
| | |
| | | $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" |
| | |
| | | </div> |
| | | <!-- edmonton.ink Artist Badge - End --> |
| | | HTML; |
| | | |
| | | return $html; |
| | | } |
| | | |
| | | /** |
| | |
| | | $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> |
| | |
| | | </div> |
| | | </div> |
| | | HTML; |
| | | |
| | | return $html; |
| | | } |
| | | } |
| | |
| | | $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 |
| | | { |
| | |
| | | 'sections' => [] |
| | | ]; |
| | | |
| | | $config = array_merge($defaults, $config); |
| | | |
| | | return $config; |
| | | return array_merge($defaults, $config); |
| | | } |
| | | |
| | | /** |
| | |
| | | $text = preg_replace('/\s+/', ' ', $text); |
| | | |
| | | // Trim |
| | | $text = trim($text); |
| | | |
| | | return $text; |
| | | return trim($text); |
| | | } |
| | | /** |
| | | * Format date for GMB API |
| | |
| | | |
| | | // Validate hour and minute ranges |
| | | if ($hour >= 0 && $hour <= 23 && $minute >= 0 && $minute <= 59) { |
| | | $result = [ |
| | | return [ |
| | | 'hours' => $hour, |
| | | 'minutes' => $minute |
| | | ]; |
| | | return $result; |
| | | } |
| | | } |
| | | |
| | |
| | | [], |
| | | 'posts' |
| | | ); |
| | | $result = $response['foodMenus'] ?? []; |
| | | |
| | | return $result; |
| | | return $response['foodMenus'] ?? []; |
| | | } |
| | | |
| | | /** |
| | |
| | | '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' |
| | | ); |
| | | } |
| | | |
| | | /** |
| | |
| | | $params = $this->addOAuthParams($params); |
| | | } |
| | | |
| | | $auth_url = $this->oauth['authorize'] . '?' . http_build_query($params); |
| | | |
| | | |
| | | return $auth_url; |
| | | return $this->oauth['authorize'] . '?' . http_build_query($params); |
| | | } |
| | | |
| | | /** |
| | |
| | | |
| | | // Clean up whitespace |
| | | $text = preg_replace('/\s+/', ' ', $text); |
| | | $text = trim($text); |
| | | |
| | | return $text; |
| | | return trim($text); |
| | | } |
| | | |
| | | /** |
| | |
| | | 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 |
| | |
| | | if (!in_array($page, $allowedPages)) { |
| | | error_log("User not allowed to access page: {$page}"); |
| | | $this->redirectToDashboard(); |
| | | return; |
| | | } |
| | | } |
| | | } |
| | |
| | | // 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 |
| | |
| | | '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' => [ |
| | |
| | | } |
| | | |
| | | // 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 |
| | | { |
| | |
| | | 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; |
| | |
| | | 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); |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | // Add term hierarchy |
| | | $crumbs = array_merge($crumbs, $this->buildTermHierarchy($term)); |
| | | |
| | | return $crumbs; |
| | | return array_merge($crumbs, $this->buildTermHierarchy($term)); |
| | | } |
| | | |
| | | /** |
| | |
| | | |
| | | $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}"; |
| | | } |
| | | |
| | | /** |
| | |
| | | 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' |
| | | ] |
| | | ], |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | |
| | | $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'] |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | } |
| | | 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')) { |
| New file |
| | |
| | | <?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; |
| | | } |
| New file |
| | |
| | | <?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; |
| | | } |
| | | } |
| New file |
| | |
| | | <?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(); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | <?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; |
| | | } |
| New file |
| | |
| | | <?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 |
| | | ); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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; |
| | | } |
| New file |
| | |
| | | <?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); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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, |
| | | ]; |
| | | } |
| | | } |
| New file |
| | |
| | | <?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(); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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 |
| | | ) {} |
| | | } |
| New file |
| | |
| | | <?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); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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 |
| | | ) {} |
| | | } |
| New file |
| | |
| | | <?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; |
| | | } |
| | | } |
| New file |
| | |
| | | <?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'; |
| New file |
| | |
| | | <?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; |
| | | } |
| | | } |
| New file |
| | |
| | | <?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; |
| | | } |
| | | } |
| | |
| | | <?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> |
| | |
| | | <?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> |
| | |
| | | return ''; |
| | | } |
| | | |
| | | $out = apply_filters('jvbMetaTypeTemplate', $out, $type); |
| | | |
| | | return $out; |
| | | return apply_filters('jvbMetaTypeTemplate', $out, $type); |
| | | } |
| | | |
| | | public function getDefaultValue(string $type):mixed { |
| | |
| | | `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, |
| | |
| | | `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, |
| | |
| | | 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) |
| | | )" |
| | | ]; |
| | | } |
| | |
| | | |
| | | // 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); |
| | | } |
| | | |
| | | /** |
| | |
| | | $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 |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | |
| | | 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; |
| | |
| | | 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(); |
| | |
| | | $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 |
| | | )); |
| | | } |
| | | |
| | | /** |
| | |
| | | $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(), |
| | |
| | | $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 |
| | |
| | | $form = []; |
| | | foreach ($form_config['fields'] as $field_name => $config) { |
| | | // Skip file upload fields |
| | | if (in_array($config['type'], ['upload'])) { |
| | | if ($config['type'] == 'upload') { |
| | | continue; |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | // Send the email |
| | | $email_sent = JVB()->email()->sendEmail( |
| | | return JVB()->email()->sendEmail( |
| | | $to, |
| | | $subject, |
| | | $body, |
| | |
| | | $headers, |
| | | $attachments |
| | | ); |
| | | |
| | | return $email_sent; |
| | | } |
| | | |
| | | /** |
| | |
| | | $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 |
| | |
| | | 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 |
| | |
| | | |
| | | 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); |
| | |
| | | ]; |
| | | } |
| | | |
| | | 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); |
| | |
| | | $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); |
| | | } |
| | | |
| | | /** |
| | |
| | | |
| | | 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', |
| | |
| | | ] |
| | | ] |
| | | ]); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Get queue operations with optional filtering |
| | |
| | | */ |
| | | 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); |
| | | } |
| | | |
| | |
| | | */ |
| | | 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; |
| | | } |
| | |
| | | $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([ |
| | |
| | | ], 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}"); |
| | | } |
| | | |
| | |
| | | |
| | | 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); |
| | | } |
| | |
| | | { |
| | | $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 |
| | |
| | | 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; |
| | |
| | | { |
| | | $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 |