=Starting refactor of Meta and Routes to fluent-style
8 files modified
9 files added
| | |
| | | <?php return array('dependencies' => array(), 'version' => '1589cfb61e8639162b4c'); |
| | | <?php return array('dependencies' => array(), 'version' => '1ef4e221261df2cef9a9'); |
| | |
| | | (()=>{class e{constructor(){this.container=document.querySelector("section.feed-block"),this.container&&(this.a11y=window.jvbA11y,this.error=window.jvbError,this.cache=new window.jvbCache("feed"),this.templates=window.jvbTemplates,this.config={source:"",context:"",highlight:null,gallery:!1,view:this.cache.get("feedView")||"grid",...this.container.dataset},this.init())}init(){this.initElements(),this.defineTemplates(),this.initListeners(),this.initFilters(),"requestIdleCallback"in window?requestIdleCallback((()=>{this.initStore(),this.initTaxonomies(),this.processCachedFilters(),this.processURLFilters(),this.updateFilterUI(),this.initGallery()}),{timeout:2e3}):setTimeout((()=>{this.initStore(),this.initTaxonomies(),this.processCachedFilters(),this.processURLFilters(),this.updateFilterUI(),this.initGallery()}),100)}initElements(){this.selectors={filterTrigger:"[data-filter]",filters:{actions:".filter-actions .toggle-text",container:".filters",content:'[data-filter="content"]',orderby:'[data-filter="orderby"]',order:'[data-filter="order"]',match:'[data-filter="match"]',favourites:'[data-filter="favourites"]',taxonomy:'[data-filter^="taxonomy"]'},grid:".item-grid",selected:".selected-items",buttons:{loadMore:"button.load-more",remove:".remove-term",clearFilters:"button.clear-filters",refresh:'button[data-action="refresh"]'}},this.ui=window.uiFromSelectors(this.selectors,this.container),this.ui.buttons.refresh=document.querySelector(this.selectors.buttons.refresh),this.ui.content=this.ui.filters.container.querySelectorAll('[name="content"]'),0===this.ui.content.length&&(this.ui.content=!1),this.ui.taxonomies=this.ui.filters.container.querySelectorAll("[data-taxonomy]"),0===this.ui.taxonomies.length&&(this.ui.taxonomies=!1),this.ui.orderbyWrap=this.ui.filters.container.querySelector("[data-for-order]"),0===this.ui.orderbyWrap.length&&(this.ui.orderbyWrap=!1),this.ui.order=this.ui.filters.container.querySelectorAll('[data-filter="order"]'),0===this.ui.order.length&&(this.ui.order=!1),this.ui.orderby=this.ui.filters.container.querySelectorAll('[data-filter="orderby"]'),0===this.ui.orderby.length&&(this.ui.orderby=!1),this.orderbyFilters=this.ui.orderby?Array.from(this.ui.orderby).map((e=>e.value)):[],this.contentTypes=this.ui.content?Array.from(this.ui.content).map((e=>e.value)):[this.container.dataset.content],this.taxonomies=this.ui.taxonomies?.length>0?Array.from(this.ui.taxonomies).map((e=>e.dataset.taxonomy)):[]}initListeners(){this.popStateHandler=this.handlePopState.bind(this),this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),window.addEventListener("popstate",this.popStateHandler),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler)}initFilters(){this.allowedFilters=["content","order","orderby","favourites","match"];let e={content:this.contentTypes[0],orderby:"date",order:"desc",page:1};this.config.context&&(e.context=this.config.context),this.config.source&&(e.source=this.config.source),this.filters=e,this.defaults={...e}}updateFilterUI(){if(this.ui.filters.container&&([this.ui.content,this.ui.orderby,this.ui.order].forEach((e=>{if(e)for(let t of e){let[e,i]=[t.dataset.filter,t.value];if(!Object.hasOwn(this.store.filters,e))break;let s=this.store.filters[e]===i;if(s){t.checked=s;break}}})),Object.hasOwn(this.store.filters,"taxonomy")))for(let[e,t]of Object.entries(this.store.filters.taxonomy))t.forEach((e=>{e=parseInt(e),this.selector.store.get(e)&&this.createTermElement(e)}))}handlePopState(e){e.state?.filters&&this.processURLFilters()&&(this.store.setFilters(this.filters),this.a11y.announce("Feed filters updated from browser history"))}handleClick(e){window.targetCheck(e,this.selectors.buttons.loadMore)?this.nextPage():window.targetCheck(e,this.selectors.buttons.clearFilters)&&this.clearFilters();let t=window.targetCheck(e,this.selectors.buttons.remove);t&&this.removeSelectedTerm(t),window.targetCheck(e,this.selectors.buttons.refresh)&&(this.store.clearCache(),this.store.fetch());let i=window.targetCheck(e,'[data-filter="orderby"]');i&&"random"===i.value&&i.checked&&this.renderItems()}nextPage(){const e=(this.store.filters.page||1)+1,t=this.store.lastResponse?.pages||e;this.store.setFilters({page:Math.min(e,t)})}handleChange(e){const t=e.target;if(Object.hasOwn(t.dataset,"filter")){if(this.allowedFilters.includes(t.dataset.filter)){let e={};e[t.dataset.filter]=t.value,this.resetFilters(e)}switch(t.dataset.filter){case"content":this.updateContentFor(t.value);break;case"orderby":this.updateOrderOptions(t.value)}}}clearFilters(){this.taxFilters={},window.removeChildren(this.ui.selected),this.taxonomies.forEach((e=>{let t=this.getFieldId(e);this.selector.selectedTerms.get(t)?.clear()})),this.store.setFilters({...this.defaults,taxonomy:null}),this.updateURL(),this.saveToCacheFilters()}resetFilters(e){e={...this.store.filters,page:1,...e},this.store.setFilters(e),this.updateURL(),this.saveToCacheFilters()}getFieldId(e){var t;return this.selector.getFieldId(null!==(t=Array.from(this.ui.taxonomies).filter((t=>t.dataset.taxonomy===e))[0])&&void 0!==t?t:null)}removeSelectedTerm(e){const t=parseInt(e.dataset.id),i=e.dataset.taxonomy;Object.hasOwn(this.taxFilters,i)&&(this.taxFilters[i]=this.taxFilters[i].filter((e=>e!==t)),0===this.taxFilters[i].length&&delete this.taxFilters[i]),e.remove();const s=this.getFieldId(i);s&&(this.selector.activeField=s,this.selector.removeSelected(t,s)),this.resetFilters({taxonomy:Object.keys(this.taxFilters).length>0?this.taxFilters:null})}updateContentFor(e){[this.ui.taxonomies,this.ui.orderby].forEach((t=>{t&&t.forEach((t=>{var i;const s=null!==(i=t.dataset.for?.split(","))&&void 0!==i?i:[];t.hidden=s.length>0&&!s.includes(e),t.hidden&&t.checked&&(t.checked=!1)}))}))}updateOrderOptions(e){if(this.ui.orderbyWrap){var t;let i=null!==(t=this.ui.orderbyWrap.dataset.forOrder.split(","))&&void 0!==t?t:[];this.ui.orderbyWrap.hidden=!i.includes(e)}}updateFilterControls(){const e=0===Object.keys(this.taxFilters).length;this.ui.buttons.clearFilters&&(this.ui.buttons.clearFilters.hidden=e),this.ui.filters.actions&&(this.ui.filters.actions.hidden=e)}async initTaxonomies(){this.taxFilters={},this.selector=window.jvbSelector,this.selector.subscribe(((e,t)=>{"selected-terms"===e&&this.handleTaxonomyChange(t)}))}handleTaxonomyChange(e){const{terms:t,taxonomy:i}=e;0!==t.size&&(this.taxFilters[i]=Array.from(t),this.resetFilters({taxonomy:this.taxFilters}),t.forEach((e=>{this.createTermElement(e)})),this.updateFilterControls())}getTaxonomyIcon(e){let t=Array.from(this.ui.taxonomies).find((t=>t.dataset.taxonomy===e));return t?.dataset.icon.trim()||"tag"}createTermElement(e){const t=this.selector.store.get(e);t&&(this.ui.selected.querySelector(`[data-id="${e}"]`)||(t.icon=this.getTaxonomyIcon(t.taxonomy),this.ui.selected.append(this.templates.create("feedTerm",t))))}processCachedFilters(){Object.keys(this.filters).forEach((e=>{let t=this.cache.get(`${this.config.source}_${this.config.context}_${e}`);t&&t!==this.filters[e]&&(this.filters[e]=t)}))}processURLFilters(){if(!this.isFirstPage())return!1;const e=new URLSearchParams(window.location.search);if(!e.toString())return!1;let t=!1;this.allowedFilters.forEach((i=>{let s=e.get(`f_${i}`);s&&(t=!0,this.filters[i]=s)}));let i=!1;return e.forEach(((e,s)=>{if(s.startsWith("f_tax_")){i=!0,t=!0;const r=s.replace("f_tax_","");this.taxFilters[r]=e.split(",").map(Number)}})),t&&(i&&(this.filters.taxonomy=this.taxFilters),this.resetFilters(this.filters)),!0}updateURL(){const e=new URLSearchParams;this.allowedFilters.forEach((t=>{Object.hasOwn(this.store.filters,t)&&this.store.filters[t]!==this.defaults[t]&&e.set(`f_${t}`,this.store.filters[t])}));for(let[t,i]of Object.entries(this.taxFilters))i.length>0&&e.set(`f_tax_${t}`,i.join(","));const t=`${window.location.pathname}${e.toString()?"?"+e.toString():""}`;t!==window.location.pathname+window.location.search&&window.history.pushState({filters:this.store.filters},"",t)}saveToCacheFilters(){Object.keys(this.store.filters).forEach((e=>{const t=`${this.config.source}_${this.config.context}_${e}`;this.store.filters[e]!==this.defaults[e]?this.cache.set(t,this.store.filters[e]):this.cache.remove(t)}));const e=`${this.config.source}_${this.config.context}_taxonomy`;Object.keys(this.taxFilters).length>0?this.cache.set(e,this.taxFilters):this.cache.remove(e)}initGallery(){this.gallery=!!this.config.gallery&&window.jvbGallery,this.gallery&&this.gallery.subscribe(((e,t)=>{"load-more"===e&&this.store.lastResponse?.has_more&&this.nextPage()}))}initStore(){let e=this.orderbyFilters.filter((e=>!["date","modified","title","random"].includes(e))),t=[];e.forEach((e=>{t.push({name:e,keyPath:e})}));const i=window.jvbStore.register("feed",{storeName:"feed",endpoint:"feed",keyPath:"id",indexes:[{name:"content",keyPath:"content"},{name:"taxonomy",keyPath:"taxonomy"},{name:"user",keyPath:"user"},{name:"date",keyPath:"date"},{name:"modified",keyPath:"modified"},{name:"title",keyPath:"title"},...t],filters:this.filters,TTL:216e5,showLoading:!0,required:"content"});this.store=i.feed,this.store.subscribe(((e,t)=>{var i;"data-loaded"===e&&(this.renderItems(t.items),this.ui.buttons.loadMore.hidden=!0,this.store.lastResponse&&this.store.lastResponse?.has_more&&(this.ui.buttons.loadMore.hidden=null===(i=!this.store.lastResponse?.has_more)||void 0===i||i))}))}isFirstPage(){return 1===this.store.filters.page}renderItems(e=null){e=null!=e?e:this.store.getFiltered(),this.isFirstPage()&&window.removeChildren(this.ui.grid),0===e.length?(this.showEmptyState(),this.a11y.announceItems(0,this.isFirstPage())):window.chunkIt(e,(e=>this.createItemElement(e)),(t=>{var i;this.removePlaceholders(),this.ui.grid.append(t),this.config.gallery&&this.gallery.buildGalleryItems(".item img"),this.a11y.makeNavigable(this.ui.grid.querySelectorAll(".item:not([data-keyboard-nav])")),this.a11y.announceItems(e.length,!this.isFirstPage(),null!==(i=this.store.lastResponse?.has_more)&&void 0!==i&&i)}),5).then((()=>{})),this.updateFilterControls()}showEmptyState(){window.removeChildren(this.ui.grid),this.ui.grid.append(this.templates.create("emptyState"))}createItemElement(e){if("object"==typeof e||(e=this.store.get(e)))return this.templates.create(`feedItem${window.uppercaseFirst(e.content)}`,e)}splitIDs(e){return String(e).split(",").map((e=>parseInt(e.trim()))).filter((e=>e))}isImageField(e,t){return!(!Object.hasOwn(e,"images")||0===Object.keys(e.images).length)&&this.splitIDs(t).some((t=>Object.keys(e.images).map((e=>parseInt(e))).includes(parseInt(t))))}formatImageFields(e,t,i){let s=this.splitIDs(t);if(0!==s.length)if(s.length>1){let t=e.querySelector("img");if(!t)return;s.forEach((s=>{let r=t.cloneNode(!0);this.formatImageField(r,s,i),e.append(r)})),t.remove()}else{if("IMG"!==e.tagName&&!(e=e.querySelector("img")))return;this.formatImageField(e,s[0],i)}}formatImageField(e,t,i){var s;let r=null!==(s=i.images[t])&&void 0!==s&&s;r&&([e.src,e.srcset,e.alt]=[r.tiny,`${r.tiny} 50w, ${r.small} 300w, ${r.medium} 1024w`,r["image-alt-text"]])}isTaxonomyField(e,t){return!(!Object.hasOwn(e,"taxonomies")||0===Object.keys(e.taxonomies).length)&&Object.keys(e.taxonomies).includes(t)}formatTaxonomyField(e,t,i,s){if("UL"!==e.tagName||!e.querySelector("li"))return;let r=this.splitIDs(s);0===r.length&&e.remove();let o=e.querySelector("li");for(let s of r){var a;let r=null!==(a=t.taxonomies[i][s])&&void 0!==a&&a;if(!r)continue;let n=o.cloneNode(!0),l=n.querySelector("a");l&&([l.href,l.title,l.textContent]=[r.url,`See more ${r.title}`,r.title],e.append(n))}o.remove()}isTimeField(e){return"TIME"===e.tagName||null!==e.querySelector("time")}formatTimeField(e,t){("TIME"===e.tagName||(e=e.querySelector("time")))&&(e.setAttribute("datetime",t),e.textContent=window.formatTimeAgo(t,"F Y"))}formatField(e,t){e.textContent=t}addTimelineElements(e,t){let[i,s,r,o]=[t.querySelector("span.after-text"),t.querySelector('[data-field="number"] b'),t.querySelector('[data-field="started"] time'),t.querySelector('[data-field="updated"] time')];i&&(i.textContent=`After ${e.number} Tx`),s&&(s.textContent=e.fields.number),r&&this.formatTimeField(r,e.fields.timeline[0].post_date),o&&this.formatTimeField(o,e.fields.timeline[e.fields.timeline.length-1].post_date)}removePlaceholders(){const e=this.ui.grid.querySelectorAll(".placeholder");e.length>0&&e.forEach((e=>e.remove()))}defineTemplates(){const e=this.templates,t=this;e.define("feedTerm",{refs:{icon:".icon",span:"span"},setup({el:e,refs:t,manyRefs:i,data:s}){e.dataset.id=s.id,e.dataset.taxonomy=s.taxonomy,t.icon&&(t.icon.className=`icon icon=${s.icon}`),t.span&&(t.span.textContent=s.name)}}),e.define("emptyState"),this.contentTypes.forEach((i=>{e.define(`feedItem${window.uppercaseFirst(i)}`,{refs:{link:"a"},manyRefs:{fields:"[data-field]"},setup({el:e,refs:i,manyRefs:s,data:r}){const o=Object.hasOwn(e.dataset,"timeline");if(s.fields){for(let e of s.fields){if(o&&["timeline","number"].includes(e.dataset.field))continue;const i=!!Object.hasOwn(r.fields,e.dataset.field)&&r.fields[e.dataset.field];i?t.isImageField(r,i)?t.formatImageField(e,i,r):t.isTaxonomyField(r,e.dataset.field)?t.formatTaxonomyField(e,r,e.dataset.field,i):t.isTimeField(e)?t.formatTimeField(e,i):t.formatField(e,i):e.remove()}var a;i.link&&""!==r.url&&(i.link.href=r.url,i.link.title=`View ${null!==(a=r.fields.post_title)&&void 0!==a?a:"Item"}`),o&&t.addTimelineElements(r,e)}}})}))}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.feedBlock=new e)}))}))})(); |
| | | (()=>{class e{constructor(){this.container=document.querySelector("section.feed-block"),this.container&&(this.a11y=window.jvbA11y,this.error=window.jvbError,this.cache=new window.jvbCache("feed"),this.templates=window.jvbTemplates,this.config={source:"",context:"",highlight:null,gallery:!1,view:this.cache.get("feedView")||"grid",...this.container.dataset},this.init())}init(){this.initElements(),this.defineTemplates(),this.initListeners(),this.initFilters(),"requestIdleCallback"in window?requestIdleCallback((()=>{this.initStore(),this.initTaxonomies(),this.processCachedFilters(),this.processURLFilters(),this.updateFilterUI(),this.initGallery()}),{timeout:2e3}):setTimeout((()=>{this.initStore(),this.initTaxonomies(),this.processCachedFilters(),this.processURLFilters(),this.updateFilterUI(),this.initGallery()}),100)}initElements(){this.selectors={filterTrigger:"[data-filter]",filters:{actions:".filter-actions .toggle-text",container:".filters",content:'[data-filter="content"]',orderby:'[data-filter="orderby"]',order:'[data-filter="order"]',match:'[data-filter="match"]',favourites:'[data-filter="favourites"]',taxonomy:'[data-filter^="taxonomy"]'},grid:".item-grid",selected:".selected-items",buttons:{loadMore:"button.load-more",remove:".remove-term",clearFilters:"button.clear-filters",refresh:'button[data-action="refresh"]'}},this.ui=window.uiFromSelectors(this.selectors,this.container),this.ui.buttons.refresh=document.querySelector(this.selectors.buttons.refresh),this.ui.content=this.ui.filters.container.querySelectorAll('[name="content"]'),0===this.ui.content.length&&(this.ui.content=!1),this.ui.taxonomies=this.ui.filters.container.querySelectorAll("[data-taxonomy]"),0===this.ui.taxonomies.length&&(this.ui.taxonomies=!1),this.ui.orderbyWrap=this.ui.filters.container.querySelector("[data-for-order]"),0===this.ui.orderbyWrap.length&&(this.ui.orderbyWrap=!1),this.ui.order=this.ui.filters.container.querySelectorAll('[data-filter="order"]'),0===this.ui.order.length&&(this.ui.order=!1),this.ui.orderby=this.ui.filters.container.querySelectorAll('[data-filter="orderby"]'),0===this.ui.orderby.length&&(this.ui.orderby=!1),this.orderbyFilters=this.ui.orderby?Array.from(this.ui.orderby).map((e=>e.value)):[],this.contentTypes=this.ui.content?Array.from(this.ui.content).map((e=>e.value)):[this.container.dataset.content],this.taxonomies=this.ui.taxonomies?.length>0?Array.from(this.ui.taxonomies).map((e=>e.dataset.taxonomy)):[]}initListeners(){this.popStateHandler=this.handlePopState.bind(this),this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),window.addEventListener("popstate",this.popStateHandler),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler)}initFilters(){this.allowedFilters=["content","order","orderby","favourites","match"];let e={content:this.contentTypes[0],orderby:"date",order:"desc",page:1};this.config.context&&(e.context=this.config.context),this.config.source&&(e.source=this.config.source),this.filters=e,this.defaults={...e}}updateFilterUI(){if(this.ui.filters.container&&([this.ui.content,this.ui.orderby,this.ui.order].forEach((e=>{if(e)for(let t of e){let[e,i]=[t.dataset.filter,t.value];if(!Object.hasOwn(this.store.filters,e))break;let s=this.store.filters[e]===i;if(s){t.checked=s;break}}})),Object.hasOwn(this.store.filters,"taxonomy")))for(let[e,t]of Object.entries(this.store.filters.taxonomy))t.forEach((e=>{e=parseInt(e),this.selector.store.get(e)&&this.createTermElement(e)}))}handlePopState(e){e.state?.filters&&this.processURLFilters()&&(this.store.setFilters(this.filters),this.a11y.announce("Feed filters updated from browser history"))}handleClick(e){window.targetCheck(e,this.selectors.buttons.loadMore)?this.nextPage():window.targetCheck(e,this.selectors.buttons.clearFilters)&&this.clearFilters();let t=window.targetCheck(e,this.selectors.buttons.remove);t&&this.removeSelectedTerm(t),window.targetCheck(e,this.selectors.buttons.refresh)&&(this.store.clearCache(),this.store.fetch());let i=window.targetCheck(e,'[data-filter="orderby"]');i&&"random"===i.value&&i.checked&&this.renderItems()}nextPage(){const e=(this.store.filters.page||1)+1,t=this.store.lastResponse?.pages||e;this.store.setFilters({page:Math.min(e,t)})}handleChange(e){const t=e.target;if(Object.hasOwn(t.dataset,"filter")){if(this.allowedFilters.includes(t.dataset.filter)){let e={};e[t.dataset.filter]=t.value,this.resetFilters(e)}switch(t.dataset.filter){case"content":this.updateContentFor(t.value);break;case"orderby":this.updateOrderOptions(t.value)}}}clearFilters(){this.taxFilters={},window.removeChildren(this.ui.selected),this.taxonomies.forEach((e=>{let t=this.getFieldId(e);this.selector.selectedTerms.get(t)?.clear()})),this.store.setFilters({...this.defaults,taxonomy:null}),this.updateURL(),this.saveToCacheFilters()}resetFilters(e){e={...this.store.filters,page:1,...e},this.store.setFilters(e),this.updateURL(),this.saveToCacheFilters()}getFieldId(e){var t;return this.selector.getFieldId(null!==(t=Array.from(this.ui.taxonomies).filter((t=>t.dataset.taxonomy===e))[0])&&void 0!==t?t:null)}removeSelectedTerm(e){const t=parseInt(e.dataset.id),i=e.dataset.taxonomy;Object.hasOwn(this.taxFilters,i)&&(this.taxFilters[i]=this.taxFilters[i].filter((e=>e!==t)),0===this.taxFilters[i].length&&delete this.taxFilters[i]),e.remove();const s=this.getFieldId(i);s&&(this.selector.activeField=s,this.selector.removeSelected(t,s)),this.resetFilters({taxonomy:Object.keys(this.taxFilters).length>0?this.taxFilters:null})}updateContentFor(e){[this.ui.taxonomies,this.ui.orderby].forEach((t=>{t&&t.forEach((t=>{var i;const s=null!==(i=t.dataset.for?.split(","))&&void 0!==i?i:[];t.hidden=s.length>0&&!s.includes(e),t.hidden&&t.checked&&(t.checked=!1)}))}))}updateOrderOptions(e){if(this.ui.orderbyWrap){var t;let i=null!==(t=this.ui.orderbyWrap.dataset.forOrder.split(","))&&void 0!==t?t:[];this.ui.orderbyWrap.hidden=!i.includes(e)}}updateFilterControls(){const e=0===Object.keys(this.taxFilters).length;this.ui.buttons.clearFilters&&(this.ui.buttons.clearFilters.hidden=e),this.ui.filters.actions&&(this.ui.filters.actions.hidden=e)}async initTaxonomies(){this.taxFilters={},this.selector=window.jvbSelector,this.selector.subscribe(((e,t)=>{"selected-terms"===e&&this.handleTaxonomyChange(t)}))}handleTaxonomyChange(e){const{terms:t,taxonomy:i}=e;0!==t.size&&(this.taxFilters[i]=Array.from(t),this.resetFilters({taxonomy:this.taxFilters}),t.forEach((e=>{this.createTermElement(e)})),this.updateFilterControls())}getTaxonomyIcon(e){let t=Array.from(this.ui.taxonomies).find((t=>t.dataset.taxonomy===e));return t?.dataset.icon.trim()||"tag"}createTermElement(e){const t=this.selector.store.get(e);t&&(this.ui.selected.querySelector(`[data-id="${e}"]`)||(t.icon=this.getTaxonomyIcon(t.taxonomy),this.ui.selected.append(this.templates.create("feedTerm",t))))}processCachedFilters(){Object.keys(this.filters).forEach((e=>{let t=this.cache.get(`${this.config.source}_${this.config.context}_${e}`);t&&t!==this.filters[e]&&(this.filters[e]=t)}))}processURLFilters(){if(!this.isFirstPage())return!1;const e=new URLSearchParams(window.location.search);if(!e.toString())return!1;let t=!1;this.allowedFilters.forEach((i=>{let s=e.get(`f_${i}`);s&&(t=!0,this.filters[i]=s)}));let i=!1;return e.forEach(((e,s)=>{if(s.startsWith("f_tax_")){i=!0,t=!0;const r=s.replace("f_tax_","");this.taxFilters[r]=e.split(",").map(Number)}})),t&&(i&&(this.filters.taxonomy=this.taxFilters),this.resetFilters(this.filters)),!0}updateURL(){const e=new URLSearchParams;this.allowedFilters.forEach((t=>{Object.hasOwn(this.store.filters,t)&&this.store.filters[t]!==this.defaults[t]&&e.set(`f_${t}`,this.store.filters[t])}));for(let[t,i]of Object.entries(this.taxFilters))i.length>0&&e.set(`f_tax_${t}`,i.join(","));const t=`${window.location.pathname}${e.toString()?"?"+e.toString():""}`;t!==window.location.pathname+window.location.search&&window.history.pushState({filters:this.store.filters},"",t)}saveToCacheFilters(){Object.keys(this.store.filters).forEach((e=>{const t=`${this.config.source}_${this.config.context}_${e}`;this.store.filters[e]!==this.defaults[e]?this.cache.set(t,this.store.filters[e]):this.cache.remove(t)}));const e=`${this.config.source}_${this.config.context}_taxonomy`;Object.keys(this.taxFilters).length>0?this.cache.set(e,this.taxFilters):this.cache.remove(e)}initGallery(){this.gallery=!!this.config.gallery&&window.jvbGallery,this.gallery&&this.gallery.subscribe(((e,t)=>{"load-more"===e&&this.store.lastResponse?.has_more&&this.nextPage()}))}initStore(){let e=this.orderbyFilters.filter((e=>!["date","modified","title","random"].includes(e))),t=[];e.forEach((e=>{t.push({name:e,keyPath:e})}));const i=window.jvbStore.register("feed",{storeName:"feed",endpoint:"feed",keyPath:"id",indexes:[{name:"content",keyPath:"content"},{name:"taxonomy",keyPath:"taxonomy"},{name:"user",keyPath:"user"},{name:"date",keyPath:"date"},{name:"modified",keyPath:"modified"},{name:"title",keyPath:"title"},...t],filters:this.filters,TTL:216e5,showLoading:!0,required:"content"});this.store=i.feed,this.store.subscribe(((e,t)=>{var i;"data-loaded"===e&&(this.renderItems(t.items),this.ui.buttons.loadMore.hidden=!0,this.store.lastResponse&&this.store.lastResponse?.has_more&&(this.ui.buttons.loadMore.hidden=null===(i=!this.store.lastResponse?.has_more)||void 0===i||i))}))}isFirstPage(){return 1===this.store.filters.page}renderItems(e=null){e=null!=e?e:this.store.getFiltered(),this.isFirstPage()&&window.removeChildren(this.ui.grid),0===e.length?(this.showEmptyState(),this.a11y.announceItems(0,this.isFirstPage())):window.chunkIt(e,(e=>this.createItemElement(e)),(t=>{var i;this.removePlaceholders(),this.ui.grid.append(t),this.config.gallery&&this.gallery.buildGalleryItems(".item img"),this.a11y.makeNavigable(this.ui.grid.querySelectorAll(".item:not([data-keyboard-nav])")),this.a11y.announceItems(e.length,!this.isFirstPage(),null!==(i=this.store.lastResponse?.has_more)&&void 0!==i&&i)}),5).then((()=>{})),this.updateFilterControls()}showEmptyState(){window.removeChildren(this.ui.grid),this.ui.grid.append(this.templates.create("emptyState"))}createItemElement(e){if("object"==typeof e||(e=this.store.get(e)))return this.templates.create(`feedItem${window.uppercaseFirst(e.content)}`,e)}splitIDs(e){return String(e).split(",").map((e=>parseInt(e.trim()))).filter((e=>e))}isImageField(e,t){return!(!Object.hasOwn(e,"images")||0===Object.keys(e.images).length)&&this.splitIDs(t).some((t=>Object.keys(e.images).map((e=>parseInt(e))).includes(parseInt(t))))}formatImageFields(e,t,i){let s=this.splitIDs(t);if(0!==s.length)if(s.length>1){let t=e.querySelector("img");if(!t)return;s.forEach((s=>{let r=t.cloneNode(!0);this.formatImageField(r,s,i),e.append(r)})),t.remove()}else{if("IMG"!==e.tagName&&!(e=e.querySelector("img")))return;this.formatImageField(e,s[0],i)}}formatImageField(e,t,i){var s;let r=null!==(s=i.images[t])&&void 0!==s&&s;r&&([e.src,e.srcset,e.alt]=[r.tiny,`${r.tiny} 50w, ${r.small} 300w, ${r.medium} 1024w`,r["image-alt-text"]])}isTaxonomyField(e,t){return!(!Object.hasOwn(e,"taxonomies")||0===Object.keys(e.taxonomies).length)&&Object.keys(e.taxonomies).includes(t)}formatTaxonomyField(e,t,i,s){if("UL"!==e.tagName||!e.querySelector("li"))return;let r=this.splitIDs(s);0===r.length&&e.remove();let o=e.querySelector("li");for(let s of r){var a;let r=null!==(a=t.taxonomies[i][s])&&void 0!==a&&a;if(!r)continue;let n=o.cloneNode(!0),l=n.querySelector("a");l&&([l.href,l.title,l.textContent]=[r.url,`See more ${r.title}`,r.title],e.append(n))}o.remove()}isTimeField(e){return"TIME"===e.tagName||null!==e.querySelector("time")}formatTimeField(e,t){("TIME"===e.tagName||(e=e.querySelector("time")))&&(e.setAttribute("datetime",t),e.textContent=window.formatTimeAgo(t,"F Y"))}formatField(e,t){e.textContent=t}addTimelineElements(e,t){let[i,s,r,o]=[t.querySelector("span.after-text"),t.querySelector('[data-field="number"] b'),t.querySelector('[data-field="started"] time'),t.querySelector('[data-field="updated"] time')];i&&(i.textContent=`After ${e.number-1} Tx`),s&&(s.textContent=e.number-1),r&&this.formatTimeField(r,e.fields.timeline[0].post_date),o&&this.formatTimeField(o,e.fields.timeline[e.fields.timeline.length-1].post_date)}removePlaceholders(){const e=this.ui.grid.querySelectorAll(".placeholder");e.length>0&&e.forEach((e=>e.remove()))}defineTemplates(){const e=this.templates,t=this;e.define("feedTerm",{refs:{icon:".icon",span:"span"},setup({el:e,refs:t,manyRefs:i,data:s}){e.dataset.id=s.id,e.dataset.taxonomy=s.taxonomy,t.icon&&(t.icon.className=`icon icon=${s.icon}`),t.span&&(t.span.textContent=s.name)}}),e.define("emptyState"),this.contentTypes.forEach((i=>{e.define(`feedItem${window.uppercaseFirst(i)}`,{refs:{link:"a"},manyRefs:{fields:"[data-field]"},setup({el:e,refs:i,manyRefs:s,data:r}){const o=Object.hasOwn(e.dataset,"timeline");if(s.fields){for(let e of s.fields){if(o&&["timeline","number"].includes(e.dataset.field))continue;const i=!!Object.hasOwn(r.fields,e.dataset.field)&&r.fields[e.dataset.field];i?t.isImageField(r,i)?t.formatImageField(e,i,r):t.isTaxonomyField(r,e.dataset.field)?t.formatTaxonomyField(e,r,e.dataset.field,i):t.isTimeField(e)?t.formatTimeField(e,i):t.formatField(e,i):e.remove()}var a;i.link&&""!==r.url&&(i.link.href=r.url,i.link.title=`View ${null!==(a=r.fields.post_title)&&void 0!==a?a:"Item"}`),o&&t.addTimelineElements(r,e)}}})}))}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.feedBlock=new e)}))}))})(); |
| | |
| | | /** |
| | | * Executor for upload-related queue operations. |
| | | * Handles: image_upload, video_upload, document_upload, |
| | | * update_metadata, temporary_cleanup, attach_upload_to_content, process_upload_groups |
| | | * update_image_meta, temporary_cleanup, attach_upload_to_content, process_upload_groups |
| | | */ |
| | | final class UploadExecutor implements Executor |
| | | { |
| | |
| | | 'image_upload', |
| | | 'video_upload', |
| | | 'document_upload', |
| | | 'update_metadata', |
| | | 'update_image_meta', |
| | | 'temporary_cleanup', |
| | | 'attach_upload_to_content', |
| | | 'process_upload_groups' |
| | |
| | | '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), |
| | | 'update_image_meta' => $this->processMetaUpdate($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), |
| | |
| | | /** |
| | | * Process metadata updates for attachments |
| | | */ |
| | | private function processMetadataUpdate(Operation $operation, array $data, Progress $progress): Result |
| | | private function processMetaUpdate(Operation $operation, array $data, Progress $progress): Result |
| | | { |
| | | $updatedCount = 0; |
| | | $errors = []; |
| | | |
| | | foreach ($data as $uploadId => $info) { |
| | | if (!is_array($info) || empty($info['depends_on'])) { |
| | | if (!is_array($info)) { |
| | | 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"; |
| | | if (array_key_exists('depends_on', $info)) { |
| | | // 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; |
| | | } |
| | | } else { |
| | | $attachmentId = $info['attachmentId']??false; |
| | | } |
| | | |
| | | if (!$attachmentId) { |
| | | $errors[] = "No attachment found for: ".print_r($info, true); |
| | | continue; |
| | | } |
| | | |
| | | $attachmentId = $this->findAttachmentByUploadId($uploadId, $depOp->result); |
| | | if (!$attachmentId) { |
| | | $errors[] = "No attachment found for upload ID: {$uploadId}"; |
| | | continue; |
| | | } |
| | | |
| | | |
| | | $this->applyMeta($attachmentId, $info); |
| | | $updatedCount++; |
| | |
| | | |
| | | private function applyMeta(int $attachmentId, array $metadata): void |
| | | { |
| | | if (!empty($metadata['title'])) { |
| | | if (!empty($metadata['image-title'])) { |
| | | wp_update_post([ |
| | | 'ID' => $attachmentId, |
| | | 'post_title' => sanitize_text_field($metadata['title']), |
| | | 'post_title' => sanitize_text_field($metadata['image-title']), |
| | | ]); |
| | | } |
| | | |
| | | if (!empty($metadata['alt'])) { |
| | | update_post_meta($attachmentId, '_wp_attachment_image_alt', sanitize_text_field($metadata['alt'])); |
| | | if (!empty($metadata['image-alt-text'])) { |
| | | update_post_meta($attachmentId, '_wp_attachment_image_alt', sanitize_text_field($metadata['image-alt-text'])); |
| | | } |
| | | |
| | | if (!empty($metadata['caption'])) { |
| | | if (!empty($metadata['image-caption'])) { |
| | | wp_update_post([ |
| | | 'ID' => $attachmentId, |
| | | 'post_excerpt' => sanitize_textarea_field($metadata['caption']), |
| | | 'post_excerpt' => sanitize_textarea_field($metadata['image-caption']), |
| | | ]); |
| | | } |
| | | } |
| New file |
| | |
| | | <?php |
| | | namespace JVBase\meta; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | /** |
| | | * Single field data container |
| | | * Holds value, config, and tracks dirty state |
| | | */ |
| | | final class Field |
| | | { |
| | | public string $name; |
| | | public mixed $value; |
| | | public mixed $originalValue; |
| | | public array $config; |
| | | public bool $isDirty = false; |
| | | public bool $isValid = true; |
| | | public array $errors = []; |
| | | |
| | | public function __construct(string $name, mixed $value, array $config = []) |
| | | { |
| | | $this->name = $name; |
| | | $this->value = $value; |
| | | $this->originalValue = $value; |
| | | $this->config = $config; |
| | | } |
| | | |
| | | public function set(mixed $value): self |
| | | { |
| | | $this->value = $value; |
| | | $this->isDirty = ($value !== $this->originalValue); |
| | | return $this; |
| | | } |
| | | |
| | | public function get(): mixed |
| | | { |
| | | return $this->value; |
| | | } |
| | | |
| | | public function markClean(): self |
| | | { |
| | | $this->originalValue = $this->value; |
| | | $this->isDirty = false; |
| | | return $this; |
| | | } |
| | | |
| | | public function reset(): self |
| | | { |
| | | $this->value = $this->originalValue; |
| | | $this->isDirty = false; |
| | | return $this; |
| | | } |
| | | |
| | | public function addError(string $message): self |
| | | { |
| | | $this->errors[] = $message; |
| | | $this->isValid = false; |
| | | return $this; |
| | | } |
| | | |
| | | public function clearErrors(): self |
| | | { |
| | | $this->errors = []; |
| | | $this->isValid = true; |
| | | return $this; |
| | | } |
| | | |
| | | public function type(): string |
| | | { |
| | | return $this->config['type'] ?? 'text'; |
| | | } |
| | | |
| | | public function isWpDefault(): bool |
| | | { |
| | | return $this->config['_wp_default'] ?? false; |
| | | } |
| | | |
| | | public function isTaxonomy(): bool |
| | | { |
| | | return $this->type() === 'taxonomy' && !isset($this->config['taxonomy_type']); |
| | | } |
| | | } |
| New file |
| | |
| | | <?php |
| | | namespace JVBase\meta; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | /** |
| | | * Data container for a single WordPress object (post, term, user, or options) |
| | | * Holds the WP object reference and a collection of Field instances |
| | | */ |
| | | final class Item |
| | | { |
| | | public int|string|null $id; |
| | | public string $objectType; // post, term, user, options |
| | | public ?string $contentType; // tattoo, artist, style (without BASE prefix) |
| | | public ?object $wpObject; // WP_Post, WP_Term, WP_User |
| | | |
| | | /** @var array<string, Field> */ |
| | | public array $fields = []; |
| | | |
| | | /** @var array<string, array> Raw field configs from registry */ |
| | | public array $fieldConfigs = []; |
| | | |
| | | public ?string $baseKey = null; // For options |
| | | |
| | | public const WP_DEFAULTS = [ |
| | | 'post' => [ |
| | | 'post_title', |
| | | 'post_excerpt', |
| | | 'post_content', |
| | | 'post_date', |
| | | 'post_status', |
| | | 'post_modified', |
| | | 'post_thumbnail', |
| | | 'menu_order' |
| | | ], |
| | | 'user' => [ |
| | | 'first_name', |
| | | 'last_name', |
| | | 'display_name', |
| | | 'description', |
| | | 'user_email', |
| | | ], |
| | | 'term' => [ |
| | | 'term_name', |
| | | 'description' |
| | | ] |
| | | ]; |
| | | |
| | | public function __construct( |
| | | int|string|null $id, |
| | | string $objectType, |
| | | ?string $contentType = null |
| | | ) { |
| | | $this->id = $id; |
| | | $this->objectType = $objectType; |
| | | $this->contentType = $contentType; |
| | | } |
| | | |
| | | public function hasField(string $name): bool |
| | | { |
| | | return isset($this->fields[$name]) || isset($this->fieldConfigs[$name]); |
| | | } |
| | | |
| | | public function getField(string $name): ?Field |
| | | { |
| | | return $this->fields[$name] ?? null; |
| | | } |
| | | |
| | | public function setField(Field $field): self |
| | | { |
| | | $this->fields[$field->name] = $field; |
| | | return $this; |
| | | } |
| | | |
| | | public function getFieldConfig(string $name): ?array |
| | | { |
| | | return $this->fieldConfigs[$name] ?? null; |
| | | } |
| | | |
| | | public function isWpDefault(string $name): bool |
| | | { |
| | | $defaults = self::WP_DEFAULTS[$this->objectType] ?? []; |
| | | return in_array($name, $defaults, true); |
| | | } |
| | | |
| | | public function getDirtyFields(): array |
| | | { |
| | | return array_filter($this->fields, fn(Field $f) => $f->isDirty); |
| | | } |
| | | |
| | | public function getInvalidFields(): array |
| | | { |
| | | return array_filter($this->fields, fn(Field $f) => !$f->isValid); |
| | | } |
| | | |
| | | public function markAllClean(): self |
| | | { |
| | | foreach ($this->fields as $field) { |
| | | $field->markClean(); |
| | | } |
| | | return $this; |
| | | } |
| | | |
| | | public function hasDirtyFields(): bool |
| | | { |
| | | return !empty($this->getDirtyFields()); |
| | | } |
| | | |
| | | public function isValid(): bool |
| | | { |
| | | return empty($this->getInvalidFields()); |
| | | } |
| | | } |
| New file |
| | |
| | | <?php |
| | | namespace JVBase\meta; |
| | | |
| | | use Exception; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | /** |
| | | * Main facade for meta operations |
| | | * Fluent API for getting/setting meta values with validation & sanitization |
| | | */ |
| | | class Meta |
| | | { |
| | | protected Item $item; |
| | | protected Storage $storage; |
| | | protected MetaValidator $validator; |
| | | protected MetaSanitizer $sanitizer; |
| | | protected MetaTypeManager $typeManager; |
| | | |
| | | protected bool $autoValidate = true; |
| | | protected bool $autoSanitize = true; |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | | // Factory Methods |
| | | // ───────────────────────────────────────────────────────────── |
| | | |
| | | public static function forPost(int $id): self |
| | | { |
| | | return new self($id, 'post'); |
| | | } |
| | | |
| | | public static function forTerm(int $id): self |
| | | { |
| | | return new self($id, 'term'); |
| | | } |
| | | |
| | | public static function forUser(int $id): self |
| | | { |
| | | return new self($id, 'user'); |
| | | } |
| | | |
| | | public static function forOptions(?string $baseKey = null): self |
| | | { |
| | | $instance = new self($baseKey, 'options'); |
| | | $instance->item->baseKey = $baseKey; |
| | | return $instance; |
| | | } |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | | // Constructor |
| | | // ───────────────────────────────────────────────────────────── |
| | | |
| | | public function __construct(int|string|null $id, string $type) |
| | | { |
| | | $this->storage = new Storage(); |
| | | $this->validator = new MetaValidator(); |
| | | $this->sanitizer = new MetaSanitizer(); |
| | | $this->typeManager = new MetaTypeManager(); |
| | | |
| | | $this->item = $this->buildItem($id, $type); |
| | | } |
| | | |
| | | protected function buildItem(int|string|null $id, string $type): Item |
| | | { |
| | | $contentType = null; |
| | | $wpObject = null; |
| | | |
| | | if ($id && $type !== 'options') { |
| | | [$wpObject, $contentType] = match ($type) { |
| | | 'post' => [get_post($id), jvbNoBase(get_post_type($id))], |
| | | 'term' => [get_term($id), jvbNoBase(get_term($id)->taxonomy)], |
| | | 'user', 'integrations' => [get_user_by('id', $id), jvbUserRole($id)], |
| | | default => [null, null] |
| | | }; |
| | | } |
| | | |
| | | $item = new Item($id, $type, $contentType); |
| | | $item->wpObject = $wpObject; |
| | | $item->fieldConfigs = $this->loadFieldConfigs($contentType, $type); |
| | | |
| | | // Mark WP defaults in configs |
| | | $defaults = Item::WP_DEFAULTS[$type] ?? []; |
| | | foreach ($defaults as $name) { |
| | | if (!isset($item->fieldConfigs[$name])) { |
| | | $item->fieldConfigs[$name] = ['type' => 'text', '_wp_default' => true]; |
| | | } else { |
| | | $item->fieldConfigs[$name]['_wp_default'] = true; |
| | | } |
| | | } |
| | | |
| | | return $item; |
| | | } |
| | | |
| | | protected function loadFieldConfigs(?string $contentType, string $objectType): array |
| | | { |
| | | if (!$contentType && $objectType !== 'options') { |
| | | return []; |
| | | } |
| | | |
| | | return jvbGetFields($contentType ?? 'options', $objectType); |
| | | } |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | | // Magic Methods for Fluent Access |
| | | // ───────────────────────────────────────────────────────────── |
| | | |
| | | public function __get(string $name): mixed |
| | | { |
| | | return $this->get($name); |
| | | } |
| | | |
| | | public function __set(string $name, mixed $value): void |
| | | { |
| | | $this->set($name, $value); |
| | | } |
| | | |
| | | public function __isset(string $name): bool |
| | | { |
| | | return $this->item->hasField($name); |
| | | } |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | | // Core API |
| | | // ───────────────────────────────────────────────────────────── |
| | | |
| | | /** |
| | | * Get a field value |
| | | */ |
| | | public function get(string $name): mixed |
| | | { |
| | | // Return from loaded field if exists |
| | | if ($field = $this->item->getField($name)) { |
| | | return $field->get(); |
| | | } |
| | | |
| | | // Load from storage |
| | | $value = $this->storage->get($this->item, $name); |
| | | $config = $this->item->getFieldConfig($name) ?? ['type' => 'text']; |
| | | |
| | | $field = new Field($name, $value, $config); |
| | | $this->item->setField($field); |
| | | |
| | | return $value; |
| | | } |
| | | |
| | | /** |
| | | * Set a field value (validates & sanitizes) |
| | | */ |
| | | public function set(string $name, mixed $value): self |
| | | { |
| | | $config = $this->item->getFieldConfig($name); |
| | | |
| | | if (!$config) { |
| | | // Allow setting unknown fields with minimal config |
| | | $config = ['type' => 'text', 'name' => $name]; |
| | | } |
| | | |
| | | $config['name'] = $name; |
| | | |
| | | // Validate |
| | | if ($this->autoValidate && !$this->validator->validate($value, $config)) { |
| | | $field = $this->item->getField($name) ?? new Field($name, $value, $config); |
| | | $field->addError("Validation failed for {$name}"); |
| | | $this->item->setField($field); |
| | | return $this; |
| | | } |
| | | |
| | | // Sanitize |
| | | if ($this->autoSanitize) { |
| | | $value = $this->sanitizer->sanitize($value, $config); |
| | | } |
| | | |
| | | // Get or create field |
| | | $field = $this->item->getField($name); |
| | | |
| | | if ($field) { |
| | | $field->set($value); |
| | | } else { |
| | | // Need to load original to track dirty state |
| | | $original = $this->storage->get($this->item, $name); |
| | | $field = new Field($name, $original, $config); |
| | | $field->set($value); |
| | | $this->item->setField($field); |
| | | } |
| | | |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Get multiple fields |
| | | */ |
| | | public function getAll(array $fields = []): array |
| | | { |
| | | if (empty($fields) || $fields === ['all']) { |
| | | $fields = array_keys($this->item->fieldConfigs); |
| | | } |
| | | |
| | | // Load all from storage |
| | | $values = $this->storage->getAll($this->item, $fields); |
| | | |
| | | // Create Field instances |
| | | foreach ($values as $name => $value) { |
| | | if (!$this->item->getField($name)) { |
| | | $config = $this->item->getFieldConfig($name) ?? ['type' => 'text']; |
| | | $this->item->setField(new Field($name, $value, $config)); |
| | | } |
| | | } |
| | | |
| | | return $values; |
| | | } |
| | | |
| | | /** |
| | | * Set multiple fields |
| | | */ |
| | | public function setAll(array $data): self |
| | | { |
| | | foreach ($data as $name => $value) { |
| | | $this->set($name, $value); |
| | | } |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Save all dirty fields to database |
| | | */ |
| | | public function save(bool $updateTimestamp = true): bool |
| | | { |
| | | if (!$this->item->isValid()) { |
| | | JVB()->error()->log('meta', 'Cannot save: validation errors exist', [ |
| | | 'fields' => array_keys($this->item->getInvalidFields()) |
| | | ], 'warning'); |
| | | return false; |
| | | } |
| | | |
| | | // Check for field overrides before saving |
| | | foreach ($this->item->getDirtyFields() as $field) { |
| | | if ($this->checkOverrides($field)) { |
| | | $field->markClean(); |
| | | } |
| | | } |
| | | |
| | | return $this->storage->save($this->item, $updateTimestamp); |
| | | } |
| | | |
| | | /** |
| | | * Delete a field value |
| | | */ |
| | | public function delete(string $name): bool |
| | | { |
| | | $result = $this->storage->delete($this->item, $name); |
| | | |
| | | if ($result && $field = $this->item->getField($name)) { |
| | | $field->set($this->getDefaultValue($name)); |
| | | $field->markClean(); |
| | | } |
| | | |
| | | return $result; |
| | | } |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | | // Utility Methods |
| | | // ───────────────────────────────────────────────────────────── |
| | | |
| | | /** |
| | | * Get all dirty (changed) fields |
| | | */ |
| | | public function getDirty(): array |
| | | { |
| | | return array_map( |
| | | fn(Field $f) => $f->value, |
| | | $this->item->getDirtyFields() |
| | | ); |
| | | } |
| | | |
| | | /** |
| | | * Check if any fields have changed |
| | | */ |
| | | public function isDirty(): bool |
| | | { |
| | | return $this->item->hasDirtyFields(); |
| | | } |
| | | |
| | | /** |
| | | * Discard all unsaved changes |
| | | */ |
| | | public function reset(): self |
| | | { |
| | | foreach ($this->item->fields as $field) { |
| | | $field->reset(); |
| | | } |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Get validation errors |
| | | */ |
| | | public function getErrors(): array |
| | | { |
| | | $errors = []; |
| | | foreach ($this->item->getInvalidFields() as $name => $field) { |
| | | $errors[$name] = $field->errors; |
| | | } |
| | | return $errors; |
| | | } |
| | | |
| | | /** |
| | | * Check if valid (no validation errors) |
| | | */ |
| | | public function isValid(): bool |
| | | { |
| | | return $this->item->isValid(); |
| | | } |
| | | |
| | | /** |
| | | * Disable auto-validation for bulk operations |
| | | */ |
| | | public function withoutValidation(): self |
| | | { |
| | | $this->autoValidate = false; |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Disable auto-sanitization |
| | | */ |
| | | public function withoutSanitization(): self |
| | | { |
| | | $this->autoSanitize = false; |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Get the underlying item (for rendering, etc) |
| | | */ |
| | | public function item(): Item |
| | | { |
| | | return $this->item; |
| | | } |
| | | |
| | | /** |
| | | * Get field configuration |
| | | */ |
| | | public function config(string $name): ?array |
| | | { |
| | | return $this->item->getFieldConfig($name); |
| | | } |
| | | |
| | | /** |
| | | * Get all field configurations |
| | | */ |
| | | public function configs(): array |
| | | { |
| | | return $this->item->fieldConfigs; |
| | | } |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | | // Protected Helpers |
| | | // ───────────────────────────────────────────────────────────── |
| | | |
| | | protected function checkOverrides(Field $field): bool |
| | | { |
| | | $name = $field->name; |
| | | $type = $field->type(); |
| | | $value = $field->value; |
| | | |
| | | do_action('jvb_meta_update', $name, $value, $this->item->objectType); |
| | | |
| | | $overrides = [ |
| | | BASE . 'update_' . $name, |
| | | BASE . 'update_' . $type, |
| | | 'jvb_update_' . $name, |
| | | 'jvb_update_' . $type, |
| | | ]; |
| | | |
| | | foreach ($overrides as $override) { |
| | | if (function_exists($override)) { |
| | | $override($this->item->id, $value); |
| | | return true; |
| | | } |
| | | } |
| | | |
| | | return false; |
| | | } |
| | | |
| | | protected function getDefaultValue(string $name): mixed |
| | | { |
| | | $config = $this->item->getFieldConfig($name); |
| | | $type = $config['type'] ?? 'text'; |
| | | |
| | | return match ($this->typeManager->getMetaType($type)) { |
| | | 'object', 'array' => [], |
| | | 'boolean' => false, |
| | | 'integer' => 0, |
| | | default => '', |
| | | }; |
| | | } |
| | | } |
| New file |
| | |
| | | <?php |
| | | namespace JVBase\meta; |
| | | |
| | | use Exception; |
| | | use wpdb; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | /** |
| | | * Handles persistence of meta values to WordPress |
| | | * Pure storage layer - no validation or sanitization |
| | | */ |
| | | class Storage |
| | | { |
| | | protected wpdb $wpdb; |
| | | |
| | | public function __construct() |
| | | { |
| | | global $wpdb; |
| | | $this->wpdb = $wpdb; |
| | | } |
| | | |
| | | /** |
| | | * Load a single field value from database |
| | | */ |
| | | public function get(Item $item, string $name): mixed |
| | | { |
| | | if ($item->isWpDefault($name)) { |
| | | return $this->getWpDefault($item, $name); |
| | | } |
| | | |
| | | $metaKey = BASE . $name; |
| | | |
| | | return match ($item->objectType) { |
| | | 'post' => get_post_meta($item->id, $metaKey, true), |
| | | 'term' => get_term_meta($item->id, $metaKey, true), |
| | | 'user', 'integrations' => get_user_meta($item->id, $metaKey, true), |
| | | 'options' => $this->getOption($item, $name), |
| | | default => '' |
| | | }; |
| | | } |
| | | |
| | | /** |
| | | * Load multiple field values in a single query |
| | | */ |
| | | public function getAll(Item $item, array $fieldNames): array |
| | | { |
| | | if (empty($fieldNames) || !$item->id) { |
| | | return []; |
| | | } |
| | | |
| | | $defaults = Item::WP_DEFAULTS[$item->objectType] ?? []; |
| | | $wpFields = array_intersect($defaults, $fieldNames); |
| | | $metaFields = array_diff($fieldNames, $wpFields); |
| | | |
| | | $values = []; |
| | | |
| | | // Get meta fields in bulk |
| | | if (!empty($metaFields)) { |
| | | $values = $this->bulkGetMeta($item, $metaFields); |
| | | } |
| | | |
| | | // Get WP default fields |
| | | foreach ($wpFields as $name) { |
| | | $values[$name] = $this->getWpDefault($item, $name); |
| | | } |
| | | |
| | | return $values; |
| | | } |
| | | |
| | | /** |
| | | * Save a single field |
| | | */ |
| | | public function saveField(Item $item, Field $field): bool |
| | | { |
| | | if ($field->isWpDefault()) { |
| | | return $this->saveWpDefault($item, $field); |
| | | } |
| | | |
| | | if ($field->isTaxonomy()) { |
| | | return $this->saveTaxonomyField($item, $field); |
| | | } |
| | | |
| | | $metaKey = BASE . $field->name; |
| | | |
| | | return match ($item->objectType) { |
| | | 'post' => update_post_meta($item->id, $metaKey, $field->value) !== false, |
| | | 'term' => update_term_meta($item->id, $metaKey, $field->value) !== false, |
| | | 'user', 'integrations' => update_user_meta($item->id, $metaKey, $field->value) !== false, |
| | | 'options' => $this->saveOption($item, $field), |
| | | default => false |
| | | }; |
| | | } |
| | | |
| | | /** |
| | | * Save all dirty fields on an item |
| | | */ |
| | | public function save(Item $item, bool $updateTimestamp = true): bool |
| | | { |
| | | $dirty = $item->getDirtyFields(); |
| | | |
| | | if (empty($dirty)) { |
| | | return true; |
| | | } |
| | | |
| | | $this->wpdb->query('START TRANSACTION'); |
| | | |
| | | try { |
| | | foreach ($dirty as $field) { |
| | | if (!$this->saveField($item, $field)) { |
| | | throw new Exception("Failed to save field: {$field->name}"); |
| | | } |
| | | $field->markClean(); |
| | | } |
| | | |
| | | $this->wpdb->query('COMMIT'); |
| | | |
| | | // Update post modified timestamp |
| | | if ($updateTimestamp && $item->objectType === 'post' && $item->id) { |
| | | wp_update_post(['ID' => $item->id]); |
| | | } |
| | | |
| | | $this->clearCache($item); |
| | | |
| | | return true; |
| | | |
| | | } catch (Exception $e) { |
| | | $this->wpdb->query('ROLLBACK'); |
| | | JVB()->error()->log('meta_storage', $e->getMessage(), [ |
| | | 'item_id' => $item->id, |
| | | 'object_type' => $item->objectType, |
| | | 'fields' => array_keys($dirty) |
| | | ], 'error'); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Delete a field value |
| | | */ |
| | | public function delete(Item $item, string $name): bool |
| | | { |
| | | $metaKey = BASE . $name; |
| | | |
| | | return match ($item->objectType) { |
| | | 'post' => delete_post_meta($item->id, $metaKey), |
| | | 'term' => delete_term_meta($item->id, $metaKey), |
| | | 'user', 'integrations' => delete_user_meta($item->id, $metaKey), |
| | | 'options' => delete_option($this->optionKey($item, $name)), |
| | | default => false |
| | | }; |
| | | } |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | | // Protected helpers |
| | | // ───────────────────────────────────────────────────────────── |
| | | |
| | | protected function getWpDefault(Item $item, string $name): mixed |
| | | { |
| | | if (in_array($name, ['featured_image', 'post_thumbnail'])) { |
| | | return get_post_thumbnail_id($item->id); |
| | | } |
| | | |
| | | return match ($item->objectType) { |
| | | 'post' => $this->getPostField($item, $name), |
| | | 'term' => $this->getTermField($item, $name), |
| | | 'user' => $this->getUserField($item, $name), |
| | | default => '' |
| | | }; |
| | | } |
| | | |
| | | protected function getPostField(Item $item, string $name): mixed |
| | | { |
| | | return match ($name) { |
| | | 'post_title' => get_the_title($item->id), |
| | | 'post_excerpt' => get_the_excerpt($item->id), |
| | | 'post_content' => get_post_field('post_content', $item->id), |
| | | default => $item->wpObject->$name ?? '' |
| | | }; |
| | | } |
| | | |
| | | protected function getTermField(Item $item, string $name): mixed |
| | | { |
| | | return match ($name) { |
| | | 'term_name' => get_term_field('name', $item->id), |
| | | 'description' => get_term_field('description', $item->id), |
| | | default => '' |
| | | }; |
| | | } |
| | | |
| | | protected function getUserField(Item $item, string $name): mixed |
| | | { |
| | | return match ($name) { |
| | | 'display_name' => get_the_author_meta('display_name', $item->id), |
| | | 'user_email' => get_the_author_meta('user_email', $item->id), |
| | | 'first_name' => get_the_author_meta('first_name', $item->id), |
| | | 'last_name' => get_the_author_meta('last_name', $item->id), |
| | | default => $item->wpObject->$name ?? '' |
| | | }; |
| | | } |
| | | |
| | | protected function saveWpDefault(Item $item, Field $field): bool |
| | | { |
| | | $name = $field->name; |
| | | $value = $field->value; |
| | | |
| | | if (in_array($name, ['featured_image', 'post_thumbnail'])) { |
| | | return set_post_thumbnail($item->id, $value) !== false; |
| | | } |
| | | |
| | | return match ($item->objectType) { |
| | | 'post' => wp_update_post(['ID' => $item->id, $name => $value]) !== 0, |
| | | 'term' => !is_wp_error(wp_update_term($item->id, $item->wpObject->taxonomy, [ |
| | | $name => $value, |
| | | 'slug' => $name === 'term_name' ? sanitize_title($value) : null |
| | | ])), |
| | | 'user' => wp_update_user(['ID' => $item->id, $name => $value]) !== 0, |
| | | default => false |
| | | }; |
| | | } |
| | | |
| | | protected function saveTaxonomyField(Item $item, Field $field): bool |
| | | { |
| | | $taxonomy = jvbCheckBase($field->config['taxonomy']); |
| | | $value = $field->value; |
| | | |
| | | if (empty(trim($value))) { |
| | | wp_set_object_terms($item->id, [], $taxonomy, false); |
| | | return true; |
| | | } |
| | | |
| | | $termIds = array_map('intval', array_filter(explode(',', $value))); |
| | | $result = wp_set_object_terms($item->id, $termIds, $taxonomy, false); |
| | | |
| | | return !is_wp_error($result); |
| | | } |
| | | |
| | | protected function bulkGetMeta(Item $item, array $fields): array |
| | | { |
| | | [$table, $idColumn] = $this->getTableInfo($item->objectType); |
| | | |
| | | if (!$table) { |
| | | return []; |
| | | } |
| | | |
| | | $metaKeys = array_map(fn($f) => BASE . $f, $fields); |
| | | $placeholders = implode(',', array_fill(0, count($metaKeys), '%s')); |
| | | |
| | | $query = $this->wpdb->prepare( |
| | | "SELECT meta_key, meta_value FROM {$table} |
| | | WHERE {$idColumn} = %d AND meta_key IN ({$placeholders})", |
| | | array_merge([$item->id], $metaKeys) |
| | | ); |
| | | |
| | | $results = $this->wpdb->get_results($query, ARRAY_A); |
| | | |
| | | $values = array_fill_keys($fields, ''); |
| | | |
| | | foreach ($results as $row) { |
| | | $key = str_replace(BASE, '', $row['meta_key']); |
| | | $values[$key] = maybe_unserialize($row['meta_value']); |
| | | } |
| | | |
| | | return $values; |
| | | } |
| | | |
| | | protected function getTableInfo(string $objectType): array |
| | | { |
| | | return match ($objectType) { |
| | | 'post' => [$this->wpdb->postmeta, 'post_id'], |
| | | 'term' => [$this->wpdb->termmeta, 'term_id'], |
| | | 'user', 'integrations' => [$this->wpdb->usermeta, 'user_id'], |
| | | default => [null, null] |
| | | }; |
| | | } |
| | | |
| | | protected function getOption(Item $item, string $name): mixed |
| | | { |
| | | return get_option($this->optionKey($item, $name)); |
| | | } |
| | | |
| | | protected function saveOption(Item $item, Field $field): bool |
| | | { |
| | | return update_option($this->optionKey($item, $field->name), $field->value); |
| | | } |
| | | |
| | | protected function optionKey(Item $item, string $name): string |
| | | { |
| | | return $item->baseKey |
| | | ? BASE . $item->baseKey . '_' . $name |
| | | : BASE . $name; |
| | | } |
| | | |
| | | protected function clearCache(Item $item): void |
| | | { |
| | | match ($item->objectType) { |
| | | 'post' => clean_post_cache($item->id), |
| | | 'term' => clean_term_cache($item->id), |
| | | 'user', 'integrations' => clean_user_cache($item->id), |
| | | default => null |
| | | }; |
| | | } |
| | | } |
| | |
| | | <?php |
| | | require(JVB_DIR . '/inc/meta/MetaTypeManager.php'); |
| | | //REFACTOR |
| | | require(JVB_DIR . '/inc/meta/Meta.php'); //Main facade |
| | | require(JVB_DIR . '/inc/meta/Item.php'); //Data container for one object |
| | | require(JVB_DIR . '/inc/meta/Field.php'); //Single field with value, dirty state |
| | | require(JVB_DIR . '/inc/meta/Storage.php'); //Persistence layer |
| | | require(JVB_DIR . '/inc/meta/MetaTypeManager.php'); //Keep as is |
| | | require(JVB_DIR . '/inc/meta/MetaValidator.php'); //Keep as is |
| | | |
| | | |
| | | require(JVB_DIR . '/inc/meta/MetaRenderer.php'); //decouple from manager |
| | | require(JVB_DIR . '/inc/meta/MetaForm.php'); //decouple from manager |
| | | |
| | | //OLD SYSTEM |
| | | require(JVB_DIR . '/inc/meta/MetaManager.php'); |
| | | require(JVB_DIR . '/inc/meta/MetaRegistry.php'); |
| | | require(JVB_DIR . '/inc/meta/MetaSanitizer.php'); |
| | | require(JVB_DIR . '/inc/meta/MetaValidator.php'); |
| | | require(JVB_DIR . '/inc/meta/MetaRenderer.php'); |
| | | require(JVB_DIR . '/inc/meta/MetaForm.php'); |
| New file |
| | |
| | | <?php |
| | | namespace JVBase\rest; |
| | | |
| | | use WP_REST_Request; |
| | | use WP_Error; |
| | | use WP_User; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | /** |
| | | * Centralized Permission Handler for REST Routes |
| | | * |
| | | * Provides reusable permission callbacks and utilities for route authentication. |
| | | */ |
| | | class PermissionHandler |
| | | { |
| | | /** |
| | | * Check if the 'user' parameter in request matches current logged-in user |
| | | * Common pattern for user-specific endpoints |
| | | */ |
| | | public static function userMatch(WP_REST_Request $request): bool|WP_Error |
| | | { |
| | | if (!is_user_logged_in()) { |
| | | return new WP_Error( |
| | | 'not_logged_in', |
| | | 'You must be logged in to access this resource', |
| | | ['status' => 401] |
| | | ); |
| | | } |
| | | |
| | | $requestedUserId = $request->get_param('user'); |
| | | |
| | | // No user param specified - allow (controller will handle) |
| | | if (empty($requestedUserId)) { |
| | | return true; |
| | | } |
| | | |
| | | $currentUserId = get_current_user_id(); |
| | | |
| | | if ((int) $requestedUserId !== $currentUserId) { |
| | | return new WP_Error( |
| | | 'forbidden', |
| | | 'You can only access your own resources', |
| | | ['status' => 403] |
| | | ); |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Check if current user is an administrator |
| | | */ |
| | | public static function isAdmin(WP_REST_Request $request): bool|WP_Error |
| | | { |
| | | if (!is_user_logged_in()) { |
| | | return new WP_Error( |
| | | 'not_logged_in', |
| | | 'You must be logged in', |
| | | ['status' => 401] |
| | | ); |
| | | } |
| | | |
| | | if (!current_user_can('manage_options')) { |
| | | return new WP_Error( |
| | | 'forbidden', |
| | | 'Administrator access required', |
| | | ['status' => 403] |
| | | ); |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Check if current user is verified (has skip_moderation capability) |
| | | */ |
| | | public static function isVerified(WP_REST_Request $request): bool|WP_Error |
| | | { |
| | | if (!is_user_logged_in()) { |
| | | return new WP_Error( |
| | | 'not_logged_in', |
| | | 'You must be logged in', |
| | | ['status' => 401] |
| | | ); |
| | | } |
| | | |
| | | if (!current_user_can('skip_moderation')) { |
| | | return new WP_Error( |
| | | 'not_verified', |
| | | 'Account verification required', |
| | | ['status' => 403] |
| | | ); |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Check if current user has a specific role |
| | | */ |
| | | public static function hasRole(WP_REST_Request $request, string $role): bool|WP_Error |
| | | { |
| | | if (!is_user_logged_in()) { |
| | | return new WP_Error( |
| | | 'not_logged_in', |
| | | 'You must be logged in', |
| | | ['status' => 401] |
| | | ); |
| | | } |
| | | |
| | | $user = wp_get_current_user(); |
| | | $role = self::normalizeRole($role); |
| | | |
| | | if (!in_array($role, $user->roles, true)) { |
| | | return new WP_Error( |
| | | 'forbidden', |
| | | 'You do not have the required role', |
| | | ['status' => 403] |
| | | ); |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Check if current user has any of the specified roles |
| | | */ |
| | | public static function hasAnyRole(WP_REST_Request $request, array $roles): bool|WP_Error |
| | | { |
| | | if (!is_user_logged_in()) { |
| | | return new WP_Error( |
| | | 'not_logged_in', |
| | | 'You must be logged in', |
| | | ['status' => 401] |
| | | ); |
| | | } |
| | | |
| | | $user = wp_get_current_user(); |
| | | $normalizedRoles = array_map([self::class, 'normalizeRole'], $roles); |
| | | |
| | | foreach ($user->roles as $userRole) { |
| | | if (in_array($userRole, $normalizedRoles, true)) { |
| | | return true; |
| | | } |
| | | } |
| | | |
| | | return new WP_Error( |
| | | 'forbidden', |
| | | 'You do not have any of the required roles', |
| | | ['status' => 403] |
| | | ); |
| | | } |
| | | |
| | | /** |
| | | * Check if current user has all specified roles |
| | | */ |
| | | public static function hasAllRoles(WP_REST_Request $request, array $roles): bool|WP_Error |
| | | { |
| | | if (!is_user_logged_in()) { |
| | | return new WP_Error( |
| | | 'not_logged_in', |
| | | 'You must be logged in', |
| | | ['status' => 401] |
| | | ); |
| | | } |
| | | |
| | | $user = wp_get_current_user(); |
| | | $normalizedRoles = array_map([self::class, 'normalizeRole'], $roles); |
| | | |
| | | foreach ($normalizedRoles as $role) { |
| | | if (!in_array($role, $user->roles, true)) { |
| | | return new WP_Error( |
| | | 'forbidden', |
| | | 'You do not have all required roles', |
| | | ['status' => 403] |
| | | ); |
| | | } |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Check if current user has a specific capability |
| | | */ |
| | | public static function hasCapability(WP_REST_Request $request, string $capability): bool|WP_Error |
| | | { |
| | | if (!is_user_logged_in()) { |
| | | return new WP_Error( |
| | | 'not_logged_in', |
| | | 'You must be logged in', |
| | | ['status' => 401] |
| | | ); |
| | | } |
| | | |
| | | if (!current_user_can($capability)) { |
| | | return new WP_Error( |
| | | 'forbidden', |
| | | 'You do not have the required permission', |
| | | ['status' => 403] |
| | | ); |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Check if current user has any of the specified capabilities |
| | | */ |
| | | public static function hasAnyCapability(WP_REST_Request $request, array $capabilities): bool|WP_Error |
| | | { |
| | | if (!is_user_logged_in()) { |
| | | return new WP_Error( |
| | | 'not_logged_in', |
| | | 'You must be logged in', |
| | | ['status' => 401] |
| | | ); |
| | | } |
| | | |
| | | foreach ($capabilities as $cap) { |
| | | if (current_user_can($cap)) { |
| | | return true; |
| | | } |
| | | } |
| | | |
| | | return new WP_Error( |
| | | 'forbidden', |
| | | 'You do not have any of the required permissions', |
| | | ['status' => 403] |
| | | ); |
| | | } |
| | | |
| | | /** |
| | | * Check if user owns a specific post |
| | | */ |
| | | public static function ownsPost(WP_REST_Request $request, string $paramName = 'post_id'): bool|WP_Error |
| | | { |
| | | if (!is_user_logged_in()) { |
| | | return new WP_Error( |
| | | 'not_logged_in', |
| | | 'You must be logged in', |
| | | ['status' => 401] |
| | | ); |
| | | } |
| | | |
| | | $postId = $request->get_param($paramName); |
| | | |
| | | if (empty($postId)) { |
| | | return new WP_Error( |
| | | 'missing_param', |
| | | 'Post ID is required', |
| | | ['status' => 400] |
| | | ); |
| | | } |
| | | |
| | | $post = get_post($postId); |
| | | |
| | | if (!$post) { |
| | | return new WP_Error( |
| | | 'not_found', |
| | | 'Post not found', |
| | | ['status' => 404] |
| | | ); |
| | | } |
| | | |
| | | if ((int) $post->post_author !== get_current_user_id()) { |
| | | // Allow admins to bypass ownership check |
| | | if (!current_user_can('manage_options')) { |
| | | return new WP_Error( |
| | | 'forbidden', |
| | | 'You can only modify your own content', |
| | | ['status' => 403] |
| | | ); |
| | | } |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Check if user can edit a specific post type |
| | | */ |
| | | public static function canEditPostType(WP_REST_Request $request, string $postType): bool|WP_Error |
| | | { |
| | | if (!is_user_logged_in()) { |
| | | return new WP_Error( |
| | | 'not_logged_in', |
| | | 'You must be logged in', |
| | | ['status' => 401] |
| | | ); |
| | | } |
| | | |
| | | $postTypeObj = get_post_type_object($postType); |
| | | |
| | | if (!$postTypeObj) { |
| | | return new WP_Error( |
| | | 'invalid_post_type', |
| | | 'Invalid post type', |
| | | ['status' => 400] |
| | | ); |
| | | } |
| | | |
| | | if (!current_user_can($postTypeObj->cap->edit_posts)) { |
| | | return new WP_Error( |
| | | 'forbidden', |
| | | 'You cannot edit this content type', |
| | | ['status' => 403] |
| | | ); |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Verify nonce from request header |
| | | */ |
| | | public static function verifyNonce(WP_REST_Request $request, string $action = 'wp_rest', string $header = 'X-WP-Nonce'): bool|WP_Error |
| | | { |
| | | $nonce = $request->get_header($header); |
| | | |
| | | if (empty($nonce)) { |
| | | return new WP_Error( |
| | | 'missing_nonce', |
| | | 'Security token is missing', |
| | | ['status' => 403] |
| | | ); |
| | | } |
| | | |
| | | if (!wp_verify_nonce($nonce, $action)) { |
| | | return new WP_Error( |
| | | 'invalid_nonce', |
| | | 'Invalid or expired security token', |
| | | ['status' => 403] |
| | | ); |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Verify action-specific nonce (e.g., 'dash-{user_id}') |
| | | */ |
| | | public static function verifyActionNonce(WP_REST_Request $request, string $actionPrefix, string $header = 'X-Action-Nonce'): bool|WP_Error |
| | | { |
| | | $userId = $request->get_param('user') ?: get_current_user_id(); |
| | | $action = $actionPrefix . $userId; |
| | | |
| | | return self::verifyNonce($request, $action, $header); |
| | | } |
| | | |
| | | /** |
| | | * Combined permission check: user match + rate limit |
| | | */ |
| | | public static function userMatchWithRateLimit(WP_REST_Request $request): bool|WP_Error |
| | | { |
| | | static $rateLimiter = null; |
| | | |
| | | if ($rateLimiter === null) { |
| | | $rateLimiter = new RateLimiter(); |
| | | } |
| | | |
| | | // Check rate limit first |
| | | if (!$rateLimiter->checkLimit($request)) { |
| | | return new WP_Error( |
| | | 'rate_limit', |
| | | 'Too many requests. Please wait before trying again.', |
| | | ['status' => 429] |
| | | ); |
| | | } |
| | | |
| | | return self::userMatch($request); |
| | | } |
| | | |
| | | /** |
| | | * Create a custom permission callback combining multiple checks |
| | | * |
| | | * Usage: |
| | | * PermissionHandler::combine(['logged_in', 'verified']) |
| | | * PermissionHandler::combine([['role' => 'artist'], ['capability' => 'edit_posts']]) |
| | | */ |
| | | public static function combine(array $checks): callable |
| | | { |
| | | return function(WP_REST_Request $request) use ($checks) { |
| | | foreach ($checks as $check) { |
| | | $result = match (true) { |
| | | $check === 'logged_in' => is_user_logged_in() ?: new WP_Error('not_logged_in', 'Login required', ['status' => 401]), |
| | | $check === 'admin' => self::isAdmin($request), |
| | | $check === 'verified' => self::isVerified($request), |
| | | $check === 'user' => self::userMatch($request), |
| | | is_array($check) && isset($check['role']) => self::hasRole($request, $check['role']), |
| | | is_array($check) && isset($check['roles']) => self::hasAnyRole($request, $check['roles']), |
| | | is_array($check) && isset($check['capability']) => self::hasCapability($request, $check['capability']), |
| | | is_callable($check) => $check($request), |
| | | default => true, |
| | | }; |
| | | |
| | | if (is_wp_error($result)) { |
| | | return $result; |
| | | } |
| | | |
| | | if ($result === false) { |
| | | return new WP_Error('forbidden', 'Access denied', ['status' => 403]); |
| | | } |
| | | } |
| | | |
| | | return true; |
| | | }; |
| | | } |
| | | |
| | | /** |
| | | * Create an OR permission callback (passes if ANY check passes) |
| | | */ |
| | | public static function any(array $checks): callable |
| | | { |
| | | return function(WP_REST_Request $request) use ($checks) { |
| | | $lastError = null; |
| | | |
| | | foreach ($checks as $check) { |
| | | $result = match (true) { |
| | | $check === 'logged_in' => is_user_logged_in(), |
| | | $check === 'admin' => self::isAdmin($request), |
| | | $check === 'verified' => self::isVerified($request), |
| | | $check === 'user' => self::userMatch($request), |
| | | is_array($check) && isset($check['role']) => self::hasRole($request, $check['role']), |
| | | is_array($check) && isset($check['capability']) => self::hasCapability($request, $check['capability']), |
| | | is_callable($check) => $check($request), |
| | | default => false, |
| | | }; |
| | | |
| | | // If it's a successful check (true), pass |
| | | if ($result === true) { |
| | | return true; |
| | | } |
| | | |
| | | // Track last error for reporting |
| | | if (is_wp_error($result)) { |
| | | $lastError = $result; |
| | | } |
| | | } |
| | | |
| | | return $lastError ?: new WP_Error('forbidden', 'Access denied', ['status' => 403]); |
| | | }; |
| | | } |
| | | |
| | | /** |
| | | * Normalize role name (add BASE prefix if needed) |
| | | */ |
| | | private static function normalizeRole(string $role): string |
| | | { |
| | | if (defined('BASE') && !str_starts_with($role, BASE)) { |
| | | // Check if it's a WordPress core role |
| | | $coreRoles = ['administrator', 'editor', 'author', 'contributor', 'subscriber']; |
| | | if (!in_array($role, $coreRoles, true)) { |
| | | return BASE . $role; |
| | | } |
| | | } |
| | | return $role; |
| | | } |
| | | |
| | | /** |
| | | * Get current user for request (cached) |
| | | */ |
| | | public static function getCurrentUser(): ?WP_User |
| | | { |
| | | static $user = null; |
| | | |
| | | if ($user === null && is_user_logged_in()) { |
| | | $user = wp_get_current_user(); |
| | | } |
| | | |
| | | return $user instanceof WP_User ? $user : null; |
| | | } |
| | | |
| | | /** |
| | | * Check if request is from same origin (basic CSRF protection) |
| | | */ |
| | | public static function verifySameOrigin(WP_REST_Request $request): bool|WP_Error |
| | | { |
| | | $origin = $request->get_header('Origin'); |
| | | $referer = $request->get_header('Referer'); |
| | | |
| | | $siteUrl = get_site_url(); |
| | | $siteDomain = parse_url($siteUrl, PHP_URL_HOST); |
| | | |
| | | // Check origin header |
| | | if ($origin) { |
| | | $originDomain = parse_url($origin, PHP_URL_HOST); |
| | | if ($originDomain !== $siteDomain) { |
| | | return new WP_Error( |
| | | 'cross_origin', |
| | | 'Cross-origin requests not allowed', |
| | | ['status' => 403] |
| | | ); |
| | | } |
| | | } |
| | | |
| | | // Check referer as fallback |
| | | if (!$origin && $referer) { |
| | | $refererDomain = parse_url($referer, PHP_URL_HOST); |
| | | if ($refererDomain !== $siteDomain) { |
| | | return new WP_Error( |
| | | 'cross_origin', |
| | | 'Cross-origin requests not allowed', |
| | | ['status' => 403] |
| | | ); |
| | | } |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | } |
| New file |
| | |
| | | <?php |
| | | namespace JVBase\rest; |
| | | |
| | | use WP_REST_Response; |
| | | use WP_Error; |
| | | use DateTime; |
| | | use DateTimeZone; |
| | | use Exception; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | /** |
| | | * Fluent Response Builder for REST API |
| | | * |
| | | * Provides consistent response formatting, caching headers, and error handling. |
| | | * |
| | | * Usage: |
| | | * return Response::success(['user' => $userData]); |
| | | * return Response::error('Not found', 'not_found', 404); |
| | | * return Response::paginated($items, $total, $page, $perPage); |
| | | */ |
| | | class Response |
| | | { |
| | | private array $data = []; |
| | | private int $status = 200; |
| | | private array $headers = []; |
| | | private ?WP_REST_Response $response = null; |
| | | |
| | | /** |
| | | * Create a success response |
| | | */ |
| | | public static function success(array $data = [], int $status = 200): WP_REST_Response |
| | | { |
| | | $response = new self(); |
| | | $response->data = array_merge(['success' => true], $data); |
| | | $response->status = $status; |
| | | return $response->build(); |
| | | } |
| | | |
| | | /** |
| | | * Create an error response |
| | | */ |
| | | public static function error( |
| | | string $message, |
| | | string $code = 'error', |
| | | int $status = 400, |
| | | ?string $field = null, |
| | | array $extra = [] |
| | | ): WP_REST_Response { |
| | | $data = [ |
| | | 'success' => false, |
| | | 'code' => $code, |
| | | 'message' => $message, |
| | | ]; |
| | | |
| | | if ($field !== null) { |
| | | $data['field'] = $field; |
| | | } |
| | | |
| | | if (!empty($extra)) { |
| | | $data = array_merge($data, $extra); |
| | | } |
| | | |
| | | return new WP_REST_Response($data, $status); |
| | | } |
| | | |
| | | /** |
| | | * Create a validation error response (422) |
| | | */ |
| | | public static function validationError(array $errors, string $message = 'Validation failed'): WP_REST_Response |
| | | { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'code' => 'validation_error', |
| | | 'message' => $message, |
| | | 'errors' => $errors, |
| | | ], 422); |
| | | } |
| | | |
| | | /** |
| | | * Create an unauthorized response (401) |
| | | */ |
| | | public static function unauthorized(string $message = 'Authentication required'): WP_REST_Response |
| | | { |
| | | return self::error($message, 'unauthorized', 401); |
| | | } |
| | | |
| | | /** |
| | | * Create a forbidden response (403) |
| | | */ |
| | | public static function forbidden(string $message = 'Access denied'): WP_REST_Response |
| | | { |
| | | return self::error($message, 'forbidden', 403); |
| | | } |
| | | |
| | | /** |
| | | * Create a not found response (404) |
| | | */ |
| | | public static function notFound(string $message = 'Resource not found'): WP_REST_Response |
| | | { |
| | | return self::error($message, 'not_found', 404); |
| | | } |
| | | |
| | | /** |
| | | * Create a rate limit response (429) |
| | | */ |
| | | public static function rateLimited(int $retryAfter = 60, string $message = 'Too many requests'): WP_REST_Response |
| | | { |
| | | $response = self::error($message, 'rate_limit', 429); |
| | | $response->header('Retry-After', (string) $retryAfter); |
| | | return $response; |
| | | } |
| | | |
| | | /** |
| | | * Create a server error response (500) |
| | | */ |
| | | public static function serverError(string $message = 'An unexpected error occurred'): WP_REST_Response |
| | | { |
| | | return self::error($message, 'server_error', 500); |
| | | } |
| | | |
| | | /** |
| | | * Create a paginated response |
| | | */ |
| | | public static function paginated( |
| | | array $items, |
| | | int $total, |
| | | int $page = 1, |
| | | int $perPage = 20, |
| | | array $extra = [] |
| | | ): WP_REST_Response { |
| | | $totalPages = (int) ceil($total / $perPage); |
| | | |
| | | $data = array_merge([ |
| | | 'success' => true, |
| | | 'items' => $items, |
| | | 'pagination' => [ |
| | | 'total' => $total, |
| | | 'per_page' => $perPage, |
| | | 'current_page' => $page, |
| | | 'total_pages' => $totalPages, |
| | | 'has_more' => $page < $totalPages, |
| | | ], |
| | | ], $extra); |
| | | |
| | | $response = new WP_REST_Response($data, 200); |
| | | |
| | | // Add pagination headers |
| | | $response->header('X-Total-Count', (string) $total); |
| | | $response->header('X-Total-Pages', (string) $totalPages); |
| | | $response->header('X-Current-Page', (string) $page); |
| | | $response->header('X-Per-Page', (string) $perPage); |
| | | |
| | | return $response; |
| | | } |
| | | |
| | | /** |
| | | * Create a collection response (list without pagination) |
| | | */ |
| | | public static function collection(array $items, array $extra = []): WP_REST_Response |
| | | { |
| | | $data = array_merge([ |
| | | 'success' => true, |
| | | 'items' => $items, |
| | | 'total' => count($items), |
| | | ], $extra); |
| | | |
| | | return new WP_REST_Response($data, 200); |
| | | } |
| | | |
| | | /** |
| | | * Create a single item response |
| | | */ |
| | | public static function item(array $item, string $key = 'item'): WP_REST_Response |
| | | { |
| | | return new WP_REST_Response([ |
| | | 'success' => true, |
| | | $key => $item, |
| | | ], 200); |
| | | } |
| | | |
| | | /** |
| | | * Create a created response (201) |
| | | */ |
| | | public static function created(array $data = [], ?string $location = null): WP_REST_Response |
| | | { |
| | | $response = new WP_REST_Response( |
| | | array_merge(['success' => true], $data), |
| | | 201 |
| | | ); |
| | | |
| | | if ($location) { |
| | | $response->header('Location', $location); |
| | | } |
| | | |
| | | return $response; |
| | | } |
| | | |
| | | /** |
| | | * Create a no content response (204) |
| | | */ |
| | | public static function noContent(): WP_REST_Response |
| | | { |
| | | return new WP_REST_Response(null, 204); |
| | | } |
| | | |
| | | /** |
| | | * Create a response from WP_Error |
| | | */ |
| | | public static function fromError(WP_Error $error): WP_REST_Response |
| | | { |
| | | $data = $error->get_error_data(); |
| | | $status = is_array($data) && isset($data['status']) ? $data['status'] : 400; |
| | | |
| | | return self::error( |
| | | $error->get_error_message(), |
| | | $error->get_error_code(), |
| | | $status |
| | | ); |
| | | } |
| | | |
| | | /** |
| | | * Build a response with fluent interface |
| | | */ |
| | | public static function make(array $data = []): self |
| | | { |
| | | $instance = new self(); |
| | | $instance->data = $data; |
| | | return $instance; |
| | | } |
| | | |
| | | /** |
| | | * Set response data |
| | | */ |
| | | public function data(array $data): self |
| | | { |
| | | $this->data = array_merge($this->data, $data); |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Set HTTP status code |
| | | */ |
| | | public function status(int $status): self |
| | | { |
| | | $this->status = $status; |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Add a response header |
| | | */ |
| | | public function header(string $name, string $value): self |
| | | { |
| | | $this->headers[$name] = $value; |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Add multiple headers |
| | | */ |
| | | public function headers(array $headers): self |
| | | { |
| | | $this->headers = array_merge($this->headers, $headers); |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Add cache control headers |
| | | */ |
| | | public function cache(int $maxAge = 300, bool $private = true): self |
| | | { |
| | | $directive = $private ? 'private' : 'public'; |
| | | $this->headers['Cache-Control'] = "{$directive}, max-age={$maxAge}"; |
| | | $this->headers['Vary'] = 'Cookie'; |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Add no-cache headers |
| | | */ |
| | | public function noCache(): self |
| | | { |
| | | $this->headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'; |
| | | $this->headers['Pragma'] = 'no-cache'; |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Add ETag header for conditional requests |
| | | */ |
| | | public function etag(string $data): self |
| | | { |
| | | $this->headers['ETag'] = '"' . md5($data) . '"'; |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Add Last-Modified header |
| | | */ |
| | | public function lastModified(string|int $timestamp): self |
| | | { |
| | | if (is_string($timestamp)) { |
| | | $timestamp = strtotime($timestamp); |
| | | } |
| | | $this->headers['Last-Modified'] = gmdate('D, d M Y H:i:s', $timestamp) . ' GMT'; |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Build and return the response |
| | | */ |
| | | public function build(): WP_REST_Response |
| | | { |
| | | $response = new WP_REST_Response($this->data, $this->status); |
| | | |
| | | foreach ($this->headers as $name => $value) { |
| | | $response->header($name, $value); |
| | | } |
| | | |
| | | return $response; |
| | | } |
| | | |
| | | /** |
| | | * Convert to WP_REST_Response (alias for build) |
| | | */ |
| | | public function toResponse(): WP_REST_Response |
| | | { |
| | | return $this->build(); |
| | | } |
| | | |
| | | // ========================================================================= |
| | | // UTILITY METHODS |
| | | // ========================================================================= |
| | | |
| | | /** |
| | | * Format MySQL datetime to ISO 8601 timestamp |
| | | */ |
| | | public static function formatTimestamp(?string $mysqlDatetime): ?string |
| | | { |
| | | if (empty($mysqlDatetime)) { |
| | | return null; |
| | | } |
| | | |
| | | try { |
| | | $wpTimezone = wp_timezone(); |
| | | $date = new DateTime($mysqlDatetime, $wpTimezone); |
| | | $date->setTimezone(new DateTimeZone('UTC')); |
| | | return $date->format('c'); |
| | | } catch (Exception $e) { |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Format an array of items with a callback |
| | | */ |
| | | public static function formatItems(array $items, callable $formatter): array |
| | | { |
| | | return array_map($formatter, $items); |
| | | } |
| | | |
| | | /** |
| | | * Pluck specific fields from items |
| | | */ |
| | | public static function pluck(array $items, array $fields): array |
| | | { |
| | | return array_map(function($item) use ($fields) { |
| | | $result = []; |
| | | foreach ($fields as $field) { |
| | | if (is_array($item) && array_key_exists($field, $item)) { |
| | | $result[$field] = $item[$field]; |
| | | } elseif (is_object($item) && property_exists($item, $field)) { |
| | | $result[$field] = $item->$field; |
| | | } |
| | | } |
| | | return $result; |
| | | }, $items); |
| | | } |
| | | |
| | | /** |
| | | * Add server timestamp to response data |
| | | */ |
| | | public static function withTimestamp(array $data): array |
| | | { |
| | | $data['timestamp'] = date('c'); |
| | | $data['server_time'] = date('c'); |
| | | return $data; |
| | | } |
| | | |
| | | /** |
| | | * Create response for queued operation |
| | | */ |
| | | public static function queued(string $operationId, string $message = 'Queued for processing', array $extra = []): WP_REST_Response |
| | | { |
| | | return self::success(array_merge([ |
| | | 'message' => $message, |
| | | 'operation_id' => $operationId, |
| | | 'status' => 'queued', |
| | | ], $extra), 202); |
| | | } |
| | | |
| | | /** |
| | | * Create response for operation status |
| | | */ |
| | | public static function operationStatus( |
| | | string $id, |
| | | string $status, |
| | | int $progress = 0, |
| | | int $total = 0, |
| | | ?string $message = null |
| | | ): WP_REST_Response { |
| | | $data = [ |
| | | 'operation_id' => $id, |
| | | 'status' => $status, |
| | | 'progress' => $progress, |
| | | 'total' => $total, |
| | | ]; |
| | | |
| | | if ($total > 0) { |
| | | $data['progress_percentage'] = round(($progress / $total) * 100); |
| | | } |
| | | |
| | | if ($message) { |
| | | $data['message'] = $message; |
| | | } |
| | | |
| | | return self::success($data); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Alias for backward compatibility |
| | | */ |
| | | class ResponseBuilder extends Response {} |
| New file |
| | |
| | | <?php |
| | | namespace JVBase\rest; |
| | | |
| | | use WP_REST_Request; |
| | | use WP_Error; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | /** |
| | | * Fluent REST Route Builder |
| | | * |
| | | * Usage: |
| | | * Route::get('queue', [$this, 'getQueue'])->auth('user')->args(['status' => 'string']); |
| | | * Route::resource('content')->get(...)->post(...)->delete(false); |
| | | */ |
| | | class Route |
| | | { |
| | | private string $path; |
| | | private array $methods = []; |
| | | private array $currentMethod = []; |
| | | private bool $registered = false; |
| | | |
| | | private static string $namespace = 'jvb/v1'; |
| | | private static ?RateLimiter $rateLimiter = null; |
| | | |
| | | /** |
| | | * Create a resource route (supports multiple methods) |
| | | */ |
| | | public static function resource(string $path): self |
| | | { |
| | | return new self($path); |
| | | } |
| | | |
| | | /** |
| | | * Create a GET route |
| | | */ |
| | | public static function get(string $path, callable|array $callback): self |
| | | { |
| | | return (new self($path))->addMethod('GET', $callback); |
| | | } |
| | | |
| | | /** |
| | | * Create a POST route |
| | | */ |
| | | public static function post(string $path, callable|array $callback): self |
| | | { |
| | | return (new self($path))->addMethod('POST', $callback); |
| | | } |
| | | |
| | | /** |
| | | * Create a PUT route |
| | | */ |
| | | public static function put(string $path, callable|array $callback): self |
| | | { |
| | | return (new self($path))->addMethod('PUT', $callback); |
| | | } |
| | | |
| | | /** |
| | | * Create a PATCH route |
| | | */ |
| | | public static function patch(string $path, callable|array $callback): self |
| | | { |
| | | return (new self($path))->addMethod('PATCH', $callback); |
| | | } |
| | | |
| | | /** |
| | | * Create a DELETE route |
| | | */ |
| | | public static function delete(string $path, callable|array $callback): self |
| | | { |
| | | return (new self($path))->addMethod('DELETE', $callback); |
| | | } |
| | | |
| | | /** |
| | | * Set custom namespace (defaults to 'jvb/v1') |
| | | */ |
| | | public static function setNamespace(string $namespace): void |
| | | { |
| | | self::$namespace = $namespace; |
| | | } |
| | | |
| | | /** |
| | | * Get current namespace |
| | | */ |
| | | public static function getNamespace(): string |
| | | { |
| | | return self::$namespace; |
| | | } |
| | | |
| | | private function __construct(string $path) |
| | | { |
| | | $this->path = '/' . ltrim($path, '/'); |
| | | } |
| | | |
| | | /** |
| | | * Add GET method to resource |
| | | */ |
| | | public function get(callable|array $callback): self |
| | | { |
| | | return $this->addMethod('GET', $callback); |
| | | } |
| | | |
| | | /** |
| | | * Add POST method to resource |
| | | */ |
| | | public function post(callable|array $callback): self |
| | | { |
| | | return $this->addMethod('POST', $callback); |
| | | } |
| | | |
| | | /** |
| | | * Add PUT method to resource |
| | | */ |
| | | public function put(callable|array $callback): self |
| | | { |
| | | return $this->addMethod('PUT', $callback); |
| | | } |
| | | |
| | | /** |
| | | * Add PATCH method to resource |
| | | */ |
| | | public function patch(callable|array $callback): self |
| | | { |
| | | return $this->addMethod('PATCH', $callback); |
| | | } |
| | | |
| | | /** |
| | | * Add DELETE method to resource (pass false to explicitly disable) |
| | | */ |
| | | public function delete(callable|array|false $callback): self |
| | | { |
| | | if ($callback === false) { |
| | | return $this; // Explicitly disabled |
| | | } |
| | | return $this->addMethod('DELETE', $callback); |
| | | } |
| | | |
| | | /** |
| | | * Internal method to add HTTP method |
| | | */ |
| | | private function addMethod(string $method, callable|array $callback): self |
| | | { |
| | | // Finalize previous method if exists |
| | | if (!empty($this->currentMethod)) { |
| | | $this->methods[] = $this->currentMethod; |
| | | } |
| | | |
| | | $this->currentMethod = [ |
| | | 'methods' => $method, |
| | | 'callback' => $callback, |
| | | 'permission_callback' => '__return_true', |
| | | 'args' => [], |
| | | ]; |
| | | |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Set authentication/permission requirement |
| | | * |
| | | * @param string|array|false $auth |
| | | * - 'public' or false: Anyone can access |
| | | * - 'user': Logged-in user must match 'user' param in request |
| | | * - 'logged_in': Any logged-in user |
| | | * - 'admin': Users with manage_options capability |
| | | * - 'verified': Users with skip_moderation capability |
| | | * - ['capability' => 'edit_posts']: Specific capability check |
| | | * - ['role' => 'artist']: Specific role check |
| | | * - ['roles' => ['artist', 'admin']]: Multiple roles (OR) |
| | | * - callable: Custom permission callback |
| | | */ |
| | | public function auth(string|array|callable|false $auth): self |
| | | { |
| | | if (empty($this->currentMethod)) { |
| | | return $this; |
| | | } |
| | | |
| | | $this->currentMethod['permission_callback'] = match (true) { |
| | | $auth === false || $auth === 'public' |
| | | => '__return_true', |
| | | |
| | | $auth === 'logged_in' |
| | | => 'is_user_logged_in', |
| | | |
| | | $auth === 'user' |
| | | => [PermissionHandler::class, 'userMatch'], |
| | | |
| | | $auth === 'admin' |
| | | => [PermissionHandler::class, 'isAdmin'], |
| | | |
| | | $auth === 'verified' |
| | | => [PermissionHandler::class, 'isVerified'], |
| | | |
| | | is_callable($auth) |
| | | => $auth, |
| | | |
| | | is_array($auth) && isset($auth['capability']) |
| | | => fn(WP_REST_Request $req) => current_user_can($auth['capability']), |
| | | |
| | | is_array($auth) && isset($auth['role']) |
| | | => fn(WP_REST_Request $req) => PermissionHandler::hasRole($req, $auth['role']), |
| | | |
| | | is_array($auth) && isset($auth['roles']) |
| | | => fn(WP_REST_Request $req) => PermissionHandler::hasAnyRole($req, $auth['roles']), |
| | | |
| | | default |
| | | => '__return_true', |
| | | }; |
| | | |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Add rate limiting to the route |
| | | * |
| | | * @param int $limit Maximum requests |
| | | * @param int $window Time window in seconds |
| | | */ |
| | | public function rateLimit(int $limit = 60, int $window = 60): self |
| | | { |
| | | if (empty($this->currentMethod)) { |
| | | return $this; |
| | | } |
| | | |
| | | $originalCallback = $this->currentMethod['permission_callback']; |
| | | |
| | | $this->currentMethod['permission_callback'] = function(WP_REST_Request $request) use ($originalCallback, $limit, $window) { |
| | | // Initialize rate limiter if needed |
| | | if (self::$rateLimiter === null) { |
| | | self::$rateLimiter = new RateLimiter(); |
| | | } |
| | | |
| | | // Check rate limit first |
| | | if (!self::$rateLimiter->checkLimit($request, $limit, $window)) { |
| | | return new WP_Error( |
| | | 'rate_limit', |
| | | 'Too many requests. Please wait before trying again.', |
| | | ['status' => 429] |
| | | ); |
| | | } |
| | | |
| | | // Then check original permission |
| | | if ($originalCallback === '__return_true') { |
| | | return true; |
| | | } |
| | | |
| | | if (is_callable($originalCallback)) { |
| | | return call_user_func($originalCallback, $request); |
| | | } |
| | | |
| | | return true; |
| | | }; |
| | | |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Require nonce verification |
| | | * |
| | | * @param string $action Nonce action name (default: 'wp_rest') |
| | | * @param string $header Header name containing nonce (default: 'X-WP-Nonce') |
| | | */ |
| | | public function nonce(string $action = 'wp_rest', string $header = 'X-WP-Nonce'): self |
| | | { |
| | | if (empty($this->currentMethod)) { |
| | | return $this; |
| | | } |
| | | |
| | | $originalCallback = $this->currentMethod['permission_callback']; |
| | | |
| | | $this->currentMethod['permission_callback'] = function(WP_REST_Request $request) use ($originalCallback, $action, $header) { |
| | | $nonce = $request->get_header($header); |
| | | |
| | | if (!wp_verify_nonce($nonce, $action)) { |
| | | return new WP_Error( |
| | | 'invalid_nonce', |
| | | 'Invalid or expired security token', |
| | | ['status' => 403] |
| | | ); |
| | | } |
| | | |
| | | // Then check original permission |
| | | if ($originalCallback === '__return_true') { |
| | | return true; |
| | | } |
| | | |
| | | if (is_callable($originalCallback)) { |
| | | return call_user_func($originalCallback, $request); |
| | | } |
| | | |
| | | return true; |
| | | }; |
| | | |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Define route arguments with shorthand syntax |
| | | * |
| | | * @param array $args Argument definitions |
| | | * Shorthand: ['name' => 'type|required|default:value|enum:a,b,c'] |
| | | * Full: ['name' => ['type' => 'string', 'required' => true, ...]] |
| | | * |
| | | * Examples: |
| | | * 'status' => 'string' |
| | | * 'status' => 'string|required' |
| | | * 'status' => 'string|default:all' |
| | | * 'status' => 'string|enum:pending,completed,failed' |
| | | * 'limit' => 'integer|default:50|min:1|max:100' |
| | | * 'ids' => 'array|required' |
| | | */ |
| | | public function args(array $args): self |
| | | { |
| | | if (empty($this->currentMethod)) { |
| | | return $this; |
| | | } |
| | | |
| | | foreach ($args as $name => $definition) { |
| | | $this->currentMethod['args'][$name] = $this->parseArgDefinition($definition); |
| | | } |
| | | |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Add a single argument |
| | | */ |
| | | public function arg(string $name, string|array $definition): self |
| | | { |
| | | if (empty($this->currentMethod)) { |
| | | return $this; |
| | | } |
| | | |
| | | $this->currentMethod['args'][$name] = $this->parseArgDefinition($definition); |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Parse shorthand argument definition into WP REST format |
| | | */ |
| | | private function parseArgDefinition(string|array $definition): array |
| | | { |
| | | // Already full format |
| | | if (is_array($definition)) { |
| | | return $definition; |
| | | } |
| | | |
| | | $parts = explode('|', $definition); |
| | | $type = trim($parts[0]); |
| | | |
| | | $arg = [ |
| | | 'type' => $type, |
| | | 'required' => false, |
| | | ]; |
| | | |
| | | // Add sanitize callback based on type |
| | | $arg['sanitize_callback'] = match ($type) { |
| | | 'integer', 'int' => 'absint', |
| | | 'string' => 'sanitize_text_field', |
| | | 'email' => 'sanitize_email', |
| | | 'url' => 'esc_url_raw', |
| | | 'boolean', 'bool' => 'rest_sanitize_boolean', |
| | | default => null, |
| | | }; |
| | | |
| | | // Normalize type for WP |
| | | if ($type === 'int') { |
| | | $arg['type'] = 'integer'; |
| | | } elseif ($type === 'bool') { |
| | | $arg['type'] = 'boolean'; |
| | | } |
| | | |
| | | // Parse modifiers |
| | | foreach (array_slice($parts, 1) as $part) { |
| | | $part = trim($part); |
| | | |
| | | if ($part === 'required') { |
| | | $arg['required'] = true; |
| | | } elseif (str_starts_with($part, 'default:')) { |
| | | $value = substr($part, 8); |
| | | $arg['default'] = $this->castValue($value, $type); |
| | | } elseif (str_starts_with($part, 'enum:')) { |
| | | $arg['enum'] = array_map('trim', explode(',', substr($part, 5))); |
| | | } elseif (str_starts_with($part, 'min:')) { |
| | | $arg['minimum'] = (int) substr($part, 4); |
| | | } elseif (str_starts_with($part, 'max:')) { |
| | | $arg['maximum'] = (int) substr($part, 4); |
| | | } elseif (str_starts_with($part, 'desc:')) { |
| | | $arg['description'] = substr($part, 5); |
| | | } elseif (str_starts_with($part, 'pattern:')) { |
| | | $arg['pattern'] = substr($part, 8); |
| | | } |
| | | } |
| | | |
| | | // Remove null sanitize callback |
| | | if ($arg['sanitize_callback'] === null) { |
| | | unset($arg['sanitize_callback']); |
| | | } |
| | | |
| | | return $arg; |
| | | } |
| | | |
| | | /** |
| | | * Cast value to appropriate type |
| | | */ |
| | | private function castValue(string $value, string $type): mixed |
| | | { |
| | | return match ($type) { |
| | | 'integer', 'int' => (int) $value, |
| | | 'boolean', 'bool' => filter_var($value, FILTER_VALIDATE_BOOLEAN), |
| | | 'number' => (float) $value, |
| | | 'array' => explode(',', $value), |
| | | default => $value, |
| | | }; |
| | | } |
| | | |
| | | /** |
| | | * Register the route with WordPress |
| | | */ |
| | | public function register(): self |
| | | { |
| | | if ($this->registered) { |
| | | return $this; |
| | | } |
| | | |
| | | // Add current method if not empty |
| | | if (!empty($this->currentMethod)) { |
| | | $this->methods[] = $this->currentMethod; |
| | | $this->currentMethod = []; |
| | | } |
| | | |
| | | if (empty($this->methods)) { |
| | | return $this; |
| | | } |
| | | |
| | | // Register single method or array of methods |
| | | $config = count($this->methods) === 1 ? $this->methods[0] : $this->methods; |
| | | |
| | | register_rest_route(self::$namespace, $this->path, $config); |
| | | |
| | | $this->registered = true; |
| | | |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Auto-register on destruction |
| | | */ |
| | | public function __destruct() |
| | | { |
| | | if (!$this->registered && !empty($this->methods) || !empty($this->currentMethod)) { |
| | | $this->register(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Convert WordPress route pattern to more readable format |
| | | * Converts: queue/{id} to queue/(?P<id>[a-zA-Z0-9_-]+) |
| | | */ |
| | | public static function pattern(string $path, array $patterns = []): string |
| | | { |
| | | $defaults = [ |
| | | 'id' => '[a-zA-Z0-9_-]+', |
| | | 'slug' => '[a-zA-Z0-9_-]+', |
| | | 'type' => '[a-zA-Z_]+', |
| | | 'int' => '[0-9]+', |
| | | ]; |
| | | |
| | | $patterns = array_merge($defaults, $patterns); |
| | | |
| | | return preg_replace_callback('/\{(\w+)(?::(\w+))?\}/', function($matches) use ($patterns) { |
| | | $name = $matches[1]; |
| | | $type = $matches[2] ?? $name; |
| | | $pattern = $patterns[$type] ?? $patterns['id']; |
| | | return "(?P<{$name}>{$pattern})"; |
| | | }, $path); |
| | | } |
| | | } |
| | |
| | | <?php |
| | | use JVBase\utility\Features; |
| | | |
| | | //NEW METHOD |
| | | //require(JVB_DIR . '/inc/rest/Route.php'); |
| | | //require(JVB_DIR . '/inc/rest/PermissionHandler.php'); |
| | | //require(JVB_DIR . '/inc/rest/Response.php'); |
| | | //require(JVB_DIR . '/inc/rest/Rest.php'); //Refactored RestRouteManager.php |
| | | //require(JVB_DIR . '/inc/rest/RateLimits.php'); |
| | | |
| | | //OLD METHOD |
| | | require(JVB_DIR . '/inc/rest/RateLimiter.php'); |
| | | require(JVB_DIR . '/inc/rest/RestRouteManager.php'); |
| | | require(JVB_DIR . '/inc/rest/RegisterRoutes.php'); |
| | |
| | | use DateTimeZone; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; // Exit if accessed directly |
| | | exit; // Exit if accessed directly |
| | | } |
| | | |
| | | class QueueRoutes extends RestRouteManager |
| | | { |
| | | public function __construct() |
| | | { |
| | | $this->cache_name = 'queue'; |
| | | $this->cache_ttl = 300; |
| | | parent::__construct(); |
| | | public function __construct() |
| | | { |
| | | $this->cache_name = 'queue'; |
| | | $this->cache_ttl = 300; |
| | | parent::__construct(); |
| | | |
| | | if (JVB_TESTING) { |
| | | $this->cache->flush(); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Registers queue routes |
| | | * @return void |
| | | */ |
| | | /** |
| | | * Registers queue routes |
| | | * @return void |
| | | */ |
| | | public function registerRoutes():void |
| | | { |
| | | register_rest_route($this->namespace, '/queue', [ |
| | |
| | | ] |
| | | ] |
| | | ]); |
| | | |
| | | register_rest_route($this->namespace, '/queue/errors', [ |
| | | 'methods' => 'GET', |
| | | 'callback' => [$this, 'getOperationErrors'], |
| | | 'permission_callback' => [$this, 'checkPermission'], |
| | | ]); |
| | | |
| | | register_rest_route($this->namespace, '/queue/poll', [ |
| | | 'methods' => 'GET', |
| | | 'callback' => [$this, 'pollQueue'], |
| | | 'permission_callback' => [$this, 'checkPermission'], |
| | | 'args' => [ |
| | | 'since' => [ |
| | | 'type' => 'string', |
| | | 'description' => 'ISO timestamp - only return operations updated after this' |
| | | ], |
| | | 'ids' => [ |
| | | 'type' => 'string', |
| | | 'description' => 'Comma-separated IDs to check' |
| | | ] |
| | | ] |
| | | ]); |
| | | |
| | | register_rest_route($this->namespace, '/queue/(?P<id>[a-zA-Z0-9_-]+)', [ |
| | | 'methods' => 'GET', |
| | | 'callback' => [$this, 'getOperation'], |
| | | 'permission_callback' => [$this, 'checkPermission'], |
| | | 'args' => [ |
| | | 'id' => [ |
| | | 'required' => true, |
| | | 'type' => 'string', |
| | | ] |
| | | ] |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Get queue operations with optional filtering |
| | | * |
| | | * @param WP_REST_Request $request |
| | | * @return WP_REST_Response |
| | | */ |
| | | /** |
| | | * Get queue operations with optional filtering |
| | | * |
| | | * @param WP_REST_Request $request |
| | | * @return WP_REST_Response |
| | | */ |
| | | public function getQueue(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $user_id = $request->get_param('user'); |
| | |
| | | /** |
| | | * Format Operation object for API response |
| | | */ |
| | | protected function formatOperationFromObject(\JVBase\managers\queue\Operation $op, bool $full = false): array |
| | | protected function formatOperationFromObject(\JVBase\managers\queue\Operation $op): array |
| | | { |
| | | $formatted = [ |
| | | 'id' => $op->id, |
| | |
| | | 'status' => $this->mapStateToStatus($op->state, $op->outcome), |
| | | 'progress_count' => $op->processedItems, |
| | | 'count' => $op->totalItems, |
| | | 'title' => $this->getOperationTitle($op->type, $op->requestData), |
| | | 'created_at' => $this->formatTimestamp($op->scheduledAt), |
| | | 'updated_at' => $this->formatTimestamp($op->completedAt ?? $op->startedAt ?? $op->scheduledAt), |
| | | 'retries' => $op->retries, |
| | | 'data' => $op->requestData, |
| | | 'result' => $op->result ?? [], |
| | | ]; |
| | | |
| | | if ($op->processedItems > 0 && $op->totalItems > 0) { |
| | | $formatted['progress_percentage'] = round(($op->processedItems / $op->totalItems) * 100); |
| | | $formatted['created_at'] = $this->formatTimestamp($op->scheduledAt); |
| | | $formatted['updated_at'] = $this->formatTimestamp($op->completedAt ?? $op->startedAt ?? $op->scheduledAt); |
| | | |
| | | if ($op->state === 'completed' && $op->completedAt) { |
| | | $formatted['completed_at'] = $this->formatTimestamp($op->completedAt); |
| | | } |
| | | |
| | | if ($op->errorMessage) { |
| | | $formatted['error_message'] = $op->errorMessage; |
| | | } |
| | | |
| | | // Only include heavy data when requested |
| | | if ($full) { |
| | | $formatted['data'] = $op->requestData; |
| | | $formatted['result'] = $op->result ?? []; |
| | | $formatted['retries'] = $op->retries; |
| | | $formatted['user_dismissed'] = $op->userDismissed; |
| | | |
| | | if ($op->state === 'completed' && $op->completedAt) { |
| | | $formatted['completed_at'] = $this->formatTimestamp($op->completedAt); |
| | | } |
| | | if ($formatted['count'] > 0) { |
| | | $formatted['progress_percentage'] = round( |
| | | ($formatted['progress_count'] / $formatted['count']) * 100 |
| | | ); |
| | | } |
| | | |
| | | $formatted['title'] = $this->getOperationTitle($op->type, $op->requestData); |
| | | $formatted['user_dismissed'] = $op->userDismissed; |
| | | |
| | | return $formatted; |
| | | } |
| | | |
| | |
| | | return $base_title; |
| | | } |
| | | |
| | | /** |
| | | * Update operation status (dismiss or retry) |
| | | * |
| | | * @param WP_REST_Request $request |
| | | * @return WP_REST_Response |
| | | */ |
| | | /** |
| | | * Update operation status (dismiss or retry) |
| | | * |
| | | * @param WP_REST_Request $request |
| | | * @return WP_REST_Response |
| | | */ |
| | | public function handleAction(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $data = $request->get_json_params(); |
| | |
| | | 'cleanup_reason' => null |
| | | ]; |
| | | } |
| | | |
| | | |
| | | public function pollQueue(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $user_id = $request->get_param('user'); |
| | | $since = $request->get_param('since'); |
| | | $ids = $request->get_param('ids'); |
| | | |
| | | $filters = [ |
| | | 'not_dismissed' => true, |
| | | 'limit' => 50, |
| | | ]; |
| | | |
| | | if (!empty($ids)) { |
| | | $filters['ids'] = array_map('trim', explode(',', $ids)); |
| | | } |
| | | |
| | | $operations = JVB()->queue()->getUserOperations($user_id, $filters); |
| | | |
| | | // Filter by timestamp if provided |
| | | if ($since) { |
| | | $sinceTime = strtotime($since); |
| | | $operations = array_filter($operations, function($op) use ($sinceTime) { |
| | | $updatedAt = strtotime($op->completedAt ?? $op->startedAt ?? $op->scheduledAt); |
| | | return $updatedAt > $sinceTime; |
| | | }); |
| | | } |
| | | |
| | | // Return minimal data |
| | | $items = array_map(function($op) { |
| | | return [ |
| | | 'id' => $op->id, |
| | | 'status' => $this->mapStateToStatus($op->state, $op->outcome), |
| | | 'progress_count' => $op->processedItems, |
| | | 'count' => $op->totalItems, |
| | | 'updated_at' => $this->formatTimestamp($op->completedAt ?? $op->startedAt ?? $op->scheduledAt), |
| | | 'error_message' => $op->errorMessage, |
| | | ]; |
| | | }, $operations); |
| | | |
| | | return new WP_REST_Response([ |
| | | 'items' => array_values($items), |
| | | 'server_time' => date('c'), |
| | | 'has_active' => count(array_filter($items, fn($i) => in_array($i['status'], ['pending', 'processing']))) > 0, |
| | | ]); |
| | | } |
| | | |
| | | public function getOperation(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $id = $request->get_param('id'); |
| | | $user_id = $request->get_param('user'); |
| | | |
| | | $op = JVB()->queue()->get($id); |
| | | |
| | | if (!$op || $op->userId !== $user_id) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Operation not found' |
| | | ], 404); |
| | | } |
| | | |
| | | return new WP_REST_Response([ |
| | | 'success' => true, |
| | | 'operation' => $this->formatOperationFromObject($op, true) // Full data |
| | | ]); |
| | | } |
| | | } |
| | |
| | | )); |
| | | |
| | | // Metadata updates |
| | | $registry->register('update_metadata', new TypeConfig( |
| | | $registry->register('update_image_meta', new TypeConfig( |
| | | executor: $executor |
| | | )); |
| | | |
| | |
| | | } |
| | | $pending = []; |
| | | $attachments = array_filter($items, function ($item) { |
| | | return array_key_exists('attachmentId', $item); |
| | | return array_key_exists('attachmentId', $item) || array_key_exists('uploadId', $item); |
| | | }); |
| | | if (count($attachments) !== count($items)) { |
| | | $pending = array_filter($items, function ($item) { |
| | | return array_key_exists('uploadId',$item); |
| | | }); |
| | | } |
| | | |
| | | |
| | | if (!empty($attachments)) { |
| | | // Phase 2B: Direct attachment update (images already processed) |
| | | return $this->updateMeta($attachments, $data['user']); |
| | | error_log('Attachments: '.print_r($attachments, true)); |
| | | return $this->queueMetaUpdate($attachments, $data['user']); |
| | | } |
| | | elseif (!empty($pending)) { |
| | | // Phase 2A: Queue metadata update with dependency on upload operation |
| | | return $this->queueMetaUpdate($pending, $data['user']); |
| | | } |
| | | |
| | | |
| | | return $this->sendResponse( |
| | | false, |
| | |
| | | foreach ($data as $info) { |
| | | try { |
| | | $attachment_id = $info['attachmentId']; |
| | | error_log('Updating attachment ID:'.print_r($attachment_id,true)); |
| | | $ids[] = $attachment_id; |
| | | unset($info['attachmentId']); |
| | | // Verify attachment exists and user has permission |
| | |
| | | $errors = []; |
| | | $original = count($data); |
| | | foreach ($data as $uploadID => $info) { |
| | | if (!array_key_exists('depends_on', $info)) { |
| | | unset($data[$uploadID]); |
| | | $errors[$uploadID] = $info; |
| | | continue; |
| | | } |
| | | if (!in_array($info['depends_on'], $depends_on)) { |
| | | if (array_key_exists('depends_on', $info) && !in_array($info['depends_on'], $depends_on)) { |
| | | $depends_on[] = $info['depends_on']; |
| | | } |
| | | } |
| | | $operationID = $queue->queueOperation( |
| | | 'update_metadata', |
| | | 'update_image_meta', |
| | | $user, |
| | | $data, |
| | | [ |
| | | 'depends_on' => $depends_on, |
| | | 'priority' => 'medium', |
| | | ] |
| | | ); |
| | | |
| | |
| | | $errors = []; |
| | | foreach ($operation->depends_on as $dependency) { |
| | | $operationData = JVB()->queue()->getOperation($dependency); |
| | | if (!$operationData || $operationData->status !== 'completed') { |
| | | if (!$operationData || $operationData->state !== 'completed') { |
| | | throw new Exception('Original upload operation not found or not completed'); |
| | | } |
| | | |
| | |
| | | ]; |
| | | |
| | | if (afterEl) { |
| | | afterEl.textContent = `After ${item.number} Tx`; |
| | | afterEl.textContent = `After ${item.number - 1} Tx`; |
| | | } |
| | | if (number) { |
| | | number.textContent = item.fields.number; |
| | | number.textContent = item.number - 1; |
| | | } |
| | | if (started) { |
| | | this.formatTimeField(started, item.fields.timeline[0]['post_date']); |