routerconsole/jsp/js/advconfig.js

/**
 * @module advconfig
 * @description Enhances the /configadvanced page in advanced mode by converting
 * the raw textarea configuration into an editable table with inline key-value editing,
 * delete buttons, filtering, and form submission handling.
 * @author dr|z3d
 * @license AGPL3 or later
 */

/**
 * Initializes the advanced configuration interface by converting the textarea
 * into an editable table with key-value pairs, filter, and CRUD operations.
 * @function advConfigInit
 * @returns {void}
 */
const advConfigInit = () => {
  const d = document;

  const query = selector => d.querySelector(selector);
  const queryAll = selector => d.querySelectorAll(selector);

  const selectors = {
    container: query("td.tabletextarea"),
    textarea: query("textarea#advancedsettings"),
    table: query("#advconf"),
    infohelp: query("#advconf thead td.infohelp"),
    header: query("h3#advancedconfig"),
    advForm: query("#advConfigForm"),
    saveButton: query("#saveConfig input.accept"),
    cancelButton: query("#saveConfig input.cancel")
  };

  const { container, textarea, table, infohelp, header, advForm, saveButton, cancelButton } = selectors;

  d.body.classList.add("js");
  textarea.style.display = "none";
  infohelp.setAttribute("colspan", "3");

  /**
   * Removes old configuration rows from the DOM before rebuilding the table.
   * @function clearOldRows
   * @returns {void}
   */
  const clearOldRows = () => {
    while (container.parentNode.firstChild && container.parentNode.firstChild.firstChild !== textarea) {
      container.parentNode.removeChild(container.parentNode.firstChild);
    }
  };
  clearOldRows();

  const configItems = textarea.value.split("\n")
    .map(item => item.split("="))
    .filter(parts => parts.length === 2)
    .sort((a, b) => a[0].localeCompare(b[0]));

  /**
   * Creates a table row with editable key and value cells and a delete cell.
   * @function createRow
   * @param {string} key - The configuration key name
   * @param {string} value - The configuration value
   * @returns {HTMLTableRowElement} The created table row element
   * @example createRow("i2np.bandwidth.inboundKByteSec", "1024")
   */
  const createRow = (key, value) => {
    const row = d.createElement("tr");
    row.className = "configline";

    const keyCell = d.createElement("td");
    const valueCell = d.createElement("td");
    const delCell = d.createElement("td");

    keyCell.textContent = key;
    valueCell.textContent = value;
    keyCell.setAttribute("contenteditable", "true");
    valueCell.setAttribute("contenteditable", "true");

    [keyCell, valueCell, delCell].forEach(cell => cell.setAttribute("spellcheck", "false"));
    [keyCell.className, valueCell.className, delCell.className] = ["key", "value", "delete"];

    row.append(keyCell, valueCell, delCell);
    return row;
  };

  let lastConfigRow = null;
  configItems.forEach(([key, value]) => {
    const row = createRow(key, value);
    lastConfigRow = table.querySelector("#advconf tbody tr:last-child");
    lastConfigRow.insertAdjacentElement("afterend", row);
    lastConfigRow = row;
  });

  const addNewRow = d.createElement("tr");
  addNewRow.id = "addNew";
  const newKeyCell = d.createElement("td");
  const newValueCell = d.createElement("td");
  const newDelCell = d.createElement("td");

  addNewRow.append(newKeyCell, newValueCell, newDelCell);
  [newKeyCell, newValueCell].forEach(cell => {
    cell.contentEditable = true;
    cell.setAttribute("spellcheck", "false");
  });
  newKeyCell.className = "newKey";
  newValueCell.className = "newValue";
  newDelCell.className = "delete";

  const firstConfigRow = table.querySelector(".configline");
  if (firstConfigRow) { firstConfigRow.insertAdjacentElement("beforebegin", addNewRow); }

  const observer = new MutationObserver(mutationsList => {
    mutationsList.forEach(mutation => {
      mutation.addedNodes.forEach(node => {
        if (node.tagName === "TD") { updateTextarea(); }
      });
    });
  });

  observer.observe(table, { childList: true, subtree: true });

  /**
   * Synchronizes the hidden textarea with the current state of the editable table rows.
   * @function updateTextarea
   * @returns {void}
   */
  const updateTextarea = () => {
    const updatedItems = [];
    const rows = table.querySelectorAll(".configline");
    rows.forEach(row => {
      const key = row.querySelector(".key").textContent.trim();
      const value = row.querySelector(".value").textContent.trim();
      if (key) { updatedItems.push(`${key}=${value}`); }
    });
    textarea.value = updatedItems.join("\n");
  };

  table.addEventListener("click", event => {
    const target = event.target;
    if (target.classList.contains("delete")) {
      const row = target.closest("tr");
      if (row.id !== "addNew") {
        const removedKey = row.querySelector("td:first-child").textContent;
        row.remove();
        updateTextarea();
        table.querySelector("thead").removeAttribute("hidden");
        infohelp.id = "removeKey";
        infohelp.innerHTML = msgKeyRemove.replace("{0}", removedKey);
        infohelp.scrollIntoView();
      } else {
        newKeyCell.textContent = "";
        newValueCell.textContent = "";
      }
    }
  });

  /**
   * Submits a new key-value pair by creating a row and clearing the input fields.
   * @function submitNewKeyValue
   * @returns {void}
   */
  const submitNewKeyValue = () => {
    const newKey = newKeyCell.textContent.trim();
    const existingKey = configItems.some(item => item[0] === newKey);
    const newValue = newValueCell.textContent.trim();
    if (newKey) {
      const newRow = createRow(newKey, newValue);
      addNewRow.insertAdjacentElement("beforebegin", newRow);
      newKeyCell.textContent = "";
      newValueCell.textContent = "";
      updateTextarea();
    }
  };

  /**
   * Adds a filter input to the configuration header for searching key-value pairs.
   * @function addFilter
   * @returns {void}
   */
  const addFilter = () => {
    let filterValue = "";
    const advFilter = d.createElement("span");
    advFilter.id = "advfilter";

    const filterInput = d.createElement("input");
    filterInput.type = "text";
    filterInput.id = "filterInput";
    advFilter.appendChild(filterInput);

    filterInput.addEventListener("input", event => {
      filterValue = event.target.value.toLowerCase();
      const rows = table.querySelectorAll(".configline");
      rows.forEach(row => {
        const key = row.querySelector(".key").textContent.toLowerCase();
        const value = row.querySelector(".value").textContent.toLowerCase();
        row.style.display = (key.includes(filterValue) || value.includes(filterValue)) ? "table-row" : "none";
      });
    });

    const clearFilter = d.createElement("button");
    clearFilter.textContent = "X";
    clearFilter.addEventListener("click", () => {
      const activeFilter = document.getElementById("filterInput");
      activeFilter.value = "";
      const event = new Event('input', { bubbles: true });
      activeFilter.dispatchEvent(event);
      window.scrollTo(0,0);
    });

    advFilter.appendChild(clearFilter);
    header.appendChild(advFilter);
  };
  addFilter();

  /**
   * Handles save logic: validates new key-value inputs, checks for duplicates,
   * and triggers form submission.
   * @function doSave
   * @param {Event} event - The keyboard or click event
   * @returns {void}
   */
  const doSave = event => {
    const modalActive = query("#modalActive");
    if (modalActive) {return;}
    updateTextarea();
    const newKey = newKeyCell.textContent.trim();
    const newValue = newValueCell.textContent.trim();

    if (newValue && !newKey) {
      modal("No keyname provided for the submitted value.<br>Please supply a keyname.");
      newKeyCell.focus();
      return;
    }

    if (newKey) {
      const existingKey = configItems.some(item => item[0] === newKey);
      if (existingKey) {
        modal(`Duplicate key <b>${newKey}</b> submitted. Please modify the existing key.`);
        newKeyCell.textContent = "";
        newValueCell.textContent = "";
        const filterInput = query("#advfilter input");
        filterInput.value = newKey;
        filterInput.dispatchEvent(new Event("input", { bubbles: true }));
        return;
      }
      submitNewKeyValue();
    }

    if (event.key === "Enter" || event.type === "click") {
      saveButton.click();
    }
  };

  d.addEventListener("keydown", event => {
    if (event.key === "Enter") {
      event.preventDefault();
      doSave(event);
    } else if (event.key === "Escape") {
      event.preventDefault();
      resetForm(event);
    }
  });

  /**
   * Resets the form to the original configuration stored in a hidden input.
   * @function resetForm
   * @param {Event} event - The event that triggered the reset
   * @returns {void}
   */
  const resetForm = event => {
    event.preventDefault();
    textarea.value = query("input[name=nofilter_oldConfig]").value;
    advForm.requestSubmit(saveButton);
  };

  cancelButton.addEventListener("click", resetForm);
  saveButton.addEventListener("click", event => {
    doSave(event);
  });

  let statusAdded = false;
  /**
   * Polls for the floodfill config element and adds a status badge to the header.
   * @function floodfillStatus
   * @returns {void}
   */
  function floodfillStatus() {
    const h3ff = document.querySelector("#ffconf");
    if (h3ff) {
      const info = document.createElement("span");
      info.id = "ffstatus";
      const ffstatus = document.querySelector("#floodfillconfig .infohelp").textContent.match(/\(.*?\)/);
      if (ffstatus && ffstatus.length > 0) {
        info.textContent = ffstatus[0].replace(/[\(\).]/g, "");
        if (!statusAdded) {
          h3ff.appendChild(info);
          statusAdded = true;
        }
      }
    } else {setTimeout(floodfillStatus, 100);}
  };
  floodfillStatus();
}

document.addEventListener("DOMContentLoaded", advConfigInit);