@metricinsights/pp-dev 1.5.0-beta.18 → 1.5.0-beta.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/cjs/cli.js +1 -1
  2. package/dist/cjs/cli.js.map +1 -1
  3. package/dist/cjs/package.json +2 -2
  4. package/dist/cjs/plugin.js +1 -1
  5. package/dist/cjs/plugin.js.map +1 -1
  6. package/dist/cjs/request-capture.middleware-C99WIh5-.js +2 -0
  7. package/dist/cjs/request-capture.middleware-C99WIh5-.js.map +1 -0
  8. package/dist/client/request-inspector.css +3 -0
  9. package/dist/client/request-inspector.css.map +1 -0
  10. package/dist/client/request-inspector.html +87 -0
  11. package/dist/client/request-inspector.js +527 -0
  12. package/dist/client/request-inspector.js.map +1 -0
  13. package/dist/client/variables-editor.css +3 -0
  14. package/dist/client/variables-editor.css.map +1 -0
  15. package/dist/client/variables-editor.html +44 -0
  16. package/dist/client/variables-editor.js +3419 -0
  17. package/dist/client/variables-editor.js.map +1 -0
  18. package/dist/esm/{auth.provider-BijrVDxp.js → auth.provider-C3RNvaVR.js} +2 -2
  19. package/dist/esm/{auth.provider-BijrVDxp.js.map → auth.provider-C3RNvaVR.js.map} +1 -1
  20. package/dist/esm/cli.js +1 -1
  21. package/dist/esm/cli.js.map +1 -1
  22. package/dist/esm/{index-CL8_j3ER.js → index-Dlv5DGp7.js} +2 -2
  23. package/dist/esm/{index-CL8_j3ER.js.map → index-Dlv5DGp7.js.map} +1 -1
  24. package/dist/esm/index.js +1 -1
  25. package/dist/esm/{migrate-B0hgQCl_.js → migrate-Dm-PSAv7.js} +2 -2
  26. package/dist/esm/{migrate-B0hgQCl_.js.map → migrate-Dm-PSAv7.js.map} +1 -1
  27. package/dist/esm/package.json +2 -2
  28. package/dist/esm/plugin.js +1 -1
  29. package/dist/esm/plugin.js.map +1 -1
  30. package/dist/esm/request-capture.middleware-DEQ-qw2b.js +2 -0
  31. package/dist/esm/request-capture.middleware-DEQ-qw2b.js.map +1 -0
  32. package/dist/esm/{version-manifest-1KdgXTW2.js → version-manifest-D2Vzplem.js} +2 -2
  33. package/dist/esm/{version-manifest-1KdgXTW2.js.map → version-manifest-D2Vzplem.js.map} +1 -1
  34. package/dist/esm/{version-plugin-BExwn1e0.js → version-plugin-A4qfBm0G.js} +2 -2
  35. package/dist/esm/{version-plugin-BExwn1e0.js.map → version-plugin-A4qfBm0G.js.map} +1 -1
  36. package/package.json +2 -2
  37. package/dist/cjs/variables-editor-CdUFaTjF.js +0 -106
  38. package/dist/cjs/variables-editor-CdUFaTjF.js.map +0 -1
  39. package/dist/esm/variables-editor-CrhYJD_R.js +0 -106
  40. package/dist/esm/variables-editor-CrhYJD_R.js.map +0 -1
@@ -1,106 +0,0 @@
1
- import{inflateSync as e,brotliDecompressSync as t,unzipSync as n,deflateSync as a,brotliCompressSync as s,gzipSync as o}from"zlib";import{c as r,a as i,f as l,e as c,l as d,P as u,b as p,k as m}from"./auth.provider-BijrVDxp.js";import{URL as h,fileURLToPath as g}from"url";import f,{isAxiosError as v}from"axios";import b from"node:http";import y from"node:https";import{g as w,w as x}from"./version-manifest-1KdgXTW2.js";import{createProxyMiddleware as k,responseInterceptor as S}from"http-proxy-middleware";import{StringDecoder as I}from"node:string_decoder";import{createHash as E,randomUUID as T}from"node:crypto";import A from"node:fs";import C from"node:os";import L from"node:path";import $ from"express";import*as R from"path";import*as O from"crypto";import{randomUUID as N,createHash as j}from"crypto";import*as B from"fs";import{promises as H}from"fs";import*as M from"process";import*as D from"os";import _ from"jszip";import*as F from"child_process";import{createRequire as V}from"module";const P="/@pp-dev-hmr";function q(e){return"string"==typeof e?e:void 0}function U(e,t){return"string"==typeof e?Buffer.from(e,t):e instanceof Uint8Array?Buffer.from(e):null}function z(i,l){return async(c,d,u)=>{if(await i(c.url??"",c,d)){const i=r(),p=d.end;if("HEAD"===c.method)return d.end=function(e,t,n){const a=[e,t,n].find(e=>"function"==typeof e);return d.headersSent||d.removeHeader("content-length"),p.call(this,a),this},void u();i.info(`Rewrite response for ${c.url}`);const m=[];d.write=function(e,t,n){const a=U(e,q(t));a&&m.push(a);const s="function"==typeof t?t:n;return"function"==typeof s&&process.nextTick(s),!0},d.end=function(r,u,h){"function"==typeof r&&(h=r,r=void 0);const g=U(r,q(u));g&&m.push(g);const f="function"==typeof u?u:h,v=d.getHeader("content-encoding"),b=function(a,s){switch(s){case"gzip":return n(a);case"br":return t(a);case"deflate":return e(a);default:return a}}(Buffer.concat(m),v),y=function(e,t){switch(t){case"gzip":return o(e);case"br":return s(e);case"deflate":return a(e);default:return e}}(l(b,c,d),v);return void 0!==d.getHeader("content-length")&&(d.headersSent?i.warn(`Rewrite response for ${c.url}: headers already sent, Content-Length left as is`):d.setHeader("content-length",y.length)),p.call(this,y,f),this}}u()}}const J=e=>e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),W=new Map,K=new Map;const G=(e,t,n)=>{let a=W.get(e);if(!a){const t=/:\d+$/.test(e)?"":"(?::(?<port>\\d+))?";a=new RegExp(`(?<keep>!!)?(?<url>http(?<secure>s)?(?<slashes>:(?:\\\\)?/(?:\\\\)?/)${J(e)}${t})`,"gi"),W.set(e,a)}return n.replace(a,(...e)=>{const{keep:n,url:a,secure:s,slashes:o,port:r}=e.at(-1);return n||r&&r!==(s?"443":"80")?a:`http${o}${t}`})},Y=(e,t,n)=>{const a=new RegExp(`${e.replace(/\\*\//gi,"\\\\/")}`,"gi"),s=new RegExp(`${e}`,"gi"),o=n.replace(a,t);return o===n?o.replace(s,t):o},Z=(e,t,n)=>{e.setHeader("location",t),e.statusCode=n,e.end()};function X(e){return e.split("?")[0]}function Q(e,t){const n=(e=(e=e.startsWith("/")?e:`/${e}`).endsWith("/")?e:`${e}/`).endsWith("/")?e.slice(0,-1):e,a=r();return(s,o,r)=>{const d=new h(s.url??"","http://localhost"),{pathname:u,search:p}=d,m=["/",n];if(t&&(m.push(`${l}/${t}`),m.push(`${c}/${t}`)),m.includes(u)){const t=`${e}${p}`;return a.info(i.yellow(`Redirecting to: ${t}`)),Z(o,t,302)}r()}}class ee{axios;constructor(e){this.axios=e}async checkAuth(e){return this.axios.get("/data/page/index/auth/info",{headers:Object.assign({},e,{accept:"text/html"}),maxRedirects:0}).then(()=>!0).catch(()=>!1)}}class te extends ee{getDownloadUrl(e){return`/admin/page/downloadassets/id/${e}`}getDownloadTemplateUrl(e){return`/admin/pagetemplate/downloadassets/id/${e}`}getUploadUrl(e){return`/admin/page/uploadassets/id/${e}`}getUploadTemplateUrl(e){return`/admin/pagetemplate/uploadassets/id/${e}`}async downloadPageAssets(e,t){return this.download(this.getDownloadUrl(e),t)}async uploadPageAssets(e,t,n){return this.upload(this.getUploadUrl(e),t,n)}async downloadTemplateAssets(e,t){return this.download(this.getDownloadTemplateUrl(e),t)}async uploadTemplateAssets(e,t,n){return this.upload(this.getUploadTemplateUrl(e),t,n)}async download(e,t){return this.axios.get(e,{withCredentials:!0,headers:Object.assign({},t,{accept:"*/*"}),responseType:"arraybuffer"}).then(e=>e.data)}async upload(e,t,n){const a=new FormData;return a.append("file",new File([new Uint8Array(t)],"file.zip",{type:"application/zip"}),"file.zip"),this.axios.post(e,a,{withCredentials:!0,headers:Object.assign({},n,{accept:"application/json","Content-Type":"multipart/form-data",Referer:this.axios.getUri({url:e})})}).then(e=>e.data)}}class ne extends te{getDownloadUrl(e){return`/api/page/id/${e}/asset/download`}getDownloadTemplateUrl(e){return`/api/page_template/id/${e}/asset/download`}getUploadUrl(e){return`/api/page/id/${e}/asset/upload`}getUploadTemplateUrl(e){return`/api/page_template/id/${e}/asset/upload`}}const ae="Something went wrong when fetching page data",se="Something went wrong when fetching page template data",oe="Something went wrong when fetching page variables",re=" The instance may be down or in maintenance mode. Please try again later.",ie=[ae,se,oe];function le(){return new Error(`${ae}.${re}`)}function ce(){return new Error(`${se}.${re}`)}function de(e){return e instanceof Error&&ie.some(t=>e.message.includes(t))}class ue extends ee{async getAll(e){const t=(await this.axios.get("/api/page",{withCredentials:!0,headers:Object.assign({},e,{Accept:"application/json","Content-Type":"application/json","Cache-Control":"no-cache",Pragma:"no-cache",Expires:"0"})})).data;if("object"!=typeof t||null===t||!Array.isArray(t.pages))throw le();return t.pages}async get(e,t){const n=(await this.axios.get(`/api/page/id/${e}`,{withCredentials:!0,headers:Object.assign({},t,{accept:"application/json","content-type":"application/json"})})).data;if("object"!=typeof n||null===n||"object"!=typeof n.page||null===n.page)throw le();return n.page}async getPageContent(e,t){return(await this.axios.get(`/p/${e}`,{withCredentials:!0,headers:Object.assign({},t,{accept:"text/html","content-type":"application/json"})})).data}async create(e,t){const n=(await this.axios.post("/api/page",e,{withCredentials:!0,headers:Object.assign({},t,{accept:"application/json","content-type":"application/json"})})).data;if("object"!=typeof n||null===n||"object"!=typeof n.page||null===n.page)throw le();return n.page}}class pe extends ee{async getAll(e,t){const n=(await this.axios.get("/api/page_template",{withCredentials:!0,headers:Object.assign({},t,{Accept:"application/json","Content-Type":"application/json","Cache-Control":"no-cache",Pragma:"no-cache",Expires:"0"}),params:{internal_name:e}})).data;if("object"!=typeof n||null===n||!Array.isArray(n.page_templates))throw ce();return n.page_templates}async get(e,t){const n=(await this.axios.get(`/api/page_template/id/${e}`,{withCredentials:!0,headers:Object.assign({},t,{accept:"application/json","content-type":"application/json"})})).data;if("object"!=typeof n||null===n||"object"!=typeof n.page_template||null===n.page_template)throw ce();return n.page_template}}class me extends ee{async getById(e,t){const n=(await this.axios.get("/api/page_variable",{withCredentials:!0,headers:Object.assign({},t,{accept:"application/json","content-type":"application/json"}),params:{page_id:e}})).data;if("object"!=typeof n||null===n)throw new Error(`${oe}.${re}`);return function(e){let t=e;if("string"==typeof e)try{t=JSON.parse(e)}catch{throw new TypeError('Page variable response contains invalid JSON in "tags".')}if(!Array.isArray(t))throw new TypeError('Page variable response must contain a "tags" array.');const n=t.findIndex(e=>{return!("object"==typeof(t=e)&&null!==t&&"string"==typeof t.name&&"string"==typeof t.value);var t});if(-1!==n)throw new TypeError(`Page variable response contains a malformed entry at index ${n}.`);return t}(n.tags)}async updateById(e,t,n){await this.axios.put("/api/page_variable",{tags:JSON.stringify(t)},{withCredentials:!0,headers:Object.assign({},n,{accept:"application/json","content-type":"application/json"}),params:{page_id:e}})}}function he(e){if(e.socket?.encrypted)return!0;const t=e.headers?.["x-forwarded-proto"],n=Array.isArray(t)?t[0]:t;return"string"==typeof n&&"https"===n.split(",",1)[0].trim().toLowerCase()}function ge(e,t){const[n,...a]=e.split(";"),s=a.map(e=>e.trim()).filter(e=>{if(!e)return!1;const n=e.split("=",1)[0].trim().toLowerCase();return"domain"!==n&&(t||"secure"!==n)}).map(e=>!t&&/^samesite\s*=\s*none$/i.test(e)?"SameSite=Lax":e);return[n.trim(),...s].join("; ")}const fe="metric_insights_session";function ve(e,t){for(const n of e){const e=n.indexOf("=");if(-1!==e&&n.slice(0,e).trim()===t)return n.slice(e+1).trim()}}function be(e,t){return e?ve(e.split(";"),t):void 0}function ye(e,t){if(void 0===e||"number"==typeof e)return;return ve((Array.isArray(e)?e:[e]).map(e=>e.split(";",1)[0]),t)}function we(e,t){return void 0===e?e:Array.isArray(e)?e.map(e=>ge(e,t)):ge(e,t)}const xe=function(e){const t={personal:{placeholder:"API Token",label:"API Token",path:"/api-token"},regular:{placeholder:"Legacy Token",label:"Legacy Token",path:"/api/get_token"}};function n(n,a,s){const o=t[n],r=document.createElement("a");r.href=`https://${e}${o.path}`,r.target="_blank",r.style.color="#007bff",r.style.textDecoration="none",r.textContent="here",a.placeholder=o.placeholder,s.replaceChildren(`You can get the ${o.label} `,r)}function a(e,t){t.textContent=e,t.classList.add("show")}function s(e){e.classList.remove("show")}function o(e,t,n,o){n.addEventListener("click",async n=>{if(n.preventDefault(),s(o),!t.value.trim())return a(`${t.placeholder} is required`,o),void t.focus();const r=document.getElementById("token-type-switcher"),i=r?.querySelector(".token-type-option.active"),l=i?.dataset.value||"personal";await async function(e,t,n,s){try{n.classList.add("loading");const o=await fetch("/@api/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:e,tokenType:t}),redirect:"follow"}),r=o.url&&new URL(o.url,window.location.origin);if(!r||r.pathname.startsWith("/login")||r.pathname.startsWith("/@api/"))try{const e=await o.json();a(`Login failed: ${e?.error||"Unknown error occurred"}`,s)}catch(e){a("Login failed: Unable to parse response",s)}else window.location.href=o.url}catch(e){a("Login failed: Network error. Please check your connection.",s)}finally{n.classList.remove("loading")}}(t.value.trim(),l,e,o)}),t.addEventListener("keydown",e=>{"Enter"===e.key&&(e.preventDefault(),n.click())}),t.addEventListener("input",()=>{s(o)})}function r(e){const t=e.firstChild;if(!t)return;const a=t.lastChild?.cloneNode(!0);if(!a)return;a.innerHTML="";const s=a.cloneNode(!0);s.innerText="OR",s.setAttribute("role","separator"),s.setAttribute("aria-label","Alternative login method");const r=a.cloneNode(!0),i=document.createElement("div"),l=document.createElement("style");l.textContent="\n.helper-login-wrapper {\n font-family: Arial, sans-serif;\n color: #222;\n padding: 8px;\n background-color: #f8f9fa;\n border-radius: 8px;\n border: 1px solid #ddd;\n width: 100%;\n max-width: 400px;\n margin: 0 auto;\n box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);\n}\n\n.helper-login-wrapper .title {\n font-weight: bold;\n font-size: 18px;\n margin-bottom: 12px;\n text-align: center;\n}\n\n.helper-login-wrapper .control {\n display: flex;\n flex-direction: column;\n gap: 12px;\n}\n\n.helper-login-wrapper .control input {\n border: 1px solid #ddd;\n border-radius: 4px;\n width: 100%;\n box-sizing: border-box;\n text-align: center;\n height: 26px;\n padding: 0 8px;\n font-size: 14px;\n}\n\n.helper-login-wrapper .control input:focus {\n outline: none;\n border-color: #007bff;\n box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);\n}\n\n.helper-login-wrapper .control .caption {\n font-size: 12px;\n color: #666;\n text-align: center;\n}\n\n.helper-login-wrapper .footer {\n margin-top: 16px;\n text-align: center;\n}\n\n.helper-login-wrapper .footer .btn.submit {\n background-color: #007bff;\n color: white;\n border: none;\n border-radius: 4px;\n cursor: pointer;\n width: 100%;\n font-size: 14px;\n transition: background-color 0.3s;\n height: 26px;\n font-weight: 500;\n}\n\n.helper-login-wrapper .footer .btn.submit:hover {\n background-color: #0056b3;\n}\n\n.helper-login-wrapper .footer .btn.submit:disabled {\n background-color: #6c757d;\n cursor: not-allowed;\n}\n\n.helper-login-wrapper .token-type-switcher {\n display: flex;\n border: 1px solid #ccc;\n border-radius: 4px;\n overflow: hidden;\n margin-bottom: 12px;\n width: 100%;\n}\n\n.helper-login-wrapper .token-type-option {\n padding: 4px 8px;\n cursor: pointer;\n background-color: #f8f9fa;\n color: #666;\n font-size: 12px;\n text-align: center;\n transition: background-color 0.2s, color 0.2s;\n border-right: 1px solid #ccc;\n flex: 1;\n user-select: none;\n}\n\n.helper-login-wrapper .token-type-option:last-child {\n border-right: none;\n}\n\n.helper-login-wrapper .token-type-option.active {\n background-color: #007bff;\n color: white;\n font-weight: bold;\n}\n\n.helper-login-wrapper .token-type-option:not(.active):hover {\n background-color: #e9ecef;\n}\n\n.helper-login-wrapper .error-message {\n color: #dc3545;\n font-size: 12px;\n text-align: center;\n margin-top: 8px;\n display: none;\n}\n\n.helper-login-wrapper .error-message.show {\n display: block;\n}\n\n.helper-login-wrapper .loading {\n opacity: 0.6;\n pointer-events: none;\n}\n",document.head.appendChild(l),i.innerHTML='\n<div class="helper-login-wrapper" role="form" aria-label="PP Dev Helper Login">\n <div class="title" role="heading" aria-level="2">PP Dev Helper</div>\n\n <div class="token-type-switcher" id="token-type-switcher" role="tablist" aria-label="Token type selection">\n <div class="token-type-option active" data-value="personal" role="tab" aria-selected="true" tabindex="0">\n API Token\n </div>\n <div class="token-type-option" data-value="regular" role="tab" aria-selected="false" tabindex="0">\n Legacy Token\n </div>\n </div>\n\n <div class="control">\n <input \n type="password" \n id="helper-token" \n placeholder="API Token"\n aria-label="Token input"\n aria-describedby="token-caption"\n >\n <span class="caption" id="token-caption"></span>\n </div>\n\n <div class="error-message" id="error-message" role="alert" aria-live="polite"></div>\n\n <div class="footer">\n <button id="helper-token-submit" class="btn submit" type="button">\n Token Login\n </button>\n </div>\n</div>',r.appendChild(i),t.appendChild(s),t.appendChild(r);const c=document.getElementById("token-type-switcher"),d=document.getElementById("helper-token"),u=document.getElementById("token-caption"),p=document.getElementById("helper-token-submit"),m=document.getElementById("error-message"),h=i.querySelector(".helper-login-wrapper");c&&d&&u&&p&&m&&h?(!function(e,t,a){const s=e.querySelectorAll(".token-type-option"),o=e=>{const o=e.dataset.value;s.forEach(e=>{e.classList.remove("active"),e.setAttribute("aria-selected","false"),e.setAttribute("tabindex","-1")}),e.classList.add("active"),e.setAttribute("aria-selected","true"),e.setAttribute("tabindex","0"),n(o,t,a)};s.forEach(e=>{e.addEventListener("click",e=>{const t=e.currentTarget;o(t)}),e.addEventListener("keydown",t=>{"Enter"!==t.key&&" "!==t.key||(t.preventDefault(),o(e))})})}(c,d,u),o(h,d,p,m),n("personal",d,u)):console.error("Failed to find required form elements")}const i='#mi-react-root form:not([id]):has(input[name="login"]):has(input[name="password"])',l=new MutationObserver(e=>{for(const t of e)if("childList"===t.type){const e=document.querySelector(i);if(e){l.disconnect(),r(e);break}}});l.observe(document.body,{childList:!0,subtree:!0});const c=document.querySelector(i);c&&(l.disconnect(),r(c))};function ke(e){const{message:t,response:n}="object"==typeof e&&null!==e?e:{},a="number"==typeof n?.status?n.status:0,s=n?.data?.message;return"string"==typeof s&&s?{status:a,message:s}:"string"==typeof t&&t?{status:a,message:t}:{status:a,message:"string"==typeof e&&e?e:"Unknown error"}}function Se(e){const{status:t,message:n}=ke(e);switch(t){case 412:{const e=n.toLowerCase();return e.includes("session expired")||e.includes("session has expired")?{status:t,message:n,code:"SESSION_EXPIRED",userFriendlyMessage:"Your session has expired",suggestions:["Refresh your token in the portal","Re-authenticate with the portal","Check if your token has been revoked","Ensure your token has the correct permissions"]}:{status:t,message:n,code:"AUTH_FAILED",userFriendlyMessage:"Authentication failed",suggestions:["Verify your token is correct","Check token permissions","Ensure the token hasn't expired","Try generating a new token"]}}case 401:return{status:t,message:n,code:"UNAUTHORIZED",userFriendlyMessage:"Unauthorized access",suggestions:["Check if your token is valid","Verify you have the required permissions","Ensure the token hasn't been revoked","Try logging in again"]};case 403:return{status:t,message:n,code:"FORBIDDEN",userFriendlyMessage:"Access forbidden",suggestions:["Check your user permissions","Verify the portal page ID is correct","Ensure your token has the right scope","Contact your administrator"]};default:return{status:t,message:n,code:"UNKNOWN_ERROR",userFriendlyMessage:"An unexpected error occurred",suggestions:["Check your network connection","Verify the portal URL is correct","Try again in a few moments","Check the portal status"]}}}function Ie(e){const{status:t}=ke(e);return 401===t||403===t||412===t}const Ee="[DEV PAGE. DO NOT DELETE]",Te=new Map;class Ae{#e;#t;#n=null;#a;#s;#o=!1;#r=null;#i=null;#l;#c;#d;#u;#p;internalPageName;appId;templateLess;assetsApi;pageApi;pageTemplateApi;pageVariableApi;logger;constructor(e,t){const{headers:n={},appId:a,templateLess:s=!0,disableSSLValidation:o=!1,v7Features:i=!1,personalAccessToken:l}=t||{};this.#e=n,this.#s=[],this.#a="",this.#l=i,this.#c=l,this.#d=!1,this.#u=Promise.resolve(!1);const c=`${e}:${o}`;if(Te.has(c))this.#t=Te.get(c);else{const t=new y.Agent({keepAlive:!1,...o?{rejectUnauthorized:!1}:{}});this.#t=f.create({baseURL:e,headers:n,timeout:3e4,maxRedirects:5,httpAgent:new b.Agent({keepAlive:!1}),httpsAgent:t}),Te.set(c,this.#t)}this.appId=a,this.templateLess=s,this.assetsApi=new(i?ne:te)(this.#t),this.pageApi=new ue(this.#t),this.pageTemplateApi=new pe(this.#t),this.pageVariableApi=new me(this.#t),this.logger=r()}async isTemplateLoaded(){return this.#u}get isV710OrHigher(){return this.#d}get v7Features(){return this.#l}#m(e){const t=Object.assign({},e,{host:void 0,referer:void 0});return Object.keys(t).forEach(e=>void 0===t[e]&&delete t[e]),this.#e=t,!t.authorization&&this.#c&&(t.authorization=`Bearer ${this.#c}`),t}#h(e){return e.replace(/[.*+?^${}()|[\]\\\/]/g,"\\$&")}get personalAccessToken(){return this.#c}set personalAccessToken(e){this.#c=e}updateHeaders(e){this.#m(e)}get localTemplateHTML(){return'<!DOCTYPE html>\n <html lang="en">\n <head>\n <meta charset="UTF-8" />\n <title>%%PAGE TITLE%%</title>\n <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />\n <meta name="theme-color" content="rgba(255, 255, 255, 1)" />\n <link rel="shortcut icon" type="image/x-icon" sizes="any" href="/img/favicon/favicon.ico" />\n <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon/favicon-16x16.png" />\n <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon/favicon-32x32.png" />\n <link rel="icon" type="image/png" sizes="48x48" href="/img/favicon/favicon-48x48.png" />\n <meta name="msapplication-config" content="/auth/browserconfig.xml" />\n <link rel="apple-touch-icon" sizes="180x180" href="/img/favicon/apple-touch-icon.png" />\n <link rel="manifest" href="/auth/site.webmanifest" />\n <link rel="manifest" href="/auth/manifest.json" />\n <link rel="stylesheet" type="text/css" href="/auth/theme-vars.css" />\n <link rel="icon" type="image/svg+xml" href="/favicon.ico" />\n <meta name="viewport" content="width=device-width, initial-scale=1.0" />\n <script src="/js/libs/underscore-latest.min.js" charset="utf-8"><\/script>\n <script src="/js/jquery/jquery-latest.min.js" charset="utf-8"><\/script>\n <script src="/js/application/rating_component.js" charset="utf-8"><\/script>\n </head>\n <body>\n <div id="mi-react-root"></div>\n <script src="/auth/info.js"><\/script>\n <script src="/js/main.js" defer><\/script>\n <link rel="stylesheet" href="/css/main.css" />\n </body>\n </html>'}async getPageTemplate(e){if(await this.#u||this.#n||(this.#u=new Promise(e=>{this.#p=e})),this.#n)return Promise.resolve(this.#n);if(void 0===this.templateLess&&(this.templateLess=!1),this.#l){const t=await this.pageApi.get(this.appId,this.#m(e)).then(e=>(this.logger.info(i.green("Page fetched")),e)).catch(async t=>{if(this.#p(!1),de(t))throw t;throw await this.pageApi.checkAuth(this.#m(e))?(this.logger.error(i.red(`Error fetching page data: ${t.message}\n${t.stack}`)),new Error("The current user does not have access to this page. Check your configuration to ensure app.id is correct.")):t});return void 0!==t.template_id&&(this.#d=!0),this.internalPageName=t.internal_name,this.#n=this.localTemplateHTML,this.#n=this.#n.replace(/%%PAGE TITLE%%/g,t.name||"Local template"),this.logger.info(i.green("Local page template fetched")),this.#p(!0),this.#n}let t=(await this.pageApi.getAll(this.#m(e)).then(e=>(this.logger.info(i.green("Page list fetched")),e)).catch(async t=>{throw this.#p(!1),await this.pageApi.checkAuth(this.#m(e))?(this.logger.error(i.red(`Error fetching page list: ${t.message}\n${t.stack}`)),new Error("Current user does not have access to page list")):t})).find(e=>e.name===Ee);return t||(this.logger.warn(i.yellow("Creating dev page template...")),t=await this.pageApi.create({enabled:"Y",name:Ee,internal_name:"dev-page-template",visible_in_homepage:"Y"},this.#m(e)).then(e=>(this.logger.info(i.green("Dev page created")),e)).catch(e=>{throw this.logger.error(i.red(`Error creating dev page: ${e.message}`)),this.#p(!1),new Error(`Error when creating dev page.\n That can be caused by missing permissions or page with name "${Ee}" already exists`)})),await this.pageApi.getPageContent(t.internal_name,this.#m(e)).then(e=>(this.#n=e,e)).then(e=>(this.#p(!0),this.logger.info(i.green("Page template fetched")),e)).catch(e=>{throw this.#p(!1),this.logger.error(i.red(`Error fetching page template: ${e.message}`)),new Error("Error fetching page template")})}async getPageVariables(e,t){return void 0===this.appId&&(this.appId=e,this.templateLess=!1),this.#n=await this.getPageTemplate(t),await this.pageApi.get(e,this.#m(t)).then(e=>{const{tags:t="[]",name:n,template:a}=e;if(a&&t){const e=JSON.parse(t);return this.#s=e,this.#o=!0,this.#a=n,e}return this.#s=[],this.#o=!0,this.#a=n,[]}).catch(t=>{if(this.logger.error(i.red(`Error fetching page variables: ${t.message}`)),404===t.response?.status)throw new Error(`Portal Page with id "${e}" not found on instance ${this.#t.getUri()}`);if(401===t.response?.status)throw this.logger.warn(`Unauthorized error: ${t}`),new Error(`Current user does not have access to page with id "${e}" on instance ${this.#t.getUri()}`);throw t})}async getLivePageVariables(e=this.#e){if(void 0===this.appId)throw new Error("Cannot fetch live page variables before the page ID is known");const t=this.appId,n=Date.now();this.logger.info(i.cyan(`[page-variables] Fetching live variables for page ID ${t} (page_id lookup)`));const a=await this.pageVariableApi.getById(t,this.#m(e));return this.logger.info(i.green(`[page-variables] Fetched ${a.length} live variable(s) for page ID ${t} in ${Date.now()-n}ms`)),a}async applyPageVariables(e,t=this.#e){if(void 0===this.appId)throw new Error("Cannot apply page variables before the page ID is known");const n=this.appId,a=Date.now();this.logger.info(i.cyan(`[page-variables] Applying ${e.length} variable(s) to page ID ${n} (page_id lookup)`)),await this.pageVariableApi.updateById(n,e,this.#m(t)),this.logger.info(i.green(`[page-variables] Applied variables to page ID ${n} in ${Date.now()-a}ms`))}async listPages(e=this.#e){return this.pageApi.getAll(this.#m(e))}async getPageVariablesFor(e,t=this.#e){return this.pageVariableApi.getById(e,this.#m(t))}get localPageVariables(){return this.templateLess||!this.#o?null:this.#s.map(({name:e,value:t})=>({name:e,value:t}))}get isTemplateLess(){return!!this.templateLess}async reloadPageVariables(e=this.#e){return this.templateLess||void 0===this.appId?null:this.getPageVariables(this.appId,e)}async getPageInfo(e,t){void 0===this.appId&&(this.appId=e);const n=this.#m(t),a=this.appId;try{return this.#l?await this.#g(a,n):await this.#f(a,n)}catch(e){this.#v(e,a)}}async#g(e,t){const n=await this.pageApi.get(e,t);return this.logger.info(i.green("Page fetched")),void 0!==n.template_id&&(this.#d=!0),this.internalPageName=n.internal_name,n}async#f(e,t){return await this.pageApi.get(e,t)}#v(e,t){if(de(e))throw e;if(this.#l&&e.message?.includes("access"))throw new Error("The current user does not have access to this page. Check your configuration to ensure app.id is correct.");if(404===e.response?.status)throw new Error(`Portal Page with id "${t}" not found on instance ${this.#t.getUri()}`);throw 401===e.response?.status&&this.logger.error(i.red(`Current user does not have access to page with id "${t}" on instance ${this.#t.getUri()}`)),e}buildPage(e,t=!1){let n="string"==typeof e?e:e.toString("utf-8");for(const{regExp:e,value:t}of this.#b())n=n.replace(e,()=>t);if(t){return new(w())(n).serialize()}const{before:a,after:s}=this.#y();return a+n+s}#b(){return this.#r?.vars!==this.#s&&(this.#r={vars:this.#s,replacements:this.#s.map(({name:e,value:t})=>({regExp:new RegExp(`\\[${this.#h(e)}\\]`,"g"),value:t}))}),this.#r.replacements}#y(){const e=this.#n,t=this.#a;if(this.#i?.template===e&&this.#i.title===t)return this.#i;const n=new(w())(e),{document:a}=n.window,s="%%PLACEHOLDER%%",o=a.createElement("div");o.innerHTML=s;const r=a.querySelector(".main-side");if(r){const e=r.querySelectorAll("script");e.length?r.insertBefore(o,e.item(e.length-1)):r.append(o)}else{const e=a.createElement("div");e.append(o),a.body.append(e)}const i=a.querySelector("head"),l=i.querySelector("title");if(l)l.text=t;else{const e=a.createElement("title");e.textContent=t,i.appendChild(e)}const c=n.serialize(),d=new RegExp(`<div>\\s*${s}\\s*<\\/div>`,"i").exec(c);return this.#i={template:e,title:t,before:c.slice(0,d.index),after:c.slice(d.index+d[0].length)},this.#i}async getAssets(){if(this.appId){if(this.templateLess)return await this.assetsApi.downloadPageAssets(this.appId,this.#e);{const e=await this.pageApi.get(this.appId,this.#e);if(this.#d){const t=await this.pageTemplateApi.get(e.template_id,this.#e);return await this.assetsApi.downloadTemplateAssets(t.id,this.#e)}if(e.template)return await this.assetsApi.downloadTemplateAssets(e.template,this.#e)}}}async updateAssets(e){if(this.appId){if(this.templateLess)return await this.assetsApi.uploadPageAssets(this.appId,e,this.#e);{const t=await this.pageApi.get(this.appId,this.#e);if(this.#d){const n=await this.pageTemplateApi.get(t.template_id,this.#e);return await this.assetsApi.uploadTemplateAssets(n.id,e,this.#e)}if(t.template)return await this.assetsApi.uploadTemplateAssets(t.template,e,this.#e)}}}async validateCredentials(e){try{const t=e||this.#e;return await this.get("/api/user",t,!0),{isValid:!0}}catch(e){const t=Se(e);return{isValid:!1,error:t.userFriendlyMessage,code:t.code}}}async get(e,t,n=!1,a={}){const s=n?t:Object.assign({},this.#m(this.#e),t);try{return await this.#t.get(e,{headers:s})}catch(e){if("UNKNOWN_ERROR"!==Se(e).code){const t=Se(e);throw a.quiet||(this.logger.error(i.red(`API request failed: ${t.userFriendlyMessage}`)),function(e,t,n){const a=Se(t),s=`[${n}] `;e.error(i.red(`${s}${a.userFriendlyMessage}`)),e.error(i.red(`Status: ${a.status} (${a.code})`)),e.error(i.red(`Details: ${a.message}`)),a.suggestions.length>0&&(e.info(i.yellow("Suggestions:")),a.suggestions.forEach((t,n)=>{e.info(i.yellow(` ${n+1}. ${t}`))}))}(this.logger,e,"API Request")),e.tokenErrorInfo=t,e}throw e}}}const Ce=String.raw`[a-z](?:[a-z0-9\-]*[a-z0-9])?`,Le=new RegExp(`^(?:${Ce})$`),$e=".localhost";function Re(e){return e?e.length>32?"The name must be at most 32 characters long":Le.test(e)?void 0:'The name may contain only lowercase latin letters, digits and "-", must start with a letter and must not end with "-"':"The name is required"}function Oe(e){if(!e)return;const t=e.toLowerCase().replace(/:\d+$/,"");if(!t.endsWith($e))return;const n=t.slice(0,-10);return n&&!n.includes(".")?n:void 0}function Ne(e,t){const n=/:(\d+)$/.exec(e.headers.host??"")?.[1];return`${he(e)?"https":"http"}://${t?`${t}.`:""}localhost${n?`:${n}`:""}`}const je=new class{#w=new Map;#x=new Set;#k=new Set;#S=!1;#I=new Set;#E;#T=new Map;#A=new WeakMap;#C;useContext(e){void 0!==this.#C&&this.#C!==e&&this.clear(),this.#C=e}get context(){return this.#C}list(){return[...this.#w.values()]}get(e){return this.#w.get(e)}add(e){const t=Re(e.name);if(t)throw new Error(t);if(this.#w.has(e.name))throw new Error(`A user named "${e.name}" already exists`);if("token"===e.kind&&!e.token)throw new Error("A token user needs an API token");const n="session"===e.kind?{state:"pending",...e}:{...e};return this.#T.delete(n.name),this.#w.set(n.name,n),this.#L(),n}replace(e,t){if(!this.#w.has(e))throw new Error(`There is no user named "${e}"`);const n=Re(t.name);if(n)throw new Error(n);if(t.name!==e&&this.#w.has(t.name))throw new Error(`A user named "${t.name}" already exists`);const a=this.#E===e,s=[...this.#T].filter(([,t])=>t===e).map(([e])=>e);let o;this.#S=!0;try{this.remove(e),o=this.add(t)}catch(n){throw this.#S=!1,this.#L(),n}finally{this.#S=!1}for(const n of t.name===e?s:[...s,e])this.#T.set(n,o.name);return a&&(this.#E=o.name),this.#L(),o}#L(){this.#S||this.#k.forEach(e=>e())}renamedTo(e){return this.#T.get(e)}remove(e){const t=this.#w.get(e);if(!t)return!1;this.#E===e&&(this.#E=void 0);for(const[t,n]of this.#T)n===e&&this.#T.delete(t);return this.#w.delete(e),this.#x.forEach(e=>e(t)),this.#L(),!0}clear(){for(const e of[...this.#w.keys()])this.remove(e)}onRemove(e){return this.#x.add(e),()=>{this.#x.delete(e)}}onSessionChange(e){return this.#I.add(e),()=>{this.#I.delete(e)}}sessionChanged(e){this.#w.get(e.name)===e&&this.#I.forEach(t=>t(e))}onChange(e){return this.#k.add(e),()=>{this.#k.delete(e)}}get defaultUser(){return void 0===this.#E?void 0:this.#w.get(this.#E)}setDefaultUser(e){if(void 0===e)return void(this.#E=void 0);const t=this.#w.get(e);if(!t)throw new Error(`There is no user named "${e}"`);if("session"===t.kind&&("active"!==t.state||!t.session))throw new Error(`"${e}" has to log in on its own host before it can be the default user`);if("token"===t.kind&&"expired"===t.state)throw new Error(`MI turned down the API token of "${e}"`);this.#E=e}resolve(e){if(this.#A.has(e))return this.#A.get(e);const t=Oe(e.headers.host);let n;if(void 0===t){const t="localhost"===(e.headers.host??"").toLowerCase().replace(/:\d+$/,"")?this.defaultUser:void 0;n={key:t?.name??"",user:t}}else{const e=this.#w.get(t)??this.#w.get(this.#T.get(t)??"");n=e?{key:e.name,user:e}:void 0}return this.#A.set(e,n),n}};function Be(e=je){return(t,n,a)=>{const s=e.resolve(t),o=Oe(t.headers.host)??"";if(s?.user&&o&&s.key!==o&&function(e){if("GET"!==e.method&&"HEAD"!==e.method)return!1;const t=e.headers["sec-fetch-mode"];return"string"==typeof t?"navigate"===t:String(e.headers.accept??"").includes("text/html")}(t)){const e=t.originalUrl??t.url??"/";return n.statusCode=302,n.setHeader("Location",`${Ne(t,s.key)}${e}`),n.setHeader("Cache-Control","no-store"),void n.end()}s?a():(n.statusCode=404,n.setHeader("Content-Type","text/plain; charset=utf-8"),n.setHeader("Cache-Control","no-store"),n.end(`pp-dev has no user named "${o}". Open users from the pp-dev dev panel on the main host.`))}}const He=/(?:^|\/\/[^/]+)\/(?:login|auth\/saml\/login)(?:[/?#]|$)/i;function Me(e){return(e??"").split(";").map(e=>e.trim()).filter(e=>e&&e.split("=",1)[0].trim()!==fe)}function De({statusCode:e=200,headers:t}){return 401===e||412===e||e>=300&&e<400&&He.test(String(t.location??""))}class _e{#w;#$;#R=r();#O=new WeakMap;#N=new WeakMap;#j=new WeakMap;#B=new WeakMap;constructor(e,t){this.#w=e,this.#$=t}isLeftBehindRequest(e,t,n){const a=Oe(t.headers.host)===e.name?be(t.headers.cookie,fe):n;return void 0!==a&&this.#H(e,a)}observe(e,t,n,a){if("session"!==e.kind||this.#w.get(e.name)!==e)return;const s=Oe(t.headers.host)===e.name,o=s?be(t.headers.cookie,fe):a,r=ye(n.headers["set-cookie"],fe),i=this.#j.get(e);let l,c,d=s;return r&&r!==o?(l=r,c=o):s&&o!==e.session&&!De(n)?l=o:i&&Date.now()>=i.retryAt&&(l=i.session,d=i.ownHost),l&&l===i?.session&&void 0===c&&(c=i.replaced),"active"===e.state&&De(n)&&o===e.session&&this.#M(e,`MI turned away ${t.method} ${t.url}`),l&&l!==e.session&&l!==this.#O.get(e)&&l!==this.#N.get(e)&&!this.#H(e,l)?this.#D(e,l,c,d):void 0}async#D(e,t,n,a){let s;this.#O.set(e,t);try{s=await this.#$(t)}catch(s){return this.#O.get(e)===t&&(this.#O.delete(e),this.#j.set(e,{session:t,replaced:n,ownHost:a,retryAt:Date.now()+5e3})),void this.#R.warn(i.yellow(`Could not check the MI session of user ${e.name}: ${String(s)}`))}if(this.#O.get(e)===t&&this.#w.get(e.name)===e)if(this.#O.delete(e),this.#j.get(e)?.session===t&&this.#j.delete(e),s){if(this.#H(e,t)||void 0!==n&&this.#H(e,n))return;const o="active"===e.state;a&&(this.#_(e),void 0!==n&&n!==t&&this.#_(e,n)),this.#j.delete(e),Object.assign(e,{state:"active",session:t,miUser:s}),this.#N.delete(e),this.#w.sessionChanged(e),o||this.#R.info(`${i.blue("User")} ${i.yellow(e.name)} ${i.blue("logged in")} (MI user ${s.id}${s.username?`, ${s.username}`:""})`)}else this.#N.set(e,t),"active"===e.state&&n===e.session&&this.#M(e,"MI started a guest session")}#H(e,t){return"active"===e.state&&Boolean(this.#B.get(e)?.has(t))}#_(e,t=e.session){if(!t)return;const n=this.#B.get(e)??new Set;n.add(t),n.size>20&&n.delete(n.values().next().value),this.#B.set(e,n)}#M(e,t){this.#_(e),e.state="expired",e.session=void 0,this.#O.delete(e),this.#j.delete(e),this.#w.sessionChanged(e),this.#w.defaultUser===e&&this.#w.setDefaultUser(void 0),this.#R.warn(`${i.yellow(`The MI session of user ${e.name} ended`)} (${t}). Log in again on its host.`)}}async function Fe(e,t){try{const n=await e.get("/data/page/index/auth/info",{"Content-Type":"application/json",Accept:"application/json",...t},!0,{quiet:!0}),a=n.data?.user;return"number"==typeof a?.user_id?{miUser:{id:a.user_id,username:"string"==typeof a.username?a.username:""}}:{miUser:void 0}}catch(e){if(Ie(e))return{miUser:void 0,refused:Se(e).userFriendlyMessage};throw e}}async function Ve(e,t){try{const n=await Fe(e,{Authorization:`Bearer ${t}`});return n.miUser?{ok:!0,miUser:n.miUser}:{ok:!1,rejected:!0,message:n.refused?`The API token was rejected: ${n.refused}`:"The API token is expired or invalid"}}catch(e){return{ok:!1,rejected:!1,message:`Could not check the API token with MI: ${Se(e).userFriendlyMessage}`}}}const Pe=/^(https?:\/\/)([^/]+)(\/.*)?$/i,qe="X-PP-Proxy";const Ue=/^\/login(?:\/|$)/i,ze="/auth/saml/login";const Je=/^\/auth\/(?:index\/)?logout(?:[/?#]|$)/i;const We=new WeakMap,Ke=new WeakMap;const Ge=[/^text\//,/^application\/(?:[\w.-]+\+)?(?:json|xml)$/,/^application\/(?:x-)?(?:java|ecma)script$/,/^[\w.-]+\/[\w.-]+\+(?:json|xml)$/];function Ye(e){const t=e.split(";",1)[0].trim().toLowerCase();return Ge.some(e=>e.test(t))}const Ze=/[\w.:/\\!-]/;function Xe(e){const t=e["content-encoding"];if("string"==typeof t&&t.trim()&&"identity"!==t.trim())return!1;const n=e["content-type"];return"string"==typeof n&&Ye(n)}function Qe(e){return async(t,n,a)=>{const s=e&&Xe(t.headers);a.statusCode=t.statusCode??a.statusCode,t.statusMessage&&(a.statusMessage=t.statusMessage),a.setHeader(qe,1);for(const[e,n]of Object.entries(t.headers))void 0!==n&&(s&&"content-length"===e.toLowerCase()||a.setHeader(e,n));if(!s)return void t.pipe(a);const o=new I("utf8");let r="",i=!1;const l=n=>{if(!n)return;a.write(e(Buffer.from(n,"utf8"),"utf8"))||i||(i=!0,t.pause(),a.once("drain",()=>{i=!1,t.resume()}))};t.on("data",e=>{r+=o.write(e);const t=r.lastIndexOf("\n");if(-1!==t&&(l(r.slice(0,t+1)),r=r.slice(t+1)),r.length>=65536){const e=function(e){for(let t=e.length-1;t>=0;t-=1)if(!Ze.test(e[t]))return t;return-1}(r)+1||r.length;l(r.slice(0,e)),r=r.slice(e)}}),t.on("end",()=>{l(r+o.end()),r="",a.end()}),t.on("error",()=>{a.end()})}}function et(e){const{rewritePath:t=/^\/(?!p[tl]).*/i,baseURL:n="",disableSSLValidation:a=!1,miAPI:s,templateName:o,users:l=je}=e;if(!n)throw new Error("Base url is required");const c=function(e){try{return new URL(e).host}catch{return e.replace(Pe,"$2")}}(n),d=n.replace(Pe,"$1$2"),u=import("file-type"),p=r(),m=e.sessionUsers??new _e(l,function(e){return async t=>(await Fe(e,{Cookie:`${fe}=${t}`})).miUser}(s));return k({selfHandleResponse:!0,pathFilter:(n,a)=>!(e.proxyIgnore||[]).some(e=>"string"==typeof e?n.startsWith(e):"function"!=typeof e.test||e.test(n))&&("string"==typeof t?n.startsWith(t):Array.isArray(t)?t.some(e=>n.startsWith(e)):"function"==typeof t.test&&t.test(n)),target:n,changeOrigin:!0,logger:{info:()=>{},warn:()=>{},error:()=>{}},secure:!a,headers:{host:c,origin:d},on:{proxyReq(e,t,a){const r=t.headers.host,c=e.getHeader("referer");r&&c&&"string"==typeof c&&e.setHeader("referer",c.replace(function(e){let t=K.get(e);return t||(t=new RegExp(`^https?://${J(e)}(?=[/?#]|$)`,"i"),K.set(e,t)),t}(r),n));const d=l.resolve(t);let u;d?.user?u=d.user.token:d&&(u=s.personalAccessToken),u?(e.setHeader("Authorization",`Bearer ${u}`),We.set(t,u)):"session"===d?.user?.kind&&e.removeHeader("Authorization"),d?.user&&e.removeHeader("Token");const m="session"===d?.user?.kind?d.user:void 0;let g;if(m&&Oe(t.headers.host)!==m.name){g=m.session;const n=g?(f=t.headers.cookie,v=g,[...Me(f),`${fe}=${v}`].join("; ")):function(e){const t=Me(e);return t.length?t.join("; "):void 0}(t.headers.cookie);n?e.setHeader("cookie",n):e.removeHeader("cookie")}var f,v;Ke.set(t,{user:d?.user,sentSession:g}),e.removeHeader("if-none-match"),e.removeHeader("if-modified-since");const b=t.url??"/",y=function(e,t,n,a){if(!t||!n||!a||n===a)return e;try{const t=new h(e,"http://localhost"),s=t.pathname.match(/^\/data\/page\/([^/]+)(\/.*)?$/);if(!s)return e;let o;try{o=decodeURIComponent(s[1])}catch{o=s[1]}if(o!==n)return e;return`/data/page/${a}${s[2]??""}`+t.search+t.hash}catch{return e}}(b,s.v7Features,o,s.internalPageName);y!==b&&(t.url=y,e.path=y,console.info(`${i.cyan("Rewrites data page to correct internal page name")}: ${i.yellow(b)} ${i.blue("→")} ${i.green(y)}`)),p.info(`${i.blue("Proxies request:")} ${i.green(t.method)} ${t.url} -> ${i.green(e.method)} ${e.protocol}//${e.host}${e.path}${d?.user?` ${i.blue("as")} ${i.yellow(d.user.name)}`:""}`);const{socket:w}=t,x=()=>{setTimeout(()=>{e.destroyed||e.destroy()},200)};return w.once("close",x),e.once("close",()=>w.off("close",x)),e},proxyRes:(e,t,n)=>{e.headers["set-cookie"]&&(e.headers["set-cookie"]=we(e.headers["set-cookie"],he(t))),e.headers.location&&(e.headers.location=function(e,t,n){const a=n.headers.host;let s;try{s=new URL(e)}catch{return e}return a&&/^https?:$/.test(s.protocol)&&s.host===t.toLowerCase()?e.replace(/^https?:\/\/[^/?#]*/i,`${he(n)?"https":"http"}://${a}`):e}(e.headers.location,c,t));const a=function(e,t){if(!Je.test(e.url??""))return;const n=Oe(e.headers.host);return void 0===n?void 0:t.get(n)}(t,l),s=Ke.get(t);if(a){e.resume();const o={"cache-control":"no-store"};s?.user===a&&(l.remove(a.name),p.info(`${i.blue("Logged out user")} ${i.yellow(a.name)}, back to the main host`),e.headers["set-cookie"]&&(o["set-cookie"]=e.headers["set-cookie"]));const r=t.headers["sec-fetch-mode"];return r&&"navigate"!==r?n.writeHead(204,o):n.writeHead(302,{...o,location:`${Ne(t)}/`}),void n.end()}const o=s?.user;let r=!0;if(o&&l.get(o.name)!==o)r=!1;else if(o){let n=!1;"session"===o.kind&&(n=m.isLeftBehindRequest(o,t,s?.sentSession),m.observe(o,t,e,s?.sentSession)),r=Oe(t.headers.host)===o.name&&!n}if(!r){const t=function(e){const t=(e??[]).filter(e=>void 0===ye(e,fe));return t.length?t:void 0}(e.headers["set-cookie"]);t?e.headers["set-cookie"]=t:delete e.headers["set-cookie"]}e.headers["cache-control"]="no-store",delete e.headers.expires,delete e.headers.pragma;const d=(e,n)=>Buffer.from(Y(ze,"/login",G(c,t.headers.host??"",e.toString(n))),n);if(e.headers["content-type"]?.includes("text/event-stream")||e.headers["transfer-encoding"]?.includes("chunked")&&"no"===e.headers["x-accel-buffering"])return p.info(`${i.blue("Start streaming for request:")} ${i.green(t.method)} ${t.url}`),Qe(d)(e,t,n);const h=function(e){const t=e["content-type"];if("string"==typeof t&&t.trim()&&!Ye(t))return"pipe";const n=Number(e["content-length"]);return Number.isFinite(n)&&n>=10485760&&Xe(e)&&!function(e){const t="string"==typeof e?e.split(";",1)[0].trim().toLowerCase():"";return"text/html"===t||"application/xhtml+xml"===t}(t)?"stream":"buffer"}(e.headers);if("stream"===h&&(e.headers["x-accel-buffering"]="no"),"buffer"!==h)return Qe("stream"===h?d:void 0)(e,t,n);const g=S(async(e,t,n,a)=>{a.setHeader(qe,1);if(await(await u).fileTypeFromBuffer(e))return e;{let t=e.toString("utf8");try{const e=new URL(n.url??"",`http://${c}`);if(e.searchParams&&e.searchParams.has("proxyRedirect")){t+=`<script>(${function(){const e="pp-dev::redirectCount",t="pp-dev::lastRedirect";let n=+(localStorage.getItem(e)??0);localStorage.getItem(t);Number.isNaN(n)&&(n=0);let a=window.location.href;const s=function(){const o=new URLSearchParams(window.location.search);if(!o.has("proxyRedirect"))return console.debug("No proxyRedirect param. Cleaning up."),void localStorage.removeItem(e);a!==window.location.href?(a=window.location.href,setTimeout(s,n<3?3e3:5e3)):window.location.href=o.get("proxyRedirect"),localStorage.setItem(e,""+ ++n),localStorage.setItem(t,(new Date).toISOString())};setTimeout(s,3e3)}.toString()})()<\/script>`}const a=t.slice(0,100);Ue.test(e.pathname)&&(a.includes("<html")||a.includes("<body")||a.includes("<head")||a.includes("html>"))&&!Oe(n.headers.host)&&(t+=`<script>(${xe.toString()})(${s=c,JSON.stringify(s).replace(/</g,"\\u003c")})<\/script>`)}catch{}const a=n.headers.host??"";return Y(ze,"/login",G(c,a,t))}var s});return g(e,t,n)},error(e,t,n){!function(e,t,n,a){const s=`Proxy error: "${e.message}" when trying to "${t.method} ${t.url}"`;if(a(`${s}\n\n${e.stack??""}`),"function"!=typeof n.writeHead)return void n.destroy();const o=n;o.writableEnded||o.destroyed||(o.headersSent?o.destroy():(o.writeHead(502,{"Content-Type":"text/plain; charset=utf-8"}),o.end(s)))}(e,t,n,e=>p.error(e))}}})}const tt=/\.(?:[a-z0-9]+)$/i,nt=6e5,at=104857600,st=1e3,ot=10485760;const rt=new class{items=new Map;totalSize=0;maxSize;maxItems;constructor(e,t){this.maxSize=e,this.maxItems=t}get(e){const t=this.items.get(e);return t?t.expiresAt<=Date.now()?(this.del(e),null):t.value:null}put(e,t,n=nt){return this.del(e),this.items.set(e,{value:t,expiresAt:Date.now()+n}),this.totalSize+=t.size,this.cleanup(),t}del(e){const t=this.items.get(e);return!!t&&(this.totalSize-=t.value.size,this.items.delete(e))}clear(){this.items.clear(),this.totalSize=0}keys(){return this.purgeExpired(),[...this.items.keys()]}getTotalSize(){return this.totalSize}getItemCount(){return this.keys().length}purgeExpired(){const e=Date.now();for(const[t,n]of this.items)n.expiresAt<=e&&this.del(t)}cleanup(){for(this.purgeExpired();this.items.size>0&&(this.items.size>this.maxItems||this.totalSize>this.maxSize);)this.del(this.items.keys().next().value)}}(at,st),it=new Set,lt=new Map;function ct(e){rt.clear(),lt.clear(),it.forEach(e=>e()),r().info(i.blue(`Proxy cache cleared: ${e}`))}const dt=new WeakSet;function ut(e,t){return t?e.startsWith(`@${t}@`):!e.startsWith("@")}function pt(e,t){if(e){for(const t of rt.keys())ut(t,e)&&rt.del(t);lt.delete(e),r().info(i.blue(`Proxy cache cleared for ${e}: ${t}`))}else ct(t)}function mt(e,t,n){const a=function(e){return We.get(e)}(e);if(void 0!==a){const e=lt.get(n),t=void 0!==e&&a!==e;return lt.set(n,a),t?"personal access token changed":void 0}const s=ye(t.getHeader("set-cookie"),fe);return void 0!==s&&s!==be(e.headers.cookie,fe)?"MI session changed":void 0}function ht(e){if(0===e.length)return Buffer.alloc(0);if(1===e.length)return e[0];const t=e.reduce((e,t)=>Buffer.byteLength(t)+e,0);return Buffer.concat(e,t)}function gt(e){const{devServer:t,ttl:n=nt,maxSize:a=at,maxItems:s=st,users:o=je}=e,l=r();return a===at&&s===st||(rt.maxSize=a,rt.maxItems=s),t.cache=rt,dt.has(o)||(dt.add(o),o.onRemove(e=>pt(e.name,"user removed")),o.onSessionChange(e=>pt(e.name,"MI session changed or ended"))),(e,t,a)=>{const s=o.resolve(e);if(!s)return a();t.once("finish",()=>{const n=t.hasHeader(qe)?mt(e,t,s.key):void 0;n&&pt(s.key,`${n} (${e.method} ${X(e.url??"")})`)});const c=s.user?.session,d=e.originalUrl||e.url||"",u="GET"===e.method?function(e,t,n){const a=e.split("?")[0];return tt.test(a)?a.includes("/auth/info.js")?"":t?`@${t}@${n}:${e}`:e:""}(d,s.key,e.headers.host??""):"";if(!u||!function(e,t){const{user:n}=t;return"session"!==n?.kind||!!n.session&&(Oe(e.headers.host)!==n.name||be(e.headers.cookie,fe)===n.session)}(e,s))return a();const p=rt.get(u);if(p)return l.info(`${i.blue("Proxies request:")} ${i.green(e.method)} ${d} -> ${i.blue("Cache")} ${u}`),function(e,t){try{for(const[n,a]of Object.entries(t))null!=a&&e.setHeader(n,a)}catch(e){r().warn(`Failed to set some response headers: ${e instanceof Error?e.message:String(e)}`)}}(t,p.headers),t.setHeader("X-PP-Cache","hit"),t.write(p.content),void t.end();const m=t.end,h=t.write,g=[];let f=0,v="undecided";const b=(e,t)=>null==e?null:"string"==typeof e?Buffer.from(e,t||"utf8"):e instanceof Uint8Array?Buffer.from(e):Buffer.from(String(e),"utf8"),y=()=>{v="pass";for(const e of g.splice(0))h.call(t,e)},w=()=>{"undecided"===v&&(t.hasHeader(qe)?!function(e){if(200!==e.statusCode)return!1;if(/^text\/event-stream/i.test(String(e.getHeader("content-type")??"")))return!1;if("no"===String(e.getHeader("x-accel-buffering")??"").toLowerCase())return!1;const t=Number(e.getHeader("content-length"));return!(Number.isFinite(t)&&t>=ot)}(t)?y():v="buffer":v="pass")};t.write=function(e,t,n){if(w(),"pass"===v)return h.call(this,e,t,n);const a="function"==typeof t?t:n,s=b(e,"string"==typeof t?t:void 0);return s&&(g.push(s),f+=s.length),f>ot&&y(),a&&process.nextTick(a),!0},t.end=function(e,a,r){if(w(),"pass"===v)return m.call(this,e,a,r);"function"==typeof e?(r=e,e=void 0):"function"==typeof a&&(r=a,a=void 0);const d=b(e,"string"==typeof a?a:void 0);let p;d&&g.push(d);try{p=ht(g);const e=Buffer.byteLength(p);if(200===t.statusCode&&e>0&&e<ot&&function(e,t,n){return!e.user||n.get(e.key)===e.user&&e.user.session===t}(s,c,o)){const{"set-cookie":a,...s}=t.getHeaders(),o={headers:s,content:p,timestamp:Date.now(),size:e};rt.put(u,o,n),l.info(`${i.blue("[Cached]")} ${u} (${(e/1024).toFixed(1)}KB)`)}}catch(e){l.error(`Failed to cache response for ${u}: ${e}`),p=ht(g)}return m.call(this,p,r)},a()}}const ft=new Map;function vt(e){const t=ft.get(e);return t?Date.now()-t.timestamp>18e4?(ft.delete(e),null):t.data:null}function bt(e,t){ft.set(e,{timestamp:Date.now(),data:t})}var yt;yt=()=>ft.clear(),it.add(yt);let wt=!1;function xt(e){wt||(wt=!0,e.info(i.blue("Clearing proxy cache after successful login")),rt.clear())}function kt(){wt=!1,d.reset()}const St="/home?proxyRedirect=";function It(e){const t=e.headers??{},n=Boolean(Oe(e.headers?.host));if(!n&&!je.resolve(e)?.user)return t;const{cookie:a,authorization:s,token:o,...r}=t;return n||void 0===a?r:{...r,cookie:a}}function Et(e,t,n){const{templateLess:a=!1,miHudLess:s=!1,appId:o,base:l,appBase:c,v7Features:u}=n,p=r();if(a&&s&&void 0===o)throw new Error("Custom App ID is required when both templateLess and miHudLess are true");return async(n,r,m)=>{try{const h=!(a&&s),g=X(n.url??""),f=e.test(g),v=!f&&!g.startsWith("/home")&&function(e,t){if(!t||"/"===t)return!0;const n=t.endsWith("/")?t:`${t}/`;return e===n||e===n.slice(0,-1)||e.startsWith(n)}(g,c)&&function(e){const t=e.headers["sec-fetch-dest"];if("string"==typeof t)return"document"===t;const n=e.headers.accept;return"string"==typeof n&&n.includes("text/html")}(n),b=d.getState();if(!b.isAuthenticated&&!b.isRedirected&&n.url?.startsWith("/home")&&!n.url?.startsWith(St)){let e;p.info(i.blue("Trying to authenticate and redirect to base"));const c=t=>{e=t};try{if(h?await At(t,{templateLess:a,miHudLess:s,appId:o,redirectUrl:u&&l?`${l}`:`${St}${encodeURIComponent("/")}`},n,r,c,p):await Tt(t,{templateLess:a,miHudLess:s,appId:o,redirectUrl:u&&l?`${l}`:`${St}${encodeURIComponent("/")}`},n,r,c,p),r.headersSent||r.writableEnded)return;return e?m(e):(d.setRedirected(!0),p.info(i.blue("Successfully authenticated. Redirecting to base")),Z(r,l??"/",302))}catch(e){if(r.headersSent||r.writableEnded)return;return m()}}if(!f&&!v)return m();if(!h){return await Tt(t,{templateLess:a,miHudLess:s,appId:o,redirectUrl:u&&l?`${l}`:`${St}${encodeURIComponent("/")}`},n,r,m,p)}return await At(t,{templateLess:a,miHudLess:s,appId:o,redirectUrl:u&&l?`${l}`:`${St}${encodeURIComponent("/")}`},n,r,m,p)}catch(e){return p.error(i.red(`Unexpected error in load-pp-data middleware: ${e instanceof Error?e.message:String(e)}`)),kt(),m(e)}}}async function Tt(e,t,n,a,s,o){const{appId:r,redirectOnAuthFailure:l,redirectUrl:c}=t;if(void 0===r){const e=new Error("Custom App ID is required for page info only mode");return o.error(i.red(e.message)),s(e)}const u=It(n);o.info(i.green("Start loading page info"));try{const t=`pageInfo:${r}`;return vt(t)?(o.info(i.green("Page info loaded from cache")),s()):(await e.getPageInfo(r,u),xt(o),bt(t,{success:!0}),d.updateState({isAuthenticated:!0}),o.info(i.green("Page info loaded successfully")),s())}catch(e){return kt(),Ct(e,l??!0,c||`${St}${encodeURIComponent("/")}`,a,s,o,"Page info")}}async function At(e,t,n,a,s,o){const{templateLess:r,appId:l,redirectOnAuthFailure:c,redirectUrl:d}=t,u=It(n);o.info(i.green("Start loading page data"));try{const t=`pageData:${r}:${l}`;if(vt(t))return o.info(i.green("Page data loaded from cache")),s();const n=r||void 0===l?e.getPageTemplate(u):e.getPageVariables(l,u);return await n,xt(o),bt(t,{success:!0}),o.info(i.green("Page data loaded successfully")),s()}catch(e){return kt(),Ct(e,c??!0,d||`${St}${encodeURIComponent("/")}`,a,s,o,"Page data")}}function Ct(e,t,n,a,s,o,r){if(function(e){return null!==e&&"object"==typeof e&&Ie(e)}(e)&&(o.info(i.red(`${r} loading failed. Not authorized`)),t)){const e=`${n}`;return o.info(i.yellow(`Redirecting to: ${e}`)),Z(a,e,302)}const l=e instanceof Error?e.message:String(e);return o.info(i.red(`${r} loading failed. Error: ${l}`)),s(e)}const Lt="/@api/users",$t=new WeakMap;function Rt(e,t){const n=Ne(t,e.name);return{name:e.name,kind:e.kind,...e.state?{state:e.state}:{},..."session"===e.kind?{loginUrl:`${n}/login`}:{},miUser:e.miUser,origin:n}}function Ot(e,t,n,a){e.status(t).json({error:a,code:n})}function Nt(e,t){const n=$t.get(e);if(n)return void(n.deps=t);const a={deps:t};$t.set(e,a);const s=r(),o=()=>a.deps.users??je;e.get(Lt,(e,t)=>{const n=o();t.json({available:Boolean(a.deps.miAPI.personalAccessToken),users:n.list().map(t=>Rt(t,e)),defaultUser:n.defaultUser?.name??null})}),e.put(`${Lt}/default`,(e,t)=>{const n=o(),a=(e.body??{}).name;if(null===a||"string"==typeof a)if(null===a||n.get(a)){try{n.setDefaultUser(a??void 0)}catch(e){return void Ot(t,409,"NOT_LOGGED_IN",e instanceof Error?e.message:String(e))}s.info(`${i.blue("Default user:")} ${i.yellow(a??"the original user")}`),t.json({defaultUser:a})}else Ot(t,404,"NOT_FOUND",`There is no user named "${a}"`);else Ot(t,400,"INVALID_NAME","name must be a user name or null")}),e.post(Lt,async(e,t)=>{const{miAPI:n}=a.deps,r=o(),l=e.body??{},c="string"==typeof l.name?l.name:"",d="string"==typeof l.token?l.token.trim():"",u=l.kind??"token";if("token"!==u&&"session"!==u)return void Ot(t,400,"INVALID_KIND",'kind must be "token" or "session"');if(!n.personalAccessToken)return void Ot(t,409,"ORIGINAL_TOKEN_REQUIRED","Adding users needs your own API token: set mi.token or MI_ACCESS_TOKEN.");const p=Re(c);if(p)return void Ot(t,400,"INVALID_NAME",p);if(r.get(c))return void Ot(t,409,"NAME_TAKEN",`A user named "${c}" already exists`);if("session"===u){const n=r.add({name:c,kind:"session"});return s.info(`${i.blue("Added user")} ${i.yellow(n.name)}: log in at ${Ne(e,n.name)}/login`),void t.status(201).json({user:Rt(n,e)})}if(!d)return void Ot(t,400,"TOKEN_REQUIRED","The API token is required");const m=r.context,h=await Ve(n,d);if(r.context!==m||a.deps.miAPI!==n||o()!==r)return void Ot(t,409,"CONTEXT_CHANGED","The dev server switched to another MI instance; add the user again");if(!h.ok)return void(h.rejected?Ot(t,400,"INVALID_TOKEN",h.message):Ot(t,502,"MI_UNAVAILABLE",h.message));const{miUser:g}=h;let f;try{f=r.add({name:c,kind:"token",token:d,miUser:g})}catch(e){return void Ot(t,409,"NAME_TAKEN",e instanceof Error?e.message:String(e))}s.info(`${i.blue("Added user")} ${i.yellow(f.name)} (MI user ${g.id}${g.username?`, ${g.username}`:""}) at ${Ne(e,f.name)}`),t.status(201).json({user:Rt(f,e)})}),e.patch(`${Lt}/:name`,async(e,t)=>{const{miAPI:n}=a.deps,r=o(),l=String(e.params.name),c=r.get(l),d=e.body??{},u="string"==typeof d.name&&d.name?d.name:l,p="string"==typeof d.token?d.token.trim():"";if(!c)return void Ot(t,404,"NOT_FOUND",`There is no user named "${l}"`);if("token"!==c.kind)return void Ot(t,400,"NOT_EDITABLE","Only users added with an API token can be edited. Remove this user and add it again.");const m=Re(u);if(m)return void Ot(t,400,"INVALID_NAME",m);if(u!==l&&r.get(u))return void Ot(t,409,"NAME_TAKEN",`A user named "${u}" already exists`);let h,g={...c,name:u};if(p&&p!==c.token){const e=r.context,s=await Ve(n,p);if(r.context!==e||a.deps.miAPI!==n||o()!==r)return void Ot(t,409,"CONTEXT_CHANGED","The dev server switched to another MI instance; edit the user again");if(!s.ok)return void(s.rejected?Ot(t,400,"INVALID_TOKEN",s.message):Ot(t,502,"MI_UNAVAILABLE",s.message));const{state:i,...l}=g;g={...l,token:p,miUser:s.miUser}}if(r.get(l)===c)if(u!==l||g.token!==c.token){try{h=r.replace(l,g)}catch(e){return void Ot(t,409,"NAME_TAKEN",e instanceof Error?e.message:String(e))}s.info(`${i.blue("Edited user")} ${i.yellow(l)}${u!==l?` → ${i.yellow(u)}`:""}${h.token!==c.token?" (new API token)":""}`),t.json({user:Rt(h,e)})}else t.json({user:Rt(c,e)});else Ot(t,409,"USER_CHANGED",`The user "${l}" changed meanwhile; edit it again`)}),e.delete(`${Lt}/:name`,(e,t)=>{const n=String(e.params.name);o().remove(n)?(s.info(`${i.blue("Removed user")} ${i.yellow(n)}`),t.status(204).end()):Ot(t,404,"NOT_FOUND",`There is no user named "${n}"`)})}function jt(e){let t;try{t=JSON.parse(A.readFileSync(e,"utf-8"))}catch(t){return"ENOENT"!==t.code&&r().warn(i.yellow(`Could not read the saved users from ${e}: ${String(t)}`)),[]}const n=t&&"object"==typeof t&&!Array.isArray(t)?t.users:void 0;return(Array.isArray(n)?n:[]).filter(e=>"string"==typeof e?.name&&"string"==typeof e.token&&Boolean(e.token))}const Bt=new WeakMap,Ht=new WeakMap;function Mt(e){Bt.delete(e),Ht.get(e)?.(),Ht.delete(e)}async function Dt(e,t){const n=function(e,t,n=C.homedir()){const a=E("sha256").update(`${L.resolve(e)}\n${t.replace(/\/+$/,"")}`).digest("hex").slice(0,16);return L.join(n,".pp-dev","users",`${a}.json`)}(t.projectRoot,t.miUrl,t.homeDir),a={project:L.resolve(t.projectRoot),miUrl:t.miUrl},s=r();if(Bt.get(e)===n)return;Mt(e),Bt.set(e,n);const o=jt(n);let l=!0,c=!1,d=e.list().some(e=>"token"===e.kind&&e.token);const u=new Set,p=new Set,m=()=>{const t=e.list(),o=t.filter(e=>"token"===e.kind&&e.token).map(e=>({name:e.name,token:e.token})),r=new Set(t.map(e=>e.name)),l=jt(n).filter(e=>!r.has(e.name)&&!p.has(e.name));try{!function(e,t,n){const a={version:1,...n,users:t},s=`${e}.${process.pid}.tmp`;A.mkdirSync(L.dirname(e),{recursive:!0,mode:448}),A.writeFileSync(s,`${JSON.stringify(a,null,2)}\n`,{mode:384}),A.renameSync(s,e),A.chmodSync(e,384)}(n,[...o,...l],a)}catch(e){s.warn(i.yellow(`Could not save the users to ${n}: ${String(e)}`))}},h=e.onRemove(t=>{Bt.get(e)===n&&(p.add(t.name),l&&u.add(t.name))}),g=e.onChange(()=>{Bt.get(e)===n&&(l?d||=!c:m())});Ht.set(e,()=>{h(),g()});try{const a=await Promise.all(o.map(e=>t.checkToken(e.token)));if(Bt.get(e)!==n)return;o.forEach((t,n)=>{if(Re(t.name)||e.get(t.name)||u.has(t.name))return;const o=a[n];c=!0;try{e.add({name:t.name,kind:"token",token:t.token,...o.ok?{miUser:o.miUser}:{},...!o.ok&&o.rejected?{state:"expired"}:{}})}finally{c=!1}o.ok||s.warn(i.yellow(`Restored user ${t.name}: ${o.message}`))})}finally{l=!1}d&&Bt.get(e)===n&&m(),o.length&&s.info(`${i.blue("Restored")} ${o.length} ${i.blue("saved user(s) from")} ${n}`)}function _t(e){const t=`${L.resolve(e.projectRoot)}\n${e.miUrl.replace(/\/+$/,"")}`;je.context!==t&&(Mt(je),je.useContext(t)),e.persist?e.miAPI.personalAccessToken?Dt(je,{projectRoot:e.projectRoot,miUrl:e.miUrl,checkToken:t=>Ve(e.miAPI,t)}).catch(e=>{r().warn(i.yellow(`Could not restore the saved users: ${String(e)}`))}):r().warn(i.yellow("users.persist is on, but users need your own API token (mi.token or MI_ACCESS_TOKEN).")):Mt(je)}async function Ft(e){const t=new _,n=async a=>{const s=await H.readdir(a,{withFileTypes:!0});for(const o of s){const s=R.join(a,o.name);if(o.isDirectory())await n(s);else if(o.isFile()){const n=R.relative(e,s).replace(/\\/g,"/");t.file(n,await H.readFile(s))}}};return await n(e),await t.generateAsync({type:"nodebuffer",compression:"DEFLATE"})}async function Vt(e,t){const n=await _.loadAsync(await H.readFile(e),{createFolders:!1}),a=R.resolve(t),s=Object.values(n.files).filter(e=>!e.name.startsWith("__MACOSX/"));for(const e of s){const t=e.unsafeOriginalName??e.name,n=R.resolve(a,t);if(n!==a&&!n.startsWith(a+R.sep))throw new Error(`Zip entry "${t}" resolves outside the extraction directory`);if("number"==typeof e.unixPermissions&&40960==(61440&e.unixPermissions))throw new Error(`Zip archive contains a symlink ("${t}"), which is not allowed`)}await H.mkdir(a,{recursive:!0});for(const e of s){const t=R.resolve(a,e.unsafeOriginalName??e.name);e.dir?await H.mkdir(t,{recursive:!0}):(await H.mkdir(R.dirname(t),{recursive:!0}),await H.writeFile(t,await e.async("nodebuffer")))}}const Pt=new Set(["GET","HEAD","OPTIONS"]);function qt(e){const t=e.headers.origin,n=e.headers.host;if("string"==typeof t){if("null"===t)return'Origin "null" (a sandboxed or file:// page)';let e;try{e=new URL(t)}catch{return`unreadable Origin "${t}"`}if("localhost"===(a=e.hostname)||a.endsWith(".localhost")||"[::1]"===a||/^127(?:\.\d{1,3}){3}$/.test(a))return null;const s=n?function(e,t){try{return new URL(`${e}//${t}`).host}catch{return}}(e.protocol,n):void 0;return s&&e.host===s?null:`Origin ${e.origin} is neither this dev server (${n??"no Host"}) nor this machine`}var a;const s=e.headers["sec-fetch-site"];return Pt.has((e.method??"GET").toUpperCase())||"cross-site"!==s?null:"sent from a cross-site page"}const Ut=(e,t,n)=>{const a=qt(e);a?t.status(403).json({error:`Blocked by pp-dev: ${a}.`}):n()},zt="10mb",Jt=(e,t,n,a)=>{const s="number"==typeof e?.status?e.status:void 0;if(!s||s<400||s>=500||"string"!=typeof e.type||n.headersSent)return void a(e);const o="entity.too.large"===e.type?`The request body is larger than the dev server accepts (${zt}).`:`The request body could not be read: ${e.message}`;n.status(s).json({error:o})};function Wt(){const e=$();return e.use(Ut),e.use($.json({limit:zt})),e.use($.urlencoded({extended:!0})),e.use(Jt),e}const Kt=Wt();function Gt(e,t){const n=t?.distDir;if("export"!==t?.output||!n||".next"===n)return null;const a=R.resolve(e,n,"dev","types");return B.existsSync(a)?(B.rmSync(a,{recursive:!0,force:!0}),a):null}const Yt=6e5;function Zt(e,t,n){if(e.pid)if("win32"!==process.platform)try{n?process.kill(-e.pid,t):e.kill(t)}catch{}else null===e.exitCode&&null===e.signalCode&&F.spawnSync("taskkill",["/PID",String(e.pid),"/T","/F"],{stdio:"ignore"})}function Xt(e,t,n,{cwd:a,env:s,timeoutMs:o,signal:r,killGraceMs:i=5e3}){return new Promise((l,c)=>{if(r?.aborted)return void c(new Error(`${e} was stopped`));const d="win32"!==process.platform&&(void 0!==r||void 0!==o),u=F.spawn(t,n,{cwd:a,env:s,stdio:"inherit",detached:d});let p,m;const h=(e,t)=>{p?t||void 0===m||(clearTimeout(m),m=void 0,Zt(u,"SIGKILL",d)):(p=e,t?(Zt(u,"SIGTERM",d),m=setTimeout(()=>{m=void 0,Zt(u,"SIGKILL",d)},i)):Zt(u,"SIGKILL",d))},g=()=>h(`${e} was stopped`,!1),f=void 0===o?void 0:setTimeout(()=>h(`${e} timed out after ${Math.round(o/1e3)} s`,!0),o);r?.addEventListener("abort",g,{once:!0});const v=e=>{clearTimeout(f),clearTimeout(m),r?.removeEventListener("abort",g),d&&Zt(u,"SIGKILL",d),e?c(e):l()};u.on("close",t=>{p?v(new Error(p)):0!==t?v(new Error(`${e} exited with code ${t}`)):v()}),u.on("error",e=>v(e))})}async function Qt(e,t={}){let n;try{const t=V(R.join(e,"package.json")).resolve("next/package.json");n=R.resolve(R.dirname(t),"dist/bin/next")}catch{throw new Error(`Unable to resolve the "next" binary from ${e}. Is Next.js installed?`)}await Xt("next build",process.execPath,[n,"build"],{...t,cwd:e,env:Object.assign({},process.env,{NODE_ENV:"production",[u]:"1"})})}const en="pageName",tn="date",nn=R.dirname("undefined"!=typeof __filename&&__filename||g(import.meta.url)),an=R.resolve(nn,"..",".."),sn=Buffer.from([80,75,3,4]),on=20,rn=e=>e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");const ln="sync-service.meta.json",cn="BUILD-MANIFEST.json",dn="__template_variables.json";class un{root;backupFolder;backupNameTemplate;dateFormat;pageName;currentMeta=null;distZipFolder;distZipFilename;versionFileTemplate;buildInputFolder;nextBuild;maxBackups;buildTimeoutMs;metaDir;builds=new AbortController;metaLoaded;logger;constructor(e,t){this.pageName=e;const{root:n=M.cwd(),backupFolder:a="backups",distZipFolder:s=R.resolve(n,"dist-zip"),distZipFilename:o=p(this.pageName),backupNameTemplate:i=`{${en}}-{${tn}}.zip`,versionFileTemplate:l="VERSION-v{packageversion}-{currentDate}.json",buildInputFolder:c,dateFormat:d=e=>e.toISOString().replace(/:/g,"-").replace(/\..*$/,""),nextBuild:u,maxBackups:h=on,buildTimeoutMs:g=Yt,metaDir:f}=t||{};this.root=n,this.backupFolder=R.resolve(n,a),this.backupNameTemplate=i,this.dateFormat=d,this.distZipFolder=s,this.distZipFilename=m(o,"ZIP output file name"),this.versionFileTemplate=m(l,"VERSION file name template"),this.buildInputFolder=c,this.nextBuild=u,this.maxBackups="number"==typeof h&&Number.isInteger(h)&&h>0?h:1/0,this.buildTimeoutMs=g,this.metaDir=void 0===f?R.join(this.backupFolder,".pp-dev-meta"):R.resolve(n,f),this.logger=r(),this.metaLoaded=this.loadMeta()}stopBuilds(){this.builds.abort()}async checkMeta(){await H.mkdir(this.metaDir,{recursive:!0}),await H.mkdir(this.backupFolder,{recursive:!0})}async loadMeta(){try{const e=JSON.parse(await this.readMetaFile());this.currentMeta=e&&"object"==typeof e?e:{}}catch(e){this.logger.warn(i.yellow(`[DistService] Could not read the sync meta (${e instanceof Error?e.message:String(e)}); starting a new one`)),this.currentMeta={}}}async meta(){return await this.metaLoaded,this.currentMeta||(this.currentMeta={}),this.currentMeta}async readMetaFile(){return await this.checkMeta(),await H.readFile(R.join(this.metaDir,ln),{encoding:"utf-8"}).catch(()=>"{}")}async writeMetaFile(e){return await this.checkMeta(),await H.writeFile(R.join(this.metaDir,ln),JSON.stringify(e,null,2),{encoding:"utf-8"})}async syncMeta(){await this.writeMetaFile(await this.meta())}async getLatestSavedBackup(){const{lastBackupName:e}=await this.meta();if(!e)return null;const t=R.resolve(this.backupFolder,e);try{return await H.stat(t),t}catch{return null}}backupName(e,t=new Date){return this.backupNameTemplate.replace(`{${en}}`,e).replace(`{${tn}}`,this.dateFormat(t))}getBackupMeta(){return this.currentMeta}async pruneBackups(e){if(Number.isFinite(this.maxBackups))try{const t=function(e,t,n){const a=n.split(/(\d+)/).map((e,t)=>t%2==1?"\\d+":rn(e)).join(""),s=[{token:`{${en}}`,pattern:rn(t)},{token:`{${tn}}`,pattern:a}].map(t=>({...t,at:e.indexOf(t.token)})).filter(e=>-1!==e.at).sort((e,t)=>e.at-t.at);let o="^",r=0;for(const{token:t,pattern:n,at:a}of s)o+=rn(e.slice(r,a))+n,r=a+t.length;return new RegExp(`${o}${rn(e.slice(r))}$`)}(this.backupNameTemplate,this.pageName,this.dateFormat(new Date)),n=new Set([e]),a=await H.readdir(this.backupFolder,{withFileTypes:!0}),s=(await Promise.all(a.filter(e=>e.isFile()&&t.test(e.name)).map(async e=>({name:e.name,mtimeMs:(await H.stat(R.resolve(this.backupFolder,e.name))).mtimeMs})))).sort((e,t)=>t.mtimeMs-e.mtimeMs||t.name.localeCompare(e.name)),o=s.filter(e=>n.has(e.name)).length,r=s.filter(e=>!n.has(e.name)).slice(Math.max(0,this.maxBackups-o));for(const e of r)await H.unlink(R.resolve(this.backupFolder,e.name));r.length>0&&this.logger.info(i.gray(`[DistService] Deleted ${r.length} old backup(s), keeping the latest ${this.maxBackups}`))}catch(e){this.logger.warn(i.yellow(`[DistService] Could not delete old backups: ${e instanceof Error?e.message:String(e)}`))}}pathToPosix(e){return e.replace(/\\/g,"/")}normalizeBackupManifestPath(e){try{return m(e,"VERSION manifest file path")}catch{throw new Error(`VERSION manifest file path "${e}" resolves outside the backup root`)}}async normalizeExtractedRootDir(e){let t=e;for(;;){const e=await H.readdir(t,{withFileTypes:!0});if(1!==e.length||!e[0]?.isDirectory())return t;t=R.join(t,e[0].name)}}async listFilesRecursive(e){const t=await H.readdir(e,{withFileTypes:!0}),n=[];for(const a of t){const t=R.join(e,a.name);a.isDirectory()?n.push(...await this.listFilesRecursive(t)):a.isFile()&&n.push(t)}return n}buildManifestChecksum(e){const t=Object.keys(e).sort().map(t=>e[t]).join("");return O.createHash("sha256").update(t).digest("hex")}buildBackupFingerprint(e){const t=Object.keys(e).sort().map(t=>`${t}:${e[t]}`).join("\n");return O.createHash("sha256").update(t,"utf-8").digest("hex")}escapeRegExp(e){return e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}versionFileTemplateMatcher(){const e=/\{[^}]+\}/g,t=[];let n=0,a=null;for(;a=e.exec(this.versionFileTemplate),a;){const[e]=a,s=this.versionFileTemplate.slice(n,a.index);t.push(this.escapeRegExp(s)),t.push("[^/]+"),n=a.index+e.length}const s=this.versionFileTemplate.slice(n);return t.push(this.escapeRegExp(s)),new RegExp(`^${t.join("")}$`,"i")}isTemplateVariablesBasename(e){return e===dn}async pickTemplateVariablesFromBackupZip(e,t){const n=e.find(e=>R.basename(e)===dn);if(!n)return null;const a=R.join(t,n),s=await H.readFile(a);return{relativePath:n,content:s,actualHash:O.createHash("sha256").update(s).digest("hex"),manifestHash:null}}async resolveBackupAnalysisFromZip(e){const t=await H.mkdtemp(R.join(D.tmpdir(),"pp-dev-backup-")),n=R.join(t,"backup.zip"),a=R.join(t,"extracted");try{await H.mkdir(a,{recursive:!0}),await H.writeFile(n,e),await Vt(n,a);const t=await this.normalizeExtractedRootDir(a),s=(await this.listFilesRecursive(t)).map(e=>this.pathToPosix(R.relative(t,e))),o=s.filter(e=>R.basename(e)===cn);if(o.length>1)throw new Error(`Expected at most one ${cn} in backup ZIP, found ${o.length}`);const r=1===o.length?o[0]:null;let l=null;if(r){const e=R.join(t,r);if(l=JSON.parse(await H.readFile(e,"utf-8")),!(l&&"pp-dev-build-manifest"===l.manifestType&&"string"==typeof l.versionFile&&l.versionFile.trim()&&"string"==typeof l.versionFileTemplate&&l.versionFileTemplate.trim()&&"string"==typeof l.buildFingerprint&&l.buildFingerprint.trim()))throw new Error("Build manifest is missing required fields")}let c=null;if(l){const e=this.pathToPosix(l.versionFile.trim()),t=s.filter(t=>t===e),n=s.filter(t=>R.basename(t)===R.basename(e)),a=t.length>0?t:n;if(a.length>1)throw new Error(`Build manifest version file "${l.versionFile}" must resolve to exactly one file in backup ZIP, found ${a.length}`);if(1===a.length&&(c=a[0]),!0===l.compat?.versionFileRequired&&null===c)throw new Error(`Build manifest requires version file "${l.versionFile}", but it is missing in backup ZIP`)}else{const e=this.versionFileTemplateMatcher(),t=s.filter(t=>e.test(R.basename(t)));if(t.length>1)throw new Error(`Expected exactly one version file matching template "${this.versionFileTemplate}" in backup ZIP, found ${t.length}`);1===t.length&&(c=t[0])}const d={};let u=null,p=null;const m={};let h=[];if(c){const e=R.join(t,c),n=JSON.parse(await H.readFile(e,"utf-8"));if(!n?.files||"object"!=typeof n.files)throw new Error("VERSION manifest files map is missing or invalid");if("string"!=typeof n.checksum||!n.checksum)throw new Error("VERSION manifest checksum is missing or invalid");p=n.checksum;for(const[e,t]of Object.entries(n.files)){if("string"!=typeof t||!t.trim())throw new Error(`VERSION manifest contains invalid hash for file: ${e}`);const n=this.normalizeBackupManifestPath(e);if(Object.prototype.hasOwnProperty.call(d,n))throw new Error(`VERSION manifest contains duplicate normalized file path: ${n}`);d[n]=t}if(this.buildManifestChecksum(d)!==n.checksum)throw new Error("VERSION manifest checksum does not match its files map");for(const[e,n]of Object.entries(d)){const a=R.join(t,e),s=await H.readFile(a).catch(()=>null);if(!s)throw new Error(`VERSION manifest references missing file: ${e}`);const o=O.createHash("sha256").update(s).digest("hex");m[e]=o,o!==n&&h.push({relativePath:e,expected:n,actual:o}),this.isTemplateVariablesBasename(R.basename(e))&&(u={relativePath:e,content:s,actualHash:o,manifestHash:n})}h.length>0&&this.logger.warn(i.yellow(`[DistService] VERSION manifest hash mismatch for ${h.length} file(s) (confirm in UI): ${h.map(e=>e.relativePath).join(", ")}`))}const g=new Set(h.map(e=>e.relativePath)),f={},v=[];for(const e of s){if(e===r)continue;if(c&&e===c)continue;if(c&&Object.prototype.hasOwnProperty.call(f,e))continue;const n=R.join(t,e),a=await H.readFile(n),s=O.createHash("sha256").update(a).digest("hex");c&&Object.prototype.hasOwnProperty.call(d,e)?f[e]=g.has(e)?m[e]:d[e]:c?(f[e]="__NOT_IN_VERSION_FILE__",v.push(e)):f[e]=s}u||(u=await this.pickTemplateVariablesFromBackupZip(s,t));const b=this.buildBackupFingerprint(f);let y=null;return l&&c&&b!==l.buildFingerprint&&(y={expected:l.buildFingerprint,actual:b},this.logger.warn(i.yellow(`[DistService] Build manifest fingerprint mismatch (confirm in UI): expected ${l.buildFingerprint}, got ${b}`))),{backupFingerprint:b,unknownFiles:v,templateVariables:u,mainHash:p,allFilesInMeta:Boolean(c&&0===v.length),buildManifestMismatch:y,versionManifestHashMismatches:h}}finally{await H.rm(t,{recursive:!0,force:!0})}}async analyzeBackup(e){if(!sn.equals(e.subarray(0,sn.length)))throw new Error("Backup file is not a ZIP file");return await this.resolveBackupAnalysisFromZip(e)}async getPublicTemplateVariablesHash(){const e=R.resolve(this.root,"public",dn),t=await H.readFile(e).catch(()=>null);return t?O.createHash("sha256").update(t).digest("hex"):null}async readPublicTemplateVariablesFile(){const e=R.resolve(this.root,"public",dn);return await H.readFile(e).catch(()=>null)}async saveTemplateVariablesFile(e){const t=R.resolve(this.root,"public",dn);return await H.mkdir(R.dirname(t),{recursive:!0}),await H.writeFile(t,e),t}async saveBackup(e,t){const n=t??await this.analyzeBackup(e),a=n.backupFingerprint,s=await this.getLatestSavedBackup(),o=await this.meta();if(s){const{lastBackupHash:e,lastMainHash:t}=o;if(e===a)return void this.logger.info(i.yellow(`[DistService] Backup skipped: same backup fingerprint as last saved (${a.slice(0,12)}...)`));if(n.allFilesInMeta&&n.mainHash&&t===n.mainHash)return void this.logger.info(i.yellow("[DistService] Backup skipped: same VERSION manifest checksum (main hash) and all files listed in VERSION"))}const r=new Date,l=this.backupName(this.pageName,r);o.lastBackupName=l,o.lastBackupHash=a,o.lastBackupDate=r.toISOString(),n.mainHash?o.lastMainHash=n.mainHash:delete o.lastMainHash,await this.syncMeta(),await H.writeFile(R.resolve(this.backupFolder,l),e),this.logger.info(`Backup saved to ${l}`),await this.pruneBackups(l)}async buildNewAssets(){if(this.nextBuild)return await this.buildNextAssets();this.logger.info(i.cyan("[DistService] Build started"));const e=Xt("pp-dev build","node",[R.resolve(an,"./bin/pp-dev.js"),"build"],{cwd:this.root,env:Object.assign({},M.env,{NODE_ENV:"production"}),timeoutMs:this.buildTimeoutMs,signal:this.builds.signal});try{if(await this.logBuild("Build",e),this.buildInputFolder){const e=R.resolve(this.root,this.buildInputFolder);if(!await this.isDirectory(e))throw new Error(`Build input directory ${e} not found`);const t=await Ft(e);return this.throwIfStopped("pp-dev build"),t}const t=R.resolve(this.root,this.distZipFolder,this.distZipFilename);let n;try{n=await H.readFile(t)}catch(e){if("ENOENT"===e.code)throw new Error(`File ${t} not found`);throw e}return this.throwIfStopped("pp-dev build"),n}catch(e){throw this.logger.error(e instanceof Error?e.stack??e.message:String(e)),e}}async saveBackupAndBuild(e){return await this.saveBackup(e),await this.buildNewAssets()}async buildNextAssets(){const{projectRoot:e,distDir:t}=this.nextBuild;this.logger.info(i.cyan("[DistService] Next.js build started")),await this.logBuild("Next.js build",Qt(e,{timeoutMs:this.buildTimeoutMs,signal:this.builds.signal}));const n=R.resolve(e,t);if(!await this.isDirectory(n))throw new Error(`Next.js build output directory not found: ${n}`);this.throwIfStopped("next build"),await x({outDir:n,packageVersion:this.nextBuild.packageVersion,versionFileTemplate:this.versionFileTemplate,packageRepositoryUrl:this.nextBuild.packageRepositoryUrl,packageBranchName:this.nextBuild.packageBranchName}),this.throwIfStopped("next build");const a=await Ft(n);return this.throwIfStopped("next build"),a}get stopped(){return this.builds.signal.aborted}throwIfStopped(e){if(this.stopped)throw new Error(`${e} was stopped`)}async logBuild(e,t){try{await t}catch(t){const n=this.builds.signal.aborted;throw this.logger.info(i.yellow(`[DistService] ${e} ${n?"stopped":"failed"}`)),t}this.logger.info(i.cyan(`[DistService] ${e} finished`))}async isDirectory(e){try{return(await H.stat(e)).isDirectory()}catch{return!1}}}class pn{server;opts;eventMap;logger;syncInFlight=null;syncActionResolvers=new Map;constructor(e,t){this.server=e,this.opts=t||{},this.eventMap=new Map,this.eventMap.set("info-data:request",this.onInfoDataRequest.bind(this)),this.eventMap.set("template:sync",this.onTemplateSync.bind(this)),this.eventMap.set("template:sync:action-response",this.onTemplateSyncActionResponse.bind(this)),this.eventMap.set("page-variables:reload",this.onPageVariablesReload.bind(this)),this.logger=r(),this.init()}init(){const{ws:e}=this.server;for(const[t,n]of this.eventMap)e.on(t,n);e.on("error",()=>{this.clearAllPendingSyncActions(!1)})}dispose(){this.clearAllPendingSyncActions(!1),this.opts.distService?.stopBuilds()}resolveSyncAction(e,t){const n=this.syncActionResolvers.get(e);n&&(clearTimeout(n.timeoutId),n.cleanup(),this.syncActionResolvers.delete(e),n.resolve(t))}clearAllPendingSyncActions(e){for(const t of[...this.syncActionResolvers.keys()])this.resolveSyncAction(t,e)}sendSyncResponse(e,t){e.send("template:sync:response",t)}onInfoDataRequest(e,t){t.send("info-data:response",{})}onTemplateSyncActionResponse(e){e&&"string"==typeof e.requestId&&"boolean"==typeof e.approved&&this.resolveSyncAction(e.requestId,e.approved)}async requestSyncAction(e,t){const n=N(),a=this.opts.syncActionTimeoutMs??12e4;return await new Promise(s=>{const o=()=>this.resolveSyncAction(n,!1),r=setTimeout(o,a),{socket:i}=t;i?.once("close",o),this.syncActionResolvers.set(n,{resolve:s,timeoutId:r,cleanup:()=>i?.off("close",o)}),i&&i.readyState!==i.OPEN?o():t.send("template:sync:action-required",{...e,requestId:n})})}async onTemplateSync(e,t){if(this.syncInFlight)return this.logger.warn(i.yellow("Template sync requested while another one is running; ignored")),void this.sendSyncResponse(t,{error:"A template sync is already running. Wait for it to finish, or answer its dialog in the tab that started it.",code:"SYNC_IN_PROGRESS"});const n=this.runTemplateSync(e,t);this.syncInFlight=n;try{await n}finally{this.syncInFlight=null}}async runTemplateSync(e,t){if(!this.opts.distService||!this.opts.miAPI)return this.sendSyncResponse(t,{error:"Dist service or MiAPI is not defined"}),void this.logger.error(i.red("Dist service or MiAPI is not defined"));{const{distService:e,miAPI:n}=this.opts;try{if(this.server.config.clientInjectionPlugin?.v7Features){if(!n?.isV710OrHigher)return void this.sendSyncResponse(t,{error:"This feature is available only for MI v7.1.0 or higher",config:{canSync:!1}});t.send("client:config:update",{config:{canSync:!0}})}const a=await(n?.getAssets().catch(async e=>{if(v(e)){if("SESSION_EXPIRED"===Se(e).code){this.logger.info(i.yellow("Session expired - attempting to validate credentials"));try{const e=await(n?.validateCredentials());e&&!e.isValid?(this.logger.error(i.red(`Authentication error: ${e.error}`)),this.sendSyncResponse(t,{error:e.error??"Authentication failed",code:e.code,refresh:!0})):(this.logger.info(i.yellow("Session expired")),this.sendSyncResponse(t,{error:"Session expired",code:"SESSION_EXPIRED",refresh:!0}))}catch(e){this.logger.info(i.yellow("Session expired")),this.sendSyncResponse(t,{error:"Session expired",code:"SESSION_EXPIRED",refresh:!0})}return e}if(e.cause instanceof Error&&("ECONNRESET"===e.cause.code||"ENOTFOUND"===e.cause.code))return this.logger.info(i.yellow("Server in maintenance mode, VPN connection is needed or no internet connection")),this.sendSyncResponse(t,{error:"Server in maintenance mode, VPN connection is needed or no internet connection",code:"CONNECTION_ERROR"}),e;const a=e.response?.status;if(502===a||503===a||504===a)return this.logger.info(i.yellow(`Server in maintenance mode or unreachable (HTTP ${a}); VPN may be required`)),this.sendSyncResponse(t,{error:"Server in maintenance mode, VPN connection is needed or no internet connection",code:"CONNECTION_ERROR"}),e}if(de(e))return this.logger.info(i.yellow("Server in maintenance mode or non-JSON page / page-template API response (unavailable HTML body)")),this.sendSyncResponse(t,{error:"Server in maintenance mode, VPN connection is needed or no internet connection",code:"CONNECTION_ERROR"}),e;throw e}));if(a instanceof Error)return;let s;if(a){const n=await(e?.analyzeBackup(a).catch(e=>"Backup file is not a ZIP file"===e.message?(this.logger.error(i.red("Backup file is not a ZIP file")),e):e));if(n instanceof Error)s=n;else if(n){await(e?.saveBackup(a,n));const o=0===n.unknownFiles.length&&1===n.versionManifestHashMismatches.length&&R.basename(n.versionManifestHashMismatches[0].relativePath)===dn,r=n.versionManifestHashMismatches,l=r.filter(e=>R.basename(e.relativePath)===dn),c=r.filter(e=>R.basename(e.relativePath)!==dn),d=!o&&l.length>0&&c.length>0,u=o?[]:c.map(e=>e.relativePath),p=async()=>{if(!n.templateVariables||!e)return;const{templateVariables:a}=n;if(await e.getPublicTemplateVariablesHash()!==a.actualHash){await this.requestSyncAction({title:"Template variables (server backup)",content:"The server backup includes __template_variables.json. It differs from your local public/__template_variables.json (or that file is missing). Replace your project copy with the server backup?",confirmText:"Replace from server",cancelText:"Keep local"},t)&&await e.saveTemplateVariablesFile(a.content)}};if(n.unknownFiles.length>0){if(!await this.requestSyncAction({title:"Unknown files found in backup",content:`Backup contains files not listed in VERSION: ${n.unknownFiles.join(", ")}. Continue sync or cancel?`,confirmText:"Continue sync",cancelText:"Cancel sync"},t))return this.sendSyncResponse(t,{cancelled:!0,message:"Sync cancelled by user. Backup was saved."}),void this.logger.info(i.yellow("Sync cancelled by user"))}if(d&&await p(),!o&&u.length>0){const e=40,n=u,a=n.slice(0,e),s=n.length-a.length,o=a.map(e=>`• ${e}`).join("\n"),r=s>0?`\n... and ${s} more file${1===s?"":"s"}`:"";if(!await this.requestSyncAction({title:"VERSION manifest out of date",content:`These files no longer match the hashes recorded in the VERSION manifest:\n\n${o}${r}\n\nCancel sync, or override and continue using the files on disk?`,confirmText:"Override and continue",cancelText:"Cancel sync"},t))return this.sendSyncResponse(t,{cancelled:!0,message:"Sync cancelled by user. Backup was saved."}),void this.logger.info(i.yellow("Sync cancelled by user"))}if(!o&&n.buildManifestMismatch&&0===n.versionManifestHashMismatches.length){const{expected:e,actual:a}=n.buildManifestMismatch;if(!await this.requestSyncAction({title:"Build manifest fingerprint mismatch",content:`The recomputed backup fingerprint does not match BUILD-MANIFEST.json.\nManifest: ${e.slice(0,12)}...\nComputed: ${a.slice(0,12)}...\nContinue sync anyway?`,confirmText:"Continue sync",cancelText:"Cancel sync"},t))return this.sendSyncResponse(t,{cancelled:!0,message:"Sync cancelled by user. Backup was saved."}),void this.logger.info(i.yellow("Sync cancelled by user"))}d||await p(),s=await(e?.buildNewAssets())}}else s=await(e?.buildNewAssets());if(!(s&&s instanceof Buffer))return s instanceof Error?(this.sendSyncResponse(t,{error:s.message}),void this.logger.error(i.red(s.message))):(this.sendSyncResponse(t,{error:"Failed to build new assets"}),void this.logger.error(i.red("Failed to build new assets")));{if(e.stopped)return this.sendSyncResponse(t,{error:"Sync stopped: the dev server is shutting down."}),void this.logger.warn(i.yellow("Sync stopped before the upload: the dev server is shutting down"));const a=await(n?.updateAssets(s));if("OK"===a?.status){const n=e?.getBackupMeta(),a=n?.lastBackupDate?new Date(n.lastBackupDate):new Date;this.sendSyncResponse(t,{syncedAt:(Number.isNaN(a.getTime())?new Date:a).toISOString(),currentHash:n?.lastBackupHash??"",backupFilename:n?.lastBackupName??""}),this.logger.info(i.green("Template synced"))}else this.sendSyncResponse(t,{error:"Failed to update assets"}),this.logger.error(i.red("Failed to update assets"))}}catch(e){const n=e instanceof Error?e.message:"Template sync failed";return this.logger.error(i.red(`Template sync failed: ${n}`)),void this.sendSyncResponse(t,{error:n,code:"SYNC_FAILED"})}}}async onPageVariablesReload(e,t){const{miAPI:n}=this.opts;if(!n)return t.send("page-variables:reload:response",{error:"MiAPI is not defined"}),void this.logger.error(i.red("MiAPI is not defined"));try{const e=await n.reloadPageVariables();t.send("page-variables:reload:response",{ok:!0,count:e?e.length:0,skipped:null===e}),this.logger.info(i.green("Page variables reloaded"))}catch(e){const n=e instanceof Error?e.message:"Reload variables failed";this.logger.error(i.red(`Reload variables failed: ${n}`)),t.send("page-variables:reload:response",{error:n})}}}function mn(e){return(e.requestBody?.byteLength??0)+(e.responseBody?.byteLength??0)+512}function hn(e){return{id:e.id,timestamp:e.timestamp,method:e.method,url:e.url,statusCode:e.statusCode,duration:e.duration,source:e.source,user:e.user,requestSize:e.requestBody?.byteLength??0,responseSize:e.responseBody?.byteLength??0,requestBodyTruncated:e.requestBodyTruncated,responseBodyTruncated:e.responseBodyTruncated}}class gn{entries=new Map;insertOrder=[];head=0;totalSize=0;maxMemory;maxEntries;constructor(e=1073741824,t=1e4){this.maxMemory=e,this.maxEntries=t}evictOldest(){const e=this.insertOrder[this.head++],t=this.entries.get(e);t&&(this.totalSize-=mn(t),this.entries.delete(e)),this.head>=1024&&2*this.head>=this.insertOrder.length&&(this.insertOrder=this.insertOrder.slice(this.head),this.head=0)}allocateId(){return T()}add(e){let t=e,n=(e.requestBody?.byteLength??0)+(e.responseBody?.byteLength??0);n>this.maxMemory&&(t={...e,requestBody:null,responseBody:null,requestBodyTruncated:e.requestBodyTruncated||null!==e.requestBody,responseBodyTruncated:e.responseBodyTruncated||null!==e.responseBody},n=0);const a=mn(t);for(;(this.totalSize+a>this.maxMemory||this.entries.size>=this.maxEntries)&&this.head<this.insertOrder.length;)this.evictOldest();this.entries.set(t.id,t),this.insertOrder.push(t.id),this.totalSize+=a}get(e){return this.entries.get(e)}list(e){const t=e?.method?.toUpperCase(),n=e?.search?.toLowerCase(),a=e=>(!t||e.method===t)&&(!n||e.url.toLowerCase().includes(n)),s=Number.isFinite(e?.limit)?e.limit:1/0,o=[];if(s<=0)return o;if(void 0===e?.offset){for(let e=this.insertOrder.length-1;e>=this.head&&o.length<s;e-=1){const t=this.entries.get(this.insertOrder[e]);t&&a(t)&&o.push(hn(t))}return o.reverse()}let r=Number.isFinite(e.offset)?Math.max(0,e.offset):0;for(let e=this.head;e<this.insertOrder.length&&o.length<s;e+=1){const t=this.entries.get(this.insertOrder[e]);t&&a(t)&&(r>0?r-=1:o.push(hn(t)))}return o}clear(){this.entries.clear(),this.insertOrder=[],this.head=0,this.totalSize=0}get size(){return this.entries.size}get memoryUsage(){return this.totalSize}}const fn="<redacted>",vn=new RegExp(`(^|;\\s*)(${fe}=)[^;]*`,"gi"),bn=new RegExp(`^(\\s*${fe}=)[^;]*`,"i");function yn(e){const t={...e};if("string"==typeof t.authorization){const[e,n]=t.authorization.split(/\s+/,2);t.authorization=void 0===n?fn:`${e} ${fn}`}return void 0!==t.token&&(t.token=fn),"string"==typeof t.cookie&&(t.cookie=t.cookie.replace(vn,`$1$2${fn}`)),t}function wn(e){const t={...e},n=t["set-cookie"],a=e=>e.replace(bn,`$1${fn}`);return Array.isArray(n)?t["set-cookie"]=n.map(a):"string"==typeof n&&(t["set-cookie"]=a(n)),t}const xn=/^\/(?:login|auth)(?:[/?#]|$)/i,kn=/pass(?:word|wd|code)?|pwd|secret|token|otp|mfa|2fa/i;function Sn(e,t){return Array.isArray(e)?e.map(e=>Sn(e,t)):e&&"object"==typeof e?Object.fromEntries(Object.entries(e).map(([e,n])=>kn.test(e)?(t.any=!0,[e,fn]):[e,Sn(n,t)])):e}function In(e,t,n){if(!n)return n;if(xn.test(e))return Buffer.from(fn);const a=(t??"").split(";",1)[0].trim().toLowerCase();try{if("application/x-www-form-urlencoded"===a){const e=new URLSearchParams(n.toString("utf8")),t=[...new Set(e.keys())].filter(e=>kn.test(e));return t.forEach(t=>e.set(t,fn)),t.length?Buffer.from(e.toString()):n}if("application/json"===a||a.endsWith("+json")){const e={any:!1},t=Sn(JSON.parse(n.toString("utf8")),e);return e.any?Buffer.from(JSON.stringify(t)):n}}catch{}return n}const En=["/@","/_next/","/__nextjs_"],Tn=new Set(["/favicon.ico","/installHook.js.map"]);function An(e,t=10485760,n=je){return(a,s,o)=>{const r=a.url||"/",i=r.split("?")[0];if(En.some(e=>r.startsWith(e))||Tn.has(i))return void o();const l=e.allocateId(),c=n.resolve(a)?.user?.name,d=Date.now(),u=[];let p=0,m=!1;const h=a.emit.bind(a);a.emit=(e,...n)=>{if("data"===e&&!m){const e=n[0],a=Buffer.isBuffer(e)?e:Buffer.from(String(e));p+=a.byteLength,p<=t?u.push(a):(m=!0,u.length=0)}return h(e,...n)};const g=[];let f=0,v=!1;const b=s.write.bind(s),y=s.end.bind(s);function w(e,n){if(!v&&null!=e)try{const a=Buffer.isBuffer(e)?e:"string"==typeof e?Buffer.from(e,n):ArrayBuffer.isView(e)?Buffer.from(e.buffer,e.byteOffset,e.byteLength):e instanceof ArrayBuffer?Buffer.from(e):Buffer.from(String(e));f+=a.byteLength,f<=t?g.push(a):(v=!0,g.length=0)}catch{}}function x(e){return"string"==typeof e[0]?e[0]:void 0}function k(){const e=s.getHeaders();return e["x-pp-proxy"]?"hit"===e["x-pp-cache"]?"proxy-cache":"proxy":"local"}let S=!1;s.write=function(e,...t){return w(e,x(t)),b(e,...t)},s.end=function(t,...n){return"function"!=typeof t&&w(t,x(n)),function(){if(S)return;S=!0;const t=In(r,"string"==typeof a.headers["content-type"]?a.headers["content-type"]:void 0,!m&&u.length>0?Buffer.concat(u):null),n=!v&&g.length>0?Buffer.concat(g):null;e.add({id:l,timestamp:d,method:a.method||"GET",url:r,statusCode:s.statusCode??null,duration:Date.now()-d,source:k(),user:c,requestHeaders:yn(a.headers),requestBody:t,requestBodyTruncated:m,responseHeaders:wn(s.getHeaders()),responseBody:n,responseBodyTruncated:v})}(),y(t,...n)},o()}}const Cn="/@pp-dev/inspector",Ln=new WeakMap;function $n(e){const t=Array.isArray(e)?e[0]:e;return"string"==typeof t?t:void 0}function Rn(e,t,n=10485760){const a=Ln.get(e);if(a)return a.store=t,void(a.captureLimit=n);const s={store:t,captureLimit:n};Ln.set(e,s),e.get("/@api/requests",(e,t)=>{const{store:n}=s,a=$n(e.query.limit),o=$n(e.query.offset),r=$n(e.query.method),i=$n(e.query.search),l=void 0!==a?parseInt(a,10):void 0,c=n.list({limit:Number.isFinite(l)?l:500,offset:void 0!==o&&Number.isFinite(parseInt(o,10))?parseInt(o,10):void 0,method:r,search:i});t.json({requests:c,total:n.size,memoryUsage:n.memoryUsage,maxMemory:n.maxMemory})}),e.get("/@api/requests/stats",(e,t)=>{const{store:n}=s;t.json({total:n.size,memoryUsage:n.memoryUsage,maxMemory:n.maxMemory,memoryUsageFormatted:Nn(n.memoryUsage),maxMemoryFormatted:Nn(n.maxMemory)})}),e.get("/@api/requests/:id",(e,t)=>{const n=s.store.get(e.params.id);if(!n)return void t.status(404).json({error:"Not found",id:e.params.id});const a=On(n.requestHeaders["content-type"]),o=On(n.responseHeaders["content-type"]);t.json({id:n.id,timestamp:n.timestamp,method:n.method,url:n.url,statusCode:n.statusCode,duration:n.duration,source:n.source,user:n.user,requestHeaders:n.requestHeaders,requestBody:n.requestBody?n.requestBody.toString("base64"):null,requestBodySize:n.requestBody?.byteLength??0,requestBodyTruncated:n.requestBodyTruncated,requestContentType:a,responseHeaders:n.responseHeaders,responseBody:n.responseBody?n.responseBody.toString("base64"):null,responseBodySize:n.responseBody?.byteLength??0,responseBodyTruncated:n.responseBodyTruncated,responseContentType:o})}),e.delete("/@api/requests",(e,t)=>{s.store.clear(),t.json({ok:!0})}),e.get(Cn,(e,t)=>{t.setHeader("Content-Type","text/html; charset=utf-8"),t.setHeader("Cache-Control","no-cache"),t.end(function(e){return`<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width,initial-scale=1">\n<title>Request Inspector — pp-dev</title>\n<script>\n// Set the stored theme override before first paint, so switching pages/reloading doesn't\n// flash the OS-default theme before settling on the user's explicit choice (see setTheme()).\ntry {\n var ppDevStoredTheme = localStorage.getItem('pp-dev-info-theme');\n\n if (ppDevStoredTheme === 'dark' || ppDevStoredTheme === 'light') {\n document.documentElement.setAttribute('data-pp-dev-theme', ppDevStoredTheme);\n }\n} catch (e) {}\n<\/script>\n<style>\n*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}\n:root,:root[data-pp-dev-theme="light"]{\n --bg:#ffffff;--bg2:#f4f5f7;--bg3:#e9ebef;--bg4:#dbdee3;\n --border:#d7dae0;--border2:#c2c6cd;\n --text:#1a1a1e;--text2:#54566b;--text3:#8a8c9c;\n --accent:#4f46e5;--accent2:#7c3aed;\n --green:#16a34a;--red:#dc2626;--yellow:#a16207;--blue:#2563eb;--orange:#c2410c;--purple:#9333ea;\n --json-text:#24292f;--json-key:#0550ae;--json-str:#0a3069;--json-num:#953800;--json-bool:#cf222e;\n --btn-primary-fg:#ffffff;\n --font-mono:'Cascadia Code','Fira Code','JetBrains Mono',Consolas,monospace;\n --font-ui:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;\n}\n:root[data-pp-dev-theme="dark"]{\n --bg:#1a1a1e;--bg2:#222228;--bg3:#2a2a32;--bg4:#32323c;\n --border:#3a3a46;--border2:#4a4a58;\n --text:#e0e0f0;--text2:#a0a0b8;--text3:#606078;\n --accent:#6e8efb;--accent2:#a78bfa;\n --green:#4ade80;--red:#f87171;--yellow:#fbbf24;--blue:#60a5fa;--orange:#fb923c;--purple:#c084fc;\n --json-text:#c9d1d9;--json-key:#79b8ff;--json-str:#9ecbff;--json-num:#f8c555;--json-bool:#f97583;\n --btn-primary-fg:#0d0d10;\n}\n@media (prefers-color-scheme: dark){\n :root{\n --bg:#1a1a1e;--bg2:#222228;--bg3:#2a2a32;--bg4:#32323c;\n --border:#3a3a46;--border2:#4a4a58;\n --text:#e0e0f0;--text2:#a0a0b8;--text3:#606078;\n --accent:#6e8efb;--accent2:#a78bfa;\n --green:#4ade80;--red:#f87171;--yellow:#fbbf24;--blue:#60a5fa;--orange:#fb923c;--purple:#c084fc;\n --json-text:#c9d1d9;--json-key:#79b8ff;--json-str:#9ecbff;--json-num:#f8c555;--json-bool:#f97583;\n --btn-primary-fg:#0d0d10;\n }\n}\nhtml,body{height:100%;overflow:hidden;background:var(--bg);color:var(--text);font-family:var(--font-ui);font-size:13px;line-height:1.5}\n\n/* ── Layout ── */\n.app{display:flex;flex-direction:column;height:100vh}\n.toolbar{display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--bg2);border-bottom:1px solid var(--border);flex-shrink:0;flex-wrap:wrap}\n.toolbar-title{font-weight:600;font-size:14px;color:var(--text);margin-right:4px}\n.toolbar-badge{font-size:11px;padding:2px 7px;border-radius:10px;background:var(--bg4);color:var(--text2)}\n.toolbar-spacer{flex:1}\n.theme-switch{display:flex;gap:2px;background:var(--bg3);border:1px solid var(--border);border-radius:4px;padding:2px}\n.theme-switch button{padding:3px 8px;border:none;border-radius:3px;background:transparent;color:var(--text2);font-size:11px;cursor:pointer;font-family:var(--font-ui)}\n.theme-switch button.active{background:var(--accent);color:var(--btn-primary-fg)}\n.mem-bar-wrap{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--text2)}\n.mem-bar{width:80px;height:6px;background:var(--bg4);border-radius:3px;overflow:hidden}\n.mem-bar-fill{height:100%;background:var(--accent);transition:width .4s}\n\n.split{display:flex;flex:1;overflow:hidden}\n.pane-list{width:380px;min-width:200px;max-width:60%;border-right:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0}\n.pane-detail{flex:1;display:flex;flex-direction:column;overflow:hidden}\n\n/* ── Filter bar ── */\n.filter-bar{display:flex;flex-wrap:wrap;gap:6px;padding:8px 10px;background:var(--bg2);border-bottom:1px solid var(--border);flex-shrink:0}\n.filter-input{flex:1 1 120px;min-width:0;background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:4px 8px;border-radius:4px;font-size:12px;outline:none}\n.filter-input:focus{border-color:var(--accent)}\n.filter-selects{display:flex;gap:6px;flex-shrink:0}\n.filter-select{background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:4px 6px;border-radius:4px;font-size:12px;outline:none;cursor:pointer;max-width:90px}\n.filter-select:focus{border-color:var(--accent)}\n\n/* ── Request list ── */\n.req-list{flex:1;overflow-y:auto;overflow-x:hidden}\n.req-list::-webkit-scrollbar{width:6px}\n.req-list::-webkit-scrollbar-track{background:var(--bg)}\n.req-list::-webkit-scrollbar-thumb{background:var(--bg4);border-radius:3px}\n.req-item{display:flex;align-items:center;gap:8px;padding:7px 10px 7px 9px;border-bottom:1px solid var(--border);border-left:3px solid transparent;cursor:pointer;user-select:none;transition:background .1s,border-left-color .1s}\n.req-item:hover{background:var(--bg3)}\n.req-item.selected{background:var(--bg4);border-left-color:var(--accent) !important}\n.req-item.src-proxy{border-left-color:var(--accent2)}\n.req-item.src-proxy-cache{border-left-color:var(--yellow)}\n.req-item.src-local{border-left-color:transparent}\n\n.method-badge{font-size:10px;font-weight:700;padding:2px 5px;border-radius:3px;min-width:38px;text-align:center;letter-spacing:.4px;flex-shrink:0}\n.m-GET{background:color-mix(in srgb, var(--green) 20%, var(--bg));color:var(--green)}\n.m-POST{background:color-mix(in srgb, var(--blue) 20%, var(--bg));color:var(--blue)}\n.m-PUT{background:color-mix(in srgb, var(--orange) 20%, var(--bg));color:var(--orange)}\n.m-PATCH{background:color-mix(in srgb, var(--yellow) 20%, var(--bg));color:var(--yellow)}\n.m-DELETE{background:color-mix(in srgb, var(--red) 20%, var(--bg));color:var(--red)}\n.m-HEAD,.m-OPTIONS{background:color-mix(in srgb, var(--purple) 20%, var(--bg));color:var(--purple)}\n.m-other{background:var(--bg4);color:var(--text2)}\n\n.req-url{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;color:var(--text)}\n.req-meta{display:flex;flex-direction:column;align-items:flex-end;gap:1px;flex-shrink:0;font-size:10px;color:var(--text3)}\n.req-status-row{display:flex;align-items:center;gap:4px}\n.source-letter{font-size:9px;font-weight:700;width:15px;height:15px;display:flex;align-items:center;justify-content:center;border-radius:2px;flex-shrink:0}\n.src-letter-proxy{background:color-mix(in srgb, var(--accent2) 20%, var(--bg));color:var(--accent2)}\n.src-letter-proxy-cache{background:color-mix(in srgb, var(--yellow) 20%, var(--bg));color:var(--yellow)}\n.src-letter-local{background:var(--bg4);color:var(--text3)}\n.status-badge{font-size:10px;font-weight:600;padding:1px 4px;border-radius:2px}\n.s-2xx{color:var(--green)}\n.s-3xx{color:var(--blue)}\n.s-4xx{color:var(--yellow)}\n.s-5xx{color:var(--red)}\n.s-pending{color:var(--text3)}\n\n.user-chip{font-size:10px;font-weight:600;padding:1px 6px;border-radius:8px;flex-shrink:0;max-width:96px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;background:color-mix(in srgb, var(--purple) 20%, var(--bg));color:var(--purple)}\n.source-chip{display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:500;padding:2px 7px;border-radius:10px}\n.src-local{background:color-mix(in srgb, var(--green) 20%, var(--bg));color:var(--green)}\n.src-proxy{background:color-mix(in srgb, var(--accent2) 20%, var(--bg));color:var(--accent2)}\n.src-proxy-cache{background:color-mix(in srgb, var(--yellow) 20%, var(--bg));color:var(--yellow)}\n\n.empty-state{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--text3);gap:8px;padding:40px}\n.empty-icon{font-size:40px;opacity:.5}\n\n/* ── Detail pane ── */\n.detail-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--text3);gap:8px}\n.detail-scroll{flex:1;overflow-y:auto;overflow-x:hidden;padding:0}\n.detail-scroll::-webkit-scrollbar{width:6px}\n.detail-scroll::-webkit-scrollbar-track{background:var(--bg)}\n.detail-scroll::-webkit-scrollbar-thumb{background:var(--bg4);border-radius:3px}\n\n.detail-section{border-bottom:1px solid var(--border)}\n.detail-section-hdr{display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--bg2);cursor:pointer;user-select:none;position:sticky;top:0;z-index:1}\n.detail-section-hdr:hover{background:var(--bg3)}\n.detail-section-hdr .toggle{font-size:10px;color:var(--text3)}\n.section-title{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;color:var(--text2)}\n.section-badge{font-size:10px;padding:1px 5px;border-radius:8px;background:var(--bg4);color:var(--text3)}\n.section-actions{display:flex;gap:4px;margin-left:auto;align-items:center}\n.section-btn{font-size:10px;padding:1px 7px;border-radius:3px;border:1px solid var(--border);background:transparent;color:var(--text3);cursor:pointer;line-height:1.6;transition:all .1s;font-family:var(--font-ui)}\n.section-btn:hover{background:var(--bg4);color:var(--text);border-color:var(--border2)}\n.detail-section-body{padding:8px 12px;font-family:var(--font-mono);font-size:12px}\n.detail-section-body.collapsed{display:none}\n\n.kv-table{width:100%;border-collapse:collapse}\n.kv-table tr:hover td{background:var(--bg3)}\n.kv-table td{padding:2px 0;vertical-align:top}\n.kv-table td:first-child{color:var(--text2);white-space:nowrap;padding-right:12px;min-width:140px;max-width:220px;overflow:hidden;text-overflow:ellipsis}\n.kv-table td:last-child{color:var(--text);word-break:break-all}\n\n.body-content{white-space:pre-wrap;word-break:break-all;color:var(--text);line-height:1.6;max-height:400px;overflow:auto}\n.body-content.json{color:var(--json-text)}\n.body-json-key{color:var(--json-key)}\n.body-json-str{color:var(--json-str)}\n.body-json-num{color:var(--json-num)}\n.body-json-bool{color:var(--json-bool)}\n.body-json-null{color:var(--json-bool);font-style:italic}\n.body-binary{color:var(--text3);font-style:italic;padding:8px 0}\n.body-image{max-width:100%;max-height:300px;object-fit:contain;margin-top:4px;border:1px solid var(--border);border-radius:4px}\n.truncated-note{font-size:11px;color:var(--yellow);margin-top:4px;font-family:var(--font-ui)}\n\n.detail-info-bar{display:flex;gap:16px;padding:10px 12px;background:var(--bg2);border-bottom:1px solid var(--border);flex-shrink:0;flex-wrap:wrap}\n.info-item{display:flex;flex-direction:column;gap:2px}\n.info-label{font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:.5px}\n.info-value{font-size:13px;font-weight:600;color:var(--text)}\n.info-url{display:flex;align-items:center;gap:8px;font-size:11px;font-family:var(--font-mono);color:var(--text2);padding:6px 12px;border-bottom:1px solid var(--border);background:var(--bg)}\n.info-url-text{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n\n/* ── Buttons ── */\n.btn{padding:4px 10px;border-radius:4px;border:1px solid var(--border);background:var(--bg3);color:var(--text);font-size:12px;cursor:pointer;transition:background .1s}\n.btn:hover{background:var(--bg4);border-color:var(--border2)}\n.btn-danger:hover{background:color-mix(in srgb, var(--red) 15%, var(--bg));border-color:color-mix(in srgb, var(--red) 45%, var(--bg));color:var(--red)}\n.btn-sm{padding:2px 7px;font-size:11px}\n\n/* ── Live indicator ── */\n.live-dot{width:7px;height:7px;border-radius:50%;background:var(--green);animation:pulse 2s infinite}\n@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}\n\n/* ── Scrollbar ── */\n::-webkit-scrollbar{width:6px;height:6px}\n::-webkit-scrollbar-track{background:var(--bg)}\n::-webkit-scrollbar-thumb{background:var(--bg4);border-radius:3px}\n</style>\n</head>\n<body>\n<div class="app">\n <div class="toolbar">\n <span class="toolbar-title">🔍 Request Inspector</span>\n <span class="live-dot" title="Live"></span>\n <span class="toolbar-badge" id="req-count">0 requests</span>\n <div class="mem-bar-wrap">\n <div class="mem-bar"><div class="mem-bar-fill" id="mem-fill" style="width:0%"></div></div>\n <span id="mem-text">0 B / 1 GB</span>\n </div>\n <div class="toolbar-spacer"></div>\n <div class="theme-switch" id="theme-switch">\n <button data-theme-choice="auto" aria-pressed="false" onclick="setTheme('auto')">Auto</button>\n <button data-theme-choice="dark" aria-pressed="false" onclick="setTheme('dark')">Dark</button>\n <button data-theme-choice="light" aria-pressed="false" onclick="setTheme('light')">Light</button>\n </div>\n <button class="btn btn-sm btn-danger" onclick="clearAll()">Clear</button>\n </div>\n <div class="split">\n <div class="pane-list">\n <div class="filter-bar">\n <input class="filter-input" id="search-input" placeholder="Filter by URL…" oninput="applyFilter()" />\n <div class="filter-selects">\n <select class="filter-select" id="method-select" onchange="applyFilter()">\n <option value="">All</option>\n <option>GET</option><option>POST</option><option>PUT</option>\n <option>PATCH</option><option>DELETE</option><option>HEAD</option>\n </select>\n <select class="filter-select" id="status-select" onchange="applyFilter()">\n <option value="">All</option>\n <option value="2">2xx</option><option value="3">3xx</option>\n <option value="4">4xx</option><option value="5">5xx</option>\n </select>\n <select class="filter-select" id="source-select" onchange="applyFilter()">\n <option value="">All</option>\n <option value="local">Local</option>\n <option value="proxy">Proxy</option>\n <option value="proxy-cache">Cached</option>\n </select>\n <select class="filter-select" id="user-select" onchange="applyFilter()" title="The user whose tab made the request" style="display:none">\n <option value="">All users</option>\n </select>\n </div>\n </div>\n <div class="req-list" id="req-list">\n <div class="empty-state">\n <div class="empty-icon">📡</div>\n <div>No requests captured yet</div>\n <div style="font-size:11px">Requests to the dev server will appear here</div>\n </div>\n </div>\n </div>\n <div class="pane-detail">\n <div class="detail-empty" id="detail-empty">\n <div style="font-size:32px;opacity:.3">←</div>\n <div>Select a request to inspect</div>\n </div>\n <div id="detail-panel" style="display:none;flex:1;flex-direction:column;overflow:hidden">\n <div class="detail-info-bar" id="detail-info"></div>\n <div class="info-url" id="detail-url"></div>\n <div class="detail-scroll" id="detail-scroll"></div>\n </div>\n </div>\n </div>\n</div>\n<script>\n(function(){\n'use strict';\n\n// ── Theme (Auto/Dark/Light) ──────────────────────────────────────────────────\n// The <head> inline script already set data-pp-dev-theme before first paint; this just keeps\n// it in sync with future picks and syncs the toolbar buttons' state. Shares its localStorage\n// key and attribute name with the dev panel and the Variables Editor, so picking a theme in\n// any of them carries over to the others.\nconst THEME_STORAGE_KEY = 'pp-dev-info-theme';\n\nfunction getStoredTheme() {\n try { return localStorage.getItem(THEME_STORAGE_KEY) || 'auto'; } catch (e) { return 'auto'; }\n}\n\nfunction applyTheme(theme) {\n if (theme === 'dark' || theme === 'light') {\n document.documentElement.setAttribute('data-pp-dev-theme', theme);\n } else {\n document.documentElement.removeAttribute('data-pp-dev-theme');\n }\n\n const switchEl = document.getElementById('theme-switch');\n\n if (switchEl) {\n Array.prototype.forEach.call(switchEl.children, (btn) => {\n const isActive = btn.dataset.themeChoice === theme;\n\n btn.classList.toggle('active', isActive);\n btn.setAttribute('aria-pressed', String(isActive));\n });\n }\n}\n\nfunction setTheme(theme) {\n try { localStorage.setItem(THEME_STORAGE_KEY, theme); } catch (e) {}\n\n applyTheme(theme);\n}\n\napplyTheme(getStoredTheme());\nwindow.setTheme = setTheme;\n\nconst CAPTURE_LIMIT = ${e};\n\nlet allRequests = [];\nlet filteredRequests = [];\nlet selectedId = null;\nlet currentEntry = null;\n\nconst listEl = document.getElementById('req-list');\nconst detailEmpty = document.getElementById('detail-empty');\nconst detailPanel = document.getElementById('detail-panel');\nconst detailInfo = document.getElementById('detail-info');\nconst detailUrl = document.getElementById('detail-url');\nconst detailScroll = document.getElementById('detail-scroll');\n\n// ── Fetch request list ────────────────────────────────────────────────────────\nasync function fetchList() {\n try {\n const r = await fetch('/@api/requests');\n const data = await r.json();\n\n allRequests = data.requests || [];\n updateStats(data);\n updateUserOptions();\n applyFilter();\n\n if (selectedId && !allRequests.find(r => r.id === selectedId)) {\n selectedId = null;\n showDetailEmpty();\n }\n } catch(e) {}\n}\n\nfunction updateStats(data) {\n const pct = data.maxMemory > 0 ? Math.min(100, (data.memoryUsage / data.maxMemory) * 100) : 0;\n\n document.getElementById('req-count').textContent = data.total + ' request' + (data.total !== 1 ? 's' : '');\n document.getElementById('mem-fill').style.width = pct + '%';\n document.getElementById('mem-text').textContent = formatBytes(data.memoryUsage) + ' / ' + formatBytes(data.maxMemory);\n}\n\n// ── Filter ────────────────────────────────────────────────────────────────────\n// Value of the user filter for requests sent as the original user (a saved user's name can't\n// start with "@").\nconst ORIGINAL_USER = '@original';\n\n// The user filter only shows up once a request was sent as a saved user.\nfunction updateUserOptions() {\n const select = document.getElementById('user-select');\n const names = Array.from(new Set(allRequests.map(r => r.user).filter(Boolean))).sort();\n const current = select.value;\n\n select.style.display = names.length ? '' : 'none';\n select.innerHTML = '<option value="">All users</option><option value="' + ORIGINAL_USER + '">Original</option>' +\n names.map(n => '<option value="' + esc(n) + '">' + esc(n) + '</option>').join('');\n select.value = current === ORIGINAL_USER || names.includes(current) ? current : '';\n}\n\nfunction applyFilter() {\n const search = document.getElementById('search-input').value.toLowerCase();\n const method = document.getElementById('method-select').value;\n const status = document.getElementById('status-select').value;\n const source = document.getElementById('source-select').value;\n const user = document.getElementById('user-select').value;\n\n filteredRequests = allRequests.filter(r => {\n if (user && (r.user || ORIGINAL_USER) !== user) {\n return false;\n }\n\n if (method && r.method !== method) {\n return false;\n }\n\n if (status && !String(r.statusCode || '').startsWith(status)) {\n return false;\n }\n\n if (source && r.source !== source) {\n return false;\n }\n\n if (search && !r.url.toLowerCase().includes(search)) {\n return false;\n }\n\n return true;\n });\n\n renderList();\n}\n// The filter inputs call it from their inline handlers.\nwindow.applyFilter = applyFilter;\n\n// ── Render list ───────────────────────────────────────────────────────────────\nfunction isAtBottom() {\n return listEl.scrollTop + listEl.clientHeight >= listEl.scrollHeight - 16;\n}\n\nfunction renderList() {\n const atBottom = isAtBottom();\n\n if (!filteredRequests.length) {\n listEl.innerHTML = '<div class="empty-state"><div class="empty-icon">🔍</div><div>No matching requests</div></div>';\n\n return;\n }\n\n const html = filteredRequests.map(r => {\n const sel = r.id === selectedId ? ' selected' : '';\n const mClass = methodClass(r.method);\n const sClass = statusClass(r.statusCode);\n const dur = r.duration != null ? formatDur(r.duration) : '…';\n const size = formatBytes((r.responseSize || 0));\n const srcCls = 'src-' + (r.source || 'local');\n const sLetter = sourceLetter(r.source);\n const sLetterCls = 'src-letter-' + (r.source || 'local').replace(/-/g, '-');\n\n return \`<div class="req-item \${srcCls}\${sel}" data-id="\${esc(r.id)}" onclick="selectRequest('\${esc(r.id)}')">\n <span class="method-badge \${mClass}">\${esc(r.method)}</span>\n \${r.user ? \`<span class="user-chip" title="From the tab of user \${esc(r.user)}">\${esc(r.user)}</span>\` : ''}\n <span class="req-url" title="\${esc(r.url)}">\${esc(r.url)}</span>\n <span class="req-meta">\n <span class="req-status-row">\n <span class="source-letter \${sLetterCls}">\${sLetter}</span>\n <span class="status-badge \${sClass}">\${r.statusCode || '…'}</span>\n </span>\n <span>\${dur}</span>\n <span>\${size}</span>\n </span>\n </div>\`;\n }).join('');\n\n listEl.innerHTML = html;\n\n if (atBottom) {\n listEl.scrollTop = listEl.scrollHeight;\n }\n}\n\n// ── Select request ────────────────────────────────────────────────────────────\nasync function selectRequest(id) {\n selectedId = id;\n\n renderList();\n\n detailEmpty.style.display = 'none';\n detailPanel.style.display = 'flex';\n detailPanel.style.flexDirection = 'column';\n detailPanel.style.overflow = 'hidden';\n detailScroll.innerHTML = '<div style="padding:20px;color:var(--text3)">Loading…</div>';\n\n try {\n const r = await fetch('/@api/requests/' + encodeURIComponent(id));\n\n if (!r.ok) {\n showDetailEmpty();\n\n return;\n }\n\n const entry = await r.json();\n\n renderDetail(entry);\n } catch(e) {\n showDetailEmpty();\n }\n}\nwindow.selectRequest = selectRequest;\n\nfunction showDetailEmpty() {\n detailPanel.style.display = 'none';\n detailEmpty.style.display = 'flex';\n}\n\nfunction renderDetail(e) {\n currentEntry = e;\n\n const sClass = statusClass(e.statusCode);\n const src = sourceInfo(e.source);\n\n detailInfo.innerHTML = \`\n <div class="info-item"><div class="info-label">Method</div><div class="info-value"><span class="method-badge \${methodClass(e.method)}">\${esc(e.method)}</span></div></div>\n <div class="info-item"><div class="info-label">Status</div><div class="info-value"><span class="status-badge \${sClass}">\${e.statusCode || '—'}</span></div></div>\n <div class="info-item"><div class="info-label">Source</div><div class="info-value"><span class="source-chip \${src.cls}">\${src.title}</span></div></div>\n <div class="info-item"><div class="info-label">User</div><div class="info-value">\${e.user ? \`<span class="user-chip">\${esc(e.user)}</span>\` : 'Original'}</div></div>\n <div class="info-item"><div class="info-label">Duration</div><div class="info-value">\${e.duration != null ? formatDur(e.duration) : '—'}</div></div>\n <div class="info-item"><div class="info-label">Request</div><div class="info-value">\${formatBytes(e.requestBodySize||0)}</div></div>\n <div class="info-item"><div class="info-label">Response</div><div class="info-value">\${formatBytes(e.responseBodySize||0)}</div></div>\n <div class="info-item"><div class="info-label">Time</div><div class="info-value" style="font-size:11px">\${new Date(e.timestamp).toLocaleTimeString()}</div></div>\n \`;\n\n detailUrl.innerHTML = \`<span class="info-url-text" title="\${esc(e.url)}">\${esc(e.url)}</span><button class="section-btn" onclick="copyUrl(this)">Copy link</button>\`;\n\n const sections = [\n makeHeadersSection('Request Headers', e.requestHeaders, 'request'),\n makeBodySection('Request Body', e.requestBody, e.requestContentType, e.requestBodyTruncated, e.requestBodySize, 'request'),\n makeHeadersSection('Response Headers', e.responseHeaders, 'response'),\n makeBodySection('Response Body', e.responseBody, e.responseContentType, e.responseBodyTruncated, e.responseBodySize, 'response'),\n ];\n\n detailScroll.innerHTML = sections.join('');\n}\n\nfunction makeHeadersSection(title, headers, type) {\n const entries = Object.entries(headers || {}).filter(([,v]) => v != null);\n const rows = entries.map(([k,v]) => \`<tr><td>\${esc(k)}</td><td>\${esc(Array.isArray(v) ? v.join(', ') : String(v))}</td></tr>\`).join('');\n const actionsHtml = entries.length ? \`<div class="section-actions"><button class="section-btn" onclick="event.stopPropagation();copyHeaders('\${type}',this)">Copy</button></div>\` : '';\n\n return \`<div class="detail-section">\n <div class="detail-section-hdr" onclick="toggleSection(this)">\n <span class="toggle">▾</span>\n <span class="section-title">\${title}</span>\n <span class="section-badge">\${entries.length}</span>\n \${actionsHtml}\n </div>\n <div class="detail-section-body">\n <table class="kv-table">\${rows}</table>\n </div>\n </div>\`;\n}\n\nfunction makeBodySection(title, b64, contentType, truncated, size, type) {\n let bodyHtml = '';\n const ct = (contentType || '').toLowerCase().split(';')[0].trim();\n const isText = ct.includes('json') || ct.startsWith('text/') || ct.includes('/javascript') || ct === 'application/xml' || ct === 'application/x-www-form-urlencoded';\n\n if (!b64 && !truncated) {\n bodyHtml = '<div class="body-binary">No body</div>';\n } else if (truncated) {\n bodyHtml = \`<div class="body-binary">Body too large to capture (\${formatBytes(size)})</div>\`;\n } else if (b64) {\n if (ct.includes('json') || ct.includes('/javascript') || ct === 'text/plain') {\n const text = decodeTextBody(b64);\n\n if (ct.includes('json')) {\n try {\n bodyHtml = \`<pre class="body-content json">\${syntaxHighlightJson(text)}</pre>\`;\n } catch {\n bodyHtml = \`<pre class="body-content">\${esc(text)}</pre>\`;\n }\n } else {\n bodyHtml = \`<pre class="body-content">\${esc(text)}</pre>\`;\n }\n } else if (ct.startsWith('text/')) {\n bodyHtml = \`<pre class="body-content">\${esc(decodeTextBody(b64))}</pre>\`;\n } else if (ct.startsWith('image/') && size < 1024*1024) {\n bodyHtml = \`<img class="body-image" src="data:\${esc(ct)};base64,\${b64}" alt="image"/>\`;\n } else if (ct === 'application/x-www-form-urlencoded') {\n const params = new URLSearchParams(decodeTextBody(b64));\n const rows = [...params.entries()].map(([k,v]) => \`<tr><td>\${esc(k)}</td><td>\${esc(v)}</td></tr>\`).join('');\n\n bodyHtml = \`<table class="kv-table">\${rows}</table>\`;\n } else {\n bodyHtml = \`<div class="body-binary">Binary data — \${formatBytes(size)} (\${esc(ct || 'unknown type')})</div>\`;\n }\n }\n\n const copyBtn = b64 && isText ? \`<button class="section-btn" onclick="event.stopPropagation();copyBody('\${type}',this)">Copy</button>\` : '';\n const saveBtn = b64 ? \`<button class="section-btn" onclick="event.stopPropagation();saveBody('\${type}')">Save</button>\` : '';\n const actionsHtml = (copyBtn || saveBtn) ? \`<div class="section-actions">\${copyBtn}\${saveBtn}</div>\` : '';\n const note = truncated ? \`<div class="truncated-note">⚠ Body was larger than the capture limit (\${formatBytes(CAPTURE_LIMIT)})</div>\` : '';\n\n return \`<div class="detail-section">\n <div class="detail-section-hdr" onclick="toggleSection(this)">\n <span class="toggle">▾</span>\n <span class="section-title">\${title}</span>\n \${size ? \`<span class="section-badge">\${formatBytes(size)}</span>\` : ''}\n \${actionsHtml}\n </div>\n <div class="detail-section-body">\${bodyHtml}\${note}</div>\n </div>\`;\n}\n\nwindow.toggleSection = function(hdr) {\n const body = hdr.nextElementSibling;\n const toggle = hdr.querySelector('.toggle');\n\n if (body.classList.toggle('collapsed')) {\n toggle.textContent = '▸';\n } else {\n toggle.textContent = '▾';\n }\n};\n\nasync function clearAll() {\n await fetch('/@api/requests', { method: 'DELETE' });\n\n allRequests = [];\n filteredRequests = [];\n selectedId = null;\n\n showDetailEmpty();\n renderList();\n updateStats({ total: 0, memoryUsage: 0, maxMemory: 1024*1024*1024 });\n}\nwindow.clearAll = clearAll;\n\n// ── JSON syntax highlighting ──────────────────────────────────────────────────\nfunction syntaxHighlightJson(str) {\n try {\n const parsed = JSON.parse(str);\n\n str = JSON.stringify(parsed, null, 2);\n } catch {\n // Invalid JSON can contain arbitrary HTML. It cannot be token-highlighted safely because\n // unmatched text is preserved by the replacement below, so render it as escaped plain text.\n return esc(str);\n }\n\n return str.replace(/("(\\\\u[a-zA-Z0-9]{4}|\\\\[^u]|[^\\\\"])*"\\s*:?|\\b(true|false|null)\\b|-?\\d+(?:\\.\\d*)?(?:[eE][+\\-]?\\d+)?)/g, function(m) {\n if (/^"/.test(m)) {\n if (/:$/.test(m)) {\n return '<span class="body-json-key">' + esc(m) + '</span>';\n }\n\n return '<span class="body-json-str">' + esc(m) + '</span>';\n }\n\n if (/true|false/.test(m)) {\n return '<span class="body-json-bool">' + m + '</span>';\n }\n\n if (/null/.test(m)) {\n return '<span class="body-json-null">' + m + '</span>';\n }\n\n return '<span class="body-json-num">' + m + '</span>';\n });\n}\n\n// ── Helpers ───────────────────────────────────────────────────────────────────\nfunction esc(s) {\n return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');\n}\n\nfunction decodeTextBody(b64) {\n const bytes = Uint8Array.from(atob(b64), function(c) { return c.charCodeAt(0); });\n\n return new TextDecoder('utf-8').decode(bytes);\n}\n\nfunction sourceInfo(source) {\n if (source === 'proxy') {\n return { cls: 'src-proxy', title: 'Backend (live)' };\n }\n\n if (source === 'proxy-cache') {\n return { cls: 'src-proxy-cache', title: 'Backend (cached)' };\n }\n\n return { cls: 'src-local', title: 'Local (dev server)' };\n}\n\nfunction sourceLetter(source) {\n if (source === 'proxy') {\n return 'P';\n }\n\n if (source === 'proxy-cache') {\n return 'C';\n }\n\n return 'L';\n}\n\n// ── Copy / Save ───────────────────────────────────────────────────────────────\nfunction clipboard(text, btn) {\n navigator.clipboard.writeText(text).then(() => {\n if (!btn) {\n return;\n }\n\n const orig = btn.textContent;\n\n btn.textContent = '✓';\n btn.style.color = 'var(--green)';\n setTimeout(() => {\n btn.textContent = orig;\n btn.style.color = '';\n }, 1500);\n }).catch(() => {});\n}\n\nfunction copyUrl(btn) {\n clipboard(currentEntry ? currentEntry.url : '', btn);\n}\nwindow.copyUrl = copyUrl;\n\nfunction copyHeaders(type, btn) {\n const h = type === 'request' ? (currentEntry && currentEntry.requestHeaders) : (currentEntry && currentEntry.responseHeaders);\n\n if (!h) {\n return;\n }\n\n const text = Object.entries(h).filter(([,v]) => v != null).map(([k,v]) => k + ': ' + (Array.isArray(v) ? v.join(', ') : v)).join('\\n');\n\n clipboard(text, btn);\n}\nwindow.copyHeaders = copyHeaders;\n\nfunction copyBody(type, btn) {\n const b64 = type === 'request' ? (currentEntry && currentEntry.requestBody) : (currentEntry && currentEntry.responseBody);\n\n if (!b64) {\n return;\n }\n\n try {\n clipboard(decodeTextBody(b64), btn);\n } catch(e) {}\n}\nwindow.copyBody = copyBody;\n\nfunction saveBody(type) {\n const b64 = type === 'request' ? (currentEntry && currentEntry.requestBody) : (currentEntry && currentEntry.responseBody);\n const ct = type === 'request' ? (currentEntry && currentEntry.requestContentType) : (currentEntry && currentEntry.responseContentType);\n\n if (!b64) {\n return;\n }\n\n downloadB64(b64, type + '-body.' + fileExt(ct));\n}\nwindow.saveBody = saveBody;\n\nfunction downloadB64(b64, filename) {\n try {\n const bytes = Uint8Array.from(atob(b64), function(c) { return c.charCodeAt(0); });\n const blob = new Blob([bytes]);\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n\n a.href = url;\n a.download = filename;\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n setTimeout(function() { URL.revokeObjectURL(url); }, 1000);\n } catch(e) {}\n}\n\nfunction fileExt(ct) {\n ct = ((ct || '').split(';')[0]).trim().toLowerCase();\n\n var map = {\n 'application/json': 'json', 'text/html': 'html', 'text/css': 'css',\n 'text/javascript': 'js', 'application/javascript': 'js', 'text/plain': 'txt',\n 'text/xml': 'xml', 'application/xml': 'xml', 'image/png': 'png',\n 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/svg+xml': 'svg',\n 'image/webp': 'webp', 'application/pdf': 'pdf', 'application/zip': 'zip',\n 'application/x-www-form-urlencoded': 'txt', 'application/octet-stream': 'bin',\n };\n\n return map[ct] || 'bin';\n}\n\nfunction formatBytes(b) {\n b = b || 0;\n\n if (b < 1024) {\n return b + ' B';\n }\n\n if (b < 1024*1024) {\n return (b/1024).toFixed(1) + ' KB';\n }\n\n if (b < 1024*1024*1024) {\n return (b/(1024*1024)).toFixed(1) + ' MB';\n }\n\n return (b/(1024*1024*1024)).toFixed(2) + ' GB';\n}\n\nfunction formatDur(ms) {\n if (ms < 1000) {\n return ms + 'ms';\n }\n\n return (ms/1000).toFixed(2) + 's';\n}\n\nfunction methodClass(m) {\n const map = { GET:'m-GET', POST:'m-POST', PUT:'m-PUT', PATCH:'m-PATCH', DELETE:'m-DELETE', HEAD:'m-HEAD', OPTIONS:'m-OPTIONS' };\n\n return map[m] || 'm-other';\n}\n\nfunction statusClass(s) {\n if (!s) {\n return 's-pending';\n }\n\n const c = Math.floor(s / 100);\n\n return ['','','s-2xx','s-3xx','s-4xx','s-5xx'][c] || '';\n}\n\n// ── Polling ───────────────────────────────────────────────────────────────────\nfetchList();\nsetInterval(fetchList, 2000);\n\n})();\n<\/script>\n</body>\n</html>`}(s.captureLimit))})}function On(e){return Array.isArray(e)?e[0]??"":String(e??"")}function Nn(e){return e<1024?`${e} B`:e<1048576?`${(e/1024).toFixed(1)} KB`:e<1073741824?`${(e/1048576).toFixed(1)} MB`:`${(e/1073741824).toFixed(2)} GB`}const jn=String.raw`
2
- function renderDescriptionMarkdown(text) {
3
- const escape = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
4
-
5
- const inline = (line) => {
6
- const codes = [];
7
- // Code spans first, so nothing inside them is formatted: a whole run of backticks up to the
8
- // same run. Mismatched runs stay as they are.
9
- let out = line.replace(/(?<!\x60)(\x60+)([^\x60]|[^\x60][\s\S]*?[^\x60])\1(?!\x60)/g, (_, ticks, code) => {
10
- codes.push('<code>' + escape(code.replace(/\n/g, ' ').trim()) + '</code>');
11
-
12
- return '\u0000' + (codes.length - 1) + '\u0000';
13
- });
14
-
15
- out = escape(out)
16
- .replace(/&lt;(\/?)(u|s|del|strike)&gt;/gi, '<$1$2>')
17
- .replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, '<strong>$1</strong>')
18
- .replace(/(^|[^\w])__(?=\S)([\s\S]*?\S)__(?=[^\w]|$)/g, '$1<strong>$2</strong>')
19
- .replace(/\*(?=\S)([\s\S]*?\S)\*/g, '<em>$1</em>')
20
- .replace(/(^|[^\w])_(?=\S)([\s\S]*?\S)_(?=[^\w]|$)/g, '$1<em>$2</em>');
21
-
22
- return out.replace(/\u0000(\d+)\u0000/g, (_, i) => codes[Number(i)]);
23
- };
24
-
25
- const lines = String(text).replace(/\r\n?/g, '\n').split('\n');
26
- const blocks = [];
27
- let paragraph = [];
28
- let list = null;
29
-
30
- const flushParagraph = () => {
31
- if (paragraph.length) {
32
- // One paragraph at a time, so code and emphasis can span its line breaks, which stay.
33
- blocks.push('<p>' + inline(paragraph.join('\n')).replace(/\n/g, '<br>') + '</p>');
34
- paragraph = [];
35
- }
36
- };
37
- const flushList = () => {
38
- if (list) {
39
- // A numbered list goes on from the number its first item has: "2." after a blank line is 2.
40
- const start = list.start && list.start !== 1 ? ' start="' + list.start + '"' : '';
41
-
42
- blocks.push('<' + list.tag + start + '>' + list.items.map((item) => '<li>' + inline(item) + '</li>').join('') + '</' + list.tag + '>');
43
- list = null;
44
- }
45
- };
46
-
47
- for (let i = 0; i < lines.length; i++) {
48
- const line = lines[i];
49
- // An opening fence: three or more backticks and an info string without any (a line with three
50
- // backticks, "a", three backticks again is inline code instead).
51
- const fence = /^\s*(\x60{3,})[^\x60]*$/.exec(line);
52
-
53
- if (fence) {
54
- flushParagraph();
55
- flushList();
56
-
57
- const code = [];
58
-
59
- // Closed by a line of only backticks, at least as many as opened it.
60
- const close = new RegExp('^\\s*\\x60{' + fence[1].length + ',}\\s*$');
61
-
62
- for (i++; i < lines.length && !close.test(lines[i]); i++) {
63
- code.push(lines[i]);
64
- }
65
-
66
- blocks.push('<pre><code>' + escape(code.join('\n')) + '</code></pre>');
67
- continue;
68
- }
69
-
70
- const item = /^\s*(?:([-*+])|(\d+)[.)])\s+(.*)$/.exec(line);
71
-
72
- if (item) {
73
- const tag = item[1] ? 'ul' : 'ol';
74
-
75
- flushParagraph();
76
-
77
- if (list && list.tag !== tag) { flushList(); }
78
-
79
- list = list || { tag, items: [], start: item[2] ? Number(item[2]) : undefined };
80
- list.items.push(item[3]);
81
- continue;
82
- }
83
-
84
- if (!line.trim()) {
85
- flushParagraph();
86
- flushList();
87
- continue;
88
- }
89
-
90
- // A line right after a list item, indented, continues it.
91
- if (list && /^\s+\S/.test(line)) {
92
- list.items[list.items.length - 1] += ' ' + line.trim();
93
- continue;
94
- }
95
-
96
- flushList();
97
- paragraph.push(line);
98
- }
99
-
100
- flushParagraph();
101
- flushList();
102
-
103
- return blocks.join('');
104
- }
105
- `,Bn=["text","select","multiselect","file","list","color","boolean"];function Hn(e){return Bn.includes(e)}const Mn=["true","false","Y","N","1","0"],Dn=/^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;function _n(e,t){return e.id===t||e.text===t}function Fn(e){if("string"==typeof e||"number"==typeof e)return{id:String(e),text:String(e)};if("object"!=typeof e||null===e)return{text:""};const{id:t,text:n}=e,a="string"==typeof t||"number"==typeof t?String(t):void 0;return{id:a,text:"string"==typeof n?n:a??""}}function Vn(e,t,n,a){const s="select"===n.type||"multi-select"===n.type;if("string"!=typeof a&&(!s||"number"!=typeof a))return[{name:e,severity:"warning",message:`${t}: field "${n.name}" should be a string, got ${typeof a}.`}];const o=String(a);if(!o)return[];switch(n.type){case"color":return Dn.test(o)?[]:[{name:e,severity:"warning",message:`${t}: field "${n.name}" value "${o}" is not a recognized color (expected e.g. "#075b7e").`}];case"select":{if(!n.options?.length)return[];const a=n.options.map(Fn);return a.some(e=>_n(e,o))?[]:[{name:e,severity:"warning",message:`${t}: field "${n.name}" value "${o}" is not one of the declared options (${a.map(e=>e.text).join(", ")}).`}]}case"multi-select":{if(!n.options?.length)return[];const a=n.options.map(Fn),s=o.split(",").map(e=>e.trim()).filter(e=>!a.some(t=>_n(t,e)));return s.length?[{name:e,severity:"warning",message:`${t}: field "${n.name}" value "${s.join(", ")}" is not among the declared options (${a.map(e=>e.text).join(", ")}).`}]:[]}default:return[]}}function Pn(e,t){const n=function(e){if(!Array.isArray(e)||0===e.length)return null;const t=e.map(e=>"string"==typeof e?{name:e,type:"textarea"}:e&&"object"==typeof e&&"string"==typeof e.name?e:null).filter(e=>null!==e);return t.length?t:null}(e.additional_options);if(!n)return[];const a=new Set(n.map(e=>e.name)),s=[];return t.forEach((t,o)=>{const r=`"${e.name}" item #${o+1}`;if("object"!=typeof t||null===t||Array.isArray(t))return void s.push({name:e.name,severity:"warning",message:`${r} should be an object with field(s): ${n.map(e=>e.name).join(", ")}.`});const i=t;for(const t of n)t.name in i?s.push(...Vn(e.name,r,t,i[t.name])):s.push({name:e.name,severity:"warning",message:`${r} is missing field "${t.name}".`});const l=Object.keys(i).filter(e=>!a.has(e));l.length&&s.push({name:e.name,severity:"warning",message:`${r} has unexpected field(s) not declared in additional_options: ${l.join(", ")}.`})}),s}function qn(e,t){const n=[];switch(e.tag_type){case"boolean":Mn.includes(t)||n.push({name:e.name,severity:"warning",message:`Value "${t}" is not a recognized boolean encoding (expected one of ${Mn.join(", ")}).`});break;case"list":try{const a=JSON.parse(t);Array.isArray(a)?n.push(...Pn(e,a)):n.push({name:e.name,severity:"warning",message:`Value for list variable "${e.name}" is valid JSON but not an array.`})}catch{n.push({name:e.name,severity:"warning",message:`Value for list variable "${e.name}" is not valid JSON.`})}break;case"select":case"multiselect":if(Array.isArray(e.additional_options)){const a=e.additional_options.map(Fn),s=("multiselect"===e.tag_type?t.split(",").map(e=>e.trim()):[t]).filter(e=>!a.some(t=>_n(t,e)));s.length&&n.push({name:e.name,severity:"warning",message:`Value "${s.join(", ")}" does not match any declared option for "${e.name}".`})}}return n}function Un(e,t){switch(e.tag_type){case"boolean":return Mn.includes(t)?null:`Not a recognized boolean value ("${t}").`;case"color":return""===t||Dn.test(t)?null:`Not a recognized color value ("${t}").`;case"list":if(""===t)return null;try{const e=JSON.parse(t);return null===e||Array.isArray(e)?null:"Value is valid JSON but not an array."}catch{return"Value is not valid JSON."}default:return null}}const zn="/@pp-dev/variables-editor",Jn=/^[A-Za-z0-9_\s-]+$/,Wn="Dist service or MiAPI is not defined",Kn=new WeakMap;async function Gn(e){const t=await e.readPublicTemplateVariablesFile();if(!t)return null;const n=t.toString("utf-8");let a;try{a=JSON.parse(n)}catch(e){return{raw:n,schema:null,parseError:e instanceof Error?e.message:"Invalid JSON"}}return"object"!=typeof a||null===a||Array.isArray(a)||!Array.isArray(a.tags)?{raw:n,schema:null,parseError:'The root value must be a JSON object with a "tags" array.'}:{raw:n,schema:a}}async function Yn(e){return e?(await Gn(e))?.schema??null:null}function Zn(e,t){const n=Kn.get(e);if(n)return void(n.deps=t);const a={deps:t};Kn.set(e,a),e.get("/@api/variables/schema",async(e,t)=>{const{distService:n}=a.deps;if(!n)return void t.status(503).json({error:Wn});const s=await Gn(n);t.json(s?{exists:!0,...s}:{exists:!1,schema:null,raw:null})}),e.put("/@api/variables/schema",async(e,t)=>{const{distService:n}=a.deps;if(!n)return void t.status(503).json({error:Wn});const s=e.body?.raw;if("string"!=typeof s)return void t.status(400).json({error:"Request body must be { raw: string }."});let o;try{o=JSON.parse(s)}catch{return void t.status(400).json({error:"Not valid JSON."})}if("object"!=typeof o||null===o||Array.isArray(o))return void t.status(400).json({error:"The root value must be a JSON object."});const r=o.tags;if(!Array.isArray(r))return void t.status(400).json({error:'"tags" must be an array.'});const i=r.findIndex(e=>"object"!=typeof e||null===e||"string"!=typeof e.name||!e.name);if(-1!==i)return void t.status(400).json({error:`tags[${i}] is missing a non-empty "name".`});const l=[],c=new Set;for(const e of r)c.has(e.name)&&l.push(`Duplicate variable name "${e.name}".`),c.add(e.name),e.tag_type&&!Hn(e.tag_type)&&l.push(`"${e.name}" has an unrecognized tag_type "${e.tag_type}".`),Jn.test(e.name)||l.push(`"${e.name}" contains a character MI's own editor doesn't allow (only letters, digits, underscore, hyphen, and whitespace).`);try{await n.saveTemplateVariablesFile(Buffer.from(s,"utf-8"))}catch(e){return void t.status(500).json({error:`Failed to write ${dn}.`,details:e instanceof Error?e.message:String(e)})}t.json({ok:!0,warnings:l})}),e.get("/@api/variables/values",async(e,t)=>{const{distService:n,miAPI:s}=a.deps;try{const e=await Yn(n),a=await s.getLivePageVariables(),o=function(e,t){const n=new Map((e?.tags??[]).map(e=>[e.name,e])),a=new Map(t.map(e=>[e.name,e.value])),s=new Set([...n.keys(),...a.keys()]);return Array.from(s).map(e=>({name:e,value:a.get(e)??n.get(e)?.default_value??""}))}(e,a);t.json({schema:e,live:a,combined:o,local:s.localPageVariables??null})}catch(e){t.status(502).json({error:"Failed to fetch live page variables.",details:e instanceof Error?e.message:String(e)})}}),e.put("/@api/variables/values",async(e,t)=>{const{distService:n,miAPI:s}=a.deps,o=function(e){const t="object"==typeof e&&null!==e?e.tags:void 0;if(!Array.isArray(t))return null;const n=[];for(const e of t){if("object"!=typeof e||null===e)return null;const{name:t,value:a}=e;if("string"!=typeof t||!t||"string"!=typeof a)return null;n.push({name:t,value:a})}return n}(e.body);if(o)try{const e=await Yn(n),a=new Map((e?.tags??[]).map(e=>[e.name,e])),r=[];for(const e of o){const t=a.get(e.name);t&&r.push(...qn(t,e.value))}let i;await s.applyPageVariables(o);try{await s.reloadPageVariables()}catch(e){i=e instanceof Error?e.message:String(e)}t.json({ok:!0,warnings:r,local:s.localPageVariables??null,...i?{localError:i}:{}})}catch(e){t.status(502).json({error:"Failed to save live page variables.",details:e instanceof Error?e.message:String(e)})}else t.status(400).json({error:"Request body must be { tags: {name, value}[] }."})}),e.post("/@api/variables/values/local",async(e,t)=>{const{miAPI:n}=a.deps;try{await n.reloadPageVariables(),t.json({local:n.localPageVariables??null})}catch(e){t.status(502).json({error:"Failed to update the local values.",details:e instanceof Error?e.message:String(e)})}}),e.get("/@api/variables/new-uid",(e,t)=>{t.json({uid:j("md5").update(N()).digest("hex")})}),e.get("/@api/variables/pages",async(e,t)=>{const{miAPI:n}=a.deps;try{const e=await n.listPages(),a=e.find(e=>e.internal_name===n.internalPageName)??null,s=e.filter(e=>e.internal_name!==n.internalPageName).map(e=>({id:e.id,name:e.name,internal_name:e.internal_name,sameTemplate:!!a&&(null!=a.template_id?e.template_id===a.template_id:!!a.template&&e.template===a.template)}));t.json({pages:s,currentPageName:a?.name??null})}catch(e){t.status(502).json({error:"Failed to fetch the page list.",details:e instanceof Error?e.message:String(e)})}}),e.get("/@api/variables/import-plan/:pageId",async(e,t)=>{const{distService:n,miAPI:s}=a.deps,o=Number(e.params.pageId);if(!Number.isInteger(o)||o<=0)t.status(400).json({error:"pageId must be a positive integer."});else try{const e=await Yn(n),a=await s.getPageVariablesFor(o);t.json(function(e,t){const n=new Map((e?.tags??[]).map(e=>[e.name,e])),a=[],s=[];for(const e of t){const t=n.get(e.name);if(!t){s.push({name:e.name,sourceValue:e.value,reason:"Not in the current page's schema."});continue}const o=Un(t,e.value);o?s.push({name:e.name,sourceValue:e.value,reason:o}):a.push({name:e.name,sourceValue:e.value,tagType:t.tag_type||"text"})}return{importable:a,skipped:s}}(e,a))}catch(e){t.status(502).json({error:"Failed to fetch values from the selected page.",details:e instanceof Error?e.message:String(e)})}}),e.get(zn,(e,t)=>{var n;t.setHeader("Content-Type","text/html; charset=utf-8"),t.setHeader("Cache-Control","no-cache"),t.end(`<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width,initial-scale=1">\n<title>Variables Editor — pp-dev</title>\n<script>\n// Set the stored theme override before first paint, so switching pages/reloading doesn't\n// flash the OS-default theme before settling on the user's explicit choice (see setTheme()).\ntry {\n var ppDevStoredTheme = localStorage.getItem('pp-dev-info-theme');\n\n if (ppDevStoredTheme === 'dark' || ppDevStoredTheme === 'light') {\n document.documentElement.setAttribute('data-pp-dev-theme', ppDevStoredTheme);\n }\n} catch (e) {}\n<\/script>\n<style>\n*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}\n:root,:root[data-pp-dev-theme="light"]{\n --bg:#ffffff;--bg2:#f4f5f7;--bg3:#e9ebef;--bg4:#dbdee3;\n --border:#d7dae0;--border2:#c2c6cd;\n --text:#1a1a1e;--text2:#54566b;--text3:#8a8c9c;\n --accent:#4f46e5;--accent2:#7c3aed;\n --m-accent:var(--accent);--m-bg2:var(--bg2);\n --green:#16a34a;--red:#dc2626;--yellow:#a16207;--blue:#2563eb;\n --btn-primary-fg:#ffffff;\n --font-mono:'Cascadia Code','Fira Code','JetBrains Mono',Consolas,monospace;\n --font-ui:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;\n}\n:root[data-pp-dev-theme="dark"]{\n --bg:#1a1a1e;--bg2:#222228;--bg3:#2a2a32;--bg4:#32323c;\n --border:#3a3a46;--border2:#4a4a58;\n --text:#e0e0f0;--text2:#a0a0b8;--text3:#606078;\n --accent:#6e8efb;--accent2:#a78bfa;\n --m-accent:var(--accent);--m-bg2:var(--bg2);\n --green:#4ade80;--red:#f87171;--yellow:#fbbf24;--blue:#60a5fa;\n --btn-primary-fg:#0d0d10;\n}\n@media (prefers-color-scheme: dark){\n :root{\n --bg:#1a1a1e;--bg2:#222228;--bg3:#2a2a32;--bg4:#32323c;\n --border:#3a3a46;--border2:#4a4a58;\n --text:#e0e0f0;--text2:#a0a0b8;--text3:#606078;\n --accent:#6e8efb;--accent2:#a78bfa;\n --m-accent:var(--accent);--m-bg2:var(--bg2);\n --green:#4ade80;--red:#f87171;--yellow:#fbbf24;--blue:#60a5fa;\n --btn-primary-fg:#0d0d10;\n }\n}\nhtml,body{height:100%;overflow:hidden;background:var(--bg);color:var(--text);font-family:var(--font-ui);font-size:13px;line-height:1.5}\n.app{display:flex;flex-direction:column;height:100vh}\n.toolbar{display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--bg2);border-bottom:1px solid var(--border);flex-shrink:0;flex-wrap:wrap}\n.toolbar-title{font-weight:600;font-size:14px;margin-right:8px}\n.tabs{display:flex;gap:4px}\n.tab{padding:5px 12px;border-radius:4px;border:1px solid transparent;background:transparent;color:var(--text2);font-size:12px;cursor:pointer}\n.tab:hover{background:var(--bg3);color:var(--text)}\n.tab.active{background:var(--bg4);color:var(--text);border-color:var(--border2)}\n.toolbar-spacer{flex:1}\n.ve-loading-indicator{display:inline-flex;align-items:center;gap:6px;color:var(--text3);font-size:11px}\n.ve-loading-dot{width:6px;height:6px;border-radius:50%;background:var(--accent);animation:ve-pulse 1s ease-in-out infinite}\n.ve-skeleton{padding:16px}\n.ve-skeleton-row{height:32px;border-radius:4px;background:var(--bg3);margin-bottom:8px;animation:ve-pulse 1.4s ease-in-out infinite}\n.ve-skeleton-row:nth-child(2){width:92%}\n.ve-skeleton-row:nth-child(3){width:96%}\n.ve-skeleton-row:nth-child(4){width:88%}\n.ve-skeleton-row:nth-child(5){width:94%}\n@keyframes ve-pulse{0%,100%{opacity:1}50%{opacity:.4}}\n.theme-switch{display:flex;gap:2px;background:var(--bg3);border:1px solid var(--border);border-radius:4px;padding:2px}\n.theme-switch button{padding:3px 8px;border:none;border-radius:3px;background:transparent;color:var(--text2);font-size:11px;cursor:pointer;font-family:var(--font-ui)}\n.theme-switch button.active{background:var(--accent);color:var(--btn-primary-fg)}\n.btn{padding:5px 12px;border-radius:4px;border:1px solid var(--border);background:var(--bg3);color:var(--text);font-size:12px;cursor:pointer;transition:background .1s}\n.btn:hover{background:var(--bg4);border-color:var(--border2)}\n.btn-primary{background:var(--accent);border-color:var(--accent);color:var(--btn-primary-fg);font-weight:600}\n.btn-primary:hover{opacity:.9;background:var(--accent)}\n.btn-sm{padding:5px 8px;font-size:11px;min-width:30px;min-height:30px;}\n.local-update-btn{position:fixed;right:16px;bottom:16px;z-index:20;box-shadow:0 4px 16px rgba(0,0,0,.25)}\n.local-update-btn[hidden]{display:none}\n.content{flex:1;overflow:auto;padding:14px}\n.banner{margin-bottom:12px;padding:8px 12px;border-radius:4px;font-size:12px}\n.banner-warning{background:color-mix(in srgb, var(--yellow) 15%, var(--bg));color:var(--yellow);border:1px solid color-mix(in srgb, var(--yellow) 40%, var(--bg))}\n.banner-error{background:color-mix(in srgb, var(--red) 15%, var(--bg));color:var(--red);border:1px solid color-mix(in srgb, var(--red) 40%, var(--bg))}\n.banner-success{background:color-mix(in srgb, var(--green) 15%, var(--bg));color:var(--green);border:1px solid color-mix(in srgb, var(--green) 40%, var(--bg))}\n.banner ul{margin:6px 0 0 18px}\ntable{width:100%;border-collapse:separate;border-spacing:0;font-size:12px}\nthead{border-radius:6px 6px 0 0;overflow:hidden}\nthead th{padding:8px 8px 6px}\nth{text-align:left;padding:6px 8px;color:var(--text2);border-bottom:1px solid var(--border);font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.4px;background:var(--bg2)}\nthead th:first-child{border-radius:6px 0 0 0}\nthead th:last-child{border-radius:0 6px 0 0}\ntd{padding:12px 8px;;vertical-align:top}\ntr:hover td{background:var(--bg2)}\ninput[type=text],select,textarea{width:100%;background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:4px 6px;border-radius:4px;font-size:12px;font-family:var(--font-mono);outline:none;min-height:30px;}\ninput[type=text]:focus,select:focus,textarea:focus{border-color:var(--accent)}\ninput[type=color]{max-width:150px;width:100%;height:30px;padding:0;border:1px solid var(--border);border-radius:4px;background:var(--bg3)}\ntextarea{resize:vertical;min-height:32px}\n.cell-textarea{min-height:30px;height:30px;resize:none;overflow:hidden;line-height:1.8;}\n.col-name{width:180px}\n.col-type{width:130px}\n.col-source{width:110px}\n.col-actions{width:70px;text-align:right}\n.col-expand{width:48px;padding-left:0px;padding-right:0!important}\n.ve-expand-cell{display:flex;align-items:center;gap:2px}\n.ve-schema-row-handle{flex-shrink:0;width:18px;height:22px;display:inline-flex;align-items:center;justify-content:center;cursor:grab;color:var(--text3);user-select:none;border:none;background:transparent;font-size:20px;line-height:1;padding:0;letter-spacing:-2px}\n.ve-schema-row-handle:hover{color:var(--text2)}\n.ve-schema-row-handle:active{cursor:grabbing}\ntr.schema-row.is-dragging td,tr.details-row.is-dragging td{opacity:.45}\ntr.schema-row.is-drop-target td,tr.details-row.is-drop-target td{box-shadow:inset 0 2px 0 var(--accent)}\n.ve-expand-btn{width:30px;height:30px;padding:0;display:inline-flex;align-items:center;justify-content:center;font-size:13px;line-height:1;color:var(--text2)}\n.ve-expand-btn:hover{color:var(--text)}\n.raw-toggle{margin-bottom:10px}\n.raw-editor{width:100%;height:min(60vh,600px);font-family:var(--font-mono);font-size:12px;white-space:pre}\n.hint{color:var(--text3);font-size:11px;}\n.ve-md p{margin:0 0 4px}\n.ve-md p:last-child,.ve-md ul:last-child,.ve-md ol:last-child,.ve-md pre:last-child{margin-bottom:0}\n.ve-md ul,.ve-md ol{margin:0 0 4px;padding-left:18px}\n.ve-md code{font-family:var(--font-mono,monospace);background:var(--bg4);border-radius:3px;padding:0 3px}\n.ve-md pre{margin:0 0 4px;padding:6px 8px;background:var(--bg4);border-radius:4px;overflow-x:auto}\n.ve-md pre code{background:none;padding:0}\n.ve-md strong{color:var(--text2)}\n.details-row td{background:var(--bg2);padding:16px}\n.details-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px}\n.details-grid-item-wrapper{display:flex;gap:12px;grid-column:1/-1;}\n.details-grid label{display:block;font-size:10px;color:var(--text3);text-transform:uppercase;margin-bottom:6px}\n.badge{font-size:10px;padding:1px 6px;border-radius:8px;background:var(--bg4);color:var(--text3);text-transform:uppercase;letter-spacing:.3px;white-space:nowrap}\n.badge-source-dataset{background:color-mix(in srgb, var(--yellow) 22%, var(--bg));color:var(--yellow)}\n.badge-source-static{background:var(--bg4);color:var(--text3)}\n.ve-values-layout{display:flex;gap:16px;align-items:stretch;height:calc(100vh - 115px);min-height:320px}\n.ve-values-detail > .ve-values-layout{height:100%;min-height:0}\n.ve-values-nav{width:280px;flex-shrink:0;display:flex;flex-direction:column;gap:8px;min-height:0}\n.ve-values-nav-list{display:flex;flex-direction:column;gap:6px;overflow:auto;min-height:0}\n.ve-values-nav-item{display:flex;align-items:center;gap:8px;min-height:30px;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg2);color:var(--text);cursor:pointer;text-align:left;width:100%;font-family:var(--font-ui);font-size:12px}\n.ve-values-nav-item:hover{border-color:var(--border2);background:var(--bg3)}\n.ve-values-nav-item.is-active{border-color:var(--m-accent);background:color-mix(in srgb, var(--m-accent) 12%, var(--m-bg2))}\n.ve-values-nav-dot{width:8px;height:8px;border-radius:50%;background:var(--text3);flex-shrink:0}\n.ve-values-nav-item.is-active .ve-values-nav-dot{background:var(--m-accent)}\n.ve-values-nav-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--font-mono)}\n.ve-values-nav-badges{display:flex;gap:4px;flex-shrink:0;align-items:center}\n.ve-values-nav-add{border-style:dashed;justify-content:center;background:transparent}\n.ve-values-nav-add:hover{background:var(--bg3)}\n.ve-values-detail{flex:1;min-width:0;overflow:auto;padding-right: 10px}\n.ve-values-anchor{scroll-margin-top:12px}\n.ve-values-anchor.is-active{border-color:var(--accent)}\n.ve-values-anchor + .ve-values-anchor{margin-top:24px;padding-top:10px;}\n.ve-values-detail-header{display:flex;align-items:center;gap:8px}\n.ve-values-detail-title{font-family:var(--font-mono);font-size:13px;font-weight:600}\n.ve-values-detail-header .ve-values-field-label{flex:1;min-width:0}\n.ve-values-detail-header .btn{margin-left:auto;flex-shrink:0}\n.ve-values-fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px}\n.ve-values-field{display:flex;flex-direction:column;align-items:flex-start;gap:10px;min-width:0}\n.ve-values-field.is-wide{grid-column:1/-1}\n.ve-values-field-label{display:flex;align-items:center;gap:6px;font-size:10px;color:var(--text3)}\n.ve-values-field-label > span:first-child{font-family:var(--font-mono)}\n.ve-values-field textarea,.ve-values-field input[type=text],.ve-values-field select{min-height:30px;height:30px;resize:none;overflow:hidden}\n.ve-values-empty{color:var(--text3);padding:24px 0}\n.ve-values-list-items{display:flex;flex-direction:column;gap:12px}\n.ve-values-list-item{background:var(--bg);border:1px solid var(--border);border-radius:4px;padding:10px 8px 16px;display:flex;gap:6px;align-items:flex-start;transition:opacity .12s ease,border-color .12s ease,box-shadow .12s ease}\n.ve-values-list-item.is-dragging{opacity:.45}\n.ve-values-list-item.is-drop-target{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}\n.ve-values-list-item-handle{flex-shrink:0;width:22px;align-self:stretch;display:inline-flex;align-items:center;justify-content:center;cursor:grab;color:var(--text3);user-select:none;border:none;background:transparent;font-size:16px;line-height:1;padding:0;letter-spacing:-2px}\n.ve-values-list-item-handle:hover{color:var(--text2)}\n.ve-values-list-item-handle:active{cursor:grabbing}\n.ve-values-list-item > .ve-values-fields{flex:1;min-width:0}\n.checkbox-row{display:flex;flex-wrap:wrap;gap:4px 16px}\n.details-grid .checkbox-label{display:inline-flex;align-items:center;gap:4px;font-size:11px;color:var(--text2);text-transform:none;margin-bottom:0}\n.checkbox-label input{width:auto}\n.empty-state{color:var(--text3);text-align:center;padding:40px}\n.ve-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:100}\n.ve-modal{background:var(--bg2);border:1px solid var(--border2);border-radius:6px;padding:16px;width:min(400px,90vw);box-shadow:0 8px 24px rgba(0,0,0,.4);position:relative}\n.ve-modal-diff{width:min(860px,94vw);min-width:min(500px,90vw);min-height:min(300px,80vh);max-height:85vh;display:flex;flex-direction:column}\n.ve-modal-wide{width:min(560px,90vw)}\n.ve-modal-title{font-size:12px;color:var(--text2);margin-bottom:8px}\n.ve-modal-title-row{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:12px}\n.ve-modal-title-row .ve-modal-title{margin-bottom:0;flex:1}\n.ve-modal-close{width:28px;height:28px;padding:0;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0}\n.ve-modal-body{font-size:12px;line-height:1.6;max-height:60vh;overflow-y:auto}\n.ve-modal-diff .ve-modal-body{flex:1;max-height:none;min-height:0;display:flex;flex-direction:column;gap:8px}\n.ve-diff-nav{display:flex;align-items:center;gap:8px;flex-shrink:0}\n.ve-diff-nav .btn{min-width:30px;min-height:30px;padding:0;display:inline-flex;align-items:center;justify-content:center}\n.ve-diff-nav-count{font-size:12px;color:var(--text2);font-variant-numeric:tabular-nums;min-width:4.5em}\n.ve-diff{background:var(--bg);border:1px solid var(--border);border-radius:6px;overflow:auto;flex:1;min-height:180px;font-family:var(--font-mono);font-size:11px;line-height:1.55}\n.ve-diff-line{display:flex;gap:8px;padding:1px 8px;white-space:pre-wrap;word-break:break-word}\n.ve-diff-prefix{flex-shrink:0;width:12px;user-select:none;opacity:.8}\n.ve-diff-line.is-add{background:color-mix(in srgb, var(--green) 14%, var(--bg));color:var(--text)}\n.ve-diff-line.is-remove{background:color-mix(in srgb, var(--red) 14%, var(--bg));color:var(--text)}\n.ve-diff-line.is-add .ve-diff-prefix{color:var(--green)}\n.ve-diff-line.is-remove .ve-diff-prefix{color:var(--red)}\n.ve-diff-line.is-current-change{outline:1px solid var(--accent);outline-offset:-1px;}\n.ve-diff-inline-add{background:color-mix(in srgb, var(--green) 35%, transparent);color:var(--green);font-weight:700;border-radius:2px}\n.ve-diff-inline-remove{background:color-mix(in srgb, var(--red) 35%, transparent);color:color-mix(in srgb, var(--red) 80%, #fff);font-weight:700;border-radius:2px}\n.ve-diff-empty{padding:24px;color:var(--text3);text-align:center}\n.ve-modal-body pre{background:var(--bg3);border:1px solid var(--border);border-radius:4px;padding:8px;font-family:var(--font-mono);font-size:11px;white-space:pre-wrap;word-break:break-word}\n.ve-modal-body code{background:var(--bg3);padding:1px 4px;border-radius:3px;font-family:var(--font-mono)}\n.ve-modal-actions{display:flex;justify-content:flex-end;align-items:center;gap:8px;margin-top:12px}\n.ve-modal-actions .hint{margin:0;margin-right:auto;text-align:left}\n.ve-combo{position:relative}\n.ve-combo-input{margin-bottom:0}\n.ve-combo-list{position:absolute;left:0;right:0;top:100%;z-index:10;max-height:220px;overflow-y:auto;background:var(--bg3);border:1px solid var(--border2);border-radius:4px;margin-top:2px;box-shadow:0 4px 12px rgba(0,0,0,.3)}\n.ve-combo-option{padding:5px 8px;font-size:12px;cursor:pointer}\n.ve-combo-option:hover{background:var(--bg4)}\n.ve-combo-chips{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px}\n.ve-chip{display:inline-flex;align-items:center;gap:4px;background:var(--bg3);border:1px solid var(--border2);border-radius:10px;padding:2px 4px 2px 8px;font-size:11px}\n.ve-chip button{background:none;border:none;color:var(--text3);cursor:pointer;font-size:11px;padding:0 2px}\n.ve-chip button:hover{color:var(--text)}\n.ve-list-items{display:flex;flex-direction:column;gap:6px}\n.ve-list-item{display:flex;gap:6px;align-items:flex-start}\n.ve-list-item > input[type=text]{flex:1}\n.ve-list-item-cols{background:var(--bg2);border:1px solid var(--border);border-radius:4px;padding:8px;display:flex;gap:8px;align-items:flex-start}\n.ve-list-item-fields{display:flex;flex-direction:column;gap:8px;width:100%}\n.ve-list-field{display:flex;flex-direction:column;gap:6px;max-width:450px;width:100%;}\n.ve-list-field label{font-size:10px;color:var(--text3);}\n.ve-list-field textarea,.ve-list-field input[type=text],.ve-list-field select{min-height:30px;height:30px;resize:none;overflow:hidden}\n.ve-list-item-actions{flex-shrink:0}\n.box-style{border:1px solid var(--border);border-radius:6px;padding:8px;background:var(--bg3)}\n.ve-ao-columns{display:flex;flex-direction:column;gap:8px;margin-top:8px}\n.ve-ao-column{background:var(--bg);border:1px solid var(--border);border-radius:4px;padding:10px 8px 16px;display:flex;gap:6px;align-items:flex-start;transition:opacity .12s ease,border-color .12s ease,box-shadow .12s ease}\n.ve-ao-column.is-dragging{opacity:.45}\n.ve-ao-column.is-drop-target{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}\n.ve-ao-column-handle{flex-shrink:0;width:22px;align-self:stretch;display:inline-flex;align-items:center;justify-content:center;cursor:grab;color:var(--text3);user-select:none;border:none;background:transparent;font-size:16px;line-height:1;padding:0;letter-spacing:-2px}\n.ve-ao-column-handle:hover{color:var(--text2)}\n.ve-ao-column-handle:active{cursor:grabbing}\n.ve-ao-column-fields{flex:1;min-width:0;display:flex;flex-direction:column;gap:6px}\n.ve-ao-column-top{display:flex;gap:6px;align-items:flex-end}\n.ve-ao-column-field{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}\n.ve-ao-column-field label{font-size:10px;color:var(--text3);text-transform:none;margin-bottom:0;font-family:var(--font-mono)}\n.ve-ao-column-name{position:relative;min-width:0}\n.ve-ao-column-name input{width:100%;padding-right:28px;font-family:var(--font-mono)}\n.ve-ao-column-remove{position:absolute;right:0px;top:50%;transform:translateY(-50%);width:24px;height:24px;padding:0;display:inline-flex;align-items:center;justify-content:center}\n.ve-ao-column-bottom{display:flex;gap:6px;align-items:flex-end}\n.ve-ao-column-bottom > .ve-ao-column-field{flex:1;min-width:0}\n.ve-json-issues{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}\n.ve-json-issue-badge{display:inline-flex;align-items:center;gap:4px;background:color-mix(in srgb, var(--yellow) 15%, var(--bg));color:var(--yellow);border:1px solid color-mix(in srgb, var(--yellow) 40%, var(--bg));border-radius:10px;padding:2px 8px;font-size:11px;cursor:default}\n.ve-modal-back{background:none;border:none;color:var(--accent);font-size:11px;cursor:pointer;padding:0 0 4px;font-family:var(--font-ui);display:block}\n.ve-import-search{position:relative;margin-bottom:12px}\n.ve-import-search input{padding-left:28px}\n.ve-import-search::before{content:"⌕";position:absolute;left:9px;top:50%;transform:translateY(-50%);color:var(--text3);font-size:14px;pointer-events:none}\n.ve-import-section-title{font-size:10.5px;color:var(--text3);text-transform:uppercase;letter-spacing:.5px;margin:14px 0 6px;font-weight:600}\n.ve-import-section-title:first-child{margin-top:0}\n.ve-import-page-list{display:flex;flex-direction:column;gap:5px}\n.ve-import-page-row{display:flex;align-items:center;gap:10px;width:100%;text-align:left;padding:8px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg3);color:var(--text);cursor:pointer;font-family:var(--font-ui);font-size:12px}\n.ve-import-page-row:hover{border-color:var(--accent)}\n.ve-import-page-name{font-weight:500}\n.ve-import-page-slug{font-size:11px;color:var(--text3);font-family:var(--font-mono);margin-left:auto;flex-shrink:0}\n.ve-import-empty{padding:20px 4px;text-align:center;color:var(--text3);font-size:12px}\n.ve-import-list-head{display:flex;align-items:center;gap:8px;margin-bottom:8px}\n.ve-import-list-head .badge{background:color-mix(in srgb, var(--green) 18%, var(--bg));color:var(--green)}\n.ve-import-select-all{margin-left:auto;display:flex;align-items:center;gap:6px;font-size:11.5px;color:var(--text2);cursor:pointer;text-transform:none}\n.ve-import-list{display:flex;flex-direction:column;gap:6px}\n.ve-import-row{display:flex;align-items:flex-start;gap:10px;padding:8px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg3);cursor:pointer}\n.ve-import-row input[type=checkbox]{margin-top:2px;flex-shrink:0}\n.ve-import-row-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:3px}\n.ve-import-row-top{display:flex;align-items:center;gap:8px}\n.ve-import-row-name{font-family:var(--font-mono);font-size:12px;font-weight:600}\n.ve-import-row-value{font-size:11.5px;color:var(--text2);font-family:var(--font-mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\n.ve-import-swatch{width:12px;height:12px;border-radius:3px;border:1px solid var(--border2);flex-shrink:0;display:inline-block;vertical-align:-1px;margin-right:5px}\n.ve-import-skip-row{display:flex;align-items:baseline;gap:8px;padding:7px 10px;opacity:.85}\n.ve-import-skip-row .ve-import-row-name{color:var(--text2)}\n.ve-import-skip-reason{font-size:11.5px;color:var(--text3)}\n::-webkit-scrollbar{width:8px;height:8px}\n::-webkit-scrollbar-track{background:var(--bg)}\n::-webkit-scrollbar-thumb{background:var(--bg4);border-radius:4px}\n</style>\n</head>\n<body>\n<div class="app">\n <div class="toolbar">\n <span class="toolbar-title">🧩 Variables Editor</span>\n ${(n=a.deps.miAPI.isTemplateLess)?"":'<div class="tabs">\n <button class="tab" id="tab-schema" onclick="switchTab(\'schema\')">Schema</button>\n <button class="tab" id="tab-values" onclick="switchTab(\'values\')">Values</button>\n </div>'}\n ${n?"":'<span class="ve-loading-indicator" id="loading-indicator" style="display:none"><span class="ve-loading-dot"></span>Refreshing…</span>'}\n <div class="toolbar-spacer"></div>\n ${n?"":'<button class="btn btn-sm" onclick="refresh()">↻ Refresh</button>\n <button class="btn btn-sm btn-primary" id="save-btn" onclick="save()">Save</button>'}\n <div class="theme-switch" id="theme-switch">\n <button data-theme-choice="auto" aria-pressed="false" onclick="setTheme('auto')">Auto</button>\n <button data-theme-choice="dark" aria-pressed="false" onclick="setTheme('dark')">Dark</button>\n <button data-theme-choice="light" aria-pressed="false" onclick="setTheme('light')">Light</button>\n </div>\n </div>\n <div class="content" id="content">${n?'<div class="empty-state">This page has no associated template — there\'s no <code>__template_variables.json</code> and no page variables to edit here.</div>':""}</div>\n</div>\n${n?"":'<button class="btn btn-sm btn-primary local-update-btn" id="local-update-btn" hidden onclick="updateLocalValues()"></button>'}\n<script>\n(function(){\n'use strict';\n\n// ── Theme (Auto/Dark/Light) ──────────────────────────────────────────────────\n// Runs unconditionally — before the templateLess early-return below — so the switcher works\n// even on the info-only page. The <head> inline script already set data-pp-dev-theme before\n// first paint; this just keeps it in sync with future picks and syncs the toolbar buttons'\n// state. Shares its localStorage key and attribute name with the dev panel and the Request\n// Inspector, so picking a theme in any of them carries over to the others.\nvar THEME_STORAGE_KEY = 'pp-dev-info-theme';\n\nfunction getStoredTheme() {\n try { return localStorage.getItem(THEME_STORAGE_KEY) || 'auto'; } catch (e) { return 'auto'; }\n}\n\nfunction applyTheme(theme) {\n if (theme === 'dark' || theme === 'light') {\n document.documentElement.setAttribute('data-pp-dev-theme', theme);\n } else {\n document.documentElement.removeAttribute('data-pp-dev-theme');\n }\n\n var switchEl = document.getElementById('theme-switch');\n\n if (switchEl) {\n Array.prototype.forEach.call(switchEl.children, function (btn) {\n var isActive = btn.dataset.themeChoice === theme;\n\n btn.classList.toggle('active', isActive);\n btn.setAttribute('aria-pressed', String(isActive));\n });\n }\n}\n\nfunction setTheme(theme) {\n try { localStorage.setItem(THEME_STORAGE_KEY, theme); } catch (e) {}\n\n applyTheme(theme);\n}\n\napplyTheme(getStoredTheme());\nwindow.setTheme = setTheme;\n\n// Server-rendered above: no tabs, no Refresh/Save, just the message — nothing here to wire up.\nif (${n?"true":"false"}) { return; }\n\nconst KNOWN_TYPES = ['text','select','multiselect','file','list','color','boolean'];\nconst KNOWN_TAG_SOURCES = ['static','page','element','folder','segment','dataset','dataset_data','announcement','group','category','custom_attribute','page_entity'];\n// select/multiselect sources whose options are hand-entered via additional_options rather than\n// loaded live from MI (the rest — dataset/announcement/group/category/custom_attribute/page/\n// page_entity — populate their own option list, so additional_options doesn't apply there).\nconst HAND_ENTERED_OPTION_SOURCES = ['static','segment','element','dataset_data'];\n// list-tag column editor: field types + sources allowed on a select/multi-select column.\nconst LIST_COLUMN_TYPES = ['textarea','select','multi-select','color','file'];\nconst LIST_COLUMN_SOURCES = ['static','dataset','group','category','custom_attribute'];\n// Matches MI's own "Create/Edit Variable" form: letters, digits, underscore, whitespace, hyphen.\n// Doubled backslash: this sits inside getVariablesEditorHtml()'s outer template literal, which\n// consumes one level of backslash-escaping when the .ts source is parsed — a bare s here would\n// collapse to a literal "s", silently dropping whitespace from the allowed character class (see\n// the identical pitfall fixed for escapeJsAttr() below).\nconst NAME_FORMAT_REGEX = /^[A-Za-z0-9_\\s-]+$/;\n// Lets a link like /@pp-dev/variables-editor?tab=values open directly on that tab.\nfunction tabFromUrl() {\n return new URLSearchParams(window.location.search).get('tab') === 'values' ? 'values' : 'schema';\n}\n\n// Values tab's raw/JSON mode is also linkable — /@pp-dev/variables-editor?tab=values&mode=json.\nfunction valuesJsonModeFromUrl() {\n return new URLSearchParams(window.location.search).get('mode') === 'json';\n}\n\nfunction setUrlState(tab, valuesJsonMode) {\n const url = new URL(window.location.href);\n\n url.searchParams.set('tab', tab);\n\n if (tab === 'values' && valuesJsonMode) {\n url.searchParams.set('mode', 'json');\n } else {\n url.searchParams.delete('mode');\n }\n\n history.replaceState(null, '', url);\n}\n\nlet activeTab = tabFromUrl();\nlet schemaState = null; // { exists, schema, raw, parseError }\nlet valuesState = null; // { schema, live, combined }\nlet schemaRows = []; // working copy of tags, edited in place\nconst invalidAdditionalOptionsRows = new WeakSet();\nlet valueRows = []; // working copy of {name, value}\nlet selectedValueIndex = 0;\nlet selectedListItemIndex = 0;\nlet importReviewCandidates = []; // last-fetched import-plan's "importable" list, for applyImportPlan()\nlet importReviewPageName = ''; // source page name, for the post-apply banner\nlet valuesRawMode = activeTab === 'values' && valuesJsonModeFromUrl();\nlet rawMode = false;\nlet dirty = false;\nlet editSeq = 0;\nlet banner = null;\n// Last-known saved JSON for each tab — used to build the post-save GitHub-style diff.\nlet jsonBaseline = { schema: '', values: '' };\n\n// Background-refresh bookkeeping, one pair of (loading flag, sequence counter) per tab: a\n// stale response (superseded by a newer load for the SAME tab before it resolved) is dropped\n// rather than clobbering fresher data — see loadTabData().\nlet schemaLoading = false;\nlet valuesLoading = false;\nlet schemaSeq = 0;\nlet valuesSeq = 0;\nlet autoLoadTimer = null;\nconst AUTO_LOAD_DEBOUNCE_MS = 200;\n\nconst contentEl = document.getElementById('content');\n\nwindow.addEventListener('beforeunload', (e) => {\n if (dirty) { e.preventDefault(); e.returnValue = ''; }\n});\n\nfunction setDirty(v) {\n dirty = v;\n\n if (v) { editSeq += 1; }\n}\n\nfunction escapeHtml(s) {\n return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');\n}\n\n// Escapes a value for embedding inside a single-quoted JS string literal that itself sits\n// inside an HTML attribute (e.g. onclick="fn('...')") — schema-derived names (column names,\n// tag names) aren't trusted local input, since the schema can come from a server backup ZIP\n// (see DistService.saveTemplateVariablesFile). Backslash/quote-escape for the JS-string\n// context first, then HTML-escape the result so it can't break out of the attribute either.\nfunction escapeJsAttr(s) {\n return escapeHtml(String(s).replace(/\\\\/g, '\\\\\\\\').replace(/'/g, "\\\\'"));\n}\n\nfunction showBanner(type, html, preserveContent) {\n banner = { type, html };\n\n if (preserveContent) {\n const existingBanner = contentEl.querySelector(':scope > .banner');\n const bannerEl = document.createElement('div');\n\n if (existingBanner) { existingBanner.remove(); }\n\n bannerEl.className = 'banner banner-' + type;\n bannerEl.innerHTML = html;\n contentEl.prepend(bannerEl);\n\n return;\n }\n\n render();\n}\n\nfunction clearBanner() { banner = null; }\n\n// Shown in place of a tab's content while it's never been loaded yet (first visit, or right\n// after a hard refresh of the page) — as opposed to ve-loading-indicator, which is for a\n// background reload of a tab that already has cached content on screen.\nfunction renderSkeleton() {\n let rows = '';\n\n for (let i = 0; i < 5; i++) { rows += '<div class="ve-skeleton-row"></div>'; }\n\n return '<div class="ve-skeleton">' + rows + '</div>';\n}\n\n// ── Data loading ─────────────────────────────────────────────────────────────\nasync function loadSchema(seq) {\n let r;\n let data;\n\n try {\n r = await fetch('/@api/variables/schema');\n data = await r.json();\n } catch (e) {\n if (seq !== schemaSeq) { return; }\n\n showBanner('error', 'Failed to load the schema: ' + escapeHtml(e.message));\n\n return;\n }\n\n if (seq !== schemaSeq) { return; }\n\n // An error answer (e.g. 503 with no dist service) is not a schema: kept as one, it rendered as\n // "No local __template_variables.json found" and offered to create a file over the real one.\n if (!r.ok) {\n schemaState = null;\n schemaRows = [];\n showBanner('error', 'Failed to load the schema: ' + escapeHtml((data && data.error) || ('HTTP ' + r.status)));\n\n return;\n }\n\n schemaState = data;\n schemaRows = (data.schema && Array.isArray(data.schema.tags)) ? data.schema.tags.map((t) => Object.assign({}, t)) : [];\n rawMode = data.exists && !data.schema;\n jsonBaseline.schema = snapshotSchemaJson();\n}\n\nasync function loadValues(seq) {\n let r;\n\n try {\n r = await fetch('/@api/variables/values');\n } catch (e) {\n if (seq !== valuesSeq) { return; }\n\n valuesState = null;\n valueRows = [];\n showBanner('error', 'Failed to load values: ' + escapeHtml(e.message));\n\n return;\n }\n\n if (!r.ok) {\n const err = await r.json().catch(() => ({}));\n\n if (seq !== valuesSeq) { return; }\n\n // A save is a full replacement. Keep the state explicitly unavailable after a failed\n // read so the user cannot accidentally submit an empty replacement set.\n valuesState = null;\n valueRows = [];\n showBanner('error', escapeHtml(err.error || 'Failed to load values.'));\n\n return;\n }\n\n let data;\n\n try {\n data = await r.json();\n } catch (e) {\n if (seq !== valuesSeq) { return; }\n\n valuesState = null;\n valueRows = [];\n showBanner('error', 'Failed to parse the values response: ' + escapeHtml(e.message));\n\n return;\n }\n\n if (seq !== valuesSeq) { return; }\n\n valuesState = data;\n valueRows = (data.combined || []).map((e) => Object.assign({}, e));\n jsonBaseline.values = snapshotValuesJson();\n}\n\nfunction snapshotSchemaJson() {\n if (!schemaState) { return ''; }\n\n if (rawMode) {\n const editorEl = document.getElementById('raw-editor');\n\n return editorEl ? editorEl.value : (schemaState.raw || '');\n }\n\n if (schemaState.raw && (!schemaState.schema || !Array.isArray(schemaRows))) {\n return schemaState.raw;\n }\n\n try {\n return JSON.stringify(Object.assign({}, schemaState.schema || {}, { tags: schemaRows }), null, 2);\n } catch (e) {\n return schemaState.raw || '';\n }\n}\n\nfunction snapshotValuesJson(rows) {\n try {\n return JSON.stringify(toExportableValueRows(rows || valueRows), null, 2);\n } catch (e) {\n return '[]';\n }\n}\n\nfunction normalizeJsonText(text) {\n try {\n return JSON.stringify(JSON.parse(text), null, 2);\n } catch (e) {\n return String(text || '');\n }\n}\n\n// Longest common subsequence line indices — small enough for template-variable JSON files.\nfunction lcsLineMatrix(a, b) {\n const m = a.length;\n const n = b.length;\n const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));\n\n for (let i = m - 1; i >= 0; i--) {\n for (let j = n - 1; j >= 0; j--) {\n dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);\n }\n }\n\n return dp;\n}\n\nfunction computeLineDiff(beforeText, afterText) {\n // Doubled backslash: outer getVariablesEditorHtml() template literal consumes one level.\n const a = String(beforeText || '').split('\\n');\n const b = String(afterText || '').split('\\n');\n const dp = lcsLineMatrix(a, b);\n const lines = [];\n let i = 0;\n let j = 0;\n\n while (i < a.length && j < b.length) {\n if (a[i] === b[j]) {\n lines.push({ type: 'equal', text: a[i] });\n i++;\n j++;\n } else if (dp[i + 1][j] >= dp[i][j + 1]) {\n lines.push({ type: 'remove', text: a[i] });\n i++;\n } else {\n lines.push({ type: 'add', text: b[j] });\n j++;\n }\n }\n\n while (i < a.length) {\n lines.push({ type: 'remove', text: a[i++] });\n }\n\n while (j < b.length) {\n lines.push({ type: 'add', text: b[j++] });\n }\n\n // Pair adjacent remove+add as inline-highlighted changes.\n const paired = [];\n\n for (let k = 0; k < lines.length; k++) {\n const cur = lines[k];\n const next = lines[k + 1];\n\n if (cur.type === 'remove' && next && next.type === 'add') {\n paired.push({ type: 'remove', text: cur.text, pair: next.text });\n paired.push({ type: 'add', text: next.text, pair: cur.text });\n k++;\n } else {\n paired.push(cur);\n }\n }\n\n return paired;\n}\n\nfunction inlineDiffHtml(text, pair, mode) {\n if (pair == null || pair === text) {\n return escapeHtml(text);\n }\n\n let start = 0;\n const minLen = Math.min(text.length, pair.length);\n\n while (start < minLen && text.charAt(start) === pair.charAt(start)) {\n start++;\n }\n\n let endOld = text.length;\n let endNew = pair.length;\n\n while (endOld > start && endNew > start && text.charAt(endOld - 1) === pair.charAt(endNew - 1)) {\n endOld--;\n endNew--;\n }\n\n const before = escapeHtml(text.slice(0, start));\n const mid = escapeHtml(text.slice(start, endOld));\n const after = escapeHtml(text.slice(endOld));\n const cls = mode === 'add' ? 've-diff-inline-add' : 've-diff-inline-remove';\n\n return before + (mid ? '<span class="' + cls + '">' + mid + '</span>' : '') + after;\n}\n\nfunction renderJsonDiffHtml(beforeText, afterText) {\n const before = normalizeJsonText(beforeText);\n const after = normalizeJsonText(afterText);\n\n if (before === after) {\n return { html: '<div class="ve-diff-empty">No differences in the JSON file.</div>', changeCount: 0 };\n }\n\n const lines = computeLineDiff(before, after);\n let changeCount = 0;\n let inHunk = false;\n\n const html = '<div class="ve-diff">' + lines.map((line) => {\n if (line.type === 'equal') {\n inHunk = false;\n\n return '<div class="ve-diff-line"><span class="ve-diff-prefix"> </span><span>' + escapeHtml(line.text) + '</span></div>';\n }\n\n const attrs = !inHunk\n ? ' data-change-index="' + changeCount + '"'\n : '';\n\n if (!inHunk) {\n changeCount++;\n inHunk = true;\n }\n\n if (line.type === 'remove') {\n return '<div class="ve-diff-line is-remove"' + attrs + '><span class="ve-diff-prefix">-</span><span>' +\n inlineDiffHtml(line.text, line.pair, 'remove') + '</span></div>';\n }\n\n return '<div class="ve-diff-line is-add"' + attrs + '><span class="ve-diff-prefix">+</span><span>' +\n inlineDiffHtml(line.text, line.pair, 'add') + '</span></div>';\n }).join('') + '</div>';\n\n return { html: html, changeCount: changeCount };\n}\n\nfunction showJsonDiffModal(beforeText, afterText, onConfirm) {\n const diff = renderJsonDiffHtml(beforeText, afterText);\n const hasChanges = diff.changeCount > 0;\n const overlay = document.createElement('div');\n\n overlay.className = 've-modal-overlay';\n overlay.innerHTML =\n '<div class="ve-modal ve-modal-diff" role="dialog" aria-modal="true">' +\n '<div class="ve-modal-title-row">' +\n '<div class="ve-modal-title">JSON changes</div>' +\n '<button type="button" class="btn btn-sm ve-modal-close" title="Close" aria-label="Close">✕</button>' +\n '</div>' +\n '<div class="ve-modal-body">' +\n (hasChanges\n ? '<div class="ve-diff-nav">' +\n '<button type="button" class="btn btn-sm ve-diff-prev" title="Previous change" aria-label="Previous change">←</button>' +\n '<button type="button" class="btn btn-sm ve-diff-next" title="Next change" aria-label="Next change">→</button>' +\n '<span class="ve-diff-nav-count">1 / ' + diff.changeCount + '</span>' +\n '</div>'\n : '') +\n diff.html +\n '</div>' +\n '<div class="ve-modal-actions">' +\n '<div class="hint">Review added (+) and removed (−) lines, including inline highlights for changed segments.</div>' +\n '<button type="button" class="btn btn-sm ve-modal-cancel">Cancel</button>' +\n '<button type="button" class="btn btn-sm btn-primary ve-modal-ok">Save Changes</button>' +\n '</div>' +\n '</div>';\n\n document.body.appendChild(overlay);\n overlay.querySelector('.ve-modal-ok').focus();\n\n let currentChange = 0;\n\n function updateChangeNav() {\n const countEl = overlay.querySelector('.ve-diff-nav-count');\n const prevBtn = overlay.querySelector('.ve-diff-prev');\n const nextBtn = overlay.querySelector('.ve-diff-next');\n const diffEl = overlay.querySelector('.ve-diff');\n\n if (!hasChanges || !diffEl) { return; }\n\n Array.prototype.forEach.call(diffEl.querySelectorAll('.ve-diff-line.is-current-change'), function (el) {\n el.classList.remove('is-current-change');\n });\n\n const target = diffEl.querySelector('.ve-diff-line[data-change-index="' + currentChange + '"]');\n\n if (target) {\n target.classList.add('is-current-change');\n target.scrollIntoView({ block: 'center', behavior: 'smooth' });\n }\n\n if (countEl) {\n countEl.textContent = (currentChange + 1) + ' / ' + diff.changeCount;\n }\n\n if (prevBtn) { prevBtn.disabled = currentChange <= 0; }\n if (nextBtn) { nextBtn.disabled = currentChange >= diff.changeCount - 1; }\n }\n\n if (hasChanges) {\n overlay.querySelector('.ve-diff-prev').addEventListener('click', function () {\n if (currentChange > 0) {\n currentChange--;\n updateChangeNav();\n }\n });\n overlay.querySelector('.ve-diff-next').addEventListener('click', function () {\n if (currentChange < diff.changeCount - 1) {\n currentChange++;\n updateChangeNav();\n }\n });\n // Wait a frame so the modal layout is ready before scrolling.\n if (typeof requestAnimationFrame === 'function') {\n requestAnimationFrame(function () { updateChangeNav(); });\n }\n }\n\n function close(confirmed) {\n overlay.remove();\n\n if (confirmed && typeof onConfirm === 'function') {\n onConfirm();\n }\n }\n\n overlay.querySelector('.ve-modal-ok').addEventListener('click', () => close(true));\n overlay.querySelector('.ve-modal-cancel').addEventListener('click', () => close(false));\n overlay.querySelector('.ve-modal-close').addEventListener('click', () => close(false));\n overlay.addEventListener('click', (ev) => {\n if (ev.target === overlay) { close(false); }\n });\n overlay.addEventListener('keydown', (ev) => {\n if (ev.key === 'ArrowLeft' && hasChanges) {\n ev.preventDefault();\n\n if (currentChange > 0) {\n currentChange--;\n updateChangeNav();\n }\n\n return;\n }\n\n if (ev.key === 'ArrowRight' && hasChanges) {\n ev.preventDefault();\n\n if (currentChange < diff.changeCount - 1) {\n currentChange++;\n updateChangeNav();\n }\n\n return;\n }\n\n if (ev.key === 'Enter') {\n ev.preventDefault();\n close(true);\n } else if (ev.key === 'Escape') {\n ev.preventDefault();\n close(false);\n }\n });\n}\n\n// Fetches fresh data for one tab, tracking a per-tab "loading" flag and sequence number so a\n// response that's superseded by a newer load for the SAME tab (started before this one\n// resolved) gets dropped instead of clobbering fresher data. Toggles the tab's own loading\n// flag but deliberately doesn't call render() itself — callers decide when to repaint.\nfunction loadTabData(tab) {\n if (tab === 'schema') {\n const seq = ++schemaSeq;\n\n schemaLoading = true;\n\n return loadSchema(seq).then(() => {\n if (seq === schemaSeq) { schemaLoading = false; }\n });\n }\n\n const seq = ++valuesSeq;\n\n valuesLoading = true;\n\n return loadValues(seq).then(() => {\n if (seq === valuesSeq) { valuesLoading = false; }\n });\n}\n\n// The manual "↻ Refresh" button — deliberately NOT debounced, so it's always the fast path\n// for "I just changed something in MI and want it now" (see scheduleAutoLoad for the\n// debounced, tab-switch-driven path).\nasync function doRefresh() {\n clearBanner();\n setDirty(false);\n\n // loadTabData() flips the tab's loading flag synchronously before its fetch resolves, so\n // render() has to run AFTER that call starts (not before) for the indicator to reflect it.\n const pending = loadTabData(activeTab);\n\n render();\n await pending;\n render();\n}\n\nfunction refresh() {\n if (dirty) {\n showConfirmModal('Discard unsaved changes?', doRefresh);\n\n return;\n }\n\n doRefresh();\n}\n\n// Debounces the network round-trip that automatically follows a tab switch: rapidly flipping\n// between tabs (or landing on one only to immediately leave it) only fires the load that's\n// still pending once things settle, rather than one request per click.\nfunction scheduleAutoLoad(tab) {\n if (autoLoadTimer) { clearTimeout(autoLoadTimer); }\n\n autoLoadTimer = setTimeout(() => {\n autoLoadTimer = null;\n\n // loadTabData() flips the tab's loading flag synchronously before its fetch resolves, so\n // render() has to run AFTER this call starts (not before) for the indicator to reflect it.\n const pending = loadTabData(tab);\n\n if (activeTab === tab) { render(); }\n\n pending.then(() => {\n if (activeTab === tab) { render(); }\n });\n }, AUTO_LOAD_DEBOUNCE_MS);\n}\n\nfunction doSwitchTab(tab) {\n activeTab = tab;\n setDirty(false);\n clearBanner();\n\n // Switching tabs by hand always starts the Values tab in table view — only a direct link\n // (?tab=values&mode=json, handled at bootstrap) opens straight into raw/JSON mode.\n if (tab === 'values') { valuesRawMode = false; }\n\n setUrlState(tab, valuesRawMode);\n\n document.getElementById('tab-schema').classList.toggle('active', tab === 'schema');\n document.getElementById('tab-values').classList.toggle('active', tab === 'values');\n\n // Paint immediately with whatever's already cached for this tab (or a skeleton, if it's\n // never been loaded) instead of leaving the previous tab's content on screen until the\n // debounced fetch below resolves.\n render();\n scheduleAutoLoad(tab);\n}\n\nfunction switchTab(tab) {\n if (dirty) {\n showConfirmModal('Discard unsaved changes and switch tabs?', () => doSwitchTab(tab));\n\n return;\n }\n\n doSwitchTab(tab);\n}\n\n// ── Schema tab ───────────────────────────────────────────────────────────────\n\n// A styled <select> (not a native <datalist>, whose popup/selected-state styling browsers\n// won't let us theme) with an escape hatch: picking "Custom…" prompts for any string, so a\n// tag_type/tag_source MI adds in the future can be configured here without a pp-dev code change.\nconst CUSTOM_TYPE_SENTINEL = '__custom__';\n\nfunction customSelectCell(field, knownValues, fallback, tag, i) {\n const currentValue = tag[field] || fallback;\n const isKnown = knownValues.includes(currentValue);\n const knownOptions = knownValues.map((v) =>\n '<option value="' + v + '"' + (currentValue === v ? ' selected' : '') + '>' + v + '</option>',\n ).join('');\n const customOption = !currentValue || isKnown\n ? ''\n : '<option value="' + escapeHtml(currentValue) + '" selected>' + escapeHtml(currentValue) + ' (custom)</option>';\n\n return (\n '<select onchange="onCustomSelectChange(' + i + ',this,\\'' + field + '\\')">' +\n knownOptions +\n customOption +\n '<option value="' + CUSTOM_TYPE_SENTINEL + '">Custom…</option>' +\n '</select>'\n );\n}\n\nfunction typeSelectCell(tag, i) {\n return customSelectCell('tag_type', KNOWN_TYPES, 'text', tag, i);\n}\n\nfunction tagSourceSelectCell(tag, i) {\n return customSelectCell('tag_source', KNOWN_TAG_SOURCES, '', tag, i);\n}\n\n// Short, one-line version for inline display next to the field — click "?" for the full\n// per-tag_source breakdown (additionalOptionsDetails()) with examples, shown in a modal.\nfunction additionalOptionsHint(tag) {\n const tagType = tag.tag_type;\n\n if (tagType === 'select' || tagType === 'multiselect') {\n const source = tag.tag_source || 'static';\n\n if (source === 'static') { return 'The option list itself.'; }\n if (source === 'dataset_data') { return 'Which dataset + columns to pull options from.'; }\n if (source === 'element') { return 'Optional filter by element type.'; }\n if (source === 'segment') { return 'Accepted by MI\\'s form, but not read for this source.'; }\n\n return 'Not used for tag_source "' + escapeHtml(source) + '" — MI loads its options live.';\n }\n\n if (tagType === 'list') {\n return 'Column definitions for list-of-objects items, or "" for a flat list of strings.';\n }\n\n if (tagType === 'boolean') {\n return 'Not used for tag_type "boolean".';\n }\n\n return 'No confirmed runtime use for tag_type "' + escapeHtml(tagType || 'text') + '".';\n}\n\n// Full breakdown with examples, reverse-engineered from MI's own options-loading logic —\n// shown in a modal (showAdditionalOptionsHelp()) so the inline hint above can stay short.\nfunction additionalOptionsDetails(tag) {\n const tagType = tag.tag_type;\n\n if (tagType === 'select' || tagType === 'multiselect') {\n const source = tag.tag_source || 'static';\n\n if (source === 'static') {\n return '<p>The option list itself. Each entry is a plain string, or an object with <code>id</code>/<code>text</code>:</p>' +\n '<pre>[{"id":"1","text":"One"},"Two"]</pre>' +\n '<p>A plain string entry uses itself as both the stored value and the label.</p>';\n }\n\n if (source === 'dataset_data') {\n return '<p>Pulls options live from a dataset, by column name:</p>' +\n '<pre>{"dataset_id":123,"key_column":"id_col","text_column":"name_col"}</pre>' +\n '<p><code>key_column</code> supplies the option value, <code>text_column</code> the label.</p>';\n }\n\n if (source === 'element') {\n return '<p>Optional — restricts the dashboard-element list to one type:</p>' +\n '<pre>{"type":"metric"}</pre>' +\n '<p>Accepted values: <code>metric</code>, <code>multi-metric chart</code>, <code>internal report</code>, <code>external report</code>, <code>other external content</code>. Leave empty to list all elements.</p>';\n }\n\n if (source === 'segment') {\n return '<p>MI\\'s own "Create/Edit Variable" form shows this field for tag_source "segment", but its options-loading endpoint never actually reads it — the segment list always comes back unfiltered. Best left empty.</p>';\n }\n\n return '<p>Not used — for tag_source "' + escapeHtml(source) + '", MI loads options live from its own data (e.g. the dataset/announcement/group/category/page list) and never consults this field.</p>';\n }\n\n if (tagType === 'list') {\n return '<p>An array of column definitions — each list item becomes an object keyed by these names:</p>' +\n '<pre>[{"name":"id","type":"textarea"},{"name":"color","type":"color"}]</pre>' +\n '<p>A bare string entry is shorthand for <code>{"name": &lt;that string&gt;, "type": "textarea"}</code>. Leave as <code>""</code> (empty string) instead for a flat list of plain strings.</p>' +\n '<p>Supported field <code>type</code>s: <code>textarea</code>, <code>color</code>, <code>select</code>, <code>multi-select</code>, <code>file</code>.</p>' +\n '<p>For a <code>select</code>/<code>multi-select</code> column, add <code>source</code> (defaults to <code>"static"</code>) and <code>options</code> — same per-source rules as the tag-level <code>tag_source</code>/<code>additional_options</code> above, just scoped to this one column and fed by <code>options</code> instead:</p>' +\n '<pre>{"name":"country","type":"select","options":["USA","Canada"]}</pre>' +\n '<p>MI\\'s own list-item editor never reads this column\\'s own <code>additional_options</code> — only <code>options</code> — so leave it out.</p>';\n }\n\n if (tagType === 'boolean') {\n return '<p>MI\\'s own editor doesn\\'t show this field for tag_type "boolean", and there\\'s no known runtime use for it — leave empty.</p>';\n }\n\n return '<p>MI\\'s own editor lets you fill this in for tag_type "' + escapeHtml(tagType || 'text') + '", but there\\'s no confirmed runtime consumer for it — treat it as unused/reserved.</p>';\n}\n\nfunction showAdditionalOptionsHelp(i) {\n const tag = schemaRows[i];\n\n showInfoModal('additional_options — ' + escapeHtml(tag.tag_type || 'text') + (tag.tag_source ? ' / ' + escapeHtml(tag.tag_source) : ''), additionalOptionsDetails(tag));\n}\n\n// Mirrors MI's own "Create/Edit Variable" form: which fields it shows/hides depends on\n// tag_type (and, for the tag_source picker and additional_options, tag_source too). For a\n// tag_type MI hasn't defined yet (i.e. a custom one entered via typeSelectCell's escape hatch),\n// there's no such logic to mirror, so give full access to everything instead of guessing.\nfunction fieldVisibility(tag) {\n const type = tag.tag_type;\n\n if (!KNOWN_TYPES.includes(type)) {\n return { tagSource: true, useHtml: true, rawHtml: true, useJson: true, defaultValue: true, additionalOptions: true };\n }\n\n const isTextOrList = type === 'text' || type === 'list';\n const isSelectLike = type === 'select' || type === 'multiselect';\n const useHtmlOn = tag.use_hmtl_editor_ind === 'Y';\n const source = tag.tag_source || 'static';\n\n return {\n tagSource: isSelectLike,\n useHtml: type === 'text',\n rawHtml: isTextOrList && !useHtmlOn,\n useJson: (type === 'text' && !useHtmlOn) || type === 'list',\n defaultValue: isTextOrList,\n additionalOptions:\n type === 'text' || type === 'file' || type === 'list' || type === 'color' ||\n (isSelectLike && HAND_ENTERED_OPTION_SOURCES.indexOf(source) !== -1),\n };\n}\n\nfunction checkboxField(label, checked, onchangeAttr) {\n return '<label class="checkbox-label"><input type="checkbox" ' + (checked ? 'checked' : '') + ' ' + onchangeAttr + ' />' + escapeHtml(label) + '</label>';\n}\n\nfunction editorFlagsRow(tag, i, vis) {\n if (!vis.useHtml && !vis.rawHtml && !vis.useJson) {\n return '';\n }\n\n const parts = [];\n\n if (vis.useHtml) {\n parts.push(checkboxField('Use WYSIWYG Editor', tag.use_hmtl_editor_ind === 'Y', 'onchange="updateEditorFlag(' + i + ',\\'use_hmtl_editor_ind\\',this.checked)"'));\n }\n\n if (vis.rawHtml) {\n parts.push(checkboxField('Raw HTML', tag.use_raw_html_ind === 'Y', 'onchange="updateEditorFlag(' + i + ',\\'use_raw_html_ind\\',this.checked)"'));\n }\n\n if (vis.useJson) {\n parts.push(checkboxField('Use JSON Editor', tag.use_json_editor_ind === 'Y', 'onchange="updateEditorFlag(' + i + ',\\'use_json_editor_ind\\',this.checked)"'));\n }\n\n return '<div style="grid-column:1/-1" class="checkbox-row">' + parts.join('') + '</div>';\n}\n\nfunction nameWarningHint(name) {\n if (!name) {\n return '<div class="hint" style="color:var(--red)">Name is required.</div>';\n }\n\n if (!NAME_FORMAT_REGEX.test(name)) {\n return '<div class="hint" style="color:var(--red)">Contains a character MI\\'s own editor doesn\\'t allow — only letters, digits, underscore, hyphen, and whitespace.</div>';\n }\n\n return '';\n}\n\n// Re-renders the whole schema table (needed because changing tag_type/tag_source/an editor\n// flag can change which OTHER fields this same row should show), then restores row i's\n// advanced panel to whatever open/closed state it was in — render() always starts it closed.\nfunction rerenderKeepingAdvancedOpen(i) {\n const before = document.getElementById('advanced-' + i);\n const wasOpen = !!before && before.style.display !== 'none';\n\n render();\n\n if (wasOpen) {\n const after = document.getElementById('advanced-' + i);\n const btn = document.getElementById('expand-btn-' + i);\n\n if (after) { after.style.display = ''; }\n if (btn) { btn.textContent = '▾'; btn.setAttribute('aria-expanded', 'true'); }\n }\n}\n\nfunction updateEditorFlag(i, field, checked) {\n schemaRows[i][field] = checked ? 'Y' : 'N';\n\n // Matches MI's own form: turning WYSIWYG on is exclusive with Raw HTML / JSON Editor.\n if (field === 'use_hmtl_editor_ind' && checked) {\n schemaRows[i].use_raw_html_ind = 'N';\n schemaRows[i].use_json_editor_ind = 'N';\n }\n\n setDirty(true);\n rerenderKeepingAdvancedOpen(i);\n}\n\n// A custom HTML modal instead of window.prompt() — some embedded webviews (e.g. Cursor's\n// built-in browser) don't implement native prompt()/confirm() at all, so it just returns null\n// immediately with no dialog ever shown. This works identically everywhere.\nfunction showCustomValueModal(title, initialValue, onDone) {\n const overlay = document.createElement('div');\n\n overlay.className = 've-modal-overlay';\n overlay.innerHTML =\n '<div class="ve-modal">' +\n '<div class="ve-modal-title">' + escapeHtml(title) + '</div>' +\n '<input type="text" class="ve-modal-input" value="' + escapeHtml(initialValue) + '" />' +\n '<div class="ve-modal-actions">' +\n '<button type="button" class="btn btn-sm ve-modal-cancel">Cancel</button>' +\n '<button type="button" class="btn btn-sm btn-primary ve-modal-ok">OK</button>' +\n '</div>' +\n '</div>';\n\n document.body.appendChild(overlay);\n\n const inputEl = overlay.querySelector('.ve-modal-input');\n\n inputEl.focus();\n inputEl.select();\n\n function close(value) {\n overlay.remove();\n onDone(value);\n }\n\n overlay.querySelector('.ve-modal-ok').addEventListener('click', () => close(inputEl.value));\n overlay.querySelector('.ve-modal-cancel').addEventListener('click', () => close(null));\n overlay.addEventListener('click', (ev) => {\n if (ev.target === overlay) {\n close(null);\n }\n });\n inputEl.addEventListener('keydown', (ev) => {\n if (ev.key === 'Enter') {\n ev.preventDefault();\n close(inputEl.value);\n } else if (ev.key === 'Escape') {\n ev.preventDefault();\n close(null);\n }\n });\n}\n\n// Same rationale as showCustomValueModal() — window.confirm() is unreliable in some embedded\n// webviews, so any "discard unsaved changes?" gate needs to go through this instead.\nfunction showConfirmModal(message, onConfirm) {\n const overlay = document.createElement('div');\n\n overlay.className = 've-modal-overlay';\n overlay.innerHTML =\n '<div class="ve-modal">' +\n '<div class="ve-modal-title">' + escapeHtml(message) + '</div>' +\n '<div class="ve-modal-actions">' +\n '<button type="button" class="btn btn-sm ve-modal-cancel">Cancel</button>' +\n '<button type="button" class="btn btn-sm btn-primary ve-modal-ok">OK</button>' +\n '</div>' +\n '</div>';\n\n document.body.appendChild(overlay);\n overlay.querySelector('.ve-modal-ok').focus();\n\n function close(confirmed) {\n overlay.remove();\n\n if (confirmed) {\n onConfirm();\n }\n }\n\n overlay.querySelector('.ve-modal-ok').addEventListener('click', () => close(true));\n overlay.querySelector('.ve-modal-cancel').addEventListener('click', () => close(false));\n overlay.addEventListener('click', (ev) => {\n if (ev.target === overlay) {\n close(false);\n }\n });\n overlay.addEventListener('keydown', (ev) => {\n if (ev.key === 'Enter') {\n ev.preventDefault();\n close(true);\n } else if (ev.key === 'Escape') {\n ev.preventDefault();\n close(false);\n }\n });\n}\n\n// A plain info modal (no input, single "Close" action) — used to keep long explanations\n// (e.g. additional_options' full per-tag_source breakdown) out of the inline hint text.\nfunction showInfoModal(title, bodyHtml) {\n const overlay = document.createElement('div');\n\n overlay.className = 've-modal-overlay';\n overlay.innerHTML =\n '<div class="ve-modal ve-modal-wide">' +\n '<div class="ve-modal-title">' + escapeHtml(title) + '</div>' +\n '<div class="ve-modal-body">' + bodyHtml + '</div>' +\n '<div class="ve-modal-actions">' +\n '<button type="button" class="btn btn-sm btn-primary ve-modal-ok">Close</button>' +\n '</div>' +\n '</div>';\n\n document.body.appendChild(overlay);\n overlay.querySelector('.ve-modal-ok').focus();\n\n function close() { overlay.remove(); }\n\n overlay.querySelector('.ve-modal-ok').addEventListener('click', close);\n overlay.addEventListener('click', (ev) => {\n if (ev.target === overlay) { close(); }\n });\n overlay.addEventListener('keydown', (ev) => {\n if (ev.key === 'Enter' || ev.key === 'Escape') {\n ev.preventDefault();\n close();\n }\n });\n}\n\nfunction onCustomSelectChange(i, selectEl, field) {\n if (selectEl.value !== CUSTOM_TYPE_SENTINEL) {\n updateSchemaField(i, field, selectEl.value);\n\n // tag_type/tag_source changes can change which other fields this row should show.\n rerenderKeepingAdvancedOpen(i);\n\n return;\n }\n\n showCustomValueModal('Custom ' + field + ' value', schemaRows[i][field] || '', (custom) => {\n if (custom) {\n updateSchemaField(i, field, custom);\n }\n\n // Re-render either way: on success, so the select shows the new custom option instead of\n // "Custom…"; on cancel, so it reverts to the row's actual (unchanged) value.\n rerenderKeepingAdvancedOpen(i);\n });\n}\n\nfunction renderSchemaTab() {\n if (!schemaState) { return renderSkeleton(); }\n\n if (rawMode) {\n return \`\n <div class="raw-toggle"><button class="btn btn-sm" onclick="toggleRawMode()">Switch to table view</button></div>\n \${schemaState.parseError ? '<div class="banner banner-error">The file is not a valid schema: ' + escapeHtml(schemaState.parseError) + '</div>' : ''}\n <textarea class="raw-editor" id="raw-editor" oninput="setDirty(true)">\${escapeHtml(schemaState.raw || '')}</textarea>\n <div class="hint">Editing the raw file text directly. Must be valid JSON with a top-level "tags" array.</div>\n \`;\n }\n\n if (!schemaState.exists) {\n return \`<div class="empty-state">No local __template_variables.json found.<br><button class="btn btn-sm" style="margin-top:10px" onclick="addSchemaRow()">Create one — add first variable</button></div>\`;\n }\n\n const rows = schemaRows.map((tag, i) => {\n const vis = fieldVisibility(tag);\n // An empty string means "not set" (the flat-list/no-options convention) — show it as an\n // empty field instead of the literal two-character JSON string '""'.\n const additionalOptionsRaw = tag.additional_options !== undefined && tag.additional_options !== ''\n ? JSON.stringify(tag.additional_options)\n : '';\n\n const defaultValueCell = vis.defaultValue\n ? '<textarea class="cell-textarea" oninput="autosizeCellTextarea(this);updateSchemaField(' + i + ',\\'default_value\\',this.value)">' + escapeHtml(tag.default_value || '') + '</textarea>'\n : '<textarea class="cell-textarea" disabled title="MI\\'s own editor has no Default Value field for this tag_type.">' + escapeHtml(tag.default_value || '') + '</textarea>';\n\n const tagSourceCell = vis.tagSource\n ? tagSourceSelectCell(tag, i)\n : '<span class="badge">' + escapeHtml(tag.tag_source || 'static') + '</span> <div class="hint">Only configurable for select/multiselect in MI\\'s own editor.</div>';\n\n const additionalOptionsCell = vis.additionalOptions\n ? '<textarea id="ao-raw-' + i + '" oninput="updateSchemaAdditionalOptions(' + i + ',this.value)">' + escapeHtml(additionalOptionsRaw) + '</textarea>'\n : '<textarea id="ao-raw-' + i + '" disabled title="Not used for this tag_type/tag_source combination in MI\\'s own editor.">' + escapeHtml(additionalOptionsRaw) + '</textarea>';\n\n const listColumnsEditor = tag.tag_type === 'list' ? listColumnsEditorHtml(tag, i) : '';\n\n return \`\n <tr class="schema-row" data-row-index="\${i}" ondragover="onSchemaRowDragOver(event)" ondrop="onSchemaRowDrop(event)" ondragleave="onSchemaRowDragLeave(event)">\n <td class="col-expand">\n <div class="ve-expand-cell">\n <span class="ve-schema-row-handle" draggable="true" ondragstart="onSchemaRowDragStart(event)" ondragend="onSchemaRowDragEnd(event)" title="Drag to reorder" aria-label="Drag to reorder" role="button" tabindex="0">⠿</span>\n <button class="btn btn-sm ve-expand-btn" id="expand-btn-\${i}" onclick="toggleSchemaAdvanced(\${i})" aria-expanded="false" title="Advanced fields (uid, tag_source, additional_options, editor flags)">▸</button>\n </div>\n </td>\n <td class="col-name">\n <input type="text" value="\${escapeHtml(tag.name || '')}" oninput="updateSchemaField(\${i},'name',this.value)" />\n \${nameWarningHint(tag.name)}\n </td>\n <td class="col-type">\${typeSelectCell(tag, i)}</td>\n <td>\${defaultValueCell}</td>\n <td><textarea class="cell-textarea" oninput="autosizeCellTextarea(this);updateSchemaField(\${i},'description',this.value)">\${escapeHtml(tag.description || '')}</textarea></td>\n <td class="col-actions">\n <button class="btn btn-sm" onclick="removeSchemaRow(\${i})">✕</button>\n </td>\n </tr>\n <tr class="details-row" id="advanced-\${i}" data-row-index="\${i}" style="display:none;" ondragover="onSchemaRowDragOver(event)" ondrop="onSchemaRowDrop(event)" ondragleave="onSchemaRowDragLeave(event)">\n <td colspan="6" style="border:none;">\n <div class="details-grid">\n <div class="details-grid-item-wrapper">\n <div class="box-style"><label>uid</label><input type="text" value="\${escapeHtml(tag.uid || '')}" oninput="updateSchemaField(\${i},'uid',this.value)" /></div>\n <div class="box-style"><label>tag_source</label>\${tagSourceCell}</div>\n <div class="box-style" style="height:fit-content;">\${editorFlagsRow(tag, i, vis)}</div>\n </div>\n <div class="box-style" style="grid-column:1/-1">\n <label>additional_options (raw JSON) <button type="button" class="btn btn-sm" style="text-transform:none;padding:0 6px;margin-left:4px" onclick="showAdditionalOptionsHelp(\${i})" title="What goes here for this type/source?">?</button></label>\n \${additionalOptionsCell}\n <div class="hint">\${additionalOptionsHint(tag)}</div>\n \n </div>\n \${listColumnsEditor}\n </div>\n </td>\n </tr>\n \`;\n }).join('');\n\n return \`\n <div class="raw-toggle"><button class="btn btn-sm" onclick="toggleRawMode()">View/edit raw JSON</button></div>\n <table>\n <thead><tr><th class="col-expand"></th><th class="col-name">Name</th><th class="col-type">Type</th><th>Default value</th><th>Description</th><th class="col-actions"></th></tr></thead>\n <tbody>\${rows || '<tr><td colspan="6" class="empty-state">No variables yet.</td></tr>'}</tbody>\n </table>\n <button class="btn btn-sm" style="margin-top:10px" onclick="addSchemaRow()">+ Add variable</button>\n <div class="hint">Pick a known type, or choose "Custom…" to enter any value if MI adds a new tag_type.</div>\n \`;\n}\n\nfunction toggleRawMode() {\n if (!rawMode) {\n // Entering raw mode: serialize the current table state so edits aren't lost.\n schemaState.raw = JSON.stringify(Object.assign({}, schemaState.schema, { tags: schemaRows }), null, 2);\n } else {\n // Leaving raw mode: try to parse back into the table; keep raw mode on failure.\n const editorEl = document.getElementById('raw-editor');\n const editorText = editorEl ? editorEl.value : schemaState.raw;\n\n try {\n const parsed = JSON.parse(editorText);\n\n if (!parsed || !Array.isArray(parsed.tags)) { throw new Error('Missing "tags" array'); }\n\n schemaState.schema = parsed;\n schemaRows = parsed.tags.map((t) => Object.assign({}, t));\n schemaState.exists = true;\n delete schemaState.parseError;\n } catch (e) {\n // showBanner() re-renders, which would otherwise regenerate this textarea from the last\n // known-good schemaState.raw — put the user's (still-broken) text back afterward so their\n // in-progress edit isn't silently discarded.\n showBanner('error', 'Cannot switch to table view: ' + escapeHtml(e.message));\n\n const freshEditorEl = document.getElementById('raw-editor');\n\n if (freshEditorEl) { freshEditorEl.value = editorText; }\n\n return;\n }\n }\n\n rawMode = !rawMode;\n render();\n}\n\nfunction updateSchemaField(i, field, value) {\n schemaRows[i][field] = value;\n setDirty(true);\n}\n\nfunction autosizeCellTextarea(el) {\n if (!el || el.type === 'color' || el.type === 'checkbox') { return; }\n\n el.style.height = '0';\n el.style.height = Math.max(30, el.scrollHeight) + 'px';\n}\n\nfunction autosizeAllCellTextareas() {\n Array.prototype.forEach.call(\n document.querySelectorAll('.cell-textarea, .ve-list-field textarea, .ve-list-field input[type=text], .ve-list-field select, .ve-values-field textarea, .ve-values-field input[type=text], .ve-values-field select'),\n autosizeCellTextarea\n );\n}\n\nfunction updateSchemaAdditionalOptions(i, text) {\n try {\n schemaRows[i].additional_options = text.trim() ? JSON.parse(text) : '';\n invalidAdditionalOptionsRows.delete(schemaRows[i]);\n } catch {\n schemaRows[i].additional_options = text;\n invalidAdditionalOptionsRows.add(schemaRows[i]);\n }\n\n setDirty(true);\n}\n\n// Normalize list-tag additional_options into editable column objects. Non-array values\n// (flat-list "" / invalid raw text) become an empty column list — editing via the UI replaces them.\nfunction listColumnsFor(tag) {\n const opts = tag && tag.additional_options;\n\n if (!Array.isArray(opts)) { return []; }\n\n return opts.map((c) => (typeof c === 'string' ? { name: c, type: 'textarea' } : Object.assign({}, c)));\n}\n\nfunction syncAdditionalOptionsRaw(i) {\n const ta = document.getElementById('ao-raw-' + i);\n\n if (!ta || ta.disabled) { return; }\n\n const value = schemaRows[i].additional_options;\n\n ta.value = value !== undefined && value !== '' ? JSON.stringify(value) : '';\n}\n\n// Persist select/multi-select columns without a redundant source when options are present —\n// source is only kept when there is no options list (non-static / empty options).\nfunction normalizeListColumn(col) {\n const out = Object.assign({ name: '', type: 'textarea' }, col);\n const type = out.type || 'textarea';\n const isSelect = type === 'select' || type === 'multi-select';\n\n if (!isSelect) {\n delete out.source;\n delete out.options;\n\n return out;\n }\n\n if (Array.isArray(out.options) && out.options.length) {\n delete out.source;\n } else {\n delete out.options;\n\n if (!out.source) {\n out.source = 'static';\n }\n }\n\n return out;\n}\n\nfunction commitListColumns(i, columns, rerender) {\n const normalized = columns.map(normalizeListColumn);\n\n schemaRows[i].additional_options = normalized.length ? normalized : '';\n invalidAdditionalOptionsRows.delete(schemaRows[i]);\n setDirty(true);\n\n if (rerender) {\n rerenderKeepingAdvancedOpen(i);\n } else {\n syncAdditionalOptionsRaw(i);\n }\n}\n\nfunction listColumnSelectOptions(values, selected) {\n return values.map((v) =>\n '<option value="' + escapeHtml(v) + '"' + (v === selected ? ' selected' : '') + '>' + escapeHtml(v) + '</option>'\n ).join('');\n}\n\nfunction listColumnItemHtml(col, i, colIndex) {\n const name = col.name != null ? String(col.name) : '';\n const type = LIST_COLUMN_TYPES.indexOf(col.type) !== -1 ? col.type : (col.type || 'textarea');\n const isSelect = type === 'select' || type === 'multi-select';\n const source = col.source || 'static';\n // Same { id, text } | string shape as the Values tab's select widget (see listItemFieldsHtml())\n // — show the readable text, not "[object Object]", for a column with object-shaped options.\n // Editing this comma-separated field still only ever writes back plain strings (id === text) —\n // this UI has never round-tripped distinct id/text pairs, only raw JSON editing does.\n const optionsText = Array.isArray(col.options) ? col.options.map((o) => normalizeOptionEntry(o).text).join(', ') : '';\n\n const typeOptions = listColumnSelectOptions(\n LIST_COLUMN_TYPES.indexOf(type) !== -1 ? LIST_COLUMN_TYPES : LIST_COLUMN_TYPES.concat([type]),\n type\n );\n\n let bottom =\n '<div class="ve-ao-column-field">' +\n '<label>type</label>' +\n '<select onchange="updateListColumnField(' + i + ',' + colIndex + ',\\'type\\',this.value)">' + typeOptions + '</select>' +\n '</div>';\n\n if (isSelect) {\n const sourceOptions = listColumnSelectOptions(\n LIST_COLUMN_SOURCES.indexOf(source) !== -1 ? LIST_COLUMN_SOURCES : LIST_COLUMN_SOURCES.concat([source]),\n source\n );\n\n bottom +=\n '<div class="ve-ao-column-field">' +\n '<label>source</label>' +\n '<select onchange="updateListColumnField(' + i + ',' + colIndex + ',\\'source\\',this.value)">' + sourceOptions + '</select>' +\n '</div>';\n\n if (source === 'static') {\n bottom +=\n '<div class="ve-ao-column-field">' +\n '<label>options</label>' +\n '<input type="text" value="' + escapeHtml(optionsText) + '" placeholder="Options (comma-separated)" oninput="updateListColumnOptions(' + i + ',' + colIndex + ',this.value)" />' +\n '</div>';\n }\n }\n\n return '<div class="ve-ao-column" data-row="' + i + '" data-col-index="' + colIndex + '" ondragover="onListColumnDragOver(event)" ondrop="onListColumnDrop(event)" ondragleave="onListColumnDragLeave(event)">' +\n '<span class="ve-ao-column-handle" draggable="true" ondragstart="onListColumnDragStart(event)" ondragend="onListColumnDragEnd(event)" title="Drag to reorder" aria-label="Drag to reorder" role="button" tabindex="0">⠿</span>' +\n '<div class="ve-ao-column-fields">' +\n '<div class="ve-ao-column-top">' +\n '<div class="ve-ao-column-field">' +\n '<label>name</label>' +\n '<div class="ve-ao-column-name">' +\n '<input type="text" value="' + escapeHtml(name) + '" oninput="updateListColumnField(' + i + ',' + colIndex + ',\\'name\\',this.value)" placeholder="Column name" />' +\n '<button type="button" class="btn btn-sm ve-ao-column-remove" onclick="removeListColumn(' + i + ',' + colIndex + ')" title="Remove column">✕</button>' +\n '</div>' +\n '</div>' +\n '</div>' +\n '<div class="ve-ao-column-bottom">' + bottom + '</div>' +\n '</div>' +\n '</div>';\n}\n\nfunction listColumnsEditorHtml(tag, i) {\n const cols = listColumnsFor(tag);\n const itemsHtml = cols.map((col, colIndex) => listColumnItemHtml(col, i, colIndex)).join('');\n\n return '<div class="box-style" style="grid-column:1/-1">' +\n '<label>additional_options (UI version)<button type="button" class="btn btn-sm" style="text-transform:none;padding:0 6px;margin-left:4px" onclick="showListColumnsHelp()" title="What is additional_options for list?">?</button></label>' +\n \n '<div class="ve-ao-columns">' + itemsHtml + '</div>' +\n '<button type="button" class="btn btn-sm" style="margin-top:8px" onclick="addListColumn(' + i + ')">+ Add column</button>' +\n '</div>';\n}\n\nfunction showListColumnsHelp() {\n showInfoModal(\n 'additional_options — list',\n '<p>An array of column definitions — each list item becomes an object keyed by these names. Supported field <code>types</code>: <code>textarea</code>, <code>color</code>, <code>select</code>, <code>multi-select</code>, <code>file</code>.</p>' +\n '<p>Edited below as a column editor — reordering/adding/removing there is the same thing as hand-editing this JSON array, just without needing to write JSON.</p>'\n );\n}\n\nfunction addListColumn(i) {\n const cols = listColumnsFor(schemaRows[i]);\n\n cols.push({ name: '', type: 'textarea' });\n commitListColumns(i, cols, true);\n}\n\nfunction removeListColumn(i, colIndex) {\n const cols = listColumnsFor(schemaRows[i]);\n\n cols.splice(colIndex, 1);\n commitListColumns(i, cols, true);\n}\n\nvar listColumnDrag = null;\n\nfunction onListColumnDragStart(ev) {\n const col = ev.target.closest('.ve-ao-column');\n\n if (!col) { return; }\n\n listColumnDrag = {\n row: Number(col.dataset.row),\n fromIndex: Number(col.dataset.colIndex),\n };\n col.classList.add('is-dragging');\n ev.dataTransfer.effectAllowed = 'move';\n // Firefox requires setData for the drag to start.\n ev.dataTransfer.setData('text/plain', String(listColumnDrag.fromIndex));\n}\n\nfunction onListColumnDragOver(ev) {\n // Schema-row reordering can pass over list-column cards inside an open advanced panel —\n // accept the drag here so the drop isn't blocked by nested handlers.\n if (schemaRowDrag) {\n ev.preventDefault();\n ev.dataTransfer.dropEffect = 'move';\n\n const details = ev.currentTarget.closest('tr.details-row');\n\n if (details) { details.classList.add('is-drop-target'); }\n\n return;\n }\n\n if (!listColumnDrag) { return; }\n\n const col = ev.currentTarget;\n\n if (Number(col.dataset.row) !== listColumnDrag.row) { return; }\n\n ev.preventDefault();\n ev.dataTransfer.dropEffect = 'move';\n col.classList.add('is-drop-target');\n}\n\nfunction onListColumnDragLeave(ev) {\n const col = ev.currentTarget;\n\n // Ignore leave events that stay inside the same column card (child elements).\n if (col.contains(ev.relatedTarget)) { return; }\n\n col.classList.remove('is-drop-target');\n\n if (schemaRowDrag) {\n const details = col.closest('tr.details-row');\n\n if (details && !details.contains(ev.relatedTarget)) {\n details.classList.remove('is-drop-target');\n }\n }\n}\n\nfunction onListColumnDrop(ev) {\n ev.preventDefault();\n\n const col = ev.currentTarget;\n\n col.classList.remove('is-drop-target');\n\n if (schemaRowDrag) {\n const details = col.closest('tr.details-row');\n\n if (!details) { return; }\n\n details.classList.remove('is-drop-target');\n\n const toIndex = Number(details.dataset.rowIndex);\n const fromIndex = schemaRowDrag.fromIndex;\n\n schemaRowDrag = null;\n\n if (fromIndex !== toIndex) {\n reorderSchemaRow(fromIndex, toIndex);\n }\n\n return;\n }\n\n if (!listColumnDrag || Number(col.dataset.row) !== listColumnDrag.row) { return; }\n\n const toIndex = Number(col.dataset.colIndex);\n const fromIndex = listColumnDrag.fromIndex;\n\n listColumnDrag = null;\n\n if (fromIndex === toIndex) { return; }\n\n reorderListColumn(Number(col.dataset.row), fromIndex, toIndex);\n}\n\nfunction onListColumnDragEnd() {\n listColumnDrag = null;\n Array.prototype.forEach.call(document.querySelectorAll('.ve-ao-column.is-dragging, .ve-ao-column.is-drop-target'), function (el) {\n el.classList.remove('is-dragging', 'is-drop-target');\n });\n}\n\nfunction reorderListColumn(i, fromIndex, toIndex) {\n const cols = listColumnsFor(schemaRows[i]);\n\n if (fromIndex < 0 || fromIndex >= cols.length || toIndex < 0 || toIndex >= cols.length) { return; }\n\n const moved = cols.splice(fromIndex, 1)[0];\n\n cols.splice(toIndex, 0, moved);\n commitListColumns(i, cols, true);\n}\n\nfunction updateListColumnField(i, colIndex, field, value) {\n const cols = listColumnsFor(schemaRows[i]);\n const col = Object.assign({ name: '', type: 'textarea' }, cols[colIndex]);\n\n if (field === 'name') {\n col.name = value;\n } else if (field === 'type') {\n col.type = value;\n\n if (value !== 'select' && value !== 'multi-select') {\n delete col.source;\n delete col.options;\n } else if (Array.isArray(col.options) && col.options.length) {\n delete col.source;\n } else if (!col.source) {\n col.source = 'static';\n }\n } else if (field === 'source') {\n col.source = value;\n\n if (value !== 'static') {\n delete col.options;\n }\n }\n\n cols[colIndex] = col;\n commitListColumns(i, cols, field === 'type' || field === 'source');\n}\n\nfunction updateListColumnOptions(i, colIndex, text) {\n const cols = listColumnsFor(schemaRows[i]);\n const col = Object.assign({ name: '', type: 'textarea' }, cols[colIndex]);\n const options = text.split(',').map((s) => s.trim()).filter(Boolean);\n\n if (options.length) {\n col.options = options;\n delete col.source;\n } else {\n delete col.options;\n\n if (!col.source) {\n col.source = 'static';\n }\n }\n\n cols[colIndex] = col;\n commitListColumns(i, cols, false);\n}\n\nfunction toggleSchemaAdvanced(i) {\n const row = document.getElementById('advanced-' + i);\n const btn = document.getElementById('expand-btn-' + i);\n const nowOpen = row.style.display === 'none';\n\n row.style.display = nowOpen ? '' : 'none';\n\n if (btn) {\n btn.textContent = nowOpen ? '▾' : '▸';\n btn.setAttribute('aria-expanded', String(nowOpen));\n }\n}\n\n// Fallback only (network hiccup) — the server generates a real MD5 via /@api/variables/new-uid,\n// since Node's crypto has real MD5 and the browser doesn't. This is just same-shaped filler.\nfunction fallbackUid() {\n var s = '';\n\n while (s.length < 32) {\n s += Math.random().toString(16).slice(2);\n }\n\n return s.slice(0, 32);\n}\n\nasync function addSchemaRow() {\n var uid = fallbackUid();\n\n try {\n var r = await fetch('/@api/variables/new-uid');\n var data = await r.json();\n\n if (data && typeof data.uid === 'string') {\n uid = data.uid;\n }\n } catch (e) {\n // keep the fallback uid\n }\n\n schemaState.exists = true;\n schemaRows.push({\n name: 'new_variable_' + (schemaRows.length + 1),\n uid: uid,\n tag_type: 'text',\n tag_source: 'static',\n default_value: '',\n additional_options: '',\n description: '',\n });\n setDirty(true);\n render();\n}\n\nfunction removeSchemaRow(i) {\n const name = (schemaRows[i] && schemaRows[i].name) || '(unnamed)';\n\n showConfirmModal('Delete variable "' + name + '"? This only takes effect once you Save.', () => {\n schemaRows.splice(i, 1);\n setDirty(true);\n render();\n });\n}\n\nfunction schemaRowOpenKey(tag, i) {\n return tag && tag.uid ? String(tag.uid) : (tag && tag.name ? 'name:' + tag.name : 'idx:' + i);\n}\n\nfunction collectOpenAdvancedKeys() {\n const open = [];\n\n schemaRows.forEach((tag, i) => {\n const el = document.getElementById('advanced-' + i);\n\n if (el && el.style.display !== 'none') {\n open.push(schemaRowOpenKey(tag, i));\n }\n });\n\n return open;\n}\n\nfunction restoreOpenAdvancedKeys(openKeys) {\n if (!openKeys || !openKeys.length) { return; }\n\n schemaRows.forEach((tag, i) => {\n if (openKeys.indexOf(schemaRowOpenKey(tag, i)) === -1) { return; }\n\n const after = document.getElementById('advanced-' + i);\n const btn = document.getElementById('expand-btn-' + i);\n\n if (after) { after.style.display = ''; }\n if (btn) {\n btn.textContent = '▾';\n btn.setAttribute('aria-expanded', 'true');\n }\n });\n}\n\nvar schemaRowDrag = null;\n\nfunction onSchemaRowDragStart(ev) {\n const row = ev.target.closest('tr.schema-row');\n\n if (!row) { return; }\n\n const fromIndex = Number(row.dataset.rowIndex);\n\n schemaRowDrag = { fromIndex: fromIndex };\n row.classList.add('is-dragging');\n\n const details = document.getElementById('advanced-' + fromIndex);\n\n if (details) { details.classList.add('is-dragging'); }\n\n ev.dataTransfer.effectAllowed = 'move';\n // Firefox requires setData for the drag to start.\n ev.dataTransfer.setData('text/plain', String(fromIndex));\n}\n\nfunction onSchemaRowDragOver(ev) {\n if (!schemaRowDrag) { return; }\n\n ev.preventDefault();\n ev.dataTransfer.dropEffect = 'move';\n ev.currentTarget.classList.add('is-drop-target');\n}\n\nfunction onSchemaRowDragLeave(ev) {\n const row = ev.currentTarget;\n\n if (row.contains(ev.relatedTarget)) { return; }\n\n row.classList.remove('is-drop-target');\n}\n\nfunction onSchemaRowDrop(ev) {\n ev.preventDefault();\n\n const row = ev.currentTarget;\n\n row.classList.remove('is-drop-target');\n\n if (!schemaRowDrag) { return; }\n\n const toIndex = Number(row.dataset.rowIndex);\n const fromIndex = schemaRowDrag.fromIndex;\n\n schemaRowDrag = null;\n\n if (fromIndex === toIndex) { return; }\n\n reorderSchemaRow(fromIndex, toIndex);\n}\n\nfunction onSchemaRowDragEnd() {\n schemaRowDrag = null;\n Array.prototype.forEach.call(document.querySelectorAll('tr.schema-row.is-dragging, tr.schema-row.is-drop-target, tr.details-row.is-dragging, tr.details-row.is-drop-target'), function (el) {\n el.classList.remove('is-dragging', 'is-drop-target');\n });\n}\n\nfunction reorderSchemaRow(fromIndex, toIndex) {\n if (fromIndex < 0 || fromIndex >= schemaRows.length || toIndex < 0 || toIndex >= schemaRows.length) { return; }\n\n const openKeys = collectOpenAdvancedKeys();\n const moved = schemaRows.splice(fromIndex, 1)[0];\n\n schemaRows.splice(toIndex, 0, moved);\n setDirty(true);\n render();\n restoreOpenAdvancedKeys(openKeys);\n}\n\nasync function saveSchema() {\n if (!schemaState) {\n showBanner('error', 'Cannot save the schema until it has loaded successfully.');\n\n return;\n }\n\n let raw;\n const beforeText = jsonBaseline.schema;\n\n if (rawMode) {\n raw = document.getElementById('raw-editor').value;\n } else {\n const invalidAdditionalOptionsIndex = schemaRows.findIndex((row) => invalidAdditionalOptionsRows.has(row));\n\n if (invalidAdditionalOptionsIndex !== -1) {\n const tagName = schemaRows[invalidAdditionalOptionsIndex].name || ('row ' + (invalidAdditionalOptionsIndex + 1));\n\n showBanner('error', 'additional_options must be valid JSON for "' + escapeHtml(tagName) + '".');\n\n return;\n }\n\n raw = JSON.stringify(Object.assign({}, schemaState.schema, { tags: schemaRows }), null, 2);\n }\n\n if (normalizeJsonText(beforeText) === normalizeJsonText(raw)) {\n setDirty(false);\n showBanner('success', 'No JSON changes to save.');\n\n return;\n }\n\n showJsonDiffModal(beforeText, raw, () => commitSchemaSave(raw).catch(showSaveError));\n}\n\n// Every way a save can fail ends in a visible error: a lost save must never look like a pending\n// one. The banner leaves the tab as it is (preserveContent), so the unsaved edits, raw JSON drafts\n// included, stay on screen and the tab stays dirty.\nfunction showSaveError(e) {\n showBanner('error', 'Save failed: ' + escapeHtml((e && e.message) || String(e)), true);\n}\n\n// PUTs a save and resolves to the server's JSON answer, or shows why the save failed and resolves\n// to null. A response that isn't JSON (Express' HTML error page, a proxy in between) counts as a\n// failure too, also with a 2xx status.\nasync function putSave(url, body) {\n let r;\n\n try {\n r = await fetch(url, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(body),\n });\n } catch (e) {\n showSaveError(e);\n\n return null;\n }\n\n const data = await r.json().catch(() => null);\n\n if (r.ok && data && typeof data === 'object') {\n return data;\n }\n\n let message = (data && data.error) || (r.ok\n ? 'the dev server sent an answer that is not JSON'\n : 'the dev server answered HTTP ' + r.status + (r.statusText ? ' ' + r.statusText : ''));\n\n if (data && data.details) { message += ' ' + data.details; }\n\n showSaveError(new Error(message));\n\n return null;\n}\n\nasync function commitSchemaSave(raw) {\n const saveEditSeq = editSeq;\n const data = await putSave('/@api/variables/schema', { raw });\n\n if (!data) { return; }\n\n const warnings = data.warnings || [];\n const hasNewerEdits = editSeq !== saveEditSeq;\n const message = warnings.length\n ? 'Saved, with warning(s):<ul>' + warnings.map((w) => '<li>' + escapeHtml(w) + '</li>').join('') + '</ul>'\n : 'Schema saved.';\n\n if (hasNewerEdits) {\n showBanner('warning', message + '<div>Newer edits remain unsaved.</div>', true);\n\n return;\n }\n\n setDirty(false);\n showBanner(warnings.length ? 'warning' : 'success', message);\n jsonBaseline.schema = raw;\n\n await loadTabData('schema');\n render();\n}\n\n// ── Values tab ───────────────────────────────────────────────────────────────\nfunction schemaTagFor(name) {\n const tags = (valuesState.schema && valuesState.schema.tags) || [];\n\n return tags.find((t) => t.name === name);\n}\n\n// MI renders descriptions as markdown; the part of it that matters there (see\n// variables-editor-markdown.ts), with everything else escaped.\n${jn}\n\nfunction descriptionHint(tag) {\n if (!tag || !tag.description) { return ''; }\n\n return '<div class="hint ve-md" style="margin-bottom: 8px;">' + renderDescriptionMarkdown(tag.description) + '</div>';\n}\n\n// The plain-text default already matches MI's own fallback (a multiline input); on top of\n// that, mirror the three editor-flag checkboxes from the Schema tab's Advanced panel — MI's\n// own value editor swaps in a rich/code editor for the first two (we use a plain textarea for\n// both, since embedding a real WYSIWYG editor is out of scope for this lightweight tool). The\n// third (use_json_editor_ind) is accepted by MI's create form but never actually acted upon by\n// its value editor either — shown here only as an FYI, no widget change.\nfunction textValueCell(tag, row, i) {\n const widget = '<textarea class="cell-textarea" oninput="autosizeCellTextarea(this);updateValueField(' + i + ', this.value)">' + escapeHtml(row.value) + '</textarea>';\n let note = '';\n\n if (tag.use_hmtl_editor_ind === 'Y') {\n note = 'WYSIWYG HTML in MI — edited as raw HTML here.';\n } else if (tag.use_raw_html_ind === 'Y') {\n note = 'Raw HTML — MI skips XSS-encoding this value on save.';\n } else if (tag.use_json_editor_ind === 'Y') {\n note = 'JSON Editor flag is set, though MI\\'s value editor doesn\\'t actually act on it.';\n }\n\n return note ? widget + '<div class="hint">' + note + '</div>' : widget;\n}\n\n// A plain string entry uses itself as both id and text; an object entry may carry either/both —\n// shared by the tag-level static option list and each list-column's own "options" (see\n// listItemFieldsHtml()), since both follow the same { id, text } | string shape.\nfunction normalizeOptionEntry(o) {\n return typeof o === 'string' ? { id: o, text: o } : { id: String(o.id), text: String(o.text != null ? o.text : o.id) };\n}\n\nfunction normalizeStaticOptions(tag) {\n const opts = tag && tag.additional_options;\n\n if (!Array.isArray(opts)) { return []; }\n\n return opts.map(normalizeOptionEntry);\n}\n\nfunction labelForOption(options, id) {\n const found = options.find((o) => o.id === id);\n\n return found ? found.text : id;\n}\n\nfunction comboOptionsHtml(options) {\n return options.map((o) =>\n '<div class="ve-combo-option" data-id="' + escapeHtml(o.id) + '" data-text="' + escapeHtml(o.text) + '" onmousedown="event.preventDefault();onComboOptionPick(this)">' +\n (o.text !== o.id ? escapeHtml(o.text) + ' <span class="hint">(' + escapeHtml(o.id) + ')</span>' : escapeHtml(o.id)) +\n '</div>',\n ).join('');\n}\n\nfunction chipHtml(id, text) {\n return '<span class="ve-chip" data-id="' + escapeHtml(id) + '">' + escapeHtml(text) + '<button type="button" onmousedown="event.preventDefault()" onclick="onChipRemove(this)">✕</button></span>';\n}\n\n// A search-as-you-type combobox — not a native <select>/<datalist> (see the Schema tab's\n// tag_type picker for why: unstylable popups, broken selected-state colors). Only viable for\n// tag_source "static", since that's the only source whose options live in additional_options\n// itself; every other source needs MI's own internal editor-only options endpoint, which we\n// deliberately don't call (no public API for it — see TEMPLATE_VARIABLES.md).\nfunction selectValueWidget(tag, row, i) {\n const options = normalizeStaticOptions(tag);\n const isMulti = tag.tag_type === 'multiselect';\n\n if (isMulti) {\n const ids = row.value ? row.value.split(',').filter(Boolean) : [];\n const chips = ids.map((id) => chipHtml(id, labelForOption(options, id))).join('');\n\n return '<div class="ve-combo ve-combo-multi">' +\n '<div class="ve-combo-chips" data-row="' + i + '" id="combo-chips-' + i + '">' + chips + '</div>' +\n '<input type="text" class="ve-combo-input" placeholder="Search by id or value…" oninput="onComboFilter(' + i + ',this.value)" onfocus="onComboFocus(' + i + ')" onblur="onComboBlur(' + i + ')" />' +\n '<div class="ve-combo-list" id="combo-list-' + i + '" style="display:none">' + comboOptionsHtml(options) + '</div>' +\n '</div>';\n }\n\n return '<div class="ve-combo">' +\n '<input type="text" class="ve-combo-input" value="' + escapeHtml(row.value || '') + '" placeholder="Search by id or value…" oninput="onSingleComboInput(' + i + ',this.value)" onfocus="onComboFocus(' + i + ')" onblur="onComboBlur(' + i + ')" />' +\n '<div class="ve-combo-list" id="combo-list-' + i + '" style="display:none">' + comboOptionsHtml(options) + '</div>' +\n '</div>';\n}\n\nfunction onComboFocus(i) {\n const list = document.getElementById('combo-list-' + i);\n\n if (list) { list.style.display = ''; }\n}\n\nfunction onComboBlur(i) {\n // mousedown+preventDefault() on options already stops most blurs from firing before a pick\n // registers; this is just a safety net for focus leaving for any other reason (e.g. Tab).\n setTimeout(() => {\n const list = document.getElementById('combo-list-' + i);\n\n if (list) { list.style.display = 'none'; }\n }, 150);\n}\n\nfunction onComboFilter(i, text) {\n const list = document.getElementById('combo-list-' + i);\n\n if (!list) { return; }\n\n const needle = text.toLowerCase();\n\n Array.from(list.children).forEach((opt) => {\n const hay = (opt.dataset.id + ' ' + opt.dataset.text).toLowerCase();\n\n opt.style.display = hay.indexOf(needle) === -1 ? 'none' : '';\n });\n}\n\nfunction onSingleComboInput(i, text) {\n onComboFilter(i, text);\n updateValueField(i, text);\n}\n\nfunction onComboOptionPick(optEl) {\n const list = optEl.closest('.ve-combo-list');\n const i = Number(list.id.replace('combo-list-', ''));\n const id = optEl.dataset.id;\n const text = optEl.dataset.text || id;\n const combo = optEl.closest('.ve-combo');\n const chipsEl = combo.querySelector('.ve-combo-chips');\n\n if (chipsEl) {\n const already = Array.from(chipsEl.children).some((c) => c.dataset.id === id);\n\n if (!already) {\n chipsEl.insertAdjacentHTML('beforeend', chipHtml(id, text));\n }\n\n const inputEl = combo.querySelector('.ve-combo-input');\n\n inputEl.value = '';\n onComboFilter(i, '');\n commitMultiValue(i, chipsEl);\n } else {\n const inputEl = combo.querySelector('.ve-combo-input');\n\n inputEl.value = id;\n updateValueField(i, id);\n list.style.display = 'none';\n }\n}\n\nfunction onChipRemove(btn) {\n const chip = btn.closest('.ve-chip');\n const chipsEl = chip.parentElement;\n const i = Number(chipsEl.dataset.row);\n\n chip.remove();\n commitMultiValue(i, chipsEl);\n}\n\nfunction commitMultiValue(i, chipsEl) {\n const ids = Array.from(chipsEl.children).map((c) => c.dataset.id);\n\n updateValueField(i, ids.join(','));\n}\n\n// Non-empty additional_options (array) means column-defined objects; empty/absent means a flat\n// list of strings. A bare string entry is shorthand for { name: <string>, type: 'textarea' }.\nfunction listConfigFor(tag) {\n const opts = tag && tag.additional_options;\n\n return Array.isArray(opts) && opts.length\n ? opts.map((c) => (typeof c === 'string' ? { name: c, type: 'textarea' } : c))\n : null;\n}\n\nfunction parseListItems(row) {\n try {\n const parsed = row.value ? JSON.parse(row.value) : [];\n\n // A stored "null" (MI's own encoding for "no value yet") means an empty list, not invalid\n // JSON — only reject genuinely non-array, non-null JSON (e.g. an object or a number).\n if (parsed === null) { return []; }\n\n return Array.isArray(parsed) ? parsed : null;\n } catch (e) {\n return null;\n }\n}\n\nfunction sourceBadgeHtml(source) {\n if (!source) { return ''; }\n\n const kind = String(source).toLowerCase();\n const extra = kind === 'dataset' ? ' badge-source-dataset' : kind === 'static' ? ' badge-source-static' : '';\n\n return '<span class="badge' + extra + '">' + escapeHtml(source) + '</span>';\n}\n\nfunction typeBadgeHtml(type) {\n if (!type) { return ''; }\n\n return '<span class="badge">' + escapeHtml(type) + '</span>';\n}\n\nfunction listItemLabel(config, item, itemIndex) {\n if (!config) {\n const val = typeof item === 'string' ? item : String(item != null ? item : '');\n\n return val || ('Item ' + (itemIndex + 1));\n }\n\n const parts = [];\n\n config.forEach((col) => {\n const colName = typeof col === 'string' ? col : col.name;\n const raw = item && typeof item === 'object' ? item[colName] : '';\n\n if (raw != null && String(raw).trim()) {\n parts.push(String(raw).trim());\n }\n });\n\n return parts.slice(0, 2).join(' · ') || ('Item ' + (itemIndex + 1));\n}\n\nfunction listFieldLabelHtml(colName, source) {\n return '<div class="ve-values-field-label"><span>' + escapeHtml(colName) + '</span>' + sourceBadgeHtml(source) + '</div>';\n}\n\nfunction listValueWidget(tag, row, i) {\n const items = parseListItems(row);\n\n if (items === null) {\n return '<div class="ve-values-field is-wide"><div class="ve-values-field-label"><span>value</span></div>' +\n '<textarea class="cell-textarea" oninput="autosizeCellTextarea(this);updateValueField(' + i + ', this.value)">' + escapeHtml(row.value) + '</textarea>' +\n '<div class="hint" style="color:var(--red)">Not a valid JSON array — edit as raw text above, or fix it via "View/edit raw JSON".</div></div>';\n }\n\n const config = listConfigFor(tag);\n const itemsHtml = items.map((item, itemIndex) => {\n return '<div class="ve-values-list-item" data-value-index="' + i + '" data-item-index="' + itemIndex + '"' +\n ' ondragover="onListItemDragOver(event)" ondrop="onListItemDrop(event)" ondragleave="onListItemDragLeave(event)">' +\n '<span class="ve-values-list-item-handle" draggable="true" ondragstart="onListItemDragStart(event)" ondragend="onListItemDragEnd(event)" title="Drag to reorder" aria-label="Drag to reorder" role="button" tabindex="0">⠿</span>' +\n listItemFieldsHtml(config, item, i, itemIndex) +\n '</div>';\n }).join('');\n\n return '<div class="ve-values-list-items">' +\n (itemsHtml || '<div class="ve-values-empty">No items yet.</div>') +\n '</div>' +\n '<button type="button" class="ve-values-nav-item ve-values-nav-add" style="margin-top:12px;width:auto" onclick="addListItem(' + i + ')">+ Add item</button>';\n}\n\nfunction listItemFieldsHtml(config, item, i, itemIndex) {\n if (!config) {\n const val = typeof item === 'string' ? item : String(item != null ? item : '');\n\n return '<div class="ve-values-fields"><div class="ve-values-field is-wide">' +\n '<textarea class="cell-textarea" oninput="autosizeCellTextarea(this);updateListItemField(' + i + ',' + itemIndex + ',null,this.value)">' + escapeHtml(val) + '</textarea>' +\n '</div></div>';\n }\n\n const fieldsHtml = config.map((col) => {\n const colName = typeof col === 'string' ? col : col.name;\n const colNameJs = escapeJsAttr(colName);\n const colType = typeof col === 'string' ? 'textarea' : (col.type || 'textarea');\n const colSource = typeof col === 'object' && col.source ? col.source : (colType === 'select' || colType === 'multi-select' ? 'static' : '');\n const rawVal = typeof item === 'object' && item !== null ? item[colName] : '';\n const val = rawVal != null ? String(rawVal) : '';\n const wide = colType === 'textarea' || colType === 'file' || val.length > 48 ? ' is-wide' : '';\n\n if (colType === 'color') {\n const safe = /^#[0-9a-fA-F]{3,8}$/.test(val) ? val : '#000000';\n\n return '<div class="ve-values-field">' + listFieldLabelHtml(colName, colSource) +\n '<input type="color" value="' + safe + '" oninput="updateListItemField(' + i + ',' + itemIndex + ',\\'' + colNameJs + '\\',this.value)" /></div>';\n }\n\n if (colType === 'select' || colType === 'multi-select') {\n const isMulti = colType === 'multi-select';\n\n // Only "static" ships its option list locally (col.options); every other source is\n // populated live by MI's own editor (see additionalOptionsDetails()) and pp-dev has no\n // API to fetch it here — fall back to free text, same as the tag-level select does.\n if (colSource !== 'static') {\n return '<div class="ve-values-field">' + listFieldLabelHtml(colName, colSource) +\n '<input type="text" value="' + escapeHtml(val) + '" oninput="updateListItemField(' + i + ',' + itemIndex + ',\\'' + colNameJs + '\\',this.value)" placeholder="source: ' + escapeHtml(colSource) + ' — no local option list" /></div>';\n }\n\n // Each entry may be a plain string or a { id, text } object (e.g. {"text":"Yes","id":"Y"}) —\n // normalize before use, or an object entry stringifies to "[object Object]" for both the\n // <option> value and its label.\n const opts = (typeof col === 'object' && Array.isArray(col.options) ? col.options : []).map(normalizeOptionEntry);\n const selected = isMulti ? val.split(',').filter(Boolean) : [val];\n const optionsHtml = opts.map((o) => '<option value="' + escapeHtml(o.id) + '"' + (selected.indexOf(o.id) !== -1 ? ' selected' : '') + '>' + escapeHtml(o.text) + '</option>').join('');\n\n return '<div class="ve-values-field">' + listFieldLabelHtml(colName, colSource) +\n '<select ' + (isMulti ? 'multiple ' : '') + 'onchange="autosizeCellTextarea(this);onListItemSelectChange(' + i + ',' + itemIndex + ',\\'' + colNameJs + '\\',this,' + isMulti + ')">' + optionsHtml + '</select></div>';\n }\n\n return '<div class="ve-values-field' + wide + '">' + listFieldLabelHtml(colName, colSource) +\n '<textarea class="cell-textarea" oninput="autosizeCellTextarea(this);updateListItemField(' + i + ',' + itemIndex + ',\\'' + colNameJs + '\\',this.value)">' + escapeHtml(val) + '</textarea></div>';\n }).join('');\n\n return '<div class="ve-values-fields">' + fieldsHtml + '</div>';\n}\n\nfunction onListItemSelectChange(i, itemIndex, fieldName, selectEl, isMulti) {\n const value = isMulti\n ? Array.from(selectEl.selectedOptions).map((o) => o.value).join(',')\n : selectEl.value;\n\n updateListItemField(i, itemIndex, fieldName, value);\n}\n\nfunction updateListItemField(i, itemIndex, fieldName, value) {\n const items = parseListItems(valueRows[i]) || [];\n\n if (fieldName === null) {\n items[itemIndex] = value;\n } else {\n if (typeof items[itemIndex] !== 'object' || items[itemIndex] === null || Array.isArray(items[itemIndex])) {\n items[itemIndex] = {};\n }\n\n items[itemIndex][fieldName] = value;\n }\n\n valueRows[i].value = JSON.stringify(items);\n setDirty(true);\n}\n\nfunction addListItem(i) {\n const items = parseListItems(valueRows[i]) || [];\n const config = listConfigFor(schemaTagFor(valueRows[i].name));\n\n if (config) {\n const newItem = {};\n\n config.forEach((col) => { newItem[typeof col === 'string' ? col : col.name] = ''; });\n items.push(newItem);\n } else {\n items.push('');\n }\n\n valueRows[i].value = JSON.stringify(items);\n selectedValueIndex = i;\n selectedListItemIndex = items.length - 1;\n setDirty(true);\n render();\n}\n\nfunction removeListItem(i, itemIndex) {\n const items = parseListItems(valueRows[i]) || [];\n\n items.splice(itemIndex, 1);\n valueRows[i].value = JSON.stringify(items);\n selectedValueIndex = i;\n selectedListItemIndex = Math.min(itemIndex, Math.max(0, items.length - 1));\n setDirty(true);\n render();\n}\n\nvar listItemDrag = null;\n\nfunction onListItemDragStart(ev) {\n const item = ev.target.closest('.ve-values-list-item');\n\n if (!item) { return; }\n\n listItemDrag = {\n valueIndex: Number(item.dataset.valueIndex),\n fromIndex: Number(item.dataset.itemIndex),\n };\n item.classList.add('is-dragging');\n ev.dataTransfer.effectAllowed = 'move';\n // Firefox requires setData for the drag to start.\n ev.dataTransfer.setData('text/plain', String(listItemDrag.fromIndex));\n}\n\nfunction onListItemDragOver(ev) {\n if (!listItemDrag) { return; }\n\n const item = ev.currentTarget;\n\n if (Number(item.dataset.valueIndex) !== listItemDrag.valueIndex) { return; }\n\n ev.preventDefault();\n ev.dataTransfer.dropEffect = 'move';\n item.classList.add('is-drop-target');\n}\n\nfunction onListItemDragLeave(ev) {\n const item = ev.currentTarget;\n\n // Ignore leave events that stay inside the same item card (child elements).\n if (item.contains(ev.relatedTarget)) { return; }\n\n item.classList.remove('is-drop-target');\n}\n\nfunction onListItemDrop(ev) {\n ev.preventDefault();\n\n const item = ev.currentTarget;\n\n item.classList.remove('is-drop-target');\n\n if (!listItemDrag || Number(item.dataset.valueIndex) !== listItemDrag.valueIndex) { return; }\n\n const toIndex = Number(item.dataset.itemIndex);\n const fromIndex = listItemDrag.fromIndex;\n\n listItemDrag = null;\n\n if (fromIndex === toIndex) { return; }\n\n reorderListItem(Number(item.dataset.valueIndex), fromIndex, toIndex);\n}\n\nfunction onListItemDragEnd() {\n listItemDrag = null;\n Array.prototype.forEach.call(document.querySelectorAll('.ve-values-list-item.is-dragging, .ve-values-list-item.is-drop-target'), function (el) {\n el.classList.remove('is-dragging', 'is-drop-target');\n });\n}\n\nfunction reorderListItem(i, fromIndex, toIndex) {\n const items = parseListItems(valueRows[i]) || [];\n\n if (fromIndex < 0 || fromIndex >= items.length || toIndex < 0 || toIndex >= items.length) { return; }\n\n const moved = items.splice(fromIndex, 1)[0];\n\n items.splice(toIndex, 0, moved);\n valueRows[i].value = JSON.stringify(items);\n selectedValueIndex = i;\n selectedListItemIndex = toIndex;\n setDirty(true);\n render();\n}\n\nfunction valueEditorCell(row, i) {\n const tag = schemaTagFor(row.name);\n const type = tag ? tag.tag_type : null;\n\n if (type === 'boolean') {\n // MI's own page-variable UI writes literal 'true'/'false' strings, not 'Y'/'N' — the\n // extra accepted values below are just legacy/lenient reads, not something we write.\n const checked = ['true','Y','1'].includes(row.value);\n\n return '<input type="checkbox" ' + (checked ? 'checked' : '') + ' onchange="updateValueField(' + i + ', this.checked ? \\'true\\' : \\'false\\')" />';\n }\n\n if (type === 'color') {\n const safe = /^#[0-9a-fA-F]{3,8}$/.test(row.value) ? row.value : '#000000';\n\n return '<input type="color" value="' + safe + '" oninput="updateValueField(' + i + ', this.value)" />';\n }\n\n if ((type === 'select' || type === 'multiselect') && tag) {\n const source = tag.tag_source || 'static';\n\n if (source === 'static') {\n return selectValueWidget(tag, row, i);\n }\n\n return '<input type="text" value="' + escapeHtml(row.value) + '" oninput="updateValueField(' + i + ', this.value)" placeholder="tag_source: ' + escapeHtml(source) + ' — no local option list" />';\n }\n\n if (type === 'file') {\n // No public MI API exists for listing/uploading a page's individual file assets (only\n // whole-bundle zip download/upload, which doesn't fit a single value) — manual path only.\n // MI's own upload control restricts these to images: jpg/jpeg/png/gif/svg.\n return '<input type="text" value="' + escapeHtml(row.value) + '" oninput="updateValueField(' + i + ', this.value)" placeholder="Existing image filename (jpg/jpeg/png/gif/svg)" />';\n }\n\n if (type === 'list') {\n return listValueWidget(tag, row, i);\n }\n\n if (type === 'text' && tag) {\n return textValueCell(tag, row, i);\n }\n\n return '<input type="text" value="' + escapeHtml(row.value) + '" oninput="updateValueField(' + i + ', this.value)" />';\n}\n\n// Same convention as the old "Export variables…"/"Setup variables…" flow (now retired in\n// favor of this JSON mode): a list-type value is shown/accepted as a native JSON array\n// instead of a JSON-string-in-a-string, converting only at this display/parse boundary —\n// the PUT /@api/variables/values endpoint itself always sees plain strings.\nfunction toExportableValueRows(rows) {\n return rows.map((row) => {\n const tag = schemaTagFor(row.name);\n\n if (tag && tag.tag_type === 'list') {\n try {\n const parsed = JSON.parse(row.value);\n\n if (Array.isArray(parsed)) {\n return { name: row.name, value: parsed };\n }\n } catch (e) {\n // not valid JSON — keep the raw string below\n }\n }\n\n return { name: row.name, value: row.value };\n });\n}\n\nfunction fromExportableValueRows(entries) {\n return entries.map(({ name, value }) => ({\n name,\n value: typeof value === 'string' ? value : JSON.stringify(value),\n }));\n}\n\n// Cheap, best-effort lint pass over the JSON textarea's current text — no textarea-internal\n// highlighting (native hover tooltips can't reach through an editable textarea sitting on top\n// of an overlay), just a small badge per flagged entry with a native title attribute explanation.\nfunction computeJsonIssues(text) {\n let entries;\n\n try {\n entries = JSON.parse(text);\n } catch (e) {\n return [];\n }\n\n if (!Array.isArray(entries)) { return []; }\n\n const baseline = new Map(\n toExportableValueRows(valuesState && valuesState.combined ? valuesState.combined : [])\n .map((e) => [e.name, JSON.stringify(e.value)]),\n );\n\n const issues = [];\n\n entries.forEach((entry) => {\n if (!entry || typeof entry.name !== 'string') { return; }\n\n const messages = [];\n const tag = schemaTagFor(entry.name);\n\n if (!tag) {\n messages.push('Not found in the current schema (__template_variables.json) — MI has no matching variable.');\n }\n\n if (tag && (tag.tag_type === 'select' || tag.tag_type === 'multiselect') && (tag.tag_source || 'static') === 'static') {\n const options = normalizeStaticOptions(tag);\n\n if (options.length && typeof entry.value === 'string') {\n const tokens = tag.tag_type === 'multiselect'\n ? entry.value.split(',').map((v) => v.trim()).filter(Boolean)\n : [entry.value].filter(Boolean);\n const unmatched = tokens.filter((t) => !options.some((o) => o.id === t || o.text === t));\n\n if (unmatched.length) {\n messages.push('Value "' + unmatched.join(', ') + '" doesn\\'t match any option in this variable\\'s static list.');\n }\n }\n }\n\n const canonicalValue = JSON.stringify(entry.value);\n\n if (baseline.has(entry.name)) {\n if (baseline.get(entry.name) !== canonicalValue) {\n messages.push('Changed since this tab was last loaded/refreshed.');\n }\n } else if (tag) {\n messages.push('New — this variable had no value when this tab was last loaded/refreshed.');\n }\n\n if (messages.length) {\n issues.push({ name: entry.name, message: messages.join(' ') });\n }\n });\n\n return issues;\n}\n\nfunction renderJsonIssuesBar(issues) {\n if (!issues.length) { return ''; }\n\n return '<div class="ve-json-issues">' + issues.map((issue) =>\n '<span class="ve-json-issue-badge" title="' + escapeHtml(issue.message) + '">⚠ ' + escapeHtml(issue.name) + '</span>',\n ).join('') + '</div>';\n}\n\nfunction refreshValuesJsonIssues() {\n const editorEl = document.getElementById('values-raw-editor');\n const issuesEl = document.getElementById('values-raw-issues');\n\n if (!editorEl || !issuesEl) { return; }\n\n issuesEl.innerHTML = renderJsonIssuesBar(computeJsonIssues(editorEl.value));\n}\n\nlet valuesRawIssuesTimer = null;\n\nfunction onValuesRawInput() {\n setDirty(true);\n\n if (valuesRawIssuesTimer) { clearTimeout(valuesRawIssuesTimer); }\n\n valuesRawIssuesTimer = setTimeout(refreshValuesJsonIssues, 300);\n}\n\nfunction downloadValuesJson() {\n const editorEl = document.getElementById('values-raw-editor');\n const text = editorEl ? editorEl.value : JSON.stringify(toExportableValueRows(valueRows), null, 2);\n const blob = new Blob([text], { type: 'application/json' });\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n\n a.href = url;\n a.download = 'page-variables.json';\n document.body.appendChild(a);\n a.click();\n a.remove();\n URL.revokeObjectURL(url);\n}\n\nfunction triggerImportValuesJson() {\n document.getElementById('import-values-file').click();\n}\n\nfunction onImportValuesFile(inputEl) {\n const file = inputEl.files[0];\n\n if (!file) { return; }\n\n const reader = new FileReader();\n\n reader.onload = () => {\n const editorEl = document.getElementById('values-raw-editor');\n\n if (editorEl) {\n editorEl.value = String(reader.result || '');\n setDirty(true);\n refreshValuesJsonIssues();\n }\n };\n\n reader.readAsText(file);\n inputEl.value = '';\n}\n\nfunction valuesNavItemHtml(row, i, isActive) {\n const tag = schemaTagFor(row.name);\n const type = tag ? (tag.tag_type || 'text') : 'unknown';\n const showsSource = tag && (tag.tag_type === 'select' || tag.tag_type === 'multiselect' || tag.tag_type === 'list');\n const source = showsSource ? (tag.tag_source || 'static') : '';\n\n return '<button type="button" class="ve-values-nav-item' + (isActive ? ' is-active' : '') + '" data-value-index="' + i + '" onclick="selectValueRow(' + i + ')">' +\n '<span class="ve-values-nav-dot"></span>' +\n '<span class="ve-values-nav-label">' + escapeHtml(row.name || '(unnamed)') + '</span>' +\n '<span class="ve-values-nav-badges">' + typeBadgeHtml(type) + sourceBadgeHtml(source) + '</span>' +\n '</button>';\n}\n\nfunction valuesDetailHtml(row, i) {\n const tag = schemaTagFor(row.name);\n const type = tag ? (tag.tag_type || 'text') : '';\n const source = tag && tag.tag_source ? tag.tag_source : '';\n const isList = type === 'list';\n\n const header = '<div class="ve-values-detail-header">' +\n '<div class="ve-values-detail-title">' + escapeHtml(row.name || '(unnamed)') + '</div>' +\n listFieldLabelHtml(type || 'value', source) +\n '<button type="button" class="btn btn-sm" onclick="removeValueRow(' + i + ')" title="Delete variable">✕</button>' +\n '</div>';\n\n if (isList) {\n return header + descriptionHint(tag) + listValueWidget(tag, row, i);\n }\n\n return header + descriptionHint(tag) +\n '<div class="ve-values-fields"><div class="ve-values-field is-wide">' +\n valueEditorCell(row, i) +\n '</div></div>';\n}\n\nfunction updateValueNavActive(i) {\n Array.prototype.forEach.call(document.querySelectorAll('.ve-values-nav-list .ve-values-nav-item[data-value-index]'), function (el) {\n const active = Number(el.dataset.valueIndex) === i;\n\n el.classList.toggle('is-active', active);\n\n if (active) {\n el.scrollIntoView({ block: 'nearest' });\n }\n });\n Array.prototype.forEach.call(document.querySelectorAll('.ve-values-anchor[data-value-index]'), function (el) {\n el.classList.toggle('is-active', Number(el.dataset.valueIndex) === i);\n });\n}\n\nfunction scrollToValueRow(i, smooth) {\n const detail = document.querySelector('.ve-values-detail');\n const target = document.getElementById('value-item-' + i);\n\n if (!detail || !target || typeof detail.scrollTo !== 'function') { return; }\n\n const top = target.getBoundingClientRect().top - detail.getBoundingClientRect().top + detail.scrollTop;\n\n detail.scrollTo({ top: Math.max(0, top - 8), behavior: smooth ? 'smooth' : 'auto' });\n}\n\nvar valueNavScrollLock = false;\nvar valueNavScrollLockTimer = null;\n\nfunction selectValueRow(i) {\n selectedValueIndex = i;\n selectedListItemIndex = 0;\n updateValueNavActive(i);\n valueNavScrollLock = true;\n\n if (valueNavScrollLockTimer) { clearTimeout(valueNavScrollLockTimer); }\n\n scrollToValueRow(i, true);\n valueNavScrollLockTimer = setTimeout(function () {\n valueNavScrollLock = false;\n valueNavScrollLockTimer = null;\n }, 500);\n}\n\nfunction syncValueNavFromScroll() {\n if (valueNavScrollLock) { return; }\n\n const detail = document.querySelector('.ve-values-detail');\n\n if (!detail) { return; }\n\n const anchors = detail.querySelectorAll('.ve-values-anchor[data-value-index]');\n\n if (!anchors.length) { return; }\n\n const nearBottom = detail.scrollTop + detail.clientHeight >= detail.scrollHeight - 4;\n let current;\n\n if (nearBottom) {\n current = Number(anchors[anchors.length - 1].dataset.valueIndex);\n } else {\n const threshold = detail.getBoundingClientRect().top + 24;\n\n current = Number(anchors[0].dataset.valueIndex) || 0;\n Array.prototype.forEach.call(anchors, function (el) {\n if (el.getBoundingClientRect().top <= threshold) {\n current = Number(el.dataset.valueIndex);\n }\n });\n }\n\n if (current !== selectedValueIndex) {\n selectedValueIndex = current;\n updateValueNavActive(current);\n }\n}\n\nfunction bindValuesDetailScroll() {\n const detail = document.querySelector('.ve-values-detail');\n\n if (!detail || detail.dataset.scrollBound === '1') { return; }\n\n detail.dataset.scrollBound = '1';\n detail.addEventListener('scroll', syncValueNavFromScroll, { passive: true });\n}\n\nfunction renderValuesTab() {\n if (!valuesState) { return renderSkeleton(); }\n\n if (valuesRawMode) {\n return \`\n <div class="raw-toggle">\n <button class="btn btn-sm" onclick="toggleValuesRawMode()">Switch to table view</button>\n <button class="btn btn-sm" onclick="downloadValuesJson()">Save to JSON file…</button>\n <button class="btn btn-sm" onclick="triggerImportValuesJson()">Import from JSON file…</button>\n <input type="file" id="import-values-file" accept="application/json,.json" style="display:none" onchange="onImportValuesFile(this)" />\n </div>\n <textarea class="raw-editor" id="values-raw-editor" spellcheck="false" oninput="onValuesRawInput()">\${escapeHtml(JSON.stringify(toExportableValueRows(valueRows), null, 2))}</textarea>\n <div id="values-raw-issues"></div>\n <div class="hint">Editing the raw JSON directly. Must be an array of { name, value } entries — value may be a native JSON array for list-type variables (converted to MI's plain-string form on save).</div>\n \`;\n }\n\n if (!valueRows.length) {\n return \`\n <div class="raw-toggle">\n <button class="btn btn-sm" onclick="toggleValuesRawMode()">View/edit raw JSON</button>\n <button class="btn btn-sm" onclick="openImportValuesModal()">⇄ Import from page…</button>\n </div>\n <div class="ve-values-layout">\n <div class="ve-values-nav">\n <div class="ve-values-nav-list"></div>\n <button type="button" class="ve-values-nav-item ve-values-nav-add" onclick="addValueRow()">+ Add item</button>\n </div>\n <div class="ve-values-detail box-style"><div class="ve-values-empty">No variables yet.</div></div>\n </div>\n \`;\n }\n\n if (selectedValueIndex < 0 || selectedValueIndex >= valueRows.length) {\n selectedValueIndex = 0;\n }\n\n const navHtml = valueRows.map((row, i) => valuesNavItemHtml(row, i, i === selectedValueIndex)).join('');\n const detailHtml = valueRows.map((row, i) =>\n '<div class="ve-values-anchor box-style' + (i === selectedValueIndex ? ' is-active' : '') + '" id="value-item-' + i + '" data-value-index="' + i + '">' + valuesDetailHtml(row, i) + '</div>'\n ).join('');\n\n return \`\n <div class="raw-toggle">\n <button class="btn btn-sm" onclick="toggleValuesRawMode()">View/edit raw JSON</button>\n <button class="btn btn-sm" onclick="openImportValuesModal()">⇄ Import from page…</button>\n </div>\n <div class="ve-values-layout">\n <div class="ve-values-nav">\n <div class="ve-values-nav-list">\${navHtml}</div>\n </div>\n <div class="ve-values-detail">\${detailHtml}</div>\n </div>\n \`;\n}\n\nfunction toggleValuesRawMode() {\n if (valuesRawMode) {\n // Leaving raw mode: try to parse back into rows; keep raw mode on failure. The value field\n // may be a native JSON array (list-type) as well as a plain string — see toExportableValueRows().\n const editorEl = document.getElementById('values-raw-editor');\n const editorText = editorEl ? editorEl.value : JSON.stringify(toExportableValueRows(valueRows));\n\n try {\n const parsed = JSON.parse(editorText);\n\n if (\n !Array.isArray(parsed) ||\n parsed.some((e) => typeof e !== 'object' || e === null || typeof e.name !== 'string' || e.value === undefined || e.value === null)\n ) {\n throw new Error('Must be an array of { name, value } entries with non-empty names and a defined value.');\n }\n\n valueRows = fromExportableValueRows(parsed);\n } catch (e) {\n // showBanner() re-renders, which would otherwise regenerate this textarea from the last\n // known-good valueRows — put the user's (still-broken) text back afterward so their\n // in-progress edit isn't silently discarded.\n showBanner('error', 'Cannot switch to table view: ' + escapeHtml(e.message));\n\n const freshEditorEl = document.getElementById('values-raw-editor');\n\n if (freshEditorEl) { freshEditorEl.value = editorText; }\n\n return;\n }\n }\n\n valuesRawMode = !valuesRawMode;\n setUrlState(activeTab, valuesRawMode);\n render();\n}\n\nfunction updateValueField(i, value) {\n valueRows[i].value = value;\n setDirty(true);\n}\n\n// ── Values tab: "Import from page…" ─────────────────────────────────────────\n// Two-step flow, both steps rendered into the same overlay element (only its innerHTML swaps)\n// so the outer overlay's click-outside/Escape handlers, bound once here, keep working through\n// "← Back". Nothing is written to MI until the user then hits the regular Save button.\nfunction openImportValuesModal() {\n closeImportModal();\n\n const overlay = document.createElement('div');\n\n overlay.className = 've-modal-overlay';\n overlay.id = 've-import-overlay';\n\n overlay.addEventListener('click', (ev) => {\n if (ev.target === overlay) { closeImportModal(); }\n });\n overlay.addEventListener('keydown', (ev) => {\n if (ev.key === 'Escape') {\n ev.preventDefault();\n closeImportModal();\n }\n });\n\n document.body.appendChild(overlay);\n renderImportPickerStep(overlay);\n}\n\nfunction closeImportModal() {\n const overlay = document.getElementById('ve-import-overlay');\n\n if (overlay) { overlay.remove(); }\n}\n\nfunction backToImportPicker() {\n const overlay = document.getElementById('ve-import-overlay');\n\n if (overlay) { renderImportPickerStep(overlay); }\n}\n\nasync function renderImportPickerStep(overlay) {\n overlay.innerHTML =\n '<div class="ve-modal ve-modal-wide" role="dialog" aria-modal="true">' +\n '<div class="ve-modal-title-row">' +\n '<div class="ve-modal-title">Import values from another page</div>' +\n '<button type="button" class="btn btn-sm ve-modal-close" title="Close" aria-label="Close" onclick="closeImportModal()">✕</button>' +\n '</div>' +\n '<div class="ve-modal-body"><span class="ve-loading-indicator"><span class="ve-loading-dot"></span>Loading pages…</span></div>' +\n '</div>';\n\n let r;\n\n try {\n r = await fetch('/@api/variables/pages');\n } catch (e) {\n setImportBodyError(overlay, e.message);\n\n return;\n }\n\n const data = await r.json().catch(() => ({}));\n\n if (!overlay.isConnected) { return; }\n\n if (!r.ok) {\n setImportBodyError(overlay, data.error || 'Failed to load the page list.');\n\n return;\n }\n\n const bodyEl = overlay.querySelector('.ve-modal-body');\n\n if (!bodyEl) { return; }\n\n bodyEl.innerHTML = importPickerBodyHtml(data.pages || []);\n\n const filterEl = document.getElementById('ve-import-filter');\n\n if (filterEl) { filterEl.focus(); }\n}\n\nfunction setImportBodyError(overlay, message) {\n if (!overlay.isConnected) { return; }\n\n const bodyEl = overlay.querySelector('.ve-modal-body');\n\n if (bodyEl) { bodyEl.innerHTML = '<div class="hint" style="color:var(--red)">' + escapeHtml(message) + '</div>'; }\n}\n\nfunction importPickerBodyHtml(pages) {\n if (!pages.length) {\n return '<div class="ve-import-empty">No other pages found on this instance.</div>';\n }\n\n const sameTemplate = pages.filter((p) => p.sameTemplate);\n const other = pages.filter((p) => !p.sameTemplate);\n\n return (\n '<div class="ve-import-search"><input type="text" id="ve-import-filter" placeholder="Filter pages…" oninput="filterImportPages(this.value)" autocomplete="off" /></div>' +\n importPageGroupHtml('Pages using this template', sameTemplate) +\n importPageGroupHtml('Other pages', other) +\n '<div class="ve-import-empty" id="ve-import-page-empty" style="display:none">No pages match your filter.</div>'\n );\n}\n\nfunction importPageGroupHtml(title, pages) {\n if (!pages.length) { return ''; }\n\n const rows = pages\n .map(\n (p) =>\n '<button type="button" class="ve-import-page-row" data-search="' +\n escapeHtml((p.name + ' ' + p.internal_name).toLowerCase()) +\n '" onclick="selectImportSourcePage(' + p.id + ', \\'' + escapeJsAttr(p.name) + '\\')">' +\n '<span class="ve-import-page-name">' + escapeHtml(p.name) + '</span>' +\n '<span class="ve-import-page-slug">' + escapeHtml(p.internal_name) + '</span>' +\n '</button>',\n )\n .join('');\n\n return (\n '<div class="ve-import-page-group">' +\n '<div class="ve-import-section-title">' + escapeHtml(title) + '</div>' +\n '<div class="ve-import-page-list">' + rows + '</div>' +\n '</div>'\n );\n}\n\nfunction filterImportPages(text) {\n const needle = text.trim().toLowerCase();\n let visible = 0;\n\n Array.prototype.forEach.call(document.querySelectorAll('.ve-import-page-row'), function (row) {\n const match = !needle || row.dataset.search.indexOf(needle) !== -1;\n\n row.style.display = match ? '' : 'none';\n\n if (match) { visible++; }\n });\n\n Array.prototype.forEach.call(document.querySelectorAll('.ve-import-page-group'), function (group) {\n const anyVisible = Array.prototype.some.call(group.querySelectorAll('.ve-import-page-row'), function (row) {\n return row.style.display !== 'none';\n });\n\n group.style.display = anyVisible ? '' : 'none';\n });\n\n const emptyEl = document.getElementById('ve-import-page-empty');\n\n if (emptyEl) { emptyEl.style.display = visible === 0 ? '' : 'none'; }\n}\n\nasync function selectImportSourcePage(pageId, pageName) {\n const overlay = document.getElementById('ve-import-overlay');\n\n if (!overlay) { return; }\n\n overlay.dataset.sourcePageId = String(pageId);\n\n overlay.innerHTML =\n '<div class="ve-modal ve-modal-wide" role="dialog" aria-modal="true">' +\n '<div class="ve-modal-title-row">' +\n '<div style="flex:1;min-width:0">' +\n '<button type="button" class="ve-modal-back" onclick="backToImportPicker()">← Back</button>' +\n '<div class="ve-modal-title" style="margin-bottom:0">Import values from "' + escapeHtml(pageName) + '"</div>' +\n '</div>' +\n '<button type="button" class="btn btn-sm ve-modal-close" title="Close" aria-label="Close" onclick="closeImportModal()">✕</button>' +\n '</div>' +\n '<div class="ve-modal-body"><span class="ve-loading-indicator"><span class="ve-loading-dot"></span>Loading values…</span></div>' +\n '<div class="ve-modal-actions" id="ve-import-actions"></div>' +\n '</div>';\n\n let r;\n\n try {\n r = await fetch('/@api/variables/import-plan/' + pageId);\n } catch (e) {\n setImportBodyError(overlay, e.message);\n\n return;\n }\n\n const data = await r.json().catch(() => ({}));\n\n // A newer pick (or Back) landed before this one resolved — drop the stale response.\n if (!overlay.isConnected || overlay.dataset.sourcePageId !== String(pageId)) { return; }\n\n if (!r.ok) {\n setImportBodyError(overlay, data.error || 'Failed to load values from this page.');\n\n return;\n }\n\n const bodyEl = overlay.querySelector('.ve-modal-body');\n const actionsEl = overlay.querySelector('#ve-import-actions');\n\n if (!bodyEl || !actionsEl) { return; }\n\n importReviewCandidates = data.importable || [];\n importReviewPageName = pageName;\n\n bodyEl.innerHTML = importReviewBodyHtml(data);\n actionsEl.innerHTML = importReviewActionsHtml(data);\n\n const applyBtn = document.getElementById('ve-import-apply-btn');\n\n if (applyBtn) { applyBtn.focus(); }\n}\n\nfunction importReviewBodyHtml(plan) {\n const importable = plan.importable || [];\n const skipped = plan.skipped || [];\n\n if (!importable.length && !skipped.length) {\n return '<div class="ve-import-empty">This page has no live variable values to import.</div>';\n }\n\n let html = '';\n\n if (importable.length) {\n html +=\n '<div class="ve-import-list-head">' +\n '<span class="badge">Importable (' + importable.length + ')</span>' +\n '<label class="ve-import-select-all"><input type="checkbox" id="ve-import-select-all" checked onchange="toggleImportAll(this.checked)" /> select all</label>' +\n '</div>' +\n '<div class="ve-import-list">' + importable.map((c, i) => importRowHtml(c, i)).join('') + '</div>';\n } else {\n html += '<div class="hint">Nothing on this page matches a variable name in the current schema.</div>';\n }\n\n if (skipped.length) {\n html +=\n '<div class="ve-import-section-title">Skipped (' + skipped.length + ')</div>' +\n '<div class="ve-import-list">' +\n skipped\n .map(\n (s) =>\n '<div class="ve-import-skip-row">' +\n '<span class="ve-import-row-name">' + escapeHtml(s.name) + '</span>' +\n '<span class="ve-import-skip-reason">' + escapeHtml(s.reason) + '</span>' +\n '</div>',\n )\n .join('') +\n '</div>';\n }\n\n return html;\n}\n\nfunction importRowHtml(candidate, i) {\n return (\n '<label class="ve-import-row">' +\n '<input type="checkbox" class="ve-import-check" data-index="' + i + '" checked onchange="updateImportCount()" />' +\n '<span class="ve-import-row-main">' +\n '<span class="ve-import-row-top"><span class="ve-import-row-name">' + escapeHtml(candidate.name) + '</span>' + typeBadgeHtml(candidate.tagType) + '</span>' +\n '<span class="ve-import-row-value">' + importValuePreviewHtml(candidate) + '</span>' +\n '</span>' +\n '</label>'\n );\n}\n\nfunction importValuePreviewHtml(candidate) {\n const raw = candidate.sourceValue || '';\n\n if (candidate.tagType === 'color' && /^#[0-9a-fA-F]{3,8}$/.test(raw)) {\n return '<span class="ve-import-swatch" style="background:' + raw + '"></span>' + escapeHtml(raw);\n }\n\n const preview = raw.length > 80 ? raw.slice(0, 80) + '…' : raw;\n\n return escapeHtml(preview || '(empty)');\n}\n\nfunction importReviewActionsHtml(plan) {\n const count = (plan.importable || []).length;\n\n if (!count) {\n return '<button type="button" class="btn btn-sm" onclick="closeImportModal()">Close</button>';\n }\n\n return (\n '<span class="hint" id="ve-import-count" style="margin-right:auto">' + count + ' of ' + count + ' selected</span>' +\n '<button type="button" class="btn btn-sm" onclick="closeImportModal()">Cancel</button>' +\n '<button type="button" class="btn btn-sm btn-primary" id="ve-import-apply-btn" onclick="applyImportPlan()">Apply ' + count + ' selected</button>'\n );\n}\n\nfunction toggleImportAll(checked) {\n Array.prototype.forEach.call(document.querySelectorAll('.ve-import-check'), function (c) { c.checked = checked; });\n updateImportCount();\n}\n\nfunction updateImportCount() {\n const checks = document.querySelectorAll('.ve-import-check');\n const checkedCount = document.querySelectorAll('.ve-import-check:checked').length;\n const countEl = document.getElementById('ve-import-count');\n const selectAllEl = document.getElementById('ve-import-select-all');\n const applyBtn = document.getElementById('ve-import-apply-btn');\n\n if (countEl) { countEl.textContent = checkedCount + ' of ' + checks.length + ' selected'; }\n if (selectAllEl) { selectAllEl.checked = checkedCount === checks.length; }\n\n if (applyBtn) {\n applyBtn.textContent = 'Apply ' + checkedCount + ' selected';\n applyBtn.disabled = checkedCount === 0;\n }\n}\n\nfunction applyImportPlan() {\n const checkedIndexes = Array.prototype.map.call(document.querySelectorAll('.ve-import-check:checked'), function (c) {\n return Number(c.dataset.index);\n });\n\n let applied = 0;\n\n checkedIndexes.forEach((idx) => {\n const candidate = importReviewCandidates[idx];\n\n if (!candidate) { return; }\n\n const row = valueRows.find((r) => r.name === candidate.name);\n\n if (!row) { return; }\n\n row.value = candidate.sourceValue;\n applied++;\n });\n\n const total = importReviewCandidates.length;\n const pageName = importReviewPageName;\n\n closeImportModal();\n\n if (!applied) { return; }\n\n setDirty(true);\n showBanner('success', 'Imported ' + applied + ' of ' + total + ' variable(s) from "' + escapeHtml(pageName) + '". Save to apply.');\n}\n\nfunction addValueRow() {\n showCustomValueModal('Variable name', '', (name) => {\n if (!name) { return; }\n\n valueRows.push({ name, value: '' });\n selectedValueIndex = valueRows.length - 1;\n selectedListItemIndex = 0;\n setDirty(true);\n render();\n });\n}\n\nfunction removeValueRow(i) {\n const name = (valueRows[i] && valueRows[i].name) || '(unnamed)';\n\n showConfirmModal('Delete variable "' + name + '"? This only takes effect once you Save.', () => {\n valueRows.splice(i, 1);\n selectedValueIndex = Math.min(i, Math.max(0, valueRows.length - 1));\n selectedListItemIndex = 0;\n setDirty(true);\n render();\n });\n}\n\nasync function saveValues() {\n if (!valuesState) {\n showBanner('error', 'Cannot save values until live variables have loaded successfully.');\n\n return;\n }\n\n let tags = valueRows;\n const beforeText = jsonBaseline.values;\n\n if (valuesRawMode) {\n try {\n // The textarea may contain native JSON arrays for list-type values (see\n // toExportableValueRows()) — convert back to MI's plain-string form before sending.\n tags = fromExportableValueRows(JSON.parse(document.getElementById('values-raw-editor').value));\n } catch (e) {\n showBanner('error', 'Raw JSON is not valid: ' + escapeHtml(e.message));\n\n return;\n }\n }\n\n const afterText = snapshotValuesJson(tags);\n\n if (normalizeJsonText(beforeText) === normalizeJsonText(afterText)) {\n setDirty(false);\n showBanner('success', 'No JSON changes to save.');\n\n return;\n }\n\n showJsonDiffModal(beforeText, afterText, () => commitValuesSave(tags, afterText).catch(showSaveError));\n}\n\nasync function commitValuesSave(tags, afterText) {\n const saveEditSeq = editSeq;\n const data = await putSave('/@api/variables/values', { tags });\n\n if (!data) { return; }\n\n const warnings = data.warnings || [];\n const hasNewerEdits = editSeq !== saveEditSeq;\n // The local pages are updated with the save; if that failed, the floating button offers it again.\n const localNote = data.localError\n ? '<div>The local pages still show the old values: ' + escapeHtml(data.localError) + '. Use <b>Update local pages</b>.</div>'\n : '';\n const message = (warnings.length\n ? 'Saved, with warning(s):<ul>' + warnings.map((w) => '<li>' + escapeHtml(w.message) + '</li>').join('') + '</ul>'\n : 'Values saved.') + localNote;\n\n // MI holds the saved values now, and the local pages what the save updated them to: compare\n // those, also when newer edits keep the tab from reloading below.\n if (valuesState) {\n valuesState.live = tags.map((t) => ({ name: t.name, value: t.value }));\n\n if (data.local !== undefined) { valuesState.local = data.local; }\n }\n\n renderLocalUpdateButton();\n\n if (hasNewerEdits) {\n showBanner('warning', message + '<div>Newer edits remain unsaved.</div>', true);\n\n return;\n }\n\n setDirty(false);\n showBanner(warnings.length ? 'warning' : 'success', message);\n jsonBaseline.values = afterText;\n\n await loadTabData('values');\n render();\n}\n\nfunction save() {\n (activeTab === 'schema' ? saveSchema() : saveValues()).catch(showSaveError);\n}\n\n// ── Local pages ──────────────────────────────────────────────────────────────\n// The dev server substitutes page variables into the pages it serves (to every user) from its own\n// copy, loaded from MI with the page data. A save updates it; a change made in MI itself doesn't,\n// so the Values tab compares that copy with MI and offers to update it.\nfunction localDifferences() {\n if (!valuesState || !Array.isArray(valuesState.local) || !Array.isArray(valuesState.live)) { return []; }\n\n // A missing variable differs from an empty one: the page keeps a missing one's [VarName] as is,\n // and drops an empty one's.\n const local = new Map(valuesState.local.map((e) => [e.name, String(e.value ?? '')]));\n const live = new Map(valuesState.live.map((e) => [e.name, String(e.value ?? '')]));\n const names = Array.from(new Set([...local.keys(), ...live.keys()]));\n\n return names\n .filter((name) => local.get(name) !== live.get(name))\n .map((name) => ({ name, local: local.get(name), live: live.get(name) }));\n}\n\nfunction shortValue(value) {\n if (value === undefined) { return '(missing)'; }\n\n if (!value) { return '(empty)'; }\n\n return value.length > 40 ? value.slice(0, 40) + '…' : value;\n}\n\nfunction renderLocalUpdateButton() {\n const btn = document.getElementById('local-update-btn');\n\n if (!btn) { return; }\n\n const diffs = activeTab === 'values' ? localDifferences() : [];\n\n btn.hidden = diffs.length === 0;\n\n if (!diffs.length) { return; }\n\n const lineBreak = String.fromCharCode(10);\n\n btn.textContent = 'Update local pages · ' + diffs.length;\n btn.title = ['The local dev server still shows older values (local → MI):']\n .concat(diffs.map((d) => d.name + ': ' + shortValue(d.local) + ' → ' + shortValue(d.live)))\n .join(lineBreak);\n}\n\nasync function updateLocalValues() {\n const btn = document.getElementById('local-update-btn');\n\n btn.disabled = true;\n\n try {\n const r = await fetch('/@api/variables/values/local', { method: 'POST' });\n const data = await r.json().catch(() => ({}));\n\n // The banners leave the tab as it is (preserveContent): an unsaved draft, raw JSON included,\n // must survive updating the local pages.\n if (!r.ok) {\n showBanner('error', escapeHtml(data.error || 'Failed to update the local values.') + (data.details ? ' ' + escapeHtml(data.details) : ''), true);\n\n return;\n }\n\n if (valuesState) {\n // Just reloaded from MI: they are MI's latest values too, as far as the comparison goes.\n valuesState.local = data.local;\n valuesState.live = data.local;\n }\n\n showBanner('success', 'Local pages updated: every tab shows the new values once reloaded.', true);\n } catch (e) {\n showBanner('error', 'Failed to update the local values: ' + escapeHtml(e.message), true);\n } finally {\n btn.disabled = false;\n renderLocalUpdateButton();\n }\n}\n\n// ── Render ───────────────────────────────────────────────────────────────────\nfunction render() {\n const bannerHtml = banner ? '<div class="banner banner-' + banner.type + '">' + banner.html + '</div>' : '';\n\n contentEl.innerHTML = bannerHtml + (activeTab === 'schema' ? renderSchemaTab() : renderValuesTab());\n autosizeAllCellTextareas();\n renderLocalUpdateButton();\n\n if (activeTab === 'values' && !valuesRawMode) {\n bindValuesDetailScroll();\n scrollToValueRow(selectedValueIndex, false);\n }\n\n if (activeTab === 'values' && valuesRawMode) {\n refreshValuesJsonIssues();\n }\n\n const loadingEl = document.getElementById('loading-indicator');\n\n if (loadingEl) {\n // Only surface the indicator when there's cached content already on screen underneath it —\n // when there isn't (state is still null), renderSkeleton() above is the loading signal.\n const stateForTab = activeTab === 'schema' ? schemaState : valuesState;\n const isLoading = activeTab === 'schema' ? schemaLoading : valuesLoading;\n\n loadingEl.style.display = isLoading && stateForTab ? '' : 'none';\n }\n}\n\nwindow.switchTab = switchTab;\nwindow.refresh = refresh;\nwindow.save = save;\nwindow.toggleRawMode = toggleRawMode;\nwindow.updateSchemaField = updateSchemaField;\nwindow.autosizeCellTextarea = autosizeCellTextarea;\nwindow.onCustomSelectChange = onCustomSelectChange;\nwindow.updateSchemaAdditionalOptions = updateSchemaAdditionalOptions;\nwindow.updateEditorFlag = updateEditorFlag;\nwindow.showAdditionalOptionsHelp = showAdditionalOptionsHelp;\nwindow.showListColumnsHelp = showListColumnsHelp;\nwindow.addListColumn = addListColumn;\nwindow.removeListColumn = removeListColumn;\nwindow.reorderListColumn = reorderListColumn;\nwindow.onListColumnDragStart = onListColumnDragStart;\nwindow.onListColumnDragOver = onListColumnDragOver;\nwindow.onListColumnDragLeave = onListColumnDragLeave;\nwindow.onListColumnDrop = onListColumnDrop;\nwindow.onListColumnDragEnd = onListColumnDragEnd;\nwindow.updateListColumnField = updateListColumnField;\nwindow.updateListColumnOptions = updateListColumnOptions;\nwindow.toggleSchemaAdvanced = toggleSchemaAdvanced;\nwindow.addSchemaRow = addSchemaRow;\nwindow.removeSchemaRow = removeSchemaRow;\nwindow.reorderSchemaRow = reorderSchemaRow;\nwindow.onSchemaRowDragStart = onSchemaRowDragStart;\nwindow.onSchemaRowDragOver = onSchemaRowDragOver;\nwindow.onSchemaRowDragLeave = onSchemaRowDragLeave;\nwindow.onSchemaRowDrop = onSchemaRowDrop;\nwindow.onSchemaRowDragEnd = onSchemaRowDragEnd;\nwindow.updateValueField = updateValueField;\nwindow.selectValueRow = selectValueRow;\nwindow.addValueRow = addValueRow;\nwindow.removeValueRow = removeValueRow;\nwindow.toggleValuesRawMode = toggleValuesRawMode;\nwindow.onValuesRawInput = onValuesRawInput;\nwindow.downloadValuesJson = downloadValuesJson;\nwindow.triggerImportValuesJson = triggerImportValuesJson;\nwindow.onImportValuesFile = onImportValuesFile;\nwindow.onComboFocus = onComboFocus;\nwindow.onComboBlur = onComboBlur;\nwindow.onComboFilter = onComboFilter;\nwindow.onSingleComboInput = onSingleComboInput;\nwindow.onComboOptionPick = onComboOptionPick;\nwindow.onChipRemove = onChipRemove;\nwindow.addListItem = addListItem;\nwindow.removeListItem = removeListItem;\nwindow.reorderListItem = reorderListItem;\nwindow.onListItemDragStart = onListItemDragStart;\nwindow.onListItemDragOver = onListItemDragOver;\nwindow.onListItemDragLeave = onListItemDragLeave;\nwindow.onListItemDrop = onListItemDrop;\nwindow.onListItemDragEnd = onListItemDragEnd;\nwindow.updateListItemField = updateListItemField;\nwindow.onListItemSelectChange = onListItemSelectChange;\nwindow.openImportValuesModal = openImportValuesModal;\nwindow.closeImportModal = closeImportModal;\nwindow.backToImportPicker = backToImportPicker;\nwindow.selectImportSourcePage = selectImportSourcePage;\nwindow.filterImportPages = filterImportPages;\nwindow.toggleImportAll = toggleImportAll;\nwindow.updateImportCount = updateImportCount;\nwindow.applyImportPlan = applyImportPlan;\nwindow.setDirty = setDirty;\nwindow.updateLocalValues = updateLocalValues;\n\ndocument.getElementById('tab-schema').classList.toggle('active', activeTab === 'schema');\ndocument.getElementById('tab-values').classList.toggle('active', activeTab === 'values');\nsetUrlState(activeTab, valuesRawMode);\n\n// Skeleton first (nothing's cached yet on a fresh page load), then swap in the real content.\nrender();\nloadTabData(activeTab).then(render);\n})();\n<\/script>\n</body>\n</html>`)})}export{pn as C,un as D,Cn as I,Ae as M,P,gn as R,Lt as U,zn as V,Qt as a,Rn as b,qt as c,An as d,Vt as e,Be as f,Q as g,gt as h,Kt as i,Et as j,et as k,z as l,Zn as m,Nt as n,Wt as o,Oe as p,ct as q,Gt as r,_t as s,Z as t,G as u,we as v,he as w,Se as x,Ft as z};
106
- //# sourceMappingURL=variables-editor-CrhYJD_R.js.map