Only run the job on the rows you selected

TL;DR — Add a custom menu that runs your worker only on the rows someone highlighted: selection to row objects, refuse header selections, process just those rows, write status back, toast the count. Plus a soft NitroGAS note for getSelectedRowObjects.

Your nightly job handles everything. Then someone asks: “Can I just re-send these three?” The honest answer is usually “change Status back to Pending and wait until 2am,” or “I’ll run it by hand.” Neither is great.

Give them a menu item that runs the same worker on only the rows they highlighted. Same code path, smaller blast radius, and the person who noticed the problem can fix it.

TL;DR

  • Custom menu → Run on selected rows
  • Selection → header-keyed row objects (with _row) via getSelectedRowObjects
  • Refuse if the header row is in the selection or nothing usable is selected
  • Run your existing worker per row; write a status back; toast the count
  • Keep the worker shared with the scheduled job, so there’s one implementation

Using the Apps Script editor a lot? NitroGAS drops free themes & snippets right into script.google.com — optional Co-Pilot when you want a boost.

How this differs from a job queue

Treating a sheet like a job queue is about a script claiming Pending rows and working them in the background. This guide is the manual lever: a person picks rows and says “do these now.” The two fit together: the menu can call the same processRow_ the queue worker uses. We won’t repeat the claim/lock logic here.

What not to do

  • Don’t copy-paste the worker into a second “manual” version. They will drift.
  • Don’t silently include the header row, or you’ll “process” a row called Email.
  • Don’t ignore multi-range selections (Ctrl/Cmd-click). Ops people use them.
  • Don’t process 2,000 rows because someone clicked a column letter; cap it and say so.

Workflow

1. Refactor the job so the per-row work lives in processRow_(row) 2. Add a menu item: Run on selected rows 3. Guard: header selected? nothing selected? too many rows? → alert and stop 4. Loop the selected row objects → processRow_ → write Status / Last run 5. Toast "N rows processed, M failed"

The menu

function onOpen() { SpreadsheetApp.getUi() .createMenu('Jobs') .addItem('Run on selected rows', 'runOnSelectedRows') .addToUi(); }

Run only what’s highlighted

var JOB_SHEET = 'Orders'; var MAX_SELECTED = 200; // stay well under the 6-minute limit function runOnSelectedRows() { var ss = SpreadsheetApp.getActive(); var sheet = ss.getActiveSheet(); var ui = SpreadsheetApp.getUi(); if (sheet.getName() !== JOB_SHEET) { ui.alert('Switch to the "' + JOB_SHEET + '" tab and select the rows to run.'); return; } // Refuse header selections outright; it's almost always a mis-click var touchesHeader = sheet.getActiveRangeList().getRanges().some(function (r) { return r.getRow() <= 1; }); if (touchesHeader) { ui.alert('Your selection includes the header row. Select data rows only.'); return; } var rows = getSelectedRowObjects(sheet); // [{ _row, Email, Status, ... }] if (!rows.length) { ui.alert('Nothing to run. Select one or more data rows first.'); return; } if (rows.length > MAX_SELECTED) { ui.alert('That is ' + rows.length + ' rows. Select ' + MAX_SELECTED + ' or fewer, or let the nightly job handle it.'); return; } var statusCol = headerIndex_(sheet, 'Status'); var ok = 0; var failed = 0; rows.forEach(function (row) { try { processRow_(row); // the SAME worker the scheduled job uses sheet.getRange(row._row, statusCol).setValue('Done (manual)'); ok++; } catch (err) { sheet.getRange(row._row, statusCol).setValue('Error: ' + err.message); failed++; } }); ss.toast(ok + ' row(s) processed' + (failed ? ', ' + failed + ' failed' : ''), 'Run on selected rows', 6); } function headerIndex_(sheet, name) { var headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0]; var i = headers.indexOf(name); if (i === -1) throw new Error('Missing column: ' + name); return i + 1; }

getSelectedRowObjects does the fiddly part. It merges multiple ranges, drops duplicates, skips the header and empty tail rows, reads the full row even when only one column is highlighted, and attaches _row so the status lands on the right line.

Share the worker with the nightly job

function nightlyJob() { var rows = readPendingRows_(); // your existing selection logic rows.forEach(processRow_); } function processRow_(row) { // One implementation for manual and scheduled runs sendConfirmation_(row.Email, row.OrderId); }

If both can run at the same time, wrap each entry point in a document lock (tryLock and skip) so a manual run and the 2am run don’t process the same row twice.

Failure modes worth naming

  • Selection on another tab: guard by sheet name, as above.
  • Filtered views: hidden rows inside a selected block are still selected. If that matters, skip rows where sheet.isRowHiddenByFilter(row._row) is true.
  • Long runs: the cap keeps you inside the 6-minute limit. For bigger batches, mark rows Pending and let the queue worker take them.
  • Someone edits a row mid-run: you read the values at the start. Usually fine; for sensitive jobs, re-read the row inside processRow_.
  • First-run authorization: menu items run with the user’s authorization, so the first click shows the consent screen. Tell your team to expect it.

Soft Co-Pilot note

NitroGAS puts getSelectedRowObjects in the snippet catalog. Paste your nightly job and Co-Pilot can pull the per-row work out into processRow_ and add the “Run on selected rows” menu around it. You still test on a copy first. Free core; Co-Pilot is a pass for when you want the refactor drafted.

Closing checklist

  • Per-row work lives in one shared processRow_
  • Menu refuses header selections, empty selections, and oversized batches
  • Multi-range selections handled; _row used for write-backs
  • Status written per row; toast shows the count
  • Manual and scheduled runs can’t collide (lock)

Happy Coding!