From e6672fe38ce5d99f3b3f026154f777aded7361de Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Thu, 29 Jan 2026 17:36:53 +0000
Subject: [PATCH] =Starting refactor of Meta and Routes to fluent-style
---
inc/managers/queue/executors/UploadExecutor.php | 50
inc/meta/Meta.php | 399 +++++++++
inc/meta/Storage.php | 305 ++++++
build/feed/view.asset.php | 2
inc/rest/Route.php | 480 ++++++++++
inc/rest/PermissionHandler.php | 514 +++++++++++
inc/rest/Response.php | 437 +++++++++
inc/rest/RateLimits.php | 0
inc/rest/Rest.php | 0
build/feed/view.js | 2
inc/meta/_setup.php | 17
inc/rest/_setup.php | 8
inc/rest/routes/UploadRoutes.php | 31
inc/meta/Field.php | 84 +
src/feed/view.js | 4
inc/rest/routes/QueueRoutes.php | 178 ---
inc/meta/Item.php | 115 ++
17 files changed, 2,437 insertions(+), 189 deletions(-)
diff --git a/build/feed/view.asset.php b/build/feed/view.asset.php
index 90ed37f..c6293e9 100644
--- a/build/feed/view.asset.php
+++ b/build/feed/view.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array(), 'version' => '1589cfb61e8639162b4c');
+<?php return array('dependencies' => array(), 'version' => '1ef4e221261df2cef9a9');
diff --git a/build/feed/view.js b/build/feed/view.js
index 4245a34..f1acc13 100644
--- a/build/feed/view.js
+++ b/build/feed/view.js
@@ -1 +1 @@
-(()=>{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)}))}))})();
\ No newline at end of file
+(()=>{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)}))}))})();
\ No newline at end of file
diff --git a/inc/managers/queue/executors/UploadExecutor.php b/inc/managers/queue/executors/UploadExecutor.php
index 7461cfd..ab2f992 100644
--- a/inc/managers/queue/executors/UploadExecutor.php
+++ b/inc/managers/queue/executors/UploadExecutor.php
@@ -14,7 +14,7 @@
/**
* 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
{
@@ -22,7 +22,7 @@
'image_upload',
'video_upload',
'document_upload',
- 'update_metadata',
+ 'update_image_meta',
'temporary_cleanup',
'attach_upload_to_content',
'process_upload_groups'
@@ -41,7 +41,7 @@
'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),
@@ -176,29 +176,39 @@
/**
* 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++;
@@ -484,21 +494,21 @@
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']),
]);
}
}
diff --git a/inc/meta/Field.php b/inc/meta/Field.php
new file mode 100644
index 0000000..0efb8e3
--- /dev/null
+++ b/inc/meta/Field.php
@@ -0,0 +1,84 @@
+<?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']);
+ }
+}
diff --git a/inc/meta/Item.php b/inc/meta/Item.php
new file mode 100644
index 0000000..33ae50c
--- /dev/null
+++ b/inc/meta/Item.php
@@ -0,0 +1,115 @@
+<?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());
+ }
+}
diff --git a/inc/meta/Meta.php b/inc/meta/Meta.php
new file mode 100644
index 0000000..05810e6
--- /dev/null
+++ b/inc/meta/Meta.php
@@ -0,0 +1,399 @@
+<?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 => '',
+ };
+ }
+}
diff --git a/inc/meta/Storage.php b/inc/meta/Storage.php
new file mode 100644
index 0000000..b2ea8ac
--- /dev/null
+++ b/inc/meta/Storage.php
@@ -0,0 +1,305 @@
+<?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
+ };
+ }
+}
diff --git a/inc/meta/_setup.php b/inc/meta/_setup.php
index 5af0e09..8e84bde 100644
--- a/inc/meta/_setup.php
+++ b/inc/meta/_setup.php
@@ -1,8 +1,17 @@
<?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');
diff --git a/inc/rest/PermissionHandler.php b/inc/rest/PermissionHandler.php
new file mode 100644
index 0000000..409d479
--- /dev/null
+++ b/inc/rest/PermissionHandler.php
@@ -0,0 +1,514 @@
+<?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;
+ }
+}
diff --git a/inc/rest/RateLimits.php b/inc/rest/RateLimits.php
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/inc/rest/RateLimits.php
diff --git a/inc/rest/Response.php b/inc/rest/Response.php
new file mode 100644
index 0000000..d77980d
--- /dev/null
+++ b/inc/rest/Response.php
@@ -0,0 +1,437 @@
+<?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 {}
diff --git a/inc/rest/Rest.php b/inc/rest/Rest.php
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/inc/rest/Rest.php
diff --git a/inc/rest/Route.php b/inc/rest/Route.php
new file mode 100644
index 0000000..2350eea
--- /dev/null
+++ b/inc/rest/Route.php
@@ -0,0 +1,480 @@
+<?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);
+ }
+}
diff --git a/inc/rest/_setup.php b/inc/rest/_setup.php
index e228477..b94d38f 100644
--- a/inc/rest/_setup.php
+++ b/inc/rest/_setup.php
@@ -1,6 +1,14 @@
<?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');
diff --git a/inc/rest/routes/QueueRoutes.php b/inc/rest/routes/QueueRoutes.php
index 400717b..3654ae0 100644
--- a/inc/rest/routes/QueueRoutes.php
+++ b/inc/rest/routes/QueueRoutes.php
@@ -11,26 +11,26 @@
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', [
@@ -79,48 +79,14 @@
]
]
]);
-
- 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');
@@ -209,7 +175,7 @@
/**
* 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,
@@ -217,31 +183,31 @@
'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;
}
@@ -276,12 +242,12 @@
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();
@@ -436,70 +402,4 @@
'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
- ]);
- }
}
diff --git a/inc/rest/routes/UploadRoutes.php b/inc/rest/routes/UploadRoutes.php
index 1da0412..c0016bf 100644
--- a/inc/rest/routes/UploadRoutes.php
+++ b/inc/rest/routes/UploadRoutes.php
@@ -57,7 +57,7 @@
));
// Metadata updates
- $registry->register('update_metadata', new TypeConfig(
+ $registry->register('update_image_meta', new TypeConfig(
executor: $executor
));
@@ -797,23 +797,15 @@
}
$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,
@@ -849,6 +841,7 @@
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
@@ -887,22 +880,16 @@
$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',
]
);
@@ -929,7 +916,7 @@
$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');
}
diff --git a/src/feed/view.js b/src/feed/view.js
index 1248322..c9b7d59 100644
--- a/src/feed/view.js
+++ b/src/feed/view.js
@@ -638,10 +638,10 @@
];
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']);
--
Gitblit v1.10.0