Action

Calculations in md Tables

Posted by K Holford, Last update 12 days ago

UPDATES

12 days ago

VERSION HISTORY (most recent first)
- v1.11 — Adds running-total token (#) inside bracket formulas; any [ … ] that
references # replaces the running total up to that point (inline reset).
- v1.1 — Adds per-table CONFIG functionality via html directive line placed immediately
before a table; supports all previous config settings plus the new TOTAL
suppression config setting. Also restores and documents support for multi-row
parentheses functionality.
- v1.01 — Adds optional prompt to insert example starter tables when no
SmartTables-compatible tables are found

show all updates...

12 days ago

VERSION HISTORY (most recent first)
- v1.11 — Adds running-total token (#) inside bracket formulas; any [ … ] that
references # replaces the running total up to that point (inline reset).
- v1.1 — Adds per-table CONFIG functionality via html directive line placed immediately
before a table; supports all previous config settings plus the new TOTAL
suppression config setting. Also restores and documents support for multi-row
parentheses functionality.
- v1.01 — Adds optional prompt to insert example starter tables when no
SmartTables-compatible tables are found

24 days ago

VERSION HISTORY (most recent first):
- v1.1 — Adds debugging output controls (inline and/or at end), adds debug
settings to the per-note config table, and auto-removes prior debug output on
each run.
- v1.01 — Adds an optional prompt when no SmartTables-compatible Markdown tables
are found, allowing the user to choose whether to insert example starter tables
and the configuration settings table

SmartTables v1.11 – Easily add math functionality to markdown tables in Drafts!

This script provides powerful math calculation processing within Markdown tables, allowing you to add spreadsheet-like functionality to your Drafts documents. Usage ranges from performimng simple math within a table (e.g. summing a column), to more complex arithmentic operations and processing using more advanced features such as precedence, grouped expressions, formulas, and variables.

Steps

  • script

    /*
    ───────────────────────────────────────────────────────────────────────────────
    SmartTables v1.11 — Spreadsheet-style math for Markdown tables in Drafts
    ───────────────────────────────────────────────────────────────────────────────
    
    SmartTables adds powerful math processing directly to Markdown tables in Drafts,
    allowing you to model budgets, projections, tax calculations, and structured
    financial workflows without leaving plain text.
    
    Usage ranges from simple summation of an Amount column to advanced arithmetic
    using formulas, reusable variables, grouped expressions, and per-table
    configuration directives.
    
    At the simple end, enter numbers in an Amount column and SmartTables will
    automatically generate and refresh a TOTAL row. If no arithmetic operators are
    present, values are summed automatically. Supported operators include:
    
      +   −   *   /   %
    
    Parentheses ( ) are supported for ordered operations, including multi-row groups.
    
    At the advanced end, SmartTables supports:
    - Inline formulas using [ ... ] in non-Amount cells
    - Reusable variables (@var) shared across tables
    - SUBTOTAL rows
    - Multi-row grouped expressions
    - TOTAL formula overrides
    - Optional TOTAL suppression (per table)
    - Per-table formatting controls via CONFIG directive
    - Variables tables that can appear anywhere in the document
    - Running-total token (#) inside bracket formulas:
      - In any [ ... ] formula, # evaluates to the current running money total
      - Any formula that references # also acts like an inline SUBTOTAL override:
        it REPLACES the running total up to that point (previous rows are “collapsed”)
    
    VERSION HISTORY (most recent first)
    - v1.11 — Adds running-total token (#) inside bracket formulas; any [ ... ] that
              references # replaces the running total up to that point (inline reset).
    - v1.1  — Adds per-table CONFIG functionality via html directive line placed immediately
              before a table; supports all previous config settings plus the new TOTAL
              suppression config setting. Also restores and documents support for multi-row
              parentheses functionality.
    - v1.01 — Adds optional prompt to insert example starter tables when no
              SmartTables-compatible tables are found
    
    ===============================================================================
    QUICKSTART GUIDE (CUT-AND-PASTE EXAMPLE)
    ===============================================================================
    
    | Date | Amount | Item | Notes |
    | ---- | ------:| ---- | ----- |
    | 3/15/2026 | 8500 | New furniture | |
    | 4/15/2026 | 7000 | Interior painting | |
    | 5/15/2026 | 2000 | Appliances | |
    | 6/15/2026 | 5000 | Office remodel | |
    | | 0 | SUBTOTAL @net | |
    | | 0 | TOTAL [@net * 1.055] @gross | |
    
    ===============================================================================
    INLINE FORMULAS + RUNNING TOTAL (#)
    ===============================================================================
    
    - Put a formula in square brackets in any non-Amount cell, e.g.:
      [@income * 20%]
      [(@a + @b) / 2]
    
    Running-total token:
    - In any [ ... ] formula, # means “current running money total”.
    
    Inline reset behavior:
    - If a [ ... ] formula includes #, its result replaces the running total so far.
      This lets you “collapse” prior rows into a computed value.
    
    Examples:
    - [#]           → emits current running total (and resets it to itself; no change)
    - [# * 8]       → multiplies the running total by 8 and replaces it
    - [# + @x]      → adds @x to the running total and replaces it
    
    You may add comments after the formula within the same cell:
      [# * 8] multiplier for calculated running total
    
    ===============================================================================
    MULTI-ROW PARENTHESES (GROUPED EXPRESSIONS ACROSS ROWS)
    ===============================================================================
    
    If an Amount cell contains an opening parenthesis without a matching closing
    parenthesis, SmartTables appends subsequent Amount rows until parentheses balance,
    evaluates the combined expression, and inserts ONE result row immediately after
    the closing-parenthesis row.
    
    The inserted row is labeled:
      #intermediate_result
    
    SmartTables also tags that row internally (HTML comment) and removes previously
    inserted group-result rows on each run so they do not duplicate or compound.
    
    Example:
    
    | Amount | Item |
    | ------:| ---- |
    | (1000 + @bonus | |
    | + [# * 5%]) | |
    | 0 | #intermediate_result |
    
    ===============================================================================
    PER-TABLE CONFIG DIRECTIVE
    ===============================================================================
    
    Place a directive immediately above a table to override settings for that table:
    
    <!-- CONFIG: total=false, decimals=0, currency_symbol='$', thousands_sep=true -->
    
    Supported keys (case-insensitive):
    - total=true|false            (show or suppress TOTAL row)
    - decimals=0–12               (number formatting)
    - currency_symbol=string      (e.g. $, €, '' for none)
    - thousands_sep=true|false    (alias: thousands_separator)
    
    ===============================================================================
    END DOCUMENTATION
    ===============================================================================
    */
    
    
    // =====================
    // Debounce (prevents accidental double-run)
    // =====================
    (() => {
      try {
        const now = Date.now();
        const last = parseInt(draft.getTag("smt_last_run") || "0", 10);
        if (now - last < 800) return;
        draft.setTag("smt_last_run", String(now));
      } catch (e) {}
    })();
    
    // =====================
    // CONFIG (edit aliases here)
    // =====================
    const CONFIG = {
      amountHeaderAliases: ["amount", "value", "cost", "price"],
      itemHeaderAliases:   ["item", "description", "desc", "name"],
      dateHeaderAliases:   ["date", "when"],
      dateMustBeFirstColumn: true,
      warningPrefix: "> ⚠️ ",
      percentVarNameSuffixes: ["_rate", "_pct", "_percent"],
      configDirectiveLookbackLines: 6,
    
      // Marker used to identify auto-inserted group result rows
      groupResultMarker: "<!-- SMT_GROUP_RESULT -->",
      groupResultLabel: "#intermediate_result",
    };
    
    // =====================
    // Default formatting (can be overridden per table via CONFIG directive)
    // =====================
    const DEFAULT_TABLE_FORMAT = {
      decimals: 2,
      thousandsSeparator: true,
      currencySymbol: "$",
    };
    
    // =====================
    // Core state (shared across tables)
    // =====================
    let vars = {};
    
    // =====================
    // Helpers
    // =====================
    function isTableLine(ln) { return /\|.*\|/.test(ln); }
    
    function isSeparatorLine(ln) {
      return /^\s*\|?\s*[-:]+\s*(\|\s*[-:]+\s*)+\|?\s*$/.test(ln);
    }
    
    function parseRow(line) {
      return line.trim().replace(/^\||\|$/g, "").split("|").map(s => s.trim());
    }
    
    function findAllHeaderIndexes(headers, aliases) {
      const lower = headers.map(h => (h || "").trim().toLowerCase());
      const aliasSet = new Set(aliases.map(a => a.toLowerCase()));
      const matches = [];
      for (let i = 0; i < lower.length; i++) if (aliasSet.has(lower[i])) matches.push(i);
      return matches;
    }
    
    function headerIndex(headers, aliases) {
      const matches = findAllHeaderIndexes(headers, aliases);
      return matches.length ? matches[0] : -1;
    }
    
    function parseBoolLike(s) {
      const v = String(s ?? "").trim().toLowerCase();
      if (v === "true" || v === "yes" || v === "1") return true;
      if (v === "false" || v === "no" || v === "0") return false;
      return null;
    }
    
    function escapeRegex(s) {
      return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
    }
    
    function splitIntFrac(s) {
      const i = s.indexOf(".");
      return i === -1 ? [s, ""] : [s.slice(0, i), s.slice(i + 1)];
    }
    
    function formatSeparator(nCols, amountIdx) {
      const cols = Array.from({ length: nCols }, (_, i) => i === amountIdx ? "------:" : "------");
      return "| " + cols.join(" | ") + " |";
    }
    
    function stripBracketed(text) {
      return String(text || "").replace(/\[[^\]]*\]/g, " ");
    }
    
    function normalizeNumberSeparators(s) {
      if (!s) return s;
    
      const hasComma = s.indexOf(",") >= 0;
      const hasDot = s.indexOf(".") >= 0;
    
      if (hasComma && hasDot) {
        const lastComma = s.lastIndexOf(",");
        const lastDot = s.lastIndexOf(".");
        if (lastDot > lastComma) return s.replace(/,/g, "");
        return s.replace(/\./g, "").replace(/,/g, ".");
      }
    
      if (hasComma && !hasDot) {
        if (/,\d{1,2}$/.test(s)) return s.replace(/,/g, ".");
        return s.replace(/,/g, "");
      }
    
      return s;
    }
    
    // =====================
    // Per-table CONFIG directive
    // =====================
    function parseConfigDirectiveLine(line) {
      const m = String(line || "").match(/^\s*<!--\s*CONFIG\s*:\s*(.*?)\s*-->\s*$/i);
      if (!m) return null;
    
      const raw = m[1];
      const parts = raw.split(",").map(p => p.trim()).filter(Boolean);
      const out = {};
    
      for (const part of parts) {
        const kv = part.split("=").map(x => x.trim());
        if (kv.length < 2) continue;
    
        const keyRaw = kv[0].toLowerCase();
        const valRaw = kv.slice(1).join("=").trim();
    
        const key = keyRaw
          .replace(/\s+/g, "_")
          .replace(/thousands_separator/g, "thousands_sep");
    
        let val = valRaw;
    
        const qm = val.match(/^(['"])(.*)\1$/);
        if (qm) val = qm[2];
    
        if (key === "decimals") {
          const n = parseInt(val, 10);
          if (Number.isFinite(n)) out.decimals = n;
          continue;
        }
    
        if (key === "total") {
          const b = parseBoolLike(val);
          if (b !== null) out.total = b;
          continue;
        }
    
        if (key === "thousands_sep") {
          const b = parseBoolLike(val);
          if (b !== null) out.thousandsSeparator = b;
          continue;
        }
    
        if (key === "currency_symbol") {
          out.currencySymbol = val;
          continue;
        }
      }
    
      if (typeof out.decimals === "number") {
        if (out.decimals < 0) out.decimals = 0;
        if (out.decimals > 12) out.decimals = 12;
      }
    
      return out;
    }
    
    function findConfigDirectiveAboveTable(lines, tableStartIndex) {
      const maxLookback = CONFIG.configDirectiveLookbackLines;
      let seen = 0;
    
      for (let k = tableStartIndex - 1; k >= 0 && seen < maxLookback; k--, seen++) {
        const ln = lines[k];
        if (String(ln).trim() === "") continue;
    
        const cfg = parseConfigDirectiveLine(ln);
        if (cfg) return cfg;
    
        break;
      }
      return null;
    }
    
    function applyTableConfig(baseFmt, cfg) {
      const fmt = { ...baseFmt };
      if (!cfg) return { fmt, totalOverride: null };
    
      if (cfg.decimals !== undefined) fmt.decimals = cfg.decimals;
      if (cfg.thousandsSeparator !== undefined) fmt.thousandsSeparator = !!cfg.thousandsSeparator;
      if (cfg.currencySymbol !== undefined) fmt.currencySymbol = cfg.currencySymbol;
    
      const totalOverride = (cfg.total === true || cfg.total === false) ? cfg.total : null;
      return { fmt, totalOverride };
    }
    
    // =====================
    // Formatting (uses per-table format)
    // =====================
    function formatMoney(n, fmt) {
      const sign = n < 0 ? "-" : "";
      const abs = Math.abs(n);
    
      const numStr = abs.toLocaleString(undefined, {
        minimumFractionDigits: fmt.decimals,
        maximumFractionDigits: fmt.decimals,
        useGrouping: !!fmt.thousandsSeparator,
      });
    
      const sym = fmt.currencySymbol ?? "";
      return sym === "" ? (sign + numStr) : (sign + sym + numStr);
    }
    
    function formatPercent(n, fmt) {
      const sign = n < 0 ? "-" : "";
      const abs = Math.abs(n) * 100;
    
      const numStr = abs.toLocaleString(undefined, {
        minimumFractionDigits: fmt.decimals,
        maximumFractionDigits: fmt.decimals,
        useGrouping: !!fmt.thousandsSeparator,
      });
    
      return sign + numStr + "%";
    }
    
    // =====================
    // Variables + formula detection
    // =====================
    function extractVarNamesFromRow(row, amountIdx) {
      const names = [];
      const re = /@([A-Za-z0-9_]+)/g;
      for (let j = 0; j < row.length; j++) {
        if (j === amountIdx) continue;
        const cell = row[j] || "";
        let m;
        while ((m = re.exec(cell)) !== null) {
          const name = m[1];
          if (!names.includes(name)) names.push(name);
        }
      }
      return names;
    }
    
    function extractVarNamesOutsideBracketsFromRow(row, amountIdx) {
      const names = [];
      const re = /@([A-Za-z0-9_]+)/g;
      for (let j = 0; j < row.length; j++) {
        if (j === amountIdx) continue;
        const cell = stripBracketed(row[j] || "");
        let m;
        while ((m = re.exec(cell)) !== null) {
          const name = m[1];
          if (!names.includes(name)) names.push(name);
        }
      }
      return names;
    }
    
    function isPercentVarName(name) {
      const n = String(name || "").toLowerCase();
      return CONFIG.percentVarNameSuffixes.some(suf => n.endsWith(suf));
    }
    
    function rowWantsPercentDisplay(rawAmountCell, forcedVarNames) {
      if (/%/.test(String(rawAmountCell || ""))) return true;
      if (forcedVarNames && forcedVarNames.length === 1 && isPercentVarName(forcedVarNames[0])) return true;
      return false;
    }
    
    function findFormulaInRow(row, amountIdx) {
      let defName = null, defFormula = null, plainFormula = null;
    
      for (let j = 0; j < row.length; j++) {
        if (j === amountIdx) continue;
        const cell = row[j] || "";
    
        if (defName === null) {
          const mDef = cell.match(/@([A-Za-z0-9_]+)\s*=\s*\[([^\]]+)\]/);
          if (mDef) { defName = mDef[1]; defFormula = mDef[2]; }
        }
        if (plainFormula === null) {
          const mPlain = cell.match(/\[([^\]]+)\]/);
          if (mPlain) plainFormula = mPlain[1];
        }
      }
    
      if (defName !== null && defFormula !== null) return { type: "defFormula", name: defName, formula: defFormula };
      if (plainFormula !== null) return { type: "formula", formula: plainFormula };
      return null;
    }
    
    function isSubtotalRow(row, itemIdx) {
      if (itemIdx < 0 || itemIdx >= row.length) return false;
      const cell = (row[itemIdx] || "").trim();
      return /^SUBTOTAL\b/i.test(cell);
    }
    
    function isTotalRowText(row) {
      return row.some(c => /^totals?\b/i.test((c || "").trim()));
    }
    
    function findBracketFormulaInNonAmountCells(row, amountIdx) {
      for (let j = 0; j < row.length; j++) {
        if (j === amountIdx) continue;
        const cell = row[j] || "";
        const m = cell.match(/\[([^\]]+)\]/);
        if (m) return m[1];
      }
      return null;
    }
    
    function formulaUsesRunningTotalToken(formula) {
      return /(^|[^A-Za-z0-9_])#([^A-Za-z0-9_]|$)/.test(String(formula || ""));
    }
    
    // =====================
    // Date normalization
    // =====================
    function normalizeDateCell(raw) {
      if (raw == null) return raw;
      let s = String(raw).trim();
      if (!s) return s;
    
      const now = new Date();
      const yyyy = now.getFullYear();
      let m, d, y;
    
      const mdy = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})$/);
      if (mdy) {
        m = parseInt(mdy[1], 10);
        d = parseInt(mdy[2], 10);
        y = parseInt(mdy[3], 10);
        y = y < 100 ? (2000 + y) : y;
      } else {
        const md = s.match(/^(\d{1,2})\/(\d{1,2})$/);
        if (!md) return s;
        m = parseInt(md[1], 10);
        d = parseInt(md[2], 10);
        y = yyyy;
      }
    
      if (m < 1 || m > 12 || d < 1 || d > 31 || y < 1000 || y > 9999) return s;
      return `${m}/${d}/${y}`;
    }
    
    // =====================
    // Expression evaluation
    // =====================
    function normalizeExpr(raw, fmtForInput, runningTotalForLeadingOps) {
      if (!raw) return "";
      let s = String(raw).trim();
    
      // Replace # with running total (only meaningful in formulas / special cases)
      if (runningTotalForLeadingOps !== undefined && runningTotalForLeadingOps !== null) {
        s = s.replace(/(^|[^A-Za-z0-9_])#([^A-Za-z0-9_]|$)/g, (m, a, b) => `${a}${runningTotalForLeadingOps}${b}`);
      }
    
      // Substitute @vars -> numeric values (undefined -> 0)
      s = s.replace(/@([A-Za-z0-9_]+)/g, (m, name) => {
        if (Object.prototype.hasOwnProperty.call(vars, name)) return String(vars[name]);
        return "0";
      });
    
      // Remove currency symbols from input (table symbol + literal '$')
      const sym = fmtForInput?.currencySymbol ?? DEFAULT_TABLE_FORMAT.currencySymbol;
      if (sym) s = s.replace(new RegExp(escapeRegex(sym), "g"), "");
      s = s.replace(/[$]/g, "");
    
      s = s
        .replace(/[\u2212\u2013\u2014]/g, "-")
        .replace(/×/g, "*")
        .replace(/÷/g, "/")
        .replace(/(?<=\d)\s*[xX]\s*(?=\d)/g, "*")
        .replace(/\s+/g, " ")
        .trim();
    
      // Percent support
      s = s.replace(/(\d+(?:[.,]\d+)?)\s*%\s*of\s*(\d+(?:[.,]\d+)?)/gi, "($1/100)*$2");
      s = s.replace(/(\d+(?:[.,]\d+)?)\s*%/g, "($1/100)");
    
      s = normalizeNumberSeparators(s);
      return s;
    }
    
    function validExpr(s) {
      return /^[0-9+\-*/().\s]*$/.test(s);
    }
    
    function parenBalance(s) {
      let b = 0;
      for (const ch of s) {
        if (ch === "(") b++;
        else if (ch === ")") b--;
      }
      return b;
    }
    
    // Evaluate a normal expression (no leading * / special handling)
    function evalExpr(raw, fmtForInput) {
      const s = normalizeExpr(raw, fmtForInput, null);
      if (!s || !validExpr(s)) return NaN;
      try {
        if (!/^\s*[-+]?(\d|\(|\.)/.test(s)) return NaN;
        const v = eval(s);
        return (typeof v === "number" && isFinite(v)) ? v : NaN;
      } catch {
        return NaN;
      }
    }
    
    // Evaluate a bracket formula; supports # running total
    function evalFormulaExpr(formula, fmtForInput, runningTotal) {
      const s = normalizeExpr(formula, fmtForInput, runningTotal);
      if (!s || !validExpr(s)) return NaN;
      try {
        if (!/^\s*[-+]?(\d|\(|\.)/.test(s)) return NaN;
        const v = eval(s);
        return (typeof v === "number" && isFinite(v)) ? v : NaN;
      } catch {
        return NaN;
      }
    }
    
    // Evaluate an Amount cell; supports leading * / % as operations on running total
    function evalAmountCell(rawAmount, fmtForInput, runningTotal) {
      const raw = String(rawAmount || "").trim();
      if (!raw) return NaN;
    
      // Leading operator behavior: "*8" "/2" "%50"
      const mLead = raw.match(/^\s*([*/%])\s*(.+?)\s*$/);
      if (mLead) {
        const op = mLead[1];
        const rest = mLead[2];
    
        const rhs = evalExpr(rest, fmtForInput);
        if (!isFinite(rhs)) return NaN;
    
        if (op === "*") return runningTotal * rhs;
        if (op === "/") return runningTotal / rhs;
        if (op === "%") return runningTotal * (rhs / 100);
      }
    
      // Otherwise, evaluate normally
      return evalExpr(raw, fmtForInput);
    }
    
    function needsPlus(prev, next) {
      const a = (prev || "").trim();
      const b = (next || "").trim();
      const prevEndsOpOrOpen = /[+\-*/(]$/.test(a);
      const nextStartsOpOrClose = /^[+\-*/)]/.test(b);
      const prevEndsNumOrClose = /[\d)]$/.test(a);
      const nextStartsNumOrOpenOrDot = /^(\d|\(|\.)/.test(b);
      return !prevEndsOpOrOpen && !nextStartsOpOrClose && prevEndsNumOrClose && nextStartsNumOrOpenOrDot;
    }
    
    // =====================
    // Warning banner builder
    // =====================
    function buildAmbiguityWarning(amountMatches, itemMatches) {
      const parts = [];
      if (amountMatches.length > 1) parts.push(`multiple Amount columns match aliases (indexes: ${amountMatches.join(", ")})`);
      if (itemMatches.length > 1) parts.push(`multiple Item columns match aliases (indexes: ${itemMatches.join(", ")})`);
      if (!parts.length) return null;
      return CONFIG.warningPrefix + "Table skipped: " + parts.join("; ") + ". Rename headers or adjust CONFIG aliases.";
    }
    
    // =====================
    // Remove previously inserted group result rows inside a table body
    // =====================
    function stripGroupResultRows(bodyRows) {
      const marker = CONFIG.groupResultMarker;
      return bodyRows.filter(row => !row.some(cell => String(cell || "").includes(marker)));
    }
    
    // =====================
    // Table processing
    // =====================
    function processTable(rawLines, tableFmt, totalDirectiveOverride) {
      if (rawLines.length < 2) return { ok: true, lines: rawLines, warning: null };
    
      const header = parseRow(rawLines[0]);
      const nCols = header.length;
    
      const amountMatches = findAllHeaderIndexes(header, CONFIG.amountHeaderAliases);
      const itemMatches   = findAllHeaderIndexes(header, CONFIG.itemHeaderAliases);
    
      const warning = buildAmbiguityWarning(amountMatches, itemMatches);
      if (warning) return { ok: false, lines: rawLines, warning };
    
      const amountIdx = amountMatches.length ? amountMatches[0] : -1;
      const itemIdx   = itemMatches.length ? itemMatches[0] : -1;
      if (amountIdx === -1 || itemIdx === -1) return { ok: true, lines: rawLines, warning: null };
    
      const dateMatches = findAllHeaderIndexes(header, CONFIG.dateHeaderAliases);
      const dateIdxFound = dateMatches.length ? dateMatches[0] : -1;
      const hasDateCol = CONFIG.dateMustBeFirstColumn ? (dateIdxFound === 0) : (dateIdxFound !== -1);
      const dateIdx = hasDateCol ? dateIdxFound : -1;
    
      let bodyStart = 1;
      while (bodyStart < rawLines.length && isSeparatorLine(rawLines[bodyStart])) bodyStart++;
    
      let body = rawLines.slice(bodyStart).map(parseRow);
      body = stripGroupResultRows(body); // prevent duplication across runs
    
      // Remove existing TOTAL/TOTALS row (save as template)
      let totalTemplate = null;
      for (let idx = body.length - 1; idx >= 0; idx--) {
        const row = body[idx];
        if (isTotalRowText(row)) {
          totalTemplate = row.slice();
          body.splice(idx, 1);
          break;
        }
      }
    
      let runningMoneySum = 0;
    
      const rebuilt = [];
      let allDefNonBlankRows = true;
      let anyNonBlankRow = false;
    
      function rowIsNonBlank(row) { return row.some(c => (c || "").trim() !== ""); }
    
      function setAmountDisplay(row, val, displayMode) {
        row[amountIdx] = (displayMode === "percent") ? formatPercent(val, tableFmt) : formatMoney(val, tableFmt);
      }
    
      function finalizeRow(row, val, forcedNames, isSubtotal, displayMode, addToMoneySum) {
        setAmountDisplay(row, val, displayMode);
    
        const nonBlank = rowIsNonBlank(row);
        if (nonBlank) anyNonBlankRow = true;
    
        let names;
        if (forcedNames !== undefined && forcedNames !== null) names = forcedNames;
        else names = extractVarNamesFromRow(row, amountIdx);
    
        if (names && names.length) names.forEach(name => { vars[name] = val; });
        else if (nonBlank) allDefNonBlankRows = false;
    
        if (addToMoneySum && !isSubtotal && nonBlank) runningMoneySum += val;
        rebuilt.push(row);
      }
    
      for (let i = 0; i < body.length; i++) {
        const row = body[i].slice();
        if (dateIdx !== -1 && dateIdx < row.length) row[dateIdx] = normalizeDateCell(row[dateIdx]);
    
        const subtotalRow = isSubtotalRow(row, itemIdx);
        const rawAmtCell = (row[amountIdx] || "");
    
        // 1) Inline formulas in non-Amount cells
        const formulaInfo = findFormulaInRow(row, amountIdx);
        if (formulaInfo) {
          const usesHash = formulaUsesRunningTotalToken(formulaInfo.formula);
          const val = evalFormulaExpr(formulaInfo.formula, tableFmt, runningMoneySum);
    
          if (isFinite(val)) {
            const forced = (formulaInfo.type === "defFormula") ? [formulaInfo.name] : [];
            const wantsPct = rowWantsPercentDisplay(rawAmtCell, forced);
            const mode = wantsPct ? "percent" : "money";
    
            // If formula uses #, treat as inline running-total reset (money only)
            if (usesHash && mode === "money") {
              finalizeRow(row, val, forced, false, mode, false);
              runningMoneySum = val; // collapse prior rows into this value
            } else {
              const addToMoneySum = !wantsPct;
              finalizeRow(row, val, forced, subtotalRow, mode, addToMoneySum);
            }
          } else {
            rebuilt.push(row);
            if (rowIsNonBlank(row)) { anyNonBlankRow = true; allDefNonBlankRows = false; }
          }
          continue;
        }
    
        // 2) SUBTOTAL rows always recalc (unless they have [ ... ])
        if (subtotalRow) {
          const hasBracket = findBracketFormulaInNonAmountCells(row, amountIdx) !== null;
          if (!hasBracket) {
            finalizeRow(row, runningMoneySum, null, true, "money", false);
            continue;
          }
        }
    
        let norm = normalizeExpr(rawAmtCell, tableFmt, null);
        if (!norm) {
          rebuilt.push(row);
          if (rowIsNonBlank(row)) { anyNonBlankRow = true; allDefNonBlankRows = false; }
          continue;
        }
    
        // 3) Multi-row parentheses grouping across Amount rows
        let expr = norm;
        let bal = parenBalance(expr);
        let endIndex = i;
    
        while (bal > 0 && endIndex + 1 < body.length) {
          endIndex++;
          const nextRow = body[endIndex];
          const nextRaw = (nextRow[amountIdx] || "");
          const nextNorm = normalizeExpr(nextRaw, tableFmt, null);
    
          if (dateIdx !== -1 && dateIdx < nextRow.length) {
            const nrCopy = nextRow.slice();
            nrCopy[dateIdx] = normalizeDateCell(nrCopy[dateIdx]);
            body[endIndex] = nrCopy;
          }
    
          if (!nextNorm) { expr += " "; continue; }
          expr += (needsPlus(expr, nextNorm) ? " + " : " ") + nextNorm;
          bal += parenBalance(nextNorm);
        }
    
        if (bal === 0) {
          const vGroup = evalExpr(expr, tableFmt);
          if (isFinite(vGroup) && endIndex > i) {
            // Preserve original rows
            for (let k = i; k <= endIndex; k++) {
              const orig = body[k].slice();
              if (dateIdx !== -1 && dateIdx < orig.length) orig[dateIdx] = normalizeDateCell(orig[dateIdx]);
              rebuilt.push(orig);
              if (rowIsNonBlank(orig)) { anyNonBlankRow = true; allDefNonBlankRows = false; }
            }
    
            // Insert a tagged result row so we can remove it on reruns
            const resultRow = new Array(nCols).fill("");
            resultRow[itemIdx] = `${CONFIG.groupResultLabel} ${CONFIG.groupResultMarker}`;
            finalizeRow(resultRow, vGroup, null, false, "money", true);
    
            i = endIndex;
            continue;
          }
        }
    
        // 4) Normal Amount cell evaluation (supports leading * / % as running-total ops)
        const v = evalAmountCell(rawAmtCell, tableFmt, runningMoneySum);
        if (isFinite(v)) {
          const names = extractVarNamesFromRow(row, amountIdx);
          const wantsPct = rowWantsPercentDisplay(rawAmtCell, (names.length === 1 ? names : null));
          const mode = wantsPct ? "percent" : "money";
          const addToMoneySum = !wantsPct;
          finalizeRow(row, v, null, subtotalRow, mode, addToMoneySum);
        } else {
          rebuilt.push(row);
          if (rowIsNonBlank(row)) { anyNonBlankRow = true; allDefNonBlankRows = false; }
        }
      }
    
      // Default: suppress TOTAL for pure variables tables
      let suppressTotal = allDefNonBlankRows && anyNonBlankRow;
    
      // Directive override:
      if (totalDirectiveOverride === false) suppressTotal = true;
      if (totalDirectiveOverride === true) suppressTotal = false;
    
      let totalOverrideFormula = null;
      if (totalTemplate) totalOverrideFormula = findBracketFormulaInNonAmountCells(totalTemplate, amountIdx);
    
      let moneyTotal = runningMoneySum;
    
      let totalRow = null;
      if (!suppressTotal) {
        totalRow = new Array(nCols).fill("");
    
        if (totalTemplate) {
          for (let j = 0; j < nCols; j++) {
            if (j === amountIdx) continue;
            totalRow[j] = totalTemplate[j] || "";
          }
        } else {
          totalRow[itemIdx] = "TOTAL";
        }
    
        if (totalOverrideFormula) {
          const usesHash = formulaUsesRunningTotalToken(totalOverrideFormula);
          const overrideVal = usesHash
            ? evalFormulaExpr(totalOverrideFormula, tableFmt, runningMoneySum)
            : evalExpr(totalOverrideFormula, tableFmt);
    
          if (isFinite(overrideVal)) moneyTotal = overrideVal;
        }
    
        totalRow[amountIdx] = formatMoney(moneyTotal, tableFmt);
    
        const storeVars = totalTemplate
          ? extractVarNamesOutsideBracketsFromRow(totalTemplate, amountIdx)
          : extractVarNamesOutsideBracketsFromRow(totalRow, amountIdx);
    
        storeVars.forEach(name => { vars[name] = moneyTotal; });
      }
    
      // Alignment padding for Amount column (supports $ and %)
      const amountCells = suppressTotal
        ? rebuilt.map(r => r[amountIdx])
        : [...rebuilt.map(r => r[amountIdx]), totalRow[amountIdx]];
    
      const targetIntWidth = Math.max(...amountCells.map(s => {
        const t = String(s || "");
        const clean = t.replace(/[%]/g, "");
        return clean ? splitIntFrac(clean)[0].length : 0;
      }), 0);
    
      function padAmountCell(cell) {
        const t = String(cell || "");
        const isPct = /%$/.test(t);
        const clean = t.replace(/%/g, "");
        const [ip] = splitIntFrac(clean);
        const pad = " ".repeat(Math.max(0, targetIntWidth - ip.length));
        return isPct ? (pad + clean + "%") : (pad + t);
      }
    
      const paddedBody = rebuilt.map(r => {
        const rr = r.slice();
        rr[amountIdx] = padAmountCell(rr[amountIdx]);
        if (dateIdx !== -1 && dateIdx < rr.length) rr[dateIdx] = normalizeDateCell(rr[dateIdx]);
        return rr;
      });
    
      if (!suppressTotal && totalRow) totalRow[amountIdx] = padAmountCell(totalRow[amountIdx]);
    
      const out = [];
      out.push("| " + header.join(" | ") + " |");
      out.push(formatSeparator(nCols, amountIdx));
      for (const r of paddedBody) out.push("| " + r.join(" | ") + " |");
      if (!suppressTotal && totalRow) out.push("| " + totalRow.join(" | ") + " |");
    
      return { ok: true, lines: out, warning: null };
    }
    
    // =====================
    // Full-document variable sweep (variables table can be anywhere)
    // =====================
    function preScanVariablesFromDocument(fullText) {
      const lines = String(fullText || "").split("\n");
    
      const numericDefs = [];
      const formulaDefs = [];
    
      for (let i = 0; i < lines.length;) {
        if (!isTableLine(lines[i])) { i++; continue; }
    
        const start = i;
        let j = i;
        while (j < lines.length && isTableLine(lines[j])) j++;
        const block = lines.slice(start, j);
    
        if (block.length < 2) { i = j; continue; }
    
        const header = parseRow(block[0]);
    
        const amountMatches = findAllHeaderIndexes(header, CONFIG.amountHeaderAliases);
        const itemMatches   = findAllHeaderIndexes(header, CONFIG.itemHeaderAliases);
        if (amountMatches.length !== 1 || itemMatches.length !== 1) { i = j; continue; }
    
        const amountIdx = amountMatches[0];
        const itemIdx = itemMatches[0];
    
        let bodyStart = 1;
        while (bodyStart < block.length && isSeparatorLine(block[bodyStart])) bodyStart++;
    
        // Read rows, ignoring previously inserted group results
        const bodyRows = stripGroupResultRows(block.slice(bodyStart).map(parseRow));
    
        for (let r = 0; r < bodyRows.length; r++) {
          const row = bodyRows[r];
          if (!row.length) continue;
    
          if (isTotalRowText(row)) continue;
          if (isSubtotalRow(row, itemIdx)) continue;
    
          // @name = [formula]
          let def = null;
          for (let c = 0; c < row.length; c++) {
            if (c === amountIdx) continue;
            const cell = row[c] || "";
            const m = cell.match(/@([A-Za-z0-9_]+)\s*=\s*\[([^\]]+)\]/);
            if (m) { def = { name: m[1], formula: m[2] }; break; }
          }
          if (def) {
            formulaDefs.push(def);
            continue;
          }
    
          // Simple @name: one @var outside brackets in non-Amount cells
          const names = extractVarNamesOutsideBracketsFromRow(row, amountIdx);
          if (names.length === 1) {
            const rawAmt = row[amountIdx] || "";
            const v = evalExpr(rawAmt, DEFAULT_TABLE_FORMAT);
            if (isFinite(v)) numericDefs.push({ name: names[0], value: v });
          }
        }
    
        i = j;
      }
    
      numericDefs.forEach(d => { vars[d.name] = d.value; });
    
      for (let iter = 0; iter < 12; iter++) {
        let changed = false;
        for (const d of formulaDefs) {
          // Note: variable sweep doesn't have a running total; treat # as 0 here
          const v = evalFormulaExpr(d.formula, DEFAULT_TABLE_FORMAT, 0);
          if (!isFinite(v)) continue;
          if (!Object.prototype.hasOwnProperty.call(vars, d.name) || Math.abs(vars[d.name] - v) > 1e-12) {
            vars[d.name] = v;
            changed = true;
          }
        }
        if (!changed) break;
      }
    }
    
    // =====================
    // Drafts prompt helper
    // =====================
    function askToInsertStarterTables() {
      const p = new Prompt();
      p.title = "SmartTables";
      p.message = "No SmartTables compatible markdown tables were found.\n\nInsert example starter tables?";
      p.addButton("Insert");
      p.addButton("Cancel");
      const ok = p.show();
      return ok && p.buttonPressed === "Insert";
    }
    
    // =====================
    // Starter tables insertion (includes CONFIG directive example; uses hyphen dashes)
    // =====================
    function buildStarterTablesText() {
      const AmountH = "Amount";
      const ItemH   = "Item";
      const DateH   = "Date";
      const NotesH  = "Notes";
    
      const fmt = { ...DEFAULT_TABLE_FORMAT };
      const amt0 = formatMoney(0, fmt);
      const [ip] = splitIntFrac(amt0);
      const pad0 = s => " ".repeat(Math.max(0, ip.length - splitIntFrac(s)[0].length)) + s;
    
      function makeStarter(headers, specialTotalPlacement) {
        const amountIdx = headerIndex(headers, CONFIG.amountHeaderAliases);
        const itemIdx   = headerIndex(headers, CONFIG.itemHeaderAliases);
        const dateIdx   = headerIndex(headers, CONFIG.dateHeaderAliases);
    
        const sep = formatSeparator(headers.length, amountIdx);
    
        const row = new Array(headers.length).fill("");
        row[amountIdx] = pad0(amt0);
        if (itemIdx !== -1) row[itemIdx] = "Example";
    
        const tot = new Array(headers.length).fill("");
        tot[amountIdx] = pad0(amt0);
    
        if (specialTotalPlacement === "date" && dateIdx !== -1) tot[dateIdx] = "TOTAL";
        else if (itemIdx !== -1) tot[itemIdx] = "TOTAL";
    
        return [
          "| " + headers.join(" | ") + " |",
          sep,
          "| " + row.join(" | ") + " |",
          "| " + tot.join(" | ") + " |"
        ];
      }
    
      const blocks = [];
    
      blocks.push("EXAMPLE TABLES:  (modify/delete sample tables as desired)");
      blocks.push("");
    
      blocks.push(...makeStarter([AmountH, ItemH]));
      blocks.push("");
    
      blocks.push("<!-- CONFIG: total=false, decimals=0, currency_symbol='$', thousands_sep=true -->");
      blocks.push(...makeStarter([DateH, AmountH], "date"));
      blocks.push("");
    
      blocks.push(...makeStarter([DateH, AmountH, ItemH]));
      blocks.push("");
    
      blocks.push(...makeStarter([DateH, AmountH, ItemH, NotesH]));
    
      return blocks.join("\n");
    }
    
    // =====================
    // Main
    // =====================
    let text = editor.getText();
    
    // 1) Preload variables from any table anywhere
    preScanVariablesFromDocument(text);
    
    // 2) Process tables in document order
    let lines = text.split("\n");
    let out = [];
    let foundEligible = false;
    
    for (let i = 0; i < lines.length;) {
      if (!isTableLine(lines[i])) { out.push(lines[i++]); continue; }
    
      let start = i, j = i;
      while (j < lines.length && isTableLine(lines[j])) j++;
    
      const block = lines.slice(start, j);
    
      // Eligible SmartTables table => Amount+Item by aliases
      const header = parseRow(block[0] || "");
      const amountMatches = findAllHeaderIndexes(header, CONFIG.amountHeaderAliases);
      const itemMatches   = findAllHeaderIndexes(header, CONFIG.itemHeaderAliases);
    
      const isEligible = (amountMatches.length === 1 && itemMatches.length === 1);
      if (isEligible) foundEligible = true;
    
      // Per-table directive
      const directive = findConfigDirectiveAboveTable(lines, start);
      const { fmt: tableFmt, totalOverride } = applyTableConfig(DEFAULT_TABLE_FORMAT, directive);
    
      const res = processTable(block, tableFmt, totalOverride);
    
      if (!res.ok && res.warning) {
        out.push(res.warning);
        out.push(...res.lines);
      } else {
        out.push(...res.lines);
      }
    
      i = j;
    }
    
    // 3) If no eligible tables, offer to insert starter tables
    if (!foundEligible) {
      if (askToInsertStarterTables()) {
        const insertion = buildStarterTablesText();
    
        const cursorPos = editor.getSelectedRange()[0];
        const cursorLine = text.slice(0, cursorPos).split("\n").length - 1;
    
        const before = lines.slice(0, cursorLine);
        const after  = lines.slice(cursorLine);
        const needsBlank = before.length && before[before.length - 1].trim() !== "";
    
        const combined = [...before];
        if (needsBlank) combined.push("");
        combined.push(insertion);
        if (after.length && after[0].trim() !== "") combined.push("");
        combined.push(...after);
    
        editor.setText(combined.join("\n"));
        app.displayInfoMessage("Inserted starter tables.");
      } else {
        app.displayInfoMessage("No tables updated.");
      }
    } else {
      editor.setText(out.join("\n"));
      app.displayInfoMessage("Tables found and updated in document.");
    }

Options

  • After Success Default
    Notification Info
    Log Level Info
Items available in the Drafts Directory are uploaded by community members. Use appropriate caution reviewing downloaded items before use.