PHPIndex

This page lists files in the current directory. You can view content, get download/execute commands for Wget, Curl, or PowerShell, or filter the list using wildcards (e.g., `*.sh`).

constants.js
wget 'https://sme10.lists2.roe3.org/spreadsheet/modules/constants.js'
View Content
/**
 * Constants Module for Spreadsheet Application
 * Contains all configuration constants used throughout the app
 */

// ========== Layout & Sizing Limits ==========
export const MAX_ROWS = 1000;
export const MAX_COLS = 1000;
export const DEBOUNCE_DELAY = 200;
export const ACTIVE_HEADER_CLASS = "header-active";
export const ROW_HEADER_WIDTH = 40;
export const HEADER_ROW_HEIGHT = 32;
export const DEFAULT_COL_WIDTH = 100;
export const MIN_COL_WIDTH = 80;

// Mobile-responsive layout detection
export const IS_MOBILE_LAYOUT = typeof window !== "undefined" && window.matchMedia && window.matchMedia("(max-width: 768px)").matches;
export const DEFAULT_ROW_HEIGHT = IS_MOBILE_LAYOUT ? 44 : 32;
export const MIN_ROW_HEIGHT = DEFAULT_ROW_HEIGHT;

// Default starting grid size
export const DEFAULT_ROWS = 10;
export const DEFAULT_COLS = 10;

// ========== URL Length Thresholds ==========
export const URL_LENGTH_WARNING = 2000; // Yellow - some older browsers may truncate
export const URL_LENGTH_CAUTION = 4000; // Orange - URL shorteners may fail
export const URL_LENGTH_CRITICAL = 8000; // Red - some browsers may fail
export const URL_LENGTH_MAX_DISPLAY = 10000; // For progress bar scaling

// ========== Key Minification Mapping for URL Compression ==========
export const KEY_MAP = {
  rows: "r",
  cols: "c",
  theme: "t",
  data: "d",
  formulas: "f",
  cellStyles: "s",
  colWidths: "w",
  rowHeights: "h",
  readOnly: "ro",
  embed: "e",
};
export const KEY_MAP_REVERSE = Object.fromEntries(Object.entries(KEY_MAP).map(([k, v]) => [v, k]));

export const STYLE_KEY_MAP = {
  align: "a",
  bg: "b",
  color: "c",
  fontSize: "z",
};
export const STYLE_KEY_MAP_REVERSE = Object.fromEntries(Object.entries(STYLE_KEY_MAP).map(([k, v]) => [v, k]));

// ========== Formula Configuration ==========
export const FORMULA_SUGGESTIONS = [
  { name: "SUM", signature: "SUM(range)", description: "Adds numbers in a range" },
  { name: "AVG", signature: "AVG(range)", description: "Average of numbers in a range" },
];

// Valid formula patterns (security whitelist)
export const VALID_FORMULA_PATTERNS = [
  /^=\s*SUM\s*\(\s*[A-Z]+\d+\s*:\s*[A-Z]+\d+\s*\)\s*$/i,
  /^=\s*AVG\s*\(\s*[A-Z]+\d+\s*:\s*[A-Z]+\d+\s*\)\s*$/i,
  /^=\s*PROGRESS\s*\(\s*[^)]+\s*\)\s*$/i,
  /^=\s*TAG\s*\(\s*[^)]+\s*\)\s*$/i,
  /^=\s*RATING\s*\(\s*[^)]+\s*\)\s*$/i,
];

export const FONT_SIZE_OPTIONS = [10, 12, 14, 16, 18, 24];

// ========== HTML Sanitization ==========
// Allowed HTML tags for sanitization (preserves basic formatting)
export const ALLOWED_TAGS = ["B", "I", "U", "STRONG", "EM", "SPAN", "BR"];
export const ALLOWED_SPAN_STYLES = ["font-weight", "font-style", "text-decoration", "color", "background-color"];

// ========== Toast Notification System ==========
export const TOAST_DURATION = 3000; // Default duration in ms
export const TOAST_ICONS = {
  success: "fa-check",
  error: "fa-xmark",
  warning: "fa-exclamation",
  info: "fa-info",
};
csvManager.js
wget 'https://sme10.lists2.roe3.org/spreadsheet/modules/csvManager.js'
View Content
import { MAX_COLS, MAX_ROWS } from "./constants.js";
import { isValidFormula } from "./formulaManager.js";
import { escapeHTML } from "./security.js";
import { createDefaultColumnWidths, createDefaultRowHeights, createEmptyCellStyles, createEmptyData } from "./urlManager.js";

function csvEscape(value) {
  const text = String(value);
  const needsQuotes = /[",\r\n]/.test(text) || /^\s|\s$/.test(text);
  const escaped = text.replace(/"/g, '""');
  return needsQuotes ? `"${escaped}"` : escaped;
}

export function parseCSV(text) {
  if (!text) return [];

  const rows = [];
  let row = [];
  let field = "";
  let inQuotes = false;

  for (let i = 0; i < text.length; i++) {
    const char = text[i];

    if (inQuotes) {
      if (char === '"') {
        if (text[i + 1] === '"') {
          field += '"';
          i++;
        } else {
          inQuotes = false;
        }
      } else {
        field += char;
      }
      continue;
    }

    if (char === '"') {
      inQuotes = true;
    } else if (char === ",") {
      row.push(field);
      field = "";
    } else if (char === "\r") {
      if (text[i + 1] === "\n") {
        i++;
      }
      row.push(field);
      rows.push(row);
      row = [];
      field = "";
    } else if (char === "\n") {
      row.push(field);
      rows.push(row);
      row = [];
      field = "";
    } else {
      field += char;
    }
  }

  row.push(field);
  rows.push(row);

  if (rows.length && rows[0].length && rows[0][0]) {
    rows[0][0] = rows[0][0].replace(/^\uFEFF/, "");
  }

  if (/\r?\n$/.test(text)) {
    const lastRow = rows[rows.length - 1];
    if (lastRow && lastRow.length === 1 && lastRow[0] === "") {
      rows.pop();
    }
  }

  return rows;
}

export const CSVManager = {
  callbacks: {
    getState: () => ({ rows: 0, cols: 0 }),
    getDataArray: () => [],
    setDataArray: () => {},
    setFormulasArray: () => {},
    setCellStylesArray: () => {},
    setState: () => {},
    renderGrid: () => {},
    recalculateFormulas: () => {},
    debouncedUpdateURL: () => {},
    showToast: () => {},
    extractPlainText: (value) => (value === null || value === undefined ? "" : String(value)),
    onImport: null,
  },

  init(callbacks = {}) {
    CSVManager.callbacks = { ...CSVManager.callbacks, ...callbacks };
  },

  buildCSV() {
    const { getState, getDataArray, extractPlainText } = CSVManager.callbacks;
    if (!getState || !getDataArray || !extractPlainText) return "";

    const { rows, cols } = getState();
    const data = getDataArray();
    const lines = [];

    for (let r = 0; r < rows; r++) {
      const rowValues = [];
      for (let c = 0; c < cols; c++) {
        const raw = data[r] && data[r][c] !== undefined ? data[r][c] : "";
        const text = extractPlainText(raw);
        rowValues.push(csvEscape(text));
      }
      lines.push(rowValues.join(","));
    }

    return lines.join("\r\n");
  },

  downloadCSV() {
    const { recalculateFormulas, showToast } = CSVManager.callbacks;

    if (recalculateFormulas) {
      recalculateFormulas();
    }
    const csv = CSVManager.buildCSV();

    const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
    const url = URL.createObjectURL(blob);
    const link = document.createElement("a");
    link.href = url;
    link.download = "spreadsheet.csv";
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
    URL.revokeObjectURL(url);

    if (showToast) {
      showToast("CSV downloaded", "success");
    }
  },

  importCSVText(text) {
    const parsedRows = parseCSV(text);
    if (!parsedRows.length) {
      if (CSVManager.callbacks.showToast) {
        CSVManager.callbacks.showToast("CSV file is empty", "error");
      }
      return;
    }

    const maxColsInFile = parsedRows.reduce((max, row) => Math.max(max, row.length), 0);
    const nextRows = Math.min(Math.max(parsedRows.length, 1), MAX_ROWS);
    const nextCols = Math.min(Math.max(maxColsInFile, 1), MAX_COLS);
    const truncated = parsedRows.length > MAX_ROWS || maxColsInFile > MAX_COLS;

    CSVManager.callbacks.setState("rows", nextRows);
    CSVManager.callbacks.setState("cols", nextCols);
    CSVManager.callbacks.setState("colWidths", createDefaultColumnWidths(nextCols));
    CSVManager.callbacks.setState("rowHeights", createDefaultRowHeights(nextRows));

    const data = createEmptyData(nextRows, nextCols);
    const formulas = createEmptyData(nextRows, nextCols);
    const cellStyles = createEmptyCellStyles(nextRows, nextCols);

    for (let r = 0; r < nextRows; r++) {
      const sourceRow = Array.isArray(parsedRows[r]) ? parsedRows[r] : [];
      for (let c = 0; c < nextCols; c++) {
        const raw = sourceRow[c] !== undefined ? String(sourceRow[c]) : "";
        if (raw.startsWith("=")) {
          if (isValidFormula(raw)) {
            formulas[r][c] = raw;
            data[r][c] = raw;
          } else {
            formulas[r][c] = "";
            data[r][c] = escapeHTML(raw);
          }
        } else {
          data[r][c] = escapeHTML(raw);
        }
      }
    }

    CSVManager.callbacks.setDataArray(data);
    CSVManager.callbacks.setFormulasArray(formulas);
    CSVManager.callbacks.setCellStylesArray(cellStyles);

    if (CSVManager.callbacks.renderGrid) {
      CSVManager.callbacks.renderGrid();
    }
    if (CSVManager.callbacks.recalculateFormulas) {
      CSVManager.callbacks.recalculateFormulas();
    }
    if (CSVManager.callbacks.debouncedUpdateURL) {
      CSVManager.callbacks.debouncedUpdateURL();
    }
    if (CSVManager.callbacks.onImport) {
      CSVManager.callbacks.onImport({
        truncated,
        rows: nextRows,
        cols: nextCols,
      });
    }

    if (CSVManager.callbacks.showToast) {
      if (truncated) {
        CSVManager.callbacks.showToast("CSV imported (some data truncated due to size limits)", "warning");
      } else {
        CSVManager.callbacks.showToast("CSV imported successfully", "success");
      }
    }
  },
};
dependencyTracer.js
wget 'https://sme10.lists2.roe3.org/spreadsheet/modules/dependencyTracer.js'
View Content
/**
 * Visual Formula Dependency Tracer
 * Draws Bezier curves between formula cells and their data sources.
 */
import { parseRange, parseCellRef } from "./formulaManager.js";
import { getState, getCellElement } from "./rowColManager.js";

const SVG_NS = "http://www.w3.org/2000/svg";
const ARROW_ID = "dependency-arrowhead";
const DOT_ID = "dependency-dot";
const LINE_COLOR = "#2196F3"; // Professional Blue

function normalizeRef(ref) {
  return ref.replace(/\$/g, "").toUpperCase();
}

function extractRefs(formula) {
  if (!formula || typeof formula !== "string") return [];
  if (!formula.startsWith("=")) return [];
  const matches = formula.match(/(\$?[A-Z]+\$?\d+)(?::(\$?[A-Z]+\$?\d+))?/gi);
  return matches || [];
}

function isInBounds(row, col, rows, cols) {
  return row >= 0 && col >= 0 && row < rows && col < cols;
}

export const DependencyTracer = {
  isActive: false,
  container: null,
  svg: null,
  resizeObserver: null,
  lastFormulas: null,

  init() {
    const container = document.getElementById("spreadsheet");
    if (!container) return;

    this.container = container;

    if (!this.svg || !container.contains(this.svg)) {
      this.svg = this.createLayer();
      this.container.appendChild(this.svg);
    }

    if (this.isActive) {
      this.svg.classList.remove("hidden");
    } else {
      this.svg.classList.add("hidden");
    }

    this.updateSvgSize();

    if (this.resizeObserver) {
      this.resizeObserver.disconnect();
      this.resizeObserver = null;
    }

    if (typeof ResizeObserver !== "undefined") {
      this.resizeObserver = new ResizeObserver(() => {
        if (this.isActive) {
          this.draw(this.lastFormulas);
        }
      });
      this.resizeObserver.observe(this.container);
    }
  },

  createLayer() {
    const svg = document.createElementNS(SVG_NS, "svg");
    svg.classList.add("dependency-layer", "hidden");
    svg.setAttribute("aria-hidden", "true");

    const defs = document.createElementNS(SVG_NS, "defs");

    // Arrow Marker (End) - Smaller Size
    const arrowMarker = document.createElementNS(SVG_NS, "marker");
    arrowMarker.setAttribute("id", ARROW_ID);
    arrowMarker.setAttribute("markerWidth", "6");
    arrowMarker.setAttribute("markerHeight", "6");
    arrowMarker.setAttribute("refX", "5"); // Tip
    arrowMarker.setAttribute("refY", "3");
    arrowMarker.setAttribute("orient", "auto");

    const arrowPath = document.createElementNS(SVG_NS, "path");
    arrowPath.setAttribute("d", "M0,0 L6,3 L0,6 L1.5,3 z"); // Sharper, smaller arrow
    arrowPath.setAttribute("fill", LINE_COLOR);
    arrowMarker.appendChild(arrowPath);

    // Dot Marker (Start)
    const dotMarker = document.createElementNS(SVG_NS, "marker");
    dotMarker.setAttribute("id", DOT_ID);
    dotMarker.setAttribute("markerWidth", "8");
    dotMarker.setAttribute("markerHeight", "8");
    dotMarker.setAttribute("refX", "4"); // Center of dot
    dotMarker.setAttribute("refY", "4");
    dotMarker.setAttribute("orient", "auto");

    const dotCircle = document.createElementNS(SVG_NS, "circle");
    dotCircle.setAttribute("cx", "4");
    dotCircle.setAttribute("cy", "4");
    dotCircle.setAttribute("r", "3");
    dotCircle.setAttribute("fill", LINE_COLOR);
    dotMarker.appendChild(dotCircle);

    defs.appendChild(arrowMarker);
    defs.appendChild(dotMarker);
    svg.appendChild(defs);

    return svg;
  },

  ensureLayer() {
    const container = document.getElementById("spreadsheet");
    if (!container) return false;

    if (this.container !== container) {
      this.container = container;
    }

    if (!this.svg || !container.contains(this.svg)) {
      this.svg = this.createLayer();
      this.container.appendChild(this.svg);
    }

    if (this.isActive) {
      this.svg.classList.remove("hidden");
    } else {
      this.svg.classList.add("hidden");
    }

    this.updateSvgSize();
    return true;
  },

  updateSvgSize() {
    if (!this.container || !this.svg) return;
    const width = Math.max(this.container.scrollWidth, this.container.clientWidth);
    const height = Math.max(this.container.scrollHeight, this.container.clientHeight);
    this.svg.setAttribute("width", width);
    this.svg.setAttribute("height", height);
    this.svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
  },

  toggle() {
    this.isActive = !this.isActive;
    if (!this.ensureLayer()) return this.isActive;

    if (this.isActive) {
      this.svg.classList.remove("hidden");
      this.draw(this.lastFormulas);
    } else {
      this.svg.classList.add("hidden");
      this.clear();
    }
    return this.isActive;
  },

  clear() {
    if (!this.svg) return;
    this.svg.querySelectorAll("path").forEach((path) => path.remove());
    this.clearHighlights();
  },

  clearHighlights() {
    if (!this.container) return;
    const sources = this.container.querySelectorAll(".dependency-source");
    const targets = this.container.querySelectorAll(".dependency-target");
    sources.forEach((el) => el.classList.remove("dependency-source"));
    targets.forEach((el) => el.classList.remove("dependency-target"));
  },

  getCellCenter(row, col) {
    return this.getCellAnchor(row, col, 'center');
  },
  
  getCellAnchor(row, col, side = 'center') {
    const cell = getCellElement(row, col);
    if (!cell || !this.container) return null;

    const rect = cell.getBoundingClientRect();
    const containerRect = this.container.getBoundingClientRect();

    const topOffset = rect.top - containerRect.top + 8; // Near top (corner-like)
    
    if (side === 'left') {
        return {
            x: rect.left - containerRect.left + 2, // Left edge
            y: topOffset,
        };
    }
    
    if (side === 'right') {
        return {
            x: rect.right - containerRect.left - 2, // Right edge
            y: topOffset,
        };
    }

    // Default center
    return {
      x: rect.left - containerRect.left + rect.width / 2,
      y: rect.top - containerRect.top + rect.height / 2,
    };
  },

  getRangeCenter(startRow, startCol, endRow, endCol) {
     // For ranges, we'll just use the visual center for now to avoid complexity
    const topLeft = this.getCellCenter(startRow, startCol);
    const bottomRight = this.getCellCenter(endRow, endCol);
    if (!topLeft || !bottomRight) return null;

    return {
      x: (topLeft.x + bottomRight.x) / 2,
      y: (topLeft.y + bottomRight.y) / 2,
    };
  },

  highlightCell(row, col, type) {
    const cell = getCellElement(row, col);
    if (cell) {
        cell.classList.add(type === "source" ? "dependency-source" : "dependency-target");
    }
  },

  draw(formulas) {
    if (!this.isActive) return;
    if (!this.ensureLayer()) return;

    const formulaData = Array.isArray(formulas) ? formulas : this.lastFormulas;
    if (Array.isArray(formulas)) {
      this.lastFormulas = formulas;
    }
    this.clear(); // This now calls clearHighlights too

    const { rows, cols } = getState();
    if (!Array.isArray(formulaData) || rows <= 0 || cols <= 0) return;

    const drawPath = (source, target, direction) => {
      if (!this.svg) return;
      if (!source || !target) return;
      if (source.x === target.x && source.y === target.y) return;

      const path = document.createElementNS(SVG_NS, "path");
      
      const deltaX = target.x - source.x;
      const deltaY = target.y - source.y;
      
      let c1x, c1y, c2x, c2y;

      if (direction === 'right-to-left') {
          // R -> L: Curve out to left from source, enter from right to target
          c1x = source.x - 30;
          c1y = source.y;
          c2x = target.x + 30;
          c2y = target.y;
      } else if (direction === 'vertical') {
           // Same column: Curve out to right and back in
          c1x = source.x + 40;
          c1y = source.y + deltaY * 0.2;
          c2x = target.x + 40;
          c2y = target.y - deltaY * 0.2;
      } else {
          // L -> R (Standard): Curve right from source, enter left to target
          c1x = source.x + 30;
          c1y = source.y;
          c2x = target.x - 30;
          c2y = target.y;
      }

      const d = `M ${source.x} ${source.y} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${target.x} ${target.y}`;

      path.setAttribute("d", d);
      path.setAttribute("stroke", LINE_COLOR);
      path.setAttribute("stroke-width", "2");
      path.setAttribute("fill", "none");
      path.setAttribute("opacity", "0.8");
      path.setAttribute("marker-start", `url(#${DOT_ID})`);
      path.setAttribute("marker-end", `url(#${ARROW_ID})`); 
      path.classList.add("dependency-line");

      this.svg.appendChild(path);
    };

    for (let r = 0; r < rows; r++) {
      const rowFormulas = formulaData[r];
      if (!Array.isArray(rowFormulas)) continue;

      for (let c = 0; c < cols; c++) {
        const formula = rowFormulas[c];
        if (!formula || typeof formula !== "string" || !formula.startsWith("=")) continue;

        const refs = extractRefs(formula);
        if (!refs.length) continue;

        // Highlight target (the cell containing the formula)
        this.highlightCell(r, c, "target");

        refs.forEach((refStr) => {
          const cleaned = normalizeRef(refStr);

          // Handle Range refs (keeping simple center logic or default L->R for now to avoid complexity explosion)
          if (cleaned.includes(":")) {
            const range = parseRange(cleaned);
            if (!range) return;
            if (!isInBounds(range.startRow, range.startCol, rows, cols)) return;
            if (!isInBounds(range.endRow, range.endCol, rows, cols)) return;
            
            for (let rr = range.startRow; rr <= range.endRow; rr++) {
                for (let cc = range.startCol; cc <= range.endCol; cc++) {
                    this.highlightCell(rr, cc, "source");
                }
            }

            // For ranges, we just draw from center of range to target 'left' default
            const sourceCenter = this.getRangeCenter(range.startRow, range.startCol, range.endRow, range.endCol);
            const targetCenter = this.getCellAnchor(r, c, 'left');
            drawPath(sourceCenter, targetCenter, 'left-to-right');
            return;
          }

          const cellRef = parseCellRef(cleaned);
          if (!cellRef) return;
          if (!isInBounds(cellRef.row, cellRef.col, rows, cols)) return;
          
          // Highlight source cell
          this.highlightCell(cellRef.row, cellRef.col, "source");
          
          // Determine Direction
          let sourceSide, targetSide, direction;
          
          if (c > cellRef.col) {
              // Target is to the RIGHT of Source (Standard Reading Order)
              // Source -> [Right Edge] ..... [Left Edge] -> Target
              sourceSide = 'right';
              targetSide = 'left';
              direction = 'left-to-right';
          } else if (c < cellRef.col) {
              // Target is to the LEFT of Source (Reverse Flow)
              // Source -> [Left Edge] ..... [Right Edge] -> Target
              sourceSide = 'left';
              targetSide = 'right';
              direction = 'right-to-left';
          } else {
              // Same Column (Vertical)
              // Use Right-to-Right loop to avoid text
              sourceSide = 'right';
              targetSide = 'right';
              direction = 'vertical';
          }

          const sourcePoint = this.getCellAnchor(cellRef.row, cellRef.col, sourceSide);
          const targetPoint = this.getCellAnchor(r, c, targetSide);
          
          drawPath(sourcePoint, targetPoint, direction);
        });
      }
    }
  },
};
encryption.js
wget 'https://sme10.lists2.roe3.org/spreadsheet/modules/encryption.js'
View Content
/**
 * Encryption Module for Spreadsheet Application
 * Provides AES-GCM 256-bit encryption with PBKDF2 key derivation
 */

// ========== Encryption Module (AES-GCM 256-bit) ==========
export const CryptoUtils = {
  algo: { name: "AES-GCM", length: 256 },
  kdf: { name: "PBKDF2", hash: "SHA-256", iterations: 100000 },

  // Derive a cryptographic key from a password using PBKDF2
  async deriveKey(password, salt) {
    const enc = new TextEncoder();
    const keyMaterial = await window.crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveKey"]);
    return window.crypto.subtle.deriveKey({ ...this.kdf, salt: salt }, keyMaterial, this.algo, false, ["encrypt", "decrypt"]);
  },

  // Encrypt data string with password, returns Base64 string
  async encrypt(dataString, password) {
    const enc = new TextEncoder();
    const salt = window.crypto.getRandomValues(new Uint8Array(16));
    const iv = window.crypto.getRandomValues(new Uint8Array(12));

    const key = await this.deriveKey(password, salt);
    const encodedData = enc.encode(dataString);

    const encryptedContent = await window.crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, key, encodedData);

    // Pack: Salt (16) + IV (12) + EncryptedData
    const buffer = new Uint8Array(salt.byteLength + iv.byteLength + encryptedContent.byteLength);
    buffer.set(salt, 0);
    buffer.set(iv, salt.byteLength);
    buffer.set(new Uint8Array(encryptedContent), salt.byteLength + iv.byteLength);

    return this.bufferToBase64(buffer);
  },

  // Decrypt Base64 string with password, returns original data string
  async decrypt(base64String, password) {
    const buffer = this.base64ToBuffer(base64String);

    // Extract: Salt (16) + IV (12) + EncryptedData
    const salt = buffer.slice(0, 16);
    const iv = buffer.slice(16, 28);
    const data = buffer.slice(28);

    const key = await this.deriveKey(password, salt);

    const decryptedContent = await window.crypto.subtle.decrypt({ name: "AES-GCM", iv: iv }, key, data);

    const dec = new TextDecoder();
    return dec.decode(decryptedContent);
  },

  // Convert Uint8Array to URL-safe Base64
  bufferToBase64(buffer) {
    let binary = "";
    const bytes = new Uint8Array(buffer);
    for (let i = 0; i < bytes.byteLength; i++) {
      binary += String.fromCharCode(bytes[i]);
    }
    // Use URL-safe Base64 (replace + with -, / with _, remove padding)
    return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
  },

  // Convert URL-safe Base64 to Uint8Array
  base64ToBuffer(base64) {
    // Restore standard Base64
    let standardBase64 = base64.replace(/-/g, "+").replace(/_/g, "/");
    // Add padding if needed
    while (standardBase64.length % 4) {
      standardBase64 += "=";
    }
    const binaryString = atob(standardBase64);
    const bytes = new Uint8Array(binaryString.length);
    for (let i = 0; i < binaryString.length; i++) {
      bytes[i] = binaryString.charCodeAt(i);
    }
    return bytes;
  },
};

// ========== Encryption Codec (Wrapper for CryptoUtils) ==========
// Handles encryption prefix and provides clean interface for encrypt/decrypt
export const EncryptionCodec = {
  PREFIX: "ENC:",

  // Check if a URL hash represents encrypted data
  isEncrypted(hash) {
    return hash && hash.startsWith(this.PREFIX);
  },

  // Wrap encrypted data with the encryption prefix
  wrap(encryptedData) {
    return this.PREFIX + encryptedData;
  },

  // Unwrap the prefix from encrypted data
  unwrap(prefixedData) {
    return prefixedData.slice(this.PREFIX.length);
  },

  // Encrypt a payload string and wrap with prefix
  async encrypt(payload, password) {
    const encrypted = await CryptoUtils.encrypt(payload, password);
    return this.wrap(encrypted);
  },

  // Decrypt data (handles both wrapped and unwrapped formats)
  async decrypt(wrappedData, password) {
    const data = this.isEncrypted(wrappedData) ? this.unwrap(wrappedData) : wrappedData;
    return await CryptoUtils.decrypt(data, password);
  },
};
formulaManager.js
wget 'https://sme10.lists2.roe3.org/spreadsheet/modules/formulaManager.js'
View Content
import { FORMULA_SUGGESTIONS } from "./constants.js";

// ==========================================
// Formula Helper Functions
// ==========================================

// Convert column index to letters (0 = A, 25 = Z, 26 = AA)
export function colToLetter(col) {
  if (!Number.isInteger(col) || col < 0) return "";
  let n = col;
  let letters = "";
  while (n >= 0) {
    const remainder = n % 26;
    letters = String.fromCharCode(65 + remainder) + letters;
    n = Math.floor(n / 26) - 1;
  }
  return letters;
}

// Convert column letter(s) to index: A=0, B=1, ..., O=14
export function letterToCol(letters) {
  letters = letters.toUpperCase();
  let col = 0;
  for (let i = 0; i < letters.length; i++) {
    col = col * 26 + (letters.charCodeAt(i) - 64);
  }
  return col - 1;
}

// Parse cell reference "A1" → { row: 0, col: 0 }
export function parseCellRef(ref) {
  const match = ref.toUpperCase().match(/^([A-Z]+)(\d+)$/);
  if (!match) return null;
  return {
    col: letterToCol(match[1]),
    row: parseInt(match[2], 10) - 1,
  };
}

// Parse range "A1:B5" → { startRow, startCol, endRow, endCol }
export function parseRange(range) {
  const parts = range.split(":");
  if (parts.length !== 2) return null;
  const start = parseCellRef(parts[0].trim());
  const end = parseCellRef(parts[1].trim());
  if (!start || !end) return null;
  return {
    startRow: Math.min(start.row, end.row),
    startCol: Math.min(start.col, end.col),
    endRow: Math.max(start.row, end.row),
    endCol: Math.max(start.col, end.col),
  };
}

// Build cell reference string like "A1"
export function buildCellRef(row, col) {
  return colToLetter(col) + (row + 1);
}

// Build range reference string like "A1:B5"
export function buildRangeRef(startRow, startCol, endRow, endCol) {
  const minRow = Math.min(startRow, endRow);
  const maxRow = Math.max(startRow, endRow);
  const minCol = Math.min(startCol, endCol);
  const maxCol = Math.max(startCol, endCol);

  if (minRow === maxRow && minCol === maxCol) {
    // Single cell
    return buildCellRef(minRow, minCol);
  }
  return buildCellRef(minRow, minCol) + ":" + buildCellRef(maxRow, maxCol);
}

const VISUAL_FORMULA_REGEX = /^=\s*(PROGRESS|TAG|RATING)\s*\((.*)\)\s*$/i;

export function isVisualFormula(formula) {
  if (!formula || typeof formula !== "string") return false;
  const match = formula.match(VISUAL_FORMULA_REGEX);
  if (!match) return false;
  return match[2].trim().length > 0;
}

// Check if a formula string is valid (arithmetic or supported functions)
export function isValidFormula(formula) {
  if (!formula || !formula.startsWith("=")) return false;

  if (isVisualFormula(formula)) return true;

  // Check supported functions
  if (/^=SUM\([A-Z]+\d+:[A-Z]+\d+\)$/i.test(formula)) return true;
  if (/^=AVG\([A-Z]+\d+:[A-Z]+\d+\)$/i.test(formula)) return true;

  // Check arithmetic
  return FormulaEvaluator.isArithmeticFormula(formula);
}

// ==========================================
// Formula Evaluation Logic
// ==========================================

export const FormulaEvaluator = {
  // Tokenize arithmetic expression into array of tokens
  tokenizeArithmetic(expr) {
    const tokens = [];
    let i = 0;

    while (i < expr.length) {
      // Skip whitespace
      if (/\s/.test(expr[i])) {
        i++;
        continue;
      }

      // Numbers (including decimals)
      if (/[\d.]/.test(expr[i])) {
        let num = "";
        while (i < expr.length && /[\d.]/.test(expr[i])) {
          num += expr[i++];
        }
        if (!/^\d+\.?\d*$|^\d*\.\d+$/.test(num)) {
          return { error: "Invalid number: " + num };
        }
        tokens.push({ type: "NUMBER", value: parseFloat(num) });
        continue;
      }

      // Cell references (A1, B2, AA99, etc.)
      if (/[A-Z]/i.test(expr[i])) {
        let ref = "";
        while (i < expr.length && /[A-Z]/i.test(expr[i])) {
          ref += expr[i++];
        }
        while (i < expr.length && /\d/.test(expr[i])) {
          ref += expr[i++];
        }
        if (!/^[A-Z]+\d+$/i.test(ref)) {
          return { error: "Invalid cell reference: " + ref };
        }
        tokens.push({ type: "CELL_REF", value: ref.toUpperCase() });
        continue;
      }

      // Operators
      if ("+-*/".includes(expr[i])) {
        tokens.push({ type: "OPERATOR", value: expr[i] });
        i++;
        continue;
      }

      // Parentheses
      if (expr[i] === "(") {
        tokens.push({ type: "LPAREN" });
        i++;
        continue;
      }
      if (expr[i] === ")") {
        tokens.push({ type: "RPAREN" });
        i++;
        continue;
      }

      // Unknown character
      return { error: "Unexpected character: " + expr[i] };
    }

    return { tokens };
  },

  // Parse and evaluate arithmetic expression with proper precedence
  evaluateArithmeticExpr(tokens, context) {
    let pos = 0;
    const { getCellValue, rows, cols } = context;

    function peek() {
      return tokens[pos];
    }

    function consume() {
      return tokens[pos++];
    }

    function parseExpr() {
      let left = parseTerm();
      if (left.error) return left;

      while (peek() && peek().type === "OPERATOR" && (peek().value === "+" || peek().value === "-")) {
        const op = consume().value;
        const right = parseTerm();
        if (right.error) return right;

        left = { value: op === "+" ? left.value + right.value : left.value - right.value };
      }
      return left;
    }

    function parseTerm() {
      let left = parseFactor();
      if (left.error) return left;

      while (peek() && peek().type === "OPERATOR" && (peek().value === "*" || peek().value === "/")) {
        const op = consume().value;
        const right = parseFactor();
        if (right.error) return right;

        if (op === "/") {
          if (right.value === 0) {
            return { error: "#DIV/0!" };
          }
          left = { value: left.value / right.value };
        } else {
          left = { value: left.value * right.value };
        }
      }
      return left;
    }

    function parseFactor() {
      const token = peek();

      if (!token) {
        return { error: "#ERROR!" };
      }

      // Unary minus
      if (token.type === "OPERATOR" && token.value === "-") {
        consume();
        const factor = parseFactor();
        if (factor.error) return factor;
        return { value: -factor.value };
      }

      // Unary plus (just consume)
      if (token.type === "OPERATOR" && token.value === "+") {
        consume();
        return parseFactor();
      }

      // Number literal
      if (token.type === "NUMBER") {
        consume();
        return { value: token.value };
      }

      // Cell reference
      if (token.type === "CELL_REF") {
        consume();
        const parsed = parseCellRef(token.value);
        if (!parsed) {
          return { error: "#REF!" };
        }
        if (parsed.row >= rows || parsed.col >= cols || parsed.row < 0 || parsed.col < 0) {
          return { error: "#REF!" };
        }
        return { value: getCellValue(parsed.row, parsed.col) };
      }

      // Parenthesized expression
      if (token.type === "LPAREN") {
        consume();
        const result = parseExpr();
        if (result.error) return result;

        if (!peek() || peek().type !== "RPAREN") {
          return { error: "#ERROR!" };
        }
        consume();
        return result;
      }

      return { error: "#ERROR!" };
    }

    const result = parseExpr();

    // Check for leftover tokens (malformed expression)
    if (!result.error && pos < tokens.length) {
      return { error: "#ERROR!" };
    }

    return result;
  },

  // Evaluate arithmetic expression string (without leading =)
  evaluateArithmetic(expr, context) {
    const tokenResult = this.tokenizeArithmetic(expr);
    if (tokenResult.error) {
      return tokenResult.error;
    }

    if (tokenResult.tokens.length === 0) {
      return "#ERROR!";
    }

    const evalResult = this.evaluateArithmeticExpr(tokenResult.tokens, context);
    if (evalResult.error) {
      return evalResult.error;
    }

    // Format result (avoid floating point display issues)
    const value = evalResult.value;
    if (Number.isInteger(value)) {
      return value;
    }
    // Round to reasonable precision
    return Math.round(value * 1e10) / 1e10;
  },

  // Check if expression is a valid arithmetic formula
  isArithmeticFormula(formula) {
    if (!formula || !formula.startsWith("=")) return false;
    const expr = formula.substring(1).trim();
    if (expr.length === 0) return false;

    const tokenResult = this.tokenizeArithmetic(expr);
    if (tokenResult.error) return false;
    if (tokenResult.tokens.length === 0) return false;

    // Validate balanced parentheses
    let parenDepth = 0;
    for (const token of tokenResult.tokens) {
      if (token.type === "LPAREN") {
        parenDepth++;
      } else if (token.type === "RPAREN") {
        parenDepth--;
        if (parenDepth < 0) return false;
      }
    }

    return parenDepth === 0;
  },

  // Evaluate SUM(range)
  evaluateSUM(rangeStr, context) {
    const { getCellValue, rows, cols } = context;
    const range = parseRange(rangeStr);
    if (!range) return "#REF!";

    // Check if range is within grid bounds
    if (range.endRow >= rows || range.endCol >= cols) return "#REF!";

    let sum = 0;
    for (let r = range.startRow; r <= range.endRow; r++) {
      for (let c = range.startCol; c <= range.endCol; c++) {
        sum += getCellValue(r, c);
      }
    }
    return sum;
  },

  // Evaluate AVG(range)
  evaluateAVG(rangeStr, context) {
    const { getCellValue, data, rows, cols } = context;
    const range = parseRange(rangeStr);
    if (!range) return "#REF!";

    // Check if range is within grid bounds
    if (range.endRow >= rows || range.endCol >= cols) return "#REF!";

    let sum = 0;
    let count = 0;
    for (let r = range.startRow; r <= range.endRow; r++) {
      for (let c = range.startCol; c <= range.endCol; c++) {
        // Need raw data for AVG to skip empty cells properly
        // If we use getCellValue it returns 0 for empty, which might affect avg?
        // script.js checked raw data for null/empty.
        // We need 'data' access in context for this precise logic.
        const raw = data[r][c];

        if (raw === null || raw === undefined) continue;
        const stripped = String(raw)
          .replace(/<[^>]*>/g, "")
          .trim();
        if (stripped === "") continue;
        const normalized = stripped.replace(/,/g, "");
        const num = parseFloat(normalized);
        if (isNaN(num)) continue;
        sum += num;
        count++;
      }
    }
    return count === 0 ? 0 : sum / count;
  },

  // Main formula evaluator
  evaluate(formula, context) {
    if (!formula || !formula.startsWith("=")) return formula;
    if (isVisualFormula(formula)) return formula;

    const expr = formula.substring(1).trim();
    const exprUpper = expr.toUpperCase();

    // Match SUM(range)
    const sumMatch = exprUpper.match(/^SUM\(([A-Z]+\d+:[A-Z]+\d+)\)$/);
    if (sumMatch) {
      return this.evaluateSUM(sumMatch[1], context);
    }

    // Match AVG(range)
    const avgMatch = exprUpper.match(/^AVG\(([A-Z]+\d+:[A-Z]+\d+)\)$/);
    if (avgMatch) {
      return this.evaluateAVG(avgMatch[1], context);
    }

    // Try arithmetic expression
    if (this.isArithmeticFormula(formula)) {
      return this.evaluateArithmetic(expr, context);
    }

    // Unknown formula
    return "#ERROR!";
  },
};

// ==========================================
// Formula Dropdown UI Manager
// ==========================================

export const FormulaDropdownManager = {
  element: null,
  items: [],
  activeIndex: -1,
  anchor: null,
  onSelect: null, // Callback function(formulaName)

  init(onSelectCallback) {
    if (this.element) return;
    this.onSelect = onSelectCallback;

    const dropdown = document.createElement("div");
    dropdown.className = "formula-dropdown";
    dropdown.setAttribute("role", "listbox");
    dropdown.setAttribute("aria-hidden", "true");

    dropdown.addEventListener("mousedown", (event) => {
      event.preventDefault(); // Prevent blur
    });

    dropdown.addEventListener("click", (event) => {
      const item = event.target.closest(".formula-item");
      if (!item) return;
      const formulaName = item.dataset.formula;
      if (formulaName && this.onSelect) {
        this.onSelect(formulaName);
      }
    });

    document.body.appendChild(dropdown);
    this.element = dropdown;
  },

  getFormulaQuery(rawValue) {
    const match = rawValue.match(/^=\s*([A-Z]*)$/i);
    if (!match) return null;
    return match[1].toUpperCase();
  },

  getSuggestions(query) {
    if (query === null) return [];
    if (query === "") return FORMULA_SUGGESTIONS.slice();
    return FORMULA_SUGGESTIONS.filter((item) => item.name.startsWith(query));
  },

  isOpen() {
    return !!(this.element && this.element.classList.contains("open"));
  },

  setActiveItem(index) {
    this.activeIndex = index;
    this.items.forEach((item, idx) => {
      if (idx === index) {
        item.classList.add("active");
        item.setAttribute("aria-selected", "true");
        item.scrollIntoView({ block: "nearest" });
      } else {
        item.classList.remove("active");
        item.setAttribute("aria-selected", "false");
      }
    });
  },

  position(anchor) {
    if (!this.element || !anchor) return;
    const rect = anchor.getBoundingClientRect();
    const viewportWidth = window.innerWidth;
    const viewportHeight = window.innerHeight;
    const padding = 6;

    this.element.style.left = `${rect.left}px`;
    this.element.style.top = `${rect.bottom + 4}px`;

    const dropdownRect = this.element.getBoundingClientRect();
    let left = rect.left;
    let top = rect.bottom + 4;

    if (left + dropdownRect.width > viewportWidth - padding) {
      left = Math.max(padding, viewportWidth - dropdownRect.width - padding);
    }

    if (top + dropdownRect.height > viewportHeight - padding) {
      const above = rect.top - dropdownRect.height - 4;
      if (above > padding) {
        top = above;
      }
    }

    this.element.style.left = `${left}px`;
    this.element.style.top = `${top}px`;
  },

  show() {
    if (!this.element) return;
    this.element.classList.add("open");
    this.element.setAttribute("aria-hidden", "false");
  },

  hide() {
    if (!this.element) return;
    this.element.classList.remove("open");
    this.element.setAttribute("aria-hidden", "true");
    this.items = [];
    this.activeIndex = -1;
    this.anchor = null;
  },

  update(anchor, rawValue) {
    this.init(this.onSelect); // Ensure initialized

    const query = this.getFormulaQuery(rawValue);
    const suggestions = this.getSuggestions(query);

    if (!anchor || suggestions.length === 0 || query === null) {
      this.hide();
      return;
    }

    this.anchor = anchor;
    this.element.innerHTML = "";
    suggestions.forEach((item) => {
      const option = document.createElement("div");
      option.className = "formula-item";
      option.dataset.formula = item.name;
      option.setAttribute("role", "option");
      option.setAttribute("aria-selected", "false");

      const nameEl = document.createElement("div");
      nameEl.className = "formula-name";
      nameEl.textContent = item.name;

      const hintEl = document.createElement("div");
      hintEl.className = "formula-hint";
      hintEl.textContent = `${item.signature} - ${item.description}`;

      option.appendChild(nameEl);
      option.appendChild(hintEl);
      this.element.appendChild(option);
    });

    this.items = Array.from(this.element.querySelectorAll(".formula-item"));
    this.setActiveItem(0);
    this.show();
    this.position(anchor);
  },

  moveSelection(delta) {
    if (!this.items.length) return;
    let nextIndex = this.activeIndex + delta;
    if (nextIndex < 0) nextIndex = this.items.length - 1;
    if (nextIndex >= this.items.length) nextIndex = 0;
    this.setActiveItem(nextIndex);
  },

  // Get currently active item's formula name
  getActiveFormulaName() {
    if (this.activeIndex >= 0 && this.activeIndex < this.items.length) {
      return this.items[this.activeIndex].dataset.formula;
    }
    return null;
  },
};
jsonManager.js
wget 'https://sme10.lists2.roe3.org/spreadsheet/modules/jsonManager.js'
View Content
const defaultCallbacks = {
  buildCurrentState: () => ({}),
  recalculateFormulas: () => {},
  showToast: () => {},
};

const elements = {
  modal: null,
  textarea: null,
  errorEl: null,
  copyBtn: null,
};

function clearJSONError() {
  if (elements.errorEl) {
    elements.errorEl.classList.add("hidden");
    elements.errorEl.textContent = "";
  }
}

export const JSONManager = {
  callbacks: { ...defaultCallbacks },

  init(callbacks = {}) {
    this.callbacks = { ...defaultCallbacks, ...callbacks };
    elements.modal = document.getElementById("json-modal");
    elements.textarea = document.getElementById("json-editor");
    elements.errorEl = document.getElementById("json-error");
    elements.copyBtn = document.getElementById("json-copy-btn");
  },

  openModal() {
    if (!elements.modal || !elements.textarea) return;

    if (this.callbacks.recalculateFormulas) {
      this.callbacks.recalculateFormulas();
    }
    const exportState = this.callbacks.buildCurrentState ? this.callbacks.buildCurrentState() : {};
    elements.textarea.value = JSON.stringify(exportState, null, 2);
    elements.textarea.scrollTop = 0;
    clearJSONError();

    elements.modal.classList.remove("hidden");
    elements.textarea.focus();
  },

  closeModal() {
    if (elements.modal) {
      elements.modal.classList.add("hidden");
    }
    clearJSONError();
  },

  async copyJSONToClipboard() {
    if (!elements.textarea) return;

    try {
      await navigator.clipboard.writeText(elements.textarea.value);
      if (elements.copyBtn) {
        const original = elements.copyBtn.innerHTML;
        elements.copyBtn.innerHTML = '<i class="fa-solid fa-check"></i> Copied!';
        setTimeout(() => {
          if (elements.copyBtn) {
            elements.copyBtn.innerHTML = original;
          }
        }, 1800);
      }
      if (this.callbacks.showToast) {
        this.callbacks.showToast("JSON copied to clipboard", "success");
      }
    } catch (err) {
      elements.textarea.select();
      try {
        document.execCommand("copy");
        if (this.callbacks.showToast) {
          this.callbacks.showToast("JSON copied to clipboard", "success");
        }
      } catch (e) {
        if (this.callbacks.showToast) {
          this.callbacks.showToast("Press Ctrl+C to copy the JSON", "warning");
        }
      }
    }
  },

};
p2pManager.js
wget 'https://sme10.lists2.roe3.org/spreadsheet/modules/p2pManager.js'
View Content
import { showToast } from "./toastManager.js";

// Default fallback ICE servers (STUN only)
const FALLBACK_ICE_SERVERS = {
  iceServers: [
    { urls: 'stun:stun.l.google.com:19302' },
    { urls: 'stun:stun1.l.google.com:19302' }
  ]
};

// Cached ICE configuration
let cachedIceConfig = null;
let cacheTimestamp = 0;
const CACHE_DURATION = 3600000; // 1 hour in milliseconds

// Fetch TURN credentials from Netlify Function (keeps API key secure)
async function fetchIceServers() {
  const now = Date.now();

  // Return cached config if still valid
  if (cachedIceConfig && (now - cacheTimestamp) < CACHE_DURATION) {
    return cachedIceConfig;
  }

  try {
    const response = await fetch('/api/turn-credentials');

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }

    const data = await response.json();

    if (data.fallback) {
      console.warn('Using fallback STUN-only configuration');
    }

    cachedIceConfig = { iceServers: data.iceServers };
    cacheTimestamp = now;

    return cachedIceConfig;
  } catch (error) {
    console.error('Failed to fetch ICE servers:', error);
    showToast('Using fallback connection (may have limited connectivity)', 'warning');
    return FALLBACK_ICE_SERVERS;
  }
}

const defaultCallbacks = {
  onHostReady: () => {},
  onConnectionOpened: () => {},
  onInitialSync: () => {},
  onRemoteCellUpdate: () => {},
  onRemoteCursorMove: () => {},
  onSyncRequest: () => {},
  onConnectionClosed: () => {},
  onConnectionError: () => {},
};

function getPeerConstructor() {
  if (typeof window === "undefined") return null;
  return window.Peer || null;
}

export const P2PManager = {
  peer: null,
  conn: null,
  isHost: false,
  myPeerId: null,
  callbacks: { ...defaultCallbacks },

  init(callbacks = {}) {
    this.callbacks = { ...defaultCallbacks, ...callbacks };
  },

  canSend() {
    return !!(this.conn && this.conn.open);
  },

  async startHosting() {
    const PeerCtor = getPeerConstructor();
    if (!PeerCtor) {
      showToast("PeerJS not available. Check network or CSP.", "error");
      return false;
    }

    this.disconnect({ silent: true });
    this.isHost = true;

    // Fetch ICE servers dynamically
    const iceConfig = await fetchIceServers();
    this.peer = new PeerCtor({ config: iceConfig });

    this.peer.on("open", (id) => {
      this.myPeerId = id;
      this.callbacks.onHostReady(id);
    });

    this.peer.on("connection", (conn) => {
      if (this.conn && this.conn.open) {
        showToast("Already connected to a peer", "warning");
        conn.close();
        return;
      }
      this.handleConnection(conn);
    });

    this.peer.on("error", (err) => {
      console.error(err);
      showToast(`P2P Error: ${err.type || "unknown"}`, "error");
      this.callbacks.onConnectionError(err);
    });

    this.peer.on("disconnected", () => {
      showToast("Disconnected from signaling server", "warning");
    });

    return true;
  },

  async joinSession(hostId) {
    const PeerCtor = getPeerConstructor();
    if (!PeerCtor) {
      showToast("PeerJS not available. Check network or CSP.", "error");
      return false;
    }

    if (!hostId) {
      showToast("Host ID required", "warning");
      return false;
    }

    this.disconnect({ silent: true });
    this.isHost = false;

    // Fetch ICE servers dynamically
    const iceConfig = await fetchIceServers();
    this.peer = new PeerCtor({ config: iceConfig });

    this.peer.on("open", () => {
      const conn = this.peer.connect(hostId);
      this.handleConnection(conn);
    });

    this.peer.on("error", (err) => {
      console.error(err);
      const msg = err && err.type === "peer-unavailable" ? "Host not found. Check the ID." : "Could not connect to host.";
      showToast(msg, "error");
      this.callbacks.onConnectionError(err);
    });

    return true;
  },

  handleConnection(conn) {
    this.conn = conn;

    conn.on("open", () => {
      this.callbacks.onConnectionOpened(this.isHost);
    });

    conn.on("data", (payload) => {
      this.handleIncomingData(payload);
    });

    conn.on("close", () => {
      this.conn = null;
      this.callbacks.onConnectionClosed();
    });

    conn.on("error", (err) => {
      console.error(err);
      showToast("Connection error", "error");
      this.callbacks.onConnectionError(err);
    });
  },

  handleIncomingData(payload) {
    if (!payload || typeof payload !== "object") return;
    const type = payload.type;
    if (!type) return;

    switch (type) {
      case "INITIAL_SYNC":
      case "FULL_SYNC":
        this.callbacks.onInitialSync(payload.data, type);
        break;
      case "UPDATE_CELL":
        this.callbacks.onRemoteCellUpdate(payload.data || {});
        break;
      case "UPDATE_CURSOR":
        this.callbacks.onRemoteCursorMove(payload.data || {});
        break;
      case "SYNC_REQUEST":
        this.callbacks.onSyncRequest();
        break;
      default:
        console.warn("Unknown P2P message:", type);
    }
  },

  sendPayload(payload) {
    if (!this.canSend()) return false;
    try {
      this.conn.send(payload);
      return true;
    } catch (err) {
      console.error(err);
      showToast("Failed to send update", "error");
      return false;
    }
  },

  sendInitialSync(fullState) {
    return this.sendPayload({ type: "INITIAL_SYNC", data: fullState });
  },

  sendFullSync(fullState) {
    return this.sendPayload({ type: "FULL_SYNC", data: fullState });
  },

  requestFullSync() {
    return this.sendPayload({ type: "SYNC_REQUEST" });
  },

  broadcastCellUpdate(row, col, value, formula) {
    return this.sendPayload({
      type: "UPDATE_CELL",
      data: { row, col, value, formula },
    });
  },

  broadcastCursor(row, col, color) {
    return this.sendPayload({
      type: "UPDATE_CURSOR",
      data: { row, col, color },
    });
  },

  disconnect({ silent = false } = {}) {
    if (this.conn) {
      try {
        this.conn.close();
      } catch (e) {}
    }
    if (this.peer) {
      try {
        this.peer.destroy();
      } catch (e) {}
    }

    this.peer = null;
    this.conn = null;
    this.myPeerId = null;
    this.isHost = false;

    if (!silent) {
      this.callbacks.onConnectionClosed();
    }
  },
};
passwordManager.js
wget 'https://sme10.lists2.roe3.org/spreadsheet/modules/passwordManager.js'
View Content
/**
 * Password Manager Module
 * Handles all password-related state, UI, and interactions.
 */
export const PasswordManager = {
  // State
  currentPassword: null,
  pendingEncryptedData: null,
  modalMode: "set",

  // Callbacks provided by main script
  callbacks: {
    decryptAndDecode: async () => null,
    onDecryptSuccess: () => {},
    updateURL: () => {},
    showToast: () => {},
    validateState: () => {},
  },

  /**
   * Initialize the password manager
   * @param {Object} callbacks - Functions required for integration
   */
  init(callbacks) {
    this.callbacks = { ...this.callbacks, ...callbacks };
    this.attachEventListeners();
    this.updateLockButtonUI();
  },

  /**
   * Get the current password
   * @returns {string|null}
   */
  getPassword() {
    return this.currentPassword;
  },

  /**
   * Set the current password (e.g. after successful decryption or setting new)
   * @param {string|null} password
   */
  setPassword(password) {
    this.currentPassword = password;
    this.updateLockButtonUI();
  },

  /**
   * Handle encrypted data found during load
   * @param {string} encryptedData
   */
  handleEncryptedData(encryptedData) {
    this.pendingEncryptedData = encryptedData;
    // Show password modal after a short delay to let UI render
    setTimeout(() => this.showPasswordModal("decrypt"), 100);
  },

  // ========== UI Functions ==========

  showPasswordModal(mode) {
    this.modalMode = mode;
    const modal = document.getElementById("password-modal");
    const title = document.getElementById("modal-title");
    const description = document.getElementById("modal-description");
    const confirmInput = document.getElementById("password-confirm");
    const submitBtn = document.getElementById("modal-submit");
    const passwordInput = document.getElementById("password-input");
    const errorEl = document.getElementById("modal-error");

    if (!modal) return;

    // Reset form
    if (passwordInput) passwordInput.value = "";
    if (confirmInput) confirmInput.value = "";
    if (errorEl) {
      errorEl.classList.add("hidden");
      errorEl.textContent = "";
    }

    if (mode === "set") {
      if (title) title.textContent = "Set Password";
      if (description) description.textContent = "Enter a password to encrypt this spreadsheet. Anyone with the link will need this password to view it.";
      if (confirmInput) {
        confirmInput.style.display = "";
        confirmInput.placeholder = "Confirm password";
      }
      if (submitBtn) submitBtn.textContent = "Set Password";
    } else if (mode === "decrypt") {
      if (title) title.textContent = "Enter Password";
      if (description) description.textContent = "This spreadsheet is password-protected. Enter the password to view it.";
      if (confirmInput) confirmInput.style.display = "none";
      if (submitBtn) submitBtn.textContent = "Unlock";
    } else if (mode === "remove") {
      if (title) title.textContent = "Remove Password";
      if (description) description.textContent = "Enter the current password to remove encryption from this spreadsheet.";
      if (confirmInput) confirmInput.style.display = "none";
      if (submitBtn) submitBtn.textContent = "Remove Password";
    }

    modal.classList.remove("hidden");
    if (passwordInput) passwordInput.focus();
  },

  hidePasswordModal() {
    const modal = document.getElementById("password-modal");
    if (modal) modal.classList.add("hidden");
    // Clear pending data if we cancelled decryption (though usually we'd want to keep it if they just closed modal?)
    // Original logic cleared it:
    // this.pendingEncryptedData = null;
    // But if we cancel decryption, we probably can't view the file anyway.
  },

  showModalError(message) {
    const errorEl = document.getElementById("modal-error");
    if (errorEl) {
      errorEl.textContent = message;
      errorEl.classList.remove("hidden");
    }
  },

  updateLockButtonUI() {
    const lockBtn = document.getElementById("lock-btn");
    if (!lockBtn) return;

    const icon = lockBtn.querySelector("i");
    if (this.currentPassword) {
      lockBtn.classList.add("locked");
      lockBtn.title = "Remove Password Protection";
      if (icon) icon.className = "fa-solid fa-lock";
    } else {
      lockBtn.classList.remove("locked");
      lockBtn.title = "Password Protection";
      if (icon) icon.className = "fa-solid fa-lock-open";
    }
  },

  handleLockButtonClick() {
    if (this.currentPassword) {
      // Already encrypted - offer to remove password
      this.showPasswordModal("remove");
    } else {
      // Not encrypted - set password
      this.showPasswordModal("set");
    }
  },

  async handleModalSubmit() {
    const passwordInput = document.getElementById("password-input");
    const confirmInput = document.getElementById("password-confirm");

    if (!passwordInput) return;
    const password = passwordInput.value;

    if (!password) {
      this.showModalError("Please enter a password.");
      return;
    }

    if (this.modalMode === "set") {
      // Setting new password
      const confirm = confirmInput ? confirmInput.value : "";
      if (password !== confirm) {
        this.showModalError("Passwords do not match.");
        return;
      }
      if (password.length < 4) {
        this.showModalError("Password must be at least 4 characters.");
        return;
      }

      this.currentPassword = password;
      this.updateLockButtonUI();
      this.hidePasswordModal();
      // Re-encode state with encryption
      this.callbacks.updateURL();
      this.callbacks.showToast("Password protection enabled", "success");
    } else if (this.modalMode === "decrypt") {
      // Decrypting loaded data
      if (!this.pendingEncryptedData) {
        this.showModalError("No encrypted data to decrypt.");
        return;
      }

      try {
        const rawState = await this.callbacks.decryptAndDecode(this.pendingEncryptedData, password);
        if (!rawState) {
          throw new Error("Invalid decrypted data");
        }

        // Validate state
        const validatedState = this.callbacks.validateState(rawState);
        if (!validatedState) {
          throw new Error("Validation failed");
        }

        // Success
        this.currentPassword = password;
        this.pendingEncryptedData = null; // Clear it
        this.callbacks.onDecryptSuccess(validatedState);

        this.hidePasswordModal();
        this.updateLockButtonUI();
        this.callbacks.showToast("Spreadsheet unlocked", "success");
      } catch (e) {
        console.error("Decryption failed:", e);
        this.showModalError("Incorrect password.");
      }
    } else if (this.modalMode === "remove") {
      // For now, we trust they have the password if they are here (or we could verify it matches current)
      // Note: Original code didn't strictly verify against currentPassword (it just checked if you entered *something*?)
      // Wait, original: `if (modalMode === "remove") { ... currentPassword = null ... }`
      // It didn't verify the typed password matches the active `currentPassword`.
      // It prompted "Enter the current password", but didn't actually check it against `currentPassword`.
      // The assumption is if you are viewing the file, you know the password (or it was just unlocked).
      // However, for better security UX, we might want to check, but let's stick to original behavior to minimize regression risk.
      // But wait, if I type "wrong" password to remove, it shouldn't work?
      // Actually, since `currentPassword` is in memory, we CAN check it.
      // If `currentPassword` is set, we SHOULD check.

      if (this.currentPassword && password !== this.currentPassword) {
        this.showModalError("Incorrect password.");
        return;
      }

      this.currentPassword = null;
      this.updateLockButtonUI();
      this.hidePasswordModal();
      // Re-encode state without encryption
      this.callbacks.updateURL();
      this.callbacks.showToast("Password protection removed", "success");
    }
  },

  attachEventListeners() {
    const lockBtn = document.getElementById("lock-btn");
    const modalCancel = document.getElementById("modal-cancel");
    const modalSubmit = document.getElementById("modal-submit");
    const modalBackdrop = document.querySelector(".modal-backdrop");
    const passwordInput = document.getElementById("password-input");
    const passwordConfirm = document.getElementById("password-confirm");

    if (lockBtn) {
      // Use logical wrapper
      lockBtn.addEventListener("click", () => this.handleLockButtonClick());
    }
    if (modalCancel) {
      modalCancel.addEventListener("click", () => this.hidePasswordModal());
    }
    if (modalSubmit) {
      modalSubmit.addEventListener("click", () => this.handleModalSubmit());
    }
    if (modalBackdrop) {
      modalBackdrop.addEventListener("click", () => {
        // Only allow closing if not in decrypt mode
        if (this.modalMode !== "decrypt") {
          this.hidePasswordModal();
        }
      });
    }

    if (passwordInput) {
      passwordInput.addEventListener("keydown", (e) => {
        if (e.key === "Enter") {
          e.preventDefault();
          // If confirm is visible and empty, focus it; otherwise submit
          if (passwordConfirm && passwordConfirm.style.display !== "none" && !passwordConfirm.value) {
            passwordConfirm.focus();
          } else {
            this.handleModalSubmit();
          }
        }
      });
    }

    if (passwordConfirm) {
      passwordConfirm.addEventListener("keydown", (e) => {
        if (e.key === "Enter") {
          e.preventDefault();
          this.handleModalSubmit();
        }
      });
    }
  },
};
presentationManager.js
wget 'https://sme10.lists2.roe3.org/spreadsheet/modules/presentationManager.js'
View Content
import { getState } from "./rowColManager.js";
import { sanitizeHTML } from "./security.js";
import { showToast } from "./toastManager.js";
import { VisualFunctions } from "./visualFunctions.js";

function extractPlainText(value) {
  if (value === null || value === undefined) return "";
  const parser = new DOMParser();
  const doc = parser.parseFromString("<body>" + String(value) + "</body>", "text/html");
  const text = doc.body.textContent || "";
  return text.replace(/\u00a0/g, " ");
}

export const PresentationManager = {
  overlay: null,
  isActive: false,
  slideCount: 0,
  slides: [],
  currentIndex: 0,
  lastFocus: null,
  _initialized: false,
  _scrollHandler: null,
  _resizeHandler: null,
  _controlsEl: null,
  _scrollRaf: null,

  init() {
    if (this._initialized) return;
    this._initialized = true;

    document.addEventListener("keydown", (event) => {
      if (!this.isActive) return;

      if (event.key === "Escape") {
        event.preventDefault();
        this.stop();
        return;
      }

      if (this._handleNavigationKey(event)) {
        return;
      }
    });
  },

  start(data, formulas, context = {}) {
    if (this.isActive) return;
    if (this.overlay) {
      this.stop();
    }

    const { rows, cols } = getState();
    const rowCount = Number.isFinite(rows) && rows > 0 ? rows : 0;
    const colCount = Number.isFinite(cols) && cols > 0 ? cols : 0;
    const safeData = Array.isArray(data) ? data : [];
    const safeFormulas = Array.isArray(formulas) ? formulas : [];

    const overlay = document.createElement("div");
    overlay.className = "presentation-overlay";
    overlay.setAttribute("tabindex", "0");
    overlay.setAttribute("role", "dialog");
    overlay.setAttribute("aria-modal", "true");
    overlay.setAttribute("aria-label", "Presentation mode");

    const slides = [];
    let slideCount = 0;

    for (let r = 0; r < rowCount; r++) {
      const rowData = Array.isArray(safeData[r]) ? safeData[r] : [];
      const rowFormulas = Array.isArray(safeFormulas[r]) ? safeFormulas[r] : [];

      if (!this._rowHasContent(rowData, rowFormulas, colCount)) {
        continue;
      }

      const slide = document.createElement("section");
      slide.className = "slide";
      slide.setAttribute("data-slide-index", String(slideCount));

      const contentDiv = document.createElement("div");
      contentDiv.className = "slide-content";

      let titleFound = false;

      for (let c = 0; c < colCount; c++) {
        const rawValue = rowData[c];
        const formula = rowFormulas[c];
        const hasFormula = typeof formula === "string" && formula.trim().startsWith("=");
        const hasValue = this._cellHasText(rawValue);

        if (!hasFormula && !hasValue) continue;

        const cellWrapper = document.createElement("div");
        let isVisual = false;

        if (hasFormula) {
          try {
            const visualEl = VisualFunctions.process(String(formula), context);
            if (visualEl) {
              cellWrapper.appendChild(visualEl);
              cellWrapper.classList.add("slide-visual");
              isVisual = true;
            }
          } catch (err) {
            console.warn("Presentation visual error:", err);
          }
        }

        if (!isVisual) {
          const displayValue = this._getDisplayValue(rawValue, formula);
          cellWrapper.innerHTML = sanitizeHTML(displayValue);
        }

        if (!titleFound) {
          cellWrapper.classList.add("slide-title");
          titleFound = true;
        } else {
          cellWrapper.classList.add("slide-text");
        }

        contentDiv.appendChild(cellWrapper);
      }

      slide.appendChild(contentDiv);
      overlay.appendChild(slide);
      slides.push(slide);
      slideCount++;
    }

    if (slideCount === 0) {
      showToast("Spreadsheet is empty. Add data to present.", "warning");
      return;
    }

    const controls = document.createElement("div");
    controls.className = "presentation-controls";
    controls.innerHTML = `
      <span class="slide-counter">1 / ${slideCount}</span>
      <button id="exit-pres-btn" type="button"><i class="fa-solid fa-xmark"></i> Exit</button>
    `;
    overlay.appendChild(controls);

    document.body.appendChild(overlay);
    document.body.classList.add("presentation-mode-active");

    this.lastFocus = document.activeElement;
    overlay.focus();

    this.overlay = overlay;
    this.isActive = true;
    this.slides = slides;
    this.slideCount = slideCount;
    this._controlsEl = controls;
    this.currentIndex = 0;

    const exitBtn = overlay.querySelector("#exit-pres-btn");
    if (exitBtn) {
      exitBtn.addEventListener("click", () => this.stop());
    }

    this._scrollHandler = () => {
      if (this._scrollRaf) return;
      this._scrollRaf = requestAnimationFrame(() => {
        this._scrollRaf = null;
        this._updateCounter();
      });
    };

    this._resizeHandler = () => this._updateCounter();
    overlay.addEventListener("scroll", this._scrollHandler);
    window.addEventListener("resize", this._resizeHandler);
    this._updateCounter();
  },

  stop() {
    if (!this.overlay) return;

    if (this._scrollHandler) {
      this.overlay.removeEventListener("scroll", this._scrollHandler);
    }
    if (this._resizeHandler) {
      window.removeEventListener("resize", this._resizeHandler);
    }
    if (this._scrollRaf) {
      cancelAnimationFrame(this._scrollRaf);
      this._scrollRaf = null;
    }

    this.overlay.remove();
    this.overlay = null;
    this.isActive = false;
    this.slideCount = 0;
    this.slides = [];
    this.currentIndex = 0;
    this._controlsEl = null;
    document.body.classList.remove("presentation-mode-active");

    if (this.lastFocus && typeof this.lastFocus.focus === "function" && document.contains(this.lastFocus)) {
      this.lastFocus.focus();
    }
    this.lastFocus = null;
  },

  _updateCounter() {
    if (!this.overlay || !this._controlsEl || !this.slideCount) return;
    const slideHeight = this.overlay.clientHeight || window.innerHeight || 1;
    const index = clampIndex(Math.round(this.overlay.scrollTop / slideHeight), this.slideCount);
    this.currentIndex = index;

    const counter = this._controlsEl.querySelector(".slide-counter");
    if (counter) {
      counter.textContent = `${index + 1} / ${this.slideCount}`;
    }
  },

  _scrollToSlide(index) {
    if (!this.overlay || !this.slideCount) return;
    const target = clampIndex(index, this.slideCount);
    const slideHeight = this.overlay.clientHeight || window.innerHeight || 1;
    this.overlay.scrollTo({ top: target * slideHeight, behavior: "smooth" });
  },

  _handleNavigationKey(event) {
    if (!this.overlay || !this.slideCount) return false;

    let nextIndex = null;
    if (event.key === "ArrowDown" || event.key === "PageDown" || event.key === " ") {
      nextIndex = this.currentIndex + 1;
    } else if (event.key === "ArrowUp" || event.key === "PageUp") {
      nextIndex = this.currentIndex - 1;
    } else if (event.key === "Home") {
      nextIndex = 0;
    } else if (event.key === "End") {
      nextIndex = this.slideCount - 1;
    }

    if (nextIndex === null) return false;

    event.preventDefault();
    this._scrollToSlide(nextIndex);
    return true;
  },

  _rowHasContent(rowData, rowFormulas, colCount) {
    for (let c = 0; c < colCount; c++) {
      const formula = rowFormulas && rowFormulas[c];
      if (typeof formula === "string" && formula.trim() !== "") return true;
      if (this._cellHasText(rowData && rowData[c])) return true;
    }
    return false;
  },

  _cellHasText(value) {
    const text = extractPlainText(value);
    return text.trim() !== "";
  },

  _getDisplayValue(rawValue, formula) {
    const rawText = rawValue === null || rawValue === undefined ? "" : String(rawValue);
    if (rawText.trim() !== "") return rawText;
    if (typeof formula === "string" && formula.trim() !== "") return String(formula);
    return "";
  },
};

function clampIndex(index, count) {
  if (count <= 0) return 0;
  if (index < 0) return 0;
  if (index >= count) return count - 1;
  return index;
}
rowColManager.js
wget 'https://sme10.lists2.roe3.org/spreadsheet/modules/rowColManager.js'
View Content
// Row and Column Manager Module
// Handles grid rendering, selection, resizing, and row/column operations

import {
  ACTIVE_HEADER_CLASS,
  DEFAULT_COL_WIDTH,
  DEFAULT_COLS,
  DEFAULT_ROW_HEIGHT,
  DEFAULT_ROWS,
  HEADER_ROW_HEIGHT,
  MAX_COLS,
  MAX_ROWS,
  MIN_COL_WIDTH,
  MIN_ROW_HEIGHT,
  ROW_HEADER_WIDTH,
} from "./constants.js";

import { colToLetter } from "./formulaManager.js";
import { sanitizeHTML } from "./security.js";
import { showToast } from "./toastManager.js";
import { createDefaultColumnWidths, createDefaultRowHeights, createEmptyCellStyle, createEmptyCellStyles, createEmptyData } from "./urlManager.js";

// ========== State ==========
const state = {
  rows: DEFAULT_ROWS,
  cols: DEFAULT_COLS,
  colWidths: createDefaultColumnWidths(DEFAULT_COLS),
  rowHeights: createDefaultRowHeights(DEFAULT_ROWS),
  selectionStart: null,
  selectionEnd: null,
  isSelecting: false,
  hoverRow: null,
  hoverCol: null,
  activeRow: null,
  activeCol: null,
  resizeState: null,
};

// ========== Callbacks ==========
let callbacks = {
  debouncedUpdateURL: null,
  recalculateFormulas: null,
  getDataArray: null,
  setDataArray: null,
  getFormulasArray: null,
  setFormulasArray: null,
  getCellStylesArray: null,
  setCellStylesArray: null,
  PasswordManager: null,
  getReadOnlyFlag: null,
  // Formula mode callbacks
  getFormulaEditMode: null,
  getFormulaEditCell: null,
  setFormulaRangeStart: null,
  setFormulaRangeEnd: null,
  getFormulaRangeStart: null,
  getFormulaRangeEnd: null,
  buildRangeRef: null,
  insertTextAtCursor: null,
  FormulaDropdownManager: null,
  onSelectionChange: null,
  onGridResize: null,
  onFormulaChange: null,
};

// ========== State Accessors ==========
export function getState() {
  return state;
}

export function setState(key, value) {
  if (key in state) {
    state[key] = value;
  }
}

export function setCallbacks(cbs) {
  callbacks = { ...callbacks, ...cbs };
}

// ========== UI Update ==========
export function updateUI() {
  const addRowBtn = document.getElementById("add-row");
  const addColBtn = document.getElementById("add-col");
  const gridSizeEl = document.getElementById("grid-size");

  if (addRowBtn) {
    addRowBtn.disabled = state.rows >= MAX_ROWS;
  }
  if (addColBtn) {
    addColBtn.disabled = state.cols >= MAX_COLS;
  }
  if (gridSizeEl) {
    gridSizeEl.textContent = `${state.rows} × ${state.cols}`;
  }
}

// ========== Grid Template ==========
export function applyGridTemplate() {
  const container = document.getElementById("spreadsheet");
  if (!container) return;

  const columnSizes = state.colWidths.map((width) => `${width}px`).join(" ");
  const rowSizes = state.rowHeights.map((height) => `${height}px`).join(" ");

  container.style.gridTemplateColumns = `${ROW_HEADER_WIDTH}px ${columnSizes}`;
  container.style.gridTemplateRows = `${HEADER_ROW_HEIGHT}px ${rowSizes}`;
}

// ========== Render Grid ==========
export function renderGrid() {
  const container = document.getElementById("spreadsheet");
  if (!container) return;

  const isReadOnly = callbacks.getReadOnlyFlag ? callbacks.getReadOnlyFlag() : false;

  const data = callbacks.getDataArray ? callbacks.getDataArray() : [];
  const cellStyles = callbacks.getCellStylesArray ? callbacks.getCellStylesArray() : [];

  // Clear selection when grid is re-rendered
  state.selectionStart = null;
  state.selectionEnd = null;
  state.isSelecting = false;
  state.hoverRow = null;
  state.hoverCol = null;
  if (callbacks.onSelectionChange) {
    callbacks.onSelectionChange(null);
  }

  container.innerHTML = "";
  applyGridTemplate();

  // Corner cell (empty)
  const corner = document.createElement("div");
  corner.className = "corner-cell";
  container.appendChild(corner);

  // Column headers (A-Z) - sticky to top
  for (let col = 0; col < state.cols; col++) {
    const header = document.createElement("div");
    header.className = "header-cell col-header";
    header.textContent = colToLetter(col);
    header.dataset.col = col;
    const colResize = document.createElement("div");
    colResize.className = "resize-handle col-resize";
    colResize.dataset.col = col;
    colResize.setAttribute("aria-hidden", "true");
    header.appendChild(colResize);
    container.appendChild(header);
  }

  // Rows
  for (let row = 0; row < state.rows; row++) {
    // Row header (1, 2, 3...) - sticky to left
    const rowHeader = document.createElement("div");
    rowHeader.className = "header-cell row-header";
    rowHeader.textContent = row + 1;
    rowHeader.dataset.row = row;
    const rowResize = document.createElement("div");
    rowResize.className = "resize-handle row-resize";
    rowResize.dataset.row = row;
    rowResize.setAttribute("aria-hidden", "true");
    rowHeader.appendChild(rowResize);
    container.appendChild(rowHeader);

    // Data cells
    for (let col = 0; col < state.cols; col++) {
      const cell = document.createElement("div");
      cell.className = "cell";

      const contentDiv = document.createElement("div");
      contentDiv.className = "cell-content";
      contentDiv.contentEditable = isReadOnly ? "false" : "true";
      contentDiv.dataset.row = row;
      contentDiv.dataset.col = col;
      contentDiv.innerHTML = sanitizeHTML(data[row] ? data[row][col] || "" : "");
      contentDiv.setAttribute("aria-label", `Cell ${colToLetter(col)}${row + 1}`);

      const style = cellStyles[row] ? cellStyles[row][col] : null;
      if (style) {
        contentDiv.style.textAlign = style.align || "";
        contentDiv.style.color = style.color || "";
        contentDiv.style.fontSize = style.fontSize ? `${style.fontSize}px` : "";
        if (style.bg) {
          cell.style.setProperty("--cell-bg", style.bg);
        } else {
          cell.style.removeProperty("--cell-bg");
        }
      } else {
        contentDiv.style.textAlign = "";
        contentDiv.style.color = "";
        contentDiv.style.fontSize = "";
        cell.style.removeProperty("--cell-bg");
      }

      cell.appendChild(contentDiv);
      container.appendChild(cell);
    }
  }

  updateUI();
}

// ========== Header Highlighting ==========
export function clearActiveHeaders() {
  if (state.activeRow !== null) {
    const rowHeader = document.querySelector(`.row-header[data-row="${state.activeRow}"]`);
    if (rowHeader) rowHeader.classList.remove(ACTIVE_HEADER_CLASS);
  }
  if (state.activeCol !== null) {
    const colHeader = document.querySelector(`.col-header[data-col="${state.activeCol}"]`);
    if (colHeader) colHeader.classList.remove(ACTIVE_HEADER_CLASS);
  }
  state.activeRow = null;
  state.activeCol = null;
}

export function setActiveHeaders(row, col) {
  if (state.activeRow === row && state.activeCol === col) return;
  clearActiveHeaders();
  state.activeRow = row;
  state.activeCol = col;

  const rowHeader = document.querySelector(`.row-header[data-row="${row}"]`);
  if (rowHeader) rowHeader.classList.add(ACTIVE_HEADER_CLASS);

  const colHeader = document.querySelector(`.col-header[data-col="${col}"]`);
  if (colHeader) colHeader.classList.add(ACTIVE_HEADER_CLASS);
}

export function setActiveHeadersForRange(minRow, maxRow, minCol, maxCol) {
  // Clear existing header highlights
  document.querySelectorAll(`.${ACTIVE_HEADER_CLASS}`).forEach((el) => {
    el.classList.remove(ACTIVE_HEADER_CLASS);
  });

  // Highlight all row headers in range
  for (let r = minRow; r <= maxRow; r++) {
    const rowHeader = document.querySelector(`.row-header[data-row="${r}"]`);
    if (rowHeader) rowHeader.classList.add(ACTIVE_HEADER_CLASS);
  }

  // Highlight all column headers in range
  for (let c = minCol; c <= maxCol; c++) {
    const colHeader = document.querySelector(`.col-header[data-col="${c}"]`);
    if (colHeader) colHeader.classList.add(ACTIVE_HEADER_CLASS);
  }

  // Update active row/col tracking
  state.activeRow = minRow;
  state.activeCol = minCol;
}

// ========== Selection Functions ==========
export function getSelectionBounds() {
  if (!state.selectionStart || !state.selectionEnd) return null;
  return {
    minRow: Math.min(state.selectionStart.row, state.selectionEnd.row),
    maxRow: Math.max(state.selectionStart.row, state.selectionEnd.row),
    minCol: Math.min(state.selectionStart.col, state.selectionEnd.col),
    maxCol: Math.max(state.selectionStart.col, state.selectionEnd.col),
  };
}

export function hasMultiSelection() {
  if (!state.selectionStart || !state.selectionEnd) return false;
  return state.selectionStart.row !== state.selectionEnd.row || state.selectionStart.col !== state.selectionEnd.col;
}

export function clearSelection() {
  state.selectionStart = null;
  state.selectionEnd = null;
  state.isSelecting = false;

  const container = document.getElementById("spreadsheet");
  if (!container) return;

  // Remove selection classes from all cells
  container.querySelectorAll(".cell-selected").forEach((cell) => {
    cell.classList.remove("cell-selected", "selection-top", "selection-bottom", "selection-left", "selection-right");
  });

  // Remove selecting mode from container
  container.classList.remove("selecting");

  if (callbacks.onSelectionChange) {
    callbacks.onSelectionChange(null);
  }
}

export function updateSelectionVisuals() {
  const container = document.getElementById("spreadsheet");
  if (!container) return;

  const bounds = getSelectionBounds();
  if (!bounds) {
    clearSelection();
    return;
  }

  // Clear previous selection classes
  container.querySelectorAll(".cell-selected").forEach((cell) => {
    cell.classList.remove("cell-selected", "selection-top", "selection-bottom", "selection-left", "selection-right");
  });

  const { minRow, maxRow, minCol, maxCol } = bounds;

  // Apply selection classes to cells in range
  for (let r = minRow; r <= maxRow; r++) {
    for (let c = minCol; c <= maxCol; c++) {
      const cellContent = container.querySelector(`.cell-content[data-row="${r}"][data-col="${c}"]`);
      if (cellContent && cellContent.parentElement) {
        const cell = cellContent.parentElement;
        cell.classList.add("cell-selected");

        // Add border classes for outer edges
        if (r === minRow) cell.classList.add("selection-top");
        if (r === maxRow) cell.classList.add("selection-bottom");
        if (c === minCol) cell.classList.add("selection-left");
        if (c === maxCol) cell.classList.add("selection-right");
      }
    }
  }

  // Highlight headers for the entire range
  setActiveHeadersForRange(minRow, maxRow, minCol, maxCol);

  if (callbacks.onSelectionChange) {
    callbacks.onSelectionChange({ ...bounds });
  }
}

export function clearSelectedCells() {
  if (!state.selectionStart || !state.selectionEnd) return;

  const bounds = getSelectionBounds();
  const container = document.getElementById("spreadsheet");
  const data = callbacks.getDataArray ? callbacks.getDataArray() : [];
  const formulas = callbacks.getFormulasArray ? callbacks.getFormulasArray() : [];

  for (let r = bounds.minRow; r <= bounds.maxRow; r++) {
    for (let c = bounds.minCol; c <= bounds.maxCol; c++) {
      // Clear data and formula
      if (data[r]) data[r][c] = "";
      if (formulas[r]) formulas[r][c] = "";

      // Update DOM
      const cell = container.querySelector(`.cell-content[data-row="${r}"][data-col="${c}"]`);
      if (cell) {
        cell.innerHTML = "";
      }
    }
  }

  if (callbacks.recalculateFormulas) callbacks.recalculateFormulas();
  if (callbacks.debouncedUpdateURL) callbacks.debouncedUpdateURL();
  if (callbacks.onSelectionChange) {
    callbacks.onSelectionChange(getSelectionBounds());
  }
}

// ========== Hover Functions ==========
export function getCellContentFromTarget(target) {
  if (!(target instanceof Element)) return null;

  if (target.classList.contains("cell-content")) {
    return target;
  }
  if (target.classList.contains("cell")) {
    return target.querySelector(".cell-content");
  }
  return target.closest(".cell-content");
}

export function addHoverRow(row) {
  const container = document.getElementById("spreadsheet");
  if (!container) return;

  container.querySelectorAll(`.cell-content[data-row="${row}"]`).forEach((cellContent) => {
    if (cellContent.parentElement) {
      cellContent.parentElement.classList.add("hover-row");
    }
  });

  const rowHeader = container.querySelector(`.row-header[data-row="${row}"]`);
  if (rowHeader) {
    rowHeader.classList.add("header-hover");
  }
}

export function addHoverCol(col) {
  const container = document.getElementById("spreadsheet");
  if (!container) return;

  container.querySelectorAll(`.cell-content[data-col="${col}"]`).forEach((cellContent) => {
    if (cellContent.parentElement) {
      cellContent.parentElement.classList.add("hover-col");
    }
  });

  const colHeader = container.querySelector(`.col-header[data-col="${col}"]`);
  if (colHeader) {
    colHeader.classList.add("header-hover");
  }
}

export function removeHoverRow(row) {
  const container = document.getElementById("spreadsheet");
  if (!container) return;

  container.querySelectorAll(`.cell-content[data-row="${row}"]`).forEach((cellContent) => {
    if (cellContent.parentElement) {
      cellContent.parentElement.classList.remove("hover-row");
    }
  });

  const rowHeader = container.querySelector(`.row-header[data-row="${row}"]`);
  if (rowHeader) {
    rowHeader.classList.remove("header-hover");
  }
}

export function removeHoverCol(col) {
  const container = document.getElementById("spreadsheet");
  if (!container) return;

  container.querySelectorAll(`.cell-content[data-col="${col}"]`).forEach((cellContent) => {
    if (cellContent.parentElement) {
      cellContent.parentElement.classList.remove("hover-col");
    }
  });

  const colHeader = container.querySelector(`.col-header[data-col="${col}"]`);
  if (colHeader) {
    colHeader.classList.remove("header-hover");
  }
}

export function clearHoverHighlights() {
  if (state.hoverRow !== null) {
    removeHoverRow(state.hoverRow);
  }
  if (state.hoverCol !== null) {
    removeHoverCol(state.hoverCol);
  }
  state.hoverRow = null;
  state.hoverCol = null;
}

export function setHoverHighlight(row, col) {
  if (row === state.hoverRow && col === state.hoverCol) return;

  if (state.hoverRow !== null && state.hoverRow !== row) {
    removeHoverRow(state.hoverRow);
  }
  if (state.hoverCol !== null && state.hoverCol !== col) {
    removeHoverCol(state.hoverCol);
  }

  state.hoverRow = row;
  state.hoverCol = col;

  if (state.hoverRow !== null) {
    addHoverRow(state.hoverRow);
  }
  if (state.hoverCol !== null) {
    addHoverCol(state.hoverCol);
  }
}

export function updateHoverFromTarget(target) {
  if (state.isSelecting || hasMultiSelection()) {
    clearHoverHighlights();
    return;
  }

  const cellContent = getCellContentFromTarget(target);
  if (!cellContent || !cellContent.classList.contains("cell-content")) {
    clearHoverHighlights();
    return;
  }

  const row = parseInt(cellContent.dataset.row, 10);
  const col = parseInt(cellContent.dataset.col, 10);
  if (isNaN(row) || isNaN(col)) {
    clearHoverHighlights();
    return;
  }

  setHoverHighlight(row, col);
}

// ========== Cell Positioning ==========
export function getCellFromPoint(x, y) {
  const element = document.elementFromPoint(x, y);
  if (!element) return null;

  // Check if it's a cell-content or its parent cell
  let cellContent = element;
  if (element.classList.contains("cell")) {
    cellContent = element.querySelector(".cell-content");
  }

  if (!cellContent || !cellContent.classList.contains("cell-content")) return null;

  const row = parseInt(cellContent.dataset.row, 10);
  const col = parseInt(cellContent.dataset.col, 10);

  if (isNaN(row) || isNaN(col)) return null;
  return { row, col };
}

export function getCellContentElement(row, col) {
  return document.querySelector(`.cell-content[data-row="${row}"][data-col="${col}"]`);
}

export function getCellElement(row, col) {
  const cellContent = getCellContentElement(row, col);
  return cellContent ? cellContent.parentElement : null;
}

export function focusCellAt(row, col) {
  const cellContent = getCellContentElement(row, col);
  if (!cellContent) return null;
  cellContent.focus();
  cellContent.scrollIntoView({ block: "nearest", inline: "nearest" });
  return cellContent;
}

// ========== Resize Handlers ==========
export function handleResizeStart(event) {
  if (event.button !== 0) return;
  if (!(event.target instanceof Element)) return;

  const handle = event.target.closest(".resize-handle");
  if (!handle) return;

  const isColResize = handle.classList.contains("col-resize");
  const indexValue = isColResize ? handle.dataset.col : handle.dataset.row;
  const index = parseInt(indexValue, 10);
  if (isNaN(index)) return;

  event.preventDefault();
  event.stopImmediatePropagation();

  state.resizeState = {
    type: isColResize ? "col" : "row",
    index,
    startX: event.clientX,
    startY: event.clientY,
    startSize: isColResize ? state.colWidths[index] || DEFAULT_COL_WIDTH : state.rowHeights[index] || DEFAULT_ROW_HEIGHT,
  };

  state.isSelecting = false;
  document.body.classList.add("resizing");
  document.body.style.cursor = isColResize ? "col-resize" : "row-resize";

  document.addEventListener("mousemove", handleResizeMove);
  document.addEventListener("mouseup", handleResizeEnd);
}

export function handleResizeMove(event) {
  if (!state.resizeState) return;

  if (state.resizeState.type === "col") {
    const delta = event.clientX - state.resizeState.startX;
    const nextWidth = Math.max(MIN_COL_WIDTH, state.resizeState.startSize + delta);
    state.colWidths[state.resizeState.index] = nextWidth;
  } else {
    const delta = event.clientY - state.resizeState.startY;
    const nextHeight = Math.max(MIN_ROW_HEIGHT, state.resizeState.startSize + delta);
    state.rowHeights[state.resizeState.index] = nextHeight;
  }

  applyGridTemplate();
}

export function handleResizeEnd() {
  if (!state.resizeState) return;

  document.removeEventListener("mousemove", handleResizeMove);
  document.removeEventListener("mouseup", handleResizeEnd);
  document.body.classList.remove("resizing");
  document.body.style.cursor = "";
  state.resizeState = null;
  if (callbacks.debouncedUpdateURL) callbacks.debouncedUpdateURL();
  if (callbacks.onGridResize) callbacks.onGridResize();
}

// ========== Mouse Event Handlers ==========
export function handleMouseDown(event) {
  // Only handle left mouse button
  if (event.button !== 0) return;

  const target = event.target;

  // Check if clicking on a cell
  let cellContent = target;
  if (target.classList.contains("cell")) {
    cellContent = target.querySelector(".cell-content");
  }

  if (!cellContent || !cellContent.classList.contains("cell-content")) {
    // Clicked outside cells - clear selection
    clearSelection();
    return;
  }

  const row = parseInt(cellContent.dataset.row, 10);
  const col = parseInt(cellContent.dataset.col, 10);

  if (isNaN(row) || isNaN(col)) return;

  const container = document.getElementById("spreadsheet");

  // If in formula edit mode and clicking on a different cell
  const formulaEditMode = callbacks.getFormulaEditMode ? callbacks.getFormulaEditMode() : false;
  const formulaEditCell = callbacks.getFormulaEditCell ? callbacks.getFormulaEditCell() : null;

  if (formulaEditMode && formulaEditCell) {
    // Don't process clicks on the formula cell itself
    if (row !== formulaEditCell.row || col !== formulaEditCell.col) {
      event.preventDefault();
      event.stopPropagation();

      if (callbacks.FormulaDropdownManager) {
        callbacks.FormulaDropdownManager.hide();
      }

      // Start range selection for formula
      if (callbacks.setFormulaRangeStart) callbacks.setFormulaRangeStart({ row, col });
      if (callbacks.setFormulaRangeEnd) callbacks.setFormulaRangeEnd({ row, col });
      state.isSelecting = true;
      clearHoverHighlights();

      // Show visual selection
      state.selectionStart = { row, col };
      state.selectionEnd = { row, col };
      updateSelectionVisuals();

      if (container) {
        container.classList.add("selecting");
      }

      return;
    }
  }

  // Shift+click: extend selection from anchor
  if (event.shiftKey && state.selectionStart) {
    state.selectionEnd = { row, col };
    updateSelectionVisuals();
    event.preventDefault();
    return;
  }

  // Start new selection
  state.selectionStart = { row, col };
  state.selectionEnd = { row, col };
  state.isSelecting = true;
  clearHoverHighlights();

  if (container) {
    container.classList.add("selecting");
  }

  updateSelectionVisuals();
}

export function handleMouseMove(event) {
  if (!state.isSelecting) {
    updateHoverFromTarget(event.target);
    return;
  }

  const cellCoords = getCellFromPoint(event.clientX, event.clientY);
  if (!cellCoords) return;

  // Only update if position changed
  if (state.selectionEnd && cellCoords.row === state.selectionEnd.row && cellCoords.col === state.selectionEnd.col) {
    return;
  }

  // If in formula edit mode, update formula range
  const formulaEditMode = callbacks.getFormulaEditMode ? callbacks.getFormulaEditMode() : false;
  const formulaRangeStart = callbacks.getFormulaRangeStart ? callbacks.getFormulaRangeStart() : null;

  if (formulaEditMode && formulaRangeStart) {
    if (callbacks.setFormulaRangeEnd) callbacks.setFormulaRangeEnd(cellCoords);
    state.selectionEnd = cellCoords;
    updateSelectionVisuals();
    event.preventDefault();
    return;
  }

  state.selectionEnd = cellCoords;
  updateSelectionVisuals();

  // Prevent text selection during drag
  event.preventDefault();
}

export function handleMouseLeave() {
  clearHoverHighlights();
}

export function handleMouseUp(event) {
  if (!state.isSelecting) return;

  state.isSelecting = false;

  const container = document.getElementById("spreadsheet");
  if (container) {
    container.classList.remove("selecting");
  }

  // If in formula edit mode, insert the range reference
  const formulaEditMode = callbacks.getFormulaEditMode ? callbacks.getFormulaEditMode() : false;
  const formulaEditCell = callbacks.getFormulaEditCell ? callbacks.getFormulaEditCell() : null;
  const formulaRangeStart = callbacks.getFormulaRangeStart ? callbacks.getFormulaRangeStart() : null;
  const formulaRangeEnd = callbacks.getFormulaRangeEnd ? callbacks.getFormulaRangeEnd() : null;

  if (formulaEditMode && formulaEditCell && formulaRangeStart) {
    const rangeRef = callbacks.buildRangeRef
      ? callbacks.buildRangeRef(formulaRangeStart.row, formulaRangeStart.col, formulaRangeEnd.row, formulaRangeEnd.col)
      : "";

    // Focus back on formula cell and insert range
    formulaEditCell.element.focus();

    const data = callbacks.getDataArray ? callbacks.getDataArray() : [];
    const formulas = callbacks.getFormulasArray ? callbacks.getFormulasArray() : [];

    // Use setTimeout to ensure focus is established before inserting
    setTimeout(function () {
      if (callbacks.insertTextAtCursor) callbacks.insertTextAtCursor(rangeRef);

      // Update stored formula
      if (formulas[formulaEditCell.row]) {
        formulas[formulaEditCell.row][formulaEditCell.col] = formulaEditCell.element.innerText;
      }
      if (data[formulaEditCell.row]) {
        data[formulaEditCell.row][formulaEditCell.col] = formulaEditCell.element.innerText;
      }

      // Clear formula range selection but stay in formula edit mode
      if (callbacks.setFormulaRangeStart) callbacks.setFormulaRangeStart(null);
      if (callbacks.setFormulaRangeEnd) callbacks.setFormulaRangeEnd(null);
      clearSelection();

      if (callbacks.debouncedUpdateURL) callbacks.debouncedUpdateURL();
      if (callbacks.onFormulaChange) callbacks.onFormulaChange();
    }, 0);

    return;
  }

  // If single cell selected, allow normal focus behavior
  if (!hasMultiSelection()) {
    // Let the cell receive focus for editing
    const cellContent = document.querySelector(`.cell-content[data-row="${state.selectionStart.row}"][data-col="${state.selectionStart.col}"]`);
    if (cellContent) {
      cellContent.focus();
    }
  }
}

// ========== Touch Event Handlers ==========
export function handleTouchStart(event) {
  // Only handle single touch
  if (event.touches.length !== 1) return;

  const touch = event.touches[0];
  const cellCoords = getCellFromPoint(touch.clientX, touch.clientY);

  if (!cellCoords) {
    clearSelection();
    return;
  }

  // Start new selection
  state.selectionStart = cellCoords;
  state.selectionEnd = cellCoords;
  state.isSelecting = true;

  const container = document.getElementById("spreadsheet");
  if (container) {
    container.classList.add("selecting");
  }

  updateSelectionVisuals();
}

export function handleTouchMove(event) {
  if (!state.isSelecting) return;
  if (event.touches.length !== 1) return;

  const touch = event.touches[0];
  const cellCoords = getCellFromPoint(touch.clientX, touch.clientY);

  if (!cellCoords) return;

  // Only update if position changed
  if (state.selectionEnd && cellCoords.row === state.selectionEnd.row && cellCoords.col === state.selectionEnd.col) {
    return;
  }

  state.selectionEnd = cellCoords;
  updateSelectionVisuals();

  // Prevent scrolling during selection
  event.preventDefault();
}

export function handleTouchEnd(event) {
  if (!state.isSelecting) return;

  state.isSelecting = false;

  const container = document.getElementById("spreadsheet");
  if (container) {
    container.classList.remove("selecting");
  }

  // If single cell selected, allow focus for editing
  if (!hasMultiSelection() && state.selectionStart) {
    const cellContent = document.querySelector(`.cell-content[data-row="${state.selectionStart.row}"][data-col="${state.selectionStart.col}"]`);
    if (cellContent) {
      cellContent.focus();
    }
  }
}

// ========== Add/Remove Row/Column ==========
export function addRow() {
  if (state.rows >= MAX_ROWS) {
    showToast(`Maximum ${MAX_ROWS} rows allowed`, "warning");
    return;
  }

  const data = callbacks.getDataArray ? callbacks.getDataArray() : [];
  const formulas = callbacks.getFormulasArray ? callbacks.getFormulasArray() : [];
  const cellStyles = callbacks.getCellStylesArray ? callbacks.getCellStylesArray() : [];

  state.rows++;
  data.push(Array(state.cols).fill(""));
  formulas.push(Array(state.cols).fill(""));
  cellStyles.push(
    Array(state.cols)
      .fill(null)
      .map(() => createEmptyCellStyle())
  );
  state.rowHeights.push(DEFAULT_ROW_HEIGHT);

  renderGrid();
  if (callbacks.debouncedUpdateURL) callbacks.debouncedUpdateURL();
  showToast(`Row ${state.rows} added`, "success");
}

export function addColumn() {
  if (state.cols >= MAX_COLS) {
    showToast(`Maximum ${MAX_COLS} columns allowed`, "warning");
    return;
  }

  const data = callbacks.getDataArray ? callbacks.getDataArray() : [];
  const formulas = callbacks.getFormulasArray ? callbacks.getFormulasArray() : [];
  const cellStyles = callbacks.getCellStylesArray ? callbacks.getCellStylesArray() : [];

  state.cols++;
  data.forEach((row) => row.push(""));
  formulas.forEach((row) => row.push(""));
  cellStyles.forEach((row) => row.push(createEmptyCellStyle()));
  state.colWidths.push(DEFAULT_COL_WIDTH);

  renderGrid();
  if (callbacks.debouncedUpdateURL) callbacks.debouncedUpdateURL();
  showToast(`Column ${colToLetter(state.cols - 1)} added`, "success");
}

// ========== Clear Spreadsheet ==========
export function clearSpreadsheet() {
  if (!confirm("Clear all data and reset to 10×10 grid?")) {
    return;
  }

  // Reset to default dimensions
  state.rows = DEFAULT_ROWS;
  state.cols = DEFAULT_COLS;
  state.colWidths = createDefaultColumnWidths(state.cols);
  state.rowHeights = createDefaultRowHeights(state.rows);

  // Reset data arrays via callbacks
  if (callbacks.setDataArray) callbacks.setDataArray(createEmptyData(state.rows, state.cols));
  if (callbacks.setFormulasArray) callbacks.setFormulasArray(createEmptyData(state.rows, state.cols));
  if (callbacks.setCellStylesArray) callbacks.setCellStylesArray(createEmptyCellStyles(state.rows, state.cols));

  // Clear password
  if (callbacks.PasswordManager) callbacks.PasswordManager.setPassword(null);

  // Clear any selection
  clearSelection();

  // Re-render and update URL
  renderGrid();
  if (callbacks.debouncedUpdateURL) callbacks.debouncedUpdateURL();

  showToast("Spreadsheet cleared", "success");
}
security.js
wget 'https://sme10.lists2.roe3.org/spreadsheet/modules/security.js'
View Content
import { ALLOWED_SPAN_STYLES, ALLOWED_TAGS } from "./constants.js";

// Validate CSS color values to prevent CSS injection
export function isValidCSSColor(color) {
  if (color === null || color === undefined) return false;
  if (typeof color !== "string") return false;

  // Allow empty string (to clear color)
  if (color === "") return true;

  // Validate hex colors (#RGB, #RRGGBB, #RRGGBBAA)
  if (/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test(color)) {
    return true;
  }

  // Validate rgb/rgba with proper bounds
  if (/^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(,\s*(0|1|0?\.\d+)\s*)?\)$/.test(color)) {
    return true;
  }

  // Reject anything else (prevents CSS injection)
  return false;
}

// Escape HTML entities for safe display (converts HTML to plain text display)
export function escapeHTML(str) {
  if (!str || typeof str !== "string") return "";
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#x27;");
}

// Filter safe CSS styles for span elements
export function filterSafeStyles(styleString) {
  if (!styleString) return "";
  const safeStyles = [];
  const parts = styleString.split(";");
  for (const part of parts) {
    const colonIndex = part.indexOf(":");
    if (colonIndex === -1) continue;
    const prop = part.substring(0, colonIndex).trim().toLowerCase();
    if (ALLOWED_SPAN_STYLES.includes(prop)) {
      safeStyles.push(part.trim());
    }
  }
  return safeStyles.join("; ");
}

// Defense-in-depth: Check for dangerous patterns that might bypass sanitization
// Returns true if content appears safe, false if dangerous patterns detected
export function isContentSafe(html) {
  if (!html || typeof html !== "string") return true;

  // Dangerous patterns to reject (case-insensitive)
  const dangerousPatterns = [
    /<script/i, // Script tags
    /javascript:/i, // JavaScript protocol
    /on\w+\s*=/i, // Event handlers (onclick, onerror, etc.)
    /data:\s*text\/html/i, // Data URLs with HTML
    /<iframe/i, // Iframes
    /<object/i, // Object embeds
    /<embed/i, // Embed tags
    /<link/i, // Link tags (can load external resources)
    /<meta/i, // Meta tags (can redirect)
    /<base/i, // Base tag (can change URL resolution)
    /expression\s*\(/i, // CSS expressions (IE)
    /url\s*\(\s*["']?\s*javascript:/i, // JavaScript in CSS url()
  ];

  for (const pattern of dangerousPatterns) {
    if (pattern.test(html)) {
      console.warn("Blocked dangerous content pattern:", pattern.toString());
      return false;
    }
  }
  return true;
}

// Sanitize HTML using DOMParser (does NOT execute scripts/event handlers)
export function sanitizeHTML(html) {
  if (!html || typeof html !== "string") return "";

  // Defense-in-depth: Pre-check for obviously dangerous patterns
  if (!isContentSafe(html)) {
    // Return escaped version instead of potentially dangerous content
    return escapeHTML(html);
  }

  // Use DOMParser - it does NOT execute scripts or event handlers
  const parser = new DOMParser();
  const doc = parser.parseFromString("<body>" + html + "</body>", "text/html");

  function sanitizeNode(node) {
    const childNodes = Array.from(node.childNodes);

    for (const child of childNodes) {
      if (child.nodeType === Node.TEXT_NODE) {
        continue;
      }

      if (child.nodeType === Node.ELEMENT_NODE) {
        const tagName = child.tagName.toUpperCase();

        if (!ALLOWED_TAGS.includes(tagName)) {
          // Replace disallowed tags with their text content
          const textNode = document.createTextNode(child.textContent || "");
          node.replaceChild(textNode, child);
        } else {
          // Remove all attributes except safe styles on SPAN
          const attrs = Array.from(child.attributes);
          for (const attr of attrs) {
            if (tagName === "SPAN" && attr.name === "style") {
              const safeStyle = filterSafeStyles(attr.value);
              if (safeStyle) {
                child.setAttribute("style", safeStyle);
              } else {
                child.removeAttribute("style");
              }
            } else {
              child.removeAttribute(attr.name);
            }
          }
          sanitizeNode(child);
        }
      } else {
        // Remove comments and other node types
        node.removeChild(child);
      }
    }
  }

  sanitizeNode(doc.body);
  const result = doc.body.innerHTML;

  // Defense-in-depth: Final verification of sanitized output
  if (!isContentSafe(result)) {
    console.warn("Sanitized output still contains dangerous patterns, escaping");
    return escapeHTML(html);
  }

  return result;
}
toastManager.js
wget 'https://sme10.lists2.roe3.org/spreadsheet/modules/toastManager.js'
View Content
import { TOAST_DURATION, TOAST_ICONS } from "./constants.js";

/**
 * Dismiss a toast with exit animation
 * @param {HTMLElement} toast - The toast element to dismiss
 */
export function dismissToast(toast) {
  if (!toast || toast.classList.contains("toast-exit")) return;

  toast.classList.add("toast-exit");
  toast.addEventListener(
    "animationend",
    () => {
      toast.remove();
    },
    { once: true }
  );
}

/**
 * Show a toast notification
 * @param {string} message - The message to display
 * @param {string} type - Type: 'success', 'error', 'warning', 'info'
 * @param {number} duration - Duration in ms (0 for no auto-dismiss)
 */
export function showToast(message, type = "info", duration = TOAST_DURATION) {
  const container = document.getElementById("toast-container");
  if (!container) return;

  // Create toast element
  const toast = document.createElement("div");
  toast.className = `toast toast-${type}`;
  toast.setAttribute("role", "alert");

  // Icon
  const icon = document.createElement("div");
  icon.className = "toast-icon";
  icon.innerHTML = `<i class="fa-solid ${TOAST_ICONS[type] || TOAST_ICONS.info}"></i>`;
  toast.appendChild(icon);

  // Message
  const msg = document.createElement("div");
  msg.className = "toast-message";
  msg.textContent = message;
  toast.appendChild(msg);

  // Close button
  const closeBtn = document.createElement("button");
  closeBtn.className = "toast-close";
  closeBtn.innerHTML = '<i class="fa-solid fa-xmark"></i>';
  closeBtn.setAttribute("aria-label", "Dismiss");
  closeBtn.addEventListener("click", () => dismissToast(toast));
  toast.appendChild(closeBtn);

  // Progress bar (if auto-dismiss)
  if (duration > 0) {
    const progress = document.createElement("div");
    progress.className = "toast-progress";
    progress.style.animationDuration = `${duration}ms`;
    toast.appendChild(progress);

    // Auto-dismiss after duration
    setTimeout(() => dismissToast(toast), duration);
  }

  // Add to container
  container.appendChild(toast);

  // Limit max toasts
  const toasts = container.querySelectorAll(".toast:not(.toast-exit)");
  if (toasts.length > 5) {
    dismissToast(toasts[0]);
  }

  return toast;
}
urlManager.js
wget 'https://sme10.lists2.roe3.org/spreadsheet/modules/urlManager.js'
View Content
import {
  DEFAULT_COL_WIDTH,
  DEFAULT_COLS,
  DEFAULT_ROW_HEIGHT,
  DEFAULT_ROWS,
  FONT_SIZE_OPTIONS,
  KEY_MAP,
  KEY_MAP_REVERSE,
  MAX_COLS,
  MAX_ROWS,
  MIN_COL_WIDTH,
  MIN_ROW_HEIGHT,
  STYLE_KEY_MAP,
  URL_LENGTH_CAUTION,
  URL_LENGTH_CRITICAL,
  URL_LENGTH_MAX_DISPLAY,
  URL_LENGTH_WARNING,
} from "./constants.js";
import { CryptoUtils, EncryptionCodec } from "./encryption.js";
import { isValidFormula } from "./formulaManager.js";
import { escapeHTML, isValidCSSColor, sanitizeHTML } from "./security.js";

// Factory functions
export function createEmptyData(r, c) {
  return Array(r)
    .fill(null)
    .map(() => Array(c).fill(""));
}

export function createEmptyCellStyle() {
  return { align: "", bg: "", color: "", fontSize: "" };
}

export function createEmptyCellStyles(r, c) {
  return Array(r)
    .fill(null)
    .map(() =>
      Array(c)
        .fill(null)
        .map(() => createEmptyCellStyle())
    );
}

export function createDefaultColumnWidths(count) {
  return Array(count).fill(DEFAULT_COL_WIDTH);
}

export function createDefaultRowHeights(count) {
  return Array(count).fill(DEFAULT_ROW_HEIGHT);
}

// Normalizers
export function normalizeColumnWidths(widths, count) {
  const normalized = [];
  for (let i = 0; i < count; i++) {
    const raw = widths && widths[i];
    const value = parseInt(raw, 10);
    if (Number.isFinite(value) && value > 0) {
      normalized.push(Math.max(MIN_COL_WIDTH, value));
    } else {
      normalized.push(DEFAULT_COL_WIDTH);
    }
  }
  return normalized;
}

export function normalizeRowHeights(heights, count) {
  const normalized = [];
  for (let i = 0; i < count; i++) {
    const raw = heights && heights[i];
    const value = parseInt(raw, 10);
    if (Number.isFinite(value) && value > 0) {
      normalized.push(Math.max(MIN_ROW_HEIGHT, value));
    } else {
      normalized.push(DEFAULT_ROW_HEIGHT);
    }
  }
  return normalized;
}

export function normalizeAlignment(value) {
  if (value === "left" || value === "center" || value === "right") {
    return value;
  }
  return "";
}

export function normalizeFontSize(value) {
  if (value === null || value === undefined) return "";
  let raw = String(value).trim();
  if (raw === "") return "";
  if (raw.endsWith("px")) {
    raw = raw.slice(0, -2).trim();
  }
  const size = parseInt(raw, 10);
  if (isNaN(size)) return "";
  if (!FONT_SIZE_OPTIONS.includes(size)) return "";
  return String(size);
}

export function normalizeCellStyles(styles, r, c) {
  const normalized = createEmptyCellStyles(r, c);
  if (!Array.isArray(styles)) return normalized;

  for (let row = 0; row < r; row++) {
    const sourceRow = Array.isArray(styles[row]) ? styles[row] : [];
    for (let col = 0; col < c; col++) {
      const cellStyle = sourceRow[col];
      if (cellStyle && typeof cellStyle === "object") {
        normalized[row][col] = {
          align: normalizeAlignment(cellStyle.align),
          bg: isValidCSSColor(cellStyle.bg) ? cellStyle.bg : "",
          color: isValidCSSColor(cellStyle.color) ? cellStyle.color : "",
          fontSize: normalizeFontSize(cellStyle.fontSize),
        };
      }
    }
  }
  return normalized;
}

// Check if all cells in data array are empty
export function isDataEmpty(d) {
  return d.every((row) => row.every((cell) => cell === ""));
}

// Check if all cells in formulas array are empty
export function isFormulasEmpty(f) {
  return f.every((row) => row.every((cell) => cell === ""));
}

// Check if a cell style is default (all empty)
export function isCellStyleDefault(style) {
  return !style || (style.align === "" && style.bg === "" && style.color === "" && style.fontSize === "");
}

// Check if all cell styles are default
export function isCellStylesDefault(styles) {
  return styles.every((row) => row.every((cell) => isCellStyleDefault(cell)));
}

// Check if column widths are all default
export function isColWidthsDefault(widths, count) {
  if (widths.length !== count) return false;
  return widths.every((w) => w === DEFAULT_COL_WIDTH);
}

// Check if row heights are all default
export function isRowHeightsDefault(heights, count) {
  if (heights.length !== count) return false;
  return heights.every((h) => h === DEFAULT_ROW_HEIGHT);
}

// Safe JSON parse with prototype pollution protection
function safeJSONParse(jsonString) {
  const parsed = JSON.parse(jsonString);

  function createSafeCopy(obj) {
    if (obj === null || typeof obj !== "object") return obj;
    if (Array.isArray(obj)) {
      return obj.map(createSafeCopy);
    }

    const safe = Object.create(null);
    for (const key of Object.keys(obj)) {
      // Block dangerous keys that could pollute prototypes
      if (key === "__proto__" || key === "constructor" || key === "prototype") {
        console.warn("Blocked prototype pollution attempt via key:", key);
        continue;
      }
      // Block keys containing prototype chain accessor patterns
      if (key.includes("__proto__") || key.includes("constructor.prototype")) {
        console.warn("Blocked prototype pollution attempt via key pattern:", key);
        continue;
      }
      safe[key] = createSafeCopy(obj[key]);
    }
    return safe;
  }

  return createSafeCopy(parsed);
}

// ========== Sparse Array Conversion Helpers ==========

// Detect whether an array is in sparse format [[row,col,val],...] or dense format [[val1,val2],...]
function detectArrayFormat(array) {
  if (!array || array.length === 0) return "empty";

  const first = array[0];
  if (!Array.isArray(first)) return "invalid";

  // Sparse format signature: first element is [number, number, anything]
  // Dense format signature: first element is array of values (strings/objects)
  if (first.length === 3 &&
      typeof first[0] === "number" &&
      typeof first[1] === "number" &&
      Number.isInteger(first[0]) &&
      Number.isInteger(first[1])) {
    return "sparse";
  }

  return "dense";
}

// Convert dense 2D array to sparse triplet array [[row, col, value], ...]
function convertToSparse(array2D) {
  if (!array2D || !Array.isArray(array2D)) return undefined;

  const sparse = [];
  for (let r = 0; r < array2D.length; r++) {
    const row = array2D[r];
    if (!Array.isArray(row)) continue;

    for (let c = 0; c < row.length; c++) {
      const val = row[c];
      // Only include non-empty values
      if (val !== "") {
        sparse.push([r, c, val]);
      }
    }
  }

  return sparse.length > 0 ? sparse : undefined;
}

// Convert sparse triplet array to dense 2D array
function expandFromSparse(sparse, rows, cols, emptyValue = "") {
  // Initialize dense array with empty values
  const dense = Array(rows).fill(null).map(() => Array(cols).fill(emptyValue));

  if (!sparse || sparse.length === 0) return dense;

  for (const entry of sparse) {
    // Validate triplet format
    if (!Array.isArray(entry) || entry.length !== 3) {
      console.warn("Invalid sparse triplet format:", entry);
      continue;
    }

    const [r, c, val] = entry;

    // Validate indices (type check and bounds check)
    if (!Number.isInteger(r) || !Number.isInteger(c)) {
      console.warn("Non-integer indices in sparse entry:", r, c);
      continue;
    }

    if (r < 0 || r >= rows || c < 0 || c >= cols) {
      console.warn("Out of bounds sparse entry (ignoring):", r, c, "grid size:", rows, "x", cols);
      continue;
    }

    // Set value in dense array
    dense[r][c] = val;
  }

  return dense;
}

// Convert cell styles dense 2D array to sparse format
function convertCellStylesToSparse(styles2D) {
  if (!styles2D || !Array.isArray(styles2D)) return undefined;

  const sparse = [];

  for (let r = 0; r < styles2D.length; r++) {
    const row = styles2D[r];
    if (!Array.isArray(row)) continue;

    for (let c = 0; c < row.length; c++) {
      const style = row[c];

      // Skip default/empty styles
      if (isCellStyleDefault(style)) continue;

      // Minify non-default style (remove empty properties)
      const minified = {};
      for (const [key, val] of Object.entries(style)) {
        if (val !== "") {
          minified[STYLE_KEY_MAP[key] || key] = val;
        }
      }

      // Only add if there are non-empty properties
      if (Object.keys(minified).length > 0) {
        sparse.push([r, c, minified]);
      }
    }
  }

  return sparse.length > 0 ? sparse : undefined;
}

// Expand cell styles from sparse format to dense 2D array
function expandCellStylesFromSparse(sparse, rows, cols) {
  // Initialize with default empty styles
  const dense = createEmptyCellStyles(rows, cols);

  if (!sparse || sparse.length === 0) return dense;

  for (const entry of sparse) {
    // Validate triplet format
    if (!Array.isArray(entry) || entry.length !== 3) {
      console.warn("Invalid sparse style triplet:", entry);
      continue;
    }

    const [r, c, minStyle] = entry;

    // Validate indices
    if (!Number.isInteger(r) || !Number.isInteger(c)) {
      console.warn("Non-integer indices in sparse style entry:", r, c);
      continue;
    }

    if (r < 0 || r >= rows || c < 0 || c >= cols) {
      console.warn("Out of bounds sparse style entry:", r, c);
      continue;
    }

    // Expand minified style keys back to full names
    if (minStyle && typeof minStyle === "object") {
      dense[r][c] = {
        align: minStyle.a || minStyle.align || "",
        bg: minStyle.b || minStyle.bg || "",
        color: minStyle.c || minStyle.color || "",
        fontSize: minStyle.z || minStyle.fontSize || "",
      };
    }
  }

  return dense;
}

// ========== End Sparse Array Helpers ==========

// Minify state object keys for smaller URL payload
function minifyState(state) {
  const minified = {};
  for (const [key, value] of Object.entries(state)) {
    const shortKey = KEY_MAP[key] || key;

    // Convert data and formulas to sparse format
    if ((key === "data" || key === "formulas") && Array.isArray(value)) {
      const sparse = convertToSparse(value);
      if (sparse !== undefined) {
        minified[shortKey] = sparse;
      }
      // If sparse is undefined (no data), don't include the key at all
    }
    // Convert cellStyles to sparse format
    else if (key === "cellStyles" && Array.isArray(value)) {
      const sparse = convertCellStylesToSparse(value);
      if (sparse !== undefined) {
        minified[shortKey] = sparse;
      }
      // If sparse is undefined (no styles), don't include the key at all
    }
    // Pass through other keys unchanged
    else {
      minified[shortKey] = value;
    }
  }
  return minified;
}

// Expose minify helper for consumers that need the hash-ready structure (e.g., JSON modal)
export function minifyStateForExport(state) {
  return minifyState(state);
}

// Expand minified state keys back to full names (backward compatible)
function expandState(minified) {
  // Handle null, non-objects, and arrays (legacy format) - return as-is
  if (!minified || typeof minified !== "object" || Array.isArray(minified)) {
    return minified;
  }
  const expanded = {};
  for (const [key, value] of Object.entries(minified)) {
    const fullKey = KEY_MAP_REVERSE[key] || key;

    // Handle data: detect sparse vs dense format
    if ((fullKey === "data" || key === "d") && Array.isArray(value)) {
      const format = detectArrayFormat(value);
      if (format === "sparse") {
        // Mark as sparse for validateAndNormalizeState to expand later
        expanded.data = { _format: "sparse", _data: value };
      } else {
        // Dense format (legacy) - pass through as-is
        expanded.data = value;
      }
    }
    // Handle formulas: detect sparse vs dense format
    else if ((fullKey === "formulas" || key === "f") && Array.isArray(value)) {
      const format = detectArrayFormat(value);
      if (format === "sparse") {
        expanded.formulas = { _format: "sparse", _data: value };
      } else {
        // Dense format (legacy) - pass through as-is
        expanded.formulas = value;
      }
    }
    // Handle cellStyles: detect sparse vs dense format
    else if ((fullKey === "cellStyles" || key === "s") && Array.isArray(value)) {
      const format = detectArrayFormat(value);
      if (format === "sparse") {
        // Mark as sparse for validateAndNormalizeState to expand later
        expanded.cellStyles = { _format: "sparse", _data: value };
      } else {
        // Dense format (legacy) - expand minified style keys
        expanded.cellStyles = value.map((row) =>
          row.map((cell) => {
            if (!cell || typeof cell !== "object") {
              return { align: "", bg: "", color: "", fontSize: "" };
            }
            return {
              align: cell.a || cell.align || "",
              bg: cell.b || cell.bg || "",
              color: cell.c || cell.color || "",
              fontSize: cell.z || cell.fontSize || "",
            };
          })
        );
      }
    }
    // Pass through other keys unchanged
    else {
      expanded[fullKey] = value;
    }
  }
  return expanded;
}

// ========== Serialization Codec (Wrapper for minify/expand + compression) ==========
const SerializationCodec = {
  // Serialize a state object to a compressed string
  serialize(state) {
    const json = JSON.stringify(minifyState(state));
    return LZString.compressToEncodedURIComponent(json);
  },

  // Deserialize a compressed string to a state object
  // Returns null on failure
  deserialize(compressed) {
    try {
      const json = LZString.decompressFromEncodedURIComponent(compressed);
      if (!json || json.length === 0) return null;
      return expandState(safeJSONParse(json));
    } catch (e) {
      console.error("Deserialization failed:", e);
      return null;
    }
  },
};

// ========== URL Codec (Main Wrapper - combines serialization and encryption) ==========
const URLCodec = {
  // Encode state to URL-ready string
  async encode(state, options = {}) {
    const serialized = SerializationCodec.serialize(state);

    // Optionally encrypt
    if (options.password) {
      try {
        return await EncryptionCodec.encrypt(serialized, options.password);
      } catch (e) {
        console.error("Encryption failed, falling back to unencrypted:", e);
        return serialized;
      }
    }

    return serialized;
  },

  // Decode URL hash - returns { state } or { encrypted: true, data }
  decode(hash) {
    // Security check
    if (!hash || hash.length > 100000) {
      if (hash && hash.length > 100000) {
        console.warn("Hash too long, rejecting");
      }
      return null;
    }

    // Check for encrypted data
    if (EncryptionCodec.isEncrypted(hash)) {
      return {
        encrypted: true,
        data: EncryptionCodec.unwrap(hash),
      };
    }

    // Try to deserialize
    const state = SerializationCodec.deserialize(hash);
    if (state) {
      return { state };
    }

    // Try legacy format
    return this._decodeLegacy(hash);
  },

  // Decrypt and decode an encrypted payload
  async decryptAndDecode(encryptedData, password) {
    const decrypted = await CryptoUtils.decrypt(encryptedData, password);
    const state = SerializationCodec.deserialize(decrypted);
    if (!state) {
      throw new Error("Invalid decrypted data");
    }
    return state;
  },

  // Handle legacy uncompressed format
  _decodeLegacy(hash) {
    try {
      // Only attempt legacy decode if it looks like valid URL-encoded JSON
      if (hash.startsWith("%7B") || hash.startsWith("%5B") || hash.startsWith("{") || hash.startsWith("[")) {
        const decoded = decodeURIComponent(hash);
        return { state: expandState(safeJSONParse(decoded)) };
      }
    } catch (e) {
      console.warn("Legacy decode failed:", e);
    }
    return null;
  },
};

// Validate and normalize a parsed state object
export function validateAndNormalizeState(parsed) {
  try {
    // Handle new format (object with rows, cols, data)
    if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
      const r = Math.min(Math.max(1, parsed.rows || DEFAULT_ROWS), MAX_ROWS);
      const c = Math.min(Math.max(1, parsed.cols || DEFAULT_COLS), MAX_COLS);

      // Validate and normalize data array
      let d = parsed.data;
      // Check if data is marked as sparse format by expandState
      if (d && typeof d === "object" && d._format === "sparse") {
        // Sparse format: expand to dense
        d = expandFromSparse(d._data, r, c, "");
        // Sanitize all cells
        d = d.map((row) => row.map((cell) => sanitizeHTML(String(cell))));
      }
      // Handle direct sparse array (backward compatibility - no metadata wrapper)
      else if (Array.isArray(d)) {
        const format = detectArrayFormat(d);
        if (format === "sparse") {
          // Direct sparse array without metadata wrapper
          d = expandFromSparse(d, r, c, "");
          d = d.map((row) => row.map((cell) => sanitizeHTML(String(cell))));
        } else {
          // Dense format (legacy): existing logic
          // Ensure correct dimensions
          d = d.slice(0, r).map((row) => {
            if (Array.isArray(row)) {
              return row.slice(0, c).map((cell) => sanitizeHTML(String(cell || "")));
            }
            return Array(c).fill("");
          });
          // Pad rows if needed
          while (d.length < r) {
            d.push(Array(c).fill(""));
          }
          // Pad columns if needed
          d = d.map((row) => {
            while (row.length < c) row.push("");
            return row;
          });
        }
      } else {
        d = createEmptyData(r, c);
      }

      // Load formulas (backward compatible - create empty if not present)
      // Security: Validate formulas against whitelist to prevent injection
      let f = parsed.formulas;
      // Check if formulas is marked as sparse format by expandState
      if (f && typeof f === "object" && f._format === "sparse") {
        // Sparse format: expand to dense
        f = expandFromSparse(f._data, r, c, "");
        // Validate all formulas
        f = f.map((row, rowIdx) => row.map((cell, colIdx) => {
          const formula = String(cell);
          if (formula.startsWith("=")) {
            if (isValidFormula(formula)) {
              return formula;
            } else {
              // Invalid formula - convert to escaped text in data
              d[rowIdx][colIdx] = escapeHTML(formula);
              return "";
            }
          }
          return formula;
        }));
      }
      // Handle direct sparse array (backward compatibility)
      else if (Array.isArray(f)) {
        const format = detectArrayFormat(f);
        if (format === "sparse") {
          // Direct sparse array without metadata wrapper
          f = expandFromSparse(f, r, c, "");
          // Validate all formulas
          f = f.map((row, rowIdx) => row.map((cell, colIdx) => {
            const formula = String(cell);
            if (formula.startsWith("=")) {
              if (isValidFormula(formula)) {
                return formula;
              } else {
                // Invalid formula - convert to escaped text in data
                d[rowIdx][colIdx] = escapeHTML(formula);
                return "";
              }
            }
            return formula;
          }));
        } else {
          // Dense format (legacy): existing logic
          f = f.slice(0, r).map((row, rowIdx) => {
            if (Array.isArray(row)) {
              return row.slice(0, c).map((cell, colIdx) => {
                const formula = String(cell || "");
                if (formula.startsWith("=")) {
                  // Validate formula against whitelist
                  if (isValidFormula(formula)) {
                    return formula;
                  } else {
                    // Invalid formula - convert to escaped text in data
                    d[rowIdx][colIdx] = escapeHTML(formula);
                    return "";
                  }
                }
                return formula;
              });
            }
            return Array(c).fill("");
          });
          while (f.length < r) {
            f.push(Array(c).fill(""));
          }
          f = f.map((row) => {
            while (row.length < c) row.push("");
            return row;
          });
        }
      } else {
        f = createEmptyData(r, c);
      }

      // Handle cellStyles: detect sparse vs dense format
      let s;
      const parsedStyles = parsed.cellStyles;
      if (parsedStyles && typeof parsedStyles === "object" && parsedStyles._format === "sparse") {
        // Sparse format: expand to dense first, then normalize/validate
        const expanded = expandCellStylesFromSparse(parsedStyles._data, r, c);
        s = normalizeCellStyles(expanded, r, c);
      } else if (Array.isArray(parsedStyles)) {
        const format = detectArrayFormat(parsedStyles);
        if (format === "sparse") {
          // Direct sparse array without metadata wrapper
          const expanded = expandCellStylesFromSparse(parsedStyles, r, c);
          s = normalizeCellStyles(expanded, r, c);
        } else {
          // Dense format (legacy): use normalizeCellStyles directly
          s = normalizeCellStyles(parsedStyles, r, c);
        }
      } else {
        // No styles or invalid format
        s = normalizeCellStyles(parsedStyles, r, c);
      }
      const w = normalizeColumnWidths(parsed.colWidths, c);
      const h = normalizeRowHeights(parsed.rowHeights, r);
      return {
        rows: r,
        cols: c,
        data: d,
        formulas: f,
        cellStyles: s,
        colWidths: w,
        rowHeights: h,
        theme: parsed.theme || null,
        readOnly: Boolean(parsed.readOnly),
        embed: Boolean(parsed.embed),
      };
    }

    // Handle legacy format (just array, assume 10x10)
    if (Array.isArray(parsed)) {
      const d = parsed.slice(0, DEFAULT_ROWS).map((row) => {
        if (Array.isArray(row)) {
          return row.slice(0, DEFAULT_COLS).map((cell) => sanitizeHTML(String(cell || "")));
        }
        return Array(DEFAULT_COLS).fill("");
      });
      while (d.length < DEFAULT_ROWS) {
        d.push(Array(DEFAULT_COLS).fill(""));
      }
      const f = createEmptyData(DEFAULT_ROWS, DEFAULT_COLS);
      const s = createEmptyCellStyles(DEFAULT_ROWS, DEFAULT_COLS);
      const w = createDefaultColumnWidths(DEFAULT_COLS);
      const h = createDefaultRowHeights(DEFAULT_ROWS);
      return {
        rows: DEFAULT_ROWS,
        cols: DEFAULT_COLS,
        data: d,
        formulas: f,
        cellStyles: s,
        colWidths: w,
        rowHeights: h,
        theme: null,
        readOnly: false,
      };
    }
  } catch (e) {
    console.warn("Failed to validate state:", e);
  }
  return null;
}

// Update URL length indicator in status bar
function updateURLLengthIndicator(length) {
  const valueEl = document.getElementById("url-length-value");
  const barEl = document.getElementById("url-progress-bar");
  const msgEl = document.getElementById("url-length-message");
  if (!valueEl || !barEl) return;

  valueEl.textContent = length.toLocaleString();

  // Calculate progress percentage (capped at 100%)
  const percent = Math.min((length / URL_LENGTH_MAX_DISPLAY) * 100, 100);
  barEl.style.width = percent + "%";

  // Update color and message based on thresholds
  barEl.classList.remove("warning", "caution", "critical");
  if (msgEl) {
    msgEl.classList.remove("warning", "caution", "critical");
    msgEl.textContent = "";
  }

  if (length >= URL_LENGTH_CRITICAL) {
    barEl.classList.add("critical");
    if (msgEl) {
      msgEl.classList.add("critical");
      msgEl.textContent = "Some browsers may fail";
    }
  } else if (length >= URL_LENGTH_CAUTION) {
    barEl.classList.add("caution");
    if (msgEl) {
      msgEl.classList.add("caution");
      msgEl.textContent = "URL shorteners may fail";
    }
  } else if (length >= URL_LENGTH_WARNING) {
    barEl.classList.add("warning");
    if (msgEl) {
      msgEl.classList.add("warning");
      msgEl.textContent = "Some older browsers may truncate";
    }
  }
}

export const URLManager = {
  // Decode URL hash to state object
  // Returns { encrypted: true, data: base64String } if data is encrypted
  // Returns { rows, ... } if success
  // Returns null if failure
  decodeState(hash) {
    try {
      const result = URLCodec.decode(hash);
      if (!result) return null;

      // If encrypted, return the encrypted marker
      if (result.encrypted) {
        return result;
      }

      // Validate and normalize the decoded state
      return validateAndNormalizeState(result.state);
    } catch (e) {
      console.warn("Failed to decode state from URL:", e);
    }
    return null;
  },

  async encodeState(state, password) {
    return await URLCodec.encode(state, { password });
  },

  async decryptAndDecode(encryptedData, password) {
    return await URLCodec.decryptAndDecode(encryptedData, password);
  },

  // Update URL hash without page jump
  async updateURL(state, password) {
    const encoded = await this.encodeState(state, password);
    const newHash = "#" + encoded;

    // Update URL length indicator
    updateURLLengthIndicator(encoded.length);

    if (history.replaceState) {
      history.replaceState(null, null, newHash);
    } else {
      location.hash = newHash;
    }
  },

  updateURLLengthIndicator,
};
visualFunctions.js
wget 'https://sme10.lists2.roe3.org/spreadsheet/modules/visualFunctions.js'
View Content
import { isValidCSSColor } from "./security.js";
import { parseCellRef } from "./formulaManager.js";

const COLOR_ALIASES = {
  green: "#22c55e",
  red: "#ef4444",
  blue: "#3b82f6",
  orange: "#f97316",
  yellow: "#facc15",
  gray: "#9ca3af",
  black: "#111111",
  white: "#ffffff",
};

function splitArgs(raw) {
  if (!raw) return [];
  if (!raw.trim()) return [];

  const args = [];
  let current = "";
  let quote = null;

  for (let i = 0; i < raw.length; i++) {
    const ch = raw[i];
    if (quote) {
      if (ch === "\\" && i + 1 < raw.length) {
        current += raw[i + 1];
        i++;
        continue;
      }
      if (ch === quote) {
        quote = null;
        continue;
      }
      current += ch;
      continue;
    }

    if (ch === "'" || ch === '"') {
      quote = ch;
      continue;
    }

    if (ch === ",") {
      args.push(current.trim());
      current = "";
      continue;
    }

    current += ch;
  }

  if (current.length || raw.trim().length) {
    args.push(current.trim());
  }

  return args;
}

function parseFormula(formula) {
  if (!formula || typeof formula !== "string") return null;
  const trimmed = formula.trim();
  if (!trimmed.startsWith("=")) return null;

  const body = trimmed.slice(1).trim();
  const openIndex = body.indexOf("(");
  const closeIndex = body.lastIndexOf(")");
  if (openIndex <= 0 || closeIndex <= openIndex) return null;

  const name = body.slice(0, openIndex).trim().toUpperCase();
  const trailing = body.slice(closeIndex + 1).trim();
  if (trailing) return null;

  const argsRaw = body.slice(openIndex + 1, closeIndex);
  return { name, args: splitArgs(argsRaw) };
}

function extractPlainText(value) {
  if (value === null || value === undefined) return "";
  const parser = new DOMParser();
  const doc = parser.parseFromString("<body>" + String(value) + "</body>", "text/html");
  const text = doc.body.textContent || "";
  return text.replace(/\u00a0/g, " ");
}

function isCellRef(value) {
  return /^[A-Z]+[1-9]\d*$/i.test(value);
}

function resolveNumberArg(arg, context) {
  if (arg === null || arg === undefined) return null;
  const trimmed = String(arg).trim();
  if (!trimmed) return null;

  if (isCellRef(trimmed)) {
    const ref = parseCellRef(trimmed);
    if (ref && context && typeof context.getCellValue === "function") {
      const value = context.getCellValue(ref.row, ref.col);
      return Number.isFinite(value) ? value : 0;
    }
  }

  const percentMatch = trimmed.match(/^(-?\d+(?:\.\d+)?)%$/);
  const numericText = percentMatch ? percentMatch[1] : trimmed.replace(/,/g, "");
  const num = parseFloat(numericText);
  return Number.isNaN(num) ? null : num;
}

function resolveTextArg(arg, context) {
  if (arg === null || arg === undefined) return "";
  const trimmed = String(arg).trim();
  if (!trimmed) return "";

  if (isCellRef(trimmed)) {
    const ref = parseCellRef(trimmed);
    if (ref && context && Array.isArray(context.data)) {
      const value = context.data[ref.row] && context.data[ref.row][ref.col];
      return extractPlainText(value);
    }
  }

  return trimmed;
}

function resolveColor(value) {
  if (!value) return "";
  const trimmed = String(value).trim();
  if (!trimmed) return "";
  const lower = trimmed.toLowerCase();
  if (COLOR_ALIASES[lower]) return COLOR_ALIASES[lower];
  if (isValidCSSColor(trimmed)) return trimmed;
  return "";
}

function clamp(value, min, max) {
  return Math.min(Math.max(value, min), max);
}

function getContrastColor(color) {
  if (!color) return "#ffffff";

  let r = 0;
  let g = 0;
  let b = 0;
  const hexMatch = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
  const rgbMatch = color.match(/^rgba?\((\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})/i);

  if (hexMatch) {
    const hex = hexMatch[1];
    if (hex.length === 3) {
      r = parseInt(hex[0] + hex[0], 16);
      g = parseInt(hex[1] + hex[1], 16);
      b = parseInt(hex[2] + hex[2], 16);
    } else {
      r = parseInt(hex.slice(0, 2), 16);
      g = parseInt(hex.slice(2, 4), 16);
      b = parseInt(hex.slice(4, 6), 16);
    }
  } else if (rgbMatch) {
    r = parseInt(rgbMatch[1], 10);
    g = parseInt(rgbMatch[2], 10);
    b = parseInt(rgbMatch[3], 10);
  } else {
    return "#ffffff";
  }

  const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
  return luminance > 0.6 ? "#111111" : "#ffffff";
}

function buildProgress(percent, label, color) {
  const normalized = clamp(Number.isFinite(percent) ? percent : 0, 0, 100);
  const wrapper = document.createElement("div");
  wrapper.className = "visual-progress";

  const track = document.createElement("div");
  track.className = "visual-progress-track";
  track.setAttribute("role", "progressbar");
  track.setAttribute("aria-valuemin", "0");
  track.setAttribute("aria-valuemax", "100");
  track.setAttribute("aria-valuenow", String(Math.round(normalized)));

  const fill = document.createElement("div");
  fill.className = "visual-progress-fill";
  fill.style.width = `${normalized}%`;
  if (color) {
    fill.style.backgroundColor = color;
    fill.style.color = getContrastColor(color);
  }

  if (label) {
    fill.textContent = label;
  } else {
    fill.textContent = `${Math.round(normalized)}%`;
  }

  track.appendChild(fill);
  wrapper.appendChild(track);

  if (label) {
    const labelEl = document.createElement("div");
    labelEl.className = "visual-progress-label";
    labelEl.textContent = `${Math.round(normalized)}%`;
    wrapper.appendChild(labelEl);
  }

  return wrapper;
}

function buildTag(label, color) {
  if (!label) return null;
  const tag = document.createElement("span");
  tag.className = "visual-tag";
  tag.textContent = label;

  if (color) {
    tag.style.backgroundColor = color;
    tag.style.borderColor = color;
    tag.style.color = getContrastColor(color);
  }

  return tag;
}

function buildRating(value, max) {
  if (!Number.isFinite(value)) return null;
  const total = clamp(Math.round(Number.isFinite(max) ? max : 5), 1, 10);
  const score = clamp(Math.round(value), 0, total);

  const rating = document.createElement("div");
  rating.className = "visual-rating";
  rating.setAttribute("role", "img");
  rating.setAttribute("aria-label", `${score} of ${total}`);

  for (let i = 0; i < total; i++) {
    const star = document.createElement("i");
    star.className = i < score ? "fa-solid fa-star" : "fa-regular fa-star";
    rating.appendChild(star);
  }

  return rating;
}

export const VisualFunctions = {
  process(formula, context = {}) {
    const parsed = parseFormula(formula);
    if (!parsed) return null;

    const { name, args } = parsed;
    if (name === "PROGRESS") {
      const value = resolveNumberArg(args[0], context);
      if (value === null) return null;

      const secondNumeric = resolveNumberArg(args[1], context);
      let percent = value;
      let label = "";
      let color = "";

      if (secondNumeric !== null) {
        percent = secondNumeric === 0 ? 0 : (value / secondNumeric) * 100;
        label = resolveTextArg(args[2], context);
        color = resolveColor(args[3]);
      } else {
        if (args.length === 1 && value >= 0 && value <= 1) {
          percent = value * 100;
        }

        const maybeColor = resolveColor(args[1]);
        if (maybeColor && args.length === 2) {
          color = maybeColor;
        } else {
          label = resolveTextArg(args[1], context);
          color = resolveColor(args[2]);
        }
      }

      return buildProgress(percent, label, color);
    }

    if (name === "TAG") {
      const label = resolveTextArg(args[0], context);
      if (!label) return null;
      const color = resolveColor(args[1]);
      return buildTag(label, color);
    }

    if (name === "RATING") {
      const value = resolveNumberArg(args[0], context);
      if (value === null) return null;
      const max = resolveNumberArg(args[1], context);
      return buildRating(value, max);
    }

    return null;
  },
};