routerconsole/jsp/js/configstats.js

/**
 * @module configstats
 * @description Sorts stat groups using natural sort order and displays dynamic
 * counts of checked/total configured stats on the /configstats page.
 * Tracks checkbox changes to indicate unsaved state.
 * @author dr|z3d
 * @license AGPL3 or later
 */

document.addEventListener("DOMContentLoaded", function () {

  /**
   * Updates or creates a count span in the table header showing checked/total stats.
   * @function updateCountSpan
   * @param {HTMLTableHeaderCellElement} thElement - The table header element to update
   * @param {number} checkedItems - Number of checked checkboxes
   * @param {number} totalItems - Total number of checkboxes
   * @param {boolean} needsSaving - Whether the state differs from initial state
   * @returns {void}
   */
  function updateCountSpan(thElement, checkedItems, totalItems, needsSaving) {
    let countSpan = thElement.querySelector("span") || document.createElement("span");
    countSpan.classList.add("statcount");
    countSpan.textContent = (needsSaving ? "* " : "") + `${checkedItems} / ${totalItems}`;
    thElement.appendChild(countSpan);
  }

  /**
   * Captures the current checked state of all checkboxes as an object.
   * @function getCheckboxState
   * @param {NodeList} checkboxes - The checkbox elements to capture state from
   * @returns {Object<string, boolean>} Map of checkbox value to checked state
   */
  function getCheckboxState(checkboxes) {
    return Array.from(checkboxes).reduce((acc, cb) => ({ ...acc, [cb.value]: cb.checked }), {});
  }

  /**
   * Performs a natural (human-friendly) sort comparison between two strings.
   * @function naturalSort
   * @param {string} a - First string to compare
   * @param {string} b - Second string to compare
   * @returns {number} Negative if a < b, positive if a > b, zero if equal
   */
  function naturalSort(a, b) {
    const re = /(\d+)|(\D+)/g;
    const aParts = a.match(re);
    const bParts = b.match(re);

    for (let i = 0; i < Math.min(aParts.length, bParts.length); i++) {
      const aPart = aParts[i];
      const bPart = bParts[i];

      if (aPart !== bPart) {
        const aIsNumber = !isNaN(aPart);
        const bIsNumber = !isNaN(bPart);

        if (aIsNumber && bIsNumber) {
          return parseInt(aPart, 10) - parseInt(bPart, 10);
        }
        if (aIsNumber) {
          return -1;
        }
        if (bIsNumber) {
          return 1;
        }
        return aPart.localeCompare(bPart);
      }
    }
    return aParts.length - bParts.length;
  }

  /**
   * Initializes stat group rows with checkbox listeners and count displays.
   * @function initialize
   * @returns {Object<string, Object<string, boolean>>} Map of section IDs to their initial checkbox states
   */
  function initialize() {
    const graphableStatRows = document.querySelectorAll(".graphableStat");
    const initialState = {};

    graphableStatRows.forEach(row => {
      const td = row.querySelector("td");
      const checkboxes = td.querySelectorAll("input[type=checkbox]");
      const totalItems = checkboxes.length;
      const checkedItems = Array.from(checkboxes).filter(cb => cb.checked).length;

      const thElement = row.previousElementSibling.querySelector("th");
      if (thElement) {updateCountSpan(thElement, checkedItems, totalItems, false);}
      const sectionId = thElement.textContent.trim();
      initialState[sectionId] = getCheckboxState(checkboxes);

      checkboxes.forEach(cb => {
        cb.addEventListener("change", () => {
          const currentSectionState = getCheckboxState(checkboxes);
          const needsSaving = JSON.stringify(currentSectionState) !== JSON.stringify(initialState[sectionId]);
          updateCountSpan(thElement, Array.from(checkboxes).filter(cb => cb.checked).length, totalItems, needsSaving);
        });
      });
    });

    return initialState;
  }

  /**
   * Renders the sorted stat sections back into the table body.
   * @function renderSortedSections
   * @param {Array<{header: HTMLElement, content: HTMLElement[]}>} sections - Sorted sections to render
   * @returns {void}
   */
  function renderSortedSections(sections) {
    const tableBody = document.querySelector("#configstats tbody");
    tableBody.innerHTML = "";
    const fragment = document.createDocumentFragment();

    sections.forEach(section => {
      fragment.appendChild(section.header);
      section.content.forEach(content => fragment.appendChild(content));
    });

    tableBody.appendChild(fragment);
  }

  const initialState = initialize(), sections = [];
  let currentSection = null;

  document.querySelectorAll(".statgroup, .graphableStat").forEach(element => {
    if (element.classList.contains("statgroup")) {
      if (currentSection) {sections.push(currentSection);}
      currentSection = { header: element, content: [] };
    } else {currentSection.content.push(element);}
  });

  if (currentSection) { sections.push(currentSection); }
  sections.sort((a, b) => naturalSort(a.header.querySelector("th").textContent.trim(), b.header.querySelector("th").textContent.trim()));
  renderSortedSections(sections);

  document.querySelectorAll("tr.statgroup th b").forEach(b => {
    b.textContent = b.textContent.replace(/([a-z])([A-Z])/g, "$1 $2");
    b.textContent = b.textContent.replace("Co Del", "CoDel");
    b.innerHTML = b.innerHTML.replace(/\[([^\]]+)\]/g, "<span class=subcat>$1</span>");
  });

  document.querySelector("#configstats .optionsave>.cancel").addEventListener("click", (event) => {
    event.preventDefault();
    location.reload();
  });

});