From 7a961049a67d5d6d084bde0897c43afd64b751fd Mon Sep 17 00:00:00 2001 From: Stuart Rowlands Date: Sun, 15 Nov 2026 15:53:21 +1000 Subject: [PATCH 1/6] fix(sync): short messages and in-run retries when the Quant API stalls When the Quant API did not answer, a push failed with raw transport text such as "cURL error 28: Operation timed out after 30001 milliseconds with 0 bytes received", shown as-is in the dashboard queue card and the push progress. Each job got one attempt per run, and the queue only retried it when WP-Cron ran. - WordPressHttpClient maps WP_Error failures (timeout, DNS, TLS, connection) to a short message and keeps the raw text as a detail. QuantContentApi does the same for 5xx answers. - Publish and upload (idempotent, keyed by URL) go through a RetryPolicy: at most 2 retries after about 1 s and 3 s plus jitter, only for a transport failure or 502/503/504, and no retry starts after 45 s. Delays, sleep and clock are injectable. - The queue stores the message and the detail separately (new error_detail column, DB_VERSION 2.0.6, added by dbDelta on upgrade). The cards show only the message; the diagnostics report, the diagnostics API section and wp quant queue errors keep the detail. - A JSON publish body of 512 KB or more gets the 120 s upload minimum. - Readme: QUANT_HTTP_REQUEST_TIMEOUT, an FAQ entry, and the 2.0.6 changelog. --- plugins/quant/assets/build/index.asset.php | 2 +- plugins/quant/assets/build/index.js | 14 +- plugins/quant/assets/src/pages/Dashboard.jsx | 7 +- .../quant/assets/src/pages/Diagnostics.jsx | 17 +- plugins/quant/assets/src/pages/queueErrors.js | 52 +++++++ .../assets/src/pages/test/queueErrors.test.js | 65 ++++++++ plugins/quant/readme.txt | 17 ++ .../quant/src/Admin/DiagnosticsController.php | 3 +- plugins/quant/src/Api/ApiResponse.php | 19 ++- plugins/quant/src/Api/HttpClientInterface.php | 16 +- plugins/quant/src/Api/QuantContentApi.php | 81 ++++++++-- plugins/quant/src/Api/RetryPolicy.php | 105 +++++++++++++ plugins/quant/src/Api/TransportError.php | 75 +++++++++ plugins/quant/src/Api/WordPressHttpClient.php | 35 ++++- plugins/quant/src/CLI/Commands.php | 6 +- plugins/quant/src/Core/Activator.php | 4 +- plugins/quant/src/Sync/DatabaseQueue.php | 23 ++- .../src/Sync/Exception/QuantApiException.php | 34 ++++ plugins/quant/src/Sync/FileSyncResult.php | 9 +- .../quant/src/Sync/Handler/FileHandler.php | 7 +- .../quant/src/Sync/Handler/PurgeHandler.php | 4 +- .../src/Sync/Handler/RedirectHandler.php | 5 +- .../src/Sync/Handler/RoutePublishHandler.php | 3 +- .../src/Sync/Handler/SearchRecordHandler.php | 4 +- plugins/quant/src/Sync/JobProcessor.php | 4 +- plugins/quant/src/Sync/MediaSync.php | 2 +- plugins/quant/src/Sync/Publisher.php | 11 +- plugins/quant/src/Sync/SyncQueueInterface.php | 7 +- plugins/quant/src/Sync/ThemeAssetSync.php | 2 +- .../quant/tests/Api/QuantContentApiTest.php | 129 +++++++++++++++- plugins/quant/tests/Api/RetryPolicyTest.php | 145 ++++++++++++++++++ .../quant/tests/Api/TransportErrorTest.php | 85 ++++++++++ .../tests/Api/WordPressHttpClientTest.php | 44 +++++- .../quant/tests/Sync/DatabaseQueueTest.php | 38 ++++- .../tests/Sync/Handler/FileHandlerTest.php | 35 +++++ plugins/quant/tests/Sync/JobProcessorTest.php | 26 +++- plugins/quant/tests/Sync/PublisherTest.php | 32 ++++ 37 files changed, 1068 insertions(+), 99 deletions(-) create mode 100644 plugins/quant/assets/src/pages/queueErrors.js create mode 100644 plugins/quant/assets/src/pages/test/queueErrors.test.js create mode 100644 plugins/quant/src/Api/RetryPolicy.php create mode 100644 plugins/quant/src/Api/TransportError.php create mode 100644 plugins/quant/src/Sync/Exception/QuantApiException.php create mode 100644 plugins/quant/tests/Api/RetryPolicyTest.php create mode 100644 plugins/quant/tests/Api/TransportErrorTest.php diff --git a/plugins/quant/assets/build/index.asset.php b/plugins/quant/assets/build/index.asset.php index 761173b..271cb98 100644 --- a/plugins/quant/assets/build/index.asset.php +++ b/plugins/quant/assets/build/index.asset.php @@ -1 +1 @@ - array('react', 'react-dom', 'wp-api-fetch', 'wp-components', 'wp-element', 'wp-i18n'), 'version' => '8039e1392598e54e4aad'); + array('react', 'react-dom', 'wp-api-fetch', 'wp-components', 'wp-element', 'wp-i18n'), 'version' => '7b36526c62d4035cb023'); diff --git a/plugins/quant/assets/build/index.js b/plugins/quant/assets/build/index.js index 684bb53..c5757c2 100644 --- a/plugins/quant/assets/build/index.js +++ b/plugins/quant/assets/build/index.js @@ -1,18 +1,18 @@ -(()=>{"use strict";var e={n:t=>{var a=t&&t.__esModule?()=>t.default:()=>t;return e.d(a,{a}),a},d:(t,a)=>{for(var n in a)e.o(a,n)&&!e.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:a[n]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t)};const t=window.React,a=window.wp.element,n=window.ReactDOM;function l(){return l=Object.assign?Object.assign.bind():function(e){for(var t=1;t=0&&(t.hash=e.substr(a),e=e.substr(0,a));let n=e.indexOf("?");n>=0&&(t.search=e.substr(n),e=e.substr(0,n)),e&&(t.pathname=e)}return t}var p;function _(e,t,a){return void 0===a&&(a="/"),function(e,t,a,n){let l=N(("string"==typeof t?d(t):t).pathname||"/",a);if(null==l)return null;let r=h(e);!function(e){e.sort((e,t)=>e.score!==t.score?t.score-e.score:function(e,t){return e.length===t.length&&e.slice(0,-1).every((e,a)=>e===t[a])?e[e.length-1]-t[t.length-1]:0}(e.routesMeta.map(e=>e.childrenIndex),t.routesMeta.map(e=>e.childrenIndex)))}(r);let s=null,o=function(e){try{return e.split("/").map(e=>decodeURIComponent(e).replace(/\//g,"%2F")).join("/")}catch(t){return i(!1,'The URL path "'+e+'" could not be decoded because it is is a malformed URL segment. This is probably due to a bad percent encoding ('+t+")."),e}}(l);for(let e=0;null==s&&e{let s={relativePath:void 0===r?e.path||"":r,caseSensitive:!0===e.caseSensitive,childrenIndex:l,route:e};s.relativePath.startsWith("/")&&(o(s.relativePath.startsWith(n),'Absolute route path "'+s.relativePath+'" nested under path "'+n+'" is not valid. An absolute child route path must start with the combined path of all its parent routes.'),s.relativePath=s.relativePath.slice(n.length));let i=R([n,s.relativePath]),c=a.concat(s);e.children&&e.children.length>0&&(o(!0!==e.index,'Index routes must not have child routes. Please remove all child routes from route path "'+i+'".'),h(e.children,t,c,i)),(null!=e.path||e.index)&&t.push({path:i,score:x(i,e.index),routesMeta:c})};return e.forEach((e,t)=>{var a;if(""!==e.path&&null!=(a=e.path)&&a.includes("?"))for(let a of g(e.path))l(e,t,a);else l(e,t)}),t}function g(e){let t=e.split("/");if(0===t.length)return[];let[a,...n]=t,l=a.endsWith("?"),r=a.replace(/\?$/,"");if(0===n.length)return l?[r,""]:[r];let s=g(n.join("/")),o=[];return o.push(...s.map(e=>""===e?r:[r,e].join("/"))),l&&o.push(...s),o.map(t=>e.startsWith("/")&&""===t?"/":t)}!function(e){e.data="data",e.deferred="deferred",e.redirect="redirect",e.error="error"}(p||(p={})),new Set(["lazy","caseSensitive","path","id","index","children"]);const E=/^:[\w-]+$/,y=3,f=2,v=1,q=10,b=-2,C=e=>"*"===e;function x(e,t){let a=e.split("/"),n=a.length;return a.some(C)&&(n+=b),t&&(n+=f),a.filter(e=>!C(e)).reduce((e,t)=>e+(E.test(t)?y:""===t?v:q),n)}function w(e,t,a){void 0===a&&(a=!1);let{routesMeta:n}=e,l={},r="/",s=[];for(let e=0;e(n.push({paramName:t,isOptional:null!=a}),a?"/?([^\\/]+)?":"/([^\\/]+)"));return e.endsWith("*")?(n.push({paramName:"*"}),l+="*"===e||"/*"===e?"(.*)$":"(?:\\/(.+)|\\/*)$"):a?l+="\\/*$":""!==e&&"/"!==e&&(l+="(?:(?=\\/|$))"),[new RegExp(l,t?void 0:"i"),n]}(e.path,e.caseSensitive,e.end),l=t.match(a);if(!l)return null;let r=l[0],s=r.replace(/(.)\/+$/,"$1"),o=l.slice(1);return{params:n.reduce((e,t,a)=>{let{paramName:n,isOptional:l}=t;if("*"===n){let e=o[a]||"";s=r.slice(0,r.length-e.length).replace(/(.)\/+$/,"$1")}const i=o[a];return e[n]=l&&!i?void 0:(i||"").replace(/%2F/g,"/"),e},{}),pathname:r,pathnameBase:s,pattern:e}}function N(e,t){if("/"===t)return e;if(!e.toLowerCase().startsWith(t.toLowerCase()))return null;let a=t.endsWith("/")?t.length-1:t.length,n=e.charAt(a);return n&&"/"!==n?null:e.slice(a)||"/"}function k(e,t){let a=t.replace(/\/+$/,"").split("/");return e.split("/").forEach(e=>{".."===e?a.length>1&&a.pop():"."!==e&&a.push(e)}),a.length>1?a.join("/"):"/"}function T(e,t,a,n){return"Cannot include a '"+e+"' character in a manually specified `to."+t+"` field ["+JSON.stringify(n)+"]. Please separate it out to the `to."+a+'` field. Alternatively you may provide the full path as a string in and the router will parse it for you.'}function B(e,t){let a=function(e){return e.filter((e,t)=>0===t||e.route.path&&e.route.path.length>0)}(e);return t?a.map((e,t)=>t===a.length-1?e.pathname:e.pathnameBase):a.map(e=>e.pathnameBase)}function P(e,t,a,n){let r;void 0===n&&(n=!1),"string"==typeof e?r=d(e):(r=l({},e),o(!r.pathname||!r.pathname.includes("?"),T("?","pathname","search",r)),o(!r.pathname||!r.pathname.includes("#"),T("#","pathname","hash",r)),o(!r.search||!r.search.includes("#"),T("#","search","hash",r)));let s,i=""===e||""===r.pathname,c=i?"/":r.pathname;if(null==c)s=a;else{let e=t.length-1;if(!n&&c.startsWith("..")){let t=c.split("/");for(;".."===t[0];)t.shift(),e-=1;r.pathname=t.join("/")}s=e>=0?t[e]:"/"}let u=function(e,t){void 0===t&&(t="/");let a,{pathname:n,search:l="",hash:r=""}="string"==typeof e?d(e):e;return n?(n=D(n),a=n.startsWith("/")?k(n.substring(1),"/"):k(n,t)):a=t,{pathname:a,search:j(l),hash:A(r)}}(r,s),m=c&&"/"!==c&&c.endsWith("/"),p=(i||"."===c)&&a.endsWith("/");return u.pathname.endsWith("/")||!m&&!p||(u.pathname+="/"),u}const D=e=>e.replace(/\/\/+/g,"/"),R=e=>D(e.join("/")),M=e=>e.replace(/\/+$/,"").replace(/^\/*/,"/"),j=e=>e&&"?"!==e?e.startsWith("?")?e:"?"+e:"",A=e=>e&&"#"!==e?e.startsWith("#")?e:"#"+e:"";Error;const F=["post","put","patch","delete"],O=(new Set(F),["get",...F]);function H(){return H=Object.assign?Object.assign.bind():function(e){for(var t=1;t{n.current=!0}),t.useCallback(function(t,l){void 0===l&&(l={}),n.current&&("number"==typeof t?e.navigate(t):e.navigate(t,H({fromRouteId:a},l)))},[e,a])}():function(){W()||o(!1);let e=t.useContext($),{basename:a,future:n,navigator:l}=t.useContext(I),{matches:r}=t.useContext(L),{pathname:s}=Y(),i=JSON.stringify(B(r,n.v7_relativeSplatPath)),c=t.useRef(!1);return V(()=>{c.current=!0}),t.useCallback(function(t,n){if(void 0===n&&(n={}),!c.current)return;if("number"==typeof t)return void l.go(t);let r=P(t,JSON.parse(i),s,"path"===n.relative);null==e&&"/"!==a&&(r.pathname="/"===r.pathname?a:R([a,r.pathname])),(n.replace?l.replace:l.push)(r,n.state,n)},[a,l,i,s,e])}()}function J(e,a){let{relative:n}=void 0===a?{}:a,{future:l}=t.useContext(I),{matches:r}=t.useContext(L),{pathname:s}=Y(),o=JSON.stringify(B(r,l.v7_relativeSplatPath));return t.useMemo(()=>P(e,JSON.parse(o),s,"path"===n),[e,o,s,n])}function K(e,a,n,l){W()||o(!1);let{navigator:s}=t.useContext(I),{matches:i}=t.useContext(L),c=i[i.length-1],u=c?c.params:{},m=(c&&c.pathname,c?c.pathnameBase:"/");c&&c.route;let p,h=Y();if(a){var g;let e="string"==typeof a?d(a):a;"/"===m||(null==(g=e.pathname)?void 0:g.startsWith(m))||o(!1),p=e}else p=h;let E=p.pathname||"/",y=E;if("/"!==m){let e=m.replace(/^\//,"").split("/");y="/"+E.replace(/^\//,"").split("/").slice(e.length).join("/")}let f=_(e,{pathname:y}),v=function(e,a,n,l){var r;if(void 0===a&&(a=[]),void 0===n&&(n=null),void 0===l&&(l=null),null==e){var s;if(!n)return null;if(n.errors)e=n.matches;else{if(!(null!=(s=l)&&s.v7_partialHydration&&0===a.length&&!n.initialized&&n.matches.length>0))return null;e=n.matches}}let i=e,c=null==(r=n)?void 0:r.errors;if(null!=c){let e=i.findIndex(e=>e.route.id&&void 0!==(null==c?void 0:c[e.route.id]));e>=0||o(!1),i=i.slice(0,Math.min(i.length,e+1))}let u=!1,m=-1;if(n&&l&&l.v7_partialHydration)for(let e=0;e=0?i.slice(0,m+1):[i[0]];break}}}return i.reduceRight((e,l,r)=>{let s,o=!1,d=null,p=null;var _;n&&(s=c&&l.route.id?c[l.route.id]:void 0,d=l.route.errorElement||Z,u&&(m<0&&0===r?(re[_="route-fallback"]||(re[_]=!0),o=!0,p=null):m===r&&(o=!0,p=l.route.hydrateFallbackElement||null)));let h=a.concat(i.slice(0,r+1)),g=()=>{let a;return a=s?d:o?p:l.route.Component?t.createElement(l.route.Component,null):l.route.element?l.route.element:e,t.createElement(te,{match:l,routeContext:{outlet:e,matches:h,isDataRoute:null!=n},children:a})};return n&&(l.route.ErrorBoundary||l.route.errorElement||0===r)?t.createElement(ee,{location:n.location,revalidation:n.revalidation,component:d,error:s,children:g(),routeContext:{outlet:null,matches:h,isDataRoute:!0}}):g()},null)}(f&&f.map(e=>Object.assign({},e,{params:Object.assign({},u,e.params),pathname:R([m,s.encodeLocation?s.encodeLocation(e.pathname).pathname:e.pathname]),pathnameBase:"/"===e.pathnameBase?m:R([m,s.encodeLocation?s.encodeLocation(e.pathnameBase).pathname:e.pathnameBase])})),i,n,l);return a&&v?t.createElement(Q.Provider,{value:{location:H({pathname:"/",search:"",hash:"",state:null,key:"default"},p),navigationType:r.Pop}},v):v}function X(){let e=function(){var e;let a=t.useContext(U),n=function(){let e=t.useContext(z);return e||o(!1),e}(ne.UseRouteError),l=le(ne.UseRouteError);return void 0!==a?a:null==(e=n.errors)?void 0:e[l]}(),a=function(e){return null!=e&&"number"==typeof e.status&&"string"==typeof e.statusText&&"boolean"==typeof e.internal&&"data"in e}(e)?e.status+" "+e.statusText:e instanceof Error?e.message:JSON.stringify(e),n=e instanceof Error?e.stack:null,l={padding:"0.5rem",backgroundColor:"rgba(200,200,200, 0.5)"};return t.createElement(t.Fragment,null,t.createElement("h2",null,"Unexpected Application Error!"),t.createElement("h3",{style:{fontStyle:"italic"}},a),n?t.createElement("pre",{style:l},n):null,null)}const Z=t.createElement(X,null);class ee extends t.Component{constructor(e){super(e),this.state={location:e.location,revalidation:e.revalidation,error:e.error}}static getDerivedStateFromError(e){return{error:e}}static getDerivedStateFromProps(e,t){return t.location!==e.location||"idle"!==t.revalidation&&"idle"===e.revalidation?{error:e.error,location:e.location,revalidation:e.revalidation}:{error:void 0!==e.error?e.error:t.error,location:t.location,revalidation:e.revalidation||t.revalidation}}componentDidCatch(e,t){console.error("React Router caught the following error during render",e,t)}render(){return void 0!==this.state.error?t.createElement(L.Provider,{value:this.props.routeContext},t.createElement(U.Provider,{value:this.state.error,children:this.props.component})):this.props.children}}function te(e){let{routeContext:a,match:n,children:l}=e,r=t.useContext($);return r&&r.static&&r.staticContext&&(n.route.errorElement||n.route.ErrorBoundary)&&(r.staticContext._deepestRenderedBoundaryId=n.route.id),t.createElement(L.Provider,{value:a},l)}var ae=function(e){return e.UseBlocker="useBlocker",e.UseRevalidator="useRevalidator",e.UseNavigateStable="useNavigate",e}(ae||{}),ne=function(e){return e.UseBlocker="useBlocker",e.UseLoaderData="useLoaderData",e.UseActionData="useActionData",e.UseRouteError="useRouteError",e.UseNavigation="useNavigation",e.UseRouteLoaderData="useRouteLoaderData",e.UseMatches="useMatches",e.UseRevalidator="useRevalidator",e.UseNavigateStable="useNavigate",e.UseRouteId="useRouteId",e}(ne||{});function le(e){let a=function(){let e=t.useContext(L);return e||o(!1),e}(),n=a.matches[a.matches.length-1];return n.route.id||o(!1),n.route.id}const re={};function se(e){let{to:a,replace:n,state:l,relative:r}=e;W()||o(!1);let{future:s,static:i}=t.useContext(I),{matches:c}=t.useContext(L),{pathname:u}=Y(),m=G(),d=P(a,B(c,s.v7_relativeSplatPath),u,"path"===r),p=JSON.stringify(d);return t.useEffect(()=>m(JSON.parse(p),{replace:n,state:l,relative:r}),[m,p,r,n,l]),null}function oe(e){o(!1)}function ie(e){let{basename:a="/",children:n=null,location:l,navigationType:s=r.Pop,navigator:i,static:c=!1,future:u}=e;W()&&o(!1);let m=a.replace(/^\/*/,"/"),p=t.useMemo(()=>({basename:m,navigator:i,static:c,future:H({v7_relativeSplatPath:!1},u)}),[m,u,i,c]);"string"==typeof l&&(l=d(l));let{pathname:_="/",search:h="",hash:g="",state:E=null,key:y="default"}=l,f=t.useMemo(()=>{let e=N(_,m);return null==e?null:{location:{pathname:e,search:h,hash:g,state:E,key:y},navigationType:s}},[m,_,h,g,E,y,s]);return null==f?null:t.createElement(I.Provider,{value:p},t.createElement(Q.Provider,{children:n,value:f}))}function ce(e){let{children:t,location:a}=e;return K(ue(t),a)}function ue(e,a){void 0===a&&(a=[]);let n=[];return t.Children.forEach(e,(e,l)=>{if(!t.isValidElement(e))return;let r=[...a,l];if(e.type===t.Fragment)return void n.push.apply(n,ue(e.props.children,r));e.type!==oe&&o(!1),e.props.index&&e.props.children&&o(!1);let s={id:e.props.id||r.join("-"),caseSensitive:e.props.caseSensitive,element:e.props.element,Component:e.props.Component,index:e.props.index,path:e.props.path,loader:e.props.loader,action:e.props.action,errorElement:e.props.errorElement,ErrorBoundary:e.props.ErrorBoundary,hasErrorBoundary:null!=e.props.ErrorBoundary||null!=e.props.errorElement,shouldRevalidate:e.props.shouldRevalidate,handle:e.props.handle,lazy:e.props.lazy};e.props.children&&(s.children=ue(e.props.children,r)),n.push(s)}),n}function me(){return me=Object.assign?Object.assign.bind():function(e){for(var t=1;t{}),t.Component,new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);const pe=["onClick","relative","reloadDocument","replace","state","target","to","preventScrollReset","viewTransition"],_e=["aria-current","caseSensitive","className","end","style","to","viewTransition","children"];try{window.__reactRouterVersion="6"}catch(e){}const he=t.createContext({isTransitioning:!1});new Map;const ge=t.startTransition;function Ee(e){let{basename:a,children:n,future:p,window:_}=e,h=t.useRef();var g;null==h.current&&(h.current=(void 0===(g={window:_,v5Compat:!0})&&(g={}),function(e,t,a,n){void 0===n&&(n={});let{window:i=document.defaultView,v5Compat:d=!1}=n,p=i.history,_=r.Pop,h=null,g=E();function E(){return(p.state||{idx:null}).idx}function y(){_=r.Pop;let e=E(),t=null==e?null:e-g;g=e,h&&h({action:_,location:v.location,delta:t})}function f(e){let t="null"!==i.location.origin?i.location.origin:i.location.href,a="string"==typeof e?e:m(e);return a=a.replace(/ $/,"%20"),o(t,"No window.location.(origin|href) available to create URL for href: "+a),new URL(a,t)}null==g&&(g=0,p.replaceState(l({},p.state,{idx:g}),""));let v={get action(){return _},get location(){return e(i,p)},listen(e){if(h)throw new Error("A history only accepts one active listener");return i.addEventListener(s,y),h=e,()=>{i.removeEventListener(s,y),h=null}},createHref:e=>t(i,e),createURL:f,encodeLocation(e){let t=f(e);return{pathname:t.pathname,search:t.search,hash:t.hash}},push:function(e,t){_=r.Push;let n=u(v.location,e,t);a&&a(n,e),g=E()+1;let l=c(n,g),s=v.createHref(n);try{p.pushState(l,"",s)}catch(e){if(e instanceof DOMException&&"DataCloneError"===e.name)throw e;i.location.assign(s)}d&&h&&h({action:_,location:v.location,delta:1})},replace:function(e,t){_=r.Replace;let n=u(v.location,e,t);a&&a(n,e),g=E();let l=c(n,g),s=v.createHref(n);p.replaceState(l,"",s),d&&h&&h({action:_,location:v.location,delta:0})},go:e=>p.go(e)};return v}(function(e,t){let{pathname:a="/",search:n="",hash:l=""}=d(e.location.hash.substr(1));return a.startsWith("/")||a.startsWith(".")||(a="/"+a),u("",{pathname:a,search:n,hash:l},t.state&&t.state.usr||null,t.state&&t.state.key||"default")},function(e,t){let a=e.document.querySelector("base"),n="";if(a&&a.getAttribute("href")){let t=e.location.href,a=t.indexOf("#");n=-1===a?t:t.slice(0,a)}return n+"#"+("string"==typeof t?t:m(t))},function(e,t){i("/"===e.pathname.charAt(0),"relative pathnames are not supported in hash history.push("+JSON.stringify(t)+")")},g)));let E=h.current,[y,f]=t.useState({action:E.action,location:E.location}),{v7_startTransition:v}=p||{},q=t.useCallback(e=>{v&&ge?ge(()=>f(e)):f(e)},[f,v]);return t.useLayoutEffect(()=>E.listen(q),[E,q]),t.useEffect(()=>{return null==(e=p)||e.v7_startTransition,void 0===(null==e?void 0:e.v7_relativeSplatPath)&&(!t||t.v7_relativeSplatPath),void(t&&(t.v7_fetcherPersist,t.v7_normalizeFormMethod,t.v7_partialHydration,t.v7_skipActionErrorRevalidation));var e,t},[p]),t.createElement(ie,{basename:a,children:n,location:y.location,navigationType:y.action,navigator:E,future:p})}n.flushSync,t.useId;const ye="undefined"!=typeof window&&void 0!==window.document&&void 0!==window.document.createElement,fe=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,ve=t.forwardRef(function(e,a){let n,{onClick:l,relative:r,reloadDocument:s,replace:i,state:c,target:u,to:d,preventScrollReset:p,viewTransition:_}=e,h=de(e,pe),{basename:g}=t.useContext(I),E=!1;if("string"==typeof d&&fe.test(d)&&(n=d,ye))try{let e=new URL(window.location.href),t=d.startsWith("//")?new URL(e.protocol+d):new URL(d),a=N(t.pathname,g);t.origin===e.origin&&null!=a?d=a+t.search+t.hash:E=!0}catch(e){}let y=function(e,a){let{relative:n}=void 0===a?{}:a;W()||o(!1);let{basename:l,navigator:r}=t.useContext(I),{hash:s,pathname:i,search:c}=J(e,{relative:n}),u=i;return"/"!==l&&(u="/"===i?l:R([l,i])),r.createHref({pathname:u,search:c,hash:s})}(d,{relative:r}),f=function(e,a){let{target:n,replace:l,state:r,preventScrollReset:s,relative:o,viewTransition:i}=void 0===a?{}:a,c=G(),u=Y(),d=J(e,{relative:o});return t.useCallback(t=>{if(function(e,t){return!(0!==e.button||t&&"_self"!==t||function(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}(e))}(t,n)){t.preventDefault();let a=void 0!==l?l:m(u)===m(d);c(e,{replace:a,state:r,preventScrollReset:s,relative:o,viewTransition:i})}},[u,c,d,l,r,n,e,s,o,i])}(d,{replace:i,state:c,target:u,preventScrollReset:p,relative:r,viewTransition:_});return t.createElement("a",me({},h,{href:n||y,onClick:E||s?l:function(e){l&&l(e),e.defaultPrevented||f(e)},ref:a,target:u}))}),qe=t.forwardRef(function(e,a){let{"aria-current":n="page",caseSensitive:l=!1,className:r="",end:s=!1,style:i,to:c,viewTransition:u,children:m}=e,d=de(e,_e),p=J(c,{relative:d.relative}),_=Y(),h=t.useContext(z),{navigator:g,basename:E}=t.useContext(I),y=null!=h&&function(e,a){void 0===a&&(a={});let n=t.useContext(he);null==n&&o(!1);let{basename:l}=function(){let e=t.useContext($);return e||o(!1),e}(be.useViewTransitionState),r=J(e,{relative:a.relative});if(!n.isTransitioning)return!1;let s=N(n.currentLocation.pathname,l)||n.currentLocation.pathname,i=N(n.nextLocation.pathname,l)||n.nextLocation.pathname;return null!=S(r.pathname,i)||null!=S(r.pathname,s)}(p)&&!0===u,f=g.encodeLocation?g.encodeLocation(p).pathname:p.pathname,v=_.pathname,q=h&&h.navigation&&h.navigation.location?h.navigation.location.pathname:null;l||(v=v.toLowerCase(),q=q?q.toLowerCase():null,f=f.toLowerCase()),q&&E&&(q=N(q,E)||q);const b="/"!==f&&f.endsWith("/")?f.length-1:f.length;let C,x=v===f||!s&&v.startsWith(f)&&"/"===v.charAt(b),w=null!=q&&(q===f||!s&&q.startsWith(f)&&"/"===q.charAt(f.length)),k={isActive:x,isPending:w,isTransitioning:y},T=x?n:void 0;C="function"==typeof r?r(k):[r,x?"active":null,w?"pending":null,y?"transitioning":null].filter(Boolean).join(" ");let B="function"==typeof i?i(k):i;return t.createElement(ve,me({},d,{"aria-current":T,className:C,ref:a,style:B,to:c,viewTransition:u}),"function"==typeof m?m(k):m)});var be,Ce;(function(e){e.UseScrollRestoration="useScrollRestoration",e.UseSubmit="useSubmit",e.UseSubmitFetcher="useSubmitFetcher",e.UseFetcher="useFetcher",e.useViewTransitionState="useViewTransitionState"})(be||(be={})),function(e){e.UseFetcher="useFetcher",e.UseFetchers="useFetchers",e.UseScrollRestoration="useScrollRestoration"}(Ce||(Ce={}));const xe=window.wp.i18n;function we({children:e}){return(0,t.createElement)("div",{className:"quant-admin"},(0,t.createElement)("header",{className:"quant-admin__header"},(0,t.createElement)("div",{className:"quant-admin__logo"},(0,t.createElement)("svg",{width:"32",height:"32",viewBox:"0 0 32 32",fill:"none"},(0,t.createElement)("rect",{width:"32",height:"32",rx:"6",fill:"#5c40ff"}),(0,t.createElement)("text",{x:"50%",y:"55%",dominantBaseline:"middle",textAnchor:"middle",fill:"white",fontSize:"16",fontWeight:"bold"},"Q")),(0,t.createElement)("span",null,"QuantCDN")),(0,t.createElement)("div",{className:"quant-admin__version"},"v",window.quantAdmin?.version||"2.0.0")),(0,t.createElement)("div",{className:"quant-admin__body"},(0,t.createElement)("nav",{className:"quant-admin__sidebar"},(0,t.createElement)("ul",null,(0,t.createElement)("li",null,(0,t.createElement)(qe,{to:"/dashboard"},(0,t.createElement)("span",{className:"dashicons dashicons-dashboard"}),(0,xe.__)("Dashboard","quant"))),(0,t.createElement)("li",null,(0,t.createElement)(qe,{to:"/content"},(0,t.createElement)("span",{className:"dashicons dashicons-media-text"}),(0,xe.__)("Content","quant"))),(0,t.createElement)("li",null,(0,t.createElement)(qe,{to:"/search"},(0,t.createElement)("span",{className:"dashicons dashicons-search"}),(0,xe.__)("Search","quant"))),(0,t.createElement)("li",null,(0,t.createElement)(qe,{to:"/cache"},(0,t.createElement)("span",{className:"dashicons dashicons-trash"}),(0,xe.__)("Cache","quant"))),(0,t.createElement)("li",null,(0,t.createElement)(qe,{to:"/settings"},(0,t.createElement)("span",{className:"dashicons dashicons-admin-settings"}),(0,xe.__)("Settings","quant"))),(0,t.createElement)("li",null,(0,t.createElement)(qe,{to:"/diagnostics"},(0,t.createElement)("span",{className:"dashicons dashicons-info-outline"}),(0,xe.__)("Diagnostics","quant"))))),(0,t.createElement)("main",{className:"quant-admin__content"},e)))}const Se=window.wp.components,Ne=window.wp.apiFetch;var ke=e.n(Ne);function Te({label:e,value:a,tone:n}){return(0,t.createElement)("div",{className:`quant-stat quant-stat--${n||"neutral"}`},(0,t.createElement)("div",{className:"quant-stat__value"},a),(0,t.createElement)("div",{className:"quant-stat__label"},e))}function Be(){var e,n,l,r;const s=G(),[o,i]=(0,a.useState)(null),[c,u]=(0,a.useState)([]),[m,d]=(0,a.useState)(!1),[p,_]=(0,a.useState)(null),[h,g]=(0,a.useState)(null),E=(0,a.useCallback)(async()=>{try{const[e,t]=await Promise.all([ke()({path:"/quant/v1/status"}),ke()({path:"/quant/v1/queue/errors"})]);i(e),u(t),g(null)}catch(e){g(e.message||(0,xe.__)("Failed to load the plugin status.","quant")),i({connected:!1,configured:!1,mode:"static",queue:{}})}},[]);if((0,a.useEffect)(()=>{E()},[E]),(0,a.useEffect)(()=>{!1===o?.configured&&ke()({path:"/quant/v1/onboarding/status"}).then(e=>{"onboarding"===e.state?s("/onboarding"):"migration"===e.state&&s("/migration")}).catch(()=>{})},[o,s]),!o)return(0,t.createElement)("div",{className:"quant-dashboard"},(0,t.createElement)("h1",null,(0,xe.__)("Dashboard","quant")),(0,t.createElement)(Se.Spinner,null));const y=o.queue||{},f="static"===o.mode;return(0,t.createElement)("div",{className:"quant-dashboard"},(0,t.createElement)("h1",null,(0,xe.__)("Dashboard","quant")),h&&(0,t.createElement)(Se.Notice,{status:"error",isDismissible:!1},h),p&&(0,t.createElement)(Se.Notice,{status:p.status,onRemove:()=>_(null),isDismissible:!0},p.message),o.cron_disabled&&y.pending>0&&(0,t.createElement)(Se.Notice,{status:"warning",isDismissible:!1},(0,xe.__)('WP-Cron is disabled on this site. Pending items are only processed by "Process Queue", a system cron that calls wp-cron.php, or WP-CLI.',"quant")),(0,t.createElement)("div",{className:"quant-dashboard__cards"},(0,t.createElement)(Se.Card,null,(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Connection","quant"))),(0,t.createElement)(Se.CardBody,null,o.connected?(0,t.createElement)("p",{className:"quant-status--success"},(0,xe.__)("Connected to Quant","quant"),o.search_enabled?" · "+(0,xe.__)("Search enabled","quant"):""):(0,t.createElement)("p",{className:"quant-status--error"},o.configured?(0,xe.sprintf)(/* translators: %s: error message */ /* translators: %s: error message */ +(()=>{"use strict";var e={n:t=>{var a=t&&t.__esModule?()=>t.default:()=>t;return e.d(a,{a}),a},d:(t,a)=>{for(var n in a)e.o(a,n)&&!e.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:a[n]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t)};const t=window.React,a=window.wp.element,n=window.ReactDOM;function l(){return l=Object.assign?Object.assign.bind():function(e){for(var t=1;t=0&&(t.hash=e.substr(a),e=e.substr(0,a));let n=e.indexOf("?");n>=0&&(t.search=e.substr(n),e=e.substr(0,n)),e&&(t.pathname=e)}return t}var p;function _(e,t,a){return void 0===a&&(a="/"),function(e,t,a,n){let l=N(("string"==typeof t?d(t):t).pathname||"/",a);if(null==l)return null;let r=h(e);!function(e){e.sort((e,t)=>e.score!==t.score?t.score-e.score:function(e,t){return e.length===t.length&&e.slice(0,-1).every((e,a)=>e===t[a])?e[e.length-1]-t[t.length-1]:0}(e.routesMeta.map(e=>e.childrenIndex),t.routesMeta.map(e=>e.childrenIndex)))}(r);let s=null,o=function(e){try{return e.split("/").map(e=>decodeURIComponent(e).replace(/\//g,"%2F")).join("/")}catch(t){return i(!1,'The URL path "'+e+'" could not be decoded because it is is a malformed URL segment. This is probably due to a bad percent encoding ('+t+")."),e}}(l);for(let e=0;null==s&&e{let s={relativePath:void 0===r?e.path||"":r,caseSensitive:!0===e.caseSensitive,childrenIndex:l,route:e};s.relativePath.startsWith("/")&&(o(s.relativePath.startsWith(n),'Absolute route path "'+s.relativePath+'" nested under path "'+n+'" is not valid. An absolute child route path must start with the combined path of all its parent routes.'),s.relativePath=s.relativePath.slice(n.length));let i=R([n,s.relativePath]),c=a.concat(s);e.children&&e.children.length>0&&(o(!0!==e.index,'Index routes must not have child routes. Please remove all child routes from route path "'+i+'".'),h(e.children,t,c,i)),(null!=e.path||e.index)&&t.push({path:i,score:x(i,e.index),routesMeta:c})};return e.forEach((e,t)=>{var a;if(""!==e.path&&null!=(a=e.path)&&a.includes("?"))for(let a of g(e.path))l(e,t,a);else l(e,t)}),t}function g(e){let t=e.split("/");if(0===t.length)return[];let[a,...n]=t,l=a.endsWith("?"),r=a.replace(/\?$/,"");if(0===n.length)return l?[r,""]:[r];let s=g(n.join("/")),o=[];return o.push(...s.map(e=>""===e?r:[r,e].join("/"))),l&&o.push(...s),o.map(t=>e.startsWith("/")&&""===t?"/":t)}!function(e){e.data="data",e.deferred="deferred",e.redirect="redirect",e.error="error"}(p||(p={})),new Set(["lazy","caseSensitive","path","id","index","children"]);const E=/^:[\w-]+$/,y=3,f=2,v=1,q=10,b=-2,C=e=>"*"===e;function x(e,t){let a=e.split("/"),n=a.length;return a.some(C)&&(n+=b),t&&(n+=f),a.filter(e=>!C(e)).reduce((e,t)=>e+(E.test(t)?y:""===t?v:q),n)}function w(e,t,a){void 0===a&&(a=!1);let{routesMeta:n}=e,l={},r="/",s=[];for(let e=0;e(n.push({paramName:t,isOptional:null!=a}),a?"/?([^\\/]+)?":"/([^\\/]+)"));return e.endsWith("*")?(n.push({paramName:"*"}),l+="*"===e||"/*"===e?"(.*)$":"(?:\\/(.+)|\\/*)$"):a?l+="\\/*$":""!==e&&"/"!==e&&(l+="(?:(?=\\/|$))"),[new RegExp(l,t?void 0:"i"),n]}(e.path,e.caseSensitive,e.end),l=t.match(a);if(!l)return null;let r=l[0],s=r.replace(/(.)\/+$/,"$1"),o=l.slice(1);return{params:n.reduce((e,t,a)=>{let{paramName:n,isOptional:l}=t;if("*"===n){let e=o[a]||"";s=r.slice(0,r.length-e.length).replace(/(.)\/+$/,"$1")}const i=o[a];return e[n]=l&&!i?void 0:(i||"").replace(/%2F/g,"/"),e},{}),pathname:r,pathnameBase:s,pattern:e}}function N(e,t){if("/"===t)return e;if(!e.toLowerCase().startsWith(t.toLowerCase()))return null;let a=t.endsWith("/")?t.length-1:t.length,n=e.charAt(a);return n&&"/"!==n?null:e.slice(a)||"/"}function k(e,t){let a=t.replace(/\/+$/,"").split("/");return e.split("/").forEach(e=>{".."===e?a.length>1&&a.pop():"."!==e&&a.push(e)}),a.length>1?a.join("/"):"/"}function T(e,t,a,n){return"Cannot include a '"+e+"' character in a manually specified `to."+t+"` field ["+JSON.stringify(n)+"]. Please separate it out to the `to."+a+'` field. Alternatively you may provide the full path as a string in and the router will parse it for you.'}function B(e,t){let a=function(e){return e.filter((e,t)=>0===t||e.route.path&&e.route.path.length>0)}(e);return t?a.map((e,t)=>t===a.length-1?e.pathname:e.pathnameBase):a.map(e=>e.pathnameBase)}function P(e,t,a,n){let r;void 0===n&&(n=!1),"string"==typeof e?r=d(e):(r=l({},e),o(!r.pathname||!r.pathname.includes("?"),T("?","pathname","search",r)),o(!r.pathname||!r.pathname.includes("#"),T("#","pathname","hash",r)),o(!r.search||!r.search.includes("#"),T("#","search","hash",r)));let s,i=""===e||""===r.pathname,c=i?"/":r.pathname;if(null==c)s=a;else{let e=t.length-1;if(!n&&c.startsWith("..")){let t=c.split("/");for(;".."===t[0];)t.shift(),e-=1;r.pathname=t.join("/")}s=e>=0?t[e]:"/"}let u=function(e,t){void 0===t&&(t="/");let a,{pathname:n,search:l="",hash:r=""}="string"==typeof e?d(e):e;return n?(n=D(n),a=n.startsWith("/")?k(n.substring(1),"/"):k(n,t)):a=t,{pathname:a,search:M(l),hash:A(r)}}(r,s),m=c&&"/"!==c&&c.endsWith("/"),p=(i||"."===c)&&a.endsWith("/");return u.pathname.endsWith("/")||!m&&!p||(u.pathname+="/"),u}const D=e=>e.replace(/\/\/+/g,"/"),R=e=>D(e.join("/")),j=e=>e.replace(/\/+$/,"").replace(/^\/*/,"/"),M=e=>e&&"?"!==e?e.startsWith("?")?e:"?"+e:"",A=e=>e&&"#"!==e?e.startsWith("#")?e:"#"+e:"";Error;const F=["post","put","patch","delete"],O=(new Set(F),["get",...F]);function H(){return H=Object.assign?Object.assign.bind():function(e){for(var t=1;t{n.current=!0}),t.useCallback(function(t,l){void 0===l&&(l={}),n.current&&("number"==typeof t?e.navigate(t):e.navigate(t,H({fromRouteId:a},l)))},[e,a])}():function(){W()||o(!1);let e=t.useContext($),{basename:a,future:n,navigator:l}=t.useContext(z),{matches:r}=t.useContext(L),{pathname:s}=Y(),i=JSON.stringify(B(r,n.v7_relativeSplatPath)),c=t.useRef(!1);return V(()=>{c.current=!0}),t.useCallback(function(t,n){if(void 0===n&&(n={}),!c.current)return;if("number"==typeof t)return void l.go(t);let r=P(t,JSON.parse(i),s,"path"===n.relative);null==e&&"/"!==a&&(r.pathname="/"===r.pathname?a:R([a,r.pathname])),(n.replace?l.replace:l.push)(r,n.state,n)},[a,l,i,s,e])}()}function J(e,a){let{relative:n}=void 0===a?{}:a,{future:l}=t.useContext(z),{matches:r}=t.useContext(L),{pathname:s}=Y(),o=JSON.stringify(B(r,l.v7_relativeSplatPath));return t.useMemo(()=>P(e,JSON.parse(o),s,"path"===n),[e,o,s,n])}function K(e,a,n,l){W()||o(!1);let{navigator:s}=t.useContext(z),{matches:i}=t.useContext(L),c=i[i.length-1],u=c?c.params:{},m=(c&&c.pathname,c?c.pathnameBase:"/");c&&c.route;let p,h=Y();if(a){var g;let e="string"==typeof a?d(a):a;"/"===m||(null==(g=e.pathname)?void 0:g.startsWith(m))||o(!1),p=e}else p=h;let E=p.pathname||"/",y=E;if("/"!==m){let e=m.replace(/^\//,"").split("/");y="/"+E.replace(/^\//,"").split("/").slice(e.length).join("/")}let f=_(e,{pathname:y}),v=function(e,a,n,l){var r;if(void 0===a&&(a=[]),void 0===n&&(n=null),void 0===l&&(l=null),null==e){var s;if(!n)return null;if(n.errors)e=n.matches;else{if(!(null!=(s=l)&&s.v7_partialHydration&&0===a.length&&!n.initialized&&n.matches.length>0))return null;e=n.matches}}let i=e,c=null==(r=n)?void 0:r.errors;if(null!=c){let e=i.findIndex(e=>e.route.id&&void 0!==(null==c?void 0:c[e.route.id]));e>=0||o(!1),i=i.slice(0,Math.min(i.length,e+1))}let u=!1,m=-1;if(n&&l&&l.v7_partialHydration)for(let e=0;e=0?i.slice(0,m+1):[i[0]];break}}}return i.reduceRight((e,l,r)=>{let s,o=!1,d=null,p=null;var _;n&&(s=c&&l.route.id?c[l.route.id]:void 0,d=l.route.errorElement||Z,u&&(m<0&&0===r?(re[_="route-fallback"]||(re[_]=!0),o=!0,p=null):m===r&&(o=!0,p=l.route.hydrateFallbackElement||null)));let h=a.concat(i.slice(0,r+1)),g=()=>{let a;return a=s?d:o?p:l.route.Component?t.createElement(l.route.Component,null):l.route.element?l.route.element:e,t.createElement(te,{match:l,routeContext:{outlet:e,matches:h,isDataRoute:null!=n},children:a})};return n&&(l.route.ErrorBoundary||l.route.errorElement||0===r)?t.createElement(ee,{location:n.location,revalidation:n.revalidation,component:d,error:s,children:g(),routeContext:{outlet:null,matches:h,isDataRoute:!0}}):g()},null)}(f&&f.map(e=>Object.assign({},e,{params:Object.assign({},u,e.params),pathname:R([m,s.encodeLocation?s.encodeLocation(e.pathname).pathname:e.pathname]),pathnameBase:"/"===e.pathnameBase?m:R([m,s.encodeLocation?s.encodeLocation(e.pathnameBase).pathname:e.pathnameBase])})),i,n,l);return a&&v?t.createElement(Q.Provider,{value:{location:H({pathname:"/",search:"",hash:"",state:null,key:"default"},p),navigationType:r.Pop}},v):v}function X(){let e=function(){var e;let a=t.useContext(U),n=function(){let e=t.useContext(I);return e||o(!1),e}(ne.UseRouteError),l=le(ne.UseRouteError);return void 0!==a?a:null==(e=n.errors)?void 0:e[l]}(),a=function(e){return null!=e&&"number"==typeof e.status&&"string"==typeof e.statusText&&"boolean"==typeof e.internal&&"data"in e}(e)?e.status+" "+e.statusText:e instanceof Error?e.message:JSON.stringify(e),n=e instanceof Error?e.stack:null,l={padding:"0.5rem",backgroundColor:"rgba(200,200,200, 0.5)"};return t.createElement(t.Fragment,null,t.createElement("h2",null,"Unexpected Application Error!"),t.createElement("h3",{style:{fontStyle:"italic"}},a),n?t.createElement("pre",{style:l},n):null,null)}const Z=t.createElement(X,null);class ee extends t.Component{constructor(e){super(e),this.state={location:e.location,revalidation:e.revalidation,error:e.error}}static getDerivedStateFromError(e){return{error:e}}static getDerivedStateFromProps(e,t){return t.location!==e.location||"idle"!==t.revalidation&&"idle"===e.revalidation?{error:e.error,location:e.location,revalidation:e.revalidation}:{error:void 0!==e.error?e.error:t.error,location:t.location,revalidation:e.revalidation||t.revalidation}}componentDidCatch(e,t){console.error("React Router caught the following error during render",e,t)}render(){return void 0!==this.state.error?t.createElement(L.Provider,{value:this.props.routeContext},t.createElement(U.Provider,{value:this.state.error,children:this.props.component})):this.props.children}}function te(e){let{routeContext:a,match:n,children:l}=e,r=t.useContext($);return r&&r.static&&r.staticContext&&(n.route.errorElement||n.route.ErrorBoundary)&&(r.staticContext._deepestRenderedBoundaryId=n.route.id),t.createElement(L.Provider,{value:a},l)}var ae=function(e){return e.UseBlocker="useBlocker",e.UseRevalidator="useRevalidator",e.UseNavigateStable="useNavigate",e}(ae||{}),ne=function(e){return e.UseBlocker="useBlocker",e.UseLoaderData="useLoaderData",e.UseActionData="useActionData",e.UseRouteError="useRouteError",e.UseNavigation="useNavigation",e.UseRouteLoaderData="useRouteLoaderData",e.UseMatches="useMatches",e.UseRevalidator="useRevalidator",e.UseNavigateStable="useNavigate",e.UseRouteId="useRouteId",e}(ne||{});function le(e){let a=function(){let e=t.useContext(L);return e||o(!1),e}(),n=a.matches[a.matches.length-1];return n.route.id||o(!1),n.route.id}const re={};function se(e){let{to:a,replace:n,state:l,relative:r}=e;W()||o(!1);let{future:s,static:i}=t.useContext(z),{matches:c}=t.useContext(L),{pathname:u}=Y(),m=G(),d=P(a,B(c,s.v7_relativeSplatPath),u,"path"===r),p=JSON.stringify(d);return t.useEffect(()=>m(JSON.parse(p),{replace:n,state:l,relative:r}),[m,p,r,n,l]),null}function oe(e){o(!1)}function ie(e){let{basename:a="/",children:n=null,location:l,navigationType:s=r.Pop,navigator:i,static:c=!1,future:u}=e;W()&&o(!1);let m=a.replace(/^\/*/,"/"),p=t.useMemo(()=>({basename:m,navigator:i,static:c,future:H({v7_relativeSplatPath:!1},u)}),[m,u,i,c]);"string"==typeof l&&(l=d(l));let{pathname:_="/",search:h="",hash:g="",state:E=null,key:y="default"}=l,f=t.useMemo(()=>{let e=N(_,m);return null==e?null:{location:{pathname:e,search:h,hash:g,state:E,key:y},navigationType:s}},[m,_,h,g,E,y,s]);return null==f?null:t.createElement(z.Provider,{value:p},t.createElement(Q.Provider,{children:n,value:f}))}function ce(e){let{children:t,location:a}=e;return K(ue(t),a)}function ue(e,a){void 0===a&&(a=[]);let n=[];return t.Children.forEach(e,(e,l)=>{if(!t.isValidElement(e))return;let r=[...a,l];if(e.type===t.Fragment)return void n.push.apply(n,ue(e.props.children,r));e.type!==oe&&o(!1),e.props.index&&e.props.children&&o(!1);let s={id:e.props.id||r.join("-"),caseSensitive:e.props.caseSensitive,element:e.props.element,Component:e.props.Component,index:e.props.index,path:e.props.path,loader:e.props.loader,action:e.props.action,errorElement:e.props.errorElement,ErrorBoundary:e.props.ErrorBoundary,hasErrorBoundary:null!=e.props.ErrorBoundary||null!=e.props.errorElement,shouldRevalidate:e.props.shouldRevalidate,handle:e.props.handle,lazy:e.props.lazy};e.props.children&&(s.children=ue(e.props.children,r)),n.push(s)}),n}function me(){return me=Object.assign?Object.assign.bind():function(e){for(var t=1;t{}),t.Component,new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);const pe=["onClick","relative","reloadDocument","replace","state","target","to","preventScrollReset","viewTransition"],_e=["aria-current","caseSensitive","className","end","style","to","viewTransition","children"];try{window.__reactRouterVersion="6"}catch(e){}const he=t.createContext({isTransitioning:!1});new Map;const ge=t.startTransition;function Ee(e){let{basename:a,children:n,future:p,window:_}=e,h=t.useRef();var g;null==h.current&&(h.current=(void 0===(g={window:_,v5Compat:!0})&&(g={}),function(e,t,a,n){void 0===n&&(n={});let{window:i=document.defaultView,v5Compat:d=!1}=n,p=i.history,_=r.Pop,h=null,g=E();function E(){return(p.state||{idx:null}).idx}function y(){_=r.Pop;let e=E(),t=null==e?null:e-g;g=e,h&&h({action:_,location:v.location,delta:t})}function f(e){let t="null"!==i.location.origin?i.location.origin:i.location.href,a="string"==typeof e?e:m(e);return a=a.replace(/ $/,"%20"),o(t,"No window.location.(origin|href) available to create URL for href: "+a),new URL(a,t)}null==g&&(g=0,p.replaceState(l({},p.state,{idx:g}),""));let v={get action(){return _},get location(){return e(i,p)},listen(e){if(h)throw new Error("A history only accepts one active listener");return i.addEventListener(s,y),h=e,()=>{i.removeEventListener(s,y),h=null}},createHref:e=>t(i,e),createURL:f,encodeLocation(e){let t=f(e);return{pathname:t.pathname,search:t.search,hash:t.hash}},push:function(e,t){_=r.Push;let n=u(v.location,e,t);a&&a(n,e),g=E()+1;let l=c(n,g),s=v.createHref(n);try{p.pushState(l,"",s)}catch(e){if(e instanceof DOMException&&"DataCloneError"===e.name)throw e;i.location.assign(s)}d&&h&&h({action:_,location:v.location,delta:1})},replace:function(e,t){_=r.Replace;let n=u(v.location,e,t);a&&a(n,e),g=E();let l=c(n,g),s=v.createHref(n);p.replaceState(l,"",s),d&&h&&h({action:_,location:v.location,delta:0})},go:e=>p.go(e)};return v}(function(e,t){let{pathname:a="/",search:n="",hash:l=""}=d(e.location.hash.substr(1));return a.startsWith("/")||a.startsWith(".")||(a="/"+a),u("",{pathname:a,search:n,hash:l},t.state&&t.state.usr||null,t.state&&t.state.key||"default")},function(e,t){let a=e.document.querySelector("base"),n="";if(a&&a.getAttribute("href")){let t=e.location.href,a=t.indexOf("#");n=-1===a?t:t.slice(0,a)}return n+"#"+("string"==typeof t?t:m(t))},function(e,t){i("/"===e.pathname.charAt(0),"relative pathnames are not supported in hash history.push("+JSON.stringify(t)+")")},g)));let E=h.current,[y,f]=t.useState({action:E.action,location:E.location}),{v7_startTransition:v}=p||{},q=t.useCallback(e=>{v&&ge?ge(()=>f(e)):f(e)},[f,v]);return t.useLayoutEffect(()=>E.listen(q),[E,q]),t.useEffect(()=>{return null==(e=p)||e.v7_startTransition,void 0===(null==e?void 0:e.v7_relativeSplatPath)&&(!t||t.v7_relativeSplatPath),void(t&&(t.v7_fetcherPersist,t.v7_normalizeFormMethod,t.v7_partialHydration,t.v7_skipActionErrorRevalidation));var e,t},[p]),t.createElement(ie,{basename:a,children:n,location:y.location,navigationType:y.action,navigator:E,future:p})}n.flushSync,t.useId;const ye="undefined"!=typeof window&&void 0!==window.document&&void 0!==window.document.createElement,fe=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,ve=t.forwardRef(function(e,a){let n,{onClick:l,relative:r,reloadDocument:s,replace:i,state:c,target:u,to:d,preventScrollReset:p,viewTransition:_}=e,h=de(e,pe),{basename:g}=t.useContext(z),E=!1;if("string"==typeof d&&fe.test(d)&&(n=d,ye))try{let e=new URL(window.location.href),t=d.startsWith("//")?new URL(e.protocol+d):new URL(d),a=N(t.pathname,g);t.origin===e.origin&&null!=a?d=a+t.search+t.hash:E=!0}catch(e){}let y=function(e,a){let{relative:n}=void 0===a?{}:a;W()||o(!1);let{basename:l,navigator:r}=t.useContext(z),{hash:s,pathname:i,search:c}=J(e,{relative:n}),u=i;return"/"!==l&&(u="/"===i?l:R([l,i])),r.createHref({pathname:u,search:c,hash:s})}(d,{relative:r}),f=function(e,a){let{target:n,replace:l,state:r,preventScrollReset:s,relative:o,viewTransition:i}=void 0===a?{}:a,c=G(),u=Y(),d=J(e,{relative:o});return t.useCallback(t=>{if(function(e,t){return!(0!==e.button||t&&"_self"!==t||function(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}(e))}(t,n)){t.preventDefault();let a=void 0!==l?l:m(u)===m(d);c(e,{replace:a,state:r,preventScrollReset:s,relative:o,viewTransition:i})}},[u,c,d,l,r,n,e,s,o,i])}(d,{replace:i,state:c,target:u,preventScrollReset:p,relative:r,viewTransition:_});return t.createElement("a",me({},h,{href:n||y,onClick:E||s?l:function(e){l&&l(e),e.defaultPrevented||f(e)},ref:a,target:u}))}),qe=t.forwardRef(function(e,a){let{"aria-current":n="page",caseSensitive:l=!1,className:r="",end:s=!1,style:i,to:c,viewTransition:u,children:m}=e,d=de(e,_e),p=J(c,{relative:d.relative}),_=Y(),h=t.useContext(I),{navigator:g,basename:E}=t.useContext(z),y=null!=h&&function(e,a){void 0===a&&(a={});let n=t.useContext(he);null==n&&o(!1);let{basename:l}=function(){let e=t.useContext($);return e||o(!1),e}(be.useViewTransitionState),r=J(e,{relative:a.relative});if(!n.isTransitioning)return!1;let s=N(n.currentLocation.pathname,l)||n.currentLocation.pathname,i=N(n.nextLocation.pathname,l)||n.nextLocation.pathname;return null!=S(r.pathname,i)||null!=S(r.pathname,s)}(p)&&!0===u,f=g.encodeLocation?g.encodeLocation(p).pathname:p.pathname,v=_.pathname,q=h&&h.navigation&&h.navigation.location?h.navigation.location.pathname:null;l||(v=v.toLowerCase(),q=q?q.toLowerCase():null,f=f.toLowerCase()),q&&E&&(q=N(q,E)||q);const b="/"!==f&&f.endsWith("/")?f.length-1:f.length;let C,x=v===f||!s&&v.startsWith(f)&&"/"===v.charAt(b),w=null!=q&&(q===f||!s&&q.startsWith(f)&&"/"===q.charAt(f.length)),k={isActive:x,isPending:w,isTransitioning:y},T=x?n:void 0;C="function"==typeof r?r(k):[r,x?"active":null,w?"pending":null,y?"transitioning":null].filter(Boolean).join(" ");let B="function"==typeof i?i(k):i;return t.createElement(ve,me({},d,{"aria-current":T,className:C,ref:a,style:B,to:c,viewTransition:u}),"function"==typeof m?m(k):m)});var be,Ce;(function(e){e.UseScrollRestoration="useScrollRestoration",e.UseSubmit="useSubmit",e.UseSubmitFetcher="useSubmitFetcher",e.UseFetcher="useFetcher",e.useViewTransitionState="useViewTransitionState"})(be||(be={})),function(e){e.UseFetcher="useFetcher",e.UseFetchers="useFetchers",e.UseScrollRestoration="useScrollRestoration"}(Ce||(Ce={}));const xe=window.wp.i18n;function we({children:e}){return(0,t.createElement)("div",{className:"quant-admin"},(0,t.createElement)("header",{className:"quant-admin__header"},(0,t.createElement)("div",{className:"quant-admin__logo"},(0,t.createElement)("svg",{width:"32",height:"32",viewBox:"0 0 32 32",fill:"none"},(0,t.createElement)("rect",{width:"32",height:"32",rx:"6",fill:"#5c40ff"}),(0,t.createElement)("text",{x:"50%",y:"55%",dominantBaseline:"middle",textAnchor:"middle",fill:"white",fontSize:"16",fontWeight:"bold"},"Q")),(0,t.createElement)("span",null,"QuantCDN")),(0,t.createElement)("div",{className:"quant-admin__version"},"v",window.quantAdmin?.version||"2.0.0")),(0,t.createElement)("div",{className:"quant-admin__body"},(0,t.createElement)("nav",{className:"quant-admin__sidebar"},(0,t.createElement)("ul",null,(0,t.createElement)("li",null,(0,t.createElement)(qe,{to:"/dashboard"},(0,t.createElement)("span",{className:"dashicons dashicons-dashboard"}),(0,xe.__)("Dashboard","quant"))),(0,t.createElement)("li",null,(0,t.createElement)(qe,{to:"/content"},(0,t.createElement)("span",{className:"dashicons dashicons-media-text"}),(0,xe.__)("Content","quant"))),(0,t.createElement)("li",null,(0,t.createElement)(qe,{to:"/search"},(0,t.createElement)("span",{className:"dashicons dashicons-search"}),(0,xe.__)("Search","quant"))),(0,t.createElement)("li",null,(0,t.createElement)(qe,{to:"/cache"},(0,t.createElement)("span",{className:"dashicons dashicons-trash"}),(0,xe.__)("Cache","quant"))),(0,t.createElement)("li",null,(0,t.createElement)(qe,{to:"/settings"},(0,t.createElement)("span",{className:"dashicons dashicons-admin-settings"}),(0,xe.__)("Settings","quant"))),(0,t.createElement)("li",null,(0,t.createElement)(qe,{to:"/diagnostics"},(0,t.createElement)("span",{className:"dashicons dashicons-info-outline"}),(0,xe.__)("Diagnostics","quant"))))),(0,t.createElement)("main",{className:"quant-admin__content"},e)))}const Se=window.wp.components,Ne=window.wp.apiFetch;var ke=e.n(Ne);function Te({label:e,value:a,tone:n}){return(0,t.createElement)("div",{className:`quant-stat quant-stat--${n||"neutral"}`},(0,t.createElement)("div",{className:"quant-stat__value"},a),(0,t.createElement)("div",{className:"quant-stat__label"},e))}function Be(){var e,n,l,r;const s=G(),[o,i]=(0,a.useState)(null),[c,u]=(0,a.useState)([]),[m,d]=(0,a.useState)(!1),[p,_]=(0,a.useState)(null),[h,g]=(0,a.useState)(null),E=(0,a.useCallback)(async()=>{try{const[e,t]=await Promise.all([ke()({path:"/quant/v1/status"}),ke()({path:"/quant/v1/queue/errors"})]);i(e),u(t),g(null)}catch(e){g(e.message||(0,xe.__)("Failed to load the plugin status.","quant")),i({connected:!1,configured:!1,mode:"static",queue:{}})}},[]);if((0,a.useEffect)(()=>{E()},[E]),(0,a.useEffect)(()=>{!1===o?.configured&&ke()({path:"/quant/v1/onboarding/status"}).then(e=>{"onboarding"===e.state?s("/onboarding"):"migration"===e.state&&s("/migration")}).catch(()=>{})},[o,s]),!o)return(0,t.createElement)("div",{className:"quant-dashboard"},(0,t.createElement)("h1",null,(0,xe.__)("Dashboard","quant")),(0,t.createElement)(Se.Spinner,null));const y=o.queue||{},f="static"===o.mode;return(0,t.createElement)("div",{className:"quant-dashboard"},(0,t.createElement)("h1",null,(0,xe.__)("Dashboard","quant")),h&&(0,t.createElement)(Se.Notice,{status:"error",isDismissible:!1},h),p&&(0,t.createElement)(Se.Notice,{status:p.status,onRemove:()=>_(null),isDismissible:!0},p.message),o.cron_disabled&&y.pending>0&&(0,t.createElement)(Se.Notice,{status:"warning",isDismissible:!1},(0,xe.__)('WP-Cron is disabled on this site. Pending items are only processed by "Process Queue", a system cron that calls wp-cron.php, or WP-CLI.',"quant")),(0,t.createElement)("div",{className:"quant-dashboard__cards"},(0,t.createElement)(Se.Card,null,(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Connection","quant"))),(0,t.createElement)(Se.CardBody,null,o.connected?(0,t.createElement)("p",{className:"quant-status--success"},(0,xe.__)("Connected to Quant","quant"),o.search_enabled?" · "+(0,xe.__)("Search enabled","quant"):""):(0,t.createElement)("p",{className:"quant-status--error"},o.configured?(0,xe.sprintf)(/* translators: %s: error message */ /* translators: %s: error message */ (0,xe.__)("Not connected: %s","quant"),o.error||""):(0,xe.__)("Not configured yet.","quant")),(0,t.createElement)(Se.Button,{variant:"link",onClick:()=>s("/settings")},(0,xe.__)("Settings","quant")))),(0,t.createElement)(Se.Card,null,(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Mode","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("p",null,(0,t.createElement)("strong",null,f?(0,xe.__)("Static Site","quant"):(0,xe.__)("CDN Only","quant")),(0,t.createElement)("br",null),(0,t.createElement)("span",{style:{color:"#666",fontSize:"13px"}},f?(0,xe.__)("Pages are published to the Quant edge.","quant"):(0,xe.__)("Quant caches WordPress and purges on change.","quant"))))),(0,t.createElement)(Se.Card,null,(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Quick Actions","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("p",null,f&&(0,t.createElement)(t.Fragment,null,(0,t.createElement)(Se.Button,{variant:"link",onClick:()=>s("/content")},(0,xe.__)("Push content","quant")),(0,t.createElement)("br",null)),(0,t.createElement)(Se.Button,{variant:"link",onClick:()=>s("/cache")},(0,xe.__)("Purge cache","quant")),(0,t.createElement)("br",null),(0,t.createElement)(Se.Button,{variant:"link",onClick:()=>s("/diagnostics")},(0,xe.__)("Diagnostics","quant")))))),f&&(0,t.createElement)(Se.Card,{style:{marginTop:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Sync Queue","quant")),(0,t.createElement)("div",null,(0,t.createElement)(Se.Button,{variant:"secondary",size:"small",onClick:async()=>{d(!0),_(null);try{const e=await ke()({path:"/quant/v1/queue/process",method:"POST",data:{limit:20}});_({status:e.failed>0?"warning":"success",message:(0,xe.sprintf)(/* translators: 1: processed items, 2: failed items, 3: items still pending */ /* translators: 1: processed items, 2: failed items, 3: items still pending */ (0,xe.__)("Processed %1$d items, %2$d failed, %3$d still pending.","quant"),e.processed||0,e.failed||0,e.remaining||0)})}catch(e){_({status:"error",message:e.message||(0,xe.__)("Failed to process the queue.","quant")})}finally{d(!1),await E()}},disabled:m||!y.pending,isBusy:m},(0,xe.__)("Process now","quant"))," ",y.failed>0&&(0,t.createElement)(Se.Button,{variant:"tertiary",size:"small",onClick:async()=>{_(null);try{const e=await ke()({path:"/quant/v1/queue/retry",method:"POST"});_({status:"info",message:(0,xe.sprintf)(/* translators: %d: number of items */ /* translators: %d: number of items */ (0,xe.__)("%d failed items were queued again.","quant"),e.queued||0)})}catch(e){_({status:"error",message:e.message||(0,xe.__)("Failed to retry.","quant")})}await E()}},(0,xe.__)("Retry failed","quant")))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("div",{className:"quant-stats"},(0,t.createElement)(Te,{label:(0,xe.__)("Pending","quant"),value:null!==(e=y.pending)&&void 0!==e?e:0,tone:"info"}),(0,t.createElement)(Te,{label:(0,xe.__)("Processing","quant"),value:null!==(n=y.processing)&&void 0!==n?n:0,tone:"warning"}),(0,t.createElement)(Te,{label:(0,xe.__)("Failed","quant"),value:null!==(l=y.failed)&&void 0!==l?l:0,tone:"error"}),(0,t.createElement)(Te,{label:(0,xe.__)("Completed","quant"),value:null!==(r=y.completed)&&void 0!==r?r:0,tone:"success"})),o.next_cron&&(0,t.createElement)("p",{style:{color:"#666",fontSize:"13px"}},(0,xe.sprintf)(/* translators: %s: time of the next cron run */ /* translators: %s: time of the next cron run */ -(0,xe.__)("Next background run: %s","quant"),o.next_cron_label||new Date(1e3*o.next_cron).toLocaleTimeString())),c.length>0&&(0,t.createElement)("table",{className:"widefat striped",style:{marginTop:"12px"}},(0,t.createElement)("thead",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",null,(0,xe.__)("Type","quant")),(0,t.createElement)("th",null,(0,xe.__)("Item","quant")),(0,t.createElement)("th",null,(0,xe.__)("Error","quant")))),(0,t.createElement)("tbody",null,c.slice(0,5).map((e,a)=>(0,t.createElement)("tr",{key:a},(0,t.createElement)("td",null,e.type),(0,t.createElement)("td",null,(0,t.createElement)("code",null,e.object_id)),(0,t.createElement)("td",{style:{color:"#721c24"}},e.error_message))))))))}function Pe({status:e}){const a={synced:(0,xe.__)("Synced","quant"),pending:(0,xe.__)("Pending","quant"),never:(0,xe.__)("Never","quant"),failed:(0,xe.__)("Failed","quant")};return(0,t.createElement)("span",{className:`quant-status-badge quant-status-badge--${e}`},a[e]||e)}function De({page:e,pages:a,onPageChange:n}){return a<=1?null:(0,t.createElement)("div",{className:"quant-pagination"},(0,t.createElement)(Se.Button,{variant:"secondary",size:"small",disabled:e<=1,onClick:()=>n(e-1)},(0,xe.__)("Previous","quant")),(0,t.createElement)("span",{className:"quant-pagination__info"},(0,xe.__)("Page","quant")," ",e," ",(0,xe.__)("of","quant")," ",a),(0,t.createElement)(Se.Button,{variant:"secondary",size:"small",disabled:e>=a,onClick:()=>n(e+1)},(0,xe.__)("Next","quant")))}function Re({contentType:e,contentTypes:n,specialType:l,onSelectionChange:r,refreshToken:s=0}){const[o,i]=(0,a.useState)([]),[c,u]=(0,a.useState)(!0),[m,d]=(0,a.useState)(null),[p,_]=(0,a.useState)(1),[h,g]=(0,a.useState)(1),[E,y]=(0,a.useState)(0),[f,v]=(0,a.useState)(new Set),[q,b]=(0,a.useState)(!1),C=Array.isArray(n)&&n.length>0,[x,w]=(0,a.useState)(()=>C?n[0].subtype||"category":""),S=e&&e.type,N=e&&e.subtype||"";(0,a.useEffect)(()=>{let e=!1;return u(!0),d(null),ke()({path:"theme_assets"===l?`/quant/v1/theme-assets?page=${p}&per_page=50`:"redirects"===l?`/quant/v1/redirects?page=${p}&per_page=50`:`/quant/v1/content?type=${C?"taxonomy":S||"post"}&subtype=${encodeURIComponent(C?x:N)}&page=${p}&per_page=20`}).then(t=>{e||(i(t.items||[]),g(t.pages||1),y(t.total||0))}).catch(t=>{e||(i([]),g(1),y(0),d(t.message||(0,xe.__)("Failed to load content.","quant")))}).finally(()=>{e||u(!1)}),()=>{e=!0}},[p,x,s,l,C,S,N]);const k=e=>{w(e),_(1),v(new Set),b(!1)};(0,a.useEffect)(()=>{v(new Set),b(!1)},[e,n,l]),(0,a.useEffect)(()=>{if(r){const e=o.filter(e=>f.has(e.id)).map(e=>({type:e.type,id:e.id,subtype:e.subtype,url:e.url}));r(e)}},[f,o,r]);const T=e=>{_(e),v(new Set),b(!1)},B=e=>e.url;return c&&0===o.length?(0,t.createElement)("div",{className:"quant-content-browser quant-content-browser--loading"},(0,t.createElement)(Se.Spinner,null),(0,t.createElement)("p",null,(0,xe.__)("Loading content…","quant"))):(0,t.createElement)("div",{className:"quant-content-browser"},(()=>{if(!n||n.length<=1)return null;const e=n.map(e=>({label:`${e.label} (${e.count})`,value:e.subtype}));return(0,t.createElement)("div",{className:"quant-content-browser__taxonomy-selector"},(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Taxonomy","quant"),value:x,options:e,onChange:k,__nextHasNoMarginBottom:!0}))})(),(0,t.createElement)("div",{className:"quant-content-browser__header"},(0,t.createElement)("span",{className:"quant-content-browser__count"},E," ",(0,xe.__)("items","quant"),f.size>0&&` (${f.size} ${(0,xe.__)("selected","quant")})`),(0,t.createElement)(De,{page:p,pages:h,onPageChange:T})),m&&(0,t.createElement)("div",{className:"quant-content-browser__empty"},(0,t.createElement)("p",{className:"quant-status--error"},m)),!m&&0===o.length&&(0,t.createElement)("div",{className:"quant-content-browser__empty"},(0,t.createElement)("p",null,(0,xe.__)("No content found.","quant"))),o.length>0&&(0,t.createElement)("table",{className:"quant-content-browser__table"},(0,t.createElement)("thead",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{className:"quant-content-browser__col-checkbox"},(0,t.createElement)(Se.CheckboxControl,{label:(0,t.createElement)("span",{className:"screen-reader-text"},(0,xe.__)("Select all","quant")),checked:q,onChange:e=>{b(e),v(e?new Set(o.map(e=>e.id)):new Set)},__nextHasNoMarginBottom:!0})),(0,t.createElement)("th",{className:"quant-content-browser__col-title"},"redirects"===l?(0,xe.__)("Source","quant"):(0,xe.__)("Title","quant")),(0,t.createElement)("th",{className:"quant-content-browser__col-url"},"redirects"===l?(0,xe.__)("Destination","quant"):(0,xe.__)("URL","quant")),"redirects"===l&&(0,t.createElement)("th",{className:"quant-content-browser__col-code"},(0,xe.__)("Code","quant")),"theme_assets"===l&&(0,t.createElement)("th",{className:"quant-content-browser__col-type"},(0,xe.__)("Type","quant")),(0,t.createElement)("th",{className:"quant-content-browser__col-status"},(0,xe.__)("Status","quant")),!l&&(0,t.createElement)("th",{className:"quant-content-browser__col-synced"},(0,xe.__)("Last Synced","quant")),(0,t.createElement)("th",{className:"quant-content-browser__col-actions"},(0,xe.__)("Actions","quant")))),(0,t.createElement)("tbody",null,o.map(e=>{return(0,t.createElement)("tr",{key:e.id,className:f.has(e.id)?"is-selected":""},(0,t.createElement)("td",{className:"quant-content-browser__col-checkbox"},(0,t.createElement)(Se.CheckboxControl,{label:(0,t.createElement)("span",{className:"screen-reader-text"},(0,xe.sprintf)(/* translators: %s: item title or URL */ /* translators: %s: item title or URL */ -(0,xe.__)("Select %s","quant"),e.title||e.url)),checked:f.has(e.id),onChange:t=>((e,t)=>{const a=new Set(f);t?a.add(e):a.delete(e),v(a),b(a.size===o.length)})(e.id,t),__nextHasNoMarginBottom:!0})),(0,t.createElement)("td",{className:"quant-content-browser__col-title"},"redirects"===l?(0,t.createElement)("code",null,e.url):(0,t.createElement)(t.Fragment,null,(0,t.createElement)("strong",null,e.title||(0,xe.__)("(No title)","quant")),e.subtype&&(0,t.createElement)("span",{className:"quant-content-browser__subtype"},e.subtype))),(0,t.createElement)("td",{className:"quant-content-browser__col-url"},(0,t.createElement)("code",null,"redirects"===l?e.destination:e.url)),"redirects"===l&&(0,t.createElement)("td",{className:"quant-content-browser__col-code"},(0,t.createElement)("span",{className:`quant-redirect-code quant-redirect-code--${e.code}`},e.code)),"theme_assets"===l&&(0,t.createElement)("td",{className:"quant-content-browser__col-type"},(0,t.createElement)("span",{className:`quant-asset-type quant-asset-type--${e.file_type}`},e.file_type)),(0,t.createElement)("td",{className:"quant-content-browser__col-status"},(0,t.createElement)(Pe,{status:e.sync_status})),!l&&(0,t.createElement)("td",{className:"quant-content-browser__col-synced"},e.last_synced_label||((a=e.last_synced)?new Date(a).toLocaleDateString(void 0,{year:"numeric",month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"}):"-")),(0,t.createElement)("td",{className:"quant-content-browser__col-actions"},(0,t.createElement)(Se.Button,{variant:"link",size:"small",href:B(e),target:"_blank",rel:"noopener noreferrer"},(0,xe.__)("View","quant"))));var a}))),(0,t.createElement)("div",{className:"quant-content-browser__footer"},(0,t.createElement)(De,{page:p,pages:h,onPageChange:T})))}const Me=["fetch_error","invalid_json","internal_server_error"];function je({onComplete:e,onFinished:n,pollInterval:l=2e3,autoProcess:r=!1}){const[s,o]=(0,a.useState)(null),[i,c]=(0,a.useState)(null),[u,m]=(0,a.useState)(0),[d,p]=(0,a.useState)(0),[_,h]=(0,a.useState)([]),[g,E]=(0,a.useState)("active"),[y,f]=(0,a.useState)(null),[v,q]=(0,a.useState)(0),[b,C]=(0,a.useState)(!1),x=(0,a.useRef)(!1),w=(0,a.useRef)(e),S=(0,a.useRef)(n);(0,a.useEffect)(()=>{w.current=e,S.current=n},[e,n]);const N=(0,a.useCallback)(e=>{E(e),S.current&&S.current()},[]);(0,a.useEffect)(()=>{let e=!1,t=null,a=!1,n=0;const s=async()=>{var i;if(!x.current)try{if(r){const t=await ke()({path:"/quant/v1/queue/process",method:"POST",data:{limit:5}});if(e)return;m(e=>e+(t.processed||0)),p(e=>e+(t.failed||0)),t.errors&&t.errors.length&&h(e=>e.concat(t.errors).slice(-10))}const i=await ke()({path:"/quant/v1/queue/status"});if(e||x.current)return;if(n=0,C(!1),o(i),a||(a=!0,c((i.pending||0)+(i.processing||0))),0===i.pending&&0===i.processing)return void N("done");t=setTimeout(s,l)}catch(a){if(e||x.current)return;if(n+=1,function(e,t){return t<=4&&function(e){if(!e||"object"!=typeof e)return!1;const t=Number(e.data?.status||e.status||0);return 429===t||t>=500||Me.includes(e.code)}(e)}(a,n))return C(!0),void(t=setTimeout(s,(i=n,Math.min(15e3,2e3*2**Math.max(0,i-1)))));C(!1),f(a.message||(0,xe.__)("Failed to process the queue","quant")),N("error")}};return x.current=!1,E("active"),f(null),s(),()=>{e=!0,t&&clearTimeout(t)}},[r,l,v,N]);const k=s?s.pending+s.processing:0,T=i||0,B=Math.max(0,Math.min(T,T-k)),P="done"===g||0===T?100:Math.round(B/T*100);let D=g;"done"===g&&d>0?D="failed":"done"===g&&(D="complete");const R={active:(0,xe.__)("Syncing content…","quant"),complete:(0,xe.__)("Sync complete","quant"),failed:(0,xe.__)("Sync completed with errors","quant"),stopped:(0,xe.__)("Stopped. Pending items stay in the queue.","quant"),error:(0,xe.__)("Processing stopped","quant")};return(0,t.createElement)(Se.Card,{className:"quant-progress"},(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("div",{className:"quant-progress__content"},(0,t.createElement)("div",{className:"quant-progress__header"},(0,t.createElement)("h3",{className:"quant-progress__title"},R[D]),"active"===g&&b&&(0,t.createElement)("span",{className:"quant-progress__processing"},(0,xe.__)("The server did not answer. Trying again…","quant")),"active"===g&&!b&&s&&(0,t.createElement)("span",{className:"quant-progress__processing"},(0,xe.sprintf)(/* translators: %d: number of pending items */ /* translators: %d: number of pending items */ +(0,xe.__)("Next background run: %s","quant"),o.next_cron_label||new Date(1e3*o.next_cron).toLocaleTimeString())),c.length>0&&(0,t.createElement)("table",{className:"widefat striped",style:{marginTop:"12px"}},(0,t.createElement)("thead",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",null,(0,xe.__)("Type","quant")),(0,t.createElement)("th",null,(0,xe.__)("Item","quant")),(0,t.createElement)("th",null,(0,xe.__)("Error","quant")))),(0,t.createElement)("tbody",null,function(e,t=5){return(e||[]).slice(0,t).map(e=>({type:e.type,objectId:e.object_id,message:e.error_message}))}(c).map((e,a)=>(0,t.createElement)("tr",{key:a},(0,t.createElement)("td",null,e.type),(0,t.createElement)("td",null,(0,t.createElement)("code",null,e.objectId)),(0,t.createElement)("td",{style:{color:"#721c24"}},e.message))))))))}function Pe({status:e}){const a={synced:(0,xe.__)("Synced","quant"),pending:(0,xe.__)("Pending","quant"),never:(0,xe.__)("Never","quant"),failed:(0,xe.__)("Failed","quant")};return(0,t.createElement)("span",{className:`quant-status-badge quant-status-badge--${e}`},a[e]||e)}function De({page:e,pages:a,onPageChange:n}){return a<=1?null:(0,t.createElement)("div",{className:"quant-pagination"},(0,t.createElement)(Se.Button,{variant:"secondary",size:"small",disabled:e<=1,onClick:()=>n(e-1)},(0,xe.__)("Previous","quant")),(0,t.createElement)("span",{className:"quant-pagination__info"},(0,xe.__)("Page","quant")," ",e," ",(0,xe.__)("of","quant")," ",a),(0,t.createElement)(Se.Button,{variant:"secondary",size:"small",disabled:e>=a,onClick:()=>n(e+1)},(0,xe.__)("Next","quant")))}function Re({contentType:e,contentTypes:n,specialType:l,onSelectionChange:r,refreshToken:s=0}){const[o,i]=(0,a.useState)([]),[c,u]=(0,a.useState)(!0),[m,d]=(0,a.useState)(null),[p,_]=(0,a.useState)(1),[h,g]=(0,a.useState)(1),[E,y]=(0,a.useState)(0),[f,v]=(0,a.useState)(new Set),[q,b]=(0,a.useState)(!1),C=Array.isArray(n)&&n.length>0,[x,w]=(0,a.useState)(()=>C?n[0].subtype||"category":""),S=e&&e.type,N=e&&e.subtype||"";(0,a.useEffect)(()=>{let e=!1;return u(!0),d(null),ke()({path:"theme_assets"===l?`/quant/v1/theme-assets?page=${p}&per_page=50`:"redirects"===l?`/quant/v1/redirects?page=${p}&per_page=50`:`/quant/v1/content?type=${C?"taxonomy":S||"post"}&subtype=${encodeURIComponent(C?x:N)}&page=${p}&per_page=20`}).then(t=>{e||(i(t.items||[]),g(t.pages||1),y(t.total||0))}).catch(t=>{e||(i([]),g(1),y(0),d(t.message||(0,xe.__)("Failed to load content.","quant")))}).finally(()=>{e||u(!1)}),()=>{e=!0}},[p,x,s,l,C,S,N]);const k=e=>{w(e),_(1),v(new Set),b(!1)};(0,a.useEffect)(()=>{v(new Set),b(!1)},[e,n,l]),(0,a.useEffect)(()=>{if(r){const e=o.filter(e=>f.has(e.id)).map(e=>({type:e.type,id:e.id,subtype:e.subtype,url:e.url}));r(e)}},[f,o,r]);const T=e=>{_(e),v(new Set),b(!1)},B=e=>e.url;return c&&0===o.length?(0,t.createElement)("div",{className:"quant-content-browser quant-content-browser--loading"},(0,t.createElement)(Se.Spinner,null),(0,t.createElement)("p",null,(0,xe.__)("Loading content…","quant"))):(0,t.createElement)("div",{className:"quant-content-browser"},(()=>{if(!n||n.length<=1)return null;const e=n.map(e=>({label:`${e.label} (${e.count})`,value:e.subtype}));return(0,t.createElement)("div",{className:"quant-content-browser__taxonomy-selector"},(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Taxonomy","quant"),value:x,options:e,onChange:k,__nextHasNoMarginBottom:!0}))})(),(0,t.createElement)("div",{className:"quant-content-browser__header"},(0,t.createElement)("span",{className:"quant-content-browser__count"},E," ",(0,xe.__)("items","quant"),f.size>0&&` (${f.size} ${(0,xe.__)("selected","quant")})`),(0,t.createElement)(De,{page:p,pages:h,onPageChange:T})),m&&(0,t.createElement)("div",{className:"quant-content-browser__empty"},(0,t.createElement)("p",{className:"quant-status--error"},m)),!m&&0===o.length&&(0,t.createElement)("div",{className:"quant-content-browser__empty"},(0,t.createElement)("p",null,(0,xe.__)("No content found.","quant"))),o.length>0&&(0,t.createElement)("table",{className:"quant-content-browser__table"},(0,t.createElement)("thead",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{className:"quant-content-browser__col-checkbox"},(0,t.createElement)(Se.CheckboxControl,{label:(0,t.createElement)("span",{className:"screen-reader-text"},(0,xe.__)("Select all","quant")),checked:q,onChange:e=>{b(e),v(e?new Set(o.map(e=>e.id)):new Set)},__nextHasNoMarginBottom:!0})),(0,t.createElement)("th",{className:"quant-content-browser__col-title"},"redirects"===l?(0,xe.__)("Source","quant"):(0,xe.__)("Title","quant")),(0,t.createElement)("th",{className:"quant-content-browser__col-url"},"redirects"===l?(0,xe.__)("Destination","quant"):(0,xe.__)("URL","quant")),"redirects"===l&&(0,t.createElement)("th",{className:"quant-content-browser__col-code"},(0,xe.__)("Code","quant")),"theme_assets"===l&&(0,t.createElement)("th",{className:"quant-content-browser__col-type"},(0,xe.__)("Type","quant")),(0,t.createElement)("th",{className:"quant-content-browser__col-status"},(0,xe.__)("Status","quant")),!l&&(0,t.createElement)("th",{className:"quant-content-browser__col-synced"},(0,xe.__)("Last Synced","quant")),(0,t.createElement)("th",{className:"quant-content-browser__col-actions"},(0,xe.__)("Actions","quant")))),(0,t.createElement)("tbody",null,o.map(e=>{return(0,t.createElement)("tr",{key:e.id,className:f.has(e.id)?"is-selected":""},(0,t.createElement)("td",{className:"quant-content-browser__col-checkbox"},(0,t.createElement)(Se.CheckboxControl,{label:(0,t.createElement)("span",{className:"screen-reader-text"},(0,xe.sprintf)(/* translators: %s: item title or URL */ /* translators: %s: item title or URL */ +(0,xe.__)("Select %s","quant"),e.title||e.url)),checked:f.has(e.id),onChange:t=>((e,t)=>{const a=new Set(f);t?a.add(e):a.delete(e),v(a),b(a.size===o.length)})(e.id,t),__nextHasNoMarginBottom:!0})),(0,t.createElement)("td",{className:"quant-content-browser__col-title"},"redirects"===l?(0,t.createElement)("code",null,e.url):(0,t.createElement)(t.Fragment,null,(0,t.createElement)("strong",null,e.title||(0,xe.__)("(No title)","quant")),e.subtype&&(0,t.createElement)("span",{className:"quant-content-browser__subtype"},e.subtype))),(0,t.createElement)("td",{className:"quant-content-browser__col-url"},(0,t.createElement)("code",null,"redirects"===l?e.destination:e.url)),"redirects"===l&&(0,t.createElement)("td",{className:"quant-content-browser__col-code"},(0,t.createElement)("span",{className:`quant-redirect-code quant-redirect-code--${e.code}`},e.code)),"theme_assets"===l&&(0,t.createElement)("td",{className:"quant-content-browser__col-type"},(0,t.createElement)("span",{className:`quant-asset-type quant-asset-type--${e.file_type}`},e.file_type)),(0,t.createElement)("td",{className:"quant-content-browser__col-status"},(0,t.createElement)(Pe,{status:e.sync_status})),!l&&(0,t.createElement)("td",{className:"quant-content-browser__col-synced"},e.last_synced_label||((a=e.last_synced)?new Date(a).toLocaleDateString(void 0,{year:"numeric",month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"}):"-")),(0,t.createElement)("td",{className:"quant-content-browser__col-actions"},(0,t.createElement)(Se.Button,{variant:"link",size:"small",href:B(e),target:"_blank",rel:"noopener noreferrer"},(0,xe.__)("View","quant"))));var a}))),(0,t.createElement)("div",{className:"quant-content-browser__footer"},(0,t.createElement)(De,{page:p,pages:h,onPageChange:T})))}const je=["fetch_error","invalid_json","internal_server_error"];function Me({onComplete:e,onFinished:n,pollInterval:l=2e3,autoProcess:r=!1}){const[s,o]=(0,a.useState)(null),[i,c]=(0,a.useState)(null),[u,m]=(0,a.useState)(0),[d,p]=(0,a.useState)(0),[_,h]=(0,a.useState)([]),[g,E]=(0,a.useState)("active"),[y,f]=(0,a.useState)(null),[v,q]=(0,a.useState)(0),[b,C]=(0,a.useState)(!1),x=(0,a.useRef)(!1),w=(0,a.useRef)(e),S=(0,a.useRef)(n);(0,a.useEffect)(()=>{w.current=e,S.current=n},[e,n]);const N=(0,a.useCallback)(e=>{E(e),S.current&&S.current()},[]);(0,a.useEffect)(()=>{let e=!1,t=null,a=!1,n=0;const s=async()=>{var i;if(!x.current)try{if(r){const t=await ke()({path:"/quant/v1/queue/process",method:"POST",data:{limit:5}});if(e)return;m(e=>e+(t.processed||0)),p(e=>e+(t.failed||0)),t.errors&&t.errors.length&&h(e=>e.concat(t.errors).slice(-10))}const i=await ke()({path:"/quant/v1/queue/status"});if(e||x.current)return;if(n=0,C(!1),o(i),a||(a=!0,c((i.pending||0)+(i.processing||0))),0===i.pending&&0===i.processing)return void N("done");t=setTimeout(s,l)}catch(a){if(e||x.current)return;if(n+=1,function(e,t){return t<=4&&function(e){if(!e||"object"!=typeof e)return!1;const t=Number(e.data?.status||e.status||0);return 429===t||t>=500||je.includes(e.code)}(e)}(a,n))return C(!0),void(t=setTimeout(s,(i=n,Math.min(15e3,2e3*2**Math.max(0,i-1)))));C(!1),f(a.message||(0,xe.__)("Failed to process the queue","quant")),N("error")}};return x.current=!1,E("active"),f(null),s(),()=>{e=!0,t&&clearTimeout(t)}},[r,l,v,N]);const k=s?s.pending+s.processing:0,T=i||0,B=Math.max(0,Math.min(T,T-k)),P="done"===g||0===T?100:Math.round(B/T*100);let D=g;"done"===g&&d>0?D="failed":"done"===g&&(D="complete");const R={active:(0,xe.__)("Syncing content…","quant"),complete:(0,xe.__)("Sync complete","quant"),failed:(0,xe.__)("Sync completed with errors","quant"),stopped:(0,xe.__)("Stopped. Pending items stay in the queue.","quant"),error:(0,xe.__)("Processing stopped","quant")};return(0,t.createElement)(Se.Card,{className:"quant-progress"},(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("div",{className:"quant-progress__content"},(0,t.createElement)("div",{className:"quant-progress__header"},(0,t.createElement)("h3",{className:"quant-progress__title"},R[D]),"active"===g&&b&&(0,t.createElement)("span",{className:"quant-progress__processing"},(0,xe.__)("The server did not answer. Trying again…","quant")),"active"===g&&!b&&s&&(0,t.createElement)("span",{className:"quant-progress__processing"},(0,xe.sprintf)(/* translators: %d: number of pending items */ /* translators: %d: number of pending items */ (0,xe.__)("%d remaining","quant"),k))),(0,t.createElement)("div",{className:"quant-progress__bar-container",role:"progressbar","aria-valuemin":0,"aria-valuemax":100,"aria-valuenow":P},(0,t.createElement)("div",{className:`quant-progress__bar quant-progress__bar--${D}`,style:{width:`${P}%`}})),(0,t.createElement)("div",{className:"quant-progress__stats"},(0,t.createElement)("span",{className:"quant-progress__stat quant-progress__stat--completed"},(0,xe.sprintf)(/* translators: %d: number of items */ /* translators: %d: number of items */ (0,xe.__)("%d completed","quant"),r?u:B)),k>0&&(0,t.createElement)("span",{className:"quant-progress__stat quant-progress__stat--pending"},(0,xe.sprintf)(/* translators: %d: number of items */ /* translators: %d: number of items */ (0,xe.__)("%d pending","quant"),k)),d>0&&(0,t.createElement)("span",{className:"quant-progress__stat quant-progress__stat--failed"},(0,xe.sprintf)(/* translators: %d: number of items */ /* translators: %d: number of items */ (0,xe.__)("%d failed","quant"),d))),y&&(0,t.createElement)("p",{className:"quant-progress__error"},y),_.length>0&&"active"!==g&&(0,t.createElement)("ul",{className:"quant-progress__errors"},_.map((e,a)=>(0,t.createElement)("li",{key:a},e))),(0,t.createElement)("div",{className:"quant-progress__actions"},"active"===g&&(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>{x.current=!0,N("stopped")}},(0,xe.__)("Stop","quant")),"active"!==g&&d>0&&(0,t.createElement)(Se.Button,{variant:"secondary",onClick:async()=>{try{await ke()({path:"/quant/v1/queue/retry",method:"POST"}),o(null),c(null),m(0),p(0),h([]),q(e=>e+1)}catch(e){f(e.message||(0,xe.__)("Failed to retry","quant"))}}},(0,xe.__)("Retry Failed","quant")),"active"!==g&&(0,t.createElement)(Se.Button,{variant:"link",onClick:()=>{w.current&&w.current()}},(0,xe.__)("Dismiss","quant"))))))}function Ae(){const[e,n]=(0,a.useState)({loading:!0,mode:"static",connected:!1}),[l,r]=(0,a.useState)([]),[s,o]=(0,a.useState)([]),[i,c]=(0,a.useState)(null),[u,m]=(0,a.useState)(!1),[d,p]=(0,a.useState)(null),[_,h]=(0,a.useState)(null),[g,E]=(0,a.useState)(!1),[y,f]=(0,a.useState)(0);(0,a.useEffect)(()=>{Promise.all([ke()({path:"/quant/v1/status"}),ke()({path:"/quant/v1/content-types"})]).then(([e,t])=>{n({loading:!1,...e}),r(t)}).catch(e=>{p(e.message||(0,xe.__)("Failed to load the content overview.","quant")),n({loading:!1,mode:"static",connected:!1})})},[]);const v=e=>{c({status:"success",message:e}),E(!0),h(e=>e?e+1:1)},q=(0,a.useCallback)(()=>{E(!1),f(e=>e+1)},[]),b=(0,a.useCallback)(()=>{h(null),c(null)},[]),C=async(e,t)=>{if(!t||window.confirm(t)){m(!0),c(null);try{const t=await ke()({path:"/quant/v1/seed",method:"POST",data:{types:e}});v((0,xe.sprintf)(/* translators: %d: number of queued items */ /* translators: %d: number of queued items */ -(0,xe.__)("Queued %d items. Processing…","quant"),t.queued))}catch(e){c({status:"error",message:e.message||(0,xe.__)("Failed to queue content","quant")})}finally{m(!1)}}},x=async e=>{if(0!==s.length){m(!0),c(null);try{const t=await ke()({path:"/quant/v1/sync",method:"POST",data:{items:s,action:e}});o([]);const a="unpublish"===e?/* translators: %d: number of items */ /* translators: %d: number of items */(0,xe.__)("Queued %d items to unpublish. Processing…","quant"):/* translators: %d: number of items */ /* translators: %d: number of items */(0,xe.__)("Queued %d items. Processing…","quant");v((0,xe.sprintf)(a,t.queued))}catch(e){c({status:"error",message:e.message||(0,xe.__)("Failed to queue items","quant")})}finally{m(!1)}}},w=(0,a.useCallback)(e=>o(e),[]),S=(0,a.useMemo)(()=>{const e=[],t=e=>l.find(t=>t.type===e);for(const a of["post","page"]){const n=t(a);n&&e.push({name:a,title:`${n.label} (${n.count})`,contentType:n})}for(const t of l.filter(e=>"cpt"===e.type))e.push({name:`cpt_${t.subtype}`,title:`${t.label} (${t.count})`,contentType:t});const a=l.filter(e=>"taxonomy"===e.type);a.length>0&&e.push({name:"taxonomies",title:`${(0,xe.__)("Taxonomies","quant")} (${a.reduce((e,t)=>e+t.count,0)})`,contentTypes:a});const n=t("media");n&&e.push({name:"media",title:`${n.label} (${n.count})`,contentType:n});const r=t("theme_assets");r&&e.push({name:"theme_assets",title:`${r.label} (${r.count})`,contentType:r,specialType:"theme_assets"});const s=t("redirects");return s&&s.count>0&&e.push({name:"redirects",title:`${s.label} (${s.count})`,contentType:s,specialType:"redirects"}),e},[l]),N=l.filter(e=>"special"===e.category);return e.loading?(0,t.createElement)("div",{className:"quant-content"},(0,t.createElement)("h1",null,(0,xe.__)("Content","quant")),(0,t.createElement)(Se.Spinner,null)):d?(0,t.createElement)("div",{className:"quant-content"},(0,t.createElement)("h1",null,(0,xe.__)("Content","quant")),(0,t.createElement)(Se.Notice,{status:"error",isDismissible:!1},d)):"static"!==e.mode?(0,t.createElement)("div",{className:"quant-content"},(0,t.createElement)("h1",null,(0,xe.__)("Content","quant")),(0,t.createElement)(Se.Notice,{status:"info",isDismissible:!1},(0,xe.__)("Content publishing is available in Static Site mode. In CDN Only mode the plugin purges the Quant cache when content changes.","quant"))):(0,t.createElement)("div",{className:"quant-content"},(0,t.createElement)("h1",null,(0,xe.__)("Content","quant")),!e.connected&&(0,t.createElement)(Se.Notice,{status:"warning",isDismissible:!1},(0,xe.__)("Not connected to the Quant API. Check the settings before you push content.","quant")),i&&(0,t.createElement)(Se.Notice,{status:i.status,onRemove:()=>c(null),isDismissible:!0},i.message),_&&(0,t.createElement)(je,{key:_,autoProcess:!0,onFinished:q,onComplete:b}),(0,t.createElement)(Se.Card,{className:"quant-content__header-card"},(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("div",{className:"quant-content__actions"},(0,t.createElement)("div",{className:"quant-content__mode-indicator"},(0,t.createElement)("span",{className:"quant-content__mode-badge quant-content__mode-badge--static"},(0,xe.__)("Static Mode","quant")),(0,t.createElement)("span",{className:"quant-content__mode-description"},(0,xe.__)("Pages, files and redirects are published to the Quant edge.","quant"))),(0,t.createElement)("div",{className:"quant-content__batch-actions"},(0,t.createElement)(Se.Button,{variant:"primary",onClick:()=>C(["all"],(0,xe.__)("Queue the whole site for publishing: posts, pages, custom post types, terms, media, home, archives, author pages, theme assets, custom routes, sitemaps and redirects. Continue?","quant")),disabled:u||g,isBusy:u},(0,xe.__)("Push everything","quant")),(0,t.createElement)(Se.DropdownMenu,{icon:"arrow-down-alt2",label:(0,xe.__)("Push one type","quant"),toggleProps:{variant:"secondary",children:(0,xe.__)("Push…","quant")}},()=>(0,t.createElement)(Se.MenuGroup,null,N.map(e=>(0,t.createElement)(Se.MenuItem,{key:e.type,onClick:()=>C([e.type])},`${e.label} (${e.count})`)),(0,t.createElement)(Se.MenuItem,{onClick:()=>C(["taxonomy"])},(0,xe.__)("All taxonomy terms","quant")),(0,t.createElement)(Se.MenuItem,{onClick:()=>C(["media"])},(0,xe.__)("All media","quant")))),(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>x("sync"),disabled:u||0===s.length||g},(0,xe.__)("Push selected","quant"),s.length>0&&` (${s.length})`),(0,t.createElement)(Se.Button,{variant:"tertiary",isDestructive:!0,onClick:()=>x("unpublish"),disabled:u||0===s.length||g},(0,xe.__)("Unpublish selected","quant")),(0,t.createElement)(Se.Button,{variant:"tertiary",onClick:async()=>{try{const e=await ke()({path:"/quant/v1/queue/status"});if(0===e.pending&&0===e.processing)return void c({status:"info",message:(0,xe.__)('The queue is empty. Use "Push everything" or select items to push.',"quant")});v((0,xe.sprintf)(/* translators: %d: number of pending items */ /* translators: %d: number of pending items */ -(0,xe.__)("Processing %d pending items…","quant"),e.pending))}catch(e){c({status:"error",message:e.message||(0,xe.__)("Failed to read the queue","quant")})}},disabled:g,isBusy:g},g?(0,xe.__)("Processing…","quant"):(0,xe.__)("Process queue","quant")))))),S.length>0&&(0,t.createElement)(Se.Card,{className:"quant-content__browser-card"},(0,t.createElement)(Se.TabPanel,{tabs:S,onSelect:()=>o([])},e=>(0,t.createElement)(Re,{contentType:e.contentType,contentTypes:e.contentTypes,specialType:e.specialType,onSelectionChange:w,refreshToken:y,key:e.name}))))}function Fe(){const[e,n]=(0,a.useState)(""),[l,r]=(0,a.useState)(!1),[s,o]=(0,a.useState)(null);return(0,t.createElement)("div",{className:"quant-cache"},(0,t.createElement)("h1",null,(0,xe.__)("Cache Management","quant")),s&&(0,t.createElement)(Se.Notice,{status:s.status,onRemove:()=>o(null),isDismissible:!0},s.message),(0,t.createElement)(Se.Card,null,(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Purge Cache","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("p",{style:{color:"#666",marginTop:0}},(0,xe.__)("Enter paths to purge from the CDN cache. One path per line.","quant")),(0,t.createElement)(Se.TextareaControl,{label:(0,xe.__)("Paths to purge","quant"),value:e,onChange:n,placeholder:"/blog/my-post\n/about\n/contact",rows:8,help:(0,xe.__)("Use /* to purge all paths under a directory","quant")}),(0,t.createElement)("div",{style:{display:"flex",gap:"12px",marginTop:"16px"}},(0,t.createElement)(Se.Button,{variant:"primary",onClick:async()=>{if(e.trim()){r(!0),o(null);try{const t=e.split("\n").filter(e=>e.trim());await ke()({path:"/quant/v1/purge",method:"POST",data:{paths:t}}),o({status:"success",message:(0,xe.__)("Cache purged successfully","quant")}),n("")}catch(e){o({status:"error",message:e.message||(0,xe.__)("Failed to purge cache","quant")})}finally{r(!1)}}},disabled:l||!e.trim(),isBusy:l},(0,xe.__)("Purge Selected Paths","quant")),(0,t.createElement)(Se.Button,{variant:"secondary",isDestructive:!0,onClick:async()=>{if(window.confirm((0,xe.__)("Purge entire cache? This cannot be undone.","quant"))){r(!0),o(null);try{await ke()({path:"/quant/v1/purge",method:"POST",data:{paths:["/*"]}}),o({status:"success",message:(0,xe.__)("Entire cache purged","quant")})}catch(e){o({status:"error",message:e.message||(0,xe.__)("Failed to purge cache","quant")})}finally{r(!1)}}},disabled:l},(0,xe.__)("Purge All Cache","quant"))))))}function Oe({gap:e=4,className:a="",children:n}){return(0,t.createElement)(Se.Flex,{direction:"column",align:"stretch",justify:"flex-start",gap:e,className:`quant-form-stack ${a}`.trim()},n)}function He({label:e,help:a,children:n}){return(0,t.createElement)("fieldset",{className:"quant-field-group"},(0,t.createElement)("legend",{className:"quant-field-group__label"},e),a&&(0,t.createElement)("p",{className:"quant-field-group__help"},a),(0,t.createElement)(Oe,{gap:3},n))}const $e={facet_type:"taxonomy",taxonomy:"category",custom_key:"",facet_display:"checkbox",facet_heading:"",facet_limit:10},ze={id:"",label:"",status:!0,route:"/search",title:"Search",description:"",post_types:[],languages:[],manual_filters:"",display:{display_search:!0,display_stats:!0,show_clear_refinements:!0,pagination_enabled:!0,per_page:10},facets:[]};function Ie({facet:e,taxonomies:a,onChange:n,onRemove:l}){return(0,t.createElement)("div",{className:"quant-facet-row"},(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Type","quant"),value:e.facet_type,options:[{label:(0,xe.__)("Taxonomy","quant"),value:"taxonomy"},{label:(0,xe.__)("Content type","quant"),value:"content_type"},{label:(0,xe.__)("Language","quant"),value:"language"},{label:(0,xe.__)("Custom attribute","quant"),value:"custom"}],onChange:t=>n({...e,facet_type:t}),__nextHasNoMarginBottom:!0}),"taxonomy"===e.facet_type&&(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Taxonomy","quant"),value:e.taxonomy,options:a.map(e=>({label:e.label,value:e.name})),onChange:t=>n({...e,taxonomy:t}),__nextHasNoMarginBottom:!0}),"custom"===e.facet_type&&(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Attribute key","quant"),value:e.custom_key,onChange:t=>n({...e,custom_key:t}),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Heading","quant"),value:e.facet_heading,onChange:t=>n({...e,facet_heading:t}),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Display","quant"),value:e.facet_display,options:[{label:(0,xe.__)("Checkboxes","quant"),value:"checkbox"},{label:(0,xe.__)("Menu","quant"),value:"menu"},{label:(0,xe.__)("Select","quant"),value:"select"}],onChange:t=>n({...e,facet_display:t}),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Limit","quant"),type:"number",value:e.facet_limit,onChange:t=>n({...e,facet_limit:parseInt(t,10)||10}),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.Button,{variant:"link",isDestructive:!0,onClick:l},(0,xe.__)("Remove","quant")))}function Qe({page:e,postTypes:n,taxonomies:l,onSave:r,onCancel:s,saving:o}){const[i,c]=(0,a.useState)({...ze,...e||{},display:{...ze.display,...e?.display||{}}}),u=(e,t)=>c(a=>({...a,[e]:t})),m=(e,t)=>c(a=>({...a,display:{...a.display,[e]:t}}));return(0,t.createElement)(Oe,{className:"quant-search-page-editor"},(0,t.createElement)("h3",{className:"quant-form-stack__title"},e?.id?(0,xe.__)("Edit search page","quant"):(0,xe.__)("New search page","quant")),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Label","quant"),value:i.label,onChange:e=>u("label",e),help:(0,xe.__)("Shown in the admin only.","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Enabled","quant"),checked:!!i.status,onChange:e=>u("status",e),help:(0,xe.__)("A disabled page is kept as a draft and removed from Quant.","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Route","quant"),value:i.route,onChange:e=>u("route",e),placeholder:"/search",help:(0,xe.__)("The plugin creates a WordPress page at this path that renders the search.","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Page title","quant"),value:i.title,onChange:e=>u("title",e),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextareaControl,{label:(0,xe.__)("Description","quant"),value:i.description,onChange:e=>u("description",e),rows:2,__nextHasNoMarginBottom:!0}),(0,t.createElement)(He,{label:(0,xe.__)("Content types to search","quant"),help:(0,xe.__)("None selected = all indexed types.","quant")},n.map(e=>(0,t.createElement)(Se.CheckboxControl,{key:e.name,label:e.label,checked:i.post_types.includes(e.name),onChange:t=>((e,t)=>u("post_types",t?[...i.post_types,e]:i.post_types.filter(t=>t!==e)))(e.name,t),__nextHasNoMarginBottom:!0}))),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Manual filters","quant"),value:i.manual_filters,onChange:e=>u("manual_filters",e),placeholder:"category_en:'News'",help:(0,xe.__)("Appended to the page filters as written. Use the project's search syntax: Algolia on QuantCDN (category_en:'News'), Typesense on QuantGov (category_en:=`News`).","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(He,{label:(0,xe.__)("Display","quant")},(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Show the search box","quant"),checked:!!i.display.display_search,onChange:e=>m("display_search",e),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Show the result count","quant"),checked:!!i.display.display_stats,onChange:e=>m("display_stats",e),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)('Show "Clear refinements"',"quant"),checked:!!i.display.show_clear_refinements,onChange:e=>m("show_clear_refinements",e),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Pagination","quant"),checked:!!i.display.pagination_enabled,onChange:e=>m("pagination_enabled",e),__nextHasNoMarginBottom:!0})),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Results per page","quant"),type:"number",value:i.display.per_page,onChange:e=>m("per_page",parseInt(e,10)||10),__nextHasNoMarginBottom:!0}),(0,t.createElement)(He,{label:(0,xe.__)("Facets","quant")},i.facets.map((e,a)=>(0,t.createElement)(Ie,{key:a,facet:e,taxonomies:l,onChange:e=>((e,t)=>c(a=>({...a,facets:a.facets.map((a,n)=>n===e?t:a)})))(a,e),onRemove:()=>(e=>c(t=>({...t,facets:t.facets.filter((t,a)=>a!==e)})))(a)})),(0,t.createElement)("div",null,(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>u("facets",[...i.facets,{...$e}])},(0,xe.__)("Add facet","quant")))),(0,t.createElement)(Se.Flex,{justify:"flex-start",gap:2,className:"quant-form-stack__actions"},(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"primary",onClick:()=>r(i),disabled:o||!i.label.trim(),isBusy:o},(0,xe.__)("Save search page","quant"))),(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"tertiary",onClick:s,disabled:o},(0,xe.__)("Cancel","quant")))))}const Le=[0,3e3,7e3,2e4];function Ue({status:e,onRefresh:a}){var n,l;return e?(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Quant Search","quant")),(0,t.createElement)(Se.Button,{variant:"tertiary",size:"small",onClick:a},(0,xe.__)("Refresh","quant"))),(0,t.createElement)(Se.CardBody,null,!e.configured&&(0,t.createElement)(Se.Notice,{status:"warning",isDismissible:!1},(0,xe.__)("Configure the project credentials first.","quant")),e.configured&&!e.connected&&(0,t.createElement)(Se.Notice,{status:"error",isDismissible:!1},(0,xe.sprintf)(/* translators: %s: error message */ /* translators: %s: error message */ +(0,xe.__)("Queued %d items. Processing…","quant"),t.queued))}catch(e){c({status:"error",message:e.message||(0,xe.__)("Failed to queue content","quant")})}finally{m(!1)}}},x=async e=>{if(0!==s.length){m(!0),c(null);try{const t=await ke()({path:"/quant/v1/sync",method:"POST",data:{items:s,action:e}});o([]);const a="unpublish"===e?/* translators: %d: number of items */ /* translators: %d: number of items */(0,xe.__)("Queued %d items to unpublish. Processing…","quant"):/* translators: %d: number of items */ /* translators: %d: number of items */(0,xe.__)("Queued %d items. Processing…","quant");v((0,xe.sprintf)(a,t.queued))}catch(e){c({status:"error",message:e.message||(0,xe.__)("Failed to queue items","quant")})}finally{m(!1)}}},w=(0,a.useCallback)(e=>o(e),[]),S=(0,a.useMemo)(()=>{const e=[],t=e=>l.find(t=>t.type===e);for(const a of["post","page"]){const n=t(a);n&&e.push({name:a,title:`${n.label} (${n.count})`,contentType:n})}for(const t of l.filter(e=>"cpt"===e.type))e.push({name:`cpt_${t.subtype}`,title:`${t.label} (${t.count})`,contentType:t});const a=l.filter(e=>"taxonomy"===e.type);a.length>0&&e.push({name:"taxonomies",title:`${(0,xe.__)("Taxonomies","quant")} (${a.reduce((e,t)=>e+t.count,0)})`,contentTypes:a});const n=t("media");n&&e.push({name:"media",title:`${n.label} (${n.count})`,contentType:n});const r=t("theme_assets");r&&e.push({name:"theme_assets",title:`${r.label} (${r.count})`,contentType:r,specialType:"theme_assets"});const s=t("redirects");return s&&s.count>0&&e.push({name:"redirects",title:`${s.label} (${s.count})`,contentType:s,specialType:"redirects"}),e},[l]),N=l.filter(e=>"special"===e.category);return e.loading?(0,t.createElement)("div",{className:"quant-content"},(0,t.createElement)("h1",null,(0,xe.__)("Content","quant")),(0,t.createElement)(Se.Spinner,null)):d?(0,t.createElement)("div",{className:"quant-content"},(0,t.createElement)("h1",null,(0,xe.__)("Content","quant")),(0,t.createElement)(Se.Notice,{status:"error",isDismissible:!1},d)):"static"!==e.mode?(0,t.createElement)("div",{className:"quant-content"},(0,t.createElement)("h1",null,(0,xe.__)("Content","quant")),(0,t.createElement)(Se.Notice,{status:"info",isDismissible:!1},(0,xe.__)("Content publishing is available in Static Site mode. In CDN Only mode the plugin purges the Quant cache when content changes.","quant"))):(0,t.createElement)("div",{className:"quant-content"},(0,t.createElement)("h1",null,(0,xe.__)("Content","quant")),!e.connected&&(0,t.createElement)(Se.Notice,{status:"warning",isDismissible:!1},(0,xe.__)("Not connected to the Quant API. Check the settings before you push content.","quant")),i&&(0,t.createElement)(Se.Notice,{status:i.status,onRemove:()=>c(null),isDismissible:!0},i.message),_&&(0,t.createElement)(Me,{key:_,autoProcess:!0,onFinished:q,onComplete:b}),(0,t.createElement)(Se.Card,{className:"quant-content__header-card"},(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("div",{className:"quant-content__actions"},(0,t.createElement)("div",{className:"quant-content__mode-indicator"},(0,t.createElement)("span",{className:"quant-content__mode-badge quant-content__mode-badge--static"},(0,xe.__)("Static Mode","quant")),(0,t.createElement)("span",{className:"quant-content__mode-description"},(0,xe.__)("Pages, files and redirects are published to the Quant edge.","quant"))),(0,t.createElement)("div",{className:"quant-content__batch-actions"},(0,t.createElement)(Se.Button,{variant:"primary",onClick:()=>C(["all"],(0,xe.__)("Queue the whole site for publishing: posts, pages, custom post types, terms, media, home, archives, author pages, theme assets, custom routes, sitemaps and redirects. Continue?","quant")),disabled:u||g,isBusy:u},(0,xe.__)("Push everything","quant")),(0,t.createElement)(Se.DropdownMenu,{icon:"arrow-down-alt2",label:(0,xe.__)("Push one type","quant"),toggleProps:{variant:"secondary",children:(0,xe.__)("Push…","quant")}},()=>(0,t.createElement)(Se.MenuGroup,null,N.map(e=>(0,t.createElement)(Se.MenuItem,{key:e.type,onClick:()=>C([e.type])},`${e.label} (${e.count})`)),(0,t.createElement)(Se.MenuItem,{onClick:()=>C(["taxonomy"])},(0,xe.__)("All taxonomy terms","quant")),(0,t.createElement)(Se.MenuItem,{onClick:()=>C(["media"])},(0,xe.__)("All media","quant")))),(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>x("sync"),disabled:u||0===s.length||g},(0,xe.__)("Push selected","quant"),s.length>0&&` (${s.length})`),(0,t.createElement)(Se.Button,{variant:"tertiary",isDestructive:!0,onClick:()=>x("unpublish"),disabled:u||0===s.length||g},(0,xe.__)("Unpublish selected","quant")),(0,t.createElement)(Se.Button,{variant:"tertiary",onClick:async()=>{try{const e=await ke()({path:"/quant/v1/queue/status"});if(0===e.pending&&0===e.processing)return void c({status:"info",message:(0,xe.__)('The queue is empty. Use "Push everything" or select items to push.',"quant")});v((0,xe.sprintf)(/* translators: %d: number of pending items */ /* translators: %d: number of pending items */ +(0,xe.__)("Processing %d pending items…","quant"),e.pending))}catch(e){c({status:"error",message:e.message||(0,xe.__)("Failed to read the queue","quant")})}},disabled:g,isBusy:g},g?(0,xe.__)("Processing…","quant"):(0,xe.__)("Process queue","quant")))))),S.length>0&&(0,t.createElement)(Se.Card,{className:"quant-content__browser-card"},(0,t.createElement)(Se.TabPanel,{tabs:S,onSelect:()=>o([])},e=>(0,t.createElement)(Re,{contentType:e.contentType,contentTypes:e.contentTypes,specialType:e.specialType,onSelectionChange:w,refreshToken:y,key:e.name}))))}function Fe(){const[e,n]=(0,a.useState)(""),[l,r]=(0,a.useState)(!1),[s,o]=(0,a.useState)(null);return(0,t.createElement)("div",{className:"quant-cache"},(0,t.createElement)("h1",null,(0,xe.__)("Cache Management","quant")),s&&(0,t.createElement)(Se.Notice,{status:s.status,onRemove:()=>o(null),isDismissible:!0},s.message),(0,t.createElement)(Se.Card,null,(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Purge Cache","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("p",{style:{color:"#666",marginTop:0}},(0,xe.__)("Enter paths to purge from the CDN cache. One path per line.","quant")),(0,t.createElement)(Se.TextareaControl,{label:(0,xe.__)("Paths to purge","quant"),value:e,onChange:n,placeholder:"/blog/my-post\n/about\n/contact",rows:8,help:(0,xe.__)("Use /* to purge all paths under a directory","quant")}),(0,t.createElement)("div",{style:{display:"flex",gap:"12px",marginTop:"16px"}},(0,t.createElement)(Se.Button,{variant:"primary",onClick:async()=>{if(e.trim()){r(!0),o(null);try{const t=e.split("\n").filter(e=>e.trim());await ke()({path:"/quant/v1/purge",method:"POST",data:{paths:t}}),o({status:"success",message:(0,xe.__)("Cache purged successfully","quant")}),n("")}catch(e){o({status:"error",message:e.message||(0,xe.__)("Failed to purge cache","quant")})}finally{r(!1)}}},disabled:l||!e.trim(),isBusy:l},(0,xe.__)("Purge Selected Paths","quant")),(0,t.createElement)(Se.Button,{variant:"secondary",isDestructive:!0,onClick:async()=>{if(window.confirm((0,xe.__)("Purge entire cache? This cannot be undone.","quant"))){r(!0),o(null);try{await ke()({path:"/quant/v1/purge",method:"POST",data:{paths:["/*"]}}),o({status:"success",message:(0,xe.__)("Entire cache purged","quant")})}catch(e){o({status:"error",message:e.message||(0,xe.__)("Failed to purge cache","quant")})}finally{r(!1)}}},disabled:l},(0,xe.__)("Purge All Cache","quant"))))))}function Oe({gap:e=4,className:a="",children:n}){return(0,t.createElement)(Se.Flex,{direction:"column",align:"stretch",justify:"flex-start",gap:e,className:`quant-form-stack ${a}`.trim()},n)}function He({label:e,help:a,children:n}){return(0,t.createElement)("fieldset",{className:"quant-field-group"},(0,t.createElement)("legend",{className:"quant-field-group__label"},e),a&&(0,t.createElement)("p",{className:"quant-field-group__help"},a),(0,t.createElement)(Oe,{gap:3},n))}const $e={facet_type:"taxonomy",taxonomy:"category",custom_key:"",facet_display:"checkbox",facet_heading:"",facet_limit:10},Ie={id:"",label:"",status:!0,route:"/search",title:"Search",description:"",post_types:[],languages:[],manual_filters:"",display:{display_search:!0,display_stats:!0,show_clear_refinements:!0,pagination_enabled:!0,per_page:10},facets:[]};function ze({facet:e,taxonomies:a,onChange:n,onRemove:l}){return(0,t.createElement)("div",{className:"quant-facet-row"},(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Type","quant"),value:e.facet_type,options:[{label:(0,xe.__)("Taxonomy","quant"),value:"taxonomy"},{label:(0,xe.__)("Content type","quant"),value:"content_type"},{label:(0,xe.__)("Language","quant"),value:"language"},{label:(0,xe.__)("Custom attribute","quant"),value:"custom"}],onChange:t=>n({...e,facet_type:t}),__nextHasNoMarginBottom:!0}),"taxonomy"===e.facet_type&&(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Taxonomy","quant"),value:e.taxonomy,options:a.map(e=>({label:e.label,value:e.name})),onChange:t=>n({...e,taxonomy:t}),__nextHasNoMarginBottom:!0}),"custom"===e.facet_type&&(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Attribute key","quant"),value:e.custom_key,onChange:t=>n({...e,custom_key:t}),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Heading","quant"),value:e.facet_heading,onChange:t=>n({...e,facet_heading:t}),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Display","quant"),value:e.facet_display,options:[{label:(0,xe.__)("Checkboxes","quant"),value:"checkbox"},{label:(0,xe.__)("Menu","quant"),value:"menu"},{label:(0,xe.__)("Select","quant"),value:"select"}],onChange:t=>n({...e,facet_display:t}),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Limit","quant"),type:"number",value:e.facet_limit,onChange:t=>n({...e,facet_limit:parseInt(t,10)||10}),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.Button,{variant:"link",isDestructive:!0,onClick:l},(0,xe.__)("Remove","quant")))}function Qe({page:e,postTypes:n,taxonomies:l,onSave:r,onCancel:s,saving:o}){const[i,c]=(0,a.useState)({...Ie,...e||{},display:{...Ie.display,...e?.display||{}}}),u=(e,t)=>c(a=>({...a,[e]:t})),m=(e,t)=>c(a=>({...a,display:{...a.display,[e]:t}}));return(0,t.createElement)(Oe,{className:"quant-search-page-editor"},(0,t.createElement)("h3",{className:"quant-form-stack__title"},e?.id?(0,xe.__)("Edit search page","quant"):(0,xe.__)("New search page","quant")),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Label","quant"),value:i.label,onChange:e=>u("label",e),help:(0,xe.__)("Shown in the admin only.","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Enabled","quant"),checked:!!i.status,onChange:e=>u("status",e),help:(0,xe.__)("A disabled page is kept as a draft and removed from Quant.","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Route","quant"),value:i.route,onChange:e=>u("route",e),placeholder:"/search",help:(0,xe.__)("The plugin creates a WordPress page at this path that renders the search.","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Page title","quant"),value:i.title,onChange:e=>u("title",e),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextareaControl,{label:(0,xe.__)("Description","quant"),value:i.description,onChange:e=>u("description",e),rows:2,__nextHasNoMarginBottom:!0}),(0,t.createElement)(He,{label:(0,xe.__)("Content types to search","quant"),help:(0,xe.__)("None selected = all indexed types.","quant")},n.map(e=>(0,t.createElement)(Se.CheckboxControl,{key:e.name,label:e.label,checked:i.post_types.includes(e.name),onChange:t=>((e,t)=>u("post_types",t?[...i.post_types,e]:i.post_types.filter(t=>t!==e)))(e.name,t),__nextHasNoMarginBottom:!0}))),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Manual filters","quant"),value:i.manual_filters,onChange:e=>u("manual_filters",e),placeholder:"category_en:'News'",help:(0,xe.__)("Appended to the page filters as written. Use the project's search syntax: Algolia on QuantCDN (category_en:'News'), Typesense on QuantGov (category_en:=`News`).","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(He,{label:(0,xe.__)("Display","quant")},(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Show the search box","quant"),checked:!!i.display.display_search,onChange:e=>m("display_search",e),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Show the result count","quant"),checked:!!i.display.display_stats,onChange:e=>m("display_stats",e),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)('Show "Clear refinements"',"quant"),checked:!!i.display.show_clear_refinements,onChange:e=>m("show_clear_refinements",e),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Pagination","quant"),checked:!!i.display.pagination_enabled,onChange:e=>m("pagination_enabled",e),__nextHasNoMarginBottom:!0})),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Results per page","quant"),type:"number",value:i.display.per_page,onChange:e=>m("per_page",parseInt(e,10)||10),__nextHasNoMarginBottom:!0}),(0,t.createElement)(He,{label:(0,xe.__)("Facets","quant")},i.facets.map((e,a)=>(0,t.createElement)(ze,{key:a,facet:e,taxonomies:l,onChange:e=>((e,t)=>c(a=>({...a,facets:a.facets.map((a,n)=>n===e?t:a)})))(a,e),onRemove:()=>(e=>c(t=>({...t,facets:t.facets.filter((t,a)=>a!==e)})))(a)})),(0,t.createElement)("div",null,(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>u("facets",[...i.facets,{...$e}])},(0,xe.__)("Add facet","quant")))),(0,t.createElement)(Se.Flex,{justify:"flex-start",gap:2,className:"quant-form-stack__actions"},(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"primary",onClick:()=>r(i),disabled:o||!i.label.trim(),isBusy:o},(0,xe.__)("Save search page","quant"))),(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"tertiary",onClick:s,disabled:o},(0,xe.__)("Cancel","quant")))))}const Le=[0,3e3,7e3,2e4];function Ue({status:e,onRefresh:a}){var n,l;return e?(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Quant Search","quant")),(0,t.createElement)(Se.Button,{variant:"tertiary",size:"small",onClick:a},(0,xe.__)("Refresh","quant"))),(0,t.createElement)(Se.CardBody,null,!e.configured&&(0,t.createElement)(Se.Notice,{status:"warning",isDismissible:!1},(0,xe.__)("Configure the project credentials first.","quant")),e.configured&&!e.connected&&(0,t.createElement)(Se.Notice,{status:"error",isDismissible:!1},(0,xe.sprintf)(/* translators: %s: error message */ /* translators: %s: error message */ (0,xe.__)("Cannot reach the Quant API: %s","quant"),e.error||"")),e.connected&&!e.enabled&&(0,t.createElement)(Se.Notice,{status:"warning",isDismissible:!1},(0,xe.__)("Search is not enabled for this project. Enable it in the Quant dashboard under the project settings.","quant")),e.enabled&&(0,t.createElement)("table",{className:"widefat quant-kv-table"},(0,t.createElement)("tbody",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",null,(0,xe.__)("Index","quant")),(0,t.createElement)("td",null,e.index?.algolia_index||e.stats?.name||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",null,(0,xe.__)("Search engine","quant")),(0,t.createElement)("td",null,"typesense"===e.backend?"Typesense":"Algolia")),(0,t.createElement)("tr",null,(0,t.createElement)("th",null,(0,xe.__)("Records","quant")),(0,t.createElement)("td",null,null!==(n=e.stats?.entries)&&void 0!==n?n:"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",null,(0,xe.__)("Pending tasks","quant")),(0,t.createElement)("td",null,null!==(l=e.stats?.numberOfPendingTasks)&&void 0!==l?l:"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",null,(0,xe.__)("Last updated","quant")),(0,t.createElement)("td",null,e.stats?.updatedAt||"-")))))):(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardBody,null,(0,t.createElement)(Se.Spinner,null)))}function We({pages:e,onEdit:a,onDelete:n}){return 0===e.length?(0,t.createElement)("p",{style:{color:"#666"}},(0,xe.__)('No search pages yet. A search page is a WordPress page that shows Quant Search results with facets. You can also place the "Quant Search" block or the [quant_search] shortcode on any page.',"quant")):(0,t.createElement)("table",{className:"widefat striped"},(0,t.createElement)("thead",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",null,(0,xe.__)("Label","quant")),(0,t.createElement)("th",null,(0,xe.__)("Route","quant")),(0,t.createElement)("th",null,(0,xe.__)("Status","quant")),(0,t.createElement)("th",null,(0,xe.__)("Facets","quant")),(0,t.createElement)("th",null))),(0,t.createElement)("tbody",null,e.map(e=>(0,t.createElement)("tr",{key:e.id},(0,t.createElement)("td",null,(0,t.createElement)("strong",null,e.label),(0,t.createElement)("br",null),(0,t.createElement)("code",null,`[quant_search page="${e.id}"]`)),(0,t.createElement)("td",null,e.url?(0,t.createElement)("a",{href:e.url,target:"_blank",rel:"noreferrer"},e.route):e.route),(0,t.createElement)("td",null,e.status?(0,xe.__)("Enabled","quant"):(0,xe.__)("Disabled","quant")),(0,t.createElement)("td",null,e.facets.length),(0,t.createElement)("td",null,(0,t.createElement)(Se.Flex,{justify:"flex-end",gap:3},(0,t.createElement)(Se.Button,{variant:"link",onClick:()=>a(e)},(0,xe.__)("Edit","quant")),(0,t.createElement)(Se.Button,{variant:"link",isDestructive:!0,onClick:()=>n(e)},(0,xe.__)("Delete","quant"))))))))}function Ye(){const[e,n]=(0,a.useState)(null),[l,r]=(0,a.useState)(null),[s,o]=(0,a.useState)([]),[i,c]=(0,a.useState)(null),[u,m]=(0,a.useState)(null),[d,p]=(0,a.useState)(!1),[_,h]=(0,a.useState)(!1),[g,E]=(0,a.useState)(0),[y,f]=(0,a.useState)(!1),v=(e,t)=>m({status:e,message:t}),q=(e,t)=>e?.push_needed?v("warning",(0,xe.sprintf)(/* translators: %s: what was saved, for example "Search settings saved." */ /* translators: %s: what was saved, for example "Search settings saved." */ -(0,xe.__)("%s The search form is part of every page, so pages on the edge keep the old form until you push them again. On the Content page, select Push everything.","quant"),t)):v("success",t),b=(0,a.useCallback)(()=>E(0),[]),C=(0,a.useRef)(!0),x=(0,a.useRef)(null);(0,a.useEffect)(()=>(C.current=!0,()=>{C.current=!1,clearTimeout(x.current)}),[]);const w=(0,a.useCallback)(()=>ke()({path:"/quant/v1/search/status"}).catch(()=>({configured:!1})).then(e=>(C.current&&n(e),e)),[]),S=(0,a.useRef)(null),N=(0,a.useCallback)(()=>{clearTimeout(x.current);const e=(t,a)=>{w().then(n=>{const l=t+1;C.current&&l0||null===e||n.entries===e||null!==t&&n.entries!==t}(S.current,a,n)&&(x.current=setTimeout(()=>{var t;return e(l,null!==(t=n?.stats?.entries)&&void 0!==t?t:null)},Le[l]))})};e(0,S.current)},[w]);(0,a.useEffect)(()=>{w(),ke()({path:"/quant/v1/search/settings"}).then(e=>{r(e),f(!!e?.settings?.enabled)}).catch(e=>v("error",e.message)),ke()({path:"/quant/v1/search/pages"}).then(o).catch(()=>o([]))},[w]);const k=l?.settings,T=(e,t)=>r(a=>({...a,settings:{...a.settings,[e]:t}}));return(0,t.createElement)("div",{className:"quant-search-admin"},(0,t.createElement)("h1",null,(0,xe.__)("Search","quant")),u&&(0,t.createElement)(Se.Notice,{status:u.status,onRemove:()=>m(null),isDismissible:!0},u.message),g>0&&(0,t.createElement)(je,{key:g,autoProcess:!0,onFinished:N,onComplete:b}),(0,t.createElement)(Ue,{status:e,onRefresh:w}),k&&(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Search Records","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)(Oe,null,(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Keep search records updated","quant"),checked:!!k.enabled,onChange:e=>T("enabled",e),help:(0,xe.__)("Send a search record with every page push so the index follows your content.","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(He,{label:(0,xe.__)("Indexed content types","quant")},l.post_types.map(e=>(0,t.createElement)(Se.CheckboxControl,{key:e.name,label:e.label,checked:k.post_types.includes(e.name),onChange:t=>((e,t)=>T("post_types",t?[...k.post_types,e]:k.post_types.filter(t=>t!==e)))(e.name,t),__nextHasNoMarginBottom:!0}))),(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Summary source","quant"),value:k.summary_source,options:[{label:(0,xe.__)("Excerpt, or the first words of the content","quant"),value:"excerpt"},{label:(0,xe.__)("First words of the content","quant"),value:"content"}],onChange:e=>T("summary_source",e),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Image size","quant"),value:k.image_size,options:(l.image_sizes||[]).map(e=>({label:e,value:e})),onChange:e=>T("image_size",e),help:(0,xe.__)("Featured image size stored in the record.","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Send the WordPress search form to a Quant Search page","quant"),checked:!!k.replace_wp_search,onChange:e=>T("replace_wp_search",e),help:(0,xe.__)("The theme search form and ?s= searches use the default search page below, so search works on the static site.","quant"),__nextHasNoMarginBottom:!0}),k.replace_wp_search&&(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Default search page","quant"),value:k.default_page,options:[{label:(0,xe.__)("First enabled page","quant"),value:""}].concat(s.map(e=>({label:e.label,value:e.id}))),onChange:e=>T("default_page",e),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.Flex,{justify:"flex-start",gap:2,className:"quant-form-stack__actions"},(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"primary",onClick:async()=>{p(!0);try{const e=await ke()({path:"/quant/v1/search/settings",method:"POST",data:k});r(t=>({...t,settings:e.settings})),f(!!e.settings?.enabled),q(e,(0,xe.__)("Search settings saved.","quant"))}catch(e){v("error",e.message||(0,xe.__)("Failed to save","quant"))}finally{p(!1)}},disabled:d,isBusy:d},(0,xe.__)("Save","quant"))),(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"secondary",onClick:async()=>{var t;h(!0),S.current=null!==(t=e?.stats?.entries)&&void 0!==t?t:null;try{const e=await ke()({path:"/quant/v1/search/reindex",method:"POST"});v("success",(0,xe.sprintf)(/* translators: %d: number of records */ /* translators: %d: number of records */ +(0,xe.__)("%s The search form is part of every page, so pages on the edge keep the old form until you push them again. On the Content page, select Push everything.","quant"),t)):v("success",t),b=(0,a.useCallback)(()=>E(0),[]),C=(0,a.useRef)(!0),x=(0,a.useRef)(null);(0,a.useEffect)(()=>(C.current=!0,()=>{C.current=!1,clearTimeout(x.current)}),[]);const w=(0,a.useCallback)(()=>ke()({path:"/quant/v1/search/status"}).catch(()=>({configured:!1})).then(e=>(C.current&&n(e),e)),[]),S=(0,a.useRef)(null),N=(0,a.useCallback)(()=>{clearTimeout(x.current);const e=(t,a)=>{w().then(n=>{const l=t+1;C.current&&l0||null===e||n.entries===e||null!==t&&n.entries!==t}(S.current,a,n)&&(x.current=setTimeout(()=>{var t;return e(l,null!==(t=n?.stats?.entries)&&void 0!==t?t:null)},Le[l]))})};e(0,S.current)},[w]);(0,a.useEffect)(()=>{w(),ke()({path:"/quant/v1/search/settings"}).then(e=>{r(e),f(!!e?.settings?.enabled)}).catch(e=>v("error",e.message)),ke()({path:"/quant/v1/search/pages"}).then(o).catch(()=>o([]))},[w]);const k=l?.settings,T=(e,t)=>r(a=>({...a,settings:{...a.settings,[e]:t}}));return(0,t.createElement)("div",{className:"quant-search-admin"},(0,t.createElement)("h1",null,(0,xe.__)("Search","quant")),u&&(0,t.createElement)(Se.Notice,{status:u.status,onRemove:()=>m(null),isDismissible:!0},u.message),g>0&&(0,t.createElement)(Me,{key:g,autoProcess:!0,onFinished:N,onComplete:b}),(0,t.createElement)(Ue,{status:e,onRefresh:w}),k&&(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Search Records","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)(Oe,null,(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Keep search records updated","quant"),checked:!!k.enabled,onChange:e=>T("enabled",e),help:(0,xe.__)("Send a search record with every page push so the index follows your content.","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(He,{label:(0,xe.__)("Indexed content types","quant")},l.post_types.map(e=>(0,t.createElement)(Se.CheckboxControl,{key:e.name,label:e.label,checked:k.post_types.includes(e.name),onChange:t=>((e,t)=>T("post_types",t?[...k.post_types,e]:k.post_types.filter(t=>t!==e)))(e.name,t),__nextHasNoMarginBottom:!0}))),(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Summary source","quant"),value:k.summary_source,options:[{label:(0,xe.__)("Excerpt, or the first words of the content","quant"),value:"excerpt"},{label:(0,xe.__)("First words of the content","quant"),value:"content"}],onChange:e=>T("summary_source",e),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Image size","quant"),value:k.image_size,options:(l.image_sizes||[]).map(e=>({label:e,value:e})),onChange:e=>T("image_size",e),help:(0,xe.__)("Featured image size stored in the record.","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Send the WordPress search form to a Quant Search page","quant"),checked:!!k.replace_wp_search,onChange:e=>T("replace_wp_search",e),help:(0,xe.__)("The theme search form and ?s= searches use the default search page below, so search works on the static site.","quant"),__nextHasNoMarginBottom:!0}),k.replace_wp_search&&(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Default search page","quant"),value:k.default_page,options:[{label:(0,xe.__)("First enabled page","quant"),value:""}].concat(s.map(e=>({label:e.label,value:e.id}))),onChange:e=>T("default_page",e),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.Flex,{justify:"flex-start",gap:2,className:"quant-form-stack__actions"},(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"primary",onClick:async()=>{p(!0);try{const e=await ke()({path:"/quant/v1/search/settings",method:"POST",data:k});r(t=>({...t,settings:e.settings})),f(!!e.settings?.enabled),q(e,(0,xe.__)("Search settings saved.","quant"))}catch(e){v("error",e.message||(0,xe.__)("Failed to save","quant"))}finally{p(!1)}},disabled:d,isBusy:d},(0,xe.__)("Save","quant"))),(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"secondary",onClick:async()=>{var t;h(!0),S.current=null!==(t=e?.stats?.entries)&&void 0!==t?t:null;try{const e=await ke()({path:"/quant/v1/search/reindex",method:"POST"});v("success",(0,xe.sprintf)(/* translators: %d: number of records */ /* translators: %d: number of records */ (0,xe.__)("Queued %d records. Processing…","quant"),e.queued)),E(e=>e+1)}catch(e){v("error",e.message||(0,xe.__)("Reindex failed","quant"))}finally{h(!1)}},disabled:_||!y||!e?.enabled},(0,xe.__)("Rebuild index","quant"))),(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"secondary",isDestructive:!0,onClick:async()=>{const e=(0,xe.__)("Remove every record from the search index?","quant");if(window.confirm(e)){h(!0);try{await ke()({path:"/quant/v1/search/clear",method:"POST"}),v("success",(0,xe.__)("Search index cleared.","quant")),w()}catch(e){v("error",e.message||(0,xe.__)("Clear failed","quant"))}finally{h(!1)}}},disabled:_||!e?.enabled},(0,xe.__)("Clear index","quant"))))))),(0,t.createElement)(Se.Card,null,(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Search Pages","quant")),!i&&(0,t.createElement)(Se.Button,{variant:"primary",size:"small",onClick:()=>c({})},(0,xe.__)("Add search page","quant"))),(0,t.createElement)(Se.CardBody,null,i?(0,t.createElement)(Qe,{page:i.id?i:null,postTypes:l?.post_types||[],taxonomies:l?.taxonomies||[],onSave:async e=>{p(!0);try{const t=await ke()({path:"/quant/v1/search/pages",method:"POST",data:e});o(e=>[...e.filter(e=>e.id!==t.id),t]),c(null),q(t,(0,xe.__)("Search page saved. Its WordPress page was queued for publishing.","quant"))}catch(e){v("error",e.message||(0,xe.__)("Failed to save the search page","quant"))}finally{p(!1)}},onCancel:()=>c(null),saving:d}):(0,t.createElement)(We,{pages:s,onEdit:c,onDelete:async e=>{const t=(0,xe.sprintf)(/* translators: %s: search page label */ /* translators: %s: search page label */ (0,xe.__)('Delete the search page "%s"? Its WordPress page is moved to the trash.',"quant"),e.label);if(window.confirm(t))try{const t=await ke()({path:`/quant/v1/search/pages/${e.id}`,method:"DELETE"});o(t=>t.filter(t=>t.id!==e.id)),q(t,(0,xe.__)("Search page deleted.","quant"))}catch(e){v("error",e.message||(0,xe.__)("Delete failed","quant"))}}}))))}const Ve=[["home",(0,xe.__)("Home page","quant")],["post",(0,xe.__)("Posts","quant")],["page",(0,xe.__)("Pages","quant")],["taxonomy",(0,xe.__)("Categories and tags","quant")],["archives",(0,xe.__)("Archives","quant")],["authors",(0,xe.__)("Author pages","quant")],["sitemaps",(0,xe.__)("Sitemaps","quant")]],Ge=e=>Array.isArray(e)?e.join("\n"):e||"",Je=e=>e.split("\n").map(e=>e.trim()).filter(Boolean);function Ke({title:e,children:a,description:n}){return(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},e)),(0,t.createElement)(Se.CardBody,null,n&&(0,t.createElement)("p",{style:{color:"#666",marginTop:0}},n),a))}const Xe=(0,xe.__)("Set by a QUANT_* environment variable or constant. Change it there.","quant");function Ze({oauthStatus:e,setOauthStatus:n,onProjectSelected:l,notify:r,showManual:s,manualConfigured:o,setShowManual:i}){const[c,u]=(0,a.useState)([]),[m,d]=(0,a.useState)(!1),[p,_]=(0,a.useState)(!1),[h,g]=(0,a.useState)(null),[E,y]=(0,a.useState)(""),[f,v]=(0,a.useState)(""),[q,b]=(0,a.useState)(!1),[C,x]=(0,a.useState)(!1),w=(0,a.useCallback)(async()=>{d(!0),g(null);try{const e=await ke()({path:"/quant/v1/oauth/projects"});u(Array.isArray(e)?e:[])}catch(e){u([]),g(e.message||(0,xe.__)("Failed to load projects","quant"))}finally{d(!1),_(!0)}},[]);(0,a.useEffect)(()=>{e?.connected&&!e.has_project&&w()},[e,w]);const S=async()=>{try{const e=await ke()({path:"/quant/v1/oauth/connect"});e.redirect_url&&(window.location.href=e.redirect_url)}catch(e){r("error",e.message||(0,xe.__)("Failed to start the login","quant"))}},N=async()=>{try{await ke()({path:"/quant/v1/oauth/disconnect",method:"POST"}),n({connected:!1}),u([]),l(),r("success",(0,xe.__)("Disconnected from Quant","quant"))}catch(e){r("error",e.message||(0,xe.__)("Failed to disconnect","quant"))}},k=[{label:(0,xe.__)("Select an organization…","quant"),value:""}].concat(c.map(e=>({label:e.organization.name,value:e.organization.machine_name||e.organization.name}))),T=c.find(e=>(e.organization.machine_name||e.organization.name)===E),B=[{label:(0,xe.__)("Select a project…","quant"),value:""}].concat((T?.projects||[]).map(e=>({label:e.name||e.machine_name,value:e.machine_name||e.name})));if(!e?.connected)return(0,t.createElement)(Ke,{title:(0,xe.__)("Connect to Quant","quant"),description:(0,xe.__)("Sign in to Quant and pick the project this site publishes to. You can also enter the project credentials by hand.","quant")},(0,t.createElement)(Se.Flex,{justify:"flex-start",gap:4},(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"primary",onClick:S,icon:"admin-users"},(0,xe.__)("Login to Quant","quant"))),!o&&(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"link",onClick:()=>i(!s)},s?(0,xe.__)("Hide manual credentials","quant"):(0,xe.__)("Use manual credentials instead","quant")))));const P=function({loading:e,loaded:t,error:a,count:n}){return e||!t?"loading":a||0===n?"empty":"list"}({loading:m,loaded:p,error:h,count:c.length});return e.has_project&&!C?(0,t.createElement)(Ke,{title:(0,xe.__)("Connect to Quant","quant")},(0,t.createElement)("p",{className:"quant-connected-badge"},(0,t.createElement)(Se.Icon,{icon:"yes-alt"})," ",(0,xe.__)("Connected","quant"),e.user?.email&&(0,t.createElement)("span",{style:{color:"#666",marginLeft:"8px"}},e.user.email)),(0,t.createElement)("p",{style:{margin:"8px 0"}},(0,t.createElement)("strong",null,(0,xe.__)("Organization:","quant"))," ",e.organization),(0,t.createElement)("p",{style:{margin:"8px 0"}},(0,t.createElement)("strong",null,(0,xe.__)("Project:","quant"))," ",e.project),(0,t.createElement)(Se.Flex,{justify:"flex-start",gap:2,style:{marginTop:"16px"}},(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>{e.project_limited?(async()=>{try{await ke()({path:"/quant/v1/oauth/disconnect",method:"POST"})}catch(e){}await S()})():(x(!0),w())}},(0,xe.__)("Change Project","quant"))),(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"secondary",isDestructive:!0,onClick:N},(0,xe.__)("Disconnect","quant"))),(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"link",onClick:()=>i(!s)},s?(0,xe.__)("Hide manual credentials","quant"):(0,xe.__)("Show manual credentials","quant"))))):"empty"===P?(0,t.createElement)(Ke,{title:(0,xe.__)("Connect to Quant","quant")},(0,t.createElement)(Se.Notice,{status:"warning",isDismissible:!1},h||(0,xe.__)("No organizations or projects are available to this Quant account. Check the account in the Quant dashboard, or log in again.","quant")),(0,t.createElement)(Se.Flex,{justify:"flex-start",gap:2,style:{marginTop:"16px"}},(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"primary",onClick:S},(0,xe.__)("Reconnect","quant"))),(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"secondary",onClick:w},(0,xe.__)("Try again","quant"))),(0,t.createElement)(Se.FlexItem,null,C?(0,t.createElement)(Se.Button,{variant:"tertiary",onClick:()=>x(!1)},(0,xe.__)("Cancel","quant")):(0,t.createElement)(Se.Button,{variant:"tertiary",isDestructive:!0,onClick:N},(0,xe.__)("Disconnect","quant"))))):(0,t.createElement)(Ke,{title:(0,xe.__)("Connect to Quant","quant"),description:C?(0,xe.__)("Select a different project for this site.","quant"):(0,xe.__)("Connected. Select the project to use with this site.","quant")},"loading"===P?(0,t.createElement)(Se.Spinner,null):(0,t.createElement)(t.Fragment,null,(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Organization","quant"),value:E,options:k,onChange:e=>{y(e),v("")}}),E&&(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Project","quant"),value:f,options:B,onChange:v}),(0,t.createElement)(Se.Flex,{justify:"flex-start",gap:2,style:{marginTop:"16px"}},(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"primary",onClick:async()=>{b(!0);try{await ke()({path:"/quant/v1/oauth/select-project",method:"POST",data:{organization:E,project:f}}),n(await ke()({path:"/quant/v1/oauth/status"})),x(!1),l(),r("success",(0,xe.__)("Project selected.","quant"))}catch(e){r("error",e.message||(0,xe.__)("Failed to select the project","quant"))}finally{b(!1)}},disabled:!E||!f||q,isBusy:q},C?(0,xe.__)("Switch to This Project","quant"):(0,xe.__)("Use This Project","quant"))),(0,t.createElement)(Se.FlexItem,null,C?(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>x(!1)},(0,xe.__)("Cancel","quant")):(0,t.createElement)(Se.Button,{variant:"secondary",isDestructive:!0,onClick:N},(0,xe.__)("Disconnect","quant"))))))}function et(){const[e,n]=(0,a.useState)(null),[l,r]=(0,a.useState)(""),[s,o]=(0,a.useState)(""),[i,c]=(0,a.useState)(""),[u,m]=(0,a.useState)(null),[d,p]=(0,a.useState)(!1),[_,h]=(0,a.useState)(!1),[g,E]=(0,a.useState)(!1),[y,f]=(0,a.useState)(!1),[v,q]=(0,a.useState)(null),[b,C]=(0,a.useState)(null),x=(0,a.useCallback)((e,t)=>C({status:e,message:t}),[]),w=(0,a.useCallback)(async()=>{const[e,t]=await Promise.all([ke()({path:"/quant/v1/settings"}),ke()({path:"/quant/v1/oauth/status"})]);n(e),m(t),r(Ge(e.custom_routes)),o(Ge(e.domain_rewrites)),c(Ge(e.schedule?.custom_routes))},[]);(0,a.useEffect)(()=>{"success"===new URLSearchParams(window.location.search).get("oauth")&&(x("success",(0,xe.__)("Connected to Quant. Now select a project below.","quant")),window.history.replaceState({},"",window.location.pathname+"?page=quant#/settings")),w().catch(e=>x("error",e.message||(0,xe.__)("Failed to load settings","quant")))},[w,x]);const S=(e,t)=>n(a=>({...a,[e]:t})),N=(e,t)=>n(a=>({...a,schedule:{...a.schedule,[e]:t}})),k=async()=>{h(!0),C(null);try{const t={...e,custom_routes:Je(l),domain_rewrites:Je(s),schedule:{...e.schedule,custom_routes:Je(i)}};delete t.overridden,delete t.schedules,delete t.has_token,t.token||delete t.token;const a=await ke()({path:"/quant/v1/settings",method:"POST",data:t});n({...a,token:""}),x("success",(0,xe.__)("Settings saved.","quant"))}catch(e){x("error",e.message||(0,xe.__)("Failed to save settings","quant"))}finally{h(!1)}};if(!e)return(0,t.createElement)("div",{className:"quant-settings"},(0,t.createElement)("h1",null,(0,xe.__)("Settings","quant")),(0,t.createElement)(Se.Spinner,null));const T=e.overridden||{},B=!!(e.organization&&e.project&&e.has_token),P=(e,t)=>!(!T[e]||!T[e].includes(t)),D=(e,t,a)=>P(e,t)?Xe:a,R=Object.entries(e.schedules||{}).map(([e,t])=>({value:e,label:t}));return(0,t.createElement)("div",{className:"quant-settings"},(0,t.createElement)("h1",null,(0,xe.__)("Settings","quant")),b&&(0,t.createElement)(Se.Notice,{status:b.status,onRemove:()=>C(null),isDismissible:!0},b.message),(0,t.createElement)(Ze,{oauthStatus:u,setOauthStatus:m,onProjectSelected:()=>w().catch(()=>{}),notify:x,showManual:d,manualConfigured:B,setShowManual:p}),(d||!u?.connected&&B)&&(0,t.createElement)(Ke,{title:(0,xe.__)("Project Credentials","quant"),description:(0,xe.__)("Copy these from the Integrations page of the Quant dashboard.","quant")},(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("API Endpoint","quant"),value:e.endpoint,onChange:e=>S("endpoint",e),disabled:P("credentials","endpoint"),help:D("credentials","endpoint",(0,xe.__)("https://api.quantcdn.io for QuantCDN, https://api.quantgov.cloud for QuantGov. Without /v1.","quant"))}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Organization (API Customer)","quant"),value:e.organization,onChange:e=>S("organization",e),disabled:P("credentials","organization"),help:D("credentials","organization")}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Project","quant"),value:e.project,onChange:e=>S("project",e),disabled:P("credentials","project"),help:D("credentials","project")}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("API Token","quant"),type:"password",value:e.token,onChange:e=>S("token",e),disabled:P("credentials","token"),placeholder:e.has_token?(0,xe.__)("Token saved. Enter a new one to replace it.","quant"):(0,xe.__)("Project token","quant"),help:D("credentials","token")}),(0,t.createElement)(Se.Flex,{justify:"flex-start",gap:2,style:{marginTop:"16px"}},(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"primary",onClick:k,disabled:_,isBusy:_},(0,xe.__)("Save Credentials","quant"))),(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"secondary",onClick:async()=>{E(!0);try{const e=await ke()({path:"/quant/v1/status"});x(e.connected?"success":"error",e.connected?(0,xe.__)("Connected to the Quant API.","quant"):(0,xe.sprintf)(/* translators: %s: error message */ /* translators: %s: error message */ (0,xe.__)("Connection failed: %s","quant"),e.error||(0,xe.__)("check your credentials","quant")))}catch(e){x("error",e.message||(0,xe.__)("Connection test failed","quant"))}finally{E(!1)}},disabled:g,isBusy:g},(0,xe.__)("Test Connection","quant"))))),(0,t.createElement)(Ke,{title:(0,xe.__)("API Connection","quant"),description:(0,xe.__)("Applies to every request the plugin makes, in both modes.","quant")},(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("HTTP request timeout (seconds)","quant"),type:"number",value:e.http_request_timeout,onChange:e=>S("http_request_timeout",parseInt(e,10)||30),disabled:P("credentials","http_request_timeout")})),(0,t.createElement)(Ke,{title:(0,xe.__)("Mode","quant")},(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Plugin mode","quant"),value:e.mode,disabled:P("mode","mode"),options:[{label:(0,xe.__)("Static Site: publish the whole site to the Quant edge","quant"),value:"static"},{label:(0,xe.__)("CDN Only: WordPress serves pages, Quant caches and purges them","quant"),value:"cdn_only"}],onChange:e=>S("mode",e)}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Automatic static push","quant"),checked:!!e.auto_sync,onChange:e=>S("auto_sync",e),disabled:"static"!==e.mode||P("sync","auto_sync"),help:(0,xe.__)("Push pages, terms and media to Quant when they change (Static mode).","quant")}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Also push related pages","quant"),checked:!1!==e.sync_related,onChange:e=>S("sync_related",e),disabled:"static"!==e.mode||!e.auto_sync,help:(0,xe.__)("When a post changes, also push the home page, its categories, tags, author and archive pages.","quant")}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Cache purge on save","quant"),checked:!!e.auto_purge,onChange:e=>S("auto_purge",e),disabled:P("sync","auto_purge"),help:(0,xe.__)("Purge the edge cache when content changes. Used in CDN Only mode, or in Static mode when automatic push is off.","quant")})),"static"===e.mode&&(0,t.createElement)(Ke,{title:(0,xe.__)("Static Site Rendering","quant"),description:(0,xe.__)("How the plugin fetches pages from this server before it pushes them.","quant")},(0,t.createElement)("div",{className:"quant-inline-test"},(0,t.createElement)("strong",null,(0,xe.__)("Internal connectivity","quant")),null===v?(0,t.createElement)("span",{style:{color:"#757575"}},(0,xe.__)("Not tested","quant")):(0,t.createElement)("span",{style:{color:v.success?"#00a32a":"#d63638"}},v.success?"✓ "+(0,xe.__)("Working","quant"):"✗ "+v.message),(0,t.createElement)(Se.Button,{variant:"secondary",size:"small",onClick:async()=>{f(!0),q(null);try{q(await ke()({path:"/quant/v1/test-internal",method:"POST"}))}catch(e){q({success:!1,message:e.message||(0,xe.__)("Test failed","quant")})}finally{f(!1)}},disabled:y,isBusy:y},(0,xe.__)("Test","quant")),v?.adjusted_url&&(0,t.createElement)("div",{className:"quant-inline-test__detail"},v.adjusted_url,v.host_header?` (Host: ${v.host_header})`:"")),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Webserver URL","quant"),value:e.webserver_url,onChange:e=>S("webserver_url",e),placeholder:"http://localhost",disabled:P("static","webserver_url"),help:D("static","webserver_url",(0,xe.__)("Where the plugin can reach this site from the server, for example http://localhost. Leave empty to use the site URL.","quant"))}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Hostname (Host header)","quant"),value:e.webserver_host,onChange:e=>S("webserver_host",e),placeholder:"www.example.com",disabled:P("static","webserver_host"),help:D("static","webserver_host",(0,xe.__)("The public hostname WordPress expects, so rendered links use the public domain.","quant"))}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Disable SSL verification for the local webserver","quant"),checked:!!e.disable_tls_verify,onChange:e=>S("disable_tls_verify",e),disabled:P("credentials","disable_tls_verify"),help:D("credentials","disable_tls_verify",(0,xe.__)("Only for a local webserver with a self-signed certificate. Calls to the Quant API always verify TLS.","quant"))}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Strip host domain from content","quant"),checked:!!e.strip_host,onChange:e=>S("strip_host",e),help:(0,xe.__)("Rewrite absolute links to this site as relative links, and upgrade http:// links to https://.","quant")}),(0,t.createElement)(Se.TextareaControl,{label:(0,xe.__)("Additional domains to make relative","quant"),value:s,onChange:o,rows:3,placeholder:"cdn.example.com",help:(0,xe.__)("One per line. Useful for staging or editorial domains.","quant")}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Follow pagination links","quant"),checked:!!e.follow_links,onChange:e=>S("follow_links",e),help:(0,xe.__)("Queue the next pages of listings found in rendered markup.","quant")}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Override existing proxies","quant"),checked:!!e.proxy_override,onChange:e=>S("proxy_override",e),help:(0,xe.__)("Tell Quant that pushed pages replace proxy rules at the same path.","quant")})),"static"===e.mode&&(0,t.createElement)(Ke,{title:(0,xe.__)("Seed Content","quant"),description:(0,xe.__)("What a full push includes besides posts, pages, terms and media.","quant")},(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Theme assets","quant"),checked:!!e.theme_assets,onChange:e=>S("theme_assets",e),help:(0,xe.__)("CSS, JavaScript, images and fonts of the active theme and its parent.","quant")}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Sitemaps","quant"),checked:!!e.sitemap,onChange:e=>S("sitemap",e),help:(0,xe.__)("WordPress core sitemaps, Yoast SEO and Rank Math sitemaps with their stylesheets.","quant")}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("404 page path","quant"),value:e.route_404,onChange:e=>S("route_404",e),placeholder:"/404",help:(0,xe.__)("A path that returns your 404 page. It is published as the Quant 404 page.","quant")}),(0,t.createElement)(Se.TextareaControl,{label:(0,xe.__)("Custom routes","quant"),value:l,onChange:r,rows:4,placeholder:"/robots.txt\n/custom-page",help:(0,xe.__)("One per line. Content routes are rendered; paths that match a file on disk are uploaded.","quant")})),"static"===e.mode&&(0,t.createElement)(Ke,{title:(0,xe.__)("Scheduled Push","quant"),description:(0,xe.__)("Re-push chosen content on a schedule, for example for listings that change without a save.","quant")},(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Enable scheduled push","quant"),checked:!!e.schedule?.enabled,onChange:e=>N("enabled",e)}),e.schedule?.enabled&&(0,t.createElement)(t.Fragment,null,(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Schedule","quant"),value:e.schedule.interval,options:R,onChange:e=>N("interval",e)}),Ve.map(([a,n])=>(0,t.createElement)(Se.CheckboxControl,{key:a,label:n,checked:(e.schedule.types||[]).includes(a),onChange:t=>N("types",t?[...e.schedule.types||[],a]:(e.schedule.types||[]).filter(e=>e!==a))})),(0,t.createElement)(Se.TextareaControl,{label:(0,xe.__)("Custom routes","quant"),value:i,onChange:c,rows:3,placeholder:"/robots.txt"}))),(0,t.createElement)("div",{style:{marginTop:"16px"}},(0,t.createElement)(Se.Button,{variant:"primary",onClick:k,disabled:_,isBusy:_},(0,xe.__)("Save Settings","quant"))))}function tt(){const e=G(),[n,l]=(0,a.useState)(null),[r,s]=(0,a.useState)(!0),[o,i]=(0,a.useState)(!1),[c,u]=(0,a.useState)(null),[m,d]=(0,a.useState)(!1),[p,_]=(0,a.useState)(!1),[h,g]=(0,a.useState)(null),[E,y]=(0,a.useState)(null),[f,v]=(0,a.useState)("detect");(0,a.useEffect)(()=>{q()},[]);const q=async()=>{s(!0),y(null);try{const e=await ke()({path:"/quant/v1/migration/status"});l(e),e.migration_complete?v("complete"):e.needs_migration?v("preview"):e.has_v1_settings||v("no-migration")}catch(e){y(e.message||(0,xe.__)("Failed to check migration status","quant"))}finally{s(!1)}},b=async()=>{d(!0),u(null);try{await ke()({path:"/quant/v1/migration/cleanup",method:"POST"}),u({status:"success",message:(0,xe.__)("The 1.x settings were removed.","quant")});const e=await ke()({path:"/quant/v1/migration/status"});l(e)}catch(e){u({status:"error",message:e.message||(0,xe.__)("The 1.x settings could not be removed.","quant")})}finally{d(!1)}};return r?(0,t.createElement)("div",{className:"quant-migration"},(0,t.createElement)("h1",null,(0,xe.__)("Migration","quant")),(0,t.createElement)(Se.Card,null,(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("div",{style:{display:"flex",alignItems:"center",gap:"10px"}},(0,t.createElement)(Se.Spinner,null),(0,t.createElement)("span",null,(0,xe.__)("Checking for v1 settings…","quant")))))):"no-migration"===f?(0,t.createElement)("div",{className:"quant-migration"},(0,t.createElement)("h1",null,(0,xe.__)("Migration","quant")),(0,t.createElement)(Se.Notice,{status:"info",isDismissible:!1},(0,xe.__)("No v1 settings found. Nothing to migrate.","quant")),(0,t.createElement)("div",{style:{marginTop:"16px"}},(0,t.createElement)(Se.Button,{variant:"primary",onClick:()=>e("/settings")},(0,xe.__)("Go to Settings","quant")))):"complete"===f?(0,t.createElement)("div",{className:"quant-migration"},(0,t.createElement)("h1",null,(0,xe.__)("Migration Complete","quant")),c&&(0,t.createElement)(Se.Notice,{status:c.status,onRemove:()=>u(null),isDismissible:!0},c.message),(0,t.createElement)(Se.Notice,{status:"success",isDismissible:!1},(0,xe.__)("Your settings have been successfully migrated to v2!","quant")),h&&h.migrated&&(0,t.createElement)(Se.Card,{style:{marginTop:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Migration Summary","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("ul",{style:{margin:0,paddingLeft:"20px"}},h.migrated.credentials&&(0,t.createElement)("li",null,(0,xe.__)("Credentials migrated:","quant")," ",h.migrated.credentials.organization," ","/"," ",h.migrated.credentials.project),h.migrated.mode&&(0,t.createElement)("li",null,(0,xe.__)("Mode set to:","quant")," ",h.migrated.mode),h.migrated.migration_date&&(0,t.createElement)("li",null,(0,xe.__)("Migrated at:","quant")," ",h.migrated.migration_date)))),(0,t.createElement)(Se.Card,{style:{marginTop:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Next Steps","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("p",null,(0,xe.__)("Your migration is complete. Here are some recommended next steps:","quant")),(0,t.createElement)("ul",{style:{paddingLeft:"20px"}},(0,t.createElement)("li",null,(0,xe.__)("Verify your connection in Settings","quant")),(0,t.createElement)("li",null,(0,xe.__)("Review your content sync status","quant")),(0,t.createElement)("li",null,(0,xe.__)("Run a full site sync if needed","quant"))),(0,t.createElement)("div",{style:{display:"flex",gap:"10px",marginTop:"16px"}},(0,t.createElement)(Se.Button,{variant:"primary",onClick:()=>e("/dashboard")},(0,xe.__)("Go to Dashboard","quant")),(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>e("/settings")},(0,xe.__)("Review Settings","quant"))))),n&&n.has_v1_settings&&(0,t.createElement)(Se.Card,{style:{marginTop:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Cleanup","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("p",null,(0,xe.__)("Old v1 data still exists. You can clean it up now or later.","quant")),(0,t.createElement)(Se.Button,{variant:"secondary",onClick:b,disabled:m,isBusy:m},m?(0,xe.__)("Cleaning up…","quant"):(0,xe.__)("Clean Up v1 Data","quant"))))):(0,t.createElement)("div",{className:"quant-migration"},(0,t.createElement)("h1",null,(0,xe.__)("Migrate from v1","quant")),E&&(0,t.createElement)(Se.Notice,{status:"error",onRemove:()=>y(null),isDismissible:!0},E),(0,t.createElement)(Se.Notice,{status:"info",isDismissible:!1},(0,xe.__)("We detected settings from v1 of the QuantCDN plugin. Let's migrate them to v2.","quant")),(0,t.createElement)(Se.Card,{style:{marginTop:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Settings to Migrate","quant"))),(0,t.createElement)(Se.CardBody,null,n&&n.v1_settings&&(0,t.createElement)("table",{className:"widefat",style:{marginBottom:"16px"}},(0,t.createElement)("tbody",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Organization","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},n.v1_settings.organization||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Project","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},n.v1_settings.project||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("API Token","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},n.v1_settings.token||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Endpoint","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},n.v1_settings.endpoint||"https://api.quantcdn.io")))),n&&n.v1_queue_count>0&&(0,t.createElement)("p",{style:{color:"#666"}},(0,xe.sprintf)(/* translators: %d: number of v1 batch rows */ /* translators: %d: number of v1 batch rows */ -(0,xe.__)("Note: %d rows of 1.x batch state are still in wp_options. Cleanup removes them.","quant"),n.v1_queue_count)),(0,t.createElement)(Se.CheckboxControl,{label:(0,xe.__)("Remove old v1 data after migration","quant"),checked:p,onChange:_,help:(0,xe.__)("This will delete old options and tables after successful migration","quant")}))),(0,t.createElement)(Se.Card,{style:{marginTop:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("What will happen","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("ul",{style:{margin:0,paddingLeft:"20px"}},(0,t.createElement)("li",null,(0,xe.__)("Your API credentials will be copied to the new format","quant")),(0,t.createElement)("li",null,(0,xe.__)('Plugin mode will be set to "Static" (default)',"quant")),(0,t.createElement)("li",null,(0,xe.__)("Auto-sync and auto-purge will be enabled","quant")),(0,t.createElement)("li",null,(0,xe.__)("A new sync queue table will be used","quant"))))),(0,t.createElement)("div",{style:{marginTop:"16px",display:"flex",gap:"10px"}},(0,t.createElement)(Se.Button,{variant:"primary",onClick:async()=>{i(!0),y(null);try{const e=await ke()({path:"/quant/v1/migration/run",method:"POST"});g(e),p&&e.success&&await b(),v("complete")}catch(e){y(e.message||(0,xe.__)("Migration failed","quant"))}finally{i(!1)}},disabled:o,isBusy:o},o?(0,xe.__)("Migrating…","quant"):(0,xe.__)("Start Migration","quant")),(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>e("/settings"),disabled:o},(0,xe.__)("Skip & Configure Manually","quant"))))}function at({result:e}){return"success"===e?(0,t.createElement)("div",{style:{padding:"20px"}},(0,t.createElement)("span",{className:"dashicons dashicons-yes-alt",style:{fontSize:"48px",color:"#00a32a",display:"block",marginBottom:"15px"}}),(0,t.createElement)("p",{style:{fontSize:"18px",color:"#00a32a",margin:0}},(0,xe.__)("Connection successful!","quant"))):"error"===e?(0,t.createElement)("div",{style:{padding:"20px"}},(0,t.createElement)("span",{className:"dashicons dashicons-dismiss",style:{fontSize:"48px",color:"#d63638",display:"block",marginBottom:"15px"}}),(0,t.createElement)("p",{style:{fontSize:"18px",color:"#d63638",margin:0}},(0,xe.__)("Connection failed","quant"))):(0,t.createElement)("div",{style:{padding:"20px"}},(0,t.createElement)("p",{style:{color:"#666",marginTop:0}},(0,xe.__)("Let's verify your connection to QuantCDN.","quant")))}const nt=[{label:"QuantCDN",value:"https://api.quantcdn.io"},{label:"QuantGov",value:"https://api.quantgov.cloud"}];function lt(){const e=G(),[n,l]=(0,a.useState)(1),[r,s]=(0,a.useState)(!0),[o,i]=(0,a.useState)(!1),[c,u]=(0,a.useState)(!1),[m,d]=(0,a.useState)(null),[p,_]=(0,a.useState)(null),[h,g]=(0,a.useState)({organization:"",project:"",token:"",endpoint:"https://api.quantcdn.io",mode:"static"});(0,a.useEffect)(()=>{E()},[]);const E=async()=>{try{const t=await ke()({path:"/quant/v1/onboarding/status"});if("migration"===t.state)return void e("/migration");"configured"===t.state&&e("/dashboard")}catch(e){}finally{s(!1)}},y=(e,t)=>{g(a=>({...a,[e]:t}))},f=async()=>{i(!0),d(null);try{await ke()({path:"/quant/v1/settings",method:"POST",data:h}),l(3)}catch(e){d(e.message||(0,xe.__)("Failed to save credentials","quant"))}finally{i(!1)}},v=async()=>{i(!0),d(null);try{await ke()({path:"/quant/v1/settings",method:"POST",data:{endpoint:h.endpoint,mode:h.mode}});const e=await ke()({path:"/quant/v1/oauth/connect"});if(e.redirect_url)return void(window.location.href=e.redirect_url);d((0,xe.__)("The login could not be started.","quant"))}catch(e){d(e.message||(0,xe.__)("The login could not be started.","quant"))}i(!1)},q=nt.find(e=>e.value===h.endpoint)?h.endpoint:"custom",b=async()=>{i(!0),d(null);try{await ke()({path:"/quant/v1/settings",method:"POST",data:{mode:h.mode}}),l(4)}catch(e){d(e.message||(0,xe.__)("Failed to save mode","quant"))}finally{i(!1)}},C=async()=>{u(!0),d(null),_(null);try{(await ke()({path:"/quant/v1/status"})).connected?(_("success"),setTimeout(()=>l(5),1500)):(_("error"),d((0,xe.__)("Connection failed. Please check your credentials.","quant")))}catch(e){_("error"),d(e.message||(0,xe.__)("Connection test failed","quant"))}finally{u(!1)}};return r?(0,t.createElement)("div",{className:"quant-onboarding"},(0,t.createElement)("div",{style:{display:"flex",alignItems:"center",justifyContent:"center",minHeight:"200px"}},(0,t.createElement)(Se.Spinner,null))):(0,t.createElement)("div",{className:"quant-onboarding",style:{maxWidth:"700px",margin:"0 auto"}},n>=2&&n<=4&&(0,t.createElement)("div",{style:{display:"flex",justifyContent:"center",gap:"10px",marginBottom:"30px"}},[2,3,4].map(e=>(0,t.createElement)("div",{key:e,style:{width:"12px",height:"12px",borderRadius:"50%",backgroundColor:n>=e?"#5c40ff":"#ddd"}}))),(()=>{switch(n){case 1:return(0,t.createElement)(Se.Card,{className:"quant-onboarding__card"},(0,t.createElement)(Se.CardBody,{style:{textAlign:"center",padding:"40px"}},(0,t.createElement)("svg",{width:"64",height:"64",viewBox:"0 0 32 32",fill:"none",style:{marginBottom:"20px"}},(0,t.createElement)("rect",{width:"32",height:"32",rx:"6",fill:"#5c40ff"}),(0,t.createElement)("text",{x:"50%",y:"55%",dominantBaseline:"middle",textAnchor:"middle",fill:"white",fontSize:"16",fontWeight:"bold"},"Q")),(0,t.createElement)("h1",{style:{marginBottom:"10px"}},(0,xe.__)("Welcome to QuantCDN","quant")),(0,t.createElement)("p",{style:{color:"#666",fontSize:"16px",maxWidth:"500px",margin:"0 auto 30px"}},(0,xe.__)("Let's get your WordPress site connected to QuantCDN. This wizard will guide you through the setup process.","quant")),(0,t.createElement)(Se.Button,{variant:"primary",onClick:()=>l(2),style:{padding:"10px 30px"}},(0,xe.__)("Get Started","quant"))));case 2:return(0,t.createElement)(Se.Card,{className:"quant-onboarding__card"},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Step 1: API Credentials","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)(Oe,null,m&&(0,t.createElement)(Se.Notice,{status:"error",onRemove:()=>d(null),isDismissible:!0},m),(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Platform","quant"),value:q,options:nt.concat([{label:(0,xe.__)("Other endpoint","quant"),value:"custom"}]),onChange:e=>{"custom"!==e&&y("endpoint",e)},help:(0,xe.__)("Choose the dashboard you sign in to.","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)("div",{className:"quant-onboarding__oauth"},(0,t.createElement)(Se.Button,{variant:"primary",icon:"admin-users",onClick:v,disabled:o,isBusy:o},(0,xe.__)("Login to Quant","quant")),(0,t.createElement)("p",{style:{color:"#666"}},(0,xe.__)("Sign in and pick the project, or enter the project credentials below.","quant"))),(0,t.createElement)("p",{style:{color:"#666",marginTop:0}},(0,xe.__)("Or enter the project credentials. You can find them in the","quant")," ",(0,t.createElement)("a",{href:"https://api.quantgov.cloud"===q?"https://dash.quantgov.cloud":"https://dashboard.quantcdn.io",target:"_blank",rel:"noopener noreferrer"},(0,xe.__)("Quant dashboard","quant"))," ",(0,xe.__)("on the Integrations page of the project.","quant")),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Organization","quant"),value:h.organization,onChange:e=>y("organization",e),help:(0,xe.__)("Your QuantCDN organization identifier","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Project","quant"),value:h.project,onChange:e=>y("project",e),help:(0,xe.__)("Your QuantCDN project name","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("API Token","quant"),type:"password",value:h.token,onChange:e=>y("token",e),help:(0,xe.__)("Your project API token","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("API Endpoint","quant"),value:h.endpoint,onChange:e=>y("endpoint",e),help:(0,xe.__)("https://api.quantcdn.io for QuantCDN, https://api.quantgov.cloud for QuantGov. Without /v1.","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)("div",{style:{display:"flex",gap:"10px"}},(0,t.createElement)(Se.Button,{variant:"primary",onClick:f,disabled:o||!h.organization||!h.project||!h.token,isBusy:o},o?(0,xe.__)("Saving…","quant"):(0,xe.__)("Continue","quant"))))));case 3:return(0,t.createElement)(Se.Card,{className:"quant-onboarding__card"},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Step 2: Choose Mode","quant"))),(0,t.createElement)(Se.CardBody,null,m&&(0,t.createElement)(Se.Notice,{status:"error",onRemove:()=>d(null),isDismissible:!0},m),(0,t.createElement)("p",{style:{color:"#666",marginTop:0}},(0,xe.__)("Choose how you want to use QuantCDN with your WordPress site.","quant")),(0,t.createElement)("div",{style:{marginBottom:"20px"}},(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Plugin Mode","quant"),value:h.mode,options:[{label:(0,xe.__)("Static Site (Recommended)","quant"),value:"static"},{label:(0,xe.__)("CDN Only","quant"),value:"cdn_only"}],onChange:e=>y("mode",e)})),(0,t.createElement)("div",{style:{backgroundColor:"#f0f0f0",padding:"15px",borderRadius:"4px",marginBottom:"20px"}},"static"===h.mode?(0,t.createElement)(t.Fragment,null,(0,t.createElement)("strong",null,(0,xe.__)("Static Site Mode","quant")),(0,t.createElement)("p",{style:{margin:"10px 0 0",color:"#666"}},(0,xe.__)("Your entire site is rendered and served from QuantCDN's edge. Pages are synced automatically when you publish or update content. Ideal for maximum performance and security.","quant"))):(0,t.createElement)(t.Fragment,null,(0,t.createElement)("strong",null,(0,xe.__)("CDN Only Mode","quant")),(0,t.createElement)("p",{style:{margin:"10px 0 0",color:"#666"}},(0,xe.__)("WordPress serves dynamic content while QuantCDN handles caching. Cache is automatically purged when content changes. Good for sites that need dynamic features.","quant")))),(0,t.createElement)("div",{style:{display:"flex",gap:"10px"}},(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>l(2)},(0,xe.__)("Back","quant")),(0,t.createElement)(Se.Button,{variant:"primary",onClick:b,disabled:o,isBusy:o},o?(0,xe.__)("Saving…","quant"):(0,xe.__)("Continue","quant")))));case 4:return(0,t.createElement)(Se.Card,{className:"quant-onboarding__card"},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Step 3: Test Connection","quant"))),(0,t.createElement)(Se.CardBody,{style:{textAlign:"center"}},m&&(0,t.createElement)(Se.Notice,{status:"error",onRemove:()=>d(null),isDismissible:!0},m),(0,t.createElement)(at,{result:p}),(0,t.createElement)("div",{style:{display:"flex",justifyContent:"center",gap:"10px",marginTop:"20px"}},(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>l(2),disabled:c},(0,xe.__)("Back to Credentials","quant")),(0,t.createElement)(Se.Button,{variant:"primary",onClick:C,disabled:c,isBusy:c},c?(0,xe.__)("Testing…","quant"):(0,xe.__)("Test Connection","quant")))));case 5:return(0,t.createElement)(Se.Card,{className:"quant-onboarding__card"},(0,t.createElement)(Se.CardBody,{style:{textAlign:"center",padding:"40px"}},(0,t.createElement)("span",{className:"dashicons dashicons-yes-alt",style:{fontSize:"64px",color:"#00a32a",display:"block",marginBottom:"20px"}}),(0,t.createElement)("h1",{style:{marginBottom:"10px"}},(0,xe.__)("You're All Set!","quant")),(0,t.createElement)("p",{style:{color:"#666",fontSize:"16px",maxWidth:"500px",margin:"0 auto 30px"}},"static"===h.mode?(0,xe.__)("Your site is now connected to QuantCDN. Head to the Content page to sync your first pages.","quant"):(0,xe.__)("Your site is now connected to QuantCDN. Cache will be automatically purged when you update content.","quant")),(0,t.createElement)("div",{style:{display:"flex",justifyContent:"center",gap:"10px"}},(0,t.createElement)(Se.Button,{variant:"primary",onClick:()=>e("/dashboard")},(0,xe.__)("Go to Dashboard","quant")),"static"===h.mode&&(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>e("/content")},(0,xe.__)("Sync Content","quant")))));default:return null}})())}function rt(){var e,n,l,r;const[s,o]=(0,a.useState)(null),[i,c]=(0,a.useState)(!0),[u,m]=(0,a.useState)(null),[d,p]=(0,a.useState)(!1);(0,a.useEffect)(()=>{_()},[]);const _=async()=>{c(!0),m(null);try{const e=await ke()({path:"/quant/v1/diagnostics"});o(e)}catch(e){m(e.message||(0,xe.__)("Failed to load diagnostics","quant"))}finally{c(!1)}},h=({success:e,successText:a,failText:n})=>(0,t.createElement)("span",{style:{display:"inline-block",padding:"2px 8px",borderRadius:"3px",fontSize:"12px",fontWeight:"500",backgroundColor:e?"#d4edda":"#f8d7da",color:e?"#155724":"#721c24"}},e?a:n);return i?(0,t.createElement)("div",{className:"quant-diagnostics"},(0,t.createElement)("h1",null,(0,xe.__)("Diagnostics","quant")),(0,t.createElement)(Se.Card,null,(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("div",{style:{display:"flex",alignItems:"center",gap:"10px"}},(0,t.createElement)(Se.Spinner,null),(0,t.createElement)("span",null,(0,xe.__)("Loading diagnostic information…","quant")))))):u?(0,t.createElement)("div",{className:"quant-diagnostics"},(0,t.createElement)("h1",null,(0,xe.__)("Diagnostics","quant")),(0,t.createElement)(Se.Notice,{status:"error",isDismissible:!1},u),(0,t.createElement)("div",{style:{marginTop:"16px"}},(0,t.createElement)(Se.Button,{variant:"secondary",onClick:_},(0,xe.__)("Retry","quant")))):(0,t.createElement)("div",{className:"quant-diagnostics"},(0,t.createElement)("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:"20px"}},(0,t.createElement)("h1",{style:{margin:0}},(0,xe.__)("Diagnostics","quant")),(0,t.createElement)("div",{style:{display:"flex",gap:"10px"}},(0,t.createElement)(Se.Button,{variant:"secondary",onClick:_},(0,xe.__)("Refresh","quant")),(0,t.createElement)(Se.Button,{variant:"primary",onClick:()=>{if(!s)return;p(!0);const e=(e=>{var t,a,n,l;const r=["=".repeat(60),"QuantCDN Diagnostic Report",`Generated: ${(new Date).toISOString()}`,"=".repeat(60),"","## Environment",`PHP Version: ${e.environment?.php_version||"Unknown"}`,`WordPress Version: ${e.environment?.wordpress_version||"Unknown"}`,`Plugin Version: ${e.environment?.plugin_version||"Unknown"}`,`PHP Memory Limit: ${e.environment?.php_memory_limit||"Unknown"}`,`PHP Max Execution Time: ${e.environment?.php_max_execution_time||"Unknown"}`,"Multisite: "+(e.environment?.is_multisite?"Yes":"No"),"","## Plugin Configuration",`Mode: ${e.plugin?.mode||"Unknown"}`,"Has Credentials: "+(e.plugin?.has_credentials?"Yes":"No"),`Organization: ${e.plugin?.organization||"Not set"}`,`Project: ${e.plugin?.project||"Not set"}`,`Endpoint: ${e.plugin?.endpoint||"Not set"}`,"","## API Connection","Connected: "+(e.api?.connected?"Yes":"No"),e.api?.error?`Error: ${e.api.error}`:"","","## Database","V2 Table Exists: "+(e.database?.v2_table_exists?"Yes":"No"),"V1 Table Exists: "+(e.database?.v1_table_exists?"Yes":"No"),"","## Queue Status",`Pending: ${null!==(t=e.queue?.pending)&&void 0!==t?t:"Unknown"}`,`Processing: ${null!==(a=e.queue?.processing)&&void 0!==a?a:"Unknown"}`,`Failed: ${null!==(n=e.queue?.failed)&&void 0!==n?n:"Unknown"}`,`Total: ${null!==(l=e.queue?.total)&&void 0!==l?l:"Unknown"}`,"","## Migration Status","Needs Migration: "+(e.migration?.needs_migration?"Yes":"No"),"Migration Complete: "+(e.migration?.migration_complete?"Yes":"No"),e.migration?.migration_date?`Migration Date: ${e.migration.migration_date}`:"",""];return e.recent_errors&&e.recent_errors.length>0&&(r.push("## Recent Errors"),e.recent_errors.forEach((e,t)=>{r.push(`${t+1}. [${e.type}:${e.object_id}] ${e.error_message}`),r.push(` At: ${e.processed_at}`)}),r.push("")),r.push("=".repeat(60)),r.push("End of Report"),r.filter(e=>null!==e).join("\n")})(s),t=new Blob([e],{type:"text/plain"}),a=URL.createObjectURL(t),n=document.createElement("a");n.href=a,n.download=`quant-diagnostics-${(new Date).toISOString().split("T")[0]}.txt`,document.body.appendChild(n),n.click(),document.body.removeChild(n),URL.revokeObjectURL(a),p(!1)},disabled:d,isBusy:d},d?(0,xe.__)("Downloading…","quant"):(0,xe.__)("Download Report","quant")))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Environment","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("table",{className:"widefat",style:{margin:0}},(0,t.createElement)("tbody",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px",width:"200px"}},(0,xe.__)("PHP Version","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.php_version||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("WordPress Version","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.wordpress_version||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Plugin Version","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.plugin_version||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("PHP Memory Limit","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.php_memory_limit||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("PHP Max Execution Time","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.php_max_execution_time||"-"," ","seconds")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Multisite","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.is_multisite?(0,xe.__)("Yes","quant"):(0,xe.__)("No","quant"))))))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Plugin Configuration","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("table",{className:"widefat",style:{margin:0}},(0,t.createElement)("tbody",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px",width:"200px"}},(0,xe.__)("Mode","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},"static"===s?.plugin?.mode?(0,xe.__)("Static Site","quant"):(0,xe.__)("CDN Only","quant"))),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Credentials","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},(0,t.createElement)(h,{success:s?.plugin?.has_credentials,successText:(0,xe.__)("Configured","quant"),failText:(0,xe.__)("Not Configured","quant")}))),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Organization","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.plugin?.organization||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Project","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.plugin?.project||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Endpoint","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.plugin?.endpoint||"-")))))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("API Connection","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("div",{style:{display:"flex",alignItems:"center",gap:"10px"}},(0,t.createElement)(h,{success:s?.api?.connected,successText:(0,xe.__)("Connected","quant"),failText:(0,xe.__)("Not Connected","quant")}),s?.api?.error&&(0,t.createElement)("span",{style:{color:"#721c24"}},s.api.error)))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Database","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("table",{className:"widefat",style:{margin:0}},(0,t.createElement)("tbody",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px",width:"200px"}},(0,xe.__)("V2 Sync Queue Table","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},(0,t.createElement)(h,{success:s?.database?.v2_table_exists,successText:(0,xe.__)("Exists","quant"),failText:(0,xe.__)("Missing","quant")}))),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("V1 Queue Table","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.database?.v1_table_exists?(0,t.createElement)("span",{style:{color:"#856404"}},(0,xe.__)("Exists (can be cleaned up)","quant")):(0,t.createElement)("span",{style:{color:"#666"}},(0,xe.__)("Not present","quant")))))))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Queue Status","quant"))),(0,t.createElement)(Se.CardBody,null,s?.queue?.error?(0,t.createElement)(Se.Notice,{status:"error",isDismissible:!1},s.queue.error):(0,t.createElement)("div",{style:{display:"flex",gap:"30px"}},(0,t.createElement)("div",{style:{textAlign:"center"}},(0,t.createElement)("div",{style:{fontSize:"24px",fontWeight:"bold",color:"#0073aa"}},null!==(e=s?.queue?.pending)&&void 0!==e?e:0),(0,t.createElement)("div",{style:{fontSize:"12px",color:"#666"}},(0,xe.__)("Pending","quant"))),(0,t.createElement)("div",{style:{textAlign:"center"}},(0,t.createElement)("div",{style:{fontSize:"24px",fontWeight:"bold",color:"#f0b849"}},null!==(n=s?.queue?.processing)&&void 0!==n?n:0),(0,t.createElement)("div",{style:{fontSize:"12px",color:"#666"}},(0,xe.__)("Processing","quant"))),(0,t.createElement)("div",{style:{textAlign:"center"}},(0,t.createElement)("div",{style:{fontSize:"24px",fontWeight:"bold",color:"#d63638"}},null!==(l=s?.queue?.failed)&&void 0!==l?l:0),(0,t.createElement)("div",{style:{fontSize:"12px",color:"#666"}},(0,xe.__)("Failed","quant"))),(0,t.createElement)("div",{style:{textAlign:"center"}},(0,t.createElement)("div",{style:{fontSize:"24px",fontWeight:"bold",color:"#666"}},null!==(r=s?.queue?.total)&&void 0!==r?r:0),(0,t.createElement)("div",{style:{fontSize:"12px",color:"#666"}},(0,xe.__)("Total","quant")))))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Migration Status","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("table",{className:"widefat",style:{margin:0}},(0,t.createElement)("tbody",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px",width:"200px"}},(0,xe.__)("Needs Migration","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.migration?.needs_migration?(0,t.createElement)("a",{href:"#/migration"},(0,xe.__)("Yes - Click to migrate","quant")):(0,xe.__)("No","quant"))),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Migration Complete","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.migration?.migration_complete?(0,xe.__)("Yes","quant"):(0,xe.__)("No","quant"))),s?.migration?.migration_date&&(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Migration Date","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s.migration.migration_date)))))),s?.recent_errors&&s.recent_errors.length>0&&(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Recent Errors","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("table",{className:"widefat",style:{margin:0}},(0,t.createElement)("thead",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Type","quant")),(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("ID","quant")),(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Error","quant")),(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Time","quant")))),(0,t.createElement)("tbody",null,s.recent_errors.map((e,a)=>(0,t.createElement)("tr",{key:a},(0,t.createElement)("td",{style:{padding:"8px"}},e.type),(0,t.createElement)("td",{style:{padding:"8px"}},e.object_id),(0,t.createElement)("td",{style:{padding:"8px",color:"#721c24"}},e.error_message),(0,t.createElement)("td",{style:{padding:"8px",whiteSpace:"nowrap"}},e.processed_at))))))))}function st(){return(0,t.createElement)(Ee,null,(0,t.createElement)(we,null,(0,t.createElement)(ce,null,(0,t.createElement)(oe,{path:"/",element:(0,t.createElement)(se,{to:"/dashboard",replace:!0})}),(0,t.createElement)(oe,{path:"/dashboard",element:(0,t.createElement)(Be,null)}),(0,t.createElement)(oe,{path:"/content",element:(0,t.createElement)(Ae,null)}),(0,t.createElement)(oe,{path:"/cache",element:(0,t.createElement)(Fe,null)}),(0,t.createElement)(oe,{path:"/search",element:(0,t.createElement)(Ye,null)}),(0,t.createElement)(oe,{path:"/settings",element:(0,t.createElement)(et,null)}),(0,t.createElement)(oe,{path:"/migration",element:(0,t.createElement)(tt,null)}),(0,t.createElement)(oe,{path:"/onboarding",element:(0,t.createElement)(lt,null)}),(0,t.createElement)(oe,{path:"/diagnostics",element:(0,t.createElement)(rt,null)}))))}const ot=document.getElementById("quant-admin-root");ot&&("function"==typeof a.createRoot?a.createRoot(ot).render((0,t.createElement)(st,null)):a.render((0,t.createElement)(st,null),ot))})(); \ No newline at end of file +(0,xe.__)("Note: %d rows of 1.x batch state are still in wp_options. Cleanup removes them.","quant"),n.v1_queue_count)),(0,t.createElement)(Se.CheckboxControl,{label:(0,xe.__)("Remove old v1 data after migration","quant"),checked:p,onChange:_,help:(0,xe.__)("This will delete old options and tables after successful migration","quant")}))),(0,t.createElement)(Se.Card,{style:{marginTop:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("What will happen","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("ul",{style:{margin:0,paddingLeft:"20px"}},(0,t.createElement)("li",null,(0,xe.__)("Your API credentials will be copied to the new format","quant")),(0,t.createElement)("li",null,(0,xe.__)('Plugin mode will be set to "Static" (default)',"quant")),(0,t.createElement)("li",null,(0,xe.__)("Auto-sync and auto-purge will be enabled","quant")),(0,t.createElement)("li",null,(0,xe.__)("A new sync queue table will be used","quant"))))),(0,t.createElement)("div",{style:{marginTop:"16px",display:"flex",gap:"10px"}},(0,t.createElement)(Se.Button,{variant:"primary",onClick:async()=>{i(!0),y(null);try{const e=await ke()({path:"/quant/v1/migration/run",method:"POST"});g(e),p&&e.success&&await b(),v("complete")}catch(e){y(e.message||(0,xe.__)("Migration failed","quant"))}finally{i(!1)}},disabled:o,isBusy:o},o?(0,xe.__)("Migrating…","quant"):(0,xe.__)("Start Migration","quant")),(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>e("/settings"),disabled:o},(0,xe.__)("Skip & Configure Manually","quant"))))}function at({result:e}){return"success"===e?(0,t.createElement)("div",{style:{padding:"20px"}},(0,t.createElement)("span",{className:"dashicons dashicons-yes-alt",style:{fontSize:"48px",color:"#00a32a",display:"block",marginBottom:"15px"}}),(0,t.createElement)("p",{style:{fontSize:"18px",color:"#00a32a",margin:0}},(0,xe.__)("Connection successful!","quant"))):"error"===e?(0,t.createElement)("div",{style:{padding:"20px"}},(0,t.createElement)("span",{className:"dashicons dashicons-dismiss",style:{fontSize:"48px",color:"#d63638",display:"block",marginBottom:"15px"}}),(0,t.createElement)("p",{style:{fontSize:"18px",color:"#d63638",margin:0}},(0,xe.__)("Connection failed","quant"))):(0,t.createElement)("div",{style:{padding:"20px"}},(0,t.createElement)("p",{style:{color:"#666",marginTop:0}},(0,xe.__)("Let's verify your connection to QuantCDN.","quant")))}const nt=[{label:"QuantCDN",value:"https://api.quantcdn.io"},{label:"QuantGov",value:"https://api.quantgov.cloud"}];function lt(){const e=G(),[n,l]=(0,a.useState)(1),[r,s]=(0,a.useState)(!0),[o,i]=(0,a.useState)(!1),[c,u]=(0,a.useState)(!1),[m,d]=(0,a.useState)(null),[p,_]=(0,a.useState)(null),[h,g]=(0,a.useState)({organization:"",project:"",token:"",endpoint:"https://api.quantcdn.io",mode:"static"});(0,a.useEffect)(()=>{E()},[]);const E=async()=>{try{const t=await ke()({path:"/quant/v1/onboarding/status"});if("migration"===t.state)return void e("/migration");"configured"===t.state&&e("/dashboard")}catch(e){}finally{s(!1)}},y=(e,t)=>{g(a=>({...a,[e]:t}))},f=async()=>{i(!0),d(null);try{await ke()({path:"/quant/v1/settings",method:"POST",data:h}),l(3)}catch(e){d(e.message||(0,xe.__)("Failed to save credentials","quant"))}finally{i(!1)}},v=async()=>{i(!0),d(null);try{await ke()({path:"/quant/v1/settings",method:"POST",data:{endpoint:h.endpoint,mode:h.mode}});const e=await ke()({path:"/quant/v1/oauth/connect"});if(e.redirect_url)return void(window.location.href=e.redirect_url);d((0,xe.__)("The login could not be started.","quant"))}catch(e){d(e.message||(0,xe.__)("The login could not be started.","quant"))}i(!1)},q=nt.find(e=>e.value===h.endpoint)?h.endpoint:"custom",b=async()=>{i(!0),d(null);try{await ke()({path:"/quant/v1/settings",method:"POST",data:{mode:h.mode}}),l(4)}catch(e){d(e.message||(0,xe.__)("Failed to save mode","quant"))}finally{i(!1)}},C=async()=>{u(!0),d(null),_(null);try{(await ke()({path:"/quant/v1/status"})).connected?(_("success"),setTimeout(()=>l(5),1500)):(_("error"),d((0,xe.__)("Connection failed. Please check your credentials.","quant")))}catch(e){_("error"),d(e.message||(0,xe.__)("Connection test failed","quant"))}finally{u(!1)}};return r?(0,t.createElement)("div",{className:"quant-onboarding"},(0,t.createElement)("div",{style:{display:"flex",alignItems:"center",justifyContent:"center",minHeight:"200px"}},(0,t.createElement)(Se.Spinner,null))):(0,t.createElement)("div",{className:"quant-onboarding",style:{maxWidth:"700px",margin:"0 auto"}},n>=2&&n<=4&&(0,t.createElement)("div",{style:{display:"flex",justifyContent:"center",gap:"10px",marginBottom:"30px"}},[2,3,4].map(e=>(0,t.createElement)("div",{key:e,style:{width:"12px",height:"12px",borderRadius:"50%",backgroundColor:n>=e?"#5c40ff":"#ddd"}}))),(()=>{switch(n){case 1:return(0,t.createElement)(Se.Card,{className:"quant-onboarding__card"},(0,t.createElement)(Se.CardBody,{style:{textAlign:"center",padding:"40px"}},(0,t.createElement)("svg",{width:"64",height:"64",viewBox:"0 0 32 32",fill:"none",style:{marginBottom:"20px"}},(0,t.createElement)("rect",{width:"32",height:"32",rx:"6",fill:"#5c40ff"}),(0,t.createElement)("text",{x:"50%",y:"55%",dominantBaseline:"middle",textAnchor:"middle",fill:"white",fontSize:"16",fontWeight:"bold"},"Q")),(0,t.createElement)("h1",{style:{marginBottom:"10px"}},(0,xe.__)("Welcome to QuantCDN","quant")),(0,t.createElement)("p",{style:{color:"#666",fontSize:"16px",maxWidth:"500px",margin:"0 auto 30px"}},(0,xe.__)("Let's get your WordPress site connected to QuantCDN. This wizard will guide you through the setup process.","quant")),(0,t.createElement)(Se.Button,{variant:"primary",onClick:()=>l(2),style:{padding:"10px 30px"}},(0,xe.__)("Get Started","quant"))));case 2:return(0,t.createElement)(Se.Card,{className:"quant-onboarding__card"},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Step 1: API Credentials","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)(Oe,null,m&&(0,t.createElement)(Se.Notice,{status:"error",onRemove:()=>d(null),isDismissible:!0},m),(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Platform","quant"),value:q,options:nt.concat([{label:(0,xe.__)("Other endpoint","quant"),value:"custom"}]),onChange:e=>{"custom"!==e&&y("endpoint",e)},help:(0,xe.__)("Choose the dashboard you sign in to.","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)("div",{className:"quant-onboarding__oauth"},(0,t.createElement)(Se.Button,{variant:"primary",icon:"admin-users",onClick:v,disabled:o,isBusy:o},(0,xe.__)("Login to Quant","quant")),(0,t.createElement)("p",{style:{color:"#666"}},(0,xe.__)("Sign in and pick the project, or enter the project credentials below.","quant"))),(0,t.createElement)("p",{style:{color:"#666",marginTop:0}},(0,xe.__)("Or enter the project credentials. You can find them in the","quant")," ",(0,t.createElement)("a",{href:"https://api.quantgov.cloud"===q?"https://dash.quantgov.cloud":"https://dashboard.quantcdn.io",target:"_blank",rel:"noopener noreferrer"},(0,xe.__)("Quant dashboard","quant"))," ",(0,xe.__)("on the Integrations page of the project.","quant")),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Organization","quant"),value:h.organization,onChange:e=>y("organization",e),help:(0,xe.__)("Your QuantCDN organization identifier","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Project","quant"),value:h.project,onChange:e=>y("project",e),help:(0,xe.__)("Your QuantCDN project name","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("API Token","quant"),type:"password",value:h.token,onChange:e=>y("token",e),help:(0,xe.__)("Your project API token","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("API Endpoint","quant"),value:h.endpoint,onChange:e=>y("endpoint",e),help:(0,xe.__)("https://api.quantcdn.io for QuantCDN, https://api.quantgov.cloud for QuantGov. Without /v1.","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)("div",{style:{display:"flex",gap:"10px"}},(0,t.createElement)(Se.Button,{variant:"primary",onClick:f,disabled:o||!h.organization||!h.project||!h.token,isBusy:o},o?(0,xe.__)("Saving…","quant"):(0,xe.__)("Continue","quant"))))));case 3:return(0,t.createElement)(Se.Card,{className:"quant-onboarding__card"},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Step 2: Choose Mode","quant"))),(0,t.createElement)(Se.CardBody,null,m&&(0,t.createElement)(Se.Notice,{status:"error",onRemove:()=>d(null),isDismissible:!0},m),(0,t.createElement)("p",{style:{color:"#666",marginTop:0}},(0,xe.__)("Choose how you want to use QuantCDN with your WordPress site.","quant")),(0,t.createElement)("div",{style:{marginBottom:"20px"}},(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Plugin Mode","quant"),value:h.mode,options:[{label:(0,xe.__)("Static Site (Recommended)","quant"),value:"static"},{label:(0,xe.__)("CDN Only","quant"),value:"cdn_only"}],onChange:e=>y("mode",e)})),(0,t.createElement)("div",{style:{backgroundColor:"#f0f0f0",padding:"15px",borderRadius:"4px",marginBottom:"20px"}},"static"===h.mode?(0,t.createElement)(t.Fragment,null,(0,t.createElement)("strong",null,(0,xe.__)("Static Site Mode","quant")),(0,t.createElement)("p",{style:{margin:"10px 0 0",color:"#666"}},(0,xe.__)("Your entire site is rendered and served from QuantCDN's edge. Pages are synced automatically when you publish or update content. Ideal for maximum performance and security.","quant"))):(0,t.createElement)(t.Fragment,null,(0,t.createElement)("strong",null,(0,xe.__)("CDN Only Mode","quant")),(0,t.createElement)("p",{style:{margin:"10px 0 0",color:"#666"}},(0,xe.__)("WordPress serves dynamic content while QuantCDN handles caching. Cache is automatically purged when content changes. Good for sites that need dynamic features.","quant")))),(0,t.createElement)("div",{style:{display:"flex",gap:"10px"}},(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>l(2)},(0,xe.__)("Back","quant")),(0,t.createElement)(Se.Button,{variant:"primary",onClick:b,disabled:o,isBusy:o},o?(0,xe.__)("Saving…","quant"):(0,xe.__)("Continue","quant")))));case 4:return(0,t.createElement)(Se.Card,{className:"quant-onboarding__card"},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Step 3: Test Connection","quant"))),(0,t.createElement)(Se.CardBody,{style:{textAlign:"center"}},m&&(0,t.createElement)(Se.Notice,{status:"error",onRemove:()=>d(null),isDismissible:!0},m),(0,t.createElement)(at,{result:p}),(0,t.createElement)("div",{style:{display:"flex",justifyContent:"center",gap:"10px",marginTop:"20px"}},(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>l(2),disabled:c},(0,xe.__)("Back to Credentials","quant")),(0,t.createElement)(Se.Button,{variant:"primary",onClick:C,disabled:c,isBusy:c},c?(0,xe.__)("Testing…","quant"):(0,xe.__)("Test Connection","quant")))));case 5:return(0,t.createElement)(Se.Card,{className:"quant-onboarding__card"},(0,t.createElement)(Se.CardBody,{style:{textAlign:"center",padding:"40px"}},(0,t.createElement)("span",{className:"dashicons dashicons-yes-alt",style:{fontSize:"64px",color:"#00a32a",display:"block",marginBottom:"20px"}}),(0,t.createElement)("h1",{style:{marginBottom:"10px"}},(0,xe.__)("You're All Set!","quant")),(0,t.createElement)("p",{style:{color:"#666",fontSize:"16px",maxWidth:"500px",margin:"0 auto 30px"}},"static"===h.mode?(0,xe.__)("Your site is now connected to QuantCDN. Head to the Content page to sync your first pages.","quant"):(0,xe.__)("Your site is now connected to QuantCDN. Cache will be automatically purged when you update content.","quant")),(0,t.createElement)("div",{style:{display:"flex",justifyContent:"center",gap:"10px"}},(0,t.createElement)(Se.Button,{variant:"primary",onClick:()=>e("/dashboard")},(0,xe.__)("Go to Dashboard","quant")),"static"===h.mode&&(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>e("/content")},(0,xe.__)("Sync Content","quant")))));default:return null}})())}function rt(){var e,n,l,r;const[s,o]=(0,a.useState)(null),[i,c]=(0,a.useState)(!0),[u,m]=(0,a.useState)(null),[d,p]=(0,a.useState)(!1);(0,a.useEffect)(()=>{_()},[]);const _=async()=>{c(!0),m(null);try{const e=await ke()({path:"/quant/v1/diagnostics"});o(e)}catch(e){m(e.message||(0,xe.__)("Failed to load diagnostics","quant"))}finally{c(!1)}},h=({success:e,successText:a,failText:n})=>(0,t.createElement)("span",{style:{display:"inline-block",padding:"2px 8px",borderRadius:"3px",fontSize:"12px",fontWeight:"500",backgroundColor:e?"#d4edda":"#f8d7da",color:e?"#155724":"#721c24"}},e?a:n);return i?(0,t.createElement)("div",{className:"quant-diagnostics"},(0,t.createElement)("h1",null,(0,xe.__)("Diagnostics","quant")),(0,t.createElement)(Se.Card,null,(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("div",{style:{display:"flex",alignItems:"center",gap:"10px"}},(0,t.createElement)(Se.Spinner,null),(0,t.createElement)("span",null,(0,xe.__)("Loading diagnostic information…","quant")))))):u?(0,t.createElement)("div",{className:"quant-diagnostics"},(0,t.createElement)("h1",null,(0,xe.__)("Diagnostics","quant")),(0,t.createElement)(Se.Notice,{status:"error",isDismissible:!1},u),(0,t.createElement)("div",{style:{marginTop:"16px"}},(0,t.createElement)(Se.Button,{variant:"secondary",onClick:_},(0,xe.__)("Retry","quant")))):(0,t.createElement)("div",{className:"quant-diagnostics"},(0,t.createElement)("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:"20px"}},(0,t.createElement)("h1",{style:{margin:0}},(0,xe.__)("Diagnostics","quant")),(0,t.createElement)("div",{style:{display:"flex",gap:"10px"}},(0,t.createElement)(Se.Button,{variant:"secondary",onClick:_},(0,xe.__)("Refresh","quant")),(0,t.createElement)(Se.Button,{variant:"primary",onClick:()=>{if(!s)return;p(!0);const e=(e=>{var t,a,n,l;const r=["=".repeat(60),"QuantCDN Diagnostic Report",`Generated: ${(new Date).toISOString()}`,"=".repeat(60),"","## Environment",`PHP Version: ${e.environment?.php_version||"Unknown"}`,`WordPress Version: ${e.environment?.wordpress_version||"Unknown"}`,`Plugin Version: ${e.environment?.plugin_version||"Unknown"}`,`PHP Memory Limit: ${e.environment?.php_memory_limit||"Unknown"}`,`PHP Max Execution Time: ${e.environment?.php_max_execution_time||"Unknown"}`,"Multisite: "+(e.environment?.is_multisite?"Yes":"No"),"","## Plugin Configuration",`Mode: ${e.plugin?.mode||"Unknown"}`,"Has Credentials: "+(e.plugin?.has_credentials?"Yes":"No"),`Organization: ${e.plugin?.organization||"Not set"}`,`Project: ${e.plugin?.project||"Not set"}`,`Endpoint: ${e.plugin?.endpoint||"Not set"}`,"","## API Connection","Connected: "+(e.api?.connected?"Yes":"No"),e.api?.error?`Error: ${e.api.error}`:"",e.api?.error_detail?`Error detail: ${e.api.error_detail}`:null,"","## Database","V2 Table Exists: "+(e.database?.v2_table_exists?"Yes":"No"),"V1 Table Exists: "+(e.database?.v1_table_exists?"Yes":"No"),"","## Queue Status",`Pending: ${null!==(t=e.queue?.pending)&&void 0!==t?t:"Unknown"}`,`Processing: ${null!==(a=e.queue?.processing)&&void 0!==a?a:"Unknown"}`,`Failed: ${null!==(n=e.queue?.failed)&&void 0!==n?n:"Unknown"}`,`Total: ${null!==(l=e.queue?.total)&&void 0!==l?l:"Unknown"}`,"","## Migration Status","Needs Migration: "+(e.migration?.needs_migration?"Yes":"No"),"Migration Complete: "+(e.migration?.migration_complete?"Yes":"No"),e.migration?.migration_date?`Migration Date: ${e.migration.migration_date}`:"",""];return r.push(...function(e){if(!e||0===e.length)return[];const t=["## Recent Errors"];return e.forEach((e,a)=>{t.push(`${a+1}. [${e.type}:${e.object_id}] ${e.error_message}`),e.error_detail&&t.push(` Detail: ${e.error_detail}`),t.push(` At: ${e.processed_at}`)}),t.push(""),t}(e.recent_errors)),r.push("=".repeat(60)),r.push("End of Report"),r.filter(e=>null!==e).join("\n")})(s),t=new Blob([e],{type:"text/plain"}),a=URL.createObjectURL(t),n=document.createElement("a");n.href=a,n.download=`quant-diagnostics-${(new Date).toISOString().split("T")[0]}.txt`,document.body.appendChild(n),n.click(),document.body.removeChild(n),URL.revokeObjectURL(a),p(!1)},disabled:d,isBusy:d},d?(0,xe.__)("Downloading…","quant"):(0,xe.__)("Download Report","quant")))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Environment","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("table",{className:"widefat",style:{margin:0}},(0,t.createElement)("tbody",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px",width:"200px"}},(0,xe.__)("PHP Version","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.php_version||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("WordPress Version","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.wordpress_version||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Plugin Version","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.plugin_version||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("PHP Memory Limit","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.php_memory_limit||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("PHP Max Execution Time","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.php_max_execution_time||"-"," ","seconds")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Multisite","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.is_multisite?(0,xe.__)("Yes","quant"):(0,xe.__)("No","quant"))))))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Plugin Configuration","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("table",{className:"widefat",style:{margin:0}},(0,t.createElement)("tbody",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px",width:"200px"}},(0,xe.__)("Mode","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},"static"===s?.plugin?.mode?(0,xe.__)("Static Site","quant"):(0,xe.__)("CDN Only","quant"))),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Credentials","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},(0,t.createElement)(h,{success:s?.plugin?.has_credentials,successText:(0,xe.__)("Configured","quant"),failText:(0,xe.__)("Not Configured","quant")}))),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Organization","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.plugin?.organization||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Project","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.plugin?.project||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Endpoint","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.plugin?.endpoint||"-")))))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("API Connection","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("div",{style:{display:"flex",alignItems:"center",gap:"10px"}},(0,t.createElement)(h,{success:s?.api?.connected,successText:(0,xe.__)("Connected","quant"),failText:(0,xe.__)("Not Connected","quant")}),s?.api?.error&&(0,t.createElement)("span",{style:{color:"#721c24"}},s.api.error)))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Database","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("table",{className:"widefat",style:{margin:0}},(0,t.createElement)("tbody",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px",width:"200px"}},(0,xe.__)("V2 Sync Queue Table","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},(0,t.createElement)(h,{success:s?.database?.v2_table_exists,successText:(0,xe.__)("Exists","quant"),failText:(0,xe.__)("Missing","quant")}))),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("V1 Queue Table","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.database?.v1_table_exists?(0,t.createElement)("span",{style:{color:"#856404"}},(0,xe.__)("Exists (can be cleaned up)","quant")):(0,t.createElement)("span",{style:{color:"#666"}},(0,xe.__)("Not present","quant")))))))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Queue Status","quant"))),(0,t.createElement)(Se.CardBody,null,s?.queue?.error?(0,t.createElement)(Se.Notice,{status:"error",isDismissible:!1},s.queue.error):(0,t.createElement)("div",{style:{display:"flex",gap:"30px"}},(0,t.createElement)("div",{style:{textAlign:"center"}},(0,t.createElement)("div",{style:{fontSize:"24px",fontWeight:"bold",color:"#0073aa"}},null!==(e=s?.queue?.pending)&&void 0!==e?e:0),(0,t.createElement)("div",{style:{fontSize:"12px",color:"#666"}},(0,xe.__)("Pending","quant"))),(0,t.createElement)("div",{style:{textAlign:"center"}},(0,t.createElement)("div",{style:{fontSize:"24px",fontWeight:"bold",color:"#f0b849"}},null!==(n=s?.queue?.processing)&&void 0!==n?n:0),(0,t.createElement)("div",{style:{fontSize:"12px",color:"#666"}},(0,xe.__)("Processing","quant"))),(0,t.createElement)("div",{style:{textAlign:"center"}},(0,t.createElement)("div",{style:{fontSize:"24px",fontWeight:"bold",color:"#d63638"}},null!==(l=s?.queue?.failed)&&void 0!==l?l:0),(0,t.createElement)("div",{style:{fontSize:"12px",color:"#666"}},(0,xe.__)("Failed","quant"))),(0,t.createElement)("div",{style:{textAlign:"center"}},(0,t.createElement)("div",{style:{fontSize:"24px",fontWeight:"bold",color:"#666"}},null!==(r=s?.queue?.total)&&void 0!==r?r:0),(0,t.createElement)("div",{style:{fontSize:"12px",color:"#666"}},(0,xe.__)("Total","quant")))))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Migration Status","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("table",{className:"widefat",style:{margin:0}},(0,t.createElement)("tbody",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px",width:"200px"}},(0,xe.__)("Needs Migration","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.migration?.needs_migration?(0,t.createElement)("a",{href:"#/migration"},(0,xe.__)("Yes - Click to migrate","quant")):(0,xe.__)("No","quant"))),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Migration Complete","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.migration?.migration_complete?(0,xe.__)("Yes","quant"):(0,xe.__)("No","quant"))),s?.migration?.migration_date&&(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Migration Date","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s.migration.migration_date)))))),s?.recent_errors&&s.recent_errors.length>0&&(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Recent Errors","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("table",{className:"widefat",style:{margin:0}},(0,t.createElement)("thead",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Type","quant")),(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("ID","quant")),(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Error","quant")),(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Time","quant")))),(0,t.createElement)("tbody",null,s.recent_errors.map((e,a)=>(0,t.createElement)("tr",{key:a},(0,t.createElement)("td",{style:{padding:"8px"}},e.type),(0,t.createElement)("td",{style:{padding:"8px"}},e.object_id),(0,t.createElement)("td",{style:{padding:"8px",color:"#721c24"}},e.error_message),(0,t.createElement)("td",{style:{padding:"8px",whiteSpace:"nowrap"}},e.processed_at))))))))}function st(){return(0,t.createElement)(Ee,null,(0,t.createElement)(we,null,(0,t.createElement)(ce,null,(0,t.createElement)(oe,{path:"/",element:(0,t.createElement)(se,{to:"/dashboard",replace:!0})}),(0,t.createElement)(oe,{path:"/dashboard",element:(0,t.createElement)(Be,null)}),(0,t.createElement)(oe,{path:"/content",element:(0,t.createElement)(Ae,null)}),(0,t.createElement)(oe,{path:"/cache",element:(0,t.createElement)(Fe,null)}),(0,t.createElement)(oe,{path:"/search",element:(0,t.createElement)(Ye,null)}),(0,t.createElement)(oe,{path:"/settings",element:(0,t.createElement)(et,null)}),(0,t.createElement)(oe,{path:"/migration",element:(0,t.createElement)(tt,null)}),(0,t.createElement)(oe,{path:"/onboarding",element:(0,t.createElement)(lt,null)}),(0,t.createElement)(oe,{path:"/diagnostics",element:(0,t.createElement)(rt,null)}))))}const ot=document.getElementById("quant-admin-root");ot&&("function"==typeof a.createRoot?a.createRoot(ot).render((0,t.createElement)(st,null)):a.render((0,t.createElement)(st,null),ot))})(); \ No newline at end of file diff --git a/plugins/quant/assets/src/pages/Dashboard.jsx b/plugins/quant/assets/src/pages/Dashboard.jsx index 3618b04..92099b1 100644 --- a/plugins/quant/assets/src/pages/Dashboard.jsx +++ b/plugins/quant/assets/src/pages/Dashboard.jsx @@ -10,6 +10,7 @@ import { __, sprintf } from '@wordpress/i18n'; import { useEffect, useState, useCallback } from '@wordpress/element'; import apiFetch from '@wordpress/api-fetch'; import { useNavigate } from 'react-router-dom'; +import { visibleErrors } from './queueErrors'; function Stat( { label, value, tone } ) { return ( @@ -343,14 +344,14 @@ export function Dashboard() { - { errors.slice( 0, 5 ).map( ( e, i ) => ( + { visibleErrors( errors ).map( ( e, i ) => ( { e.type } - { e.object_id } + { e.objectId } - { e.error_message } + { e.message } ) ) } diff --git a/plugins/quant/assets/src/pages/Diagnostics.jsx b/plugins/quant/assets/src/pages/Diagnostics.jsx index 678d4f1..4e13b55 100644 --- a/plugins/quant/assets/src/pages/Diagnostics.jsx +++ b/plugins/quant/assets/src/pages/Diagnostics.jsx @@ -9,6 +9,7 @@ import { } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import apiFetch from '@wordpress/api-fetch'; +import { errorReportLines } from './queueErrors'; export function Diagnostics() { const [ diagnostics, setDiagnostics ] = useState( null ); @@ -93,6 +94,9 @@ export function Diagnostics() { '## API Connection', `Connected: ${ data.api?.connected ? 'Yes' : 'No' }`, data.api?.error ? `Error: ${ data.api.error }` : '', + data.api?.error_detail + ? `Error detail: ${ data.api.error_detail }` + : null, '', '## Database', `V2 Table Exists: ${ @@ -121,18 +125,7 @@ export function Diagnostics() { '', ]; - if ( data.recent_errors && data.recent_errors.length > 0 ) { - lines.push( '## Recent Errors' ); - data.recent_errors.forEach( ( err, i ) => { - lines.push( - `${ i + 1 }. [${ err.type }:${ err.object_id }] ${ - err.error_message - }` - ); - lines.push( ` At: ${ err.processed_at }` ); - } ); - lines.push( '' ); - } + lines.push( ...errorReportLines( data.recent_errors ) ); lines.push( '='.repeat( 60 ) ); lines.push( 'End of Report' ); diff --git a/plugins/quant/assets/src/pages/queueErrors.js b/plugins/quant/assets/src/pages/queueErrors.js new file mode 100644 index 0000000..4519cab --- /dev/null +++ b/plugins/quant/assets/src/pages/queueErrors.js @@ -0,0 +1,52 @@ +/** + * Queue failures as the admin shows them. + * + * Each failure from /quant/v1/queue/errors and the diagnostics endpoint has + * a short `error_message` for editors and, for a transport or server + * failure, the raw `error_detail` (for example "cURL error 28: ..."). + * The cards show only the message. The detail goes only into the + * diagnostics report, for support. + */ + +/** + * The rows the queue card shows. + * + * @param {Array} errors Failures from the REST API. + * @param {number} limit The most rows to show. + * @return {Array<{type: string, objectId: string, message: string}>} Rows without the raw detail. + */ +export function visibleErrors( errors, limit = 5 ) { + return ( errors || [] ).slice( 0, limit ).map( ( err ) => ( { + type: err.type, + objectId: err.object_id, + message: err.error_message, + } ) ); +} + +/** + * The "Recent Errors" section of the diagnostics report. + * + * @param {Array} errors Failures from the diagnostics endpoint. + * @return {Array} Report lines; empty when there are no failures. + */ +export function errorReportLines( errors ) { + if ( ! errors || errors.length === 0 ) { + return []; + } + + const lines = [ '## Recent Errors' ]; + errors.forEach( ( err, i ) => { + lines.push( + `${ i + 1 }. [${ err.type }:${ err.object_id }] ${ + err.error_message + }` + ); + if ( err.error_detail ) { + lines.push( ` Detail: ${ err.error_detail }` ); + } + lines.push( ` At: ${ err.processed_at }` ); + } ); + lines.push( '' ); + + return lines; +} diff --git a/plugins/quant/assets/src/pages/test/queueErrors.test.js b/plugins/quant/assets/src/pages/test/queueErrors.test.js new file mode 100644 index 0000000..c9f93cf --- /dev/null +++ b/plugins/quant/assets/src/pages/test/queueErrors.test.js @@ -0,0 +1,65 @@ +import { visibleErrors, errorReportLines } from '../queueErrors'; + +const timeout = { + type: 'post', + object_id: '12', + error_message: + 'API error publishing /about/: Quant did not answer in time. The plugin will try again.', + error_detail: + 'http_request_failed: cURL error 28: Operation timed out after 30001 milliseconds with 0 bytes received (3 attempts)', + processed_at: '2026-11-15 01:02:03', +}; + +const notFound = { + type: 'route', + object_id: '/gone', + error_message: 'HTTP 404 response for /gone', + error_detail: null, + processed_at: '2026-11-15 01:00:00', +}; + +describe( 'visibleErrors', () => { + it( 'shows the short message and never the raw detail', () => { + const rows = visibleErrors( [ timeout ] ); + + expect( rows ).toEqual( [ + { + type: 'post', + objectId: '12', + message: + 'API error publishing /about/: Quant did not answer in time. The plugin will try again.', + }, + ] ); + expect( JSON.stringify( rows ) ).not.toContain( 'cURL' ); + } ); + + it( 'limits the rows', () => { + const many = Array.from( { length: 8 }, () => notFound ); + + expect( visibleErrors( many ) ).toHaveLength( 5 ); + expect( visibleErrors( many, 2 ) ).toHaveLength( 2 ); + } ); + + it( 'accepts a missing list', () => { + expect( visibleErrors( undefined ) ).toEqual( [] ); + } ); +} ); + +describe( 'errorReportLines', () => { + it( 'keeps the raw detail in the diagnostics report', () => { + expect( errorReportLines( [ timeout, notFound ] ) ).toEqual( [ + '## Recent Errors', + '1. [post:12] API error publishing /about/: Quant did not answer in time. The plugin will try again.', + ' Detail: http_request_failed: cURL error 28: Operation timed out after 30001 milliseconds with 0 bytes received (3 attempts)', + ' At: 2026-11-15 01:02:03', + '2. [route:/gone] HTTP 404 response for /gone', + ' At: 2026-11-15 01:00:00', + '', + ] ); + } ); + + it( 'has no section when nothing failed', () => { + expect( errorReportLines( [] ) ).toEqual( [] ); + expect( errorReportLines( undefined ) ).toEqual( [] ); + } ); +} ); diff --git a/plugins/quant/readme.txt b/plugins/quant/readme.txt index 9e03d37..4cc5b69 100644 --- a/plugins/quant/readme.txt +++ b/plugins/quant/readme.txt @@ -72,6 +72,12 @@ Version 2.0 requires PHP 8.2. Sites on older PHP are not offered the update and Set these to configure the plugin without the admin UI (for example on Quant Cloud): `QUANT_API_ENDPOINT`, `QUANT_CUSTOMER`, `QUANT_PROJECT`, `QUANT_TOKEN`, `QUANT_WEBSERVER_URL`, `QUANT_WEBSERVER_HOST`, `QUANT_ENABLED`, `QUANT_PURGE_ON_SAVE`, `QUANT_DISABLE_TLS_VERIFY`, `QUANT_HTTP_REQUEST_TIMEOUT`, `QUANT_MODE`. The same names work as PHP constants in `wp-config.php`. Overridden fields are read-only in the UI. +`QUANT_HTTP_REQUEST_TIMEOUT` is the time in seconds that the plugin waits for one answer from the Quant API (default 30, minimum 5). It is the same value as "HTTP request timeout" on the Settings page. File uploads, and pages larger than 512 KB, always get at least 120 seconds. The connection itself must open within 10 seconds (the WordPress HTTP API default). Increase the value if a slow network makes pushes fail with "Quant did not answer in time": + +` +define('QUANT_HTTP_REQUEST_TIMEOUT', 60); +` + == Styling the search page == The search page (the page the plugin creates, the "Quant Search" block and the `[quant_search]` shortcode) uses the theme's fonts and text colour. Result titles are in the text colour and are underlined on hover and focus. Lines, count badges, tags and secondary text are mixes of the text colour, so the page also works on a dark theme. The plugin's own rules apply only inside `.quant-search`; the bundled InstantSearch.js theme styles the `ais-` widget classes. @@ -130,12 +136,23 @@ The plugin renders pages by requesting them from the web server. Set **Webserver Enable search for the project in the Quant dashboard, then open **QuantCDN > Search**, turn on search records and create a search page. Place the "Quant Search" block or `[quant_search]` shortcode on any page for more search pages, and the "Quant Search Autocomplete" block or `[quant_search_autocomplete]` shortcode in a header or sidebar. += A push failed with "Quant did not answer in time" = + +The Quant API did not answer within the HTTP request timeout, or a gateway between your server and Quant was busy. The plugin sends a page or file again up to two times in the same run (after about 1 and 3 seconds). If it still fails, the job stays in the queue as failed, and the background run (WP-Cron) tries it again up to three times, with a longer wait each time. You can also press "Retry failed". The raw error text (for example the cURL error) is in the report from QuantCDN > Diagnostics > Download Report. + = Forms on a static site = Follow the [Forms documentation](https://docs.quantcdn.io/docs/dashboard/forms). Contact Form 7 needs `define('WPCF7_LOAD_JS', false);`. == Changelog == += 2.0.6 = +* Fixed: when the Quant API did not answer, the push showed raw transport text such as "cURL error 28: Operation timed out after 30001 milliseconds with 0 bytes received". The dashboard queue card and the push progress now show a short message (for example "Quant did not answer in time. The plugin will try again."). The raw text is kept apart and is in the diagnostics report and in `wp quant queue errors`. +* Fixed: one stalled request or gateway error (502, 503, 504) failed the page until the next background run, and the background run only happens when WP-Cron runs. A page or file push is now sent again up to two times in the same run, after about 1 and 3 seconds. +* A page larger than 512 KB now gets the same 120 second minimum timeout as a file upload. +* Readme: `QUANT_HTTP_REQUEST_TIMEOUT` is documented. +* The queue table has a new column for the error detail. It is added automatically on the first request after the update. + = 2.0.5 = * Search page: a cleaner default style that still follows the theme. The checkboxes, counts, headings, tags, buttons and pagination have even spacing, every control has a visible focus outline, the "Clear refinements" button uses the theme text colour (it was pale blue), the result title is larger than its summary, and on a phone the facets move above the results. The InstantSearch widgets no longer turn the text navy. * Search page: every size, space and colour is a CSS custom property (`--quant-search-gap`, `--quant-search-accent` and others). See "Styling the search page" in the readme. diff --git a/plugins/quant/src/Admin/DiagnosticsController.php b/plugins/quant/src/Admin/DiagnosticsController.php index eccbb84..08e7190 100644 --- a/plugins/quant/src/Admin/DiagnosticsController.php +++ b/plugins/quant/src/Admin/DiagnosticsController.php @@ -95,12 +95,13 @@ public function getDiagnostics(WP_REST_Request $request): array { global $wp_version, $wpdb; - $api = ['connected' => false, 'error' => null, 'search_enabled' => false]; + $api = ['connected' => false, 'error' => null, 'error_detail' => null, 'search_enabled' => false]; if ($this->settings->hasValidCredentials()) { $response = $this->api->ping(); $api = [ 'connected' => $response->success, 'error' => $response->success ? null : $response->message, + 'error_detail' => $response->success || $response->detail === '' ? null : $response->detail, 'search_enabled' => !empty($response->data['config']['search_enabled']), ]; } diff --git a/plugins/quant/src/Api/ApiResponse.php b/plugins/quant/src/Api/ApiResponse.php index e0d83a7..15e17b4 100644 --- a/plugins/quant/src/Api/ApiResponse.php +++ b/plugins/quant/src/Api/ApiResponse.php @@ -6,11 +6,16 @@ class ApiResponse { + /** + * @param string $message Short text that the admin can show to an editor. + * @param string $detail Raw transport or server text for diagnostics; never shown in the progress card. + */ public function __construct( public readonly bool $success, public readonly string $message = '', public readonly array $data = [], - public readonly int $statusCode = 200 + public readonly int $statusCode = 200, + public readonly string $detail = '' ) {} public static function success(array $data = [], string $message = ''): self @@ -18,8 +23,16 @@ public static function success(array $data = [], string $message = ''): self return new self(true, $message, $data); } - public static function error(string $message, int $statusCode = 400, array $data = []): self + public static function error(string $message, int $statusCode = 400, array $data = [], string $detail = ''): self { - return new self(false, $message, $data, $statusCode); + return new self(false, $message, $data, $statusCode, $detail); + } + + /** + * The message with the raw detail in brackets, for administrators (WP-CLI, logs). + */ + public function messageWithDetail(): string + { + return $this->detail === '' ? $this->message : "{$this->message} ({$this->detail})"; } } diff --git a/plugins/quant/src/Api/HttpClientInterface.php b/plugins/quant/src/Api/HttpClientInterface.php index 9da6069..2a9437d 100644 --- a/plugins/quant/src/Api/HttpClientInterface.php +++ b/plugins/quant/src/Api/HttpClientInterface.php @@ -8,13 +8,15 @@ * Minimal HTTP client contract. * * Every method returns ['code' => int, 'body' => string, 'headers' => array] - * or, when the transport failed, ['code' => 0, 'body' => '', 'error' => string]. + * or, when the request failed, ['code' => 0, 'body' => '', 'error' => string]. + * 'error' is a short message for the admin. When the transport failed (no + * answer, DNS, TLS), 'transport' is true and 'detail' holds the raw text. */ interface HttpClientInterface { /** * @param array $headers - * @return array{code: int, body: string, headers?: array, error?: string} + * @return array{code: int, body: string, headers?: array, error?: string, detail?: string, transport?: bool} */ public function get(string $url, array $headers = []): array; @@ -23,20 +25,20 @@ public function get(string $url, array $headers = []): array; * * @param array $headers * @param array $body - * @return array{code: int, body: string, headers?: array, error?: string} + * @return array{code: int, body: string, headers?: array, error?: string, detail?: string, transport?: bool} */ public function post(string $url, array $headers = [], array $body = []): array; /** * @param array $headers * @param array $body - * @return array{code: int, body: string, headers?: array, error?: string} + * @return array{code: int, body: string, headers?: array, error?: string, detail?: string, transport?: bool} */ public function patch(string $url, array $headers = [], array $body = []): array; /** * @param array $headers - * @return array{code: int, body: string, headers?: array, error?: string} + * @return array{code: int, body: string, headers?: array, error?: string, detail?: string, transport?: bool} */ public function delete(string $url, array $headers = []): array; @@ -44,7 +46,7 @@ public function delete(string $url, array $headers = []): array; * Multipart POST of one file in the `data` field. * * @param array $headers - * @return array{code: int, body: string, headers?: array, error?: string} + * @return array{code: int, body: string, headers?: array, error?: string, detail?: string, transport?: bool} */ public function upload(string $url, array $headers, string $filePath, string $fieldName = 'data'): array; @@ -52,7 +54,7 @@ public function upload(string $url, array $headers, string $filePath, string $fi * Multipart POST of in-memory contents in the `data` field. * * @param array $headers - * @return array{code: int, body: string, headers?: array, error?: string} + * @return array{code: int, body: string, headers?: array, error?: string, detail?: string, transport?: bool} */ public function uploadContents(string $url, array $headers, string $contents, string $filename, string $fieldName = 'data'): array; } diff --git a/plugins/quant/src/Api/QuantContentApi.php b/plugins/quant/src/Api/QuantContentApi.php index 93952c3..2314eee 100644 --- a/plugins/quant/src/Api/QuantContentApi.php +++ b/plugins/quant/src/Api/QuantContentApi.php @@ -12,12 +12,16 @@ * Paths follow the Quant Content API: markup and files go to POST /v1, files * as multipart with a Quant-File-Url header, unpublish is PATCH /v1/unpublish * with Quant-Url, purge is POST /v1/purge with Quant-Url. + * + * Publish and upload replace the content at a URL, so they are safe to send + * again: they go through the RetryPolicy. Other calls are sent once. */ class QuantContentApi implements ContentApiInterface { public function __construct( private readonly Settings $settings, - private readonly HttpClientInterface $http + private readonly HttpClientInterface $http, + private readonly RetryPolicy $retry = new RetryPolicy() ) { } @@ -47,7 +51,7 @@ public function publish(string $url, string $markup, array $info = [], array $op } } - return $this->parse($this->http->post($this->url(''), $this->headers(), $body)); + return $this->parse($this->retry->run(fn(): array => $this->http->post($this->url(''), $this->headers(), $body))); } public function unpublish(string $url): ApiResponse @@ -73,15 +77,15 @@ public function uploadFile(string $path, string $localPath): ApiResponse return ApiResponse::error("File not found: {$localPath}"); } - $response = $this->http->upload( + $response = $this->retry->run(fn(): array => $this->http->upload( $this->url(''), $this->headers(['Quant-File-Url' => $this->normalisePath($path)]), $localPath - ); + )); $parsed = $this->parse($response); - if (!$parsed->success && str_contains(strtolower($parsed->message), 'md5 already matches')) { + if ($this->isUnchangedFile($parsed)) { return ApiResponse::success($parsed->data, 'File unchanged'); } @@ -93,14 +97,14 @@ public function uploadContents(string $path, string $contents): ApiResponse $path = $this->normalisePath($path); $filename = basename(strtok($path, '?') ?: $path) ?: 'index.html'; - $parsed = $this->parse($this->http->uploadContents( + $parsed = $this->parse($this->retry->run(fn(): array => $this->http->uploadContents( $this->url(''), $this->headers(['Quant-File-Url' => $path]), $contents, $filename - )); + ))); - if (!$parsed->success && str_contains(strtolower($parsed->message), 'md5 already matches')) { + if ($this->isUnchangedFile($parsed)) { return ApiResponse::success($parsed->data, 'File unchanged'); } @@ -127,17 +131,21 @@ public function createRedirect(string $from, string $to, int $code = 301): ApiRe public function purge(string|array $paths): ApiResponse { $errors = []; + $details = []; foreach ($this->purgeVariants((array) $paths) as $variant) { $response = $this->http->post($this->url('/purge'), $this->headers(['Quant-Url' => $variant])); $parsed = $this->parse($response); if (!$parsed->success) { $errors[] = "{$variant}: {$parsed->message}"; + if ($parsed->detail !== '') { + $details[] = "{$variant}: {$parsed->detail}"; + } } } if ($errors !== []) { - return ApiResponse::error('Failed to purge: ' . implode('; ', $errors)); + return ApiResponse::error('Failed to purge: ' . implode('; ', $errors), 400, [], implode('; ', $details)); } return ApiResponse::success([], 'Cache purged successfully'); @@ -299,12 +307,20 @@ private function isAlreadyUnpublished(ApiResponse $response): bool } /** - * @param array{code?: int, body?: string, error?: string} $response + * "MD5 already matches" means the file at Quant is the same: a success. + */ + private function isUnchangedFile(ApiResponse $parsed): bool + { + return !$parsed->success && str_contains(strtolower($parsed->message . ' ' . $parsed->detail), 'md5 already matches'); + } + + /** + * @param array $response */ private function parse(array $response): ApiResponse { if (isset($response['error'])) { - return ApiResponse::error((string) $response['error'], 0); + return ApiResponse::error((string) $response['error'], 0, [], $this->withAttempts((string) ($response['detail'] ?? ''), $response)); } $code = (int) ($response['code'] ?? 0); @@ -315,11 +331,48 @@ private function parse(array $response): ApiResponse return ApiResponse::success($body); } + $message = $this->apiMessage($body); + $friendly = TransportError::forStatus($code); + if ($friendly === null) { + return ApiResponse::error($message ?? "HTTP {$code}", $code, $body); + } + + // A 5xx answer is often a proxy's HTML page: show the short message, keep the text. + $detail = $message ?? trim("HTTP {$code}: " . $this->excerpt((string) ($response['body'] ?? '')), ': '); + + return ApiResponse::error($friendly, $code, $body, $this->withAttempts($detail, $response)); + } + + /** + * @param array $body + */ + private function apiMessage(array $body): ?string + { $message = $body['message'] ?? $body['errorMsg'] ?? null; - if (!is_string($message) || $message === '') { - $message = is_string($body['error'] ?? null) ? $body['error'] : "HTTP {$code}"; + if (is_string($message) && $message !== '') { + return $message; } - return ApiResponse::error($message, $code, $body); + return is_string($body['error'] ?? null) && $body['error'] !== '' ? $body['error'] : null; + } + + /** + * The visible text of an error page, shortened for the diagnostics. + */ + private function excerpt(string $body): string + { + $text = trim((string) preg_replace('/\s+/', ' ', strip_tags($body))); + + return mb_substr($text, 0, 300); + } + + /** + * @param array $response + */ + private function withAttempts(string $detail, array $response): string + { + $attempts = (int) ($response['attempts'] ?? 1); + + return $attempts > 1 ? trim("{$detail} ({$attempts} attempts)") : $detail; } } diff --git a/plugins/quant/src/Api/RetryPolicy.php b/plugins/quant/src/Api/RetryPolicy.php new file mode 100644 index 0000000..7287421 --- /dev/null +++ b/plugins/quant/src/Api/RetryPolicy.php @@ -0,0 +1,105 @@ + $delaysMs Wait before each retry; the count is the retry limit. + * @param int $maxJitterMs Random extra wait per retry, so parallel workers do not retry together. + * @param float $maxElapsedSeconds No retry starts after this much time since the first attempt. + * A request that timed out already used its full timeout; this keeps one job from + * holding a run for several timeouts in a row. + * @param (Closure(int): void)|null $sleep Waits the given milliseconds (injected in tests). + * @param (Closure(): float)|null $clock Current time in seconds (injected in tests). + */ + public function __construct( + private readonly array $delaysMs = [1000, 3000], + private readonly int $maxJitterMs = 500, + private readonly float $maxElapsedSeconds = 45.0, + ?Closure $sleep = null, + ?Closure $clock = null, + ) { + $this->sleep = $sleep ?? static function (int $ms): void { + usleep($ms * 1000); + }; + $this->clock = $clock ?? static fn(): float => microtime(true); + } + + public static function none(): self + { + return new self([]); + } + + /** + * True for a transport failure (no answer) or a gateway status. A local + * refusal (unreadable file, size limit) also has code 0 but is not a + * transport failure, and a retry cannot fix it. + * + * @param array $response + */ + public static function isRetryable(array $response): bool + { + $code = (int) ($response['code'] ?? 0); + if ($code === 0) { + return !empty($response['transport']); + } + + return in_array($code, self::RETRY_STATUSES, true); + } + + /** + * @param Closure(): array $send + * @return array The last response; 'attempts' is set when there was more than one. + */ + public function run(Closure $send): array + { + $started = ($this->clock)(); + $response = $send(); + $attempts = 1; + + foreach ($this->delaysMs as $delay) { + $wait = $delay + ($this->maxJitterMs > 0 ? random_int(0, $this->maxJitterMs) : 0); + if (!self::isRetryable($response) || !$this->hasTimeFor($started, $wait)) { + break; + } + + ($this->sleep)($wait); + $response = $send(); + $attempts++; + } + + if ($attempts > 1) { + $response['attempts'] = $attempts; + } + + return $response; + } + + private function hasTimeFor(float $started, int $waitMs): bool + { + return ($this->clock)() - $started + ($waitMs / 1000) <= $this->maxElapsedSeconds; + } +} diff --git a/plugins/quant/src/Api/TransportError.php b/plugins/quant/src/Api/TransportError.php new file mode 100644 index 0000000..bb75985 --- /dev/null +++ b/plugins/quant/src/Api/TransportError.php @@ -0,0 +1,75 @@ + '/cURL error 6:|could not resolve|getaddrinfo|name or service not known/i', + self::TIMEOUT => '/cURL error 28:|timed out|timeout/i', + self::TLS => '/cURL error (35|51|53|54|58|59|60|64|66|77|80|83|90|91):|\bssl\b|\btls\b|certificate/i', + self::CONNECTION => '/cURL error (7|52|55|56):|connection (refused|reset)|failed to connect|empty reply/i', + ]; + + /** + * @return array{message: string, detail: string} + */ + public static function fromWpError(WP_Error $error): array + { + $code = (string) $error->get_error_code(); + $raw = (string) $error->get_error_message(); + + return [ + 'message' => self::classify($raw), + 'detail' => $code !== '' ? "{$code}: {$raw}" : $raw, + ]; + } + + /** + * The short message for a server-side status, or null when the API's own + * message is more useful (4xx answers explain what was wrong). + */ + public static function forStatus(int $status): ?string + { + if (in_array($status, RetryPolicy::RETRY_STATUSES, true)) { + return sprintf(self::UNAVAILABLE, $status); + } + + return $status >= 500 && $status < 600 ? sprintf(self::SERVER, $status) : null; + } + + private static function classify(string $raw): string + { + foreach (self::PATTERNS as $message => $pattern) { + if (preg_match($pattern, $raw) === 1) { + return $message; + } + } + + // Any other http_request_failed: the request did not complete. + return self::TIMEOUT; + } +} diff --git a/plugins/quant/src/Api/WordPressHttpClient.php b/plugins/quant/src/Api/WordPressHttpClient.php index 5c74795..91c3a9d 100644 --- a/plugins/quant/src/Api/WordPressHttpClient.php +++ b/plugins/quant/src/Api/WordPressHttpClient.php @@ -12,6 +12,19 @@ */ class WordPressHttpClient implements HttpClientInterface { + /** Uploads and large publish bodies get at least this timeout (seconds). */ + public const UPLOAD_MIN_TIMEOUT = 120; + + /** + * A JSON body this size or larger gets UPLOAD_MIN_TIMEOUT. A typical page + * is 50-300 KB of HTML. At a slow 256 kbit/s uplink (32 KB/s), 512 KB + * takes about 16 s to send, which leaves about half of the default 30 s + * timeout for Quant to process the page (find_attachments parses all of + * it). A larger body could time out on the upload alone, so it gets the + * same allowance as a file upload. + */ + public const LARGE_BODY_BYTES = 512 * 1024; + /** * @param Closure(): array{timeout: int, sslverify: bool} $options Resolved per request so settings changes apply immediately. */ @@ -104,7 +117,7 @@ private function sizeRefusal(int $bytes): ?string /** * @param array $headers - * @return array{code: int, body: string, headers?: array, error?: string} + * @return array{code: int, body: string, headers?: array, error?: string, detail?: string, transport?: bool} */ private function uploadBuffered(string $url, array $headers, string $filePath, string $fieldName): array { @@ -126,7 +139,7 @@ public function uploadContents(string $url, array $headers, string $contents, st 'Content-Type' => "multipart/form-data; boundary={$boundary}", ])); $args['body'] = $payload; - $args['timeout'] = max($args['timeout'], 120); + $args['timeout'] = max($args['timeout'], self::UPLOAD_MIN_TIMEOUT); return $this->send($url, $args); } @@ -134,14 +147,17 @@ public function uploadContents(string $url, array $headers, string $contents, st /** * @param array $headers * @param array $body - * @return array{code: int, body: string, headers?: array, error?: string} + * @return array{code: int, body: string, headers?: array, error?: string, detail?: string, transport?: bool} */ private function request(string $method, string $url, array $headers, array $body = []): array { $args = $this->baseArgs($method, array_merge(['Content-Type' => 'application/json'], $headers)); if ($body !== []) { - $args['body'] = wp_json_encode($body); + $args['body'] = (string) wp_json_encode($body); + if (strlen($args['body']) >= self::LARGE_BODY_BYTES) { + $args['timeout'] = max($args['timeout'], self::UPLOAD_MIN_TIMEOUT); + } } return $this->send($url, $args); @@ -164,15 +180,22 @@ private function baseArgs(string $method, array $headers): array } /** + * WordPress does not pass a connect timeout to its transport, so the + * Requests library default applies (10 s, for cURL and fsockopen alike). + * A host that does not accept the connection fails after 10 s, not after + * the full request timeout. + * * @param array $args - * @return array{code: int, body: string, headers?: array, error?: string} + * @return array{code: int, body: string, headers?: array, error?: string, detail?: string, transport?: bool} */ private function send(string $url, array $args): array { $response = wp_remote_request($url, $args); if ($response instanceof WP_Error) { - return ['code' => 0, 'body' => '', 'error' => $response->get_error_message()]; + $error = TransportError::fromWpError($response); + + return ['code' => 0, 'body' => '', 'error' => $error['message'], 'detail' => $error['detail'], 'transport' => true]; } $headers = wp_remote_retrieve_headers($response); diff --git a/plugins/quant/src/CLI/Commands.php b/plugins/quant/src/CLI/Commands.php index ef91286..313b256 100644 --- a/plugins/quant/src/CLI/Commands.php +++ b/plugins/quant/src/CLI/Commands.php @@ -82,7 +82,7 @@ public function status(array $args, array $assocArgs): void $search = !empty($response->data['config']['search_enabled']) ? 'enabled' : 'disabled'; WP_CLI::success("Connected to the Quant API (search {$search})."); } else { - WP_CLI::error("Connection failed: {$response->message}"); + WP_CLI::error("Connection failed: {$response->messageWithDetail()}"); } } @@ -95,7 +95,7 @@ public function ping(array $args, array $assocArgs): void { $this->requireCredentials(); $response = $this->api->ping(); - $response->success ? WP_CLI::success('pong') : WP_CLI::error($response->message); + $response->success ? WP_CLI::success('pong') : WP_CLI::error($response->messageWithDetail()); } /** @@ -566,7 +566,7 @@ private function queueErrors(): void return; } - WP_CLI\Utils\format_items('table', $errors, ['type', 'object_id', 'error_message', 'processed_at']); + WP_CLI\Utils\format_items('table', $errors, ['type', 'object_id', 'error_message', 'error_detail', 'processed_at']); } /** diff --git a/plugins/quant/src/Core/Activator.php b/plugins/quant/src/Core/Activator.php index 81ded08..8a58e97 100644 --- a/plugins/quant/src/Core/Activator.php +++ b/plugins/quant/src/Core/Activator.php @@ -8,7 +8,8 @@ class Activator { - public const DB_VERSION = '2.0.3'; + /** Bump with every schema change; Upgrader runs dbDelta when it differs. 2.0.6 adds error_detail. */ + public const DB_VERSION = '2.0.6'; public static function activate(): void { @@ -47,6 +48,7 @@ private static function createTables(): void status VARCHAR(20) DEFAULT 'pending', attempts TINYINT DEFAULT 0, error_message TEXT NULL, + error_detail TEXT NULL, meta LONGTEXT NULL, claim VARCHAR(36) NULL, dedupe_key CHAR(32) NULL, diff --git a/plugins/quant/src/Sync/DatabaseQueue.php b/plugins/quant/src/Sync/DatabaseQueue.php index 7fb69eb..3ac575c 100644 --- a/plugins/quant/src/Sync/DatabaseQueue.php +++ b/plugins/quant/src/Sync/DatabaseQueue.php @@ -136,26 +136,33 @@ public function markCompleted(string $jobId): void [ 'status' => self::STATUS_COMPLETED, 'error_message' => null, + 'error_detail' => null, 'processed_at' => gmdate('Y-m-d H:i:s'), ], ['job_id' => $jobId, 'status' => self::STATUS_PROCESSING], - ['%s', '%s', '%s'], + ['%s', '%s', '%s', '%s'], ['%s', '%s'] ); } - public function markFailed(string $jobId, string $error): void + /** + * error_message holds the short text the admin shows; error_detail holds + * the raw transport or server text for the diagnostics (NULL when empty). + */ + public function markFailed(string $jobId, string $error, string $detail = ''): void { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safely constructed $sql = $this->wpdb->prepare( "UPDATE {$this->tableName} SET status = %s, error_message = %s, + error_detail = NULLIF(%s, ''), attempts = attempts + 1, processed_at = %s WHERE job_id = %s AND status = %s", self::STATUS_FAILED, mb_substr($error, 0, 2000), + mb_substr($detail, 0, 4000), gmdate('Y-m-d H:i:s'), $jobId, self::STATUS_PROCESSING @@ -216,6 +223,7 @@ public function retry(): int SET status = %s, attempts = 0, error_message = NULL, + error_detail = NULL, processed_at = NULL WHERE status = %s", self::STATUS_PENDING, @@ -241,13 +249,15 @@ public function recoverStale(int $olderThanSeconds = self::STALE_AFTER): int SET attempts = attempts + 1, claim = NULL, status = IF(attempts >= %d, %s, %s), - error_message = IF(attempts >= %d, %s, error_message) + error_message = IF(attempts >= %d, %s, error_message), + error_detail = IF(attempts >= %d, NULL, error_detail) WHERE status = %s AND (processed_at IS NULL OR processed_at < %s)", self::MAX_ATTEMPTS, self::STATUS_FAILED, self::STATUS_PENDING, self::MAX_ATTEMPTS, 'Processing did not finish (timeout, memory limit or fatal error)', + self::MAX_ATTEMPTS, self::STATUS_PROCESSING, $cutoff ); @@ -334,15 +344,16 @@ public function clearTypes(array $types): int } /** - * Recent failures for diagnostics. + * Recent failures for diagnostics. error_message is for display; + * error_detail (raw transport text, may be NULL) is for the diagnostics report. * - * @return array + * @return array */ public function recentErrors(int $limit = 10): array { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safely constructed $sql = $this->wpdb->prepare( - "SELECT type, object_id, error_message, processed_at + "SELECT type, object_id, error_message, error_detail, processed_at FROM {$this->tableName} WHERE status = %s AND error_message IS NOT NULL ORDER BY processed_at DESC diff --git a/plugins/quant/src/Sync/Exception/QuantApiException.php b/plugins/quant/src/Sync/Exception/QuantApiException.php new file mode 100644 index 0000000..03facf9 --- /dev/null +++ b/plugins/quant/src/Sync/Exception/QuantApiException.php @@ -0,0 +1,34 @@ +message : "{$context}: {$response->message}"; + + return new self(esc_html($message), $response->detail); + } +} diff --git a/plugins/quant/src/Sync/FileSyncResult.php b/plugins/quant/src/Sync/FileSyncResult.php index 27b2c48..4249219 100644 --- a/plugins/quant/src/Sync/FileSyncResult.php +++ b/plugins/quant/src/Sync/FileSyncResult.php @@ -16,7 +16,8 @@ public function __construct( public readonly bool $success, public readonly string $path, public readonly string $message = '', - public readonly array $meta = [] + public readonly array $meta = [], + public readonly string $detail = '' ) { } @@ -32,9 +33,11 @@ public static function success(string $path, array $meta = []): self /** * Create a failure result. + * + * @param string $detail Raw transport or server text for diagnostics. */ - public static function failure(string $path, string $message): self + public static function failure(string $path, string $message, string $detail = ''): self { - return new self(false, $path, $message); + return new self(false, $path, $message, [], $detail); } } diff --git a/plugins/quant/src/Sync/Handler/FileHandler.php b/plugins/quant/src/Sync/Handler/FileHandler.php index 220c5eb..81f7020 100644 --- a/plugins/quant/src/Sync/Handler/FileHandler.php +++ b/plugins/quant/src/Sync/Handler/FileHandler.php @@ -5,6 +5,7 @@ namespace Quant\Sync\Handler; use Quant\Api\ContentApiInterface; +use Quant\Sync\Exception\QuantApiException; use Quant\Sync\MediaSync; use Quant\Sync\Paths; use Quant\Sync\SyncAction; @@ -44,7 +45,7 @@ private function handleMedia(int $attachmentId): void { $result = $this->media->sync($attachmentId); if (!$result->success) { - throw new RuntimeException(esc_html($result->message)); + throw new QuantApiException(esc_html($result->message), $result->detail); } } @@ -53,7 +54,7 @@ private function handleAllThemeAssets(): void $failures = array_filter($this->themeAssets->syncActiveTheme(), static fn($r): bool => !$r->success); if ($failures !== []) { $first = reset($failures); - throw new RuntimeException(esc_html(count($failures) . " theme assets failed, first: {$first->path}: {$first->message}")); + throw new QuantApiException(esc_html(count($failures) . " theme assets failed, first: {$first->path}: {$first->message}"), $first->detail); } } @@ -71,7 +72,7 @@ private function handleFile(SyncJob $job): void $response = $this->api->uploadFile(Paths::normalise(strtok($url, '?') ?: $url), $file); if (!$response->success) { - throw new RuntimeException(esc_html("API error uploading {$url}: {$response->message}")); + throw QuantApiException::fromResponse("API error uploading {$url}", $response); } } } diff --git a/plugins/quant/src/Sync/Handler/PurgeHandler.php b/plugins/quant/src/Sync/Handler/PurgeHandler.php index 84794de..8c29918 100644 --- a/plugins/quant/src/Sync/Handler/PurgeHandler.php +++ b/plugins/quant/src/Sync/Handler/PurgeHandler.php @@ -5,9 +5,9 @@ namespace Quant\Sync\Handler; use Quant\Api\ContentApiInterface; +use Quant\Sync\Exception\QuantApiException; use Quant\Sync\SyncAction; use Quant\Sync\SyncJob; -use RuntimeException; final class PurgeHandler implements HandlerInterface { @@ -27,7 +27,7 @@ public function handle(SyncJob $job): void $response = $this->api->purge(array_values(array_filter($paths, 'is_string'))); if (!$response->success) { - throw new RuntimeException(esc_html($response->message)); + throw QuantApiException::fromResponse('', $response); } } } diff --git a/plugins/quant/src/Sync/Handler/RedirectHandler.php b/plugins/quant/src/Sync/Handler/RedirectHandler.php index e1b14cb..41c78f5 100644 --- a/plugins/quant/src/Sync/Handler/RedirectHandler.php +++ b/plugins/quant/src/Sync/Handler/RedirectHandler.php @@ -5,6 +5,7 @@ namespace Quant\Sync\Handler; use Quant\Api\ContentApiInterface; +use Quant\Sync\Exception\QuantApiException; use Quant\Sync\SyncAction; use Quant\Sync\SyncJob; use Quant\Sync\SyncType; @@ -32,7 +33,7 @@ public function handle(SyncJob $job): void if ($job->action === SyncAction::Unpublish) { $response = $this->api->unpublish($from); if (!$response->success) { - throw new RuntimeException(esc_html("API error removing redirect {$from}: {$response->message}")); + throw QuantApiException::fromResponse("API error removing redirect {$from}", $response); } return; } @@ -44,7 +45,7 @@ public function handle(SyncJob $job): void $response = $this->api->createRedirect($from, $to, (int) ($job->meta['code'] ?? 301)); if (!$response->success) { - throw new RuntimeException(esc_html("API error publishing redirect {$from}: {$response->message}")); + throw QuantApiException::fromResponse("API error publishing redirect {$from}", $response); } } } diff --git a/plugins/quant/src/Sync/Handler/RoutePublishHandler.php b/plugins/quant/src/Sync/Handler/RoutePublishHandler.php index b99e712..9542ea6 100644 --- a/plugins/quant/src/Sync/Handler/RoutePublishHandler.php +++ b/plugins/quant/src/Sync/Handler/RoutePublishHandler.php @@ -5,6 +5,7 @@ namespace Quant\Sync\Handler; use Quant\Api\ContentApiInterface; +use Quant\Sync\Exception\QuantApiException; use Quant\Sync\Exception\RedirectResponseException; use Quant\Sync\Exception\UnpublishedContentException; use Quant\Sync\Paths; @@ -89,7 +90,7 @@ private function uploadFile(string $path, string $file): void { $response = $this->api->uploadFile(Paths::normalise(strtok($path, '?') ?: $path), $file); if (!$response->success) { - throw new RuntimeException(esc_html("API error uploading {$path}: {$response->message}")); + throw QuantApiException::fromResponse("API error uploading {$path}", $response); } } } diff --git a/plugins/quant/src/Sync/Handler/SearchRecordHandler.php b/plugins/quant/src/Sync/Handler/SearchRecordHandler.php index 021d7d3..0ccb485 100644 --- a/plugins/quant/src/Sync/Handler/SearchRecordHandler.php +++ b/plugins/quant/src/Sync/Handler/SearchRecordHandler.php @@ -6,10 +6,10 @@ use Quant\Api\ContentApiInterface; use Quant\Search\SearchRecordBuilder; +use Quant\Sync\Exception\QuantApiException; use Quant\Sync\SyncAction; use Quant\Sync\SyncJob; use Quant\Sync\SyncType; -use RuntimeException; use WP_Post; /** @@ -42,7 +42,7 @@ public function handle(SyncJob $job): void $response = $this->api->sendSearchRecords([$record]); if (!$response->success) { - throw new RuntimeException(esc_html("API error indexing {$record['url']}: {$response->message}")); + throw QuantApiException::fromResponse("API error indexing {$record['url']}", $response); } } } diff --git a/plugins/quant/src/Sync/JobProcessor.php b/plugins/quant/src/Sync/JobProcessor.php index 2418c59..998ff12 100644 --- a/plugins/quant/src/Sync/JobProcessor.php +++ b/plugins/quant/src/Sync/JobProcessor.php @@ -5,6 +5,7 @@ namespace Quant\Sync; use Quant\Core\Settings; +use Quant\Sync\Exception\QuantApiException; use Quant\Sync\Handler\HandlerInterface; use RuntimeException; use Throwable; @@ -92,7 +93,8 @@ private function runOne(SyncJob $job, array &$errors): bool } catch (Throwable $e) { // Messages are HTML-escaped when thrown; store them as plain text. $message = $e->getMessage() !== '' ? html_entity_decode($e->getMessage(), ENT_QUOTES | ENT_HTML5) : get_class($e); - $this->queue->markFailed($job->id, $message); + $detail = $e instanceof QuantApiException ? $e->detail : ''; + $this->queue->markFailed($job->id, $message, $detail); $errors[] = "{$job->type->value} {$job->objectId}: {$message}"; /** This action is documented in v2.0.0; it fires when a sync job fails. */ diff --git a/plugins/quant/src/Sync/MediaSync.php b/plugins/quant/src/Sync/MediaSync.php index eeb3449..3e0fc98 100644 --- a/plugins/quant/src/Sync/MediaSync.php +++ b/plugins/quant/src/Sync/MediaSync.php @@ -45,7 +45,7 @@ public function sync(int $attachmentId): FileSyncResult // Upload the main file $response = $this->api->uploadFile($cdnPath, $localPath); if (!$response->success) { - return FileSyncResult::failure($cdnPath, $response->message); + return FileSyncResult::failure($cdnPath, $response->message, $response->detail); } // Sync image sizes if this is an image diff --git a/plugins/quant/src/Sync/Publisher.php b/plugins/quant/src/Sync/Publisher.php index 3b3ae66..446e95d 100644 --- a/plugins/quant/src/Sync/Publisher.php +++ b/plugins/quant/src/Sync/Publisher.php @@ -6,6 +6,7 @@ use Quant\Api\ContentApiInterface; use Quant\Core\Settings; +use Quant\Sync\Exception\QuantApiException; use Quant\Sync\Renderer\RenderedContent; use RuntimeException; @@ -36,7 +37,7 @@ public function publish(RenderedContent $content): void $response = $this->api->publish($content->url, $content->markup, $content->info, $content->options); if (!$response->success) { - throw new RuntimeException(esc_html("API error publishing {$content->url}: {$response->message}")); + throw QuantApiException::fromResponse("API error publishing {$content->url}", $response); } $attachments = $response->data['attachments'] ?? null; @@ -68,7 +69,7 @@ private function publishAsFile(RenderedContent $content): bool $response = $this->api->uploadContents($content->url, $content->markup); if (!$response->success) { - throw new RuntimeException(esc_html("API error uploading {$content->url}: {$response->message}")); + throw QuantApiException::fromResponse("API error uploading {$content->url}", $response); } $this->purgeVariants([$content->url]); @@ -100,15 +101,19 @@ private function purgeVariants(array $paths): void public function unpublish(array $paths): void { $errors = []; + $details = []; foreach (array_unique($paths) as $path) { $response = $this->api->unpublish($path); if (!$response->success) { $errors[] = "{$path}: {$response->message}"; + if ($response->detail !== '') { + $details[] = "{$path}: {$response->detail}"; + } } } if ($errors !== []) { - throw new RuntimeException(esc_html('API error unpublishing: ' . implode('; ', $errors))); + throw new QuantApiException(esc_html('API error unpublishing: ' . implode('; ', $errors)), implode('; ', $details)); } $this->purgeVariants(array_values(array_unique($paths))); diff --git a/plugins/quant/src/Sync/SyncQueueInterface.php b/plugins/quant/src/Sync/SyncQueueInterface.php index a0515fe..37d9a1e 100644 --- a/plugins/quant/src/Sync/SyncQueueInterface.php +++ b/plugins/quant/src/Sync/SyncQueueInterface.php @@ -30,9 +30,12 @@ public function fetchPending(int $limit = 50): array; public function markCompleted(string $jobId): void; /** - * Mark a job as failed with an error message. + * Mark a job as failed. + * + * @param string $error Short message the admin shows. + * @param string $detail Raw transport or server text, kept for the diagnostics only. */ - public function markFailed(string $jobId, string $error): void; + public function markFailed(string $jobId, string $error, string $detail = ''): void; public function getStatus(): QueueStatus; diff --git a/plugins/quant/src/Sync/ThemeAssetSync.php b/plugins/quant/src/Sync/ThemeAssetSync.php index 5a879a1..b893366 100644 --- a/plugins/quant/src/Sync/ThemeAssetSync.php +++ b/plugins/quant/src/Sync/ThemeAssetSync.php @@ -75,7 +75,7 @@ public function syncAsset(string $localPath, string $cdnPath): FileSyncResult $response = $this->api->uploadFile($cdnPath, $localPath); if (!$response->success) { - return FileSyncResult::failure($cdnPath, $response->message); + return FileSyncResult::failure($cdnPath, $response->message, $response->detail); } return FileSyncResult::success($cdnPath); diff --git a/plugins/quant/tests/Api/QuantContentApiTest.php b/plugins/quant/tests/Api/QuantContentApiTest.php index 94893e1..eb349b3 100644 --- a/plugins/quant/tests/Api/QuantContentApiTest.php +++ b/plugins/quant/tests/Api/QuantContentApiTest.php @@ -11,6 +11,8 @@ use PHPUnit\Framework\TestCase; use Quant\Api\ApiResponse; use Quant\Api\QuantContentApi; +use Quant\Api\RetryPolicy; +use Quant\Api\TransportError; use Quant\Tests\Support\FakeHttpClient; use Quant\Tests\Support\WordPressStubs; @@ -37,7 +39,7 @@ protected function setUp(): void 'project' => 'site', 'token' => 'secret', 'endpoint' => 'https://api.quantcdn.io/', - ]), $this->http); + ]), $this->http, RetryPolicy::none()); $this->file = tempnam(sys_get_temp_dir(), 'quant'); file_put_contents($this->file, 'hello'); @@ -230,7 +232,8 @@ public function it_reports_purge_failures(): void $response = $this->api->purge('/x'); $this->assertFalse($response->success); - $this->assertSame('Failed to purge: /x/: edge down', $response->message); + $this->assertSame('Failed to purge: /x/: ' . sprintf(TransportError::SERVER, 500), $response->message); + $this->assertSame('/x/: edge down', $response->detail); } #[Test] @@ -270,7 +273,10 @@ public function it_parses_error_bodies(): void $this->assertSame(403, $forbidden->statusCode); $this->http->respondWith(['code' => 500, 'body' => 'not json']); - $this->assertSame('HTTP 500', $this->api->ping()->message); + $server = $this->api->ping(); + $this->assertSame(sprintf(TransportError::SERVER, 500), $server->message); + $this->assertSame('HTTP 500: not json', $server->detail); + $this->assertSame(500, $server->statusCode); $this->http->respondWith(['code' => 200, 'body' => json_encode(['error' => 'Project deleted'])]); $this->assertSame('Project deleted', $this->api->ping()->message); @@ -281,4 +287,121 @@ public function it_parses_error_bodies(): void $this->assertSame('cURL error 7', $transport->message); $this->assertSame(0, $transport->statusCode); } + + /** @var array */ + private array $slept = []; + + private function apiWithRetry(): QuantContentApi + { + return new QuantContentApi($this->mockSettings(credentials: [ + 'organization' => 'acme', + 'project' => 'site', + 'token' => 'secret', + 'endpoint' => 'https://api.quantcdn.io/', + ]), $this->http, new RetryPolicy(maxJitterMs: 0, sleep: function (int $ms): void { + $this->slept[] = $ms; + })); + } + + private const TIMEOUT = [ + 'code' => 0, + 'body' => '', + 'error' => TransportError::TIMEOUT, + 'detail' => 'http_request_failed: cURL error 28: Operation timed out after 30001 milliseconds with 0 bytes received', + 'transport' => true, + ]; + + #[Test] + public function it_retries_a_publish_after_a_gateway_error(): void + { + $this->http->respondWith(['code' => 503, 'body' => ''])->respondWith(['code' => 502, 'body' => ''])->respondOk(['ok' => true]); + + $response = $this->apiWithRetry()->publish('/a', '

a

'); + + $this->assertTrue($response->success); + $this->assertCount(3, $this->http->calls); + $this->assertSame([1000, 3000], $this->slept); + } + + #[Test] + public function it_retries_file_uploads_after_a_transport_failure(): void + { + $api = $this->apiWithRetry(); + + $this->http->respondWith(self::TIMEOUT)->respondOk(); + $this->assertTrue($api->uploadFile('/a.txt', $this->file)->success); + + $this->http->respondWith(self::TIMEOUT)->respondOk(); + $this->assertTrue($api->uploadContents('/sitemap.xml', '')->success); + + $this->assertSame(['UPLOAD', 'UPLOAD', 'UPLOAD_CONTENTS', 'UPLOAD_CONTENTS'], array_column($this->http->calls, 'method')); + } + + #[Test] + public function it_gives_up_after_two_retries_with_a_short_message_and_the_raw_detail(): void + { + $this->http->alwaysRespond(self::TIMEOUT); + + $response = $this->apiWithRetry()->publish('/a', '

a

'); + + $this->assertFalse($response->success); + $this->assertCount(3, $this->http->calls); + $this->assertSame('Quant did not answer in time. The plugin will try again.', $response->message); + $this->assertSame(self::TIMEOUT['detail'] . ' (3 attempts)', $response->detail); + $this->assertSame(0, $response->statusCode); + } + + #[Test] + public function it_does_not_retry_calls_that_are_not_publish_or_upload(): void + { + $api = $this->apiWithRetry(); + $this->http->alwaysRespond(['code' => 503, 'body' => '']); + + $api->unpublish('/a'); + $api->createRedirect('/a', '/b'); + $api->ping(); + $api->sendSearchRecords([['url' => '/a']]); + + $this->assertCount(4, $this->http->calls); + $this->assertSame([], $this->slept); + } + + #[Test] + public function it_does_not_retry_a_client_error(): void + { + $this->http->alwaysRespond(['code' => 400, 'body' => json_encode(['message' => 'Bad content'])]); + + $response = $this->apiWithRetry()->publish('/a', '

a

'); + + $this->assertCount(1, $this->http->calls); + $this->assertSame('Bad content', $response->message); + $this->assertSame('', $response->detail); + } + + #[Test] + public function it_hides_a_gateway_error_page_behind_a_short_message(): void + { + $this->http->alwaysRespond(['code' => 502, 'body' => "\n

502 Bad Gateway

\n"]); + + $response = $this->apiWithRetry()->publish('/a', '

a

'); + + $this->assertSame(sprintf(TransportError::UNAVAILABLE, 502), $response->message); + $this->assertSame('HTTP 502: 502 Bad Gateway (3 attempts)', $response->detail); + $this->assertSame(502, $response->statusCode); + } + + #[Test] + public function the_detail_is_added_in_brackets_only_for_administrators(): void + { + $this->assertSame('busy (HTTP 503: down)', ApiResponse::error('busy', 503, [], 'HTTP 503: down')->messageWithDetail()); + $this->assertSame('Bad content', ApiResponse::error('Bad content')->messageWithDetail()); + } + + #[Test] + public function it_still_treats_an_unchanged_file_as_success_when_the_api_says_so(): void + { + $this->http->respondWith(['code' => 400, 'body' => json_encode(['message' => 'MD5 already matches'])]); + + $this->assertTrue($this->api->uploadFile('/a.txt', $this->file)->success); + } } diff --git a/plugins/quant/tests/Api/RetryPolicyTest.php b/plugins/quant/tests/Api/RetryPolicyTest.php new file mode 100644 index 0000000..0e0ceee --- /dev/null +++ b/plugins/quant/tests/Api/RetryPolicyTest.php @@ -0,0 +1,145 @@ + */ + private array $slept = []; + + private float $now = 1000.0; + + private function policy(int $jitterMs = 0, float $maxElapsed = 45.0): RetryPolicy + { + return new RetryPolicy( + delaysMs: [1000, 3000], + maxJitterMs: $jitterMs, + maxElapsedSeconds: $maxElapsed, + sleep: function (int $ms): void { + $this->slept[] = $ms; + $this->now += $ms / 1000; + }, + clock: fn(): float => $this->now, + ); + } + + /** + * @param array> $responses + * @return \Closure(): array + */ + private function answers(array $responses, int &$calls): \Closure + { + return static function () use (&$responses, &$calls): array { + $calls++; + return array_shift($responses) ?? ['code' => 200, 'body' => '{}']; + }; + } + + private const TIMEOUT = ['code' => 0, 'body' => '', 'error' => 'Quant did not answer in time.', 'detail' => 'raw', 'transport' => true]; + + #[Test] + public function it_returns_the_first_success_without_a_retry(): void + { + $calls = 0; + $response = $this->policy()->run($this->answers([['code' => 200, 'body' => '{}']], $calls)); + + $this->assertSame(200, $response['code']); + $this->assertSame(1, $calls); + $this->assertSame([], $this->slept); + $this->assertArrayNotHasKey('attempts', $response); + } + + #[Test] + public function it_retries_a_transport_failure_twice_with_growing_delays(): void + { + $calls = 0; + $response = $this->policy()->run($this->answers([self::TIMEOUT, self::TIMEOUT, self::TIMEOUT], $calls)); + + $this->assertSame(3, $calls); + $this->assertSame([1000, 3000], $this->slept); + $this->assertSame(0, $response['code']); + $this->assertSame(3, $response['attempts']); + } + + #[Test] + public function it_stops_at_the_first_success(): void + { + $calls = 0; + $response = $this->policy()->run($this->answers([['code' => 503, 'body' => ''], ['code' => 201, 'body' => '{}']], $calls)); + + $this->assertSame(201, $response['code']); + $this->assertSame(2, $calls); + $this->assertSame([1000], $this->slept); + } + + #[Test] + public function it_retries_only_502_503_and_504(): void + { + foreach ([502, 503, 504] as $code) { + $this->assertTrue(RetryPolicy::isRetryable(['code' => $code, 'body' => '']), (string) $code); + } + + foreach ([200, 400, 401, 403, 404, 409, 429, 500, 501] as $code) { + $this->assertFalse(RetryPolicy::isRetryable(['code' => $code, 'body' => '']), (string) $code); + } + } + + #[Test] + public function it_does_not_retry_a_local_refusal_with_code_zero(): void + { + $calls = 0; + $response = $this->policy()->run($this->answers([['code' => 0, 'body' => '', 'error' => 'Could not read file: /x']], $calls)); + + $this->assertSame(1, $calls); + $this->assertSame([], $this->slept); + $this->assertSame('Could not read file: /x', $response['error']); + } + + #[Test] + public function it_adds_jitter_within_the_limit(): void + { + $calls = 0; + $this->policy(jitterMs: 400)->run($this->answers([self::TIMEOUT, self::TIMEOUT, self::TIMEOUT], $calls)); + + $this->assertCount(2, $this->slept); + $this->assertGreaterThanOrEqual(1000, $this->slept[0]); + $this->assertLessThanOrEqual(1400, $this->slept[0]); + $this->assertGreaterThanOrEqual(3000, $this->slept[1]); + $this->assertLessThanOrEqual(3400, $this->slept[1]); + } + + #[Test] + public function it_does_not_start_a_retry_after_the_elapsed_limit(): void + { + $calls = 0; + $slow = function () use (&$calls): array { + $calls++; + $this->now += 30.0; // Each attempt waits for the full request timeout. + return self::TIMEOUT; + }; + + $response = $this->policy(maxElapsed: 45.0)->run($slow); + + // 30 s + 1 s delay fits in 45 s; a second retry at 61 s + 3 s does not. + $this->assertSame(2, $calls); + $this->assertSame([1000], $this->slept); + $this->assertSame(2, $response['attempts']); + } + + #[Test] + public function none_never_retries(): void + { + $calls = 0; + RetryPolicy::none()->run($this->answers([self::TIMEOUT, self::TIMEOUT], $calls)); + + $this->assertSame(1, $calls); + } +} diff --git a/plugins/quant/tests/Api/TransportErrorTest.php b/plugins/quant/tests/Api/TransportErrorTest.php new file mode 100644 index 0000000..40715f3 --- /dev/null +++ b/plugins/quant/tests/Api/TransportErrorTest.php @@ -0,0 +1,85 @@ +stubCommonWordPressFunctions(); + } + + protected function tearDown(): void + { + Monkey\tearDown(); + parent::tearDown(); + } + + /** + * @return array + */ + public static function transportFailures(): array + { + return [ + 'curl timeout' => ['http_request_failed', 'cURL error 28: Operation timed out after 30001 milliseconds with 0 bytes received', TransportError::TIMEOUT], + 'fsockopen timeout' => ['http_request_failed', 'fsockopen: connection timed out', TransportError::TIMEOUT], + 'other http_request_failed' => ['http_request_failed', 'A valid URL was not provided.', TransportError::TIMEOUT], + 'dns' => ['http_request_failed', 'cURL error 6: Could not resolve host: api.quantcdn.io', TransportError::DNS], + 'tls certificate' => ['http_request_failed', 'cURL error 60: SSL certificate problem: unable to get local issuer certificate', TransportError::TLS], + 'tls handshake' => ['http_request_failed', 'cURL error 35: OpenSSL SSL_connect: SSL_ERROR_SYSCALL in connection to api.quantcdn.io:443', TransportError::TLS], + 'connection refused' => ['http_request_failed', 'cURL error 7: Failed to connect to api.quantcdn.io port 443: Connection refused', TransportError::CONNECTION], + 'connection reset' => ['http_request_failed', 'cURL error 56: Recv failure: Connection reset by peer', TransportError::CONNECTION], + ]; + } + + #[Test] + #[DataProvider('transportFailures')] + public function it_maps_a_transport_failure_to_a_short_message_and_keeps_the_raw_text(string $code, string $raw, string $expected): void + { + $error = TransportError::fromWpError(new \WP_Error($code, $raw)); + + $this->assertSame($expected, $error['message']); + $this->assertSame("{$code}: {$raw}", $error['detail']); + $this->assertStringNotContainsString('cURL', $error['message']); + } + + #[Test] + public function the_timeout_message_says_the_plugin_tries_again(): void + { + $this->assertSame('Quant did not answer in time. The plugin will try again.', TransportError::TIMEOUT); + } + + #[Test] + public function it_maps_gateway_and_server_errors(): void + { + $this->assertSame(sprintf(TransportError::UNAVAILABLE, 503), TransportError::forStatus(503)); + $this->assertSame(sprintf(TransportError::UNAVAILABLE, 502), TransportError::forStatus(502)); + $this->assertSame(sprintf(TransportError::UNAVAILABLE, 504), TransportError::forStatus(504)); + $this->assertSame(sprintf(TransportError::SERVER, 500), TransportError::forStatus(500)); + $this->assertStringContainsString('(HTTP 500)', (string) TransportError::forStatus(500)); + } + + #[Test] + public function it_leaves_other_statuses_to_the_api_message(): void + { + $this->assertNull(TransportError::forStatus(200)); + $this->assertNull(TransportError::forStatus(400)); + $this->assertNull(TransportError::forStatus(404)); + $this->assertNull(TransportError::forStatus(429)); + } +} diff --git a/plugins/quant/tests/Api/WordPressHttpClientTest.php b/plugins/quant/tests/Api/WordPressHttpClientTest.php index f5bafe5..e4bc5c4 100644 --- a/plugins/quant/tests/Api/WordPressHttpClientTest.php +++ b/plugins/quant/tests/Api/WordPressHttpClientTest.php @@ -179,13 +179,51 @@ public function it_reports_an_unreadable_upload_without_a_request(): void } #[Test] - public function it_returns_transport_errors(): void + public function it_returns_a_short_message_for_a_transport_error_and_keeps_the_raw_text_as_detail(): void { - Functions\when('wp_remote_request')->justReturn(new \WP_Error('http_request_failed', 'timed out')); + $raw = 'cURL error 28: Operation timed out after 30001 milliseconds with 0 bytes received'; + Functions\when('wp_remote_request')->justReturn(new \WP_Error('http_request_failed', $raw)); $client = WordPressHttpClient::withDefaults(); $response = $client->patch('https://api.test/v1/unpublish', ['Quant-Url' => '/x']); - $this->assertSame(['code' => 0, 'body' => '', 'error' => 'timed out'], $response); + $this->assertSame([ + 'code' => 0, + 'body' => '', + 'error' => 'Quant did not answer in time. The plugin will try again.', + 'detail' => "http_request_failed: {$raw}", + 'transport' => true, + ], $response); + } + + #[Test] + public function it_keeps_the_configured_timeout_for_a_normal_json_publish(): void + { + $client = WordPressHttpClient::withDefaults(30, true); + + $client->post('https://api.test/v1', [], ['url' => '/a', 'content' => str_repeat('x', 100 * 1024)]); + + $this->assertSame(30, $this->captured['args']['timeout']); + } + + #[Test] + public function it_gives_a_large_json_body_the_upload_timeout(): void + { + $client = WordPressHttpClient::withDefaults(30, true); + + $client->post('https://api.test/v1', [], ['url' => '/a', 'content' => str_repeat('x', WordPressHttpClient::LARGE_BODY_BYTES)]); + + $this->assertSame(WordPressHttpClient::UPLOAD_MIN_TIMEOUT, $this->captured['args']['timeout']); + $this->assertSame(120, WordPressHttpClient::UPLOAD_MIN_TIMEOUT); + } + + #[Test] + public function a_longer_configured_timeout_wins_over_the_large_body_minimum(): void + { + $client = WordPressHttpClient::withDefaults(300, true); + + $client->post('https://api.test/v1', [], ['content' => str_repeat('x', WordPressHttpClient::LARGE_BODY_BYTES)]); + + $this->assertSame(300, $this->captured['args']['timeout']); } } diff --git a/plugins/quant/tests/Sync/DatabaseQueueTest.php b/plugins/quant/tests/Sync/DatabaseQueueTest.php index cf0ba05..c91b319 100644 --- a/plugins/quant/tests/Sync/DatabaseQueueTest.php +++ b/plugins/quant/tests/Sync/DatabaseQueueTest.php @@ -185,9 +185,9 @@ public function it_marks_a_job_completed_and_clears_its_error(): void { $this->wpdb->shouldReceive('update')->once()->with( $this->table, - Mockery::on(static fn(array $data): bool => $data['status'] === 'completed' && $data['error_message'] === null && isset($data['processed_at'])), + Mockery::on(static fn(array $data): bool => $data['status'] === 'completed' && $data['error_message'] === null && $data['error_detail'] === null && isset($data['processed_at'])), ['job_id' => 'job-1', 'status' => 'processing'], - ['%s', '%s', '%s'], + ['%s', '%s', '%s', '%s'], ['%s', '%s'] ); @@ -198,9 +198,12 @@ public function it_marks_a_job_completed_and_clears_its_error(): void public function it_marks_a_job_failed_and_increments_attempts(): void { $this->wpdb->shouldReceive('prepare')->once()->with( - Mockery::on(static fn(string $sql): bool => str_contains($sql, 'attempts = attempts + 1') && str_contains($sql, 'WHERE job_id = %s AND status = %s')), + Mockery::on(static fn(string $sql): bool => str_contains($sql, 'attempts = attempts + 1') + && str_contains($sql, "error_detail = NULLIF(%s, '')") + && str_contains($sql, 'WHERE job_id = %s AND status = %s')), 'failed', 'Boom', + '', Mockery::type('string'), 'job-1', 'processing' @@ -210,6 +213,24 @@ public function it_marks_a_job_failed_and_increments_attempts(): void $this->queue->markFailed('job-1', 'Boom'); } + #[Test] + public function it_stores_the_short_message_and_the_detail_separately(): void + { + $detail = 'http_request_failed: cURL error 28: Operation timed out after 30001 milliseconds with 0 bytes received'; + $this->wpdb->shouldReceive('prepare')->once()->with( + Mockery::type('string'), + 'failed', + 'API error publishing /a: Quant did not answer in time. The plugin will try again.', + $detail, + Mockery::type('string'), + 'job-1', + 'processing' + )->andReturn('update_sql'); + $this->wpdb->shouldReceive('query')->once()->with('update_sql'); + + $this->queue->markFailed('job-1', 'API error publishing /a: Quant did not answer in time. The plugin will try again.', $detail); + } + #[Test] public function it_truncates_long_error_messages(): void { @@ -217,13 +238,14 @@ public function it_truncates_long_error_messages(): void Mockery::type('string'), 'failed', Mockery::on(static fn(string $message): bool => mb_strlen($message) === 2000), + Mockery::on(static fn(string $detail): bool => mb_strlen($detail) === 4000), Mockery::type('string'), 'job-1', 'processing' )->andReturn('sql'); $this->wpdb->shouldReceive('query')->once(); - $this->queue->markFailed('job-1', str_repeat('x', 5000)); + $this->queue->markFailed('job-1', str_repeat('x', 5000), str_repeat('y', 9000)); } #[Test] @@ -266,7 +288,7 @@ public function it_clears_pending_jobs_only(): void #[Test] public function it_retries_failed_jobs_and_returns_the_count(): void { - $this->wpdb->shouldReceive('query')->once()->with(Mockery::on(static fn(string $sql): bool => str_contains($sql, 'error_message = NULL') && str_contains($sql, 'attempts = 0'))); + $this->wpdb->shouldReceive('query')->once()->with(Mockery::on(static fn(string $sql): bool => str_contains($sql, 'error_message = NULL') && str_contains($sql, 'error_detail = NULL') && str_contains($sql, 'attempts = 0'))); $this->wpdb->rows_affected = 3; $this->assertSame(3, $this->queue->retry()); @@ -279,12 +301,14 @@ public function it_recovers_stale_jobs_and_fails_them_after_the_attempt_limit(): Mockery::on(static fn(string $sql): bool => str_contains($sql, 'attempts = attempts + 1') && str_contains($sql, 'claim = NULL') && str_contains($sql, 'status = IF(attempts >= %d, %s, %s)') + && str_contains($sql, 'error_detail = IF(attempts >= %d, NULL, error_detail)') && str_contains($sql, 'processed_at IS NULL OR processed_at < %s')), DatabaseQueue::MAX_ATTEMPTS, 'failed', 'pending', DatabaseQueue::MAX_ATTEMPTS, Mockery::on(static fn(string $m): bool => str_contains($m, 'did not finish')), + DatabaseQueue::MAX_ATTEMPTS, 'processing', Mockery::on(static function (string $cutoff): bool { $age = time() - strtotime($cutoff . ' UTC'); @@ -377,9 +401,9 @@ public function it_clears_everything_pending_when_no_types_are_given(): void #[Test] public function it_lists_recent_errors(): void { - $rows = [['type' => 'post', 'object_id' => '1', 'error_message' => 'HTTP 500', 'processed_at' => '2026-01-01 00:00:00']]; + $rows = [['type' => 'post', 'object_id' => '1', 'error_message' => 'HTTP 500', 'error_detail' => 'raw', 'processed_at' => '2026-01-01 00:00:00']]; $this->wpdb->shouldReceive('get_results')->once() - ->with(Mockery::on(static fn(string $sql): bool => str_contains($sql, 'error_message IS NOT NULL')), ARRAY_A) + ->with(Mockery::on(static fn(string $sql): bool => str_contains($sql, 'error_message IS NOT NULL') && str_contains($sql, 'error_message, error_detail, processed_at')), ARRAY_A) ->andReturn($rows); $this->assertSame($rows, $this->queue->recentErrors(5)); diff --git a/plugins/quant/tests/Sync/Handler/FileHandlerTest.php b/plugins/quant/tests/Sync/Handler/FileHandlerTest.php index e6c7133..8d1cb52 100644 --- a/plugins/quant/tests/Sync/Handler/FileHandlerTest.php +++ b/plugins/quant/tests/Sync/Handler/FileHandlerTest.php @@ -20,6 +20,7 @@ use Quant\Sync\SyncType; use Quant\Sync\ThemeAssetSync; use Quant\Tests\Support\WordPressStubs; +use Quant\Sync\Exception\QuantApiException; use RuntimeException; #[CoversClass(FileHandler::class)] @@ -105,6 +106,40 @@ public function it_syncs_media_through_media_sync_and_reports_failures(): void $this->handler->handle(new SyncJob(SyncType::Media, '9', SyncAction::Publish)); } + #[Test] + public function it_carries_the_raw_detail_of_a_failed_media_upload(): void + { + $attachment = $this->makePost(9, 'attachment'); + $attachment->post_mime_type = 'application/pdf'; + Functions\when('get_post')->justReturn($attachment); + Functions\when('get_attached_file')->justReturn($this->file); + Functions\when('wp_get_attachment_url')->justReturn('https://example.com/wp-content/uploads/doc.pdf'); + Functions\when('wp_get_attachment_metadata')->justReturn([]); + $this->api->shouldReceive('uploadFile')->once()->andReturn(ApiResponse::error('Quant did not answer in time. The plugin will try again.', 0, [], 'raw transport text')); + + try { + $this->handler->handle(new SyncJob(SyncType::Media, '9', SyncAction::Publish)); + $this->fail('Expected an exception'); + } catch (QuantApiException $e) { + $this->assertSame('Quant did not answer in time. The plugin will try again.', $e->getMessage()); + $this->assertSame('raw transport text', $e->detail); + } + } + + #[Test] + public function it_carries_the_raw_detail_of_a_failed_file_upload(): void + { + $this->api->shouldReceive('uploadFile')->once()->andReturn(ApiResponse::error('busy', 503, [], 'HTTP 503: down')); + + try { + $this->handler->handle(new SyncJob(SyncType::File, '/style.css', SyncAction::Publish, ['file' => $this->file, 'url' => '/style.css'])); + $this->fail('Expected an exception'); + } catch (QuantApiException $e) { + $this->assertSame('API error uploading /style.css: busy', $e->getMessage()); + $this->assertSame('HTTP 503: down', $e->detail); + } + } + #[Test] public function it_syncs_all_theme_assets_for_the_legacy_job(): void { diff --git a/plugins/quant/tests/Sync/JobProcessorTest.php b/plugins/quant/tests/Sync/JobProcessorTest.php index da9b295..5ff5a14 100644 --- a/plugins/quant/tests/Sync/JobProcessorTest.php +++ b/plugins/quant/tests/Sync/JobProcessorTest.php @@ -11,6 +11,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use Quant\Sync\Exception\QuantApiException; use Quant\Sync\Handler\HandlerInterface; use Quant\Sync\JobProcessor; use Quant\Sync\SyncAction; @@ -97,7 +98,7 @@ public function it_runs_a_batch_marking_jobs_completed_or_failed(): void $this->queue->shouldReceive('recoverStale')->once(); $this->queue->shouldReceive('fetchPending')->once()->with(20)->andReturn([$ok, $bad]); $this->queue->shouldReceive('markCompleted')->once()->with($ok->id); - $this->queue->shouldReceive('markFailed')->once()->with($bad->id, 'HTTP 500'); + $this->queue->shouldReceive('markFailed')->once()->with($bad->id, 'HTTP 500', ''); Actions\expectDone('quant_sync_job_failed')->once()->with($bad, Mockery::type(RuntimeException::class)); $result = (new JobProcessor($this->queue, $this->mockSettings(), [$handler]))->run(20, 25); @@ -107,6 +108,29 @@ public function it_runs_a_batch_marking_jobs_completed_or_failed(): void $this->assertSame(['post 2: HTTP 500'], $result->errors); } + #[Test] + public function it_stores_the_detail_of_an_api_failure_apart_from_the_message(): void + { + $this->stubCommonWordPressFunctions(); + $job = new SyncJob(SyncType::Post, '7', SyncAction::Publish); + $handler = $this->handler(SyncType::Post, static function (): void { + throw new QuantApiException('API error publishing /a: Quant did not answer in time. The plugin will try again.', 'http_request_failed: cURL error 28: timed out'); + }); + + $this->queue->shouldReceive('recoverStale')->once(); + $this->queue->shouldReceive('fetchPending')->once()->andReturn([$job]); + $this->queue->shouldReceive('markFailed')->once()->with( + $job->id, + 'API error publishing /a: Quant did not answer in time. The plugin will try again.', + 'http_request_failed: cURL error 28: timed out' + ); + Actions\expectDone('quant_sync_job_failed')->once(); + + $result = (new JobProcessor($this->queue, $this->mockSettings(), [$handler]))->run(20, 25); + + $this->assertSame(['post 7: API error publishing /a: Quant did not answer in time. The plugin will try again.'], $result->errors); + } + #[Test] public function it_releases_unprocessed_jobs_when_the_time_budget_is_spent(): void { diff --git a/plugins/quant/tests/Sync/PublisherTest.php b/plugins/quant/tests/Sync/PublisherTest.php index c811e35..108bc93 100644 --- a/plugins/quant/tests/Sync/PublisherTest.php +++ b/plugins/quant/tests/Sync/PublisherTest.php @@ -20,6 +20,7 @@ use Quant\Sync\SyncQueueInterface; use Quant\Sync\SyncType; use Quant\Tests\Support\WordPressStubs; +use Quant\Sync\Exception\QuantApiException; use RuntimeException; #[CoversClass(Publisher::class)] @@ -143,6 +144,37 @@ public function it_throws_when_the_api_rejects_the_content(): void $this->publisher()->publish(new RenderedContent('/x', 'y')); } + #[Test] + public function it_carries_the_raw_detail_of_a_failed_publish(): void + { + $this->api->shouldReceive('publish')->once()->andReturn( + ApiResponse::error('Quant did not answer in time. The plugin will try again.', 0, [], 'http_request_failed: cURL error 28: timed out') + ); + + try { + $this->publisher()->publish(new RenderedContent('/x', 'y')); + $this->fail('Expected an exception'); + } catch (QuantApiException $e) { + $this->assertSame('API error publishing /x: Quant did not answer in time. The plugin will try again.', $e->getMessage()); + $this->assertSame('http_request_failed: cURL error 28: timed out', $e->detail); + } + } + + #[Test] + public function it_carries_the_details_of_failed_unpublishes(): void + { + $this->api->shouldReceive('unpublish')->once()->with('/a')->andReturn(ApiResponse::error('busy', 503, [], 'HTTP 503: down')); + $this->api->shouldReceive('unpublish')->once()->with('/b')->andReturn(ApiResponse::error('nope')); + + try { + $this->publisher()->unpublish(['/a', '/b']); + $this->fail('Expected an exception'); + } catch (QuantApiException $e) { + $this->assertSame('API error unpublishing: /a: busy; /b: nope', $e->getMessage()); + $this->assertSame('/a: HTTP 503: down', $e->detail); + } + } + #[Test] public function it_unpublishes_each_path_once_and_aggregates_errors(): void { From 1048d8e9ab89293a8cb655eea83932917a61fc2f Mon Sep 17 00:00:00 2001 From: Stuart Rowlands Date: Sun, 15 Nov 2026 15:53:21 +1000 Subject: [PATCH 2/6] test(e2e): stalled API and gateway retry; short message in the queue card The mock API can delay a simulated failure (delayMs) so the plugin's request times out. The failed-push spec now expects the short server-error message instead of the API text. --- tests/e2e/lib/wp.js | 4 +- tests/e2e/mock-api/server.js | 10 +++- tests/e2e/specs/content-sync.spec.js | 4 +- tests/e2e/specs/transport-errors.spec.js | 64 ++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 tests/e2e/specs/transport-errors.spec.js diff --git a/tests/e2e/lib/wp.js b/tests/e2e/lib/wp.js index 95a70c9..871ab53 100644 --- a/tests/e2e/lib/wp.js +++ b/tests/e2e/lib/wp.js @@ -89,8 +89,8 @@ async function mockReset() { return (await mockFetch('DELETE', '/_mock/requests')).json; } -async function mockFail({ path: p, status = 500, times = 1 }) { - return (await mockFetch('POST', '/_mock/fail', { path: p, status, times })).json; +async function mockFail({ path: p, status = 500, times = 1, delayMs = 0 }) { + return (await mockFetch('POST', '/_mock/fail', { path: p, status, times, delayMs })).json; } /** Poll until predicate(state) is truthy or timeout. */ diff --git a/tests/e2e/mock-api/server.js b/tests/e2e/mock-api/server.js index 225c9d7..5f4b142 100644 --- a/tests/e2e/mock-api/server.js +++ b/tests/e2e/mock-api/server.js @@ -10,6 +10,8 @@ * DELETE /_mock/requests clear requests AND state * GET /_mock/state published, files, redirects, search, facets, unpublished * POST /_mock/fail {path,status,times} fail the next N requests to path + * (delayMs: wait that long first, to + * make the plugin's request time out) * POST /_mock/platform {mode,custom_ranking,facets} "aws" makes the project * a QuantGov (Typesense) search project; * facets replaces the registered facets @@ -383,7 +385,11 @@ async function routeContentApi(req, res, url, raw) { record(req, url, body); const fail = takeFailure(url.pathname); - if (fail) return send(res, fail.status, { error: true, errorMsg: 'Simulated failure' }); + if (fail) { + if (fail.delayMs) await new Promise((resolve) => setTimeout(resolve, fail.delayMs)); + if (res.destroyed || res.writableEnded) return; + return send(res, fail.status, { error: true, errorMsg: 'Simulated failure' }); + } if (!isAuthorized(req)) return send(res, 403, { error: true, errorMsg: 'Invalid credentials' }); @@ -497,7 +503,7 @@ function routeInspection(req, res, url, raw) { if (m === 'POST' && p === '/_mock/fail') { const body = parseJson(raw) || {}; if (!body.path) return send(res, 400, { error: 'path required' }); - failures.push({ path: body.path, status: body.status || 500, times: body.times || 1 }); + failures.push({ path: body.path, status: body.status || 500, times: body.times || 1, delayMs: Number(body.delayMs) || 0 }); return send(res, 200, { success: true, failures }); } if (m === 'POST' && p === '/_mock/platform') { diff --git a/tests/e2e/specs/content-sync.spec.js b/tests/e2e/specs/content-sync.spec.js index d1a76a9..e97b1d2 100644 --- a/tests/e2e/specs/content-sync.spec.js +++ b/tests/e2e/specs/content-sync.spec.js @@ -111,8 +111,10 @@ test.describe('content sync (static mode)', () => { await page.locator('#quant-admin-root h1').first().waitFor(); await expect(page.locator('tr', { hasText: 'Third Post' }).locator('.quant-status-badge')).toHaveText('Failed'); + // The card shows the short message; the API's own text is only in the diagnostics. await gotoQuant(page, 'dashboard'); - await expect(page.getByText('Simulated failure')).toBeVisible(); + await expect(page.getByText('Quant had a server error (HTTP 500). The plugin will try again.')).toBeVisible(); + await expect(page.getByText('Simulated failure')).toHaveCount(0); await screenshot(page, 'dashboard-failed-job'); await page.getByRole('button', { name: 'Retry failed' }).click(); diff --git a/tests/e2e/specs/transport-errors.spec.js b/tests/e2e/specs/transport-errors.spec.js new file mode 100644 index 0000000..db092eb --- /dev/null +++ b/tests/e2e/specs/transport-errors.spec.js @@ -0,0 +1,64 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); +const { resetPlugin, configurePlugin, mockRequests, mockFail, wp, postIdBySlug } = require('../lib/wp'); +const { processQueue, waitForMock } = require('../lib/queue'); +const { gotoQuant, rest } = require('../lib/admin'); + +/** POST /v1 publish requests for one page. */ +async function publishAttempts(url) { + const requests = await mockRequests({ path: '/v1', method: 'POST' }); + return requests.filter((r) => r.body && r.body.url === url).length; +} + +test.describe('Quant API stalls and gateway errors', () => { + test.setTimeout(180_000); + + test.beforeEach(async () => { + await resetPlugin(); + configurePlugin({ mode: 'static' }); + }); + + test('a gateway error is retried in the same run', async ({ page }) => { + await mockFail({ path: '/v1', status: 503, times: 1 }); + await gotoQuant(page, 'content'); + await rest(page, '/sync', { method: 'POST', data: { items: [{ type: 'post', id: postIdBySlug('third-post') }], action: 'sync' } }); + processQueue(); + + await waitForMock((s) => s.published['/third-post'], { timeout: 30_000 }); + expect(await publishAttempts('/third-post')).toBe(2); + const status = await rest(page, '/queue/status'); + expect(status.failed).toBe(0); + }); + + test('a stalled API shows a short message and the report keeps the raw text', async ({ page }) => { + // The lowest timeout the plugin accepts; the mock answers after 8 s. + wp(['option', 'patch', 'insert', 'quant_credentials', 'http_request_timeout', '5']); + await mockFail({ path: '/v1', status: 504, times: 3, delayMs: 8000 }); + await gotoQuant(page, 'content'); + await rest(page, '/sync', { method: 'POST', data: { items: [{ type: 'post', id: postIdBySlug('third-post') }], action: 'sync' } }); + processQueue(); + + // One attempt and two retries in the same run. + expect(await publishAttempts('/third-post')).toBe(3); + const errors = await rest(page, '/queue/errors'); + expect(errors).toHaveLength(1); + expect(errors[0].error_message).toBe('API error publishing /third-post: Quant did not answer in time. The plugin will try again.'); + expect(errors[0].error_detail).toMatch(/^http_request_failed: cURL error 28: .* \(3 attempts\)$/); + + await gotoQuant(page, 'dashboard'); + await expect(page.getByText('Quant did not answer in time. The plugin will try again.')).toBeVisible(); + await expect(page.getByText(/cURL error/)).toHaveCount(0); + + await gotoQuant(page, 'diagnostics'); + await expect(page.getByRole('heading', { name: 'Diagnostics' })).toBeVisible(); + await expect(page.getByText(/cURL error/)).toHaveCount(0); + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.getByRole('button', { name: 'Download Report' }).click(), + ]); + const content = require('fs').readFileSync(await download.path(), 'utf8'); + expect(content).toContain('[post:'); + expect(content).toContain('Quant did not answer in time. The plugin will try again.'); + expect(content).toMatch(/ {3}Detail: http_request_failed: cURL error 28: .* \(3 attempts\)/); + }); +}); From 7a3233c6c60bb4cf5d31417b1e93f416cc0c6226 Mon Sep 17 00:00:00 2001 From: Stuart Rowlands Date: Sun, 15 Nov 2026 16:07:37 +1000 Subject: [PATCH 3/6] fix(sync): say what happens next only when it is true The short messages promised "The plugin will try again." even when the job had used all its attempts or WP-Cron was disabled, and on the connection test. - TransportError reasons no longer say what happens next. ApiResponse, QuantApiException and FileSyncResult carry a transient flag (no answer or 5xx). - JobProcessor adds "The plugin will try again." only when the job has attempts left (attempts + 1 < MAX_ATTEMPTS) and background processing runs (Scheduler::backgroundProcessingEnabled, the DISABLE_WP_CRON check the dashboard warning already uses). Otherwise it adds "Retry it from the queue.". Permanent errors get no next step. - SyncJob carries attempts from the queue row. - The connection test (ping) adds "Test the connection again in a minute.". --- plugins/quant/readme.txt | 4 +- .../quant/src/Admin/DiagnosticsController.php | 2 +- plugins/quant/src/Admin/RestController.php | 3 +- plugins/quant/src/Api/ApiResponse.php | 8 +- plugins/quant/src/Api/QuantContentApi.php | 15 ++- plugins/quant/src/Api/TransportError.php | 24 +++-- plugins/quant/src/Core/Scheduler.php | 12 ++- plugins/quant/src/Sync/DatabaseQueue.php | 2 +- .../src/Sync/Exception/QuantApiException.php | 8 +- plugins/quant/src/Sync/FileSyncResult.php | 8 +- .../quant/src/Sync/Handler/FileHandler.php | 4 +- plugins/quant/src/Sync/JobProcessor.php | 29 +++++- plugins/quant/src/Sync/MediaSync.php | 2 +- plugins/quant/src/Sync/Publisher.php | 4 +- plugins/quant/src/Sync/SyncJob.php | 5 +- plugins/quant/src/Sync/ThemeAssetSync.php | 2 +- .../quant/tests/Api/QuantContentApiTest.php | 30 +++++- .../quant/tests/Api/TransportErrorTest.php | 11 ++- .../tests/Api/WordPressHttpClientTest.php | 2 +- .../quant/tests/Sync/DatabaseQueueTest.php | 10 +- .../tests/Sync/Handler/FileHandlerTest.php | 5 +- plugins/quant/tests/Sync/JobProcessorTest.php | 99 ++++++++++++++++--- plugins/quant/tests/Sync/PublisherTest.php | 8 +- plugins/quant/tests/Sync/SyncJobTest.php | 2 + tests/e2e/specs/transport-errors.spec.js | 19 +++- 25 files changed, 261 insertions(+), 57 deletions(-) diff --git a/plugins/quant/readme.txt b/plugins/quant/readme.txt index 4cc5b69..089fc16 100644 --- a/plugins/quant/readme.txt +++ b/plugins/quant/readme.txt @@ -138,7 +138,7 @@ Enable search for the project in the Quant dashboard, then open **QuantCDN > Sea = A push failed with "Quant did not answer in time" = -The Quant API did not answer within the HTTP request timeout, or a gateway between your server and Quant was busy. The plugin sends a page or file again up to two times in the same run (after about 1 and 3 seconds). If it still fails, the job stays in the queue as failed, and the background run (WP-Cron) tries it again up to three times, with a longer wait each time. You can also press "Retry failed". The raw error text (for example the cURL error) is in the report from QuantCDN > Diagnostics > Download Report. +The Quant API did not answer within the HTTP request timeout, or a gateway between your server and Quant was busy. The plugin sends a page or file again up to two times in the same run (after about 1 and 3 seconds). If it still fails, the job stays in the queue as failed. While the job has attempts left (three in total) and WP-Cron runs, the message ends with "The plugin will try again." and the background run tries it again, with a longer wait each time. When the job has used all attempts, or WP-Cron is disabled (`DISABLE_WP_CRON`), the message ends with "Retry it from the queue.": press "Retry failed" on the dashboard or run `wp quant queue retry`. A failed connection test says "Test the connection again in a minute." The raw error text (for example the cURL error) is in the report from QuantCDN > Diagnostics > Download Report. = Forms on a static site = @@ -147,7 +147,7 @@ Follow the [Forms documentation](https://docs.quantcdn.io/docs/dashboard/forms). == Changelog == = 2.0.6 = -* Fixed: when the Quant API did not answer, the push showed raw transport text such as "cURL error 28: Operation timed out after 30001 milliseconds with 0 bytes received". The dashboard queue card and the push progress now show a short message (for example "Quant did not answer in time. The plugin will try again."). The raw text is kept apart and is in the diagnostics report and in `wp quant queue errors`. +* Fixed: when the Quant API did not answer, the push showed raw transport text such as "cURL error 28: Operation timed out after 30001 milliseconds with 0 bytes received". The dashboard queue card and the push progress now show a short message, for example "Quant did not answer in time. The plugin will try again." The message says "Retry it from the queue." instead when the job has used all attempts or WP-Cron is disabled, and a failed connection test says "Test the connection again in a minute." The raw text is kept apart and is in the diagnostics report and in `wp quant queue errors`. * Fixed: one stalled request or gateway error (502, 503, 504) failed the page until the next background run, and the background run only happens when WP-Cron runs. A page or file push is now sent again up to two times in the same run, after about 1 and 3 seconds. * A page larger than 512 KB now gets the same 120 second minimum timeout as a file upload. * Readme: `QUANT_HTTP_REQUEST_TIMEOUT` is documented. diff --git a/plugins/quant/src/Admin/DiagnosticsController.php b/plugins/quant/src/Admin/DiagnosticsController.php index 08e7190..bb141a2 100644 --- a/plugins/quant/src/Admin/DiagnosticsController.php +++ b/plugins/quant/src/Admin/DiagnosticsController.php @@ -119,7 +119,7 @@ public function getDiagnostics(WP_REST_Request $request): array 'php_memory_limit' => ini_get('memory_limit'), 'php_max_execution_time' => ini_get('max_execution_time'), 'is_multisite' => is_multisite(), - 'cron_disabled' => defined('DISABLE_WP_CRON') && DISABLE_WP_CRON, + 'cron_disabled' => !\Quant\Core\Scheduler::backgroundProcessingEnabled(), 'next_queue_run' => wp_next_scheduled('quant_process_queue') ?: null, 'permalink_structure' => get_option('permalink_structure') ?: 'plain', ], diff --git a/plugins/quant/src/Admin/RestController.php b/plugins/quant/src/Admin/RestController.php index 731825c..fb928c1 100644 --- a/plugins/quant/src/Admin/RestController.php +++ b/plugins/quant/src/Admin/RestController.php @@ -5,6 +5,7 @@ namespace Quant\Admin; use Quant\Api\ContentApiInterface; +use Quant\Core\Scheduler; use Quant\Core\Settings; use Quant\Core\SiteTime; use Quant\Redirects\RedirectProvider; @@ -121,7 +122,7 @@ public function getStatus(WP_REST_Request $request): array ], 'next_cron' => $nextCron, 'next_cron_label' => $nextCron === null ? null : SiteTime::time($nextCron), - 'cron_disabled' => defined('DISABLE_WP_CRON') && DISABLE_WP_CRON, + 'cron_disabled' => !Scheduler::backgroundProcessingEnabled(), ]; } diff --git a/plugins/quant/src/Api/ApiResponse.php b/plugins/quant/src/Api/ApiResponse.php index 15e17b4..c188da5 100644 --- a/plugins/quant/src/Api/ApiResponse.php +++ b/plugins/quant/src/Api/ApiResponse.php @@ -9,13 +9,15 @@ class ApiResponse /** * @param string $message Short text that the admin can show to an editor. * @param string $detail Raw transport or server text for diagnostics; never shown in the progress card. + * @param bool $transient True when Quant did not answer or answered 5xx: the same call can work later. */ public function __construct( public readonly bool $success, public readonly string $message = '', public readonly array $data = [], public readonly int $statusCode = 200, - public readonly string $detail = '' + public readonly string $detail = '', + public readonly bool $transient = false ) {} public static function success(array $data = [], string $message = ''): self @@ -23,9 +25,9 @@ public static function success(array $data = [], string $message = ''): self return new self(true, $message, $data); } - public static function error(string $message, int $statusCode = 400, array $data = [], string $detail = ''): self + public static function error(string $message, int $statusCode = 400, array $data = [], string $detail = '', bool $transient = false): self { - return new self(false, $message, $data, $statusCode, $detail); + return new self(false, $message, $data, $statusCode, $detail, $transient); } /** diff --git a/plugins/quant/src/Api/QuantContentApi.php b/plugins/quant/src/Api/QuantContentApi.php index 2314eee..0c3de10 100644 --- a/plugins/quant/src/Api/QuantContentApi.php +++ b/plugins/quant/src/Api/QuantContentApi.php @@ -27,7 +27,12 @@ public function __construct( public function ping(): ApiResponse { - return $this->parse($this->http->get($this->url('/ping'), $this->headers())); + $response = $this->parse($this->http->get($this->url('/ping'), $this->headers())); + if (!$response->transient) { + return $response; + } + + return ApiResponse::error("{$response->message} " . TransportError::NEXT_TEST_AGAIN, $response->statusCode, $response->data, $response->detail, true); } public function publish(string $url, string $markup, array $info = [], array $options = []): ApiResponse @@ -132,12 +137,14 @@ public function purge(string|array $paths): ApiResponse { $errors = []; $details = []; + $transient = false; foreach ($this->purgeVariants((array) $paths) as $variant) { $response = $this->http->post($this->url('/purge'), $this->headers(['Quant-Url' => $variant])); $parsed = $this->parse($response); if (!$parsed->success) { $errors[] = "{$variant}: {$parsed->message}"; + $transient = $transient || $parsed->transient; if ($parsed->detail !== '') { $details[] = "{$variant}: {$parsed->detail}"; } @@ -145,7 +152,7 @@ public function purge(string|array $paths): ApiResponse } if ($errors !== []) { - return ApiResponse::error('Failed to purge: ' . implode('; ', $errors), 400, [], implode('; ', $details)); + return ApiResponse::error('Failed to purge: ' . implode('; ', $errors), 400, [], implode('; ', $details), $transient); } return ApiResponse::success([], 'Cache purged successfully'); @@ -320,7 +327,7 @@ private function isUnchangedFile(ApiResponse $parsed): bool private function parse(array $response): ApiResponse { if (isset($response['error'])) { - return ApiResponse::error((string) $response['error'], 0, [], $this->withAttempts((string) ($response['detail'] ?? ''), $response)); + return ApiResponse::error((string) $response['error'], 0, [], $this->withAttempts((string) ($response['detail'] ?? ''), $response), !empty($response['transport'])); } $code = (int) ($response['code'] ?? 0); @@ -340,7 +347,7 @@ private function parse(array $response): ApiResponse // A 5xx answer is often a proxy's HTML page: show the short message, keep the text. $detail = $message ?? trim("HTTP {$code}: " . $this->excerpt((string) ($response['body'] ?? '')), ': '); - return ApiResponse::error($friendly, $code, $body, $this->withAttempts($detail, $response)); + return ApiResponse::error($friendly, $code, $body, $this->withAttempts($detail, $response), true); } /** diff --git a/plugins/quant/src/Api/TransportError.php b/plugins/quant/src/Api/TransportError.php index bb75985..76ac18e 100644 --- a/plugins/quant/src/Api/TransportError.php +++ b/plugins/quant/src/Api/TransportError.php @@ -12,15 +12,27 @@ * The raw transport text ("cURL error 28: Operation timed out after ...") * helps support but means nothing to an editor. The admin shows the short * message; the raw text is kept as a detail for diagnostics. + * + * The reasons do not say what happens next. Only the caller knows that, so + * the caller adds one of the NEXT_* sentences: the queue adds NEXT_AUTOMATIC + * or NEXT_MANUAL (Quant\Sync\JobProcessor), the connection test adds + * NEXT_TEST_AGAIN (QuantContentApi::ping). */ final class TransportError { - public const TIMEOUT = 'Quant did not answer in time. The plugin will try again.'; - public const DNS = 'The Quant API address could not be found (DNS lookup failed). The plugin will try again.'; - public const TLS = 'A secure connection to Quant could not be made (TLS error). The plugin will try again.'; - public const CONNECTION = 'The connection to Quant failed. The plugin will try again.'; - public const UNAVAILABLE = 'Quant is busy or unavailable (HTTP %d). The plugin will try again.'; - public const SERVER = 'Quant had a server error (HTTP %d). The plugin will try again.'; + public const TIMEOUT = 'Quant did not answer in time.'; + public const DNS = 'The Quant API address could not be found (DNS lookup failed).'; + public const TLS = 'A secure connection to Quant could not be made (TLS error).'; + public const CONNECTION = 'The connection to Quant failed.'; + public const UNAVAILABLE = 'Quant is busy or unavailable (HTTP %d).'; + public const SERVER = 'Quant had a server error (HTTP %d).'; + + /** The job has attempts left and background processing runs. */ + public const NEXT_AUTOMATIC = 'The plugin will try again.'; + /** The job used all attempts, or nothing runs the queue in the background. */ + public const NEXT_MANUAL = 'Retry it from the queue.'; + /** A failed connection test. */ + public const NEXT_TEST_AGAIN = 'Test the connection again in a minute.'; /** * Patterns over the raw message, checked in order. WordPress reports all diff --git a/plugins/quant/src/Core/Scheduler.php b/plugins/quant/src/Core/Scheduler.php index a196023..db41f7a 100644 --- a/plugins/quant/src/Core/Scheduler.php +++ b/plugins/quant/src/Core/Scheduler.php @@ -27,6 +27,16 @@ class Scheduler public const SCHEDULED_SYNC_HOOK = 'quant_scheduled_sync'; public const INTERVAL = 'quant_every_minute'; + /** + * Whether WP-Cron runs the queue (and its retryDue backoff) in the + * background. The same check as the dashboard's "WP-Cron is disabled" + * warning (RestController, DiagnosticsController). + */ + public static function backgroundProcessingEnabled(): bool + { + return !(defined('DISABLE_WP_CRON') && DISABLE_WP_CRON); + } + public function __construct( private readonly Settings $settings, private readonly SyncQueueInterface $queue, @@ -97,7 +107,7 @@ public function ensureProcessing(): void // Not from WP-CLI: there the site URL is the stored (often public, static) // domain, and spawn_cron() would only set a lock that delays the next run. $cli = defined('WP_CLI') && WP_CLI; - if (!$this->spawnRequested && !$cli && !wp_doing_cron() && !(defined('DISABLE_WP_CRON') && DISABLE_WP_CRON)) { + if (!$this->spawnRequested && !$cli && !wp_doing_cron() && self::backgroundProcessingEnabled()) { $this->spawnRequested = true; add_action('shutdown', [$this, 'spawnCron'], 100); } diff --git a/plugins/quant/src/Sync/DatabaseQueue.php b/plugins/quant/src/Sync/DatabaseQueue.php index 3ac575c..574426a 100644 --- a/plugins/quant/src/Sync/DatabaseQueue.php +++ b/plugins/quant/src/Sync/DatabaseQueue.php @@ -110,7 +110,7 @@ public function fetchPending(int $limit = 50): array // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safely constructed $select = $this->wpdb->prepare( - "SELECT job_id, type, object_id, action, created_at, meta + "SELECT job_id, type, object_id, action, created_at, meta, attempts FROM {$this->tableName} WHERE claim = %s AND status = %s ORDER BY id ASC", diff --git a/plugins/quant/src/Sync/Exception/QuantApiException.php b/plugins/quant/src/Sync/Exception/QuantApiException.php index 03facf9..1636598 100644 --- a/plugins/quant/src/Sync/Exception/QuantApiException.php +++ b/plugins/quant/src/Sync/Exception/QuantApiException.php @@ -15,9 +15,13 @@ */ final class QuantApiException extends RuntimeException { + /** + * @param bool $transient Quant did not answer or answered 5xx; the job can work on a later attempt. + */ public function __construct( string $message, - public readonly string $detail = '' + public readonly string $detail = '', + public readonly bool $transient = false ) { parent::__construct($message); } @@ -29,6 +33,6 @@ public static function fromResponse(string $context, ApiResponse $response): sel { $message = $context === '' ? $response->message : "{$context}: {$response->message}"; - return new self(esc_html($message), $response->detail); + return new self(esc_html($message), $response->detail, $response->transient); } } diff --git a/plugins/quant/src/Sync/FileSyncResult.php b/plugins/quant/src/Sync/FileSyncResult.php index 4249219..9a1310f 100644 --- a/plugins/quant/src/Sync/FileSyncResult.php +++ b/plugins/quant/src/Sync/FileSyncResult.php @@ -17,7 +17,8 @@ public function __construct( public readonly string $path, public readonly string $message = '', public readonly array $meta = [], - public readonly string $detail = '' + public readonly string $detail = '', + public readonly bool $transient = false ) { } @@ -35,9 +36,10 @@ public static function success(string $path, array $meta = []): self * Create a failure result. * * @param string $detail Raw transport or server text for diagnostics. + * @param bool $transient The same upload can work later (see ApiResponse::$transient). */ - public static function failure(string $path, string $message, string $detail = ''): self + public static function failure(string $path, string $message, string $detail = '', bool $transient = false): self { - return new self(false, $path, $message, [], $detail); + return new self(false, $path, $message, [], $detail, $transient); } } diff --git a/plugins/quant/src/Sync/Handler/FileHandler.php b/plugins/quant/src/Sync/Handler/FileHandler.php index 81f7020..5541d88 100644 --- a/plugins/quant/src/Sync/Handler/FileHandler.php +++ b/plugins/quant/src/Sync/Handler/FileHandler.php @@ -45,7 +45,7 @@ private function handleMedia(int $attachmentId): void { $result = $this->media->sync($attachmentId); if (!$result->success) { - throw new QuantApiException(esc_html($result->message), $result->detail); + throw new QuantApiException(esc_html($result->message), $result->detail, $result->transient); } } @@ -54,7 +54,7 @@ private function handleAllThemeAssets(): void $failures = array_filter($this->themeAssets->syncActiveTheme(), static fn($r): bool => !$r->success); if ($failures !== []) { $first = reset($failures); - throw new QuantApiException(esc_html(count($failures) . " theme assets failed, first: {$first->path}: {$first->message}"), $first->detail); + throw new QuantApiException(esc_html(count($failures) . " theme assets failed, first: {$first->path}: {$first->message}"), $first->detail, $first->transient); } } diff --git a/plugins/quant/src/Sync/JobProcessor.php b/plugins/quant/src/Sync/JobProcessor.php index 998ff12..83b9fbb 100644 --- a/plugins/quant/src/Sync/JobProcessor.php +++ b/plugins/quant/src/Sync/JobProcessor.php @@ -4,6 +4,9 @@ namespace Quant\Sync; +use Closure; +use Quant\Api\TransportError; +use Quant\Core\Scheduler; use Quant\Core\Settings; use Quant\Sync\Exception\QuantApiException; use Quant\Sync\Handler\HandlerInterface; @@ -18,15 +21,21 @@ class JobProcessor /** @var array */ private array $handlers; + /** @var Closure(): bool */ + private readonly Closure $backgroundProcessing; + /** * @param array $handlers + * @param (Closure(): bool)|null $backgroundProcessing Whether WP-Cron retries failed jobs (injected in tests). */ public function __construct( private readonly SyncQueueInterface $queue, private readonly Settings $settings, - array $handlers + array $handlers, + ?Closure $backgroundProcessing = null ) { $this->handlers = $handlers; + $this->backgroundProcessing = $backgroundProcessing ?? Scheduler::backgroundProcessingEnabled(...); } /** @@ -94,6 +103,7 @@ private function runOne(SyncJob $job, array &$errors): bool // Messages are HTML-escaped when thrown; store them as plain text. $message = $e->getMessage() !== '' ? html_entity_decode($e->getMessage(), ENT_QUOTES | ENT_HTML5) : get_class($e); $detail = $e instanceof QuantApiException ? $e->detail : ''; + $message = $this->withNextStep($message, $job, $e); $this->queue->markFailed($job->id, $message, $detail); $errors[] = "{$job->type->value} {$job->objectId}: {$message}"; @@ -103,4 +113,21 @@ private function runOne(SyncJob $job, array &$errors): bool return false; } } + + /** + * Tell the editor what happens next after a transient failure, and only + * what is true: DatabaseQueue::retryDue requeues a failed job while it has + * attempts left, but only when WP-Cron runs the queue. + */ + private function withNextStep(string $message, SyncJob $job, Throwable $e): string + { + if (!$e instanceof QuantApiException || !$e->transient) { + return $message; + } + + $attemptsLeft = $job->attempts + 1 < DatabaseQueue::MAX_ATTEMPTS; + $next = $attemptsLeft && ($this->backgroundProcessing)() ? TransportError::NEXT_AUTOMATIC : TransportError::NEXT_MANUAL; + + return "{$message} {$next}"; + } } diff --git a/plugins/quant/src/Sync/MediaSync.php b/plugins/quant/src/Sync/MediaSync.php index 3e0fc98..93b0e2b 100644 --- a/plugins/quant/src/Sync/MediaSync.php +++ b/plugins/quant/src/Sync/MediaSync.php @@ -45,7 +45,7 @@ public function sync(int $attachmentId): FileSyncResult // Upload the main file $response = $this->api->uploadFile($cdnPath, $localPath); if (!$response->success) { - return FileSyncResult::failure($cdnPath, $response->message, $response->detail); + return FileSyncResult::failure($cdnPath, $response->message, $response->detail, $response->transient); } // Sync image sizes if this is an image diff --git a/plugins/quant/src/Sync/Publisher.php b/plugins/quant/src/Sync/Publisher.php index 446e95d..a45611a 100644 --- a/plugins/quant/src/Sync/Publisher.php +++ b/plugins/quant/src/Sync/Publisher.php @@ -102,10 +102,12 @@ public function unpublish(array $paths): void { $errors = []; $details = []; + $transient = false; foreach (array_unique($paths) as $path) { $response = $this->api->unpublish($path); if (!$response->success) { $errors[] = "{$path}: {$response->message}"; + $transient = $transient || $response->transient; if ($response->detail !== '') { $details[] = "{$path}: {$response->detail}"; } @@ -113,7 +115,7 @@ public function unpublish(array $paths): void } if ($errors !== []) { - throw new QuantApiException(esc_html('API error unpublishing: ' . implode('; ', $errors)), implode('; ', $details)); + throw new QuantApiException(esc_html('API error unpublishing: ' . implode('; ', $errors)), implode('; ', $details), $transient); } $this->purgeVariants(array_values(array_unique($paths))); diff --git a/plugins/quant/src/Sync/SyncJob.php b/plugins/quant/src/Sync/SyncJob.php index dc3a994..3a3291b 100644 --- a/plugins/quant/src/Sync/SyncJob.php +++ b/plugins/quant/src/Sync/SyncJob.php @@ -14,6 +14,7 @@ final class SyncJob implements JsonSerializable /** * @param array $meta + * @param int $attempts Failed or stalled runs so far (from the queue table). */ public function __construct( public readonly SyncType $type, @@ -22,6 +23,7 @@ public function __construct( public readonly array $meta = [], ?string $id = null, ?DateTimeImmutable $createdAt = null, + public readonly int $attempts = 0, ) { $this->id = $id ?? uniqid('', true); $this->createdAt = $createdAt ?? new DateTimeImmutable(); @@ -30,7 +32,7 @@ public function __construct( /** * Create a SyncJob from database row data. * - * @param array{job_id: string, type: string, object_id: string, action: string, created_at: string, meta?: string|null} $row + * @param array{job_id: string, type: string, object_id: string, action: string, created_at: string, meta?: string|null, attempts?: int|string} $row */ public static function fromDatabaseRow(array $row): self { @@ -41,6 +43,7 @@ public static function fromDatabaseRow(array $row): self meta: self::decodeMeta($row['meta'] ?? null), id: $row['job_id'], createdAt: new DateTimeImmutable($row['created_at']), + attempts: (int) ($row['attempts'] ?? 0), ); } diff --git a/plugins/quant/src/Sync/ThemeAssetSync.php b/plugins/quant/src/Sync/ThemeAssetSync.php index b893366..e6c6289 100644 --- a/plugins/quant/src/Sync/ThemeAssetSync.php +++ b/plugins/quant/src/Sync/ThemeAssetSync.php @@ -75,7 +75,7 @@ public function syncAsset(string $localPath, string $cdnPath): FileSyncResult $response = $this->api->uploadFile($cdnPath, $localPath); if (!$response->success) { - return FileSyncResult::failure($cdnPath, $response->message, $response->detail); + return FileSyncResult::failure($cdnPath, $response->message, $response->detail, $response->transient); } return FileSyncResult::success($cdnPath); diff --git a/plugins/quant/tests/Api/QuantContentApiTest.php b/plugins/quant/tests/Api/QuantContentApiTest.php index eb349b3..852401c 100644 --- a/plugins/quant/tests/Api/QuantContentApiTest.php +++ b/plugins/quant/tests/Api/QuantContentApiTest.php @@ -274,7 +274,7 @@ public function it_parses_error_bodies(): void $this->http->respondWith(['code' => 500, 'body' => 'not json']); $server = $this->api->ping(); - $this->assertSame(sprintf(TransportError::SERVER, 500), $server->message); + $this->assertSame(sprintf(TransportError::SERVER, 500) . ' ' . TransportError::NEXT_TEST_AGAIN, $server->message); $this->assertSame('HTTP 500: not json', $server->detail); $this->assertSame(500, $server->statusCode); @@ -346,7 +346,8 @@ public function it_gives_up_after_two_retries_with_a_short_message_and_the_raw_d $this->assertFalse($response->success); $this->assertCount(3, $this->http->calls); - $this->assertSame('Quant did not answer in time. The plugin will try again.', $response->message); + $this->assertSame('Quant did not answer in time.', $response->message); + $this->assertTrue($response->transient); $this->assertSame(self::TIMEOUT['detail'] . ' (3 attempts)', $response->detail); $this->assertSame(0, $response->statusCode); } @@ -376,6 +377,7 @@ public function it_does_not_retry_a_client_error(): void $this->assertCount(1, $this->http->calls); $this->assertSame('Bad content', $response->message); $this->assertSame('', $response->detail); + $this->assertFalse($response->transient); } #[Test] @@ -386,10 +388,34 @@ public function it_hides_a_gateway_error_page_behind_a_short_message(): void $response = $this->apiWithRetry()->publish('/a', '

a

'); $this->assertSame(sprintf(TransportError::UNAVAILABLE, 502), $response->message); + $this->assertTrue($response->transient); $this->assertSame('HTTP 502: 502 Bad Gateway (3 attempts)', $response->detail); $this->assertSame(502, $response->statusCode); } + #[Test] + public function a_failed_connection_test_says_to_test_again(): void + { + $this->http->respondWith(self::TIMEOUT); + $this->assertSame('Quant did not answer in time. Test the connection again in a minute.', $this->api->ping()->message); + + $this->http->respondWith(['code' => 503, 'body' => '']); + $this->assertSame('Quant is busy or unavailable (HTTP 503). Test the connection again in a minute.', $this->api->ping()->message); + + $this->http->respondWith(['code' => 403, 'body' => json_encode(['errorMsg' => 'Invalid credentials'])]); + $this->assertSame('Invalid credentials', $this->api->ping()->message); + } + + #[Test] + public function a_failed_purge_is_transient_when_one_variant_was(): void + { + $this->http->respondOk()->respondWith(['code' => 504, 'body' => '']); + $this->assertTrue($this->api->purge('/x')->transient); + + $this->http->respondOk()->respondWith(['code' => 400, 'body' => json_encode(['message' => 'bad'])]); + $this->assertFalse($this->api->purge('/x')->transient); + } + #[Test] public function the_detail_is_added_in_brackets_only_for_administrators(): void { diff --git a/plugins/quant/tests/Api/TransportErrorTest.php b/plugins/quant/tests/Api/TransportErrorTest.php index 40715f3..a31f003 100644 --- a/plugins/quant/tests/Api/TransportErrorTest.php +++ b/plugins/quant/tests/Api/TransportErrorTest.php @@ -59,9 +59,16 @@ public function it_maps_a_transport_failure_to_a_short_message_and_keeps_the_raw } #[Test] - public function the_timeout_message_says_the_plugin_tries_again(): void + public function the_reasons_do_not_promise_a_next_step(): void { - $this->assertSame('Quant did not answer in time. The plugin will try again.', TransportError::TIMEOUT); + // The caller adds the next step, because only the caller knows if it is true. + $this->assertSame('Quant did not answer in time.', TransportError::TIMEOUT); + foreach ([TransportError::TIMEOUT, TransportError::DNS, TransportError::TLS, TransportError::CONNECTION, TransportError::UNAVAILABLE, TransportError::SERVER] as $reason) { + $this->assertStringNotContainsString('try again', $reason); + } + $this->assertSame('The plugin will try again.', TransportError::NEXT_AUTOMATIC); + $this->assertSame('Retry it from the queue.', TransportError::NEXT_MANUAL); + $this->assertSame('Test the connection again in a minute.', TransportError::NEXT_TEST_AGAIN); } #[Test] diff --git a/plugins/quant/tests/Api/WordPressHttpClientTest.php b/plugins/quant/tests/Api/WordPressHttpClientTest.php index e4bc5c4..13262c1 100644 --- a/plugins/quant/tests/Api/WordPressHttpClientTest.php +++ b/plugins/quant/tests/Api/WordPressHttpClientTest.php @@ -190,7 +190,7 @@ public function it_returns_a_short_message_for_a_transport_error_and_keeps_the_r $this->assertSame([ 'code' => 0, 'body' => '', - 'error' => 'Quant did not answer in time. The plugin will try again.', + 'error' => 'Quant did not answer in time.', 'detail' => "http_request_failed: {$raw}", 'transport' => true, ], $response); diff --git a/plugins/quant/tests/Sync/DatabaseQueueTest.php b/plugins/quant/tests/Sync/DatabaseQueueTest.php index c91b319..5184291 100644 --- a/plugins/quant/tests/Sync/DatabaseQueueTest.php +++ b/plugins/quant/tests/Sync/DatabaseQueueTest.php @@ -151,7 +151,7 @@ public function it_handles_an_empty_batch(): void public function it_claims_pending_jobs_atomically_and_returns_them_with_meta(): void { $rows = [ - ['job_id' => 'job-1', 'type' => 'taxonomy', 'object_id' => '7', 'action' => 'publish', 'created_at' => '2026-01-15 10:00:00', 'meta' => '{"taxonomy":"category","page":1}'], + ['job_id' => 'job-1', 'type' => 'taxonomy', 'object_id' => '7', 'action' => 'publish', 'created_at' => '2026-01-15 10:00:00', 'meta' => '{"taxonomy":"category","page":1}', 'attempts' => '1'], ['job_id' => 'job-2', 'type' => 'page', 'object_id' => '9', 'action' => 'unpublish', 'created_at' => '2026-01-15 10:01:00', 'meta' => null], ]; @@ -159,7 +159,7 @@ public function it_claims_pending_jobs_atomically_and_returns_them_with_meta(): ->with(Mockery::on(static fn(string $sql): bool => str_contains($sql, 'SET status = %s, claim = %s') && str_contains($sql, 'ORDER BY id ASC') && str_contains($sql, 'LIMIT %d'))) ->andReturn(2); $this->wpdb->shouldReceive('get_results')->once() - ->with(Mockery::on(static fn(string $sql): bool => str_contains($sql, 'WHERE claim = %s AND status = %s')), ARRAY_A) + ->with(Mockery::on(static fn(string $sql): bool => str_contains($sql, 'WHERE claim = %s AND status = %s') && str_contains($sql, 'meta, attempts')), ARRAY_A) ->andReturn($rows); $jobs = $this->queue->fetchPending(10); @@ -167,6 +167,8 @@ public function it_claims_pending_jobs_atomically_and_returns_them_with_meta(): $this->assertCount(2, $jobs); $this->assertSame(SyncType::Taxonomy, $jobs[0]->type); $this->assertSame(['taxonomy' => 'category', 'page' => 1], $jobs[0]->meta); + $this->assertSame(1, $jobs[0]->attempts); + $this->assertSame(0, $jobs[1]->attempts); $this->assertSame([], $jobs[1]->meta); $this->assertSame(SyncAction::Unpublish, $jobs[1]->action); } @@ -220,7 +222,7 @@ public function it_stores_the_short_message_and_the_detail_separately(): void $this->wpdb->shouldReceive('prepare')->once()->with( Mockery::type('string'), 'failed', - 'API error publishing /a: Quant did not answer in time. The plugin will try again.', + 'API error publishing /a: Quant did not answer in time. Retry it from the queue.', $detail, Mockery::type('string'), 'job-1', @@ -228,7 +230,7 @@ public function it_stores_the_short_message_and_the_detail_separately(): void )->andReturn('update_sql'); $this->wpdb->shouldReceive('query')->once()->with('update_sql'); - $this->queue->markFailed('job-1', 'API error publishing /a: Quant did not answer in time. The plugin will try again.', $detail); + $this->queue->markFailed('job-1', 'API error publishing /a: Quant did not answer in time. Retry it from the queue.', $detail); } #[Test] diff --git a/plugins/quant/tests/Sync/Handler/FileHandlerTest.php b/plugins/quant/tests/Sync/Handler/FileHandlerTest.php index 8d1cb52..ba2e987 100644 --- a/plugins/quant/tests/Sync/Handler/FileHandlerTest.php +++ b/plugins/quant/tests/Sync/Handler/FileHandlerTest.php @@ -115,14 +115,15 @@ public function it_carries_the_raw_detail_of_a_failed_media_upload(): void Functions\when('get_attached_file')->justReturn($this->file); Functions\when('wp_get_attachment_url')->justReturn('https://example.com/wp-content/uploads/doc.pdf'); Functions\when('wp_get_attachment_metadata')->justReturn([]); - $this->api->shouldReceive('uploadFile')->once()->andReturn(ApiResponse::error('Quant did not answer in time. The plugin will try again.', 0, [], 'raw transport text')); + $this->api->shouldReceive('uploadFile')->once()->andReturn(ApiResponse::error('Quant did not answer in time.', 0, [], 'raw transport text', true)); try { $this->handler->handle(new SyncJob(SyncType::Media, '9', SyncAction::Publish)); $this->fail('Expected an exception'); } catch (QuantApiException $e) { - $this->assertSame('Quant did not answer in time. The plugin will try again.', $e->getMessage()); + $this->assertSame('Quant did not answer in time.', $e->getMessage()); $this->assertSame('raw transport text', $e->detail); + $this->assertTrue($e->transient); } } diff --git a/plugins/quant/tests/Sync/JobProcessorTest.php b/plugins/quant/tests/Sync/JobProcessorTest.php index 5ff5a14..134b48e 100644 --- a/plugins/quant/tests/Sync/JobProcessorTest.php +++ b/plugins/quant/tests/Sync/JobProcessorTest.php @@ -11,6 +11,8 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use Quant\Core\Scheduler; +use Quant\Sync\DatabaseQueue; use Quant\Sync\Exception\QuantApiException; use Quant\Sync\Handler\HandlerInterface; use Quant\Sync\JobProcessor; @@ -108,27 +110,102 @@ public function it_runs_a_batch_marking_jobs_completed_or_failed(): void $this->assertSame(['post 2: HTTP 500'], $result->errors); } - #[Test] - public function it_stores_the_detail_of_an_api_failure_apart_from_the_message(): void + /** + * Run one job that fails with $error and return the message stored for it. + */ + private function failOnce(\Throwable $error, int $attempts, bool $background): string { $this->stubCommonWordPressFunctions(); - $job = new SyncJob(SyncType::Post, '7', SyncAction::Publish); - $handler = $this->handler(SyncType::Post, static function (): void { - throw new QuantApiException('API error publishing /a: Quant did not answer in time. The plugin will try again.', 'http_request_failed: cURL error 28: timed out'); + $job = new SyncJob(SyncType::Post, '7', SyncAction::Publish, [], 'job-7', null, $attempts); + $handler = $this->handler(SyncType::Post, static function () use ($error): void { + throw $error; }); + $stored = null; $this->queue->shouldReceive('recoverStale')->once(); $this->queue->shouldReceive('fetchPending')->once()->andReturn([$job]); - $this->queue->shouldReceive('markFailed')->once()->with( - $job->id, + $this->queue->shouldReceive('markFailed')->once()->andReturnUsing(static function (string $id, string $message, string $detail) use (&$stored): void { + $stored = [$id, $message, $detail]; + }); + Actions\expectDone('quant_sync_job_failed')->once(); + + $result = (new JobProcessor($this->queue, $this->mockSettings(), [$handler], static fn(): bool => $background))->run(20, 25); + + $this->assertSame('job-7', $stored[0]); + $this->assertSame(["post 7: {$stored[1]}"], $result->errors); + $this->assertSame($error instanceof QuantApiException ? $error->detail : '', $stored[2]); + + return $stored[1]; + } + + private function timeout(): QuantApiException + { + return new QuantApiException('API error publishing /a: Quant did not answer in time.', 'http_request_failed: cURL error 28: timed out', true); + } + + #[Test] + public function a_transient_failure_with_attempts_left_says_the_plugin_will_try_again(): void + { + $this->assertSame( 'API error publishing /a: Quant did not answer in time. The plugin will try again.', - 'http_request_failed: cURL error 28: timed out' + $this->failOnce($this->timeout(), 0, true) ); - Actions\expectDone('quant_sync_job_failed')->once(); + } - $result = (new JobProcessor($this->queue, $this->mockSettings(), [$handler]))->run(20, 25); + #[Test] + public function the_last_attempt_says_to_retry_from_the_queue(): void + { + // DatabaseQueue::retryDue only requeues while attempts < MAX_ATTEMPTS after this failure. + $this->assertSame( + 'API error publishing /a: Quant did not answer in time. Retry it from the queue.', + $this->failOnce($this->timeout(), DatabaseQueue::MAX_ATTEMPTS - 1, true) + ); + } + + #[Test] + public function without_background_processing_it_says_to_retry_from_the_queue(): void + { + $this->assertSame( + 'API error publishing /a: Quant did not answer in time. Retry it from the queue.', + $this->failOnce($this->timeout(), 0, false) + ); + } + + #[Test] + public function the_busy_message_follows_the_same_rule(): void + { + $busy = new QuantApiException('API error publishing /a: Quant is busy or unavailable (HTTP 503).', 'HTTP 503', true); + + $this->assertSame('API error publishing /a: Quant is busy or unavailable (HTTP 503). The plugin will try again.', $this->failOnce($busy, 1, true)); + } + + #[Test] + public function the_busy_message_on_the_last_attempt_says_to_retry_from_the_queue(): void + { + $busy = new QuantApiException('API error publishing /a: Quant is busy or unavailable (HTTP 503).', 'HTTP 503', true); + + $this->assertSame('API error publishing /a: Quant is busy or unavailable (HTTP 503). Retry it from the queue.', $this->failOnce($busy, 2, true)); + } - $this->assertSame(['post 7: API error publishing /a: Quant did not answer in time. The plugin will try again.'], $result->errors); + #[Test] + public function a_permanent_api_error_gets_no_next_step(): void + { + $this->assertSame('API error publishing /a: Invalid credentials', $this->failOnce(new QuantApiException('API error publishing /a: Invalid credentials'), 0, true)); + $this->assertSame('HTTP 404 response for /gone', $this->failOnceAgain(new RuntimeException('HTTP 404 response for /gone'))); + } + + private function failOnceAgain(\Throwable $error): string + { + $this->queue = Mockery::mock(SyncQueueInterface::class); + + return $this->failOnce($error, 0, true); + } + + #[Test] + public function background_processing_follows_disable_wp_cron(): void + { + // DISABLE_WP_CRON is not defined in the unit tests. + $this->assertTrue(Scheduler::backgroundProcessingEnabled()); } #[Test] diff --git a/plugins/quant/tests/Sync/PublisherTest.php b/plugins/quant/tests/Sync/PublisherTest.php index 108bc93..553200e 100644 --- a/plugins/quant/tests/Sync/PublisherTest.php +++ b/plugins/quant/tests/Sync/PublisherTest.php @@ -148,22 +148,23 @@ public function it_throws_when_the_api_rejects_the_content(): void public function it_carries_the_raw_detail_of_a_failed_publish(): void { $this->api->shouldReceive('publish')->once()->andReturn( - ApiResponse::error('Quant did not answer in time. The plugin will try again.', 0, [], 'http_request_failed: cURL error 28: timed out') + ApiResponse::error('Quant did not answer in time.', 0, [], 'http_request_failed: cURL error 28: timed out', true) ); try { $this->publisher()->publish(new RenderedContent('/x', 'y')); $this->fail('Expected an exception'); } catch (QuantApiException $e) { - $this->assertSame('API error publishing /x: Quant did not answer in time. The plugin will try again.', $e->getMessage()); + $this->assertSame('API error publishing /x: Quant did not answer in time.', $e->getMessage()); $this->assertSame('http_request_failed: cURL error 28: timed out', $e->detail); + $this->assertTrue($e->transient); } } #[Test] public function it_carries_the_details_of_failed_unpublishes(): void { - $this->api->shouldReceive('unpublish')->once()->with('/a')->andReturn(ApiResponse::error('busy', 503, [], 'HTTP 503: down')); + $this->api->shouldReceive('unpublish')->once()->with('/a')->andReturn(ApiResponse::error('busy', 503, [], 'HTTP 503: down', true)); $this->api->shouldReceive('unpublish')->once()->with('/b')->andReturn(ApiResponse::error('nope')); try { @@ -172,6 +173,7 @@ public function it_carries_the_details_of_failed_unpublishes(): void } catch (QuantApiException $e) { $this->assertSame('API error unpublishing: /a: busy; /b: nope', $e->getMessage()); $this->assertSame('/a: HTTP 503: down', $e->detail); + $this->assertTrue($e->transient); } } diff --git a/plugins/quant/tests/Sync/SyncJobTest.php b/plugins/quant/tests/Sync/SyncJobTest.php index 7a864c4..68fc2a1 100644 --- a/plugins/quant/tests/Sync/SyncJobTest.php +++ b/plugins/quant/tests/Sync/SyncJobTest.php @@ -55,9 +55,11 @@ public function it_restores_meta_from_a_database_row(): void 'action' => 'publish', 'created_at' => '2026-01-15 10:00:00', 'meta' => '{"to":"/new","code":301}', + 'attempts' => '2', ]); $this->assertSame('abc', $job->id); + $this->assertSame(2, $job->attempts); $this->assertSame(SyncType::Redirect, $job->type); $this->assertSame(['to' => '/new', 'code' => 301], $job->meta); $this->assertSame('2026-01-15 10:00:00', $job->createdAt->format('Y-m-d H:i:s')); diff --git a/tests/e2e/specs/transport-errors.spec.js b/tests/e2e/specs/transport-errors.spec.js index db092eb..96205ef 100644 --- a/tests/e2e/specs/transport-errors.spec.js +++ b/tests/e2e/specs/transport-errors.spec.js @@ -1,6 +1,6 @@ // @ts-check const { test, expect } = require('@playwright/test'); -const { resetPlugin, configurePlugin, mockRequests, mockFail, wp, postIdBySlug } = require('../lib/wp'); +const { resetPlugin, configurePlugin, mockRequests, mockFail, wp, wpEval, postIdBySlug } = require('../lib/wp'); const { processQueue, waitForMock } = require('../lib/queue'); const { gotoQuant, rest } = require('../lib/admin'); @@ -61,4 +61,21 @@ test.describe('Quant API stalls and gateway errors', () => { expect(content).toContain('Quant did not answer in time. The plugin will try again.'); expect(content).toMatch(/ {3}Detail: http_request_failed: cURL error 28: .* \(3 attempts\)/); }); + + test('on the last attempt the card says to retry from the queue', async ({ page }) => { + await mockFail({ path: '/v1', status: 503, times: 3 }); + await gotoQuant(page, 'content'); + await rest(page, '/sync', { method: 'POST', data: { items: [{ type: 'post', id: postIdBySlug('third-post') }], action: 'sync' } }); + // As if WP-Cron had already tried the job twice. + wpEval('global $wpdb; $wpdb->query("UPDATE {$wpdb->prefix}quant_sync_queue SET attempts = 2 WHERE status = \'pending\'");'); + processQueue(); + + expect(await publishAttempts('/third-post')).toBe(3); + const errors = await rest(page, '/queue/errors'); + expect(errors[0].error_message).toBe('API error publishing /third-post: Quant is busy or unavailable (HTTP 503). Retry it from the queue.'); + + await gotoQuant(page, 'dashboard'); + await expect(page.getByText('Quant is busy or unavailable (HTTP 503). Retry it from the queue.')).toBeVisible(); + await expect(page.getByText(/will try again/)).toHaveCount(0); + }); }); From 8662e84319191d0a953c3f70f1b51175e8694c4f Mon Sep 17 00:00:00 2001 From: Stuart Rowlands Date: Sun, 15 Nov 2026 16:15:48 +1000 Subject: [PATCH 4/6] fix(sync): name the Retry failed button in the manual next step The manual next step now reads "Select Retry failed on the dashboard.", the label of the dashboard button. The push progress card said "Retry Failed"; it now says "Retry failed" too, so the two controls match the message. --- plugins/quant/assets/build/index.asset.php | 2 +- plugins/quant/assets/build/index.js | 10 +++++----- .../quant/assets/src/components/Progress/Progress.jsx | 2 +- plugins/quant/readme.txt | 4 ++-- plugins/quant/src/Api/TransportError.php | 2 +- plugins/quant/tests/Api/TransportErrorTest.php | 2 +- plugins/quant/tests/Sync/DatabaseQueueTest.php | 4 ++-- plugins/quant/tests/Sync/JobProcessorTest.php | 6 +++--- tests/e2e/specs/transport-errors.spec.js | 6 +++--- 9 files changed, 19 insertions(+), 19 deletions(-) diff --git a/plugins/quant/assets/build/index.asset.php b/plugins/quant/assets/build/index.asset.php index 271cb98..f487136 100644 --- a/plugins/quant/assets/build/index.asset.php +++ b/plugins/quant/assets/build/index.asset.php @@ -1 +1 @@ - array('react', 'react-dom', 'wp-api-fetch', 'wp-components', 'wp-element', 'wp-i18n'), 'version' => '7b36526c62d4035cb023'); + array('react', 'react-dom', 'wp-api-fetch', 'wp-components', 'wp-element', 'wp-i18n'), 'version' => '44aa51464948046fb351'); diff --git a/plugins/quant/assets/build/index.js b/plugins/quant/assets/build/index.js index c5757c2..5a4feb6 100644 --- a/plugins/quant/assets/build/index.js +++ b/plugins/quant/assets/build/index.js @@ -1,4 +1,4 @@ -(()=>{"use strict";var e={n:t=>{var a=t&&t.__esModule?()=>t.default:()=>t;return e.d(a,{a}),a},d:(t,a)=>{for(var n in a)e.o(a,n)&&!e.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:a[n]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t)};const t=window.React,a=window.wp.element,n=window.ReactDOM;function l(){return l=Object.assign?Object.assign.bind():function(e){for(var t=1;t=0&&(t.hash=e.substr(a),e=e.substr(0,a));let n=e.indexOf("?");n>=0&&(t.search=e.substr(n),e=e.substr(0,n)),e&&(t.pathname=e)}return t}var p;function _(e,t,a){return void 0===a&&(a="/"),function(e,t,a,n){let l=N(("string"==typeof t?d(t):t).pathname||"/",a);if(null==l)return null;let r=h(e);!function(e){e.sort((e,t)=>e.score!==t.score?t.score-e.score:function(e,t){return e.length===t.length&&e.slice(0,-1).every((e,a)=>e===t[a])?e[e.length-1]-t[t.length-1]:0}(e.routesMeta.map(e=>e.childrenIndex),t.routesMeta.map(e=>e.childrenIndex)))}(r);let s=null,o=function(e){try{return e.split("/").map(e=>decodeURIComponent(e).replace(/\//g,"%2F")).join("/")}catch(t){return i(!1,'The URL path "'+e+'" could not be decoded because it is is a malformed URL segment. This is probably due to a bad percent encoding ('+t+")."),e}}(l);for(let e=0;null==s&&e{let s={relativePath:void 0===r?e.path||"":r,caseSensitive:!0===e.caseSensitive,childrenIndex:l,route:e};s.relativePath.startsWith("/")&&(o(s.relativePath.startsWith(n),'Absolute route path "'+s.relativePath+'" nested under path "'+n+'" is not valid. An absolute child route path must start with the combined path of all its parent routes.'),s.relativePath=s.relativePath.slice(n.length));let i=R([n,s.relativePath]),c=a.concat(s);e.children&&e.children.length>0&&(o(!0!==e.index,'Index routes must not have child routes. Please remove all child routes from route path "'+i+'".'),h(e.children,t,c,i)),(null!=e.path||e.index)&&t.push({path:i,score:x(i,e.index),routesMeta:c})};return e.forEach((e,t)=>{var a;if(""!==e.path&&null!=(a=e.path)&&a.includes("?"))for(let a of g(e.path))l(e,t,a);else l(e,t)}),t}function g(e){let t=e.split("/");if(0===t.length)return[];let[a,...n]=t,l=a.endsWith("?"),r=a.replace(/\?$/,"");if(0===n.length)return l?[r,""]:[r];let s=g(n.join("/")),o=[];return o.push(...s.map(e=>""===e?r:[r,e].join("/"))),l&&o.push(...s),o.map(t=>e.startsWith("/")&&""===t?"/":t)}!function(e){e.data="data",e.deferred="deferred",e.redirect="redirect",e.error="error"}(p||(p={})),new Set(["lazy","caseSensitive","path","id","index","children"]);const E=/^:[\w-]+$/,y=3,f=2,v=1,q=10,b=-2,C=e=>"*"===e;function x(e,t){let a=e.split("/"),n=a.length;return a.some(C)&&(n+=b),t&&(n+=f),a.filter(e=>!C(e)).reduce((e,t)=>e+(E.test(t)?y:""===t?v:q),n)}function w(e,t,a){void 0===a&&(a=!1);let{routesMeta:n}=e,l={},r="/",s=[];for(let e=0;e(n.push({paramName:t,isOptional:null!=a}),a?"/?([^\\/]+)?":"/([^\\/]+)"));return e.endsWith("*")?(n.push({paramName:"*"}),l+="*"===e||"/*"===e?"(.*)$":"(?:\\/(.+)|\\/*)$"):a?l+="\\/*$":""!==e&&"/"!==e&&(l+="(?:(?=\\/|$))"),[new RegExp(l,t?void 0:"i"),n]}(e.path,e.caseSensitive,e.end),l=t.match(a);if(!l)return null;let r=l[0],s=r.replace(/(.)\/+$/,"$1"),o=l.slice(1);return{params:n.reduce((e,t,a)=>{let{paramName:n,isOptional:l}=t;if("*"===n){let e=o[a]||"";s=r.slice(0,r.length-e.length).replace(/(.)\/+$/,"$1")}const i=o[a];return e[n]=l&&!i?void 0:(i||"").replace(/%2F/g,"/"),e},{}),pathname:r,pathnameBase:s,pattern:e}}function N(e,t){if("/"===t)return e;if(!e.toLowerCase().startsWith(t.toLowerCase()))return null;let a=t.endsWith("/")?t.length-1:t.length,n=e.charAt(a);return n&&"/"!==n?null:e.slice(a)||"/"}function k(e,t){let a=t.replace(/\/+$/,"").split("/");return e.split("/").forEach(e=>{".."===e?a.length>1&&a.pop():"."!==e&&a.push(e)}),a.length>1?a.join("/"):"/"}function T(e,t,a,n){return"Cannot include a '"+e+"' character in a manually specified `to."+t+"` field ["+JSON.stringify(n)+"]. Please separate it out to the `to."+a+'` field. Alternatively you may provide the full path as a string in and the router will parse it for you.'}function B(e,t){let a=function(e){return e.filter((e,t)=>0===t||e.route.path&&e.route.path.length>0)}(e);return t?a.map((e,t)=>t===a.length-1?e.pathname:e.pathnameBase):a.map(e=>e.pathnameBase)}function P(e,t,a,n){let r;void 0===n&&(n=!1),"string"==typeof e?r=d(e):(r=l({},e),o(!r.pathname||!r.pathname.includes("?"),T("?","pathname","search",r)),o(!r.pathname||!r.pathname.includes("#"),T("#","pathname","hash",r)),o(!r.search||!r.search.includes("#"),T("#","search","hash",r)));let s,i=""===e||""===r.pathname,c=i?"/":r.pathname;if(null==c)s=a;else{let e=t.length-1;if(!n&&c.startsWith("..")){let t=c.split("/");for(;".."===t[0];)t.shift(),e-=1;r.pathname=t.join("/")}s=e>=0?t[e]:"/"}let u=function(e,t){void 0===t&&(t="/");let a,{pathname:n,search:l="",hash:r=""}="string"==typeof e?d(e):e;return n?(n=D(n),a=n.startsWith("/")?k(n.substring(1),"/"):k(n,t)):a=t,{pathname:a,search:M(l),hash:A(r)}}(r,s),m=c&&"/"!==c&&c.endsWith("/"),p=(i||"."===c)&&a.endsWith("/");return u.pathname.endsWith("/")||!m&&!p||(u.pathname+="/"),u}const D=e=>e.replace(/\/\/+/g,"/"),R=e=>D(e.join("/")),j=e=>e.replace(/\/+$/,"").replace(/^\/*/,"/"),M=e=>e&&"?"!==e?e.startsWith("?")?e:"?"+e:"",A=e=>e&&"#"!==e?e.startsWith("#")?e:"#"+e:"";Error;const F=["post","put","patch","delete"],O=(new Set(F),["get",...F]);function H(){return H=Object.assign?Object.assign.bind():function(e){for(var t=1;t{n.current=!0}),t.useCallback(function(t,l){void 0===l&&(l={}),n.current&&("number"==typeof t?e.navigate(t):e.navigate(t,H({fromRouteId:a},l)))},[e,a])}():function(){W()||o(!1);let e=t.useContext($),{basename:a,future:n,navigator:l}=t.useContext(z),{matches:r}=t.useContext(L),{pathname:s}=Y(),i=JSON.stringify(B(r,n.v7_relativeSplatPath)),c=t.useRef(!1);return V(()=>{c.current=!0}),t.useCallback(function(t,n){if(void 0===n&&(n={}),!c.current)return;if("number"==typeof t)return void l.go(t);let r=P(t,JSON.parse(i),s,"path"===n.relative);null==e&&"/"!==a&&(r.pathname="/"===r.pathname?a:R([a,r.pathname])),(n.replace?l.replace:l.push)(r,n.state,n)},[a,l,i,s,e])}()}function J(e,a){let{relative:n}=void 0===a?{}:a,{future:l}=t.useContext(z),{matches:r}=t.useContext(L),{pathname:s}=Y(),o=JSON.stringify(B(r,l.v7_relativeSplatPath));return t.useMemo(()=>P(e,JSON.parse(o),s,"path"===n),[e,o,s,n])}function K(e,a,n,l){W()||o(!1);let{navigator:s}=t.useContext(z),{matches:i}=t.useContext(L),c=i[i.length-1],u=c?c.params:{},m=(c&&c.pathname,c?c.pathnameBase:"/");c&&c.route;let p,h=Y();if(a){var g;let e="string"==typeof a?d(a):a;"/"===m||(null==(g=e.pathname)?void 0:g.startsWith(m))||o(!1),p=e}else p=h;let E=p.pathname||"/",y=E;if("/"!==m){let e=m.replace(/^\//,"").split("/");y="/"+E.replace(/^\//,"").split("/").slice(e.length).join("/")}let f=_(e,{pathname:y}),v=function(e,a,n,l){var r;if(void 0===a&&(a=[]),void 0===n&&(n=null),void 0===l&&(l=null),null==e){var s;if(!n)return null;if(n.errors)e=n.matches;else{if(!(null!=(s=l)&&s.v7_partialHydration&&0===a.length&&!n.initialized&&n.matches.length>0))return null;e=n.matches}}let i=e,c=null==(r=n)?void 0:r.errors;if(null!=c){let e=i.findIndex(e=>e.route.id&&void 0!==(null==c?void 0:c[e.route.id]));e>=0||o(!1),i=i.slice(0,Math.min(i.length,e+1))}let u=!1,m=-1;if(n&&l&&l.v7_partialHydration)for(let e=0;e=0?i.slice(0,m+1):[i[0]];break}}}return i.reduceRight((e,l,r)=>{let s,o=!1,d=null,p=null;var _;n&&(s=c&&l.route.id?c[l.route.id]:void 0,d=l.route.errorElement||Z,u&&(m<0&&0===r?(re[_="route-fallback"]||(re[_]=!0),o=!0,p=null):m===r&&(o=!0,p=l.route.hydrateFallbackElement||null)));let h=a.concat(i.slice(0,r+1)),g=()=>{let a;return a=s?d:o?p:l.route.Component?t.createElement(l.route.Component,null):l.route.element?l.route.element:e,t.createElement(te,{match:l,routeContext:{outlet:e,matches:h,isDataRoute:null!=n},children:a})};return n&&(l.route.ErrorBoundary||l.route.errorElement||0===r)?t.createElement(ee,{location:n.location,revalidation:n.revalidation,component:d,error:s,children:g(),routeContext:{outlet:null,matches:h,isDataRoute:!0}}):g()},null)}(f&&f.map(e=>Object.assign({},e,{params:Object.assign({},u,e.params),pathname:R([m,s.encodeLocation?s.encodeLocation(e.pathname).pathname:e.pathname]),pathnameBase:"/"===e.pathnameBase?m:R([m,s.encodeLocation?s.encodeLocation(e.pathnameBase).pathname:e.pathnameBase])})),i,n,l);return a&&v?t.createElement(Q.Provider,{value:{location:H({pathname:"/",search:"",hash:"",state:null,key:"default"},p),navigationType:r.Pop}},v):v}function X(){let e=function(){var e;let a=t.useContext(U),n=function(){let e=t.useContext(I);return e||o(!1),e}(ne.UseRouteError),l=le(ne.UseRouteError);return void 0!==a?a:null==(e=n.errors)?void 0:e[l]}(),a=function(e){return null!=e&&"number"==typeof e.status&&"string"==typeof e.statusText&&"boolean"==typeof e.internal&&"data"in e}(e)?e.status+" "+e.statusText:e instanceof Error?e.message:JSON.stringify(e),n=e instanceof Error?e.stack:null,l={padding:"0.5rem",backgroundColor:"rgba(200,200,200, 0.5)"};return t.createElement(t.Fragment,null,t.createElement("h2",null,"Unexpected Application Error!"),t.createElement("h3",{style:{fontStyle:"italic"}},a),n?t.createElement("pre",{style:l},n):null,null)}const Z=t.createElement(X,null);class ee extends t.Component{constructor(e){super(e),this.state={location:e.location,revalidation:e.revalidation,error:e.error}}static getDerivedStateFromError(e){return{error:e}}static getDerivedStateFromProps(e,t){return t.location!==e.location||"idle"!==t.revalidation&&"idle"===e.revalidation?{error:e.error,location:e.location,revalidation:e.revalidation}:{error:void 0!==e.error?e.error:t.error,location:t.location,revalidation:e.revalidation||t.revalidation}}componentDidCatch(e,t){console.error("React Router caught the following error during render",e,t)}render(){return void 0!==this.state.error?t.createElement(L.Provider,{value:this.props.routeContext},t.createElement(U.Provider,{value:this.state.error,children:this.props.component})):this.props.children}}function te(e){let{routeContext:a,match:n,children:l}=e,r=t.useContext($);return r&&r.static&&r.staticContext&&(n.route.errorElement||n.route.ErrorBoundary)&&(r.staticContext._deepestRenderedBoundaryId=n.route.id),t.createElement(L.Provider,{value:a},l)}var ae=function(e){return e.UseBlocker="useBlocker",e.UseRevalidator="useRevalidator",e.UseNavigateStable="useNavigate",e}(ae||{}),ne=function(e){return e.UseBlocker="useBlocker",e.UseLoaderData="useLoaderData",e.UseActionData="useActionData",e.UseRouteError="useRouteError",e.UseNavigation="useNavigation",e.UseRouteLoaderData="useRouteLoaderData",e.UseMatches="useMatches",e.UseRevalidator="useRevalidator",e.UseNavigateStable="useNavigate",e.UseRouteId="useRouteId",e}(ne||{});function le(e){let a=function(){let e=t.useContext(L);return e||o(!1),e}(),n=a.matches[a.matches.length-1];return n.route.id||o(!1),n.route.id}const re={};function se(e){let{to:a,replace:n,state:l,relative:r}=e;W()||o(!1);let{future:s,static:i}=t.useContext(z),{matches:c}=t.useContext(L),{pathname:u}=Y(),m=G(),d=P(a,B(c,s.v7_relativeSplatPath),u,"path"===r),p=JSON.stringify(d);return t.useEffect(()=>m(JSON.parse(p),{replace:n,state:l,relative:r}),[m,p,r,n,l]),null}function oe(e){o(!1)}function ie(e){let{basename:a="/",children:n=null,location:l,navigationType:s=r.Pop,navigator:i,static:c=!1,future:u}=e;W()&&o(!1);let m=a.replace(/^\/*/,"/"),p=t.useMemo(()=>({basename:m,navigator:i,static:c,future:H({v7_relativeSplatPath:!1},u)}),[m,u,i,c]);"string"==typeof l&&(l=d(l));let{pathname:_="/",search:h="",hash:g="",state:E=null,key:y="default"}=l,f=t.useMemo(()=>{let e=N(_,m);return null==e?null:{location:{pathname:e,search:h,hash:g,state:E,key:y},navigationType:s}},[m,_,h,g,E,y,s]);return null==f?null:t.createElement(z.Provider,{value:p},t.createElement(Q.Provider,{children:n,value:f}))}function ce(e){let{children:t,location:a}=e;return K(ue(t),a)}function ue(e,a){void 0===a&&(a=[]);let n=[];return t.Children.forEach(e,(e,l)=>{if(!t.isValidElement(e))return;let r=[...a,l];if(e.type===t.Fragment)return void n.push.apply(n,ue(e.props.children,r));e.type!==oe&&o(!1),e.props.index&&e.props.children&&o(!1);let s={id:e.props.id||r.join("-"),caseSensitive:e.props.caseSensitive,element:e.props.element,Component:e.props.Component,index:e.props.index,path:e.props.path,loader:e.props.loader,action:e.props.action,errorElement:e.props.errorElement,ErrorBoundary:e.props.ErrorBoundary,hasErrorBoundary:null!=e.props.ErrorBoundary||null!=e.props.errorElement,shouldRevalidate:e.props.shouldRevalidate,handle:e.props.handle,lazy:e.props.lazy};e.props.children&&(s.children=ue(e.props.children,r)),n.push(s)}),n}function me(){return me=Object.assign?Object.assign.bind():function(e){for(var t=1;t{}),t.Component,new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);const pe=["onClick","relative","reloadDocument","replace","state","target","to","preventScrollReset","viewTransition"],_e=["aria-current","caseSensitive","className","end","style","to","viewTransition","children"];try{window.__reactRouterVersion="6"}catch(e){}const he=t.createContext({isTransitioning:!1});new Map;const ge=t.startTransition;function Ee(e){let{basename:a,children:n,future:p,window:_}=e,h=t.useRef();var g;null==h.current&&(h.current=(void 0===(g={window:_,v5Compat:!0})&&(g={}),function(e,t,a,n){void 0===n&&(n={});let{window:i=document.defaultView,v5Compat:d=!1}=n,p=i.history,_=r.Pop,h=null,g=E();function E(){return(p.state||{idx:null}).idx}function y(){_=r.Pop;let e=E(),t=null==e?null:e-g;g=e,h&&h({action:_,location:v.location,delta:t})}function f(e){let t="null"!==i.location.origin?i.location.origin:i.location.href,a="string"==typeof e?e:m(e);return a=a.replace(/ $/,"%20"),o(t,"No window.location.(origin|href) available to create URL for href: "+a),new URL(a,t)}null==g&&(g=0,p.replaceState(l({},p.state,{idx:g}),""));let v={get action(){return _},get location(){return e(i,p)},listen(e){if(h)throw new Error("A history only accepts one active listener");return i.addEventListener(s,y),h=e,()=>{i.removeEventListener(s,y),h=null}},createHref:e=>t(i,e),createURL:f,encodeLocation(e){let t=f(e);return{pathname:t.pathname,search:t.search,hash:t.hash}},push:function(e,t){_=r.Push;let n=u(v.location,e,t);a&&a(n,e),g=E()+1;let l=c(n,g),s=v.createHref(n);try{p.pushState(l,"",s)}catch(e){if(e instanceof DOMException&&"DataCloneError"===e.name)throw e;i.location.assign(s)}d&&h&&h({action:_,location:v.location,delta:1})},replace:function(e,t){_=r.Replace;let n=u(v.location,e,t);a&&a(n,e),g=E();let l=c(n,g),s=v.createHref(n);p.replaceState(l,"",s),d&&h&&h({action:_,location:v.location,delta:0})},go:e=>p.go(e)};return v}(function(e,t){let{pathname:a="/",search:n="",hash:l=""}=d(e.location.hash.substr(1));return a.startsWith("/")||a.startsWith(".")||(a="/"+a),u("",{pathname:a,search:n,hash:l},t.state&&t.state.usr||null,t.state&&t.state.key||"default")},function(e,t){let a=e.document.querySelector("base"),n="";if(a&&a.getAttribute("href")){let t=e.location.href,a=t.indexOf("#");n=-1===a?t:t.slice(0,a)}return n+"#"+("string"==typeof t?t:m(t))},function(e,t){i("/"===e.pathname.charAt(0),"relative pathnames are not supported in hash history.push("+JSON.stringify(t)+")")},g)));let E=h.current,[y,f]=t.useState({action:E.action,location:E.location}),{v7_startTransition:v}=p||{},q=t.useCallback(e=>{v&&ge?ge(()=>f(e)):f(e)},[f,v]);return t.useLayoutEffect(()=>E.listen(q),[E,q]),t.useEffect(()=>{return null==(e=p)||e.v7_startTransition,void 0===(null==e?void 0:e.v7_relativeSplatPath)&&(!t||t.v7_relativeSplatPath),void(t&&(t.v7_fetcherPersist,t.v7_normalizeFormMethod,t.v7_partialHydration,t.v7_skipActionErrorRevalidation));var e,t},[p]),t.createElement(ie,{basename:a,children:n,location:y.location,navigationType:y.action,navigator:E,future:p})}n.flushSync,t.useId;const ye="undefined"!=typeof window&&void 0!==window.document&&void 0!==window.document.createElement,fe=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,ve=t.forwardRef(function(e,a){let n,{onClick:l,relative:r,reloadDocument:s,replace:i,state:c,target:u,to:d,preventScrollReset:p,viewTransition:_}=e,h=de(e,pe),{basename:g}=t.useContext(z),E=!1;if("string"==typeof d&&fe.test(d)&&(n=d,ye))try{let e=new URL(window.location.href),t=d.startsWith("//")?new URL(e.protocol+d):new URL(d),a=N(t.pathname,g);t.origin===e.origin&&null!=a?d=a+t.search+t.hash:E=!0}catch(e){}let y=function(e,a){let{relative:n}=void 0===a?{}:a;W()||o(!1);let{basename:l,navigator:r}=t.useContext(z),{hash:s,pathname:i,search:c}=J(e,{relative:n}),u=i;return"/"!==l&&(u="/"===i?l:R([l,i])),r.createHref({pathname:u,search:c,hash:s})}(d,{relative:r}),f=function(e,a){let{target:n,replace:l,state:r,preventScrollReset:s,relative:o,viewTransition:i}=void 0===a?{}:a,c=G(),u=Y(),d=J(e,{relative:o});return t.useCallback(t=>{if(function(e,t){return!(0!==e.button||t&&"_self"!==t||function(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}(e))}(t,n)){t.preventDefault();let a=void 0!==l?l:m(u)===m(d);c(e,{replace:a,state:r,preventScrollReset:s,relative:o,viewTransition:i})}},[u,c,d,l,r,n,e,s,o,i])}(d,{replace:i,state:c,target:u,preventScrollReset:p,relative:r,viewTransition:_});return t.createElement("a",me({},h,{href:n||y,onClick:E||s?l:function(e){l&&l(e),e.defaultPrevented||f(e)},ref:a,target:u}))}),qe=t.forwardRef(function(e,a){let{"aria-current":n="page",caseSensitive:l=!1,className:r="",end:s=!1,style:i,to:c,viewTransition:u,children:m}=e,d=de(e,_e),p=J(c,{relative:d.relative}),_=Y(),h=t.useContext(I),{navigator:g,basename:E}=t.useContext(z),y=null!=h&&function(e,a){void 0===a&&(a={});let n=t.useContext(he);null==n&&o(!1);let{basename:l}=function(){let e=t.useContext($);return e||o(!1),e}(be.useViewTransitionState),r=J(e,{relative:a.relative});if(!n.isTransitioning)return!1;let s=N(n.currentLocation.pathname,l)||n.currentLocation.pathname,i=N(n.nextLocation.pathname,l)||n.nextLocation.pathname;return null!=S(r.pathname,i)||null!=S(r.pathname,s)}(p)&&!0===u,f=g.encodeLocation?g.encodeLocation(p).pathname:p.pathname,v=_.pathname,q=h&&h.navigation&&h.navigation.location?h.navigation.location.pathname:null;l||(v=v.toLowerCase(),q=q?q.toLowerCase():null,f=f.toLowerCase()),q&&E&&(q=N(q,E)||q);const b="/"!==f&&f.endsWith("/")?f.length-1:f.length;let C,x=v===f||!s&&v.startsWith(f)&&"/"===v.charAt(b),w=null!=q&&(q===f||!s&&q.startsWith(f)&&"/"===q.charAt(f.length)),k={isActive:x,isPending:w,isTransitioning:y},T=x?n:void 0;C="function"==typeof r?r(k):[r,x?"active":null,w?"pending":null,y?"transitioning":null].filter(Boolean).join(" ");let B="function"==typeof i?i(k):i;return t.createElement(ve,me({},d,{"aria-current":T,className:C,ref:a,style:B,to:c,viewTransition:u}),"function"==typeof m?m(k):m)});var be,Ce;(function(e){e.UseScrollRestoration="useScrollRestoration",e.UseSubmit="useSubmit",e.UseSubmitFetcher="useSubmitFetcher",e.UseFetcher="useFetcher",e.useViewTransitionState="useViewTransitionState"})(be||(be={})),function(e){e.UseFetcher="useFetcher",e.UseFetchers="useFetchers",e.UseScrollRestoration="useScrollRestoration"}(Ce||(Ce={}));const xe=window.wp.i18n;function we({children:e}){return(0,t.createElement)("div",{className:"quant-admin"},(0,t.createElement)("header",{className:"quant-admin__header"},(0,t.createElement)("div",{className:"quant-admin__logo"},(0,t.createElement)("svg",{width:"32",height:"32",viewBox:"0 0 32 32",fill:"none"},(0,t.createElement)("rect",{width:"32",height:"32",rx:"6",fill:"#5c40ff"}),(0,t.createElement)("text",{x:"50%",y:"55%",dominantBaseline:"middle",textAnchor:"middle",fill:"white",fontSize:"16",fontWeight:"bold"},"Q")),(0,t.createElement)("span",null,"QuantCDN")),(0,t.createElement)("div",{className:"quant-admin__version"},"v",window.quantAdmin?.version||"2.0.0")),(0,t.createElement)("div",{className:"quant-admin__body"},(0,t.createElement)("nav",{className:"quant-admin__sidebar"},(0,t.createElement)("ul",null,(0,t.createElement)("li",null,(0,t.createElement)(qe,{to:"/dashboard"},(0,t.createElement)("span",{className:"dashicons dashicons-dashboard"}),(0,xe.__)("Dashboard","quant"))),(0,t.createElement)("li",null,(0,t.createElement)(qe,{to:"/content"},(0,t.createElement)("span",{className:"dashicons dashicons-media-text"}),(0,xe.__)("Content","quant"))),(0,t.createElement)("li",null,(0,t.createElement)(qe,{to:"/search"},(0,t.createElement)("span",{className:"dashicons dashicons-search"}),(0,xe.__)("Search","quant"))),(0,t.createElement)("li",null,(0,t.createElement)(qe,{to:"/cache"},(0,t.createElement)("span",{className:"dashicons dashicons-trash"}),(0,xe.__)("Cache","quant"))),(0,t.createElement)("li",null,(0,t.createElement)(qe,{to:"/settings"},(0,t.createElement)("span",{className:"dashicons dashicons-admin-settings"}),(0,xe.__)("Settings","quant"))),(0,t.createElement)("li",null,(0,t.createElement)(qe,{to:"/diagnostics"},(0,t.createElement)("span",{className:"dashicons dashicons-info-outline"}),(0,xe.__)("Diagnostics","quant"))))),(0,t.createElement)("main",{className:"quant-admin__content"},e)))}const Se=window.wp.components,Ne=window.wp.apiFetch;var ke=e.n(Ne);function Te({label:e,value:a,tone:n}){return(0,t.createElement)("div",{className:`quant-stat quant-stat--${n||"neutral"}`},(0,t.createElement)("div",{className:"quant-stat__value"},a),(0,t.createElement)("div",{className:"quant-stat__label"},e))}function Be(){var e,n,l,r;const s=G(),[o,i]=(0,a.useState)(null),[c,u]=(0,a.useState)([]),[m,d]=(0,a.useState)(!1),[p,_]=(0,a.useState)(null),[h,g]=(0,a.useState)(null),E=(0,a.useCallback)(async()=>{try{const[e,t]=await Promise.all([ke()({path:"/quant/v1/status"}),ke()({path:"/quant/v1/queue/errors"})]);i(e),u(t),g(null)}catch(e){g(e.message||(0,xe.__)("Failed to load the plugin status.","quant")),i({connected:!1,configured:!1,mode:"static",queue:{}})}},[]);if((0,a.useEffect)(()=>{E()},[E]),(0,a.useEffect)(()=>{!1===o?.configured&&ke()({path:"/quant/v1/onboarding/status"}).then(e=>{"onboarding"===e.state?s("/onboarding"):"migration"===e.state&&s("/migration")}).catch(()=>{})},[o,s]),!o)return(0,t.createElement)("div",{className:"quant-dashboard"},(0,t.createElement)("h1",null,(0,xe.__)("Dashboard","quant")),(0,t.createElement)(Se.Spinner,null));const y=o.queue||{},f="static"===o.mode;return(0,t.createElement)("div",{className:"quant-dashboard"},(0,t.createElement)("h1",null,(0,xe.__)("Dashboard","quant")),h&&(0,t.createElement)(Se.Notice,{status:"error",isDismissible:!1},h),p&&(0,t.createElement)(Se.Notice,{status:p.status,onRemove:()=>_(null),isDismissible:!0},p.message),o.cron_disabled&&y.pending>0&&(0,t.createElement)(Se.Notice,{status:"warning",isDismissible:!1},(0,xe.__)('WP-Cron is disabled on this site. Pending items are only processed by "Process Queue", a system cron that calls wp-cron.php, or WP-CLI.',"quant")),(0,t.createElement)("div",{className:"quant-dashboard__cards"},(0,t.createElement)(Se.Card,null,(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Connection","quant"))),(0,t.createElement)(Se.CardBody,null,o.connected?(0,t.createElement)("p",{className:"quant-status--success"},(0,xe.__)("Connected to Quant","quant"),o.search_enabled?" · "+(0,xe.__)("Search enabled","quant"):""):(0,t.createElement)("p",{className:"quant-status--error"},o.configured?(0,xe.sprintf)(/* translators: %s: error message */ /* translators: %s: error message */ +(()=>{"use strict";var e={n:t=>{var a=t&&t.__esModule?()=>t.default:()=>t;return e.d(a,{a}),a},d:(t,a)=>{for(var n in a)e.o(a,n)&&!e.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:a[n]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t)};const t=window.React,a=window.wp.element,n=window.ReactDOM;function l(){return l=Object.assign?Object.assign.bind():function(e){for(var t=1;t=0&&(t.hash=e.substr(a),e=e.substr(0,a));let n=e.indexOf("?");n>=0&&(t.search=e.substr(n),e=e.substr(0,n)),e&&(t.pathname=e)}return t}var p;function _(e,t,a){return void 0===a&&(a="/"),function(e,t,a,n){let l=N(("string"==typeof t?d(t):t).pathname||"/",a);if(null==l)return null;let r=h(e);!function(e){e.sort((e,t)=>e.score!==t.score?t.score-e.score:function(e,t){return e.length===t.length&&e.slice(0,-1).every((e,a)=>e===t[a])?e[e.length-1]-t[t.length-1]:0}(e.routesMeta.map(e=>e.childrenIndex),t.routesMeta.map(e=>e.childrenIndex)))}(r);let s=null,o=function(e){try{return e.split("/").map(e=>decodeURIComponent(e).replace(/\//g,"%2F")).join("/")}catch(t){return i(!1,'The URL path "'+e+'" could not be decoded because it is is a malformed URL segment. This is probably due to a bad percent encoding ('+t+")."),e}}(l);for(let e=0;null==s&&e{let s={relativePath:void 0===r?e.path||"":r,caseSensitive:!0===e.caseSensitive,childrenIndex:l,route:e};s.relativePath.startsWith("/")&&(o(s.relativePath.startsWith(n),'Absolute route path "'+s.relativePath+'" nested under path "'+n+'" is not valid. An absolute child route path must start with the combined path of all its parent routes.'),s.relativePath=s.relativePath.slice(n.length));let i=R([n,s.relativePath]),c=a.concat(s);e.children&&e.children.length>0&&(o(!0!==e.index,'Index routes must not have child routes. Please remove all child routes from route path "'+i+'".'),h(e.children,t,c,i)),(null!=e.path||e.index)&&t.push({path:i,score:x(i,e.index),routesMeta:c})};return e.forEach((e,t)=>{var a;if(""!==e.path&&null!=(a=e.path)&&a.includes("?"))for(let a of g(e.path))l(e,t,a);else l(e,t)}),t}function g(e){let t=e.split("/");if(0===t.length)return[];let[a,...n]=t,l=a.endsWith("?"),r=a.replace(/\?$/,"");if(0===n.length)return l?[r,""]:[r];let s=g(n.join("/")),o=[];return o.push(...s.map(e=>""===e?r:[r,e].join("/"))),l&&o.push(...s),o.map(t=>e.startsWith("/")&&""===t?"/":t)}!function(e){e.data="data",e.deferred="deferred",e.redirect="redirect",e.error="error"}(p||(p={})),new Set(["lazy","caseSensitive","path","id","index","children"]);const E=/^:[\w-]+$/,y=3,f=2,v=1,q=10,b=-2,C=e=>"*"===e;function x(e,t){let a=e.split("/"),n=a.length;return a.some(C)&&(n+=b),t&&(n+=f),a.filter(e=>!C(e)).reduce((e,t)=>e+(E.test(t)?y:""===t?v:q),n)}function w(e,t,a){void 0===a&&(a=!1);let{routesMeta:n}=e,l={},r="/",s=[];for(let e=0;e(n.push({paramName:t,isOptional:null!=a}),a?"/?([^\\/]+)?":"/([^\\/]+)"));return e.endsWith("*")?(n.push({paramName:"*"}),l+="*"===e||"/*"===e?"(.*)$":"(?:\\/(.+)|\\/*)$"):a?l+="\\/*$":""!==e&&"/"!==e&&(l+="(?:(?=\\/|$))"),[new RegExp(l,t?void 0:"i"),n]}(e.path,e.caseSensitive,e.end),l=t.match(a);if(!l)return null;let r=l[0],s=r.replace(/(.)\/+$/,"$1"),o=l.slice(1);return{params:n.reduce((e,t,a)=>{let{paramName:n,isOptional:l}=t;if("*"===n){let e=o[a]||"";s=r.slice(0,r.length-e.length).replace(/(.)\/+$/,"$1")}const i=o[a];return e[n]=l&&!i?void 0:(i||"").replace(/%2F/g,"/"),e},{}),pathname:r,pathnameBase:s,pattern:e}}function N(e,t){if("/"===t)return e;if(!e.toLowerCase().startsWith(t.toLowerCase()))return null;let a=t.endsWith("/")?t.length-1:t.length,n=e.charAt(a);return n&&"/"!==n?null:e.slice(a)||"/"}function k(e,t){let a=t.replace(/\/+$/,"").split("/");return e.split("/").forEach(e=>{".."===e?a.length>1&&a.pop():"."!==e&&a.push(e)}),a.length>1?a.join("/"):"/"}function T(e,t,a,n){return"Cannot include a '"+e+"' character in a manually specified `to."+t+"` field ["+JSON.stringify(n)+"]. Please separate it out to the `to."+a+'` field. Alternatively you may provide the full path as a string in and the router will parse it for you.'}function B(e,t){let a=function(e){return e.filter((e,t)=>0===t||e.route.path&&e.route.path.length>0)}(e);return t?a.map((e,t)=>t===a.length-1?e.pathname:e.pathnameBase):a.map(e=>e.pathnameBase)}function P(e,t,a,n){let r;void 0===n&&(n=!1),"string"==typeof e?r=d(e):(r=l({},e),o(!r.pathname||!r.pathname.includes("?"),T("?","pathname","search",r)),o(!r.pathname||!r.pathname.includes("#"),T("#","pathname","hash",r)),o(!r.search||!r.search.includes("#"),T("#","search","hash",r)));let s,i=""===e||""===r.pathname,c=i?"/":r.pathname;if(null==c)s=a;else{let e=t.length-1;if(!n&&c.startsWith("..")){let t=c.split("/");for(;".."===t[0];)t.shift(),e-=1;r.pathname=t.join("/")}s=e>=0?t[e]:"/"}let u=function(e,t){void 0===t&&(t="/");let a,{pathname:n,search:l="",hash:r=""}="string"==typeof e?d(e):e;return n?(n=D(n),a=n.startsWith("/")?k(n.substring(1),"/"):k(n,t)):a=t,{pathname:a,search:M(l),hash:A(r)}}(r,s),m=c&&"/"!==c&&c.endsWith("/"),p=(i||"."===c)&&a.endsWith("/");return u.pathname.endsWith("/")||!m&&!p||(u.pathname+="/"),u}const D=e=>e.replace(/\/\/+/g,"/"),R=e=>D(e.join("/")),j=e=>e.replace(/\/+$/,"").replace(/^\/*/,"/"),M=e=>e&&"?"!==e?e.startsWith("?")?e:"?"+e:"",A=e=>e&&"#"!==e?e.startsWith("#")?e:"#"+e:"";Error;const O=["post","put","patch","delete"],F=(new Set(O),["get",...O]);function H(){return H=Object.assign?Object.assign.bind():function(e){for(var t=1;t{n.current=!0}),t.useCallback(function(t,l){void 0===l&&(l={}),n.current&&("number"==typeof t?e.navigate(t):e.navigate(t,H({fromRouteId:a},l)))},[e,a])}():function(){W()||o(!1);let e=t.useContext($),{basename:a,future:n,navigator:l}=t.useContext(z),{matches:r}=t.useContext(L),{pathname:s}=Y(),i=JSON.stringify(B(r,n.v7_relativeSplatPath)),c=t.useRef(!1);return V(()=>{c.current=!0}),t.useCallback(function(t,n){if(void 0===n&&(n={}),!c.current)return;if("number"==typeof t)return void l.go(t);let r=P(t,JSON.parse(i),s,"path"===n.relative);null==e&&"/"!==a&&(r.pathname="/"===r.pathname?a:R([a,r.pathname])),(n.replace?l.replace:l.push)(r,n.state,n)},[a,l,i,s,e])}()}function J(e,a){let{relative:n}=void 0===a?{}:a,{future:l}=t.useContext(z),{matches:r}=t.useContext(L),{pathname:s}=Y(),o=JSON.stringify(B(r,l.v7_relativeSplatPath));return t.useMemo(()=>P(e,JSON.parse(o),s,"path"===n),[e,o,s,n])}function K(e,a,n,l){W()||o(!1);let{navigator:s}=t.useContext(z),{matches:i}=t.useContext(L),c=i[i.length-1],u=c?c.params:{},m=(c&&c.pathname,c?c.pathnameBase:"/");c&&c.route;let p,h=Y();if(a){var g;let e="string"==typeof a?d(a):a;"/"===m||(null==(g=e.pathname)?void 0:g.startsWith(m))||o(!1),p=e}else p=h;let E=p.pathname||"/",y=E;if("/"!==m){let e=m.replace(/^\//,"").split("/");y="/"+E.replace(/^\//,"").split("/").slice(e.length).join("/")}let f=_(e,{pathname:y}),v=function(e,a,n,l){var r;if(void 0===a&&(a=[]),void 0===n&&(n=null),void 0===l&&(l=null),null==e){var s;if(!n)return null;if(n.errors)e=n.matches;else{if(!(null!=(s=l)&&s.v7_partialHydration&&0===a.length&&!n.initialized&&n.matches.length>0))return null;e=n.matches}}let i=e,c=null==(r=n)?void 0:r.errors;if(null!=c){let e=i.findIndex(e=>e.route.id&&void 0!==(null==c?void 0:c[e.route.id]));e>=0||o(!1),i=i.slice(0,Math.min(i.length,e+1))}let u=!1,m=-1;if(n&&l&&l.v7_partialHydration)for(let e=0;e=0?i.slice(0,m+1):[i[0]];break}}}return i.reduceRight((e,l,r)=>{let s,o=!1,d=null,p=null;var _;n&&(s=c&&l.route.id?c[l.route.id]:void 0,d=l.route.errorElement||Z,u&&(m<0&&0===r?(re[_="route-fallback"]||(re[_]=!0),o=!0,p=null):m===r&&(o=!0,p=l.route.hydrateFallbackElement||null)));let h=a.concat(i.slice(0,r+1)),g=()=>{let a;return a=s?d:o?p:l.route.Component?t.createElement(l.route.Component,null):l.route.element?l.route.element:e,t.createElement(te,{match:l,routeContext:{outlet:e,matches:h,isDataRoute:null!=n},children:a})};return n&&(l.route.ErrorBoundary||l.route.errorElement||0===r)?t.createElement(ee,{location:n.location,revalidation:n.revalidation,component:d,error:s,children:g(),routeContext:{outlet:null,matches:h,isDataRoute:!0}}):g()},null)}(f&&f.map(e=>Object.assign({},e,{params:Object.assign({},u,e.params),pathname:R([m,s.encodeLocation?s.encodeLocation(e.pathname).pathname:e.pathname]),pathnameBase:"/"===e.pathnameBase?m:R([m,s.encodeLocation?s.encodeLocation(e.pathnameBase).pathname:e.pathnameBase])})),i,n,l);return a&&v?t.createElement(Q.Provider,{value:{location:H({pathname:"/",search:"",hash:"",state:null,key:"default"},p),navigationType:r.Pop}},v):v}function X(){let e=function(){var e;let a=t.useContext(U),n=function(){let e=t.useContext(I);return e||o(!1),e}(ne.UseRouteError),l=le(ne.UseRouteError);return void 0!==a?a:null==(e=n.errors)?void 0:e[l]}(),a=function(e){return null!=e&&"number"==typeof e.status&&"string"==typeof e.statusText&&"boolean"==typeof e.internal&&"data"in e}(e)?e.status+" "+e.statusText:e instanceof Error?e.message:JSON.stringify(e),n=e instanceof Error?e.stack:null,l={padding:"0.5rem",backgroundColor:"rgba(200,200,200, 0.5)"};return t.createElement(t.Fragment,null,t.createElement("h2",null,"Unexpected Application Error!"),t.createElement("h3",{style:{fontStyle:"italic"}},a),n?t.createElement("pre",{style:l},n):null,null)}const Z=t.createElement(X,null);class ee extends t.Component{constructor(e){super(e),this.state={location:e.location,revalidation:e.revalidation,error:e.error}}static getDerivedStateFromError(e){return{error:e}}static getDerivedStateFromProps(e,t){return t.location!==e.location||"idle"!==t.revalidation&&"idle"===e.revalidation?{error:e.error,location:e.location,revalidation:e.revalidation}:{error:void 0!==e.error?e.error:t.error,location:t.location,revalidation:e.revalidation||t.revalidation}}componentDidCatch(e,t){console.error("React Router caught the following error during render",e,t)}render(){return void 0!==this.state.error?t.createElement(L.Provider,{value:this.props.routeContext},t.createElement(U.Provider,{value:this.state.error,children:this.props.component})):this.props.children}}function te(e){let{routeContext:a,match:n,children:l}=e,r=t.useContext($);return r&&r.static&&r.staticContext&&(n.route.errorElement||n.route.ErrorBoundary)&&(r.staticContext._deepestRenderedBoundaryId=n.route.id),t.createElement(L.Provider,{value:a},l)}var ae=function(e){return e.UseBlocker="useBlocker",e.UseRevalidator="useRevalidator",e.UseNavigateStable="useNavigate",e}(ae||{}),ne=function(e){return e.UseBlocker="useBlocker",e.UseLoaderData="useLoaderData",e.UseActionData="useActionData",e.UseRouteError="useRouteError",e.UseNavigation="useNavigation",e.UseRouteLoaderData="useRouteLoaderData",e.UseMatches="useMatches",e.UseRevalidator="useRevalidator",e.UseNavigateStable="useNavigate",e.UseRouteId="useRouteId",e}(ne||{});function le(e){let a=function(){let e=t.useContext(L);return e||o(!1),e}(),n=a.matches[a.matches.length-1];return n.route.id||o(!1),n.route.id}const re={};function se(e){let{to:a,replace:n,state:l,relative:r}=e;W()||o(!1);let{future:s,static:i}=t.useContext(z),{matches:c}=t.useContext(L),{pathname:u}=Y(),m=G(),d=P(a,B(c,s.v7_relativeSplatPath),u,"path"===r),p=JSON.stringify(d);return t.useEffect(()=>m(JSON.parse(p),{replace:n,state:l,relative:r}),[m,p,r,n,l]),null}function oe(e){o(!1)}function ie(e){let{basename:a="/",children:n=null,location:l,navigationType:s=r.Pop,navigator:i,static:c=!1,future:u}=e;W()&&o(!1);let m=a.replace(/^\/*/,"/"),p=t.useMemo(()=>({basename:m,navigator:i,static:c,future:H({v7_relativeSplatPath:!1},u)}),[m,u,i,c]);"string"==typeof l&&(l=d(l));let{pathname:_="/",search:h="",hash:g="",state:E=null,key:y="default"}=l,f=t.useMemo(()=>{let e=N(_,m);return null==e?null:{location:{pathname:e,search:h,hash:g,state:E,key:y},navigationType:s}},[m,_,h,g,E,y,s]);return null==f?null:t.createElement(z.Provider,{value:p},t.createElement(Q.Provider,{children:n,value:f}))}function ce(e){let{children:t,location:a}=e;return K(ue(t),a)}function ue(e,a){void 0===a&&(a=[]);let n=[];return t.Children.forEach(e,(e,l)=>{if(!t.isValidElement(e))return;let r=[...a,l];if(e.type===t.Fragment)return void n.push.apply(n,ue(e.props.children,r));e.type!==oe&&o(!1),e.props.index&&e.props.children&&o(!1);let s={id:e.props.id||r.join("-"),caseSensitive:e.props.caseSensitive,element:e.props.element,Component:e.props.Component,index:e.props.index,path:e.props.path,loader:e.props.loader,action:e.props.action,errorElement:e.props.errorElement,ErrorBoundary:e.props.ErrorBoundary,hasErrorBoundary:null!=e.props.ErrorBoundary||null!=e.props.errorElement,shouldRevalidate:e.props.shouldRevalidate,handle:e.props.handle,lazy:e.props.lazy};e.props.children&&(s.children=ue(e.props.children,r)),n.push(s)}),n}function me(){return me=Object.assign?Object.assign.bind():function(e){for(var t=1;t{}),t.Component,new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);const pe=["onClick","relative","reloadDocument","replace","state","target","to","preventScrollReset","viewTransition"],_e=["aria-current","caseSensitive","className","end","style","to","viewTransition","children"];try{window.__reactRouterVersion="6"}catch(e){}const he=t.createContext({isTransitioning:!1});new Map;const ge=t.startTransition;function Ee(e){let{basename:a,children:n,future:p,window:_}=e,h=t.useRef();var g;null==h.current&&(h.current=(void 0===(g={window:_,v5Compat:!0})&&(g={}),function(e,t,a,n){void 0===n&&(n={});let{window:i=document.defaultView,v5Compat:d=!1}=n,p=i.history,_=r.Pop,h=null,g=E();function E(){return(p.state||{idx:null}).idx}function y(){_=r.Pop;let e=E(),t=null==e?null:e-g;g=e,h&&h({action:_,location:v.location,delta:t})}function f(e){let t="null"!==i.location.origin?i.location.origin:i.location.href,a="string"==typeof e?e:m(e);return a=a.replace(/ $/,"%20"),o(t,"No window.location.(origin|href) available to create URL for href: "+a),new URL(a,t)}null==g&&(g=0,p.replaceState(l({},p.state,{idx:g}),""));let v={get action(){return _},get location(){return e(i,p)},listen(e){if(h)throw new Error("A history only accepts one active listener");return i.addEventListener(s,y),h=e,()=>{i.removeEventListener(s,y),h=null}},createHref:e=>t(i,e),createURL:f,encodeLocation(e){let t=f(e);return{pathname:t.pathname,search:t.search,hash:t.hash}},push:function(e,t){_=r.Push;let n=u(v.location,e,t);a&&a(n,e),g=E()+1;let l=c(n,g),s=v.createHref(n);try{p.pushState(l,"",s)}catch(e){if(e instanceof DOMException&&"DataCloneError"===e.name)throw e;i.location.assign(s)}d&&h&&h({action:_,location:v.location,delta:1})},replace:function(e,t){_=r.Replace;let n=u(v.location,e,t);a&&a(n,e),g=E();let l=c(n,g),s=v.createHref(n);p.replaceState(l,"",s),d&&h&&h({action:_,location:v.location,delta:0})},go:e=>p.go(e)};return v}(function(e,t){let{pathname:a="/",search:n="",hash:l=""}=d(e.location.hash.substr(1));return a.startsWith("/")||a.startsWith(".")||(a="/"+a),u("",{pathname:a,search:n,hash:l},t.state&&t.state.usr||null,t.state&&t.state.key||"default")},function(e,t){let a=e.document.querySelector("base"),n="";if(a&&a.getAttribute("href")){let t=e.location.href,a=t.indexOf("#");n=-1===a?t:t.slice(0,a)}return n+"#"+("string"==typeof t?t:m(t))},function(e,t){i("/"===e.pathname.charAt(0),"relative pathnames are not supported in hash history.push("+JSON.stringify(t)+")")},g)));let E=h.current,[y,f]=t.useState({action:E.action,location:E.location}),{v7_startTransition:v}=p||{},q=t.useCallback(e=>{v&&ge?ge(()=>f(e)):f(e)},[f,v]);return t.useLayoutEffect(()=>E.listen(q),[E,q]),t.useEffect(()=>{return null==(e=p)||e.v7_startTransition,void 0===(null==e?void 0:e.v7_relativeSplatPath)&&(!t||t.v7_relativeSplatPath),void(t&&(t.v7_fetcherPersist,t.v7_normalizeFormMethod,t.v7_partialHydration,t.v7_skipActionErrorRevalidation));var e,t},[p]),t.createElement(ie,{basename:a,children:n,location:y.location,navigationType:y.action,navigator:E,future:p})}n.flushSync,t.useId;const ye="undefined"!=typeof window&&void 0!==window.document&&void 0!==window.document.createElement,fe=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,ve=t.forwardRef(function(e,a){let n,{onClick:l,relative:r,reloadDocument:s,replace:i,state:c,target:u,to:d,preventScrollReset:p,viewTransition:_}=e,h=de(e,pe),{basename:g}=t.useContext(z),E=!1;if("string"==typeof d&&fe.test(d)&&(n=d,ye))try{let e=new URL(window.location.href),t=d.startsWith("//")?new URL(e.protocol+d):new URL(d),a=N(t.pathname,g);t.origin===e.origin&&null!=a?d=a+t.search+t.hash:E=!0}catch(e){}let y=function(e,a){let{relative:n}=void 0===a?{}:a;W()||o(!1);let{basename:l,navigator:r}=t.useContext(z),{hash:s,pathname:i,search:c}=J(e,{relative:n}),u=i;return"/"!==l&&(u="/"===i?l:R([l,i])),r.createHref({pathname:u,search:c,hash:s})}(d,{relative:r}),f=function(e,a){let{target:n,replace:l,state:r,preventScrollReset:s,relative:o,viewTransition:i}=void 0===a?{}:a,c=G(),u=Y(),d=J(e,{relative:o});return t.useCallback(t=>{if(function(e,t){return!(0!==e.button||t&&"_self"!==t||function(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}(e))}(t,n)){t.preventDefault();let a=void 0!==l?l:m(u)===m(d);c(e,{replace:a,state:r,preventScrollReset:s,relative:o,viewTransition:i})}},[u,c,d,l,r,n,e,s,o,i])}(d,{replace:i,state:c,target:u,preventScrollReset:p,relative:r,viewTransition:_});return t.createElement("a",me({},h,{href:n||y,onClick:E||s?l:function(e){l&&l(e),e.defaultPrevented||f(e)},ref:a,target:u}))}),qe=t.forwardRef(function(e,a){let{"aria-current":n="page",caseSensitive:l=!1,className:r="",end:s=!1,style:i,to:c,viewTransition:u,children:m}=e,d=de(e,_e),p=J(c,{relative:d.relative}),_=Y(),h=t.useContext(I),{navigator:g,basename:E}=t.useContext(z),y=null!=h&&function(e,a){void 0===a&&(a={});let n=t.useContext(he);null==n&&o(!1);let{basename:l}=function(){let e=t.useContext($);return e||o(!1),e}(be.useViewTransitionState),r=J(e,{relative:a.relative});if(!n.isTransitioning)return!1;let s=N(n.currentLocation.pathname,l)||n.currentLocation.pathname,i=N(n.nextLocation.pathname,l)||n.nextLocation.pathname;return null!=S(r.pathname,i)||null!=S(r.pathname,s)}(p)&&!0===u,f=g.encodeLocation?g.encodeLocation(p).pathname:p.pathname,v=_.pathname,q=h&&h.navigation&&h.navigation.location?h.navigation.location.pathname:null;l||(v=v.toLowerCase(),q=q?q.toLowerCase():null,f=f.toLowerCase()),q&&E&&(q=N(q,E)||q);const b="/"!==f&&f.endsWith("/")?f.length-1:f.length;let C,x=v===f||!s&&v.startsWith(f)&&"/"===v.charAt(b),w=null!=q&&(q===f||!s&&q.startsWith(f)&&"/"===q.charAt(f.length)),k={isActive:x,isPending:w,isTransitioning:y},T=x?n:void 0;C="function"==typeof r?r(k):[r,x?"active":null,w?"pending":null,y?"transitioning":null].filter(Boolean).join(" ");let B="function"==typeof i?i(k):i;return t.createElement(ve,me({},d,{"aria-current":T,className:C,ref:a,style:B,to:c,viewTransition:u}),"function"==typeof m?m(k):m)});var be,Ce;(function(e){e.UseScrollRestoration="useScrollRestoration",e.UseSubmit="useSubmit",e.UseSubmitFetcher="useSubmitFetcher",e.UseFetcher="useFetcher",e.useViewTransitionState="useViewTransitionState"})(be||(be={})),function(e){e.UseFetcher="useFetcher",e.UseFetchers="useFetchers",e.UseScrollRestoration="useScrollRestoration"}(Ce||(Ce={}));const xe=window.wp.i18n;function we({children:e}){return(0,t.createElement)("div",{className:"quant-admin"},(0,t.createElement)("header",{className:"quant-admin__header"},(0,t.createElement)("div",{className:"quant-admin__logo"},(0,t.createElement)("svg",{width:"32",height:"32",viewBox:"0 0 32 32",fill:"none"},(0,t.createElement)("rect",{width:"32",height:"32",rx:"6",fill:"#5c40ff"}),(0,t.createElement)("text",{x:"50%",y:"55%",dominantBaseline:"middle",textAnchor:"middle",fill:"white",fontSize:"16",fontWeight:"bold"},"Q")),(0,t.createElement)("span",null,"QuantCDN")),(0,t.createElement)("div",{className:"quant-admin__version"},"v",window.quantAdmin?.version||"2.0.0")),(0,t.createElement)("div",{className:"quant-admin__body"},(0,t.createElement)("nav",{className:"quant-admin__sidebar"},(0,t.createElement)("ul",null,(0,t.createElement)("li",null,(0,t.createElement)(qe,{to:"/dashboard"},(0,t.createElement)("span",{className:"dashicons dashicons-dashboard"}),(0,xe.__)("Dashboard","quant"))),(0,t.createElement)("li",null,(0,t.createElement)(qe,{to:"/content"},(0,t.createElement)("span",{className:"dashicons dashicons-media-text"}),(0,xe.__)("Content","quant"))),(0,t.createElement)("li",null,(0,t.createElement)(qe,{to:"/search"},(0,t.createElement)("span",{className:"dashicons dashicons-search"}),(0,xe.__)("Search","quant"))),(0,t.createElement)("li",null,(0,t.createElement)(qe,{to:"/cache"},(0,t.createElement)("span",{className:"dashicons dashicons-trash"}),(0,xe.__)("Cache","quant"))),(0,t.createElement)("li",null,(0,t.createElement)(qe,{to:"/settings"},(0,t.createElement)("span",{className:"dashicons dashicons-admin-settings"}),(0,xe.__)("Settings","quant"))),(0,t.createElement)("li",null,(0,t.createElement)(qe,{to:"/diagnostics"},(0,t.createElement)("span",{className:"dashicons dashicons-info-outline"}),(0,xe.__)("Diagnostics","quant"))))),(0,t.createElement)("main",{className:"quant-admin__content"},e)))}const Se=window.wp.components,Ne=window.wp.apiFetch;var ke=e.n(Ne);function Te({label:e,value:a,tone:n}){return(0,t.createElement)("div",{className:`quant-stat quant-stat--${n||"neutral"}`},(0,t.createElement)("div",{className:"quant-stat__value"},a),(0,t.createElement)("div",{className:"quant-stat__label"},e))}function Be(){var e,n,l,r;const s=G(),[o,i]=(0,a.useState)(null),[c,u]=(0,a.useState)([]),[m,d]=(0,a.useState)(!1),[p,_]=(0,a.useState)(null),[h,g]=(0,a.useState)(null),E=(0,a.useCallback)(async()=>{try{const[e,t]=await Promise.all([ke()({path:"/quant/v1/status"}),ke()({path:"/quant/v1/queue/errors"})]);i(e),u(t),g(null)}catch(e){g(e.message||(0,xe.__)("Failed to load the plugin status.","quant")),i({connected:!1,configured:!1,mode:"static",queue:{}})}},[]);if((0,a.useEffect)(()=>{E()},[E]),(0,a.useEffect)(()=>{!1===o?.configured&&ke()({path:"/quant/v1/onboarding/status"}).then(e=>{"onboarding"===e.state?s("/onboarding"):"migration"===e.state&&s("/migration")}).catch(()=>{})},[o,s]),!o)return(0,t.createElement)("div",{className:"quant-dashboard"},(0,t.createElement)("h1",null,(0,xe.__)("Dashboard","quant")),(0,t.createElement)(Se.Spinner,null));const y=o.queue||{},f="static"===o.mode;return(0,t.createElement)("div",{className:"quant-dashboard"},(0,t.createElement)("h1",null,(0,xe.__)("Dashboard","quant")),h&&(0,t.createElement)(Se.Notice,{status:"error",isDismissible:!1},h),p&&(0,t.createElement)(Se.Notice,{status:p.status,onRemove:()=>_(null),isDismissible:!0},p.message),o.cron_disabled&&y.pending>0&&(0,t.createElement)(Se.Notice,{status:"warning",isDismissible:!1},(0,xe.__)('WP-Cron is disabled on this site. Pending items are only processed by "Process Queue", a system cron that calls wp-cron.php, or WP-CLI.',"quant")),(0,t.createElement)("div",{className:"quant-dashboard__cards"},(0,t.createElement)(Se.Card,null,(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Connection","quant"))),(0,t.createElement)(Se.CardBody,null,o.connected?(0,t.createElement)("p",{className:"quant-status--success"},(0,xe.__)("Connected to Quant","quant"),o.search_enabled?" · "+(0,xe.__)("Search enabled","quant"):""):(0,t.createElement)("p",{className:"quant-status--error"},o.configured?(0,xe.sprintf)(/* translators: %s: error message */ /* translators: %s: error message */ (0,xe.__)("Not connected: %s","quant"),o.error||""):(0,xe.__)("Not configured yet.","quant")),(0,t.createElement)(Se.Button,{variant:"link",onClick:()=>s("/settings")},(0,xe.__)("Settings","quant")))),(0,t.createElement)(Se.Card,null,(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Mode","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("p",null,(0,t.createElement)("strong",null,f?(0,xe.__)("Static Site","quant"):(0,xe.__)("CDN Only","quant")),(0,t.createElement)("br",null),(0,t.createElement)("span",{style:{color:"#666",fontSize:"13px"}},f?(0,xe.__)("Pages are published to the Quant edge.","quant"):(0,xe.__)("Quant caches WordPress and purges on change.","quant"))))),(0,t.createElement)(Se.Card,null,(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Quick Actions","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("p",null,f&&(0,t.createElement)(t.Fragment,null,(0,t.createElement)(Se.Button,{variant:"link",onClick:()=>s("/content")},(0,xe.__)("Push content","quant")),(0,t.createElement)("br",null)),(0,t.createElement)(Se.Button,{variant:"link",onClick:()=>s("/cache")},(0,xe.__)("Purge cache","quant")),(0,t.createElement)("br",null),(0,t.createElement)(Se.Button,{variant:"link",onClick:()=>s("/diagnostics")},(0,xe.__)("Diagnostics","quant")))))),f&&(0,t.createElement)(Se.Card,{style:{marginTop:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Sync Queue","quant")),(0,t.createElement)("div",null,(0,t.createElement)(Se.Button,{variant:"secondary",size:"small",onClick:async()=>{d(!0),_(null);try{const e=await ke()({path:"/quant/v1/queue/process",method:"POST",data:{limit:20}});_({status:e.failed>0?"warning":"success",message:(0,xe.sprintf)(/* translators: 1: processed items, 2: failed items, 3: items still pending */ /* translators: 1: processed items, 2: failed items, 3: items still pending */ (0,xe.__)("Processed %1$d items, %2$d failed, %3$d still pending.","quant"),e.processed||0,e.failed||0,e.remaining||0)})}catch(e){_({status:"error",message:e.message||(0,xe.__)("Failed to process the queue.","quant")})}finally{d(!1),await E()}},disabled:m||!y.pending,isBusy:m},(0,xe.__)("Process now","quant"))," ",y.failed>0&&(0,t.createElement)(Se.Button,{variant:"tertiary",size:"small",onClick:async()=>{_(null);try{const e=await ke()({path:"/quant/v1/queue/retry",method:"POST"});_({status:"info",message:(0,xe.sprintf)(/* translators: %d: number of items */ /* translators: %d: number of items */ (0,xe.__)("%d failed items were queued again.","quant"),e.queued||0)})}catch(e){_({status:"error",message:e.message||(0,xe.__)("Failed to retry.","quant")})}await E()}},(0,xe.__)("Retry failed","quant")))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("div",{className:"quant-stats"},(0,t.createElement)(Te,{label:(0,xe.__)("Pending","quant"),value:null!==(e=y.pending)&&void 0!==e?e:0,tone:"info"}),(0,t.createElement)(Te,{label:(0,xe.__)("Processing","quant"),value:null!==(n=y.processing)&&void 0!==n?n:0,tone:"warning"}),(0,t.createElement)(Te,{label:(0,xe.__)("Failed","quant"),value:null!==(l=y.failed)&&void 0!==l?l:0,tone:"error"}),(0,t.createElement)(Te,{label:(0,xe.__)("Completed","quant"),value:null!==(r=y.completed)&&void 0!==r?r:0,tone:"success"})),o.next_cron&&(0,t.createElement)("p",{style:{color:"#666",fontSize:"13px"}},(0,xe.sprintf)(/* translators: %s: time of the next cron run */ /* translators: %s: time of the next cron run */ @@ -7,12 +7,12 @@ (0,xe.__)("%d remaining","quant"),k))),(0,t.createElement)("div",{className:"quant-progress__bar-container",role:"progressbar","aria-valuemin":0,"aria-valuemax":100,"aria-valuenow":P},(0,t.createElement)("div",{className:`quant-progress__bar quant-progress__bar--${D}`,style:{width:`${P}%`}})),(0,t.createElement)("div",{className:"quant-progress__stats"},(0,t.createElement)("span",{className:"quant-progress__stat quant-progress__stat--completed"},(0,xe.sprintf)(/* translators: %d: number of items */ /* translators: %d: number of items */ (0,xe.__)("%d completed","quant"),r?u:B)),k>0&&(0,t.createElement)("span",{className:"quant-progress__stat quant-progress__stat--pending"},(0,xe.sprintf)(/* translators: %d: number of items */ /* translators: %d: number of items */ (0,xe.__)("%d pending","quant"),k)),d>0&&(0,t.createElement)("span",{className:"quant-progress__stat quant-progress__stat--failed"},(0,xe.sprintf)(/* translators: %d: number of items */ /* translators: %d: number of items */ -(0,xe.__)("%d failed","quant"),d))),y&&(0,t.createElement)("p",{className:"quant-progress__error"},y),_.length>0&&"active"!==g&&(0,t.createElement)("ul",{className:"quant-progress__errors"},_.map((e,a)=>(0,t.createElement)("li",{key:a},e))),(0,t.createElement)("div",{className:"quant-progress__actions"},"active"===g&&(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>{x.current=!0,N("stopped")}},(0,xe.__)("Stop","quant")),"active"!==g&&d>0&&(0,t.createElement)(Se.Button,{variant:"secondary",onClick:async()=>{try{await ke()({path:"/quant/v1/queue/retry",method:"POST"}),o(null),c(null),m(0),p(0),h([]),q(e=>e+1)}catch(e){f(e.message||(0,xe.__)("Failed to retry","quant"))}}},(0,xe.__)("Retry Failed","quant")),"active"!==g&&(0,t.createElement)(Se.Button,{variant:"link",onClick:()=>{w.current&&w.current()}},(0,xe.__)("Dismiss","quant"))))))}function Ae(){const[e,n]=(0,a.useState)({loading:!0,mode:"static",connected:!1}),[l,r]=(0,a.useState)([]),[s,o]=(0,a.useState)([]),[i,c]=(0,a.useState)(null),[u,m]=(0,a.useState)(!1),[d,p]=(0,a.useState)(null),[_,h]=(0,a.useState)(null),[g,E]=(0,a.useState)(!1),[y,f]=(0,a.useState)(0);(0,a.useEffect)(()=>{Promise.all([ke()({path:"/quant/v1/status"}),ke()({path:"/quant/v1/content-types"})]).then(([e,t])=>{n({loading:!1,...e}),r(t)}).catch(e=>{p(e.message||(0,xe.__)("Failed to load the content overview.","quant")),n({loading:!1,mode:"static",connected:!1})})},[]);const v=e=>{c({status:"success",message:e}),E(!0),h(e=>e?e+1:1)},q=(0,a.useCallback)(()=>{E(!1),f(e=>e+1)},[]),b=(0,a.useCallback)(()=>{h(null),c(null)},[]),C=async(e,t)=>{if(!t||window.confirm(t)){m(!0),c(null);try{const t=await ke()({path:"/quant/v1/seed",method:"POST",data:{types:e}});v((0,xe.sprintf)(/* translators: %d: number of queued items */ /* translators: %d: number of queued items */ +(0,xe.__)("%d failed","quant"),d))),y&&(0,t.createElement)("p",{className:"quant-progress__error"},y),_.length>0&&"active"!==g&&(0,t.createElement)("ul",{className:"quant-progress__errors"},_.map((e,a)=>(0,t.createElement)("li",{key:a},e))),(0,t.createElement)("div",{className:"quant-progress__actions"},"active"===g&&(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>{x.current=!0,N("stopped")}},(0,xe.__)("Stop","quant")),"active"!==g&&d>0&&(0,t.createElement)(Se.Button,{variant:"secondary",onClick:async()=>{try{await ke()({path:"/quant/v1/queue/retry",method:"POST"}),o(null),c(null),m(0),p(0),h([]),q(e=>e+1)}catch(e){f(e.message||(0,xe.__)("Failed to retry","quant"))}}},(0,xe.__)("Retry failed","quant")),"active"!==g&&(0,t.createElement)(Se.Button,{variant:"link",onClick:()=>{w.current&&w.current()}},(0,xe.__)("Dismiss","quant"))))))}function Ae(){const[e,n]=(0,a.useState)({loading:!0,mode:"static",connected:!1}),[l,r]=(0,a.useState)([]),[s,o]=(0,a.useState)([]),[i,c]=(0,a.useState)(null),[u,m]=(0,a.useState)(!1),[d,p]=(0,a.useState)(null),[_,h]=(0,a.useState)(null),[g,E]=(0,a.useState)(!1),[y,f]=(0,a.useState)(0);(0,a.useEffect)(()=>{Promise.all([ke()({path:"/quant/v1/status"}),ke()({path:"/quant/v1/content-types"})]).then(([e,t])=>{n({loading:!1,...e}),r(t)}).catch(e=>{p(e.message||(0,xe.__)("Failed to load the content overview.","quant")),n({loading:!1,mode:"static",connected:!1})})},[]);const v=e=>{c({status:"success",message:e}),E(!0),h(e=>e?e+1:1)},q=(0,a.useCallback)(()=>{E(!1),f(e=>e+1)},[]),b=(0,a.useCallback)(()=>{h(null),c(null)},[]),C=async(e,t)=>{if(!t||window.confirm(t)){m(!0),c(null);try{const t=await ke()({path:"/quant/v1/seed",method:"POST",data:{types:e}});v((0,xe.sprintf)(/* translators: %d: number of queued items */ /* translators: %d: number of queued items */ (0,xe.__)("Queued %d items. Processing…","quant"),t.queued))}catch(e){c({status:"error",message:e.message||(0,xe.__)("Failed to queue content","quant")})}finally{m(!1)}}},x=async e=>{if(0!==s.length){m(!0),c(null);try{const t=await ke()({path:"/quant/v1/sync",method:"POST",data:{items:s,action:e}});o([]);const a="unpublish"===e?/* translators: %d: number of items */ /* translators: %d: number of items */(0,xe.__)("Queued %d items to unpublish. Processing…","quant"):/* translators: %d: number of items */ /* translators: %d: number of items */(0,xe.__)("Queued %d items. Processing…","quant");v((0,xe.sprintf)(a,t.queued))}catch(e){c({status:"error",message:e.message||(0,xe.__)("Failed to queue items","quant")})}finally{m(!1)}}},w=(0,a.useCallback)(e=>o(e),[]),S=(0,a.useMemo)(()=>{const e=[],t=e=>l.find(t=>t.type===e);for(const a of["post","page"]){const n=t(a);n&&e.push({name:a,title:`${n.label} (${n.count})`,contentType:n})}for(const t of l.filter(e=>"cpt"===e.type))e.push({name:`cpt_${t.subtype}`,title:`${t.label} (${t.count})`,contentType:t});const a=l.filter(e=>"taxonomy"===e.type);a.length>0&&e.push({name:"taxonomies",title:`${(0,xe.__)("Taxonomies","quant")} (${a.reduce((e,t)=>e+t.count,0)})`,contentTypes:a});const n=t("media");n&&e.push({name:"media",title:`${n.label} (${n.count})`,contentType:n});const r=t("theme_assets");r&&e.push({name:"theme_assets",title:`${r.label} (${r.count})`,contentType:r,specialType:"theme_assets"});const s=t("redirects");return s&&s.count>0&&e.push({name:"redirects",title:`${s.label} (${s.count})`,contentType:s,specialType:"redirects"}),e},[l]),N=l.filter(e=>"special"===e.category);return e.loading?(0,t.createElement)("div",{className:"quant-content"},(0,t.createElement)("h1",null,(0,xe.__)("Content","quant")),(0,t.createElement)(Se.Spinner,null)):d?(0,t.createElement)("div",{className:"quant-content"},(0,t.createElement)("h1",null,(0,xe.__)("Content","quant")),(0,t.createElement)(Se.Notice,{status:"error",isDismissible:!1},d)):"static"!==e.mode?(0,t.createElement)("div",{className:"quant-content"},(0,t.createElement)("h1",null,(0,xe.__)("Content","quant")),(0,t.createElement)(Se.Notice,{status:"info",isDismissible:!1},(0,xe.__)("Content publishing is available in Static Site mode. In CDN Only mode the plugin purges the Quant cache when content changes.","quant"))):(0,t.createElement)("div",{className:"quant-content"},(0,t.createElement)("h1",null,(0,xe.__)("Content","quant")),!e.connected&&(0,t.createElement)(Se.Notice,{status:"warning",isDismissible:!1},(0,xe.__)("Not connected to the Quant API. Check the settings before you push content.","quant")),i&&(0,t.createElement)(Se.Notice,{status:i.status,onRemove:()=>c(null),isDismissible:!0},i.message),_&&(0,t.createElement)(Me,{key:_,autoProcess:!0,onFinished:q,onComplete:b}),(0,t.createElement)(Se.Card,{className:"quant-content__header-card"},(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("div",{className:"quant-content__actions"},(0,t.createElement)("div",{className:"quant-content__mode-indicator"},(0,t.createElement)("span",{className:"quant-content__mode-badge quant-content__mode-badge--static"},(0,xe.__)("Static Mode","quant")),(0,t.createElement)("span",{className:"quant-content__mode-description"},(0,xe.__)("Pages, files and redirects are published to the Quant edge.","quant"))),(0,t.createElement)("div",{className:"quant-content__batch-actions"},(0,t.createElement)(Se.Button,{variant:"primary",onClick:()=>C(["all"],(0,xe.__)("Queue the whole site for publishing: posts, pages, custom post types, terms, media, home, archives, author pages, theme assets, custom routes, sitemaps and redirects. Continue?","quant")),disabled:u||g,isBusy:u},(0,xe.__)("Push everything","quant")),(0,t.createElement)(Se.DropdownMenu,{icon:"arrow-down-alt2",label:(0,xe.__)("Push one type","quant"),toggleProps:{variant:"secondary",children:(0,xe.__)("Push…","quant")}},()=>(0,t.createElement)(Se.MenuGroup,null,N.map(e=>(0,t.createElement)(Se.MenuItem,{key:e.type,onClick:()=>C([e.type])},`${e.label} (${e.count})`)),(0,t.createElement)(Se.MenuItem,{onClick:()=>C(["taxonomy"])},(0,xe.__)("All taxonomy terms","quant")),(0,t.createElement)(Se.MenuItem,{onClick:()=>C(["media"])},(0,xe.__)("All media","quant")))),(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>x("sync"),disabled:u||0===s.length||g},(0,xe.__)("Push selected","quant"),s.length>0&&` (${s.length})`),(0,t.createElement)(Se.Button,{variant:"tertiary",isDestructive:!0,onClick:()=>x("unpublish"),disabled:u||0===s.length||g},(0,xe.__)("Unpublish selected","quant")),(0,t.createElement)(Se.Button,{variant:"tertiary",onClick:async()=>{try{const e=await ke()({path:"/quant/v1/queue/status"});if(0===e.pending&&0===e.processing)return void c({status:"info",message:(0,xe.__)('The queue is empty. Use "Push everything" or select items to push.',"quant")});v((0,xe.sprintf)(/* translators: %d: number of pending items */ /* translators: %d: number of pending items */ -(0,xe.__)("Processing %d pending items…","quant"),e.pending))}catch(e){c({status:"error",message:e.message||(0,xe.__)("Failed to read the queue","quant")})}},disabled:g,isBusy:g},g?(0,xe.__)("Processing…","quant"):(0,xe.__)("Process queue","quant")))))),S.length>0&&(0,t.createElement)(Se.Card,{className:"quant-content__browser-card"},(0,t.createElement)(Se.TabPanel,{tabs:S,onSelect:()=>o([])},e=>(0,t.createElement)(Re,{contentType:e.contentType,contentTypes:e.contentTypes,specialType:e.specialType,onSelectionChange:w,refreshToken:y,key:e.name}))))}function Fe(){const[e,n]=(0,a.useState)(""),[l,r]=(0,a.useState)(!1),[s,o]=(0,a.useState)(null);return(0,t.createElement)("div",{className:"quant-cache"},(0,t.createElement)("h1",null,(0,xe.__)("Cache Management","quant")),s&&(0,t.createElement)(Se.Notice,{status:s.status,onRemove:()=>o(null),isDismissible:!0},s.message),(0,t.createElement)(Se.Card,null,(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Purge Cache","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("p",{style:{color:"#666",marginTop:0}},(0,xe.__)("Enter paths to purge from the CDN cache. One path per line.","quant")),(0,t.createElement)(Se.TextareaControl,{label:(0,xe.__)("Paths to purge","quant"),value:e,onChange:n,placeholder:"/blog/my-post\n/about\n/contact",rows:8,help:(0,xe.__)("Use /* to purge all paths under a directory","quant")}),(0,t.createElement)("div",{style:{display:"flex",gap:"12px",marginTop:"16px"}},(0,t.createElement)(Se.Button,{variant:"primary",onClick:async()=>{if(e.trim()){r(!0),o(null);try{const t=e.split("\n").filter(e=>e.trim());await ke()({path:"/quant/v1/purge",method:"POST",data:{paths:t}}),o({status:"success",message:(0,xe.__)("Cache purged successfully","quant")}),n("")}catch(e){o({status:"error",message:e.message||(0,xe.__)("Failed to purge cache","quant")})}finally{r(!1)}}},disabled:l||!e.trim(),isBusy:l},(0,xe.__)("Purge Selected Paths","quant")),(0,t.createElement)(Se.Button,{variant:"secondary",isDestructive:!0,onClick:async()=>{if(window.confirm((0,xe.__)("Purge entire cache? This cannot be undone.","quant"))){r(!0),o(null);try{await ke()({path:"/quant/v1/purge",method:"POST",data:{paths:["/*"]}}),o({status:"success",message:(0,xe.__)("Entire cache purged","quant")})}catch(e){o({status:"error",message:e.message||(0,xe.__)("Failed to purge cache","quant")})}finally{r(!1)}}},disabled:l},(0,xe.__)("Purge All Cache","quant"))))))}function Oe({gap:e=4,className:a="",children:n}){return(0,t.createElement)(Se.Flex,{direction:"column",align:"stretch",justify:"flex-start",gap:e,className:`quant-form-stack ${a}`.trim()},n)}function He({label:e,help:a,children:n}){return(0,t.createElement)("fieldset",{className:"quant-field-group"},(0,t.createElement)("legend",{className:"quant-field-group__label"},e),a&&(0,t.createElement)("p",{className:"quant-field-group__help"},a),(0,t.createElement)(Oe,{gap:3},n))}const $e={facet_type:"taxonomy",taxonomy:"category",custom_key:"",facet_display:"checkbox",facet_heading:"",facet_limit:10},Ie={id:"",label:"",status:!0,route:"/search",title:"Search",description:"",post_types:[],languages:[],manual_filters:"",display:{display_search:!0,display_stats:!0,show_clear_refinements:!0,pagination_enabled:!0,per_page:10},facets:[]};function ze({facet:e,taxonomies:a,onChange:n,onRemove:l}){return(0,t.createElement)("div",{className:"quant-facet-row"},(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Type","quant"),value:e.facet_type,options:[{label:(0,xe.__)("Taxonomy","quant"),value:"taxonomy"},{label:(0,xe.__)("Content type","quant"),value:"content_type"},{label:(0,xe.__)("Language","quant"),value:"language"},{label:(0,xe.__)("Custom attribute","quant"),value:"custom"}],onChange:t=>n({...e,facet_type:t}),__nextHasNoMarginBottom:!0}),"taxonomy"===e.facet_type&&(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Taxonomy","quant"),value:e.taxonomy,options:a.map(e=>({label:e.label,value:e.name})),onChange:t=>n({...e,taxonomy:t}),__nextHasNoMarginBottom:!0}),"custom"===e.facet_type&&(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Attribute key","quant"),value:e.custom_key,onChange:t=>n({...e,custom_key:t}),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Heading","quant"),value:e.facet_heading,onChange:t=>n({...e,facet_heading:t}),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Display","quant"),value:e.facet_display,options:[{label:(0,xe.__)("Checkboxes","quant"),value:"checkbox"},{label:(0,xe.__)("Menu","quant"),value:"menu"},{label:(0,xe.__)("Select","quant"),value:"select"}],onChange:t=>n({...e,facet_display:t}),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Limit","quant"),type:"number",value:e.facet_limit,onChange:t=>n({...e,facet_limit:parseInt(t,10)||10}),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.Button,{variant:"link",isDestructive:!0,onClick:l},(0,xe.__)("Remove","quant")))}function Qe({page:e,postTypes:n,taxonomies:l,onSave:r,onCancel:s,saving:o}){const[i,c]=(0,a.useState)({...Ie,...e||{},display:{...Ie.display,...e?.display||{}}}),u=(e,t)=>c(a=>({...a,[e]:t})),m=(e,t)=>c(a=>({...a,display:{...a.display,[e]:t}}));return(0,t.createElement)(Oe,{className:"quant-search-page-editor"},(0,t.createElement)("h3",{className:"quant-form-stack__title"},e?.id?(0,xe.__)("Edit search page","quant"):(0,xe.__)("New search page","quant")),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Label","quant"),value:i.label,onChange:e=>u("label",e),help:(0,xe.__)("Shown in the admin only.","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Enabled","quant"),checked:!!i.status,onChange:e=>u("status",e),help:(0,xe.__)("A disabled page is kept as a draft and removed from Quant.","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Route","quant"),value:i.route,onChange:e=>u("route",e),placeholder:"/search",help:(0,xe.__)("The plugin creates a WordPress page at this path that renders the search.","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Page title","quant"),value:i.title,onChange:e=>u("title",e),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextareaControl,{label:(0,xe.__)("Description","quant"),value:i.description,onChange:e=>u("description",e),rows:2,__nextHasNoMarginBottom:!0}),(0,t.createElement)(He,{label:(0,xe.__)("Content types to search","quant"),help:(0,xe.__)("None selected = all indexed types.","quant")},n.map(e=>(0,t.createElement)(Se.CheckboxControl,{key:e.name,label:e.label,checked:i.post_types.includes(e.name),onChange:t=>((e,t)=>u("post_types",t?[...i.post_types,e]:i.post_types.filter(t=>t!==e)))(e.name,t),__nextHasNoMarginBottom:!0}))),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Manual filters","quant"),value:i.manual_filters,onChange:e=>u("manual_filters",e),placeholder:"category_en:'News'",help:(0,xe.__)("Appended to the page filters as written. Use the project's search syntax: Algolia on QuantCDN (category_en:'News'), Typesense on QuantGov (category_en:=`News`).","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(He,{label:(0,xe.__)("Display","quant")},(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Show the search box","quant"),checked:!!i.display.display_search,onChange:e=>m("display_search",e),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Show the result count","quant"),checked:!!i.display.display_stats,onChange:e=>m("display_stats",e),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)('Show "Clear refinements"',"quant"),checked:!!i.display.show_clear_refinements,onChange:e=>m("show_clear_refinements",e),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Pagination","quant"),checked:!!i.display.pagination_enabled,onChange:e=>m("pagination_enabled",e),__nextHasNoMarginBottom:!0})),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Results per page","quant"),type:"number",value:i.display.per_page,onChange:e=>m("per_page",parseInt(e,10)||10),__nextHasNoMarginBottom:!0}),(0,t.createElement)(He,{label:(0,xe.__)("Facets","quant")},i.facets.map((e,a)=>(0,t.createElement)(ze,{key:a,facet:e,taxonomies:l,onChange:e=>((e,t)=>c(a=>({...a,facets:a.facets.map((a,n)=>n===e?t:a)})))(a,e),onRemove:()=>(e=>c(t=>({...t,facets:t.facets.filter((t,a)=>a!==e)})))(a)})),(0,t.createElement)("div",null,(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>u("facets",[...i.facets,{...$e}])},(0,xe.__)("Add facet","quant")))),(0,t.createElement)(Se.Flex,{justify:"flex-start",gap:2,className:"quant-form-stack__actions"},(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"primary",onClick:()=>r(i),disabled:o||!i.label.trim(),isBusy:o},(0,xe.__)("Save search page","quant"))),(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"tertiary",onClick:s,disabled:o},(0,xe.__)("Cancel","quant")))))}const Le=[0,3e3,7e3,2e4];function Ue({status:e,onRefresh:a}){var n,l;return e?(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Quant Search","quant")),(0,t.createElement)(Se.Button,{variant:"tertiary",size:"small",onClick:a},(0,xe.__)("Refresh","quant"))),(0,t.createElement)(Se.CardBody,null,!e.configured&&(0,t.createElement)(Se.Notice,{status:"warning",isDismissible:!1},(0,xe.__)("Configure the project credentials first.","quant")),e.configured&&!e.connected&&(0,t.createElement)(Se.Notice,{status:"error",isDismissible:!1},(0,xe.sprintf)(/* translators: %s: error message */ /* translators: %s: error message */ +(0,xe.__)("Processing %d pending items…","quant"),e.pending))}catch(e){c({status:"error",message:e.message||(0,xe.__)("Failed to read the queue","quant")})}},disabled:g,isBusy:g},g?(0,xe.__)("Processing…","quant"):(0,xe.__)("Process queue","quant")))))),S.length>0&&(0,t.createElement)(Se.Card,{className:"quant-content__browser-card"},(0,t.createElement)(Se.TabPanel,{tabs:S,onSelect:()=>o([])},e=>(0,t.createElement)(Re,{contentType:e.contentType,contentTypes:e.contentTypes,specialType:e.specialType,onSelectionChange:w,refreshToken:y,key:e.name}))))}function Oe(){const[e,n]=(0,a.useState)(""),[l,r]=(0,a.useState)(!1),[s,o]=(0,a.useState)(null);return(0,t.createElement)("div",{className:"quant-cache"},(0,t.createElement)("h1",null,(0,xe.__)("Cache Management","quant")),s&&(0,t.createElement)(Se.Notice,{status:s.status,onRemove:()=>o(null),isDismissible:!0},s.message),(0,t.createElement)(Se.Card,null,(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Purge Cache","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("p",{style:{color:"#666",marginTop:0}},(0,xe.__)("Enter paths to purge from the CDN cache. One path per line.","quant")),(0,t.createElement)(Se.TextareaControl,{label:(0,xe.__)("Paths to purge","quant"),value:e,onChange:n,placeholder:"/blog/my-post\n/about\n/contact",rows:8,help:(0,xe.__)("Use /* to purge all paths under a directory","quant")}),(0,t.createElement)("div",{style:{display:"flex",gap:"12px",marginTop:"16px"}},(0,t.createElement)(Se.Button,{variant:"primary",onClick:async()=>{if(e.trim()){r(!0),o(null);try{const t=e.split("\n").filter(e=>e.trim());await ke()({path:"/quant/v1/purge",method:"POST",data:{paths:t}}),o({status:"success",message:(0,xe.__)("Cache purged successfully","quant")}),n("")}catch(e){o({status:"error",message:e.message||(0,xe.__)("Failed to purge cache","quant")})}finally{r(!1)}}},disabled:l||!e.trim(),isBusy:l},(0,xe.__)("Purge Selected Paths","quant")),(0,t.createElement)(Se.Button,{variant:"secondary",isDestructive:!0,onClick:async()=>{if(window.confirm((0,xe.__)("Purge entire cache? This cannot be undone.","quant"))){r(!0),o(null);try{await ke()({path:"/quant/v1/purge",method:"POST",data:{paths:["/*"]}}),o({status:"success",message:(0,xe.__)("Entire cache purged","quant")})}catch(e){o({status:"error",message:e.message||(0,xe.__)("Failed to purge cache","quant")})}finally{r(!1)}}},disabled:l},(0,xe.__)("Purge All Cache","quant"))))))}function Fe({gap:e=4,className:a="",children:n}){return(0,t.createElement)(Se.Flex,{direction:"column",align:"stretch",justify:"flex-start",gap:e,className:`quant-form-stack ${a}`.trim()},n)}function He({label:e,help:a,children:n}){return(0,t.createElement)("fieldset",{className:"quant-field-group"},(0,t.createElement)("legend",{className:"quant-field-group__label"},e),a&&(0,t.createElement)("p",{className:"quant-field-group__help"},a),(0,t.createElement)(Fe,{gap:3},n))}const $e={facet_type:"taxonomy",taxonomy:"category",custom_key:"",facet_display:"checkbox",facet_heading:"",facet_limit:10},Ie={id:"",label:"",status:!0,route:"/search",title:"Search",description:"",post_types:[],languages:[],manual_filters:"",display:{display_search:!0,display_stats:!0,show_clear_refinements:!0,pagination_enabled:!0,per_page:10},facets:[]};function ze({facet:e,taxonomies:a,onChange:n,onRemove:l}){return(0,t.createElement)("div",{className:"quant-facet-row"},(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Type","quant"),value:e.facet_type,options:[{label:(0,xe.__)("Taxonomy","quant"),value:"taxonomy"},{label:(0,xe.__)("Content type","quant"),value:"content_type"},{label:(0,xe.__)("Language","quant"),value:"language"},{label:(0,xe.__)("Custom attribute","quant"),value:"custom"}],onChange:t=>n({...e,facet_type:t}),__nextHasNoMarginBottom:!0}),"taxonomy"===e.facet_type&&(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Taxonomy","quant"),value:e.taxonomy,options:a.map(e=>({label:e.label,value:e.name})),onChange:t=>n({...e,taxonomy:t}),__nextHasNoMarginBottom:!0}),"custom"===e.facet_type&&(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Attribute key","quant"),value:e.custom_key,onChange:t=>n({...e,custom_key:t}),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Heading","quant"),value:e.facet_heading,onChange:t=>n({...e,facet_heading:t}),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Display","quant"),value:e.facet_display,options:[{label:(0,xe.__)("Checkboxes","quant"),value:"checkbox"},{label:(0,xe.__)("Menu","quant"),value:"menu"},{label:(0,xe.__)("Select","quant"),value:"select"}],onChange:t=>n({...e,facet_display:t}),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Limit","quant"),type:"number",value:e.facet_limit,onChange:t=>n({...e,facet_limit:parseInt(t,10)||10}),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.Button,{variant:"link",isDestructive:!0,onClick:l},(0,xe.__)("Remove","quant")))}function Qe({page:e,postTypes:n,taxonomies:l,onSave:r,onCancel:s,saving:o}){const[i,c]=(0,a.useState)({...Ie,...e||{},display:{...Ie.display,...e?.display||{}}}),u=(e,t)=>c(a=>({...a,[e]:t})),m=(e,t)=>c(a=>({...a,display:{...a.display,[e]:t}}));return(0,t.createElement)(Fe,{className:"quant-search-page-editor"},(0,t.createElement)("h3",{className:"quant-form-stack__title"},e?.id?(0,xe.__)("Edit search page","quant"):(0,xe.__)("New search page","quant")),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Label","quant"),value:i.label,onChange:e=>u("label",e),help:(0,xe.__)("Shown in the admin only.","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Enabled","quant"),checked:!!i.status,onChange:e=>u("status",e),help:(0,xe.__)("A disabled page is kept as a draft and removed from Quant.","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Route","quant"),value:i.route,onChange:e=>u("route",e),placeholder:"/search",help:(0,xe.__)("The plugin creates a WordPress page at this path that renders the search.","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Page title","quant"),value:i.title,onChange:e=>u("title",e),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextareaControl,{label:(0,xe.__)("Description","quant"),value:i.description,onChange:e=>u("description",e),rows:2,__nextHasNoMarginBottom:!0}),(0,t.createElement)(He,{label:(0,xe.__)("Content types to search","quant"),help:(0,xe.__)("None selected = all indexed types.","quant")},n.map(e=>(0,t.createElement)(Se.CheckboxControl,{key:e.name,label:e.label,checked:i.post_types.includes(e.name),onChange:t=>((e,t)=>u("post_types",t?[...i.post_types,e]:i.post_types.filter(t=>t!==e)))(e.name,t),__nextHasNoMarginBottom:!0}))),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Manual filters","quant"),value:i.manual_filters,onChange:e=>u("manual_filters",e),placeholder:"category_en:'News'",help:(0,xe.__)("Appended to the page filters as written. Use the project's search syntax: Algolia on QuantCDN (category_en:'News'), Typesense on QuantGov (category_en:=`News`).","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(He,{label:(0,xe.__)("Display","quant")},(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Show the search box","quant"),checked:!!i.display.display_search,onChange:e=>m("display_search",e),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Show the result count","quant"),checked:!!i.display.display_stats,onChange:e=>m("display_stats",e),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)('Show "Clear refinements"',"quant"),checked:!!i.display.show_clear_refinements,onChange:e=>m("show_clear_refinements",e),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Pagination","quant"),checked:!!i.display.pagination_enabled,onChange:e=>m("pagination_enabled",e),__nextHasNoMarginBottom:!0})),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Results per page","quant"),type:"number",value:i.display.per_page,onChange:e=>m("per_page",parseInt(e,10)||10),__nextHasNoMarginBottom:!0}),(0,t.createElement)(He,{label:(0,xe.__)("Facets","quant")},i.facets.map((e,a)=>(0,t.createElement)(ze,{key:a,facet:e,taxonomies:l,onChange:e=>((e,t)=>c(a=>({...a,facets:a.facets.map((a,n)=>n===e?t:a)})))(a,e),onRemove:()=>(e=>c(t=>({...t,facets:t.facets.filter((t,a)=>a!==e)})))(a)})),(0,t.createElement)("div",null,(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>u("facets",[...i.facets,{...$e}])},(0,xe.__)("Add facet","quant")))),(0,t.createElement)(Se.Flex,{justify:"flex-start",gap:2,className:"quant-form-stack__actions"},(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"primary",onClick:()=>r(i),disabled:o||!i.label.trim(),isBusy:o},(0,xe.__)("Save search page","quant"))),(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"tertiary",onClick:s,disabled:o},(0,xe.__)("Cancel","quant")))))}const Le=[0,3e3,7e3,2e4];function Ue({status:e,onRefresh:a}){var n,l;return e?(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Quant Search","quant")),(0,t.createElement)(Se.Button,{variant:"tertiary",size:"small",onClick:a},(0,xe.__)("Refresh","quant"))),(0,t.createElement)(Se.CardBody,null,!e.configured&&(0,t.createElement)(Se.Notice,{status:"warning",isDismissible:!1},(0,xe.__)("Configure the project credentials first.","quant")),e.configured&&!e.connected&&(0,t.createElement)(Se.Notice,{status:"error",isDismissible:!1},(0,xe.sprintf)(/* translators: %s: error message */ /* translators: %s: error message */ (0,xe.__)("Cannot reach the Quant API: %s","quant"),e.error||"")),e.connected&&!e.enabled&&(0,t.createElement)(Se.Notice,{status:"warning",isDismissible:!1},(0,xe.__)("Search is not enabled for this project. Enable it in the Quant dashboard under the project settings.","quant")),e.enabled&&(0,t.createElement)("table",{className:"widefat quant-kv-table"},(0,t.createElement)("tbody",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",null,(0,xe.__)("Index","quant")),(0,t.createElement)("td",null,e.index?.algolia_index||e.stats?.name||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",null,(0,xe.__)("Search engine","quant")),(0,t.createElement)("td",null,"typesense"===e.backend?"Typesense":"Algolia")),(0,t.createElement)("tr",null,(0,t.createElement)("th",null,(0,xe.__)("Records","quant")),(0,t.createElement)("td",null,null!==(n=e.stats?.entries)&&void 0!==n?n:"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",null,(0,xe.__)("Pending tasks","quant")),(0,t.createElement)("td",null,null!==(l=e.stats?.numberOfPendingTasks)&&void 0!==l?l:"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",null,(0,xe.__)("Last updated","quant")),(0,t.createElement)("td",null,e.stats?.updatedAt||"-")))))):(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardBody,null,(0,t.createElement)(Se.Spinner,null)))}function We({pages:e,onEdit:a,onDelete:n}){return 0===e.length?(0,t.createElement)("p",{style:{color:"#666"}},(0,xe.__)('No search pages yet. A search page is a WordPress page that shows Quant Search results with facets. You can also place the "Quant Search" block or the [quant_search] shortcode on any page.',"quant")):(0,t.createElement)("table",{className:"widefat striped"},(0,t.createElement)("thead",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",null,(0,xe.__)("Label","quant")),(0,t.createElement)("th",null,(0,xe.__)("Route","quant")),(0,t.createElement)("th",null,(0,xe.__)("Status","quant")),(0,t.createElement)("th",null,(0,xe.__)("Facets","quant")),(0,t.createElement)("th",null))),(0,t.createElement)("tbody",null,e.map(e=>(0,t.createElement)("tr",{key:e.id},(0,t.createElement)("td",null,(0,t.createElement)("strong",null,e.label),(0,t.createElement)("br",null),(0,t.createElement)("code",null,`[quant_search page="${e.id}"]`)),(0,t.createElement)("td",null,e.url?(0,t.createElement)("a",{href:e.url,target:"_blank",rel:"noreferrer"},e.route):e.route),(0,t.createElement)("td",null,e.status?(0,xe.__)("Enabled","quant"):(0,xe.__)("Disabled","quant")),(0,t.createElement)("td",null,e.facets.length),(0,t.createElement)("td",null,(0,t.createElement)(Se.Flex,{justify:"flex-end",gap:3},(0,t.createElement)(Se.Button,{variant:"link",onClick:()=>a(e)},(0,xe.__)("Edit","quant")),(0,t.createElement)(Se.Button,{variant:"link",isDestructive:!0,onClick:()=>n(e)},(0,xe.__)("Delete","quant"))))))))}function Ye(){const[e,n]=(0,a.useState)(null),[l,r]=(0,a.useState)(null),[s,o]=(0,a.useState)([]),[i,c]=(0,a.useState)(null),[u,m]=(0,a.useState)(null),[d,p]=(0,a.useState)(!1),[_,h]=(0,a.useState)(!1),[g,E]=(0,a.useState)(0),[y,f]=(0,a.useState)(!1),v=(e,t)=>m({status:e,message:t}),q=(e,t)=>e?.push_needed?v("warning",(0,xe.sprintf)(/* translators: %s: what was saved, for example "Search settings saved." */ /* translators: %s: what was saved, for example "Search settings saved." */ -(0,xe.__)("%s The search form is part of every page, so pages on the edge keep the old form until you push them again. On the Content page, select Push everything.","quant"),t)):v("success",t),b=(0,a.useCallback)(()=>E(0),[]),C=(0,a.useRef)(!0),x=(0,a.useRef)(null);(0,a.useEffect)(()=>(C.current=!0,()=>{C.current=!1,clearTimeout(x.current)}),[]);const w=(0,a.useCallback)(()=>ke()({path:"/quant/v1/search/status"}).catch(()=>({configured:!1})).then(e=>(C.current&&n(e),e)),[]),S=(0,a.useRef)(null),N=(0,a.useCallback)(()=>{clearTimeout(x.current);const e=(t,a)=>{w().then(n=>{const l=t+1;C.current&&l0||null===e||n.entries===e||null!==t&&n.entries!==t}(S.current,a,n)&&(x.current=setTimeout(()=>{var t;return e(l,null!==(t=n?.stats?.entries)&&void 0!==t?t:null)},Le[l]))})};e(0,S.current)},[w]);(0,a.useEffect)(()=>{w(),ke()({path:"/quant/v1/search/settings"}).then(e=>{r(e),f(!!e?.settings?.enabled)}).catch(e=>v("error",e.message)),ke()({path:"/quant/v1/search/pages"}).then(o).catch(()=>o([]))},[w]);const k=l?.settings,T=(e,t)=>r(a=>({...a,settings:{...a.settings,[e]:t}}));return(0,t.createElement)("div",{className:"quant-search-admin"},(0,t.createElement)("h1",null,(0,xe.__)("Search","quant")),u&&(0,t.createElement)(Se.Notice,{status:u.status,onRemove:()=>m(null),isDismissible:!0},u.message),g>0&&(0,t.createElement)(Me,{key:g,autoProcess:!0,onFinished:N,onComplete:b}),(0,t.createElement)(Ue,{status:e,onRefresh:w}),k&&(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Search Records","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)(Oe,null,(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Keep search records updated","quant"),checked:!!k.enabled,onChange:e=>T("enabled",e),help:(0,xe.__)("Send a search record with every page push so the index follows your content.","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(He,{label:(0,xe.__)("Indexed content types","quant")},l.post_types.map(e=>(0,t.createElement)(Se.CheckboxControl,{key:e.name,label:e.label,checked:k.post_types.includes(e.name),onChange:t=>((e,t)=>T("post_types",t?[...k.post_types,e]:k.post_types.filter(t=>t!==e)))(e.name,t),__nextHasNoMarginBottom:!0}))),(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Summary source","quant"),value:k.summary_source,options:[{label:(0,xe.__)("Excerpt, or the first words of the content","quant"),value:"excerpt"},{label:(0,xe.__)("First words of the content","quant"),value:"content"}],onChange:e=>T("summary_source",e),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Image size","quant"),value:k.image_size,options:(l.image_sizes||[]).map(e=>({label:e,value:e})),onChange:e=>T("image_size",e),help:(0,xe.__)("Featured image size stored in the record.","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Send the WordPress search form to a Quant Search page","quant"),checked:!!k.replace_wp_search,onChange:e=>T("replace_wp_search",e),help:(0,xe.__)("The theme search form and ?s= searches use the default search page below, so search works on the static site.","quant"),__nextHasNoMarginBottom:!0}),k.replace_wp_search&&(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Default search page","quant"),value:k.default_page,options:[{label:(0,xe.__)("First enabled page","quant"),value:""}].concat(s.map(e=>({label:e.label,value:e.id}))),onChange:e=>T("default_page",e),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.Flex,{justify:"flex-start",gap:2,className:"quant-form-stack__actions"},(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"primary",onClick:async()=>{p(!0);try{const e=await ke()({path:"/quant/v1/search/settings",method:"POST",data:k});r(t=>({...t,settings:e.settings})),f(!!e.settings?.enabled),q(e,(0,xe.__)("Search settings saved.","quant"))}catch(e){v("error",e.message||(0,xe.__)("Failed to save","quant"))}finally{p(!1)}},disabled:d,isBusy:d},(0,xe.__)("Save","quant"))),(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"secondary",onClick:async()=>{var t;h(!0),S.current=null!==(t=e?.stats?.entries)&&void 0!==t?t:null;try{const e=await ke()({path:"/quant/v1/search/reindex",method:"POST"});v("success",(0,xe.sprintf)(/* translators: %d: number of records */ /* translators: %d: number of records */ +(0,xe.__)("%s The search form is part of every page, so pages on the edge keep the old form until you push them again. On the Content page, select Push everything.","quant"),t)):v("success",t),b=(0,a.useCallback)(()=>E(0),[]),C=(0,a.useRef)(!0),x=(0,a.useRef)(null);(0,a.useEffect)(()=>(C.current=!0,()=>{C.current=!1,clearTimeout(x.current)}),[]);const w=(0,a.useCallback)(()=>ke()({path:"/quant/v1/search/status"}).catch(()=>({configured:!1})).then(e=>(C.current&&n(e),e)),[]),S=(0,a.useRef)(null),N=(0,a.useCallback)(()=>{clearTimeout(x.current);const e=(t,a)=>{w().then(n=>{const l=t+1;C.current&&l0||null===e||n.entries===e||null!==t&&n.entries!==t}(S.current,a,n)&&(x.current=setTimeout(()=>{var t;return e(l,null!==(t=n?.stats?.entries)&&void 0!==t?t:null)},Le[l]))})};e(0,S.current)},[w]);(0,a.useEffect)(()=>{w(),ke()({path:"/quant/v1/search/settings"}).then(e=>{r(e),f(!!e?.settings?.enabled)}).catch(e=>v("error",e.message)),ke()({path:"/quant/v1/search/pages"}).then(o).catch(()=>o([]))},[w]);const k=l?.settings,T=(e,t)=>r(a=>({...a,settings:{...a.settings,[e]:t}}));return(0,t.createElement)("div",{className:"quant-search-admin"},(0,t.createElement)("h1",null,(0,xe.__)("Search","quant")),u&&(0,t.createElement)(Se.Notice,{status:u.status,onRemove:()=>m(null),isDismissible:!0},u.message),g>0&&(0,t.createElement)(Me,{key:g,autoProcess:!0,onFinished:N,onComplete:b}),(0,t.createElement)(Ue,{status:e,onRefresh:w}),k&&(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Search Records","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)(Fe,null,(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Keep search records updated","quant"),checked:!!k.enabled,onChange:e=>T("enabled",e),help:(0,xe.__)("Send a search record with every page push so the index follows your content.","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(He,{label:(0,xe.__)("Indexed content types","quant")},l.post_types.map(e=>(0,t.createElement)(Se.CheckboxControl,{key:e.name,label:e.label,checked:k.post_types.includes(e.name),onChange:t=>((e,t)=>T("post_types",t?[...k.post_types,e]:k.post_types.filter(t=>t!==e)))(e.name,t),__nextHasNoMarginBottom:!0}))),(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Summary source","quant"),value:k.summary_source,options:[{label:(0,xe.__)("Excerpt, or the first words of the content","quant"),value:"excerpt"},{label:(0,xe.__)("First words of the content","quant"),value:"content"}],onChange:e=>T("summary_source",e),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Image size","quant"),value:k.image_size,options:(l.image_sizes||[]).map(e=>({label:e,value:e})),onChange:e=>T("image_size",e),help:(0,xe.__)("Featured image size stored in the record.","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Send the WordPress search form to a Quant Search page","quant"),checked:!!k.replace_wp_search,onChange:e=>T("replace_wp_search",e),help:(0,xe.__)("The theme search form and ?s= searches use the default search page below, so search works on the static site.","quant"),__nextHasNoMarginBottom:!0}),k.replace_wp_search&&(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Default search page","quant"),value:k.default_page,options:[{label:(0,xe.__)("First enabled page","quant"),value:""}].concat(s.map(e=>({label:e.label,value:e.id}))),onChange:e=>T("default_page",e),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.Flex,{justify:"flex-start",gap:2,className:"quant-form-stack__actions"},(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"primary",onClick:async()=>{p(!0);try{const e=await ke()({path:"/quant/v1/search/settings",method:"POST",data:k});r(t=>({...t,settings:e.settings})),f(!!e.settings?.enabled),q(e,(0,xe.__)("Search settings saved.","quant"))}catch(e){v("error",e.message||(0,xe.__)("Failed to save","quant"))}finally{p(!1)}},disabled:d,isBusy:d},(0,xe.__)("Save","quant"))),(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"secondary",onClick:async()=>{var t;h(!0),S.current=null!==(t=e?.stats?.entries)&&void 0!==t?t:null;try{const e=await ke()({path:"/quant/v1/search/reindex",method:"POST"});v("success",(0,xe.sprintf)(/* translators: %d: number of records */ /* translators: %d: number of records */ (0,xe.__)("Queued %d records. Processing…","quant"),e.queued)),E(e=>e+1)}catch(e){v("error",e.message||(0,xe.__)("Reindex failed","quant"))}finally{h(!1)}},disabled:_||!y||!e?.enabled},(0,xe.__)("Rebuild index","quant"))),(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"secondary",isDestructive:!0,onClick:async()=>{const e=(0,xe.__)("Remove every record from the search index?","quant");if(window.confirm(e)){h(!0);try{await ke()({path:"/quant/v1/search/clear",method:"POST"}),v("success",(0,xe.__)("Search index cleared.","quant")),w()}catch(e){v("error",e.message||(0,xe.__)("Clear failed","quant"))}finally{h(!1)}}},disabled:_||!e?.enabled},(0,xe.__)("Clear index","quant"))))))),(0,t.createElement)(Se.Card,null,(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Search Pages","quant")),!i&&(0,t.createElement)(Se.Button,{variant:"primary",size:"small",onClick:()=>c({})},(0,xe.__)("Add search page","quant"))),(0,t.createElement)(Se.CardBody,null,i?(0,t.createElement)(Qe,{page:i.id?i:null,postTypes:l?.post_types||[],taxonomies:l?.taxonomies||[],onSave:async e=>{p(!0);try{const t=await ke()({path:"/quant/v1/search/pages",method:"POST",data:e});o(e=>[...e.filter(e=>e.id!==t.id),t]),c(null),q(t,(0,xe.__)("Search page saved. Its WordPress page was queued for publishing.","quant"))}catch(e){v("error",e.message||(0,xe.__)("Failed to save the search page","quant"))}finally{p(!1)}},onCancel:()=>c(null),saving:d}):(0,t.createElement)(We,{pages:s,onEdit:c,onDelete:async e=>{const t=(0,xe.sprintf)(/* translators: %s: search page label */ /* translators: %s: search page label */ (0,xe.__)('Delete the search page "%s"? Its WordPress page is moved to the trash.',"quant"),e.label);if(window.confirm(t))try{const t=await ke()({path:`/quant/v1/search/pages/${e.id}`,method:"DELETE"});o(t=>t.filter(t=>t.id!==e.id)),q(t,(0,xe.__)("Search page deleted.","quant"))}catch(e){v("error",e.message||(0,xe.__)("Delete failed","quant"))}}}))))}const Ve=[["home",(0,xe.__)("Home page","quant")],["post",(0,xe.__)("Posts","quant")],["page",(0,xe.__)("Pages","quant")],["taxonomy",(0,xe.__)("Categories and tags","quant")],["archives",(0,xe.__)("Archives","quant")],["authors",(0,xe.__)("Author pages","quant")],["sitemaps",(0,xe.__)("Sitemaps","quant")]],Ge=e=>Array.isArray(e)?e.join("\n"):e||"",Je=e=>e.split("\n").map(e=>e.trim()).filter(Boolean);function Ke({title:e,children:a,description:n}){return(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},e)),(0,t.createElement)(Se.CardBody,null,n&&(0,t.createElement)("p",{style:{color:"#666",marginTop:0}},n),a))}const Xe=(0,xe.__)("Set by a QUANT_* environment variable or constant. Change it there.","quant");function Ze({oauthStatus:e,setOauthStatus:n,onProjectSelected:l,notify:r,showManual:s,manualConfigured:o,setShowManual:i}){const[c,u]=(0,a.useState)([]),[m,d]=(0,a.useState)(!1),[p,_]=(0,a.useState)(!1),[h,g]=(0,a.useState)(null),[E,y]=(0,a.useState)(""),[f,v]=(0,a.useState)(""),[q,b]=(0,a.useState)(!1),[C,x]=(0,a.useState)(!1),w=(0,a.useCallback)(async()=>{d(!0),g(null);try{const e=await ke()({path:"/quant/v1/oauth/projects"});u(Array.isArray(e)?e:[])}catch(e){u([]),g(e.message||(0,xe.__)("Failed to load projects","quant"))}finally{d(!1),_(!0)}},[]);(0,a.useEffect)(()=>{e?.connected&&!e.has_project&&w()},[e,w]);const S=async()=>{try{const e=await ke()({path:"/quant/v1/oauth/connect"});e.redirect_url&&(window.location.href=e.redirect_url)}catch(e){r("error",e.message||(0,xe.__)("Failed to start the login","quant"))}},N=async()=>{try{await ke()({path:"/quant/v1/oauth/disconnect",method:"POST"}),n({connected:!1}),u([]),l(),r("success",(0,xe.__)("Disconnected from Quant","quant"))}catch(e){r("error",e.message||(0,xe.__)("Failed to disconnect","quant"))}},k=[{label:(0,xe.__)("Select an organization…","quant"),value:""}].concat(c.map(e=>({label:e.organization.name,value:e.organization.machine_name||e.organization.name}))),T=c.find(e=>(e.organization.machine_name||e.organization.name)===E),B=[{label:(0,xe.__)("Select a project…","quant"),value:""}].concat((T?.projects||[]).map(e=>({label:e.name||e.machine_name,value:e.machine_name||e.name})));if(!e?.connected)return(0,t.createElement)(Ke,{title:(0,xe.__)("Connect to Quant","quant"),description:(0,xe.__)("Sign in to Quant and pick the project this site publishes to. You can also enter the project credentials by hand.","quant")},(0,t.createElement)(Se.Flex,{justify:"flex-start",gap:4},(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"primary",onClick:S,icon:"admin-users"},(0,xe.__)("Login to Quant","quant"))),!o&&(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"link",onClick:()=>i(!s)},s?(0,xe.__)("Hide manual credentials","quant"):(0,xe.__)("Use manual credentials instead","quant")))));const P=function({loading:e,loaded:t,error:a,count:n}){return e||!t?"loading":a||0===n?"empty":"list"}({loading:m,loaded:p,error:h,count:c.length});return e.has_project&&!C?(0,t.createElement)(Ke,{title:(0,xe.__)("Connect to Quant","quant")},(0,t.createElement)("p",{className:"quant-connected-badge"},(0,t.createElement)(Se.Icon,{icon:"yes-alt"})," ",(0,xe.__)("Connected","quant"),e.user?.email&&(0,t.createElement)("span",{style:{color:"#666",marginLeft:"8px"}},e.user.email)),(0,t.createElement)("p",{style:{margin:"8px 0"}},(0,t.createElement)("strong",null,(0,xe.__)("Organization:","quant"))," ",e.organization),(0,t.createElement)("p",{style:{margin:"8px 0"}},(0,t.createElement)("strong",null,(0,xe.__)("Project:","quant"))," ",e.project),(0,t.createElement)(Se.Flex,{justify:"flex-start",gap:2,style:{marginTop:"16px"}},(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>{e.project_limited?(async()=>{try{await ke()({path:"/quant/v1/oauth/disconnect",method:"POST"})}catch(e){}await S()})():(x(!0),w())}},(0,xe.__)("Change Project","quant"))),(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"secondary",isDestructive:!0,onClick:N},(0,xe.__)("Disconnect","quant"))),(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"link",onClick:()=>i(!s)},s?(0,xe.__)("Hide manual credentials","quant"):(0,xe.__)("Show manual credentials","quant"))))):"empty"===P?(0,t.createElement)(Ke,{title:(0,xe.__)("Connect to Quant","quant")},(0,t.createElement)(Se.Notice,{status:"warning",isDismissible:!1},h||(0,xe.__)("No organizations or projects are available to this Quant account. Check the account in the Quant dashboard, or log in again.","quant")),(0,t.createElement)(Se.Flex,{justify:"flex-start",gap:2,style:{marginTop:"16px"}},(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"primary",onClick:S},(0,xe.__)("Reconnect","quant"))),(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"secondary",onClick:w},(0,xe.__)("Try again","quant"))),(0,t.createElement)(Se.FlexItem,null,C?(0,t.createElement)(Se.Button,{variant:"tertiary",onClick:()=>x(!1)},(0,xe.__)("Cancel","quant")):(0,t.createElement)(Se.Button,{variant:"tertiary",isDestructive:!0,onClick:N},(0,xe.__)("Disconnect","quant"))))):(0,t.createElement)(Ke,{title:(0,xe.__)("Connect to Quant","quant"),description:C?(0,xe.__)("Select a different project for this site.","quant"):(0,xe.__)("Connected. Select the project to use with this site.","quant")},"loading"===P?(0,t.createElement)(Se.Spinner,null):(0,t.createElement)(t.Fragment,null,(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Organization","quant"),value:E,options:k,onChange:e=>{y(e),v("")}}),E&&(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Project","quant"),value:f,options:B,onChange:v}),(0,t.createElement)(Se.Flex,{justify:"flex-start",gap:2,style:{marginTop:"16px"}},(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"primary",onClick:async()=>{b(!0);try{await ke()({path:"/quant/v1/oauth/select-project",method:"POST",data:{organization:E,project:f}}),n(await ke()({path:"/quant/v1/oauth/status"})),x(!1),l(),r("success",(0,xe.__)("Project selected.","quant"))}catch(e){r("error",e.message||(0,xe.__)("Failed to select the project","quant"))}finally{b(!1)}},disabled:!E||!f||q,isBusy:q},C?(0,xe.__)("Switch to This Project","quant"):(0,xe.__)("Use This Project","quant"))),(0,t.createElement)(Se.FlexItem,null,C?(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>x(!1)},(0,xe.__)("Cancel","quant")):(0,t.createElement)(Se.Button,{variant:"secondary",isDestructive:!0,onClick:N},(0,xe.__)("Disconnect","quant"))))))}function et(){const[e,n]=(0,a.useState)(null),[l,r]=(0,a.useState)(""),[s,o]=(0,a.useState)(""),[i,c]=(0,a.useState)(""),[u,m]=(0,a.useState)(null),[d,p]=(0,a.useState)(!1),[_,h]=(0,a.useState)(!1),[g,E]=(0,a.useState)(!1),[y,f]=(0,a.useState)(!1),[v,q]=(0,a.useState)(null),[b,C]=(0,a.useState)(null),x=(0,a.useCallback)((e,t)=>C({status:e,message:t}),[]),w=(0,a.useCallback)(async()=>{const[e,t]=await Promise.all([ke()({path:"/quant/v1/settings"}),ke()({path:"/quant/v1/oauth/status"})]);n(e),m(t),r(Ge(e.custom_routes)),o(Ge(e.domain_rewrites)),c(Ge(e.schedule?.custom_routes))},[]);(0,a.useEffect)(()=>{"success"===new URLSearchParams(window.location.search).get("oauth")&&(x("success",(0,xe.__)("Connected to Quant. Now select a project below.","quant")),window.history.replaceState({},"",window.location.pathname+"?page=quant#/settings")),w().catch(e=>x("error",e.message||(0,xe.__)("Failed to load settings","quant")))},[w,x]);const S=(e,t)=>n(a=>({...a,[e]:t})),N=(e,t)=>n(a=>({...a,schedule:{...a.schedule,[e]:t}})),k=async()=>{h(!0),C(null);try{const t={...e,custom_routes:Je(l),domain_rewrites:Je(s),schedule:{...e.schedule,custom_routes:Je(i)}};delete t.overridden,delete t.schedules,delete t.has_token,t.token||delete t.token;const a=await ke()({path:"/quant/v1/settings",method:"POST",data:t});n({...a,token:""}),x("success",(0,xe.__)("Settings saved.","quant"))}catch(e){x("error",e.message||(0,xe.__)("Failed to save settings","quant"))}finally{h(!1)}};if(!e)return(0,t.createElement)("div",{className:"quant-settings"},(0,t.createElement)("h1",null,(0,xe.__)("Settings","quant")),(0,t.createElement)(Se.Spinner,null));const T=e.overridden||{},B=!!(e.organization&&e.project&&e.has_token),P=(e,t)=>!(!T[e]||!T[e].includes(t)),D=(e,t,a)=>P(e,t)?Xe:a,R=Object.entries(e.schedules||{}).map(([e,t])=>({value:e,label:t}));return(0,t.createElement)("div",{className:"quant-settings"},(0,t.createElement)("h1",null,(0,xe.__)("Settings","quant")),b&&(0,t.createElement)(Se.Notice,{status:b.status,onRemove:()=>C(null),isDismissible:!0},b.message),(0,t.createElement)(Ze,{oauthStatus:u,setOauthStatus:m,onProjectSelected:()=>w().catch(()=>{}),notify:x,showManual:d,manualConfigured:B,setShowManual:p}),(d||!u?.connected&&B)&&(0,t.createElement)(Ke,{title:(0,xe.__)("Project Credentials","quant"),description:(0,xe.__)("Copy these from the Integrations page of the Quant dashboard.","quant")},(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("API Endpoint","quant"),value:e.endpoint,onChange:e=>S("endpoint",e),disabled:P("credentials","endpoint"),help:D("credentials","endpoint",(0,xe.__)("https://api.quantcdn.io for QuantCDN, https://api.quantgov.cloud for QuantGov. Without /v1.","quant"))}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Organization (API Customer)","quant"),value:e.organization,onChange:e=>S("organization",e),disabled:P("credentials","organization"),help:D("credentials","organization")}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Project","quant"),value:e.project,onChange:e=>S("project",e),disabled:P("credentials","project"),help:D("credentials","project")}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("API Token","quant"),type:"password",value:e.token,onChange:e=>S("token",e),disabled:P("credentials","token"),placeholder:e.has_token?(0,xe.__)("Token saved. Enter a new one to replace it.","quant"):(0,xe.__)("Project token","quant"),help:D("credentials","token")}),(0,t.createElement)(Se.Flex,{justify:"flex-start",gap:2,style:{marginTop:"16px"}},(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"primary",onClick:k,disabled:_,isBusy:_},(0,xe.__)("Save Credentials","quant"))),(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"secondary",onClick:async()=>{E(!0);try{const e=await ke()({path:"/quant/v1/status"});x(e.connected?"success":"error",e.connected?(0,xe.__)("Connected to the Quant API.","quant"):(0,xe.sprintf)(/* translators: %s: error message */ /* translators: %s: error message */ (0,xe.__)("Connection failed: %s","quant"),e.error||(0,xe.__)("check your credentials","quant")))}catch(e){x("error",e.message||(0,xe.__)("Connection test failed","quant"))}finally{E(!1)}},disabled:g,isBusy:g},(0,xe.__)("Test Connection","quant"))))),(0,t.createElement)(Ke,{title:(0,xe.__)("API Connection","quant"),description:(0,xe.__)("Applies to every request the plugin makes, in both modes.","quant")},(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("HTTP request timeout (seconds)","quant"),type:"number",value:e.http_request_timeout,onChange:e=>S("http_request_timeout",parseInt(e,10)||30),disabled:P("credentials","http_request_timeout")})),(0,t.createElement)(Ke,{title:(0,xe.__)("Mode","quant")},(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Plugin mode","quant"),value:e.mode,disabled:P("mode","mode"),options:[{label:(0,xe.__)("Static Site: publish the whole site to the Quant edge","quant"),value:"static"},{label:(0,xe.__)("CDN Only: WordPress serves pages, Quant caches and purges them","quant"),value:"cdn_only"}],onChange:e=>S("mode",e)}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Automatic static push","quant"),checked:!!e.auto_sync,onChange:e=>S("auto_sync",e),disabled:"static"!==e.mode||P("sync","auto_sync"),help:(0,xe.__)("Push pages, terms and media to Quant when they change (Static mode).","quant")}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Also push related pages","quant"),checked:!1!==e.sync_related,onChange:e=>S("sync_related",e),disabled:"static"!==e.mode||!e.auto_sync,help:(0,xe.__)("When a post changes, also push the home page, its categories, tags, author and archive pages.","quant")}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Cache purge on save","quant"),checked:!!e.auto_purge,onChange:e=>S("auto_purge",e),disabled:P("sync","auto_purge"),help:(0,xe.__)("Purge the edge cache when content changes. Used in CDN Only mode, or in Static mode when automatic push is off.","quant")})),"static"===e.mode&&(0,t.createElement)(Ke,{title:(0,xe.__)("Static Site Rendering","quant"),description:(0,xe.__)("How the plugin fetches pages from this server before it pushes them.","quant")},(0,t.createElement)("div",{className:"quant-inline-test"},(0,t.createElement)("strong",null,(0,xe.__)("Internal connectivity","quant")),null===v?(0,t.createElement)("span",{style:{color:"#757575"}},(0,xe.__)("Not tested","quant")):(0,t.createElement)("span",{style:{color:v.success?"#00a32a":"#d63638"}},v.success?"✓ "+(0,xe.__)("Working","quant"):"✗ "+v.message),(0,t.createElement)(Se.Button,{variant:"secondary",size:"small",onClick:async()=>{f(!0),q(null);try{q(await ke()({path:"/quant/v1/test-internal",method:"POST"}))}catch(e){q({success:!1,message:e.message||(0,xe.__)("Test failed","quant")})}finally{f(!1)}},disabled:y,isBusy:y},(0,xe.__)("Test","quant")),v?.adjusted_url&&(0,t.createElement)("div",{className:"quant-inline-test__detail"},v.adjusted_url,v.host_header?` (Host: ${v.host_header})`:"")),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Webserver URL","quant"),value:e.webserver_url,onChange:e=>S("webserver_url",e),placeholder:"http://localhost",disabled:P("static","webserver_url"),help:D("static","webserver_url",(0,xe.__)("Where the plugin can reach this site from the server, for example http://localhost. Leave empty to use the site URL.","quant"))}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Hostname (Host header)","quant"),value:e.webserver_host,onChange:e=>S("webserver_host",e),placeholder:"www.example.com",disabled:P("static","webserver_host"),help:D("static","webserver_host",(0,xe.__)("The public hostname WordPress expects, so rendered links use the public domain.","quant"))}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Disable SSL verification for the local webserver","quant"),checked:!!e.disable_tls_verify,onChange:e=>S("disable_tls_verify",e),disabled:P("credentials","disable_tls_verify"),help:D("credentials","disable_tls_verify",(0,xe.__)("Only for a local webserver with a self-signed certificate. Calls to the Quant API always verify TLS.","quant"))}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Strip host domain from content","quant"),checked:!!e.strip_host,onChange:e=>S("strip_host",e),help:(0,xe.__)("Rewrite absolute links to this site as relative links, and upgrade http:// links to https://.","quant")}),(0,t.createElement)(Se.TextareaControl,{label:(0,xe.__)("Additional domains to make relative","quant"),value:s,onChange:o,rows:3,placeholder:"cdn.example.com",help:(0,xe.__)("One per line. Useful for staging or editorial domains.","quant")}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Follow pagination links","quant"),checked:!!e.follow_links,onChange:e=>S("follow_links",e),help:(0,xe.__)("Queue the next pages of listings found in rendered markup.","quant")}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Override existing proxies","quant"),checked:!!e.proxy_override,onChange:e=>S("proxy_override",e),help:(0,xe.__)("Tell Quant that pushed pages replace proxy rules at the same path.","quant")})),"static"===e.mode&&(0,t.createElement)(Ke,{title:(0,xe.__)("Seed Content","quant"),description:(0,xe.__)("What a full push includes besides posts, pages, terms and media.","quant")},(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Theme assets","quant"),checked:!!e.theme_assets,onChange:e=>S("theme_assets",e),help:(0,xe.__)("CSS, JavaScript, images and fonts of the active theme and its parent.","quant")}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Sitemaps","quant"),checked:!!e.sitemap,onChange:e=>S("sitemap",e),help:(0,xe.__)("WordPress core sitemaps, Yoast SEO and Rank Math sitemaps with their stylesheets.","quant")}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("404 page path","quant"),value:e.route_404,onChange:e=>S("route_404",e),placeholder:"/404",help:(0,xe.__)("A path that returns your 404 page. It is published as the Quant 404 page.","quant")}),(0,t.createElement)(Se.TextareaControl,{label:(0,xe.__)("Custom routes","quant"),value:l,onChange:r,rows:4,placeholder:"/robots.txt\n/custom-page",help:(0,xe.__)("One per line. Content routes are rendered; paths that match a file on disk are uploaded.","quant")})),"static"===e.mode&&(0,t.createElement)(Ke,{title:(0,xe.__)("Scheduled Push","quant"),description:(0,xe.__)("Re-push chosen content on a schedule, for example for listings that change without a save.","quant")},(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Enable scheduled push","quant"),checked:!!e.schedule?.enabled,onChange:e=>N("enabled",e)}),e.schedule?.enabled&&(0,t.createElement)(t.Fragment,null,(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Schedule","quant"),value:e.schedule.interval,options:R,onChange:e=>N("interval",e)}),Ve.map(([a,n])=>(0,t.createElement)(Se.CheckboxControl,{key:a,label:n,checked:(e.schedule.types||[]).includes(a),onChange:t=>N("types",t?[...e.schedule.types||[],a]:(e.schedule.types||[]).filter(e=>e!==a))})),(0,t.createElement)(Se.TextareaControl,{label:(0,xe.__)("Custom routes","quant"),value:i,onChange:c,rows:3,placeholder:"/robots.txt"}))),(0,t.createElement)("div",{style:{marginTop:"16px"}},(0,t.createElement)(Se.Button,{variant:"primary",onClick:k,disabled:_,isBusy:_},(0,xe.__)("Save Settings","quant"))))}function tt(){const e=G(),[n,l]=(0,a.useState)(null),[r,s]=(0,a.useState)(!0),[o,i]=(0,a.useState)(!1),[c,u]=(0,a.useState)(null),[m,d]=(0,a.useState)(!1),[p,_]=(0,a.useState)(!1),[h,g]=(0,a.useState)(null),[E,y]=(0,a.useState)(null),[f,v]=(0,a.useState)("detect");(0,a.useEffect)(()=>{q()},[]);const q=async()=>{s(!0),y(null);try{const e=await ke()({path:"/quant/v1/migration/status"});l(e),e.migration_complete?v("complete"):e.needs_migration?v("preview"):e.has_v1_settings||v("no-migration")}catch(e){y(e.message||(0,xe.__)("Failed to check migration status","quant"))}finally{s(!1)}},b=async()=>{d(!0),u(null);try{await ke()({path:"/quant/v1/migration/cleanup",method:"POST"}),u({status:"success",message:(0,xe.__)("The 1.x settings were removed.","quant")});const e=await ke()({path:"/quant/v1/migration/status"});l(e)}catch(e){u({status:"error",message:e.message||(0,xe.__)("The 1.x settings could not be removed.","quant")})}finally{d(!1)}};return r?(0,t.createElement)("div",{className:"quant-migration"},(0,t.createElement)("h1",null,(0,xe.__)("Migration","quant")),(0,t.createElement)(Se.Card,null,(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("div",{style:{display:"flex",alignItems:"center",gap:"10px"}},(0,t.createElement)(Se.Spinner,null),(0,t.createElement)("span",null,(0,xe.__)("Checking for v1 settings…","quant")))))):"no-migration"===f?(0,t.createElement)("div",{className:"quant-migration"},(0,t.createElement)("h1",null,(0,xe.__)("Migration","quant")),(0,t.createElement)(Se.Notice,{status:"info",isDismissible:!1},(0,xe.__)("No v1 settings found. Nothing to migrate.","quant")),(0,t.createElement)("div",{style:{marginTop:"16px"}},(0,t.createElement)(Se.Button,{variant:"primary",onClick:()=>e("/settings")},(0,xe.__)("Go to Settings","quant")))):"complete"===f?(0,t.createElement)("div",{className:"quant-migration"},(0,t.createElement)("h1",null,(0,xe.__)("Migration Complete","quant")),c&&(0,t.createElement)(Se.Notice,{status:c.status,onRemove:()=>u(null),isDismissible:!0},c.message),(0,t.createElement)(Se.Notice,{status:"success",isDismissible:!1},(0,xe.__)("Your settings have been successfully migrated to v2!","quant")),h&&h.migrated&&(0,t.createElement)(Se.Card,{style:{marginTop:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Migration Summary","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("ul",{style:{margin:0,paddingLeft:"20px"}},h.migrated.credentials&&(0,t.createElement)("li",null,(0,xe.__)("Credentials migrated:","quant")," ",h.migrated.credentials.organization," ","/"," ",h.migrated.credentials.project),h.migrated.mode&&(0,t.createElement)("li",null,(0,xe.__)("Mode set to:","quant")," ",h.migrated.mode),h.migrated.migration_date&&(0,t.createElement)("li",null,(0,xe.__)("Migrated at:","quant")," ",h.migrated.migration_date)))),(0,t.createElement)(Se.Card,{style:{marginTop:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Next Steps","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("p",null,(0,xe.__)("Your migration is complete. Here are some recommended next steps:","quant")),(0,t.createElement)("ul",{style:{paddingLeft:"20px"}},(0,t.createElement)("li",null,(0,xe.__)("Verify your connection in Settings","quant")),(0,t.createElement)("li",null,(0,xe.__)("Review your content sync status","quant")),(0,t.createElement)("li",null,(0,xe.__)("Run a full site sync if needed","quant"))),(0,t.createElement)("div",{style:{display:"flex",gap:"10px",marginTop:"16px"}},(0,t.createElement)(Se.Button,{variant:"primary",onClick:()=>e("/dashboard")},(0,xe.__)("Go to Dashboard","quant")),(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>e("/settings")},(0,xe.__)("Review Settings","quant"))))),n&&n.has_v1_settings&&(0,t.createElement)(Se.Card,{style:{marginTop:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Cleanup","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("p",null,(0,xe.__)("Old v1 data still exists. You can clean it up now or later.","quant")),(0,t.createElement)(Se.Button,{variant:"secondary",onClick:b,disabled:m,isBusy:m},m?(0,xe.__)("Cleaning up…","quant"):(0,xe.__)("Clean Up v1 Data","quant"))))):(0,t.createElement)("div",{className:"quant-migration"},(0,t.createElement)("h1",null,(0,xe.__)("Migrate from v1","quant")),E&&(0,t.createElement)(Se.Notice,{status:"error",onRemove:()=>y(null),isDismissible:!0},E),(0,t.createElement)(Se.Notice,{status:"info",isDismissible:!1},(0,xe.__)("We detected settings from v1 of the QuantCDN plugin. Let's migrate them to v2.","quant")),(0,t.createElement)(Se.Card,{style:{marginTop:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Settings to Migrate","quant"))),(0,t.createElement)(Se.CardBody,null,n&&n.v1_settings&&(0,t.createElement)("table",{className:"widefat",style:{marginBottom:"16px"}},(0,t.createElement)("tbody",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Organization","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},n.v1_settings.organization||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Project","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},n.v1_settings.project||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("API Token","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},n.v1_settings.token||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Endpoint","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},n.v1_settings.endpoint||"https://api.quantcdn.io")))),n&&n.v1_queue_count>0&&(0,t.createElement)("p",{style:{color:"#666"}},(0,xe.sprintf)(/* translators: %d: number of v1 batch rows */ /* translators: %d: number of v1 batch rows */ -(0,xe.__)("Note: %d rows of 1.x batch state are still in wp_options. Cleanup removes them.","quant"),n.v1_queue_count)),(0,t.createElement)(Se.CheckboxControl,{label:(0,xe.__)("Remove old v1 data after migration","quant"),checked:p,onChange:_,help:(0,xe.__)("This will delete old options and tables after successful migration","quant")}))),(0,t.createElement)(Se.Card,{style:{marginTop:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("What will happen","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("ul",{style:{margin:0,paddingLeft:"20px"}},(0,t.createElement)("li",null,(0,xe.__)("Your API credentials will be copied to the new format","quant")),(0,t.createElement)("li",null,(0,xe.__)('Plugin mode will be set to "Static" (default)',"quant")),(0,t.createElement)("li",null,(0,xe.__)("Auto-sync and auto-purge will be enabled","quant")),(0,t.createElement)("li",null,(0,xe.__)("A new sync queue table will be used","quant"))))),(0,t.createElement)("div",{style:{marginTop:"16px",display:"flex",gap:"10px"}},(0,t.createElement)(Se.Button,{variant:"primary",onClick:async()=>{i(!0),y(null);try{const e=await ke()({path:"/quant/v1/migration/run",method:"POST"});g(e),p&&e.success&&await b(),v("complete")}catch(e){y(e.message||(0,xe.__)("Migration failed","quant"))}finally{i(!1)}},disabled:o,isBusy:o},o?(0,xe.__)("Migrating…","quant"):(0,xe.__)("Start Migration","quant")),(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>e("/settings"),disabled:o},(0,xe.__)("Skip & Configure Manually","quant"))))}function at({result:e}){return"success"===e?(0,t.createElement)("div",{style:{padding:"20px"}},(0,t.createElement)("span",{className:"dashicons dashicons-yes-alt",style:{fontSize:"48px",color:"#00a32a",display:"block",marginBottom:"15px"}}),(0,t.createElement)("p",{style:{fontSize:"18px",color:"#00a32a",margin:0}},(0,xe.__)("Connection successful!","quant"))):"error"===e?(0,t.createElement)("div",{style:{padding:"20px"}},(0,t.createElement)("span",{className:"dashicons dashicons-dismiss",style:{fontSize:"48px",color:"#d63638",display:"block",marginBottom:"15px"}}),(0,t.createElement)("p",{style:{fontSize:"18px",color:"#d63638",margin:0}},(0,xe.__)("Connection failed","quant"))):(0,t.createElement)("div",{style:{padding:"20px"}},(0,t.createElement)("p",{style:{color:"#666",marginTop:0}},(0,xe.__)("Let's verify your connection to QuantCDN.","quant")))}const nt=[{label:"QuantCDN",value:"https://api.quantcdn.io"},{label:"QuantGov",value:"https://api.quantgov.cloud"}];function lt(){const e=G(),[n,l]=(0,a.useState)(1),[r,s]=(0,a.useState)(!0),[o,i]=(0,a.useState)(!1),[c,u]=(0,a.useState)(!1),[m,d]=(0,a.useState)(null),[p,_]=(0,a.useState)(null),[h,g]=(0,a.useState)({organization:"",project:"",token:"",endpoint:"https://api.quantcdn.io",mode:"static"});(0,a.useEffect)(()=>{E()},[]);const E=async()=>{try{const t=await ke()({path:"/quant/v1/onboarding/status"});if("migration"===t.state)return void e("/migration");"configured"===t.state&&e("/dashboard")}catch(e){}finally{s(!1)}},y=(e,t)=>{g(a=>({...a,[e]:t}))},f=async()=>{i(!0),d(null);try{await ke()({path:"/quant/v1/settings",method:"POST",data:h}),l(3)}catch(e){d(e.message||(0,xe.__)("Failed to save credentials","quant"))}finally{i(!1)}},v=async()=>{i(!0),d(null);try{await ke()({path:"/quant/v1/settings",method:"POST",data:{endpoint:h.endpoint,mode:h.mode}});const e=await ke()({path:"/quant/v1/oauth/connect"});if(e.redirect_url)return void(window.location.href=e.redirect_url);d((0,xe.__)("The login could not be started.","quant"))}catch(e){d(e.message||(0,xe.__)("The login could not be started.","quant"))}i(!1)},q=nt.find(e=>e.value===h.endpoint)?h.endpoint:"custom",b=async()=>{i(!0),d(null);try{await ke()({path:"/quant/v1/settings",method:"POST",data:{mode:h.mode}}),l(4)}catch(e){d(e.message||(0,xe.__)("Failed to save mode","quant"))}finally{i(!1)}},C=async()=>{u(!0),d(null),_(null);try{(await ke()({path:"/quant/v1/status"})).connected?(_("success"),setTimeout(()=>l(5),1500)):(_("error"),d((0,xe.__)("Connection failed. Please check your credentials.","quant")))}catch(e){_("error"),d(e.message||(0,xe.__)("Connection test failed","quant"))}finally{u(!1)}};return r?(0,t.createElement)("div",{className:"quant-onboarding"},(0,t.createElement)("div",{style:{display:"flex",alignItems:"center",justifyContent:"center",minHeight:"200px"}},(0,t.createElement)(Se.Spinner,null))):(0,t.createElement)("div",{className:"quant-onboarding",style:{maxWidth:"700px",margin:"0 auto"}},n>=2&&n<=4&&(0,t.createElement)("div",{style:{display:"flex",justifyContent:"center",gap:"10px",marginBottom:"30px"}},[2,3,4].map(e=>(0,t.createElement)("div",{key:e,style:{width:"12px",height:"12px",borderRadius:"50%",backgroundColor:n>=e?"#5c40ff":"#ddd"}}))),(()=>{switch(n){case 1:return(0,t.createElement)(Se.Card,{className:"quant-onboarding__card"},(0,t.createElement)(Se.CardBody,{style:{textAlign:"center",padding:"40px"}},(0,t.createElement)("svg",{width:"64",height:"64",viewBox:"0 0 32 32",fill:"none",style:{marginBottom:"20px"}},(0,t.createElement)("rect",{width:"32",height:"32",rx:"6",fill:"#5c40ff"}),(0,t.createElement)("text",{x:"50%",y:"55%",dominantBaseline:"middle",textAnchor:"middle",fill:"white",fontSize:"16",fontWeight:"bold"},"Q")),(0,t.createElement)("h1",{style:{marginBottom:"10px"}},(0,xe.__)("Welcome to QuantCDN","quant")),(0,t.createElement)("p",{style:{color:"#666",fontSize:"16px",maxWidth:"500px",margin:"0 auto 30px"}},(0,xe.__)("Let's get your WordPress site connected to QuantCDN. This wizard will guide you through the setup process.","quant")),(0,t.createElement)(Se.Button,{variant:"primary",onClick:()=>l(2),style:{padding:"10px 30px"}},(0,xe.__)("Get Started","quant"))));case 2:return(0,t.createElement)(Se.Card,{className:"quant-onboarding__card"},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Step 1: API Credentials","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)(Oe,null,m&&(0,t.createElement)(Se.Notice,{status:"error",onRemove:()=>d(null),isDismissible:!0},m),(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Platform","quant"),value:q,options:nt.concat([{label:(0,xe.__)("Other endpoint","quant"),value:"custom"}]),onChange:e=>{"custom"!==e&&y("endpoint",e)},help:(0,xe.__)("Choose the dashboard you sign in to.","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)("div",{className:"quant-onboarding__oauth"},(0,t.createElement)(Se.Button,{variant:"primary",icon:"admin-users",onClick:v,disabled:o,isBusy:o},(0,xe.__)("Login to Quant","quant")),(0,t.createElement)("p",{style:{color:"#666"}},(0,xe.__)("Sign in and pick the project, or enter the project credentials below.","quant"))),(0,t.createElement)("p",{style:{color:"#666",marginTop:0}},(0,xe.__)("Or enter the project credentials. You can find them in the","quant")," ",(0,t.createElement)("a",{href:"https://api.quantgov.cloud"===q?"https://dash.quantgov.cloud":"https://dashboard.quantcdn.io",target:"_blank",rel:"noopener noreferrer"},(0,xe.__)("Quant dashboard","quant"))," ",(0,xe.__)("on the Integrations page of the project.","quant")),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Organization","quant"),value:h.organization,onChange:e=>y("organization",e),help:(0,xe.__)("Your QuantCDN organization identifier","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Project","quant"),value:h.project,onChange:e=>y("project",e),help:(0,xe.__)("Your QuantCDN project name","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("API Token","quant"),type:"password",value:h.token,onChange:e=>y("token",e),help:(0,xe.__)("Your project API token","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("API Endpoint","quant"),value:h.endpoint,onChange:e=>y("endpoint",e),help:(0,xe.__)("https://api.quantcdn.io for QuantCDN, https://api.quantgov.cloud for QuantGov. Without /v1.","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)("div",{style:{display:"flex",gap:"10px"}},(0,t.createElement)(Se.Button,{variant:"primary",onClick:f,disabled:o||!h.organization||!h.project||!h.token,isBusy:o},o?(0,xe.__)("Saving…","quant"):(0,xe.__)("Continue","quant"))))));case 3:return(0,t.createElement)(Se.Card,{className:"quant-onboarding__card"},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Step 2: Choose Mode","quant"))),(0,t.createElement)(Se.CardBody,null,m&&(0,t.createElement)(Se.Notice,{status:"error",onRemove:()=>d(null),isDismissible:!0},m),(0,t.createElement)("p",{style:{color:"#666",marginTop:0}},(0,xe.__)("Choose how you want to use QuantCDN with your WordPress site.","quant")),(0,t.createElement)("div",{style:{marginBottom:"20px"}},(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Plugin Mode","quant"),value:h.mode,options:[{label:(0,xe.__)("Static Site (Recommended)","quant"),value:"static"},{label:(0,xe.__)("CDN Only","quant"),value:"cdn_only"}],onChange:e=>y("mode",e)})),(0,t.createElement)("div",{style:{backgroundColor:"#f0f0f0",padding:"15px",borderRadius:"4px",marginBottom:"20px"}},"static"===h.mode?(0,t.createElement)(t.Fragment,null,(0,t.createElement)("strong",null,(0,xe.__)("Static Site Mode","quant")),(0,t.createElement)("p",{style:{margin:"10px 0 0",color:"#666"}},(0,xe.__)("Your entire site is rendered and served from QuantCDN's edge. Pages are synced automatically when you publish or update content. Ideal for maximum performance and security.","quant"))):(0,t.createElement)(t.Fragment,null,(0,t.createElement)("strong",null,(0,xe.__)("CDN Only Mode","quant")),(0,t.createElement)("p",{style:{margin:"10px 0 0",color:"#666"}},(0,xe.__)("WordPress serves dynamic content while QuantCDN handles caching. Cache is automatically purged when content changes. Good for sites that need dynamic features.","quant")))),(0,t.createElement)("div",{style:{display:"flex",gap:"10px"}},(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>l(2)},(0,xe.__)("Back","quant")),(0,t.createElement)(Se.Button,{variant:"primary",onClick:b,disabled:o,isBusy:o},o?(0,xe.__)("Saving…","quant"):(0,xe.__)("Continue","quant")))));case 4:return(0,t.createElement)(Se.Card,{className:"quant-onboarding__card"},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Step 3: Test Connection","quant"))),(0,t.createElement)(Se.CardBody,{style:{textAlign:"center"}},m&&(0,t.createElement)(Se.Notice,{status:"error",onRemove:()=>d(null),isDismissible:!0},m),(0,t.createElement)(at,{result:p}),(0,t.createElement)("div",{style:{display:"flex",justifyContent:"center",gap:"10px",marginTop:"20px"}},(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>l(2),disabled:c},(0,xe.__)("Back to Credentials","quant")),(0,t.createElement)(Se.Button,{variant:"primary",onClick:C,disabled:c,isBusy:c},c?(0,xe.__)("Testing…","quant"):(0,xe.__)("Test Connection","quant")))));case 5:return(0,t.createElement)(Se.Card,{className:"quant-onboarding__card"},(0,t.createElement)(Se.CardBody,{style:{textAlign:"center",padding:"40px"}},(0,t.createElement)("span",{className:"dashicons dashicons-yes-alt",style:{fontSize:"64px",color:"#00a32a",display:"block",marginBottom:"20px"}}),(0,t.createElement)("h1",{style:{marginBottom:"10px"}},(0,xe.__)("You're All Set!","quant")),(0,t.createElement)("p",{style:{color:"#666",fontSize:"16px",maxWidth:"500px",margin:"0 auto 30px"}},"static"===h.mode?(0,xe.__)("Your site is now connected to QuantCDN. Head to the Content page to sync your first pages.","quant"):(0,xe.__)("Your site is now connected to QuantCDN. Cache will be automatically purged when you update content.","quant")),(0,t.createElement)("div",{style:{display:"flex",justifyContent:"center",gap:"10px"}},(0,t.createElement)(Se.Button,{variant:"primary",onClick:()=>e("/dashboard")},(0,xe.__)("Go to Dashboard","quant")),"static"===h.mode&&(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>e("/content")},(0,xe.__)("Sync Content","quant")))));default:return null}})())}function rt(){var e,n,l,r;const[s,o]=(0,a.useState)(null),[i,c]=(0,a.useState)(!0),[u,m]=(0,a.useState)(null),[d,p]=(0,a.useState)(!1);(0,a.useEffect)(()=>{_()},[]);const _=async()=>{c(!0),m(null);try{const e=await ke()({path:"/quant/v1/diagnostics"});o(e)}catch(e){m(e.message||(0,xe.__)("Failed to load diagnostics","quant"))}finally{c(!1)}},h=({success:e,successText:a,failText:n})=>(0,t.createElement)("span",{style:{display:"inline-block",padding:"2px 8px",borderRadius:"3px",fontSize:"12px",fontWeight:"500",backgroundColor:e?"#d4edda":"#f8d7da",color:e?"#155724":"#721c24"}},e?a:n);return i?(0,t.createElement)("div",{className:"quant-diagnostics"},(0,t.createElement)("h1",null,(0,xe.__)("Diagnostics","quant")),(0,t.createElement)(Se.Card,null,(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("div",{style:{display:"flex",alignItems:"center",gap:"10px"}},(0,t.createElement)(Se.Spinner,null),(0,t.createElement)("span",null,(0,xe.__)("Loading diagnostic information…","quant")))))):u?(0,t.createElement)("div",{className:"quant-diagnostics"},(0,t.createElement)("h1",null,(0,xe.__)("Diagnostics","quant")),(0,t.createElement)(Se.Notice,{status:"error",isDismissible:!1},u),(0,t.createElement)("div",{style:{marginTop:"16px"}},(0,t.createElement)(Se.Button,{variant:"secondary",onClick:_},(0,xe.__)("Retry","quant")))):(0,t.createElement)("div",{className:"quant-diagnostics"},(0,t.createElement)("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:"20px"}},(0,t.createElement)("h1",{style:{margin:0}},(0,xe.__)("Diagnostics","quant")),(0,t.createElement)("div",{style:{display:"flex",gap:"10px"}},(0,t.createElement)(Se.Button,{variant:"secondary",onClick:_},(0,xe.__)("Refresh","quant")),(0,t.createElement)(Se.Button,{variant:"primary",onClick:()=>{if(!s)return;p(!0);const e=(e=>{var t,a,n,l;const r=["=".repeat(60),"QuantCDN Diagnostic Report",`Generated: ${(new Date).toISOString()}`,"=".repeat(60),"","## Environment",`PHP Version: ${e.environment?.php_version||"Unknown"}`,`WordPress Version: ${e.environment?.wordpress_version||"Unknown"}`,`Plugin Version: ${e.environment?.plugin_version||"Unknown"}`,`PHP Memory Limit: ${e.environment?.php_memory_limit||"Unknown"}`,`PHP Max Execution Time: ${e.environment?.php_max_execution_time||"Unknown"}`,"Multisite: "+(e.environment?.is_multisite?"Yes":"No"),"","## Plugin Configuration",`Mode: ${e.plugin?.mode||"Unknown"}`,"Has Credentials: "+(e.plugin?.has_credentials?"Yes":"No"),`Organization: ${e.plugin?.organization||"Not set"}`,`Project: ${e.plugin?.project||"Not set"}`,`Endpoint: ${e.plugin?.endpoint||"Not set"}`,"","## API Connection","Connected: "+(e.api?.connected?"Yes":"No"),e.api?.error?`Error: ${e.api.error}`:"",e.api?.error_detail?`Error detail: ${e.api.error_detail}`:null,"","## Database","V2 Table Exists: "+(e.database?.v2_table_exists?"Yes":"No"),"V1 Table Exists: "+(e.database?.v1_table_exists?"Yes":"No"),"","## Queue Status",`Pending: ${null!==(t=e.queue?.pending)&&void 0!==t?t:"Unknown"}`,`Processing: ${null!==(a=e.queue?.processing)&&void 0!==a?a:"Unknown"}`,`Failed: ${null!==(n=e.queue?.failed)&&void 0!==n?n:"Unknown"}`,`Total: ${null!==(l=e.queue?.total)&&void 0!==l?l:"Unknown"}`,"","## Migration Status","Needs Migration: "+(e.migration?.needs_migration?"Yes":"No"),"Migration Complete: "+(e.migration?.migration_complete?"Yes":"No"),e.migration?.migration_date?`Migration Date: ${e.migration.migration_date}`:"",""];return r.push(...function(e){if(!e||0===e.length)return[];const t=["## Recent Errors"];return e.forEach((e,a)=>{t.push(`${a+1}. [${e.type}:${e.object_id}] ${e.error_message}`),e.error_detail&&t.push(` Detail: ${e.error_detail}`),t.push(` At: ${e.processed_at}`)}),t.push(""),t}(e.recent_errors)),r.push("=".repeat(60)),r.push("End of Report"),r.filter(e=>null!==e).join("\n")})(s),t=new Blob([e],{type:"text/plain"}),a=URL.createObjectURL(t),n=document.createElement("a");n.href=a,n.download=`quant-diagnostics-${(new Date).toISOString().split("T")[0]}.txt`,document.body.appendChild(n),n.click(),document.body.removeChild(n),URL.revokeObjectURL(a),p(!1)},disabled:d,isBusy:d},d?(0,xe.__)("Downloading…","quant"):(0,xe.__)("Download Report","quant")))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Environment","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("table",{className:"widefat",style:{margin:0}},(0,t.createElement)("tbody",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px",width:"200px"}},(0,xe.__)("PHP Version","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.php_version||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("WordPress Version","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.wordpress_version||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Plugin Version","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.plugin_version||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("PHP Memory Limit","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.php_memory_limit||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("PHP Max Execution Time","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.php_max_execution_time||"-"," ","seconds")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Multisite","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.is_multisite?(0,xe.__)("Yes","quant"):(0,xe.__)("No","quant"))))))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Plugin Configuration","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("table",{className:"widefat",style:{margin:0}},(0,t.createElement)("tbody",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px",width:"200px"}},(0,xe.__)("Mode","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},"static"===s?.plugin?.mode?(0,xe.__)("Static Site","quant"):(0,xe.__)("CDN Only","quant"))),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Credentials","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},(0,t.createElement)(h,{success:s?.plugin?.has_credentials,successText:(0,xe.__)("Configured","quant"),failText:(0,xe.__)("Not Configured","quant")}))),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Organization","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.plugin?.organization||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Project","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.plugin?.project||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Endpoint","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.plugin?.endpoint||"-")))))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("API Connection","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("div",{style:{display:"flex",alignItems:"center",gap:"10px"}},(0,t.createElement)(h,{success:s?.api?.connected,successText:(0,xe.__)("Connected","quant"),failText:(0,xe.__)("Not Connected","quant")}),s?.api?.error&&(0,t.createElement)("span",{style:{color:"#721c24"}},s.api.error)))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Database","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("table",{className:"widefat",style:{margin:0}},(0,t.createElement)("tbody",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px",width:"200px"}},(0,xe.__)("V2 Sync Queue Table","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},(0,t.createElement)(h,{success:s?.database?.v2_table_exists,successText:(0,xe.__)("Exists","quant"),failText:(0,xe.__)("Missing","quant")}))),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("V1 Queue Table","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.database?.v1_table_exists?(0,t.createElement)("span",{style:{color:"#856404"}},(0,xe.__)("Exists (can be cleaned up)","quant")):(0,t.createElement)("span",{style:{color:"#666"}},(0,xe.__)("Not present","quant")))))))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Queue Status","quant"))),(0,t.createElement)(Se.CardBody,null,s?.queue?.error?(0,t.createElement)(Se.Notice,{status:"error",isDismissible:!1},s.queue.error):(0,t.createElement)("div",{style:{display:"flex",gap:"30px"}},(0,t.createElement)("div",{style:{textAlign:"center"}},(0,t.createElement)("div",{style:{fontSize:"24px",fontWeight:"bold",color:"#0073aa"}},null!==(e=s?.queue?.pending)&&void 0!==e?e:0),(0,t.createElement)("div",{style:{fontSize:"12px",color:"#666"}},(0,xe.__)("Pending","quant"))),(0,t.createElement)("div",{style:{textAlign:"center"}},(0,t.createElement)("div",{style:{fontSize:"24px",fontWeight:"bold",color:"#f0b849"}},null!==(n=s?.queue?.processing)&&void 0!==n?n:0),(0,t.createElement)("div",{style:{fontSize:"12px",color:"#666"}},(0,xe.__)("Processing","quant"))),(0,t.createElement)("div",{style:{textAlign:"center"}},(0,t.createElement)("div",{style:{fontSize:"24px",fontWeight:"bold",color:"#d63638"}},null!==(l=s?.queue?.failed)&&void 0!==l?l:0),(0,t.createElement)("div",{style:{fontSize:"12px",color:"#666"}},(0,xe.__)("Failed","quant"))),(0,t.createElement)("div",{style:{textAlign:"center"}},(0,t.createElement)("div",{style:{fontSize:"24px",fontWeight:"bold",color:"#666"}},null!==(r=s?.queue?.total)&&void 0!==r?r:0),(0,t.createElement)("div",{style:{fontSize:"12px",color:"#666"}},(0,xe.__)("Total","quant")))))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Migration Status","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("table",{className:"widefat",style:{margin:0}},(0,t.createElement)("tbody",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px",width:"200px"}},(0,xe.__)("Needs Migration","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.migration?.needs_migration?(0,t.createElement)("a",{href:"#/migration"},(0,xe.__)("Yes - Click to migrate","quant")):(0,xe.__)("No","quant"))),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Migration Complete","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.migration?.migration_complete?(0,xe.__)("Yes","quant"):(0,xe.__)("No","quant"))),s?.migration?.migration_date&&(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Migration Date","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s.migration.migration_date)))))),s?.recent_errors&&s.recent_errors.length>0&&(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Recent Errors","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("table",{className:"widefat",style:{margin:0}},(0,t.createElement)("thead",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Type","quant")),(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("ID","quant")),(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Error","quant")),(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Time","quant")))),(0,t.createElement)("tbody",null,s.recent_errors.map((e,a)=>(0,t.createElement)("tr",{key:a},(0,t.createElement)("td",{style:{padding:"8px"}},e.type),(0,t.createElement)("td",{style:{padding:"8px"}},e.object_id),(0,t.createElement)("td",{style:{padding:"8px",color:"#721c24"}},e.error_message),(0,t.createElement)("td",{style:{padding:"8px",whiteSpace:"nowrap"}},e.processed_at))))))))}function st(){return(0,t.createElement)(Ee,null,(0,t.createElement)(we,null,(0,t.createElement)(ce,null,(0,t.createElement)(oe,{path:"/",element:(0,t.createElement)(se,{to:"/dashboard",replace:!0})}),(0,t.createElement)(oe,{path:"/dashboard",element:(0,t.createElement)(Be,null)}),(0,t.createElement)(oe,{path:"/content",element:(0,t.createElement)(Ae,null)}),(0,t.createElement)(oe,{path:"/cache",element:(0,t.createElement)(Fe,null)}),(0,t.createElement)(oe,{path:"/search",element:(0,t.createElement)(Ye,null)}),(0,t.createElement)(oe,{path:"/settings",element:(0,t.createElement)(et,null)}),(0,t.createElement)(oe,{path:"/migration",element:(0,t.createElement)(tt,null)}),(0,t.createElement)(oe,{path:"/onboarding",element:(0,t.createElement)(lt,null)}),(0,t.createElement)(oe,{path:"/diagnostics",element:(0,t.createElement)(rt,null)}))))}const ot=document.getElementById("quant-admin-root");ot&&("function"==typeof a.createRoot?a.createRoot(ot).render((0,t.createElement)(st,null)):a.render((0,t.createElement)(st,null),ot))})(); \ No newline at end of file +(0,xe.__)("Note: %d rows of 1.x batch state are still in wp_options. Cleanup removes them.","quant"),n.v1_queue_count)),(0,t.createElement)(Se.CheckboxControl,{label:(0,xe.__)("Remove old v1 data after migration","quant"),checked:p,onChange:_,help:(0,xe.__)("This will delete old options and tables after successful migration","quant")}))),(0,t.createElement)(Se.Card,{style:{marginTop:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("What will happen","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("ul",{style:{margin:0,paddingLeft:"20px"}},(0,t.createElement)("li",null,(0,xe.__)("Your API credentials will be copied to the new format","quant")),(0,t.createElement)("li",null,(0,xe.__)('Plugin mode will be set to "Static" (default)',"quant")),(0,t.createElement)("li",null,(0,xe.__)("Auto-sync and auto-purge will be enabled","quant")),(0,t.createElement)("li",null,(0,xe.__)("A new sync queue table will be used","quant"))))),(0,t.createElement)("div",{style:{marginTop:"16px",display:"flex",gap:"10px"}},(0,t.createElement)(Se.Button,{variant:"primary",onClick:async()=>{i(!0),y(null);try{const e=await ke()({path:"/quant/v1/migration/run",method:"POST"});g(e),p&&e.success&&await b(),v("complete")}catch(e){y(e.message||(0,xe.__)("Migration failed","quant"))}finally{i(!1)}},disabled:o,isBusy:o},o?(0,xe.__)("Migrating…","quant"):(0,xe.__)("Start Migration","quant")),(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>e("/settings"),disabled:o},(0,xe.__)("Skip & Configure Manually","quant"))))}function at({result:e}){return"success"===e?(0,t.createElement)("div",{style:{padding:"20px"}},(0,t.createElement)("span",{className:"dashicons dashicons-yes-alt",style:{fontSize:"48px",color:"#00a32a",display:"block",marginBottom:"15px"}}),(0,t.createElement)("p",{style:{fontSize:"18px",color:"#00a32a",margin:0}},(0,xe.__)("Connection successful!","quant"))):"error"===e?(0,t.createElement)("div",{style:{padding:"20px"}},(0,t.createElement)("span",{className:"dashicons dashicons-dismiss",style:{fontSize:"48px",color:"#d63638",display:"block",marginBottom:"15px"}}),(0,t.createElement)("p",{style:{fontSize:"18px",color:"#d63638",margin:0}},(0,xe.__)("Connection failed","quant"))):(0,t.createElement)("div",{style:{padding:"20px"}},(0,t.createElement)("p",{style:{color:"#666",marginTop:0}},(0,xe.__)("Let's verify your connection to QuantCDN.","quant")))}const nt=[{label:"QuantCDN",value:"https://api.quantcdn.io"},{label:"QuantGov",value:"https://api.quantgov.cloud"}];function lt(){const e=G(),[n,l]=(0,a.useState)(1),[r,s]=(0,a.useState)(!0),[o,i]=(0,a.useState)(!1),[c,u]=(0,a.useState)(!1),[m,d]=(0,a.useState)(null),[p,_]=(0,a.useState)(null),[h,g]=(0,a.useState)({organization:"",project:"",token:"",endpoint:"https://api.quantcdn.io",mode:"static"});(0,a.useEffect)(()=>{E()},[]);const E=async()=>{try{const t=await ke()({path:"/quant/v1/onboarding/status"});if("migration"===t.state)return void e("/migration");"configured"===t.state&&e("/dashboard")}catch(e){}finally{s(!1)}},y=(e,t)=>{g(a=>({...a,[e]:t}))},f=async()=>{i(!0),d(null);try{await ke()({path:"/quant/v1/settings",method:"POST",data:h}),l(3)}catch(e){d(e.message||(0,xe.__)("Failed to save credentials","quant"))}finally{i(!1)}},v=async()=>{i(!0),d(null);try{await ke()({path:"/quant/v1/settings",method:"POST",data:{endpoint:h.endpoint,mode:h.mode}});const e=await ke()({path:"/quant/v1/oauth/connect"});if(e.redirect_url)return void(window.location.href=e.redirect_url);d((0,xe.__)("The login could not be started.","quant"))}catch(e){d(e.message||(0,xe.__)("The login could not be started.","quant"))}i(!1)},q=nt.find(e=>e.value===h.endpoint)?h.endpoint:"custom",b=async()=>{i(!0),d(null);try{await ke()({path:"/quant/v1/settings",method:"POST",data:{mode:h.mode}}),l(4)}catch(e){d(e.message||(0,xe.__)("Failed to save mode","quant"))}finally{i(!1)}},C=async()=>{u(!0),d(null),_(null);try{(await ke()({path:"/quant/v1/status"})).connected?(_("success"),setTimeout(()=>l(5),1500)):(_("error"),d((0,xe.__)("Connection failed. Please check your credentials.","quant")))}catch(e){_("error"),d(e.message||(0,xe.__)("Connection test failed","quant"))}finally{u(!1)}};return r?(0,t.createElement)("div",{className:"quant-onboarding"},(0,t.createElement)("div",{style:{display:"flex",alignItems:"center",justifyContent:"center",minHeight:"200px"}},(0,t.createElement)(Se.Spinner,null))):(0,t.createElement)("div",{className:"quant-onboarding",style:{maxWidth:"700px",margin:"0 auto"}},n>=2&&n<=4&&(0,t.createElement)("div",{style:{display:"flex",justifyContent:"center",gap:"10px",marginBottom:"30px"}},[2,3,4].map(e=>(0,t.createElement)("div",{key:e,style:{width:"12px",height:"12px",borderRadius:"50%",backgroundColor:n>=e?"#5c40ff":"#ddd"}}))),(()=>{switch(n){case 1:return(0,t.createElement)(Se.Card,{className:"quant-onboarding__card"},(0,t.createElement)(Se.CardBody,{style:{textAlign:"center",padding:"40px"}},(0,t.createElement)("svg",{width:"64",height:"64",viewBox:"0 0 32 32",fill:"none",style:{marginBottom:"20px"}},(0,t.createElement)("rect",{width:"32",height:"32",rx:"6",fill:"#5c40ff"}),(0,t.createElement)("text",{x:"50%",y:"55%",dominantBaseline:"middle",textAnchor:"middle",fill:"white",fontSize:"16",fontWeight:"bold"},"Q")),(0,t.createElement)("h1",{style:{marginBottom:"10px"}},(0,xe.__)("Welcome to QuantCDN","quant")),(0,t.createElement)("p",{style:{color:"#666",fontSize:"16px",maxWidth:"500px",margin:"0 auto 30px"}},(0,xe.__)("Let's get your WordPress site connected to QuantCDN. This wizard will guide you through the setup process.","quant")),(0,t.createElement)(Se.Button,{variant:"primary",onClick:()=>l(2),style:{padding:"10px 30px"}},(0,xe.__)("Get Started","quant"))));case 2:return(0,t.createElement)(Se.Card,{className:"quant-onboarding__card"},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Step 1: API Credentials","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)(Fe,null,m&&(0,t.createElement)(Se.Notice,{status:"error",onRemove:()=>d(null),isDismissible:!0},m),(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Platform","quant"),value:q,options:nt.concat([{label:(0,xe.__)("Other endpoint","quant"),value:"custom"}]),onChange:e=>{"custom"!==e&&y("endpoint",e)},help:(0,xe.__)("Choose the dashboard you sign in to.","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)("div",{className:"quant-onboarding__oauth"},(0,t.createElement)(Se.Button,{variant:"primary",icon:"admin-users",onClick:v,disabled:o,isBusy:o},(0,xe.__)("Login to Quant","quant")),(0,t.createElement)("p",{style:{color:"#666"}},(0,xe.__)("Sign in and pick the project, or enter the project credentials below.","quant"))),(0,t.createElement)("p",{style:{color:"#666",marginTop:0}},(0,xe.__)("Or enter the project credentials. You can find them in the","quant")," ",(0,t.createElement)("a",{href:"https://api.quantgov.cloud"===q?"https://dash.quantgov.cloud":"https://dashboard.quantcdn.io",target:"_blank",rel:"noopener noreferrer"},(0,xe.__)("Quant dashboard","quant"))," ",(0,xe.__)("on the Integrations page of the project.","quant")),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Organization","quant"),value:h.organization,onChange:e=>y("organization",e),help:(0,xe.__)("Your QuantCDN organization identifier","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Project","quant"),value:h.project,onChange:e=>y("project",e),help:(0,xe.__)("Your QuantCDN project name","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("API Token","quant"),type:"password",value:h.token,onChange:e=>y("token",e),help:(0,xe.__)("Your project API token","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("API Endpoint","quant"),value:h.endpoint,onChange:e=>y("endpoint",e),help:(0,xe.__)("https://api.quantcdn.io for QuantCDN, https://api.quantgov.cloud for QuantGov. Without /v1.","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)("div",{style:{display:"flex",gap:"10px"}},(0,t.createElement)(Se.Button,{variant:"primary",onClick:f,disabled:o||!h.organization||!h.project||!h.token,isBusy:o},o?(0,xe.__)("Saving…","quant"):(0,xe.__)("Continue","quant"))))));case 3:return(0,t.createElement)(Se.Card,{className:"quant-onboarding__card"},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Step 2: Choose Mode","quant"))),(0,t.createElement)(Se.CardBody,null,m&&(0,t.createElement)(Se.Notice,{status:"error",onRemove:()=>d(null),isDismissible:!0},m),(0,t.createElement)("p",{style:{color:"#666",marginTop:0}},(0,xe.__)("Choose how you want to use QuantCDN with your WordPress site.","quant")),(0,t.createElement)("div",{style:{marginBottom:"20px"}},(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Plugin Mode","quant"),value:h.mode,options:[{label:(0,xe.__)("Static Site (Recommended)","quant"),value:"static"},{label:(0,xe.__)("CDN Only","quant"),value:"cdn_only"}],onChange:e=>y("mode",e)})),(0,t.createElement)("div",{style:{backgroundColor:"#f0f0f0",padding:"15px",borderRadius:"4px",marginBottom:"20px"}},"static"===h.mode?(0,t.createElement)(t.Fragment,null,(0,t.createElement)("strong",null,(0,xe.__)("Static Site Mode","quant")),(0,t.createElement)("p",{style:{margin:"10px 0 0",color:"#666"}},(0,xe.__)("Your entire site is rendered and served from QuantCDN's edge. Pages are synced automatically when you publish or update content. Ideal for maximum performance and security.","quant"))):(0,t.createElement)(t.Fragment,null,(0,t.createElement)("strong",null,(0,xe.__)("CDN Only Mode","quant")),(0,t.createElement)("p",{style:{margin:"10px 0 0",color:"#666"}},(0,xe.__)("WordPress serves dynamic content while QuantCDN handles caching. Cache is automatically purged when content changes. Good for sites that need dynamic features.","quant")))),(0,t.createElement)("div",{style:{display:"flex",gap:"10px"}},(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>l(2)},(0,xe.__)("Back","quant")),(0,t.createElement)(Se.Button,{variant:"primary",onClick:b,disabled:o,isBusy:o},o?(0,xe.__)("Saving…","quant"):(0,xe.__)("Continue","quant")))));case 4:return(0,t.createElement)(Se.Card,{className:"quant-onboarding__card"},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Step 3: Test Connection","quant"))),(0,t.createElement)(Se.CardBody,{style:{textAlign:"center"}},m&&(0,t.createElement)(Se.Notice,{status:"error",onRemove:()=>d(null),isDismissible:!0},m),(0,t.createElement)(at,{result:p}),(0,t.createElement)("div",{style:{display:"flex",justifyContent:"center",gap:"10px",marginTop:"20px"}},(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>l(2),disabled:c},(0,xe.__)("Back to Credentials","quant")),(0,t.createElement)(Se.Button,{variant:"primary",onClick:C,disabled:c,isBusy:c},c?(0,xe.__)("Testing…","quant"):(0,xe.__)("Test Connection","quant")))));case 5:return(0,t.createElement)(Se.Card,{className:"quant-onboarding__card"},(0,t.createElement)(Se.CardBody,{style:{textAlign:"center",padding:"40px"}},(0,t.createElement)("span",{className:"dashicons dashicons-yes-alt",style:{fontSize:"64px",color:"#00a32a",display:"block",marginBottom:"20px"}}),(0,t.createElement)("h1",{style:{marginBottom:"10px"}},(0,xe.__)("You're All Set!","quant")),(0,t.createElement)("p",{style:{color:"#666",fontSize:"16px",maxWidth:"500px",margin:"0 auto 30px"}},"static"===h.mode?(0,xe.__)("Your site is now connected to QuantCDN. Head to the Content page to sync your first pages.","quant"):(0,xe.__)("Your site is now connected to QuantCDN. Cache will be automatically purged when you update content.","quant")),(0,t.createElement)("div",{style:{display:"flex",justifyContent:"center",gap:"10px"}},(0,t.createElement)(Se.Button,{variant:"primary",onClick:()=>e("/dashboard")},(0,xe.__)("Go to Dashboard","quant")),"static"===h.mode&&(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>e("/content")},(0,xe.__)("Sync Content","quant")))));default:return null}})())}function rt(){var e,n,l,r;const[s,o]=(0,a.useState)(null),[i,c]=(0,a.useState)(!0),[u,m]=(0,a.useState)(null),[d,p]=(0,a.useState)(!1);(0,a.useEffect)(()=>{_()},[]);const _=async()=>{c(!0),m(null);try{const e=await ke()({path:"/quant/v1/diagnostics"});o(e)}catch(e){m(e.message||(0,xe.__)("Failed to load diagnostics","quant"))}finally{c(!1)}},h=({success:e,successText:a,failText:n})=>(0,t.createElement)("span",{style:{display:"inline-block",padding:"2px 8px",borderRadius:"3px",fontSize:"12px",fontWeight:"500",backgroundColor:e?"#d4edda":"#f8d7da",color:e?"#155724":"#721c24"}},e?a:n);return i?(0,t.createElement)("div",{className:"quant-diagnostics"},(0,t.createElement)("h1",null,(0,xe.__)("Diagnostics","quant")),(0,t.createElement)(Se.Card,null,(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("div",{style:{display:"flex",alignItems:"center",gap:"10px"}},(0,t.createElement)(Se.Spinner,null),(0,t.createElement)("span",null,(0,xe.__)("Loading diagnostic information…","quant")))))):u?(0,t.createElement)("div",{className:"quant-diagnostics"},(0,t.createElement)("h1",null,(0,xe.__)("Diagnostics","quant")),(0,t.createElement)(Se.Notice,{status:"error",isDismissible:!1},u),(0,t.createElement)("div",{style:{marginTop:"16px"}},(0,t.createElement)(Se.Button,{variant:"secondary",onClick:_},(0,xe.__)("Retry","quant")))):(0,t.createElement)("div",{className:"quant-diagnostics"},(0,t.createElement)("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:"20px"}},(0,t.createElement)("h1",{style:{margin:0}},(0,xe.__)("Diagnostics","quant")),(0,t.createElement)("div",{style:{display:"flex",gap:"10px"}},(0,t.createElement)(Se.Button,{variant:"secondary",onClick:_},(0,xe.__)("Refresh","quant")),(0,t.createElement)(Se.Button,{variant:"primary",onClick:()=>{if(!s)return;p(!0);const e=(e=>{var t,a,n,l;const r=["=".repeat(60),"QuantCDN Diagnostic Report",`Generated: ${(new Date).toISOString()}`,"=".repeat(60),"","## Environment",`PHP Version: ${e.environment?.php_version||"Unknown"}`,`WordPress Version: ${e.environment?.wordpress_version||"Unknown"}`,`Plugin Version: ${e.environment?.plugin_version||"Unknown"}`,`PHP Memory Limit: ${e.environment?.php_memory_limit||"Unknown"}`,`PHP Max Execution Time: ${e.environment?.php_max_execution_time||"Unknown"}`,"Multisite: "+(e.environment?.is_multisite?"Yes":"No"),"","## Plugin Configuration",`Mode: ${e.plugin?.mode||"Unknown"}`,"Has Credentials: "+(e.plugin?.has_credentials?"Yes":"No"),`Organization: ${e.plugin?.organization||"Not set"}`,`Project: ${e.plugin?.project||"Not set"}`,`Endpoint: ${e.plugin?.endpoint||"Not set"}`,"","## API Connection","Connected: "+(e.api?.connected?"Yes":"No"),e.api?.error?`Error: ${e.api.error}`:"",e.api?.error_detail?`Error detail: ${e.api.error_detail}`:null,"","## Database","V2 Table Exists: "+(e.database?.v2_table_exists?"Yes":"No"),"V1 Table Exists: "+(e.database?.v1_table_exists?"Yes":"No"),"","## Queue Status",`Pending: ${null!==(t=e.queue?.pending)&&void 0!==t?t:"Unknown"}`,`Processing: ${null!==(a=e.queue?.processing)&&void 0!==a?a:"Unknown"}`,`Failed: ${null!==(n=e.queue?.failed)&&void 0!==n?n:"Unknown"}`,`Total: ${null!==(l=e.queue?.total)&&void 0!==l?l:"Unknown"}`,"","## Migration Status","Needs Migration: "+(e.migration?.needs_migration?"Yes":"No"),"Migration Complete: "+(e.migration?.migration_complete?"Yes":"No"),e.migration?.migration_date?`Migration Date: ${e.migration.migration_date}`:"",""];return r.push(...function(e){if(!e||0===e.length)return[];const t=["## Recent Errors"];return e.forEach((e,a)=>{t.push(`${a+1}. [${e.type}:${e.object_id}] ${e.error_message}`),e.error_detail&&t.push(` Detail: ${e.error_detail}`),t.push(` At: ${e.processed_at}`)}),t.push(""),t}(e.recent_errors)),r.push("=".repeat(60)),r.push("End of Report"),r.filter(e=>null!==e).join("\n")})(s),t=new Blob([e],{type:"text/plain"}),a=URL.createObjectURL(t),n=document.createElement("a");n.href=a,n.download=`quant-diagnostics-${(new Date).toISOString().split("T")[0]}.txt`,document.body.appendChild(n),n.click(),document.body.removeChild(n),URL.revokeObjectURL(a),p(!1)},disabled:d,isBusy:d},d?(0,xe.__)("Downloading…","quant"):(0,xe.__)("Download Report","quant")))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Environment","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("table",{className:"widefat",style:{margin:0}},(0,t.createElement)("tbody",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px",width:"200px"}},(0,xe.__)("PHP Version","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.php_version||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("WordPress Version","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.wordpress_version||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Plugin Version","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.plugin_version||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("PHP Memory Limit","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.php_memory_limit||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("PHP Max Execution Time","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.php_max_execution_time||"-"," ","seconds")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Multisite","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.is_multisite?(0,xe.__)("Yes","quant"):(0,xe.__)("No","quant"))))))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Plugin Configuration","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("table",{className:"widefat",style:{margin:0}},(0,t.createElement)("tbody",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px",width:"200px"}},(0,xe.__)("Mode","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},"static"===s?.plugin?.mode?(0,xe.__)("Static Site","quant"):(0,xe.__)("CDN Only","quant"))),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Credentials","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},(0,t.createElement)(h,{success:s?.plugin?.has_credentials,successText:(0,xe.__)("Configured","quant"),failText:(0,xe.__)("Not Configured","quant")}))),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Organization","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.plugin?.organization||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Project","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.plugin?.project||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Endpoint","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.plugin?.endpoint||"-")))))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("API Connection","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("div",{style:{display:"flex",alignItems:"center",gap:"10px"}},(0,t.createElement)(h,{success:s?.api?.connected,successText:(0,xe.__)("Connected","quant"),failText:(0,xe.__)("Not Connected","quant")}),s?.api?.error&&(0,t.createElement)("span",{style:{color:"#721c24"}},s.api.error)))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Database","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("table",{className:"widefat",style:{margin:0}},(0,t.createElement)("tbody",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px",width:"200px"}},(0,xe.__)("V2 Sync Queue Table","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},(0,t.createElement)(h,{success:s?.database?.v2_table_exists,successText:(0,xe.__)("Exists","quant"),failText:(0,xe.__)("Missing","quant")}))),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("V1 Queue Table","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.database?.v1_table_exists?(0,t.createElement)("span",{style:{color:"#856404"}},(0,xe.__)("Exists (can be cleaned up)","quant")):(0,t.createElement)("span",{style:{color:"#666"}},(0,xe.__)("Not present","quant")))))))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Queue Status","quant"))),(0,t.createElement)(Se.CardBody,null,s?.queue?.error?(0,t.createElement)(Se.Notice,{status:"error",isDismissible:!1},s.queue.error):(0,t.createElement)("div",{style:{display:"flex",gap:"30px"}},(0,t.createElement)("div",{style:{textAlign:"center"}},(0,t.createElement)("div",{style:{fontSize:"24px",fontWeight:"bold",color:"#0073aa"}},null!==(e=s?.queue?.pending)&&void 0!==e?e:0),(0,t.createElement)("div",{style:{fontSize:"12px",color:"#666"}},(0,xe.__)("Pending","quant"))),(0,t.createElement)("div",{style:{textAlign:"center"}},(0,t.createElement)("div",{style:{fontSize:"24px",fontWeight:"bold",color:"#f0b849"}},null!==(n=s?.queue?.processing)&&void 0!==n?n:0),(0,t.createElement)("div",{style:{fontSize:"12px",color:"#666"}},(0,xe.__)("Processing","quant"))),(0,t.createElement)("div",{style:{textAlign:"center"}},(0,t.createElement)("div",{style:{fontSize:"24px",fontWeight:"bold",color:"#d63638"}},null!==(l=s?.queue?.failed)&&void 0!==l?l:0),(0,t.createElement)("div",{style:{fontSize:"12px",color:"#666"}},(0,xe.__)("Failed","quant"))),(0,t.createElement)("div",{style:{textAlign:"center"}},(0,t.createElement)("div",{style:{fontSize:"24px",fontWeight:"bold",color:"#666"}},null!==(r=s?.queue?.total)&&void 0!==r?r:0),(0,t.createElement)("div",{style:{fontSize:"12px",color:"#666"}},(0,xe.__)("Total","quant")))))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Migration Status","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("table",{className:"widefat",style:{margin:0}},(0,t.createElement)("tbody",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px",width:"200px"}},(0,xe.__)("Needs Migration","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.migration?.needs_migration?(0,t.createElement)("a",{href:"#/migration"},(0,xe.__)("Yes - Click to migrate","quant")):(0,xe.__)("No","quant"))),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Migration Complete","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.migration?.migration_complete?(0,xe.__)("Yes","quant"):(0,xe.__)("No","quant"))),s?.migration?.migration_date&&(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Migration Date","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s.migration.migration_date)))))),s?.recent_errors&&s.recent_errors.length>0&&(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Recent Errors","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("table",{className:"widefat",style:{margin:0}},(0,t.createElement)("thead",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Type","quant")),(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("ID","quant")),(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Error","quant")),(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Time","quant")))),(0,t.createElement)("tbody",null,s.recent_errors.map((e,a)=>(0,t.createElement)("tr",{key:a},(0,t.createElement)("td",{style:{padding:"8px"}},e.type),(0,t.createElement)("td",{style:{padding:"8px"}},e.object_id),(0,t.createElement)("td",{style:{padding:"8px",color:"#721c24"}},e.error_message),(0,t.createElement)("td",{style:{padding:"8px",whiteSpace:"nowrap"}},e.processed_at))))))))}function st(){return(0,t.createElement)(Ee,null,(0,t.createElement)(we,null,(0,t.createElement)(ce,null,(0,t.createElement)(oe,{path:"/",element:(0,t.createElement)(se,{to:"/dashboard",replace:!0})}),(0,t.createElement)(oe,{path:"/dashboard",element:(0,t.createElement)(Be,null)}),(0,t.createElement)(oe,{path:"/content",element:(0,t.createElement)(Ae,null)}),(0,t.createElement)(oe,{path:"/cache",element:(0,t.createElement)(Oe,null)}),(0,t.createElement)(oe,{path:"/search",element:(0,t.createElement)(Ye,null)}),(0,t.createElement)(oe,{path:"/settings",element:(0,t.createElement)(et,null)}),(0,t.createElement)(oe,{path:"/migration",element:(0,t.createElement)(tt,null)}),(0,t.createElement)(oe,{path:"/onboarding",element:(0,t.createElement)(lt,null)}),(0,t.createElement)(oe,{path:"/diagnostics",element:(0,t.createElement)(rt,null)}))))}const ot=document.getElementById("quant-admin-root");ot&&("function"==typeof a.createRoot?a.createRoot(ot).render((0,t.createElement)(st,null)):a.render((0,t.createElement)(st,null),ot))})(); \ No newline at end of file diff --git a/plugins/quant/assets/src/components/Progress/Progress.jsx b/plugins/quant/assets/src/components/Progress/Progress.jsx index fe44bc1..1c7e1cf 100644 --- a/plugins/quant/assets/src/components/Progress/Progress.jsx +++ b/plugins/quant/assets/src/components/Progress/Progress.jsx @@ -270,7 +270,7 @@ export function Progress( { ) } { state !== 'active' && failedRun > 0 && ( ) } { state !== 'active' && ( diff --git a/plugins/quant/readme.txt b/plugins/quant/readme.txt index 089fc16..92d40c4 100644 --- a/plugins/quant/readme.txt +++ b/plugins/quant/readme.txt @@ -138,7 +138,7 @@ Enable search for the project in the Quant dashboard, then open **QuantCDN > Sea = A push failed with "Quant did not answer in time" = -The Quant API did not answer within the HTTP request timeout, or a gateway between your server and Quant was busy. The plugin sends a page or file again up to two times in the same run (after about 1 and 3 seconds). If it still fails, the job stays in the queue as failed. While the job has attempts left (three in total) and WP-Cron runs, the message ends with "The plugin will try again." and the background run tries it again, with a longer wait each time. When the job has used all attempts, or WP-Cron is disabled (`DISABLE_WP_CRON`), the message ends with "Retry it from the queue.": press "Retry failed" on the dashboard or run `wp quant queue retry`. A failed connection test says "Test the connection again in a minute." The raw error text (for example the cURL error) is in the report from QuantCDN > Diagnostics > Download Report. +The Quant API did not answer within the HTTP request timeout, or a gateway between your server and Quant was busy. The plugin sends a page or file again up to two times in the same run (after about 1 and 3 seconds). If it still fails, the job stays in the queue as failed. While the job has attempts left (three in total) and WP-Cron runs, the message ends with "The plugin will try again." and the background run tries it again, with a longer wait each time. When the job has used all attempts, or WP-Cron is disabled (`DISABLE_WP_CRON`), the message ends with "Select Retry failed on the dashboard.": select "Retry failed" on the QuantCDN dashboard (or in the push progress), or run `wp quant queue retry`. A failed connection test says "Test the connection again in a minute." The raw error text (for example the cURL error) is in the report from QuantCDN > Diagnostics > Download Report. = Forms on a static site = @@ -147,7 +147,7 @@ Follow the [Forms documentation](https://docs.quantcdn.io/docs/dashboard/forms). == Changelog == = 2.0.6 = -* Fixed: when the Quant API did not answer, the push showed raw transport text such as "cURL error 28: Operation timed out after 30001 milliseconds with 0 bytes received". The dashboard queue card and the push progress now show a short message, for example "Quant did not answer in time. The plugin will try again." The message says "Retry it from the queue." instead when the job has used all attempts or WP-Cron is disabled, and a failed connection test says "Test the connection again in a minute." The raw text is kept apart and is in the diagnostics report and in `wp quant queue errors`. +* Fixed: when the Quant API did not answer, the push showed raw transport text such as "cURL error 28: Operation timed out after 30001 milliseconds with 0 bytes received". The dashboard queue card and the push progress now show a short message, for example "Quant did not answer in time. The plugin will try again." The message says "Select Retry failed on the dashboard." instead when the job has used all attempts or WP-Cron is disabled, and a failed connection test says "Test the connection again in a minute." The raw text is kept apart and is in the diagnostics report and in `wp quant queue errors`. * Fixed: one stalled request or gateway error (502, 503, 504) failed the page until the next background run, and the background run only happens when WP-Cron runs. A page or file push is now sent again up to two times in the same run, after about 1 and 3 seconds. * A page larger than 512 KB now gets the same 120 second minimum timeout as a file upload. * Readme: `QUANT_HTTP_REQUEST_TIMEOUT` is documented. diff --git a/plugins/quant/src/Api/TransportError.php b/plugins/quant/src/Api/TransportError.php index 76ac18e..89ba688 100644 --- a/plugins/quant/src/Api/TransportError.php +++ b/plugins/quant/src/Api/TransportError.php @@ -30,7 +30,7 @@ final class TransportError /** The job has attempts left and background processing runs. */ public const NEXT_AUTOMATIC = 'The plugin will try again.'; /** The job used all attempts, or nothing runs the queue in the background. */ - public const NEXT_MANUAL = 'Retry it from the queue.'; + public const NEXT_MANUAL = 'Select Retry failed on the dashboard.'; /** A failed connection test. */ public const NEXT_TEST_AGAIN = 'Test the connection again in a minute.'; diff --git a/plugins/quant/tests/Api/TransportErrorTest.php b/plugins/quant/tests/Api/TransportErrorTest.php index a31f003..719790a 100644 --- a/plugins/quant/tests/Api/TransportErrorTest.php +++ b/plugins/quant/tests/Api/TransportErrorTest.php @@ -67,7 +67,7 @@ public function the_reasons_do_not_promise_a_next_step(): void $this->assertStringNotContainsString('try again', $reason); } $this->assertSame('The plugin will try again.', TransportError::NEXT_AUTOMATIC); - $this->assertSame('Retry it from the queue.', TransportError::NEXT_MANUAL); + $this->assertSame('Select Retry failed on the dashboard.', TransportError::NEXT_MANUAL); $this->assertSame('Test the connection again in a minute.', TransportError::NEXT_TEST_AGAIN); } diff --git a/plugins/quant/tests/Sync/DatabaseQueueTest.php b/plugins/quant/tests/Sync/DatabaseQueueTest.php index 5184291..4dc03c9 100644 --- a/plugins/quant/tests/Sync/DatabaseQueueTest.php +++ b/plugins/quant/tests/Sync/DatabaseQueueTest.php @@ -222,7 +222,7 @@ public function it_stores_the_short_message_and_the_detail_separately(): void $this->wpdb->shouldReceive('prepare')->once()->with( Mockery::type('string'), 'failed', - 'API error publishing /a: Quant did not answer in time. Retry it from the queue.', + 'API error publishing /a: Quant did not answer in time. Select Retry failed on the dashboard.', $detail, Mockery::type('string'), 'job-1', @@ -230,7 +230,7 @@ public function it_stores_the_short_message_and_the_detail_separately(): void )->andReturn('update_sql'); $this->wpdb->shouldReceive('query')->once()->with('update_sql'); - $this->queue->markFailed('job-1', 'API error publishing /a: Quant did not answer in time. Retry it from the queue.', $detail); + $this->queue->markFailed('job-1', 'API error publishing /a: Quant did not answer in time. Select Retry failed on the dashboard.', $detail); } #[Test] diff --git a/plugins/quant/tests/Sync/JobProcessorTest.php b/plugins/quant/tests/Sync/JobProcessorTest.php index 134b48e..198d46b 100644 --- a/plugins/quant/tests/Sync/JobProcessorTest.php +++ b/plugins/quant/tests/Sync/JobProcessorTest.php @@ -157,7 +157,7 @@ public function the_last_attempt_says_to_retry_from_the_queue(): void { // DatabaseQueue::retryDue only requeues while attempts < MAX_ATTEMPTS after this failure. $this->assertSame( - 'API error publishing /a: Quant did not answer in time. Retry it from the queue.', + 'API error publishing /a: Quant did not answer in time. Select Retry failed on the dashboard.', $this->failOnce($this->timeout(), DatabaseQueue::MAX_ATTEMPTS - 1, true) ); } @@ -166,7 +166,7 @@ public function the_last_attempt_says_to_retry_from_the_queue(): void public function without_background_processing_it_says_to_retry_from_the_queue(): void { $this->assertSame( - 'API error publishing /a: Quant did not answer in time. Retry it from the queue.', + 'API error publishing /a: Quant did not answer in time. Select Retry failed on the dashboard.', $this->failOnce($this->timeout(), 0, false) ); } @@ -184,7 +184,7 @@ public function the_busy_message_on_the_last_attempt_says_to_retry_from_the_queu { $busy = new QuantApiException('API error publishing /a: Quant is busy or unavailable (HTTP 503).', 'HTTP 503', true); - $this->assertSame('API error publishing /a: Quant is busy or unavailable (HTTP 503). Retry it from the queue.', $this->failOnce($busy, 2, true)); + $this->assertSame('API error publishing /a: Quant is busy or unavailable (HTTP 503). Select Retry failed on the dashboard.', $this->failOnce($busy, 2, true)); } #[Test] diff --git a/tests/e2e/specs/transport-errors.spec.js b/tests/e2e/specs/transport-errors.spec.js index 96205ef..7dde31b 100644 --- a/tests/e2e/specs/transport-errors.spec.js +++ b/tests/e2e/specs/transport-errors.spec.js @@ -62,7 +62,7 @@ test.describe('Quant API stalls and gateway errors', () => { expect(content).toMatch(/ {3}Detail: http_request_failed: cURL error 28: .* \(3 attempts\)/); }); - test('on the last attempt the card says to retry from the queue', async ({ page }) => { + test('on the last attempt the card names the Retry failed button', async ({ page }) => { await mockFail({ path: '/v1', status: 503, times: 3 }); await gotoQuant(page, 'content'); await rest(page, '/sync', { method: 'POST', data: { items: [{ type: 'post', id: postIdBySlug('third-post') }], action: 'sync' } }); @@ -72,10 +72,10 @@ test.describe('Quant API stalls and gateway errors', () => { expect(await publishAttempts('/third-post')).toBe(3); const errors = await rest(page, '/queue/errors'); - expect(errors[0].error_message).toBe('API error publishing /third-post: Quant is busy or unavailable (HTTP 503). Retry it from the queue.'); + expect(errors[0].error_message).toBe('API error publishing /third-post: Quant is busy or unavailable (HTTP 503). Select Retry failed on the dashboard.'); await gotoQuant(page, 'dashboard'); - await expect(page.getByText('Quant is busy or unavailable (HTTP 503). Retry it from the queue.')).toBeVisible(); + await expect(page.getByText('Quant is busy or unavailable (HTTP 503). Select Retry failed on the dashboard.')).toBeVisible(); await expect(page.getByText(/will try again/)).toHaveCount(0); }); }); From 7670585585b53e8b6f68498e4ecb142aa5cb9d88 Mon Sep 17 00:00:00 2001 From: Stuart Rowlands Date: Sun, 15 Nov 2026 16:26:33 +1000 Subject: [PATCH 5/6] fix(sync): bound retries per request and tighten failure handling Review fixes for the in-run retry and the short messages. - RetryPolicy keeps request-wide state (one instance per request, now passed explicitly in Plugin.php): a circuit breaker stops retries for later calls once one call used all its retries and still failed (a success closes it again), and all calls share one 45 s retry budget. A job with many uploads against a failing API sends one request per upload after the first, not three. - A retry starts only when the call so far, the wait and another attempt as long as the last one fit in 45 s. Fast failures still retry twice; a request that used the full 30 s timeout is not retried in the run. - Only an http_request_failed error that matches a known transport failure is marked as transport (retryable). Other WP_Errors (request not executed, invalid URL, too many redirects) get "The request to Quant failed." and no retry. - The scheduled queue run records quant_last_queue_run. Background processing counts as on when WP-Cron is enabled or a run happened in the last 15 minutes (DISABLE_WP_CRON with a system cron). Diagnostics report both, and the last run time. - Activator records quant_db_version only after error_detail exists, so a failed dbDelta runs again on the next request. - Readme and changelog say exactly when a push is retried. --- plugins/quant/assets/build/index.asset.php | 2 +- plugins/quant/assets/build/index.js | 2 +- .../quant/assets/src/pages/Diagnostics.jsx | 7 ++ plugins/quant/readme.txt | 17 +++- .../quant/src/Admin/DiagnosticsController.php | 14 ++- plugins/quant/src/Api/RetryPolicy.php | 67 +++++++++++--- plugins/quant/src/Api/TransportError.php | 17 +++- plugins/quant/src/Api/WordPressHttpClient.php | 2 +- plugins/quant/src/Core/Activator.php | 18 ++++ plugins/quant/src/Core/Plugin.php | 4 +- plugins/quant/src/Core/Scheduler.php | 22 ++++- .../quant/tests/Api/QuantContentApiTest.php | 12 +++ plugins/quant/tests/Api/RetryPolicyTest.php | 91 +++++++++++++++++-- .../quant/tests/Api/TransportErrorTest.php | 27 +++++- .../tests/Api/WordPressHttpClientTest.php | 10 ++ plugins/quant/tests/Core/ActivatorTest.php | 65 +++++++++++++ plugins/quant/tests/Core/SchedulerTest.php | 29 ++++++ 17 files changed, 366 insertions(+), 40 deletions(-) create mode 100644 plugins/quant/tests/Core/ActivatorTest.php diff --git a/plugins/quant/assets/build/index.asset.php b/plugins/quant/assets/build/index.asset.php index f487136..24a2c86 100644 --- a/plugins/quant/assets/build/index.asset.php +++ b/plugins/quant/assets/build/index.asset.php @@ -1 +1 @@ - array('react', 'react-dom', 'wp-api-fetch', 'wp-components', 'wp-element', 'wp-i18n'), 'version' => '44aa51464948046fb351'); + array('react', 'react-dom', 'wp-api-fetch', 'wp-components', 'wp-element', 'wp-i18n'), 'version' => 'd001e9dadb9dbbba31c4'); diff --git a/plugins/quant/assets/build/index.js b/plugins/quant/assets/build/index.js index 5a4feb6..a771a0f 100644 --- a/plugins/quant/assets/build/index.js +++ b/plugins/quant/assets/build/index.js @@ -15,4 +15,4 @@ (0,xe.__)("Queued %d records. Processing…","quant"),e.queued)),E(e=>e+1)}catch(e){v("error",e.message||(0,xe.__)("Reindex failed","quant"))}finally{h(!1)}},disabled:_||!y||!e?.enabled},(0,xe.__)("Rebuild index","quant"))),(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"secondary",isDestructive:!0,onClick:async()=>{const e=(0,xe.__)("Remove every record from the search index?","quant");if(window.confirm(e)){h(!0);try{await ke()({path:"/quant/v1/search/clear",method:"POST"}),v("success",(0,xe.__)("Search index cleared.","quant")),w()}catch(e){v("error",e.message||(0,xe.__)("Clear failed","quant"))}finally{h(!1)}}},disabled:_||!e?.enabled},(0,xe.__)("Clear index","quant"))))))),(0,t.createElement)(Se.Card,null,(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Search Pages","quant")),!i&&(0,t.createElement)(Se.Button,{variant:"primary",size:"small",onClick:()=>c({})},(0,xe.__)("Add search page","quant"))),(0,t.createElement)(Se.CardBody,null,i?(0,t.createElement)(Qe,{page:i.id?i:null,postTypes:l?.post_types||[],taxonomies:l?.taxonomies||[],onSave:async e=>{p(!0);try{const t=await ke()({path:"/quant/v1/search/pages",method:"POST",data:e});o(e=>[...e.filter(e=>e.id!==t.id),t]),c(null),q(t,(0,xe.__)("Search page saved. Its WordPress page was queued for publishing.","quant"))}catch(e){v("error",e.message||(0,xe.__)("Failed to save the search page","quant"))}finally{p(!1)}},onCancel:()=>c(null),saving:d}):(0,t.createElement)(We,{pages:s,onEdit:c,onDelete:async e=>{const t=(0,xe.sprintf)(/* translators: %s: search page label */ /* translators: %s: search page label */ (0,xe.__)('Delete the search page "%s"? Its WordPress page is moved to the trash.',"quant"),e.label);if(window.confirm(t))try{const t=await ke()({path:`/quant/v1/search/pages/${e.id}`,method:"DELETE"});o(t=>t.filter(t=>t.id!==e.id)),q(t,(0,xe.__)("Search page deleted.","quant"))}catch(e){v("error",e.message||(0,xe.__)("Delete failed","quant"))}}}))))}const Ve=[["home",(0,xe.__)("Home page","quant")],["post",(0,xe.__)("Posts","quant")],["page",(0,xe.__)("Pages","quant")],["taxonomy",(0,xe.__)("Categories and tags","quant")],["archives",(0,xe.__)("Archives","quant")],["authors",(0,xe.__)("Author pages","quant")],["sitemaps",(0,xe.__)("Sitemaps","quant")]],Ge=e=>Array.isArray(e)?e.join("\n"):e||"",Je=e=>e.split("\n").map(e=>e.trim()).filter(Boolean);function Ke({title:e,children:a,description:n}){return(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},e)),(0,t.createElement)(Se.CardBody,null,n&&(0,t.createElement)("p",{style:{color:"#666",marginTop:0}},n),a))}const Xe=(0,xe.__)("Set by a QUANT_* environment variable or constant. Change it there.","quant");function Ze({oauthStatus:e,setOauthStatus:n,onProjectSelected:l,notify:r,showManual:s,manualConfigured:o,setShowManual:i}){const[c,u]=(0,a.useState)([]),[m,d]=(0,a.useState)(!1),[p,_]=(0,a.useState)(!1),[h,g]=(0,a.useState)(null),[E,y]=(0,a.useState)(""),[f,v]=(0,a.useState)(""),[q,b]=(0,a.useState)(!1),[C,x]=(0,a.useState)(!1),w=(0,a.useCallback)(async()=>{d(!0),g(null);try{const e=await ke()({path:"/quant/v1/oauth/projects"});u(Array.isArray(e)?e:[])}catch(e){u([]),g(e.message||(0,xe.__)("Failed to load projects","quant"))}finally{d(!1),_(!0)}},[]);(0,a.useEffect)(()=>{e?.connected&&!e.has_project&&w()},[e,w]);const S=async()=>{try{const e=await ke()({path:"/quant/v1/oauth/connect"});e.redirect_url&&(window.location.href=e.redirect_url)}catch(e){r("error",e.message||(0,xe.__)("Failed to start the login","quant"))}},N=async()=>{try{await ke()({path:"/quant/v1/oauth/disconnect",method:"POST"}),n({connected:!1}),u([]),l(),r("success",(0,xe.__)("Disconnected from Quant","quant"))}catch(e){r("error",e.message||(0,xe.__)("Failed to disconnect","quant"))}},k=[{label:(0,xe.__)("Select an organization…","quant"),value:""}].concat(c.map(e=>({label:e.organization.name,value:e.organization.machine_name||e.organization.name}))),T=c.find(e=>(e.organization.machine_name||e.organization.name)===E),B=[{label:(0,xe.__)("Select a project…","quant"),value:""}].concat((T?.projects||[]).map(e=>({label:e.name||e.machine_name,value:e.machine_name||e.name})));if(!e?.connected)return(0,t.createElement)(Ke,{title:(0,xe.__)("Connect to Quant","quant"),description:(0,xe.__)("Sign in to Quant and pick the project this site publishes to. You can also enter the project credentials by hand.","quant")},(0,t.createElement)(Se.Flex,{justify:"flex-start",gap:4},(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"primary",onClick:S,icon:"admin-users"},(0,xe.__)("Login to Quant","quant"))),!o&&(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"link",onClick:()=>i(!s)},s?(0,xe.__)("Hide manual credentials","quant"):(0,xe.__)("Use manual credentials instead","quant")))));const P=function({loading:e,loaded:t,error:a,count:n}){return e||!t?"loading":a||0===n?"empty":"list"}({loading:m,loaded:p,error:h,count:c.length});return e.has_project&&!C?(0,t.createElement)(Ke,{title:(0,xe.__)("Connect to Quant","quant")},(0,t.createElement)("p",{className:"quant-connected-badge"},(0,t.createElement)(Se.Icon,{icon:"yes-alt"})," ",(0,xe.__)("Connected","quant"),e.user?.email&&(0,t.createElement)("span",{style:{color:"#666",marginLeft:"8px"}},e.user.email)),(0,t.createElement)("p",{style:{margin:"8px 0"}},(0,t.createElement)("strong",null,(0,xe.__)("Organization:","quant"))," ",e.organization),(0,t.createElement)("p",{style:{margin:"8px 0"}},(0,t.createElement)("strong",null,(0,xe.__)("Project:","quant"))," ",e.project),(0,t.createElement)(Se.Flex,{justify:"flex-start",gap:2,style:{marginTop:"16px"}},(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>{e.project_limited?(async()=>{try{await ke()({path:"/quant/v1/oauth/disconnect",method:"POST"})}catch(e){}await S()})():(x(!0),w())}},(0,xe.__)("Change Project","quant"))),(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"secondary",isDestructive:!0,onClick:N},(0,xe.__)("Disconnect","quant"))),(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"link",onClick:()=>i(!s)},s?(0,xe.__)("Hide manual credentials","quant"):(0,xe.__)("Show manual credentials","quant"))))):"empty"===P?(0,t.createElement)(Ke,{title:(0,xe.__)("Connect to Quant","quant")},(0,t.createElement)(Se.Notice,{status:"warning",isDismissible:!1},h||(0,xe.__)("No organizations or projects are available to this Quant account. Check the account in the Quant dashboard, or log in again.","quant")),(0,t.createElement)(Se.Flex,{justify:"flex-start",gap:2,style:{marginTop:"16px"}},(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"primary",onClick:S},(0,xe.__)("Reconnect","quant"))),(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"secondary",onClick:w},(0,xe.__)("Try again","quant"))),(0,t.createElement)(Se.FlexItem,null,C?(0,t.createElement)(Se.Button,{variant:"tertiary",onClick:()=>x(!1)},(0,xe.__)("Cancel","quant")):(0,t.createElement)(Se.Button,{variant:"tertiary",isDestructive:!0,onClick:N},(0,xe.__)("Disconnect","quant"))))):(0,t.createElement)(Ke,{title:(0,xe.__)("Connect to Quant","quant"),description:C?(0,xe.__)("Select a different project for this site.","quant"):(0,xe.__)("Connected. Select the project to use with this site.","quant")},"loading"===P?(0,t.createElement)(Se.Spinner,null):(0,t.createElement)(t.Fragment,null,(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Organization","quant"),value:E,options:k,onChange:e=>{y(e),v("")}}),E&&(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Project","quant"),value:f,options:B,onChange:v}),(0,t.createElement)(Se.Flex,{justify:"flex-start",gap:2,style:{marginTop:"16px"}},(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"primary",onClick:async()=>{b(!0);try{await ke()({path:"/quant/v1/oauth/select-project",method:"POST",data:{organization:E,project:f}}),n(await ke()({path:"/quant/v1/oauth/status"})),x(!1),l(),r("success",(0,xe.__)("Project selected.","quant"))}catch(e){r("error",e.message||(0,xe.__)("Failed to select the project","quant"))}finally{b(!1)}},disabled:!E||!f||q,isBusy:q},C?(0,xe.__)("Switch to This Project","quant"):(0,xe.__)("Use This Project","quant"))),(0,t.createElement)(Se.FlexItem,null,C?(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>x(!1)},(0,xe.__)("Cancel","quant")):(0,t.createElement)(Se.Button,{variant:"secondary",isDestructive:!0,onClick:N},(0,xe.__)("Disconnect","quant"))))))}function et(){const[e,n]=(0,a.useState)(null),[l,r]=(0,a.useState)(""),[s,o]=(0,a.useState)(""),[i,c]=(0,a.useState)(""),[u,m]=(0,a.useState)(null),[d,p]=(0,a.useState)(!1),[_,h]=(0,a.useState)(!1),[g,E]=(0,a.useState)(!1),[y,f]=(0,a.useState)(!1),[v,q]=(0,a.useState)(null),[b,C]=(0,a.useState)(null),x=(0,a.useCallback)((e,t)=>C({status:e,message:t}),[]),w=(0,a.useCallback)(async()=>{const[e,t]=await Promise.all([ke()({path:"/quant/v1/settings"}),ke()({path:"/quant/v1/oauth/status"})]);n(e),m(t),r(Ge(e.custom_routes)),o(Ge(e.domain_rewrites)),c(Ge(e.schedule?.custom_routes))},[]);(0,a.useEffect)(()=>{"success"===new URLSearchParams(window.location.search).get("oauth")&&(x("success",(0,xe.__)("Connected to Quant. Now select a project below.","quant")),window.history.replaceState({},"",window.location.pathname+"?page=quant#/settings")),w().catch(e=>x("error",e.message||(0,xe.__)("Failed to load settings","quant")))},[w,x]);const S=(e,t)=>n(a=>({...a,[e]:t})),N=(e,t)=>n(a=>({...a,schedule:{...a.schedule,[e]:t}})),k=async()=>{h(!0),C(null);try{const t={...e,custom_routes:Je(l),domain_rewrites:Je(s),schedule:{...e.schedule,custom_routes:Je(i)}};delete t.overridden,delete t.schedules,delete t.has_token,t.token||delete t.token;const a=await ke()({path:"/quant/v1/settings",method:"POST",data:t});n({...a,token:""}),x("success",(0,xe.__)("Settings saved.","quant"))}catch(e){x("error",e.message||(0,xe.__)("Failed to save settings","quant"))}finally{h(!1)}};if(!e)return(0,t.createElement)("div",{className:"quant-settings"},(0,t.createElement)("h1",null,(0,xe.__)("Settings","quant")),(0,t.createElement)(Se.Spinner,null));const T=e.overridden||{},B=!!(e.organization&&e.project&&e.has_token),P=(e,t)=>!(!T[e]||!T[e].includes(t)),D=(e,t,a)=>P(e,t)?Xe:a,R=Object.entries(e.schedules||{}).map(([e,t])=>({value:e,label:t}));return(0,t.createElement)("div",{className:"quant-settings"},(0,t.createElement)("h1",null,(0,xe.__)("Settings","quant")),b&&(0,t.createElement)(Se.Notice,{status:b.status,onRemove:()=>C(null),isDismissible:!0},b.message),(0,t.createElement)(Ze,{oauthStatus:u,setOauthStatus:m,onProjectSelected:()=>w().catch(()=>{}),notify:x,showManual:d,manualConfigured:B,setShowManual:p}),(d||!u?.connected&&B)&&(0,t.createElement)(Ke,{title:(0,xe.__)("Project Credentials","quant"),description:(0,xe.__)("Copy these from the Integrations page of the Quant dashboard.","quant")},(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("API Endpoint","quant"),value:e.endpoint,onChange:e=>S("endpoint",e),disabled:P("credentials","endpoint"),help:D("credentials","endpoint",(0,xe.__)("https://api.quantcdn.io for QuantCDN, https://api.quantgov.cloud for QuantGov. Without /v1.","quant"))}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Organization (API Customer)","quant"),value:e.organization,onChange:e=>S("organization",e),disabled:P("credentials","organization"),help:D("credentials","organization")}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Project","quant"),value:e.project,onChange:e=>S("project",e),disabled:P("credentials","project"),help:D("credentials","project")}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("API Token","quant"),type:"password",value:e.token,onChange:e=>S("token",e),disabled:P("credentials","token"),placeholder:e.has_token?(0,xe.__)("Token saved. Enter a new one to replace it.","quant"):(0,xe.__)("Project token","quant"),help:D("credentials","token")}),(0,t.createElement)(Se.Flex,{justify:"flex-start",gap:2,style:{marginTop:"16px"}},(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"primary",onClick:k,disabled:_,isBusy:_},(0,xe.__)("Save Credentials","quant"))),(0,t.createElement)(Se.FlexItem,null,(0,t.createElement)(Se.Button,{variant:"secondary",onClick:async()=>{E(!0);try{const e=await ke()({path:"/quant/v1/status"});x(e.connected?"success":"error",e.connected?(0,xe.__)("Connected to the Quant API.","quant"):(0,xe.sprintf)(/* translators: %s: error message */ /* translators: %s: error message */ (0,xe.__)("Connection failed: %s","quant"),e.error||(0,xe.__)("check your credentials","quant")))}catch(e){x("error",e.message||(0,xe.__)("Connection test failed","quant"))}finally{E(!1)}},disabled:g,isBusy:g},(0,xe.__)("Test Connection","quant"))))),(0,t.createElement)(Ke,{title:(0,xe.__)("API Connection","quant"),description:(0,xe.__)("Applies to every request the plugin makes, in both modes.","quant")},(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("HTTP request timeout (seconds)","quant"),type:"number",value:e.http_request_timeout,onChange:e=>S("http_request_timeout",parseInt(e,10)||30),disabled:P("credentials","http_request_timeout")})),(0,t.createElement)(Ke,{title:(0,xe.__)("Mode","quant")},(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Plugin mode","quant"),value:e.mode,disabled:P("mode","mode"),options:[{label:(0,xe.__)("Static Site: publish the whole site to the Quant edge","quant"),value:"static"},{label:(0,xe.__)("CDN Only: WordPress serves pages, Quant caches and purges them","quant"),value:"cdn_only"}],onChange:e=>S("mode",e)}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Automatic static push","quant"),checked:!!e.auto_sync,onChange:e=>S("auto_sync",e),disabled:"static"!==e.mode||P("sync","auto_sync"),help:(0,xe.__)("Push pages, terms and media to Quant when they change (Static mode).","quant")}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Also push related pages","quant"),checked:!1!==e.sync_related,onChange:e=>S("sync_related",e),disabled:"static"!==e.mode||!e.auto_sync,help:(0,xe.__)("When a post changes, also push the home page, its categories, tags, author and archive pages.","quant")}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Cache purge on save","quant"),checked:!!e.auto_purge,onChange:e=>S("auto_purge",e),disabled:P("sync","auto_purge"),help:(0,xe.__)("Purge the edge cache when content changes. Used in CDN Only mode, or in Static mode when automatic push is off.","quant")})),"static"===e.mode&&(0,t.createElement)(Ke,{title:(0,xe.__)("Static Site Rendering","quant"),description:(0,xe.__)("How the plugin fetches pages from this server before it pushes them.","quant")},(0,t.createElement)("div",{className:"quant-inline-test"},(0,t.createElement)("strong",null,(0,xe.__)("Internal connectivity","quant")),null===v?(0,t.createElement)("span",{style:{color:"#757575"}},(0,xe.__)("Not tested","quant")):(0,t.createElement)("span",{style:{color:v.success?"#00a32a":"#d63638"}},v.success?"✓ "+(0,xe.__)("Working","quant"):"✗ "+v.message),(0,t.createElement)(Se.Button,{variant:"secondary",size:"small",onClick:async()=>{f(!0),q(null);try{q(await ke()({path:"/quant/v1/test-internal",method:"POST"}))}catch(e){q({success:!1,message:e.message||(0,xe.__)("Test failed","quant")})}finally{f(!1)}},disabled:y,isBusy:y},(0,xe.__)("Test","quant")),v?.adjusted_url&&(0,t.createElement)("div",{className:"quant-inline-test__detail"},v.adjusted_url,v.host_header?` (Host: ${v.host_header})`:"")),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Webserver URL","quant"),value:e.webserver_url,onChange:e=>S("webserver_url",e),placeholder:"http://localhost",disabled:P("static","webserver_url"),help:D("static","webserver_url",(0,xe.__)("Where the plugin can reach this site from the server, for example http://localhost. Leave empty to use the site URL.","quant"))}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Hostname (Host header)","quant"),value:e.webserver_host,onChange:e=>S("webserver_host",e),placeholder:"www.example.com",disabled:P("static","webserver_host"),help:D("static","webserver_host",(0,xe.__)("The public hostname WordPress expects, so rendered links use the public domain.","quant"))}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Disable SSL verification for the local webserver","quant"),checked:!!e.disable_tls_verify,onChange:e=>S("disable_tls_verify",e),disabled:P("credentials","disable_tls_verify"),help:D("credentials","disable_tls_verify",(0,xe.__)("Only for a local webserver with a self-signed certificate. Calls to the Quant API always verify TLS.","quant"))}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Strip host domain from content","quant"),checked:!!e.strip_host,onChange:e=>S("strip_host",e),help:(0,xe.__)("Rewrite absolute links to this site as relative links, and upgrade http:// links to https://.","quant")}),(0,t.createElement)(Se.TextareaControl,{label:(0,xe.__)("Additional domains to make relative","quant"),value:s,onChange:o,rows:3,placeholder:"cdn.example.com",help:(0,xe.__)("One per line. Useful for staging or editorial domains.","quant")}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Follow pagination links","quant"),checked:!!e.follow_links,onChange:e=>S("follow_links",e),help:(0,xe.__)("Queue the next pages of listings found in rendered markup.","quant")}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Override existing proxies","quant"),checked:!!e.proxy_override,onChange:e=>S("proxy_override",e),help:(0,xe.__)("Tell Quant that pushed pages replace proxy rules at the same path.","quant")})),"static"===e.mode&&(0,t.createElement)(Ke,{title:(0,xe.__)("Seed Content","quant"),description:(0,xe.__)("What a full push includes besides posts, pages, terms and media.","quant")},(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Theme assets","quant"),checked:!!e.theme_assets,onChange:e=>S("theme_assets",e),help:(0,xe.__)("CSS, JavaScript, images and fonts of the active theme and its parent.","quant")}),(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Sitemaps","quant"),checked:!!e.sitemap,onChange:e=>S("sitemap",e),help:(0,xe.__)("WordPress core sitemaps, Yoast SEO and Rank Math sitemaps with their stylesheets.","quant")}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("404 page path","quant"),value:e.route_404,onChange:e=>S("route_404",e),placeholder:"/404",help:(0,xe.__)("A path that returns your 404 page. It is published as the Quant 404 page.","quant")}),(0,t.createElement)(Se.TextareaControl,{label:(0,xe.__)("Custom routes","quant"),value:l,onChange:r,rows:4,placeholder:"/robots.txt\n/custom-page",help:(0,xe.__)("One per line. Content routes are rendered; paths that match a file on disk are uploaded.","quant")})),"static"===e.mode&&(0,t.createElement)(Ke,{title:(0,xe.__)("Scheduled Push","quant"),description:(0,xe.__)("Re-push chosen content on a schedule, for example for listings that change without a save.","quant")},(0,t.createElement)(Se.ToggleControl,{label:(0,xe.__)("Enable scheduled push","quant"),checked:!!e.schedule?.enabled,onChange:e=>N("enabled",e)}),e.schedule?.enabled&&(0,t.createElement)(t.Fragment,null,(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Schedule","quant"),value:e.schedule.interval,options:R,onChange:e=>N("interval",e)}),Ve.map(([a,n])=>(0,t.createElement)(Se.CheckboxControl,{key:a,label:n,checked:(e.schedule.types||[]).includes(a),onChange:t=>N("types",t?[...e.schedule.types||[],a]:(e.schedule.types||[]).filter(e=>e!==a))})),(0,t.createElement)(Se.TextareaControl,{label:(0,xe.__)("Custom routes","quant"),value:i,onChange:c,rows:3,placeholder:"/robots.txt"}))),(0,t.createElement)("div",{style:{marginTop:"16px"}},(0,t.createElement)(Se.Button,{variant:"primary",onClick:k,disabled:_,isBusy:_},(0,xe.__)("Save Settings","quant"))))}function tt(){const e=G(),[n,l]=(0,a.useState)(null),[r,s]=(0,a.useState)(!0),[o,i]=(0,a.useState)(!1),[c,u]=(0,a.useState)(null),[m,d]=(0,a.useState)(!1),[p,_]=(0,a.useState)(!1),[h,g]=(0,a.useState)(null),[E,y]=(0,a.useState)(null),[f,v]=(0,a.useState)("detect");(0,a.useEffect)(()=>{q()},[]);const q=async()=>{s(!0),y(null);try{const e=await ke()({path:"/quant/v1/migration/status"});l(e),e.migration_complete?v("complete"):e.needs_migration?v("preview"):e.has_v1_settings||v("no-migration")}catch(e){y(e.message||(0,xe.__)("Failed to check migration status","quant"))}finally{s(!1)}},b=async()=>{d(!0),u(null);try{await ke()({path:"/quant/v1/migration/cleanup",method:"POST"}),u({status:"success",message:(0,xe.__)("The 1.x settings were removed.","quant")});const e=await ke()({path:"/quant/v1/migration/status"});l(e)}catch(e){u({status:"error",message:e.message||(0,xe.__)("The 1.x settings could not be removed.","quant")})}finally{d(!1)}};return r?(0,t.createElement)("div",{className:"quant-migration"},(0,t.createElement)("h1",null,(0,xe.__)("Migration","quant")),(0,t.createElement)(Se.Card,null,(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("div",{style:{display:"flex",alignItems:"center",gap:"10px"}},(0,t.createElement)(Se.Spinner,null),(0,t.createElement)("span",null,(0,xe.__)("Checking for v1 settings…","quant")))))):"no-migration"===f?(0,t.createElement)("div",{className:"quant-migration"},(0,t.createElement)("h1",null,(0,xe.__)("Migration","quant")),(0,t.createElement)(Se.Notice,{status:"info",isDismissible:!1},(0,xe.__)("No v1 settings found. Nothing to migrate.","quant")),(0,t.createElement)("div",{style:{marginTop:"16px"}},(0,t.createElement)(Se.Button,{variant:"primary",onClick:()=>e("/settings")},(0,xe.__)("Go to Settings","quant")))):"complete"===f?(0,t.createElement)("div",{className:"quant-migration"},(0,t.createElement)("h1",null,(0,xe.__)("Migration Complete","quant")),c&&(0,t.createElement)(Se.Notice,{status:c.status,onRemove:()=>u(null),isDismissible:!0},c.message),(0,t.createElement)(Se.Notice,{status:"success",isDismissible:!1},(0,xe.__)("Your settings have been successfully migrated to v2!","quant")),h&&h.migrated&&(0,t.createElement)(Se.Card,{style:{marginTop:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Migration Summary","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("ul",{style:{margin:0,paddingLeft:"20px"}},h.migrated.credentials&&(0,t.createElement)("li",null,(0,xe.__)("Credentials migrated:","quant")," ",h.migrated.credentials.organization," ","/"," ",h.migrated.credentials.project),h.migrated.mode&&(0,t.createElement)("li",null,(0,xe.__)("Mode set to:","quant")," ",h.migrated.mode),h.migrated.migration_date&&(0,t.createElement)("li",null,(0,xe.__)("Migrated at:","quant")," ",h.migrated.migration_date)))),(0,t.createElement)(Se.Card,{style:{marginTop:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Next Steps","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("p",null,(0,xe.__)("Your migration is complete. Here are some recommended next steps:","quant")),(0,t.createElement)("ul",{style:{paddingLeft:"20px"}},(0,t.createElement)("li",null,(0,xe.__)("Verify your connection in Settings","quant")),(0,t.createElement)("li",null,(0,xe.__)("Review your content sync status","quant")),(0,t.createElement)("li",null,(0,xe.__)("Run a full site sync if needed","quant"))),(0,t.createElement)("div",{style:{display:"flex",gap:"10px",marginTop:"16px"}},(0,t.createElement)(Se.Button,{variant:"primary",onClick:()=>e("/dashboard")},(0,xe.__)("Go to Dashboard","quant")),(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>e("/settings")},(0,xe.__)("Review Settings","quant"))))),n&&n.has_v1_settings&&(0,t.createElement)(Se.Card,{style:{marginTop:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Cleanup","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("p",null,(0,xe.__)("Old v1 data still exists. You can clean it up now or later.","quant")),(0,t.createElement)(Se.Button,{variant:"secondary",onClick:b,disabled:m,isBusy:m},m?(0,xe.__)("Cleaning up…","quant"):(0,xe.__)("Clean Up v1 Data","quant"))))):(0,t.createElement)("div",{className:"quant-migration"},(0,t.createElement)("h1",null,(0,xe.__)("Migrate from v1","quant")),E&&(0,t.createElement)(Se.Notice,{status:"error",onRemove:()=>y(null),isDismissible:!0},E),(0,t.createElement)(Se.Notice,{status:"info",isDismissible:!1},(0,xe.__)("We detected settings from v1 of the QuantCDN plugin. Let's migrate them to v2.","quant")),(0,t.createElement)(Se.Card,{style:{marginTop:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Settings to Migrate","quant"))),(0,t.createElement)(Se.CardBody,null,n&&n.v1_settings&&(0,t.createElement)("table",{className:"widefat",style:{marginBottom:"16px"}},(0,t.createElement)("tbody",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Organization","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},n.v1_settings.organization||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Project","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},n.v1_settings.project||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("API Token","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},n.v1_settings.token||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Endpoint","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},n.v1_settings.endpoint||"https://api.quantcdn.io")))),n&&n.v1_queue_count>0&&(0,t.createElement)("p",{style:{color:"#666"}},(0,xe.sprintf)(/* translators: %d: number of v1 batch rows */ /* translators: %d: number of v1 batch rows */ -(0,xe.__)("Note: %d rows of 1.x batch state are still in wp_options. Cleanup removes them.","quant"),n.v1_queue_count)),(0,t.createElement)(Se.CheckboxControl,{label:(0,xe.__)("Remove old v1 data after migration","quant"),checked:p,onChange:_,help:(0,xe.__)("This will delete old options and tables after successful migration","quant")}))),(0,t.createElement)(Se.Card,{style:{marginTop:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("What will happen","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("ul",{style:{margin:0,paddingLeft:"20px"}},(0,t.createElement)("li",null,(0,xe.__)("Your API credentials will be copied to the new format","quant")),(0,t.createElement)("li",null,(0,xe.__)('Plugin mode will be set to "Static" (default)',"quant")),(0,t.createElement)("li",null,(0,xe.__)("Auto-sync and auto-purge will be enabled","quant")),(0,t.createElement)("li",null,(0,xe.__)("A new sync queue table will be used","quant"))))),(0,t.createElement)("div",{style:{marginTop:"16px",display:"flex",gap:"10px"}},(0,t.createElement)(Se.Button,{variant:"primary",onClick:async()=>{i(!0),y(null);try{const e=await ke()({path:"/quant/v1/migration/run",method:"POST"});g(e),p&&e.success&&await b(),v("complete")}catch(e){y(e.message||(0,xe.__)("Migration failed","quant"))}finally{i(!1)}},disabled:o,isBusy:o},o?(0,xe.__)("Migrating…","quant"):(0,xe.__)("Start Migration","quant")),(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>e("/settings"),disabled:o},(0,xe.__)("Skip & Configure Manually","quant"))))}function at({result:e}){return"success"===e?(0,t.createElement)("div",{style:{padding:"20px"}},(0,t.createElement)("span",{className:"dashicons dashicons-yes-alt",style:{fontSize:"48px",color:"#00a32a",display:"block",marginBottom:"15px"}}),(0,t.createElement)("p",{style:{fontSize:"18px",color:"#00a32a",margin:0}},(0,xe.__)("Connection successful!","quant"))):"error"===e?(0,t.createElement)("div",{style:{padding:"20px"}},(0,t.createElement)("span",{className:"dashicons dashicons-dismiss",style:{fontSize:"48px",color:"#d63638",display:"block",marginBottom:"15px"}}),(0,t.createElement)("p",{style:{fontSize:"18px",color:"#d63638",margin:0}},(0,xe.__)("Connection failed","quant"))):(0,t.createElement)("div",{style:{padding:"20px"}},(0,t.createElement)("p",{style:{color:"#666",marginTop:0}},(0,xe.__)("Let's verify your connection to QuantCDN.","quant")))}const nt=[{label:"QuantCDN",value:"https://api.quantcdn.io"},{label:"QuantGov",value:"https://api.quantgov.cloud"}];function lt(){const e=G(),[n,l]=(0,a.useState)(1),[r,s]=(0,a.useState)(!0),[o,i]=(0,a.useState)(!1),[c,u]=(0,a.useState)(!1),[m,d]=(0,a.useState)(null),[p,_]=(0,a.useState)(null),[h,g]=(0,a.useState)({organization:"",project:"",token:"",endpoint:"https://api.quantcdn.io",mode:"static"});(0,a.useEffect)(()=>{E()},[]);const E=async()=>{try{const t=await ke()({path:"/quant/v1/onboarding/status"});if("migration"===t.state)return void e("/migration");"configured"===t.state&&e("/dashboard")}catch(e){}finally{s(!1)}},y=(e,t)=>{g(a=>({...a,[e]:t}))},f=async()=>{i(!0),d(null);try{await ke()({path:"/quant/v1/settings",method:"POST",data:h}),l(3)}catch(e){d(e.message||(0,xe.__)("Failed to save credentials","quant"))}finally{i(!1)}},v=async()=>{i(!0),d(null);try{await ke()({path:"/quant/v1/settings",method:"POST",data:{endpoint:h.endpoint,mode:h.mode}});const e=await ke()({path:"/quant/v1/oauth/connect"});if(e.redirect_url)return void(window.location.href=e.redirect_url);d((0,xe.__)("The login could not be started.","quant"))}catch(e){d(e.message||(0,xe.__)("The login could not be started.","quant"))}i(!1)},q=nt.find(e=>e.value===h.endpoint)?h.endpoint:"custom",b=async()=>{i(!0),d(null);try{await ke()({path:"/quant/v1/settings",method:"POST",data:{mode:h.mode}}),l(4)}catch(e){d(e.message||(0,xe.__)("Failed to save mode","quant"))}finally{i(!1)}},C=async()=>{u(!0),d(null),_(null);try{(await ke()({path:"/quant/v1/status"})).connected?(_("success"),setTimeout(()=>l(5),1500)):(_("error"),d((0,xe.__)("Connection failed. Please check your credentials.","quant")))}catch(e){_("error"),d(e.message||(0,xe.__)("Connection test failed","quant"))}finally{u(!1)}};return r?(0,t.createElement)("div",{className:"quant-onboarding"},(0,t.createElement)("div",{style:{display:"flex",alignItems:"center",justifyContent:"center",minHeight:"200px"}},(0,t.createElement)(Se.Spinner,null))):(0,t.createElement)("div",{className:"quant-onboarding",style:{maxWidth:"700px",margin:"0 auto"}},n>=2&&n<=4&&(0,t.createElement)("div",{style:{display:"flex",justifyContent:"center",gap:"10px",marginBottom:"30px"}},[2,3,4].map(e=>(0,t.createElement)("div",{key:e,style:{width:"12px",height:"12px",borderRadius:"50%",backgroundColor:n>=e?"#5c40ff":"#ddd"}}))),(()=>{switch(n){case 1:return(0,t.createElement)(Se.Card,{className:"quant-onboarding__card"},(0,t.createElement)(Se.CardBody,{style:{textAlign:"center",padding:"40px"}},(0,t.createElement)("svg",{width:"64",height:"64",viewBox:"0 0 32 32",fill:"none",style:{marginBottom:"20px"}},(0,t.createElement)("rect",{width:"32",height:"32",rx:"6",fill:"#5c40ff"}),(0,t.createElement)("text",{x:"50%",y:"55%",dominantBaseline:"middle",textAnchor:"middle",fill:"white",fontSize:"16",fontWeight:"bold"},"Q")),(0,t.createElement)("h1",{style:{marginBottom:"10px"}},(0,xe.__)("Welcome to QuantCDN","quant")),(0,t.createElement)("p",{style:{color:"#666",fontSize:"16px",maxWidth:"500px",margin:"0 auto 30px"}},(0,xe.__)("Let's get your WordPress site connected to QuantCDN. This wizard will guide you through the setup process.","quant")),(0,t.createElement)(Se.Button,{variant:"primary",onClick:()=>l(2),style:{padding:"10px 30px"}},(0,xe.__)("Get Started","quant"))));case 2:return(0,t.createElement)(Se.Card,{className:"quant-onboarding__card"},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Step 1: API Credentials","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)(Fe,null,m&&(0,t.createElement)(Se.Notice,{status:"error",onRemove:()=>d(null),isDismissible:!0},m),(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Platform","quant"),value:q,options:nt.concat([{label:(0,xe.__)("Other endpoint","quant"),value:"custom"}]),onChange:e=>{"custom"!==e&&y("endpoint",e)},help:(0,xe.__)("Choose the dashboard you sign in to.","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)("div",{className:"quant-onboarding__oauth"},(0,t.createElement)(Se.Button,{variant:"primary",icon:"admin-users",onClick:v,disabled:o,isBusy:o},(0,xe.__)("Login to Quant","quant")),(0,t.createElement)("p",{style:{color:"#666"}},(0,xe.__)("Sign in and pick the project, or enter the project credentials below.","quant"))),(0,t.createElement)("p",{style:{color:"#666",marginTop:0}},(0,xe.__)("Or enter the project credentials. You can find them in the","quant")," ",(0,t.createElement)("a",{href:"https://api.quantgov.cloud"===q?"https://dash.quantgov.cloud":"https://dashboard.quantcdn.io",target:"_blank",rel:"noopener noreferrer"},(0,xe.__)("Quant dashboard","quant"))," ",(0,xe.__)("on the Integrations page of the project.","quant")),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Organization","quant"),value:h.organization,onChange:e=>y("organization",e),help:(0,xe.__)("Your QuantCDN organization identifier","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Project","quant"),value:h.project,onChange:e=>y("project",e),help:(0,xe.__)("Your QuantCDN project name","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("API Token","quant"),type:"password",value:h.token,onChange:e=>y("token",e),help:(0,xe.__)("Your project API token","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("API Endpoint","quant"),value:h.endpoint,onChange:e=>y("endpoint",e),help:(0,xe.__)("https://api.quantcdn.io for QuantCDN, https://api.quantgov.cloud for QuantGov. Without /v1.","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)("div",{style:{display:"flex",gap:"10px"}},(0,t.createElement)(Se.Button,{variant:"primary",onClick:f,disabled:o||!h.organization||!h.project||!h.token,isBusy:o},o?(0,xe.__)("Saving…","quant"):(0,xe.__)("Continue","quant"))))));case 3:return(0,t.createElement)(Se.Card,{className:"quant-onboarding__card"},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Step 2: Choose Mode","quant"))),(0,t.createElement)(Se.CardBody,null,m&&(0,t.createElement)(Se.Notice,{status:"error",onRemove:()=>d(null),isDismissible:!0},m),(0,t.createElement)("p",{style:{color:"#666",marginTop:0}},(0,xe.__)("Choose how you want to use QuantCDN with your WordPress site.","quant")),(0,t.createElement)("div",{style:{marginBottom:"20px"}},(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Plugin Mode","quant"),value:h.mode,options:[{label:(0,xe.__)("Static Site (Recommended)","quant"),value:"static"},{label:(0,xe.__)("CDN Only","quant"),value:"cdn_only"}],onChange:e=>y("mode",e)})),(0,t.createElement)("div",{style:{backgroundColor:"#f0f0f0",padding:"15px",borderRadius:"4px",marginBottom:"20px"}},"static"===h.mode?(0,t.createElement)(t.Fragment,null,(0,t.createElement)("strong",null,(0,xe.__)("Static Site Mode","quant")),(0,t.createElement)("p",{style:{margin:"10px 0 0",color:"#666"}},(0,xe.__)("Your entire site is rendered and served from QuantCDN's edge. Pages are synced automatically when you publish or update content. Ideal for maximum performance and security.","quant"))):(0,t.createElement)(t.Fragment,null,(0,t.createElement)("strong",null,(0,xe.__)("CDN Only Mode","quant")),(0,t.createElement)("p",{style:{margin:"10px 0 0",color:"#666"}},(0,xe.__)("WordPress serves dynamic content while QuantCDN handles caching. Cache is automatically purged when content changes. Good for sites that need dynamic features.","quant")))),(0,t.createElement)("div",{style:{display:"flex",gap:"10px"}},(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>l(2)},(0,xe.__)("Back","quant")),(0,t.createElement)(Se.Button,{variant:"primary",onClick:b,disabled:o,isBusy:o},o?(0,xe.__)("Saving…","quant"):(0,xe.__)("Continue","quant")))));case 4:return(0,t.createElement)(Se.Card,{className:"quant-onboarding__card"},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Step 3: Test Connection","quant"))),(0,t.createElement)(Se.CardBody,{style:{textAlign:"center"}},m&&(0,t.createElement)(Se.Notice,{status:"error",onRemove:()=>d(null),isDismissible:!0},m),(0,t.createElement)(at,{result:p}),(0,t.createElement)("div",{style:{display:"flex",justifyContent:"center",gap:"10px",marginTop:"20px"}},(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>l(2),disabled:c},(0,xe.__)("Back to Credentials","quant")),(0,t.createElement)(Se.Button,{variant:"primary",onClick:C,disabled:c,isBusy:c},c?(0,xe.__)("Testing…","quant"):(0,xe.__)("Test Connection","quant")))));case 5:return(0,t.createElement)(Se.Card,{className:"quant-onboarding__card"},(0,t.createElement)(Se.CardBody,{style:{textAlign:"center",padding:"40px"}},(0,t.createElement)("span",{className:"dashicons dashicons-yes-alt",style:{fontSize:"64px",color:"#00a32a",display:"block",marginBottom:"20px"}}),(0,t.createElement)("h1",{style:{marginBottom:"10px"}},(0,xe.__)("You're All Set!","quant")),(0,t.createElement)("p",{style:{color:"#666",fontSize:"16px",maxWidth:"500px",margin:"0 auto 30px"}},"static"===h.mode?(0,xe.__)("Your site is now connected to QuantCDN. Head to the Content page to sync your first pages.","quant"):(0,xe.__)("Your site is now connected to QuantCDN. Cache will be automatically purged when you update content.","quant")),(0,t.createElement)("div",{style:{display:"flex",justifyContent:"center",gap:"10px"}},(0,t.createElement)(Se.Button,{variant:"primary",onClick:()=>e("/dashboard")},(0,xe.__)("Go to Dashboard","quant")),"static"===h.mode&&(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>e("/content")},(0,xe.__)("Sync Content","quant")))));default:return null}})())}function rt(){var e,n,l,r;const[s,o]=(0,a.useState)(null),[i,c]=(0,a.useState)(!0),[u,m]=(0,a.useState)(null),[d,p]=(0,a.useState)(!1);(0,a.useEffect)(()=>{_()},[]);const _=async()=>{c(!0),m(null);try{const e=await ke()({path:"/quant/v1/diagnostics"});o(e)}catch(e){m(e.message||(0,xe.__)("Failed to load diagnostics","quant"))}finally{c(!1)}},h=({success:e,successText:a,failText:n})=>(0,t.createElement)("span",{style:{display:"inline-block",padding:"2px 8px",borderRadius:"3px",fontSize:"12px",fontWeight:"500",backgroundColor:e?"#d4edda":"#f8d7da",color:e?"#155724":"#721c24"}},e?a:n);return i?(0,t.createElement)("div",{className:"quant-diagnostics"},(0,t.createElement)("h1",null,(0,xe.__)("Diagnostics","quant")),(0,t.createElement)(Se.Card,null,(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("div",{style:{display:"flex",alignItems:"center",gap:"10px"}},(0,t.createElement)(Se.Spinner,null),(0,t.createElement)("span",null,(0,xe.__)("Loading diagnostic information…","quant")))))):u?(0,t.createElement)("div",{className:"quant-diagnostics"},(0,t.createElement)("h1",null,(0,xe.__)("Diagnostics","quant")),(0,t.createElement)(Se.Notice,{status:"error",isDismissible:!1},u),(0,t.createElement)("div",{style:{marginTop:"16px"}},(0,t.createElement)(Se.Button,{variant:"secondary",onClick:_},(0,xe.__)("Retry","quant")))):(0,t.createElement)("div",{className:"quant-diagnostics"},(0,t.createElement)("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:"20px"}},(0,t.createElement)("h1",{style:{margin:0}},(0,xe.__)("Diagnostics","quant")),(0,t.createElement)("div",{style:{display:"flex",gap:"10px"}},(0,t.createElement)(Se.Button,{variant:"secondary",onClick:_},(0,xe.__)("Refresh","quant")),(0,t.createElement)(Se.Button,{variant:"primary",onClick:()=>{if(!s)return;p(!0);const e=(e=>{var t,a,n,l;const r=["=".repeat(60),"QuantCDN Diagnostic Report",`Generated: ${(new Date).toISOString()}`,"=".repeat(60),"","## Environment",`PHP Version: ${e.environment?.php_version||"Unknown"}`,`WordPress Version: ${e.environment?.wordpress_version||"Unknown"}`,`Plugin Version: ${e.environment?.plugin_version||"Unknown"}`,`PHP Memory Limit: ${e.environment?.php_memory_limit||"Unknown"}`,`PHP Max Execution Time: ${e.environment?.php_max_execution_time||"Unknown"}`,"Multisite: "+(e.environment?.is_multisite?"Yes":"No"),"","## Plugin Configuration",`Mode: ${e.plugin?.mode||"Unknown"}`,"Has Credentials: "+(e.plugin?.has_credentials?"Yes":"No"),`Organization: ${e.plugin?.organization||"Not set"}`,`Project: ${e.plugin?.project||"Not set"}`,`Endpoint: ${e.plugin?.endpoint||"Not set"}`,"","## API Connection","Connected: "+(e.api?.connected?"Yes":"No"),e.api?.error?`Error: ${e.api.error}`:"",e.api?.error_detail?`Error detail: ${e.api.error_detail}`:null,"","## Database","V2 Table Exists: "+(e.database?.v2_table_exists?"Yes":"No"),"V1 Table Exists: "+(e.database?.v1_table_exists?"Yes":"No"),"","## Queue Status",`Pending: ${null!==(t=e.queue?.pending)&&void 0!==t?t:"Unknown"}`,`Processing: ${null!==(a=e.queue?.processing)&&void 0!==a?a:"Unknown"}`,`Failed: ${null!==(n=e.queue?.failed)&&void 0!==n?n:"Unknown"}`,`Total: ${null!==(l=e.queue?.total)&&void 0!==l?l:"Unknown"}`,"","## Migration Status","Needs Migration: "+(e.migration?.needs_migration?"Yes":"No"),"Migration Complete: "+(e.migration?.migration_complete?"Yes":"No"),e.migration?.migration_date?`Migration Date: ${e.migration.migration_date}`:"",""];return r.push(...function(e){if(!e||0===e.length)return[];const t=["## Recent Errors"];return e.forEach((e,a)=>{t.push(`${a+1}. [${e.type}:${e.object_id}] ${e.error_message}`),e.error_detail&&t.push(` Detail: ${e.error_detail}`),t.push(` At: ${e.processed_at}`)}),t.push(""),t}(e.recent_errors)),r.push("=".repeat(60)),r.push("End of Report"),r.filter(e=>null!==e).join("\n")})(s),t=new Blob([e],{type:"text/plain"}),a=URL.createObjectURL(t),n=document.createElement("a");n.href=a,n.download=`quant-diagnostics-${(new Date).toISOString().split("T")[0]}.txt`,document.body.appendChild(n),n.click(),document.body.removeChild(n),URL.revokeObjectURL(a),p(!1)},disabled:d,isBusy:d},d?(0,xe.__)("Downloading…","quant"):(0,xe.__)("Download Report","quant")))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Environment","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("table",{className:"widefat",style:{margin:0}},(0,t.createElement)("tbody",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px",width:"200px"}},(0,xe.__)("PHP Version","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.php_version||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("WordPress Version","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.wordpress_version||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Plugin Version","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.plugin_version||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("PHP Memory Limit","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.php_memory_limit||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("PHP Max Execution Time","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.php_max_execution_time||"-"," ","seconds")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Multisite","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.is_multisite?(0,xe.__)("Yes","quant"):(0,xe.__)("No","quant"))))))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Plugin Configuration","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("table",{className:"widefat",style:{margin:0}},(0,t.createElement)("tbody",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px",width:"200px"}},(0,xe.__)("Mode","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},"static"===s?.plugin?.mode?(0,xe.__)("Static Site","quant"):(0,xe.__)("CDN Only","quant"))),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Credentials","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},(0,t.createElement)(h,{success:s?.plugin?.has_credentials,successText:(0,xe.__)("Configured","quant"),failText:(0,xe.__)("Not Configured","quant")}))),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Organization","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.plugin?.organization||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Project","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.plugin?.project||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Endpoint","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.plugin?.endpoint||"-")))))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("API Connection","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("div",{style:{display:"flex",alignItems:"center",gap:"10px"}},(0,t.createElement)(h,{success:s?.api?.connected,successText:(0,xe.__)("Connected","quant"),failText:(0,xe.__)("Not Connected","quant")}),s?.api?.error&&(0,t.createElement)("span",{style:{color:"#721c24"}},s.api.error)))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Database","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("table",{className:"widefat",style:{margin:0}},(0,t.createElement)("tbody",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px",width:"200px"}},(0,xe.__)("V2 Sync Queue Table","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},(0,t.createElement)(h,{success:s?.database?.v2_table_exists,successText:(0,xe.__)("Exists","quant"),failText:(0,xe.__)("Missing","quant")}))),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("V1 Queue Table","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.database?.v1_table_exists?(0,t.createElement)("span",{style:{color:"#856404"}},(0,xe.__)("Exists (can be cleaned up)","quant")):(0,t.createElement)("span",{style:{color:"#666"}},(0,xe.__)("Not present","quant")))))))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Queue Status","quant"))),(0,t.createElement)(Se.CardBody,null,s?.queue?.error?(0,t.createElement)(Se.Notice,{status:"error",isDismissible:!1},s.queue.error):(0,t.createElement)("div",{style:{display:"flex",gap:"30px"}},(0,t.createElement)("div",{style:{textAlign:"center"}},(0,t.createElement)("div",{style:{fontSize:"24px",fontWeight:"bold",color:"#0073aa"}},null!==(e=s?.queue?.pending)&&void 0!==e?e:0),(0,t.createElement)("div",{style:{fontSize:"12px",color:"#666"}},(0,xe.__)("Pending","quant"))),(0,t.createElement)("div",{style:{textAlign:"center"}},(0,t.createElement)("div",{style:{fontSize:"24px",fontWeight:"bold",color:"#f0b849"}},null!==(n=s?.queue?.processing)&&void 0!==n?n:0),(0,t.createElement)("div",{style:{fontSize:"12px",color:"#666"}},(0,xe.__)("Processing","quant"))),(0,t.createElement)("div",{style:{textAlign:"center"}},(0,t.createElement)("div",{style:{fontSize:"24px",fontWeight:"bold",color:"#d63638"}},null!==(l=s?.queue?.failed)&&void 0!==l?l:0),(0,t.createElement)("div",{style:{fontSize:"12px",color:"#666"}},(0,xe.__)("Failed","quant"))),(0,t.createElement)("div",{style:{textAlign:"center"}},(0,t.createElement)("div",{style:{fontSize:"24px",fontWeight:"bold",color:"#666"}},null!==(r=s?.queue?.total)&&void 0!==r?r:0),(0,t.createElement)("div",{style:{fontSize:"12px",color:"#666"}},(0,xe.__)("Total","quant")))))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Migration Status","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("table",{className:"widefat",style:{margin:0}},(0,t.createElement)("tbody",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px",width:"200px"}},(0,xe.__)("Needs Migration","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.migration?.needs_migration?(0,t.createElement)("a",{href:"#/migration"},(0,xe.__)("Yes - Click to migrate","quant")):(0,xe.__)("No","quant"))),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Migration Complete","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.migration?.migration_complete?(0,xe.__)("Yes","quant"):(0,xe.__)("No","quant"))),s?.migration?.migration_date&&(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Migration Date","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s.migration.migration_date)))))),s?.recent_errors&&s.recent_errors.length>0&&(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Recent Errors","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("table",{className:"widefat",style:{margin:0}},(0,t.createElement)("thead",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Type","quant")),(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("ID","quant")),(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Error","quant")),(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Time","quant")))),(0,t.createElement)("tbody",null,s.recent_errors.map((e,a)=>(0,t.createElement)("tr",{key:a},(0,t.createElement)("td",{style:{padding:"8px"}},e.type),(0,t.createElement)("td",{style:{padding:"8px"}},e.object_id),(0,t.createElement)("td",{style:{padding:"8px",color:"#721c24"}},e.error_message),(0,t.createElement)("td",{style:{padding:"8px",whiteSpace:"nowrap"}},e.processed_at))))))))}function st(){return(0,t.createElement)(Ee,null,(0,t.createElement)(we,null,(0,t.createElement)(ce,null,(0,t.createElement)(oe,{path:"/",element:(0,t.createElement)(se,{to:"/dashboard",replace:!0})}),(0,t.createElement)(oe,{path:"/dashboard",element:(0,t.createElement)(Be,null)}),(0,t.createElement)(oe,{path:"/content",element:(0,t.createElement)(Ae,null)}),(0,t.createElement)(oe,{path:"/cache",element:(0,t.createElement)(Oe,null)}),(0,t.createElement)(oe,{path:"/search",element:(0,t.createElement)(Ye,null)}),(0,t.createElement)(oe,{path:"/settings",element:(0,t.createElement)(et,null)}),(0,t.createElement)(oe,{path:"/migration",element:(0,t.createElement)(tt,null)}),(0,t.createElement)(oe,{path:"/onboarding",element:(0,t.createElement)(lt,null)}),(0,t.createElement)(oe,{path:"/diagnostics",element:(0,t.createElement)(rt,null)}))))}const ot=document.getElementById("quant-admin-root");ot&&("function"==typeof a.createRoot?a.createRoot(ot).render((0,t.createElement)(st,null)):a.render((0,t.createElement)(st,null),ot))})(); \ No newline at end of file +(0,xe.__)("Note: %d rows of 1.x batch state are still in wp_options. Cleanup removes them.","quant"),n.v1_queue_count)),(0,t.createElement)(Se.CheckboxControl,{label:(0,xe.__)("Remove old v1 data after migration","quant"),checked:p,onChange:_,help:(0,xe.__)("This will delete old options and tables after successful migration","quant")}))),(0,t.createElement)(Se.Card,{style:{marginTop:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("What will happen","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("ul",{style:{margin:0,paddingLeft:"20px"}},(0,t.createElement)("li",null,(0,xe.__)("Your API credentials will be copied to the new format","quant")),(0,t.createElement)("li",null,(0,xe.__)('Plugin mode will be set to "Static" (default)',"quant")),(0,t.createElement)("li",null,(0,xe.__)("Auto-sync and auto-purge will be enabled","quant")),(0,t.createElement)("li",null,(0,xe.__)("A new sync queue table will be used","quant"))))),(0,t.createElement)("div",{style:{marginTop:"16px",display:"flex",gap:"10px"}},(0,t.createElement)(Se.Button,{variant:"primary",onClick:async()=>{i(!0),y(null);try{const e=await ke()({path:"/quant/v1/migration/run",method:"POST"});g(e),p&&e.success&&await b(),v("complete")}catch(e){y(e.message||(0,xe.__)("Migration failed","quant"))}finally{i(!1)}},disabled:o,isBusy:o},o?(0,xe.__)("Migrating…","quant"):(0,xe.__)("Start Migration","quant")),(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>e("/settings"),disabled:o},(0,xe.__)("Skip & Configure Manually","quant"))))}function at({result:e}){return"success"===e?(0,t.createElement)("div",{style:{padding:"20px"}},(0,t.createElement)("span",{className:"dashicons dashicons-yes-alt",style:{fontSize:"48px",color:"#00a32a",display:"block",marginBottom:"15px"}}),(0,t.createElement)("p",{style:{fontSize:"18px",color:"#00a32a",margin:0}},(0,xe.__)("Connection successful!","quant"))):"error"===e?(0,t.createElement)("div",{style:{padding:"20px"}},(0,t.createElement)("span",{className:"dashicons dashicons-dismiss",style:{fontSize:"48px",color:"#d63638",display:"block",marginBottom:"15px"}}),(0,t.createElement)("p",{style:{fontSize:"18px",color:"#d63638",margin:0}},(0,xe.__)("Connection failed","quant"))):(0,t.createElement)("div",{style:{padding:"20px"}},(0,t.createElement)("p",{style:{color:"#666",marginTop:0}},(0,xe.__)("Let's verify your connection to QuantCDN.","quant")))}const nt=[{label:"QuantCDN",value:"https://api.quantcdn.io"},{label:"QuantGov",value:"https://api.quantgov.cloud"}];function lt(){const e=G(),[n,l]=(0,a.useState)(1),[r,s]=(0,a.useState)(!0),[o,i]=(0,a.useState)(!1),[c,u]=(0,a.useState)(!1),[m,d]=(0,a.useState)(null),[p,_]=(0,a.useState)(null),[h,g]=(0,a.useState)({organization:"",project:"",token:"",endpoint:"https://api.quantcdn.io",mode:"static"});(0,a.useEffect)(()=>{E()},[]);const E=async()=>{try{const t=await ke()({path:"/quant/v1/onboarding/status"});if("migration"===t.state)return void e("/migration");"configured"===t.state&&e("/dashboard")}catch(e){}finally{s(!1)}},y=(e,t)=>{g(a=>({...a,[e]:t}))},f=async()=>{i(!0),d(null);try{await ke()({path:"/quant/v1/settings",method:"POST",data:h}),l(3)}catch(e){d(e.message||(0,xe.__)("Failed to save credentials","quant"))}finally{i(!1)}},v=async()=>{i(!0),d(null);try{await ke()({path:"/quant/v1/settings",method:"POST",data:{endpoint:h.endpoint,mode:h.mode}});const e=await ke()({path:"/quant/v1/oauth/connect"});if(e.redirect_url)return void(window.location.href=e.redirect_url);d((0,xe.__)("The login could not be started.","quant"))}catch(e){d(e.message||(0,xe.__)("The login could not be started.","quant"))}i(!1)},q=nt.find(e=>e.value===h.endpoint)?h.endpoint:"custom",b=async()=>{i(!0),d(null);try{await ke()({path:"/quant/v1/settings",method:"POST",data:{mode:h.mode}}),l(4)}catch(e){d(e.message||(0,xe.__)("Failed to save mode","quant"))}finally{i(!1)}},C=async()=>{u(!0),d(null),_(null);try{(await ke()({path:"/quant/v1/status"})).connected?(_("success"),setTimeout(()=>l(5),1500)):(_("error"),d((0,xe.__)("Connection failed. Please check your credentials.","quant")))}catch(e){_("error"),d(e.message||(0,xe.__)("Connection test failed","quant"))}finally{u(!1)}};return r?(0,t.createElement)("div",{className:"quant-onboarding"},(0,t.createElement)("div",{style:{display:"flex",alignItems:"center",justifyContent:"center",minHeight:"200px"}},(0,t.createElement)(Se.Spinner,null))):(0,t.createElement)("div",{className:"quant-onboarding",style:{maxWidth:"700px",margin:"0 auto"}},n>=2&&n<=4&&(0,t.createElement)("div",{style:{display:"flex",justifyContent:"center",gap:"10px",marginBottom:"30px"}},[2,3,4].map(e=>(0,t.createElement)("div",{key:e,style:{width:"12px",height:"12px",borderRadius:"50%",backgroundColor:n>=e?"#5c40ff":"#ddd"}}))),(()=>{switch(n){case 1:return(0,t.createElement)(Se.Card,{className:"quant-onboarding__card"},(0,t.createElement)(Se.CardBody,{style:{textAlign:"center",padding:"40px"}},(0,t.createElement)("svg",{width:"64",height:"64",viewBox:"0 0 32 32",fill:"none",style:{marginBottom:"20px"}},(0,t.createElement)("rect",{width:"32",height:"32",rx:"6",fill:"#5c40ff"}),(0,t.createElement)("text",{x:"50%",y:"55%",dominantBaseline:"middle",textAnchor:"middle",fill:"white",fontSize:"16",fontWeight:"bold"},"Q")),(0,t.createElement)("h1",{style:{marginBottom:"10px"}},(0,xe.__)("Welcome to QuantCDN","quant")),(0,t.createElement)("p",{style:{color:"#666",fontSize:"16px",maxWidth:"500px",margin:"0 auto 30px"}},(0,xe.__)("Let's get your WordPress site connected to QuantCDN. This wizard will guide you through the setup process.","quant")),(0,t.createElement)(Se.Button,{variant:"primary",onClick:()=>l(2),style:{padding:"10px 30px"}},(0,xe.__)("Get Started","quant"))));case 2:return(0,t.createElement)(Se.Card,{className:"quant-onboarding__card"},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Step 1: API Credentials","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)(Fe,null,m&&(0,t.createElement)(Se.Notice,{status:"error",onRemove:()=>d(null),isDismissible:!0},m),(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Platform","quant"),value:q,options:nt.concat([{label:(0,xe.__)("Other endpoint","quant"),value:"custom"}]),onChange:e=>{"custom"!==e&&y("endpoint",e)},help:(0,xe.__)("Choose the dashboard you sign in to.","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)("div",{className:"quant-onboarding__oauth"},(0,t.createElement)(Se.Button,{variant:"primary",icon:"admin-users",onClick:v,disabled:o,isBusy:o},(0,xe.__)("Login to Quant","quant")),(0,t.createElement)("p",{style:{color:"#666"}},(0,xe.__)("Sign in and pick the project, or enter the project credentials below.","quant"))),(0,t.createElement)("p",{style:{color:"#666",marginTop:0}},(0,xe.__)("Or enter the project credentials. You can find them in the","quant")," ",(0,t.createElement)("a",{href:"https://api.quantgov.cloud"===q?"https://dash.quantgov.cloud":"https://dashboard.quantcdn.io",target:"_blank",rel:"noopener noreferrer"},(0,xe.__)("Quant dashboard","quant"))," ",(0,xe.__)("on the Integrations page of the project.","quant")),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Organization","quant"),value:h.organization,onChange:e=>y("organization",e),help:(0,xe.__)("Your QuantCDN organization identifier","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("Project","quant"),value:h.project,onChange:e=>y("project",e),help:(0,xe.__)("Your QuantCDN project name","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("API Token","quant"),type:"password",value:h.token,onChange:e=>y("token",e),help:(0,xe.__)("Your project API token","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)(Se.TextControl,{label:(0,xe.__)("API Endpoint","quant"),value:h.endpoint,onChange:e=>y("endpoint",e),help:(0,xe.__)("https://api.quantcdn.io for QuantCDN, https://api.quantgov.cloud for QuantGov. Without /v1.","quant"),__nextHasNoMarginBottom:!0}),(0,t.createElement)("div",{style:{display:"flex",gap:"10px"}},(0,t.createElement)(Se.Button,{variant:"primary",onClick:f,disabled:o||!h.organization||!h.project||!h.token,isBusy:o},o?(0,xe.__)("Saving…","quant"):(0,xe.__)("Continue","quant"))))));case 3:return(0,t.createElement)(Se.Card,{className:"quant-onboarding__card"},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Step 2: Choose Mode","quant"))),(0,t.createElement)(Se.CardBody,null,m&&(0,t.createElement)(Se.Notice,{status:"error",onRemove:()=>d(null),isDismissible:!0},m),(0,t.createElement)("p",{style:{color:"#666",marginTop:0}},(0,xe.__)("Choose how you want to use QuantCDN with your WordPress site.","quant")),(0,t.createElement)("div",{style:{marginBottom:"20px"}},(0,t.createElement)(Se.SelectControl,{label:(0,xe.__)("Plugin Mode","quant"),value:h.mode,options:[{label:(0,xe.__)("Static Site (Recommended)","quant"),value:"static"},{label:(0,xe.__)("CDN Only","quant"),value:"cdn_only"}],onChange:e=>y("mode",e)})),(0,t.createElement)("div",{style:{backgroundColor:"#f0f0f0",padding:"15px",borderRadius:"4px",marginBottom:"20px"}},"static"===h.mode?(0,t.createElement)(t.Fragment,null,(0,t.createElement)("strong",null,(0,xe.__)("Static Site Mode","quant")),(0,t.createElement)("p",{style:{margin:"10px 0 0",color:"#666"}},(0,xe.__)("Your entire site is rendered and served from QuantCDN's edge. Pages are synced automatically when you publish or update content. Ideal for maximum performance and security.","quant"))):(0,t.createElement)(t.Fragment,null,(0,t.createElement)("strong",null,(0,xe.__)("CDN Only Mode","quant")),(0,t.createElement)("p",{style:{margin:"10px 0 0",color:"#666"}},(0,xe.__)("WordPress serves dynamic content while QuantCDN handles caching. Cache is automatically purged when content changes. Good for sites that need dynamic features.","quant")))),(0,t.createElement)("div",{style:{display:"flex",gap:"10px"}},(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>l(2)},(0,xe.__)("Back","quant")),(0,t.createElement)(Se.Button,{variant:"primary",onClick:b,disabled:o,isBusy:o},o?(0,xe.__)("Saving…","quant"):(0,xe.__)("Continue","quant")))));case 4:return(0,t.createElement)(Se.Card,{className:"quant-onboarding__card"},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Step 3: Test Connection","quant"))),(0,t.createElement)(Se.CardBody,{style:{textAlign:"center"}},m&&(0,t.createElement)(Se.Notice,{status:"error",onRemove:()=>d(null),isDismissible:!0},m),(0,t.createElement)(at,{result:p}),(0,t.createElement)("div",{style:{display:"flex",justifyContent:"center",gap:"10px",marginTop:"20px"}},(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>l(2),disabled:c},(0,xe.__)("Back to Credentials","quant")),(0,t.createElement)(Se.Button,{variant:"primary",onClick:C,disabled:c,isBusy:c},c?(0,xe.__)("Testing…","quant"):(0,xe.__)("Test Connection","quant")))));case 5:return(0,t.createElement)(Se.Card,{className:"quant-onboarding__card"},(0,t.createElement)(Se.CardBody,{style:{textAlign:"center",padding:"40px"}},(0,t.createElement)("span",{className:"dashicons dashicons-yes-alt",style:{fontSize:"64px",color:"#00a32a",display:"block",marginBottom:"20px"}}),(0,t.createElement)("h1",{style:{marginBottom:"10px"}},(0,xe.__)("You're All Set!","quant")),(0,t.createElement)("p",{style:{color:"#666",fontSize:"16px",maxWidth:"500px",margin:"0 auto 30px"}},"static"===h.mode?(0,xe.__)("Your site is now connected to QuantCDN. Head to the Content page to sync your first pages.","quant"):(0,xe.__)("Your site is now connected to QuantCDN. Cache will be automatically purged when you update content.","quant")),(0,t.createElement)("div",{style:{display:"flex",justifyContent:"center",gap:"10px"}},(0,t.createElement)(Se.Button,{variant:"primary",onClick:()=>e("/dashboard")},(0,xe.__)("Go to Dashboard","quant")),"static"===h.mode&&(0,t.createElement)(Se.Button,{variant:"secondary",onClick:()=>e("/content")},(0,xe.__)("Sync Content","quant")))));default:return null}})())}function rt(){var e,n,l,r;const[s,o]=(0,a.useState)(null),[i,c]=(0,a.useState)(!0),[u,m]=(0,a.useState)(null),[d,p]=(0,a.useState)(!1);(0,a.useEffect)(()=>{_()},[]);const _=async()=>{c(!0),m(null);try{const e=await ke()({path:"/quant/v1/diagnostics"});o(e)}catch(e){m(e.message||(0,xe.__)("Failed to load diagnostics","quant"))}finally{c(!1)}},h=({success:e,successText:a,failText:n})=>(0,t.createElement)("span",{style:{display:"inline-block",padding:"2px 8px",borderRadius:"3px",fontSize:"12px",fontWeight:"500",backgroundColor:e?"#d4edda":"#f8d7da",color:e?"#155724":"#721c24"}},e?a:n);return i?(0,t.createElement)("div",{className:"quant-diagnostics"},(0,t.createElement)("h1",null,(0,xe.__)("Diagnostics","quant")),(0,t.createElement)(Se.Card,null,(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("div",{style:{display:"flex",alignItems:"center",gap:"10px"}},(0,t.createElement)(Se.Spinner,null),(0,t.createElement)("span",null,(0,xe.__)("Loading diagnostic information…","quant")))))):u?(0,t.createElement)("div",{className:"quant-diagnostics"},(0,t.createElement)("h1",null,(0,xe.__)("Diagnostics","quant")),(0,t.createElement)(Se.Notice,{status:"error",isDismissible:!1},u),(0,t.createElement)("div",{style:{marginTop:"16px"}},(0,t.createElement)(Se.Button,{variant:"secondary",onClick:_},(0,xe.__)("Retry","quant")))):(0,t.createElement)("div",{className:"quant-diagnostics"},(0,t.createElement)("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:"20px"}},(0,t.createElement)("h1",{style:{margin:0}},(0,xe.__)("Diagnostics","quant")),(0,t.createElement)("div",{style:{display:"flex",gap:"10px"}},(0,t.createElement)(Se.Button,{variant:"secondary",onClick:_},(0,xe.__)("Refresh","quant")),(0,t.createElement)(Se.Button,{variant:"primary",onClick:()=>{if(!s)return;p(!0);const e=(e=>{var t,a,n,l;const r=["=".repeat(60),"QuantCDN Diagnostic Report",`Generated: ${(new Date).toISOString()}`,"=".repeat(60),"","## Environment",`PHP Version: ${e.environment?.php_version||"Unknown"}`,`WordPress Version: ${e.environment?.wordpress_version||"Unknown"}`,`Plugin Version: ${e.environment?.plugin_version||"Unknown"}`,`PHP Memory Limit: ${e.environment?.php_memory_limit||"Unknown"}`,`PHP Max Execution Time: ${e.environment?.php_max_execution_time||"Unknown"}`,"Multisite: "+(e.environment?.is_multisite?"Yes":"No"),"WP-Cron Disabled: "+(e.environment?.cron_disabled?"Yes":"No"),"Background Processing: "+(e.environment?.background_processing?"Yes":"No"),`Last Queue Run: ${e.environment?.last_queue_run||"Never"}`,"","## Plugin Configuration",`Mode: ${e.plugin?.mode||"Unknown"}`,"Has Credentials: "+(e.plugin?.has_credentials?"Yes":"No"),`Organization: ${e.plugin?.organization||"Not set"}`,`Project: ${e.plugin?.project||"Not set"}`,`Endpoint: ${e.plugin?.endpoint||"Not set"}`,"","## API Connection","Connected: "+(e.api?.connected?"Yes":"No"),e.api?.error?`Error: ${e.api.error}`:"",e.api?.error_detail?`Error detail: ${e.api.error_detail}`:null,"","## Database","V2 Table Exists: "+(e.database?.v2_table_exists?"Yes":"No"),"V1 Table Exists: "+(e.database?.v1_table_exists?"Yes":"No"),"","## Queue Status",`Pending: ${null!==(t=e.queue?.pending)&&void 0!==t?t:"Unknown"}`,`Processing: ${null!==(a=e.queue?.processing)&&void 0!==a?a:"Unknown"}`,`Failed: ${null!==(n=e.queue?.failed)&&void 0!==n?n:"Unknown"}`,`Total: ${null!==(l=e.queue?.total)&&void 0!==l?l:"Unknown"}`,"","## Migration Status","Needs Migration: "+(e.migration?.needs_migration?"Yes":"No"),"Migration Complete: "+(e.migration?.migration_complete?"Yes":"No"),e.migration?.migration_date?`Migration Date: ${e.migration.migration_date}`:"",""];return r.push(...function(e){if(!e||0===e.length)return[];const t=["## Recent Errors"];return e.forEach((e,a)=>{t.push(`${a+1}. [${e.type}:${e.object_id}] ${e.error_message}`),e.error_detail&&t.push(` Detail: ${e.error_detail}`),t.push(` At: ${e.processed_at}`)}),t.push(""),t}(e.recent_errors)),r.push("=".repeat(60)),r.push("End of Report"),r.filter(e=>null!==e).join("\n")})(s),t=new Blob([e],{type:"text/plain"}),a=URL.createObjectURL(t),n=document.createElement("a");n.href=a,n.download=`quant-diagnostics-${(new Date).toISOString().split("T")[0]}.txt`,document.body.appendChild(n),n.click(),document.body.removeChild(n),URL.revokeObjectURL(a),p(!1)},disabled:d,isBusy:d},d?(0,xe.__)("Downloading…","quant"):(0,xe.__)("Download Report","quant")))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Environment","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("table",{className:"widefat",style:{margin:0}},(0,t.createElement)("tbody",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px",width:"200px"}},(0,xe.__)("PHP Version","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.php_version||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("WordPress Version","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.wordpress_version||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Plugin Version","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.plugin_version||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("PHP Memory Limit","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.php_memory_limit||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("PHP Max Execution Time","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.php_max_execution_time||"-"," ","seconds")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Multisite","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.environment?.is_multisite?(0,xe.__)("Yes","quant"):(0,xe.__)("No","quant"))))))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Plugin Configuration","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("table",{className:"widefat",style:{margin:0}},(0,t.createElement)("tbody",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px",width:"200px"}},(0,xe.__)("Mode","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},"static"===s?.plugin?.mode?(0,xe.__)("Static Site","quant"):(0,xe.__)("CDN Only","quant"))),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Credentials","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},(0,t.createElement)(h,{success:s?.plugin?.has_credentials,successText:(0,xe.__)("Configured","quant"),failText:(0,xe.__)("Not Configured","quant")}))),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Organization","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.plugin?.organization||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Project","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.plugin?.project||"-")),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Endpoint","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.plugin?.endpoint||"-")))))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("API Connection","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("div",{style:{display:"flex",alignItems:"center",gap:"10px"}},(0,t.createElement)(h,{success:s?.api?.connected,successText:(0,xe.__)("Connected","quant"),failText:(0,xe.__)("Not Connected","quant")}),s?.api?.error&&(0,t.createElement)("span",{style:{color:"#721c24"}},s.api.error)))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Database","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("table",{className:"widefat",style:{margin:0}},(0,t.createElement)("tbody",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px",width:"200px"}},(0,xe.__)("V2 Sync Queue Table","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},(0,t.createElement)(h,{success:s?.database?.v2_table_exists,successText:(0,xe.__)("Exists","quant"),failText:(0,xe.__)("Missing","quant")}))),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("V1 Queue Table","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.database?.v1_table_exists?(0,t.createElement)("span",{style:{color:"#856404"}},(0,xe.__)("Exists (can be cleaned up)","quant")):(0,t.createElement)("span",{style:{color:"#666"}},(0,xe.__)("Not present","quant")))))))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Queue Status","quant"))),(0,t.createElement)(Se.CardBody,null,s?.queue?.error?(0,t.createElement)(Se.Notice,{status:"error",isDismissible:!1},s.queue.error):(0,t.createElement)("div",{style:{display:"flex",gap:"30px"}},(0,t.createElement)("div",{style:{textAlign:"center"}},(0,t.createElement)("div",{style:{fontSize:"24px",fontWeight:"bold",color:"#0073aa"}},null!==(e=s?.queue?.pending)&&void 0!==e?e:0),(0,t.createElement)("div",{style:{fontSize:"12px",color:"#666"}},(0,xe.__)("Pending","quant"))),(0,t.createElement)("div",{style:{textAlign:"center"}},(0,t.createElement)("div",{style:{fontSize:"24px",fontWeight:"bold",color:"#f0b849"}},null!==(n=s?.queue?.processing)&&void 0!==n?n:0),(0,t.createElement)("div",{style:{fontSize:"12px",color:"#666"}},(0,xe.__)("Processing","quant"))),(0,t.createElement)("div",{style:{textAlign:"center"}},(0,t.createElement)("div",{style:{fontSize:"24px",fontWeight:"bold",color:"#d63638"}},null!==(l=s?.queue?.failed)&&void 0!==l?l:0),(0,t.createElement)("div",{style:{fontSize:"12px",color:"#666"}},(0,xe.__)("Failed","quant"))),(0,t.createElement)("div",{style:{textAlign:"center"}},(0,t.createElement)("div",{style:{fontSize:"24px",fontWeight:"bold",color:"#666"}},null!==(r=s?.queue?.total)&&void 0!==r?r:0),(0,t.createElement)("div",{style:{fontSize:"12px",color:"#666"}},(0,xe.__)("Total","quant")))))),(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Migration Status","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("table",{className:"widefat",style:{margin:0}},(0,t.createElement)("tbody",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px",width:"200px"}},(0,xe.__)("Needs Migration","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.migration?.needs_migration?(0,t.createElement)("a",{href:"#/migration"},(0,xe.__)("Yes - Click to migrate","quant")):(0,xe.__)("No","quant"))),(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Migration Complete","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s?.migration?.migration_complete?(0,xe.__)("Yes","quant"):(0,xe.__)("No","quant"))),s?.migration?.migration_date&&(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Migration Date","quant")),(0,t.createElement)("td",{style:{padding:"8px"}},s.migration.migration_date)))))),s?.recent_errors&&s.recent_errors.length>0&&(0,t.createElement)(Se.Card,{style:{marginBottom:"16px"}},(0,t.createElement)(Se.CardHeader,null,(0,t.createElement)("h2",{style:{margin:0}},(0,xe.__)("Recent Errors","quant"))),(0,t.createElement)(Se.CardBody,null,(0,t.createElement)("table",{className:"widefat",style:{margin:0}},(0,t.createElement)("thead",null,(0,t.createElement)("tr",null,(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Type","quant")),(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("ID","quant")),(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Error","quant")),(0,t.createElement)("th",{style:{textAlign:"left",padding:"8px"}},(0,xe.__)("Time","quant")))),(0,t.createElement)("tbody",null,s.recent_errors.map((e,a)=>(0,t.createElement)("tr",{key:a},(0,t.createElement)("td",{style:{padding:"8px"}},e.type),(0,t.createElement)("td",{style:{padding:"8px"}},e.object_id),(0,t.createElement)("td",{style:{padding:"8px",color:"#721c24"}},e.error_message),(0,t.createElement)("td",{style:{padding:"8px",whiteSpace:"nowrap"}},e.processed_at))))))))}function st(){return(0,t.createElement)(Ee,null,(0,t.createElement)(we,null,(0,t.createElement)(ce,null,(0,t.createElement)(oe,{path:"/",element:(0,t.createElement)(se,{to:"/dashboard",replace:!0})}),(0,t.createElement)(oe,{path:"/dashboard",element:(0,t.createElement)(Be,null)}),(0,t.createElement)(oe,{path:"/content",element:(0,t.createElement)(Ae,null)}),(0,t.createElement)(oe,{path:"/cache",element:(0,t.createElement)(Oe,null)}),(0,t.createElement)(oe,{path:"/search",element:(0,t.createElement)(Ye,null)}),(0,t.createElement)(oe,{path:"/settings",element:(0,t.createElement)(et,null)}),(0,t.createElement)(oe,{path:"/migration",element:(0,t.createElement)(tt,null)}),(0,t.createElement)(oe,{path:"/onboarding",element:(0,t.createElement)(lt,null)}),(0,t.createElement)(oe,{path:"/diagnostics",element:(0,t.createElement)(rt,null)}))))}const ot=document.getElementById("quant-admin-root");ot&&("function"==typeof a.createRoot?a.createRoot(ot).render((0,t.createElement)(st,null)):a.render((0,t.createElement)(st,null),ot))})(); \ No newline at end of file diff --git a/plugins/quant/assets/src/pages/Diagnostics.jsx b/plugins/quant/assets/src/pages/Diagnostics.jsx index 4e13b55..ca2331b 100644 --- a/plugins/quant/assets/src/pages/Diagnostics.jsx +++ b/plugins/quant/assets/src/pages/Diagnostics.jsx @@ -83,6 +83,13 @@ export function Diagnostics() { data.environment?.php_max_execution_time || 'Unknown' }`, `Multisite: ${ data.environment?.is_multisite ? 'Yes' : 'No' }`, + `WP-Cron Disabled: ${ + data.environment?.cron_disabled ? 'Yes' : 'No' + }`, + `Background Processing: ${ + data.environment?.background_processing ? 'Yes' : 'No' + }`, + `Last Queue Run: ${ data.environment?.last_queue_run || 'Never' }`, '', '## Plugin Configuration', `Mode: ${ data.plugin?.mode || 'Unknown' }`, diff --git a/plugins/quant/readme.txt b/plugins/quant/readme.txt index 92d40c4..87c0a0c 100644 --- a/plugins/quant/readme.txt +++ b/plugins/quant/readme.txt @@ -138,7 +138,14 @@ Enable search for the project in the Quant dashboard, then open **QuantCDN > Sea = A push failed with "Quant did not answer in time" = -The Quant API did not answer within the HTTP request timeout, or a gateway between your server and Quant was busy. The plugin sends a page or file again up to two times in the same run (after about 1 and 3 seconds). If it still fails, the job stays in the queue as failed. While the job has attempts left (three in total) and WP-Cron runs, the message ends with "The plugin will try again." and the background run tries it again, with a longer wait each time. When the job has used all attempts, or WP-Cron is disabled (`DISABLE_WP_CRON`), the message ends with "Select Retry failed on the dashboard.": select "Retry failed" on the QuantCDN dashboard (or in the push progress), or run `wp quant queue retry`. A failed connection test says "Test the connection again in a minute." The raw error text (for example the cURL error) is in the report from QuantCDN > Diagnostics > Download Report. +The Quant API did not answer within the HTTP request timeout, or a gateway between your server and Quant was busy (HTTP 502, 503 or 504). + +* A page or file push that fails fast (a gateway error, or a refused or reset connection) is sent again up to two times in the same run, after about 1 and 3 seconds. +* A request that timed out is sent again only when another attempt of the same length still fits in 45 seconds. With the default 30 second timeout it is not sent again in the same run; with a 10 second timeout it can be sent again twice. +* All retries in one run share the same 45 seconds. When one push used both retries and still failed, the other pushes in the same run are not retried until a push works again. +* Unpublish, purge, redirects and search records are not sent again in the same run. + +If the push still fails, the job stays in the queue as failed. While the job has attempts left (three in total) and the queue runs in the background, the message ends with "The plugin will try again." and the background run tries it again, with a longer wait each time. The queue runs in the background when WP-Cron is enabled, or when `DISABLE_WP_CRON` is set and a system cron ran the queue in the last 15 minutes. When the job has used all attempts, or nothing runs the queue in the background, the message ends with "Select Retry failed on the dashboard.": select "Retry failed" on the QuantCDN dashboard (or in the push progress), or run `wp quant queue retry`. A failed connection test says "Test the connection again in a minute." The raw error text (for example the cURL error) is in the report from QuantCDN > Diagnostics > Download Report. = Forms on a static site = @@ -147,11 +154,13 @@ Follow the [Forms documentation](https://docs.quantcdn.io/docs/dashboard/forms). == Changelog == = 2.0.6 = -* Fixed: when the Quant API did not answer, the push showed raw transport text such as "cURL error 28: Operation timed out after 30001 milliseconds with 0 bytes received". The dashboard queue card and the push progress now show a short message, for example "Quant did not answer in time. The plugin will try again." The message says "Select Retry failed on the dashboard." instead when the job has used all attempts or WP-Cron is disabled, and a failed connection test says "Test the connection again in a minute." The raw text is kept apart and is in the diagnostics report and in `wp quant queue errors`. -* Fixed: one stalled request or gateway error (502, 503, 504) failed the page until the next background run, and the background run only happens when WP-Cron runs. A page or file push is now sent again up to two times in the same run, after about 1 and 3 seconds. +* Fixed: when the Quant API did not answer, the push showed raw transport text such as "cURL error 28: Operation timed out after 30001 milliseconds with 0 bytes received". The dashboard queue card and the push progress now show a short message, for example "Quant did not answer in time. The plugin will try again." The message says "Select Retry failed on the dashboard." instead when the job has used all attempts or nothing runs the queue in the background (WP-Cron disabled and no system cron run in the last 15 minutes), and a failed connection test says "Test the connection again in a minute." The raw text is kept apart and is in the diagnostics report and in `wp quant queue errors`. +* Fixed: one stalled request or gateway error (502, 503, 504) failed the page until the next background run, and the background run only happens when WP-Cron runs. A page or file push that fails fast is now sent again up to two times in the same run, after about 1 and 3 seconds. A request that timed out is sent again only when another attempt still fits in 45 seconds. All retries in one run share those 45 seconds, and after one push fails with both retries the others in the run are not retried. +* Other request failures (for example too many redirects) show "The request to Quant failed." and are not sent again in the same run. * A page larger than 512 KB now gets the same 120 second minimum timeout as a file upload. * Readme: `QUANT_HTTP_REQUEST_TIMEOUT` is documented. -* The queue table has a new column for the error detail. It is added automatically on the first request after the update. +* The queue table has a new column for the error detail. It is added automatically on the first request after the update; the schema version is recorded only after the column exists. +* Diagnostics: the report shows whether the queue runs in the background and when it last ran. = 2.0.5 = * Search page: a cleaner default style that still follows the theme. The checkboxes, counts, headings, tags, buttons and pagination have even spacing, every control has a visible focus outline, the "Clear refinements" button uses the theme text colour (it was pale blue), the result title is larger than its summary, and on a phone the facets move above the results. The InstantSearch widgets no longer turn the text navy. diff --git a/plugins/quant/src/Admin/DiagnosticsController.php b/plugins/quant/src/Admin/DiagnosticsController.php index bb141a2..91c32d2 100644 --- a/plugins/quant/src/Admin/DiagnosticsController.php +++ b/plugins/quant/src/Admin/DiagnosticsController.php @@ -119,7 +119,9 @@ public function getDiagnostics(WP_REST_Request $request): array 'php_memory_limit' => ini_get('memory_limit'), 'php_max_execution_time' => ini_get('max_execution_time'), 'is_multisite' => is_multisite(), - 'cron_disabled' => !\Quant\Core\Scheduler::backgroundProcessingEnabled(), + 'cron_disabled' => defined('DISABLE_WP_CRON') && DISABLE_WP_CRON, + 'background_processing' => \Quant\Core\Scheduler::backgroundProcessingEnabled(), + 'last_queue_run' => $this->lastQueueRun(), 'next_queue_run' => wp_next_scheduled('quant_process_queue') ?: null, 'permalink_structure' => get_option('permalink_structure') ?: 'plain', ], @@ -167,4 +169,14 @@ public function getOnboardingStatus(WP_REST_Request $request): array 'has_settings' => $this->detector->hasV2Settings(), ]; } + + /** + * When the scheduled processor last ran the queue, in UTC, or null. + */ + private function lastQueueRun(): ?string + { + $time = (int) get_option(\Quant\Core\Scheduler::OPTION_LAST_RUN, 0); + + return $time > 0 ? gmdate('Y-m-d H:i:s', $time) . ' UTC' : null; + } } diff --git a/plugins/quant/src/Api/RetryPolicy.php b/plugins/quant/src/Api/RetryPolicy.php index 7287421..6806da1 100644 --- a/plugins/quant/src/Api/RetryPolicy.php +++ b/plugins/quant/src/Api/RetryPolicy.php @@ -12,8 +12,17 @@ * and upload, which replace the content at a URL). * * The queue retries failed jobs later (DatabaseQueue::retryDue), but only - * when WP-Cron runs. A short retry here clears most brief stalls in the same - * run, so an editor who pushes from the admin does not see a failure. + * when background processing runs. A short retry here clears most brief + * stalls in the same run. + * + * Use one instance per PHP request (the container holds a single + * QuantContentApi). The instance keeps request-wide state, so a struggling + * API does not get three times the requests and one job with many uploads + * cannot hold a run for minutes: + * - a circuit breaker: once one call used all its retries and still failed, + * later calls get no retries until a call succeeds; + * - one retry time budget for all calls: the waits and retried attempts of + * the whole request together stay within $maxElapsedSeconds. */ final class RetryPolicy { @@ -26,12 +35,18 @@ final class RetryPolicy /** @var Closure(): float */ private readonly Closure $clock; + private bool $open = false; + + /** Seconds spent in waits and retried attempts in this request. */ + private float $retrySpent = 0.0; + /** * @param array $delaysMs Wait before each retry; the count is the retry limit. * @param int $maxJitterMs Random extra wait per retry, so parallel workers do not retry together. - * @param float $maxElapsedSeconds No retry starts after this much time since the first attempt. - * A request that timed out already used its full timeout; this keeps one job from - * holding a run for several timeouts in a row. + * @param float $maxElapsedSeconds A retry starts only when the call so far, the wait and one more + * attempt as long as the last one fit in this time, and the same holds for the retry time of + * the whole request. A fast failure can retry; a request that used its full timeout retries + * only when the timeout is short enough. * @param (Closure(int): void)|null $sleep Waits the given milliseconds (injected in tests). * @param (Closure(): float)|null $clock Current time in seconds (injected in tests). */ @@ -55,8 +70,8 @@ public static function none(): self /** * True for a transport failure (no answer) or a gateway status. A local - * refusal (unreadable file, size limit) also has code 0 but is not a - * transport failure, and a retry cannot fix it. + * refusal (unreadable file, size limit) or another WP_Error also has + * code 0 but no 'transport' flag, and a retry cannot fix it. * * @param array $response */ @@ -78,19 +93,25 @@ public function run(Closure $send): array { $started = ($this->clock)(); $response = $send(); + $lastAttempt = ($this->clock)() - $started; $attempts = 1; foreach ($this->delaysMs as $delay) { - $wait = $delay + ($this->maxJitterMs > 0 ? random_int(0, $this->maxJitterMs) : 0); - if (!self::isRetryable($response) || !$this->hasTimeFor($started, $wait)) { + $wait = ($delay + ($this->maxJitterMs > 0 ? random_int(0, $this->maxJitterMs) : 0)) / 1000; + if (!self::isRetryable($response) || !$this->mayRetry($started, $wait, $lastAttempt)) { break; } - ($this->sleep)($wait); + $retryStart = ($this->clock)(); + ($this->sleep)((int) round($wait * 1000)); + $attemptStart = ($this->clock)(); $response = $send(); + $lastAttempt = ($this->clock)() - $attemptStart; + $this->retrySpent += ($this->clock)() - $retryStart; $attempts++; } + $this->updateBreaker($response, $attempts); if ($attempts > 1) { $response['attempts'] = $attempts; } @@ -98,8 +119,30 @@ public function run(Closure $send): array return $response; } - private function hasTimeFor(float $started, int $waitMs): bool + private function mayRetry(float $started, float $wait, float $lastAttempt): bool { - return ($this->clock)() - $started + ($waitMs / 1000) <= $this->maxElapsedSeconds; + if ($this->open) { + return false; + } + + $callElapsed = ($this->clock)() - $started; + + return $callElapsed + $wait + $lastAttempt <= $this->maxElapsedSeconds + && $this->retrySpent + $wait + $lastAttempt <= $this->maxElapsedSeconds; + } + + /** + * @param array $response + */ + private function updateBreaker(array $response, int $attempts): void + { + if (!self::isRetryable($response)) { + $this->open = false; + return; + } + + if ($this->delaysMs !== [] && $attempts > count($this->delaysMs)) { + $this->open = true; + } } } diff --git a/plugins/quant/src/Api/TransportError.php b/plugins/quant/src/Api/TransportError.php index 89ba688..4cadea6 100644 --- a/plugins/quant/src/Api/TransportError.php +++ b/plugins/quant/src/Api/TransportError.php @@ -26,6 +26,8 @@ final class TransportError public const CONNECTION = 'The connection to Quant failed.'; public const UNAVAILABLE = 'Quant is busy or unavailable (HTTP %d).'; public const SERVER = 'Quant had a server error (HTTP %d).'; + /** Any other WP_Error (request not executed, invalid URL, too many redirects): not a transport failure. */ + public const FAILED = 'The request to Quant failed.'; /** The job has attempts left and background processing runs. */ public const NEXT_AUTOMATIC = 'The plugin will try again.'; @@ -47,16 +49,22 @@ final class TransportError ]; /** - * @return array{message: string, detail: string} + * 'transport' is true only for an http_request_failed error whose text + * matches a known transport failure (timeout, DNS, TLS, connection). + * Only those can work when the same request is sent again. + * + * @return array{message: string, detail: string, transport: bool} */ public static function fromWpError(WP_Error $error): array { $code = (string) $error->get_error_code(); $raw = (string) $error->get_error_message(); + $message = $code === 'http_request_failed' ? self::classify($raw) : null; return [ - 'message' => self::classify($raw), + 'message' => $message ?? self::FAILED, 'detail' => $code !== '' ? "{$code}: {$raw}" : $raw, + 'transport' => $message !== null, ]; } @@ -73,7 +81,7 @@ public static function forStatus(int $status): ?string return $status >= 500 && $status < 600 ? sprintf(self::SERVER, $status) : null; } - private static function classify(string $raw): string + private static function classify(string $raw): ?string { foreach (self::PATTERNS as $message => $pattern) { if (preg_match($pattern, $raw) === 1) { @@ -81,7 +89,6 @@ private static function classify(string $raw): string } } - // Any other http_request_failed: the request did not complete. - return self::TIMEOUT; + return null; } } diff --git a/plugins/quant/src/Api/WordPressHttpClient.php b/plugins/quant/src/Api/WordPressHttpClient.php index 91c3a9d..60a8e05 100644 --- a/plugins/quant/src/Api/WordPressHttpClient.php +++ b/plugins/quant/src/Api/WordPressHttpClient.php @@ -195,7 +195,7 @@ private function send(string $url, array $args): array if ($response instanceof WP_Error) { $error = TransportError::fromWpError($response); - return ['code' => 0, 'body' => '', 'error' => $error['message'], 'detail' => $error['detail'], 'transport' => true]; + return ['code' => 0, 'body' => '', 'error' => $error['message'], 'detail' => $error['detail'], 'transport' => $error['transport']]; } $headers = wp_remote_retrieve_headers($response); diff --git a/plugins/quant/src/Core/Activator.php b/plugins/quant/src/Core/Activator.php index 8a58e97..d1104db 100644 --- a/plugins/quant/src/Core/Activator.php +++ b/plugins/quant/src/Core/Activator.php @@ -65,7 +65,25 @@ private static function createTables(): void require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta($sql); + self::markSchemaCurrent($wpdb, $tableName); + } + + /** + * Record the schema version only when the newest column exists, so a + * failed dbDelta (permissions, a locked table) runs again on the next + * request instead of being marked done. + */ + public static function markSchemaCurrent(\wpdb $wpdb, string $tableName): bool + { + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safely constructed + $column = $wpdb->get_var($wpdb->prepare("SHOW COLUMNS FROM `{$tableName}` LIKE %s", 'error_detail')); + if ($column !== 'error_detail') { + return false; + } + update_option('quant_db_version', self::DB_VERSION); + + return true; } private static function setDefaultOptions(): void diff --git a/plugins/quant/src/Core/Plugin.php b/plugins/quant/src/Core/Plugin.php index 02f34ce..d507c45 100644 --- a/plugins/quant/src/Core/Plugin.php +++ b/plugins/quant/src/Core/Plugin.php @@ -63,9 +63,11 @@ private function registerServices(): void $c->get(Settings::class) )); + // One RetryPolicy per request: its circuit breaker and retry budget are request-wide. $c->singleton(Api\ContentApiInterface::class, static fn(Container $c): Api\ContentApiInterface => new Api\QuantContentApi( $c->get(Settings::class), - $c->get(Api\HttpClientInterface::class) + $c->get(Api\HttpClientInterface::class), + new Api\RetryPolicy() )); $c->singleton(Sync\SyncQueueInterface::class, static function (): Sync\SyncQueueInterface { diff --git a/plugins/quant/src/Core/Scheduler.php b/plugins/quant/src/Core/Scheduler.php index db41f7a..d166416 100644 --- a/plugins/quant/src/Core/Scheduler.php +++ b/plugins/quant/src/Core/Scheduler.php @@ -27,14 +27,27 @@ class Scheduler public const SCHEDULED_SYNC_HOOK = 'quant_scheduled_sync'; public const INTERVAL = 'quant_every_minute'; + /** Unix time of the last scheduled queue run (processQueue). */ + public const OPTION_LAST_RUN = 'quant_last_queue_run'; + + /** A run within this many seconds means something runs the queue in the background. */ + public const RECENT_RUN = 900; + /** - * Whether WP-Cron runs the queue (and its retryDue backoff) in the - * background. The same check as the dashboard's "WP-Cron is disabled" - * warning (RestController, DiagnosticsController). + * Whether the queue (and its retryDue backoff) runs in the background: + * WP-Cron is enabled, or a scheduled run happened recently. The second + * case covers DISABLE_WP_CRON with a system cron that calls wp-cron.php. */ public static function backgroundProcessingEnabled(): bool { - return !(defined('DISABLE_WP_CRON') && DISABLE_WP_CRON); + $cronDisabled = defined('DISABLE_WP_CRON') && DISABLE_WP_CRON; + + return self::isBackgroundActive($cronDisabled, $cronDisabled ? (int) get_option(self::OPTION_LAST_RUN, 0) : 0, time()); + } + + public static function isBackgroundActive(bool $cronDisabled, int $lastRun, int $now): bool + { + return !$cronDisabled || ($lastRun > 0 && $now - $lastRun <= self::RECENT_RUN); } public function __construct( @@ -127,6 +140,7 @@ public function processQueue(): void return; } + update_option(self::OPTION_LAST_RUN, time(), false); $this->maintain(); /** This filter is documented in v2.0.0; it sets how many jobs one cron run processes. */ diff --git a/plugins/quant/tests/Api/QuantContentApiTest.php b/plugins/quant/tests/Api/QuantContentApiTest.php index 852401c..186bb32 100644 --- a/plugins/quant/tests/Api/QuantContentApiTest.php +++ b/plugins/quant/tests/Api/QuantContentApiTest.php @@ -393,6 +393,18 @@ public function it_hides_a_gateway_error_page_behind_a_short_message(): void $this->assertSame(502, $response->statusCode); } + #[Test] + public function a_non_transport_failure_is_not_retried_and_not_transient(): void + { + $this->http->alwaysRespond(['code' => 0, 'body' => '', 'error' => 'The request to Quant failed.', 'detail' => 'http_request_failed: Too many redirects.', 'transport' => false]); + + $response = $this->apiWithRetry()->publish('/a', '

a

'); + + $this->assertCount(1, $this->http->calls); + $this->assertFalse($response->transient); + $this->assertSame('http_request_failed: Too many redirects.', $response->detail); + } + #[Test] public function a_failed_connection_test_says_to_test_again(): void { diff --git a/plugins/quant/tests/Api/RetryPolicyTest.php b/plugins/quant/tests/Api/RetryPolicyTest.php index 0e0ceee..5545022 100644 --- a/plugins/quant/tests/Api/RetryPolicyTest.php +++ b/plugins/quant/tests/Api/RetryPolicyTest.php @@ -116,22 +116,95 @@ public function it_adds_jitter_within_the_limit(): void $this->assertLessThanOrEqual(3400, $this->slept[1]); } - #[Test] - public function it_does_not_start_a_retry_after_the_elapsed_limit(): void + /** + * @param array> $responses Answers in order; each attempt takes $seconds. + */ + private function slow(float $seconds, array $responses, int &$calls): \Closure { - $calls = 0; - $slow = function () use (&$calls): array { + return function () use ($seconds, &$responses, &$calls): array { $calls++; - $this->now += 30.0; // Each attempt waits for the full request timeout. - return self::TIMEOUT; + $this->now += $seconds; + return array_shift($responses) ?? self::TIMEOUT; }; + } - $response = $this->policy(maxElapsed: 45.0)->run($slow); + #[Test] + public function a_request_that_used_the_full_30_s_timeout_is_not_retried(): void + { + $calls = 0; + $response = $this->policy()->run($this->slow(30.0, [], $calls)); - // 30 s + 1 s delay fits in 45 s; a second retry at 61 s + 3 s does not. + // 30 s + 1 s wait + another 30 s attempt does not fit in 45 s. + $this->assertSame(1, $calls); + $this->assertSame([], $this->slept); + $this->assertArrayNotHasKey('attempts', $response); + } + + #[Test] + public function a_timed_out_request_is_retried_while_the_next_attempt_fits(): void + { + $calls = 0; + $this->policy()->run($this->slow(10.0, [], $calls)); + + // 10 + 1 + 10 = 21 s and 21 + 3 + 10 = 34 s both fit in 45 s. + $this->assertSame(3, $calls); + $this->assertSame([1000, 3000], $this->slept); + + $calls = 0; + $this->slept = []; + $this->policy()->run($this->slow(15.0, [], $calls)); + + // 15 + 1 + 15 = 31 s fits; 31 + 3 + 15 = 49 s does not. $this->assertSame(2, $calls); $this->assertSame([1000], $this->slept); - $this->assertSame(2, $response['attempts']); + } + + #[Test] + public function after_one_call_used_all_its_retries_later_calls_get_none(): void + { + $policy = $this->policy(); + $calls = 0; + $busy = static function () use (&$calls): array { + $calls++; + return ['code' => 503, 'body' => '']; + }; + + for ($i = 0; $i < 10; $i++) { + $policy->run($busy); + } + + // 3 attempts for the first upload, then 1 for each of the other 9. + $this->assertSame(12, $calls); + $this->assertSame([1000, 3000], $this->slept); + } + + #[Test] + public function a_success_closes_the_breaker_again(): void + { + $policy = $this->policy(); + $calls = 0; + $policy->run($this->answers([self::TIMEOUT, self::TIMEOUT, self::TIMEOUT], $calls)); + $policy->run($this->answers([['code' => 200, 'body' => '{}']], $calls)); + $policy->run($this->answers([self::TIMEOUT, ['code' => 200, 'body' => '{}']], $calls)); + + $this->assertSame(6, $calls); + $this->assertSame([1000, 3000, 1000], $this->slept); + } + + #[Test] + public function all_calls_share_one_retry_time_budget(): void + { + $policy = $this->policy(); + $calls = 0; + $results = []; + for ($i = 0; $i < 4; $i++) { + // Each attempt takes 5 s; the third attempt of each call works. + $results[] = $policy->run($this->slow(5.0, [['code' => 503, 'body' => ''], ['code' => 503, 'body' => ''], ['code' => 201, 'body' => '{}']], $calls))['code']; + } + + // Retries cost (1 + 5) + (3 + 5) = 14 s per call: 14, 28, 42 s. The fourth call has no time left. + $this->assertSame([201, 201, 201, 503], $results); + $this->assertSame(10, $calls); } #[Test] diff --git a/plugins/quant/tests/Api/TransportErrorTest.php b/plugins/quant/tests/Api/TransportErrorTest.php index 719790a..b5bfc06 100644 --- a/plugins/quant/tests/Api/TransportErrorTest.php +++ b/plugins/quant/tests/Api/TransportErrorTest.php @@ -38,7 +38,6 @@ public static function transportFailures(): array return [ 'curl timeout' => ['http_request_failed', 'cURL error 28: Operation timed out after 30001 milliseconds with 0 bytes received', TransportError::TIMEOUT], 'fsockopen timeout' => ['http_request_failed', 'fsockopen: connection timed out', TransportError::TIMEOUT], - 'other http_request_failed' => ['http_request_failed', 'A valid URL was not provided.', TransportError::TIMEOUT], 'dns' => ['http_request_failed', 'cURL error 6: Could not resolve host: api.quantcdn.io', TransportError::DNS], 'tls certificate' => ['http_request_failed', 'cURL error 60: SSL certificate problem: unable to get local issuer certificate', TransportError::TLS], 'tls handshake' => ['http_request_failed', 'cURL error 35: OpenSSL SSL_connect: SSL_ERROR_SYSCALL in connection to api.quantcdn.io:443', TransportError::TLS], @@ -56,6 +55,32 @@ public function it_maps_a_transport_failure_to_a_short_message_and_keeps_the_raw $this->assertSame($expected, $error['message']); $this->assertSame("{$code}: {$raw}", $error['detail']); $this->assertStringNotContainsString('cURL', $error['message']); + $this->assertTrue($error['transport']); + } + + /** + * @return array + */ + public static function otherFailures(): array + { + return [ + 'not executed' => ['http_request_not_executed', 'User has blocked requests through HTTP.'], + 'invalid url' => ['http_request_failed', 'A valid URL was not provided.'], + 'too many redirects' => ['http_request_failed', 'Too many redirects.'], + 'timeout text under another code' => ['http_request_not_executed', 'timed out'], + ]; + } + + #[Test] + #[DataProvider('otherFailures')] + public function other_failures_get_a_neutral_message_and_are_not_transport_failures(string $code, string $raw): void + { + $error = TransportError::fromWpError(new \WP_Error($code, $raw)); + + $this->assertSame('The request to Quant failed.', $error['message']); + $this->assertSame(TransportError::FAILED, $error['message']); + $this->assertSame("{$code}: {$raw}", $error['detail']); + $this->assertFalse($error['transport']); } #[Test] diff --git a/plugins/quant/tests/Api/WordPressHttpClientTest.php b/plugins/quant/tests/Api/WordPressHttpClientTest.php index 13262c1..b941067 100644 --- a/plugins/quant/tests/Api/WordPressHttpClientTest.php +++ b/plugins/quant/tests/Api/WordPressHttpClientTest.php @@ -196,6 +196,16 @@ public function it_returns_a_short_message_for_a_transport_error_and_keeps_the_r ], $response); } + #[Test] + public function a_failure_that_is_not_a_transport_failure_is_not_marked_for_retry(): void + { + Functions\when('wp_remote_request')->justReturn(new \WP_Error('http_request_failed', 'Too many redirects.')); + + $response = WordPressHttpClient::withDefaults()->post('https://api.test/v1', [], ['url' => '/a']); + + $this->assertSame(['code' => 0, 'body' => '', 'error' => 'The request to Quant failed.', 'detail' => 'http_request_failed: Too many redirects.', 'transport' => false], $response); + } + #[Test] public function it_keeps_the_configured_timeout_for_a_normal_json_publish(): void { diff --git a/plugins/quant/tests/Core/ActivatorTest.php b/plugins/quant/tests/Core/ActivatorTest.php new file mode 100644 index 0000000..391c995 --- /dev/null +++ b/plugins/quant/tests/Core/ActivatorTest.php @@ -0,0 +1,65 @@ + */ + private array $options = []; + + protected function setUp(): void + { + parent::setUp(); + Monkey\setUp(); + Functions\when('update_option')->alias(function (string $key, $value): bool { + $this->options[$key] = $value; + return true; + }); + } + + protected function tearDown(): void + { + Monkey\tearDown(); + parent::tearDown(); + } + + private function wpdb(?string $column): \wpdb&Mockery\MockInterface + { + $wpdb = Mockery::mock(\wpdb::class); + $wpdb->shouldReceive('prepare')->once() + ->with('SHOW COLUMNS FROM `wp_quant_sync_queue` LIKE %s', 'error_detail') + ->andReturn('sql'); + $wpdb->shouldReceive('get_var')->once()->with('sql')->andReturn($column); + + return $wpdb; + } + + #[Test] + public function it_records_the_schema_version_when_the_new_column_exists(): void + { + $this->assertTrue(Activator::markSchemaCurrent($this->wpdb('error_detail'), 'wp_quant_sync_queue')); + $this->assertSame(Activator::DB_VERSION, $this->options['quant_db_version']); + } + + #[Test] + public function it_keeps_the_old_version_when_dbdelta_did_not_add_the_column(): void + { + // The upgrade then runs again on the next request. + $this->assertFalse(Activator::markSchemaCurrent($this->wpdb(null), 'wp_quant_sync_queue')); + $this->assertArrayNotHasKey('quant_db_version', $this->options); + } +} diff --git a/plugins/quant/tests/Core/SchedulerTest.php b/plugins/quant/tests/Core/SchedulerTest.php index 6d76fd2..071d2b6 100644 --- a/plugins/quant/tests/Core/SchedulerTest.php +++ b/plugins/quant/tests/Core/SchedulerTest.php @@ -33,12 +33,19 @@ final class SchedulerTest extends TestCase private JobProcessor&Mockery\MockInterface $processor; private Seeder&Mockery\MockInterface $seeder; + /** @var array */ + private array $options = []; + protected function setUp(): void { parent::setUp(); Monkey\setUp(); $this->stubCommonWordPressFunctions(); Functions\when('wp_get_schedules')->justReturn(['hourly' => [], 'daily' => [], 'weekly' => []]); + Functions\when('update_option')->alias(function (string $key, $value): bool { + $this->options[$key] = $value; + return true; + }); $this->queue = Mockery::mock(SyncQueueInterface::class); $this->processor = Mockery::mock(JobProcessor::class); @@ -140,6 +147,28 @@ public function it_processes_a_batch_and_queues_another_run_when_work_remains(): $this->scheduler()->processQueue(); } + #[Test] + public function a_queue_run_records_when_it_ran(): void + { + $this->processor->shouldReceive('run')->once()->andReturn(new SyncResult(0, 0)); + + $this->scheduler()->processQueue(); + + $this->assertEqualsWithDelta(time(), $this->options[Scheduler::OPTION_LAST_RUN], 2); + } + + #[Test] + public function background_processing_counts_as_on_when_wp_cron_runs_or_a_run_was_recent(): void + { + $now = 1_800_000_000; + + $this->assertTrue(Scheduler::isBackgroundActive(false, 0, $now), 'WP-Cron enabled'); + $this->assertTrue(Scheduler::isBackgroundActive(true, $now - 60, $now), 'a system cron ran a minute ago'); + $this->assertTrue(Scheduler::isBackgroundActive(true, $now - 900, $now), 'a run 15 minutes ago'); + $this->assertFalse(Scheduler::isBackgroundActive(true, $now - 901, $now), 'no run for over 15 minutes'); + $this->assertFalse(Scheduler::isBackgroundActive(true, 0, $now), 'never ran'); + } + #[Test] public function it_retries_due_jobs_every_run_and_prunes_once_a_day(): void { From 5517e1cac7addce10b18ea92bdd8854257538110 Mon Sep 17 00:00:00 2001 From: Stuart Rowlands Date: Sun, 15 Nov 2026 16:26:33 +1000 Subject: [PATCH 6/6] test(e2e): upgrade a 2.0.5 queue table with rows to the 2.0.6 schema --- tests/e2e/specs/schema-upgrade.spec.js | 71 ++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tests/e2e/specs/schema-upgrade.spec.js diff --git a/tests/e2e/specs/schema-upgrade.spec.js b/tests/e2e/specs/schema-upgrade.spec.js new file mode 100644 index 0000000..ddd8fd0 --- /dev/null +++ b/tests/e2e/specs/schema-upgrade.spec.js @@ -0,0 +1,71 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); +const { resetPlugin, configurePlugin, wp, wpEval } = require('../lib/wp'); +const { gotoQuant, rest } = require('../lib/admin'); + +// The queue table as 2.0.3-2.0.5 created it (no error_detail column). +const TABLE_2_0_5 = ` + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + job_id VARCHAR(36) NOT NULL, + type VARCHAR(32) NOT NULL, + object_id VARCHAR(255) NOT NULL, + action VARCHAR(20) NOT NULL, + status VARCHAR(20) DEFAULT 'pending', + attempts TINYINT DEFAULT 0, + error_message TEXT NULL, + meta LONGTEXT NULL, + claim VARCHAR(36) NULL, + dedupe_key CHAR(32) NULL, + created_at DATETIME NOT NULL, + processed_at DATETIME NULL, + PRIMARY KEY (id), + KEY idx_status (status), + KEY idx_type_object (type, object_id(191)), + KEY idx_job_id (job_id), + KEY idx_claim (claim), + KEY idx_dedupe (dedupe_key, status)`; + +function sql(query) { + return wpEval(`global $wpdb; echo json_encode(${query});`); +} + +test.describe('queue table upgrade', () => { + test.setTimeout(120_000); + + test.beforeEach(async () => { + await resetPlugin(); + configurePlugin({ mode: 'static' }); + }); + + test('a 2.0.5 table with rows gets error_detail and keeps its rows', async ({ page }) => { + const before = wpEval(` + global $wpdb; + $t = $wpdb->prefix . 'quant_sync_queue'; + $wpdb->query("DROP TABLE IF EXISTS $t"); + $wpdb->query("CREATE TABLE $t (${TABLE_2_0_5.replace(/\n/g, ' ')}) " . $wpdb->get_charset_collate()); + $wpdb->query("INSERT INTO $t (job_id, type, object_id, action, status, attempts, error_message, created_at, processed_at) + VALUES ('old-failed', 'post', '7', 'publish', 'failed', 1, 'HTTP 500', UTC_TIMESTAMP(), UTC_TIMESTAMP()), + ('old-pending', 'post', '8', 'publish', 'pending', 0, NULL, UTC_TIMESTAMP(), NULL)"); + update_option('quant_db_version', '2.0.3'); + echo json_encode($wpdb->get_var("SHOW COLUMNS FROM $t LIKE 'error_detail'")); + `); + expect(JSON.parse(before)).toBeNull(); + + // The next request runs the upgrade (Upgrader on init, before the command itself). + wp(['option', 'get', 'quant_version']); + + expect(wp(['option', 'get', 'quant_db_version']).trim()).toBe('2.0.6'); + expect(JSON.parse(sql(`$wpdb->get_var("SHOW COLUMNS FROM {$wpdb->prefix}quant_sync_queue LIKE 'error_detail'")`))).toBe('error_detail'); + const rows = JSON.parse(sql(`$wpdb->get_results("SELECT job_id, status, attempts, error_message, error_detail FROM {$wpdb->prefix}quant_sync_queue ORDER BY id", ARRAY_A)`)); + expect(rows).toEqual([ + { job_id: 'old-failed', status: 'failed', attempts: '1', error_message: 'HTTP 500', error_detail: null }, + { job_id: 'old-pending', status: 'pending', attempts: '0', error_message: null, error_detail: null }, + ]); + + // The old failure still shows in the card. + await gotoQuant(page, 'dashboard'); + const errors = await rest(page, '/queue/errors'); + expect(errors).toEqual([expect.objectContaining({ object_id: '7', error_message: 'HTTP 500', error_detail: null })]); + await expect(page.getByText('HTTP 500')).toBeVisible(); + }); +});