@metricinsights/pp-dev 1.4.1-beta.3 → 1.4.1

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 (31) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/CHANGELOG.md +7 -0
  3. package/dist/cjs/cli.js +1 -1
  4. package/dist/cjs/{index-CtxApOJm.js → index-Cab7kIQH.js} +2 -2
  5. package/dist/cjs/{index-CtxApOJm.js.map → index-Cab7kIQH.js.map} +1 -1
  6. package/dist/cjs/index.js +1 -1
  7. package/dist/cjs/{migrate-t_tUHdbD.js → migrate-tyzkQsrd.js} +2 -2
  8. package/dist/cjs/{migrate-t_tUHdbD.js.map → migrate-tyzkQsrd.js.map} +1 -1
  9. package/dist/cjs/package.json +1 -1
  10. package/dist/cjs/plugin-BcxTOlCf.js +2 -0
  11. package/dist/cjs/plugin-BcxTOlCf.js.map +1 -0
  12. package/dist/cjs/plugin.js +1 -1
  13. package/dist/cjs/{version-plugin-Dozkvk1U.js → version-plugin-rZ3JgnX3.js} +2 -2
  14. package/dist/cjs/{version-plugin-Dozkvk1U.js.map → version-plugin-rZ3JgnX3.js.map} +1 -1
  15. package/dist/esm/cli.js +1 -1
  16. package/dist/esm/{index-CQo1fAKu.js → index-BcReEkRb.js} +2 -2
  17. package/dist/esm/{index-CQo1fAKu.js.map → index-BcReEkRb.js.map} +1 -1
  18. package/dist/esm/index.js +1 -1
  19. package/dist/esm/{migrate-D_3UZxND.js → migrate-Be2sIPUg.js} +2 -2
  20. package/dist/esm/{migrate-D_3UZxND.js.map → migrate-Be2sIPUg.js.map} +1 -1
  21. package/dist/esm/package.json +1 -1
  22. package/dist/esm/plugin-B4Zp4ldE.js +2 -0
  23. package/dist/esm/plugin-B4Zp4ldE.js.map +1 -0
  24. package/dist/esm/plugin.js +1 -1
  25. package/dist/esm/{version-plugin-Tc8xw5lR.js → version-plugin--Vz_dsFm.js} +2 -2
  26. package/dist/esm/{version-plugin-Tc8xw5lR.js.map → version-plugin--Vz_dsFm.js.map} +1 -1
  27. package/package.json +1 -1
  28. package/dist/cjs/plugin-CkZeoDh1.js +0 -2
  29. package/dist/cjs/plugin-CkZeoDh1.js.map +0 -1
  30. package/dist/esm/plugin-sOaJ2ffx.js +0 -2
  31. package/dist/esm/plugin-sOaJ2ffx.js.map +0 -1
@@ -0,0 +1,2 @@
1
+ import{createProxyMiddleware as e,responseInterceptor as t}from"http-proxy-middleware";import{fileURLToPath as n,URL as a}from"url";import{createLogger as o}from"vite";import{createColors as s}from"picocolors";import{StringDecoder as i}from"node:string_decoder";import r,{isAxiosError as l}from"axios";import c from"node:http";import d from"node:https";import{JSDOM as p}from"jsdom";import*as u from"path";import m,{resolve as h}from"path";import{readFileSync as g,existsSync as f,readdirSync as v,writeFileSync as b,statSync as y,mkdirSync as w,promises as x}from"fs";import*as k from"crypto";import{createHash as S,randomUUID as I}from"crypto";import*as E from"process";import*as T from"child_process";import{execSync as C}from"child_process";import*as A from"os";import L from"extract-zip";import R from"node:path";import O from"jszip";import{createRequire as N}from"module";import*as H from"memory-cache";import{inflateSync as B,brotliDecompressSync as j,unzipSync as D,deflateSync as _,brotliCompressSync as $,gzipSync as F}from"zlib";import M from"express";import{randomUUID as P}from"node:crypto";const V=h("undefined"!=typeof __filename&&__filename||n(import.meta.url),"../../.."),q=h("undefined"!=typeof __filename&&__filename||n(import.meta.url),"../.."),z=f(h(V,"package.json"))?V:q,U=h(z,"dist/client/client.js"),{version:J,name:W}=JSON.parse(g(h(z,"package.json")).toString()),Y=J,K=W,Z="/@pp-dev-hmr",G="/p",X="/pt",Q="/pl",ee="PP_DEV_NEXT_BUILD",te=[".pp-dev.config.js",".pp-dev.config.cjs",".pp-dev.config.mjs",".pp-dev.config.ts",".pp-dev.config.cts",".pp-dev.config.mts",".pp-dev.config.json","pp-dev.config.js","pp-dev.config.cjs","pp-dev.config.mjs","pp-dev.config.ts","pp-dev.config.cts","pp-dev.config.mts","pp-dev.config.json"];let ne=new Map;const ae="default",oe=(e="info",t=ae)=>{if(ne&&"function"==typeof ne.has||(ne=new Map),ne.has(t))return ne.get(t);if(t===ae){const n=o(e);return ne.set(t,n),n}const n=o(e);return ne.set(t,n),n},se=s(),ie=(e,t,n)=>{const a=new RegExp(`(!!)?(https?(:(\\\\)?/(\\\\)?/)${e})`,"gi");return n.replace(a,(e,...n)=>e.startsWith("!!")?n[1]:`http${n[2]}${t}`)},re=(e,t,n)=>{e.setHeader("location",t),e.statusCode=n,e.end()};const le=function(){const e={personal:{placeholder:"API Token",caption:`You can get the API Token\n <a href="https://${host}/api-token" target="_blank" \n style="color: #007bff; text-decoration: none;">here</a>`,endpoint:"/@api/login"},regular:{placeholder:"Legacy Token",caption:`You can get the Legacy Token\n <a href="https://${host}/api/get_token" target="_blank" \n style="color: #007bff; text-decoration: none;">here</a>`,endpoint:"/@api/login"}};function t(t,n,a){const o=e[t];o&&(n.placeholder=o.placeholder,a.innerHTML=o.caption)}function n(e,t){t.textContent=e,t.classList.add("show")}function a(e){e.classList.remove("show")}function o(e,t,o,s){o.addEventListener("click",async o=>{if(o.preventDefault(),a(s),!t.value.trim())return n(`${t.placeholder} is required`,s),void t.focus();const i=document.getElementById("token-type-switcher"),r=i?.querySelector(".token-type-option.active"),l=r?.dataset.value||"personal";await async function(e,t,a,o){try{a.classList.add("loading");const s=await fetch("/@api/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:e,tokenType:t}),redirect:"follow"}),i=s.url&&new URL(s.url,window.location.origin);if(!i||i.pathname.startsWith("/login")||i.pathname.startsWith("/@api/"))try{const e=await s.json();n(`Login failed: ${e?.error||"Unknown error occurred"}`,o)}catch(e){n("Login failed: Unable to parse response",o)}else window.location.href=s.url}catch(e){n("Login failed: Network error. Please check your connection.",o)}finally{a.classList.remove("loading")}}(t.value.trim(),l,e,s)}),t.addEventListener("keydown",e=>{"Enter"===e.key&&(e.preventDefault(),o.click())}),t.addEventListener("input",()=>{a(s)})}function s(e){const n=e.firstChild;if(!n)return;const a=n.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 i=a.cloneNode(!0),r=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),r.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">\n You can get the API Token\n <a href="https://${host}/api-token" target="_blank" style="color: #007bff; text-decoration: none;">here</a>\n </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>`,i.appendChild(r),n.appendChild(s),n.appendChild(i);const c=document.getElementById("token-type-switcher"),d=document.getElementById("helper-token"),p=document.getElementById("token-caption"),u=document.getElementById("helper-token-submit"),m=document.getElementById("error-message"),h=r.querySelector(".helper-login-wrapper");c&&d&&p&&u&&m&&h?(!function(e,n,a){const o=e.querySelectorAll(".token-type-option"),s=e=>{const s=e.dataset.value;o.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"),t(s,n,a)};o.forEach(e=>{e.addEventListener("click",e=>{const t=e.currentTarget;s(t)}),e.addEventListener("keydown",t=>{"Enter"!==t.key&&" "!==t.key||(t.preventDefault(),s(e))})})}(c,d,p),o(h,d,u,m),t("personal",d,p)):console.error("Failed to find required form elements")}const i='#mi-react-root form:not([id]):has(input[name="login"]):has(input[name="password"])',r=new MutationObserver(e=>{for(const t of e)if("childList"===t.type){const e=document.querySelector(i);if(e){r.disconnect(),s(e);break}}});r.observe(document.body,{childList:!0,subtree:!0});const l=document.querySelector(i);l&&(r.disconnect(),s(l))},ce=/^(https?:\/\/)([^/]+)(\/.*)?$/i,de="X-PP-Proxy",pe=[/^text\//,/^application\/(?:[\w.-]+\+)?(?:json|xml)$/,/^application\/(?:x-)?(?:java|ecma)script$/];function ue(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&&function(e){const t=e.split(";",1)[0].trim().toLowerCase();return pe.some(e=>e.test(t))}(n)}function me(n){const{rewritePath:o=/^\/(?!p[tl]).*/i,baseURL:s="",disableSSLValidation:r=!1,miAPI:l,templateName:c}=n;if(!s)throw new Error("Base url is required");const d=s.replace(ce,"$2"),p=s.replace(ce,"$1$2"),u=import("file-type"),m=oe();return e({selfHandleResponse:!0,pathFilter:(e,t)=>!(n.proxyIgnore||[]).some(t=>"string"==typeof t?e.startsWith(t):"function"!=typeof t.test||t.test(e))&&("string"==typeof o?e.startsWith(o):Array.isArray(o)?o.some(t=>e.startsWith(t)):"function"==typeof o.test&&o.test(e)),target:s,changeOrigin:!0,autoRewrite:!0,cookieDomainRewrite:{[d]:"localhost"},logger:{info:()=>{},warn:()=>{},error:()=>{}},secure:!r,headers:{host:d,origin:p},on:{proxyReq(e,t,n){const o=t.headers.host,i=e.getHeader("referer");o&&i&&"string"==typeof i&&e.setHeader("referer",i.replace(new RegExp(`https?://${o}`),s)),l.personalAccessToken&&e.setHeader("Authorization",`Bearer ${l.personalAccessToken}`);const r=t.url??"/",d=function(e,t,n,o){if(!t||!n||!o||n===o)return e;try{const t=new a(e,"http://localhost"),s=t.pathname.match(/^\/data\/page\/([^/]+)(\/.*)?$/);if(!s)return e;let i;try{i=decodeURIComponent(s[1])}catch{i=s[1]}if(i!==n)return e;return`/data/page/${o}${s[2]??""}`+t.search+t.hash}catch{return e}}(r,l.v7Features,c,l.internalPageName);return d!==r&&(t.url=d,e.path=d,console.info(`${se.cyan("Rewrites data page to correct internal page name")}: ${se.yellow(r)} ${se.blue("→")} ${se.green(d)}`)),m.info(`${se.blue("Proxies request:")} ${se.green(t.method)} ${t.url} -> ${se.green(e.method)} ${e.protocol}//${e.host}${e.path}`),t.socket.setMaxListeners(0),t.socket.once("close",()=>{setTimeout(()=>{e.destroyed||e.destroy()},200)}),e},proxyRes:(e,n,a)=>{if(e.headers["content-type"]?.includes("text/event-stream")||e.headers["transfer-encoding"]?.includes("chunked")&&"no"===e.headers["x-accel-buffering"]){m.info(`${se.blue("Start streaming for request:")} ${se.green(n.method)} ${n.url}`);const t=(o=(e,t)=>Buffer.from(ie(d,n.headers.host??"",e.toString(t)),t),async(e,t,n)=>{const a=o&&ue(e.headers);n.statusCode=e.statusCode??n.statusCode,e.statusMessage&&(n.statusMessage=e.statusMessage),n.setHeader(de,1);for(const[t,o]of Object.entries(e.headers))void 0!==o&&(a&&"content-length"===t.toLowerCase()||n.setHeader(t,o));if(!a)return void e.pipe(n);const s=new i("utf8");let r="",l=!1;const c=t=>{t&&(n.write(o(Buffer.from(t,"utf8"),"utf8"))||l||(l=!0,e.pause(),n.once("drain",()=>{l=!1,e.resume()})))};e.on("data",e=>{r+=s.write(e);const t=r.lastIndexOf("\n");-1!==t&&(c(r.slice(0,t+1)),r=r.slice(t+1)),r.length>=65536&&(c(r),r="")}),e.on("end",()=>{c(r+s.end()),r="",n.end()}),e.on("error",()=>{n.end()})});return t(e,n,a)}var o;const s=t(async(e,t,n,a)=>{a.setHeader(de,1);if(await(await u).fileTypeFromBuffer(e))return e;{let t=e.toString("utf8");try{const e=new URL(n.url??"",`http://${d}`);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 o=function(){const s=new URLSearchParams(window.location.search);if(!s.has("proxyRedirect"))return console.debug("No proxyRedirect param. Cleaning up."),void localStorage.removeItem(e);a!==window.location.href?(a=window.location.href,setTimeout(o,n<3?3e3:5e3)):window.location.href=s.get("proxyRedirect"),localStorage.setItem(e,""+ ++n),localStorage.setItem(t,(new Date).toISOString())};setTimeout(o,3e3)}.toString()})()<\/script>`}e.pathname.startsWith("/login")&&(a.setHeader("Cache-Control","no-cache, no-store, must-revalidate"),a.setHeader("Pragma","no-cache"),a.setHeader("Expires","0"));const o=t.slice(0,100);(o.includes("<html")||o.includes("<body")||o.includes("<head")||o.includes("html>"))&&(t+=`<script>const host = "${d}";\n(${le.toString()})()<\/script>`)}catch{}const o=n.headers.host??"";return((e,t,n)=>{const a=new RegExp(`${e.replace(/\\*\//gi,"\\\\/")}`,"gi"),o=new RegExp(`${e}`,"gi"),s=n.replace(a,t);return s===n?s.replace(o,t):s})("/auth/saml/login","/login",ie(d,o,t))}});return s(e,n,a)},error(e,t,n){const a=`Proxy error: "${e.message}" when trying to "${t.method} ${t.url}"\n\n${e.stack}`;m.error(a),n.writable&&n.writeHead(500,{"Content-Type":"text/plain"}),n.end(a)}}})}class he{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 ge extends he{formdataModulePromise=import("formdata-node");constructor(e){super(e)}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.axios.get(this.getDownloadUrl(e),{withCredentials:!0,headers:Object.assign({},t,{accept:"*/*"}),responseType:"arraybuffer"}).then(e=>e.data)}async uploadPageAssets(e,t,n){const a=new(await this.formdataModulePromise).FormData,{File:o}=await this.formdataModulePromise,s=new o([t],"file.zip",{type:"application/zip"});a.append("file",s);const i=this.getUploadUrl(e);return this.axios.post(i,a,{withCredentials:!0,headers:Object.assign({},n,{accept:"application/json","Content-Type":"multipart/form-data",Referer:this.axios.getUri({url:i})})}).then(e=>e.data)}async downloadTemplateAssets(e,t){return this.axios.get(this.getDownloadTemplateUrl(e),{withCredentials:!0,headers:Object.assign({},t,{accept:"*/*"}),responseType:"arraybuffer"}).then(e=>e.data)}async uploadTemplateAssets(e,t,n){const a=new(await this.formdataModulePromise).FormData,{File:o}=await this.formdataModulePromise,s=new o([t],"file.zip",{type:"application/zip"});a.append("file",s,"file.zip");const i=this.getUploadTemplateUrl(e);return this.axios.post(i,a,{withCredentials:!0,headers:Object.assign({},n,{accept:"application/json","Content-Type":"multipart/form-data",Referer:this.axios.getUri({url:i})})}).then(e=>e.data)}}class fe extends ge{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 ve="Something went wrong when fetching page data",be="Something went wrong when fetching page template data",ye=" The instance may be down or in maintenance mode. Please try again later.";function we(){return new Error(`${be}.${ye}`)}function xe(e){return e instanceof Error&&(e.message.includes(ve)||e.message.includes(be))}class ke extends he{async getAll(e){return(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.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)throw new Error(`${ve}.${ye}`);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){return(await this.axios.post("/api/page",e,{withCredentials:!0,headers:Object.assign({},t,{accept:"application/json","content-type":"application/json"})})).data.page}}class Se extends he{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 we();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 we();return n.page_template}}class Ie extends he{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;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 Ee(e){const t=e.response?.status||0,n=e.response?.data?.message||e.message||"Unknown error";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"]}}}const Te="[DEV PAGE. DO NOT DELETE]",Ce=new Map;class Ae{#e;#t;#n=null;#a;#o;#s;#i;#r;#l;#c;internalPageName;appId;templateLess;assetsApi;pageApi;pageTemplateApi;pageVariableApi;logger;constructor(e,t){const{headers:n={},appId:a,templateLess:o=!0,disableSSLValidation:s=!1,v7Features:i=!1,personalAccessToken:l}=t||{};this.#e=n,this.#o=[],this.#a="",this.#s=i,this.#i=l,this.#r=!1,this.#l=Promise.resolve(!1);const p=`${e}:${s}`;if(Ce.has(p))this.#t=Ce.get(p);else{s&&(process.env.NODE_TLS_REJECT_UNAUTHORIZED="0");const t=new d.Agent({keepAlive:!1,...s?{rejectUnauthorized:!1}:{}});this.#t=r.create({baseURL:e,headers:n,timeout:3e4,maxRedirects:5,httpAgent:new c.Agent({keepAlive:!1}),httpsAgent:t}),s&&(r.defaults.httpsAgent=t),Ce.set(p,this.#t)}this.appId=a,this.templateLess=o,this.assetsApi=new(i?fe:ge)(this.#t),this.pageApi=new ke(this.#t),this.pageTemplateApi=new Se(this.#t),this.pageVariableApi=new Ie(this.#t),this.logger=oe()}async isTemplateLoaded(){return this.#l}get isV710OrHigher(){return this.#r}get v7Features(){return this.#s}#d(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.#i&&(t.authorization=`Bearer ${this.#i}`),t}#p(e){return e.replace(/[.*+?^${}()|[\]\\\/]/g,"\\$&")}get personalAccessToken(){return this.#i}set personalAccessToken(e){this.#i=e}updateHeaders(e){this.#d(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.#l||this.#n||(this.#l=new Promise(e=>{this.#c=e})),this.#n)return Promise.resolve(this.#n);if(void 0===this.templateLess&&(this.templateLess=!1),this.#s){const t=await this.pageApi.get(this.appId,this.#d(e)).then(e=>(this.logger.info(se.green("Page fetched")),e)).catch(async t=>{if(this.#c(!1),xe(t))throw t;throw await this.pageApi.checkAuth(this.#d(e))?(this.logger.error(se.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.#r=!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(se.green("Local page template fetched")),this.#c(!0),this.#n}let t=(await this.pageApi.getAll(this.#d(e)).then(e=>(this.logger.info(se.green("Page list fetched")),e)).catch(async t=>{throw this.#c(!1),await this.pageApi.checkAuth(this.#d(e))?(this.logger.error(se.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===Te);return t||(this.logger.warn(se.yellow("Creating dev page template...")),t=await this.pageApi.create({enabled:"Y",name:Te,internal_name:"dev-page-template",visible_in_homepage:"Y"},this.#d(e)).then(e=>(this.logger.info(se.green("Dev page created")),e)).catch(e=>{throw this.logger.error(se.red(`Error creating dev page: ${e.message}`)),this.#c(!1),new Error(`Error when creating dev page.\n That can be caused by missing permissions or page with name "${Te}" already exists`)})),await this.pageApi.getPageContent(t.internal_name,this.#d(e)).then(e=>(this.#n=e,e)).then(e=>(this.#c(!0),this.logger.info(se.green("Page template fetched")),e)).catch(e=>{throw this.#c(!1),this.logger.error(se.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.#d(t)).then(e=>{const{tags:t="[]",name:n,template:a}=e;if(a&&t){const e=JSON.parse(t);return this.#o=e,this.#a=n,e}return this.#o=[],this.#a=n,[]}).catch(t=>{if(this.logger.error(se.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(se.cyan(`[page-variables] Fetching live variables for page ID ${t} (page_id lookup)`));const a=await this.pageVariableApi.getById(t,this.#d(e));return this.logger.info(se.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(se.cyan(`[page-variables] Applying ${e.length} variable(s) to page ID ${n} (page_id lookup)`)),await this.pageVariableApi.updateById(n,e,this.#d(t)),this.logger.info(se.green(`[page-variables] Applied variables to page ID ${n} in ${Date.now()-a}ms`))}async listPages(e=this.#e){return this.pageApi.getAll(this.#d(e))}async getPageVariablesFor(e,t=this.#e){return this.pageVariableApi.getById(e,this.#d(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.#d(t),a=this.appId;try{return this.#s?await this.#u(a,n):await this.#m(a,n)}catch(e){this.#h(e,a)}}async#u(e,t){const n=await this.pageApi.get(e,t);return this.logger.info(se.green("Page fetched")),void 0!==n.template_id&&(this.#r=!0),this.internalPageName=n.internal_name,n}async#m(e,t){return await this.pageApi.get(e,t)}#h(e,t){if(xe(e))throw e;if(this.#s&&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(se.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 e of this.#o){const t=this.#p(e.name),a=new RegExp(`\\[${t}\\]`,"g");n=n.replace(a,()=>e.value)}const a=new p(t?n:this.#n),o="%%PLACEHOLDER%%";if(!t){const e=a.window.document.createElement("div");e.innerHTML=o;const t=a.window.document.querySelector(".main-side");if(t){const n=t.querySelectorAll("script");n.length?t.insertBefore(e,n.item(n.length-1)):t.append(e)}else{const t=a.window.document.createElement("div");t.append(e),a.window.document.body.append(t)}const n=a.window.document.querySelector("head"),s=n.querySelector("title");if(s)s.text=this.#a;else{const e=a.window.document.createElement("title");e.textContent=this.#a,n.appendChild(e)}}return a.serialize().replace(new RegExp(`<div>\\s*${o}\\s*<\\/div>`,"i"),n)}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.#r){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.#r){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=Ee(e);return{isValid:!1,error:t.userFriendlyMessage,code:t.code}}}async get(e,t,n=!1){const a=n?t:Object.assign({},this.#d(this.#e),t);try{return await this.#t.get(e,{headers:a})}catch(e){if("UNKNOWN_ERROR"!==Ee(e).code){const t=Ee(e);throw this.logger.error(se.red(`API request failed: ${t.userFriendlyMessage}`)),function(e,t,n){const a=Ee(t),o=`[${n}] `;e.error(se.red(`${o}${a.userFriendlyMessage}`)),e.error(se.red(`Status: ${a.status} (${a.code})`)),e.error(se.red(`Details: ${a.message}`)),a.suggestions.length>0&&(e.info(se.yellow("Suggestions:")),a.suggestions.forEach((t,n)=>{e.info(se.yellow(` ${n+1}. ${t}`))}))}(this.logger,e,"API Request"),e.response||(e.response={status:t.status,data:{message:t.message}}),e.tokenErrorInfo=t,e}throw e}}}function Le(e,t){if(!e||e.includes("\0")||R.posix.isAbsolute(e)||R.win32.isAbsolute(e)||R.win32.parse(e).root)throw new Error(`[pp-dev] ${t} must be a relative path inside its output directory.`);const n=R.posix.normalize(e.replace(/\\/g,"/"));if("."===n||".."===n||n.startsWith("../"))throw new Error(`[pp-dev] ${t} must stay inside its output directory.`);return n}function Re(e){return Le(`${e.replace(/^@/,"").replace(/[\\/]+/g,"-")}.zip`,"default ZIP output file name")}function Oe(e,t,n){const a=Le(t,n),o=R.resolve(e),s=R.resolve(o,...a.split("/")),i=R.relative(o,s);if(".."===i||i.startsWith(`..${R.sep}`)||R.isAbsolute(i))throw new Error(`[pp-dev] ${n} must resolve inside its output directory.`);return s}function Ne(e){const t=new p(`<!doctype html><html><body>${e}</body></html>`).window.document,n=t.querySelector("svg");return n?(Be(n),function(e){const t=Array.from(e.attributes),n=e=>"xmlns"===e||e.startsWith("xmlns:"),a=[...t.filter(e=>n(e.name)),...t.filter(e=>!n(e.name))];t.forEach(t=>e.removeAttribute(t.name)),a.forEach(t=>e.setAttribute(t.name,t.value))}(n),je(n,0,t),`<?xml version="1.0" encoding="UTF-8"?>\n${n.outerHTML}\n`):e}function He(e){for(const t of v(e,{withFileTypes:!0})){const n=m.join(e,t.name);if(t.isDirectory())He(n);else if(t.isFile()&&t.name.toLowerCase().endsWith(".svg")){const e=g(n,"utf-8"),t=Ne(e);t!==e&&b(n,t,"utf-8")}}}function Be(e){Array.from(e.childNodes).filter(e=>8===e.nodeType).forEach(t=>e.removeChild(t)),Array.from(e.children).forEach(e=>Be(e))}function je(e,t,n){const a=Array.from(e.childNodes);if(a.some(e=>3===e.nodeType&&""!==(e.textContent??"").trim()))return;const o=a.filter(e=>1===e.nodeType);if(0===o.length)return;a.forEach(t=>e.removeChild(t));const s=`\n${" ".repeat(t+1)}`,i=`\n${" ".repeat(t)}`;o.forEach(a=>{e.appendChild(n.createTextNode(s)),e.appendChild(a),je(a,t+1,n)}),e.appendChild(n.createTextNode(i))}const De="BUILD-MANIFEST.json";function _e(e,t,n=new Set){const a=v(e,{withFileTypes:!0}),o=[];for(const s of a){const a=m.join(e,s.name),i=m.relative(t,a).replace(/\\/g,"/");s.isDirectory()?o.push(..._e(a,t,n)):s.isFile()&&!n.has(i)&&o.push(a)}return o}function $e(e){const t=g(e);return S("sha256").update(t).digest("hex")}function Fe(e){return e.replace(/^([a-z][a-z\d+.-]*:\/\/)(?:[^/?#@]+(?::[^/?#@]*)?@)/i,"$1")}function Me(e){const t=e.trim().replace(/^git\+/,"").replace(/\.git$/i,""),n=t.match(/^([^@]+)@([^:]+):(.+)$/);if(n){const[,,e,t]=n;return Fe(`https://${e}/${t.replace(/^\/+/,"")}`)}const a=t.match(/^ssh:\/\/(?:[^@]+@)?([^/:]+)(?::\d+)?\/(.+)$/i);if(a){const[,e,t]=a;return Fe(`https://${e}/${t.replace(/^\/+/,"")}`)}const o=t.match(/^git:\/\/([^/]+)\/(.+)$/i);if(o){const[,e,t]=o;return Fe(`https://${e}/${t.replace(/^\/+/,"")}`)}return Fe(t)}function Pe(e){if(!1===e.enabled)return;const{outDir:t,root:a=process.cwd(),versionFileTemplate:o,packageVersion:s}=e,i=m.resolve(a,t);if(!y(i,{throwIfNoEntry:!1})?.isDirectory())return;He(i);const r=(new Date).toISOString(),l=o??"VERSION-v{packageversion}-{currentDate}.json",c=Le(function(e,t,n){return e.replace(/\{packageversion\}/g,t).replace(/\{currentDate\}/g,n)}(l,s,r.replace(/:/g,"-")),"VERSION output file name"),d=Oe(i,c,"VERSION output file name"),p=_e(i,i,new Set([c,De])),u={};for(const e of p){u[m.relative(i,e).replace(/\\/g,"/")]=$e(e)}const h=function(e){const t=Object.keys(e).sort().map(t=>e[t]).join("");return S("sha256").update(t).digest("hex")}(u),f=function(e){const t=Object.keys(e).sort().map(t=>`${t}:${e[t]}`).join("\n");return S("sha256").update(t,"utf-8").digest("hex")}(u),v=function(){const e=n(import.meta.url);let t=m.dirname(e);for(;;){const e=m.join(t,"package.json"),n=y(e,{throwIfNoEntry:!1});if(n?.isFile())try{const t=JSON.parse(g(e,"utf-8"));if("string"==typeof t.version)return t.version}catch{}const a=m.dirname(t);if(a===t)return"unknown";t=a}}(),x=function(e){if("string"==typeof e&&e.trim())return e.trim();const t=["GITHUB_HEAD_REF","GITHUB_REF_NAME","CI_COMMIT_REF_NAME","BITBUCKET_BRANCH","BUILD_SOURCEBRANCHNAME","BRANCH_NAME","TRAVIS_BRANCH","CIRCLE_BRANCH","BUILDKITE_BRANCH","VERCEL_GIT_COMMIT_REF"];for(const e of t){const t=process.env[e];if("string"==typeof t&&t.trim())return t.trim()}try{const e=C("git rev-parse --abbrev-ref HEAD",{encoding:"utf8",stdio:["ignore","pipe","ignore"]}).trim();if(e&&"HEAD"!==e)return e}catch{}}(e.packageBranchName),k={schemaVersion:1,version:`v${s}`,date:r,checksum:h,files:u,repositoryUrl:e.packageRepositoryUrl?Me(e.packageRepositoryUrl):void 0,branchName:x,helperVersion:v},I=m.join(i,De),E={schemaVersion:1,manifestType:"pp-dev-build-manifest",generatedAt:r,versionFile:c,versionFileTemplate:l,versionFileSchemaVersion:1,buildFingerprint:f,compat:{versionFileRequired:!0}};w(m.dirname(d),{recursive:!0}),b(d,JSON.stringify(k,null,2),"utf-8"),b(I,JSON.stringify(E,null,2),"utf-8")}async function Ve(e){const t=new O,n=async a=>{const o=await x.readdir(a,{withFileTypes:!0});for(const s of o){const o=u.join(a,s.name);if(s.isDirectory())await n(o);else if(s.isFile()){const n=u.relative(e,o).replace(/\\/g,"/");t.file(n,await x.readFile(o))}}};return await n(e),await t.generateAsync({type:"nodebuffer",compression:"DEFLATE"})}async function qe(e){const t=await x.readdir(e,{withFileTypes:!0});for(const n of t){const t=u.join(e,n.name);if(n.isSymbolicLink())throw new Error(`Zip archive contains a symlink ("${n.name}"), which is not allowed`);n.isDirectory()&&await qe(t)}}function ze(e){return new Promise((t,n)=>{let a;try{const t=N(u.join(e,"package.json")).resolve("next/package.json");a=u.resolve(u.dirname(t),"dist/bin/next")}catch{return void n(new Error(`Unable to resolve the "next" binary from ${e}. Is Next.js installed?`))}const o=T.spawn(E.execPath,[a,"build"],{cwd:e,env:Object.assign({},E.env,{NODE_ENV:"production",[ee]:"1"}),stdio:"inherit"});o.on("close",e=>{0===e?t():n(new Error(`next build exited with code ${e}`))}),o.on("error",n)})}const Ue="pageName",Je="date",We=u.dirname("undefined"!=typeof __filename&&__filename||n(import.meta.url)),Ye=u.resolve(We,"..",".."),Ke=u.resolve(Ye,"..",".."),Ze=u.resolve(Ke,".pp-dev-meta"),Ge=u.resolve(Ze,"sync-service.meta.json"),Xe="BUILD-MANIFEST.json",Qe="__template_variables.json";class et{root;backupFolder;backupNameTemplate;dateFormat;pageName;currentMeta=null;distZipFolder;distZipFilename;versionFileTemplate;buildInputFolder;nextBuild;logger;constructor(e,t){this.pageName=e;const{root:n=E.cwd(),backupFolder:a="backups",distZipFolder:o=u.resolve(n,"dist-zip"),distZipFilename:s=Re(this.pageName),backupNameTemplate:i=`{${Ue}}-{${Je}}.zip`,versionFileTemplate:r="VERSION-v{packageversion}-{currentDate}.json",buildInputFolder:l,dateFormat:c=e=>e.toISOString().replace(/:/g,"-").replace(/\..*$/,""),nextBuild:d}=t||{};this.root=n,this.backupFolder=u.resolve(n,a),this.backupNameTemplate=i,this.dateFormat=c,this.distZipFolder=o,this.distZipFilename=Le(s,"ZIP output file name"),this.versionFileTemplate=Le(r,"VERSION file name template"),this.buildInputFolder=l,this.nextBuild=d,this.syncMeta(),this.logger=oe()}async checkMeta(){try{await x.stat(Ze)}catch{await x.mkdir(Ze)}try{await x.stat(this.backupFolder)}catch{await x.mkdir(this.backupFolder)}}async readMetaFile(){return await this.checkMeta(),await x.readFile(Ge,{encoding:"utf-8"}).catch(()=>"{}")}async writeMetaFile(e){return await this.checkMeta(),await x.writeFile(Ge,JSON.stringify(e,null,2),{encoding:"utf-8"})}async syncMeta(){this.currentMeta?await this.writeMetaFile(this.currentMeta):this.currentMeta=JSON.parse(await this.readMetaFile())}async getLatestSavedBackup(){const{lastBackupName:e}=this.currentMeta;if(!e)return null;const t=u.resolve(this.backupFolder,e);try{return await x.stat(t),t}catch{return null}}backupName(e,t=new Date){return this.backupNameTemplate.replace(`{${Ue}}`,e).replace(`{${Je}}`,this.dateFormat(t))}getBackupMeta(){return this.currentMeta}pathToPosix(e){return e.replace(/\\/g,"/")}normalizeBackupManifestPath(e){try{return Le(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 x.readdir(t,{withFileTypes:!0});if(1!==e.length||!e[0]?.isDirectory())return t;t=u.join(t,e[0].name)}}async listFilesRecursive(e){const t=await x.readdir(e,{withFileTypes:!0}),n=[];for(const a of t){const t=u.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 k.createHash("sha256").update(t).digest("hex")}buildBackupFingerprint(e){const t=Object.keys(e).sort().map(t=>`${t}:${e[t]}`).join("\n");return k.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,o=this.versionFileTemplate.slice(n,a.index);t.push(this.escapeRegExp(o)),t.push("[^/]+"),n=a.index+e.length}const o=this.versionFileTemplate.slice(n);return t.push(this.escapeRegExp(o)),new RegExp(`^${t.join("")}$`,"i")}isTemplateVariablesBasename(e){return e===Qe}async pickTemplateVariablesFromBackupZip(e,t){const n=e.find(e=>u.basename(e)===Qe);if(!n)return null;const a=u.join(t,n),o=await x.readFile(a);return{relativePath:n,content:o,actualHash:k.createHash("sha256").update(o).digest("hex"),manifestHash:null}}async resolveBackupAnalysisFromZip(e){const t=await x.mkdtemp(u.join(A.tmpdir(),"pp-dev-backup-")),n=u.join(t,"backup.zip"),a=u.join(t,"extracted");try{await x.mkdir(a,{recursive:!0}),await x.writeFile(n,e),await L(n,{dir:a}),await qe(a);const t=await this.normalizeExtractedRootDir(a),o=(await this.listFilesRecursive(t)).map(e=>this.pathToPosix(u.relative(t,e))),s=o.filter(e=>u.basename(e)===Xe);if(s.length>1)throw new Error(`Expected at most one ${Xe} in backup ZIP, found ${s.length}`);const i=1===s.length?s[0]:null;let r=null;if(i){const e=u.join(t,i);if(r=JSON.parse(await x.readFile(e,"utf-8")),!(r&&"pp-dev-build-manifest"===r.manifestType&&"string"==typeof r.versionFile&&r.versionFile.trim()&&"string"==typeof r.versionFileTemplate&&r.versionFileTemplate.trim()&&"string"==typeof r.buildFingerprint&&r.buildFingerprint.trim()))throw new Error("Build manifest is missing required fields")}let l=null;if(r){const e=this.pathToPosix(r.versionFile.trim()),t=o.filter(t=>t===e),n=o.filter(t=>u.basename(t)===u.basename(e)),a=t.length>0?t:n;if(a.length>1)throw new Error(`Build manifest version file "${r.versionFile}" must resolve to exactly one file in backup ZIP, found ${a.length}`);if(1===a.length&&(l=a[0]),!0===r.compat?.versionFileRequired&&null===l)throw new Error(`Build manifest requires version file "${r.versionFile}", but it is missing in backup ZIP`)}else{const e=this.versionFileTemplateMatcher(),t=o.filter(t=>e.test(u.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&&(l=t[0])}const c={};let d=null,p=null;const m={};let h=[];if(l){const e=u.join(t,l),n=JSON.parse(await x.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(c,n))throw new Error(`VERSION manifest contains duplicate normalized file path: ${n}`);c[n]=t}if(this.buildManifestChecksum(c)!==n.checksum)throw new Error("VERSION manifest checksum does not match its files map");for(const[e,n]of Object.entries(c)){const a=u.join(t,e),o=await x.readFile(a).catch(()=>null);if(!o)throw new Error(`VERSION manifest references missing file: ${e}`);const s=k.createHash("sha256").update(o).digest("hex");m[e]=s,s!==n&&h.push({relativePath:e,expected:n,actual:s}),this.isTemplateVariablesBasename(u.basename(e))&&(d={relativePath:e,content:o,actualHash:s,manifestHash:n})}h.length>0&&this.logger.warn(se.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 o){if(e===i)continue;if(l&&e===l)continue;if(l&&Object.prototype.hasOwnProperty.call(f,e))continue;const n=u.join(t,e),a=await x.readFile(n),o=k.createHash("sha256").update(a).digest("hex");l&&Object.prototype.hasOwnProperty.call(c,e)?f[e]=g.has(e)?m[e]:c[e]:l?(f[e]="__NOT_IN_VERSION_FILE__",v.push(e)):f[e]=o}d||(d=await this.pickTemplateVariablesFromBackupZip(o,t));const b=this.buildBackupFingerprint(f);let y=null;return r&&l&&b!==r.buildFingerprint&&(y={expected:r.buildFingerprint,actual:b},this.logger.warn(se.yellow(`[DistService] Build manifest fingerprint mismatch (confirm in UI): expected ${r.buildFingerprint}, got ${b}`))),{backupFingerprint:b,unknownFiles:v,templateVariables:d,mainHash:p,allFilesInMeta:Boolean(l&&0===v.length),buildManifestMismatch:y,versionManifestHashMismatches:h}}finally{await x.rm(t,{recursive:!0,force:!0})}}async analyzeBackup(e){if("PK"!==e.toString("utf-8").slice(0,4))throw new Error("Backup file is not a ZIP file");return await this.resolveBackupAnalysisFromZip(e)}async getPublicTemplateVariablesHash(){const e=u.resolve(this.root,"public",Qe),t=await x.readFile(e).catch(()=>null);return t?k.createHash("sha256").update(t).digest("hex"):null}async readPublicTemplateVariablesFile(){const e=u.resolve(this.root,"public",Qe);return await x.readFile(e).catch(()=>null)}async saveTemplateVariablesFile(e){const t=u.resolve(this.root,"public",Qe);return await x.mkdir(u.dirname(t),{recursive:!0}),await x.writeFile(t,e),t}async saveBackup(e,t){const n=t??await this.analyzeBackup(e),a=n.backupFingerprint;if(await this.getLatestSavedBackup()){const{lastBackupHash:e,lastMainHash:t}=this.currentMeta;if(e===a)return void this.logger.info(se.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(se.yellow("[DistService] Backup skipped: same VERSION manifest checksum (main hash) and all files listed in VERSION"))}const o=new Date,s=this.backupName(this.pageName,o);return this.currentMeta.lastBackupName=s,this.currentMeta.lastBackupHash=a,this.currentMeta.lastBackupDate=o.toISOString(),n.mainHash?this.currentMeta.lastMainHash=n.mainHash:delete this.currentMeta.lastMainHash,await this.syncMeta(),await x.writeFile(u.resolve(this.backupFolder,s),e).finally(()=>{this.logger.info(`Backup saved to ${s}`)})}async buildNewAssets(){if(this.nextBuild)return await this.buildNextAssets();const e=new Promise((e,t)=>{let n="";this.logger.info(se.cyan("[DistService] Build started"));const a=T.spawn("node",[u.resolve(Ye,"./bin/pp-dev.js"),"build"],{cwd:this.root,env:Object.assign({},E.env,{NODE_ENV:"production"}),stdio:"inherit"});a.on("message",e=>{n+=e}),a.on("close",a=>{0===a?e(n):t(new Error(`build command exited with code ${a}`))}),a.on("error",e=>{t(e)})});try{if(await e.finally(()=>{this.logger.info(se.cyan("[DistService] Build finished"))}),this.buildInputFolder){const e=u.resolve(this.root,this.buildInputFolder);if(!await this.isDirectory(e))throw new Error(`Build input directory ${e} not found`);return await Ve(e)}const t=u.resolve(this.root,this.distZipFolder,this.distZipFilename);if(!await x.stat(t))throw new Error(`File ${t} not found`);return await x.readFile(t)}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(se.cyan("[DistService] Next.js build started")),await ze(e),this.logger.info(se.cyan("[DistService] Next.js build finished"));const n=u.resolve(e,t);if(!await this.isDirectory(n))throw new Error(`Next.js build output directory not found: ${n}`);return Pe({outDir:n,packageVersion:this.nextBuild.packageVersion,versionFileTemplate:this.versionFileTemplate,packageRepositoryUrl:this.nextBuild.packageRepositoryUrl,packageBranchName:this.nextBuild.packageBranchName}),await Ve(n)}async isDirectory(e){try{return(await x.stat(e)).isDirectory()}catch{return!1}}}class tt{server;opts;eventMap;logger;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=oe(),this.init()}init(){const{ws:e}=this.server;for(const[t,n]of this.eventMap)e.on(t,n);e.on("close",()=>{this.clearAllPendingSyncActions(!1)}),e.on("error",()=>{this.clearAllPendingSyncActions(!1)})}resolveSyncAction(e,t){const n=this.syncActionResolvers.get(e);n&&(clearTimeout(n.timeoutId),this.syncActionResolvers.delete(e),n.resolve(t))}clearAllPendingSyncActions(e){for(const t of[...this.syncActionResolvers.keys()])this.resolveSyncAction(t,e)}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=I(),a=this.opts.syncActionTimeoutMs??12e4;return await new Promise(o=>{const s=setTimeout(()=>{this.resolveSyncAction(n,!1)},a);this.syncActionResolvers.set(n,{resolve:o,timeoutId:s}),t.send("template:sync:action-required",{...e,requestId:n})})}async onTemplateSync(e,t){if(!this.opts.distService||!this.opts.miAPI)return t.send("template:sync:response",{error:"Dist service or MiAPI is not defined"}),void this.logger.error(se.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 t.send("template:sync:response",{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(l(e)){if("SESSION_EXPIRED"===Ee(e).code){this.logger.info(se.yellow("Session expired - attempting to validate credentials"));try{const e=await(n?.validateCredentials());e&&!e.isValid?(this.logger.error(se.red(`Authentication error: ${e.error}`)),t.send("template:sync:response",{error:e.error,code:e.code,refresh:!0})):(this.logger.info(se.yellow("Session expired")),t.send("template:sync:response",{error:"Session expired",code:"SESSION_EXPIRED",refresh:!0}))}catch(e){this.logger.info(se.yellow("Session expired")),t.send("template:sync:response",{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(se.yellow("Server in maintenance mode, VPN connection is needed or no internet connection")),t.send("template:sync:response",{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(se.yellow(`Server in maintenance mode or unreachable (HTTP ${a}); VPN may be required`)),t.send("template:sync:response",{error:"Server in maintenance mode, VPN connection is needed or no internet connection",code:"CONNECTION_ERROR"}),e}if(xe(e))return this.logger.info(se.yellow("Server in maintenance mode or non-JSON page / page-template API response (unavailable HTML body)")),t.send("template:sync:response",{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 o;if(a){const n=await(e?.analyzeBackup(a).catch(e=>"Backup file is not a ZIP file"===e.message?(this.logger.error(se.red("Backup file is not a ZIP file")),e):e));if(n instanceof Error)o=n;else if(n){await(e?.saveBackup(a,n));const s=0===n.unknownFiles.length&&1===n.versionManifestHashMismatches.length&&u.basename(n.versionManifestHashMismatches[0].relativePath)===Qe,i=n.versionManifestHashMismatches,r=i.filter(e=>u.basename(e.relativePath)===Qe),l=i.filter(e=>u.basename(e.relativePath)!==Qe),c=!s&&r.length>0&&l.length>0,d=s?[]:l.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 t.send("template:sync:response",{cancelled:!0,message:"Sync cancelled by user. Backup was saved."}),void this.logger.info(se.yellow("Sync cancelled by user"))}if(c&&await p(),!s&&d.length>0){const e=40,n=d,a=n.slice(0,e),o=n.length-a.length,s=a.map(e=>`• ${e}`).join("\n"),i=o>0?`\n... and ${o} more file${1===o?"":"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${s}${i}\n\nCancel sync, or override and continue using the files on disk?`,confirmText:"Override and continue",cancelText:"Cancel sync"},t))return t.send("template:sync:response",{cancelled:!0,message:"Sync cancelled by user. Backup was saved."}),void this.logger.info(se.yellow("Sync cancelled by user"))}if(!s&&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 t.send("template:sync:response",{cancelled:!0,message:"Sync cancelled by user. Backup was saved."}),void this.logger.info(se.yellow("Sync cancelled by user"))}c||await p(),o=await(e?.buildNewAssets())}}else o=await(e?.buildNewAssets());if(!(o&&o instanceof Buffer))return o instanceof Error?(t.send("template:sync:response",{error:o.message}),void this.logger.error(se.red(o.message))):(t.send("template:sync:response",{error:"Failed to build new assets"}),void this.logger.error(se.red("Failed to build new assets")));{const a=await(n?.updateAssets(o));if("OK"===a?.status){const n=e?.getBackupMeta(),{lastBackupName:a,lastBackupHash:o,lastBackupDate:s}=n||{lastBackupName:"",lastBackupHash:"",lastBackupDate:(new Date).toISOString()};t.send("template:sync:response",{syncedAt:new Date(s),currentHash:o,backupFilename:a}),this.logger.info(se.green("Template synced"))}else t.send("template:sync:response",{error:"Failed to update assets"}),this.logger.error(se.red("Failed to update assets"))}}catch(e){const n=e instanceof Error?e.message:"Template sync failed";return this.logger.error(se.red(`Template sync failed: ${n}`)),void t.send("template:sync:response",{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(se.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(se.green("Page variables reloaded"))}catch(e){const n=e instanceof Error?e.message:"Reload variables failed";this.logger.error(se.red(`Reload variables failed: ${n}`)),t.send("page-variables:reload:response",{error:n})}}}const nt=/\.(?:[a-z0-9]+)$/i,at=6e5,ot=104857600,st=1e3;class it extends H.Cache{totalSize=0;maxSize;maxItems;constructor(e,t){super(),this.maxSize=e,this.maxItems=t}put(e,t,n){const a=this.get(e);a&&(this.totalSize-=a.size);const o=super.put(e,t,n);return this.totalSize+=t.size,this.cleanup(),o}cleanup(){const e=this.keys();if(e.length>this.maxItems){const t=e.length-this.maxItems,n=e.sort((e,t)=>{const n=this.get(e),a=this.get(t);return(n?.timestamp||0)-(a?.timestamp||0)});for(let e=0;e<t;e++){const t=n[e],a=this.get(t);a&&(this.totalSize-=a.size,this.del(t))}}for(;this.totalSize>this.maxSize;){const e=this.keys();if(0===e.length)break;const t=e.sort((e,t)=>{const n=this.get(e),a=this.get(t);return(n?.timestamp||0)-(a?.timestamp||0)})[0],n=this.get(t);if(!n)break;this.totalSize-=n.size,this.del(t)}}getTotalSize(){return this.totalSize}getItemCount(){return this.keys().length}}const rt=new it(ot,st);function lt(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 ct(e){const{devServer:t,ttl:n=at,maxSize:a=ot,maxItems:o=st}=e,s=oe();return a===ot&&o===st||(rt.maxSize=a,rt.maxItems=o),t.cache=rt,(e,t,a)=>{const o=e.originalUrl||e.url||"",i=function(e){const t=e.split("?")[0];return nt.test(t)?t.includes("/auth/info.js")?"":e:""}(o);if(!i)return a();const r=rt.get(i);if(r)return s.info(`${se.blue("Proxies request:")} ${se.green(e.method)} ${o} -> ${se.blue("Cache")} ${i}`),function(e,t){try{for(const[n,a]of Object.entries(t))null!=a&&e.setHeader(n,a)}catch(e){oe().warn(`Failed to set some response headers: ${e instanceof Error?e.message:String(e)}`)}}(t,r.headers),t.setHeader("X-PP-Cache","hit"),t.write(r.content),void t.end();const l=t.end,c=t.write,d=[];t.write=function(e,n,a){if(!t.hasHeader(de))return c.call(this,e,n,a);if("string"==typeof e){const t=n||"utf8";d.push(Buffer.from(e,t))}else Buffer.isBuffer(e)?d.push(e):null!=e&&d.push(Buffer.from(String(e),"utf8"));return a?.(),!0},t.end=function(e,a,o){if(!t.hasHeader(de))return l.call(this,e,a,o);if(null!=e)if("string"==typeof e){const t=a||"utf8";d.push(Buffer.from(e,t))}else Buffer.isBuffer(e)?d.push(e):d.push(Buffer.from(String(e),"utf8"));let r;try{r=lt(d);const e=Buffer.byteLength(r);if(e>0&&e<10485760){const a={headers:t.getHeaders(),content:r,timestamp:Date.now(),size:e};rt.put(i,a,n),s.info(`${se.blue("[Cached]")} ${i} (${(e/1024).toFixed(1)}KB)`)}}catch(e){s.error(`Failed to cache response for ${i}: ${e}`),r=lt(d)}const c=a||"utf8";return l.call(this,r,c,o)},a()}}function dt(e,t){return async(n,a,o)=>{if(await e(n.url??"",n,a)){oe().info(`Rewrite response for ${n.url}`);const e=a.end,o=[];a.write=function(e){return o.push(e),!0},a.end=function(s,i,r){"string"==typeof s&&o.push(Buffer.from(s));const l=a.getHeader("content-encoding"),c=function(e,t){switch(t){case"gzip":return D(e);case"br":return j(e);case"deflate":return B(e);default:return e}}(Buffer.from(Buffer.concat(o)),l),d="function"!=typeof i&&i?i:"utf-8",p="function"==typeof i?i:r;return e.call(this,function(e,t){switch(t){case"gzip":return F(e);case"br":return $(e);case"deflate":return _(e);default:return e}}(t(c,n,a),l),d,p),this}}o()}}function pt(e,t){const n=(e=(e=e.startsWith("/")?e:`/${e}`).endsWith("/")?e:`${e}/`).endsWith("/")?e.slice(0,-1):e,o=oe();return(s,i,r)=>{const l=new a(s.url??"","http://localhost"),{pathname:c,search:d}=l,p=["/",n];if(t&&(p.push(`/pt/${t}`),p.push(`/pl/${t}`)),p.includes(c)){const t=`${e}${d}`;return o.info(se.yellow(`Redirecting to: ${t}`)),re(i,t,302)}r()}}class ut{state={isAuthenticated:!1,isRedirected:!1,lastChecked:0};listeners=new Set;getState(){return{...this.state}}isAuthenticated(){return this.state.isAuthenticated}isRedirected(){return this.state.isRedirected}setAuthenticated(e){this.state.isAuthenticated=e,this.state.lastChecked=Date.now(),this.notifyListeners()}setRedirected(e){this.state.isRedirected=e,this.notifyListeners()}updateState(e){void 0!==e.isAuthenticated&&(this.state.isAuthenticated=e.isAuthenticated),void 0!==e.isRedirected&&(this.state.isRedirected=e.isRedirected),this.state.lastChecked=Date.now(),this.notifyListeners()}reset(){this.state={isAuthenticated:!1,isRedirected:!1,lastChecked:0},this.notifyListeners()}subscribe(e){return this.listeners.add(e),()=>{this.listeners.delete(e)}}notifyListeners(){this.listeners.forEach(e=>{try{e(this.getState())}catch(e){console.error("Error in auth state listener:",e)}})}}const mt=new ut,ht=new Map;function gt(e){const t=ht.get(e);return t?Date.now()-t.timestamp>18e4?(ht.delete(e),null):t.data:null}function ft(e,t){ht.set(e,{timestamp:Date.now(),data:t})}const vt="/home?proxyRedirect=";function bt(e,t,n){const{templateLess:a=!1,miHudLess:o=!1,appId:s,base:i,appBase:r,v7Features:l}=n,c=oe();if(a&&o&&void 0===s)throw new Error("Custom App ID is required when both templateLess and miHudLess are true");return async(n,d,p)=>{try{const u=!(a&&o),m=(n.url??"").split("?")[0],h=e.test(m),g=!h&&!m.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)}(m,r)&&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),f=mt.getState();if(!f.isAuthenticated&&!f.isRedirected&&n.url?.startsWith("/home")&&!n.url?.startsWith(vt)){c.info(se.blue("Trying to authenticate and redirect to base"));try{return u?await wt(t,{templateLess:a,miHudLess:o,appId:s,redirectUrl:l&&i?`${i}`:`${vt}${encodeURIComponent("/")}`},n,d,()=>{},c):await yt(t,{templateLess:a,miHudLess:o,appId:s,redirectUrl:l&&i?`${i}`:`${vt}${encodeURIComponent("/")}`},n,d,()=>{},c),mt.setRedirected(!0),c.info(se.blue("Successfully authenticated. Redirecting to base")),re(d,i??"/",302)}catch(e){return p()}}if(!h&&!g)return p();if(!u){return await yt(t,{templateLess:a,miHudLess:o,appId:s,redirectUrl:l&&i?`${i}`:`${vt}${encodeURIComponent("/")}`},n,d,p,c)}return await wt(t,{templateLess:a,miHudLess:o,appId:s,redirectUrl:l&&i?`${i}`:`${vt}${encodeURIComponent("/")}`},n,d,p,c)}catch(e){return c.error(se.red(`Unexpected error in load-pp-data middleware: ${e instanceof Error?e.message:String(e)}`)),mt.reset(),p(e)}}}async function yt(e,t,n,a,o,s){const{appId:i,redirectOnAuthFailure:r,redirectUrl:l}=t;if(void 0===i){const e=new Error("Custom App ID is required for page info only mode");return s.error(se.red(e.message)),o(e)}const c=n.headers??{};s.info(se.green("Start loading page info"));try{const t=`pageInfo:${i}`;return gt(t)?(s.info(se.green("Page info loaded from cache")),o()):(await e.getPageInfo(i,c),s.info(se.blue("Clearing proxy cache after successful login")),rt.clear(),ft(t,{success:!0}),mt.updateState({isAuthenticated:!0}),s.info(se.green("Page info loaded successfully")),o())}catch(e){return mt.reset(),xt(e,r??!0,l||`${vt}${encodeURIComponent("/")}`,a,o,s,"Page info")}}async function wt(e,t,n,a,o,s){const{templateLess:i,appId:r,redirectOnAuthFailure:l,redirectUrl:c}=t,d=n.headers??{};s.info(se.green("Start loading page data"));try{const t=`pageData:${i}:${r}`;if(gt(t))return s.info(se.green("Page data loaded from cache")),o();const n=i||void 0===r?e.getPageTemplate(d):e.getPageVariables(r,d);return await n,s.info(se.blue("Clearing proxy cache after successful login")),rt.clear(),ft(t,{success:!0}),s.info(se.green("Page data loaded successfully")),o()}catch(e){return mt.reset(),xt(e,l??!0,c||`${vt}${encodeURIComponent("/")}`,a,o,s,"Page data")}}function xt(e,t,n,a,o,s,i){if(function(e){return null!==e&&"object"==typeof e&&"response"in e&&void 0!==e.response}(e)&&(s.info(se.red(`${i} loading failed. Not authorized`)),t)){const e=`${n}`;return s.info(se.yellow(`Redirecting to: ${e}`)),re(a,e,302)}const r=e instanceof Error?e.message:String(e);return s.info(se.red(`${i} loading failed. Error: ${r}`)),o(e)}function kt(){const e=M();return e.use(M.json()),e.use(M.urlencoded({extended:!0})),e}const St=kt();class It{entries=new Map;insertOrder=[];totalSize=0;maxMemory;constructor(e=1073741824){this.maxMemory=e}allocateId(){return P()}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=n+512;for(;this.totalSize+a>this.maxMemory&&this.insertOrder.length>0;){const e=this.insertOrder.shift(),t=this.entries.get(e);t&&(this.totalSize-=(t.requestBody?.byteLength??0)+(t.responseBody?.byteLength??0)+512,this.entries.delete(e))}this.entries.set(t.id,t),this.insertOrder.push(t.id),this.totalSize+=a}get(e){return this.entries.get(e)}list(e){let t=this.insertOrder.slice().map(e=>{const t=this.entries.get(e);return{id:t.id,timestamp:t.timestamp,method:t.method,url:t.url,statusCode:t.statusCode,duration:t.duration,source:t.source,requestSize:t.requestBody?.byteLength??0,responseSize:t.responseBody?.byteLength??0,requestBodyTruncated:t.requestBodyTruncated,responseBodyTruncated:t.responseBodyTruncated}});if(e?.method&&(t=t.filter(t=>t.method===e.method.toUpperCase())),e?.search){const n=e.search.toLowerCase();t=t.filter(e=>e.url.toLowerCase().includes(n))}const n=Number.isFinite(e?.limit)?e.limit:t.length;if(void 0===e?.offset)return t.slice(Math.max(0,t.length-n));const a=Number.isFinite(e.offset)?e.offset:0;return t.slice(a,a+n)}clear(){this.entries.clear(),this.insertOrder=[],this.totalSize=0}get size(){return this.entries.size}get memoryUsage(){return this.totalSize}}const Et=["/@pp-dev/","/@api/","/_next/","/__nextjs_","/@vite","/@metricinsights","/@"],Tt=new Set(["/favicon.ico","/installHook.js.map"]);function Ct(e,t=10485760){return(n,a,o)=>{const s=n.url||"/",i=s.split("?")[0];if(Et.some(e=>s.startsWith(e))||Tt.has(i))return void o();const r=e.allocateId(),l=Date.now(),c=[];let d=0,p=!1;const u=n.emit.bind(n);n.emit=(e,...n)=>{if("data"===e&&!p){const e=n[0],a=Buffer.isBuffer(e)?e:Buffer.from(String(e));d+=a.byteLength,d<=t?c.push(a):(p=!0,c.length=0)}return u(e,...n)};const m=[];let h=0,g=!1;const f=a.write.bind(a),v=a.end.bind(a);function b(e,n){if(!g&&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));h+=a.byteLength,h<=t?m.push(a):(g=!0,m.length=0)}catch{}}function y(e){return"string"==typeof e[0]?e[0]:void 0}function w(){const e=a.getHeaders();return e["x-pp-proxy"]?"hit"===e["x-pp-cache"]?"proxy-cache":"proxy":"local"}let x=!1;a.write=function(e,...t){return b(e,y(t)),f(e,...t)},a.end=function(t,...o){return"function"!=typeof t&&b(t,y(o)),function(){if(x)return;x=!0;const t=!p&&c.length>0?Buffer.concat(c):null,o=!g&&m.length>0?Buffer.concat(m):null;e.add({id:r,timestamp:l,method:n.method||"GET",url:s,statusCode:a.statusCode??null,duration:Date.now()-l,source:w(),requestHeaders:n.headers,requestBody:t,requestBodyTruncated:p,responseHeaders:a.getHeaders(),responseBody:o,responseBodyTruncated:g})}(),v(t,...o)},o()}}const At="/@pp-dev/inspector",Lt=new WeakMap;function Rt(e,t,n=10485760){const a=Lt.get(e);if(a)return a.store=t,void(a.captureLimit=n);const o={store:t,captureLimit:n};Lt.set(e,o),e.get("/@api/requests",(e,t)=>{const{store:n}=o,{limit:a,offset:s,method:i,search:r}=e.query,l=void 0!==a?parseInt(a,10):void 0,c=n.list({limit:Number.isFinite(l)?l:500,offset:void 0!==s&&Number.isFinite(parseInt(s,10))?parseInt(s,10):void 0,method:i,search:r});t.json({requests:c,total:n.size,memoryUsage:n.memoryUsage,maxMemory:n.maxMemory})}),e.get("/@api/requests/stats",(e,t)=>{const{store:n}=o;t.json({total:n.size,memoryUsage:n.memoryUsage,maxMemory:n.maxMemory,memoryUsageFormatted:Nt(n.memoryUsage),maxMemoryFormatted:Nt(n.maxMemory)})}),e.get("/@api/requests/:id",(e,t)=>{const n=o.store.get(e.params.id);if(!n)return void t.status(404).json({error:"Not found",id:e.params.id});const a=Ot(n.requestHeaders["content-type"]),s=Ot(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,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:s})}),e.delete("/@api/requests",(e,t)=>{o.store.clear(),t.json({ok:!0})}),e.get(At,(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.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 </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 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 ────────────────────────────────────────────────────────────────────\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\n filteredRequests = allRequests.filter(r => {\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\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 <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">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>`}(o.captureLimit))})}function Ot(e){return Array.isArray(e)?e[0]??"":String(e??"")}function Nt(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 Ht=["true","false","Y","N","1","0"],Bt=/^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;function jt(e,t){return e.id===t||e.text===t}function Dt(e){if("string"==typeof e)return{id:e,text:e};const t=e.text??e.id??"";return{id:e.id,text:t}}function _t(e,t,n,a){const o="select"===n.type||"multi-select"===n.type;if("string"!=typeof a&&(!o||"number"!=typeof a))return[{name:e,severity:"warning",message:`${t}: field "${n.name}" should be a string, got ${typeof a}.`}];const s=String(a);if(!s)return[];switch(n.type){case"color":return Bt.test(s)?[]:[{name:e,severity:"warning",message:`${t}: field "${n.name}" value "${s}" is not a recognized color (expected e.g. "#075b7e").`}];case"select":{if(!n.options?.length)return[];const a=n.options.map(Dt);return a.some(e=>jt(e,s))?[]:[{name:e,severity:"warning",message:`${t}: field "${n.name}" value "${s}" 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(Dt),o=s.split(",").map(e=>e.trim()).filter(e=>!a.some(t=>jt(t,e)));return o.length?[{name:e,severity:"warning",message:`${t}: field "${n.name}" value "${o.join(", ")}" is not among the declared options (${a.map(e=>e.text).join(", ")}).`}]:[]}default:return[]}}function $t(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)),o=[];return t.forEach((t,s)=>{const i=`"${e.name}" item #${s+1}`;if("object"!=typeof t||null===t||Array.isArray(t))return void o.push({name:e.name,severity:"warning",message:`${i} should be an object with field(s): ${n.map(e=>e.name).join(", ")}.`});const r=t;for(const t of n)t.name in r?o.push(..._t(e.name,i,t,r[t.name])):o.push({name:e.name,severity:"warning",message:`${i} is missing field "${t.name}".`});const l=Object.keys(r).filter(e=>!a.has(e));l.length&&o.push({name:e.name,severity:"warning",message:`${i} has unexpected field(s) not declared in additional_options: ${l.join(", ")}.`})}),o}function Ft(e,t){const n=[];switch(e.tag_type){case"boolean":Ht.includes(t)||n.push({name:e.name,severity:"warning",message:`Value "${t}" is not a recognized boolean encoding (expected one of ${Ht.join(", ")}).`});break;case"list":try{const a=JSON.parse(t);Array.isArray(a)?n.push(...$t(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(a=e.additional_options,Array.isArray(a)){const a=e.additional_options,o=("multiselect"===e.tag_type?t.split(",").map(e=>e.trim()):[t]).filter(e=>!a.some(t=>jt(t,e)));o.length&&n.push({name:e.name,severity:"warning",message:`Value "${o.join(", ")}" does not match any declared option for "${e.name}".`})}}var a;return n}function Mt(e,t){switch(e.tag_type){case"boolean":return Ht.includes(t)?null:`Not a recognized boolean value ("${t}").`;case"color":return""===t||Bt.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 Pt="/@pp-dev/variables-editor",Vt=new Set(["text","select","multiselect","file","list","color","boolean"]),qt=/^[A-Za-z0-9_\s-]+$/,zt="Dist service or MiAPI is not defined",Ut=new WeakMap;async function Jt(e){if(!e)return null;const t=await e.readPublicTemplateVariablesFile();if(!t)return null;try{const e=JSON.parse(t.toString("utf-8"));return e&&Array.isArray(e.tags)?e:null}catch{return null}}function Wt(e,t){const n=Ut.get(e);if(n)return void(n.deps=t);const a={deps:t};Ut.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:zt});const o=await n.readPublicTemplateVariablesFile();if(!o)return void t.json({exists:!1,schema:null,raw:null});const s=o.toString("utf-8");try{t.json({exists:!0,schema:JSON.parse(s),raw:s})}catch(e){t.json({exists:!0,schema:null,raw:s,parseError:e instanceof Error?e.message:"Invalid JSON"})}}),e.put("/@api/variables/schema",async(e,t)=>{const{distService:n}=a.deps;if(!n)return void t.status(503).json({error:zt});const o=e.body?.raw;if("string"!=typeof o)return void t.status(400).json({error:"Request body must be { raw: string }."});let s;try{s=JSON.parse(o)}catch{return void t.status(400).json({error:"Not valid JSON."})}if("object"!=typeof s||null===s||Array.isArray(s))return void t.status(400).json({error:"The root value must be a JSON object."});const i=s.tags;if(!Array.isArray(i))return void t.status(400).json({error:'"tags" must be an array.'});const r=i.findIndex(e=>"object"!=typeof e||null===e||"string"!=typeof e.name||!e.name);if(-1!==r)return void t.status(400).json({error:`tags[${r}] is missing a non-empty "name".`});const l=[],c=new Set;for(const e of i)c.has(e.name)&&l.push(`Duplicate variable name "${e.name}".`),c.add(e.name),e.tag_type&&!Vt.has(e.tag_type)&&l.push(`"${e.name}" has an unrecognized tag_type "${e.tag_type}".`),qt.test(e.name)||l.push(`"${e.name}" contains a character MI's own editor doesn't allow (only letters, digits, underscore, hyphen, and whitespace).`);await n.saveTemplateVariablesFile(Buffer.from(o,"utf-8")),t.json({ok:!0,warnings:l})}),e.get("/@api/variables/values",async(e,t)=>{const{distService:n,miAPI:o}=a.deps;try{const e=await Jt(n),a=await o.getLivePageVariables(),s=function(e,t){const n=new Map((e?.tags??[]).map(e=>[e.name,e])),a=new Map(t.map(e=>[e.name,e.value])),o=new Set([...n.keys(),...a.keys()]);return Array.from(o).map(e=>({name:e,value:a.get(e)??n.get(e)?.default_value??""}))}(e,a);t.json({schema:e,live:a,combined:s})}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:o}=a.deps,s=e.body?.tags;if(Array.isArray(s)&&!s.some(e=>"object"!=typeof e||null===e||"string"!=typeof e.name||!e.name||"string"!=typeof e.value))try{const e=await Jt(n),a=new Map((e?.tags??[]).map(e=>[e.name,e])),i=[];for(const e of s){const t=a.get(e.name);t&&i.push(...Ft(t,e.value))}await o.applyPageVariables(s),t.json({ok:!0,warnings: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.get("/@api/variables/new-uid",(e,t)=>{t.json({uid:S("md5").update(I()).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,o=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:o,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:o}=a.deps,s=Number(e.params.pageId);if(!Number.isInteger(s)||s<=0)t.status(400).json({error:"pageId must be a positive integer."});else try{const e=await Jt(n),a=await o.getPageVariablesFor(s);t.json(function(e,t){const n=new Map((e?.tags??[]).map(e=>[e.name,e])),a=[],o=[];for(const e of t){const t=n.get(e.name);if(!t){o.push({name:e.name,sourceValue:e.value,reason:"Not in the current page's schema."});continue}const s=Mt(t,e.value);s?o.push({name:e.name,sourceValue:e.value,reason:s}):a.push({name:e.name,sourceValue:e.value,tagType:t.tag_type||"text"})}return{importable:a,skipped:o}}(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(Pt,(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.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.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<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 data;\n\n try {\n const r = await fetch('/@api/variables/schema');\n\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 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">File exists but is not valid JSON: ' + 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 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));\n}\n\nasync function commitSchemaSave(raw) {\n const saveEditSeq = editSeq;\n const r = await fetch('/@api/variables/schema', {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ raw }),\n });\n const data = await r.json();\n\n if (!r.ok) {\n showBanner('error', escapeHtml(data.error || 'Save failed.'));\n\n return;\n }\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// Not full markdown (MI itself renders this as markdown) — a lightweight, dependency-free\n// line-break-preserving plain-text display is enough context to have here.\nfunction descriptionHint(tag) {\n if (!tag || !tag.description) { return ''; }\n\n return '<div class="hint" style="white-space:pre-wrap;margin-bottom: 8px;">' + escapeHtml(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));\n}\n\nasync function commitValuesSave(tags, afterText) {\n const saveEditSeq = editSeq;\n const r = await fetch('/@api/variables/values', {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ tags }),\n });\n const data = await r.json();\n\n if (!r.ok) {\n showBanner('error', escapeHtml(data.error || 'Save failed.'));\n\n return;\n }\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.message) + '</li>').join('') + '</ul>'\n : 'Values 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.values = afterText;\n\n await loadTabData('values');\n render();\n}\n\nfunction save() {\n (activeTab === 'schema' ? saveSchema() : saveValues());\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\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;\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>`)})}const Yt=["top-left","top-right","bottom-left","bottom-right"];function Kt(e,t){const n=e.mi?.mode??"standalone",a=e.app?.type??"template",o=e.mi?.apiVersion??7,s=e.mi?.url??process.env.MI_BACKEND_URL;if(!t)throw new Error("[pp-dev] app.name is required (or set package.json#name)");if(e.mi?.include&&"standalone"!==n)throw console.warn('[pp-dev] mi.include is only valid when mi.mode is "standalone"'),new Error('[pp-dev] mi.include requires mi.mode to be "standalone"');if(!s){if("embedding"===n||"template"===a)throw new Error('[pp-dev] mi.url is required when mi.mode is "embedding" or app.type is "template"');"standalone"===n&&"page"===a&&console.warn("[pp-dev] mi.url is not set — dev server will run without a backend proxy")}if("template"===a&&!e.app?.id)throw new Error('[pp-dev] app.id is required when app.type is "template"');if("page"===a&&s&&("standalone"===n||o>=7)&&!e.app?.id)throw new Error('[pp-dev] app.id is required when backend page data is loaded for app.type "page"');if(void 0!==e.devPanel?.position&&!Yt.includes(e.devPanel.position))throw new Error(`[pp-dev] devPanel.position must be one of: ${Yt.join(", ")}`)}function Zt(e,t){const{mi:n={},app:a={},proxy:o={},build:s={},sync:i={},inspector:r={},devPanel:l={}}=e,c=n.mode??"standalone",d=a.type??"template",p=n.apiVersion??7,u="standalone"===c;let m=!1;"top-bar"===n.include?m=!0:"shared-components"===n.include&&(m={addSharedComponentsScripts:!0,addRootElement:!1});const h="page"===d,g=a.name??t,f="VERSION-v{packageversion}-{currentDate}.json";let v;const b=s.zip;let y;v=!1!==b&&(!0===b||void 0===b?{outFileName:Re(g),outDir:"dist-zip"}:{outFileName:Le(b.fileName??Re(g),"build.zip.fileName"),outDir:b.outDir??"dist-zip",...b.inDir?{inDir:b.inDir}:{}});const w=s.versionFile;let x;y=!1!==w&&(!0===w||void 0===w?{versionFileTemplate:f,enabled:!0}:{versionFileTemplate:Le(w.fileNameTemplate??f,"build.versionFile.fileNameTemplate"),enabled:w.enabled??!0});const k=s.imageOptimisations;return x=!1!==k&&(!0===k||void 0===k?{}:k),{templateName:g,backendBaseURL:n.url??process.env.MI_BACKEND_URL,appId:a.id,templateLess:h,miHudLess:u,integrateMiTopBar:m,enableProxyCache:o.cache??!0,proxyCacheTTL:o.cacheTtl??6e5,disableSSLValidation:o.tls?.allowSelfSigned??!1,imageOptimizer:x,outDir:s.outDir??"dist",distZip:v,versionPlugin:y,syncBackupsDir:i.backupsDir??"backups",v7Features:p>=7,personalAccessToken:n.token??process.env.MI_ACCESS_TOKEN,inspectorEnabled:r.enabled??!0,inspectorMaxMemory:r.maxMemory??1073741824,inspectorCaptureLimit:r.captureLimit??10485760,devPanelPosition:l.position??"bottom-right",devPanelHidden:l.hidden??!1,devPanelAutoHide:l.autoHide??!1}}function Gt(e){const{templateName:t,templateLess:n,backendBaseURL:a,miHudLess:o,appId:s,enableProxyCache:i,proxyCacheTTL:r,disableSSLValidation:l,outDir:c,distZip:d,versionPlugin:p,syncBackupsDir:u,v7Features:m,personalAccessToken:h,inspectorEnabled:g,inspectorMaxMemory:f,inspectorCaptureLimit:v,devPanelPosition:b,devPanelHidden:y,devPanelAutoHide:w}=e||{};let x=!0;return{name:"vite-pp-dev",apply:"serve",config:e=>(e.clientInjectionPlugin={backendBaseURL:a,appId:s,templateLess:n,v7Features:m,devPanelPosition:b,devPanelHidden:y,devPanelAutoHide:w},m&&(e.base=`/pl/${t}`),e),transformIndexHtml:async(e,t)=>{const n={html:e,tags:[]};return x&&(x=!1,n.tags.push({tag:"script",injectTo:"body",children:`${Math.random()}`})),n},configureServer:b=>{let y=b.config.base;y.endsWith("/")||(y+="/");const w=y.substring(0,y.lastIndexOf("/"));if(b.middlewares.use(pt(y,t)),!1!==g){const e=new It(f);b.middlewares.use(Ct(e,v));const t=kt();Rt(t,e,v),b.middlewares.use((e,n,a)=>{e.url?.startsWith("/@api/")||e.url?.startsWith(At)?t(e,n,a):a()})}if(a){b.config.logger.info(se.blue(`🌐 Backend URL: ${a}`)),b.config.logger.info(se.blue(`🆔 Custom App ID: ${s}`));const g=new URL(a).host,f={headers:{host:g,referer:a,origin:a.replace(/^(https?:\/\/)([^/]+)(\/.*)?$/i,"$1$2")},appId:s,templateLess:n,disableSSLValidation:l,v7Features:m,personalAccessToken:h??process.env.MI_ACCESS_TOKEN},v=new Ae(a,f);if(i){let e=+r;(!e||Number.isNaN(e)||e<0)&&(e=6e5),b.middlewares.use(ct({devServer:b,ttl:e}))}const x=new RegExp(`^((${y})|/)$`);b.middlewares.use(bt(x,v,Object.assign({},e,{appId:s,appBase:y}))),b.middlewares.use(me({baseURL:a,proxyIgnore:["/@vite","/@metricinsights","/@",w],disableSSLValidation:l,miAPI:v,templateName:t}));const k=(e,t,n,a,o)=>{const s={error:n};a&&(s.details=a),o&&(s.code=o),e.status(t).json(s).end()},S=(e,t,n)=>{b.config.logger.error(`${n} token validation error:`,t);const a=Ee(t);if(t.tokenErrorInfo){const n=t.tokenErrorInfo;k(e,n.status||500,n.userFriendlyMessage,n.message,n.code)}else{let t,o,s;switch(a.code){case"SESSION_EXPIRED":t=412,o="personal"===n?"Personal access token expired":"Session expired",s="personal"===n?"PAT_EXPIRED":"SESSION_EXPIRED";break;case"UNAUTHORIZED":t=401,o="Unauthorized",s="UNAUTHORIZED";break;case"FORBIDDEN":t=403,o="Access forbidden",s="FORBIDDEN";break;default:t=500,o="Internal server error",s=a.code}const i="personal"===n&&"PAT_EXPIRED"===s?"Your personal access token has expired. Please generate a new token from the portal.":"regular"===n&&"SESSION_EXPIRED"===s?"Your portal session has expired. Please refresh your token or re-authenticate.":a.userFriendlyMessage;k(e,t,o,i,s)}return null},I=kt();I.post("/@api/login",async(e,t,n)=>{const{token:a,tokenType:o}=e.body;if(a)if(b.config.logger.info(`Attempting to validate ${o} token...`),"personal"===o){if(!await v.get("/data/page/index/auth/info",{"Content-Type":"application/json",Accept:"application/json",Authorization:`Bearer ${a}`},!0).then(async e=>"number"==typeof e.data?.user?.user_id?(v.personalAccessToken=a,b.config.logger.info(`Personal access token validated successfully for user ${e.data.user.user_id}`),e):(k(t,400,"Token expired or invalid"),null)).catch(e=>S(t,e,"personal")))return;re(t,"/",302)}else if("regular"===o){if(!await v.get("/api/user",{"Content-Type":"application/json",Accept:"application/json",Token:a},!0).then(e=>e.data?.users?.length?(v.personalAccessToken=void 0,b.config.logger.info(`Regular token validated successfully for ${e.data.users.length} user(s)`),t.setHeader("set-cookie",e.headers["set-cookie"]??""),e):(k(t,400,"Token expired or invalid"),null)).catch(e=>S(t,e,"regular")))return;re(t,"/",302)}else k(t,400,"Invalid tokenType",'tokenType must be "personal" or "regular".',"INVALID_TOKEN_TYPE");else k(t,400,"Token is required")});const E=new et(t,Object.assign({root:b.config.root,backupFolder:u,buildInputFolder:"object"==typeof d?d.inDir??c:c},"object"==typeof d?{distZipFolder:d.outDir,distZipFilename:d.outFileName}:void 0,"object"==typeof p?{versionFileTemplate:p.versionFileTemplate}:void 0));return new tt(b,{distService:E,miAPI:v}),Wt(I,{distService:E,miAPI:v}),b.middlewares.use((e,t,n)=>{e.url?.startsWith("/@api/login")||e.url?.startsWith("/@api/variables/")||e.url?.startsWith(Pt)?I(e,t,n):n()}),()=>{b.middlewares.use(dt(e=>e.split("?")[0].endsWith("index.html"),(e,t)=>Buffer.from(ie(g,t.headers.host??"",v.buildPage(e,o)))))}}}}}export{K as A,U as B,tt as C,et as D,z as E,Le as F,ut as G,mt as H,At as I,Gt as J,Ae as M,Z as P,It as R,Y as V,se as a,ee as b,oe as c,Re as d,ze as e,Oe as f,G as g,Q as h,X as i,Rt as j,St as k,Ct as l,pt as m,Zt as n,ct as o,bt as p,me as q,qe as r,Pt as s,dt as t,ie as u,Kt as v,Pe as w,Wt as x,te as y,Ve as z};
2
+ //# sourceMappingURL=plugin-B4Zp4ldE.js.map