routerconsole/jsp/js/tablesort/sortShared.js

/**
 * @module sortShared
 * @description Pure comparison functions shared between tablesort.js (main thread)
 * and sortWorker.js (web worker). All comparators return ascending order;
 * empty/null cells always sort to the bottom, regardless of sort direction.
 * Based on tristen/tablesort (MIT) https://github.com/tristen/tablesort,
 * ported to standalone functions by dr|z3d for I2P+ (AGPLv3).
 * @license AGPLv3 or later
 */

/** @returns {boolean} True if null, undefined, or whitespace-only. */
function empty(v) { return v == null || String(v).trim() === ""; }

/**
 * Wrap a comparator so empty cells always sort to the bottom, regardless of direction.
 * @param {function(string, string): number} cmp - Ascending comparator
 * @returns {function(string, string): number}
 */
function emptyLast(cmp) {
  return function(a, b) {
    const ae = empty(a), be = empty(b);
    if (ae || be) return ae && be ? 0 : ae ? 1 : -1;
    return cmp(a, b);
  };
}

/**
 * Number comparator — strips non-numeric chars except E/e/%.
 * @param {string} a
 * @param {string} b
 * @returns {number}
 */
function numberCmp(a, b) {
  const clean = v => typeof v === "string" ? v.replace(/[^-0-9.Ee%]/g, "") : "";
  const na = parseFloat(clean(a)), nb = parseFloat(clean(b));
  if (isNaN(na) && isNaN(nb)) return 0;
  if (isNaN(na)) return 1;
  if (isNaN(nb)) return -1;
  return na - nb;
}

/**
 * Match only pure numbers/percentages. Previously any string containing a digit
 * matched (e.g. "0.9.69" -> 0.9, "3KiB" -> 3, "Aug 5, 2026" -> 520261148), which
 * hijacked columns that need dotsep, filesize or date sorting.
 * @param {string} item
 */
function numberPattern(item) {
  if (typeof item !== "string" || item.trim() === "") return false;
  return /^[+-]?(\d+(\.\d+)?|\.\d+)([eE][+-]?\d+)?%?$/.test(item.trim());
}

/**
 * Natural sort — splits text into numeric/text chunks for human-friendly ordering.
 * @param {string} a
 * @param {string} b
 * @returns {number}
 */
function naturalCmp(a, b) {
  if (a === b) return 0;
  if (a === "unknown") return 1;
  if (b === "unknown") return -1;
  const aParts = a.toLowerCase().match(/(\.\d+)|(\d+(\.\d+)?)|([^\d.]+)/g) || [];
  const bParts = b.toLowerCase().match(/(\.\d+)|(\d+(\.\d+)?)|([^\d.]+)/g) || [];
  const min = Math.min(aParts.length, bParts.length);
  for (let i = 0; i < min; i++) {
    const aNum = parseFloat(aParts[i]), bNum = parseFloat(bParts[i]);
    if (!isNaN(aNum) && !isNaN(bNum)) {
      if (aNum !== bNum) return aNum - bNum;
    } else if (aParts[i] !== bParts[i]) {
      return aParts[i] < bParts[i] ? -1 : 1;
    }
  }
  return aParts.length - bParts.length;
}

/** @param {string} item */
function naturalPattern(item) { return item != null && item.trim() !== ""; }

/**
 * Dot-separated comparator (IPs, version numbers).
 * Pads shorter segment lists with zeros for unequal-length comparison.
 * @param {string} a
 * @param {string} b
 * @returns {number}
 */
function dotsepCmp(a, b) {
  const aParts = a.split("."), bParts = b.split(".");
  const len = Math.max(aParts.length, bParts.length);
  for (let i = 0; i < len; i++) {
    const ai = parseInt(aParts[i] || "0", 10), bi = parseInt(bParts[i] || "0", 10);
    if (ai !== bi) return ai - bi;
  }
  return 0;
}

/** @param {string} item */
function dotsepPattern(item) { return /^(\d+\.)+\d+$/.test(item); }

/**
 * Filesize comparator — converts with/without suffix to bytes (1024-base).
 * @param {string} a
 * @param {string} b
 * @returns {number}
 */
function filesizeCmp(a, b) {
  const toBytes = f => {
    const m = f.match(/^(\d+(\.\d+)?) ?((K|M|G|T|P|E|Z|Y|B$)i?B?)$/i);
    if (!m) return 0;
    const num = parseFloat(m[1].replace(/[^\-?0-9.]/g, ""));
    const suf = (m[3][0] || "").toLowerCase();
    const mult = suf === "k" ? 1024 : suf === "m" ? 1048576 : suf === "g" ? 1073741824 :
                 suf === "t" ? 1099511627776 : suf === "p" ? 1125899906842624 :
                 suf === "e" ? 1152921504606846976 : suf === "z" ? 1180591620717411303424 :
                 suf === "y" ? 1208925819614629174706176 : 1;
    return num * mult;
  };
  return toBytes(a) - toBytes(b);
}

/** @param {string} item */
function filesizePattern(item) {
  return /^\d+(\.\d+)? ?(K|M|G|T|P|E|Z|Y|B$)i?B?$/i.test(item);
}

/** Month-name comparator. @param {string} a @param {string} b @returns {number} */
function monthnameCmp(a, b) {
  const names = ["January", "February", "March", "April", "May", "June",
                 "July", "August", "September", "October", "November", "December"];
  return names.indexOf(a) - names.indexOf(b);
}

/** @param {string} item */
function monthnamePattern(item) {
  return /January|February|March|April|May|June|July|August|September|October|November|December/i.test(item);
}

/**
 * Date comparator — normalises separators and formats before parsing.
 * @param {string} a
 * @param {string} b
 * @returns {number}
 */
function dateCmp(a, b) {
  const parseDate = d => {
    d = d.replace(/-/g, "/").replace(/(\d{1,2})\/(\d{1,2})\/(\d{2,4})/, "$3-$2-$1");
    const t = new Date(d).getTime();
    return isNaN(t) ? -1 : t;
  };
  return parseDate(a.toLowerCase()) - parseDate(b.toLowerCase());
}

/** @param {string} item */
function datePattern(item) {
  const parseDate = d => {
    d = d.replace(/-/g, "/").replace(/(\d{1,2})\/(\d{1,2})\/(\d{2,4})/, "$3-$2-$1");
    return isNaN(new Date(d).getTime()) ? -1 : new Date(d).getTime();
  };
  if (parseDate(item) === -1) return false;
  return /(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\.?\,?\s*/i.test(item) ||
         /\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4}/.test(item) ||
         /(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)/i.test(item);
}

/** Intl.Collator comparator (locale-aware). @param {string} a @param {string} b @returns {number} */
function intlCmp(a, b) { return new Intl.Collator().compare(a, b); }

/** Intl never auto-detects; requires data-sort-method="intl". @returns {boolean} */
function intlPattern() { return false; }

/** Default string fallback (case-insensitive). @param {string} a @param {string} b @returns {number} */
function stringCmp(a, b) {
  a = String(a).toLowerCase();
  b = String(b).toLowerCase();
  return a < b ? -1 : (a > b ? 1 : 0);
}

// Exported comparators (empty-last wrapped)
const numberCmpEL = emptyLast(numberCmp);
const naturalCmpEL = emptyLast(naturalCmp);
const dotsepCmpEL = emptyLast(dotsepCmp);
const filesizeCmpEL = emptyLast(filesizeCmp);
const monthnameCmpEL = emptyLast(monthnameCmp);
const dateCmpEL = emptyLast(dateCmp);
const intlCmpEL = emptyLast(intlCmp);
const stringCmpEL = emptyLast(stringCmp);

/**
 * Sort an array of row data in place by a column, applying the selected comparator and
 * direction. Empty cells always sort to the bottom, regardless of direction.
 * Shared between the main thread fallback and the sort worker.
 *
 * @param {Array<Object<string, string>>} rows - Objects mapping the sort column to a string
 * @param {string} sortColumn - Key holding the cell text
 * @param {string} direction - "ascending" or "descending"
 * @param {string} columnType - Comparator key, or the default string sort
 * @returns {Array<Object<string, string>>} the sorted array
 */
function sortRows(rows, sortColumn, direction, columnType) {
  const multiplier = direction === "descending" ? -1 : 1;
  rows.sort((a, b) => {
    const valA = a[sortColumn], valB = b[sortColumn];
    const aEmpty = empty(valA), bEmpty = empty(valB);
    if (aEmpty || bEmpty) {
      if (aEmpty && bEmpty) return 0;
      return aEmpty ? 1 : -1;
    }
    let res;
    switch (columnType) {
      case "number":    res = numberCmp(valA, valB); break;
      case "date":      res = dateCmp(valA, valB); break;
      case "natural":   res = naturalCmp(valA, valB); break;
      case "dotsep":    res = dotsepCmp(valA, valB); break;
      case "filesize":  res = filesizeCmp(valA, valB); break;
      case "monthname": res = monthnameCmp(valA, valB); break;
      case "intl":      res = intlCmp(valA, valB); break;
      default:          res = stringCmp(valA, valB); break;
    }
    return res * multiplier;
  });
  return rows;
}