<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>VerseDocs Configuration</title>
<style>
  /* See scripts/check_webresource_css.py and tests/browser/webresource.spec.js: the HTML
     `hidden` attribute only hides an element because the browser's own stylesheet says
     [hidden] { display: none }. Any author rule that sets display on the same element beats
     it. This guard must stay first, same as templatestudio.htm. */
  [hidden] { display: none !important; }

  :root {
    --navy: #1E3A5F;
    --navy-dark: #16293F;
    --navy-tint: #EAF0F6;
    --ink: #1B1E24;
    --ink-soft: #5B6472;
    --border: #E1E5EA;
    --surface: #FFFFFF;
    --bg: #F5F7FA;
    --good: #157F3C;
    --good-bg: #E6F6EB;
    --bad: #B3261E;
    --bad-bg: #FCEAE9;
    --radius: 8px;
    --shadow: 0 1px 2px rgba(16, 24, 40, 0.06), 0 1px 3px rgba(16, 24, 40, 0.08);
  }
  * { box-sizing: border-box; }
  html, body { height: 100%; }
  body {
    margin: 0;
    font-family: "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, Arial, sans-serif;
    color: var(--ink);
    background: var(--bg);
    font-size: 13px;
    line-height: 1.45;
  }
  button { font-family: inherit; }
  h1, h2 { margin: 0; font-weight: 600; }

  .app { display: flex; flex-direction: column; height: 100vh; min-height: 480px; }
  .topbar {
    background: var(--navy); color: #fff; padding: 10px 18px;
    display: flex; align-items: center; gap: 10px; flex: none;
    box-shadow: var(--shadow);
  }
  .brand { display: flex; align-items: center; gap: 10px; font-size: 15px; font-weight: 600; letter-spacing: .1px; }
  .brand svg { width: 20px; height: 20px; fill: #fff; }
  .brand .sub { font-weight: 400; opacity: .8; font-size: 12px; margin-left: 4px; }

  .btn {
    appearance: none; border: 1px solid transparent; border-radius: 6px; padding: 7px 13px;
    font-size: 12.5px; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; gap: 6px;
    transition: background .12s ease, border-color .12s ease, color .12s ease;
  }
  .btn-solid { background: var(--navy); color: #fff; }
  .btn-solid:hover { background: var(--navy-dark); }
  .btn-solid:disabled { opacity: .45; cursor: default; }
  .btn-outline { background: #fff; color: var(--ink); border-color: var(--border); }
  .btn-outline:hover { background: #F2F4F7; }
  .btn-sm { padding: 4px 9px; font-size: 11.5px; }

  .body { flex: 1; overflow-y: auto; padding: 22px 26px; max-width: 720px; margin: 0 auto; width: 100%; }
  .page-title { font-size: 18px; margin-bottom: 4px; }
  .page-sub { color: var(--ink-soft); font-size: 12.6px; margin-bottom: 6px; }

  .note-banner {
    background: var(--navy-tint); color: var(--navy); border-radius: var(--radius);
    padding: 10px 14px; font-size: 12.4px; margin: 14px 0 18px;
  }

  .search-box { position: relative; margin-bottom: 12px; }
  .search-box svg { position: absolute; left: 9px; top: 50%; transform: translateY(-50%); width: 14px; height: 14px; color: var(--ink-soft); fill: currentColor; }
  .search-box input { width: 100%; padding: 7px 9px 7px 28px; border: 1px solid var(--border); border-radius: 6px; font-size: 12.5px; }

  .table-list { border: 1px solid var(--border); border-radius: var(--radius); background: #fff; overflow: hidden; }
  .table-row { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border-bottom: 1px solid var(--border); }
  .table-row:last-child { border-bottom: none; }
  .table-row:hover { background: #F7F9FB; }
  .table-row input[type=checkbox] { width: 16px; height: 16px; flex: none; cursor: pointer; }
  .table-row label { flex: 1; cursor: pointer; }
  .table-row .lname { font-weight: 600; font-size: 12.6px; }
  .table-row .llabel { color: var(--ink-soft); font-size: 11.6px; margin-left: 7px; }

  .empty-state { color: var(--ink-soft); font-size: 12.6px; padding: 18px 14px; text-align: center; }
  .loading-state { padding: 18px; color: var(--ink-soft); font-size: 12.6px; text-align: center; }
  .spinner { display: inline-block; width: 13px; height: 13px; border: 2px solid #D8DEE6; border-top-color: var(--navy); border-radius: 50%; animation: spin .7s linear infinite; vertical-align: -2px; margin-right: 6px; }
  @keyframes spin { to { transform: rotate(360deg); } }
  .error-banner { background: var(--bad-bg); border: 1px solid #F0C6C3; color: var(--bad); border-radius: var(--radius); padding: 12px 14px; font-size: 12.6px; }
  .field-ok { color: var(--good); font-size: 12px; margin-top: 7px; }
  .fatal-error { max-width: 560px; margin: 60px auto; }

  .footer-bar {
    flex: none; border-top: 1px solid var(--border); background: #fff; padding: 12px 26px;
    display: flex; align-items: center; gap: 12px; box-shadow: 0 -1px 2px rgba(16,24,40,0.05);
  }
  .footer-bar .count { color: var(--ink-soft); font-size: 12.2px; }
</style>
</head>
<body>
<div class="app" id="app">

  <header class="topbar">
    <div class="brand">
      <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6.25 2h7.09c.4 0 .78.16 1.06.44l4.66 4.66c.28.28.44.66.44 1.06v11.59A2.25 2.25 0 0 1 17.25 22H6.25A2.25 2.25 0 0 1 4 19.75V4.25A2.25 2.25 0 0 1 6.25 2zm0 1.5a.75.75 0 0 0-.75.75v15.5c0 .41.34.75.75.75h11a.75.75 0 0 0 .75-.75V9h-3.75A1.75 1.75 0 0 1 12.5 7.25V3.5H6.25zM14 4.31v2.94c0 .14.11.25.25.25h2.94L14 4.31z"/></svg>
      Configuration <span class="sub">VerseDocs</span>
    </div>
  </header>

  <div class="body" id="body" hidden>
    <h1 class="page-title">Generate document button</h1>
    <div class="page-sub">Choose which tables show the VerseDocs "Generate document" command-bar button.</div>

    <div class="note-banner">
      A change here takes effect the next time each affected table's form loads. No publish is
      required: this page only writes the value of an environment variable, it does not touch
      any table's ribbon customization.
    </div>

    <div id="defMissing" class="error-banner" hidden>
      The vdocs_EnabledRibbonTables environment variable was not found in this environment. Import
      or update the VerseDocs managed solution, then reload this page.
    </div>

    <div id="loadError" class="error-banner" hidden></div>

    <div id="listSection" hidden>
      <div class="search-box">
        <svg viewBox="0 0 24 24"><path d="M10.5 3a7.5 7.5 0 1 0 4.66 13.4l4.72 4.72a1 1 0 0 0 1.42-1.42l-4.72-4.72A7.5 7.5 0 0 0 10.5 3zm-5.5 7.5a5.5 5.5 0 1 1 11 0 5.5 5.5 0 0 1-11 0z"/></svg>
        <input type="search" id="tableSearch" placeholder="Search tables…" />
      </div>
      <div id="tableLoading" class="loading-state"><span class="spinner"></span>Loading tables…</div>
      <div id="tableList" class="table-list" hidden></div>
      <div id="noMatch" class="empty-state" hidden>No tables match your search.</div>
    </div>
  </div>

  <div class="footer-bar" id="footerBar" hidden>
    <button class="btn btn-solid" id="saveBtn" type="button">Save changes</button>
    <span class="count" id="selectedCount"></span>
    <span id="saveFeedback" class="field-ok" hidden></span>
    <span id="saveError" class="error-banner" hidden style="padding:6px 10px;"></span>
  </div>

  <div id="fatalError" class="fatal-error" hidden></div>
</div>

<script>
(function () {
  "use strict";

  // ======================================================================
  // Xrm bootstrap -- same "look in self/parent/top, fail with a clear
  // message rather than a blank page" pattern as templatestudio.htm /
  // licensing.htm / generateandattach.htm.
  // ======================================================================
  function findXrm() {
    if (typeof Xrm !== "undefined" && Xrm.WebApi) { return Xrm; }
    try { if (window.parent && window.parent.Xrm && window.parent.Xrm.WebApi) { return window.parent.Xrm; } } catch (e) { /* cross-frame blocked */ }
    try { if (window.top && window.top.Xrm && window.top.Xrm.WebApi) { return window.top.Xrm; } } catch (e) { /* ignore */ }
    return null;
  }

  var xrm = findXrm();

  function showFatal(message) {
    document.getElementById("body").hidden = true;
    document.getElementById("footerBar").hidden = true;
    var el = document.getElementById("fatalError");
    el.hidden = false;
    el.innerHTML = '<div class="error-banner">' + escapeHtml(message) + "</div>";
  }

  if (!xrm) {
    showFatal("This page must be opened from inside a model-driven app (it calls Dataverse via the client API, which is only injected inside the app shell).");
    return;
  }

  function clientUrl() {
    try { return xrm.Utility.getGlobalContext().getClientUrl(); } catch (e) { return ""; }
  }

  var API_BASE = clientUrl() + "/api/data/v9.2/";
  var ENV_VAR_SCHEMA_NAME = "vdocs_EnabledRibbonTables";

  function webApiFetch(path, opts) {
    opts = opts || {};
    var headers = Object.assign({
      "OData-MaxVersion": "4.0",
      "OData-Version": "4.0",
      "Accept": "application/json",
      "Content-Type": "application/json; charset=utf-8",
    }, opts.headers || {});
    return fetch(API_BASE + path, {
      method: opts.method || "GET",
      headers: headers,
      credentials: "same-origin",
      body: opts.body,
    }).then(function (r) {
      if (!r.ok) {
        return r.text().then(function (t) { throw new Error(describeError(r.status, t)); });
      }
      return r;
    });
  }

  /**
   * Turns a Dataverse error body into the one sentence worth showing.
   *
   * This used to dump the raw response text, which is how a privilege failure
   * reached a user as, verbatim:
   *
   *   {5}{2}(Id = {3}) is missing {0} privilege on {1} entity(OTC={4}).
   *
   * Dataverse hands back SEVERAL message fields, and not all of them are
   * substituted -- one carries the raw format template with its {0}..{5}
   * placeholders intact. Whichever one appeared first is what the user got, so
   * they were told nothing at all while a perfectly good message naming
   * prvWriteEnvironmentVariableDefinition sat in the same payload.
   *
   * So: parse, gather every candidate, and prefer one with no unsubstituted
   * placeholder left in it. Falls back to the raw text, because an ugly
   * message beats a blank one.
   */
  function describeError(status, text) {
    var candidates = [];
    try {
      var body = JSON.parse(text);
      var err = body.error || body;
      candidates.push(err.message);
      if (err.innererror) {
        candidates.push(err.innererror.message);
        if (err.innererror.internalexception) {
          candidates.push(err.innererror.internalexception.message);
        }
      }
      candidates.push(body.Message);
    } catch (e) {
      // Not JSON -- the raw text is all there is.
    }

    var unsubstituted = /\{\d\}/;
    for (var i = 0; i < candidates.length; i++) {
      var c = candidates[i];
      if (typeof c === "string" && c.length > 0 && !unsubstituted.test(c)) {
        return c;
      }
    }

    return "Request failed (" + status + "): " + text;
  }

  /**
   * What to tell the user when saving this page fails.
   *
   * Reading a message off the error object DOES NOT WORK on this channel, and
   * it took two attempts to establish that. Over Xrm.WebApi, Dataverse returns
   * the UNFORMATTED message template in every field it exposes -- message and
   * raw.message both arrive as:
   *
   *   {5}{2}(Id = {3}) is missing {0} privilege on {1} entity(OTC={4}).
   *
   * The same operation over the raw Web API comes back fully substituted,
   * naming prvWriteEnvironmentVariableDefinition. So there is no substituted
   * field to prefer, and an earlier fix that tried to pick the best one still
   * showed the user placeholders (QA 2026-08-21).
   *
   * So: detect the unsubstituted template and write our own sentence. This
   * page has exactly one privileged write, so we know what the failure means
   * without asking Dataverse to phrase it -- and can say which role fixes it,
   * which the platform message never would. error.title is clean on this
   * channel ("Insufficient Permissions") and is used as the lead when present.
   */
  function describeSaveFailure(error) {
    var raw = error && error.message ? String(error.message) : String(error);
    var unsubstituted = /\{\d\}/;

    if (!unsubstituted.test(raw)) {
      return "Could not save: " + raw;
    }

    var lead = (error && error.title) ? String(error.title) : "Insufficient permissions";
    return "Could not save: " + lead + ". Your security role cannot write environment variables, which is what "
      + "this page saves. It needs Write plus Append and AppendTo on Environment Variable Definition. "
      + "The VerseDocs Administrator role grants them; VerseDocs Template Author deliberately does not, so an "
      + "author can read this page without being able to change it. Ask an administrator to make the change, "
      + "or to grant you that role.";
  }

  function escapeHtml(s) {
    return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
      return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
    });
  }

  // ======================================================================
  // State
  // ======================================================================
  var state = {
    definitionId: null,   // environmentvariabledefinitionid, once resolved
    valueId: null,         // environmentvariablevalueid, if a current value row already exists
    tables: [],             // [{logicalName, displayLabel}]
    enabled: {},            // {logicalName (lowercase): true}
  };

  // ======================================================================
  // Load the table list -- same EntityDefinitions query/filter Template
  // Studio's bound-table dropdown uses (IsPrivate, IsIntersect and
  // IsValidForAdvancedFind exclude the tables that can't sensibly carry a
  // form command-bar button anyway).
  // ======================================================================
  function loadTableList() {
    return webApiFetch("EntityDefinitions?$select=LogicalName,DisplayName,IsPrivate,IsIntersect,IsValidForAdvancedFind")
      .then(function (r) { return r.json(); })
      .then(function (body) {
        return (body.value || [])
          .filter(function (e) {
            return e.IsPrivate !== true && e.IsIntersect !== true && e.IsValidForAdvancedFind !== false;
          })
          .map(function (e) {
            var label = (e.DisplayName && e.DisplayName.UserLocalizedLabel && e.DisplayName.UserLocalizedLabel.Label) || e.LogicalName;
            return { logicalName: e.LogicalName, displayLabel: label };
          })
          .sort(function (a, b) {
            var x = a.displayLabel.toLowerCase(), y = b.displayLabel.toLowerCase();
            return x < y ? -1 : (x > y ? 1 : 0);
          });
      });
  }

  // ======================================================================
  // Load the environment variable's definition + current value (same
  // definition/expand pattern generateandattach.js's isTableEnabled rule
  // reads at runtime, so this page and that rule always agree on what
  // "current value" means).
  // ======================================================================
  function loadEnvVar() {
    return webApiFetch(
      "environmentvariabledefinitions" +
      "?$select=environmentvariabledefinitionid,defaultvalue" +
      "&$filter=schemaname eq '" + ENV_VAR_SCHEMA_NAME + "'" +
      "&$expand=environmentvariabledefinition_environmentvariablevalue($select=environmentvariablevalueid,value)"
    ).then(function (r) { return r.json(); }).then(function (body) {
      var def = (body.value || [])[0];
      if (!def) { return null; }
      state.definitionId = def.environmentvariabledefinitionid;
      var values = def.environmentvariabledefinition_environmentvariablevalue;
      var current = null;
      if (values && values.length > 0) {
        state.valueId = values[0].environmentvariablevalueid;
        current = values[0].value;
      } else {
        state.valueId = null;
        current = def.defaultvalue;
      }
      var set = {};
      if (current) {
        try {
          var parsed = JSON.parse(current);
          if (Array.isArray(parsed)) {
            parsed.forEach(function (name) {
              if (typeof name === "string") { set[name.toLowerCase()] = true; }
            });
          }
        } catch (e) { /* malformed value -- show as if nothing were enabled, do not block the page */ }
      }
      return set;
    });
  }

  // ======================================================================
  // Render
  // ======================================================================
  function renderList() {
    var listEl = document.getElementById("tableList");
    var noMatchEl = document.getElementById("noMatch");
    var q = (document.getElementById("tableSearch").value || "").trim().toLowerCase();

    var visible = state.tables.filter(function (t) {
      if (!q) { return true; }
      return t.logicalName.toLowerCase().indexOf(q) >= 0 || t.displayLabel.toLowerCase().indexOf(q) >= 0;
    });

    if (visible.length === 0) {
      listEl.hidden = true;
      noMatchEl.hidden = false;
      return;
    }
    noMatchEl.hidden = true;
    listEl.hidden = false;

    listEl.innerHTML = visible.map(function (t) {
      var checked = state.enabled[t.logicalName.toLowerCase()] === true;
      var id = "tbl_" + t.logicalName;
      return '<div class="table-row">' +
        '<input type="checkbox" id="' + escapeHtml(id) + '" data-name="' + escapeHtml(t.logicalName) + '"' + (checked ? " checked" : "") + ' />' +
        '<label for="' + escapeHtml(id) + '"><span class="lname">' + escapeHtml(t.displayLabel) + '</span>' +
        '<span class="llabel">' + escapeHtml(t.logicalName) + '</span></label>' +
        "</div>";
    }).join("");

    Array.prototype.forEach.call(listEl.querySelectorAll('input[type=checkbox]'), function (cb) {
      cb.addEventListener("change", function () {
        var name = cb.getAttribute("data-name").toLowerCase();
        if (cb.checked) { state.enabled[name] = true; } else { delete state.enabled[name]; }
        updateSelectedCount();
      });
    });

    updateSelectedCount();
  }

  function updateSelectedCount() {
    var n = Object.keys(state.enabled).length;
    document.getElementById("selectedCount").textContent =
      n + " table" + (n === 1 ? "" : "s") + " enabled";
  }

  document.getElementById("tableSearch").addEventListener("input", renderList);

  // ======================================================================
  // Save -- writes the environment variable's CURRENT value (creating the
  // environmentvariablevalue row the first time, updating it after that).
  // The DEFINITION always ships in the managed solution; only the value is
  // environment-specific, same split scripts/strip_envvar_values.py already
  // enforces for exported solutions.
  // ======================================================================
  document.getElementById("saveBtn").addEventListener("click", function () {
    var saveBtn = document.getElementById("saveBtn");
    var feedback = document.getElementById("saveFeedback");
    var errorEl = document.getElementById("saveError");
    feedback.hidden = true;
    errorEl.hidden = true;
    saveBtn.disabled = true;

    var names = Object.keys(state.enabled).sort();
    var json = JSON.stringify(names);

    var op;
    if (state.valueId) {
      op = xrm.WebApi.updateRecord("environmentvariablevalue", state.valueId, { value: json });
    } else {
      op = xrm.WebApi.createRecord("environmentvariablevalue", {
        value: json,
        "EnvironmentVariableDefinitionId@odata.bind": "/environmentvariabledefinitions(" + state.definitionId + ")",
      }).then(function (created) { state.valueId = created.id; });
    }

    op.then(function () {
      saveBtn.disabled = false;
      feedback.hidden = false;
      feedback.textContent = "Saved. Affected tables pick this up on their next form load.";
      setTimeout(function () { feedback.hidden = true; }, 3000);
    }, function (error) {
      saveBtn.disabled = false;
      errorEl.hidden = false;
      errorEl.textContent = describeSaveFailure(error);
    });
  });

  // ======================================================================
  // Boot
  // ======================================================================
  document.getElementById("body").hidden = false;

  Promise.all([loadTableList(), loadEnvVar()]).then(function (results) {
    document.getElementById("tableLoading").hidden = true;
    document.getElementById("listSection").hidden = false;
    document.getElementById("footerBar").hidden = false;

    state.tables = results[0];
    var enabledSet = results[1];

    if (enabledSet === null) {
      document.getElementById("defMissing").hidden = false;
      state.enabled = {};
    } else {
      state.enabled = enabledSet;
    }

    renderList();
  }, function (error) {
    document.getElementById("tableLoading").hidden = true;
    document.getElementById("listSection").hidden = false;
    document.getElementById("loadError").hidden = false;
    document.getElementById("loadError").textContent =
      "Could not load configuration: " + (error && error.message ? error.message : String(error));
  });
})();
</script>
</body>
</html>
