google-apps-script
替 Google Sheets 寫選單、trigger、對話框和寄信自動化
中文翻譯
---
name: google-apps-script
description: "為 Sheets 和 Workspace 打造 Google Apps Script 自動化。包含自訂選單、trigger(onEdit / 定時 / 表單送出)、對話框、側邊欄、批次寄信、PDF 匯出、外部 API。只要使用者想自動化 Google Sheet、做 Sheets 選單 / 側邊欄 / 對話框、想從 email 或 webhook 寫入 Sheets 的某一列、想排程 Sheets 工作流程,或問「這個在 Sheets 裡要怎麼寫 script」,就用這個 skill。"
compatibility: claude-code-only
---
# Google Apps Script
為 Google Sheets 和 Workspace 應用程式打造自動化 script。Script 在 Google 的基礎設施上於伺服器端執行,免費額度相當寬鬆。
## 你會產出什麼
- 貼到 Extensions > Apps Script 的 Apps Script 程式碼
- 自訂選單、對話框、側邊欄
- 自動化 trigger(編輯時、定時、表單送出)
- Email 通知、PDF 匯出、API 整合
## 工作流程
### 步驟 1:了解要自動化什麼
問使用者想自動化什麼。常見情境:
- 帶有動作的自訂選單(產生報表、處理資料)
- 自動觸發的行為(編輯時、表單送出時、排程)
- 用來輸入資料的側邊欄 app
- 根據試算表資料寄 email 通知
- PDF 匯出與寄送
### 步驟 2:產生 script
照下面的結構範本寫。每個 script 都要有標頭註解、放在最上面的設定常數,以及用來建立選單的 `onOpen()`。
### 步驟 3:提供安裝說明
所有 script 的安裝方式都一樣:
1. 打開 Google Sheet
2. **Extensions > Apps Script**
3. 刪掉編輯器裡原有的程式碼
4. 貼上 script
5. 按 **Save**
6. 關掉 Apps Script 分頁
7. **重新載入試算表**(onOpen 是在頁面載入時執行)
### 步驟 4:第一次授權
每個使用者第一次執行時都會看到 Google OAuth 同意畫面。對於未驗證的 script(大部分內部 script 都是),使用者必須點:
**Advanced > Go to [Project Name] (unsafe) > Allow**
這個步驟每個使用者只需做一次。輸出時要提醒使用者這件事。
---
## Script 結構範本
每個 script 都要照這個模式:
```javascript
/**
* [Project Name] - [Brief Description]
*
* [What it does, key features]
*
* INSTALL: Extensions > Apps Script > paste this > Save > Reload sheet
*/
// --- CONFIGURATION ---
const SOME_SETTING = 'value';
// --- MENU SETUP ---
function onOpen() {
const ui = SpreadsheetApp.getUi();
ui.createMenu('My Menu')
.addItem('Do Something', 'myFunction')
.addSeparator()
.addSubMenu(ui.createMenu('More Options')
.addItem('Option A', 'optionA'))
.addToUi();
}
// --- FUNCTIONS ---
function myFunction() {
// Implementation
}
```
---
## 關鍵規則
### 公開 vs 私有函式
名稱結尾是 `_`(底線)的函式是**私有**的,**不能**從 client 端 HTML 透過 `google.script.run` 呼叫。這是靜默失敗:呼叫就是沒反應,也不會報錯。
```javascript
// WRONG - dialog can't call this, fails silently
function doWork_() { return 'done'; }
// RIGHT - dialog can call this
function doWork() { return 'done'; }
```
**同樣適用於**:選單項目所參照的函式,必須是以字串表示的公開函式名稱。
### 批次操作(對效能至關重要)
資料要整批讀寫,絕對不要一格一格來。差距是 70 倍。
```javascript
// SLOW (70 seconds on 100x100) - reads one cell at a time
for (let i = 1; i <= 100; i++) {
const val = sheet.getRange(i, 1).getValue();
}
// FAST (1 second) - reads all at once
const allData = sheet.getRange(1, 1, 100, 1).getValues();
for (const row of allData) {
const val = row[0];
}
```
大量讀寫一律用 `getRange().getValues()` / `setValues()`。
### V8 Runtime
V8 是**唯一**的 runtime(Rhino 已在 2026 年 1 月移除)。支援現代 JavaScript:`const`、`let`、arrow function、template literal、解構、class、async/generator。
**沒有**的東西(改用 Apps Script 的替代方案):
| 缺少的 API | Apps Script 替代方案 |
|-------------|------------------------|
| `setTimeout` / `setInterval` | `Utilities.sleep(ms)`(會阻塞) |
| `fetch` | `UrlFetchApp.fetch()` |
| `FormData` | 手動組 payload |
| `URL` | 字串處理 |
| `crypto` | `Utilities.computeDigest()` / `Utilities.getUuid()` |
### Return 之前先 flush
在會修改試算表的函式 return 之前,要呼叫 `SpreadsheetApp.flush()`,從 HTML 對話框呼叫的函式尤其要這樣。沒呼叫的話,對話框顯示「Done」時,改動可能還看不到。
### Simple trigger vs Installable trigger
| 功能 | Simple(`onEdit`) | Installable |
|---------|-------------------|-------------|
| 需要授權 | 否 | 是 |
| 寄 email | 否 | 是 |
| 存取其他檔案 | 否 | 是 |
| URL fetch | 否 | 是 |
| 開對話框 | 否 | 是 |
| 以誰的身分執行 | 目前的使用者 | 建立 trigger 的人 |
輕量的反應用 simple trigger。需要寄 email、呼叫外部 API 或跨檔案存取時,用 installable trigger(透過 `ScriptApp.newTrigger()`)。
### 自訂試算表函式
在儲存格裡用 `=MY_FUNCTION()` 的函式有嚴格限制:
```javascript
/**
* Calculates something custom.
* @param {string} input The input value
* @return {string} The result
* @customfunction
*/
function MY_FUNCTION(input) {
// Can use: basic JS, Utilities, CacheService
// CANNOT use: MailApp, UrlFetchApp, SpreadsheetApp.getUi(), triggers
return input.toUpperCase();
}
```
- 一定要有 `@customfunction` JSDoc 標籤
- 執行時間上限 30 秒(一般函式是 6 分鐘)
- 不能存取需要授權的服務
---
## 配額與限制
| 資源 | 免費帳號 | Google Workspace |
|----------|-------------|-----------------|
| Script 執行時間 | 每次執行 6 分鐘 | 每次執行 6 分鐘 |
| 定時 trigger 執行時間 | 30 分鐘 | 30 分鐘 |
| Trigger 每日總執行時間 | 90 分鐘 | 6 小時 |
| Trigger 總數 | 每個使用者每個 script 20 個 | 每個使用者每個 script 20 個 |
| 每天收件者數 | 100 | 1,500 |
| 每天 URL Fetch 次數 | 20,000 | 100,000 |
| Properties 儲存空間 | 500 KB | 500 KB |
| 自訂函式執行時間 | 30 秒 | 30 秒 |
| 同時執行數 | 30 | 30 |
---
## Modal 進度對話框
長時間操作時,用會自動關閉的 spinner 擋住使用者操作。任何超過幾秒的操作都適用。
**模式:選單函式 > showProgress() > 對話框呼叫動作函式 > 自動關閉**
```javascript
function showProgress(message, serverFn) {
const html = HtmlService.createHtmlOutput(`
<style>
body { font-family: 'Google Sans', Arial, sans-serif; display: flex;
flex-direction: column; align-items: center; justify-content: center;
height: 100%; margin: 0; padding: 20px; box-sizing: border-box; }
.spinner { width: 36px; height: 36px; border: 4px solid #e0e0e0;
border-top: 4px solid #1a73e8; border-radius: 50%;
animation: spin 0.8s linear infinite; margin-bottom: 16px; }
@keyframes spin { to { transform: rotate(360deg); } }
.message { font-size: 14px; color: #333; text-align: center; }
.done { color: #1e8e3e; font-weight: 500; }
.error { color: #d93025; font-weight: 500; }
</style>
<div class="spinner" id="spinner"></div>
<div class="message" id="msg">${message}</div>
<script>
google.script.run
.withSuccessHandler(function(r) {
document.getElementById('spinner').style.display = 'none';
var m = document.getElementById('msg');
m.className = 'message done';
m.innerText = 'Done! ' + (r || '');
setTimeout(function() { google.script.host.close(); }, 1200);
})
.withFailureHandler(function(err) {
document.getElementById('spinner').style.display = 'none';
var m = document.getElementById('msg');
m.className = 'message error';
m.innerText = 'Error: ' + err.message;
setTimeout(function() { google.script.host.close(); }, 3000);
})
.${serverFn}();
</script>
`).setWidth(320).setHeight(140);
SpreadsheetApp.getUi().showModalDialog(html, 'Working...');
}
// Menu calls this wrapper
function menuDoWork() {
showProgress('Processing data...', 'doTheWork');
}
// MUST be public (no underscore) for the dialog to call it
function doTheWork() {
// ... do the work ...
SpreadsheetApp.flush();
return 'Processed 50 rows'; // shown in success message
}
```
---
## 常見模式
### Toast 通知
```javascript
SpreadsheetApp.getActiveSpreadsheet().toast('Operation complete!', 'Title', 5);
// Arguments: message, title, duration in seconds (-1 = until dismissed)
```
### Alert 與 Prompt 對話框
```javascript
const ui = SpreadsheetApp.getUi();
// Yes/No confirmation
const response = ui.alert('Delete this data?', 'This cannot be undone.',
ui.ButtonSet.YES_NO);
if (response === ui.Button.YES) { /* proceed */ }
// Prompt for input
const result = ui.prompt('Enter your name:', ui.ButtonSet.OK_CANCEL);
if (result.getSelectedButton() === ui.Button.OK) {
const name = result.getResponseText();
}
```
### 側邊欄 App
右側的 HTML 面板。用 `google.script.run` 呼叫伺服器端函式。
```javascript
function showSidebar() {
const html = HtmlService.createHtmlOutput(`
<h3>Quick Entry</h3>
<select id="worker"><option>Craig</option><option>Steve</option></select>
<input id="suburb" placeholder="Suburb">
<button onclick="submit()">Add Job</button>
<script>
function submit() {
google.script.run.withSuccessHandler(function() { alert('Added!'); })
.addJob(document.getElementById('worker').value,
document.getElementById('suburb').value);
}
</script>
`).setTitle('Job Entry').setWidth(300);
SpreadsheetApp.getUi().showSidebar(html);
}
function addJob(worker, suburb) { // MUST be public (no underscore)
SpreadsheetApp.getActiveSpreadsheet().getActiveSheet().appendRow([new Date(), worker, suburb]);
}
```
### Trigger
**onEdit(simple trigger)**:權限有限,但不需要授權:
```javascript
function onEdit(e) {
const sheet = e.source.getActiveSheet();
if (sheet.getName() !== 'Data') return;
if (e.range.getColumn() !== 3) return;
// Auto-timestamp when column C is edited
sheet.getRange(e.range.getRow(), 4).setValue(new Date());
}
```
**Installable trigger**:用 script 建立,並手動執行一次設定函式:
```javascript
function createTriggers() {
// Time-driven: run every day at 8am
ScriptApp.newTrigger('dailyReport')
.timeBased().atHour(8).everyDays(1).create();
// On edit with full permissions (can send email, fetch URLs)
ScriptApp.newTrigger('onEditFull')
.forSpreadsheet(SpreadsheetApp.getActive()).onEdit().create();
// On form submit
ScriptApp.newTrigger('onFormSubmit')
.forSpreadsheet(SpreadsheetApp.getActive()).onFormSubmit().create();
}
```
### 從 Sheets 寄 Email
```javascript
function emailWeeklySchedule() {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
const data = sheet.getRange('A2:E10').getDisplayValues();
let body = '<h2>Weekly Schedule</h2><table border="1" cellpadding="8">';
body += '<tr><th>Job</th><th>Suburb</th><th>Time</th><th>Price</th></tr>';
for (const row of data) {
if (row[0]) body += '<tr>' + row.map(c => '<td>' + c + '</td>').join('') + '</tr>';
}
body += '</table>';
MailApp.sendEmail({ to: 'worker@example.com',
subject: 'Schedule - Week ' + sheet.getName(), htmlBody: body });
}
```
### PDF 匯出
URL 的組法不直觀,匯出參數沒有官方文件:
```javascript
function exportSheetAsPdf() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const url = ss.getUrl().replace(/\/edit.*$/, '')
+ '/export?exportFormat=pdf&format=pdf&size=A4&portrait=true'
+ '&fitw=true&sheetnames=false&printtitle=false&gridlines=false'
+ '&gid=' + ss.getActiveSheet().getSheetId();
const blob = UrlFetchApp.fetch(url, {
headers: { 'Authorization': 'Bearer ' + ScriptApp.getOAuthToken() }
}).getBlob().setName('report.pdf');
MailApp.sendEmail({ to: 'boss@example.com', subject: 'Weekly Report PDF',
body: 'Attached.', attachments: [blob] });
}
```
### 呼叫外部 API
```javascript
// GET
function fetchData() {
const r = UrlFetchApp.fetch('https://api.example.com/data', {
headers: { 'Authorization': 'Bearer ' + getApiKey() } });
return JSON.parse(r.getContentText());
}
// POST (muteHttpExceptions to handle errors yourself)
function postData(payload) {
const r = UrlFetchApp.fetch('https://api.example.com/submit', {
method: 'post', contentType: 'application/json',
payload: JSON.stringify(payload), muteHttpExceptions: true });
if (r.getResponseCode() !== 200) throw new Error('API error: ' + r.getContentText());
return JSON.parse(r.getContentText());
}
```
### 資料驗證下拉選單
```javascript
// Dropdown from list
const rule = SpreadsheetApp.newDataValidation()
.requireValueInList(['Option A', 'Option B', 'Option C'], true)
.setAllowInvalid(false).setHelpText('Select an option').build();
sheet.getRange('C3:C50').setDataValidation(rule);
// Dropdown from range (e.g. a Lookups sheet)
const rule2 = SpreadsheetApp.newDataValidation()
.requireValueInRange(ss.getSheetByName('Lookups').getRange('A1:A100')).build();
sheet.getRange('B3:B50').setDataValidation(rule2);
```
### Properties Service(持久儲存)
三種範圍:`PropertiesService.getScriptProperties()`(共用)、`.getUserProperties()`(每個使用者)、`.getDocumentProperties()`(每份試算表)。全部都用 `.setProperty(key, value)` / `.getProperty(key)`。上限 500 KB。
---
## 食譜
### 自動封存已完成的列
把狀態是「Complete」的列移到 Archive 工作表。由下往上處理,避免列索引位移。
```javascript
function archiveCompleted() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const source = ss.getSheetByName('Active');
const archive = ss.getSheetByName('Archive');
const data = source.getDataRange().getValues();
const statusCol = 4; // column E (0-indexed)
for (let i = data.length - 1; i >= 1; i--) {
if (data[i][statusCol] === 'Complete') {
archive.appendRow(data[i]);
source.deleteRow(i + 1); // +1 for 1-indexed rows
}
}
SpreadsheetApp.flush();
}
```
### 重複偵測與標示
模式:用 `getValues()` 讀出一欄,用物件記錄已出現過的值,並用 `setBackground('#f4cccc')` 把原始列和重複列都標示起來。所有資料用一次 `getValues()` 處理,再逐個設定背景色(分散的標示無法避免要逐個設定)。
### 批次寄信程式
關鍵模式:寄信前先檢查 `MailApp.getRemainingDailyQuota()`,每一列標記狀態,每次寄送都包在 try/catch 裡。
```javascript
function sendBatchEmails() {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Recipients');
const data = sheet.getRange('A2:C' + sheet.getLastRow()).getValues(); // Email, Name, Status
const remaining = MailApp.getRemainingDailyQuota();
if (remaining < data.length) {
SpreadsheetApp.getUi().alert('Only ' + remaining + ' emails left. Need ' + data.length);
return;
}
let sent = 0;
for (let i = 0; i < data.length; i++) {
const [email, name, status] = data[i];
if (!email || status === 'Sent') continue;
try {
MailApp.sendEmail({ to: email, subject: 'Your Weekly Update',
htmlBody: '<p>Hi ' + name + ',</p><p>Here is your update...</p>' });
sheet.getRange(i + 2, 3).setValue('Sent'); sent++;
} catch (e) { sheet.getRange(i + 2, 3).setValue('Error: ' + e.message); }
}
SpreadsheetApp.flush();
}
```
### 摘要儀表板產生器
模式:走訪編號的週分頁(`01`-`52`),從每個分頁讀取摘要儲存格,把彙總後的列寫進 Summary 工作表。用 `ss.getSheetByName(tabName)` 來走訪,Summary 不存在的話用 `ss.insertSheet('Summary')` 建立,最後用 `summary.autoResizeColumns()`,return 前要 `flush()`。
---
## 錯誤處理
外部呼叫一律包在 try/catch 裡。用 `muteHttpExceptions: true` 自己處理 HTTP 錯誤。要讓對話框的錯誤處理器接得到,就再 throw 出去。
```javascript
function fetchExternalData() {
try {
const response = UrlFetchApp.fetch('https://api.example.com/data', {
headers: { 'Authorization': 'Bearer ' + getApiKey() },
muteHttpExceptions: true
});
if (response.getResponseCode() !== 200)
throw new Error('API returned ' + response.getResponseCode());
return JSON.parse(response.getContentText());
} catch (e) { Logger.log('Error: ' + e.message); throw e; }
}
```
---
## 錯誤預防
| 錯誤 | 修法 |
|---------|-----|
| 對話框呼叫不到函式 | 把函式名稱結尾的 `_` 拿掉 |
| 資料量大時 script 很慢 | 用 `getValues()`/`setValues()` 批次操作 |
| 對話框結束後看不到改動 | return 前加 `SpreadsheetApp.flush()` |
| `onEdit` 不能寄 email | 改用 installable trigger(`ScriptApp.newTrigger()`) |
| 自訂函式逾時 | 上限 30 秒,要簡化,或改成一般函式 |
| 找不到 `setTimeout` | 用 `Utilities.sleep(ms)`(會阻塞) |
| Script 超過 6 分鐘 | 切成小塊,批次處理改用定時 trigger |
| 授權視窗沒跳出來 | 使用者要點 Advanced > Go to (unsafe) > Allow |
## 除錯
- **Logger.log()** / **console.log()**:在 Apps Script 編輯器的 View > Execution Log 查看
- **手動執行**:在編輯器下拉選單選函式 > Run
- **Executions 分頁**:顯示最近所有執行紀錄,含錯誤和 stack trace
- **Trigger 失敗**:script.google.com > My Projects > Executions
- **部署前一定要先在試算表的副本上測試**
## 部署檢查清單
- [ ] 所有從 HTML 對話框呼叫的函式都是公開的(結尾沒有底線)
- [ ] 會修改資料的函式在 return 前都呼叫了 `SpreadsheetApp.flush()`
- [ ] 外部 API 呼叫和 MailApp 都有錯誤處理(try/catch)
- [ ] 設定常數放在檔案最上面
- [ ] 標頭註解裡有安裝說明
- [ ] 已在試算表副本上測試過
- [ ] 考慮過多使用者的行為(權限不同、目前啟用的工作表不同)
- [ ] 長時間操作使用 modal 進度對話框
- [ ] 沒有寫死工作表名稱,改用設定常數
- [ ] 批次寄信前檢查過 email 配額
---
## 需要時再去 Apps Script 文件重建
- **列/欄顯示/隱藏**:`sheet.hideRows()`、`showRows()`、`isRowHiddenByUser()`
- **格式設定**:`setBackground()`、`setFontWeight()`、`setBorder()`、`setNumberFormat()`、條件式格式
- **資料保護**:`range.protect()`、`setUnprotectedRanges()`、編輯者管理
- **多個工作表**:`getSheetByName()`、走訪編號分頁、`copyTo()`、`insertSheet()`
- **列自動編號**:用 `onEdit` trigger,在 B 欄被編輯時自動替 A 欄編號
- **Google Chat webhook**:用 JSON payload POST 到 `chat.googleapis.com`英文原文
---
name: google-apps-script
description: "Build Google Apps Script automation for Sheets and Workspace. Custom menus, triggers (onEdit / time-driven / form submit), dialogs, sidebars, email batches, PDF export, external API. Use whenever the user wants to automate a Google Sheet, build a Sheets menu / sidebar / dialog, hit a Sheets row from email or a webhook, schedule a Sheets workflow, or asks 'how do I script this in Sheets'."
compatibility: claude-code-only
---
# Google Apps Script
Build automation scripts for Google Sheets and Workspace apps. Scripts run server-side on Google's infrastructure with a generous free tier.
## What You Produce
- Apps Script code pasted into Extensions > Apps Script
- Custom menus, dialogs, sidebars
- Automated triggers (on edit, time-driven, form submit)
- Email notifications, PDF exports, API integrations
## Workflow
### Step 1: Understand the Automation
Ask what the user wants automated. Common scenarios:
- Custom menu with actions (report generation, data processing)
- Auto-triggered behaviour (on edit, on form submit, scheduled)
- Sidebar app for data entry
- Email notifications from sheet data
- PDF export and distribution
### Step 2: Generate the Script
Follow the structure template below. Every script needs a header comment, configuration constants at top, and `onOpen()` for menu setup.
### Step 3: Provide Installation Instructions
All scripts install the same way:
1. Open the Google Sheet
2. **Extensions > Apps Script**
3. Delete any existing code in the editor
4. Paste the script
5. Click **Save**
6. Close the Apps Script tab
7. **Reload the spreadsheet** (onOpen runs on page load)
### Step 4: First-Time Authorisation
Each user gets a Google OAuth consent screen on first run. For unverified scripts (most internal scripts), users must click:
**Advanced > Go to [Project Name] (unsafe) > Allow**
This is a one-time step per user. Warn users about this in your output.
---
## Script Structure Template
Every script should follow this pattern:
```javascript
/**
* [Project Name] - [Brief Description]
*
* [What it does, key features]
*
* INSTALL: Extensions > Apps Script > paste this > Save > Reload sheet
*/
// --- CONFIGURATION ---
const SOME_SETTING = 'value';
// --- MENU SETUP ---
function onOpen() {
const ui = SpreadsheetApp.getUi();
ui.createMenu('My Menu')
.addItem('Do Something', 'myFunction')
.addSeparator()
.addSubMenu(ui.createMenu('More Options')
.addItem('Option A', 'optionA'))
.addToUi();
}
// --- FUNCTIONS ---
function myFunction() {
// Implementation
}
```
---
## Critical Rules
### Public vs Private Functions
Functions ending with `_` (underscore) are **private** and CANNOT be called from client-side HTML via `google.script.run`. This is a silent failure -- the call simply doesn't work with no error.
```javascript
// WRONG - dialog can't call this, fails silently
function doWork_() { return 'done'; }
// RIGHT - dialog can call this
function doWork() { return 'done'; }
```
**Also applies to**: Menu item function references must be public function names as strings.
### Batch Operations (Critical for Performance)
Read/write data in bulk, never cell-by-cell. The difference is 70x.
```javascript
// SLOW (70 seconds on 100x100) - reads one cell at a time
for (let i = 1; i <= 100; i++) {
const val = sheet.getRange(i, 1).getValue();
}
// FAST (1 second) - reads all at once
const allData = sheet.getRange(1, 1, 100, 1).getValues();
for (const row of allData) {
const val = row[0];
}
```
Always use `getRange().getValues()` / `setValues()` for bulk reads/writes.
### V8 Runtime
V8 is the **only** runtime (Rhino was removed January 2026). Supports modern JavaScript: `const`, `let`, arrow functions, template literals, destructuring, classes, async/generators.
**NOT available** (use Apps Script alternatives):
| Missing API | Apps Script Alternative |
|-------------|------------------------|
| `setTimeout` / `setInterval` | `Utilities.sleep(ms)` (blocking) |
| `fetch` | `UrlFetchApp.fetch()` |
| `FormData` | Build payload manually |
| `URL` | String manipulation |
| `crypto` | `Utilities.computeDigest()` / `Utilities.getUuid()` |
### Flush Before Returning
Call `SpreadsheetApp.flush()` before returning from functions that modify the sheet, especially when called from HTML dialogs. Without it, changes may not be visible when the dialog shows "Done."
### Simple vs Installable Triggers
| Feature | Simple (`onEdit`) | Installable |
|---------|-------------------|-------------|
| Auth required | No | Yes |
| Send email | No | Yes |
| Access other files | No | Yes |
| URL fetch | No | Yes |
| Open dialogs | No | Yes |
| Runs as | Active user | Trigger creator |
Use simple triggers for lightweight reactions. Use installable triggers (via `ScriptApp.newTrigger()`) when you need email, external APIs, or cross-file access.
### Custom Spreadsheet Functions
Functions used as `=MY_FUNCTION()` in cells have strict limitations:
```javascript
/**
* Calculates something custom.
* @param {string} input The input value
* @return {string} The result
* @customfunction
*/
function MY_FUNCTION(input) {
// Can use: basic JS, Utilities, CacheService
// CANNOT use: MailApp, UrlFetchApp, SpreadsheetApp.getUi(), triggers
return input.toUpperCase();
}
```
- Must include `@customfunction` JSDoc tag
- 30-second execution limit (vs 6 minutes for regular functions)
- Cannot access services requiring authorisation
---
## Quotas and Limits
| Resource | Free Account | Google Workspace |
|----------|-------------|-----------------|
| Script runtime | 6 min / execution | 6 min / execution |
| Time-driven trigger runtime | 30 min | 30 min |
| Triggers total daily runtime | 90 min | 6 hours |
| Triggers total | 20 per user per script | 20 per user per script |
| Email recipients/day | 100 | 1,500 |
| URL Fetch calls/day | 20,000 | 100,000 |
| Properties storage | 500 KB | 500 KB |
| Custom function runtime | 30 seconds | 30 seconds |
| Simultaneous executions | 30 | 30 |
---
## Modal Progress Dialog
Block user interaction during long operations with a spinner that auto-closes. Use for any operation taking more than a few seconds.
**Pattern: menu function > showProgress() > dialog calls action function > auto-close**
```javascript
function showProgress(message, serverFn) {
const html = HtmlService.createHtmlOutput(`
<style>
body { font-family: 'Google Sans', Arial, sans-serif; display: flex;
flex-direction: column; align-items: center; justify-content: center;
height: 100%; margin: 0; padding: 20px; box-sizing: border-box; }
.spinner { width: 36px; height: 36px; border: 4px solid #e0e0e0;
border-top: 4px solid #1a73e8; border-radius: 50%;
animation: spin 0.8s linear infinite; margin-bottom: 16px; }
@keyframes spin { to { transform: rotate(360deg); } }
.message { font-size: 14px; color: #333; text-align: center; }
.done { color: #1e8e3e; font-weight: 500; }
.error { color: #d93025; font-weight: 500; }
</style>
<div class="spinner" id="spinner"></div>
<div class="message" id="msg">${message}</div>
<script>
google.script.run
.withSuccessHandler(function(r) {
document.getElementById('spinner').style.display = 'none';
var m = document.getElementById('msg');
m.className = 'message done';
m.innerText = 'Done! ' + (r || '');
setTimeout(function() { google.script.host.close(); }, 1200);
})
.withFailureHandler(function(err) {
document.getElementById('spinner').style.display = 'none';
var m = document.getElementById('msg');
m.className = 'message error';
m.innerText = 'Error: ' + err.message;
setTimeout(function() { google.script.host.close(); }, 3000);
})
.${serverFn}();
</script>
`).setWidth(320).setHeight(140);
SpreadsheetApp.getUi().showModalDialog(html, 'Working...');
}
// Menu calls this wrapper
function menuDoWork() {
showProgress('Processing data...', 'doTheWork');
}
// MUST be public (no underscore) for the dialog to call it
function doTheWork() {
// ... do the work ...
SpreadsheetApp.flush();
return 'Processed 50 rows'; // shown in success message
}
```
---
## Common Patterns
### Toast Notifications
```javascript
SpreadsheetApp.getActiveSpreadsheet().toast('Operation complete!', 'Title', 5);
// Arguments: message, title, duration in seconds (-1 = until dismissed)
```
### Alert and Prompt Dialogs
```javascript
const ui = SpreadsheetApp.getUi();
// Yes/No confirmation
const response = ui.alert('Delete this data?', 'This cannot be undone.',
ui.ButtonSet.YES_NO);
if (response === ui.Button.YES) { /* proceed */ }
// Prompt for input
const result = ui.prompt('Enter your name:', ui.ButtonSet.OK_CANCEL);
if (result.getSelectedButton() === ui.Button.OK) {
const name = result.getResponseText();
}
```
### Sidebar Apps
HTML panel on the right. Use `google.script.run` to call server functions.
```javascript
function showSidebar() {
const html = HtmlService.createHtmlOutput(`
<h3>Quick Entry</h3>
<select id="worker"><option>Craig</option><option>Steve</option></select>
<input id="suburb" placeholder="Suburb">
<button onclick="submit()">Add Job</button>
<script>
function submit() {
google.script.run.withSuccessHandler(function() { alert('Added!'); })
.addJob(document.getElementById('worker').value,
document.getElementById('suburb').value);
}
</script>
`).setTitle('Job Entry').setWidth(300);
SpreadsheetApp.getUi().showSidebar(html);
}
function addJob(worker, suburb) { // MUST be public (no underscore)
SpreadsheetApp.getActiveSpreadsheet().getActiveSheet().appendRow([new Date(), worker, suburb]);
}
```
### Triggers
**onEdit (simple trigger)** -- limited permissions but no auth needed:
```javascript
function onEdit(e) {
const sheet = e.source.getActiveSheet();
if (sheet.getName() !== 'Data') return;
if (e.range.getColumn() !== 3) return;
// Auto-timestamp when column C is edited
sheet.getRange(e.range.getRow(), 4).setValue(new Date());
}
```
**Installable triggers** -- create via script, run setup function once manually:
```javascript
function createTriggers() {
// Time-driven: run every day at 8am
ScriptApp.newTrigger('dailyReport')
.timeBased().atHour(8).everyDays(1).create();
// On edit with full permissions (can send email, fetch URLs)
ScriptApp.newTrigger('onEditFull')
.forSpreadsheet(SpreadsheetApp.getActive()).onEdit().create();
// On form submit
ScriptApp.newTrigger('onFormSubmit')
.forSpreadsheet(SpreadsheetApp.getActive()).onFormSubmit().create();
}
```
### Email from Sheets
```javascript
function emailWeeklySchedule() {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
const data = sheet.getRange('A2:E10').getDisplayValues();
let body = '<h2>Weekly Schedule</h2><table border="1" cellpadding="8">';
body += '<tr><th>Job</th><th>Suburb</th><th>Time</th><th>Price</th></tr>';
for (const row of data) {
if (row[0]) body += '<tr>' + row.map(c => '<td>' + c + '</td>').join('') + '</tr>';
}
body += '</table>';
MailApp.sendEmail({ to: 'worker@example.com',
subject: 'Schedule - Week ' + sheet.getName(), htmlBody: body });
}
```
### PDF Export
Non-obvious URL construction -- export parameters are undocumented:
```javascript
function exportSheetAsPdf() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const url = ss.getUrl().replace(/\/edit.*$/, '')
+ '/export?exportFormat=pdf&format=pdf&size=A4&portrait=true'
+ '&fitw=true&sheetnames=false&printtitle=false&gridlines=false'
+ '&gid=' + ss.getActiveSheet().getSheetId();
const blob = UrlFetchApp.fetch(url, {
headers: { 'Authorization': 'Bearer ' + ScriptApp.getOAuthToken() }
}).getBlob().setName('report.pdf');
MailApp.sendEmail({ to: 'boss@example.com', subject: 'Weekly Report PDF',
body: 'Attached.', attachments: [blob] });
}
```
### External API Calls
```javascript
// GET
function fetchData() {
const r = UrlFetchApp.fetch('https://api.example.com/data', {
headers: { 'Authorization': 'Bearer ' + getApiKey() } });
return JSON.parse(r.getContentText());
}
// POST (muteHttpExceptions to handle errors yourself)
function postData(payload) {
const r = UrlFetchApp.fetch('https://api.example.com/submit', {
method: 'post', contentType: 'application/json',
payload: JSON.stringify(payload), muteHttpExceptions: true });
if (r.getResponseCode() !== 200) throw new Error('API error: ' + r.getContentText());
return JSON.parse(r.getContentText());
}
```
### Data Validation Dropdowns
```javascript
// Dropdown from list
const rule = SpreadsheetApp.newDataValidation()
.requireValueInList(['Option A', 'Option B', 'Option C'], true)
.setAllowInvalid(false).setHelpText('Select an option').build();
sheet.getRange('C3:C50').setDataValidation(rule);
// Dropdown from range (e.g. a Lookups sheet)
const rule2 = SpreadsheetApp.newDataValidation()
.requireValueInRange(ss.getSheetByName('Lookups').getRange('A1:A100')).build();
sheet.getRange('B3:B50').setDataValidation(rule2);
```
### Properties Service (Persistent Storage)
Three scopes: `PropertiesService.getScriptProperties()` (shared), `.getUserProperties()` (per user), `.getDocumentProperties()` (per spreadsheet). All use `.setProperty(key, value)` / `.getProperty(key)`. 500 KB limit.
---
## Recipes
### Auto-Archive Completed Rows
Move rows with "Complete" status to an Archive sheet. Processes bottom-up to avoid shifting row indices.
```javascript
function archiveCompleted() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const source = ss.getSheetByName('Active');
const archive = ss.getSheetByName('Archive');
const data = source.getDataRange().getValues();
const statusCol = 4; // column E (0-indexed)
for (let i = data.length - 1; i >= 1; i--) {
if (data[i][statusCol] === 'Complete') {
archive.appendRow(data[i]);
source.deleteRow(i + 1); // +1 for 1-indexed rows
}
}
SpreadsheetApp.flush();
}
```
### Duplicate Detection and Highlighting
Pattern: read column with `getValues()`, track seen values in an object, highlight both the original and duplicate rows with `setBackground('#f4cccc')`. Process all data in one `getValues()` call, then set backgrounds individually (unavoidable for scattered highlights).
### Batch Email Sender
Key pattern: check `MailApp.getRemainingDailyQuota()` before sending, mark status per row, wrap each send in try/catch.
```javascript
function sendBatchEmails() {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Recipients');
const data = sheet.getRange('A2:C' + sheet.getLastRow()).getValues(); // Email, Name, Status
const remaining = MailApp.getRemainingDailyQuota();
if (remaining < data.length) {
SpreadsheetApp.getUi().alert('Only ' + remaining + ' emails left. Need ' + data.length);
return;
}
let sent = 0;
for (let i = 0; i < data.length; i++) {
const [email, name, status] = data[i];
if (!email || status === 'Sent') continue;
try {
MailApp.sendEmail({ to: email, subject: 'Your Weekly Update',
htmlBody: '<p>Hi ' + name + ',</p><p>Here is your update...</p>' });
sheet.getRange(i + 2, 3).setValue('Sent'); sent++;
} catch (e) { sheet.getRange(i + 2, 3).setValue('Error: ' + e.message); }
}
SpreadsheetApp.flush();
}
```
### Summary Dashboard Generator
Pattern: loop numbered weekly tabs (`01`-`52`), read summary cells from each, write aggregated rows into a Summary sheet. Use `ss.getSheetByName(tabName)` to iterate, `ss.insertSheet('Summary')` if it doesn't exist, `summary.autoResizeColumns()` at end, `flush()` before return.
---
## Error Handling
Always wrap external calls in try/catch. Use `muteHttpExceptions: true` to handle HTTP errors yourself. Re-throw for dialog error handlers.
```javascript
function fetchExternalData() {
try {
const response = UrlFetchApp.fetch('https://api.example.com/data', {
headers: { 'Authorization': 'Bearer ' + getApiKey() },
muteHttpExceptions: true
});
if (response.getResponseCode() !== 200)
throw new Error('API returned ' + response.getResponseCode());
return JSON.parse(response.getContentText());
} catch (e) { Logger.log('Error: ' + e.message); throw e; }
}
```
---
## Error Prevention
| Mistake | Fix |
|---------|-----|
| Dialog can't call function | Remove trailing `_` from function name |
| Script is slow on large data | Use `getValues()`/`setValues()` batch operations |
| Changes not visible after dialog | Add `SpreadsheetApp.flush()` before return |
| `onEdit` can't send email | Use installable trigger via `ScriptApp.newTrigger()` |
| Custom function times out | 30s limit -- simplify or move to regular function |
| `setTimeout` not found | Use `Utilities.sleep(ms)` (blocking) |
| Script exceeds 6 min | Break into chunks, use time-driven trigger for batches |
| Auth popup doesn't appear | User must click Advanced > Go to (unsafe) > Allow |
## Debugging
- **Logger.log()** / **console.log()** -- View > Execution Log in Apps Script editor
- **Run manually** -- select function in editor dropdown > Run
- **Executions tab** -- shows all recent runs with errors and stack traces
- **Trigger failures** -- script.google.com > My Projects > Executions
- **Always test on a copy** of the sheet before deploying
## Deployment Checklist
- [ ] All functions called from HTML dialogs are public (no trailing underscore)
- [ ] `SpreadsheetApp.flush()` called before returning from modifying functions
- [ ] Error handling (try/catch) around external API calls and MailApp
- [ ] Configuration constants at the top of the file
- [ ] Header comment with install instructions
- [ ] Tested on a copy of the sheet
- [ ] Considered multi-user behaviour (different permissions, different active sheet)
- [ ] Long operations use modal progress dialogs
- [ ] No hardcoded sheet names -- use configuration constants
- [ ] Checked email quota before batch sends
---
## Reconstruct from Apps Script docs if needed
- **Row/Column show/hide** — `sheet.hideRows()`, `showRows()`, `isRowHiddenByUser()`
- **Formatting** — `setBackground()`, `setFontWeight()`, `setBorder()`, `setNumberFormat()`, conditional formatting
- **Data protection** — `range.protect()`, `setUnprotectedRanges()`, editor management
- **Multiple sheets** — `getSheetByName()`, looping numbered tabs, `copyTo()`, `insertSheet()`
- **Auto-numbering rows** — `onEdit` trigger to auto-number column A when column B is edited
- **Google Chat webhooks** — POST to `chat.googleapis.com` with JSON payload