Jake Vanderwerf
2026-01-29 e6672fe38ce5d99f3b3f026154f777aded7361de
=Starting refactor of Meta and Routes to fluent-style
8 files modified
9 files added
2626 ■■■■■ changed files
build/feed/view.asset.php 2 ●●● patch | view | raw | blame | history
build/feed/view.js 2 ●●● patch | view | raw | blame | history
inc/managers/queue/executors/UploadExecutor.php 50 ●●●●● patch | view | raw | blame | history
inc/meta/Field.php 84 ●●●●● patch | view | raw | blame | history
inc/meta/Item.php 115 ●●●●● patch | view | raw | blame | history
inc/meta/Meta.php 399 ●●●●● patch | view | raw | blame | history
inc/meta/Storage.php 305 ●●●●● patch | view | raw | blame | history
inc/meta/_setup.php 17 ●●●● patch | view | raw | blame | history
inc/rest/PermissionHandler.php 514 ●●●●● patch | view | raw | blame | history
inc/rest/RateLimits.php patch | view | raw | blame | history
inc/rest/Response.php 437 ●●●●● patch | view | raw | blame | history
inc/rest/Rest.php patch | view | raw | blame | history
inc/rest/Route.php 480 ●●●●● patch | view | raw | blame | history
inc/rest/_setup.php 8 ●●●●● patch | view | raw | blame | history
inc/rest/routes/QueueRoutes.php 178 ●●●● patch | view | raw | blame | history
inc/rest/routes/UploadRoutes.php 31 ●●●● patch | view | raw | blame | history
src/feed/view.js 4 ●●●● patch | view | raw | blame | history
build/feed/view.asset.php
@@ -1 +1 @@
<?php return array('dependencies' => array(), 'version' => '1589cfb61e8639162b4c');
<?php return array('dependencies' => array(), 'version' => '1ef4e221261df2cef9a9');
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)}))}))})();
(()=>{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)}))}))})();
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']),
            ]);
        }
    }
inc/meta/Field.php
New file
@@ -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']);
    }
}
inc/meta/Item.php
New file
@@ -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());
    }
}
inc/meta/Meta.php
New file
@@ -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 => '',
        };
    }
}
inc/meta/Storage.php
New file
@@ -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
        };
    }
}
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');
inc/rest/PermissionHandler.php
New file
@@ -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;
    }
}
inc/rest/RateLimits.php
inc/rest/Response.php
New file
@@ -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 {}
inc/rest/Rest.php
inc/rest/Route.php
New file
@@ -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);
    }
}
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');
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
        ]);
    }
}
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');
                }
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']);