Build a personal Workspace add-on
Build a Workspace add-on that shows a handoff checklist in the Drive side panel with CardService and saves it per user, without reading Drive files.
Install a test deployment of the add-on, save and reload a personal checklist, and explain how the homepage trigger, button actions, cards, and per-user storage work together.
Build a handoff checklist in Google Drive's side panel. It has three items (name the next owner, check the requested access, choose the next step) and a short note. Save it and reopen it later to retrieve the same checklist.
This example is a Google Workspace add-on with a card interface. Drive provides the side panel, and Apps Script builds the card and handles its buttons. The add-on stores one checklist per Google account in this script project. It does not read Drive files, change sharing, or send messages. You need runtime and permissions and Work with JavaScript arrays first.
Choose the right kind of interface
An add-on is an application installed into a Workspace host such as Drive or Gmail. A bound script belongs to a single file.
| Interface | Where the UI lives | What starts the interaction |
|---|---|---|
| A bound script's menu, sidebar, or dialog | A particular editor file, where HTML Service can provide custom browser UI | A menu callback, followed by google.script.run for HTML-to-server calls |
| An Editor add-on | An editor such as Docs, Sheets, Slides, or Forms, with menus and HTML dialogs or sidebars | Installing or enabling the add-on, then editor callbacks such as onOpen |
| This Google Workspace add-on | Drive's add-on side panel, rendered from CardService widgets | A manifest homepage trigger, then named server callbacks attached to card actions |
| An Apps Script web app | A separately deployed URL | HTTP requests handled by doGet or doPost |
One Workspace add-on project can extend several hosts, and each host is enabled in appsscript.json. An add-on built with cards inside Sheets is a Workspace add-on, even though it runs in an editor. The two add-on types build their UI and request authorization differently. See Google's add-on type comparison.
A CardService card has no browser DOM, no CSS stylesheet, and no google.script.run bridge. You build a Card on the Apps Script server and return it to the host. The host draws the widgets and sends each button click to the named server callback. See the CardService reference.
Store the checklist per user
The checklist follows your Google account as you move between folders and files in Drive.
PropertiesService.getUserProperties() gives the current user a property store that belongs to this script project. Workspace add-on callbacks run as the user at the keyboard, so the checklist belongs to one user in one script project. See Google's execution-identity table and the PropertiesService reference.
Two browser tabs signed into the same Google account read and write the same record. Another user of the same add-on has a separate store. Use User Properties for this record. Script Properties are shared by every user of the script.
The three checkboxes and the note stay a draft inside the card until you click Save checklist. The progress text at the top describes the saved record. Reload saved discards unsaved edits and reloads the stored record.
Create the development project
Open
script.google.comand create a new standalone project named Handoff checklist.Replace
Code.gswith the source file below.Open Project Settings, enable Show "appsscript.json" manifest file in editor, replace
appsscript.jsonwith the manifest below, and save both files.Keep the project's default Google Cloud project for this unpublished test installation. A standard Google Cloud project is only needed later for versioned add-on deployments, OAuth verification, or Marketplace publication. See the Cloud project guide and deployment guide.
const CHECKLIST_KEY = 'HANDOFF_CHECKLIST_V1';
const NOTE_LIMIT = 120;
const RECORD_BYTE_LIMIT = 2048;
const CHECKLIST_STEPS = [
{ id: 'owner', label: 'Confirm the next owner' },
{ id: 'access', label: 'Check requested access' },
{ id: 'next', label: 'Identify the next step' },
];
/**
* @typedef {{version: number, revision: number, note: string, checked: string[]}} ChecklistState_
*/
/**
* @typedef {{note: string, checked: string[]}} ChecklistDraft_
*/
/**
* @typedef {{hostApp: string, parameters: Record<string, string>, formInputs?: Record<string, unknown>}} ChecklistEvent_
*/
class ChecklistError_ extends Error {
/** @param {string} code */
constructor(code) {
super(code);
this.code = code;
}
}
// A manifest homepage trigger returns cards, not an ActionResponse or HTML.
/**
* @param {unknown} e
* @returns {CardService.Card[]}
*/
function onHomepage(e) {
try {
requireDriveEvent_(e, false);
const state = withUserState_((_properties, current) => current);
return [buildChecklistCard_(state)];
} catch (error) {
return [buildErrorCard_(messageFor_(error))];
}
}
// Save replaces this user's one checklist, never a Drive file or its sharing.
/**
* @param {unknown} e
* @returns {CardService.ActionResponse}
*/
function saveChecklist(e) {
try {
const common = requireAction_(e, 'save');
const revision = readRevision_(common.parameters.revision);
const draft = readDraft_(common.formInputs);
const result = withUserState_((properties, current) => {
// A repeated submission can already match, even after a lost response.
if (sameChecklist_(current, draft)) {
return { state: current, changed: false };
}
if (revision !== current.revision) {
throw new ChecklistError_('STALE');
}
if (current.revision === Number.MAX_SAFE_INTEGER) {
throw new ChecklistError_('LIMIT');
}
const next = {
version: 1,
revision: current.revision + 1,
note: draft.note,
checked: draft.checked,
};
const serialized = JSON.stringify(next);
if (utf8Length_(serialized) > RECORD_BYTE_LIMIT) {
throw new ChecklistError_('LIMIT');
}
properties.setProperty(CHECKLIST_KEY, serialized);
return { state: next, changed: true };
});
return replaceCard_(
result.state,
result.changed ? 'Checklist saved.' : 'Already current; nothing written.',
result.changed,
);
} catch (error) {
// Notification only: do not replace the card or erase an unsaved draft.
// An unexpected failure can follow a completed property write.
return notify_(messageFor_(error));
}
}
// Deliberately discard the displayed draft and read the current saved record.
/**
* @param {unknown} e
* @returns {CardService.ActionResponse}
*/
function reloadChecklist(e) {
try {
requireAction_(e, 'reload');
const state = withUserState_((_properties, current) => current);
return replaceCard_(state, 'Saved checklist loaded.', false);
} catch (error) {
return notify_(messageFor_(error));
}
}
/**
* @param {unknown} e
* @param {boolean} requireParameters
* @returns {ChecklistEvent_}
*/
function requireDriveEvent_(e, requireParameters) {
if (!isObject_(e) || !isObject_(e.commonEventObject)) {
throw new ChecklistError_('EVENT');
}
const common = e.commonEventObject;
// Google omits commonEventObject.parameters from homepage events; button
// actions always carry the parameters their own card attached.
const parameters = common.parameters === undefined && !requireParameters ? {} : common.parameters;
if (common.hostApp !== 'DRIVE' || !isStringRecord_(parameters) ||
(common.formInputs !== undefined && !isObject_(common.formInputs))) {
throw new ChecklistError_('EVENT');
}
return {
hostApp: 'DRIVE',
parameters: parameters,
...(common.formInputs === undefined ? {} : { formInputs: common.formInputs }),
};
}
/**
* @param {unknown} e
* @param {string} operation
* @returns {ChecklistEvent_}
*/
function requireAction_(e, operation) {
const common = requireDriveEvent_(e, true);
const keys = operation === 'save' ? ['operation', 'revision'] : ['operation'];
if (!exactKeys_(common.parameters, keys) ||
common.parameters.operation !== operation) {
throw new ChecklistError_('EVENT');
}
return common;
}
/**
* @param {unknown} text
* @returns {number}
*/
function readRevision_(text) {
if (typeof text !== 'string' || !/^(0|[1-9][0-9]{0,15})$/.test(text)) {
throw new ChecklistError_('EVENT');
}
const revision = Number(text);
if (!Number.isSafeInteger(revision)) {
throw new ChecklistError_('EVENT');
}
return revision;
}
/**
* @param {unknown} formInputs
* @returns {ChecklistDraft_}
*/
function readDraft_(formInputs) {
// Empty widgets can be absent. A present malformed widget is not empty.
const inputs = formInputs === undefined ? {} : formInputs;
if (!isObject_(inputs) ||
Object.keys(inputs).some((key) => key !== 'note' && key !== 'checked')) {
throw new ChecklistError_('EVENT');
}
const notes = stringValues_(inputs, 'note', 1);
const note = notes.length === 0 ? '' : notes[0];
if (!validNote_(note)) {
throw new ChecklistError_('NOTE');
}
const checked = normalizeChecked_(stringValues_(inputs, 'checked', 3), 'STEPS');
return { note, checked };
}
/**
* @param {Record<string, unknown>} inputs
* @param {string} name
* @param {number} maximum
* @returns {string[]}
*/
function stringValues_(inputs, name, maximum) {
if (!Object.prototype.hasOwnProperty.call(inputs, name)) return [];
const widget = inputs[name];
if (!isObject_(widget) || !isObject_(widget.stringInputs) ||
!Array.isArray(widget.stringInputs.value)) {
throw new ChecklistError_('EVENT');
}
const values = widget.stringInputs.value;
if (values.length > maximum || values.some((value) => typeof value !== 'string')) {
throw new ChecklistError_('EVENT');
}
return values;
}
/**
* @param {unknown} note
* @returns {boolean}
*/
function validNote_(note) {
return typeof note === 'string' && note.length <= NOTE_LIMIT &&
!/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(note);
}
/**
* @param {string[]} values
* @param {string} errorCode
* @returns {string[]}
*/
function normalizeChecked_(values, errorCode) {
if (!Array.isArray(values) || values.length > CHECKLIST_STEPS.length ||
values.some((value, index) =>
typeof value !== 'string' ||
!CHECKLIST_STEPS.some((step) => step.id === value) ||
values.indexOf(value) !== index)) {
throw new ChecklistError_(errorCode);
}
// Widget selection order must not turn the same choices into a new save.
return CHECKLIST_STEPS.filter((step) => values.includes(step.id)).map((step) => step.id);
}
/**
* @template T
* @param {(properties: PropertiesService.Properties, current: ChecklistState_) => T} work
* @returns {T}
*/
function withUserState_(work) {
const lock = LockService.getUserLock();
if (!lock.tryLock(1000)) throw new ChecklistError_('BUSY');
try {
const properties = PropertiesService.getUserProperties();
return work(properties, readState_(properties));
} finally {
lock.releaseLock();
}
}
/**
* @param {PropertiesService.Properties} properties
* @returns {ChecklistState_}
*/
function readState_(properties) {
const raw = properties.getProperty(CHECKLIST_KEY);
if (raw === null) {
// First render does not create a record.
return { version: 1, revision: 0, note: '', checked: [] };
}
if (typeof raw !== 'string' || raw.length > RECORD_BYTE_LIMIT ||
utf8Length_(raw) > RECORD_BYTE_LIMIT) {
throw new ChecklistError_('STATE');
}
let state;
try {
state = JSON.parse(raw);
} catch (_error) {
throw new ChecklistError_('STATE');
}
if (!exactKeys_(state, ['version', 'revision', 'note', 'checked']) ||
state.version !== 1 || !Number.isSafeInteger(state.revision) ||
state.revision < 1 || !validNote_(state.note)) {
throw new ChecklistError_('STATE');
}
return {
version: 1,
revision: state.revision,
note: state.note,
checked: normalizeChecked_(state.checked, 'STATE'),
};
}
/**
* @param {ChecklistState_} state
* @param {ChecklistDraft_} draft
* @returns {boolean}
*/
function sameChecklist_(state, draft) {
return state.note === draft.note &&
state.checked.length === draft.checked.length &&
state.checked.every((value, index) => value === draft.checked[index]);
}
/**
* @param {unknown} value
* @returns {value is Record<string, unknown>}
*/
function isObject_(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
/**
* @param {unknown} value
* @returns {value is Record<string, string>}
*/
function isStringRecord_(value) {
return isObject_(value) && Object.values(value).every((entry) => typeof entry === 'string');
}
/**
* @param {unknown} value
* @param {string[]} keys
* @returns {boolean}
*/
function exactKeys_(value, keys) {
return isObject_(value) && Object.keys(value).length === keys.length &&
keys.every((key) => Object.prototype.hasOwnProperty.call(value, key));
}
/**
* @param {string} text
* @returns {number}
*/
function utf8Length_(text) {
return Utilities.newBlob(text).getBytes().length;
}
/**
* @param {string} functionName
* @param {Record<string, string>} parameters
* @returns {CardService.Action}
*/
function cardAction_(functionName, parameters) {
return CardService.newAction()
.setFunctionName(functionName)
.setParameters(parameters)
.setLoadIndicator(CardService.LoadIndicator.SPINNER)
.setPersistValues(false);
}
/**
* CardService's generated declarations omit the shared widget base type for
* input widgets, although the runtime accepts them in a card section.
*
* @param {CardService.SelectionInput | CardService.TextInput} widget
* @returns {CardService.Widget}
*/
function inputWidget_(widget) {
return /** @type {CardService.Widget} */ (/** @type {unknown} */ (widget));
}
function reloadButton_() {
return CardService.newTextButton()
.setText('Reload saved')
.setOnClickAction(cardAction_('reloadChecklist', { operation: 'reload' }));
}
/**
* @param {ChecklistState_} state
* @returns {CardService.Card}
*/
function buildChecklistCard_(state) {
const progress = state.revision === 0
? 'No saved checklist yet. 0 of 3 checked.'
: 'Saved progress: ' + state.checked.length + ' of 3 checked. Revision ' + state.revision + '.';
const choices = CardService.newSelectionInput()
.setType(CardService.SelectionInputType.CHECK_BOX)
.setFieldName('checked')
.setTitle('Completed items');
for (const step of CHECKLIST_STEPS) {
choices.addItem(step.label, step.id, state.checked.includes(step.id));
}
const note = CardService.newTextInput()
.setFieldName('note')
.setTitle('Next-step note')
.setHint('Optional; one line, up to 120 characters. Use fictional data.')
.setMultiline(false)
// A widget value, not interpolated HTML or a formatted TextParagraph.
.setValue(state.note);
const save = CardService.newTextButton()
.setText('Save checklist')
.setOnClickAction(cardAction_('saveChecklist', {
operation: 'save',
revision: String(state.revision),
}));
const buttons = CardService.newButtonSet().addButton(save).addButton(reloadButton_());
const section = CardService.newCardSection()
.addWidget(CardService.newTextParagraph().setText(progress))
.addWidget(CardService.newTextParagraph().setText(
'One personal checklist, not linked to a selected file. No files or permissions are checked.',
))
.addWidget(inputWidget_(choices))
.addWidget(inputWidget_(note))
.addWidget(CardService.newTextParagraph().setText(
'Edits are not saved until Save checklist. Reload saved discards unsaved edits.',
))
.addWidget(buttons);
return CardService.newCardBuilder()
.setHeader(CardService.newCardHeader().setTitle('Handoff checklist'))
.addSection(section)
.build();
}
/**
* @param {string} message
* @returns {CardService.Card}
*/
function buildErrorCard_(message) {
return CardService.newCardBuilder()
.setHeader(CardService.newCardHeader().setTitle('Checklist unavailable'))
.addSection(CardService.newCardSection()
.addWidget(CardService.newTextParagraph().setText(message))
.addWidget(CardService.newButtonSet().addButton(reloadButton_())))
.build();
}
/**
* @param {ChecklistState_} state
* @param {string} message
* @param {boolean} changed
* @returns {CardService.ActionResponse}
*/
function replaceCard_(state, message, changed) {
return CardService.newActionResponseBuilder()
.setNavigation(CardService.newNavigation().updateCard(buildChecklistCard_(state)))
.setNotification(CardService.newNotification().setText(message))
.setStateChanged(changed)
.build();
}
/**
* @param {string} message
* @returns {CardService.ActionResponse}
*/
function notify_(message) {
return CardService.newActionResponseBuilder()
.setNotification(CardService.newNotification().setText(message))
.build();
}
/**
* @param {unknown} error
* @returns {string}
*/
function messageFor_(error) {
if (error instanceof ChecklistError_) {
switch (error.code) {
case 'EVENT':
return 'Invalid card input. Reopen the installed add-on in Drive; nothing was written.';
case 'NOTE':
return 'Note not saved. Use one line of at most 120 characters without control characters.';
case 'STEPS':
return 'Checklist not saved. Choose only the three displayed items, without duplicates.';
case 'STALE':
return 'Not saved: the stored checklist changed. Keep your draft elsewhere, then Reload saved.';
case 'BUSY':
return 'Another checklist operation is busy. Wait, then try again; this action wrote nothing.';
case 'STATE':
return 'Stored checklist is invalid. It was not reset. Preserve it and ask the project owner for help.';
case 'LIMIT':
return 'Checklist storage limit reached. Nothing was written; ask the project owner for help.';
}
}
return 'Could not confirm the result. Keep your draft elsewhere, then Reload saved before saving again.';
}
{
"timeZone": "Etc/UTC",
"runtimeVersion": "V8",
"oauthScopes": [],
"addOns": {
"common": {
"name": "Handoff checklist",
"logoUrl": "https://www.gstatic.com/images/icons/material/system/1x/check_circle_black_48dp.png",
"homepageTrigger": {
"runFunction": "onHomepage",
"enabled": true
}
},
"drive": {}
}
}
Read the add-on manifest
addOns.common.name and logoUrl identify the add-on in the Workspace side panel. Before you publish your own add-on, replace the sample icon URL with an image you control.
addOns.common.homepageTrigger.runFunction names onHomepage with enabled: true. The empty addOns.drive object enables the add-on in Drive and inherits the common homepage without declaring a file-selection trigger. Omitting addOns.drive leaves the add-on disabled in Drive. See the Common manifest resource, Drive resource, and Drive homepage instructions.
The manifest sets oauthScopes to []. The add-on shows a CardService homepage in Drive and calls PropertiesService.getUserProperties(), LockService.getUserLock(), and Utilities.newBlob. None of these needs a Drive, Gmail, or external-request scope. See the Workspace add-on scope guide.
Install the unpublished add-on
Install the development add-on into your Google account:
In the Apps Script editor, select Deploy > Test deployments.
Select Install, then Done.
Open Google Drive (
drive.google.com) in the same account and refresh the tab.Expand the right-side panel and open Handoff checklist using its check-circle icon.
Google's unpublished add-on testing instructions use Test deployments > Install. A test installation needs edit access to the script project, and any other testers must belong to the script owner's domain.
On a fresh user store, the card shows No saved checklist yet. 0 of 3 checked. with three unchecked boxes and an empty note. Drawing this first card writes no property.
Do not test the UI by clicking Run on onHomepage in the editor. The editor passes no host event object, so onHomepage returns an invalid-event error card, and nothing appears in Drive.
Follow a homepage load and a button click
The homepage is a non-contextual card: it opens in Drive without a selected file. Its event object carries host metadata such as commonEventObject.hostApp, and no selected Drive items or form inputs. See the homepage guide.
Opening the add-on runs four steps:
Drive calls
onHomepage(e).requireDriveEvent_verifies thate.commonEventObject.hostAppis'DRIVE'.The handler reads the user's stored checklist under the user lock and builds the card widgets.
onHomepagereturns[card](or an error card if the stored property is malformed).
A card is made of sections and widgets. This one has a SelectionInput of type SelectionInputType.CHECK_BOX for the three items and a TextInput with fieldName: 'note' for the note. Adding a widget to a section copies the widget as it is at that moment, so finish configuring each widget before you call addWidget. See the Workspace add-ons building guide.
The Save checklist button attaches an Action with setOnClickAction. The action names the callback 'saveChecklist' and carries the string parameters operation and revision. Clicking the button sends the current widget values and those parameters to saveChecklist(e). The inputs have no onChange action, so ticking a box or typing in the note field does not call the server until you click a button. See add-on actions.
Return the response the host expects
onHomepage returns a Card or an array of cards. A button callback that updates the UI returns an ActionResponse instead.
After a save, replaceCard_ builds an ActionResponse with Navigation.updateCard(...) and a notification. updateCard replaces the current card in place, so the navigation stack does not gain a second card. pushCard and popCard handle multi-card drilldowns. See the navigation guide.
Both buttons set LoadIndicator.SPINNER and setPersistValues(false). With setPersistValues(false), the host shows the widget values the server returned in the new card instead of keeping the draft inputs. That setting lets Reload saved discard an unsaved draft. On a validation error, the callback returns only a notification and does not call updateCard, so the draft stays in the card for you to fix. See the Action reference.
A save that writes new data calls setStateChanged(true). A save that changes nothing, and a reload, call setStateChanged(false). See ActionResponseBuilder.
Do not confuse manifest triggers with editor triggers
A Workspace add-on declares its homepage and contextual triggers in appsscript.json. It does not install triggers with ScriptApp.newTrigger and does not use simple triggers such as onOpen. See the manifest trigger guide. To react to Drive file selections in a later add-on, you would declare onItemsSelectedTrigger and the required Drive metadata scope in appsscript.json.
Validate the current event-object fields
The handlers read e.commonEventObject.parameters and e.commonEventObject.formInputs. Older Gmail add-on examples read the deprecated top-level e.parameters, e.formInput, and e.formInputs instead. See the Workspace add-on event-object reference.
saveChecklist validates five inputs:
| Field | Required meaning in this project |
|---|---|
commonEventObject.hostApp | 'DRIVE' |
commonEventObject.parameters.operation | 'save' |
commonEventObject.parameters.revision | Non-negative integer string |
commonEventObject.formInputs.note.stringInputs.value | Zero or one string, where an omitted widget is treated as "" |
commonEventObject.formInputs.checked.stringInputs.value | Up to three unique strings from 'owner', 'access', and 'next' |
In commonEventObject.formInputs, even a single-line TextInput puts its string in stringInputs.value as a one-element array. A CHECK_BOX group puts every selected item value in that array. See the TextInput and SelectionInput references.
Before reading or writing UserProperties, the validator rejects unexpected field names, extra parameters, duplicate checkbox IDs, and unknown item IDs. It also rejects a note longer than 120 UTF-16 code units (note.length) or a note with control characters or line breaks.
The note goes into TextInput.setValue(note), which sets the plain-text field value without parsing HTML. Text such as <draft> and & appears as typed.
Save one size-limited record
The HANDOFF_CHECKLIST_V1 user property stores a single JSON record capped at 2,048 UTF-8 bytes. The add-on measures the size with Utilities.newBlob(text).getBytes().length. See the Properties and Utilities references. A saved record looks like this:
{
"version": 1,
"revision": 1,
"note": "REQ-001: check access with the next owner.",
"checked": ["owner", "access"]
} When no property exists yet, the card shows revision 0, and the add-on writes nothing to UserProperties. If the stored property is malformed or larger than 2,048 bytes, the add-on keeps it as it is and shows an error card. It never overwrites the value with defaults.
Prevent two tabs from overwriting each other
A user can have Drive open in two tabs at once. So every read and write runs inside withUserState_, which takes LockService.getUserLock() with tryLock(1000) and releases it in finally. See the LockService and Lock references.
Under that lock, saveChecklist compares the submitted values and revision with the stored record:
Unchanged values: the note and the normalized
checkedlist match the stored record. It returns the current card without writing or raisingrevision.Stale revision: the values differ, and the card's
revisiondoes not match the storedrevision. It returns a notification. The newer record stays, and the user's draft stays in the card.Matching revision with changes: it raises
revisionby one, writesHANDOFF_CHECKLIST_V1once, and updates the card.
Limit the work in each callback
A homepage load or a reload makes one getProperty call. A save makes one getProperty call and at most one setProperty call. Google's quotas table limits a Workspace add-on callback to 30 seconds and a property value to 9 KB, so keep each callback to a few property reads and writes.
Understand installation and authorization separately
Installing an add-on adds it to the host's side panel. OAuth authorization is a separate step that grants the scopes declared in appsscript.json. This project declares "oauthScopes": [], so it asks for no access to Drive files or other Google data. See Workspace add-on scopes and Apps Script authorization.
Editor add-ons, for Docs, Sheets, Slides, and Forms, authorize differently. They use onOpen(e) and e.authMode (AuthMode.NONE, AuthMode.LIMITED, AuthMode.FULL), as the Editor add-on authorization guide describes. A Workspace add-on reads commonEventObject instead.
Exercise: save, reject, and reload the checklist
Start with a fresh revision-0 card in Drive.
Save the draft and repeat it
Enter
REQ-001: check access with the next owner.in Next-step note, and check Confirm the next owner and Check requested access.Confirm the top progress line still reads
0 of 3 checkedbefore saving.Click Save checklist. The card updates to Saved progress: 2 of 3 checked with revision
1.Click Save checklist again without editing. The notification reports Already current; nothing written. and the revision stays
1.Check Identify the next step and click Save checklist. The card shows
3 of 3 checkedand revision2.
Reject an invalid note without losing the saved one
Replace the note with
0123456789repeated 13 times. That is 130 characters, over the 120-character limit.Click Save checklist. The server rejects the note with a notification. The stored revision stays at
2, and the 130-character draft stays in the input field.Click Reload saved. The invalid draft is discarded, and the saved note, three checked boxes, and revision
2come back.
Keep text as text
Change the note to Review <draft> & confirm access., uncheck Identify the next step, and click Save checklist. The angle brackets and ampersand appear as typed, and the revision becomes 3. Restore the original note, check the third box again, and save. The revision becomes 4.
Test a save from a stale tab
Open the add-on in two Drive tabs signed into the same account. Click Reload saved in both, so both cards carry the same revision.
In the first tab, change the note to
REQ-001: ask for a named next owner.and click Save checklist.In the second tab, without reloading, change the note to
REQ-001: compare before replacing the note.and click Save checklist.The second tab shows a stale-card notification, and the first tab's save is not overwritten. Click Reload saved in the second tab to load the first tab's saved state.
Restore the note
REQ-001: check access with the next owner.and the three checked items, then save.
Rename a checklist item without losing its saved state
In CHECKLIST_STEPS in Code.gs, change the label 'Check requested access' to 'Check access with the next owner' and keep id: 'access'. Save Code.gs and click Reload saved in the Drive card.
The new label appears because the test installation runs the head deployment, the latest saved code (see head and versioned deployments). The item stays checked because the stored record identifies items by id, independently of their labels.
Restore the original label in Code.gs, save, and reload the card.
Recover from an error
| Symptom | Meaning and next action |
|---|---|
| No icon after installation | Check that Drive is open in the same Google account. Refresh Drive, open the right-side panel, and check Test deployments in this project. |
| Invalid card input | The callback did not receive a Drive card event. Use the installed Drive side panel, not Run in the editor. |
| A note or checklist validation notification | Shorten or fix the draft note, or click Reload saved to go back to the stored record. |
| Another checklist operation is busy | Another execution holds the user lock. Wait a moment and try again. |
| The stored checklist changed | Another tab saved a newer revision. Copy your draft if you need it, click Reload saved, and apply your changes to the current revision. |
| Stored checklist is invalid | HANDOFF_CHECKLIST_V1 holds malformed JSON or an unsupported version. Inspect the user property before you change or replace it. |
| Could not confirm the result, a timeout, or no response | The write may have succeeded before the response was lost. Click Reload saved and check the stored revision before you save again. |
Uninstall the test add-on
To clear the checklist before you uninstall, clear the note, uncheck the three boxes, and click Save checklist.
To remove the test installation, open the Apps Script editor, choose Deploy > Test deployments > Uninstall > Done, and refresh Drive. See Google's uninstall instructions.
Uninstalling removes the icon from Drive. It does not delete UserProperties or the script project. If you authorized any OAuth connections, you can revoke them in your Google Account security settings, as the authorization guide describes.
A Test deployments installation runs the project's latest saved code, the head deployment, for the project's editors. Releasing an add-on to other users needs a standard Google Cloud project, a versioned deployment, OAuth consent configuration, and a Google Workspace Marketplace listing. The add-on building and publication overview and the next chapter, Install and release a Workspace add-on, cover those steps.
Project files
Complete source files for this chapter's examples.
Keep a personal handoff checklist in a Drive add-on
Code.gs
Download fileconst CHECKLIST_KEY = 'HANDOFF_CHECKLIST_V1';
const NOTE_LIMIT = 120;
const RECORD_BYTE_LIMIT = 2048;
const CHECKLIST_STEPS = [
{ id: 'owner', label: 'Confirm the next owner' },
{ id: 'access', label: 'Check requested access' },
{ id: 'next', label: 'Identify the next step' },
];
/**
* @typedef {{version: number, revision: number, note: string, checked: string[]}} ChecklistState_
*/
/**
* @typedef {{note: string, checked: string[]}} ChecklistDraft_
*/
/**
* @typedef {{hostApp: string, parameters: Record<string, string>, formInputs?: Record<string, unknown>}} ChecklistEvent_
*/
class ChecklistError_ extends Error {
/** @param {string} code */
constructor(code) {
super(code);
this.code = code;
}
}
// A manifest homepage trigger returns cards, not an ActionResponse or HTML.
/**
* @param {unknown} e
* @returns {CardService.Card[]}
*/
function onHomepage(e) {
try {
requireDriveEvent_(e, false);
const state = withUserState_((_properties, current) => current);
return [buildChecklistCard_(state)];
} catch (error) {
return [buildErrorCard_(messageFor_(error))];
}
}
// Save replaces this user's one checklist, never a Drive file or its sharing.
/**
* @param {unknown} e
* @returns {CardService.ActionResponse}
*/
function saveChecklist(e) {
try {
const common = requireAction_(e, 'save');
const revision = readRevision_(common.parameters.revision);
const draft = readDraft_(common.formInputs);
const result = withUserState_((properties, current) => {
// A repeated submission can already match, even after a lost response.
if (sameChecklist_(current, draft)) {
return { state: current, changed: false };
}
if (revision !== current.revision) {
throw new ChecklistError_('STALE');
}
if (current.revision === Number.MAX_SAFE_INTEGER) {
throw new ChecklistError_('LIMIT');
}
const next = {
version: 1,
revision: current.revision + 1,
note: draft.note,
checked: draft.checked,
};
const serialized = JSON.stringify(next);
if (utf8Length_(serialized) > RECORD_BYTE_LIMIT) {
throw new ChecklistError_('LIMIT');
}
properties.setProperty(CHECKLIST_KEY, serialized);
return { state: next, changed: true };
});
return replaceCard_(
result.state,
result.changed ? 'Checklist saved.' : 'Already current; nothing written.',
result.changed,
);
} catch (error) {
// Notification only: do not replace the card or erase an unsaved draft.
// An unexpected failure can follow a completed property write.
return notify_(messageFor_(error));
}
}
// Deliberately discard the displayed draft and read the current saved record.
/**
* @param {unknown} e
* @returns {CardService.ActionResponse}
*/
function reloadChecklist(e) {
try {
requireAction_(e, 'reload');
const state = withUserState_((_properties, current) => current);
return replaceCard_(state, 'Saved checklist loaded.', false);
} catch (error) {
return notify_(messageFor_(error));
}
}
/**
* @param {unknown} e
* @param {boolean} requireParameters
* @returns {ChecklistEvent_}
*/
function requireDriveEvent_(e, requireParameters) {
if (!isObject_(e) || !isObject_(e.commonEventObject)) {
throw new ChecklistError_('EVENT');
}
const common = e.commonEventObject;
// Google omits commonEventObject.parameters from homepage events; button
// actions always carry the parameters their own card attached.
const parameters = common.parameters === undefined && !requireParameters ? {} : common.parameters;
if (common.hostApp !== 'DRIVE' || !isStringRecord_(parameters) ||
(common.formInputs !== undefined && !isObject_(common.formInputs))) {
throw new ChecklistError_('EVENT');
}
return {
hostApp: 'DRIVE',
parameters: parameters,
...(common.formInputs === undefined ? {} : { formInputs: common.formInputs }),
};
}
/**
* @param {unknown} e
* @param {string} operation
* @returns {ChecklistEvent_}
*/
function requireAction_(e, operation) {
const common = requireDriveEvent_(e, true);
const keys = operation === 'save' ? ['operation', 'revision'] : ['operation'];
if (!exactKeys_(common.parameters, keys) ||
common.parameters.operation !== operation) {
throw new ChecklistError_('EVENT');
}
return common;
}
/**
* @param {unknown} text
* @returns {number}
*/
function readRevision_(text) {
if (typeof text !== 'string' || !/^(0|[1-9][0-9]{0,15})$/.test(text)) {
throw new ChecklistError_('EVENT');
}
const revision = Number(text);
if (!Number.isSafeInteger(revision)) {
throw new ChecklistError_('EVENT');
}
return revision;
}
/**
* @param {unknown} formInputs
* @returns {ChecklistDraft_}
*/
function readDraft_(formInputs) {
// Empty widgets can be absent. A present malformed widget is not empty.
const inputs = formInputs === undefined ? {} : formInputs;
if (!isObject_(inputs) ||
Object.keys(inputs).some((key) => key !== 'note' && key !== 'checked')) {
throw new ChecklistError_('EVENT');
}
const notes = stringValues_(inputs, 'note', 1);
const note = notes.length === 0 ? '' : notes[0];
if (!validNote_(note)) {
throw new ChecklistError_('NOTE');
}
const checked = normalizeChecked_(stringValues_(inputs, 'checked', 3), 'STEPS');
return { note, checked };
}
/**
* @param {Record<string, unknown>} inputs
* @param {string} name
* @param {number} maximum
* @returns {string[]}
*/
function stringValues_(inputs, name, maximum) {
if (!Object.prototype.hasOwnProperty.call(inputs, name)) return [];
const widget = inputs[name];
if (!isObject_(widget) || !isObject_(widget.stringInputs) ||
!Array.isArray(widget.stringInputs.value)) {
throw new ChecklistError_('EVENT');
}
const values = widget.stringInputs.value;
if (values.length > maximum || values.some((value) => typeof value !== 'string')) {
throw new ChecklistError_('EVENT');
}
return values;
}
/**
* @param {unknown} note
* @returns {boolean}
*/
function validNote_(note) {
return typeof note === 'string' && note.length <= NOTE_LIMIT &&
!/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(note);
}
/**
* @param {string[]} values
* @param {string} errorCode
* @returns {string[]}
*/
function normalizeChecked_(values, errorCode) {
if (!Array.isArray(values) || values.length > CHECKLIST_STEPS.length ||
values.some((value, index) =>
typeof value !== 'string' ||
!CHECKLIST_STEPS.some((step) => step.id === value) ||
values.indexOf(value) !== index)) {
throw new ChecklistError_(errorCode);
}
// Widget selection order must not turn the same choices into a new save.
return CHECKLIST_STEPS.filter((step) => values.includes(step.id)).map((step) => step.id);
}
/**
* @template T
* @param {(properties: PropertiesService.Properties, current: ChecklistState_) => T} work
* @returns {T}
*/
function withUserState_(work) {
const lock = LockService.getUserLock();
if (!lock.tryLock(1000)) throw new ChecklistError_('BUSY');
try {
const properties = PropertiesService.getUserProperties();
return work(properties, readState_(properties));
} finally {
lock.releaseLock();
}
}
/**
* @param {PropertiesService.Properties} properties
* @returns {ChecklistState_}
*/
function readState_(properties) {
const raw = properties.getProperty(CHECKLIST_KEY);
if (raw === null) {
// First render does not create a record.
return { version: 1, revision: 0, note: '', checked: [] };
}
if (typeof raw !== 'string' || raw.length > RECORD_BYTE_LIMIT ||
utf8Length_(raw) > RECORD_BYTE_LIMIT) {
throw new ChecklistError_('STATE');
}
let state;
try {
state = JSON.parse(raw);
} catch (_error) {
throw new ChecklistError_('STATE');
}
if (!exactKeys_(state, ['version', 'revision', 'note', 'checked']) ||
state.version !== 1 || !Number.isSafeInteger(state.revision) ||
state.revision < 1 || !validNote_(state.note)) {
throw new ChecklistError_('STATE');
}
return {
version: 1,
revision: state.revision,
note: state.note,
checked: normalizeChecked_(state.checked, 'STATE'),
};
}
/**
* @param {ChecklistState_} state
* @param {ChecklistDraft_} draft
* @returns {boolean}
*/
function sameChecklist_(state, draft) {
return state.note === draft.note &&
state.checked.length === draft.checked.length &&
state.checked.every((value, index) => value === draft.checked[index]);
}
/**
* @param {unknown} value
* @returns {value is Record<string, unknown>}
*/
function isObject_(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
/**
* @param {unknown} value
* @returns {value is Record<string, string>}
*/
function isStringRecord_(value) {
return isObject_(value) && Object.values(value).every((entry) => typeof entry === 'string');
}
/**
* @param {unknown} value
* @param {string[]} keys
* @returns {boolean}
*/
function exactKeys_(value, keys) {
return isObject_(value) && Object.keys(value).length === keys.length &&
keys.every((key) => Object.prototype.hasOwnProperty.call(value, key));
}
/**
* @param {string} text
* @returns {number}
*/
function utf8Length_(text) {
return Utilities.newBlob(text).getBytes().length;
}
/**
* @param {string} functionName
* @param {Record<string, string>} parameters
* @returns {CardService.Action}
*/
function cardAction_(functionName, parameters) {
return CardService.newAction()
.setFunctionName(functionName)
.setParameters(parameters)
.setLoadIndicator(CardService.LoadIndicator.SPINNER)
.setPersistValues(false);
}
/**
* CardService's generated declarations omit the shared widget base type for
* input widgets, although the runtime accepts them in a card section.
*
* @param {CardService.SelectionInput | CardService.TextInput} widget
* @returns {CardService.Widget}
*/
function inputWidget_(widget) {
return /** @type {CardService.Widget} */ (/** @type {unknown} */ (widget));
}
function reloadButton_() {
return CardService.newTextButton()
.setText('Reload saved')
.setOnClickAction(cardAction_('reloadChecklist', { operation: 'reload' }));
}
/**
* @param {ChecklistState_} state
* @returns {CardService.Card}
*/
function buildChecklistCard_(state) {
const progress = state.revision === 0
? 'No saved checklist yet. 0 of 3 checked.'
: 'Saved progress: ' + state.checked.length + ' of 3 checked. Revision ' + state.revision + '.';
const choices = CardService.newSelectionInput()
.setType(CardService.SelectionInputType.CHECK_BOX)
.setFieldName('checked')
.setTitle('Completed items');
for (const step of CHECKLIST_STEPS) {
choices.addItem(step.label, step.id, state.checked.includes(step.id));
}
const note = CardService.newTextInput()
.setFieldName('note')
.setTitle('Next-step note')
.setHint('Optional; one line, up to 120 characters. Use fictional data.')
.setMultiline(false)
// A widget value, not interpolated HTML or a formatted TextParagraph.
.setValue(state.note);
const save = CardService.newTextButton()
.setText('Save checklist')
.setOnClickAction(cardAction_('saveChecklist', {
operation: 'save',
revision: String(state.revision),
}));
const buttons = CardService.newButtonSet().addButton(save).addButton(reloadButton_());
const section = CardService.newCardSection()
.addWidget(CardService.newTextParagraph().setText(progress))
.addWidget(CardService.newTextParagraph().setText(
'One personal checklist, not linked to a selected file. No files or permissions are checked.',
))
.addWidget(inputWidget_(choices))
.addWidget(inputWidget_(note))
.addWidget(CardService.newTextParagraph().setText(
'Edits are not saved until Save checklist. Reload saved discards unsaved edits.',
))
.addWidget(buttons);
return CardService.newCardBuilder()
.setHeader(CardService.newCardHeader().setTitle('Handoff checklist'))
.addSection(section)
.build();
}
/**
* @param {string} message
* @returns {CardService.Card}
*/
function buildErrorCard_(message) {
return CardService.newCardBuilder()
.setHeader(CardService.newCardHeader().setTitle('Checklist unavailable'))
.addSection(CardService.newCardSection()
.addWidget(CardService.newTextParagraph().setText(message))
.addWidget(CardService.newButtonSet().addButton(reloadButton_())))
.build();
}
/**
* @param {ChecklistState_} state
* @param {string} message
* @param {boolean} changed
* @returns {CardService.ActionResponse}
*/
function replaceCard_(state, message, changed) {
return CardService.newActionResponseBuilder()
.setNavigation(CardService.newNavigation().updateCard(buildChecklistCard_(state)))
.setNotification(CardService.newNotification().setText(message))
.setStateChanged(changed)
.build();
}
/**
* @param {string} message
* @returns {CardService.ActionResponse}
*/
function notify_(message) {
return CardService.newActionResponseBuilder()
.setNotification(CardService.newNotification().setText(message))
.build();
}
/**
* @param {unknown} error
* @returns {string}
*/
function messageFor_(error) {
if (error instanceof ChecklistError_) {
switch (error.code) {
case 'EVENT':
return 'Invalid card input. Reopen the installed add-on in Drive; nothing was written.';
case 'NOTE':
return 'Note not saved. Use one line of at most 120 characters without control characters.';
case 'STEPS':
return 'Checklist not saved. Choose only the three displayed items, without duplicates.';
case 'STALE':
return 'Not saved: the stored checklist changed. Keep your draft elsewhere, then Reload saved.';
case 'BUSY':
return 'Another checklist operation is busy. Wait, then try again; this action wrote nothing.';
case 'STATE':
return 'Stored checklist is invalid. It was not reset. Preserve it and ask the project owner for help.';
case 'LIMIT':
return 'Checklist storage limit reached. Nothing was written; ask the project owner for help.';
}
}
return 'Could not confirm the result. Keep your draft elsewhere, then Reload saved before saving again.';
}
appsscript.json
Download file{
"timeZone": "Etc/UTC",
"runtimeVersion": "V8",
"oauthScopes": [],
"addOns": {
"common": {
"name": "Handoff checklist",
"logoUrl": "https://www.gstatic.com/images/icons/material/system/1x/check_circle_black_48dp.png",
"homepageTrigger": {
"runFunction": "onHomepage",
"enabled": true
}
},
"drive": {}
}
}