From 89549fd8daf620cdc46dbd98732c387bd9846080 Mon Sep 17 00:00:00 2001 From: acodebeard Date: Fri, 5 Jun 2026 18:10:21 -0700 Subject: [PATCH 1/3] Make REST token bootstrap cache safe --- plugin/plan-your-day/assets/js/plan.js | 69 ++++++++++++++++++- plugin/plan-your-day/assets/js/plan.min.js | 2 +- .../src/Frontend/PlannerRenderer.php | 26 +++---- plugin/plan-your-day/src/Plugin.php | 3 +- .../plan-your-day/src/Rest/PlannerRoutes.php | 50 ++++++++++++++ .../plan-your-day/tests/PlannerBlockTest.php | 8 ++- .../plan-your-day/tests/PlannerRoutesTest.php | 40 +++++++++++ .../tests/browser-app/router.php | 17 +++-- 8 files changed, 186 insertions(+), 29 deletions(-) diff --git a/plugin/plan-your-day/assets/js/plan.js b/plugin/plan-your-day/assets/js/plan.js index 86011fa..21cdb4e 100644 --- a/plugin/plan-your-day/assets/js/plan.js +++ b/plugin/plan-your-day/assets/js/plan.js @@ -962,14 +962,19 @@ browse: config.initialData?.browse || {}, route: config.initialData?.route || {}, }; + const canBootstrapEndpointToken = + typeof config.rest?.bootstrapUrl === 'string' && + config.rest.bootstrapUrl !== ''; + let endpointToken = typeof config.rest?.endpointToken === 'string' ? config.rest.endpointToken : ''; + let hasBootstrappedEndpointToken = false; + let endpointTokenRequest = null; const hasRestConfig = refs.form instanceof HTMLFormElement && typeof config.rest?.browseUrl === 'string' && config.rest.browseUrl !== '' && typeof config.rest?.routeUrl === 'string' && config.rest.routeUrl !== '' && - typeof config.rest?.endpointToken === 'string' && - config.rest.endpointToken !== ''; + (endpointToken !== '' || canBootstrapEndpointToken); const shouldHydrateOnLoad = Boolean(config.hydration?.shouldHydrateOnLoad); const colorModeDefault = normalizeColorModeDefault( config.colorModeDefault || root.getAttribute('data-plan-color-mode-default') @@ -1144,6 +1149,58 @@ animateStartPanel(false); }; + const ensureEndpointToken = async () => { + if (!canBootstrapEndpointToken) { + return endpointToken; + } + + if (hasBootstrappedEndpointToken && endpointToken !== '') { + return endpointToken; + } + + if (endpointTokenRequest) { + return endpointTokenRequest; + } + + endpointTokenRequest = fetch(config.rest.bootstrapUrl, { + method: 'POST', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }) + .then(async (response) => { + const responseBody = await response.json().catch(() => ({})); + debugLog(config, response.ok ? 'info' : 'warn', 'request:bootstrap', { + status: response.status, + ok: response.ok, + body: responseBody, + }); + + if (!response.ok) { + throw new Error(responseBody?.message || strings.requestFailed || ''); + } + + const freshToken = String(responseBody?.endpointToken || ''); + + if (freshToken === '') { + throw new Error(strings.requestFailed || ''); + } + + endpointToken = freshToken; + hasBootstrappedEndpointToken = true; + + return endpointToken; + }) + .finally(() => { + endpointTokenRequest = null; + }); + + return endpointTokenRequest; + }; + const sendRequest = async (endpointKey, payload, requestOptions = {}) => { if (!hasRestConfig) { return 'unsupported'; @@ -1193,9 +1250,15 @@ }); try { + const requestEndpointToken = await ensureEndpointToken(); + + if (requestEndpointToken === '') { + throw new Error(strings.requestFailed || ''); + } + const requestBody = { ...payload, - endpoint_token: config.rest.endpointToken, + endpoint_token: requestEndpointToken, }; if (endpointKey === 'browse') { diff --git a/plugin/plan-your-day/assets/js/plan.min.js b/plugin/plan-your-day/assets/js/plan.min.js index 63267ea..68d458a 100644 --- a/plugin/plan-your-day/assets/js/plan.min.js +++ b/plugin/plan-your-day/assets/js/plan.min.js @@ -1 +1 @@ -(()=>{const e="planYourDayEnhanced",t="planYourDayColorMode",a="light",n="dark",r="system",o=[a,n],s=[...o,r],i=e=>String(e??"").replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'"),l=(e,t={})=>{let a=String(e||"");return Object.entries(t||{}).forEach((([e,t])=>{a=a.split(`{${e}}`).join(String(t??""))})),a},u=(e,t="")=>Array.isArray(e)?e.map((e=>u(e))):e&&"object"==typeof e?Object.fromEntries(Object.entries(e).map((([e,t])=>{const a=String(e).toLowerCase();return[e,a.includes("token")||a.includes("api_key")||a.includes("authorization")||a.includes("cookie")||a.includes("secret")?"[redacted]":u(t,e)]}))):"string"==typeof e?String(t).toLowerCase().includes("token")?"[redacted]":e.replace(/([?&](?:key|api_key|token)=)[^&]+/gi,"$1[redacted]"):e,d=(e,t,a,n={})=>{if(!e?.debug||"undefined"==typeof console)return;("function"==typeof console[t]?console[t]:console.log).call(console,`[plan-your-day] ${a}`,u(n))},c=e=>Array.isArray(e)?e.map((e=>String(e??""))).filter(Boolean):[],p=(e,t)=>{e&&t&&(e.textContent="",window.requestAnimationFrame((()=>{e.textContent=t})))},y=e=>o.includes(String(e||"")),g=e=>{const t=String(e||"");return s.includes(t)?t:a},m=()=>{try{const e=window.localStorage?.getItem(t);return y(e)?e:""}catch(e){return""}},f=()=>"undefined"==typeof window||"function"!=typeof window.matchMedia?null:window.matchMedia("(prefers-color-scheme: dark)"),b=(e,t=f())=>{const o=m();if(y(o))return o;const s=g(e);return s===r?t?.matches?n:a:s},h=(e,t,r,o)=>{const s=y(r)?r:a;e.setAttribute("data-plan-color-mode",s),((e,t,a)=>{e.colorModeToggle instanceof HTMLButtonElement&&(e.colorModeToggle.hidden=!1,e.colorModeToggle.setAttribute("aria-pressed",String(t===n)),e.colorModeToggle.setAttribute("aria-label",String(a.darkModeLabel||"Dark mode")),e.colorModeToggleLabel instanceof HTMLElement&&(e.colorModeToggleLabel.textContent=String(a.darkModeLabel||"Dark mode")))})(t,s,o)},S=e=>{const t=e.find((e=>e.checked));return t?t.value:""},v=(e,t)=>{const a=e.form instanceof HTMLFormElement?new FormData(e.form):new FormData;return{category:String(a.get("category")||t.category||""),category_search:String(a.get("category_search")||t.categorySearch||""),waypoints:a.getAll("waypoints[]").map((e=>String(e||""))).filter(Boolean),start_mode:String(a.get("start_mode")||t.startMode||"default"),custom_start:String(a.get("custom_start")||"")}},w=(e,t,a)=>{const n=String(e?.id||""),r=String(e?.label||""),o=String(e?.address||""),s=String(e?.distance_label||""),u=String(e?.maps_uri||""),d=t.includes(n);return`\n
  • \n
    \n

    ${i(r)}

    \n ${s?`

    ${i(s)}

    `:""}\n

    ${i(o)}

    \n
    \n
    \n ${u?`${i(a.viewInGoogleMaps||"")}`:""}\n ${d?`${i(a.inTrip||"")}`:``}\n
    \n
  • \n `},M=(e,t,a=!1)=>{if(!e?.hasMoreResults)return"";const n=String(a?t.loadingMoreResults||t.moreResultsButton||"":t.moreResultsButton||"");return`\n
    \n \n
    \n `},L=(e,t,a,n={})=>`\n
    \n ${((e,t,a)=>{const n=Array.isArray(e?.searchResults)?e.searchResults:[];if(0===n.length){const t=e?.resultsEmptyState||{};return`\n
    \n

    ${i(t.heading||"")}

    \n

    ${i(t.body||"")}

    \n
    \n `}return`\n \n `})(e,t,a)}\n
    \n ${M(e,a,Boolean(n.isLoadingMore))}\n `,R=(e,t)=>{const a=new Set((e=>{const t=[];return(Array.isArray(e?.searchResults)?e.searchResults:[]).forEach((e=>{const a=String(e?.id||"");a&&!t.includes(a)&&t.push(a)})),t})(e));return(Array.isArray(t?.searchResults)?t.searchResults:[]).filter((e=>{const t=String(e?.id||"");return!t||!a.has(t)&&(a.add(t),!0)}))},_=(e,t)=>{e.categoryInput&&(e.categoryInput.value=t.category||""),e.waypointInputs&&(e.waypointInputs.innerHTML=c(t.route?.selectedWaypointIds).map((e=>``)).join(""))},E=(e,t)=>{if(!e.messages)return;const a=Array.isArray(t)?t:[];e.messages.hidden=0===a.length,e.messages.innerHTML=(e=>(Array.isArray(e)?e:[]).map((e=>{const t=String(e?.type||"note"),a=String(e?.text||""),n="warning"===t?"alert":"";return`
  • ${i(a)}
  • `})).join(""))(a)},A=(e,t,a,n)=>{if(!(e instanceof HTMLElement))return;const r=e.querySelector("[data-plan-load-more-wrap]"),o=M(t,a,n);o?r instanceof HTMLElement?r.outerHTML=o:e.insertAdjacentHTML("beforeend",o):r instanceof HTMLElement&&r.remove()},q=(e,t,a,n,r={})=>{if(!(e instanceof HTMLElement))return;const o=Boolean(r.appendResults),s=Array.isArray(r.appendedResults)?r.appendedResults:[];o&&(s.length>0&&((e,t,a,n)=>{if(!(e instanceof HTMLElement&&Array.isArray(t)&&0!==t.length))return!1;const r=e.querySelector("[data-plan-results-list]");if(!(r instanceof HTMLElement))return!1;const o=e.querySelector("[data-plan-results-empty]");return o instanceof HTMLElement&&o.remove(),r.insertAdjacentHTML("beforeend",t.map((e=>w(e,a,n))).join("")),!0})(e,s,a,n)||0===s.length&&(e=>e instanceof HTMLElement&&e.querySelector("[data-plan-results-list], [data-plan-results-empty]")instanceof HTMLElement)(e))?A(e,t,n,Boolean(r.isLoadingMore)):e.innerHTML=L(t,a,n,r)},T=(e,t,a,n={})=>{const r=t.category||"",o=t.expandedCategory||"",s=c(t.route?.selectedWaypointIds),i=t.browse||{},u=Boolean(i.hasSearch)&&!r,d=u||0===e.categoryButtons.length,p=u&&Boolean(t.customResultsExpanded)||!u&&0===e.categoryButtons.length,y={appendResults:Boolean(n.appendResults),appendedResults:Array.isArray(n.appendedResults)?n.appendedResults:[],isLoadingMore:Boolean(n.isLoadingMore)};e.categoryButtons.forEach((e=>{const t=e.getAttribute("data-category-key")||"",a=t===r&&t===o,n=e.closest(".plan-your-day__category-accordion-item");e.setAttribute("aria-expanded",String(a)),n instanceof HTMLElement&&n.classList.toggle("is-expanded",a)})),e.categoryRegions.forEach((e=>{const t=e.getAttribute("data-category-key")||"",n=t===r&&t===o,l=e.querySelector("[data-plan-category-results-panel]");if(e.hidden=!n,l instanceof HTMLElement){if(!n)return void(l.innerHTML="");q(l,i,s,a,y)}})),e.customResults&&(e.customResults.hidden=!d,e.customResults.classList.toggle("is-expanded",p)),e.customResultsButton&&e.customResultsButton.setAttribute("aria-expanded",String(p)),e.customResultsRegion&&(e.customResultsRegion.hidden=!p),e.customResultsHeading&&(e.customResultsHeading.textContent=u?l(a.searchResultsFor||"",{search:i.categoryLabel||""}):String(i?.resultsEmptyState?.heading||"")),e.customResultsDescription&&(e.customResultsDescription.textContent=String(u?a.customSearchResultsDescription||"":i?.resultsEmptyState?.body||"")),e.customResultsPanel&&(p?u?q(e.customResultsPanel,i,s,a,y):e.customResultsPanel.innerHTML=L(i,s,a,{isLoadingMore:!1}):e.customResultsPanel.innerHTML="")},H=(e,t,a)=>{e.tripHeaderActions&&(e.tripHeaderActions.innerHTML=((e,t)=>{const a=c(e?.selectedWaypointIds),n=String(e?.tripCountLabel||"");return`\n ${i(n)}\n ${a.length>0?``:""}\n `})(t.route,a)),e.tripRegion&&(e.tripRegion.innerHTML=((e,t,a)=>{const n=Array.isArray(e?.tripWaypoints)?e.tripWaypoints:[];if(0===n.length){const a=e?.tripEmptyState||{};return`\n
    \n

    ${i(a.heading||t.tripEmptyHeading||"")}

    \n

    ${i(a.body||t.tripEmptyBody||"")}

    \n
    \n `}return`\n
      \n ${n.map(((e,a)=>{const r=String(e?.id||""),o=String(e?.label||""),s=String(e?.address||""),u=a>0,d=a\n
      \n \n
      \n

      ${i(o)}

      \n

      ${i(s)}

      \n
      \n
      \n
      \n \n ${i(t.moveUp||"")}\n \n \n ${i(t.moveDown||"")}\n \n \n
      \n \n `})).join("")}\n
    \n `})(t.route,a,e.tripRegion.getAttribute("data-plan-trip-help-id")||""))},$=(e,t,a)=>{const n=t.route||{},r=String(n.iframeSrc||""),o=n.emptyPreviewState||{},s=String(n.mapsUrl||""),i=c(n.selectedWaypointIds).length>0;E(e,n.messages),e.mapWrap&&(e.mapWrap.hidden=""===r),e.iframe&&(e.iframe.src=r),e.previewEmpty&&(e.previewEmpty.hidden=""!==r),e.previewEmptyHeading&&(e.previewEmptyHeading.textContent=String(o.heading||"")),e.previewEmptyBody&&(e.previewEmptyBody.textContent=String(o.body||"")),e.summaryCount&&(e.summaryCount.textContent=String(n.tripCountLabel||""),e.summaryCount.hidden=i),e.openLinkLabel&&(e.openLinkLabel.textContent=String(n.mapsLinkLabel||"")),e.openLink&&(e.openLink.hidden=!i,e.openLink.classList.toggle("is-disabled",""===s),s?(e.openLink.href=s,e.openLink.removeAttribute("aria-disabled"),e.openLink.removeAttribute("tabindex"),e.openLink.removeAttribute("role")):(e.openLink.removeAttribute("href"),e.openLink.setAttribute("aria-disabled","true"),e.openLink.setAttribute("tabindex","0"),e.openLink.setAttribute("role","button")))},k=e=>e instanceof HTMLElement&&"function"==typeof e.focus&&(e.focus(),document.activeElement===e),x=e=>{if(!(e instanceof HTMLButtonElement))return null;if(e.matches('[data-plan-action="add-waypoint"]'))return{action:"add-waypoint",placeId:e.getAttribute("data-place-id")||e.value||""};if(e.matches('[data-plan-action="remove-waypoint"]'))return{action:"remove-waypoint",placeId:e.getAttribute("data-place-id")||e.value||""};if(e.matches("[data-plan-clear-trip]"))return{action:"clear-trip",placeId:""};if("move_waypoint"===e.name&&e.value){const[t,a]=String(e.value).split(":",2);return{action:"move-waypoint",placeId:t||"",direction:a||""}}return null},I=e=>0===c(e?.selectedWaypointIds).length,C=(e,t)=>{e.startModeInputs.forEach((e=>{e.checked=e.value===t.startMode})),e.customStartInput&&(e.customStartInput.value=t.customStart||"");const a="custom"===(S(e.startModeInputs)||t.startMode||"default");e.customStartWrap&&(e.customStartWrap.hidden=!a),e.customStartInput&&(e.customStartInput.disabled=!a)},B=(e,t)=>{if(!(e.categorySearchInput instanceof HTMLInputElement))return;const a=String(t.categorySearch||"");e.categorySearchInput.value!==a&&(e.categorySearchInput.value=a)},D=(e,t)=>{e.classList.toggle("is-submitting",t),e.setAttribute("aria-busy",String(t))},F=(e,t,a)=>{e.forEach((e=>{if(!(e instanceof HTMLButtonElement||e instanceof HTMLInputElement))return;if(t)return e.hasAttribute(a)||e.setAttribute(a,e.disabled?"true":"false"),void(e.disabled=!0);const n=e.getAttribute(a);null!==n&&(e.disabled="true"===n,e.removeAttribute(a))}))},P=(e,t)=>{F(e.querySelectorAll("[data-plan-route-mutation]"),t,"data-plan-disabled-before-request")},U=(e,t)=>{F(e.querySelectorAll(['[data-plan-form] button[type="submit"]:not([data-plan-route-mutation])',"[data-plan-load-more-button]","[data-plan-start-toggle]","[data-plan-custom-results-button]",'input[name="start_mode"]',"[data-plan-custom-start]","[data-plan-category-search]"].join(",")),t,"data-plan-browse-disabled-before-request")},W=(e,t,a)=>{const n=t.category||"";if(e.categoryPanels.forEach((t=>{const r=t.getAttribute("data-category-key")||"",o=0===e.categoryButtons.length||r===n||!1===e.customResults?.hidden;t.setAttribute("aria-busy",String(a&&o))})),e.customResultsPanel instanceof HTMLElement){const t=!1===e.customResults?.hidden;e.customResultsPanel.setAttribute("aria-busy",String(a&&t))}e.tripRegion instanceof HTMLElement&&e.tripRegion.setAttribute("aria-busy",String(a)),e.previewCard instanceof HTMLElement&&e.previewCard.setAttribute("aria-busy",String(a))},j=o=>{if(!(o instanceof HTMLElement)||"true"===o.dataset[e])return;o.dataset[e]="true";const s=(e=>{const t=e.querySelector("[data-plan-config]");if(!t)return{};try{return JSON.parse(t.textContent||"{}")}catch(e){return{}}})(o),i=s.strings||{},u={form:o.querySelector("[data-plan-form]"),liveRegion:o.querySelector("[data-plan-live-region]"),categoryInput:o.querySelector("[data-plan-category-input]"),waypointInputs:o.querySelector("[data-plan-waypoint-inputs]"),categoryButtons:Array.from(o.querySelectorAll("[data-plan-category-button]")),categoryItems:Array.from(o.querySelectorAll("[data-plan-category-item]")),categoryRegions:Array.from(o.querySelectorAll("[data-plan-category-region]")),categoryPanels:Array.from(o.querySelectorAll("[data-plan-category-results-panel]")),categorySearchInput:o.querySelector("[data-plan-category-search]"),customResults:o.querySelector("[data-plan-custom-results]"),customResultsButton:o.querySelector("[data-plan-custom-results-button]"),customResultsHeading:o.querySelector("[data-plan-custom-results-heading]"),customResultsDescription:o.querySelector("[data-plan-custom-results-description]"),customResultsRegion:o.querySelector("[data-plan-custom-results-region]"),customResultsPanel:o.querySelector("[data-plan-custom-results-panel]"),resultsHeading:o.querySelector("[data-plan-results-heading]"),startModeInputs:Array.from(o.querySelectorAll('input[name="start_mode"]')),customStartWrap:o.querySelector("[data-plan-custom-start-wrap]"),customStartInput:o.querySelector("[data-plan-custom-start]"),startToggle:o.querySelector("[data-plan-start-toggle]"),startToggleLabel:o.querySelector("[data-plan-start-toggle-label]"),startPanel:o.querySelector("[data-plan-start-panel]"),colorModeToggle:o.querySelector("[data-plan-color-mode-toggle]"),colorModeToggleLabel:o.querySelector("[data-plan-color-mode-toggle-label]"),tripHeaderActions:o.querySelector("[data-plan-trip-header-actions]"),tripHeading:o.querySelector("[data-plan-trip-heading]"),tripRegion:o.querySelector("[data-plan-trip-region]"),messages:o.querySelector("[data-plan-messages]"),previewCard:o.querySelector("[data-plan-preview-card]"),mapWrap:o.querySelector("[data-plan-map-wrap]"),iframe:o.querySelector("[data-plan-iframe]"),previewEmpty:o.querySelector("[data-plan-preview-empty]"),previewEmptyHeading:o.querySelector("[data-plan-preview-empty-heading]"),previewEmptyBody:o.querySelector("[data-plan-preview-empty-body]"),summaryCount:o.querySelector("[data-plan-summary-count]"),openLink:o.querySelector("[data-plan-open-link]"),openLinkLabel:o.querySelector("[data-plan-open-link-label]")},c={category:String(s.initialState?.category||""),categorySearch:String(s.initialState?.categorySearch||""),startMode:String(s.initialState?.startMode||"default"),customStart:String(s.initialState?.customStart||""),expandedCategory:String(s.initialState?.category||""),customResultsExpanded:Boolean(s.initialData?.browse?.isCustomSearch),isLoadingMore:!1,browse:s.initialData?.browse||{},route:s.initialData?.route||{}},w=u.form instanceof HTMLFormElement&&"string"==typeof s.rest?.browseUrl&&""!==s.rest.browseUrl&&"string"==typeof s.rest?.routeUrl&&""!==s.rest.routeUrl&&"string"==typeof s.rest?.endpointToken&&""!==s.rest.endpointToken,M=Boolean(s.hydration?.shouldHydrateOnLoad),L=g(s.colorModeDefault||o.getAttribute("data-plan-color-mode-default")),A=f();let q=!0,F=null,j="",K=0,O=null;const G="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(prefers-reduced-motion: reduce)").matches;let J=0;h(o,u,b(L,A),i),L===r&&A&&A.addEventListener("change",(()=>{m()||h(o,u,b(L,A),i)}));const N=()=>{T(u,c,i,{isLoadingMore:c.isLoadingMore}),H(u,c,i),$(u,c),_(u,c),C(u,c),B(u,c),P(o,!1)},Y=(e,t="")=>{E(u,[{type:"warning",text:e||i.requestFailed||""}]),p(u.liveRegion,t||e||i.requestFailed||"")},z=(e={})=>{if(!u.startToggle||!u.startPanel)return;const t=!1!==e.syncHidden;u.startToggle.hidden=!1,u.startToggle.setAttribute("aria-expanded",String(q)),u.startToggle.classList.toggle("is-collapsed",!q),t&&(u.startPanel.hidden=!q),u.startToggleLabel&&(u.startToggleLabel.textContent=String(q?i.hideStartOptions||"Hide options":i.showStartOptions||"Show options"))},Q=e=>{if(!(u.startPanel instanceof HTMLElement&&u.startToggle))return q=e,void z();const t=u.startPanel,a=G?0:480;J&&(window.cancelAnimationFrame(J),J=0);const n=t.hidden?0:t.getBoundingClientRect().height;t.hidden=!1,e&&(t.style.height="");const r=e?t.scrollHeight:0,o=e&&r>0?Math.max(n/r,0):1,s=e?1:0;if(q=e,t.style.overflow="hidden",t.style.pointerEvents="none",t.style.height=`${n}px`,t.style.opacity=String(o),z({syncHidden:!1}),a<=0||n===r)return t.style.height="",t.style.overflow="",t.style.pointerEvents="",t.style.opacity="",t.hidden=!e,void z();const i=performance.now(),l=u=>{const d=Math.min((u-i)/a,1),c=(p=d)<.5?4*p*p*p:1-Math.pow(-2*p+2,3)/2;var p;const y=n+(r-n)*c,g=o+(s-o)*c;t.style.height=`${Math.max(y,0)}px`,t.style.opacity=String(Math.max(Math.min(g,1),0)),d<1?J=window.requestAnimationFrame(l):(t.style.height="",t.style.overflow="",t.style.pointerEvents="",t.style.opacity="",t.hidden=!e,z(),J=0)};J=window.requestAnimationFrame(l)},V=async(e,t,a={})=>{if(!w)return"unsupported";const n=Boolean(a.appendBrowseResults),r=String(a.announcementMessage||""),y=String(a.errorMessage||""),g=String(a.searchContextKey||""),m="browse"===e&&!1!==a.refreshRoute,f="route"===e?a.routeFocusRequest??null:null;if(F instanceof AbortController){if("route"===j)return d(s,"info","request:blocked",{endpointKey:e,blockedBy:j}),"busy";F.abort()}K+=1;const b=K;"route"===e&&(O=f?{requestId:b,focusRequest:f}:null),F=new AbortController,j=e,D(o,!0),W(u,c,!0),P(o,"route"===e),U(o,"route"===e),d(s,"info","request:start",{endpointKey:e,payload:t});try{const a={...t,endpoint_token:s.rest.endpointToken};"browse"===e&&(a.refresh_route=m,""!==g&&(a.search_context_key=g));const f=await fetch(s.rest["browse"===e?"browseUrl":"routeUrl"],{method:"POST",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"},body:JSON.stringify(a),signal:F.signal}),v=await f.json().catch((()=>({})));if(d(s,f.ok?"info":"warn","request:response",{endpointKey:e,status:f.status,ok:f.ok,body:v}),!f.ok)throw new Error(v?.message||i.requestFailed||"");if(b!==K)return!0;if("browse"===e){const e=c.category||"",a=c.expandedCategory||"",r=c.categorySearch||"",s=v?.browse||{};if(n&&""!==String(s.searchResultsError||""))return c.isLoadingMore=!1,T(u,c,i,{appendResults:!0,appendedResults:[],isLoadingMore:!1}),Y(y||i.requestFailed||"",y||i.requestFailed||""),"failed";const d=n&&""!==g&&g===String(c.browse?.searchContextKey||"")&&g===String(s.searchContextKey||""),m=d?R(c.browse,s):[];if(c.browse=d?{...(h=c.browse)||{},...(S=s)||{},searchResults:[...Array.isArray(h?.searchResults)?h.searchResults:[],...R(h,S)]}:s,c.route=v?.route||c.route||{},c.category=String(c.browse.categoryKey||t.category||""),c.categorySearch=String(c.browse.categorySearch||t.category_search||""),c.isLoadingMore=!1,c.category?(c.expandedCategory=c.category===e?a:c.category,c.customResultsExpanded=!1):c.browse.hasSearch?String(t.category_search||"")!==r&&(c.expandedCategory="",c.customResultsExpanded=!0):(c.expandedCategory="",c.customResultsExpanded=!1),d)return T(u,c,i,{appendResults:!0,appendedResults:m,isLoadingMore:!1}),H(u,c,i),$(u,c),_(u,c),C(u,c),B(u,c),P(o,!1),p(u.liveRegion,c.browse?.searchResultsError?y||i.requestFailed||"":((e,t,a)=>e>0?l(a.loadedMoreResults||"",{count:e}):String(t?.hasMoreResults?a.resultsUpdated||"":a.noMoreResults||""))(m.length,c.browse,i)),"success"}else c.route=v?.route||c.route||{},c.category=String(c.route.categoryKey||c.category||""),c.categorySearch=String(c.route.categorySearch||t.category_search||"");return c.startMode=String(t.start_mode||c.startMode||"default"),c.customStart=String(t.custom_start||""),N(),"route"===e&&O&&O.requestId===b&&(((e,t)=>{if(!t)return;const a=String(t.placeId||""),n=e.tripHeaderActions?.querySelector("button:not([disabled]):not([hidden])");let r=null;if(a&&"add-waypoint"===t.action)r=e.tripRegion?.querySelector(`[data-waypoint-id="${a}"] button[name="remove_waypoint"]`);else if(a&&"move-waypoint"===t.action){const n=String(t.direction||"");r=e.tripRegion?.querySelector(`[data-waypoint-id="${a}"] button[name="move_waypoint"][value="${a}:${n}"]`)||e.tripRegion?.querySelector(`[data-waypoint-id="${a}"] button[name="remove_waypoint"]`)}else"remove-waypoint"===t.action?r=e.tripRegion?.querySelector('[data-plan-trip-list] button[name="remove_waypoint"]')||n:"clear-trip"===t.action&&(r=n||e.tripHeading);k(r)||k(e.tripHeading)})(u,O.focusRequest),O=null),p(u.liveRegion,r||""),"success"}catch(a){return"AbortError"===a?.name?(d(s,"info","request:aborted",{endpointKey:e}),"aborted"):(d(s,"error","request:failed",{endpointKey:e,error:a instanceof Error?a.message:String(a||""),payload:t}),n&&(c.isLoadingMore=!1,T(u,c,i,{appendResults:!0,appendedResults:[],isLoadingMore:!1})),Y(n?y||i.requestFailed||"":a instanceof Error?a.message:i.requestFailed||"",n&&(y||i.requestFailed)||""),"failed")}finally{b===K&&(n&&c.isLoadingMore&&(c.isLoadingMore=!1),D(o,!1),W(u,c,!1),P(o,!1),"route"===e&&O&&O.requestId===b&&(O=null),U(o,!1),F=null,j="")}var h,S};if(u.startModeInputs.forEach((e=>{e.addEventListener("change",(()=>{c.startMode=S(u.startModeInputs)||c.startMode||"default",C(u,c),w?V("browse",v(u,c),{announcementMessage:i.startingPointUpdated||"",refreshRoute:!0}):p(u.liveRegion,i.startingPointUpdated||"")}))})),u.customStartInput instanceof HTMLInputElement&&u.customStartInput.addEventListener("change",(()=>{c.customStart=u.customStartInput.value||"",C(u,c),w?V("browse",v(u,c),{announcementMessage:i.startingPointUpdated||"",refreshRoute:!0}):p(u.liveRegion,i.startingPointUpdated||"")})),u.categorySearchInput instanceof HTMLInputElement&&(u.categorySearchInput.addEventListener("input",(()=>{c.categorySearch=u.categorySearchInput.value||""})),u.categorySearchInput.addEventListener("keydown",(e=>{if("Enter"!==e.key||!w)return;e.preventDefault();const t=v(u,c);t.category="",t.category_search=u.categorySearchInput.value||"",c.expandedCategory="",c.customResultsExpanded=!0,V("browse",t,{announcementMessage:i.resultsUpdated||"",refreshRoute:I(c.route)})}))),u.form instanceof HTMLFormElement&&u.form.addEventListener("submit",(e=>{const t=e.submitter;if(!(t instanceof HTMLButtonElement&&w))return;let a="browse",n=i.resultsUpdated||"";const r=v(u,c);if(t.matches("[data-plan-category-button]")){const a=t.getAttribute("data-category-key")||"";if(a===c.category){const n=t.querySelector(".plan-your-day__category-title")?.textContent?.trim()||a;return e.preventDefault(),c.expandedCategory=c.expandedCategory===a?"":a,c.customResultsExpanded=!1,T(u,c,i),void p(u.liveRegion,c.expandedCategory===a?l(i.categoryResultsExpanded||"",{category:n}):l(i.categoryResultsCollapsed||"",{category:n}))}r.category=a,r.category_search="",c.expandedCategory=a,c.customResultsExpanded=!1}else t.matches('[data-plan-action="search-category-query"]')?(r.category="",r.category_search=u.categorySearchInput instanceof HTMLInputElement&&u.categorySearchInput.value||"",c.expandedCategory="",c.customResultsExpanded=!0):t.matches('[data-plan-action="add-waypoint"]')?(r.waypoints=[...r.waypoints,t.getAttribute("data-place-id")||t.value||""],a="route",n=i.tripUpdated||""):t.matches('[data-plan-action="remove-waypoint"]')?(r.remove_waypoint=t.getAttribute("data-place-id")||t.value||"",a="route",n=i.tripUpdated||""):t.matches("[data-plan-clear-trip]")?(r.clear_trip=!0,a="route",n=i.tripUpdated||""):"move_waypoint"===t.name&&t.value&&(r.move_waypoint=t.value,a="route",n=i.tripUpdated||"");e.preventDefault(),V(a,r,{announcementMessage:n,refreshRoute:"browse"===a?I(c.route):void 0,routeFocusRequest:"route"===a?x(t):null})})),o.addEventListener("click",(e=>{const r=e.target;if(!(r instanceof HTMLElement))return;if(r.closest('[data-plan-open-link][aria-disabled="true"]')instanceof HTMLElement)return e.preventDefault(),void p(u.liveRegion,i.openMapsDisabled||"");if(r.closest("[data-plan-start-toggle]")instanceof HTMLButtonElement)return e.preventDefault(),q?q&&Q(!1):q||Q(!0),void p(u.liveRegion,q?i.startOptionsExpanded||"":i.startOptionsCollapsed||"");if(r.closest("[data-plan-color-mode-toggle]")instanceof HTMLButtonElement){e.preventDefault();const r=(o.getAttribute("data-plan-color-mode")===n?n:a)===n?a:n;return(e=>{if(y(e))try{window.localStorage?.setItem(t,e)}catch(e){}})(r),void h(o,u,r,i)}if(r.closest("[data-plan-custom-results-button]")instanceof HTMLButtonElement){if(e.preventDefault(),!c.browse?.hasSearch||c.category)return;return c.expandedCategory="",c.customResultsExpanded=!c.customResultsExpanded,T(u,c,i),void p(u.liveRegion,c.customResultsExpanded?String(i.customResultsExpanded||""):String(i.customResultsCollapsed||""))}const s=r.closest("[data-plan-load-more-button]");if(s instanceof HTMLButtonElement){if(e.preventDefault(),!w||c.isLoadingMore||s.disabled)return;const t=String(c.browse?.nextPageToken||"");if(!t)return void p(u.liveRegion,i.noMoreResults||"");c.isLoadingMore=!0,T(u,c,i,{appendResults:!0,appendedResults:[],isLoadingMore:!0}),p(u.liveRegion,i.loadingMoreResults||""),V("browse",{...v(u,c),page_token:t,append_results:!0},{appendBrowseResults:!0,errorMessage:i.loadMoreError||"",refreshRoute:!1,searchContextKey:String(c.browse?.searchContextKey||"")})}})),o.addEventListener("keydown",(e=>{const t=e.target;if(!(t instanceof HTMLElement))return;t.closest('[data-plan-open-link][aria-disabled="true"]')instanceof HTMLElement&&("Enter"!==e.key&&" "!==e.key||(e.preventDefault(),p(u.liveRegion,i.openMapsDisabled||"")))})),N(),z(),o.classList.add("is-enhanced"),M){if(!w)return void Y(i.requestFailed||"");V("browse",v(u,c),{refreshRoute:!0})}},K=()=>{document.querySelectorAll("[data-plan-root]").forEach(j)};"loading"===document.readyState?document.addEventListener("DOMContentLoaded",K,{once:!0}):K()})(); \ No newline at end of file +(()=>{const e="planYourDayEnhanced",t="planYourDayColorMode",a="light",n="dark",r="system",o=[a,n],s=[...o,r],i=e=>String(e??"").replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'"),l=(e,t={})=>{let a=String(e||"");return Object.entries(t||{}).forEach((([e,t])=>{a=a.split(`{${e}}`).join(String(t??""))})),a},u=(e,t="")=>Array.isArray(e)?e.map((e=>u(e))):e&&"object"==typeof e?Object.fromEntries(Object.entries(e).map((([e,t])=>{const a=String(e).toLowerCase();return[e,a.includes("token")||a.includes("api_key")||a.includes("authorization")||a.includes("cookie")||a.includes("secret")?"[redacted]":u(t,e)]}))):"string"==typeof e?String(t).toLowerCase().includes("token")?"[redacted]":e.replace(/([?&](?:key|api_key|token)=)[^&]+/gi,"$1[redacted]"):e,d=(e,t,a,n={})=>{if(!e?.debug||"undefined"==typeof console)return;("function"==typeof console[t]?console[t]:console.log).call(console,`[plan-your-day] ${a}`,u(n))},c=e=>Array.isArray(e)?e.map((e=>String(e??""))).filter(Boolean):[],p=(e,t)=>{e&&t&&(e.textContent="",window.requestAnimationFrame((()=>{e.textContent=t})))},y=e=>o.includes(String(e||"")),g=e=>{const t=String(e||"");return s.includes(t)?t:a},m=()=>{try{const e=window.localStorage?.getItem(t);return y(e)?e:""}catch(e){return""}},f=()=>"undefined"==typeof window||"function"!=typeof window.matchMedia?null:window.matchMedia("(prefers-color-scheme: dark)"),b=(e,t=f())=>{const o=m();if(y(o))return o;const s=g(e);return s===r?t?.matches?n:a:s},h=(e,t,r,o)=>{const s=y(r)?r:a;e.setAttribute("data-plan-color-mode",s),((e,t,a)=>{e.colorModeToggle instanceof HTMLButtonElement&&(e.colorModeToggle.hidden=!1,e.colorModeToggle.setAttribute("aria-pressed",String(t===n)),e.colorModeToggle.setAttribute("aria-label",String(a.darkModeLabel||"Dark mode")),e.colorModeToggleLabel instanceof HTMLElement&&(e.colorModeToggleLabel.textContent=String(a.darkModeLabel||"Dark mode")))})(t,s,o)},S=e=>{const t=e.find((e=>e.checked));return t?t.value:""},w=(e,t)=>{const a=e.form instanceof HTMLFormElement?new FormData(e.form):new FormData;return{category:String(a.get("category")||t.category||""),category_search:String(a.get("category_search")||t.categorySearch||""),waypoints:a.getAll("waypoints[]").map((e=>String(e||""))).filter(Boolean),start_mode:String(a.get("start_mode")||t.startMode||"default"),custom_start:String(a.get("custom_start")||"")}},v=(e,t,a)=>{const n=String(e?.id||""),r=String(e?.label||""),o=String(e?.address||""),s=String(e?.distance_label||""),u=String(e?.maps_uri||""),d=t.includes(n);return`\n
  • \n
    \n

    ${i(r)}

    \n ${s?`

    ${i(s)}

    `:""}\n

    ${i(o)}

    \n
    \n
    \n ${u?`${i(a.viewInGoogleMaps||"")}`:""}\n ${d?`${i(a.inTrip||"")}`:``}\n
    \n
  • \n `},M=(e,t,a=!1)=>{if(!e?.hasMoreResults)return"";const n=String(a?t.loadingMoreResults||t.moreResultsButton||"":t.moreResultsButton||"");return`\n
    \n \n
    \n `},L=(e,t,a,n={})=>`\n
    \n ${((e,t,a)=>{const n=Array.isArray(e?.searchResults)?e.searchResults:[];if(0===n.length){const t=e?.resultsEmptyState||{};return`\n
    \n

    ${i(t.heading||"")}

    \n

    ${i(t.body||"")}

    \n
    \n `}return`\n \n `})(e,t,a)}\n
    \n ${M(e,a,Boolean(n.isLoadingMore))}\n `,R=(e,t)=>{const a=new Set((e=>{const t=[];return(Array.isArray(e?.searchResults)?e.searchResults:[]).forEach((e=>{const a=String(e?.id||"");a&&!t.includes(a)&&t.push(a)})),t})(e));return(Array.isArray(t?.searchResults)?t.searchResults:[]).filter((e=>{const t=String(e?.id||"");return!t||!a.has(t)&&(a.add(t),!0)}))},E=(e,t)=>{e.categoryInput&&(e.categoryInput.value=t.category||""),e.waypointInputs&&(e.waypointInputs.innerHTML=c(t.route?.selectedWaypointIds).map((e=>``)).join(""))},_=(e,t)=>{if(!e.messages)return;const a=Array.isArray(t)?t:[];e.messages.hidden=0===a.length,e.messages.innerHTML=(e=>(Array.isArray(e)?e:[]).map((e=>{const t=String(e?.type||"note"),a=String(e?.text||""),n="warning"===t?"alert":"";return`
  • ${i(a)}
  • `})).join(""))(a)},A=(e,t,a,n)=>{if(!(e instanceof HTMLElement))return;const r=e.querySelector("[data-plan-load-more-wrap]"),o=M(t,a,n);o?r instanceof HTMLElement?r.outerHTML=o:e.insertAdjacentHTML("beforeend",o):r instanceof HTMLElement&&r.remove()},q=(e,t,a,n,r={})=>{if(!(e instanceof HTMLElement))return;const o=Boolean(r.appendResults),s=Array.isArray(r.appendedResults)?r.appendedResults:[];o&&(s.length>0&&((e,t,a,n)=>{if(!(e instanceof HTMLElement&&Array.isArray(t)&&0!==t.length))return!1;const r=e.querySelector("[data-plan-results-list]");if(!(r instanceof HTMLElement))return!1;const o=e.querySelector("[data-plan-results-empty]");return o instanceof HTMLElement&&o.remove(),r.insertAdjacentHTML("beforeend",t.map((e=>v(e,a,n))).join("")),!0})(e,s,a,n)||0===s.length&&(e=>e instanceof HTMLElement&&e.querySelector("[data-plan-results-list], [data-plan-results-empty]")instanceof HTMLElement)(e))?A(e,t,n,Boolean(r.isLoadingMore)):e.innerHTML=L(t,a,n,r)},T=(e,t,a,n={})=>{const r=t.category||"",o=t.expandedCategory||"",s=c(t.route?.selectedWaypointIds),i=t.browse||{},u=Boolean(i.hasSearch)&&!r,d=u||0===e.categoryButtons.length,p=u&&Boolean(t.customResultsExpanded)||!u&&0===e.categoryButtons.length,y={appendResults:Boolean(n.appendResults),appendedResults:Array.isArray(n.appendedResults)?n.appendedResults:[],isLoadingMore:Boolean(n.isLoadingMore)};e.categoryButtons.forEach((e=>{const t=e.getAttribute("data-category-key")||"",a=t===r&&t===o,n=e.closest(".plan-your-day__category-accordion-item");e.setAttribute("aria-expanded",String(a)),n instanceof HTMLElement&&n.classList.toggle("is-expanded",a)})),e.categoryRegions.forEach((e=>{const t=e.getAttribute("data-category-key")||"",n=t===r&&t===o,l=e.querySelector("[data-plan-category-results-panel]");if(e.hidden=!n,l instanceof HTMLElement){if(!n)return void(l.innerHTML="");q(l,i,s,a,y)}})),e.customResults&&(e.customResults.hidden=!d,e.customResults.classList.toggle("is-expanded",p)),e.customResultsButton&&e.customResultsButton.setAttribute("aria-expanded",String(p)),e.customResultsRegion&&(e.customResultsRegion.hidden=!p),e.customResultsHeading&&(e.customResultsHeading.textContent=u?l(a.searchResultsFor||"",{search:i.categoryLabel||""}):String(i?.resultsEmptyState?.heading||"")),e.customResultsDescription&&(e.customResultsDescription.textContent=String(u?a.customSearchResultsDescription||"":i?.resultsEmptyState?.body||"")),e.customResultsPanel&&(p?u?q(e.customResultsPanel,i,s,a,y):e.customResultsPanel.innerHTML=L(i,s,a,{isLoadingMore:!1}):e.customResultsPanel.innerHTML="")},H=(e,t,a)=>{e.tripHeaderActions&&(e.tripHeaderActions.innerHTML=((e,t)=>{const a=c(e?.selectedWaypointIds),n=String(e?.tripCountLabel||"");return`\n ${i(n)}\n ${a.length>0?``:""}\n `})(t.route,a)),e.tripRegion&&(e.tripRegion.innerHTML=((e,t,a)=>{const n=Array.isArray(e?.tripWaypoints)?e.tripWaypoints:[];if(0===n.length){const a=e?.tripEmptyState||{};return`\n
    \n

    ${i(a.heading||t.tripEmptyHeading||"")}

    \n

    ${i(a.body||t.tripEmptyBody||"")}

    \n
    \n `}return`\n
      \n ${n.map(((e,a)=>{const r=String(e?.id||""),o=String(e?.label||""),s=String(e?.address||""),u=a>0,d=a\n
      \n \n
      \n

      ${i(o)}

      \n

      ${i(s)}

      \n
      \n
      \n
      \n \n ${i(t.moveUp||"")}\n \n \n ${i(t.moveDown||"")}\n \n \n
      \n \n `})).join("")}\n
    \n `})(t.route,a,e.tripRegion.getAttribute("data-plan-trip-help-id")||""))},k=(e,t,a)=>{const n=t.route||{},r=String(n.iframeSrc||""),o=n.emptyPreviewState||{},s=String(n.mapsUrl||""),i=c(n.selectedWaypointIds).length>0;_(e,n.messages),e.mapWrap&&(e.mapWrap.hidden=""===r),e.iframe&&(e.iframe.src=r),e.previewEmpty&&(e.previewEmpty.hidden=""!==r),e.previewEmptyHeading&&(e.previewEmptyHeading.textContent=String(o.heading||"")),e.previewEmptyBody&&(e.previewEmptyBody.textContent=String(o.body||"")),e.summaryCount&&(e.summaryCount.textContent=String(n.tripCountLabel||""),e.summaryCount.hidden=i),e.openLinkLabel&&(e.openLinkLabel.textContent=String(n.mapsLinkLabel||"")),e.openLink&&(e.openLink.hidden=!i,e.openLink.classList.toggle("is-disabled",""===s),s?(e.openLink.href=s,e.openLink.removeAttribute("aria-disabled"),e.openLink.removeAttribute("tabindex"),e.openLink.removeAttribute("role")):(e.openLink.removeAttribute("href"),e.openLink.setAttribute("aria-disabled","true"),e.openLink.setAttribute("tabindex","0"),e.openLink.setAttribute("role","button")))},$=e=>e instanceof HTMLElement&&"function"==typeof e.focus&&(e.focus(),document.activeElement===e),x=e=>{if(!(e instanceof HTMLButtonElement))return null;if(e.matches('[data-plan-action="add-waypoint"]'))return{action:"add-waypoint",placeId:e.getAttribute("data-place-id")||e.value||""};if(e.matches('[data-plan-action="remove-waypoint"]'))return{action:"remove-waypoint",placeId:e.getAttribute("data-place-id")||e.value||""};if(e.matches("[data-plan-clear-trip]"))return{action:"clear-trip",placeId:""};if("move_waypoint"===e.name&&e.value){const[t,a]=String(e.value).split(":",2);return{action:"move-waypoint",placeId:t||"",direction:a||""}}return null},I=e=>0===c(e?.selectedWaypointIds).length,C=(e,t)=>{e.startModeInputs.forEach((e=>{e.checked=e.value===t.startMode})),e.customStartInput&&(e.customStartInput.value=t.customStart||"");const a="custom"===(S(e.startModeInputs)||t.startMode||"default");e.customStartWrap&&(e.customStartWrap.hidden=!a),e.customStartInput&&(e.customStartInput.disabled=!a)},B=(e,t)=>{if(!(e.categorySearchInput instanceof HTMLInputElement))return;const a=String(t.categorySearch||"");e.categorySearchInput.value!==a&&(e.categorySearchInput.value=a)},D=(e,t)=>{e.classList.toggle("is-submitting",t),e.setAttribute("aria-busy",String(t))},F=(e,t,a)=>{e.forEach((e=>{if(!(e instanceof HTMLButtonElement||e instanceof HTMLInputElement))return;if(t)return e.hasAttribute(a)||e.setAttribute(a,e.disabled?"true":"false"),void(e.disabled=!0);const n=e.getAttribute(a);null!==n&&(e.disabled="true"===n,e.removeAttribute(a))}))},P=(e,t)=>{F(e.querySelectorAll("[data-plan-route-mutation]"),t,"data-plan-disabled-before-request")},U=(e,t)=>{F(e.querySelectorAll(['[data-plan-form] button[type="submit"]:not([data-plan-route-mutation])',"[data-plan-load-more-button]","[data-plan-start-toggle]","[data-plan-custom-results-button]",'input[name="start_mode"]',"[data-plan-custom-start]","[data-plan-category-search]"].join(",")),t,"data-plan-browse-disabled-before-request")},j=(e,t,a)=>{const n=t.category||"";if(e.categoryPanels.forEach((t=>{const r=t.getAttribute("data-category-key")||"",o=0===e.categoryButtons.length||r===n||!1===e.customResults?.hidden;t.setAttribute("aria-busy",String(a&&o))})),e.customResultsPanel instanceof HTMLElement){const t=!1===e.customResults?.hidden;e.customResultsPanel.setAttribute("aria-busy",String(a&&t))}e.tripRegion instanceof HTMLElement&&e.tripRegion.setAttribute("aria-busy",String(a)),e.previewCard instanceof HTMLElement&&e.previewCard.setAttribute("aria-busy",String(a))},W=o=>{if(!(o instanceof HTMLElement)||"true"===o.dataset[e])return;o.dataset[e]="true";const s=(e=>{const t=e.querySelector("[data-plan-config]");if(!t)return{};try{return JSON.parse(t.textContent||"{}")}catch(e){return{}}})(o),i=s.strings||{},u={form:o.querySelector("[data-plan-form]"),liveRegion:o.querySelector("[data-plan-live-region]"),categoryInput:o.querySelector("[data-plan-category-input]"),waypointInputs:o.querySelector("[data-plan-waypoint-inputs]"),categoryButtons:Array.from(o.querySelectorAll("[data-plan-category-button]")),categoryItems:Array.from(o.querySelectorAll("[data-plan-category-item]")),categoryRegions:Array.from(o.querySelectorAll("[data-plan-category-region]")),categoryPanels:Array.from(o.querySelectorAll("[data-plan-category-results-panel]")),categorySearchInput:o.querySelector("[data-plan-category-search]"),customResults:o.querySelector("[data-plan-custom-results]"),customResultsButton:o.querySelector("[data-plan-custom-results-button]"),customResultsHeading:o.querySelector("[data-plan-custom-results-heading]"),customResultsDescription:o.querySelector("[data-plan-custom-results-description]"),customResultsRegion:o.querySelector("[data-plan-custom-results-region]"),customResultsPanel:o.querySelector("[data-plan-custom-results-panel]"),resultsHeading:o.querySelector("[data-plan-results-heading]"),startModeInputs:Array.from(o.querySelectorAll('input[name="start_mode"]')),customStartWrap:o.querySelector("[data-plan-custom-start-wrap]"),customStartInput:o.querySelector("[data-plan-custom-start]"),startToggle:o.querySelector("[data-plan-start-toggle]"),startToggleLabel:o.querySelector("[data-plan-start-toggle-label]"),startPanel:o.querySelector("[data-plan-start-panel]"),colorModeToggle:o.querySelector("[data-plan-color-mode-toggle]"),colorModeToggleLabel:o.querySelector("[data-plan-color-mode-toggle-label]"),tripHeaderActions:o.querySelector("[data-plan-trip-header-actions]"),tripHeading:o.querySelector("[data-plan-trip-heading]"),tripRegion:o.querySelector("[data-plan-trip-region]"),messages:o.querySelector("[data-plan-messages]"),previewCard:o.querySelector("[data-plan-preview-card]"),mapWrap:o.querySelector("[data-plan-map-wrap]"),iframe:o.querySelector("[data-plan-iframe]"),previewEmpty:o.querySelector("[data-plan-preview-empty]"),previewEmptyHeading:o.querySelector("[data-plan-preview-empty-heading]"),previewEmptyBody:o.querySelector("[data-plan-preview-empty-body]"),summaryCount:o.querySelector("[data-plan-summary-count]"),openLink:o.querySelector("[data-plan-open-link]"),openLinkLabel:o.querySelector("[data-plan-open-link-label]")},c={category:String(s.initialState?.category||""),categorySearch:String(s.initialState?.categorySearch||""),startMode:String(s.initialState?.startMode||"default"),customStart:String(s.initialState?.customStart||""),expandedCategory:String(s.initialState?.category||""),customResultsExpanded:Boolean(s.initialData?.browse?.isCustomSearch),isLoadingMore:!1,browse:s.initialData?.browse||{},route:s.initialData?.route||{}},v="string"==typeof s.rest?.bootstrapUrl&&""!==s.rest.bootstrapUrl;let M="string"==typeof s.rest?.endpointToken?s.rest.endpointToken:"",L=!1,A=null;const q=u.form instanceof HTMLFormElement&&"string"==typeof s.rest?.browseUrl&&""!==s.rest.browseUrl&&"string"==typeof s.rest?.routeUrl&&""!==s.rest.routeUrl&&(""!==M||v),F=Boolean(s.hydration?.shouldHydrateOnLoad),W=g(s.colorModeDefault||o.getAttribute("data-plan-color-mode-default")),O=f();let K=!0,J=null,N="",G=0,Y=null;const z="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(prefers-reduced-motion: reduce)").matches;let Q=0;h(o,u,b(W,O),i),W===r&&O&&O.addEventListener("change",(()=>{m()||h(o,u,b(W,O),i)}));const V=()=>{T(u,c,i,{isLoadingMore:c.isLoadingMore}),H(u,c,i),k(u,c),E(u,c),C(u,c),B(u,c),P(o,!1)},X=(e,t="")=>{_(u,[{type:"warning",text:e||i.requestFailed||""}]),p(u.liveRegion,t||e||i.requestFailed||"")},Z=(e={})=>{if(!u.startToggle||!u.startPanel)return;const t=!1!==e.syncHidden;u.startToggle.hidden=!1,u.startToggle.setAttribute("aria-expanded",String(K)),u.startToggle.classList.toggle("is-collapsed",!K),t&&(u.startPanel.hidden=!K),u.startToggleLabel&&(u.startToggleLabel.textContent=String(K?i.hideStartOptions||"Hide options":i.showStartOptions||"Show options"))},ee=e=>{if(!(u.startPanel instanceof HTMLElement&&u.startToggle))return K=e,void Z();const t=u.startPanel,a=z?0:480;Q&&(window.cancelAnimationFrame(Q),Q=0);const n=t.hidden?0:t.getBoundingClientRect().height;t.hidden=!1,e&&(t.style.height="");const r=e?t.scrollHeight:0,o=e&&r>0?Math.max(n/r,0):1,s=e?1:0;if(K=e,t.style.overflow="hidden",t.style.pointerEvents="none",t.style.height=`${n}px`,t.style.opacity=String(o),Z({syncHidden:!1}),a<=0||n===r)return t.style.height="",t.style.overflow="",t.style.pointerEvents="",t.style.opacity="",t.hidden=!e,void Z();const i=performance.now(),l=u=>{const d=Math.min((u-i)/a,1),c=(p=d)<.5?4*p*p*p:1-Math.pow(-2*p+2,3)/2;var p;const y=n+(r-n)*c,g=o+(s-o)*c;t.style.height=`${Math.max(y,0)}px`,t.style.opacity=String(Math.max(Math.min(g,1),0)),d<1?Q=window.requestAnimationFrame(l):(t.style.height="",t.style.overflow="",t.style.pointerEvents="",t.style.opacity="",t.hidden=!e,Z(),Q=0)};Q=window.requestAnimationFrame(l)},te=async(e,t,a={})=>{if(!q)return"unsupported";const n=Boolean(a.appendBrowseResults),r=String(a.announcementMessage||""),y=String(a.errorMessage||""),g=String(a.searchContextKey||""),m="browse"===e&&!1!==a.refreshRoute,f="route"===e?a.routeFocusRequest??null:null;if(J instanceof AbortController){if("route"===N)return d(s,"info","request:blocked",{endpointKey:e,blockedBy:N}),"busy";J.abort()}G+=1;const b=G;"route"===e&&(Y=f?{requestId:b,focusRequest:f}:null),J=new AbortController,N=e,D(o,!0),j(u,c,!0),P(o,"route"===e),U(o,"route"===e),d(s,"info","request:start",{endpointKey:e,payload:t});try{const a=await(async()=>v?L&&""!==M?M:A||(A=fetch(s.rest.bootstrapUrl,{method:"POST",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"},body:JSON.stringify({})}).then((async e=>{const t=await e.json().catch((()=>({})));if(d(s,e.ok?"info":"warn","request:bootstrap",{status:e.status,ok:e.ok,body:t}),!e.ok)throw new Error(t?.message||i.requestFailed||"");const a=String(t?.endpointToken||"");if(""===a)throw new Error(i.requestFailed||"");return M=a,L=!0,M})).finally((()=>{A=null})),A):M)();if(""===a)throw new Error(i.requestFailed||"");const f={...t,endpoint_token:a};"browse"===e&&(f.refresh_route=m,""!==g&&(f.search_context_key=g));const w=await fetch(s.rest["browse"===e?"browseUrl":"routeUrl"],{method:"POST",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"},body:JSON.stringify(f),signal:J.signal}),_=await w.json().catch((()=>({})));if(d(s,w.ok?"info":"warn","request:response",{endpointKey:e,status:w.status,ok:w.ok,body:_}),!w.ok)throw new Error(_?.message||i.requestFailed||"");if(b!==G)return!0;if("browse"===e){const e=c.category||"",a=c.expandedCategory||"",r=c.categorySearch||"",s=_?.browse||{};if(n&&""!==String(s.searchResultsError||""))return c.isLoadingMore=!1,T(u,c,i,{appendResults:!0,appendedResults:[],isLoadingMore:!1}),X(y||i.requestFailed||"",y||i.requestFailed||""),"failed";const d=n&&""!==g&&g===String(c.browse?.searchContextKey||"")&&g===String(s.searchContextKey||""),m=d?R(c.browse,s):[];if(c.browse=d?{...(h=c.browse)||{},...(S=s)||{},searchResults:[...Array.isArray(h?.searchResults)?h.searchResults:[],...R(h,S)]}:s,c.route=_?.route||c.route||{},c.category=String(c.browse.categoryKey||t.category||""),c.categorySearch=String(c.browse.categorySearch||t.category_search||""),c.isLoadingMore=!1,c.category?(c.expandedCategory=c.category===e?a:c.category,c.customResultsExpanded=!1):c.browse.hasSearch?String(t.category_search||"")!==r&&(c.expandedCategory="",c.customResultsExpanded=!0):(c.expandedCategory="",c.customResultsExpanded=!1),d)return T(u,c,i,{appendResults:!0,appendedResults:m,isLoadingMore:!1}),H(u,c,i),k(u,c),E(u,c),C(u,c),B(u,c),P(o,!1),p(u.liveRegion,c.browse?.searchResultsError?y||i.requestFailed||"":((e,t,a)=>e>0?l(a.loadedMoreResults||"",{count:e}):String(t?.hasMoreResults?a.resultsUpdated||"":a.noMoreResults||""))(m.length,c.browse,i)),"success"}else c.route=_?.route||c.route||{},c.category=String(c.route.categoryKey||c.category||""),c.categorySearch=String(c.route.categorySearch||t.category_search||"");return c.startMode=String(t.start_mode||c.startMode||"default"),c.customStart=String(t.custom_start||""),V(),"route"===e&&Y&&Y.requestId===b&&(((e,t)=>{if(!t)return;const a=String(t.placeId||""),n=e.tripHeaderActions?.querySelector("button:not([disabled]):not([hidden])");let r=null;if(a&&"add-waypoint"===t.action)r=e.tripRegion?.querySelector(`[data-waypoint-id="${a}"] button[name="remove_waypoint"]`);else if(a&&"move-waypoint"===t.action){const n=String(t.direction||"");r=e.tripRegion?.querySelector(`[data-waypoint-id="${a}"] button[name="move_waypoint"][value="${a}:${n}"]`)||e.tripRegion?.querySelector(`[data-waypoint-id="${a}"] button[name="remove_waypoint"]`)}else"remove-waypoint"===t.action?r=e.tripRegion?.querySelector('[data-plan-trip-list] button[name="remove_waypoint"]')||n:"clear-trip"===t.action&&(r=n||e.tripHeading);$(r)||$(e.tripHeading)})(u,Y.focusRequest),Y=null),p(u.liveRegion,r||""),"success"}catch(a){return"AbortError"===a?.name?(d(s,"info","request:aborted",{endpointKey:e}),"aborted"):(d(s,"error","request:failed",{endpointKey:e,error:a instanceof Error?a.message:String(a||""),payload:t}),n&&(c.isLoadingMore=!1,T(u,c,i,{appendResults:!0,appendedResults:[],isLoadingMore:!1})),X(n?y||i.requestFailed||"":a instanceof Error?a.message:i.requestFailed||"",n&&(y||i.requestFailed)||""),"failed")}finally{b===G&&(n&&c.isLoadingMore&&(c.isLoadingMore=!1),D(o,!1),j(u,c,!1),P(o,!1),"route"===e&&Y&&Y.requestId===b&&(Y=null),U(o,!1),J=null,N="")}var h,S};if(u.startModeInputs.forEach((e=>{e.addEventListener("change",(()=>{c.startMode=S(u.startModeInputs)||c.startMode||"default",C(u,c),q?te("browse",w(u,c),{announcementMessage:i.startingPointUpdated||"",refreshRoute:!0}):p(u.liveRegion,i.startingPointUpdated||"")}))})),u.customStartInput instanceof HTMLInputElement&&u.customStartInput.addEventListener("change",(()=>{c.customStart=u.customStartInput.value||"",C(u,c),q?te("browse",w(u,c),{announcementMessage:i.startingPointUpdated||"",refreshRoute:!0}):p(u.liveRegion,i.startingPointUpdated||"")})),u.categorySearchInput instanceof HTMLInputElement&&(u.categorySearchInput.addEventListener("input",(()=>{c.categorySearch=u.categorySearchInput.value||""})),u.categorySearchInput.addEventListener("keydown",(e=>{if("Enter"!==e.key||!q)return;e.preventDefault();const t=w(u,c);t.category="",t.category_search=u.categorySearchInput.value||"",c.expandedCategory="",c.customResultsExpanded=!0,te("browse",t,{announcementMessage:i.resultsUpdated||"",refreshRoute:I(c.route)})}))),u.form instanceof HTMLFormElement&&u.form.addEventListener("submit",(e=>{const t=e.submitter;if(!(t instanceof HTMLButtonElement&&q))return;let a="browse",n=i.resultsUpdated||"";const r=w(u,c);if(t.matches("[data-plan-category-button]")){const a=t.getAttribute("data-category-key")||"";if(a===c.category){const n=t.querySelector(".plan-your-day__category-title")?.textContent?.trim()||a;return e.preventDefault(),c.expandedCategory=c.expandedCategory===a?"":a,c.customResultsExpanded=!1,T(u,c,i),void p(u.liveRegion,c.expandedCategory===a?l(i.categoryResultsExpanded||"",{category:n}):l(i.categoryResultsCollapsed||"",{category:n}))}r.category=a,r.category_search="",c.expandedCategory=a,c.customResultsExpanded=!1}else t.matches('[data-plan-action="search-category-query"]')?(r.category="",r.category_search=u.categorySearchInput instanceof HTMLInputElement&&u.categorySearchInput.value||"",c.expandedCategory="",c.customResultsExpanded=!0):t.matches('[data-plan-action="add-waypoint"]')?(r.waypoints=[...r.waypoints,t.getAttribute("data-place-id")||t.value||""],a="route",n=i.tripUpdated||""):t.matches('[data-plan-action="remove-waypoint"]')?(r.remove_waypoint=t.getAttribute("data-place-id")||t.value||"",a="route",n=i.tripUpdated||""):t.matches("[data-plan-clear-trip]")?(r.clear_trip=!0,a="route",n=i.tripUpdated||""):"move_waypoint"===t.name&&t.value&&(r.move_waypoint=t.value,a="route",n=i.tripUpdated||"");e.preventDefault(),te(a,r,{announcementMessage:n,refreshRoute:"browse"===a?I(c.route):void 0,routeFocusRequest:"route"===a?x(t):null})})),o.addEventListener("click",(e=>{const r=e.target;if(!(r instanceof HTMLElement))return;if(r.closest('[data-plan-open-link][aria-disabled="true"]')instanceof HTMLElement)return e.preventDefault(),void p(u.liveRegion,i.openMapsDisabled||"");if(r.closest("[data-plan-start-toggle]")instanceof HTMLButtonElement)return e.preventDefault(),K?K&&ee(!1):K||ee(!0),void p(u.liveRegion,K?i.startOptionsExpanded||"":i.startOptionsCollapsed||"");if(r.closest("[data-plan-color-mode-toggle]")instanceof HTMLButtonElement){e.preventDefault();const r=(o.getAttribute("data-plan-color-mode")===n?n:a)===n?a:n;return(e=>{if(y(e))try{window.localStorage?.setItem(t,e)}catch(e){}})(r),void h(o,u,r,i)}if(r.closest("[data-plan-custom-results-button]")instanceof HTMLButtonElement){if(e.preventDefault(),!c.browse?.hasSearch||c.category)return;return c.expandedCategory="",c.customResultsExpanded=!c.customResultsExpanded,T(u,c,i),void p(u.liveRegion,c.customResultsExpanded?String(i.customResultsExpanded||""):String(i.customResultsCollapsed||""))}const s=r.closest("[data-plan-load-more-button]");if(s instanceof HTMLButtonElement){if(e.preventDefault(),!q||c.isLoadingMore||s.disabled)return;const t=String(c.browse?.nextPageToken||"");if(!t)return void p(u.liveRegion,i.noMoreResults||"");c.isLoadingMore=!0,T(u,c,i,{appendResults:!0,appendedResults:[],isLoadingMore:!0}),p(u.liveRegion,i.loadingMoreResults||""),te("browse",{...w(u,c),page_token:t,append_results:!0},{appendBrowseResults:!0,errorMessage:i.loadMoreError||"",refreshRoute:!1,searchContextKey:String(c.browse?.searchContextKey||"")})}})),o.addEventListener("keydown",(e=>{const t=e.target;if(!(t instanceof HTMLElement))return;t.closest('[data-plan-open-link][aria-disabled="true"]')instanceof HTMLElement&&("Enter"!==e.key&&" "!==e.key||(e.preventDefault(),p(u.liveRegion,i.openMapsDisabled||"")))})),V(),Z(),o.classList.add("is-enhanced"),F){if(!q)return void X(i.requestFailed||"");te("browse",w(u,c),{refreshRoute:!0})}},O=()=>{document.querySelectorAll("[data-plan-root]").forEach(W)};"loading"===document.readyState?document.addEventListener("DOMContentLoaded",O,{once:!0}):O()})(); \ No newline at end of file diff --git a/plugin/plan-your-day/src/Frontend/PlannerRenderer.php b/plugin/plan-your-day/src/Frontend/PlannerRenderer.php index 54bd9f7..7e94d49 100644 --- a/plugin/plan-your-day/src/Frontend/PlannerRenderer.php +++ b/plugin/plan-your-day/src/Frontend/PlannerRenderer.php @@ -8,7 +8,6 @@ use Acodebeard\PlanYourDay\Planner\PlannerStateBuilder; use Acodebeard\PlanYourDay\Planner\RequestStateParser; use Acodebeard\PlanYourDay\Rest\PlannerRoutes; -use Acodebeard\PlanYourDay\Security\VisitorTokenManager; use Acodebeard\PlanYourDay\Settings\Settings; defined( 'ABSPATH' ) || exit; @@ -19,22 +18,19 @@ final class PlannerRenderer { private RequestStateParser $request_state_parser; private PlannerStateBuilder $planner_state_builder; private PlannerPayloadBuilder $planner_payload_builder; - private VisitorTokenManager $visitor_token_manager; public function __construct( Settings $settings, CategoryCatalog $category_catalog, RequestStateParser $request_state_parser, PlannerStateBuilder $planner_state_builder, - PlannerPayloadBuilder $planner_payload_builder, - VisitorTokenManager $visitor_token_manager + PlannerPayloadBuilder $planner_payload_builder ) { $this->settings = $settings; $this->category_catalog = $category_catalog; $this->request_state_parser = $request_state_parser; $this->planner_state_builder = $planner_state_builder; $this->planner_payload_builder = $planner_payload_builder; - $this->visitor_token_manager = $visitor_token_manager; } public function render( array $request = [], string $action_url = '' ): string { @@ -67,7 +63,6 @@ public function render( array $request = [], string $action_url = '' ): string { $action_url = '' !== $action_url ? $action_url : $this->get_current_url(); $form_action = $action_url . '#' . $instance_id; $maps_link_enabled = '' !== $planner_state['maps_url']; - $endpoint_token = $this->visitor_token_manager->get_endpoint_token(); $color_mode_default = $this->settings->get_color_mode_default(); $initial_color_mode = Settings::COLOR_MODE_SYSTEM === $color_mode_default ? '' : $color_mode_default; @@ -126,7 +121,7 @@ class="plan-your-day__layout" - + planner_payload_builder->build_browse_payload( $planner_state ); $route_payload = $this->planner_payload_builder->build_route_payload( $planner_state ); @@ -681,14 +676,15 @@ private function build_config( string $instance_id, string $action_url, array $p 'actionUrl' => $action_url, 'sectionId' => $instance_id, 'colorModeDefault' => $this->settings->get_color_mode_default(), - 'startPoints' => $start_points, - 'categoryCatalog' => $category_catalog, - 'rest' => [ - 'browseUrl' => rest_url( PlannerRoutes::REST_NAMESPACE . '/browse' ), - 'routeUrl' => rest_url( PlannerRoutes::REST_NAMESPACE . '/route' ), - 'endpointToken' => $endpoint_token, + 'startPoints' => $start_points, + 'categoryCatalog' => $category_catalog, + 'rest' => [ + 'bootstrapUrl' => rest_url( PlannerRoutes::REST_NAMESPACE . '/bootstrap' ), + 'browseUrl' => rest_url( PlannerRoutes::REST_NAMESPACE . '/browse' ), + 'routeUrl' => rest_url( PlannerRoutes::REST_NAMESPACE . '/route' ), + 'endpointToken' => '', ], - 'hydration' => [ + 'hydration' => [ 'shouldHydrateOnLoad' => $should_hydrate_on_load, ], 'strings' => [ diff --git a/plugin/plan-your-day/src/Plugin.php b/plugin/plan-your-day/src/Plugin.php index 470ee20..a48101a 100644 --- a/plugin/plan-your-day/src/Plugin.php +++ b/plugin/plan-your-day/src/Plugin.php @@ -89,8 +89,7 @@ private function __construct() { $this->category_catalog, $this->request_state_parser, $this->planner_state_builder(), - $this->planner_payload_builder, - $this->visitor_token_manager + $this->planner_payload_builder ); $this->planner_shortcode = new PlannerShortcode( $this->planner_renderer, $this->frontend_assets ); $this->planner_block = new PlannerBlock( $this->planner_renderer, $this->frontend_assets ); diff --git a/plugin/plan-your-day/src/Rest/PlannerRoutes.php b/plugin/plan-your-day/src/Rest/PlannerRoutes.php index ceadce7..9eb56d2 100644 --- a/plugin/plan-your-day/src/Rest/PlannerRoutes.php +++ b/plugin/plan-your-day/src/Rest/PlannerRoutes.php @@ -56,6 +56,16 @@ public function __construct( } public function register(): void { + register_rest_route( + self::REST_NAMESPACE, + '/bootstrap', + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [ $this, 'bootstrap' ], + 'permission_callback' => '__return_true', + ] + ); + register_rest_route( self::REST_NAMESPACE, '/browse', @@ -79,6 +89,32 @@ public function register(): void { ); } + public function bootstrap( WP_REST_Request $request ): WP_REST_Response|WP_Error { + $guard = $this->guard_bootstrap_request(); + + if ( $guard instanceof WP_Error ) { + return $guard; + } + + $endpoint_token = $this->visitor_token_manager->get_endpoint_token(); + + if ( '' === $endpoint_token ) { + return new WP_Error( + 'plan_your_day_token_unavailable', + $this->request_verification_failed_message(), + [ + 'status' => 403, + ] + ); + } + + return new WP_REST_Response( + [ + 'endpointToken' => $endpoint_token, + ] + ); + } + public function browse( WP_REST_Request $request ): WP_REST_Response|WP_Error { DebugLogger::log( 'rest.browse.request', @@ -180,6 +216,20 @@ public function route( WP_REST_Request $request ): WP_REST_Response|WP_Error { ); } + private function guard_bootstrap_request(): ?WP_Error { + if ( ! $this->request_origin_validator->is_same_site_request( $_SERVER ) ) { + return new WP_Error( + 'plan_your_day_invalid_origin', + $this->request_verification_failed_message(), + [ + 'status' => 403, + ] + ); + } + + return $this->rate_limiter->enforce( 'bootstrap', $_SERVER, self::RATE_LIMIT_BASE_COST ); + } + private function guard_request( WP_REST_Request $request, string $scope, array $request_state ): ?WP_Error { if ( ! $this->request_origin_validator->is_same_site_request( $_SERVER ) ) { $error = new WP_Error( diff --git a/plugin/plan-your-day/tests/PlannerBlockTest.php b/plugin/plan-your-day/tests/PlannerBlockTest.php index 4e95f1b..3efe079 100644 --- a/plugin/plan-your-day/tests/PlannerBlockTest.php +++ b/plugin/plan-your-day/tests/PlannerBlockTest.php @@ -132,7 +132,6 @@ function wp_unique_id( string $prefix = '' ): string { use Acodebeard\PlanYourDay\Planner\StartContextResolver; use Acodebeard\PlanYourDay\Planner\WaypointList; use Acodebeard\PlanYourDay\Security\RequestOriginValidator; - use Acodebeard\PlanYourDay\Security\VisitorTokenManager; use Acodebeard\PlanYourDay\Settings\Settings; use PHPUnit\Framework\TestCase; @@ -224,6 +223,10 @@ public function test_render_enqueues_frontend_assets_and_uses_shared_renderer_ou self::assertStringContainsString( 'class="plan-your-day"', $output ); self::assertStringContainsString( 'data-plan-color-mode-default="dark"', $output ); self::assertStringContainsString( '"colorModeDefault":"dark"', $output ); + self::assertStringContainsString( '"bootstrapUrl":"https:\/\/example.test\/wp-json\/plan-your-day\/v1\/bootstrap"', $output ); + self::assertStringContainsString( '"endpointToken":""', $output ); + self::assertStringNotContainsString( 'plan_your_day_visitor', $output ); + self::assertStringNotContainsString( hash_hmac( 'sha256', str_repeat( 'ab', 24 ), 'tests-auth|plan-your-day' ), $output ); self::assertStringContainsString( 'action="https://example.test/planner#plan-your-day-1"', $output ); self::assertStringNotContainsString( 'Editable starting point helper.', $output ); self::assertStringNotContainsString( 'Editable custom start label', $output ); @@ -280,8 +283,7 @@ private function build_renderer(): PlannerRenderer { $category_catalog, $request_state_parser, $planner_state_builder, - new PlannerPayloadBuilder( $settings ), - new VisitorTokenManager() + new PlannerPayloadBuilder( $settings ) ); } } diff --git a/plugin/plan-your-day/tests/PlannerRoutesTest.php b/plugin/plan-your-day/tests/PlannerRoutesTest.php index a860f0c..56c6d79 100644 --- a/plugin/plan-your-day/tests/PlannerRoutesTest.php +++ b/plugin/plan-your-day/tests/PlannerRoutesTest.php @@ -204,6 +204,46 @@ public function test_route_rate_limiter_uses_trusted_proxy_forwarded_client_ip() self::assertSame( 'plan_your_day_rate_limited', $second->get_error_code() ); } + public function test_bootstrap_returns_endpoint_token_for_same_site_request(): void { + $visitor_token = str_repeat( 'ab', 24 ); + $routes = $this->build_routes( 10 ); + $request = new WP_REST_Request( 'POST', '/plan-your-day/v1/bootstrap' ); + + $_SERVER = $this->same_site_server( '198.51.100.10' ); + $_COOKIE['plan_your_day_visitor'] = $visitor_token; + + $response = $routes->bootstrap( $request ); + + self::assertInstanceOf( WP_REST_Response::class, $response ); + self::assertSame( + [ + 'endpointToken' => hash_hmac( 'sha256', $visitor_token, 'tests-auth|plan-your-day' ), + ], + $response->get_data() + ); + } + + public function test_bootstrap_rejects_cross_site_request_before_token_creation(): void { + $routes = $this->build_routes( 10 ); + $request = new WP_REST_Request( 'POST', '/plan-your-day/v1/bootstrap' ); + + $_SERVER = $this->same_site_server( + '198.51.100.10', + [ + 'HTTP_ORIGIN' => 'https://evil.example', + 'HTTP_REFERER' => 'https://evil.example/planner', + 'HTTP_SEC_FETCH_SITE' => 'cross-site', + ] + ); + + $response = $routes->bootstrap( $request ); + + self::assertInstanceOf( WP_Error::class, $response ); + self::assertSame( 'plan_your_day_invalid_origin', $response->get_error_code() ); + self::assertSame( 403, $response->get_error_data()['status'] ?? null ); + self::assertArrayNotHasKey( 'plan_your_day_visitor', $_COOKIE ); + } + public function test_browse_append_results_uses_cached_search_context_ids_and_skips_route_refresh(): void { $google_api_client = new PlannerRoutesGoogleApiClient( GoogleApiResult::success( diff --git a/plugin/plan-your-day/tests/browser-app/router.php b/plugin/plan-your-day/tests/browser-app/router.php index 5ee9f8e..7323ae9 100644 --- a/plugin/plan-your-day/tests/browser-app/router.php +++ b/plugin/plan-your-day/tests/browser-app/router.php @@ -62,6 +62,10 @@ plan_your_day_browser_render_page( 'plain' ); return; + case '/wp-json/plan-your-day/v1/bootstrap': + plan_your_day_browser_dispatch_rest( 'bootstrap' ); + return; + case '/wp-json/plan-your-day/v1/browse': plan_your_day_browser_dispatch_rest( 'browse' ); return; @@ -109,8 +113,7 @@ function plan_your_day_browser_app(): array { $category_catalog, $request_state_parser, $planner_state_builder, - $planner_payload_builder, - $visitor_token_manager + $planner_payload_builder ); $planner_routes = new PlannerRoutes( @@ -192,9 +195,13 @@ function plan_your_day_browser_dispatch_rest( string $route_name ): void { $request->set_param( (string) $key, $value ); } - $result = 'browse' === $route_name - ? $app['routes']->browse( $request ) - : $app['routes']->route( $request ); + if ( 'bootstrap' === $route_name ) { + $result = $app['routes']->bootstrap( $request ); + } elseif ( 'browse' === $route_name ) { + $result = $app['routes']->browse( $request ); + } else { + $result = $app['routes']->route( $request ); + } if ( $result instanceof WP_Error ) { plan_your_day_browser_send_json( From 7cfd13fc43f48eca750f53aed564b747a547d014 Mon Sep 17 00:00:00 2001 From: acodebeard Date: Fri, 5 Jun 2026 18:17:12 -0700 Subject: [PATCH 2/3] Fix plugin check workflow --- .github/workflows/plugin-quality.yml | 73 ++++++++++++++++++++++++++-- plugin/plan-your-day/readme.txt | 2 +- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/.github/workflows/plugin-quality.yml b/.github/workflows/plugin-quality.yml index 4f63526..12cf024 100644 --- a/.github/workflows/plugin-quality.yml +++ b/.github/workflows/plugin-quality.yml @@ -104,9 +104,72 @@ jobs: working-directory: plugin/plan-your-day composer-options: "--no-dev --optimize-autoloader" - - uses: wordpress/plugin-check-action@v1 + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Run Plugin Check + env: + WP_ENV_HOME: ${{ runner.temp }}/wp-env + run: | + trap 'status=$?; if [ "$status" -ne 0 ]; then docker ps -a || true; find "$WP_ENV_HOME" -maxdepth 3 -type f -print || true; fi; exit "$status"' EXIT + + plugin_dir="$(realpath ./plugin/plan-your-day)" + + cat > .wp-env.json < "$RUNNER_TEMP/plugin-check-results.txt" + status=$? + set -e + + cat "$RUNNER_TEMP/plugin-check-results.txt" + if grep -Eq '"type"[[:space:]]*:[[:space:]]*"ERROR"' "$RUNNER_TEMP/plugin-check-results.txt"; then + exit 1 + fi + + exit "$status" + + - uses: actions/upload-artifact@v4 + if: ${{ always() }} with: - build-dir: ./plugin/plan-your-day - exclude-directories: tests,tools - exclude-files: .distignore,DECISIONS.md,phpcs.xml.dist,phpunit.xml.dist - ignore-warnings: true + name: plugin-check-results + path: ${{ runner.temp }}/plugin-check-results.txt + if-no-files-found: ignore + + - name: Stop wp-env + if: ${{ always() }} + env: + WP_ENV_HOME: ${{ runner.temp }}/wp-env + run: | + if command -v wp-env >/dev/null 2>&1; then + wp-env destroy --force || true + fi diff --git a/plugin/plan-your-day/readme.txt b/plugin/plan-your-day/readme.txt index 95de31d..deaac9c 100644 --- a/plugin/plan-your-day/readme.txt +++ b/plugin/plan-your-day/readme.txt @@ -2,7 +2,7 @@ Contributors: acodebeard Tags: planning, maps, wayfinding Requires at least: 6.8 -Tested up to: 6.9 +Tested up to: 7.0 Requires PHP: 8.2 Stable tag: 0.5 License: GPLv2 or later From e24e45ac3d419661b85db7183a7c56702cfe0c62 Mon Sep 17 00:00:00 2001 From: acodebeard Date: Fri, 5 Jun 2026 19:24:58 -0700 Subject: [PATCH 3/3] Avoid deprecated glob warning in wp-env install --- .github/workflows/plugin-quality.yml | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/plugin-quality.yml b/.github/workflows/plugin-quality.yml index 12cf024..4079a16 100644 --- a/.github/workflows/plugin-quality.yml +++ b/.github/workflows/plugin-quality.yml @@ -128,7 +128,23 @@ jobs: } EOF - npm -g --no-fund install @wordpress/env@11.5.0 + wp_env_tools="$RUNNER_TEMP/wp-env-tools" + mkdir -p "$wp_env_tools" + cat > "$wp_env_tools/package.json" <<'EOF' + { + "private": true, + "dependencies": { + "@wordpress/env": "11.5.0" + }, + "overrides": { + "rimraf": { + "glob": "^13.0.6" + } + } + } + EOF + npm --prefix "$wp_env_tools" --no-audit --no-fund install + export PATH="$wp_env_tools/node_modules/.bin:$PATH" command -v wp-env wp-env --version cat .wp-env.json @@ -170,6 +186,7 @@ jobs: env: WP_ENV_HOME: ${{ runner.temp }}/wp-env run: | + export PATH="$RUNNER_TEMP/wp-env-tools/node_modules/.bin:$PATH" if command -v wp-env >/dev/null 2>&1; then wp-env destroy --force || true fi