Skip to main content

Google Sheets - Super Menu w/ Apps Script

Add a sweet fuckin' menu in Google Sheets that will shave off hours of your life. After all, time is the only resource you can never get back. 

    Replace fill color   (replaceFillColor) - Finds all cells with hex color and replaces the hex color of your choosing Select matching cells (selectMatchingCells) - This one is so tight, you can find all cells that match certain conditions and bulk edit them.   Copy (Formula Safe) - this copies formats and formulas of a selection Paste (Formula Safe) - this copies formats and formulas of a selection, formulas references are absolute and aren't transposed (unless they are referenced within the copied selection, and then the self-reference updates). You're welcome!!!

    image.png

    Here's the Apps Script

    /**
     * Tech Almanac Tools — utilities under a sub menu:
     *   1. Replace fill color   (replaceFillColor)
     *   2. Select matching cells (selectMatchingCells)
     *   3. Copy (Formula Safe) - this copies formats and formulas of a selection
     *   4. Paste (Formula Safe) - this copies formats and formulas of a selection, formulas references are absolute and aren't transposed (unless they are referenced within the copied selection, and then the self-reference updates). You're welcome!!!
     * "Select matching cells" needs a companion HTML file named "MatchDialog".
     */

    function onOpen() {
        SpreadsheetApp.getUi()
          .createMenu('Tech Almanac Tools')
          .addItem('Replace fill color…', 'replaceFillColor')
          .addItem('Select matching cells…', 'selectMatchingCells')
          .addSeparator()
          .addItem('Copy (formula-safe)', 'formulaSafeCopy')
          .addItem('Paste (formula-safe)', 'formulaSafePaste')
          .addToUi();
    }

    /* ============================================================
     * TOOL 1 — Replace fill color
     * ============================================================ */

    function replaceFillColor() {
        const ui = SpreadsheetApp.getUi();
        const sheet = SpreadsheetApp.getActiveSheet();

        const findResp = ui.prompt(
          'Replace fill color',
          'Color to FIND (hex, e.g. #cfecff):',
          ui.ButtonSet.OK_CANCEL
        );
        if (findResp.getSelectedButton() !== ui.Button.OK) return;
        const findColor = normalizeHex(findResp.getResponseText());
        if (!findColor) {
          ui.alert('That doesn\'t look like a valid hex color. Try something like #cfecff.');
          return;
        }

        const replaceResp = ui.prompt(
          'Replace fill color',
          'Color to REPLACE it with (hex, e.g. #ffffff):',
          ui.ButtonSet.OK_CANCEL
        );
        if (replaceResp.getSelectedButton() !== ui.Button.OK) return;
        const replaceColor = normalizeHex(replaceResp.getResponseText());
        if (!replaceColor) {
          ui.alert('That doesn\'t look like a valid hex color. Try something like #ffffff.');
          return;
        }

        const range = sheet.getDataRange();
        const backgrounds = range.getBackgrounds();
        const startRow = range.getRow();
        const startCol = range.getColumn();

        const matchedA1 = [];
        for (let r = 0; r < backgrounds.length; r++) {
          for (let c = 0; c < backgrounds[r].length; c++) {
            if (backgrounds[r][c].toLowerCase() === findColor) {
              backgrounds[r][c] = replaceColor;
              matchedA1.push(sheet.getRange(startRow + r, startCol + c).getA1Notation());
            }
          }
        }

        if (matchedA1.length === 0) {
          ui.alert('No cells with fill ' + findColor + ' found on "' + sheet.getName() + '".');
          return;
        }

        sheet.setActiveRangeList(sheet.getRangeList(matchedA1));

        const confirm = ui.alert(
          'Confirm replacement',
          'Found ' + matchedA1.length + ' cell(s) with ' + findColor +
          ' on "' + sheet.getName() + '".\n\nReplace their fill with ' + replaceColor + '?',
          ui.ButtonSet.OK_CANCEL
        );
        if (confirm !== ui.Button.OK) return;

        range.setBackgrounds(backgrounds);
        SpreadsheetApp.getActive().toast('Replaced ' + matchedA1.length + ' cell(s).', 'Done', 5);
    }

    /**
     * Normalize a user-entered hex string to lowercase 6-digit form (#rrggbb).
     * Accepts "#cfecff", "cfecff", "#abc", "abc". Returns null if invalid.
     */
    function normalizeHex(input) {
        if (!input) return null;
        let h = input.trim().toLowerCase().replace(/^#/, '');
        if (/^[0-9a-f]{3}$/.test(h)) {
          h = h.split('').map(function (ch) { return ch + ch; }).join('');
        }
        if (/^[0-9a-f]{6}$/.test(h)) return '#' + h;
        return null;
    }

    /* ============================================================
     * TOOL 2 — Select matching cells
     * ============================================================ */

    function selectMatchingCells() {
        const html = HtmlService.createHtmlOutputFromFile('MatchDialog')
          .setWidth(320)
          .setHeight(460);
        SpreadsheetApp.getUi().showModalDialog(html, 'Select matching cells');
    }

    /** Called by the dialog on load, to show which cell is the reference. */
    function getActiveCellAddress() {
        const sheet = SpreadsheetApp.getActiveSheet();
        return sheet.getName() + '!' + sheet.getActiveCell().getA1Notation();
    }

    /**
     * Called by the dialog. `options` is an object of booleans keyed by attribute.
     * Selects every cell on the active sheet that matches the reference cell on
     * all enabled attributes. Returns { count } or { error }.
     */
    function runMatch(options) {
        const sheet = SpreadsheetApp.getActiveSheet();
        const range = sheet.getDataRange();
        const startRow = range.getRow();
        const startCol = range.getColumn();
        const numRows = range.getNumRows();
        const numCols = range.getNumColumns();

        // Reference cell position relative to the data range.
        const active = sheet.getActiveCell();
        let refR = active.getRow() - startRow;
        let refC = active.getColumn() - startCol;
        if (refR < 0 || refC < 0 || refR >= numRows || refC >= numCols) {
          return { error: 'Select a cell that contains data first, then run this again.' };
        }

        // Read only the attribute matrices we actually need.
        const data = {};
        if (options.background)   data.background   = range.getBackgrounds();
        if (options.fontColor)    data.fontColor    = range.getFontColors();
        if (options.fontWeight)   data.fontWeight   = range.getFontWeights();
        if (options.fontStyle)    data.fontStyle    = range.getFontStyles();
        if (options.fontFamily)   data.fontFamily   = range.getFontFamilies();
        if (options.fontSize)     data.fontSize     = range.getFontSizes();
        if (options.numberFormat) data.numberFormat = range.getNumberFormats();
        if (options.hAlign)       data.hAlign       = range.getHorizontalAlignments();
        if (options.vAlign)       data.vAlign       = range.getVerticalAlignments();
        if (options.wrap)         data.wrap         = range.getWraps();

        const hasAttr = Object.keys(data).length > 0;
        if (!hasAttr && !options.merge) {
          return { error: 'Pick at least one condition to match on.' };
        }

        // Build merge maps: dimensions string per cell + which cells are anchors.
        const mergeDims = [];
        const isAnchor = [];
        for (let r = 0; r < numRows; r++) {
          mergeDims[r] = new Array(numCols).fill('1x1');
          isAnchor[r] = new Array(numCols).fill(true);
        }
        const merges = range.getMergedRanges();
        merges.forEach(function (m) {
          const mr = m.getRow() - startRow;
          const mc = m.getColumn() - startCol;
          const rows = m.getNumRows();
          const cols = m.getNumColumns();
          const dim = rows + 'x' + cols;
          for (let r = mr; r < mr + rows; r++) {
            for (let c = mc; c < mc + cols; c++) {
              if (r >= 0 && c >= 0 && r < numRows && c < numCols) {
                mergeDims[r][c] = dim;
                isAnchor[r][c] = (r === mr && c === mc);
              }
            }
          }
          // Snap the reference to its merge anchor if it sits inside this merge.
          if (refR >= mr && refR < mr + rows && refC >= mc && refC < mc + cols) {
            refR = mr;
            refC = mc;
          }
        });

        // Reference values.
        const ref = {};
        Object.keys(data).forEach(function (k) {
          ref[k] = norm(k, data[k][refR][refC]);
        });
        const refMerge = mergeDims[refR][refC];

        // Scan and collect anchor cells that match on every enabled attribute.
        const matched = [];
        for (let r = 0; r < numRows; r++) {
          for (let c = 0; c < numCols; c++) {
            if (!isAnchor[r][c]) continue; // skip interior cells of merges
            let ok = true;
            for (const k in data) {
              if (norm(k, data[k][r][c]) !== ref[k]) { ok = false; break; }
            }
            if (ok && options.merge && mergeDims[r][c] !== refMerge) ok = false;
            if (ok) matched.push(sheet.getRange(startRow + r, startCol + c).getA1Notation());
          }
        }

        if (matched.length === 0) return { count: 0 };
        sheet.setActiveRangeList(sheet.getRangeList(matched));
        return { count: matched.length };
    }

    /** Normalize values so equivalent formats compare equal (colors are case-folded). */
    function norm(key, val) {
        if (key === 'background' || key === 'fontColor') return String(val).toLowerCase();
        return val;
    }

    // ── Formula-Safe Copy ────────────────────────────────────────────────────────

    function formulaSafeCopy() {
        const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
        const range = sheet.getActiveRange();

        // Store the A1 notation and sheet name in script properties
        const props = PropertiesService.getScriptProperties();
        props.setProperty('FSC_RANGE',  range.getA1Notation());
        props.setProperty('FSC_SHEET',  sheet.getName());

        SpreadsheetApp.getUi().alert(
          `Copied ${range.getA1Notation()} — now select your destination cell and run Paste (formula-safe).`
        );
    }

    // ── Formula-Safe Paste ───────────────────────────────────────────────────────

    function formulaSafePaste() {
        const ui    = SpreadsheetApp.getUi();
        const props = PropertiesService.getScriptProperties();
        const srcRangeNotation = props.getProperty('FSC_RANGE');
        const srcSheetName     = props.getProperty('FSC_SHEET');

        if (!srcRangeNotation || !srcSheetName) {
          ui.alert('Nothing copied yet — run Copy (formula-safe) first.');
          return;
        }

        const ss       = SpreadsheetApp.getActiveSpreadsheet();
        const srcSheet = ss.getSheetByName(srcSheetName);
        const srcRange = srcSheet.getRange(srcRangeNotation);

        // Grab formulas (as strings) and display values separately
        const formulas = srcRange.getFormulas();       // raw formula strings, e.g. "=A1+B1"
        const values   = srcRange.getDisplayValues();  // what the cell shows if not a formula

        // Source range bounds
        const srcRow  = srcRange.getRow();
        const srcCol  = srcRange.getColumn();
        const srcRows = srcRange.getNumRows();
        const srcCols = srcRange.getNumColumns();

        // Destination anchor
        const dest    = ss.getActiveSheet().getActiveRange();
        const destRow = dest.getRow();
        const destCol = dest.getColumn();

        // Row/col offset from source to destination
        const rowOffset = destRow - srcRow;
        const colOffset = destCol - srcCol;

        // Regex matches cell addresses like B4, $B$4, B$4, $B4
        const cellRef = /(\$?)([A-Za-z]{1,3})(\$?)(\d+)/g;

        /**
         * If the address falls within the source range, shift it by the paste offset.
         * Anchored ($) axes are never shifted, matching normal paste behaviour.
         * Addresses outside the source range are left untouched.
         */
        function shiftIfInRange(colAnchor, colLetter, rowAnchor, rowNum) {
          const col = columnLetterToIndex(colLetter.toUpperCase());
          const row = parseInt(rowNum, 10);
          if (row >= srcRow && row < srcRow + srcRows &&
              col >= srcCol && col < srcCol + srcCols) {
            const newCol = colAnchor ? col : col + colOffset;
            const newRow = rowAnchor ? row : row + rowOffset;
            return (colAnchor ? '$' : '') + columnIndexToLetter(newCol) +
                   (rowAnchor ? '$' : '') + newRow;
          }
          return (colAnchor ? '$' : '') + colLetter.toUpperCase() +
                 (rowAnchor ? '$' : '') + rowNum;
        }

        // Build payload: rewrite intra-range references, pass external refs through as-is
        const payload = formulas.map((row, r) =>
          row.map((formula, c) => {
            if (formula === '') return values[r][c];
            return formula.replace(cellRef, (match, ca, cl, ra, rn) =>
              shiftIfInRange(ca, cl, ra, rn)
            );
          })
        );

        const destRange = ss.getActiveSheet().getRange(
          destRow, destCol, payload.length, payload[0].length
        );

        // Copy formatting first, then overwrite with values/formulas
        srcRange.copyTo(destRange, SpreadsheetApp.CopyPasteType.PASTE_FORMAT, false);
        destRange.setValues(payload);
    }

    // ── Helpers ──────────────────────────────────────────────────────────────────

    function columnLetterToIndex(letters) {
        let n = 0;
        for (let i = 0; i < letters.length; i++) {
          n = n * 26 + (letters.charCodeAt(i) - 64);
        }
        return n;
    }

    function columnIndexToLetter(n) {
        let s = '';
        while (n > 0) {
          const rem = (n - 1) % 26;
          s = String.fromCharCode(65 + rem) + s;
          n = Math.floor((n - 1) / 26);
        }
        return s;
    }

    Here's the MarchDialog.html for the Select Matching Cells Modal

    <!DOCTYPE html>
    <html>
    <head>
      <base target="_top">
      <style>
        body {
          font-family: Arial, Helvetica, sans-serif;
          font-size: 13px;
          color: #202124;
          margin: 12px;
        }
        .ref {
          background: #f1f3f4;
          border-radius: 6px;
          padding: 8px 10px;
          margin-bottom: 12px;
          font-size: 12px;
          color: #5f6368;
        }
        .ref b { color: #202124; }
        label {
          display: block;
          padding: 4px 0;
          cursor: pointer;
        }
        label input { margin-right: 8px; }
        .toggle {
          margin-bottom: 6px;
          padding-bottom: 6px;
          border-bottom: 1px solid #e8eaed;
        }
        .toggle a {
          font-size: 12px;
          color: #1a73e8;
          text-decoration: none;
          cursor: pointer;
        }
        .toggle a:hover { text-decoration: underline; }
        .buttons {
          margin-top: 14px;
          display: flex;
          gap: 8px;
        }
        button {
          font-size: 13px;
          padding: 7px 14px;
          border-radius: 6px;
          border: 1px solid #dadce0;
          background: #fff;
          cursor: pointer;
        }
        button.primary {
          background: #1a73e8;
          color: #fff;
          border-color: #1a73e8;
        }
        button:disabled { opacity: 0.5; cursor: default; }
        #status { margin-top: 12px; font-size: 12px; min-height: 16px; }
        #status.err { color: #c5221f; }
        #status.ok { color: #188038; }
      </style>
    </head>
    <body>
      <div class="ref">
        Matching against reference cell: <b id="refCell">…</b>
      </div>
    
      <div class="toggle">
        <a href="#" id="toggleAll" onclick="toggleAll(); return false;">Select all</a>
      </div>
    
      <div id="opts">
        <label><input type="checkbox" id="background" checked> Fill color</label>
        <label><input type="checkbox" id="fontColor"> Font color</label>
        <label><input type="checkbox" id="fontWeight"> Bold (font weight)</label>
        <label><input type="checkbox" id="fontStyle"> Italic (font style)</label>
        <label><input type="checkbox" id="fontFamily"> Font family</label>
        <label><input type="checkbox" id="fontSize"> Font size</label>
        <label><input type="checkbox" id="numberFormat"> Number format</label>
        <label><input type="checkbox" id="hAlign"> Horizontal alignment</label>
        <label><input type="checkbox" id="vAlign"> Vertical alignment</label>
        <label><input type="checkbox" id="wrap"> Text wrap</label>
        <label><input type="checkbox" id="merge"> Merge size</label>
      </div>
    
      <div class="buttons">
        <button class="primary" id="run" onclick="run()">Select matching cells</button>
        <button onclick="google.script.host.close()">Cancel</button>
      </div>
    
      <div id="status"></div>
    
      <script>
        var KEYS = ['background','fontColor','fontWeight','fontStyle','fontFamily',
                    'fontSize','numberFormat','hAlign','vAlign','wrap','merge'];
    
        google.script.run.withSuccessHandler(function (addr) {
          document.getElementById('refCell').textContent = addr;
        }).getActiveCellAddress();
    
        function setStatus(msg, cls) {
          var s = document.getElementById('status');
          s.textContent = msg;
          s.className = cls || '';
        }
    
        function syncToggleLabel() {
          var allChecked = KEYS.every(function (k) {
            return document.getElementById(k).checked;
          });
          document.getElementById('toggleAll').textContent = allChecked ? 'Select none' : 'Select all';
        }
        KEYS.forEach(function (k) {
          document.getElementById(k).addEventListener('change', syncToggleLabel);
        });
        syncToggleLabel();
    
        function toggleAll() {
          var allChecked = KEYS.every(function (k) {
            return document.getElementById(k).checked;
          });
          KEYS.forEach(function (k) { document.getElementById(k).checked = !allChecked; });
          syncToggleLabel();
        }
    
        function run() {
          var options = {};
          KEYS.forEach(function (k) { options[k] = document.getElementById(k).checked; });
    
          var btn = document.getElementById('run');
          btn.disabled = true;
          setStatus('Scanning…');
    
          google.script.run
            .withSuccessHandler(function (result) {
              btn.disabled = false;
              if (result.error) { setStatus(result.error, 'err'); return; }
              if (result.count === 0) { setStatus('No matching cells found.', 'err'); return; }
              setStatus('Selected ' + result.count + ' cell(s). Closing…', 'ok');
              setTimeout(google.script.host.close, 700);
            })
            .withFailureHandler(function (err) {
              btn.disabled = false;
              setStatus(err.message || String(err), 'err');
            })
            .runMatch(options);
        }
      </script>
    </body>
    </html>