Action

Calculations in md Tables

Last update 4 days ago

UPDATES

4 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.1 – 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.1 — 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 performing simple math within a table (e.g. summing a column),
    to more complex arithmetic operations using advanced features such as operator
    precedence, grouped expressions, formulas, and reusable variables.
    
    At the simple end, you can put numbers and arithmetic operators in an Amount
    column within the rows of a Markdown table, and the script will generate and
    update a TOTAL row at the end of the table. If you list numbers with no math
    operators, SmartTables assumes addition and simply sums the values in the Amount
    column. It supports +, −, *, /, and % operators, as well as parentheses ( ) for
    specifying operator precedence.
    
    At the more advanced end, SmartTables supports inline formulas, reusable variables
    across multiple tables in the same note, multi-row grouped expressions,
    SUBTOTALs, configurable column layouts and aliases, optional per-note formatting
    overrides, and reusable “variables tables” that can appear anywhere in the draft.
    These features allow you to model budgets, projections, tax calculations, and
    other structured math workflows directly inside Markdown tables.
    
    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.
    
    ===============================================================================
    DEBUGGING (HOW TO USE IT)
    ===============================================================================
    
    Enable debugging per-note with the Config table (anywhere in the draft):
    
    | Value | Config Item |
    | -----:| ----------- |
    | true  | @debug_enabled |
    | true  | @debug_inline |
    | true  | @debug_at_end |
    
    Meaning:
    - @debug_enabled: master on/off
    - @debug_inline: inserts debug blocks near where tables were processed
    - @debug_at_end: appends a single debug section at the end of the note
    
    Notes:
    - Debug output is automatically stripped at the start of each run.
    - If @debug_enabled is true but both inline and atEnd are false, no debug output
      is shown (but debug events are still collected internally).
    
    ===============================================================================
    CORE CONCEPTS (HOW IT THINKS)
    ===============================================================================
    
    • Tables are processed top-to-bottom, row-by-row.
    • Variables are global within a draft.
    • Before updating tables, SmartTables performs a full-document variable sweep
      across ALL tables, allowing variables to be defined anywhere (even at the end).
    
    ===============================================================================
    QUICKSTART GUIDE (CUT-AND-PASTE EXAMPLE)
    ===============================================================================
    Copy/paste this table into a Draft and run the script:
    
    | Date | Amount | Item | Notes |
    | ------ | ------: | ------ | ------ |
    | 3/15/2026 | $8,500.00 | New furniture | |
    | 4/15/2026 | $7,000.00 | Interior painting | |
    | 5/15/2026 | $2,000.00 | Appliances | |
    | 6/15/2026 | $5,000.00 | Office remodel | |
    |  | $0.00 | SUBTOTAL @2026_net | Net total |
    |  | $0.00 | TOTAL [@2026_net * 1.055] @2026_gross | With 5.5% tax |
    
    ===============================================================================
    ONE-PAGE CHEAT SHEET (SYNTAX QUICK REFERENCE)
    ===============================================================================
    
    AMOUNT CELL:
    - 100            → adds 100
    - -25            → subtracts 25
    - 10 * 2         → arithmetic supported
    - 20%            → treated as 0.20 internally
    - $1,234.56      → currency symbols and separators accepted
    
    INLINE FORMULA:
    - Put a formula inside square brackets in a non-Amount cell: [ ... ]
      Examples:
      [100 * 1.05]
      [@income * 20%]
      [(@a + @b) / 2]
    
    VARIABLE DEFINITIONS:
    - Simple: Amount cell holds value; Item cell contains @var
    - Formula: @net = [@gross - @tax]
    - Formula-only row (computed, not stored): [@net / 12]
    
    SUBTOTAL:
    - Item begins with SUBTOTAL
    - Always recalculates from the running sum above (unless SUBTOTAL has [ ... ])
    - SUBTOTAL rows do NOT contribute to TOTAL (prevents double counting)
    
    TOTAL:
    - Row is recognized if any cell starts with TOTAL or TOTALS
    - TOTAL variable storage: TOTAL @my_total
    - TOTAL formula override (Option 1):
      TOTAL [ ... ] @my_total
      Example: TOTAL [@net / @divisor] @gross
    
    PERCENT DISPLAY:
    - If Amount cell contains % OR a variable name ends with _rate/_pct/_percent,
      it is displayed as a percent while stored as a fraction.
    - Percent rows do not contribute to money totals.
    
    ===============================================================================
    PER-NOTE OUTPUT FORMATTING (DEFAULTS + CONFIG TABLE OVERRIDES)
    ===============================================================================
    
    Defaults:
    - Decimals: 2
    - Thousands separators: enabled
    - Currency symbol: "$"
    
    Optional Config table (anywhere in draft):
    
    | Value | Config Item |
    | -----:| ----------- |
    | 2     | @decimals |
    | true  | @thousands_separator |
    | $     | @currency_symbol |
    | false | @debug_enabled |
    | false | @debug_inline |
    | false | @debug_at_end |
    
    Supported config items (case-insensitive; leading @ optional):
    - decimals (0–12)
    - thousands_separator (true/false/yes/no/1/0)
    - currency_symbol (any string; empty = no symbol)
    - debug_enabled (true/false)
    - debug_inline (true/false)
    - debug_at_end (true/false)
    
    ===============================================================================
    AUTO-GENERATED “STARTER” TABLES (WHEN NO TABLES EXIST IN THE DRAFT)
    ===============================================================================
    
    If you run SmartTables in a Draft that contains no Markdown tables, the script
    prompts whether you want to insert example starter tables and a configuration
    settings table.
    
    If accepted, it inserts:
    
    EXAMPLE TABLES:  (modify/delete sample tables as desired)
    
    1) Amount + Item
    2) Date + Amount
    3) Date + Amount + Item
    4) Date + Amount + Item + Notes
    
    (two blank lines)
    
    CONFIGURATION SETTINGS:
    
    A Config table containing default formatting + debug settings.
    
    ===============================================================================
    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,
    
      configValueHeaderAliases: ["value"],
      configItemHeaderAliases: ["config item", "config_item", "config"],
    
      warningPrefix: "> ⚠️ ",
    
      // Percent heuristics for variable names:
      percentVarNameSuffixes: ["_rate", "_pct", "_percent"],
    
      // Debug output markers (used for stripping)
      debugBlockStart: "> [SmartTables DEBUG] BEGIN",
      debugBlockEnd: "> [SmartTables DEBUG] END",
      debugAtEndStart: "SMARTTABLES DEBUG SUMMARY (AUTO-GENERATED)",
      debugAtEndEnd: "END SMARTTABLES DEBUG SUMMARY",
    };
    
    
    // =====================
    // Per-note formatting defaults (overridden by config table)
    // =====================
    const DEFAULT_NOTE_FORMAT = {
      decimals: 2,
      thousandsSeparator: true,
      currencySymbol: "$",
    
      // Debug config defaults
      debugEnabled: false,
      debugInline: false,
      debugAtEnd: false,
    };
    
    let NOTE_FORMAT = { ...DEFAULT_NOTE_FORMAT };
    
    
    // =====================
    // Core state (shared across tables)
    // =====================
    let vars = {};
    
    
    // =====================
    // Debugging
    // =====================
    let DEBUG_EVENTS = [];
    function dbg(event, data) {
      // Always collect events; whether they render is controlled by NOTE_FORMAT flags.
      try {
        DEBUG_EVENTS.push({
          t: new Date().toISOString(),
          event: String(event || ""),
          data: (data === undefined ? "" : String(data)),
        });
      } catch (e) {}
    }
    
    function renderInlineDebugBlock(lines) {
      if (!NOTE_FORMAT.debugEnabled || !NOTE_FORMAT.debugInline) return [];
      const out = [];
      out.push(CONFIG.debugBlockStart);
      for (const ln of lines) out.push("> " + ln);
      out.push(CONFIG.debugBlockEnd);
      return out;
    }
    
    function renderAtEndDebugSection() {
      if (!NOTE_FORMAT.debugEnabled || !NOTE_FORMAT.debugAtEnd) return [];
      const out = [];
      out.push("");
      out.push(CONFIG.debugAtEndStart);
      out.push("------------------------------------------------------------");
      for (const e of DEBUG_EVENTS) {
        out.push(`${e.t} | ${e.event}${e.data ? " | " + e.data : ""}`);
      }
      out.push("------------------------------------------------------------");
      out.push(CONFIG.debugAtEndEnd);
      out.push("");
      return out;
    }
    
    function stripPreviousDebugOutput(fullText) {
      const lines = String(fullText || "").split("\n");
      const out = [];
      let inInline = false;
      let inAtEnd = false;
    
      for (let i = 0; i < lines.length; i++) {
        const ln = lines[i];
    
        if (!inInline && ln.trim() === CONFIG.debugBlockStart) { inInline = true; continue; }
        if (inInline && ln.trim() === CONFIG.debugBlockEnd) { inInline = false; continue; }
        if (inInline) continue;
    
        if (!inAtEnd && ln.trim() === CONFIG.debugAtEndStart) { inAtEnd = true; continue; }
        if (inAtEnd && ln.trim() === CONFIG.debugAtEndEnd) { inAtEnd = false; continue; }
        if (inAtEnd) continue;
    
        out.push(ln);
      }
    
      return out.join("\n");
    }
    
    
    // =====================
    // Helpers
    // =====================
    function isTableLine(ln) {
      return /\|.*\|/.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 normalizeConfigKey(s) {
      let v = String(s || "").trim();
      if (!v) return "";
      if (v.startsWith("@")) v = v.slice(1);
      return v.trim().toLowerCase();
    }
    
    function isSeparatorLine(ln) {
      return /^\s*\|?\s*[-:]+\s*(\|\s*[-:]+\s*)+\|?\s*$/.test(ln);
    }
    
    
    // =====================
    // NOTE_FORMAT overrides (config table)
    // =====================
    function isConfigTableHeader(header) {
      const vMatches = findAllHeaderIndexes(header, CONFIG.configValueHeaderAliases);
      const kMatches = findAllHeaderIndexes(header, CONFIG.configItemHeaderAliases);
      return (vMatches.length === 1 && kMatches.length === 1);
    }
    
    function readNoteFormatOverridesFromDocument(fullText) {
      const fmt = { ...DEFAULT_NOTE_FORMAT };
      const lines = String(fullText || "").split("\n");
    
      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) {
          const header = parseRow(block[0]);
          const vMatches = findAllHeaderIndexes(header, CONFIG.configValueHeaderAliases);
          const kMatches = findAllHeaderIndexes(header, CONFIG.configItemHeaderAliases);
    
          if (vMatches.length === 1 && kMatches.length === 1) {
            const valueIdx = vMatches[0];
            const keyIdx = kMatches[0];
    
            let bodyStart = 1;
            while (bodyStart < block.length && isSeparatorLine(block[bodyStart])) bodyStart++;
    
            for (let r = bodyStart; r < block.length; r++) {
              const row = parseRow(block[r]);
              if (!row.length) continue;
    
              const key = normalizeConfigKey(row[keyIdx] || "");
              const valRaw = (row[valueIdx] || "").trim();
              if (!key) continue;
    
              if (key === "decimals") {
                const n = parseInt(valRaw, 10);
                if (Number.isFinite(n) && n >= 0 && n <= 12) fmt.decimals = n;
              } else if (key === "thousands_separator") {
                const b = parseBoolLike(valRaw);
                if (b !== null) fmt.thousandsSeparator = b;
              } else if (key === "currency_symbol") {
                fmt.currencySymbol = valRaw; // empty => no symbol
              } else if (key === "debug_enabled") {
                const b = parseBoolLike(valRaw);
                if (b !== null) fmt.debugEnabled = b;
              } else if (key === "debug_inline") {
                const b = parseBoolLike(valRaw);
                if (b !== null) fmt.debugInline = b;
              } else if (key === "debug_at_end") {
                const b = parseBoolLike(valRaw);
                if (b !== null) fmt.debugAtEnd = b;
              }
            }
          }
        }
    
        i = j;
      }
    
      return fmt;
    }
    
    
    // =====================
    // Formatting
    // =====================
    function formatMoney(n) {
      const sign = n < 0 ? "-" : "";
      const abs = Math.abs(n);
    
      const numStr = abs.toLocaleString(undefined, {
        minimumFractionDigits: NOTE_FORMAT.decimals,
        maximumFractionDigits: NOTE_FORMAT.decimals,
        useGrouping: !!NOTE_FORMAT.thousandsSeparator,
      });
    
      const sym = NOTE_FORMAT.currencySymbol ?? "";
      return sym === "" ? (sign + numStr) : (sign + sym + numStr);
    }
    
    function formatPercent(n) {
      const sign = n < 0 ? "-" : "";
      const abs = Math.abs(n) * 100;
    
      const numStr = abs.toLocaleString(undefined, {
        minimumFractionDigits: NOTE_FORMAT.decimals,
        maximumFractionDigits: NOTE_FORMAT.decimals,
        useGrouping: !!NOTE_FORMAT.thousandsSeparator,
      });
    
      return sign + numStr + "%";
    }
    
    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(" | ") + " |";
    }
    
    
    // =====================
    // Variables + formula detection
    // =====================
    function stripBracketed(text) {
      return String(text || "").replace(/\[[^\]]*\]/g, " ");
    }
    
    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;
    }
    
    
    // =====================
    // 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 escapeRegex(s) {
      return String(s).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;
    }
    
    function normalizeExpr(raw) {
      if (!raw) return "";
      let s = String(raw).trim();
    
      // 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 (configured + literal '$')
      const sym = NOTE_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;
    }
    
    function evalExpr(raw) {
      const s = normalizeExpr(raw);
      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;
      }
    }
    
    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.";
    }
    
    
    // =====================
    // Table processing
    // =====================
    function processTable(rawLines) {
      if (rawLines.length < 2) return { ok: true, lines: rawLines, warning: null, debugInline: [] };
    
      const header = parseRow(rawLines[0]);
      if (isConfigTableHeader(header)) return { ok: true, lines: rawLines, warning: null, debugInline: [] };
    
      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, debugInline: [`Skipped table: ${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, debugInline: [] };
    
      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);
    
      // Remove existing TOTAL/TOTALS row (save as template)
      let totalTemplate = null;
      for (let idx = body.length - 1; idx >= 0; idx--) {
        if (isTotalRowText(body[idx])) {
          totalTemplate = body[idx].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) : formatMoney(val);
      }
    
      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] || "");
    
        const formulaInfo = findFormulaInRow(row, amountIdx);
        if (formulaInfo) {
          const val = evalExpr(formulaInfo.formula);
          if (isFinite(val)) {
            const forced = (formulaInfo.type === "defFormula") ? [formulaInfo.name] : [];
            const wantsPct = rowWantsPercentDisplay(rawAmtCell, forced);
            const mode = wantsPct ? "percent" : "money";
            const addToMoneySum = !wantsPct;
            finalizeRow(row, val, forced, subtotalRow, mode, addToMoneySum);
          } else {
            rebuilt.push(row);
            if (rowIsNonBlank(row)) { anyNonBlankRow = true; allDefNonBlankRows = false; }
          }
          continue;
        }
    
        // 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);
        if (!norm) {
          rebuilt.push(row);
          if (rowIsNonBlank(row)) { anyNonBlankRow = true; allDefNonBlankRows = false; }
          continue;
        }
    
        // 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);
    
          if (!nextNorm) { expr += " "; continue; }
          expr += (needsPlus(expr, nextNorm) ? " + " : " ") + nextNorm;
          bal += parenBalance(nextNorm);
        }
    
        if (bal === 0) {
          const vGroup = evalExpr(expr);
          if (isFinite(vGroup) && endIndex > i) {
            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; }
            }
            const resultRow = new Array(nCols).fill("");
            finalizeRow(resultRow, vGroup, null, false, "money", true);
            i = endIndex;
            continue;
          }
        }
    
        const v = evalExpr(rawAmtCell);
        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; }
        }
      }
    
      const suppressTotal = allDefNonBlankRows && anyNonBlankRow;
    
      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 overrideVal = evalExpr(totalOverrideFormula);
          if (isFinite(overrideVal)) moneyTotal = overrideVal;
        }
    
        totalRow[amountIdx] = formatMoney(moneyTotal);
    
        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(" | ") + " |");
    
      const dbgLines = [
        `Processed table: cols=${nCols}, amountIdx=${amountIdx}, itemIdx=${itemIdx}, dateIdx=${dateIdx}`,
        `runningMoneySum=${runningMoneySum}, suppressTotal=${suppressTotal}, finalTotal=${moneyTotal}`,
      ];
    
      return { ok: true, lines: out, warning: null, debugInline: dbgLines };
    }
    
    
    // =====================
    // Full-document variable sweep (variables table can be anywhere)
    // =====================
    function preScanVariablesFromDocument(fullText) {
      const lines = String(fullText || "").split("\n");
    
      const numericDefs = []; // {name, value}
      const formulaDefs = []; // {name, formula}
    
      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]);
    
        if (isConfigTableHeader(header)) { i = j; continue; }
    
        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];
    
        let bodyStart = 1;
        while (bodyStart < block.length && isSeparatorLine(block[bodyStart])) bodyStart++;
    
        for (let r = bodyStart; r < block.length; r++) {
          const row = parseRow(block[r]);
          if (!row.length) continue;
    
          if (isTotalRowText(row)) continue;
          if (isSubtotalRow(row, itemMatches[0])) continue;
    
          // (A) @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;
          }
    
          // (B) Simple @name definition: any @var outside brackets in non-Amount cells
          const names = extractVarNamesOutsideBracketsFromRow(row, amountIdx);
          if (names.length === 1) {
            const rawAmt = row[amountIdx] || "";
            const v = evalExpr(rawAmt);
            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) {
          const v = evalExpr(d.formula);
          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;
      }
    
      dbg("preScanVariables", `numeric=${numericDefs.length}, formula=${formulaDefs.length}, varsNow=${Object.keys(vars).length}`);
    }
    
    
    // =====================
    // Starter tables insertion
    // =====================
    function buildStarterTablesText() {
      const AmountH = "Amount";
      const ItemH = "Item";
      const DateH = "Date";
      const NotesH = "Notes";
    
      const starter = [];
    
      starter.push("EXAMPLE TABLES:  (modify/delete sample tables as desired)");
      starter.push("");
    
      starter.push("| Amount | Item |");
      starter.push("| ------: | ------ |");
      starter.push("| $0.00 | Example |");
      starter.push("| $0.00 | TOTAL |");
      starter.push("");
    
      starter.push("| Date | Amount |");
      starter.push("| ------ | ------: |");
      starter.push("| 1/1/2026 | $0.00 |");
      starter.push("| TOTAL | $0.00 |");
      starter.push("");
    
      starter.push("| Date | Amount | Item |");
      starter.push("| ------ | ------: | ------ |");
      starter.push("| 1/1/2026 | $0.00 | Example |");
      starter.push("|  | $0.00 | TOTAL |");
      starter.push("");
    
      starter.push("| Date | Amount | Item | Notes |");
      starter.push("| ------ | ------: | ------ | ------ |");
      starter.push("| 1/1/2026 | $0.00 | Example |  |");
      starter.push("|  | $0.00 | TOTAL |  |");
    
      starter.push("");
      starter.push("");
      starter.push("CONFIGURATION SETTINGS:");
      starter.push("");
    
      starter.push("| Value | Config Item |");
      starter.push("| -----: | ----------- |");
      starter.push("| 2 | @decimals |");
      starter.push("| true | @thousands_separator |");
      starter.push("| $ | @currency_symbol |");
      starter.push("| false | @debug_enabled |");
      starter.push("| false | @debug_inline |");
      starter.push("| false | @debug_at_end |");
    
      starter.push("");
      return starter.join("\n");
    }
    
    
    // =====================
    // Main
    // =====================
    function main() {
      // Strip prior debug output first so toggling debug off removes old output.
      let text = editor.getText();
      text = stripPreviousDebugOutput(text);
    
      // Re-read after stripping (so we don't parse stale debug blocks)
      NOTE_FORMAT = readNoteFormatOverridesFromDocument(text);
    
      // Reset debug events buffer each run
      DEBUG_EVENTS = [];
      dbg("start", `debugEnabled=${NOTE_FORMAT.debugEnabled}, inline=${NOTE_FORMAT.debugInline}, atEnd=${NOTE_FORMAT.debugAtEnd}`);
    
      // Preload variables from any table anywhere
      vars = {};
      preScanVariablesFromDocument(text);
    
      // Process tables in document order
      let lines = text.split("\n");
      let out = [];
      let foundTableLine = false;
      let foundEligibleTable = false;
    
      for (let i = 0; i < lines.length;) {
        if (!isTableLine(lines[i])) { out.push(lines[i++]); continue; }
    
        foundTableLine = true;
    
        const start = i;
        let j = i;
        while (j < lines.length && isTableLine(lines[j])) j++;
        const block = lines.slice(start, j);
    
        // Determine eligibility: must have header with exactly one Amount + one Item alias match
        let eligible = false;
        if (block.length >= 2) {
          const header = parseRow(block[0]);
          if (!isConfigTableHeader(header)) {
            const a = findAllHeaderIndexes(header, CONFIG.amountHeaderAliases);
            const it = findAllHeaderIndexes(header, CONFIG.itemHeaderAliases);
            eligible = (a.length === 1 && it.length === 1);
          }
        }
    
        if (eligible) foundEligibleTable = true;
    
        const res = processTable(block);
    
        if (!res.ok && res.warning) {
          dbg("tableSkipped", res.warning);
          out.push(res.warning);
          out.push(...res.lines);
          out.push(...renderInlineDebugBlock([`WARNING: ${res.warning}`]));
        } else {
          if (eligible) dbg("tableProcessed", `lines=${block.length} -> ${res.lines.length}`);
          out.push(...res.lines);
          out.push(...renderInlineDebugBlock(res.debugInline || []));
        }
    
        i = j;
      }
    
      // If no eligible tables exist (even if there are table-like lines), prompt to insert starter tables.
      if (!foundEligibleTable) {
        dbg("noEligibleTables", `foundTableLine=${foundTableLine}`);
    
        // Use Prompt (Drafts API) — app.confirm is not available in Drafts.
        const p = Prompt.create();
        p.title = "SmartTables";
        p.message = "No SmartTables-compatible Markdown tables were found.\n\nInsert example starter tables and configuration settings table?";
        p.addButton("Yes");
        p.addButton("No");
    
        const ok = p.show();
        const pressed = ok ? p.buttonPressed : "No";
        dbg("starterPrompt", `pressed=${pressed}`);
    
        if (pressed === "Yes") {
          const insertText = buildStarterTablesText();
    
          // Insert at cursor position
          const sel = editor.getSelectedRange();
          const startPos = sel[0];
          const endPos = sel[1];
    
          const before = text.slice(0, startPos);
          const after = text.slice(endPos);
    
          const glueBefore = before.length && !before.endsWith("\n") ? "\n" : "";
          const glueAfter = after.length && !after.startsWith("\n") ? "\n" : "";
    
          const newText = before + glueBefore + insertText + glueAfter + after;
          editor.setText(newText);
    
          app.displayInfoMessage("Inserted example tables and configuration settings.");
          return;
        }
    
        // User chose No: just write stripped text back (so debug blocks are removed if present)
        editor.setText(text);
        app.displayInfoMessage("No SmartTables changes made.");
        return;
      }
    
      // Write updated content (+ optional at-end debug section)
      const finalLines = out.concat(renderAtEndDebugSection());
      editor.setText(finalLines.join("\n"));
      app.displayInfoMessage("Tables found and updated in document.");
    }
    
    main();

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.