Action

Calculations in md Tables

Posted by K Holford, Last update 7 days ago

SmartTables v1.0 – 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.0 -- 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.
    
    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 the +, −, *, /, and % operators, as well as parentheses ( )
    for specifying operator precedence.
    
    At the more advanced end of the spectrum, 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. Together, these features allow you to model things such 
    as budgets, projections, tax calculations, and other structured math workflows 
    directly inside Markdown tables.
    
    PLEASE NOTE: This script is shared as-is for your use and convenience. Since it
    is not an officially supported product, you should test it with your own data
    and validate results to ensure it meets your accuracy and workflow requirements.
    ───────────────────────────────────────────────────────────────────────────────
    
    
    ===============================================================================
    CORE CONCEPTS (HOW IT THINKS)
    ===============================================================================
    
    • Tables are processed top-to-bottom, and rows are interpreted as “statements”.
    • Variables are global within the draft note.
    • Before updating tables, SmartTables pre-scans the entire draft for variable
      definitions across ALL tables. This allows variables to be defined anywhere
      (even at the end) without breaking earlier formulas.
    
    Practical implication:
    - You can keep your “variables/config” tables near the bottom to reduce clutter,
      while still using those variables earlier in the note.
    
    ===============================================================================
    QUICKSTART GUIDE (CUT-AND-PASTE EXAMPLE)
    ===============================================================================
    Copy/paste this single table into a Draft and run the script to see:
    - Auto-calculated SUBTOTAL
    - TOTAL formula override (Option 1)
    - Stored variables you can reuse later in the note
    
    | 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 | New appliances |  |
    | 6/15/2026 |  $5,000.00 | Office remodel |  |
    |  | $0.00 | SUBTOTAL @2026_tot_spend_b4_tax | Total before sales tax (recalculated each run) |
    |  | $0.00 | TOTAL [@2026_tot_spend_b4_tax * 1.055] @2026_tot_spend | Total with tax of 5.5% |
    
    Likewise, for the TOTAL row, if you don’t need to store the total as a variable
    for later processing within the script, you could simply have the following line
    to just have the total calculated.
    
    | $0.00 | TOTAL optional additional text here | |
    
    ===============================================================================
    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 are accepted
    
    INLINE FORMULA:
    - Put a formula inside square brackets in a non-Amount cell:
      `[ ... ]`
    - Examples:
      `[100 * 1.05]`
      `[@income * 20%]`
      `[(@a + @b) / 2]`
    
    VARIABLES:
    - Simple definition (stores value from Amount):
      Amount: `5000`   Item: `@gross`
    - Formula definition (stores computed value):
      `@net = [@gross - @tax]`
    - Formula-only row (computed, not stored):
      `[ @net / 12 ]`
    
    SUBTOTAL:
    - Item begins with `SUBTOTAL`:
      `SUBTOTAL @food_subtotal`
    - Always recalculates from the running sum above it (unless SUBTOTAL itself has
      a [ ... ] formula).
    - SUBTOTAL rows do NOT contribute to TOTAL (prevents double counting).
    
    TOTAL:
    - Item contains `TOTAL` or `TOTALS`:
      `TOTAL`
      `TOTAL @my_total_var`
    - TOTAL formula override (Option 1):
      `TOTAL [ ... ]`
      Example:
      `TOTAL [@net / @divisor] @gross`
    
    PERCENT DISPLAY:
    - If the Amount cell contains `%` OR a variable name ends with `_rate`, `_pct`,
      or `_percent`, it is displayed as a percent (e.g. 20.00%) while stored as a
      fraction (0.2).
    - Percent rows do NOT contribute to money totals.
    
    PARENTHESIS GROUPING ACROSS ROWS:
    - If parentheses are unbalanced in one Amount row, SmartTables will append
      subsequent Amount rows until the parentheses balance, evaluate the whole group,
      and then insert the group result as its own new line AFTER the closing paren
      line (preserving the original lines).
    
    ===============================================================================
    EXAMPLE USAGE PATTERNS (BUDGETS / TAXES / PROJECTIONS)
    ===============================================================================
    
    BUDGETS:
    - Use plain line items for categories.
    - Use SUBTOTAL for section totals (so you can store them as variables).
    - Use TOTAL at bottom for final sum, or use TOTAL override for “net → gross”.
    
    TAX / WITHHOLDING:
    1) Define rates once (percent rows or in a variables table).
    2) Define a divisor (e.g. 75% as 0.75).
    3) Sum net withdrawals in a single table, store as @net.
    4) Use TOTAL override to compute gross:
       `TOTAL [@net / @tax_wh_divisor] @gross`
    
    PROJECTIONS / WHAT-IF:
    - Put assumptions in a pure “variables table”.
    - Use formulas everywhere else.
    - Change assumptions → rerun → everything refreshes.
    
    VARIABLES TABLE (ANYWHERE):
    - Store “constants” and formulas used across the note.
    - Tables where every non-blank row defines variables suppress TOTAL automatically
      (so you don’t get a meaningless “TOTAL = 0” row).
    
    ===============================================================================
    GOTCHAS / BEST PRACTICES (READ THIS ONCE)
    ===============================================================================
    
    • Leave SUBTOTAL/TOTAL Amount cells blank or 0.
      - They are recomputed anyway, and this avoids confusion.
    
    • Put computations you want to “refresh every run” inside `[ ... ]`.
      - Manually typed results won’t update unless they’re formulas.
    
    • Use SUBTOTAL for intermediate totals.
      - Prevents double counting because SUBTOTAL does not contribute to TOTAL.
    
    • Ambiguous headers will skip a table.
      - If multiple columns match the Amount or Item alias lists, the script inserts
        a warning banner line above the table and leaves the table unchanged.
    
    • Currency symbols:
      - `$` is accepted in input and preserved in formatted output.
      - Negative values are formatted with the minus sign before the currency symbol
        (e.g. -$123.45).
    
    ===============================================================================
    PER-NOTE OUTPUT FORMATTING (DEFAULTS + OPTIONAL CONFIG TABLE OVERRIDES)
    ===============================================================================
    
    Defaults:
    - Decimals:               2
    - Thousands separators:   enabled (e.g., 12,345.67)
    - Currency symbol:        "$"
    - Negative values:        leading minus sign (e.g., -$123.45)
    
    Optional override:
    Add a config table anywhere in the draft (applies globally to this note).
    
    | Value | Config Item |
    | -----:| ----------- |
    | 2     | @decimals |
    | true  | @thousands_separator |
    | $     | @currency_symbol |
    
    Supported config items (case-insensitive; leading @ optional):
    - decimals               (integer 0–12)
    - thousands_separator    (true/false/yes/no/1/0)
    - currency_symbol        (any string; empty Value cell = no symbol)
    
    Config table behavior:
    - Config tables are read to set formatting, but they are not rewritten/reformatted
      as calculation tables.
    
    ===============================================================================
    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
    inserts a set of “starter” tables at your cursor position so you can immediately
    begin using SmartTables without having to remember the syntax.
    
    It inserts FIVE sample tables (with one blank line between each):
    
    1) Two-column (Amount + Item)
       - A basic money table with a placeholder row and a TOTAL row.
    
    2) Two-column (Date + Amount)
       - A date + amount table where the TOTAL label is placed in the Date column.
    
    3) Three-column (Date + Amount + Item)
       - A common layout for dated line items with descriptions.
    
    4) Four-column (Date + Amount + Item + Notes)
       - A practical layout for budgets with explanatory Notes.
    
    5) Config table (Value + Config Item)
       - Provides per-note formatting overrides. Default values inserted:
    
         | Value | Config Item |
         | -----:| ----------- |
         | 2     | @decimals |
         | true  | @thousands_separator |
         | $     | @currency_symbol |
    
    Notes:
    - The script uses the same header-alias logic for these tables (see CONFIG).
    - These starter tables are meant as examples; delete what you don’t need.
    - Formatting overrides apply to ALL processed tables in the current Draft note.
    
    ===============================================================================
    COLUMN RULES (CONFIGURABLE VIA ALIASES)
    ===============================================================================
    
    - Amount and Item columns are required (found by aliases in CONFIG).
    - Date is optional (MM/DD[/YYYY] is normalized; year assumed if missing).
    - Notes is optional and may be renamed.
    - You may add any number of custom columns in any position; they are preserved
      and ignored by the math engine.
    
    Configurable column names:
    - You can rename columns so long as their names match an alias list in CONFIG
      (e.g. Amount can be “Value”).
    - To change alias behavior, edit the CONFIG section near the top of the script:
      CONFIG.amountHeaderAliases, CONFIG.itemHeaderAliases, CONFIG.dateHeaderAliases,
      CONFIG.configValueHeaderAliases, CONFIG.configItemHeaderAliases.
    
    About dateMustBeFirstColumn:
    - If CONFIG.dateMustBeFirstColumn is true, SmartTables only treats a Date column
      as a Date column when it is the FIRST column in the table.
    - If false, Date can appear anywhere and still be recognized.
    
    Ambiguity rule:
    - If more than one column matches the Amount aliases or Item aliases, that table
      is skipped and a warning banner is inserted.
    
    ===============================================================================
    PURE VARIABLES TABLES (TOTAL SUPPRESSION)
    ===============================================================================
    
    If every non-blank row defines at least one variable (simple `@var` or
    `@var = [ ... ]`), SmartTables suppresses the TOTAL row automatically.
    
    This is ideal for assumption/config tables:
    - rates
    - constants
    - intermediate values you reuse later
    
    ===============================================================================
    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"],
    };
    
    // =====================
    // Per-note formatting defaults (overridden by config table)
    // =====================
    const DEFAULT_NOTE_FORMAT = {
      decimals: 2,
      thousandsSeparator: true,
      currencySymbol: "$",
    };
    
    let NOTE_FORMAT = { ...DEFAULT_NOTE_FORMAT };
    
    // =====================
    // Core state (shared across tables)
    // =====================
    let vars = {};
    
    // =====================
    // 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();
    }
    
    // =====================
    // NOTE_FORMAT overrides (config table)
    // =====================
    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 &&
                   /^\s*\|?\s*[-:]+\s*(\|\s*[-:]+\s*)+\|?\s*$/.test(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
              }
            }
          }
        }
    
        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 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 stripBracketed(text) {
      return String(text || "").replace(/\[[^\]]*\]/g, " ");
    }
    
    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()));
    }
    
    // =====================
    // 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 isConfigTableHeader(header) {
      const vMatches = findAllHeaderIndexes(header, CONFIG.configValueHeaderAliases);
      const kMatches = findAllHeaderIndexes(header, CONFIG.configItemHeaderAliases);
      return (vMatches.length === 1 && kMatches.length === 1);
    }
    
    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 processTable(rawLines) {
      if (rawLines.length < 2) return { ok: true, lines: rawLines, warning: null };
    
      const header = parseRow(rawLines[0]);
      if (isConfigTableHeader(header)) return { ok: true, lines: rawLines, warning: null };
    
      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 &&
             /^\s*\|?\s*[-:]+\s*(\|\s*[-:]+\s*)+\|?\s*$/.test(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--) {
        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) : 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 (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);
          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(" | ") + " |");
    
      return { ok: true, lines: out, warning: null };
    }
    
    // =====================
    // NEW: Full-document variable sweep (variables table can be anywhere)
    // =====================
    function preScanVariablesFromDocument(fullText) {
      const lines = String(fullText || "").split("\n");
    
      // Gather candidate definition rows across all tables
      const numericDefs = [];   // { name, value }
      const formulaDefs = [];   // { name, formula }
    
      function isSeparatorLine(ln) {
        return /^\s*\|?\s*[-:]+\s*(\|\s*[-:]+\s*)+\|?\s*$/.test(ln);
      }
    
      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]);
    
        // Skip config tables (handled separately via NOTE_FORMAT already)
        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;
    
          // Ignore TOTAL/SUBTOTAL rows as "definitions" sources (they’re derived)
          if (isTotalRowText(row)) continue;
          if (isSubtotalRow(row, itemMatches[0])) continue;
    
          // (A) @name = [formula] anywhere outside Amount column
          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); // supports $ and % (20% => 0.2)
            if (isFinite(v)) numericDefs.push({ name: names[0], value: v });
          }
        }
    
        i = j;
      }
    
      // Seed numeric defs first
      numericDefs.forEach(d => { vars[d.name] = d.value; });
    
      // Iteratively resolve formula defs (handles forward references & chains)
      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;
      }
    }
    
    // =====================
    // Main
    // =====================
    let text = editor.getText();
    
    // 1) NOTE_FORMAT: config table can be anywhere
    NOTE_FORMAT = readNoteFormatOverridesFromDocument(text);
    
    // 2) NEW: preload variables from any table anywhere (including near the end)
    preScanVariablesFromDocument(text);
    
    // 3) Now process/refresh tables in document order
    let lines = text.split("\n");
    let out = [];
    let found = false;
    
    for (let i = 0; i < lines.length;) {
      if (!isTableLine(lines[i])) { out.push(lines[i++]); continue; }
    
      found = true;
      let start = i, j = i;
      while (j < lines.length && isTableLine(lines[j])) j++;
    
      const block = lines.slice(start, j);
      const res = processTable(block);
    
      if (!res.ok && res.warning) {
        out.push(res.warning);
        out.push(...res.lines);
      } else {
        out.push(...res.lines);
      }
    
      i = j;
    }
    
    if (!found) {
      // Insert starter templates with capitalized headers + a config table
      const AmountH = "Amount";
      const ItemH   = "Item";
      const DateH   = "Date";
      const NotesH  = "Notes";
    
      const amt0 = formatMoney(0);
      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] = "name";
    
        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 tables = [];
      tables.push(makeStarter([AmountH, ItemH]));
      tables.push(makeStarter([DateH, AmountH], "date"));
      tables.push(makeStarter([DateH, AmountH, ItemH]));
      tables.push(makeStarter([DateH, AmountH, ItemH, NotesH]));
    
      const configTable = [
        "| Value | Config Item |",
        "| -----:| ----------- |",
        "| 2 | @decimals |",
        "| true | @thousands_separator |",
        "| $ | @currency_symbol |"
      ];
      tables.push(configTable);
    
      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("");
    
      for (let t = 0; t < tables.length; t++) {
        combined.push(...tables[t]);
        if (t < tables.length - 1) combined.push("");
      }
    
      combined.push(...after);
    
      editor.setText(combined.join("\n"));
      app.displayInfoMessage("No tables found ==> Inserted starter tables.");
    } 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.